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:
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user