Path-prefix routing (`localhost:9100/<id>/`) broke SPAs because absolute asset paths (`/assets/index.js`) didn't match the `handle_path /<id>/*` route. Subdomain routing (`<id>.localhost:9100/`) resolves this since all paths are relative to the root. Chrome/Firefox resolve *.localhost to 127.0.0.1 natively — no DNS setup needed.
245 lines
7.2 KiB
TypeScript
245 lines
7.2 KiB
TypeScript
/**
|
|
* Gateway Manager
|
|
*
|
|
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
|
|
* subdomain-based requests to per-preview compose stacks on a shared Docker network.
|
|
*
|
|
* Architecture:
|
|
* .cw-previews/gateway/
|
|
* docker-compose.yml ← single Caddy container
|
|
* Caddyfile ← regenerated on each preview add/remove
|
|
*
|
|
* Caddy runs with `--watch` so it auto-reloads when the Caddyfile changes on disk.
|
|
*/
|
|
|
|
import { join } from 'node:path';
|
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import yaml from 'js-yaml';
|
|
import { GATEWAY_PROJECT_NAME, GATEWAY_NETWORK } from './types.js';
|
|
import {
|
|
ensureDockerNetwork,
|
|
removeDockerNetwork,
|
|
composeUp,
|
|
composeDown,
|
|
composePs,
|
|
} from './docker-client.js';
|
|
import { allocatePort } from './port-allocator.js';
|
|
import { createModuleLogger } from '../logger/index.js';
|
|
|
|
const log = createModuleLogger('preview:gateway');
|
|
|
|
/** Directory for preview deployment artifacts (relative to workspace root) */
|
|
const PREVIEWS_DIR = '.cw-previews';
|
|
|
|
/**
|
|
* A route entry for the gateway Caddyfile.
|
|
*/
|
|
export interface GatewayRoute {
|
|
containerName: string;
|
|
port: number;
|
|
route: string;
|
|
}
|
|
|
|
export class GatewayManager {
|
|
private readonly workspaceRoot: string;
|
|
private cachedPort: number | null = null;
|
|
|
|
constructor(workspaceRoot: string) {
|
|
this.workspaceRoot = workspaceRoot;
|
|
}
|
|
|
|
/**
|
|
* Ensure the gateway is running. Idempotent — returns the port if already up.
|
|
*
|
|
* 1. Create the shared Docker network
|
|
* 2. Check if gateway already running → return existing port
|
|
* 3. Allocate a port, write compose + empty Caddyfile, start
|
|
*/
|
|
async ensureGateway(): Promise<number> {
|
|
await ensureDockerNetwork(GATEWAY_NETWORK);
|
|
|
|
// Check if already running
|
|
const existingPort = await this.getPort();
|
|
if (existingPort !== null) {
|
|
this.cachedPort = existingPort;
|
|
log.info({ port: existingPort }, 'gateway already running');
|
|
return existingPort;
|
|
}
|
|
|
|
// Allocate a port for the gateway
|
|
const port = await allocatePort();
|
|
this.cachedPort = port;
|
|
|
|
// Write gateway compose + empty Caddyfile
|
|
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
|
|
await mkdir(gatewayDir, { recursive: true });
|
|
|
|
const composeContent = this.generateGatewayCompose(port);
|
|
await writeFile(join(gatewayDir, 'docker-compose.yml'), composeContent, 'utf-8');
|
|
|
|
// Start with an empty Caddyfile — will be populated by updateRoutes()
|
|
const emptyCaddyfile = '{\n auto_https off\n}\n';
|
|
await writeFile(join(gatewayDir, 'Caddyfile'), emptyCaddyfile, 'utf-8');
|
|
|
|
const composePath = join(gatewayDir, 'docker-compose.yml');
|
|
await composeUp(composePath, GATEWAY_PROJECT_NAME);
|
|
|
|
log.info({ port }, 'gateway started');
|
|
return port;
|
|
}
|
|
|
|
/**
|
|
* Regenerate the Caddyfile from all active previews.
|
|
* Caddy's `--watch` flag picks up the file change automatically.
|
|
*/
|
|
async updateRoutes(previews: Map<string, GatewayRoute[]>): Promise<void> {
|
|
const port = this.cachedPort ?? (await this.getPort());
|
|
if (port === null) {
|
|
log.warn('cannot update routes — gateway not running');
|
|
return;
|
|
}
|
|
|
|
const caddyfile = generateGatewayCaddyfile(previews, port);
|
|
const caddyfilePath = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway', 'Caddyfile');
|
|
await writeFile(caddyfilePath, caddyfile, 'utf-8');
|
|
|
|
log.info({ previewCount: previews.size }, 'gateway routes updated');
|
|
}
|
|
|
|
/**
|
|
* Stop the gateway and remove the shared network.
|
|
*/
|
|
async stopGateway(): Promise<void> {
|
|
await composeDown(GATEWAY_PROJECT_NAME).catch(() => {});
|
|
await removeDockerNetwork(GATEWAY_NETWORK);
|
|
|
|
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
|
|
await rm(gatewayDir, { recursive: true, force: true }).catch(() => {});
|
|
|
|
this.cachedPort = null;
|
|
log.info('gateway stopped');
|
|
}
|
|
|
|
/**
|
|
* Check if the gateway compose project is running.
|
|
*/
|
|
async isRunning(): Promise<boolean> {
|
|
const services = await composePs(GATEWAY_PROJECT_NAME);
|
|
return services.some((s) => s.state === 'running');
|
|
}
|
|
|
|
/**
|
|
* Read the gateway port from the running container's labels.
|
|
* Returns null if the gateway isn't running.
|
|
*/
|
|
async getPort(): Promise<number | null> {
|
|
if (this.cachedPort !== null) {
|
|
// Verify the container is still running
|
|
if (await this.isRunning()) {
|
|
return this.cachedPort;
|
|
}
|
|
this.cachedPort = null;
|
|
}
|
|
|
|
try {
|
|
const { execa } = await import('execa');
|
|
const result = await execa('docker', [
|
|
'ps',
|
|
'--filter', `label=cw.gateway=true`,
|
|
'--format', `{{.Label "cw.gateway-port"}}`,
|
|
], { timeout: 15000 });
|
|
|
|
if (!result.stdout.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const port = parseInt(result.stdout.trim().split('\n')[0], 10);
|
|
if (isNaN(port)) return null;
|
|
|
|
this.cachedPort = port;
|
|
return port;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate the gateway docker-compose.yml content.
|
|
*/
|
|
private generateGatewayCompose(port: number): string {
|
|
const compose = {
|
|
services: {
|
|
caddy: {
|
|
image: 'caddy:2-alpine',
|
|
command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile', '--watch'],
|
|
ports: [`${port}:80`],
|
|
volumes: ['./Caddyfile:/etc/caddy/Caddyfile:rw'],
|
|
networks: [GATEWAY_NETWORK],
|
|
labels: {
|
|
'cw.gateway': 'true',
|
|
'cw.gateway-port': String(port),
|
|
},
|
|
},
|
|
},
|
|
networks: {
|
|
[GATEWAY_NETWORK]: {
|
|
external: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
return yaml.dump(compose, { lineWidth: 120, noRefs: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a Caddyfile for the gateway from all active preview routes.
|
|
*
|
|
* Uses subdomain-based routing: each preview gets its own `<previewId>.localhost:80` block.
|
|
* Chrome/Firefox resolve `*.localhost` to 127.0.0.1 natively — no DNS setup needed.
|
|
* Routes within a preview are sorted by specificity (longest path first).
|
|
*/
|
|
export function generateGatewayCaddyfile(
|
|
previews: Map<string, GatewayRoute[]>,
|
|
_port: number,
|
|
): string {
|
|
// Caddy runs inside a container where Docker maps host:${port} → container:80.
|
|
// The Caddyfile must listen on the container-internal port (80), not the host port.
|
|
const lines: string[] = [
|
|
'{',
|
|
' auto_https off',
|
|
'}',
|
|
];
|
|
|
|
for (const [previewId, routes] of previews) {
|
|
// Sort routes by specificity (longer paths first, root last)
|
|
const sorted = [...routes].sort((a, b) => {
|
|
if (a.route === '/') return 1;
|
|
if (b.route === '/') return -1;
|
|
return b.route.length - a.route.length;
|
|
});
|
|
|
|
lines.push('');
|
|
lines.push(`${previewId}.localhost:80 {`);
|
|
|
|
for (const route of sorted) {
|
|
if (route.route === '/') {
|
|
lines.push(` handle /* {`);
|
|
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
|
lines.push(` }`);
|
|
} else {
|
|
const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route;
|
|
lines.push(` handle_path ${path}/* {`);
|
|
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
|
lines.push(` }`);
|
|
}
|
|
}
|
|
|
|
lines.push('}');
|
|
}
|
|
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
}
|