Files
Codewalkers/apps/server/preview/gateway.ts
Lukas May 143aad58e8 feat: Replace per-preview Caddy sidecars with shared gateway architecture
Refactor preview deployments to use a single shared Caddy gateway container
with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy
sidecar and one port per preview. Adds dev/preview modes, git worktree
support for branch checkouts, and auto-start on phase:pending_review.

- Add GatewayManager for shared Caddy lifecycle + Caddyfile generation
- Add git worktree helpers for preview mode branch checkouts
- Add dev mode: volume-mount + dev server image instead of build
- Remove per-preview Caddy sidecar and port publishing
- Use shared cw-preview-net Docker network with container name DNS
- Auto-start previews when phase enters pending_review
- Delete unused PreviewPanel.tsx
- Update all tests (40 pass), docs, events, CLI, tRPC, frontend
2026-03-05 12:22:29 +01:00

241 lines
6.9 KiB
TypeScript

/**
* Gateway Manager
*
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
* subdomain 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.
*
* Each preview gets a subdomain block: `<previewId>.localhost:<port>`
* Routes within a preview are sorted by specificity (longest path first).
*/
export function generateGatewayCaddyfile(
previews: Map<string, GatewayRoute[]>,
port: number,
): string {
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(`${previewId}.localhost:${port} {`);
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');
}