diff --git a/packages/viewer/src/server.ts b/packages/viewer/src/server.ts index 2d4495d..97d4ed0 100644 --- a/packages/viewer/src/server.ts +++ b/packages/viewer/src/server.ts @@ -374,6 +374,16 @@ export function createViewerServer(opts: ServerOpts) { if (parts[0] !== "w" || !parts[1]) return send(res, 404, "not found"); const wsid = parts[1]; if (wsid === SHARES_WSID || wsid === TEMPLATES_WSID) return send(res, 404, "not found"); // reserved store wsids, never servable + // Trailing slash matters: index.html's relative assets (viewer.js/style.css) must resolve under + // /w// — without it the browser fetches /w/viewer.js, which the SPA fallback answers with + // HTML, so the module load fails and the page is blank. Redirect the bare form so a hand-typed/ + // clicked link without the slash still works (mirrors the /s/ redirect above). The URL + // fragment (#scope) is preserved by the browser across a fragment-less redirect. + if (parts.length === 2 && !url.pathname.endsWith("/")) { + res.writeHead(302, { location: `/w/${wsid}/${url.search}` }); + res.end(); + return; + } const action = parts[2] ?? ""; // The built-in demo workspace is READ-ONLY and has no agent: every mutating endpoint is a POST diff --git a/packages/viewer/test/server-static.test.ts b/packages/viewer/test/server-static.test.ts index cd5e08a..ffe1db9 100644 --- a/packages/viewer/test/server-static.test.ts +++ b/packages/viewer/test/server-static.test.ts @@ -69,6 +69,23 @@ describe("static serving", () => { expect(await r.text()).toContain("VIEWER_INDEX"); }); + // A workspace URL without the trailing slash must redirect to the slashed form. Otherwise the + // browser resolves index.html's relative