feat: add authenticated realtime presence
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
import {
|
||||
isEntityPoseSnapshot,
|
||||
isInterestCell,
|
||||
validateClientRealtimeMessage,
|
||||
validateServerRealtimeMessage,
|
||||
} from "./protocol.ts";
|
||||
import type {
|
||||
EntityPoseSnapshot,
|
||||
InterestCell,
|
||||
JoinGrant,
|
||||
JoinRequest,
|
||||
ResumeGrant,
|
||||
ResumeRequest,
|
||||
ServerRealtimeMessage,
|
||||
} from "./types.ts";
|
||||
|
||||
const MIN_SEND_INTERVAL_MS = 1_000 / 15;
|
||||
const MAX_SEND_INTERVAL_MS = 1_000 / 10;
|
||||
const UINT32_MAX = 4_294_967_295;
|
||||
|
||||
type Fetch = typeof globalThis.fetch;
|
||||
type TimerHandle = ReturnType<typeof globalThis.setTimeout>;
|
||||
|
||||
export interface RealtimeClientTimers {
|
||||
setTimeout(callback: () => void, delayMs: number): TimerHandle;
|
||||
clearTimeout(handle: TimerHandle): void;
|
||||
}
|
||||
|
||||
export interface RealtimeClientEndpoints {
|
||||
join: string;
|
||||
pose: string;
|
||||
events: string;
|
||||
leave: string;
|
||||
}
|
||||
|
||||
export type RealtimeClientStatus =
|
||||
| "idle"
|
||||
| "joined"
|
||||
| "streaming"
|
||||
| "reconnecting"
|
||||
| "left"
|
||||
| "disposed";
|
||||
|
||||
export interface RealtimeClientState {
|
||||
status: RealtimeClientStatus;
|
||||
sessionId: string | null;
|
||||
actorId: string;
|
||||
interests: readonly InterestCell[];
|
||||
serverEpoch: string | null;
|
||||
lastReceivedSequence: number | null;
|
||||
nextPoseSequence: number;
|
||||
posePending: boolean;
|
||||
subscribed: boolean;
|
||||
reconnectAttempt: number;
|
||||
}
|
||||
|
||||
export interface RealtimeSubscriptionCallbacks {
|
||||
onMessage?(message: ServerRealtimeMessage): void;
|
||||
onStateChange?(state: RealtimeClientState): void;
|
||||
/** Receives protocol/parser errors without the untrusted payload or credentials. */
|
||||
onMalformedEvent?(error: Error): void;
|
||||
onError?(error: Error): void;
|
||||
}
|
||||
|
||||
export interface RealtimeClientOptions {
|
||||
actorId: string;
|
||||
/**
|
||||
* Optional SSO bearer, kept only in this closure and never placed in a URL.
|
||||
* Omit it when the deployment uses an HttpOnly same-origin session cookie.
|
||||
*/
|
||||
accessToken?: string;
|
||||
/**
|
||||
* Preferred request adapter for hosted builds. Passing `authFetch` keeps SSO
|
||||
* token lookup at request time and also supports HttpOnly cookie sessions.
|
||||
*/
|
||||
authenticatedFetch?: Fetch;
|
||||
endpoints?: Partial<RealtimeClientEndpoints>;
|
||||
fetch?: Fetch;
|
||||
timers?: RealtimeClientTimers;
|
||||
sendIntervalMs?: number;
|
||||
reconnectBaseMs?: number;
|
||||
reconnectMaximumMs?: number;
|
||||
reconnectJitter?: number;
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export interface RealtimeClient {
|
||||
join(
|
||||
interest: InterestCell | readonly InterestCell[],
|
||||
entity: EntityPoseSnapshot,
|
||||
): Promise<JoinGrant>;
|
||||
publishPose(entity: EntityPoseSnapshot): void;
|
||||
subscribe(callbacks: RealtimeSubscriptionCallbacks): () => void;
|
||||
moveInterest(interest: InterestCell | readonly InterestCell[]): Promise<ResumeGrant>;
|
||||
rejoin(): Promise<ResumeGrant>;
|
||||
state(): RealtimeClientState;
|
||||
leave(): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_ENDPOINTS: RealtimeClientEndpoints = {
|
||||
join: "/api/v1/realtime/join",
|
||||
pose: "/api/v1/realtime/pose",
|
||||
events: "/api/v1/realtime/events",
|
||||
leave: "/api/v1/realtime/leave",
|
||||
};
|
||||
|
||||
const DEFAULT_TIMERS: RealtimeClientTimers = {
|
||||
setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
|
||||
clearTimeout: (handle) => globalThis.clearTimeout(handle),
|
||||
};
|
||||
|
||||
function requireNonEmpty(value: string, name: string): void {
|
||||
if (value.length === 0 || value.length > 256) throw new TypeError(`${name} must be 1-256 characters`);
|
||||
}
|
||||
|
||||
function normalizeInterests(value: InterestCell | readonly InterestCell[]): readonly InterestCell[] {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (values.length === 0 || values.length > 128 || !values.every(isInterestCell)) {
|
||||
throw new TypeError("realtime interests are invalid");
|
||||
}
|
||||
return values.map((interest) => ({ ...interest }));
|
||||
}
|
||||
|
||||
function errorFrom(reason: unknown, fallback: string): Error {
|
||||
return reason instanceof Error ? reason : new Error(fallback);
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: RealtimeClientStatus): boolean {
|
||||
return status === "disposed" || status === "left";
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser transport for the server-authoritative realtime protocol.
|
||||
*
|
||||
* Streaming uses fetch + ReadableStream instead of EventSource. This permits a
|
||||
* normal Authorization header and POST body, keeping both the access token and
|
||||
* resume token out of URLs, referrers, history, and intermediary URL logs.
|
||||
*/
|
||||
export function createRealtimeClient(options: RealtimeClientOptions): RealtimeClient {
|
||||
requireNonEmpty(options.actorId, "actorId");
|
||||
if (options.accessToken !== undefined) requireNonEmpty(options.accessToken, "accessToken");
|
||||
if (options.fetch && options.authenticatedFetch) {
|
||||
throw new TypeError("provide either fetch or authenticatedFetch, not both");
|
||||
}
|
||||
const fetcher = options.authenticatedFetch ?? options.fetch ?? globalThis.fetch;
|
||||
if (typeof fetcher !== "function") throw new TypeError("fetch is required");
|
||||
const timers = options.timers ?? DEFAULT_TIMERS;
|
||||
const endpoints = { ...DEFAULT_ENDPOINTS, ...options.endpoints };
|
||||
for (const [name, endpoint] of Object.entries(endpoints)) requireNonEmpty(endpoint, `${name} endpoint`);
|
||||
|
||||
const sendIntervalMs = options.sendIntervalMs ?? MAX_SEND_INTERVAL_MS;
|
||||
if (!Number.isFinite(sendIntervalMs) || sendIntervalMs < MIN_SEND_INTERVAL_MS || sendIntervalMs > MAX_SEND_INTERVAL_MS) {
|
||||
throw new RangeError("sendIntervalMs must be between 66.67ms (15Hz) and 100ms (10Hz)");
|
||||
}
|
||||
const reconnectBaseMs = options.reconnectBaseMs ?? 500;
|
||||
const reconnectMaximumMs = options.reconnectMaximumMs ?? 30_000;
|
||||
const reconnectJitter = options.reconnectJitter ?? 0.2;
|
||||
if (!Number.isFinite(reconnectBaseMs) || reconnectBaseMs <= 0) throw new RangeError("reconnectBaseMs must be positive");
|
||||
if (!Number.isFinite(reconnectMaximumMs) || reconnectMaximumMs < reconnectBaseMs) {
|
||||
throw new RangeError("reconnectMaximumMs must be at least reconnectBaseMs");
|
||||
}
|
||||
if (!Number.isFinite(reconnectJitter) || reconnectJitter < 0 || reconnectJitter > 1) {
|
||||
throw new RangeError("reconnectJitter must be between zero and one");
|
||||
}
|
||||
const random = options.random ?? Math.random;
|
||||
|
||||
let status: RealtimeClientStatus = "idle";
|
||||
let interests: readonly InterestCell[] = [];
|
||||
let sessionId: string | null = null;
|
||||
let serverEpoch: string | null = null;
|
||||
let resumeToken: string | null = null;
|
||||
let lastReceivedSequence: number | null = null;
|
||||
let nextPoseSequence = 0;
|
||||
let pendingPose: EntityPoseSnapshot | null = null;
|
||||
let poseTimer: TimerHandle | null = null;
|
||||
let reconnectTimer: TimerHandle | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let subscription: RealtimeSubscriptionCallbacks | null = null;
|
||||
let streamAbort: AbortController | null = null;
|
||||
// Resume rotates the credential. Do not race a pose against the first grant
|
||||
// and send the token the server has just retired.
|
||||
let streamReady = false;
|
||||
let streamGeneration = 0;
|
||||
// Scene changes can overtake join/resume HTTP calls. Only the newest
|
||||
// transition may commit credentials or interest cells.
|
||||
let transitionGeneration = 0;
|
||||
let requestCounter = 0;
|
||||
|
||||
function snapshot(): RealtimeClientState {
|
||||
return {
|
||||
status,
|
||||
sessionId,
|
||||
actorId: options.actorId,
|
||||
interests: interests.map((interest) => ({ ...interest })),
|
||||
serverEpoch,
|
||||
lastReceivedSequence,
|
||||
nextPoseSequence,
|
||||
posePending: pendingPose !== null,
|
||||
subscribed: subscription !== null,
|
||||
reconnectAttempt,
|
||||
};
|
||||
}
|
||||
|
||||
function changed(): void {
|
||||
subscription?.onStateChange?.(snapshot());
|
||||
}
|
||||
|
||||
function requestId(): string {
|
||||
requestCounter += 1;
|
||||
return `tera-client-${requestCounter}`;
|
||||
}
|
||||
|
||||
function authHeaders(accept = "application/json"): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: accept,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (options.accessToken !== undefined) headers.Authorization = `Bearer ${options.accessToken}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function post(endpoint: string, body: unknown, accept?: string, signal?: AbortSignal): Promise<Response> {
|
||||
return fetcher(endpoint, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
headers: authHeaders(accept),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function json(response: Response): Promise<unknown> {
|
||||
if (!response.ok) throw new Error(`realtime request failed (${response.status})`);
|
||||
return response.json() as Promise<unknown>;
|
||||
}
|
||||
|
||||
function cancelPose(): void {
|
||||
if (poseTimer !== null) timers.clearTimeout(poseTimer);
|
||||
poseTimer = null;
|
||||
pendingPose = null;
|
||||
}
|
||||
|
||||
function cancelStream(): void {
|
||||
streamGeneration += 1;
|
||||
streamAbort?.abort();
|
||||
streamAbort = null;
|
||||
streamReady = false;
|
||||
if (reconnectTimer !== null) timers.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
function acceptMessage(message: ServerRealtimeMessage): boolean {
|
||||
if (serverEpoch !== null && "serverEpoch" in message && message.serverEpoch !== serverEpoch) {
|
||||
subscription?.onMalformedEvent?.(new Error("realtime event belongs to a different server epoch"));
|
||||
return false;
|
||||
}
|
||||
if (message.type === "pose-delta" || message.type === "membership-revoked") {
|
||||
if (lastReceivedSequence !== null && message.sequence <= lastReceivedSequence) {
|
||||
subscription?.onMalformedEvent?.(new Error("realtime event sequence is not monotonic"));
|
||||
return false;
|
||||
}
|
||||
lastReceivedSequence = message.sequence;
|
||||
} else if (message.type === "resume-grant") {
|
||||
resumeToken = message.resumeToken;
|
||||
serverEpoch = message.serverEpoch;
|
||||
lastReceivedSequence = message.nextSequence === 0 ? 0 : message.nextSequence - 1;
|
||||
streamReady = true;
|
||||
status = "streaming";
|
||||
}
|
||||
reconnectAttempt = 0;
|
||||
subscription?.onMessage?.(message);
|
||||
if (message.type === "membership-revoked" && !message.reconnectAllowed) {
|
||||
cancelStream();
|
||||
status = "left";
|
||||
resumeToken = null;
|
||||
}
|
||||
changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseEvent(data: string): void {
|
||||
if (data.length === 0) return;
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(data);
|
||||
} catch {
|
||||
subscription?.onMalformedEvent?.(new Error("realtime event is not valid JSON"));
|
||||
return;
|
||||
}
|
||||
const result = validateServerRealtimeMessage(raw);
|
||||
if (!result.ok) {
|
||||
subscription?.onMalformedEvent?.(new Error(result.error));
|
||||
return;
|
||||
}
|
||||
acceptMessage(result.value);
|
||||
}
|
||||
|
||||
async function readSse(response: Response, generation: number): Promise<void> {
|
||||
if (!response.ok) throw new Error(`realtime stream failed (${response.status})`);
|
||||
if (!response.body) throw new Error("realtime stream response has no body");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let dataLines: string[] = [];
|
||||
const line = (value: string): void => {
|
||||
if (value === "") {
|
||||
if (dataLines.length > 0) parseEvent(dataLines.join("\n"));
|
||||
dataLines = [];
|
||||
} else if (value.startsWith("data:")) {
|
||||
dataLines.push(value.slice(5).replace(/^ /, ""));
|
||||
}
|
||||
};
|
||||
try {
|
||||
while (generation === streamGeneration) {
|
||||
const chunk = await reader.read();
|
||||
// An abort and an already-queued chunk may settle together. Never
|
||||
// parse the old cell after another transition has won that race.
|
||||
if (generation !== streamGeneration) break;
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
line(buffer.slice(0, newline));
|
||||
buffer = buffer.slice(newline + 1);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
if (generation !== streamGeneration) return;
|
||||
buffer += decoder.decode();
|
||||
if (buffer.length > 0) line(buffer);
|
||||
if (dataLines.length > 0) parseEvent(dataLines.join("\n"));
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function reconnectDelay(): number {
|
||||
const exponential = Math.min(reconnectMaximumMs, reconnectBaseMs * 2 ** reconnectAttempt);
|
||||
const factor = 1 + (random() * 2 - 1) * reconnectJitter;
|
||||
return Math.max(0, exponential * factor);
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (!subscription || status === "left" || status === "disposed" || reconnectTimer !== null) return;
|
||||
status = "reconnecting";
|
||||
const delay = reconnectDelay();
|
||||
reconnectAttempt += 1;
|
||||
changed();
|
||||
reconnectTimer = timers.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void startStream();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function startStream(): Promise<void> {
|
||||
if (!subscription || !sessionId || !resumeToken || !serverEpoch || lastReceivedSequence === null) return;
|
||||
cancelStream();
|
||||
const generation = streamGeneration;
|
||||
const abort = new AbortController();
|
||||
streamAbort = abort;
|
||||
const resume: ResumeRequest = {
|
||||
type: "resume-request",
|
||||
protocolVersion: 1,
|
||||
requestId: requestId(),
|
||||
sessionId,
|
||||
resumeToken,
|
||||
serverEpoch,
|
||||
lastReceivedSequence,
|
||||
interests,
|
||||
};
|
||||
if (!validateClientRealtimeMessage(resume).ok) throw new Error("realtime resume state is invalid");
|
||||
try {
|
||||
const response = await post(endpoints.events, resume, "text/event-stream", abort.signal);
|
||||
if (generation !== streamGeneration) return;
|
||||
await readSse(response, generation);
|
||||
if (generation === streamGeneration) scheduleReconnect();
|
||||
} catch (reason) {
|
||||
if (generation !== streamGeneration || abort.signal.aborted) return;
|
||||
subscription?.onError?.(errorFrom(reason, "realtime stream failed"));
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPose(): Promise<void> {
|
||||
poseTimer = null;
|
||||
const pose = pendingPose;
|
||||
pendingPose = null;
|
||||
if (!pose || !sessionId || status === "left" || status === "disposed") return;
|
||||
if (subscription && !streamReady) {
|
||||
pendingPose = pose;
|
||||
poseTimer = timers.setTimeout(() => void sendPose(), sendIntervalMs);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await post(endpoints.pose, { sessionId, token: resumeToken, snapshot: pose });
|
||||
if (!response.ok) throw new Error(`realtime pose request failed (${response.status})`);
|
||||
} catch (reason) {
|
||||
subscription?.onError?.(errorFrom(reason, "realtime pose request failed"));
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
async function join(interest: InterestCell | readonly InterestCell[], entity: EntityPoseSnapshot): Promise<JoinGrant> {
|
||||
if (status === "disposed") throw new Error("realtime client is disposed");
|
||||
const generation = ++transitionGeneration;
|
||||
const nextInterests = normalizeInterests(interest);
|
||||
if (!isEntityPoseSnapshot(entity)) throw new TypeError("realtime entity pose is invalid");
|
||||
const request: JoinRequest = {
|
||||
type: "join-request",
|
||||
protocolVersion: 1,
|
||||
requestId: requestId(),
|
||||
actorId: options.actorId,
|
||||
resumeToken,
|
||||
lastReceivedSequence,
|
||||
interests: nextInterests,
|
||||
};
|
||||
if (!validateClientRealtimeMessage(request).ok) throw new Error("realtime join request is invalid");
|
||||
const parsed = validateServerRealtimeMessage(await json(await post(endpoints.join, { ...request, entity })));
|
||||
if (!parsed.ok || parsed.value.type !== "join-grant") throw new Error("realtime join response is invalid");
|
||||
const grant = parsed.value;
|
||||
if (grant.actorId !== options.actorId || grant.requestId !== request.requestId) {
|
||||
throw new Error("realtime join response identity does not match request");
|
||||
}
|
||||
if (generation !== transitionGeneration || isTerminalStatus(status)) {
|
||||
// A superseded request can still have created a server session before
|
||||
// the browser ignored its answer. Retire it without adopting its token.
|
||||
void post(endpoints.leave, { sessionId: grant.sessionId, token: grant.resumeToken })
|
||||
.catch(() => { /* normal idle expiry is the fallback */ });
|
||||
throw new Error("realtime join was superseded");
|
||||
}
|
||||
interests = grant.interests.map((entry) => ({ ...entry }));
|
||||
sessionId = grant.sessionId;
|
||||
serverEpoch = grant.serverEpoch;
|
||||
resumeToken = grant.resumeToken;
|
||||
lastReceivedSequence = grant.nextSequence === 0 ? 0 : grant.nextSequence - 1;
|
||||
nextPoseSequence = Math.max(entity.sequence + 1, grant.nextSequence);
|
||||
status = "joined";
|
||||
streamReady = subscription === null;
|
||||
reconnectAttempt = 0;
|
||||
changed();
|
||||
if (subscription && lastReceivedSequence !== null) void startStream();
|
||||
return grant;
|
||||
}
|
||||
|
||||
function publishPose(entity: EntityPoseSnapshot): void {
|
||||
if (!sessionId || status === "idle" || status === "left" || status === "disposed") {
|
||||
throw new Error("realtime client must be joined before publishing a pose");
|
||||
}
|
||||
if (!isEntityPoseSnapshot(entity)) throw new TypeError("realtime entity pose is invalid");
|
||||
if (nextPoseSequence > UINT32_MAX) throw new RangeError("realtime pose sequence exhausted");
|
||||
const sequenced = { ...entity, sequence: nextPoseSequence } as EntityPoseSnapshot;
|
||||
if (!isEntityPoseSnapshot(sequenced)) throw new TypeError("realtime entity pose is invalid");
|
||||
nextPoseSequence += 1;
|
||||
pendingPose = sequenced;
|
||||
if (poseTimer === null) poseTimer = timers.setTimeout(() => void sendPose(), sendIntervalMs);
|
||||
changed();
|
||||
}
|
||||
|
||||
function subscribe(callbacks: RealtimeSubscriptionCallbacks): () => void {
|
||||
if (status === "disposed") throw new Error("realtime client is disposed");
|
||||
subscription = callbacks;
|
||||
changed();
|
||||
if (sessionId && lastReceivedSequence !== null) void startStream();
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active || subscription !== callbacks) return;
|
||||
active = false;
|
||||
subscription = null;
|
||||
cancelStream();
|
||||
if (status === "streaming" || status === "reconnecting") status = sessionId ? "joined" : "idle";
|
||||
};
|
||||
}
|
||||
|
||||
async function resume(nextInterests: readonly InterestCell[]): Promise<ResumeGrant> {
|
||||
if (status === "disposed") throw new Error("realtime client is disposed");
|
||||
if (!sessionId || !resumeToken || !serverEpoch || lastReceivedSequence === null) {
|
||||
throw new Error("realtime client has no resumable session");
|
||||
}
|
||||
const generation = ++transitionGeneration;
|
||||
const expectedSessionId = sessionId;
|
||||
const request: ResumeRequest = {
|
||||
type: "resume-request",
|
||||
protocolVersion: 1,
|
||||
requestId: requestId(),
|
||||
sessionId,
|
||||
resumeToken,
|
||||
serverEpoch,
|
||||
lastReceivedSequence,
|
||||
interests: nextInterests,
|
||||
};
|
||||
if (!validateClientRealtimeMessage(request).ok) throw new Error("realtime resume request is invalid");
|
||||
// The old stream belongs to a different privacy and coordinate cell. Close
|
||||
// it before the handoff request, not after its network round trip.
|
||||
cancelStream();
|
||||
status = "joined";
|
||||
changed();
|
||||
try {
|
||||
const parsed = validateServerRealtimeMessage(await json(await post(endpoints.join, request)));
|
||||
if (!parsed.ok || parsed.value.type !== "resume-grant") throw new Error("realtime resume response is invalid");
|
||||
const grant = parsed.value;
|
||||
if (grant.sessionId !== expectedSessionId || grant.requestId !== request.requestId) {
|
||||
throw new Error("realtime resume response identity does not match request");
|
||||
}
|
||||
if (generation !== transitionGeneration || isTerminalStatus(status)) {
|
||||
throw new Error("realtime resume was superseded");
|
||||
}
|
||||
interests = nextInterests.map((entry) => ({ ...entry }));
|
||||
resumeToken = grant.resumeToken;
|
||||
serverEpoch = grant.serverEpoch;
|
||||
lastReceivedSequence = grant.nextSequence === 0 ? 0 : grant.nextSequence - 1;
|
||||
streamReady = subscription === null;
|
||||
reconnectAttempt = 0;
|
||||
changed();
|
||||
if (subscription && lastReceivedSequence !== null) void startStream();
|
||||
return grant;
|
||||
} catch (error) {
|
||||
if (generation === transitionGeneration && !isTerminalStatus(status)) {
|
||||
status = "joined";
|
||||
changed();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejoin(): Promise<ResumeGrant> {
|
||||
return resume(interests);
|
||||
}
|
||||
|
||||
async function moveInterest(interest: InterestCell | readonly InterestCell[]): Promise<ResumeGrant> {
|
||||
return resume(normalizeInterests(interest));
|
||||
}
|
||||
|
||||
async function leave(): Promise<void> {
|
||||
if (status === "disposed") return;
|
||||
transitionGeneration += 1;
|
||||
cancelPose();
|
||||
cancelStream();
|
||||
const leavingSession = sessionId;
|
||||
const leavingToken = resumeToken;
|
||||
status = "left";
|
||||
sessionId = null;
|
||||
serverEpoch = null;
|
||||
resumeToken = null;
|
||||
lastReceivedSequence = null;
|
||||
reconnectAttempt = 0;
|
||||
changed();
|
||||
if (leavingSession && leavingToken) {
|
||||
const response = await post(endpoints.leave, { sessionId: leavingSession, token: leavingToken });
|
||||
if (!response.ok) throw new Error(`realtime leave request failed (${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
if (status === "disposed") return;
|
||||
try {
|
||||
await leave();
|
||||
} finally {
|
||||
subscription = null;
|
||||
status = "disposed";
|
||||
}
|
||||
}
|
||||
|
||||
return { join, publishPose, subscribe, moveInterest, rejoin, state: snapshot, leave, dispose };
|
||||
}
|
||||
@@ -8,4 +8,27 @@ export {
|
||||
validateServerRealtimeMessage,
|
||||
} from "./protocol.ts";
|
||||
export { PoseInterpolationBuffer } from "./interpolation.ts";
|
||||
export {
|
||||
createRealtimeClient,
|
||||
type RealtimeClient,
|
||||
type RealtimeClientEndpoints,
|
||||
type RealtimeClientOptions,
|
||||
type RealtimeClientState,
|
||||
type RealtimeClientStatus,
|
||||
type RealtimeClientTimers,
|
||||
type RealtimeSubscriptionCallbacks,
|
||||
} from "./client.ts";
|
||||
export {
|
||||
createScenePeers,
|
||||
scenePeerId,
|
||||
type ScenePeers,
|
||||
type ScenePeersOptions,
|
||||
} from "./scenePeers.ts";
|
||||
export {
|
||||
createPresenceIndicator,
|
||||
type PresenceConnectionState,
|
||||
type PresenceIndicator,
|
||||
type PresenceIndicatorOptions,
|
||||
type PresenceIndicatorState,
|
||||
} from "./presenceIndicator.ts";
|
||||
export type * from "./types.ts";
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Small, identity-free hosted-presence status for a caller-owned container. */
|
||||
|
||||
export type PresenceConnectionState = "off" | "connecting" | "live" | "reconnecting" | "offline";
|
||||
|
||||
export interface PresenceIndicatorState {
|
||||
signedIn: boolean;
|
||||
connection: PresenceConnectionState;
|
||||
/** Aggregate only. Individual peer identity never enters this adapter. */
|
||||
nearbyPeerCount: number;
|
||||
}
|
||||
|
||||
export interface PresenceIndicatorOptions {
|
||||
container: HTMLElement;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export interface PresenceIndicator {
|
||||
root: HTMLElement;
|
||||
update(state: PresenceIndicatorState): PresenceIndicatorState;
|
||||
state(): PresenceIndicatorState;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const CONNECTIONS = new Set<PresenceConnectionState>([
|
||||
"off", "connecting", "live", "reconnecting", "offline",
|
||||
]);
|
||||
|
||||
function copy(state: PresenceIndicatorState): PresenceIndicatorState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function normalize(input: PresenceIndicatorState): PresenceIndicatorState {
|
||||
if (!input || typeof input !== "object") throw new TypeError("presence indicator: invalid state");
|
||||
if (typeof input.signedIn !== "boolean" || !CONNECTIONS.has(input.connection)) {
|
||||
throw new TypeError("presence indicator: invalid connection state");
|
||||
}
|
||||
if (!Number.isSafeInteger(input.nearbyPeerCount) || input.nearbyPeerCount < 0 || input.nearbyPeerCount > 10_000) {
|
||||
throw new RangeError("presence indicator: nearbyPeerCount must be an integer from 0 to 10000");
|
||||
}
|
||||
if (!input.signedIn) return { signedIn: false, connection: "off", nearbyPeerCount: 0 };
|
||||
return {
|
||||
signedIn: true,
|
||||
connection: input.connection,
|
||||
nearbyPeerCount: input.connection === "live" ? input.nearbyPeerCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function label(state: PresenceIndicatorState): string {
|
||||
if (!state.signedIn || state.connection === "off") return "Hosted presence is off.";
|
||||
switch (state.connection) {
|
||||
case "connecting": return "Hosted presence is connecting.";
|
||||
case "reconnecting": return "Hosted presence is reconnecting.";
|
||||
case "offline": return "Hosted presence is offline.";
|
||||
case "live": {
|
||||
const nearby = state.nearbyPeerCount === 1 ? "1 nearby peer" : `${state.nearbyPeerCount} nearby peers`;
|
||||
return `Hosted presence is live · ${nearby}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPresenceIndicator(options: PresenceIndicatorOptions): PresenceIndicator {
|
||||
if (!options.container || typeof options.container.append !== "function") {
|
||||
throw new TypeError("presence indicator: container must be an HTMLElement");
|
||||
}
|
||||
if (options.onRetry !== undefined && typeof options.onRetry !== "function") {
|
||||
throw new TypeError("presence indicator: onRetry must be a function");
|
||||
}
|
||||
|
||||
const doc = options.container.ownerDocument;
|
||||
const root = doc.createElement("div");
|
||||
root.className = "tera-presence";
|
||||
root.setAttribute("data-presence", "off");
|
||||
|
||||
const statusLine = doc.createElement("span");
|
||||
statusLine.className = "tera-presence__status";
|
||||
statusLine.setAttribute("role", "status");
|
||||
statusLine.setAttribute("aria-live", "polite");
|
||||
statusLine.setAttribute("aria-atomic", "true");
|
||||
|
||||
const retry = doc.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.className = "tera-presence__retry";
|
||||
retry.textContent = "Retry presence";
|
||||
retry.hidden = true;
|
||||
root.append(statusLine, retry);
|
||||
options.container.append(root);
|
||||
|
||||
let current: PresenceIndicatorState = { signedIn: false, connection: "off", nearbyPeerCount: 0 };
|
||||
let disposed = false;
|
||||
let onRetry: (() => void) | null = options.onRetry ?? null;
|
||||
|
||||
function render(): void {
|
||||
statusLine.textContent = label(current);
|
||||
root.setAttribute("data-presence", current.connection);
|
||||
retry.hidden = onRetry === null || !current.signedIn ||
|
||||
(current.connection !== "offline" && current.connection !== "reconnecting");
|
||||
}
|
||||
|
||||
retry.addEventListener("click", () => {
|
||||
if (!disposed && !retry.hidden) onRetry?.();
|
||||
});
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
update(next) {
|
||||
if (disposed) return copy(current);
|
||||
current = normalize(next);
|
||||
render();
|
||||
return copy(current);
|
||||
},
|
||||
state: () => copy(current),
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
onRetry = null;
|
||||
current = { signedIn: false, connection: "off", nearbyPeerCount: 0 };
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Three.js presentation for already-validated remote entity snapshots.
|
||||
*
|
||||
* Networking, trust, auth, and display names stay outside this module. It keeps
|
||||
* only canonical protocol ids in a private Map, buffers authoritative motion,
|
||||
* and gives each peer one stable, identity-free scene root. Procedural clones
|
||||
* share four prototype resource sets for the lifetime of the adapter.
|
||||
*/
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
buildCrow,
|
||||
buildDog,
|
||||
buildHumanoid,
|
||||
cloneCrow,
|
||||
cloneDog,
|
||||
cloneHumanoid,
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrowFlight,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseHumanoid,
|
||||
type CrowRig,
|
||||
type DogRig,
|
||||
type HumanoidRig,
|
||||
} from "../assets/actors/index.ts";
|
||||
import {
|
||||
buildModelX,
|
||||
cloneModelX,
|
||||
disposeModelX,
|
||||
setModelXSteering,
|
||||
setModelXWheelRotation,
|
||||
type ModelXRig,
|
||||
} from "../assets/vehicles/index.ts";
|
||||
import { PoseInterpolationBuffer } from "./interpolation.ts";
|
||||
import { isEntityPoseSnapshot } from "./protocol.ts";
|
||||
import type {
|
||||
BufferedPoseSample,
|
||||
EntityPoseSnapshot,
|
||||
InterpolationOptions,
|
||||
SpatialPose,
|
||||
} from "./types.ts";
|
||||
|
||||
export interface ScenePeersOptions {
|
||||
/** Geographic map projection. Returned X/Z are already scene units. */
|
||||
project(lat: number, lng: number): readonly [number, number];
|
||||
/** Terrain height in scene units at a geographic position. */
|
||||
groundAt(lat: number, lng: number): number;
|
||||
/** Converts geographic altitude metres and, by default, asset metres to scene units. */
|
||||
geographicSceneUnitsPerMetre?: number;
|
||||
/** Optional presentation exaggeration independent of geographic motion scale. */
|
||||
geographicVisualScale?: number;
|
||||
/** Local office/city metre scale. Defaults to direct 1 metre = 1 scene unit. */
|
||||
localSceneUnitsPerMetre?: number;
|
||||
/** Optional presentation scale independent of local coordinate scale. */
|
||||
localVisualScale?: number;
|
||||
interpolation?: InterpolationOptions;
|
||||
/** Bounded nearby peer count. Defaults to 128, maximum 512. */
|
||||
maximumPeers?: number;
|
||||
}
|
||||
|
||||
export interface ScenePeers {
|
||||
root: THREE.Group;
|
||||
/** Add a new authoritative sample. Invalid/stale/over-capacity input returns false. */
|
||||
upsert(snapshot: EntityPoseSnapshot): boolean;
|
||||
/** Remove by canonical id from `scenePeerId`/`ids`. */
|
||||
remove(id: string): boolean;
|
||||
/** Apply interpolated state at authoritative server time; returns roots updated. */
|
||||
tick(serverTimeMs: number): number;
|
||||
ids(): readonly string[];
|
||||
count(): number;
|
||||
/** Interest reset: drops peers but retains shared prototype GPU resources. */
|
||||
clear(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type PeerVisual =
|
||||
| { kind: "humanoid"; rig: HumanoidRig }
|
||||
| { kind: "dog"; rig: DogRig }
|
||||
| { kind: "crow"; rig: CrowRig }
|
||||
| { kind: "model-x"; rig: ModelXRig };
|
||||
|
||||
interface Prototypes {
|
||||
humanoid: HumanoidRig;
|
||||
dog: DogRig;
|
||||
crow: CrowRig;
|
||||
vehicle: ModelXRig;
|
||||
}
|
||||
|
||||
interface Peer {
|
||||
readonly id: string;
|
||||
readonly root: THREE.Group;
|
||||
readonly buffer: PoseInterpolationBuffer;
|
||||
visual: PeerVisual;
|
||||
kind: EntityPoseSnapshot["kind"];
|
||||
spaceKey: string;
|
||||
latest: EntityPoseSnapshot;
|
||||
lastSequence: number;
|
||||
lastTimestampMs: number;
|
||||
}
|
||||
|
||||
export function scenePeerId(snapshot: EntityPoseSnapshot): string {
|
||||
return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`;
|
||||
}
|
||||
|
||||
function poseSpaceKey(pose: SpatialPose): string {
|
||||
return pose.space === "geographic" ? "geographic" : `local:${JSON.stringify(pose.cell)}`;
|
||||
}
|
||||
|
||||
function positive(value: number, name: string): number {
|
||||
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveMaximumPeers(value: number | undefined): number {
|
||||
const resolved = value ?? 128;
|
||||
if (!Number.isInteger(resolved) || resolved < 1 || resolved > 512) {
|
||||
throw new RangeError("maximumPeers must be an integer from 1 to 512");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function makePrototypes(): Prototypes {
|
||||
const vehicle = buildModelX({ detail: "corridor", paint: 0x050607 });
|
||||
vehicle.root.name = "generic-black-ev.prototype";
|
||||
vehicle.root.userData.vehicleModel = "generic-black-ev";
|
||||
return {
|
||||
humanoid: buildHumanoid(),
|
||||
dog: buildDog(),
|
||||
crow: buildCrow(),
|
||||
vehicle,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneVisual(snapshot: EntityPoseSnapshot, prototypes: Prototypes): PeerVisual {
|
||||
if (snapshot.entity === "vehicle") {
|
||||
const rig = cloneModelX(prototypes.vehicle);
|
||||
rig.root.name = "generic-black-ev";
|
||||
rig.root.userData.vehicleModel = "generic-black-ev";
|
||||
return { kind: "model-x", rig };
|
||||
}
|
||||
if (snapshot.kind === "dog") return { kind: "dog", rig: cloneDog(prototypes.dog) };
|
||||
if (snapshot.kind === "crow") return { kind: "crow", rig: cloneCrow(prototypes.crow) };
|
||||
return { kind: "humanoid", rig: cloneHumanoid(prototypes.humanoid) };
|
||||
}
|
||||
|
||||
function visualRoot(visual: PeerVisual): THREE.Group {
|
||||
return visual.rig.root;
|
||||
}
|
||||
|
||||
function replaceVisual(peer: Peer, snapshot: EntityPoseSnapshot, prototypes: Prototypes): void {
|
||||
visualRoot(peer.visual).removeFromParent();
|
||||
peer.visual = cloneVisual(snapshot, prototypes);
|
||||
peer.root.add(visualRoot(peer.visual));
|
||||
peer.kind = snapshot.kind;
|
||||
}
|
||||
|
||||
function animateActor(
|
||||
visual: Exclude<PeerVisual, { kind: "model-x" }>,
|
||||
sample: BufferedPoseSample,
|
||||
): void {
|
||||
const speed = Math.hypot(sample.velocity.xMps, sample.velocity.zMps);
|
||||
const seconds = sample.timestampMs / 1_000;
|
||||
if (visual.kind === "humanoid") {
|
||||
poseHumanoid(visual.rig, {
|
||||
walkPhase: seconds * Math.max(1.2, speed * 5.8),
|
||||
stride: THREE.MathUtils.clamp(speed / 3.2, 0, 0.68),
|
||||
});
|
||||
} else if (visual.kind === "dog") {
|
||||
poseDogWalk(
|
||||
visual.rig,
|
||||
seconds * Math.max(1.4, speed * 7.2),
|
||||
THREE.MathUtils.clamp(speed / 4, 0, 0.68),
|
||||
);
|
||||
poseDogAttention(visual.rig, 0, seconds * 5);
|
||||
} else {
|
||||
poseCrowFlight(
|
||||
visual.rig,
|
||||
seconds * Math.max(3, speed * 1.5),
|
||||
THREE.MathUtils.clamp(0.35 + speed / 14, 0.35, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createScenePeers(options: ScenePeersOptions): ScenePeers {
|
||||
if (typeof options.project !== "function" || typeof options.groundAt !== "function") {
|
||||
throw new RangeError("scene peers require project and groundAt functions");
|
||||
}
|
||||
const geographicScale = positive(
|
||||
options.geographicSceneUnitsPerMetre ?? 1,
|
||||
"geographicSceneUnitsPerMetre",
|
||||
);
|
||||
const geographicVisualScale = positive(
|
||||
options.geographicVisualScale ?? geographicScale,
|
||||
"geographicVisualScale",
|
||||
);
|
||||
const localScale = positive(options.localSceneUnitsPerMetre ?? 1, "localSceneUnitsPerMetre");
|
||||
const localVisualScale = positive(options.localVisualScale ?? localScale, "localVisualScale");
|
||||
const limit = resolveMaximumPeers(options.maximumPeers);
|
||||
const interpolation = { ...(options.interpolation ?? {}) };
|
||||
const prototypes = makePrototypes();
|
||||
const peers = new Map<string, Peer>();
|
||||
const root = new THREE.Group();
|
||||
root.name = "realtime-scene-peers";
|
||||
root.userData.kind = "remote-presence";
|
||||
let disposed = false;
|
||||
|
||||
function add(snapshot: EntityPoseSnapshot): Peer {
|
||||
const visual = cloneVisual(snapshot, prototypes);
|
||||
const peerRoot = new THREE.Group();
|
||||
// IDs deliberately stay in the private Map, not names/userData inspected by render/debug tooling.
|
||||
peerRoot.name = "remote-peer";
|
||||
peerRoot.userData.entity = snapshot.entity;
|
||||
peerRoot.userData.kind = snapshot.kind;
|
||||
peerRoot.userData.forwardAxis = "-Z";
|
||||
peerRoot.add(visualRoot(visual));
|
||||
root.add(peerRoot);
|
||||
const peer: Peer = {
|
||||
id: scenePeerId(snapshot),
|
||||
root: peerRoot,
|
||||
buffer: new PoseInterpolationBuffer(interpolation),
|
||||
visual,
|
||||
kind: snapshot.kind,
|
||||
spaceKey: poseSpaceKey(snapshot.pose),
|
||||
latest: snapshot,
|
||||
lastSequence: -1,
|
||||
lastTimestampMs: -1,
|
||||
};
|
||||
peers.set(peer.id, peer);
|
||||
return peer;
|
||||
}
|
||||
|
||||
function apply(peer: Peer, sample: BufferedPoseSample): boolean {
|
||||
const pose = sample.pose;
|
||||
let visualScale: number;
|
||||
if (pose.space === "geographic") {
|
||||
const projected = options.project(pose.lat, pose.lng);
|
||||
const ground = options.groundAt(pose.lat, pose.lng);
|
||||
if (
|
||||
projected.length < 2 ||
|
||||
!Number.isFinite(projected[0]) ||
|
||||
!Number.isFinite(projected[1]) ||
|
||||
!Number.isFinite(ground)
|
||||
) {
|
||||
peer.root.visible = false;
|
||||
return false;
|
||||
}
|
||||
peer.root.position.set(
|
||||
projected[0],
|
||||
ground + pose.altitudeM * geographicScale,
|
||||
projected[1],
|
||||
);
|
||||
visualScale = geographicVisualScale;
|
||||
} else {
|
||||
peer.root.position.set(pose.xM * localScale, pose.yM * localScale, pose.zM * localScale);
|
||||
visualScale = localVisualScale;
|
||||
}
|
||||
peer.root.visible = true;
|
||||
peer.root.scale.setScalar(visualScale);
|
||||
peer.root.rotation.order = "YXZ";
|
||||
peer.root.rotation.set(
|
||||
THREE.MathUtils.degToRad(pose.pitchDeg),
|
||||
-THREE.MathUtils.degToRad(pose.headingDeg),
|
||||
0,
|
||||
);
|
||||
if (peer.visual.kind === "model-x") {
|
||||
const latest = peer.latest.entity === "vehicle" ? peer.latest : null;
|
||||
if (latest) {
|
||||
setModelXSteering(peer.visual.rig, latest.steering * 0.62);
|
||||
// Simulation publishes positive travelled radians; the -Z-forward
|
||||
// wheel geometry rolls forward with negative local-X rotation.
|
||||
setModelXWheelRotation(peer.visual.rig, -latest.wheelRadians);
|
||||
}
|
||||
} else {
|
||||
animateActor(peer.visual, sample);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
upsert(snapshot) {
|
||||
if (disposed || !isEntityPoseSnapshot(snapshot)) return false;
|
||||
const id = scenePeerId(snapshot);
|
||||
let peer = peers.get(id);
|
||||
if (!peer) {
|
||||
if (peers.size >= limit) return false;
|
||||
peer = add(snapshot);
|
||||
} else if (
|
||||
snapshot.sequence <= peer.lastSequence ||
|
||||
snapshot.timestampMs <= peer.lastTimestampMs
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (peer.kind !== snapshot.kind) replaceVisual(peer, snapshot, prototypes);
|
||||
const nextSpaceKey = poseSpaceKey(snapshot.pose);
|
||||
if (nextSpaceKey !== peer.spaceKey) {
|
||||
// Interest boundaries are not continuous coordinate frames. Resetting
|
||||
// prevents a floor/city switch from interpolating through empty space.
|
||||
peer.buffer.clear();
|
||||
peer.spaceKey = nextSpaceKey;
|
||||
}
|
||||
if (!peer.buffer.push(snapshot)) {
|
||||
// A failed first push must not consume bounded capacity.
|
||||
if (peer.lastSequence < 0) {
|
||||
peer.root.removeFromParent();
|
||||
peers.delete(id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
peer.latest = snapshot;
|
||||
peer.lastSequence = snapshot.sequence;
|
||||
peer.lastTimestampMs = snapshot.timestampMs;
|
||||
peer.root.userData.entity = snapshot.entity;
|
||||
peer.root.userData.kind = snapshot.kind;
|
||||
return true;
|
||||
},
|
||||
remove(id) {
|
||||
if (disposed) return false;
|
||||
const peer = peers.get(id);
|
||||
if (!peer) return false;
|
||||
peer.root.removeFromParent();
|
||||
peer.buffer.clear();
|
||||
peers.delete(id);
|
||||
return true;
|
||||
},
|
||||
tick(serverTimeMs) {
|
||||
if (disposed || !Number.isFinite(serverTimeMs)) return 0;
|
||||
let updated = 0;
|
||||
for (const peer of peers.values()) {
|
||||
const sample = peer.buffer.sample(serverTimeMs);
|
||||
if (sample && apply(peer, sample)) updated += 1;
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
ids: () => [...peers.keys()],
|
||||
count: () => peers.size,
|
||||
clear() {
|
||||
for (const peer of peers.values()) {
|
||||
peer.root.removeFromParent();
|
||||
peer.buffer.clear();
|
||||
}
|
||||
peers.clear();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
for (const peer of peers.values()) peer.root.removeFromParent();
|
||||
peers.clear();
|
||||
root.removeFromParent();
|
||||
disposeHumanoid(prototypes.humanoid);
|
||||
disposeDog(prototypes.dog);
|
||||
disposeCrow(prototypes.crow);
|
||||
disposeModelX(prototypes.vehicle);
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user