Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/live-websocket-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": minor
---

Add a server `onEvent` feed tap for hosts and a `liveTransport: "ws"` viewer-embed option so hosted wrappers can provide hibernation-friendly WebSocket live updates while self-hosted sideshow keeps using SSE by default.
157 changes: 157 additions & 0 deletions e2e/embed-ws.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// End-to-end proof that an embedding host can opt the viewer engine into the
// WebSocket live-update transport while keeping the same event payloads and
// reconciliation behavior as the default EventSource path.
import { expect, publish, serveEmbedBundle, test } from "./fixtures.ts";

const embedHtml = (sessionId: string) => `<!doctype html>
<html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}#m{position:fixed;inset:0}</style></head>
<body><div id="m"></div>
<script>
window.__SIDESHOW_PUBLIC_READ__ = "session";
const NativeSetInterval = window.setInterval.bind(window);
window.setInterval = (cb, ms, ...args) => NativeSetInterval(cb, ms === 30000 ? 10 : ms, ...args);
const sockets = [];
const sent = [];
const urls = [];
const closedSockets = [];
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
CONNECTING = 0;
OPEN = 1;
CLOSING = 2;
CLOSED = 3;
readyState = FakeWebSocket.CONNECTING;
onopen = null;
onmessage = null;
onerror = null;
onclose = null;
constructor(url) {
this.url = url;
sockets.push(this);
urls.push(url);
setTimeout(() => {
if (this.readyState !== FakeWebSocket.CONNECTING) return;
this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open"));
}, 0);
}
send(data) {
sent.push(String(data));
if (data === "ping") setTimeout(() => this.onmessage?.({ data: "pong" }), 0);
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
closedSockets.push(this);
this.onclose?.(new CloseEvent("close"));
}
deliver(event) {
this.onmessage?.({ data: JSON.stringify(event) });
}
}
window.WebSocket = FakeWebSocket;
window.__wsHarness = {
sent,
urls,
closedCount() { return closedSockets.length; },
deliver(event) { sockets.at(-1)?.deliver(event); },
closeLatest() { sockets.at(-1)?.close(); },
};
</script>
<script type="module">
import { mountViewer } from "/__embed/engine.js";
window.__viewerHandle = mountViewer(document.getElementById("m"), {
basePath: "",
layout: "stream",
readonly: true,
liveTransport: "ws",
router: {
get: () => ({ sessionId: ${JSON.stringify(sessionId)} }),
navigate() {},
subscribe() { return () => {}; },
},
});
</script></body></html>`;

test("embedded engine: liveTransport:'ws' applies events, reconnects, and heartbeats", async ({
page,
server,
}) => {
const first = await publish(
server.url,
{ html: "<p>first websocket card</p>", title: "First WS", agent: "e2e" },
"",
);

page.on("pageerror", (e) => console.error("[pageerror]", e.message));
page.on("console", (m) => m.type() === "error" && console.error("[console]", m.text()));

await page.route("**/__embedtest", (route) =>
route.fulfill({ contentType: "text/html", body: embedHtml(first.sessionId) }),
);
await serveEmbedBundle(page);

await page.goto(`${server.url}/__embedtest`);
await expect(page.locator(".card-title")).toContainText("First WS");

await expect
.poll(() => page.evaluate(() => window.__wsHarness.urls[0]))
.toContain(`/api/events?session=${encodeURIComponent(first.sessionId)}`);
await expect.poll(() => page.evaluate(() => window.__wsHarness.sent.includes("ping"))).toBe(true);

const second = await publish(
server.url,
{
html: "<p>second websocket card</p>",
title: "Second WS",
agent: "e2e",
session: first.sessionId,
},
"",
);
await page.evaluate((event) => window.__wsHarness.deliver(event), {
type: "post-created",
id: second.id,
sessionId: second.sessionId,
version: second.version,
});
await expect(page.locator(".card-title")).toContainText(["First WS", "Second WS"]);

await page.evaluate(() => window.__wsHarness.closeLatest());
const third = await publish(
server.url,
{
html: "<p>third websocket card</p>",
title: "Third WS",
agent: "e2e",
session: first.sessionId,
},
"",
);
expect(third.sessionId).toBe(first.sessionId);

await expect.poll(() => page.evaluate(() => window.__wsHarness.urls.length)).toBeGreaterThan(1);
await expect(page.locator(".card-title")).toContainText(["First WS", "Second WS", "Third WS"]);

const socketsBeforeDispose = await page.evaluate(() => window.__wsHarness.urls.length);
await page.evaluate(() => window.__viewerHandle.dispose());
await expect.poll(() => page.evaluate(() => window.__wsHarness.closedCount())).toBeGreaterThan(1);
await page.waitForTimeout(1100);
expect(await page.evaluate(() => window.__wsHarness.urls.length)).toBe(socketsBeforeDispose);
});

declare global {
interface Window {
__viewerHandle: { dispose(): void };
__wsHarness: {
sent: string[];
urls: string[];
closedCount(): number;
deliver(event: unknown): void;
closeLatest(): void;
};
}
}
18 changes: 17 additions & 1 deletion server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { bodyLimit } from "hono/body-limit";
import { getCookie, setCookie } from "hono/cookie";
import { streamSSE } from "hono/streaming";
import { decodeBase64 } from "./base64.ts";
import { EventBus } from "./events.ts";
import { EventBus, type FeedEvent } from "./events.ts";
import { kitSummaries } from "./kits.ts";
import { registerMcp } from "./mcpHttp.ts";
import {
Expand Down Expand Up @@ -34,6 +34,8 @@ import {
} from "./types.ts";
import { validateSurfaces } from "./postSurfaces.ts";

export type { FeedEvent } from "./events.ts";

const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
const MAX_WAIT_SECONDS = 300;
// Hard ceiling on any request body, applied globally. Every write endpoint
Expand Down Expand Up @@ -157,6 +159,10 @@ export interface AppOptions {
upgradeCommand?: string;
// Test seam: replaces the npm-registry/GitHub lookup for the latest release.
fetchLatestRelease?: () => Promise<LatestRelease | null>;
// Optional live-feed tap for hosts that provide their own transport (for
// example, a Cloudflare Durable Object WebSocket-hibernation wrapper). Receives
// every event; transport-specific session filtering stays with the host.
onEvent?: (event: FeedEvent) => void;
// Max concurrently-held SSE (`/api/events`) + long-poll (`/api/comments?wait`)
// connections before new ones are rejected with 503. Bounds a connection flood
// on publicRead boards; defaults to DEFAULT_MAX_HOLD_CONNECTIONS.
Expand Down Expand Up @@ -413,10 +419,20 @@ export function createApp({
version,
upgradeCommand,
fetchLatestRelease,
onEvent,
maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS,
}: AppOptions) {
const app = new Hono();
const bus = new EventBus();
if (onEvent) {
bus.subscribe((event) => {
try {
onEvent(event);
} catch (err) {
console.warn("[sideshow] onEvent listener failed", err);
}
});
}

// Live count of held SSE + long-poll connections, gated by maxHoldConnections.
// Each holder increments on entry and releases exactly once via a guarded
Expand Down
2 changes: 1 addition & 1 deletion server/public.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Stable public server-core entrypoint for integrations that reuse sideshow's
// HTTP/SSE/MCP app without depending on the package's internal dist layout.

export { createApp, type AppOptions, type AuthenticateHook } from "./app.js";
export { createApp, type AppOptions, type AuthenticateHook, type FeedEvent } from "./app.js";
export { SqlStore } from "./sqlStore.js";
export { createSqliteStorage, migrateJsonToSqlite } from "./sqliteStorage.js";
export { JsonFileStore } from "./storage.js";
Expand Down
43 changes: 43 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function makeApp(
viewerHtml?: string;
screenshots?: boolean;
maxHoldConnections?: number;
onEvent?: Parameters<typeof createApp>[0]["onEvent"];
},
) {
const dir = mkdtempSync(join(tmpdir(), "sideshow-test-"));
Expand Down Expand Up @@ -60,6 +61,48 @@ test("publish without session auto-creates one", async () => {
assert.equal(sessions[0].surfaceCount, 1);
});

test("onEvent receives published feed events", async () => {
const events: unknown[] = [];
const app = makeApp(undefined, { onEvent: (event) => events.push(event) });

const res = await app.request(
"/api/snippets",
json({ html: "<p>hi</p>", agent: "pi", title: "First" }),
);
assert.equal(res.status, 201);
const post = (await res.json()) as { id: string; sessionId: string; version: number };

assert.deepEqual(events, [
{ type: "session-created", id: post.sessionId },
{ type: "post-created", id: post.id, sessionId: post.sessionId, version: post.version },
]);
});

test("onEvent errors do not fail writes", async () => {
const warn = console.warn;
console.warn = () => {};
try {
const app = makeApp(undefined, {
onEvent: () => {
throw new Error("fanout failed");
},
});

const res = await app.request(
"/api/snippets",
json({ html: "<p>hi</p>", agent: "pi", title: "First" }),
);
assert.equal(res.status, 201);
const post = (await res.json()) as { sessionId: string };
const posts = (await (
await app.request(`/api/sessions/${post.sessionId}/posts`)
).json()) as unknown[];
assert.equal(posts.length, 1);
} finally {
console.warn = warn;
}
});

test("publish into an existing session groups snippets", async () => {
const app = makeApp();
const first = (await (
Expand Down
6 changes: 6 additions & 0 deletions viewer/embed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// surface so hosts get types without depending on the viewer source.

export type Route = { sessionId?: string | null; surfaceId?: string | null };
export type LiveTransport = "sse" | "ws";

export interface HostRouter {
/** The route the engine should render. */
Expand Down Expand Up @@ -30,6 +31,11 @@ export interface SideshowHost {
* Orthogonal to `layout`. Self-hosted drives the same flag via a window global.
*/
readonly?: boolean;
/**
* Live-update transport. Defaults to SSE; embedders can opt into WebSocket
* when their host implements `/api/events` as a hibernatable socket.
*/
liveTransport?: LiveTransport;
/**
* Whether this deployment can render a surface as a PNG (the /s/:id.png route).
* That route needs Cloudflare Browser Rendering, so it exists only on a Workers
Expand Down
3 changes: 2 additions & 1 deletion viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ export default function App() {
setInitialLoaded(true);
host().onReady?.();
});
connect();
const disconnect = connect();
onCleanup(disconnect);
checkVersion();
void initTheme();
const timer = setInterval(() => {
Expand Down
2 changes: 1 addition & 1 deletion viewer/src/embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import App from "./App.tsx";
import { createDefaultHost, setEngine, type SideshowHost } from "./host.ts";
import stylesCss from "./styles.css?inline";

export type { SideshowHost, HostRouter, Route, SlotName } from "./host.ts";
export type { SideshowHost, HostRouter, Route, SlotName, LiveTransport } from "./host.ts";
// Runtime registry of host-overridable slot names (embedders project light DOM
// with these `slot=` attributes). Exported as a value so embedders share one
// source of truth instead of hardcoding the strings.
Expand Down
4 changes: 4 additions & 0 deletions viewer/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import type { ThemeTokens } from "../../server/theme-tokens.ts";

export type Route = { sessionId?: string | null; surfaceId?: string | null };
export type LiveTransport = "sse" | "ws";

export interface HostRouter {
// The current route the engine should render.
Expand Down Expand Up @@ -39,6 +40,9 @@ export interface SideshowHost {
// connect action). Orthogonal to `layout` — a host can have either without the
// other. Self-hosted drives the same flag via window.__SIDESHOW_READONLY__.
readonly?: boolean;
// Live-update transport. Self-hosted defaults to SSE; embedders can opt into
// WebSocket when their host implements `/api/events` as a hibernatable socket.
liveTransport?: LiveTransport;
// Whether this deployment can render a surface as a PNG (the /s/:id.png route).
// That route is served only by a Cloudflare Worker with the Browser Rendering
// binding; a plain Node server (local dev, `npm start`) has no way to drive a
Expand Down
Loading
Loading