feat: add presentation sync client

This commit is contained in:
lda
2026-07-14 13:07:14 +07:00 Verified
parent a79fe8b362
commit 5b540651c3
9 changed files with 1675 additions and 1 deletions
+4
View File
@@ -3,13 +3,17 @@
"private": true,
"type": "module",
"scripts": {
"predev": "pnpm --filter @lda/presentation-sync build",
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
"prebuild": "pnpm --filter @lda/presentation-sync build",
"build": "tsc -b && vite build",
"test": "vitest run",
"pretypecheck": "pnpm --filter @lda/presentation-sync build",
"typecheck": "tsc -b --pretty false",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@lda/presentation-sync": "workspace:*",
"@assistant-ui/react": "^0.14.26",
"@dagrejs/dagre": "3.0.0",
"@fontsource-variable/newsreader": "5.2.10",
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createPresentationSyncClient,
PRESENTATION_SYNC_GRANT_STORAGE_KEY,
presentationSyncJoinUrl,
type PresentationSyncClientEvent,
} from "./presentation-sync-client.js";
const grant = {
sessionId: "session-1",
code: "ABC123",
connectionToken: "token-1",
websocketPath: "/api/presentation-sync/ws" as const,
snapshot: { hash: "#scene/thesis/title", revision: 0 },
};
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number {
return this.values.size;
}
clear(): void {
this.values.clear();
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
key(index: number): string | null {
return [...this.values.keys()][index] ?? null;
}
removeItem(key: string): void {
this.values.delete(key);
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
}
class FakeSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
readonly sent: string[] = [];
readyState = 0;
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
constructor(readonly url: string) {}
send(message: string): void {
this.sent.push(message);
}
open(): void {
this.readyState = FakeSocket.OPEN;
this.onopen?.();
}
serverMessage(message: unknown): void {
this.onmessage?.({ data: JSON.stringify(message) });
}
close(code = 1000, reason = "closed"): void {
this.readyState = FakeSocket.CLOSED;
this.onclose?.({ code, reason });
}
}
const makeDependencies = (storage: Storage, sockets: FakeSocket[]) => ({
fetch: vi.fn<typeof fetch>(),
createWebSocket: (url: string) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket as unknown as WebSocket;
},
storage,
origin: "http://console.test",
protocol: "http:",
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
});
describe("presentation sync client", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("posts create and join to same-origin HTTP paths and normalizes codes", async () => {
const storage = new MemoryStorage();
const sockets: FakeSocket[] = [];
const dependencies = makeDependencies(storage, sockets);
dependencies.fetch.mockImplementation(async () =>
new Response(JSON.stringify(grant), { status: 201 }),
);
const client = createPresentationSyncClient(dependencies);
await client.create("presenter", "#scene/thesis/title");
expect(dependencies.fetch).toHaveBeenNthCalledWith(
1,
"http://console.test/api/presentation-sync/sessions",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
role: "presenter",
initialHash: "#scene/thesis/title",
}),
}),
);
await client.join("audience", "ab-c 123");
expect(dependencies.fetch).toHaveBeenNthCalledWith(
2,
"http://console.test/api/presentation-sync/sessions/join",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ role: "audience", code: "ABC123" }),
}),
);
expect(storage.getItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY)).toContain(
'"connectionToken":"token-1"',
);
});
it("uses wss for HTTPS and builds the opposite-route join URL", () => {
const storage = new MemoryStorage();
const sockets: FakeSocket[] = [];
const dependencies = makeDependencies(storage, sockets);
const client = createPresentationSyncClient({
...dependencies,
origin: "https://console.test",
protocol: "https:",
});
const events: PresentationSyncClientEvent[] = [];
client.connect(grant, (event) => events.push(event));
expect(sockets[0]?.url).toBe(
"wss://console.test/api/presentation-sync/ws?token=token-1",
);
expect(presentationSyncJoinUrl("presenter", "ab-c 123", "https://console.test")).toBe(
"https://console.test/present?pair=ABC123",
);
expect(presentationSyncJoinUrl("audience", "ABC123", "https://console.test")).toBe(
"https://console.test/presenter?pair=ABC123",
);
expect(events).toEqual([]);
});
it("restores a saved grant after reload", () => {
const storage = new MemoryStorage();
storage.setItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY, JSON.stringify(grant));
const sockets: FakeSocket[] = [];
const client = createPresentationSyncClient(makeDependencies(storage, sockets));
expect(client.restoreGrant()).toEqual(grant);
});
it("uses bounded reconnect delays of 500, 1000, 2000, then 5000 milliseconds", () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
const sockets: FakeSocket[] = [];
const client = createPresentationSyncClient(makeDependencies(storage, sockets));
const events: PresentationSyncClientEvent[] = [];
client.connect(grant, (event) => events.push(event));
sockets[0]?.close(1006, "network");
expect(sockets).toHaveLength(1);
vi.advanceTimersByTime(499);
expect(sockets).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(sockets).toHaveLength(2);
sockets[1]?.close(1006, "network");
vi.advanceTimersByTime(999);
expect(sockets).toHaveLength(2);
vi.advanceTimersByTime(1);
expect(sockets).toHaveLength(3);
sockets[2]?.close(1006, "network");
vi.advanceTimersByTime(1_999);
expect(sockets).toHaveLength(3);
vi.advanceTimersByTime(1);
expect(sockets).toHaveLength(4);
sockets[3]?.close(1006, "network");
vi.advanceTimersByTime(4_999);
expect(sockets).toHaveLength(4);
vi.advanceTimersByTime(1);
expect(sockets).toHaveLength(5);
expect(events.filter((event) => event.type === "reconnecting")).toHaveLength(4);
});
it("clears the grant and never reconnects after a terminal server event", () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
storage.setItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY, JSON.stringify(grant));
const sockets: FakeSocket[] = [];
const client = createPresentationSyncClient(makeDependencies(storage, sockets));
const events: PresentationSyncClientEvent[] = [];
client.connect(grant, (event) => events.push(event));
sockets[0]?.serverMessage({ type: "session.ended", reason: "expired" });
sockets[0]?.close(1000, "expired");
vi.advanceTimersByTime(10_000);
expect(storage.getItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY)).toBeNull();
expect(sockets).toHaveLength(1);
expect(events).toContainEqual({ type: "ended", reason: "expired" });
});
it("clears the grant and never reconnects after explicit leave", () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
storage.setItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY, JSON.stringify(grant));
const sockets: FakeSocket[] = [];
const client = createPresentationSyncClient(makeDependencies(storage, sockets));
client.connect(grant, () => {});
client.leave();
vi.advanceTimersByTime(10_000);
expect(storage.getItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY)).toBeNull();
expect(sockets).toHaveLength(1);
});
});
@@ -0,0 +1,338 @@
import {
decodeServerSyncMessage,
decodeSessionGrant,
normalizeJoinCode,
type PresentationRole,
type ServerSyncMessage,
type SessionGrant,
} from "@lda/presentation-sync";
export const PRESENTATION_SYNC_GRANT_STORAGE_KEY =
"lda.presentation-sync.connection.v1";
const RECONNECT_BASE_DELAY_MS = 500;
const RECONNECT_MAX_DELAY_MS = 5_000;
export type PresentationSyncClientEvent =
| { readonly type: "open" }
| { readonly type: "message"; readonly message: ServerSyncMessage }
| {
readonly type: "reconnecting";
readonly attempt: number;
readonly delayMs: number;
}
| { readonly type: "ended"; readonly reason: "presenter_ended" | "expired" }
| {
readonly type: "failed";
readonly message: string;
readonly retryable: boolean;
};
export type PresentationSyncClientDependencies = {
readonly fetch: typeof fetch;
readonly createWebSocket: (url: string) => WebSocket;
readonly storage: Storage;
readonly origin: string;
readonly protocol: string;
readonly setTimeout: typeof window.setTimeout;
readonly clearTimeout: typeof window.clearTimeout;
};
export type PresentationSyncClient = ReturnType<
typeof createPresentationSyncClient
>;
const websocketIsOpen = (socket: WebSocket): boolean => socket.readyState === 1;
const routeForOppositeRole = (role: PresentationRole): "/present" | "/presenter" =>
role === "presenter" ? "/present" : "/presenter";
export const presentationSyncJoinUrl = (
role: PresentationRole,
code: string,
origin: string,
): string => {
const url = new URL(routeForOppositeRole(role), origin);
url.searchParams.set("pair", normalizeJoinCode(code));
return url.toString();
};
export const createPresentationSyncClient = (
dependencies: PresentationSyncClientDependencies,
) => {
let currentGrant: SessionGrant | null = null;
let socket: WebSocket | null = null;
let active = false;
let reconnectAttempt = 0;
let reconnectTimer: number | null = null;
let clientMessageCounter = 0;
let emit: (event: PresentationSyncClientEvent) => void = () => {};
const clearReconnectTimer = (): void => {
if (reconnectTimer === null) return;
dependencies.clearTimeout(reconnectTimer);
reconnectTimer = null;
};
const clearSavedGrant = (): void => {
dependencies.storage.removeItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY);
};
const saveGrant = (grant: SessionGrant): void => {
dependencies.storage.setItem(
PRESENTATION_SYNC_GRANT_STORAGE_KEY,
JSON.stringify(grant),
);
};
const restoreGrant = (): SessionGrant | null => {
const encoded = dependencies.storage.getItem(
PRESENTATION_SYNC_GRANT_STORAGE_KEY,
);
if (encoded === null) return null;
const decoded = decodeSessionGrant(encoded);
if (!decoded.ok) {
clearSavedGrant();
return null;
}
return decoded.value;
};
const websocketUrlFor = (grant: SessionGrant): string => {
const url = new URL(grant.websocketPath, dependencies.origin);
url.protocol = dependencies.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("token", grant.connectionToken);
return url.toString();
};
const closeSocket = (): void => {
const currentSocket = socket;
socket = null;
if (currentSocket === null || currentSocket.readyState >= 2) return;
currentSocket.close(1000, "closed");
};
const scheduleReconnect = (): void => {
if (!active || currentGrant === null || reconnectTimer !== null) return;
reconnectAttempt += 1;
const delayMs =
reconnectAttempt <= 3
? RECONNECT_BASE_DELAY_MS * 2 ** (reconnectAttempt - 1)
: RECONNECT_MAX_DELAY_MS;
emit({ type: "reconnecting", attempt: reconnectAttempt, delayMs });
reconnectTimer = dependencies.setTimeout(() => {
reconnectTimer = null;
if (active && currentGrant !== null) openSocket();
}, delayMs);
};
const handleServerMessage = (
currentSocket: WebSocket,
data: unknown,
): void => {
if (currentSocket !== socket || !active) return;
if (typeof data !== "string") {
active = false;
clearSavedGrant();
emit({
type: "failed",
message: "server sent a non-text synchronization frame",
retryable: false,
});
currentSocket.close(1003, "text messages required");
return;
}
const decoded = decodeServerSyncMessage(data);
if (!decoded.ok) {
active = false;
clearSavedGrant();
emit({
type: "failed",
message: "server sent an invalid synchronization frame",
retryable: false,
});
currentSocket.close(1003, "invalid server message");
return;
}
const message = decoded.value;
if (message.type === "session.ended") {
active = false;
clearReconnectTimer();
clearSavedGrant();
emit({ type: "ended", reason: message.reason });
return;
}
if (
message.type === "protocol.error" &&
(message.code === "forbidden" || message.code === "invalid_message")
) {
active = false;
clearReconnectTimer();
clearSavedGrant();
emit({ type: "failed", message: message.message, retryable: false });
currentSocket.close(1008, message.message);
return;
}
emit({ type: "message", message });
};
const openSocket = (): void => {
if (!active || currentGrant === null) return;
const nextSocket = dependencies.createWebSocket(websocketUrlFor(currentGrant));
socket = nextSocket;
nextSocket.onopen = () => {
if (nextSocket !== socket || !active) return;
reconnectAttempt = 0;
emit({ type: "open" });
};
nextSocket.onmessage = (event) => {
handleServerMessage(nextSocket, event.data);
};
nextSocket.onerror = () => {
if (nextSocket === socket && active) nextSocket.close(1006, "socket error");
};
nextSocket.onclose = (event) => {
if (nextSocket !== socket) return;
socket = null;
if (!active) return;
if (event.code === 1008) {
active = false;
clearReconnectTimer();
clearSavedGrant();
emit({
type: "failed",
message: event.reason || "synchronization session is no longer valid",
retryable: false,
});
return;
}
scheduleReconnect();
};
};
const requestGrant = async (
path: "/api/presentation-sync/sessions" | "/api/presentation-sync/sessions/join",
body: unknown,
): Promise<SessionGrant> => {
const response = await dependencies.fetch(
new URL(path, dependencies.origin).toString(),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
const responseText = await response.text();
if (!response.ok) {
let message = "unable to establish presentation synchronization";
try {
const parsed = JSON.parse(responseText) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
"error" in parsed &&
typeof parsed.error === "object" &&
parsed.error !== null &&
"message" in parsed.error &&
typeof parsed.error.message === "string"
) {
message = parsed.error.message;
}
} catch {
// The status is still enough to expose a retryable pairing failure.
}
throw new Error(message);
}
const decoded = decodeSessionGrant(responseText);
if (!decoded.ok) throw new Error("server returned an invalid session grant");
saveGrant(decoded.value);
return decoded.value;
};
const create = async (
role: PresentationRole,
initialHash: string,
): Promise<SessionGrant> =>
requestGrant("/api/presentation-sync/sessions", { role, initialHash });
const join = async (
role: PresentationRole,
code: string,
): Promise<SessionGrant> =>
requestGrant("/api/presentation-sync/sessions/join", {
role,
code: normalizeJoinCode(code),
});
const connect = (
grant: SessionGrant,
onEvent: (event: PresentationSyncClientEvent) => void,
): void => {
active = true;
currentGrant = grant;
emit = onEvent;
reconnectAttempt = 0;
clearReconnectTimer();
saveGrant(grant);
closeSocket();
openSocket();
};
const publish = (hash: string, baseRevision: number): string | null => {
if (socket === null || !websocketIsOpen(socket) || !active) return null;
clientMessageCounter += 1;
const messageId = `${clientMessageCounter}-${globalThis.crypto.randomUUID()}`;
socket.send(
JSON.stringify({
type: "location.publish",
hash,
baseRevision,
messageId,
}),
);
return messageId;
};
const end = (): void => {
active = false;
clearReconnectTimer();
if (socket !== null && websocketIsOpen(socket)) {
socket.send(JSON.stringify({ type: "session.end" }));
}
clearSavedGrant();
closeSocket();
};
const leave = (): void => {
active = false;
clearReconnectTimer();
clearSavedGrant();
closeSocket();
};
const dispose = (): void => {
active = false;
clearReconnectTimer();
closeSocket();
emit = () => {};
};
return {
create,
join,
connect,
publish,
end,
leave,
restoreGrant,
dispose,
};
};
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
initialPresentationSyncState,
presentationSyncReducer,
type PresentationSyncState,
} from "./presentation-sync-state.js";
const grant = {
sessionId: "session-1",
code: "ABC123",
connectionToken: "token-1",
websocketPath: "/api/presentation-sync/ws" as const,
snapshot: { hash: "#scene/thesis/title", revision: 0 },
};
const presence = (presenters: number, audience: number) => ({
presenters,
audience,
});
const reduce = (
state: PresentationSyncState,
action: Parameters<typeof presentationSyncReducer>[1],
): PresentationSyncState => presentationSyncReducer(state, action);
describe("presentationSyncReducer", () => {
it("tracks create and join progress", () => {
expect(
reduce(initialPresentationSyncState, { type: "start_create" }),
).toEqual({ kind: "creating" });
expect(
reduce(initialPresentationSyncState, {
type: "start_join",
code: "a-b c123",
}),
).toEqual({ kind: "joining", code: "a-b c123" });
});
it("keeps the grant and initial snapshot while waiting for a peer", () => {
expect(
reduce(initialPresentationSyncState, {
type: "grant_received",
grant,
}),
).toEqual({
kind: "waiting",
grant,
snapshot: grant.snapshot,
presence: presence(0, 0),
});
});
it("moves between waiting and connected from peer presence", () => {
const waiting = reduce(initialPresentationSyncState, {
type: "grant_received",
grant,
});
const withPresenter = reduce(waiting, {
type: "presence_received",
presence: presence(1, 0),
});
expect(withPresenter).toMatchObject({ kind: "waiting", presence: presence(1, 0) });
const connected = reduce(withPresenter, {
type: "presence_received",
presence: presence(1, 1),
});
expect(connected).toMatchObject({ kind: "connected", presence: presence(1, 1) });
expect(
reduce(connected, {
type: "presence_received",
presence: presence(1, 0),
}),
).toMatchObject({ kind: "waiting", presence: presence(1, 0) });
});
it("accepts snapshots without losing grant or presence", () => {
const connected = reduce(
reduce(initialPresentationSyncState, {
type: "grant_received",
grant,
}),
{
type: "socket_ready",
snapshot: { hash: "#scene/thesis/title", revision: 0 },
presence: presence(1, 1),
},
);
expect(
reduce(connected, {
type: "location_snapshot",
snapshot: { hash: "#scene/problem/direct-actions", revision: 1 },
}),
).toEqual({
kind: "connected",
grant,
snapshot: { hash: "#scene/problem/direct-actions", revision: 1 },
presence: presence(1, 1),
});
});
it("converges to the server snapshot after a stale publish", () => {
const connected = reduce(
reduce(initialPresentationSyncState, {
type: "grant_received",
grant,
}),
{
type: "socket_ready",
snapshot: grant.snapshot,
presence: presence(1, 1),
},
);
expect(
reduce(connected, {
type: "location_rejected",
snapshot: { hash: "#scene/problem/direct-actions", revision: 4 },
}),
).toMatchObject({
kind: "connected",
snapshot: { hash: "#scene/problem/direct-actions", revision: 4 },
});
});
it("retains connected data while reconnecting", () => {
const connected = reduce(
reduce(initialPresentationSyncState, {
type: "grant_received",
grant,
}),
{
type: "socket_ready",
snapshot: grant.snapshot,
presence: presence(1, 1),
},
);
expect(
reduce(connected, { type: "socket_reconnecting" }),
).toEqual({
kind: "reconnecting",
grant,
snapshot: grant.snapshot,
presence: presence(1, 1),
});
});
it("represents retryable failure, explicit end, and local leave", () => {
expect(
reduce(initialPresentationSyncState, {
type: "failed",
message: "session not found",
retryable: true,
}),
).toEqual({
kind: "failed",
message: "session not found",
retryable: true,
});
expect(
reduce(initialPresentationSyncState, {
type: "session_ended",
reason: "presenter_ended",
}),
).toEqual({ kind: "ended", reason: "presenter_ended" });
expect(
reduce(initialPresentationSyncState, { type: "left" }),
).toEqual({ kind: "ended", reason: "left" });
});
});
@@ -0,0 +1,165 @@
import type {
PresentationPresence,
PresentationSnapshot,
SessionGrant,
} from "@lda/presentation-sync";
export type ConnectedSyncState = {
readonly grant: SessionGrant;
readonly snapshot: PresentationSnapshot;
readonly presence: PresentationPresence;
};
export type PresentationSyncState =
| { readonly kind: "standalone" }
| { readonly kind: "creating" }
| { readonly kind: "joining"; readonly code: string }
| (ConnectedSyncState & {
readonly kind: "waiting" | "connected" | "reconnecting";
})
| {
readonly kind: "failed";
readonly message: string;
readonly retryable: boolean;
}
| {
readonly kind: "ended";
readonly reason: "presenter_ended" | "expired" | "left";
};
export type PresentationSyncAction =
| { readonly type: "start_create" }
| { readonly type: "start_join"; readonly code: string }
| { readonly type: "grant_received"; readonly grant: SessionGrant }
| {
readonly type: "socket_ready";
readonly snapshot: PresentationSnapshot;
readonly presence: PresentationPresence;
}
| { readonly type: "presence_received"; readonly presence: PresentationPresence }
| { readonly type: "location_snapshot"; readonly snapshot: PresentationSnapshot }
| { readonly type: "location_rejected"; readonly snapshot: PresentationSnapshot }
| { readonly type: "socket_reconnecting" }
| {
readonly type: "failed";
readonly message: string;
readonly retryable: boolean;
}
| {
readonly type: "session_ended";
readonly reason: "presenter_ended" | "expired";
}
| { readonly type: "left" };
export type PresentationSyncController = {
readonly state: PresentationSyncState;
readonly startSession: () => Promise<void>;
readonly joinSession: (code: string) => Promise<void>;
readonly retry: () => void;
readonly leaveSession: () => void;
readonly endSession: () => void;
};
export const initialPresentationSyncState: PresentationSyncState = {
kind: "standalone",
};
const emptyPresence: PresentationPresence = { presenters: 0, audience: 0 };
const connectedKindFor = (
presence: PresentationPresence,
): "waiting" | "connected" =>
presence.presenters > 0 && presence.audience > 0
? "connected"
: "waiting";
const withPresence = (
state: PresentationSyncState,
presence: PresentationPresence,
): PresentationSyncState => {
if (
state.kind !== "waiting" &&
state.kind !== "connected" &&
state.kind !== "reconnecting"
) {
return state;
}
return {
...state,
kind: connectedKindFor(presence),
presence,
};
};
const withSnapshot = (
state: PresentationSyncState,
snapshot: PresentationSnapshot,
): PresentationSyncState => {
if (
state.kind !== "waiting" &&
state.kind !== "connected" &&
state.kind !== "reconnecting"
) {
return state;
}
return { ...state, snapshot };
};
export const presentationSyncReducer = (
state: PresentationSyncState,
action: PresentationSyncAction,
): PresentationSyncState => {
switch (action.type) {
case "start_create":
return { kind: "creating" };
case "start_join":
return { kind: "joining", code: action.code };
case "grant_received":
return {
kind: "waiting",
grant: action.grant,
snapshot: action.grant.snapshot,
presence: emptyPresence,
};
case "socket_ready":
if (
state.kind !== "waiting" &&
state.kind !== "connected" &&
state.kind !== "reconnecting"
) {
return state;
}
return {
...state,
kind: connectedKindFor(action.presence),
snapshot: action.snapshot,
presence: action.presence,
};
case "presence_received":
return withPresence(state, action.presence);
case "location_snapshot":
case "location_rejected":
return withSnapshot(state, action.snapshot);
case "socket_reconnecting":
if (
state.kind !== "waiting" &&
state.kind !== "connected" &&
state.kind !== "reconnecting"
) {
return state;
}
return { ...state, kind: "reconnecting" };
case "failed":
return {
kind: "failed",
message: action.message,
retryable: action.retryable,
};
case "session_ended":
return { kind: "ended", reason: action.reason };
case "left":
return { kind: "ended", reason: "left" };
}
};
@@ -0,0 +1,346 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
PRESENTATION_SYNC_GRANT_STORAGE_KEY,
type PresentationSyncClientDependencies,
} from "./presentation-sync-client.js";
import { usePresentationSync } from "./usePresentationSync.js";
const grant = {
sessionId: "session-1",
code: "ABC123",
connectionToken: "token-1",
websocketPath: "/api/presentation-sync/ws" as const,
snapshot: { hash: "#scene/thesis/title", revision: 0 },
};
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number {
return this.values.size;
}
clear(): void {
this.values.clear();
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
key(index: number): string | null {
return [...this.values.keys()][index] ?? null;
}
removeItem(key: string): void {
this.values.delete(key);
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
}
class FakeSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
readonly sent: string[] = [];
readyState = 0;
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
constructor(readonly url: string) {}
send(message: string): void {
this.sent.push(message);
}
open(): void {
this.readyState = FakeSocket.OPEN;
this.onopen?.();
}
serverMessage(message: unknown): void {
this.onmessage?.({ data: JSON.stringify(message) });
}
close(code = 1000, reason = "closed"): void {
this.readyState = FakeSocket.CLOSED;
this.onclose?.({ code, reason });
}
}
const snapshot = (hash: string, revision: number) => ({
type: "location.snapshot",
snapshot: { hash, revision },
originatingMessageId: null,
});
const presence = (presenters: number, audience: number) => ({
type: "presence.snapshot",
presence: { presenters, audience },
});
const makeDependencies = () => {
const storage = new MemoryStorage();
const sockets: FakeSocket[] = [];
const fetch = vi.fn<typeof globalThis.fetch>();
const dependencies: PresentationSyncClientDependencies & {
readonly location: { readonly search: string; readonly href: string };
readonly history: { readonly replaceState: ReturnType<typeof vi.fn> };
} = {
fetch,
createWebSocket: (url: string) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket as unknown as WebSocket;
},
storage,
origin: "http://console.test",
protocol: "http:",
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
location: { search: "", href: "http://console.test/present" },
history: { replaceState: vi.fn() },
};
return { dependencies, sockets, storage, fetch };
};
const resolvedGrant = () =>
new Response(JSON.stringify(grant), { status: 201 });
const connectSession = async (
result: { readonly current: ReturnType<typeof usePresentationSync> },
sockets: FakeSocket[],
hash = "#scene/thesis/title",
): Promise<void> => {
await act(async () => {
await result.current.startSession();
});
await act(async () => {
sockets[0]?.open();
sockets[0]?.serverMessage(snapshot(hash, hash === "#scene/thesis/title" ? 0 : 1));
sockets[0]?.serverMessage(presence(1, 1));
});
};
describe("usePresentationSync", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("publishes one local hash change after the server snapshot is ready", async () => {
const { dependencies, sockets, fetch } = makeDependencies();
fetch.mockImplementation(async () => resolvedGrant());
const applyRemoteHash = vi.fn();
const { result, rerender } = renderHook(
({ hash }) =>
usePresentationSync({
role: "presenter",
currentHash: hash,
applyRemoteHash,
dependencies,
}),
{ initialProps: { hash: "#scene/thesis/title" } },
);
await connectSession(result, sockets);
expect(sockets[0]?.sent).toHaveLength(0);
rerender({ hash: "#scene/problem/direct-actions" });
expect(sockets[0]?.sent).toHaveLength(1);
expect(JSON.parse(sockets[0]?.sent[0] ?? "{}")).toMatchObject({
type: "location.publish",
hash: "#scene/problem/direct-actions",
baseRevision: 0,
});
});
it("applies a remote hash without publishing it back", async () => {
const { dependencies, sockets, fetch } = makeDependencies();
fetch.mockImplementation(async () => resolvedGrant());
const applyRemoteHash = vi.fn();
const { result, rerender } = renderHook(
({ hash }) =>
usePresentationSync({
role: "audience",
currentHash: hash,
applyRemoteHash,
dependencies,
}),
{ initialProps: { hash: "#scene/thesis/title" } },
);
await connectSession(result, sockets);
await act(async () => {
sockets[0]?.serverMessage(snapshot("#scene/problem/direct-actions", 1));
});
expect(applyRemoteHash).toHaveBeenCalledWith("#scene/problem/direct-actions");
rerender({ hash: "#scene/problem/direct-actions" });
expect(sockets[0]?.sent.filter((message) =>
JSON.parse(message).type === "location.publish",
)).toHaveLength(0);
});
it("applies stale rejection snapshots and does not echo convergence", async () => {
const { dependencies, sockets, fetch } = makeDependencies();
fetch.mockImplementation(async () => resolvedGrant());
const applyRemoteHash = vi.fn();
const { result, rerender } = renderHook(
({ hash }) =>
usePresentationSync({
role: "audience",
currentHash: hash,
applyRemoteHash,
dependencies,
}),
{ initialProps: { hash: "#scene/thesis/title" } },
);
await connectSession(result, sockets);
await act(async () => {
sockets[0]?.serverMessage({
type: "location.rejected",
reason: "stale_revision",
current: {
hash: "#scene/problem/direct-actions",
revision: 4,
},
messageId: "1-stale",
});
});
rerender({ hash: "#scene/problem/direct-actions" });
expect(applyRemoteHash).toHaveBeenCalledWith("#scene/problem/direct-actions");
expect(sockets[0]?.sent.filter((message) =>
JSON.parse(message).type === "location.publish",
)).toHaveLength(0);
});
it("lets the server snapshot win after reconnecting over local navigation", async () => {
vi.useFakeTimers();
const { dependencies, sockets, fetch } = makeDependencies();
fetch.mockImplementation(async () => resolvedGrant());
const applyRemoteHash = vi.fn();
const { result, rerender } = renderHook(
({ hash }) =>
usePresentationSync({
role: "presenter",
currentHash: hash,
applyRemoteHash,
dependencies,
}),
{ initialProps: { hash: "#scene/thesis/title" } },
);
await connectSession(result, sockets);
sockets[0]?.close(1006, "network");
rerender({ hash: "#scene/problem/direct-actions" });
expect(sockets[0]?.sent).toHaveLength(0);
await act(async () => {
vi.advanceTimersByTime(500);
});
await act(async () => {
sockets[1]?.open();
sockets[1]?.serverMessage(snapshot("#scene/architecture/runtime", 2));
sockets[1]?.serverMessage(presence(1, 1));
});
rerender({ hash: "#scene/architecture/runtime" });
expect(applyRemoteHash).toHaveBeenCalledWith("#scene/architecture/runtime");
expect(sockets[1]?.sent.filter((message) =>
JSON.parse(message).type === "location.publish",
)).toHaveLength(0);
});
it("keeps local navigation standalone when session creation fails", async () => {
const { dependencies, fetch } = makeDependencies();
fetch.mockRejectedValue(new Error("server unavailable"));
const applyRemoteHash = vi.fn();
const { result, rerender } = renderHook(
({ hash }) =>
usePresentationSync({
role: "presenter",
currentHash: hash,
applyRemoteHash,
dependencies,
}),
{ initialProps: { hash: "#scene/thesis/title" } },
);
await act(async () => {
await result.current.startSession();
});
rerender({ hash: "#scene/problem/direct-actions" });
expect(result.current.state).toEqual({
kind: "failed",
message: "server unavailable",
retryable: true,
});
expect(applyRemoteHash).not.toHaveBeenCalled();
});
it("consumes a query-string pair code once on mount", async () => {
const { dependencies, sockets, fetch } = makeDependencies();
const pairDependencies = {
...dependencies,
location: {
search: "?pair=ab-c%20123",
href: "http://console.test/present?pair=ab-c%20123",
},
};
fetch.mockImplementation(async () => resolvedGrant());
const { result, rerender } = renderHook(
({ hash }) =>
usePresentationSync({
role: "audience",
currentHash: hash,
applyRemoteHash: () => {},
dependencies: pairDependencies,
}),
{ initialProps: { hash: "#scene/thesis/title" } },
);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
"http://console.test/api/presentation-sync/sessions/join",
expect.objectContaining({
body: JSON.stringify({ role: "audience", code: "ABC123" }),
}),
);
rerender({ hash: "#scene/thesis/title" });
expect(fetch).toHaveBeenCalledTimes(1);
expect(sockets).toHaveLength(1);
expect(result.current.state.kind).toBe("waiting");
});
it("closes the socket and reconnect timer on unmount", async () => {
vi.useFakeTimers();
const { dependencies, sockets, fetch, storage } = makeDependencies();
fetch.mockImplementation(async () => resolvedGrant());
const { result, unmount } = renderHook(() =>
usePresentationSync({
role: "presenter",
currentHash: "#scene/thesis/title",
applyRemoteHash: () => {},
dependencies,
}),
);
await connectSession(result, sockets);
unmount();
vi.advanceTimersByTime(10_000);
expect(sockets).toHaveLength(1);
expect(sockets[0]?.readyState).toBe(FakeSocket.CLOSED);
expect(storage.getItem(PRESENTATION_SYNC_GRANT_STORAGE_KEY)).not.toBeNull();
});
});
@@ -0,0 +1,412 @@
import {
useEffect,
useRef,
useSyncExternalStore,
} from "react";
import {
normalizeJoinCode,
type PresentationRole,
type PresentationSnapshot,
type SessionGrant,
} from "@lda/presentation-sync";
import {
createPresentationSyncClient,
type PresentationSyncClient,
type PresentationSyncClientDependencies,
} from "./presentation-sync-client.js";
import {
initialPresentationSyncState,
presentationSyncReducer,
type PresentationSyncController,
type PresentationSyncState,
} from "./presentation-sync-state.js";
export type PresentationSyncHookDependencies =
Partial<PresentationSyncClientDependencies> & {
readonly location?: Pick<Location, "href" | "search">;
readonly history?: { readonly replaceState: unknown };
};
export type UsePresentationSyncOptions = {
readonly role: PresentationRole;
readonly currentHash: string;
readonly applyRemoteHash: (hash: string) => void;
readonly dependencies?: PresentationSyncHookDependencies;
readonly client?: PresentationSyncClient;
};
type BrowserUrlState = {
readonly location: Pick<Location, "href" | "search">;
readonly history: { readonly replaceState: unknown };
};
type InternalController = PresentationSyncController & {
readonly subscribe: (listener: () => void) => () => void;
readonly getSnapshot: () => PresentationSyncState;
readonly mount: () => void;
readonly dispose: () => void;
readonly publish: (hash: string) => string | null;
readonly restoreSavedGrant: () => SessionGrant | null;
readonly restoreGrant: (grant: SessionGrant) => void;
readonly browserUrl: BrowserUrlState;
};
type LastOperation =
| { readonly kind: "create" }
| { readonly kind: "join"; readonly code: string }
| null;
const defaultBrowserDependencies = (): {
readonly client: PresentationSyncClientDependencies;
readonly url: BrowserUrlState;
} => {
const browserWindow = globalThis.window;
return {
client: {
fetch: browserWindow.fetch.bind(browserWindow),
createWebSocket: (url) => new browserWindow.WebSocket(url),
storage: browserWindow.sessionStorage,
origin: browserWindow.location.origin,
protocol: browserWindow.location.protocol,
setTimeout: browserWindow.setTimeout.bind(browserWindow),
clearTimeout: browserWindow.clearTimeout.bind(browserWindow),
},
url: {
location: browserWindow.location,
history: browserWindow.history,
},
};
};
const browserDependenciesFor = (
dependencies: PresentationSyncHookDependencies | undefined,
): { readonly client: PresentationSyncClientDependencies; readonly url: BrowserUrlState } => {
const defaults = defaultBrowserDependencies();
const { location, history, ...clientOverrides } = dependencies ?? {};
return {
client: { ...defaults.client, ...clientOverrides } as PresentationSyncClientDependencies,
url: {
location: location ?? defaults.url.location,
history: history ?? defaults.url.history,
},
};
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const createController = (options: {
readonly client: PresentationSyncClient;
readonly getRole: () => PresentationRole;
readonly getCurrentHash: () => string;
readonly applyRemoteHash: (hash: string) => void;
readonly browserUrl: BrowserUrlState;
}): InternalController => {
let state: PresentationSyncState = initialPresentationSyncState;
let mounted = true;
let currentGrant: SessionGrant | null = null;
let lastOperation: LastOperation = null;
const listeners = new Set<() => void>();
const pendingMessageIds = new Set<string>();
const notify = (): void => {
for (const listener of listeners) listener();
};
const dispatch = (
action: Parameters<typeof presentationSyncReducer>[1],
): void => {
const nextState = presentationSyncReducer(state, action);
if (nextState === state) return;
state = nextState;
notify();
};
const applySnapshot = (snapshot: PresentationSnapshot): void => {
if (snapshot.hash === options.getCurrentHash()) return;
options.applyRemoteHash(snapshot.hash);
};
const handleClientEvent = (event: Parameters<PresentationSyncClient["connect"]>[1] extends (
event: infer Event,
) => void
? Event
: never): void => {
if (!mounted) return;
switch (event.type) {
case "open":
return;
case "reconnecting":
dispatch({ type: "socket_reconnecting" });
return;
case "ended":
currentGrant = null;
pendingMessageIds.clear();
dispatch({ type: "session_ended", reason: event.reason });
return;
case "failed":
currentGrant = null;
pendingMessageIds.clear();
dispatch({
type: "failed",
message: event.message,
retryable: event.retryable,
});
return;
case "message": {
const message = event.message;
if (message.type === "location.snapshot") {
const isOwnPublish =
message.originatingMessageId !== null &&
pendingMessageIds.delete(message.originatingMessageId);
if (!isOwnPublish) applySnapshot(message.snapshot);
dispatch({ type: "location_snapshot", snapshot: message.snapshot });
return;
}
if (message.type === "location.rejected") {
pendingMessageIds.delete(message.messageId);
applySnapshot(message.current);
dispatch({ type: "location_rejected", snapshot: message.current });
return;
}
if (message.type === "presence.snapshot") {
dispatch({ type: "presence_received", presence: message.presence });
return;
}
if (message.type === "protocol.error") {
dispatch({
type: "failed",
message: message.message,
retryable: false,
});
}
return;
}
}
};
const connectGrant = (grant: SessionGrant): void => {
currentGrant = grant;
dispatch({ type: "grant_received", grant });
options.client.connect(grant, handleClientEvent);
};
const publish = (hash: string): string | null => {
if (state.kind !== "connected") return null;
const messageId = options.client.publish(hash, state.snapshot.revision);
if (messageId !== null) pendingMessageIds.add(messageId);
return messageId;
};
const restoreSavedGrant = (): SessionGrant | null =>
options.client.restoreGrant();
const restoreGrant = (grant: SessionGrant): void => {
if (!mounted) return;
connectGrant(grant);
};
const startSession = async (): Promise<void> => {
lastOperation = { kind: "create" };
currentGrant = null;
pendingMessageIds.clear();
options.client.leave();
dispatch({ type: "start_create" });
try {
const grant = await options.client.create(
options.getRole(),
options.getCurrentHash(),
);
if (!mounted) return;
connectGrant(grant);
} catch (error) {
if (mounted) {
dispatch({
type: "failed",
message: errorMessage(error),
retryable: true,
});
}
}
};
const joinSession = async (code: string): Promise<void> => {
const normalizedCode = normalizeJoinCode(code);
lastOperation = { kind: "join", code: normalizedCode };
currentGrant = null;
pendingMessageIds.clear();
options.client.leave();
dispatch({ type: "start_join", code: normalizedCode });
try {
const grant = await options.client.join(options.getRole(), normalizedCode);
if (!mounted) return;
connectGrant(grant);
} catch (error) {
if (mounted) {
dispatch({
type: "failed",
message: errorMessage(error),
retryable: true,
});
}
}
};
const retry = (): void => {
if (lastOperation?.kind === "create") {
void startSession();
return;
}
if (lastOperation?.kind === "join") void joinSession(lastOperation.code);
};
const leaveSession = (): void => {
currentGrant = null;
pendingMessageIds.clear();
options.client.leave();
dispatch({ type: "left" });
};
const endSession = (): void => {
currentGrant = null;
pendingMessageIds.clear();
options.client.end();
dispatch({ type: "session_ended", reason: "presenter_ended" });
};
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const getSnapshot = (): PresentationSyncState => state;
const mount = (): void => {
mounted = true;
};
const dispose = (): void => {
mounted = false;
options.client.dispose();
listeners.clear();
};
return {
get state() {
return state;
},
startSession,
joinSession,
retry,
leaveSession,
endSession,
subscribe,
getSnapshot,
mount,
dispose,
publish,
restoreSavedGrant,
restoreGrant,
browserUrl: options.browserUrl,
};
};
const pairCodeFromUrl = (url: BrowserUrlState): string | null => {
const pair = new URL(url.location.href).searchParams.get("pair");
if (pair === null || normalizeJoinCode(pair) === "") return null;
const consumed = new URL(url.location.href);
consumed.searchParams.delete("pair");
if (typeof url.history.replaceState === "function") {
url.history.replaceState(
null,
"",
`${consumed.pathname}${consumed.search}${consumed.hash}`,
);
}
return normalizeJoinCode(pair);
};
export const usePresentationSync = ({
role,
currentHash,
applyRemoteHash,
dependencies,
client: injectedClient,
}: UsePresentationSyncOptions): PresentationSyncController => {
const roleRef = useRef(role);
const currentHashRef = useRef(currentHash);
const applyRemoteHashRef = useRef(applyRemoteHash);
const remoteHashInFlightRef = useRef<string | null>(null);
const lastObservedHashRef = useRef(currentHash);
roleRef.current = role;
currentHashRef.current = currentHash;
applyRemoteHashRef.current = applyRemoteHash;
const controllerRef = useRef<InternalController | null>(null);
const autoJoinConsumedRef = useRef(false);
if (controllerRef.current === null) {
const browser = browserDependenciesFor(dependencies);
const client =
injectedClient ?? createPresentationSyncClient(browser.client);
controllerRef.current = createController({
client,
getRole: () => roleRef.current,
getCurrentHash: () => currentHashRef.current,
applyRemoteHash: (hash) => {
// Set this immediately before applying so the next hash effect consumes
// the remote update instead of echoing it as another revision.
remoteHashInFlightRef.current = hash;
applyRemoteHashRef.current(hash);
},
browserUrl: browser.url,
});
}
const controller = controllerRef.current;
if (controller === null) throw new Error("presentation sync controller unavailable");
const state = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
);
useEffect(() => {
controller.mount();
if (autoJoinConsumedRef.current) return () => controller.dispose();
autoJoinConsumedRef.current = true;
const restoredGrant = controller.restoreSavedGrant();
if (restoredGrant !== null) controller.restoreGrant(restoredGrant);
else {
const pairCode = pairCodeFromUrl(controller.browserUrl);
if (pairCode !== null) void controller.joinSession(pairCode);
}
return () => controller.dispose();
}, [controller, injectedClient]);
useEffect(() => {
const remoteHash = remoteHashInFlightRef.current;
if (remoteHash !== null) {
lastObservedHashRef.current = currentHash;
if (remoteHash === currentHash) remoteHashInFlightRef.current = null;
return;
}
if (lastObservedHashRef.current === currentHash) return;
lastObservedHashRef.current = currentHash;
if (state.kind !== "connected") return;
controller.publish(currentHash);
}, [controller, currentHash, state.kind]);
return {
state,
startSession: controller.startSession,
joinSession: controller.joinSession,
retry: controller.retry,
leaveSession: controller.leaveSession,
endSession: controller.endSession,
};
};
+2 -1
View File
@@ -13,5 +13,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src"],
"references": [{ "path": "../../packages/presentation-sync" }]
}
+3
View File
@@ -35,6 +35,9 @@ importers:
'@fontsource/ibm-plex-mono':
specifier: 5.2.7
version: 5.2.7
'@lda/presentation-sync':
specifier: workspace:*
version: link:../../packages/presentation-sync
'@use-gesture/react':
specifier: ^10.3.1
version: 10.3.1([email protected])