feat: add authenticated realtime presence
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createPresenceIndicator } from "../realtime/index.ts";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class FakeElement {
|
||||
readonly children: FakeElement[] = [];
|
||||
readonly attributes = new Map<string, string>();
|
||||
readonly listeners = new Map<string, Listener[]>();
|
||||
parentElement: FakeElement | null = null;
|
||||
className = "";
|
||||
textContent = "";
|
||||
hidden = false;
|
||||
type = "";
|
||||
readonly ownerDocument: FakeDocument;
|
||||
constructor(ownerDocument: FakeDocument) { this.ownerDocument = ownerDocument; }
|
||||
append(...nodes: FakeElement[]): void {
|
||||
for (const node of nodes) { node.parentElement = this; this.children.push(node); }
|
||||
}
|
||||
setAttribute(name: string, value: string): void { this.attributes.set(name, value); }
|
||||
getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; }
|
||||
addEventListener(type: string, listener: Listener): void {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (listeners) listeners.push(listener); else this.listeners.set(type, [listener]);
|
||||
}
|
||||
click(): void { for (const listener of this.listeners.get("click") ?? []) listener(); }
|
||||
remove(): void {
|
||||
if (!this.parentElement) return;
|
||||
const index = this.parentElement.children.indexOf(this);
|
||||
if (index >= 0) this.parentElement.children.splice(index, 1);
|
||||
this.parentElement = null;
|
||||
}
|
||||
text(): string { return this.textContent + this.children.map((child) => child.text()).join(""); }
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
createElement(): FakeElement { return new FakeElement(this); }
|
||||
}
|
||||
|
||||
function setup(onRetry?: () => void) {
|
||||
const document = new FakeDocument();
|
||||
const container = document.createElement();
|
||||
const indicator = createPresenceIndicator({
|
||||
container: container as unknown as HTMLElement,
|
||||
onRetry,
|
||||
});
|
||||
return { container, indicator, root: indicator.root as unknown as FakeElement };
|
||||
}
|
||||
|
||||
describe("hosted presence indicator", () => {
|
||||
it("is private and off by default with a polite atomic status", () => {
|
||||
const { indicator, root } = setup();
|
||||
assert.deepEqual(indicator.state(), { signedIn: false, connection: "off", nearbyPeerCount: 0 });
|
||||
assert.equal(root.getAttribute("data-presence"), "off");
|
||||
const status = root.children[0]!;
|
||||
assert.equal(status.getAttribute("role"), "status");
|
||||
assert.equal(status.getAttribute("aria-live"), "polite");
|
||||
assert.equal(status.getAttribute("aria-atomic"), "true");
|
||||
assert.equal(root.text(), "Hosted presence is off.Retry presence");
|
||||
assert.equal(root.children[1]?.hidden, true);
|
||||
});
|
||||
|
||||
it("renders only signed-in connection state and aggregate nearby count", () => {
|
||||
const { indicator, root } = setup();
|
||||
const source = { signedIn: true, connection: "live" as const, nearbyPeerCount: 7 };
|
||||
assert.deepEqual(indicator.update(source), source);
|
||||
source.nearbyPeerCount = 99;
|
||||
assert.deepEqual(indicator.state(), { signedIn: true, connection: "live", nearbyPeerCount: 7 });
|
||||
assert.match(root.text(), /live · 7 nearby peers/);
|
||||
assert.doesNotMatch(root.text(), /actor|token|display name/i);
|
||||
|
||||
indicator.update({ signedIn: false, connection: "live", nearbyPeerCount: 400 });
|
||||
assert.deepEqual(indicator.state(), { signedIn: false, connection: "off", nearbyPeerCount: 0 });
|
||||
assert.equal(root.text(), "Hosted presence is off.Retry presence");
|
||||
});
|
||||
|
||||
it("shows retry only while signed-in offline/reconnecting and emits intent only", () => {
|
||||
let retries = 0;
|
||||
const { indicator, root } = setup(() => { retries += 1; });
|
||||
const retry = root.children[1]!;
|
||||
retry.click();
|
||||
assert.equal(retries, 0);
|
||||
indicator.update({ signedIn: true, connection: "connecting", nearbyPeerCount: 0 });
|
||||
assert.equal(retry.hidden, true);
|
||||
indicator.update({ signedIn: true, connection: "reconnecting", nearbyPeerCount: 12 });
|
||||
assert.equal(retry.hidden, false);
|
||||
assert.match(root.text(), /reconnecting/);
|
||||
assert.doesNotMatch(root.text(), /12/);
|
||||
retry.click();
|
||||
indicator.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||||
retry.click();
|
||||
assert.equal(retries, 2);
|
||||
assert.equal("fetch" in indicator, false);
|
||||
});
|
||||
|
||||
it("rejects invalid aggregate state and disposes idempotently", () => {
|
||||
const { container, indicator, root } = setup(() => assert.fail("disposed retry fired"));
|
||||
assert.throws(() => indicator.update({ signedIn: true, connection: "live", nearbyPeerCount: -1 }), /integer/);
|
||||
assert.throws(() => indicator.update({
|
||||
signedIn: true, connection: "secret" as "live", nearbyPeerCount: 0,
|
||||
}), /connection/);
|
||||
indicator.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||||
indicator.dispose();
|
||||
indicator.dispose();
|
||||
root.children[1]!.click();
|
||||
assert.equal(container.children.length, 0);
|
||||
assert.deepEqual(indicator.state(), { signedIn: false, connection: "off", nearbyPeerCount: 0 });
|
||||
assert.deepEqual(
|
||||
indicator.update({ signedIn: true, connection: "live", nearbyPeerCount: 2 }),
|
||||
{ signedIn: false, connection: "off", nearbyPeerCount: 0 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
createRealtimeClient,
|
||||
type ActorPoseSnapshot,
|
||||
type RealtimeClientTimers,
|
||||
} from "../realtime/index.ts";
|
||||
|
||||
const OFFICE = { kind: "office" as const, officeId: "hq" };
|
||||
|
||||
function actor(sequence = 0, xM = 0): ActorPoseSnapshot {
|
||||
return {
|
||||
entity: "actor",
|
||||
actorId: "actor-1",
|
||||
kind: "humanoid",
|
||||
sequence,
|
||||
timestampMs: 1_765_000_000_000 + sequence,
|
||||
pose: {
|
||||
space: "local",
|
||||
cell: OFFICE,
|
||||
xM,
|
||||
yM: 0,
|
||||
zM: 0,
|
||||
headingDeg: 0,
|
||||
pitchDeg: 0,
|
||||
},
|
||||
velocity: { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function joinGrant(requestId: string) {
|
||||
return {
|
||||
type: "join-grant",
|
||||
protocolVersion: 1,
|
||||
requestId,
|
||||
sessionId: "session-1",
|
||||
actorId: "actor-1",
|
||||
role: "member",
|
||||
serverEpoch: "epoch-1",
|
||||
serverTimeMs: 1_765_000_000_000,
|
||||
nextSequence: 4,
|
||||
resumeToken: "resume-secret",
|
||||
interests: [OFFICE],
|
||||
initial: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resumeGrant(requestId: string, interests = [OFFICE]) {
|
||||
return {
|
||||
type: "resume-grant",
|
||||
protocolVersion: 1,
|
||||
requestId,
|
||||
sessionId: "session-1",
|
||||
serverEpoch: "epoch-1",
|
||||
serverTimeMs: 1_765_000_000_001,
|
||||
nextSequence: 5,
|
||||
resumeToken: "rotated-resume-secret",
|
||||
continuous: true,
|
||||
snapshot: [],
|
||||
interests,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
class FakeTimers implements RealtimeClientTimers {
|
||||
readonly pending = new Map<number, { callback: () => void; delay: number }>();
|
||||
private next = 1;
|
||||
|
||||
setTimeout(callback: () => void, delay: number): ReturnType<typeof setTimeout> {
|
||||
const id = this.next++;
|
||||
this.pending.set(id, { callback, delay });
|
||||
return id as unknown as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void {
|
||||
this.pending.delete(handle as unknown as number);
|
||||
}
|
||||
|
||||
runFirst(): number {
|
||||
const entry = this.pending.entries().next().value as [number, { callback: () => void; delay: number }] | undefined;
|
||||
if (!entry) throw new Error("no timer pending");
|
||||
this.pending.delete(entry[0]);
|
||||
entry[1].callback();
|
||||
return entry[1].delay;
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => globalThis.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("realtime browser client", () => {
|
||||
it("uses memory-only bearer auth, same-origin credentials, and validates join identity", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit; body: Record<string, unknown> }> = [];
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
calls.push({ url: String(input), init, body });
|
||||
return jsonResponse(joinGrant(String(body.requestId)));
|
||||
};
|
||||
const client = createRealtimeClient({ actorId: "actor-1", accessToken: "access-secret", fetch: fetcher });
|
||||
await client.join(OFFICE, actor());
|
||||
|
||||
assert.equal(calls[0]?.url, "/api/v1/realtime/join");
|
||||
assert.equal(calls[0]?.init.credentials, "same-origin");
|
||||
assert.equal(calls[0]?.init.cache, "no-store");
|
||||
assert.equal(new Headers(calls[0]?.init.headers).get("Authorization"), "Bearer access-secret");
|
||||
assert.equal(calls[0]?.url.includes("access-secret"), false);
|
||||
assert.equal(JSON.stringify(client.state()).includes("secret"), false);
|
||||
assert.throws(() => client.publishPose({
|
||||
...actor(), velocity: { ...actor().velocity, xMps: Number.NaN },
|
||||
}), /invalid/);
|
||||
});
|
||||
|
||||
it("uses an HttpOnly same-origin session when no browser-readable bearer exists", async () => {
|
||||
const calls: RequestInit[] = [];
|
||||
const fetcher: typeof fetch = async (_input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
calls.push(init);
|
||||
return jsonResponse(joinGrant(String(body.requestId)), 201);
|
||||
};
|
||||
const client = createRealtimeClient({ actorId: "actor-1", fetch: fetcher });
|
||||
await client.join(OFFICE, actor());
|
||||
|
||||
assert.equal(new Headers(calls[0]?.headers).has("authorization"), false);
|
||||
assert.equal(calls[0]?.credentials, "same-origin");
|
||||
});
|
||||
|
||||
it("accepts one authenticated request adapter without retaining its credential", async () => {
|
||||
let used = false;
|
||||
const authenticatedFetch: typeof fetch = async (_input, init = {}) => {
|
||||
used = true;
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
return jsonResponse(joinGrant(String(body.requestId)), 201);
|
||||
};
|
||||
const client = createRealtimeClient({ actorId: "actor-1", authenticatedFetch });
|
||||
await client.join(OFFICE, actor());
|
||||
assert.equal(used, true);
|
||||
assert.throws(
|
||||
() => createRealtimeClient({ actorId: "actor-1", authenticatedFetch, fetch }),
|
||||
/either fetch or authenticatedFetch/,
|
||||
);
|
||||
});
|
||||
|
||||
it("coalesces poses at 10-15Hz and assigns monotonic client sequences", async () => {
|
||||
const timers = new FakeTimers();
|
||||
const poses: ActorPoseSnapshot[] = [];
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
if (String(input).endsWith("/join")) return jsonResponse(joinGrant(String(body.requestId)));
|
||||
if (String(input).endsWith("/pose")) {
|
||||
poses.push(body.snapshot as ActorPoseSnapshot);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const client = createRealtimeClient({
|
||||
actorId: "actor-1", accessToken: "token", fetch: fetcher, timers, sendIntervalMs: 80,
|
||||
});
|
||||
await client.join(OFFICE, actor());
|
||||
client.publishPose(actor(0, 1));
|
||||
client.publishPose(actor(0, 2));
|
||||
assert.equal(timers.pending.size, 1);
|
||||
assert.equal(timers.runFirst(), 80);
|
||||
await settle();
|
||||
assert.equal(poses.length, 1);
|
||||
assert.equal(poses[0]?.pose.space === "local" ? poses[0].pose.xM : -1, 2);
|
||||
assert.equal(poses[0]?.sequence, 5, "the superseding pose keeps the later monotonic sequence");
|
||||
|
||||
client.publishPose(actor(0, 3));
|
||||
timers.runFirst();
|
||||
await settle();
|
||||
assert.equal(poses[1]?.sequence, 6);
|
||||
assert.throws(
|
||||
() => createRealtimeClient({ actorId: "actor-1", accessToken: "token", fetch: fetcher, sendIntervalMs: 50 }),
|
||||
/66\.67ms/,
|
||||
);
|
||||
});
|
||||
|
||||
it("holds a pose until a stream resume delivers the rotated credential", async () => {
|
||||
const timers = new FakeTimers();
|
||||
const poseTokens: unknown[] = [];
|
||||
const stream: { current: ReadableStreamDefaultController<Uint8Array> | null } = { current: null };
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
if (String(input).endsWith("/join")) return jsonResponse(joinGrant(String(body.requestId)));
|
||||
if (String(input).endsWith("/events")) {
|
||||
return new Response(new ReadableStream<Uint8Array>({ start(controller) { stream.current = controller; } }), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
if (String(input).endsWith("/pose")) {
|
||||
poseTokens.push(body.token);
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const client = createRealtimeClient({
|
||||
actorId: "actor-1", accessToken: "token", fetch: fetcher, timers,
|
||||
});
|
||||
client.subscribe({});
|
||||
await client.join(OFFICE, actor());
|
||||
await settle();
|
||||
client.publishPose(actor(0, 1));
|
||||
timers.runFirst();
|
||||
await settle();
|
||||
assert.equal(poseTokens.length, 0);
|
||||
|
||||
stream.current?.enqueue(new TextEncoder().encode(
|
||||
`data: ${JSON.stringify(resumeGrant("stream-resume"))}\n\n`,
|
||||
));
|
||||
await settle();
|
||||
timers.runFirst();
|
||||
await settle();
|
||||
assert.deepEqual(poseTokens, ["rotated-resume-secret"]);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
it("parses validated POST SSE events, rejects malformed input, and reconnects with backoff", async () => {
|
||||
const timers = new FakeTimers();
|
||||
const malformed: string[] = [];
|
||||
const messages: string[] = [];
|
||||
let streamCalls = 0;
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
if (String(input).endsWith("/join")) return jsonResponse(joinGrant(String(body.requestId)));
|
||||
if (String(input).endsWith("/events")) {
|
||||
streamCalls += 1;
|
||||
const delta = {
|
||||
type: "pose-delta", protocolVersion: 1, serverEpoch: "epoch-1", sequence: 4,
|
||||
timestampMs: 1_765_000_000_004, updates: [], removedEntityIds: [],
|
||||
};
|
||||
return new Response(`data: not-json\n\ndata: ${JSON.stringify(delta)}\n\n`, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const client = createRealtimeClient({
|
||||
actorId: "actor-1",
|
||||
accessToken: "token",
|
||||
fetch: fetcher,
|
||||
timers,
|
||||
reconnectBaseMs: 200,
|
||||
reconnectMaximumMs: 2_000,
|
||||
reconnectJitter: 0,
|
||||
random: () => 0.5,
|
||||
});
|
||||
client.subscribe({
|
||||
onMalformedEvent: (error) => malformed.push(error.message),
|
||||
onMessage: (message) => messages.push(message.type),
|
||||
});
|
||||
await client.join(OFFICE, actor());
|
||||
await settle();
|
||||
assert.deepEqual(messages, ["pose-delta"]);
|
||||
assert.equal(malformed.length, 1);
|
||||
assert.equal(client.state().status, "reconnecting");
|
||||
assert.equal(timers.runFirst(), 200);
|
||||
await settle();
|
||||
assert.equal(streamCalls, 2);
|
||||
});
|
||||
|
||||
it("moves interest through resume and aborts streaming before leave/dispose", async () => {
|
||||
const requests: Array<{ url: string; body: Record<string, unknown>; signal: AbortSignal | null }> = [];
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
requests.push({ url: String(input), body, signal: init.signal ?? null });
|
||||
if (String(input).endsWith("/join")) {
|
||||
if (body.type === "resume-request") return jsonResponse(resumeGrant(String(body.requestId)));
|
||||
return jsonResponse(joinGrant(String(body.requestId)));
|
||||
}
|
||||
if (String(input).endsWith("/events")) {
|
||||
return new Response(new ReadableStream({ start() { /* held open until aborted */ } }), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const client = createRealtimeClient({ actorId: "actor-1", accessToken: "token", fetch: fetcher });
|
||||
client.subscribe({});
|
||||
await client.join(OFFICE, actor());
|
||||
await settle();
|
||||
const firstStream = requests.find((request) => request.url.endsWith("/events"));
|
||||
assert.equal(firstStream?.signal?.aborted, false);
|
||||
|
||||
const moved = { kind: "floor" as const, officeId: "hq", floorId: "two" };
|
||||
await client.moveInterest(moved);
|
||||
assert.deepEqual(client.state().interests, [moved]);
|
||||
assert.equal(firstStream?.signal?.aborted, true);
|
||||
await client.dispose();
|
||||
assert.equal(client.state().status, "disposed");
|
||||
assert.equal(requests.filter((request) => request.url.endsWith("/leave")).length, 1);
|
||||
assert.equal(JSON.stringify(client.state()).includes("resume-secret"), false);
|
||||
});
|
||||
|
||||
it("closes the old-cell stream when an interest handoff fails", async () => {
|
||||
const requests: Array<{ url: string; signal: AbortSignal | null }> = [];
|
||||
let resumes = 0;
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
requests.push({ url, signal: init.signal ?? null });
|
||||
if (url.endsWith("/join") && body.type === "resume-request") {
|
||||
resumes += 1;
|
||||
return jsonResponse({ error: "interest" }, 409);
|
||||
}
|
||||
if (url.endsWith("/join")) return jsonResponse(joinGrant(String(body.requestId)));
|
||||
if (url.endsWith("/events")) {
|
||||
return new Response(new ReadableStream({ start() { /* held until abort */ } }), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const client = createRealtimeClient({ actorId: "actor-1", accessToken: "token", fetch: fetcher });
|
||||
client.subscribe({});
|
||||
await client.join(OFFICE, actor());
|
||||
await settle();
|
||||
const oldStream = requests.find((request) => request.url.endsWith("/events"));
|
||||
await assert.rejects(
|
||||
client.moveInterest({ kind: "city", cityId: "bay-area" }),
|
||||
/request failed/,
|
||||
);
|
||||
assert.equal(resumes, 1);
|
||||
assert.equal(oldStream?.signal?.aborted, true);
|
||||
assert.equal(client.state().status, "joined");
|
||||
assert.deepEqual(client.state().interests, [OFFICE]);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
it("closes the old-cell stream before a slow interest handoff settles", async () => {
|
||||
const requests: Array<{ url: string; signal: AbortSignal | null }> = [];
|
||||
let finishResume: ((response: Response) => void) | null = null;
|
||||
let resumeRequestId = "";
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
requests.push({ url, signal: init.signal ?? null });
|
||||
if (url.endsWith("/join") && body.type === "resume-request") {
|
||||
resumeRequestId = String(body.requestId);
|
||||
return new Promise<Response>((resolve) => { finishResume = resolve; });
|
||||
}
|
||||
if (url.endsWith("/join")) return jsonResponse(joinGrant(String(body.requestId)));
|
||||
if (url.endsWith("/events")) {
|
||||
return new Response(new ReadableStream({ start() { /* held until abort */ } }), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const client = createRealtimeClient({ actorId: "actor-1", fetch: fetcher });
|
||||
client.subscribe({});
|
||||
await client.join(OFFICE, actor());
|
||||
await settle();
|
||||
const oldStream = requests.find((request) => request.url.endsWith("/events"));
|
||||
const moved = { kind: "city" as const, cityId: "bay-area" as const };
|
||||
const handoff = client.moveInterest(moved);
|
||||
assert.equal(oldStream?.signal?.aborted, true, "privacy-cell stream closes before HTTP completes");
|
||||
assert.ok(finishResume);
|
||||
(finishResume as (response: Response) => void)(jsonResponse(resumeGrant(resumeRequestId)));
|
||||
await handoff;
|
||||
assert.deepEqual(client.state().interests, [moved]);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
it("lets only the newest concurrent join commit client state", async () => {
|
||||
const pending: Array<{
|
||||
body: Record<string, unknown>;
|
||||
resolve: (response: Response) => void;
|
||||
}> = [];
|
||||
const leaves: Record<string, unknown>[] = [];
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
if (String(input).endsWith("/leave")) {
|
||||
leaves.push(body);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Promise<Response>((resolve) => pending.push({ body, resolve }));
|
||||
};
|
||||
const client = createRealtimeClient({ actorId: "actor-1", fetch: fetcher });
|
||||
const first = client.join(OFFICE, actor());
|
||||
const latestInterest = { kind: "city" as const, cityId: "socal" as const };
|
||||
const latest = client.join(latestInterest, actor());
|
||||
const secondRequest = pending[1];
|
||||
assert.ok(secondRequest);
|
||||
secondRequest.resolve(jsonResponse({
|
||||
...joinGrant(String(secondRequest.body.requestId)),
|
||||
sessionId: "session-latest",
|
||||
interests: [latestInterest],
|
||||
}));
|
||||
await latest;
|
||||
const firstRequest = pending[0];
|
||||
assert.ok(firstRequest);
|
||||
firstRequest.resolve(jsonResponse({
|
||||
...joinGrant(String(firstRequest.body.requestId)),
|
||||
sessionId: "session-stale",
|
||||
}));
|
||||
await assert.rejects(first, /superseded/);
|
||||
await settle();
|
||||
assert.equal(client.state().sessionId, "session-latest");
|
||||
assert.deepEqual(client.state().interests, [latestInterest]);
|
||||
assert.deepEqual(leaves, [{ sessionId: "session-stale", token: "resume-secret" }]);
|
||||
await client.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
createScenePeers,
|
||||
scenePeerId,
|
||||
type ActorPoseSnapshot,
|
||||
type EntityPoseSnapshot,
|
||||
type InterestCell,
|
||||
type ScenePeersOptions,
|
||||
type VehiclePoseSnapshot,
|
||||
} from "../realtime/index.ts";
|
||||
|
||||
const VELOCITY = { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 };
|
||||
|
||||
function actor(
|
||||
id: string,
|
||||
kind: "humanoid" | "dog" | "crow",
|
||||
sequence: number,
|
||||
timestampMs: number,
|
||||
xM: number,
|
||||
cell: InterestCell = { kind: "floor", officeId: "hq", floorId: "one" },
|
||||
): ActorPoseSnapshot {
|
||||
if (cell.kind === "california-tile") throw new Error("local poses cannot use tile cells");
|
||||
return {
|
||||
entity: "actor",
|
||||
actorId: id,
|
||||
kind,
|
||||
sequence,
|
||||
timestampMs,
|
||||
pose: { space: "local", cell, xM, yM: 2, zM: 3, headingDeg: 0, pitchDeg: 0 },
|
||||
velocity: { ...VELOCITY, xMps: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
function vehicle(sequence: number, timestampMs: number, lat: number): VehiclePoseSnapshot {
|
||||
return {
|
||||
entity: "vehicle",
|
||||
vehicleId: "ev-1",
|
||||
kind: "model-x",
|
||||
driverActorId: null,
|
||||
sequence,
|
||||
timestampMs,
|
||||
pose: { space: "geographic", lat, lng: -120, altitudeM: 10, headingDeg: 90, pitchDeg: 2 },
|
||||
velocity: { ...VELOCITY, xMps: 20 },
|
||||
steering: 0.4,
|
||||
wheelRadians: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(over: Partial<ScenePeersOptions> = {}) {
|
||||
return createScenePeers({
|
||||
project: (lat, lng) => [lng * 2, -lat * 3],
|
||||
groundAt: () => 5,
|
||||
geographicSceneUnitsPerMetre: 0.1,
|
||||
localSceneUnitsPerMetre: 1,
|
||||
interpolation: { interpolationDelayMs: 0, maximumExtrapolationMs: 0 },
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function mesh(root: THREE.Object3D, name: string): THREE.Mesh {
|
||||
const found = root.getObjectByName(name);
|
||||
if (!(found instanceof THREE.Mesh)) throw new Error(`missing mesh ${name}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("remote scene peers", () => {
|
||||
it("maps local poses directly and keeps one identity-free stable root per peer", () => {
|
||||
const peers = fixture();
|
||||
const first = actor("private-member-id", "humanoid", 1, 1_000, 1);
|
||||
assert.equal(peers.upsert(first), true);
|
||||
assert.deepEqual(peers.ids(), ["actor:private-member-id"]);
|
||||
assert.equal(peers.count(), 1);
|
||||
const root = peers.root.children[0] as THREE.Group;
|
||||
assert.equal(root.name, "remote-peer");
|
||||
assert.equal(JSON.stringify(root.userData).includes("private-member-id"), false);
|
||||
assert.equal(root.getObjectByName("humanoid.face") instanceof THREE.Mesh, true);
|
||||
assert.equal((mesh(root, "humanoid.face").material as THREE.MeshBasicMaterial).map, null);
|
||||
peers.tick(1_000);
|
||||
assert.deepEqual(root.position.toArray(), [1, 2, 3]);
|
||||
|
||||
assert.equal(peers.upsert(actor("private-member-id", "humanoid", 2, 2_000, 5)), true);
|
||||
peers.tick(1_500);
|
||||
assert.equal(peers.root.children[0], root);
|
||||
assert.equal(root.position.x, 3);
|
||||
assert.equal(peers.upsert(actor("private-member-id", "humanoid", 2, 2_100, 8)), false);
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("resets interpolation at local interest-cell boundaries without replacing the root", () => {
|
||||
const peers = fixture();
|
||||
peers.upsert(actor("a", "dog", 1, 1_000, 0));
|
||||
peers.upsert(actor("a", "dog", 2, 2_000, 10));
|
||||
peers.tick(1_500);
|
||||
const root = peers.root.children[0] as THREE.Group;
|
||||
assert.equal(root.position.x, 5);
|
||||
const nextCell = { kind: "floor", officeId: "hq", floorId: "two" } as const;
|
||||
assert.equal(peers.upsert(actor("a", "dog", 3, 3_000, 100, nextCell)), true);
|
||||
peers.tick(2_500);
|
||||
assert.equal(peers.root.children[0], root);
|
||||
assert.equal(root.position.x, 100, "new coordinate frame is held, never mixed with old floor");
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("projects geographic vehicles onto caller terrain and renders a generic black EV", () => {
|
||||
const peers = fixture();
|
||||
const snapshot = vehicle(1, 1_000, 34);
|
||||
assert.equal(scenePeerId(snapshot), "vehicle:ev-1");
|
||||
peers.upsert(snapshot);
|
||||
assert.equal(peers.tick(1_000), 1);
|
||||
const root = peers.root.children[0] as THREE.Group;
|
||||
assert.deepEqual(root.position.toArray(), [-240, 6, -102]);
|
||||
assert.equal(root.scale.x, 0.1);
|
||||
assert.ok(Math.abs(root.rotation.y + Math.PI / 2) < 1e-12);
|
||||
assert.equal(root.getObjectByName("generic-black-ev")?.userData.vehicleModel, "generic-black-ev");
|
||||
assert.equal(root.getObjectByName("frontLeft.spin")?.rotation.x, -snapshot.wheelRadians);
|
||||
const paint = root.getObjectByName("model-x.body:model-x.paint") as THREE.Mesh;
|
||||
assert.equal((paint.material as THREE.MeshStandardMaterial).color.getHex(), 0x050607);
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("shares prototype resources while kind changes retain the entity root", () => {
|
||||
const peers = fixture();
|
||||
peers.upsert(actor("one", "humanoid", 1, 1_000, 0));
|
||||
peers.upsert(actor("two", "humanoid", 1, 1_000, 2));
|
||||
const one = peers.root.children[0] as THREE.Group;
|
||||
const two = peers.root.children[1] as THREE.Group;
|
||||
assert.equal(mesh(one, "humanoid.chest").geometry, mesh(two, "humanoid.chest").geometry);
|
||||
assert.equal(mesh(one, "humanoid.chest").material, mesh(two, "humanoid.chest").material);
|
||||
const oldRig = one.children[0];
|
||||
peers.upsert(actor("one", "crow", 2, 2_000, 1));
|
||||
peers.tick(2_000);
|
||||
assert.equal(peers.root.children[0], one);
|
||||
assert.equal(oldRig?.parent, null);
|
||||
assert.ok(one.getObjectByName("crow"));
|
||||
peers.dispose();
|
||||
});
|
||||
|
||||
it("bounds nearby peers and removes/clears without prematurely disposing shared assets", () => {
|
||||
const peers = fixture({ maximumPeers: 2 });
|
||||
peers.upsert(actor("one", "humanoid", 1, 1_000, 0));
|
||||
peers.upsert(actor("two", "dog", 1, 1_000, 1));
|
||||
assert.equal(peers.upsert(actor("three", "crow", 1, 1_000, 2)), false);
|
||||
const firstPeer = peers.root.children[0];
|
||||
if (!firstPeer) throw new Error("missing first peer");
|
||||
const geometry = mesh(firstPeer, "humanoid.chest").geometry;
|
||||
let disposals = 0;
|
||||
geometry.addEventListener("dispose", () => disposals++);
|
||||
assert.equal(peers.remove("actor:one"), true);
|
||||
assert.equal(peers.remove("actor:one"), false);
|
||||
assert.equal(disposals, 0, "removing one clone cannot release shared prototype resources");
|
||||
peers.clear();
|
||||
assert.equal(peers.count(), 0);
|
||||
assert.deepEqual(peers.ids(), []);
|
||||
assert.equal(disposals, 0);
|
||||
peers.dispose();
|
||||
peers.dispose();
|
||||
assert.equal(disposals, 1);
|
||||
assert.equal(peers.upsert(actor("late", "humanoid", 1, 1_000, 0)), false);
|
||||
});
|
||||
|
||||
it("contains invalid snapshots and projection failures", () => {
|
||||
const peers = fixture({ project: () => [Number.NaN, 0] });
|
||||
assert.equal(peers.upsert({} as EntityPoseSnapshot), false);
|
||||
peers.upsert(vehicle(1, 1_000, 34));
|
||||
assert.equal(peers.tick(1_000), 0);
|
||||
assert.equal(peers.root.children[0]?.visible, false);
|
||||
assert.equal(peers.tick(Number.NaN), 0);
|
||||
peers.dispose();
|
||||
assert.throws(() => fixture({ maximumPeers: 0 }), RangeError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user