diff --git a/.changeset/slim-session-hydrate.md b/.changeset/slim-session-hydrate.md new file mode 100644 index 0000000..44ee97d --- /dev/null +++ b/.changeset/slim-session-hydrate.md @@ -0,0 +1,23 @@ +--- +"sideshow": patch +--- + +Stop shipping surface bodies the viewer never reads in the session hydrate +response. Opening a session fetches `GET /api/sessions/:id/posts?hydrate=1`, +which served every post through `postDetailView` — the full current surfaces +plus every prior version's surfaces. But a sandboxed surface (`html`, `diff`, +`markdown`, `terminal`, `mermaid`, `code`) renders as an opaque-origin iframe +pointed at `/s/:id?part=N`, which fetches its own body; the viewer only ever +reads `{ id, kind, index }` to build that URL. So the hydrate response carried a +second, unread copy of every surface in the session, and it blocks the stream +from rendering. + +`?hydrate=1` now drops just the content field of sandboxed surfaces, and reduces +history surfaces to refs (history is there to size the version dropdown — +choosing an older version re-points each iframe at `?ver=N`, so past bodies are +never rendered from this payload either). Native kinds — `image`, `trace`, +`json` — do render from inline data and are untouched. Across the six largest +sessions in a real store this cut the response 95% raw / 91% gzipped. + +`GET /api/posts/:id` and the MCP `read_post` tool still return full detail; only +the session-list hydrate view changed. diff --git a/server/apiViews.ts b/server/apiViews.ts index 5a9cc23..4da8586 100644 --- a/server/apiViews.ts +++ b/server/apiViews.ts @@ -1,3 +1,4 @@ +import { isSandboxedSurfaceKind, SURFACE_CONTENT_FIELDS } from "./types.ts"; import type { Comment, CommentAnchor, Post, Session, Surface } from "./types.ts"; export interface Feedback { @@ -23,6 +24,27 @@ export const fullSurfaceView = (surface: Surface, index: number) => ({ ...surfac export const sessionListSurfaceView = (surface: Surface, index: number) => surface.kind === "html" ? surfaceRef(surface, index) : fullSurfaceView(surface, index); +// A sandboxed surface renders as an opaque-origin iframe pointed at +// /s/:id?part=N, which fetches the body itself — so the viewer builds the frame +// from the ref alone and never reads the content field. Shipping the body in the +// session hydrate too made every stream load carry a second, unread copy of every +// surface, the larger half of the response. Drop just that field; the rest of the +// surface (id, kits, …) is small and stays. Native kinds (image/trace/json) DO +// render from inline data, so they keep everything. +export const hydratedSurfaceView = (surface: Surface, index: number) => { + const field = isSandboxedSurfaceKind(surface.kind) + ? SURFACE_CONTENT_FIELDS[surface.kind] + : undefined; + if (!field) return fullSurfaceView(surface, index); + // Surface is a union of interfaces, so the content key can't be dropped through + // the union type (no implicit index signature). Widen to a bag, delete the one + // key, and let the result type stay the bag — the shape is kind-dependent and + // this value only ever gets serialized. + const view = { ...surface, index } as unknown as Record; + delete view[field]; + return view; +}; + export const postWriteView = (post: Post) => ({ id: post.id, sessionId: post.sessionId, @@ -42,6 +64,21 @@ export const postDetailView = (post: Post) => ({ })), }); +// One session's whole stream, hydrated in a single response (`?hydrate=1`). Same +// envelope as postDetailView — the viewer identifies a hydrated row by `history` +// being an array — minus the bodies it never reads. History is here only to size +// the version dropdown (`history.length`): picking an older version just re-points +// each iframe at /s/:id?part=N&ver=N, so past surfaces are never rendered from +// this payload and reduce to refs. +export const sessionPostHydratedView = (post: Post) => ({ + ...post, + surfaces: post.surfaces.map(hydratedSurfaceView), + history: post.history.map((version) => ({ + ...version, + surfaces: version.surfaces.map(surfaceRef), + })), +}); + export const sessionPostListRowView = (post: Post) => { const surfaces = post.surfaces.map(sessionListSurfaceView); return { diff --git a/server/app.ts b/server/app.ts index 0dae938..01b79c3 100644 --- a/server/app.ts +++ b/server/app.ts @@ -8,6 +8,7 @@ import { postDetailView, postWriteView, recentPostRowView, + sessionPostHydratedView, sessionPostListRowView, sessionRowView, type Feedback, @@ -1069,7 +1070,7 @@ export function createApp({ const posts = await store.listPosts(session.id); return c.json( c.req.query("hydrate") === "1" - ? posts.map(postDetailView) + ? posts.map(sessionPostHydratedView) : posts.map(sessionPostListRowView), ); }; diff --git a/test/api.test.ts b/test/api.test.ts index c408687..eb6fa06 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -2320,7 +2320,7 @@ test("GET /api/sessions/:id/posts lists lean surfaces with ids and omitted html assert.deepEqual(list[0].parts, list[0].surfaces, "legacy parts aliases surfaces"); }); -test("GET /api/sessions/:id/posts?hydrate=1 returns full post details in one response", async () => { +test("GET /api/sessions/:id/posts?hydrate=1 returns every post the viewer needs in one response", async () => { const app = makeApp(); const created = (await ( await app.request( @@ -2338,10 +2338,66 @@ test("GET /api/sessions/:id/posts?hydrate=1 returns full post details in one res ).json()) as any[]; assert.equal(list.length, 1); assert.equal(list[0].id, created.id); - assert.equal(list[0].surfaces[0].html, "

new

"); + assert.equal(list[0].title, "Hydrated v2"); + assert.equal(list[0].version, 2); + // The frame ref survives — it's what /s/:id?part=N is built from. + assert.equal(list[0].surfaces[0].kind, "html"); assert.equal(list[0].surfaces[0].index, 0); - assert.equal(list[0].history[0].surfaces[0].html, "

heavy

"); + // History is present (the viewer keys "hydrated" off it) and long enough to + // size the version dropdown, but carries no bodies. + assert.equal(list[0].history.length, 1); assert.equal(list[0].history[0].surfaces[0].index, 0); + assert.equal(list[0].history[0].surfaces[0].kind, "html"); +}); + +test("hydrated posts omit sandboxed bodies the viewer never reads, and keep native ones", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Mixed", + surfaces: [ + { kind: "html", html: "

heavy

" }, + { kind: "markdown", markdown: "# heavy" }, + { kind: "terminal", text: "heavy" }, + { kind: "json", data: { keep: true } }, + ], + }), + ) + ).json()) as any; + await app.request(`/api/posts/${created.id}`, { + ...json({ title: "Mixed v2", surfaces: [{ kind: "diff", patch: "--- a\n+++ b\n" }] }), + method: "PUT", + }); + + const [post] = (await ( + await app.request(`/api/sessions/${created.sessionId}/posts?hydrate=1`) + ).json()) as any[]; + + // Sandboxed kinds render in an iframe that fetches its own body from + // /s/:id?part=N — the content key is absent, not empty. + assert.ok(!("patch" in post.surfaces[0]), "diff patch body is absent"); + // Native kinds render from inline data and must survive intact. + const [older] = post.history; + assert.ok(!("html" in older.surfaces[0]), "history html body is absent"); + assert.ok(!("markdown" in older.surfaces[1]), "history markdown body is absent"); + assert.ok(!("text" in older.surfaces[2]), "history terminal body is absent"); + assert.ok(!("data" in older.surfaces[3]), "history drops native bodies too"); + + // A native surface in the CURRENT version keeps its payload — check via a post + // whose latest version holds one. + const native = (await ( + await app.request( + "/api/posts", + json({ title: "Native", surfaces: [{ kind: "json", data: { keep: true } }] }), + ) + ).json()) as any; + const rows = (await ( + await app.request(`/api/sessions/${native.sessionId}/posts?hydrate=1`) + ).json()) as any[]; + const nativeRow = rows.find((r) => r.id === native.id); + assert.deepEqual(nativeRow.surfaces[0].data, { keep: true }); }); test("read responses expose derived surface indexes and renumber after edits", async () => {