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:<port>/<id>/) since macOS doesn't resolve wildcard
  localhost subdomains.
This commit is contained in:
Lukas May
2026-03-05 21:56:05 +01:00
parent 0e61c48c86
commit 4958b6624d
7 changed files with 33 additions and 29 deletions

View File

@@ -156,7 +156,7 @@ describe('generateComposeFile', () => {
}); });
describe('generateGatewayCaddyfile', () => { describe('generateGatewayCaddyfile', () => {
it('generates single-preview Caddyfile with subdomain routing', () => { it('generates single-preview Caddyfile with path-based routing', () => {
const previews = new Map<string, GatewayRoute[]>(); const previews = new Map<string, GatewayRoute[]>();
previews.set('abc123', [ previews.set('abc123', [
{ containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' },
@@ -164,7 +164,8 @@ describe('generateGatewayCaddyfile', () => {
const caddyfile = generateGatewayCaddyfile(previews, 9100); const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('auto_https off'); 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'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000');
}); });
@@ -176,13 +177,13 @@ describe('generateGatewayCaddyfile', () => {
]); ]);
const caddyfile = generateGatewayCaddyfile(previews, 9100); 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('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'); 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<string, GatewayRoute[]>(); const previews = new Map<string, GatewayRoute[]>();
previews.set('abc', [ previews.set('abc', [
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, { containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
@@ -192,8 +193,9 @@ describe('generateGatewayCaddyfile', () => {
]); ]);
const caddyfile = generateGatewayCaddyfile(previews, 9100); const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('abc.localhost:9100 {'); expect(caddyfile).toContain('localhost:9100 {');
expect(caddyfile).toContain('xyz.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-abc-app:3000');
expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000');
}); });
@@ -207,12 +209,12 @@ describe('generateGatewayCaddyfile', () => {
]); ]);
const caddyfile = generateGatewayCaddyfile(previews, 9100); const caddyfile = generateGatewayCaddyfile(previews, 9100);
const apiAuthIdx = caddyfile.indexOf('/api/auth'); const apiAuthIdx = caddyfile.indexOf('/abc/api/auth');
const apiIdx = caddyfile.indexOf('handle_path /api/*'); const apiIdx = caddyfile.indexOf('handle_path /abc/api/*');
const handleIdx = caddyfile.indexOf('handle {'); const rootIdx = caddyfile.indexOf('handle_path /abc/*');
expect(apiAuthIdx).toBeLessThan(apiIdx); expect(apiAuthIdx).toBeLessThan(apiIdx);
expect(apiIdx).toBeLessThan(handleIdx); expect(apiIdx).toBeLessThan(rootIdx);
}); });
}); });

View File

@@ -2,7 +2,7 @@
* Gateway Manager * Gateway Manager
* *
* Manages a single shared Caddy reverse proxy (the "gateway") that routes * 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: * Architecture:
* .cw-previews/gateway/ * .cw-previews/gateway/
@@ -195,7 +195,8 @@ export class GatewayManager {
/** /**
* Generate a Caddyfile for the gateway from all active preview routes. * Generate a Caddyfile for the gateway from all active preview routes.
* *
* Each preview gets a subdomain block: `<previewId>.localhost:<port>` * 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). * Routes within a preview are sorted by specificity (longest path first).
*/ */
export function generateGatewayCaddyfile( export function generateGatewayCaddyfile(
@@ -207,6 +208,7 @@ export function generateGatewayCaddyfile(
' auto_https off', ' auto_https off',
'}', '}',
'', '',
`localhost:${port} {`,
]; ];
for (const [previewId, routes] of previews) { for (const [previewId, routes] of previews) {
@@ -217,24 +219,22 @@ export function generateGatewayCaddyfile(
return b.route.length - a.route.length; return b.route.length - a.route.length;
}); });
lines.push(`${previewId}.localhost:${port} {`);
for (const route of sorted) { for (const route of sorted) {
if (route.route === '/') { if (route.route === '/') {
lines.push(` handle {`); lines.push(` handle_path /${previewId}/* {`);
lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
lines.push(` }`); lines.push(` }`);
} else { } else {
const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; 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(` reverse_proxy ${route.containerName}:${route.port}`);
lines.push(` }`); lines.push(` }`);
} }
} }
}
lines.push('}'); lines.push('}');
lines.push(''); lines.push('');
}
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Health Checker * 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. * 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 * Wait for all non-internal services to become healthy by polling their
* healthcheck endpoints through the gateway's subdomain routing. * 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 gatewayPort - The gateway's host port
* @param config - Preview config with service definitions * @param config - Preview config with service definitions
* @param timeoutMs - Maximum time to wait (default: 120s) * @param timeoutMs - Maximum time to wait (default: 120s)
@@ -60,7 +60,7 @@ export async function waitForHealthy(
const route = svc.route ?? '/'; const route = svc.route ?? '/';
const healthPath = svc.healthcheck!.path; const healthPath = svc.healthcheck!.path;
const basePath = route === '/' ? '' : route; const basePath = route === '/' ? '' : route;
const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; const url = `http://localhost:${gatewayPort}/${previewId}${basePath}${healthPath}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {

View File

@@ -220,7 +220,7 @@ describe('PreviewManager', () => {
expect(result.projectId).toBe('proj-1'); expect(result.projectId).toBe('proj-1');
expect(result.branch).toBe('feature-x'); expect(result.branch).toBe('feature-x');
expect(result.gatewayPort).toBe(9100); 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.mode).toBe('preview');
expect(result.status).toBe('running'); expect(result.status).toBe('running');
@@ -233,7 +233,7 @@ describe('PreviewManager', () => {
expect(buildingEvent).toBeDefined(); expect(buildingEvent).toBeDefined();
expect(readyEvent).toBeDefined(); expect(readyEvent).toBeDefined();
expect((readyEvent!.payload as Record<string, unknown>).url).toBe( expect((readyEvent!.payload as Record<string, unknown>).url).toBe(
'http://abc123test.localhost:9100', 'http://localhost:9100/abc123test/',
); );
}); });
@@ -472,7 +472,7 @@ describe('PreviewManager', () => {
expect(previews).toHaveLength(2); expect(previews).toHaveLength(2);
expect(previews[0].id).toBe('aaa'); expect(previews[0].id).toBe('aaa');
expect(previews[0].gatewayPort).toBe(9100); 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].mode).toBe('preview');
expect(previews[0].services).toHaveLength(1); expect(previews[0].services).toHaveLength(1);
expect(previews[1].id).toBe('bbb'); expect(previews[1].id).toBe('bbb');
@@ -573,7 +573,7 @@ describe('PreviewManager', () => {
expect(status!.status).toBe('running'); expect(status!.status).toBe('running');
expect(status!.id).toBe('abc'); expect(status!.id).toBe('abc');
expect(status!.gatewayPort).toBe(9100); 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'); expect(status!.mode).toBe('preview');
}); });

View File

@@ -239,7 +239,7 @@ export class PreviewManager {
await this.runSeeds(projectName, config); await this.runSeeds(projectName, config);
// 11. Success // 11. Success
const url = `http://${id}.localhost:${gatewayPort}`; const url = `http://localhost:${gatewayPort}/${id}/`;
log.info({ id, url }, 'preview deployment ready'); log.info({ id, url }, 'preview deployment ready');
this.eventBus.emit<PreviewReadyEvent>({ this.eventBus.emit<PreviewReadyEvent>({
@@ -605,7 +605,7 @@ export class PreviewManager {
projectId, projectId,
branch, branch,
gatewayPort, gatewayPort,
url: `http://${previewId}.localhost:${gatewayPort}`, url: `http://localhost:${gatewayPort}/${previewId}/`,
mode, mode,
status: 'running', status: 'running',
services: [], services: [],

View File

@@ -73,6 +73,7 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
const startPreview = trpc.startPreview.useMutation({ const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
setActivePreviewId(data.id); setActivePreviewId(data.id);
previewsQuery.refetch();
toast.success(`Preview running at ${data.url}`); toast.success(`Preview running at ${data.url}`);
}, },
onError: (err) => toast.error(`Preview failed: ${err.message}`), onError: (err) => toast.error(`Preview failed: ${err.message}`),

View File

@@ -99,6 +99,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const startPreview = trpc.startPreview.useMutation({ const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
setActivePreviewId(data.id); setActivePreviewId(data.id);
previewsQuery.refetch();
toast.success(`Preview running at ${data.url}`); toast.success(`Preview running at ${data.url}`);
}, },
onError: (err) => toast.error(`Preview failed: ${err.message}`), onError: (err) => toast.error(`Preview failed: ${err.message}`),