From 4958b6624d04b0b64c60dc41f2ef85cb53a6505c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:56:05 +0100 Subject: [PATCH] fix: Refetch previews on start and switch to path-based routing Two fixes: - Call previewsQuery.refetch() in startPreview.onSuccess so the UI transitions from "building" to the preview link without a page refresh. - Switch from subdomain routing (*.localhost) to path-based routing (localhost://) since macOS doesn't resolve wildcard localhost subdomains. --- 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 ++-- .../components/review/InitiativeReview.tsx | 1 + apps/web/src/components/review/ReviewTab.tsx | 1 + 7 files changed, 33 insertions(+), 29 deletions(-) diff --git a/apps/server/preview/compose-generator.test.ts b/apps/server/preview/compose-generator.test.ts index 62448f0..37aebf2 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 subdomain routing', () => { + it('generates single-preview Caddyfile with path-based routing', () => { const previews = new Map(); previews.set('abc123', [ { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, @@ -164,7 +164,8 @@ describe('generateGatewayCaddyfile', () => { const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('auto_https off'); - expect(caddyfile).toContain('abc123.localhost:9100 {'); + expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); @@ -176,13 +177,13 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('handle_path /api/*'); + expect(caddyfile).toContain('handle_path /abc123/api/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); - expect(caddyfile).toContain('handle {'); + expect(caddyfile).toContain('handle_path /abc123/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('generates multi-preview Caddyfile with separate subdomain blocks', () => { + it('generates multi-preview Caddyfile under single host block', () => { const previews = new Map(); previews.set('abc', [ { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, @@ -192,8 +193,9 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - expect(caddyfile).toContain('abc.localhost:9100 {'); - expect(caddyfile).toContain('xyz.localhost:9100 {'); + expect(caddyfile).toContain('localhost:9100 {'); + expect(caddyfile).toContain('handle_path /abc/*'); + expect(caddyfile).toContain('handle_path /xyz/*'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); }); @@ -207,12 +209,12 @@ describe('generateGatewayCaddyfile', () => { ]); const caddyfile = generateGatewayCaddyfile(previews, 9100); - const apiAuthIdx = caddyfile.indexOf('/api/auth'); - const apiIdx = caddyfile.indexOf('handle_path /api/*'); - const handleIdx = caddyfile.indexOf('handle {'); + const apiAuthIdx = caddyfile.indexOf('/abc/api/auth'); + const apiIdx = caddyfile.indexOf('handle_path /abc/api/*'); + const rootIdx = caddyfile.indexOf('handle_path /abc/*'); expect(apiAuthIdx).toBeLessThan(apiIdx); - expect(apiIdx).toBeLessThan(handleIdx); + expect(apiIdx).toBeLessThan(rootIdx); }); }); diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts index 967921a..c9b67c0 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 - * subdomain requests to per-preview compose stacks on a shared Docker network. + * path-prefixed requests to per-preview compose stacks on a shared Docker network. * * Architecture: * .cw-previews/gateway/ @@ -195,7 +195,8 @@ export class GatewayManager { /** * Generate a Caddyfile for the gateway from all active preview routes. * - * Each preview gets a subdomain block: `.localhost:` + * Uses path-based routing under a single `localhost:` block. + * Each preview is accessible at `//...` — no subdomain DNS needed. * Routes within a preview are sorted by specificity (longest path first). */ export function generateGatewayCaddyfile( @@ -207,6 +208,7 @@ export function generateGatewayCaddyfile( ' auto_https off', '}', '', + `localhost:${port} {`, ]; for (const [previewId, routes] of previews) { @@ -217,24 +219,22 @@ export function generateGatewayCaddyfile( 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(` 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 ${path}/* {`); + lines.push(` handle_path /${previewId}${path}/* {`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` }`); } } - - 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 529cdf1..0eaf38d 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 subdomain routing + * Polls service healthcheck endpoints through the gateway's path-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 subdomain) + * @param previewId - The preview deployment ID (used as path prefix) * @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://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; + const url = `http://localhost:${gatewayPort}/${previewId}${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 d668b6b..39cacb7 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://abc123test.localhost:9100'); + expect(result.url).toBe('http://localhost:9100/abc123test/'); 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://abc123test.localhost:9100', + 'http://localhost:9100/abc123test/', ); }); @@ -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://aaa.localhost:9100'); + expect(previews[0].url).toBe('http://localhost:9100/aaa/'); 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://abc.localhost:9100'); + expect(status!.url).toBe('http://localhost:9100/abc/'); expect(status!.mode).toBe('preview'); }); diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 6806c4f..7e50bed 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://${id}.localhost:${gatewayPort}`; + const url = `http://localhost:${gatewayPort}/${id}/`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ @@ -605,7 +605,7 @@ export class PreviewManager { projectId, branch, gatewayPort, - url: `http://${previewId}.localhost:${gatewayPort}`, + url: `http://localhost:${gatewayPort}/${previewId}/`, mode, status: 'running', services: [], diff --git a/apps/web/src/components/review/InitiativeReview.tsx b/apps/web/src/components/review/InitiativeReview.tsx index 14b4864..50b4750 100644 --- a/apps/web/src/components/review/InitiativeReview.tsx +++ b/apps/web/src/components/review/InitiativeReview.tsx @@ -73,6 +73,7 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview const startPreview = trpc.startPreview.useMutation({ onSuccess: (data) => { setActivePreviewId(data.id); + previewsQuery.refetch(); toast.success(`Preview running at ${data.url}`); }, onError: (err) => toast.error(`Preview failed: ${err.message}`), diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index def288f..7d05afa 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -99,6 +99,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const startPreview = trpc.startPreview.useMutation({ onSuccess: (data) => { setActivePreviewId(data.id); + previewsQuery.refetch(); toast.success(`Preview running at ${data.url}`); }, onError: (err) => toast.error(`Preview failed: ${err.message}`),