feat: add presentation sync client
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -13,5 +13,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../../packages/presentation-sync" }]
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user