diff --git a/src/servers/api/dev-server/router.ts b/src/servers/api/dev-server/router.ts index f8edb54a..257a9807 100644 --- a/src/servers/api/dev-server/router.ts +++ b/src/servers/api/dev-server/router.ts @@ -192,29 +192,24 @@ devServerRouter.get('/logs', async (ctx) => { // Proxy router — mounted outside protected router (no auth needed for sub-resources). // Security: only proxies to ports that were started via authenticated /start calls. -devServerProxyRouter.all('/:slug/*', async (ctx) => { - const slug = ctx.req.param('slug'); - if (!slug) return ctx.text('Not found', 404); - const entry = findEntryBySlug(slug); - if (!entry) return ctx.text('No dev server running', 404); +type ProxyParams = { entry: ServerEntry; slug: string; proxyPath: string; search: string; method: string; rawReq: Request }; + +async function proxyRequest({ entry, slug, proxyPath, search, method, rawReq }: ProxyParams): Promise { + const prefix = `/api/dev-server-proxy/${slug}`; + const targetUrl = `http://localhost:${entry.port}${proxyPath}${search}`; touchEntry(entry); - const originalUrl = new URL(ctx.req.url); - const prefix = `/api/dev-server-proxy/${slug}`; - const proxyPath = originalUrl.pathname.replace(prefix, '') || '/'; - const targetUrl = `http://localhost:${entry.port}${proxyPath}${originalUrl.search}`; - try { - const headers = new Headers(ctx.req.raw.headers); + const headers = new Headers(rawReq.headers); headers.delete('host'); headers.delete('authorization'); const res = await fetch(targetUrl, { - method: ctx.req.method, + method, headers, - body: ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD' ? ctx.req.raw.body : undefined, + body: method !== 'GET' && method !== 'HEAD' ? rawReq.body : undefined, redirect: 'manual', }); @@ -235,13 +230,42 @@ devServerProxyRouter.all('/:slug/*', async (ctx) => { return new Response(rewritten, { status: res.status, headers: newHeaders }); } - return new Response(res.body, { - status: res.status, - headers: res.headers, - }); + return new Response(res.body, { status: res.status, headers: res.headers }); } catch { - return ctx.text('Dev server not reachable', 502); + return new Response('Dev server not reachable', { status: 502 }); } +} + +// Proxy handler: tries slug from URL first, falls back to Referer for misrouted +// chunk requests caused by relative imports resolving at wrong path depth. +devServerProxyRouter.all('/:slug/*', async (ctx) => { + const slug = ctx.req.param('slug'); + if (!slug) return ctx.text('Not found', 404); + + const originalUrl = new URL(ctx.req.url); + const entry = findEntryBySlug(slug); + + if (entry) { + const prefix = `/api/dev-server-proxy/${slug}`; + const proxyPath = originalUrl.pathname.replace(prefix, '') || '/'; + return proxyRequest({ entry, slug, proxyPath, search: originalUrl.search, method: ctx.req.method, rawReq: ctx.req.raw }); + } + + // Slug didn't match a dev server — check Referer for the real slug + const referer = ctx.req.header('referer'); + if (referer) { + const match = referer.match(/\/api\/dev-server-proxy\/([^/]+)/); + if (match) { + const realSlug = match[1]!; + const realEntry = findEntryBySlug(realSlug); + if (realEntry) { + const proxyPath = originalUrl.pathname.replace('/api/dev-server-proxy', '') || '/'; + return proxyRequest({ entry: realEntry, slug: realSlug, proxyPath, search: originalUrl.search, method: ctx.req.method, rawReq: ctx.req.raw }); + } + } + } + + return ctx.text('No dev server running', 404); }); // Injected into proxied HTML to intercept fetch/XHR so absolute paths (e.g. /api/hello) diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewProvider.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewProvider.tsx index 32726226..306ad8bc 100644 --- a/src/workspaces/officerdev/src/apps/Preview/PreviewProvider.tsx +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewProvider.tsx @@ -87,6 +87,24 @@ export const PreviewProvider = ({ children }: PreviewProviderProps) => { return () => { cancelled = true; }; }, [slug]); + // Poll status while a server is supposedly running — auto-restart if it died (e.g. idle timeout) + useEffect(() => { + if (!slug || !url) return; + + const interval = setInterval(async () => { + try { + const status = await client.get(`/dev-server/status?slug=${encodeURIComponent(slug)}`); + if (!status.running) { + startServer(slug); + } + } catch { + // ignore transient failures + } + }, 30_000); + + return () => clearInterval(interval); + }, [slug, url]); + return (