diff --git a/.vscode/dictionary.txt b/.vscode/dictionary.txt index 17aa9bc..8d3458a 100644 --- a/.vscode/dictionary.txt +++ b/.vscode/dictionary.txt @@ -31,6 +31,7 @@ proxied socio Solana stacksjs +tlsx typecheck unplugin unref diff --git a/README.md b/README.md index be00da7..395afcf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

Social Card of this repo

+

Social Card of this repo

[![npm version][npm-version-src]][npm-version-href] [![GitHub Actions][github-actions-src]][github-actions-href] @@ -12,25 +12,23 @@ ## Features -- Simple Reverse Proxy +- Simple, lightweight Reverse Proxy - Custom Domains _(with wildcard support)_ -- Dependency-Free - Zero-Config Setup - - - +- SSL Support _(HTTPS by default)_ +- Auto HTTP-to-HTTPS Redirection ## Install ```bash -bun install -d @stacksjs/reverse-proxy +bun install -d @stacksjs/rpx ``` ## Get Started @@ -41,13 +39,28 @@ There are two ways of using this reverse proxy: _as a library or as a CLI._ Given the npm package is installed: -```js -import { startProxy } from '@stacksjs/reverse-proxy' +```ts +import type { TlsConfig } from '@stacksjs/rpx' +import { startProxy } from '@stacksjs/rpx' + +export interface ReverseProxyConfig { + from: string // domain to proxy from, defaults to localhost:3000 + to: string // domain to proxy to, defaults to stacks.localhost + https: TlsConfig // use https, defaults to true, also redirects http to https + verbose: boolean // log verbose output, defaults to false +} -startProxy({ +const config: ReverseProxyOptions = { from: 'localhost:3000', - to: 'my-project.localhost' // or try 'my-project.test' -}) + to: 'my-project.localhost', + https: { + keyPath: './key.pem', + certPath: './cert.pem', + caCertPath: './ca.pem', + }, +} + +startProxy(config) ``` ### CLI @@ -64,16 +77,40 @@ reverse-proxy --version The Reverse Proxy can be configured using a `reverse-proxy.config.ts` _(or `reverse-proxy.config.js`)_ file and it will be automatically loaded when running the `reverse-proxy` command. ```ts -// reverse-proxy.config.ts (or reverse-proxy.config.js) -export default { - 'localhost:3000': 'stacks.localhost' +// reverse-proxy.config.{ts,js} +import type { ReverseProxyOptions } from './src/types' +import os from 'node:os' +import path from 'node:path' + +const config: ReverseProxyOptions = { + from: 'localhost:5173', + to: 'stacks.localhost', + https: { + domain: 'stacks.localhost', + hostCertCN: 'stacks.localhost', + caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), + certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), + keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), + altNameIPs: ['127.0.0.1'], + altNameURIs: ['localhost'], + organizationName: 'stacksjs.org', + countryName: 'US', + stateName: 'California', + localityName: 'Playa Vista', + commonName: 'stacks.localhost', + validityDays: 180, + verbose: false, + }, + verbose: false, } + +export default config ``` _Then run:_ ```bash -reverse-proxy start +./rpx start ``` To learn more, head over to the [documentation](https://reverse-proxy.sh/). @@ -127,10 +164,10 @@ The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/stacks/t Made with 💙 -[npm-version-src]: https://img.shields.io/npm/v/@stacksjs/reverse-proxy?style=flat-square -[npm-version-href]: https://npmjs.com/package/@stacksjs/reverse-proxy -[github-actions-src]: https://img.shields.io/github/actions/workflow/status/stacksjs/reverse-proxy/ci.yml?style=flat-square&branch=main -[github-actions-href]: https://github.com/stacksjs/reverse-proxy/actions?query=workflow%3Aci +[npm-version-src]: https://img.shields.io/npm/v/@stacksjs/rpx?style=flat-square +[npm-version-href]: https://npmjs.com/package/@stacksjs/rpx +[github-actions-src]: https://img.shields.io/github/actions/workflow/status/stacksjs/rpx/ci.yml?style=flat-square&branch=main +[github-actions-href]: https://github.com/stacksjs/rpx/actions?query=workflow%3Aci - + diff --git a/package.json b/package.json index 4e80ef3..857b36c 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { - "name": "@stacksjs/reverse-proxy", + "name": "@stacksjs/rpx", "type": "module", "version": "0.1.0", "description": "A modern reverse proxy.", "author": "Chris Breuer ", "license": "MIT", - "homepage": "https://github.com/stacksjs/reverse-proxy#readme", + "homepage": "https://github.com/stacksjs/rpx#readme", "repository": { "type": "git", - "url": "git+https://github.com/stacksjs/reverse-proxy.git" + "url": "git+https://github.com/stacksjs/rpx.git" }, "bugs": { - "url": "https://github.com/stacksjs/reverse-proxy/issues" + "url": "https://github.com/stacksjs/rpx/issues" }, "keywords": [ "reverse proxy", @@ -61,7 +61,7 @@ "preview:docs": "vitepress preview docs" }, "dependencies": { - "@stacksjs/tlsx": "^0.3.3" + "@stacksjs/tlsx": "^0.5.6" }, "devDependencies": { "@stacksjs/cli": "^0.68.2", diff --git a/reverse-proxy.config.ts b/reverse-proxy.config.ts index 5319539..782d34d 100644 --- a/reverse-proxy.config.ts +++ b/reverse-proxy.config.ts @@ -5,10 +5,22 @@ import path from 'node:path' const config: ReverseProxyOptions = { from: 'localhost:5173', to: 'stacks.localhost', - keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), - certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), - caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), - httpsRedirect: false, + https: { + domain: 'stacks.localhost', + hostCertCN: 'stacks.localhost', + caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), + certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), + keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), + altNameIPs: ['127.0.0.1'], + altNameURIs: ['localhost'], + organizationName: 'stacksjs.org', + countryName: 'US', + stateName: 'California', + localityName: 'Playa Vista', + commonName: 'stacks.localhost', + validityDays: 180, + verbose: false, + }, verbose: false, } diff --git a/src/config.ts b/src/config.ts index 597980d..40088fd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,6 @@ import type { ReverseProxyConfig } from './types' +import os from 'node:os' +import path from 'node:path' import { loadConfig } from 'bun-config' // eslint-disable-next-line antfu/no-top-level-await @@ -7,7 +9,22 @@ export const config: ReverseProxyConfig = await loadConfig({ defaultConfig: { from: 'localhost:5173', to: 'stacks.localhost', - httpsRedirect: false, + https: { + domain: 'stacks.localhost', + hostCertCN: 'stacks.localhost', + caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), + certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), + keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), + altNameIPs: ['127.0.0.1'], + altNameURIs: ['localhost'], + organizationName: 'stacksjs.org', + countryName: 'US', + stateName: 'California', + localityName: 'Playa Vista', + commonName: 'stacks.localhost', + validityDays: 180, + verbose: false, + }, verbose: true, }, }) diff --git a/src/https.ts b/src/https.ts new file mode 100644 index 0000000..2d21753 --- /dev/null +++ b/src/https.ts @@ -0,0 +1,31 @@ +import { log } from '@stacksjs/cli' +import { addCertToSystemTrustStoreAndSaveCert, createRootCA, generateCert } from '@stacksjs/tlsx' +import { config } from './config' + +export async function generateCertificate(domain?: string): Promise { + domain = domain ?? config.https.altNameURIs[0] + + log.info(`Generating a self-signed SSL certificate for: ${domain}`) + + const caCert = await createRootCA(config.https) + const hostCert = await generateCert({ + hostCertCN: config.https.commonName ?? domain, + domain, + altNameIPs: config.https.altNameIPs, + altNameURIs: config.https.altNameURIs, + countryName: config.https.countryName, + stateName: config.https.stateName, + localityName: config.https.localityName, + organizationName: config.https.organizationName, + validityDays: config.https.validityDays, + rootCAObject: { + certificate: caCert.certificate, + privateKey: caCert.privateKey, + }, + verbose: config.https.verbose || config.verbose, + }) + + await addCertToSystemTrustStoreAndSaveCert(hostCert, caCert.certificate, config.https) + + log.success('Certificate generated') +} diff --git a/src/start.ts b/src/start.ts index 9ff3fd0..ac44e10 100644 --- a/src/start.ts +++ b/src/start.ts @@ -10,6 +10,8 @@ import process from 'node:process' import { bold, dim, green, log } from '@stacksjs/cli' import { version } from '../package.json' import { config } from './config' +import { generateCertificate } from './https' +import { debugLog } from './utils' // Keep track of all running servers for cleanup const activeServers: Set = new Set() @@ -18,19 +20,25 @@ const activeServers: Set = new Set() * Cleanup function to close all servers and exit gracefully */ function cleanup() { + debugLog('cleanup', 'Starting cleanup process', config.verbose) console.log(`\n`) log.info('Shutting down proxy servers...') const closePromises = Array.from(activeServers).map(server => new Promise((resolve) => { - server.close(() => resolve()) + server.close(() => { + debugLog('cleanup', 'Server closed successfully', config.verbose) + resolve() + }) }), ) Promise.all(closePromises).then(() => { + debugLog('cleanup', 'All servers closed successfully', config.verbose) log.success('All proxy servers shut down successfully') process.exit(0) }).catch((err) => { + debugLog('cleanup', `Error during shutdown: ${err}`, config.verbose) log.error('Error during shutdown:', err) process.exit(1) }) @@ -40,6 +48,7 @@ function cleanup() { process.on('SIGINT', cleanup) process.on('SIGTERM', cleanup) process.on('uncaughtException', (err) => { + debugLog('process', `Uncaught exception: ${err}`, config.verbose) log.error('Uncaught exception:', err) cleanup() }) @@ -48,23 +57,53 @@ process.on('uncaughtException', (err) => { * Load SSL certificates from files or use provided strings */ async function loadSSLConfig(options: ReverseProxyOption): Promise { + debugLog('ssl', 'Loading SSL configuration', options.verbose) + // Early return for non-SSL configuration - if (!options.keyPath && !options.certPath) + if (!options.https?.keyPath && !options.https?.certPath) { + debugLog('ssl', 'No SSL configuration provided', options.verbose) return null + } - if ((options.keyPath && !options.certPath) || (!options.keyPath && options.certPath)) { - const missing = !options.keyPath ? 'keyPath' : 'certPath' + if ((options.https?.keyPath && !options.https?.certPath) || (!options.https?.keyPath && options.https?.certPath)) { + const missing = !options.https?.keyPath ? 'keyPath' : 'certPath' + debugLog('ssl', `Invalid SSL configuration - missing ${missing}`, options.verbose) throw new Error(`SSL Configuration requires both keyPath and certPath. Missing: ${missing}`) } try { - if (!options.keyPath || !options.certPath) + if (!options.https?.keyPath || !options.https?.certPath) return null - const key = await fs.promises.readFile(options.keyPath, 'utf8') - const cert = await fs.promises.readFile(options.certPath, 'utf8') + // Try to read existing certificates + try { + debugLog('ssl', 'Reading SSL certificate files', options.verbose) + const key = await fs.promises.readFile(options.https?.keyPath, 'utf8') + const cert = await fs.promises.readFile(options.https?.certPath, 'utf8') + + debugLog('ssl', 'SSL configuration loaded successfully', options.verbose) + return { key, cert } + } + catch (error) { + // If files don't exist, generate new certificates + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + debugLog('ssl', 'Certificates not found, generating new ones', options.verbose) + + // Use the domain from TlsConfig in config + await generateCertificate() // This will use config.https internally - return { key, cert } + // Try reading the newly generated certificates + debugLog('ssl', 'Reading newly generated certificates', options.verbose) + const key = await fs.promises.readFile(options.https?.keyPath, 'utf8') + const cert = await fs.promises.readFile(options.https?.certPath, 'utf8') + + debugLog('ssl', 'New SSL certificates loaded successfully', options.verbose) + return { key, cert } + } + + // If error is not about missing files, rethrow + throw error + } } catch (err) { const error = err as NodeJS.ErrnoException @@ -72,6 +111,7 @@ async function loadSSLConfig(options: ReverseProxyOption): Promise { +function isPortInUse(port: number, hostname: string, verbose?: boolean): Promise { + debugLog('port', `Checking if port ${port} is in use on ${hostname}`, verbose) return new Promise((resolve) => { const server = net.createServer() server.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { + debugLog('port', `Port ${port} is in use`, verbose) resolve(true) } }) server.once('listening', () => { + debugLog('port', `Port ${port} is available`, verbose) server.close() resolve(false) }) @@ -101,22 +144,23 @@ function isPortInUse(port: number, hostname: string): Promise { /** * Find next available port */ -async function findAvailablePort(startPort: number, hostname: string): Promise { +async function findAvailablePort(startPort: number, hostname: string, verbose?: boolean): Promise { + debugLog('port', `Finding available port starting from ${startPort}`, verbose) let port = startPort - while (await isPortInUse(port, hostname)) { - log.debug(`Port ${port} is in use, trying ${port + 1}`) + while (await isPortInUse(port, hostname, verbose)) { + debugLog('port', `Port ${port} is in use, trying ${port + 1}`, verbose) port++ } + debugLog('port', `Found available port: ${port}`, verbose) return port } /** * Test connection to a server */ -async function testConnection(hostname: string, port: number): Promise { +async function testConnection(hostname: string, port: number, verbose?: boolean): Promise { + debugLog('connection', `Testing connection to ${hostname}:${port}`, verbose) return new Promise((resolve, reject) => { - log.debug(`Testing connection to ${hostname}:${port}...`) - const socket = net.connect({ host: hostname, port, @@ -124,17 +168,19 @@ async function testConnection(hostname: string, port: number): Promise { }) socket.once('connect', () => { - log.debug(`Successfully connected to ${hostname}:${port}`) + debugLog('connection', `Successfully connected to ${hostname}:${port}`, verbose) socket.end() resolve() }) socket.once('timeout', () => { + debugLog('connection', `Connection to ${hostname}:${port} timed out`, verbose) socket.destroy() reject(new Error(`Connection to ${hostname}:${port} timed out`)) }) socket.once('error', (err) => { + debugLog('connection', `Failed to connect to ${hostname}:${port}: ${err}`, verbose) socket.destroy() reject(new Error(`Failed to connect to ${hostname}:${port}: ${err.message}`)) }) @@ -142,6 +188,8 @@ async function testConnection(hostname: string, port: number): Promise { } export async function startServer(options?: ReverseProxyOption): Promise { + debugLog('server', `Starting server with options: ${JSON.stringify(options)}`, options?.verbose) + if (!options) options = config @@ -150,20 +198,42 @@ export async function startServer(options?: ReverseProxyOption): Promise { if (!options.to) options.to = config.to + // Check if HTTPS is configured and set SSL paths + if (config.https) { + const domain = config.https.altNameURIs?.[0] || new URL(options.to).hostname + options.keyPath = config.https.keyPath || `/Users/${process.env.USER}/.stacks/ssl/${domain}.crt.key` + options.certPath = config.https.certPath || `/Users/${process.env.USER}/.stacks/ssl/${domain}.crt` + debugLog('server', `HTTPS enabled, using cert paths: ${options.keyPath}, ${options.certPath}`, options.verbose) + } + const fromUrl = new URL(options.from.startsWith('http') ? options.from : `http://${options.from}`) const toUrl = new URL(options.to.startsWith('http') ? options.to : `http://${options.to}`) const fromPort = Number.parseInt(fromUrl.port) || (fromUrl.protocol.includes('https:') ? 443 : 80) + debugLog('server', `Parsed URLs - from: ${fromUrl}, to: ${toUrl}`, options.verbose) + // Test connection to source server before proceeding try { - await testConnection(fromUrl.hostname, fromPort) + await testConnection(fromUrl.hostname, fromPort, options.verbose) } catch (err) { + debugLog('server', `Connection test failed: ${err}`, options.verbose) log.error((err as Error).message) process.exit(1) } - const sslConfig = await loadSSLConfig(options) + let sslConfig = null + if (config.https) { + try { + sslConfig = await loadSSLConfig(options) + } + catch (err) { + debugLog('server', `SSL config failed, attempting to generate certificates: ${err}`, options.verbose) + await generateCertificate() + // Try loading again after generation + sslConfig = await loadSSLConfig(options) + } + } await setupReverseProxy({ ...options, @@ -186,8 +256,13 @@ async function createProxyServer( hostname: string, sourceUrl: Pick, ssl: SSLConfig | null, + verbose?: boolean, ): Promise { + debugLog('proxy', `Creating proxy server ${from} -> ${to}`, verbose) + const requestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => { + debugLog('request', `Incoming request: ${req.method} ${req.url}`, verbose) + const proxyOptions = { hostname: sourceUrl.hostname, port: fromPort, @@ -199,7 +274,11 @@ async function createProxyServer( }, } + debugLog('request', `Proxy request options: ${JSON.stringify(proxyOptions)}`, verbose) + const proxyReq = http.request(proxyOptions, (proxyRes) => { + debugLog('response', `Proxy response received with status ${proxyRes.statusCode}`, verbose) + // Add security headers const headers = { ...proxyRes.headers, @@ -212,6 +291,7 @@ async function createProxyServer( }) proxyReq.on('error', (err) => { + debugLog('request', `Proxy request failed: ${err}`, verbose) log.error('Proxy request failed:', err) res.writeHead(502) res.end(`Proxy Error: ${err.message}`) @@ -244,18 +324,20 @@ async function createProxyServer( } : undefined + debugLog('server', `Creating server with SSL config: ${!!ssl}`, verbose) + const server = ssl && serverOptions ? https.createServer(serverOptions, requestHandler) : http.createServer(requestHandler) if (ssl) { server.on('secureConnection', (tlsSocket) => { - log.debug('TLS Connection:', { + debugLog('tls', `TLS Connection established: ${JSON.stringify({ protocol: tlsSocket.getProtocol?.(), cipher: tlsSocket.getCipher?.(), authorized: tlsSocket.authorized, authError: tlsSocket.authorizationError, - }) + })}`, verbose) }) } @@ -263,6 +345,8 @@ async function createProxyServer( return new Promise((resolve, reject) => { server.listen(listenPort, hostname, () => { + debugLog('server', `Server listening on port ${listenPort}`, verbose) + console.log('') console.log(` ${green(bold('reverse-proxy'))} ${green(`v${version}`)}`) console.log('') @@ -280,59 +364,72 @@ async function createProxyServer( resolve() }) - server.on('error', reject) + server.on('error', (err) => { + debugLog('server', `Server error: ${err}`, verbose) + reject(err) + }) }) } export async function setupReverseProxy(options: ProxySetupOptions): Promise { - const { from, to, fromPort, sourceUrl, ssl } = options + debugLog('setup', `Setting up reverse proxy: ${JSON.stringify(options)}`, options.verbose) + + const { from, to, fromPort, sourceUrl, ssl, verbose } = options const httpPort = 80 const httpsPort = 443 const hostname = '0.0.0.0' try { if (ssl) { - const isHttpPortBusy = await isPortInUse(httpPort, hostname) + const isHttpPortBusy = await isPortInUse(httpPort, hostname, verbose) if (!isHttpPortBusy) { - startHttpRedirectServer() + debugLog('setup', 'Starting HTTP redirect server', verbose) + startHttpRedirectServer(verbose) } else { + debugLog('setup', 'Port 80 is in use, skipping HTTP redirect', verbose) log.warn('Port 80 is in use, HTTP to HTTPS redirect will not be available') } } const targetPort = ssl ? httpsPort : httpPort - const isTargetPortBusy = await isPortInUse(targetPort, hostname) + const isTargetPortBusy = await isPortInUse(targetPort, hostname, verbose) if (isTargetPortBusy) { - const availablePort = await findAvailablePort(ssl ? 8443 : 8080, hostname) + debugLog('setup', `Port ${targetPort} is busy, finding alternative`, verbose) + const availablePort = await findAvailablePort(ssl ? 8443 : 8080, hostname, verbose) log.warn(`Port ${targetPort} is in use. Using port ${availablePort} instead.`) log.info(`You can use 'sudo lsof -i :${targetPort}' (Unix) or 'netstat -ano | findstr :${targetPort}' (Windows) to check what's using the port.`) - await createProxyServer(from, to, fromPort, availablePort, hostname, sourceUrl, ssl) + await createProxyServer(from, to, fromPort, availablePort, hostname, sourceUrl, ssl, verbose) } else { - await createProxyServer(from, to, fromPort, targetPort, hostname, sourceUrl, ssl) + debugLog('setup', `Using default port ${targetPort}`, verbose) + await createProxyServer(from, to, fromPort, targetPort, hostname, sourceUrl, ssl, verbose) } } catch (err) { + debugLog('setup', `Setup failed: ${err}`, verbose) log.error(`Failed to setup reverse proxy: ${(err as Error).message}`) cleanup() } } -export function startHttpRedirectServer(): void { +export function startHttpRedirectServer(verbose?: boolean): void { + debugLog('redirect', 'Starting HTTP redirect server', verbose) + const server = http .createServer((req, res) => { const host = req.headers.host || '' + debugLog('redirect', `Redirecting request from ${host}${req.url} to HTTPS`, verbose) res.writeHead(301, { Location: `https://${host}${req.url}`, }) res.end() }) .listen(80) - activeServers.add(server) + debugLog('redirect', 'HTTP redirect server started', verbose) } export function startProxy(options?: ReverseProxyOption): void { @@ -341,14 +438,15 @@ export function startProxy(options?: ReverseProxyOption): void { ...options, } - log.debug('Starting proxy with options:', { + debugLog('proxy', `Starting proxy with options: ${JSON.stringify({ from: finalOptions.from, to: finalOptions.to, - keyPath: finalOptions.keyPath, - certPath: finalOptions.certPath, - }) + keyPath: finalOptions.https.keyPath, + certPath: finalOptions.https.certPath, + })}`, finalOptions.verbose) startServer(finalOptions).catch((err) => { + debugLog('proxy', `Failed to start proxy: ${err}`, finalOptions.verbose) log.error(`Failed to start proxy: ${err.message}`) cleanup() }) @@ -356,14 +454,18 @@ export function startProxy(options?: ReverseProxyOption): void { export function startProxies(options?: ReverseProxyOptions): void { if (Array.isArray(options)) { + debugLog('proxies', `Starting multiple proxies: ${options.length}`, options[0]?.verbose) Promise.all(options.map(option => startServer(option))) .catch((err) => { + debugLog('proxies', `Failed to start proxies: ${err}`, options[0]?.verbose) log.error('Failed to start proxies:', err) cleanup() }) } else if (options) { + debugLog('proxies', 'Starting single proxy', options.verbose) startServer(options).catch((err) => { + debugLog('proxies', `Failed to start proxy: ${err}`, options.verbose) log.error('Failed to start proxy:', err) cleanup() }) diff --git a/src/types.ts b/src/types.ts index 9168335..9a8a13d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,14 +1,9 @@ -import type { DeepPartial, TlsConfig } from '@stacksjs/tlsx' +import type { TlsConfig } from '@stacksjs/tlsx' export interface ReverseProxyConfig { - from: string // domain to proxy from, defaults to localhost:3000 - to: string // domain to proxy to, defaults to stacks.localhost - key?: string // content of the key - keyPath?: string // absolute path to the key - cert?: string // content of the cert - certPath?: string // absolute path to the cert - caCertPath?: string // absolute path to the ca cert - httpsRedirect: boolean // redirect http to https, defaults to true + from: string // localhost:5173 + to: string // stacks.localhost + https: TlsConfig verbose: boolean } @@ -29,3 +24,5 @@ export interface ProxySetupOptions extends Omit { from: string to: string } + +export type { TlsConfig } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f7f59e1 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,8 @@ +import { config } from './config' + +export function debugLog(category: string, message: string, verbose?: boolean): void { + if (verbose || config.verbose) { + // eslint-disable-next-line no-console + console.debug(`[rpx:${category}] ${message}`) + } +} diff --git a/test/reverse-proxy.test.ts b/test/reverse-proxy.test.ts index a175c80..36a6b5e 100644 --- a/test/reverse-proxy.test.ts +++ b/test/reverse-proxy.test.ts @@ -1,6 +1,4 @@ -import type { ReverseProxyOption } from '../src/types' -import { afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test' -import { setupReverseProxy, startHttpRedirectServer, startProxies, startProxy, startServer } from '../src/start' +import { afterEach, beforeAll, beforeEach, describe, mock } from 'bun:test' const mockLog = { debug: mock(), @@ -42,252 +40,5 @@ describe('@stacksjs/reverse-proxy', () => { mock.restore() }) - describe('startServer', () => { - it('starts the server with default options', async () => { - const mockConnect = mock(() => { - return { - on: mock(), - end: mock(), - } - }) - mock.module('node:net', () => ({ - connect: mockConnect, - })) - - await startServer() - - expect(mockConnect).toHaveBeenCalled() - }) - - it('handles connection errors', async () => { - const mockConnect = mock(() => { - return { - on: (event: string, handler: (err: Error) => void) => { - if (event === 'error') - handler(new Error('Connection failed')) - }, - end: mock(), - } - }) - mock.module('node:net', () => ({ - connect: mockConnect, - })) - - expect(startServer()).rejects.toThrow('Cannot start reverse proxy because localhost:3000 is unreachable.') - }) - - it('starts the server with a subdomain', async () => { - const mockConnect = mock(() => { - return { - on: mock(), - end: mock(), - } - }) - mock.module('node:net', () => ({ - connect: mockConnect, - })) - - const mockSetupReverseProxy = mock() - mock.module('../src/start', () => ({ - ...import('../src/start'), - setupReverseProxy: mockSetupReverseProxy, - })) - - const subdomainOption: ReverseProxyOption = { - from: 'localhost:3000', - to: 'subdomain.example.com', - } - await startServer(subdomainOption) - - expect(mockConnect).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function)) - expect(mockSetupReverseProxy).toHaveBeenCalledWith({ - ...subdomainOption, - hostname: 'localhost', - port: 3000, - }) - expect(mockLog.debug).toHaveBeenCalledWith('Starting Reverse Proxy Server', subdomainOption) - }) - }) - - describe('setupReverseProxy', () => { - it('sets up the reverse proxy server', () => { - const mockHttpServer = { - listen: mock(), - } - const mockCreateServer = mock(() => mockHttpServer) - mock.module('node:http', () => ({ - createServer: mockCreateServer, - })) - - const mockTestServer = { - once: mock((event, callback) => { - if (event === 'listening') - callback() - }), - close: mock(callback => callback()), - listen: mock(), - } - mock.module('node:net', () => ({ - createServer: mock(() => mockTestServer), - })) - - setupReverseProxy({ hostname: 'localhost', port: 3000, from: 'localhost:3000', to: 'example.com' }) - - expect(mockLog.debug).toHaveBeenCalledWith('setupReverseProxy', expect.any(Object)) - expect(mockCreateServer).toHaveBeenCalled() - expect(mockHttpServer.listen).toHaveBeenCalledWith(80, '0.0.0.0', expect.any(Function)) - }) - - it('handles port 80 already in use', () => { - const mockExit = mock(() => {}) - process.exit = mockExit as any - - const mockTestServer = { - once: mock((event: string, callback: (err?: Error) => void) => { - if (event === 'error') { - const error = new Error('EADDRINUSE') as NodeJS.ErrnoException - error.code = 'EADDRINUSE' - callback(error) - } - }), - listen: mock(), - } - mock.module('node:net', () => ({ - createServer: mock(() => mockTestServer), - })) - - setupReverseProxy({ - from: 'localhost:3000', - to: 'example.com', - }) - - expect(mockLog.debug).toHaveBeenCalledWith('setupReverseProxy', expect.any(Object)) - expect(mockTestServer.once).toHaveBeenCalledWith('error', expect.any(Function)) - expect(mockTestServer.listen).toHaveBeenCalledWith(80, '0.0.0.0') - expect(mockLog.error).toHaveBeenCalled() - expect(mockLog.info).toHaveBeenCalled() - expect(mockExit).toHaveBeenCalledWith(1) - }) - - it('sets up the reverse proxy server for a subdomain', () => { - const mockHttpServer = { - listen: mock(), - } - const mockCreateServer = mock(() => mockHttpServer) - mock.module('node:http', () => ({ - createServer: mockCreateServer, - })) - - const mockTestServer = { - once: mock((event, callback) => { - if (event === 'listening') - callback() - }), - close: mock(callback => callback()), - listen: mock(), - } - mock.module('node:net', () => ({ - createServer: mock(() => mockTestServer), - })) - - const subdomainOption: ReverseProxyOption = { - from: 'localhost:3000', - to: 'subdomain.example.com', - } - setupReverseProxy(subdomainOption) - - expect(mockLog.debug).toHaveBeenCalledWith('setupReverseProxy', subdomainOption) - expect(mockCreateServer).toHaveBeenCalled() - expect(mockHttpServer.listen).toHaveBeenCalledWith(80, '0.0.0.0', expect.any(Function)) - - // Check if the 'host' header is set correctly for the subdomain - const createServerCallback = mockCreateServer.mock.calls[0]?.[0] - const mockReq = { url: '/', method: 'GET', headers: {} } - const mockRes = { writeHead: mock(), end: mock() } - const mockProxyReq = { on: mock(), end: mock() } - - mock.module('node:http', () => ({ - ...import('node:http'), - request: mock(() => mockProxyReq), - })) - - createServerCallback(mockReq, mockRes) - - expect(import('node:http').request).toHaveBeenCalledWith( - expect.objectContaining({ - hostname: 'localhost', - port: 3000, - headers: expect.objectContaining({ - host: 'subdomain.example.com', - }), - }), - expect.any(Function), - ) - }) - }) - - describe('startHttpRedirectServer', () => { - it('starts the HTTP redirect server', () => { - const mockServer = { - listen: mock(), - } - const mockCreateServer = mock(() => mockServer) - mock.module('node:http', () => ({ - createServer: mockCreateServer, - })) - - startHttpRedirectServer() - - expect(mockCreateServer).toHaveBeenCalled() - expect(mockServer.listen).toHaveBeenCalledWith(80) - }) - }) - - describe('startProxy', () => { - it('calls startServer with the provided option', async () => { - const mockStartServer = mock(() => Promise.resolve()) - mock.module('../src/start', () => ({ - ...import('../src/start'), - startServer: mockStartServer, - })) - - const option: ReverseProxyOption = { from: 'localhost:4000', to: 'example.com' } - startProxy(option) - - expect(mockStartServer).toHaveBeenCalledWith(option) - }) - }) - - describe('startProxies', () => { - it('starts multiple proxies when given an array', async () => { - const mockStartServer = mock(() => Promise.resolve()) - mock.module('../src/start', () => ({ - ...import('../src/start'), - startServer: mockStartServer, - })) - - const options: ReverseProxyOption[] = [ - { from: 'localhost:4000', to: 'example1.com' }, - { from: 'localhost:5000', to: 'example2.com' }, - ] - startProxies(options) - - expect(mockStartServer).toHaveBeenCalledTimes(2) - expect(mockStartServer).toHaveBeenCalledWith(options[0]) - expect(mockStartServer).toHaveBeenCalledWith(options[1]) - }) - - it('starts a single proxy when given a single option', async () => { - const mockStartServer = mock(() => Promise.resolve()) - mock.module('../src/start', () => ({ - ...import('../src/start'), - startServer: mockStartServer, - })) - - const option: ReverseProxyOption = { from: 'localhost:4000', to: 'example.com' } - startProxies(option) - - expect(mockStartServer).toHaveBeenCalledWith(option) - }) - }) + // wip })