The Caddyfile was using the host port (e.g., 9100) as the Caddy listen address, but Docker maps host:9100 → container:80. Caddy inside the container was listening on 9100 while Docker only forwarded to port 80, causing all health checks to fail with "connection reset by peer".
243 lines
7.2 KiB
TypeScript
243 lines
7.2 KiB
TypeScript
/**
|
|
* Gateway Manager
|
|
*
|
|
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
|
|
* path-prefixed 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 path-based routing under a single `localhost:<port>` block.
|
|
* Each preview is accessible at `/<previewId>/...` — no subdomain DNS 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',
|
|
'}',
|
|
'',
|
|
`:80 {`,
|
|
];
|
|
|
|
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;
|
|
});
|
|
|
|
for (const route of sorted) {
|
|
if (route.route === '/') {
|
|
lines.push(` handle_path /${previewId}/* {`);
|
|
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 /${previewId}${path}/* {`);
|
|
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
|
lines.push(` }`);
|
|
}
|
|
}
|
|
}
|
|
|
|
lines.push('}');
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
}
|