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 }) {