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 @@
-
+
[![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
})