/** * 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 { 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): Promise { 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 { 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 { 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 { 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 `.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, _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'); }