feat: add presentation sync client
This commit is contained in:
@@ -3,13 +3,17 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "pnpm --filter @lda/presentation-sync build",
|
||||||
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||||
|
"prebuild": "pnpm --filter @lda/presentation-sync build",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"pretypecheck": "pnpm --filter @lda/presentation-sync build",
|
||||||
"typecheck": "tsc -b --pretty false",
|
"typecheck": "tsc -b --pretty false",
|
||||||
"preview": "vite preview --host 127.0.0.1"
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lda/presentation-sync": "workspace:*",
|
||||||
"@assistant-ui/react": "^0.14.26",
|
"@assistant-ui/react": "^0.14.26",
|
||||||
"@dagrejs/dagre": "3.0.0",
|
"@dagrejs/dagre": "3.0.0",
|
||||||
"@fontsource-variable/newsreader": "5.2.10",
|
"@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/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../../packages/presentation-sync" }]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
@@ -35,6 +35,9 @@ importers:
|
|||||||
'@fontsource/ibm-plex-mono':
|
'@fontsource/ibm-plex-mono':
|
||||||
specifier: 5.2.7
|
specifier: 5.2.7
|
||||||
version: 5.2.7
|
version: 5.2.7
|
||||||
|
'@lda/presentation-sync':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/presentation-sync
|
||||||
'@use-gesture/react':
|
'@use-gesture/react':
|
||||||
specifier: ^10.3.1
|
specifier: ^10.3.1
|
||||||
version: 10.3.1([email protected])
|
version: 10.3.1([email protected])
|
||||||
|
|||||||
Reference in New Issue
Block a user