From 1b8e496d395ebf7a632016ecad69e87ca73bc8b0 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 22:38:00 +0100 Subject: [PATCH] fix: Switch preview gateway from path-prefix to subdomain routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path-prefix routing (`localhost:9100//`) broke SPAs because absolute asset paths (`/assets/index.js`) didn't match the `handle_path //*` route. Subdomain routing (`.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. --- apps/server/preview/compose-generator.test.ts | 24 +++++++++---------- apps/server/preview/gateway.ts | 18 +++++++------- apps/server/preview/health-checker.ts | 6 ++--- apps/server/preview/manager.test.ts | 8 +++---- apps/server/preview/manager.ts | 4 ++-- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 1e1f650..234d62b 100644 --- a/apps/server/preview/compose-generator.test.ts +++ b/apps/server/preview/compose-generator.test.ts @@ -156,7 +156,7 @@ describe('generateComposeFile', () => { }); describe('generateGatewayCaddyfile', () => { - it('generates single-preview Caddyfile with path-based routing', () => { + it('generates single-preview Caddyfile with subdomain routing', () => { const previews = new Map(); previews.set('abc123', [ { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, @@ -164,8 +164,8 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain(':80 {'); - expect(caddyfile).toContain('handle_path /abc123/*'); + expect(caddyfile).toContain('abc123.localhost:80 {'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -177,13 +177,14 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('handle_path /abc123/api/*'); + expect(caddyfile).toContain('abc123.localhost:80 {'); + expect(caddyfile).toContain('handle_path /api/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); - expect(caddyfile).toContain('handle_path /abc123/*'); + expect(caddyfile).toContain('handle /* {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('generates multi-preview Caddyfile under single host block', () => { + it('generates separate subdomain blocks for each preview', () => { const previews = new Map(); previews.set('abc', [ { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, @@ -193,9 +194,8 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain(':80 {'); - expect(caddyfile).toContain('handle_path /abc/*'); - expect(caddyfile).toContain('handle_path /xyz/*'); + expect(caddyfile).toContain('abc.localhost:80 {'); + expect(caddyfile).toContain('xyz.localhost:80 {'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); }); @@ -209,9 +209,9 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - const apiAuthIdx = caddyfile.indexOf('/abc/api/auth'); - const apiIdx = caddyfile.indexOf('handle_path /abc/api/*'); - const rootIdx = caddyfile.indexOf('handle_path /abc/*'); + const apiAuthIdx = caddyfile.indexOf('/api/auth'); + const apiIdx = caddyfile.indexOf('handle_path /api/*'); + const rootIdx = caddyfile.indexOf('handle /* {'); expect(apiAuthIdx).toBeLessThan(apiIdx); expect(apiIdx).toBeLessThan(rootIdx); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index 57536c5..ed4ec96 100644 --- a/apps/server/preview/gateway.ts +++ b/apps/server/preview/gateway.ts @@ -2,7 +2,7 @@ * 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. + * subdomain-based requests to per-preview compose stacks on a shared Docker network. * * Architecture: * .cw-previews/gateway/ @@ -195,8 +195,8 @@ export class GatewayManager { /** * Generate a Caddyfile for the gateway from all active preview routes. * - * Uses path-based routing under a single `localhost:` block. - * Each preview is accessible at `//...` — no subdomain DNS needed. + * 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( @@ -209,8 +209,6 @@ export function generateGatewayCaddyfile( '{', ' auto_https off', '}', - '', - `:80 {`, ]; for (const [previewId, routes] of previews) { @@ -221,21 +219,25 @@ export function generateGatewayCaddyfile( 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_path /${previewId}/* {`); + 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 /${previewId}${path}/* {`); + lines.push(` handle_path ${path}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } } + + lines.push('}'); } - lines.push('}'); lines.push(''); return lines.join('\n'); diff --git a/apps/server/preview/health-checker.ts b/apps/server/preview/health-checker.ts index 0eaf38d..febe87b 100644 --- a/apps/server/preview/health-checker.ts +++ b/apps/server/preview/health-checker.ts @@ -1,7 +1,7 @@ /** * Health Checker * - * Polls service healthcheck endpoints through the gateway's path-based routing + * Polls service healthcheck endpoints through the gateway's subdomain-based routing * to verify that preview services are ready. */ @@ -20,7 +20,7 @@ const DEFAULT_INTERVAL_MS = 3_000; * Wait for all non-internal services to become healthy by polling their * healthcheck endpoints through the gateway's subdomain routing. * - * @param previewId - The preview deployment ID (used as path prefix) + * @param previewId - The preview deployment ID (used as subdomain) * @param gatewayPort - The gateway's host port * @param config - Preview config with service definitions * @param timeoutMs - Maximum time to wait (default: 120s) @@ -60,7 +60,7 @@ export async function waitForHealthy( const route = svc.route ?? '/'; const healthPath = svc.healthcheck!.path; const basePath = route === '/' ? '' : route; - const url = `http://localhost:${gatewayPort}/${previewId}${basePath}${healthPath}`; + const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; try { const response = await fetch(url, { diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index 39cacb7..4f60cfc 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -220,7 +220,7 @@ describe('PreviewManager', () => { expect(result.projectId).toBe('proj-1'); expect(result.branch).toBe('feature-x'); expect(result.gatewayPort).toBe(9100); - expect(result.url).toBe('http://localhost:9100/abc123test/'); + expect(result.url).toBe('http://abc123test.localhost:9100/'); expect(result.mode).toBe('preview'); expect(result.status).toBe('running'); @@ -233,7 +233,7 @@ describe('PreviewManager', () => { expect(buildingEvent).toBeDefined(); expect(readyEvent).toBeDefined(); expect((readyEvent!.payload as Record).url).toBe( - 'http://localhost:9100/abc123test/', + 'http://abc123test.localhost:9100/', ); }); @@ -472,7 +472,7 @@ describe('PreviewManager', () => { expect(previews).toHaveLength(2); expect(previews[0].id).toBe('aaa'); expect(previews[0].gatewayPort).toBe(9100); - expect(previews[0].url).toBe('http://localhost:9100/aaa/'); + expect(previews[0].url).toBe('http://aaa.localhost:9100/'); expect(previews[0].mode).toBe('preview'); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); @@ -573,7 +573,7 @@ describe('PreviewManager', () => { expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); expect(status!.gatewayPort).toBe(9100); - expect(status!.url).toBe('http://localhost:9100/abc/'); + expect(status!.url).toBe('http://abc.localhost:9100/'); expect(status!.mode).toBe('preview'); }); diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 7e50bed..2b04cc3 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -239,7 +239,7 @@ export class PreviewManager { await this.runSeeds(projectName, config); // 11. Success - const url = `http://localhost:${gatewayPort}/${id}/`; + const url = `http://${id}.localhost:${gatewayPort}/`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ @@ -605,7 +605,7 @@ export class PreviewManager { projectId, branch, gatewayPort, - url: `http://localhost:${gatewayPort}/${previewId}/`, + url: `http://${previewId}.localhost:${gatewayPort}/`, mode, status: 'running', services: [],