From 9aedf537407989dca56903d7ef4fe9e09775a0d7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 28 Jun 2026 11:17:45 -0400 Subject: [PATCH] fix(viewer): respect base paths for asset surfaces --- .changeset/polite-assets-kneel.md | 5 +++ e2e/fixtures.ts | 21 +++++++++- e2e/uploads.spec.ts | 65 +++++++++++++++++++++++++++++++ server/app.ts | 4 +- test/api.test.ts | 11 ++++++ viewer/src/ImageSurface.tsx | 9 +++-- viewer/src/TraceSurface.tsx | 21 ++++++---- 7 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 .changeset/polite-assets-kneel.md diff --git a/.changeset/polite-assets-kneel.md b/.changeset/polite-assets-kneel.md new file mode 100644 index 0000000..61d7a71 --- /dev/null +++ b/.changeset/polite-assets-kneel.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Respect configured base paths for uploaded asset URLs and native image/trace surface loads. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 9a99a7e..dd67238 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,10 +1,12 @@ import { expect, test as base, type Locator, type Page } from "@playwright/test"; import { type ChildProcess, spawn } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; +const embedDir = fileURLToPath(new URL("../viewer/dist-embed", import.meta.url)); + type ServerHandle = { url: string; stop: () => void }; type PublicReadServer = { url: string; token: string; mode: "full" | "session" }; @@ -141,6 +143,23 @@ export async function expectNoHorizontalOverflow(page: Page, selector: string) { .toBeLessThanOrEqual(1); } +function embedContentType(path: string): string { + if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript"; + if (path.endsWith(".wasm")) return "application/wasm"; + if (path.endsWith(".css")) return "text/css"; + return "application/octet-stream"; +} + +export async function serveEmbedBundle(page: Page) { + await page.route("**/__embed/**", (route) => { + const name = new URL(route.request().url()).pathname.replace("/__embed/", ""); + route.fulfill({ + contentType: embedContentType(name), + body: readFileSync(`${embedDir}/${name}`), + }); + }); +} + export async function expectIframesNoHorizontalOverflow(page: Page, container: Locator) { const frameUrls = await container .locator("iframe") diff --git a/e2e/uploads.spec.ts b/e2e/uploads.spec.ts index a263363..2ce74ba 100644 --- a/e2e/uploads.spec.ts +++ b/e2e/uploads.spec.ts @@ -4,11 +4,29 @@ import { expectNoHorizontalOverflow, publish, publishParts, + serveEmbedBundle, test, TINY_PNG_B64, upload, } from "./fixtures.ts"; +const embedHtml = (sessionId: string) => ` + +
+`; + test("an image surface renders an served from /a/:id", async ({ page, server }) => { const asset = await upload(server.url, { data: TINY_PNG_B64, @@ -34,6 +52,53 @@ test("an image surface renders an served from /a/:id", async ({ page, serv await expect(page.locator(".asset-caption")).toHaveText("one pixel"); }); +test("embedded native image and trace assets use the host base path", async ({ page, server }) => { + const image = await upload(server.url, { + data: TINY_PNG_B64, + contentType: "image/png", + filename: "pixel.png", + kind: "image", + }); + const jsonl = '{"label":"from prefixed asset","kind":"shell"}'; + const trace = await upload(server.url, { + data: Buffer.from(jsonl).toString("base64"), + contentType: "application/x-ndjson", + filename: "trace.jsonl", + kind: "trace", + session: image.sessionId, + }); + await publishParts(server.url, { + title: "Prefixed assets", + agent: "e2e", + session: image.sessionId, + parts: [ + { kind: "image", assetId: image.id, caption: "prefixed image" }, + { kind: "trace", assetId: trace.id }, + ], + }); + + await page.route("**/__embedtest", (route) => + route.fulfill({ contentType: "text/html", body: embedHtml(image.sessionId) }), + ); + await serveEmbedBundle(page); + await page.route("**/u/alice/**", (route) => { + const url = new URL(route.request().url()); + url.pathname = url.pathname.replace(/^\/u\/alice(?=\/|$)/, "") || "/"; + route.continue({ url: url.toString() }); + }); + + await page.goto(`${server.url}/__embedtest`); + + const card = page.locator(".card:not(#whatsNew)"); + const img = card.locator(".asset-img"); + await expect(img).toHaveAttribute("src", `/u/alice/a/${image.id}`); + await expect + .poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth)) + .toBeGreaterThan(0); + await expect(card.locator(".trace-dl")).toHaveAttribute("href", `/u/alice/a/${trace.id}`); + await expect(card.locator(".trace-label")).toHaveText("from prefixed asset"); +}); + test("a trace surface renders a step timeline with expandable detail", async ({ page, server }) => { await publishParts(server.url, { title: "Run trace", diff --git a/server/app.ts b/server/app.ts index 8db52f1..8f589ed 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1554,7 +1554,9 @@ export function createApp({ const result = await uploadAsset(body); if ("error" in result) return c.json({ error: result.error }, result.status); const origin = new URL(c.req.url).origin; - return c.json({ ...result.asset, url: `${origin}/a/${result.asset.id}` }, 201); + const publicBasePath = requestBasePath(c.req.raw); + const assetPath = encodeURIComponent(result.asset.id); + return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201); }); app.get("/a/:id", async (c) => { diff --git a/test/api.test.ts b/test/api.test.ts index acdbc33..1a7ff91 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1740,6 +1740,17 @@ test("uploads an asset via base64 JSON and serves the exact bytes", async () => assert.deepEqual([...new Uint8Array(await served.arrayBuffer())], [137, 80, 78, 71, 0, 255]); }); +test("asset upload URL respects configured base path", async () => { + const app = makeApp(undefined, { basePath: "/u/alice" }); + const res = await app.request( + "https://board.test/api/assets", + json({ data: b64([1, 2, 3]), contentType: "image/png" }), + ); + assert.equal(res.status, 201); + const asset = (await res.json()) as any; + assert.equal(asset.url, `https://board.test/u/alice/a/${asset.id}`); +}); + test("uploads raw bytes with metadata from the query string", async () => { const app = makeApp(); const res = await app.request("/api/assets?filename=trace.json&kind=trace", { diff --git a/viewer/src/ImageSurface.tsx b/viewer/src/ImageSurface.tsx index 6071726..a0a1ebb 100644 --- a/viewer/src/ImageSurface.tsx +++ b/viewer/src/ImageSurface.tsx @@ -1,12 +1,13 @@ import { createSignal, Show } from "solid-js"; -import type { ImageSurface as ImageSurfaceData } from "./api.ts"; +import { appPath, type ImageSurface as ImageSurfaceData } from "./api.ts"; // A trusted, viewer-chrome for an uploaded asset (no iframe). The bytes -// live at /a/:id; an evicted/missing asset 404s, so show a placeholder rather -// than a broken image. Clicking opens the asset in a new tab. +// live at /a/:id under the host base path; an evicted/missing asset 404s, so +// show a placeholder rather than a broken image. Clicking opens the asset in a +// new tab. export function ImageSurface(props: { surface: ImageSurfaceData }) { const [failed, setFailed] = createSignal(false); - const src = () => `/a/${props.surface.assetId}`; + const src = () => appPath(`/a/${encodeURIComponent(props.surface.assetId)}`); return (
(props.surface.steps ?? []); const [note, setNote] = createSignal(null); + const assetUrl = () => + props.surface.assetId ? appPath(`/a/${encodeURIComponent(props.surface.assetId)}`) : null; + onMount(() => { - if ((props.surface.steps?.length ?? 0) > 0 || !props.surface.assetId) return; - void fetch(`/a/${props.surface.assetId}`) + if ((props.surface.steps?.length ?? 0) > 0) return; + const url = assetUrl(); + if (!url) return; + void fetch(url) .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) .then((text) => setSteps(parseTrace(text))) .catch(() => setNote("Trace file unavailable — it may have been evicted.")); @@ -21,10 +26,12 @@ export function TraceSurface(props: { surface: TraceSurfaceData }) {
{props.surface.title ?? "Agent trace"} - - - download ↓ - + + {(url) => ( + + download ↓ + + )}