diff --git a/README.md b/README.md index a3afe00..06fd514 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,23 @@ # MiakAPI -MiakAPI is the typed Node.js SDK for running a trusted Miakapp coordinator. A -coordinator owns complete state, access, event, and function declarations for one -integration and exchanges canonical MessagePack frames with the Miakapp relay. +MiakAPI is the typed SDK for running a trusted Node.js coordinator and connecting +a first-party browser application to a Miakapp home. A coordinator owns complete +state, access, event, and function declarations for one integration. The isolated +browser entry point exposes the authenticated user role without bundling Node.js +or coordinator-only dependencies. Version 4 is a complete replacement for the legacy callback-based MiakAPI 3 client. It is currently an alpha while the Miakapp 3.5 relay is being deployed. -## Requirements +## Coordinator requirements - Node.js 22.9 or newer - A Miakapp Home Key or another approved short-lived access-token provider - A Miakapp relay implementing wire protocol 1.0 -MiakAPI is server-side software. Do not ship coordinator credentials, Home Keys, -or access-token providers to a browser or an untrusted plugin runtime. +The default `miakapi` entry point is server-side software. Do not ship +coordinator credentials, Home Keys, or coordinator access-token providers to a +browser or an untrusted plugin runtime. ## Installation @@ -147,6 +150,84 @@ const result = await call.result; MiakAPI never retries state mutations, events, or calls. An idempotency key is passed to the callee but does not enable hidden retries. +## Trusted browser client + +Use the isolated `miakapi/browser` entry point in the first-party Miakapp web +application. It relies on the browser's native WebSocket implementation and does +not expose coordinator declarations, Home Keys, or the Node.js `ws` transport. + +```ts +import { createBrowserClient } from 'miakapi/browser'; + +const client = createBrowserClient({ + homeId: 'my-home', + relayUrl: 'wss://relay.example.com/miakapp/ws', + idTokenProvider: { + async getIdToken({ signal }) { + if (signal.aborted) throw signal.reason; + const user = firebaseAuth.currentUser; + if (user === null) throw new Error('The user is signed out'); + return user.getIdToken(); + }, + }, +}); + +await client.start(); + +const removeStateListener = client.state.subscribe((snapshot) => { + if (!snapshot.stale) renderHome(snapshot.values); +}); + +const removeHomeListener = client.home.subscribe((home) => { + renderAvailability(home.enrolled, home.coordinators, home.stale); +}); + +const call = client.calls.start({ + function: 'lighting.scene.activate', + arguments: { scene: 'evening' }, + timeoutMs: 10_000, + idempotencyKey: 'intent-018f', +}); +await call.accepted; +const result = await call.result; + +removeStateListener(); +removeHomeListener(); +await client.stop(); +``` + +`idTokenProvider` is invoked for the initial connection, same-socket +reauthentication, and reconnects. Return a fresh Firebase ID token from trusted +in-memory application state. Never place the token in the relay URL, a WebSocket +subprotocol, persistent browser storage, logs, or error messages. MiakAPI sends +it only inside the authenticated binary protocol handshake or `REAUTH` frame. +Stop and discard the client immediately when the Firebase user signs out or the +selected home or relay changes; create a new client for the new identity tuple. + +The configured relay receives that Firebase ID token as a bearer credential and +can observe the home data flowing through it. Until the control plane issues a +short-lived credential scoped to one relay, home, and user role, use this client +only with an official relay or one the user explicitly trusts as completely as +the Miakapp backend. An arbitrary community relay catalogue is not a safe +production use of this authentication profile. The first browser integration +fixture uses synthetic credentials only; Miakapp application wiring remains +blocked on this trust decision. + +Browser state snapshots are defensive copies and become `stale` immediately +when continuity is lost. Revision or dictionary mismatches trigger one +fail-closed resynchronization request. Browser calls target the home's default +coordinator by function name, have no progress stream, and are never replayed by +the SDK. Incoming calls are rejected with an application error because this +first user profile deliberately exposes no browser call handlers. An +`outcome_unknown` failure means an effect may already have happened. + +Token acquisition, protocol welcome, bootstrap, and reauthentication each have +bounded deadlines. The browser transport also caps individual frames, its +outbound queue, and rolling inbound bytes and frame counts. A native WebSocket +still materializes a complete message before JavaScript can reject it, so these +limits are defense in depth rather than isolation from a malicious relay; a +Worker boundary remains an option for a later hardened browser profile. + ## Failure outcomes Every `CoordinatorFailure` includes an `outcome`: @@ -203,8 +284,9 @@ bun install --frozen-lockfile bun run check ``` -The check includes strict type checking, unit and adversarial tests, a Node.js -package smoke test, canonical external conformance, and an npm package dry run. +The check includes strict type checking, unit and adversarial tests, Node.js and +browser-bundle smoke tests, canonical external conformance, and an npm package +dry run. ## License diff --git a/bun.lock b/bun.lock index afc6bb2..a7f801d 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@types/bun": "1.2.23", "@types/node": "22.20.1", "@types/ws": "8.18.1", + "playwright": "1.62.1", "typescript": "7.0.2", }, }, @@ -70,6 +71,12 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/package.json b/package.json index 73a4da6..c7a6d10 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "miakapi", "version": "4.0.0-alpha.0", - "description": "Typed coordinator SDK for Miakapp", + "description": "Typed coordinator and trusted-browser SDK for Miakapp", "type": "module", "packageManager": "bun@1.2.23", "engines": { @@ -11,6 +11,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js" } }, "files": [ @@ -25,11 +29,12 @@ "build:contract": "tsc -p tsconfig.contract.json", "pack:check": "npm pack --dry-run", "prepublishOnly": "bun run check", + "smoke:browser": "node scripts/check-browser-bundle.mjs", "smoke:node": "node test/node-smoke.mjs", "test": "bun test", "test:contract": "bun run build:contract && node scripts/check-contract.mjs", "typecheck": "tsc --noEmit", - "check": "bun run typecheck && bun run test && bun run build && bun run smoke:node && bun run test:contract && bun run pack:check" + "check": "bun run typecheck && bun run test && bun run build && bun run smoke:node && bun run smoke:browser && bun run test:contract && bun run pack:check" }, "repository": { "type": "git", @@ -44,6 +49,7 @@ "MiakAPI", "smart-home", "coordinator", + "browser", "websocket" ], "author": "Mathieu Colmon", @@ -52,6 +58,7 @@ "@types/bun": "1.2.23", "@types/node": "22.20.1", "@types/ws": "8.18.1", + "playwright": "1.62.1", "typescript": "7.0.2" }, "dependencies": { diff --git a/scripts/check-browser-bundle.mjs b/scripts/check-browser-bundle.mjs new file mode 100644 index 0000000..a0c9721 --- /dev/null +++ b/scripts/check-browser-bundle.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const directory = await mkdtemp(path.join(tmpdir(), 'miakapi-browser-smoke-')); +const output = path.join(directory, 'browser.js'); + +try { + const build = spawnSync( + 'bun', + ['build', 'src/browser.ts', '--target=browser', '--outfile', output], + { cwd: process.cwd(), encoding: 'utf8' }, + ); + if (build.status !== 0) { + throw new Error(`Browser bundle failed:\n${build.stderr || build.stdout}`); + } + const source = await readFile(output, 'utf8'); + const forbidden = [ + /from\s+["']node:/u, + /require\(["']node:/u, + /from\s+["']ws["']/u, + /require\(["']ws["']\)/u, + ]; + if (forbidden.some((pattern) => pattern.test(source))) { + throw new Error('Browser bundle contains a Node-only import'); + } + process.stdout.write(`${JSON.stringify({ + schema: 'miakapi.browser-bundle-smoke/1', + bytes: Buffer.byteLength(source), + node_imports: false, + })}\n`); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/src/browser-api.ts b/src/browser-api.ts new file mode 100644 index 0000000..4406598 --- /dev/null +++ b/src/browser-api.ts @@ -0,0 +1,147 @@ +import type { + DispatchOutcome, + ProtocolValue, + StartOptions, + StopOptions, + Unsubscribe, +} from './api.js'; + +export type BrowserClientStatus = + | 'idle' + | 'connecting' + | 'authenticating' + | 'synchronizing' + | 'ready' + | 'reconnecting' + | 'draining' + | 'stopping' + | 'stopped'; + +export type FirebaseIdTokenReason = 'initial' | 'reauth' | 'reconnect'; + +export interface FirebaseIdTokenRequest { + readonly homeId: string; + readonly reason: FirebaseIdTokenReason; + readonly signal: AbortSignal; +} + +export interface FirebaseIdTokenProvider { + getIdToken(request: FirebaseIdTokenRequest): Promise; +} + +export interface BrowserClientLogRecord { + readonly level: 'debug' | 'info' | 'warn' | 'error'; + readonly event: string; + readonly status?: BrowserClientStatus; + readonly code?: number; +} + +export interface BrowserClientLogger { + write(record: BrowserClientLogRecord): void; +} + +export interface BrowserClientOptions { + readonly homeId: string; + readonly relayUrl: string; + readonly idTokenProvider: FirebaseIdTokenProvider; + readonly logger?: BrowserClientLogger; +} + +export interface BrowserCoordinatorStatus { + readonly name: string; + readonly generation: number; + readonly status: 'connected' | 'grace'; +} + +export interface BrowserReadySession { + readonly sessionId: number; + readonly connectedAtMs: number; + readonly enrolled: boolean; + readonly coordinators: readonly BrowserCoordinatorStatus[]; +} + +export interface BrowserHomeStatus { + readonly enrolled: boolean; + readonly coordinators: readonly BrowserCoordinatorStatus[]; + readonly stale: boolean; +} + +export interface BrowserHome { + snapshot(): BrowserHomeStatus | undefined; + subscribe(listener: (status: BrowserHomeStatus) => void): Unsubscribe; +} + +export interface BrowserClientFailure extends Error { + readonly kind: + | 'protocol' + | 'authentication' + | 'authorization' + | 'conflict' + | 'invalid_lifecycle' + | 'unavailable' + | 'cancelled' + | 'internal'; + readonly code?: number; + readonly retryable: boolean; + readonly outcome: DispatchOutcome; + readonly correlation?: { + readonly kind: 'call'; + readonly localId: string; + }; +} + +export interface BrowserLifecycleEvent { + readonly previous: BrowserClientStatus; + readonly current: BrowserClientStatus; + readonly session?: BrowserReadySession; + readonly reason?: BrowserClientFailure; +} + +export interface BrowserStateSnapshot { + readonly epoch: Uint8Array; + readonly revision: number; + readonly values: Readonly>; + readonly stale: boolean; +} + +export interface BrowserState { + snapshot(): BrowserStateSnapshot | undefined; + subscribe(listener: (snapshot: BrowserStateSnapshot) => void): Unsubscribe; +} + +export interface BrowserCallOptions { + readonly function: string; + readonly arguments: ProtocolValue; + readonly timeoutMs: number; + readonly idempotencyKey?: string; + readonly signal?: AbortSignal; +} + +export interface BrowserCallHandle { + readonly localId: string; + readonly accepted: Promise; + readonly result: Promise; + cancel(): void; +} + +export interface BrowserCalls { + start(options: BrowserCallOptions): BrowserCallHandle; +} + +export interface BrowserClientErrors { + subscribe(listener: (failure: BrowserClientFailure) => void): Unsubscribe; +} + +export interface BrowserClient { + readonly status: BrowserClientStatus; + readonly home: BrowserHome; + readonly state: BrowserState; + readonly calls: BrowserCalls; + readonly errors: BrowserClientErrors; + + start(options?: StartOptions): Promise; + stop(options?: StopOptions): Promise; + subscribe(listener: (event: BrowserLifecycleEvent) => void): Unsubscribe; +} + +export type BrowserClientFactory = (options: BrowserClientOptions) => BrowserClient; diff --git a/src/browser-client.ts b/src/browser-client.ts new file mode 100644 index 0000000..31e4187 --- /dev/null +++ b/src/browser-client.ts @@ -0,0 +1,682 @@ +import type { + StartOptions, + StopOptions, + Unsubscribe, +} from './api.js'; +import type { + BrowserClient, + BrowserClientErrors, + BrowserClientFailure, + BrowserClientOptions, + BrowserClientStatus, + BrowserHome, + BrowserHomeStatus, + BrowserLifecycleEvent, + BrowserReadySession, + FirebaseIdTokenReason, + FirebaseIdTokenRequest, +} from './browser-api.js'; +import { createBrowserRuntime } from './internal/browser-socket.js'; +import { + BrowserClientError, + browserCancelled, + browserInternalFailure, + browserInvalidLifecycle, + browserProtocolFailure, + browserRelayFailure, + browserUnavailable, + safeBrowserLog, +} from './internal/browser-errors.js'; +import { UserCallManager, type UserCallHost } from './internal/user-calls.js'; +import { + childAbortController, + createDeferred, + IdSequence, + ListenerSet, + type Deferred, +} from './internal/resources.js'; +import { delay, type BrowserRuntime, type RuntimeTimer } from './internal/runtime.js'; +import { parseUserHomeStatus, UserRelaySession } from './internal/user-session.js'; +import { UserStateManager, type UserStateHost } from './internal/user-state.js'; +import { + validateBrowserClientOptions, + validateFirebaseIdToken, + validateStartOptions, + validateStopOptions, +} from './internal/validation.js'; +import { Opcode, type Frame, type ProtocolValue } from './protocol/codec.js'; + +interface SessionEnd { + readonly failure?: BrowserClientFailure; + readonly retryAfterMs?: number; +} + +interface TokenRequest { + readonly controller: AbortController; + readonly dispose: Unsubscribe; + readonly promise: Promise; +} + +interface PendingReauthentication { + readonly requestId: number; + readonly deferred: Deferred; + readonly timer: RuntimeTimer; +} + +const FIRST_RECONNECT_CEILING_MS = 1_000; +const MAX_RECONNECT_CEILING_MS = 30_000; +const SESSION_PHASE_TIMEOUT_MS = 10_000; + +function relayInteger(frame: Frame, index: number, label: string, minimum = 0): number { + const value = frame.payload[index]; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) { + throw browserProtocolFailure(`${label} is not an integer`); + } + return value; +} + +function relayBoolean(frame: Frame, index: number, label: string): boolean { + const value = frame.payload[index]; + if (typeof value !== 'boolean') throw browserProtocolFailure(`${label} is not a boolean`); + return value; +} + +function sameEpoch(left: Uint8Array, right: ProtocolValue | undefined): boolean { + return right instanceof Uint8Array + && right.length === left.length + && right.every((value, index) => value === left[index]); +} + +class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { + readonly #options: BrowserClientOptions; + readonly #runtime: BrowserRuntime; + readonly #lifecycleListeners = new ListenerSet(); + readonly #errorListeners = new ListenerSet(); + readonly #homeListeners = new ListenerSet(); + readonly #loopController = new AbortController(); + readonly home: BrowserHome; + readonly state: UserStateManager; + readonly calls: UserCallManager; + readonly errors: BrowserClientErrors; + #status: BrowserClientStatus = 'idle'; + #started = false; + #session: UserRelaySession | undefined; + #sessionEnd: Deferred | undefined; + #sessionReady: Deferred | undefined; + #startDeferred: Deferred | undefined; + #stopDeferred: Deferred | undefined; + #stopTimer: RuntimeTimer | undefined; + #bootstrapTimer: RuntimeTimer | undefined; + #loopTask: Promise | undefined; + #startSignal: AbortSignal | undefined; + #startAbort: (() => void) | undefined; + #tokenRequest: TokenRequest | undefined; + #reauthentication: PendingReauthentication | undefined; + #reauthTimer: RuntimeTimer | undefined; + #requestIds = new IdSequence(); + #callIds = new IdSequence(); + readonly #localCallIds = new IdSequence(); + #reconnectAttempt = 0; + #goawayRetryAfterMs: number | undefined; + #stateReady = false; + #functionReady = false; + #topicReady = false; + #homeStatus: BrowserHomeStatus | undefined; + + constructor(options: BrowserClientOptions, runtime: BrowserRuntime) { + this.#options = options; + this.#runtime = runtime; + this.state = new UserStateManager(this); + this.calls = new UserCallManager(this); + this.home = Object.freeze({ + snapshot: () => this.#homeStatus, + subscribe: (listener: (status: BrowserHomeStatus) => void) => this.#subscribeHome(listener), + }); + this.errors = Object.freeze({ + subscribe: (listener: (failure: BrowserClientFailure) => void) => { + if (typeof listener !== 'function') throw new TypeError('error listener must be a function'); + return this.#errorListeners.subscribe(listener); + }, + }); + } + + get status(): BrowserClientStatus { + return this.#status; + } + + start(options: StartOptions = {}): Promise { + if (this.#started || this.#status !== 'idle') { + return Promise.reject(browserInvalidLifecycle('Browser client has already started or stopped')); + } + const signal = validateStartOptions(options); + if (signal?.aborted === true) return Promise.reject(browserCancelled('not_dispatched')); + this.#started = true; + this.#startDeferred = createDeferred(); + if (signal !== undefined) { + const abort = () => void this.stop(); + this.#startSignal = signal; + this.#startAbort = abort; + signal.addEventListener('abort', abort, { once: true }); + } + this.#loopTask = this.#runConnectionLoop(); + void this.#loopTask.catch(() => { + const failure = browserInternalFailure(); + this.#startDeferred?.reject(failure); + this.#emitFailure(failure); + void this.stop(); + }); + return this.#startDeferred.promise; + } + + stop(options: StopOptions = {}): Promise { + if (this.#stopDeferred !== undefined) return this.#stopDeferred.promise; + let deadlineMs: number; + try { + deadlineMs = validateStopOptions(options); + } catch (error) { + return Promise.reject(error); + } + this.#stopDeferred = createDeferred(); + const stopping = browserCancelled('not_dispatched', 'Browser client is stopping'); + this.#transition('stopping'); + this.calls.stop(); + this.state.stop(); + this.#markHomeStale(); + this.#clearReauthentication(); + this.#abortTokenRequest(); + this.#loopController.abort(stopping); + this.#sessionReady?.reject(stopping); + this.#sessionEnd?.resolve({ failure: stopping }); + this.#session?.terminate(); + this.#startDeferred?.reject(stopping); + const finish = () => this.#finishStop(); + if (this.#loopTask === undefined) finish(); + else { + this.#stopTimer = this.#runtime.setTimer(finish, deadlineMs); + void this.#loopTask.then(finish, finish); + } + return this.#stopDeferred.promise; + } + + subscribe(listener: (event: BrowserLifecycleEvent) => void): Unsubscribe { + if (typeof listener !== 'function') throw new TypeError('lifecycle listener must be a function'); + return this.#lifecycleListeners.subscribe(listener); + } + + nextRequestId(): number { + return this.#requestIds.take(); + } + + nextCallId(): number { + return this.#callIds.take(); + } + + nextLocalCallId(): number { + return this.#localCallIds.take(); + } + + runtime(): BrowserRuntime { + return this.#runtime; + } + + readySession(): UserRelaySession | undefined { + return this.#status === 'ready' ? this.#session : undefined; + } + + send(frame: Frame): Promise { + const session = this.#session; + if (session === undefined || (this.#status !== 'ready' && this.#status !== 'synchronizing')) { + return Promise.reject(browserUnavailable()); + } + return session.send(frame); + } + + stateSynchronized(): void { + this.#stateReady = true; + this.#maybeReady(); + } + + functionDictionarySynchronized(): void { + this.#functionReady = true; + this.#maybeReady(); + } + + transportFailure(error: Error): void { + const failure = error instanceof BrowserClientError + ? error + : browserUnavailable('Browser relay transport failed'); + this.#sessionReady?.reject(failure); + this.#sessionEnd?.resolve({ failure }); + this.#session?.terminate(); + } + + emitFailure(failure: BrowserClientError): void { + this.#emitFailure(failure); + } + + async #runConnectionLoop(): Promise { + let reason: FirebaseIdTokenReason = 'initial'; + while (!this.#loopController.signal.aborted) { + let end: SessionEnd = {}; + let connectionEnd: Deferred | undefined; + try { + this.#transition('connecting'); + if (this.#loopController.signal.aborted) break; + const token = await this.#getIdToken(reason); + if (this.#loopController.signal.aborted) break; + this.#transition('authenticating'); + if (this.#loopController.signal.aborted) break; + this.#requestIds = new IdSequence(); + this.#callIds = new IdSequence(); + connectionEnd = createDeferred(); + const sessionReady = createDeferred(); + void sessionReady.promise.catch(() => undefined); + this.#sessionEnd = connectionEnd; + this.#sessionReady = sessionReady; + this.#goawayRetryAfterMs = undefined; + const session = await UserRelaySession.connect( + this.#runtime, + this.#options.homeId, + this.#options.relayUrl, + token, + this.#loopController.signal, + { + frame: (frame) => this.#handleFrame(frame), + closed: () => { + const failure = browserUnavailable('Browser relay connection closed'); + sessionReady.reject(failure); + connectionEnd?.resolve(this.#goawayRetryAfterMs === undefined + ? { failure } + : { failure, retryAfterMs: this.#goawayRetryAfterMs }); + }, + failed: (error) => { + const failure = error instanceof BrowserClientError + ? error + : browserUnavailable('Browser relay connection failed'); + sessionReady.reject(failure); + connectionEnd?.resolve({ failure }); + }, + }, + ); + this.#reconnectAttempt = 0; + if (this.#loopController.signal.aborted) { + session.terminate(); + session.detach(); + break; + } + this.#session = session; + this.#setHomeStatus(Object.freeze({ + enrolled: session.welcome.readySession.enrolled, + coordinators: session.welcome.readySession.coordinators, + stale: false, + })); + if (this.#loopController.signal.aborted) break; + this.#stateReady = false; + this.#functionReady = false; + this.#topicReady = false; + this.state.beginSession(session.welcome.epoch); + this.calls.beginSession(session.welcome.epoch); + this.#transition('synchronizing'); + if (this.#loopController.signal.aborted) break; + this.#scheduleReauthentication(session.welcome.expiresAtMs); + const bootstrapTimeout = this.#runtime.setTimer(() => { + const failure = browserUnavailable('Browser relay bootstrap timed out'); + sessionReady.reject(failure); + connectionEnd?.resolve({ failure }); + session.terminate(); + }, SESSION_PHASE_TIMEOUT_MS); + this.#bootstrapTimer = bootstrapTimeout; + session.startDelivery(); + try { + await Promise.race([ + sessionReady.promise, + connectionEnd.promise.then((closed) => Promise.reject( + closed.failure ?? browserUnavailable('Browser relay closed during synchronization'), + )), + ]); + } finally { + bootstrapTimeout.cancel(); + if (this.#bootstrapTimer === bootstrapTimeout) this.#bootstrapTimer = undefined; + } + if (this.#loopController.signal.aborted) break; + end = await connectionEnd.promise; + } catch (error) { + if (this.#loopController.signal.aborted) break; + end = connectionEnd?.settled === true + ? await connectionEnd.promise + : { failure: error instanceof BrowserClientError + ? error + : browserUnavailable('Browser relay connection attempt failed') }; + } + this.#disconnectSession(); + if (this.#loopController.signal.aborted) break; + if (end.failure !== undefined) this.#emitFailure(end.failure); + if (this.#loopController.signal.aborted) break; + this.#transition('reconnecting', undefined, end.failure); + if (this.#loopController.signal.aborted) break; + const ceiling = Math.min( + FIRST_RECONNECT_CEILING_MS * (2 ** this.#reconnectAttempt), + MAX_RECONNECT_CEILING_MS, + ); + this.#reconnectAttempt += 1; + const randomDelay = Math.floor(this.#runtime.random() * (ceiling + 1)); + try { + await delay( + this.#runtime, + Math.max(randomDelay, end.retryAfterMs ?? 0), + this.#loopController.signal, + ); + } catch { + break; + } + reason = 'reconnect'; + } + } + + async #getIdToken( + reason: FirebaseIdTokenReason, + timeoutMs = SESSION_PHASE_TIMEOUT_MS, + ): Promise { + if (this.#tokenRequest !== undefined) return this.#tokenRequest.promise; + const child = childAbortController(this.#loopController.signal); + const request: FirebaseIdTokenRequest = Object.freeze({ + homeId: this.#options.homeId, + reason, + signal: child.controller.signal, + }); + let abort: (() => void) | undefined; + const interrupted = new Promise((_resolve, reject) => { + abort = () => reject(child.controller.signal.reason ?? browserCancelled('not_dispatched')); + if (child.controller.signal.aborted) abort(); + else child.controller.signal.addEventListener('abort', abort, { once: true }); + }); + const provider = Promise.resolve() + .then(() => { + if (child.controller.signal.aborted) { + throw child.controller.signal.reason ?? browserCancelled('not_dispatched'); + } + return this.#options.idTokenProvider.getIdToken(request); + }) + .then((value) => validateFirebaseIdToken(value)) + .catch(() => { throw browserUnavailable('Firebase ID token provider failed'); }); + const timeout = this.#runtime.setTimer(() => { + child.controller.abort(browserUnavailable('Firebase ID token request timed out')); + }, Math.max(1, Math.min(timeoutMs, SESSION_PHASE_TIMEOUT_MS))); + const promise = Promise.race([provider, interrupted]); + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + timeout.cancel(); + if (abort !== undefined) child.controller.signal.removeEventListener('abort', abort); + child.dispose(); + }; + const tokenRequest = { controller: child.controller, dispose, promise }; + this.#tokenRequest = tokenRequest; + void promise.finally(() => { + dispose(); + if (this.#tokenRequest === tokenRequest) this.#tokenRequest = undefined; + }).catch(() => undefined); + return promise; + } + + #abortTokenRequest(): void { + const request = this.#tokenRequest; + if (request === undefined) return; + request.controller.abort(browserCancelled('not_dispatched')); + request.dispose(); + this.#tokenRequest = undefined; + } + + #scheduleReauthentication(expiresAtMs: number): void { + this.#clearReauthentication(); + if (this.#status === 'draining' || this.#status === 'stopping' || this.#status === 'stopped') return; + const remaining = Math.max(0, expiresAtMs - this.#runtime.now()); + const lead = Math.min(30_000, Math.floor(remaining / 2)); + this.#reauthTimer = this.#runtime.setTimer( + () => void this.#reauthenticate(expiresAtMs), + remaining - lead, + ); + } + + async #reauthenticate(currentExpiresAtMs: number): Promise { + const session = this.#session; + if (session === undefined || this.#loopController.signal.aborted) return; + try { + const token = await this.#getIdToken( + 'reauth', + Math.max(1, currentExpiresAtMs - this.#runtime.now()), + ); + if (!this.#mayReauthenticate(session)) return; + const remaining = currentExpiresAtMs - this.#runtime.now(); + if (remaining <= 0) throw browserUnavailable('Browser authentication lease expired'); + const requestId = this.nextRequestId(); + const deferred = createDeferred(); + void deferred.promise.catch(() => undefined); + const timer = this.#runtime.setTimer(() => { + deferred.reject(browserUnavailable('Browser reauthentication timed out')); + }, Math.max(1, Math.min(remaining, SESSION_PHASE_TIMEOUT_MS))); + this.#reauthentication = { requestId, deferred, timer }; + await session.send({ opcode: Opcode.Reauth, payload: [requestId, token] }); + const expiresAtMs = await deferred.promise; + if (this.#mayReauthenticate(session)) this.#scheduleReauthentication(expiresAtMs); + } catch { + if (this.#mayReauthenticate(session)) { + this.transportFailure(browserUnavailable('Browser reauthentication failed')); + } + } + } + + #mayReauthenticate(session: UserRelaySession): boolean { + return this.#session === session + && !this.#loopController.signal.aborted + && this.#status !== 'draining'; + } + + #clearReauthentication(): void { + this.#reauthTimer?.cancel(); + this.#reauthTimer = undefined; + this.#reauthentication?.timer.cancel(); + this.#reauthentication?.deferred.reject(browserCancelled('not_dispatched')); + this.#reauthentication = undefined; + } + + #handleFrame(frame: Frame): void { + try { + if (frame.opcode >= 0x80) return; + if (frame.opcode === Opcode.Error) { + this.#handleRelayError(frame); + return; + } + if (frame.opcode === Opcode.Fatal) { + const code = relayInteger(frame, 1, 'FATAL.code', 1); + const retryable = relayBoolean(frame, 2, 'FATAL.retryable'); + const failure = browserRelayFailure(code, retryable, 'not_dispatched'); + if (retryable) this.transportFailure(failure); + else { + this.#emitFailure(failure); + this.#startDeferred?.reject(failure); + void this.stop(); + } + return; + } + if (frame.opcode === Opcode.ReauthOk) { + const requestId = relayInteger(frame, 0, 'REAUTH_OK.requestId', 1); + if (this.#reauthentication?.requestId !== requestId) { + throw browserProtocolFailure('REAUTH_OK is not correlated'); + } + const expiresAtMs = relayInteger(frame, 1, 'REAUTH_OK.expiresAtMs', 1); + if (expiresAtMs <= this.#runtime.now()) { + throw browserProtocolFailure('REAUTH_OK expiry is not in the future'); + } + this.#reauthentication.timer.cancel(); + this.#reauthentication.deferred.resolve(expiresAtMs); + this.#reauthentication = undefined; + return; + } + if (frame.opcode === Opcode.Goaway) { + this.#transition('draining'); + this.#reauthTimer?.cancel(); + this.#reauthTimer = undefined; + this.#abortTokenRequest(); + this.#goawayRetryAfterMs = relayInteger(frame, 0, 'GOAWAY.retryAfterMs'); + return; + } + if (frame.opcode === Opcode.HomeStatus) { + this.#setHomeStatus(parseUserHomeStatus(frame.payload[0], frame.payload[1], false)); + return; + } + if (frame.opcode === Opcode.TopicDict) { + if (this.#session === undefined || !sameEpoch(this.#session.welcome.epoch, frame.payload[0])) { + throw browserProtocolFailure('TOPIC_DICT uses a stale epoch'); + } + if (frame.payload[1] === true) { + this.#topicReady = true; + this.#maybeReady(); + } + return; + } + if (this.state.handleFrame(frame) || this.calls.handleFrame(frame)) return; + throw browserProtocolFailure('Relay sent an unsupported user frame'); + } catch (error) { + this.transportFailure(error instanceof Error ? error : browserProtocolFailure()); + } + } + + #handleRelayError(frame: Frame): void { + const correlationId = relayInteger(frame, 0, 'ERROR.correlationId'); + const sourceOpcode = relayInteger(frame, 1, 'ERROR.sourceOpcode'); + const code = relayInteger(frame, 2, 'ERROR.code', 1); + const retryable = relayBoolean(frame, 3, 'ERROR.retryable'); + if (sourceOpcode === Opcode.StateResync + && this.state.handleError(correlationId, code, retryable)) return; + if ((sourceOpcode === Opcode.Call || sourceOpcode === Opcode.CallCancel) + && this.calls.handleError(correlationId, code, retryable)) return; + if (sourceOpcode === Opcode.CallError + && this.calls.handleResponseError(correlationId, code, retryable)) return; + if (sourceOpcode === Opcode.Reauth && this.#reauthentication?.requestId === correlationId) { + this.#reauthentication.timer.cancel(); + this.#reauthentication.deferred.reject( + browserRelayFailure(code, retryable, 'not_dispatched'), + ); + this.#reauthentication = undefined; + return; + } + if (correlationId !== 0 || sourceOpcode !== 0) { + throw browserProtocolFailure('ERROR is not correlated to an active operation'); + } + this.#emitFailure(browserRelayFailure(code, retryable, 'not_dispatched')); + } + + #maybeReady(): void { + const session = this.#session; + if (this.#status !== 'synchronizing' + || !this.#stateReady + || !this.#functionReady + || !this.#topicReady + || session === undefined) return; + const ready = session.welcome.readySession; + this.#bootstrapTimer?.cancel(); + this.#bootstrapTimer = undefined; + this.#transition('ready', ready); + this.#sessionReady?.resolve(undefined); + this.#startDeferred?.resolve(ready); + } + + #disconnectSession(): void { + this.#bootstrapTimer?.cancel(); + this.#bootstrapTimer = undefined; + this.#clearReauthentication(); + this.#abortTokenRequest(); + this.state.disconnected(); + this.calls.disconnected(); + this.#markHomeStale(); + this.#session?.detach(); + this.#session = undefined; + this.#sessionEnd = undefined; + this.#sessionReady = undefined; + this.#goawayRetryAfterMs = undefined; + this.#stateReady = false; + this.#functionReady = false; + this.#topicReady = false; + } + + #emitFailure(failure: BrowserClientFailure): void { + this.#errorListeners.emit(failure, () => { + safeBrowserLog(this.#options.logger, { level: 'error', event: 'error_listener_failed' }); + }); + } + + #subscribeHome(listener: (status: BrowserHomeStatus) => void): Unsubscribe { + if (typeof listener !== 'function') throw new TypeError('home listener must be a function'); + const remove = this.#homeListeners.subscribe(listener); + if (this.#homeStatus !== undefined) { + try { + listener(this.#homeStatus); + } catch { + this.#emitFailure(browserInternalFailure()); + } + } + return remove; + } + + #setHomeStatus(status: BrowserHomeStatus): void { + this.#homeStatus = status; + this.#homeListeners.emit(status, () => this.#emitFailure(browserInternalFailure())); + } + + #markHomeStale(): void { + const status = this.#homeStatus; + if (status === undefined || status.stale) return; + this.#setHomeStatus(Object.freeze({ ...status, stale: true })); + } + + #transition( + current: BrowserClientStatus, + session?: BrowserReadySession, + reason?: BrowserClientFailure, + ): void { + if ((this.#status === 'stopping' || this.#status === 'stopped') && current !== 'stopped') return; + if (this.#status === current) return; + const previous = this.#status; + this.#status = current; + const event: BrowserLifecycleEvent = session === undefined && reason === undefined + ? Object.freeze({ previous, current }) + : session === undefined + ? Object.freeze({ previous, current, reason }) + : reason === undefined + ? Object.freeze({ previous, current, session }) + : Object.freeze({ previous, current, session, reason }); + safeBrowserLog(this.#options.logger, { level: 'info', event: 'status_changed', status: current }); + this.#lifecycleListeners.emit(event, () => { + safeBrowserLog(this.#options.logger, { level: 'error', event: 'lifecycle_listener_failed' }); + }); + } + + #finishStop(): void { + if (this.#status === 'stopped') return; + this.#stopTimer?.cancel(); + this.#stopTimer = undefined; + this.#disconnectSession(); + if (this.#startSignal !== undefined && this.#startAbort !== undefined) { + this.#startSignal.removeEventListener('abort', this.#startAbort); + } + this.#transition('stopped'); + this.#stopDeferred?.resolve(undefined); + this.#lifecycleListeners.clear(); + this.#errorListeners.clear(); + this.#homeListeners.clear(); + } +} + +export function createBrowserClient(options: BrowserClientOptions): BrowserClient { + return new BrowserClientImpl(validateBrowserClientOptions(options), createBrowserRuntime()); +} + +/** @internal */ +export function createBrowserClientWithRuntime( + options: BrowserClientOptions, + runtime: BrowserRuntime, +): BrowserClient { + return new BrowserClientImpl(validateBrowserClientOptions(options), runtime); +} diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..6f287bb --- /dev/null +++ b/src/browser.ts @@ -0,0 +1,3 @@ +export * from './browser-api.js'; + +export { createBrowserClient } from './browser-client.js'; diff --git a/src/internal/browser-errors.ts b/src/internal/browser-errors.ts new file mode 100644 index 0000000..747f43c --- /dev/null +++ b/src/internal/browser-errors.ts @@ -0,0 +1,106 @@ +import type { + BrowserClientFailure, + BrowserClientLogRecord, + BrowserClientLogger, +} from '../browser-api.js'; +import type { DispatchOutcome } from '../api.js'; + +interface FailureOptions { + readonly code?: number; + readonly retryable?: boolean; + readonly correlation?: { + readonly kind: 'call'; + readonly localId: string; + }; +} + +export class BrowserClientError extends Error implements BrowserClientFailure { + readonly kind: BrowserClientFailure['kind']; + readonly retryable: boolean; + readonly outcome: DispatchOutcome; + readonly code?: number; + readonly correlation?: { + readonly kind: 'call'; + readonly localId: string; + }; + + constructor( + kind: BrowserClientFailure['kind'], + outcome: DispatchOutcome, + message: string, + options: FailureOptions = {}, + ) { + super(message); + this.name = 'BrowserClientFailure'; + this.kind = kind; + this.outcome = outcome; + this.retryable = options.retryable ?? false; + if (options.code !== undefined) this.code = options.code; + if (options.correlation !== undefined) { + this.correlation = Object.freeze({ ...options.correlation }); + } + } +} + +export function browserInvalidLifecycle(message: string): BrowserClientError { + return new BrowserClientError('invalid_lifecycle', 'not_dispatched', message); +} + +export function browserUnavailable(message = 'Browser client is not ready'): BrowserClientError { + return new BrowserClientError('unavailable', 'not_dispatched', message, { retryable: true }); +} + +export function browserCancelled( + outcome: 'not_dispatched' | 'outcome_unknown', + message = 'Operation was cancelled', +): BrowserClientError { + return new BrowserClientError('cancelled', outcome, message); +} + +export function browserOutcomeUnknown( + message = 'Transport closed after operation handoff', +): BrowserClientError { + return new BrowserClientError('unavailable', 'outcome_unknown', message); +} + +export function browserInternalFailure(): BrowserClientError { + return new BrowserClientError('internal', 'not_dispatched', 'Browser client internal failure'); +} + +export function browserProtocolFailure(message = 'Relay protocol violation'): BrowserClientError { + return new BrowserClientError('protocol', 'not_dispatched', message); +} + +function kindFromCode(code: number): BrowserClientFailure['kind'] { + if (code >= 1100 && code <= 1102) return 'authentication'; + if (code >= 1200 && code <= 1203) return 'authorization'; + if (code >= 1300 && code <= 1305) return 'conflict'; + if (code === 1405) return 'cancelled'; + if (code >= 1000 && code <= 1005) return 'protocol'; + if (code === 1500) return 'internal'; + return 'unavailable'; +} + +export function browserRelayFailure( + code: number, + retryable: boolean, + outcome: DispatchOutcome, + correlation?: FailureOptions['correlation'], +): BrowserClientError { + const options: FailureOptions = correlation === undefined + ? { code, retryable } + : { code, retryable, correlation }; + return new BrowserClientError(kindFromCode(code), outcome, 'Relay rejected the operation', options); +} + +export function safeBrowserLog( + logger: BrowserClientLogger | undefined, + record: BrowserClientLogRecord, +): void { + if (logger === undefined) return; + try { + logger.write(Object.freeze({ ...record })); + } catch { + // Diagnostic sinks never control client execution. + } +} diff --git a/src/internal/browser-socket.ts b/src/internal/browser-socket.ts new file mode 100644 index 0000000..709a6da --- /dev/null +++ b/src/internal/browser-socket.ts @@ -0,0 +1,256 @@ +import { LIMITS } from '../protocol/codec.js'; +import type { + BrowserRuntime, + ManagedSocket, + RuntimeTimer, + SocketFactory, + SocketHandlers, +} from './runtime.js'; + +const MAX_QUEUED_BYTES = 1_048_576; +const MAX_INBOUND_BYTES_PER_WINDOW = 1_048_576; +const MAX_INBOUND_FRAMES_PER_WINDOW = 256; +const INBOUND_WINDOW_MS = 1_000; +const MAX_TIMER_DELAY_MS = 2_147_483_647; +const WEBSOCKET_SUBPROTOCOL = 'miakapp'; +const CONNECTING = 0; +const OPEN = 1; +const CLOSED = 3; + +interface NativeMessageEvent { + readonly data: unknown; +} + +interface NativeCloseEvent { + readonly code: number; + readonly reason: string; +} + +interface NativeWebSocket { + binaryType: string; + readonly bufferedAmount: number; + readonly protocol: string; + readonly readyState: number; + addEventListener(type: string, listener: (event: never) => void): void; + removeEventListener(type: string, listener: (event: never) => void): void; + send(data: Uint8Array): void; + close(code?: number, reason?: string): void; +} + +interface NativeWebSocketConstructor { + new(url: string, protocols?: string | readonly string[]): NativeWebSocket; +} + +function nativeConstructor(): NativeWebSocketConstructor { + const value = (globalThis as unknown as { WebSocket?: NativeWebSocketConstructor }).WebSocket; + if (value === undefined) throw new Error('Native WebSocket is not available'); + return value; +} + +class BrowserManagedSocket implements ManagedSocket { + readonly #socket: NativeWebSocket; + readonly #handlers: SocketHandlers; + readonly #signal: AbortSignal; + readonly #now: () => number; + readonly #ready: Promise; + #resolveReady: (() => void) | undefined; + #rejectReady: ((reason: unknown) => void) | undefined; + #readySettled = false; + #detached = false; + #inboundWindowStartedMs: number | undefined; + #inboundBytes = 0; + #inboundFrames = 0; + + constructor( + socket: NativeWebSocket, + handlers: SocketHandlers, + signal: AbortSignal, + now: () => number, + ) { + this.#socket = socket; + this.#handlers = handlers; + this.#signal = signal; + this.#now = now; + socket.binaryType = 'arraybuffer'; + this.#ready = new Promise((resolve, reject) => { + this.#resolveReady = resolve; + this.#rejectReady = reject; + }); + socket.addEventListener('open', this.#onOpen); + socket.addEventListener('message', this.#onMessage); + socket.addEventListener('close', this.#onClose); + socket.addEventListener('error', this.#onError); + signal.addEventListener('abort', this.#onAbort, { once: true }); + } + + readonly #onOpen = (): void => { + if (this.#readySettled) return; + this.#readySettled = true; + if (this.#socket.protocol !== WEBSOCKET_SUBPROTOCOL) { + this.#rejectReady?.(new Error('Relay did not negotiate the Miakapp WebSocket subprotocol')); + this.terminate(); + return; + } + this.#resolveReady?.(); + }; + + readonly #onMessage = (raw: never): void => { + if (this.#detached) return; + const event = raw as NativeMessageEvent; + if (!(event.data instanceof ArrayBuffer)) { + this.#handlers.error(new Error('Relay sent a non-binary WebSocket message')); + this.terminate(); + return; + } + const bytes = event.data.byteLength; + const now = this.#now(); + if (this.#inboundWindowStartedMs === undefined + || now < this.#inboundWindowStartedMs + || now - this.#inboundWindowStartedMs >= INBOUND_WINDOW_MS) { + this.#inboundWindowStartedMs = now; + this.#inboundBytes = 0; + this.#inboundFrames = 0; + } + this.#inboundBytes += bytes; + this.#inboundFrames += 1; + if (bytes > LIMITS.frameBytes + || this.#inboundBytes > MAX_INBOUND_BYTES_PER_WINDOW + || this.#inboundFrames > MAX_INBOUND_FRAMES_PER_WINDOW) { + this.#handlers.error(new Error('Relay exceeded the browser inbound budget')); + this.terminate(); + return; + } + this.#handlers.message(new Uint8Array(event.data)); + }; + + readonly #onClose = (raw: never): void => { + const event = raw as NativeCloseEvent; + if (!this.#readySettled) { + this.#readySettled = true; + this.#rejectReady?.(new Error('WebSocket closed before authentication')); + } + if (!this.#detached) this.#handlers.close(event.code, event.reason); + }; + + readonly #onError = (): void => { + const failure = new Error('WebSocket transport error'); + if (!this.#readySettled) { + this.#readySettled = true; + this.#rejectReady?.(failure); + } + if (!this.#detached) this.#handlers.error(failure); + }; + + readonly #onAbort = (): void => { + if (!this.#readySettled) { + this.#readySettled = true; + this.#rejectReady?.(this.#signal.reason); + } + this.terminate(); + }; + + ready(): Promise { + return this.#ready; + } + + get bufferedBytes(): number { + return this.#socket.bufferedAmount; + } + + write(bytes: Uint8Array): Promise { + if (this.#socket.readyState !== OPEN) { + return Promise.reject(new Error('WebSocket is not open')); + } + if (bytes.byteLength > LIMITS.frameBytes + || this.#socket.bufferedAmount + bytes.byteLength > MAX_QUEUED_BYTES) { + return Promise.reject(new RangeError('WebSocket outbound queue limit exceeded')); + } + try { + this.#socket.send(bytes); + return Promise.resolve(); + } catch { + return Promise.reject(new Error('WebSocket send failed')); + } + } + + close(code = 1000, reason = 'shutdown'): void { + if (this.#socket.readyState === OPEN || this.#socket.readyState === CONNECTING) { + try { + this.#socket.close(code, reason); + } catch { + try { + this.#socket.close(); + } catch { + // The browser owns final transport cleanup after both close attempts fail. + } + } + } + } + + terminate(): void { + if (this.#socket.readyState !== CLOSED) this.close(1000, 'shutdown'); + } + + detach(): void { + if (this.#detached) return; + this.#detached = true; + this.#signal.removeEventListener('abort', this.#onAbort); + this.#socket.removeEventListener('open', this.#onOpen); + this.#socket.removeEventListener('message', this.#onMessage); + this.#socket.removeEventListener('close', this.#onClose); + this.#socket.removeEventListener('error', this.#onError); + } +} + +export class BrowserSocketFactory implements SocketFactory { + readonly #now: () => number; + + constructor(now: () => number = () => Date.now()) { + this.#now = now; + } + + async connect( + url: string, + handlers: SocketHandlers, + signal: AbortSignal, + ): Promise { + if (signal.aborted) throw signal.reason; + const ManagedWebSocket = nativeConstructor(); + const socket = new BrowserManagedSocket( + new ManagedWebSocket(url, WEBSOCKET_SUBPROTOCOL), + handlers, + signal, + this.#now, + ); + try { + await socket.ready(); + return socket; + } catch (error) { + socket.detach(); + socket.terminate(); + throw error; + } + } +} + +class BrowserTimer implements RuntimeTimer { + readonly #timer: ReturnType; + + constructor(callback: () => void, delayMs: number) { + this.#timer = setTimeout(callback, Math.min(delayMs, MAX_TIMER_DELAY_MS)); + } + + cancel(): void { + clearTimeout(this.#timer); + } +} + +export function createBrowserRuntime(): BrowserRuntime { + const now = () => Date.now(); + return Object.freeze({ + socketFactory: new BrowserSocketFactory(now), + now, + random: () => Math.random(), + setTimer: (callback: () => void, delayMs: number) => new BrowserTimer(callback, delayMs), + }); +} diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 3f5de32..5074bc5 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -29,6 +29,8 @@ export interface CoordinatorRuntime { setTimer(callback: () => void, delayMs: number): RuntimeTimer; } +export type BrowserRuntime = CoordinatorRuntime; + export interface SessionTransport { readonly generation: number; readonly epoch: Uint8Array; diff --git a/src/internal/user-calls.ts b/src/internal/user-calls.ts new file mode 100644 index 0000000..763a9b7 --- /dev/null +++ b/src/internal/user-calls.ts @@ -0,0 +1,364 @@ +import type { + BrowserCallHandle, + BrowserCallOptions, + BrowserCalls, +} from '../browser-api.js'; +import type { ProtocolValue } from '../api.js'; +import { LIMITS, Opcode, type Frame } from '../protocol/codec.js'; +import { + browserCancelled, + browserOutcomeUnknown, + browserProtocolFailure, + browserRelayFailure, + browserUnavailable, + type BrowserClientError, +} from './browser-errors.js'; +import { createDeferred, type Deferred } from './resources.js'; +import type { BrowserRuntime, RuntimeTimer } from './runtime.js'; +import type { UserRelaySession } from './user-session.js'; +import { validateBrowserCallOptions, validateProtocolValue } from './validation.js'; + +export interface UserCallHost { + readySession(): UserRelaySession | undefined; + send(frame: Frame): Promise; + nextCallId(): number; + nextLocalCallId(): number; + runtime(): BrowserRuntime; + emitFailure(failure: BrowserClientError): void; + transportFailure(error: Error): void; + functionDictionarySynchronized(): void; +} + +interface PendingCall { + readonly id: number; + readonly localId: string; + readonly session: UserRelaySession; + readonly accepted: Deferred; + readonly result: Deferred; + readonly timer: RuntimeTimer; + handedOff: boolean; + wasAccepted: boolean; + terminal: boolean; + cancellationRequested: boolean; + signal?: AbortSignal; + abort?: () => void; +} + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw browserProtocolFailure(`${label} is invalid`); + } + return value; +} + +function entries(value: ProtocolValue | undefined, label: string): ProtocolValue[] { + if (!Array.isArray(value)) throw browserProtocolFailure(`${label} is not an array`); + return value; +} + +function sameEpoch(left: Uint8Array | undefined, right: ProtocolValue | undefined): right is Uint8Array { + return left !== undefined + && right instanceof Uint8Array + && right.length === left.length + && right.every((value, index) => value === left[index]); +} + +export class UserCallManager implements BrowserCalls { + readonly #host: UserCallHost; + readonly #functionIds = new Map(); + readonly #calls = new Map(); + readonly #locallyCompleted = new Set(); + readonly #rejectedIncoming = new Set(); + #epoch: Uint8Array | undefined; + #dictionarySeen = false; + + constructor(host: UserCallHost) { + this.#host = host; + } + + beginSession(epoch: Uint8Array): void { + this.#epoch = epoch.slice(); + this.#functionIds.clear(); + this.#locallyCompleted.clear(); + this.#rejectedIncoming.clear(); + this.#dictionarySeen = false; + } + + start(rawOptions: BrowserCallOptions): BrowserCallHandle { + const options = validateBrowserCallOptions(rawOptions); + const localId = `call:${this.#host.nextLocalCallId()}`; + const accepted = createDeferred(); + const result = createDeferred(); + void accepted.promise.catch(() => undefined); + void result.promise.catch(() => undefined); + const inactive = (failure: BrowserClientError): BrowserCallHandle => { + accepted.reject(failure); + result.reject(failure); + return Object.freeze({ localId, accepted: accepted.promise, result: result.promise, cancel() {} }); + }; + if (options.signal?.aborted === true) return inactive(browserCancelled('not_dispatched')); + const session = this.#host.readySession(); + const functionId = this.#functionIds.get(options.function); + if (session === undefined || functionId === undefined) { + return inactive(browserUnavailable('Call target function is not available in a ready session')); + } + if (this.#calls.size >= session.welcome.limits.inflightCalls) { + return inactive(browserUnavailable('Call concurrency limit is reached')); + } + const id = this.#host.nextCallId(); + const pending: PendingCall = { + id, + localId, + session, + accepted, + result, + handedOff: false, + wasAccepted: false, + terminal: false, + cancellationRequested: false, + timer: this.#host.runtime().setTimer(() => { + this.#cancel(id, false, 'Call deadline expired'); + }, options.timeoutMs), + }; + if (options.signal !== undefined) { + const abort = () => this.#cancel(id, true, 'Call was aborted'); + pending.signal = options.signal; + pending.abort = abort; + options.signal.addEventListener('abort', abort, { once: true }); + } + this.#calls.set(id, pending); + const handle = Object.freeze({ + localId, + accepted: accepted.promise, + result: result.promise, + cancel: () => this.#cancel(id, true, 'Call was cancelled'), + }); + queueMicrotask(() => { + if (pending.terminal) return; + pending.handedOff = true; + void session.send({ + opcode: Opcode.Call, + payload: [ + id, + 0, + null, + functionId, + options.timeoutMs, + options.idempotencyKey ?? null, + 0, + options.arguments, + ], + }).then(() => undefined, () => { + if (this.#calls.get(id) === pending) { + this.#fail( + pending, + browserOutcomeUnknown('Call transport handoff could not be confirmed'), + true, + ); + } + }); + }); + return handle; + } + + handleFrame(frame: Frame): boolean { + if (frame.opcode === Opcode.FunctionDict) { + this.#handleDictionary(frame); + return true; + } + if (frame.opcode === Opcode.CallDispatch) { + const id = integer(frame.payload[0], 'CALL_DISPATCH.callId'); + if (this.#rejectedIncoming.has(id)) { + throw browserProtocolFailure('CALL_DISPATCH was duplicated'); + } + this.#rememberRejectedIncoming(id); + void this.#host.send({ + opcode: Opcode.CallError, + payload: [id, 2000, false, 'Browser call handlers are not available', null], + }).catch((error: unknown) => { + this.#host.transportFailure(error instanceof Error ? error : browserUnavailable()); + }); + return true; + } + if (frame.opcode === Opcode.CallCancel || frame.opcode === Opcode.CallCredit) { + const id = integer(frame.payload[0], 'callee callId'); + if (!this.#rejectedIncoming.has(id)) { + throw browserProtocolFailure('Callee call frame is not correlated'); + } + return true; + } + if (frame.opcode === Opcode.CallAccepted) { + const id = integer(frame.payload[0], 'callId'); + if (this.#locallyCompleted.has(id)) return true; + const call = this.#call(id); + if (call.wasAccepted) throw browserProtocolFailure('CALL_ACCEPTED was duplicated'); + call.wasAccepted = true; + call.accepted.resolve(undefined); + return true; + } + if (frame.opcode === Opcode.CallResult) { + const id = integer(frame.payload[0], 'callId'); + if (this.#locallyCompleted.delete(id)) return true; + const call = this.#call(id); + if (!call.wasAccepted) throw browserProtocolFailure('CALL_RESULT arrived before CALL_ACCEPTED'); + if (frame.payload[1] !== true) { + throw browserProtocolFailure('Streaming CALL_RESULT exceeded zero browser credit'); + } + call.result.resolve(validateProtocolValue(frame.payload[2], 'call result')); + this.#finish(call); + return true; + } + if (frame.opcode === Opcode.CallError) { + const id = integer(frame.payload[0], 'callId'); + if (this.#locallyCompleted.delete(id)) return true; + const call = this.#call(id); + const code = integer(frame.payload[1], 'CALL_ERROR.code'); + const retryable = frame.payload[2]; + if (typeof retryable !== 'boolean') throw browserProtocolFailure('CALL_ERROR.retryable is invalid'); + const outcome = code === 1404 || (code === 1405 && call.wasAccepted) + ? 'outcome_unknown' + : call.wasAccepted ? 'accepted' : 'not_dispatched'; + const failure = browserRelayFailure(code, retryable, outcome, { + kind: 'call', localId: call.localId, + }); + this.#fail(call, failure); + this.#host.emitFailure(failure); + return true; + } + return false; + } + + handleError(id: number, code: number, retryable: boolean): boolean { + if (this.#locallyCompleted.delete(id)) return true; + const call = this.#calls.get(id); + if (call === undefined || call.terminal) return false; + const outcome = code === 1404 || (code === 1405 && call.wasAccepted) + ? 'outcome_unknown' + : call.wasAccepted ? 'accepted' : 'not_dispatched'; + const failure = browserRelayFailure(code, retryable, outcome, { + kind: 'call', localId: call.localId, + }); + this.#fail(call, failure); + this.#host.emitFailure(failure); + return true; + } + + handleResponseError(id: number, code: number, retryable: boolean): boolean { + if (!this.#rejectedIncoming.delete(id)) return false; + this.#host.transportFailure(browserRelayFailure(code, retryable, 'outcome_unknown')); + return true; + } + + disconnected(): void { + for (const call of [...this.#calls.values()]) { + this.#fail(call, call.handedOff ? browserOutcomeUnknown() : browserUnavailable()); + } + this.#functionIds.clear(); + this.#locallyCompleted.clear(); + this.#rejectedIncoming.clear(); + this.#dictionarySeen = false; + this.#epoch = undefined; + } + + stop(): void { + for (const call of [...this.#calls.values()]) { + if (call.handedOff && !call.terminal) { + void call.session.send({ opcode: Opcode.CallCancel, payload: [call.id, 1405] }) + .catch(() => undefined); + } + this.#fail(call, call.handedOff ? browserOutcomeUnknown() : browserCancelled('not_dispatched')); + } + this.#rejectedIncoming.clear(); + } + + #handleDictionary(frame: Frame): void { + if (!sameEpoch(this.#epoch, frame.payload[0])) { + throw browserProtocolFailure('FUNCTION_DICT uses a stale epoch'); + } + const replace = frame.payload[1]; + if (typeof replace !== 'boolean') throw browserProtocolFailure('FUNCTION_DICT.replace is invalid'); + if (!replace && !this.#dictionarySeen) { + throw browserProtocolFailure('FUNCTION_DICT addition arrived before a replacement'); + } + const nextFunctionIds = replace ? new Map() : new Map(this.#functionIds); + const ids = new Set(nextFunctionIds.values()); + for (const raw of entries(frame.payload[2], 'FUNCTION_DICT.entries')) { + const tuple = entries(raw, 'FUNCTION_DICT entry'); + const id = integer(tuple[0], 'FUNCTION_DICT.functionId'); + const name = tuple[1]; + if (typeof name !== 'string') throw browserProtocolFailure('FUNCTION_DICT.name is invalid'); + const existing = nextFunctionIds.get(name); + if ((existing !== undefined && existing !== id) || (ids.has(id) && existing !== id)) { + throw browserProtocolFailure('FUNCTION_DICT reassigns an identifier'); + } + if (existing === undefined && nextFunctionIds.size >= LIMITS.statePathsPerHome) { + throw browserProtocolFailure('FUNCTION_DICT exceeds the cumulative function limit'); + } + nextFunctionIds.set(name, id); + ids.add(id); + } + this.#functionIds.clear(); + for (const [name, id] of nextFunctionIds) this.#functionIds.set(name, id); + if (replace) { + this.#dictionarySeen = true; + this.#host.functionDictionarySynchronized(); + } + } + + #call(id: number): PendingCall { + const call = this.#calls.get(id); + if (call === undefined || call.terminal) throw browserProtocolFailure('Call frame is not correlated'); + return call; + } + + #cancel(id: number, awaitTerminal: boolean, message: string): void { + const call = this.#calls.get(id); + if (call === undefined || call.terminal) return; + if (!call.handedOff) { + this.#fail(call, browserCancelled('not_dispatched', message)); + return; + } + if (call.cancellationRequested) { + if (!awaitTerminal) this.#fail(call, browserOutcomeUnknown(message), true); + return; + } + call.cancellationRequested = true; + void call.session.send({ opcode: Opcode.CallCancel, payload: [id, 1405] }).then( + () => { if (!awaitTerminal) this.#fail(call, browserOutcomeUnknown(message), true); }, + () => this.#fail(call, browserOutcomeUnknown(message), true), + ); + } + + #fail(call: PendingCall, failure: BrowserClientError, remember = false): void { + if (call.terminal) return; + if (!call.accepted.settled) call.accepted.reject(failure); + call.result.reject(failure); + this.#finish(call); + if (remember) this.#rememberLocallyCompleted(call.id); + } + + #finish(call: PendingCall): void { + call.terminal = true; + call.timer.cancel(); + if (call.signal !== undefined && call.abort !== undefined) { + call.signal.removeEventListener('abort', call.abort); + } + if (this.#calls.get(call.id) === call) this.#calls.delete(call.id); + } + + #rememberLocallyCompleted(id: number): void { + this.#locallyCompleted.delete(id); + this.#locallyCompleted.add(id); + if (this.#locallyCompleted.size <= LIMITS.inflightCalls) return; + const oldest = this.#locallyCompleted.values().next().value; + if (oldest !== undefined) this.#locallyCompleted.delete(oldest); + } + + #rememberRejectedIncoming(id: number): void { + this.#rejectedIncoming.delete(id); + this.#rejectedIncoming.add(id); + if (this.#rejectedIncoming.size <= LIMITS.inflightCalls) return; + const oldest = this.#rejectedIncoming.values().next().value; + if (oldest !== undefined) this.#rejectedIncoming.delete(oldest); + } +} diff --git a/src/internal/user-session.ts b/src/internal/user-session.ts new file mode 100644 index 0000000..afed1f0 --- /dev/null +++ b/src/internal/user-session.ts @@ -0,0 +1,283 @@ +import type { + BrowserCoordinatorStatus, + BrowserHomeStatus, + BrowserReadySession, +} from '../browser-api.js'; +import { + LIMITS, + Opcode, + type Frame, + type ProtocolValue, +} from '../protocol/codec.js'; +import { UserProtocolSession } from '../protocol/user-session.js'; +import { BrowserClientError, browserProtocolFailure } from './browser-errors.js'; +import { childAbortController, createDeferred } from './resources.js'; +import type { + BrowserRuntime, + ManagedSocket, + SocketHandlers, +} from './runtime.js'; + +export interface UserRelayLimits { + readonly frameBytes: number; + readonly inflightCalls: number; + readonly subscriptions: number; + readonly queuedBytes: number; +} + +export interface UserRelayWelcome { + readonly readySession: BrowserReadySession; + readonly epoch: Uint8Array; + readonly expiresAtMs: number; + readonly limits: UserRelayLimits; +} + +export interface UserRelaySessionCallbacks { + frame(frame: Frame): void; + closed(code: number, reason: string): void; + failed(error: Error): void; +} + +const PROTOCOL_MAJOR = 1; +const PROTOCOL_MINOR = 0; +const HANDSHAKE_TIMEOUT_MS = 10_000; + +function integer(value: ProtocolValue | undefined, label: string, minimum = 1): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) { + throw browserProtocolFailure(`${label} is invalid`); + } + return value; +} + +function array(value: ProtocolValue | undefined, label: string): ProtocolValue[] { + if (!Array.isArray(value)) throw browserProtocolFailure(`${label} is not an array`); + return value; +} + +function bytes(value: ProtocolValue | undefined, label: string): Uint8Array { + if (!(value instanceof Uint8Array) || value.byteLength !== 16) { + throw browserProtocolFailure(`${label} is invalid`); + } + return value.slice(); +} + +function coordinatorStatuses(value: ProtocolValue | undefined): readonly BrowserCoordinatorStatus[] { + return Object.freeze(array(value, 'WELCOME.coordinators').map((raw, index) => { + const fields = array(raw, `WELCOME.coordinators[${index}]`); + const name = fields[0]; + const rawStatus = fields[2]; + if (typeof name !== 'string' || (rawStatus !== 1 && rawStatus !== 2)) { + throw browserProtocolFailure(`WELCOME.coordinators[${index}] is invalid`); + } + return Object.freeze({ + name, + generation: integer(fields[1], `WELCOME.coordinators[${index}].generation`), + status: rawStatus === 1 ? 'connected' as const : 'grace' as const, + }); + })); +} + +export function parseUserHomeStatus( + enrolledValue: ProtocolValue | undefined, + coordinatorsValue: ProtocolValue | undefined, + stale: boolean, +): BrowserHomeStatus { + if (typeof enrolledValue !== 'boolean') { + throw browserProtocolFailure('HOME_STATUS.enrolled is invalid'); + } + return Object.freeze({ + enrolled: enrolledValue, + coordinators: coordinatorStatuses(coordinatorsValue), + stale, + }); +} + +function parseWelcome(frame: Frame, connectedAtMs: number, receivedAtMs: number): UserRelayWelcome { + if (frame.opcode !== Opcode.Welcome) throw browserProtocolFailure('Expected WELCOME'); + const major = integer(frame.payload[0], 'WELCOME.major', 0); + const minor = integer(frame.payload[1], 'WELCOME.minor', 0); + if (major !== PROTOCOL_MAJOR || minor !== PROTOCOL_MINOR) { + throw browserProtocolFailure('WELCOME selected an unsupported protocol version'); + } + const home = parseUserHomeStatus(frame.payload[4], frame.payload[5], false); + const limits = array(frame.payload[6], 'WELCOME.limits'); + const frameBytes = integer(limits[0], 'WELCOME.maxFrameBytes'); + const inflightCalls = integer(limits[1], 'WELCOME.maxInflightCalls'); + const subscriptions = integer(limits[2], 'WELCOME.maxSubscriptions'); + const queuedBytes = integer(limits[3], 'WELCOME.maxQueuedBytes'); + if (frameBytes > LIMITS.frameBytes + || inflightCalls > LIMITS.inflightCalls + || subscriptions > LIMITS.subscriptions + || queuedBytes > 1_048_576) { + throw browserProtocolFailure('WELCOME limits exceed the protocol maxima'); + } + const expiresAtMs = integer(frame.payload[7], 'WELCOME.expiresAtMs'); + if (expiresAtMs <= receivedAtMs) throw browserProtocolFailure('WELCOME expiry is not in the future'); + return Object.freeze({ + readySession: Object.freeze({ + sessionId: integer(frame.payload[2], 'WELCOME.sessionId'), + connectedAtMs, + enrolled: home.enrolled, + coordinators: home.coordinators, + }), + epoch: bytes(frame.payload[3], 'WELCOME.epoch'), + expiresAtMs, + limits: Object.freeze({ frameBytes, inflightCalls, subscriptions, queuedBytes }), + }); +} + +export class UserRelaySession { + readonly #callbacks: UserRelaySessionCallbacks; + readonly #now: () => number; + readonly #protocol = new UserProtocolSession(); + readonly #welcome = createDeferred(); + readonly #queuedFrames: Frame[] = []; + #queuedFrameBytes = 0; + #socket: ManagedSocket | undefined; + #closed = false; + #connectedAtMs = 0; + #welcomeValue: UserRelayWelcome | undefined; + #deliverFrames = false; + + private constructor(callbacks: UserRelaySessionCallbacks, now: () => number) { + this.#callbacks = callbacks; + this.#now = now; + void this.#welcome.promise.catch(() => undefined); + } + + static async connect( + runtime: BrowserRuntime, + homeId: string, + relayUrl: string, + token: string, + signal: AbortSignal, + callbacks: UserRelaySessionCallbacks, + ): Promise { + const session = new UserRelaySession(callbacks, () => runtime.now()); + const handshake = childAbortController(signal); + const timeout = runtime.setTimer(() => { + handshake.controller.abort(new Error('Browser relay handshake timed out')); + }, HANDSHAKE_TIMEOUT_MS); + const handlers: SocketHandlers = { + message: (value) => session.#receive(value), + close: (code, reason) => session.#didClose(code, reason), + error: (error) => session.#didFail(error), + }; + try { + session.#socket = await runtime.socketFactory.connect( + relayUrl, + handlers, + handshake.controller.signal, + ); + session.#connectedAtMs = runtime.now(); + await session.#socket.write(session.#protocol.encode({ + opcode: Opcode.Hello, + payload: [1, 0, 0, 1, token, [homeId]], + })); + await session.#welcome.promise; + return session; + } catch (error) { + session.terminate(); + session.detach(); + throw error; + } finally { + timeout.cancel(); + handshake.dispose(); + } + } + + get welcome(): UserRelayWelcome { + if (this.#welcomeValue === undefined) throw new Error('User relay session is not authenticated'); + return this.#welcomeValue; + } + + get bufferedBytes(): number { + return this.#socket?.bufferedBytes ?? 0; + } + + async send(frame: Frame): Promise { + if (this.#closed || this.#socket === undefined) throw new Error('User relay session is closed'); + if (this.#socket.bufferedBytes > this.welcome.limits.queuedBytes) { + throw new RangeError('User relay session outbound queue limit exceeded'); + } + const encoded = this.#protocol.encode(frame); + if (encoded.byteLength > this.welcome.limits.frameBytes + || this.#socket.bufferedBytes + encoded.byteLength > this.welcome.limits.queuedBytes) { + throw new RangeError('User relay session outbound queue limit exceeded'); + } + await this.#socket.write(encoded); + } + + startDelivery(): void { + if (this.#deliverFrames) return; + this.#deliverFrames = true; + this.#queuedFrameBytes = 0; + for (const frame of this.#queuedFrames.splice(0)) { + if (this.#closed) break; + this.#callbacks.frame(frame); + } + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#protocol.close(); + this.#socket?.close(); + } + + terminate(): void { + if (this.#closed) return; + this.#closed = true; + this.#protocol.close(); + this.#socket?.terminate(); + } + + detach(): void { + this.#socket?.detach(); + } + + #receive(value: Uint8Array): void { + if (this.#closed) return; + try { + const frame = this.#protocol.decode(value); + if (frame.opcode === Opcode.Welcome) { + if (this.#welcomeValue !== undefined) throw browserProtocolFailure('Relay sent WELCOME twice'); + this.#welcomeValue = parseWelcome(frame, this.#connectedAtMs, this.#now()); + this.#welcome.resolve(this.#welcomeValue); + } else if (!this.#welcome.settled && frame.opcode === Opcode.Fatal) { + this.#callbacks.frame(frame); + this.#welcome.reject(browserProtocolFailure('Relay sent FATAL before WELCOME')); + this.terminate(); + } else if (this.#deliverFrames) { + this.#callbacks.frame(frame); + } else { + this.#queuedFrameBytes += value.byteLength; + if (this.#queuedFrames.length >= 256 + || this.#queuedFrameBytes > this.welcome.limits.queuedBytes) { + throw browserProtocolFailure('Relay sent too many frames before session activation'); + } + this.#queuedFrames.push(frame); + } + } catch (error) { + const failure = error instanceof BrowserClientError ? error : browserProtocolFailure(); + this.#welcome.reject(failure); + this.#callbacks.failed(failure); + this.terminate(); + } + } + + #didClose(code: number, reason: string): void { + const wasClosed = this.#closed; + this.#closed = true; + this.#protocol.close(); + this.#welcome.reject(browserProtocolFailure('Relay closed before WELCOME')); + if (!wasClosed) this.#callbacks.closed(code, reason); + } + + #didFail(error: Error): void { + if (this.#closed) return; + this.#welcome.reject(error); + this.#callbacks.failed(error); + this.terminate(); + } +} diff --git a/src/internal/user-state.ts b/src/internal/user-state.ts new file mode 100644 index 0000000..dc544aa --- /dev/null +++ b/src/internal/user-state.ts @@ -0,0 +1,268 @@ +import type { + BrowserState, + BrowserStateSnapshot, +} from '../browser-api.js'; +import type { ProtocolValue } from '../api.js'; +import { LIMITS, Opcode, type Frame } from '../protocol/codec.js'; +import { + browserInternalFailure, + browserProtocolFailure, + browserRelayFailure, +} from './browser-errors.js'; +import { ListenerSet } from './resources.js'; +import { validateProtocolValue } from './validation.js'; + +export interface UserStateHost { + nextRequestId(): number; + send(frame: Frame): Promise; + stateSynchronized(): void; + transportFailure(error: Error): void; + emitFailure(error: import('./browser-errors.js').BrowserClientError): void; +} + +interface InternalSnapshot { + readonly epoch: Uint8Array; + readonly revision: number; + readonly values: Readonly>; + readonly stale: boolean; +} + +function integer(value: ProtocolValue | undefined, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw browserProtocolFailure(`${label} is invalid`); + } + return value; +} + +function entries(value: ProtocolValue | undefined, label: string): ProtocolValue[] { + if (!Array.isArray(value)) throw browserProtocolFailure(`${label} is not an array`); + return value; +} + +function sameEpoch(left: Uint8Array | undefined, right: ProtocolValue | undefined): right is Uint8Array { + return left !== undefined + && right instanceof Uint8Array + && right.length === left.length + && right.every((value, index) => value === left[index]); +} + +function cloneValues(source: Readonly>): Readonly> { + const output: Record = Object.create(null); + for (const [path, value] of Object.entries(source)) { + output[path] = validateProtocolValue(value, `state.${path}`); + } + return Object.freeze(output); +} + +function publicSnapshot(snapshot: InternalSnapshot): BrowserStateSnapshot { + return Object.freeze({ + epoch: snapshot.epoch.slice(), + revision: snapshot.revision, + values: cloneValues(snapshot.values), + stale: snapshot.stale, + }); +} + +export class UserStateManager implements BrowserState { + readonly #host: UserStateHost; + readonly #listeners = new ListenerSet(); + readonly #paths = new Map(); + #epoch: Uint8Array | undefined; + #snapshot: InternalSnapshot | undefined; + #dictionarySeen = false; + #pendingResync: number | undefined; + + constructor(host: UserStateHost) { + this.#host = host; + } + + snapshot(): BrowserStateSnapshot | undefined { + return this.#snapshot === undefined ? undefined : publicSnapshot(this.#snapshot); + } + + subscribe(listener: (snapshot: BrowserStateSnapshot) => void): () => void { + if (typeof listener !== 'function') throw new TypeError('state listener must be a function'); + const remove = this.#listeners.subscribe(listener); + if (this.#snapshot !== undefined) { + try { + listener(publicSnapshot(this.#snapshot)); + } catch { + this.#host.emitFailure(browserInternalFailure()); + } + } + return remove; + } + + beginSession(epoch: Uint8Array): void { + this.#epoch = epoch.slice(); + this.#paths.clear(); + this.#dictionarySeen = false; + this.#pendingResync = undefined; + } + + disconnected(): void { + this.#epoch = undefined; + this.#paths.clear(); + this.#dictionarySeen = false; + this.#pendingResync = undefined; + if (this.#snapshot !== undefined && !this.#snapshot.stale) { + this.#snapshot = Object.freeze({ ...this.#snapshot, stale: true }); + this.#publish(); + } + } + + stop(): void { + this.disconnected(); + this.#listeners.clear(); + } + + handleFrame(frame: Frame): boolean { + if (frame.opcode === Opcode.StateDict) { + this.#handleDictionary(frame); + return true; + } + if (frame.opcode === Opcode.StateSnapshot) { + this.#handleSnapshot(frame); + return true; + } + if (frame.opcode === Opcode.StatePatch) { + this.#handlePatch(frame); + return true; + } + return false; + } + + handleError(requestId: number, code: number, retryable: boolean): boolean { + if (this.#pendingResync !== requestId) return false; + this.#pendingResync = undefined; + this.#host.transportFailure(browserRelayFailure(code, retryable, 'not_dispatched')); + return true; + } + + #handleDictionary(frame: Frame): void { + if (!sameEpoch(this.#epoch, frame.payload[0])) { + throw browserProtocolFailure('STATE_DICT uses a stale epoch'); + } + const replace = frame.payload[1]; + if (typeof replace !== 'boolean') throw browserProtocolFailure('STATE_DICT.replace is invalid'); + if (!replace && !this.#dictionarySeen) { + throw browserProtocolFailure('STATE_DICT addition arrived before a replacement'); + } + const nextPaths = replace ? new Map() : new Map(this.#paths); + const names = new Set(nextPaths.values()); + for (const raw of entries(frame.payload[2], 'STATE_DICT.entries')) { + const tuple = entries(raw, 'STATE_DICT entry'); + const id = integer(tuple[0], 'STATE_DICT.pathId'); + const path = tuple[1]; + if (typeof path !== 'string') throw browserProtocolFailure('STATE_DICT.path is invalid'); + const existing = nextPaths.get(id); + if ((existing !== undefined && existing !== path) + || (names.has(path) && existing !== path)) { + throw browserProtocolFailure('STATE_DICT reassigns an identifier'); + } + if (existing === undefined && nextPaths.size >= LIMITS.statePathsPerHome) { + throw browserProtocolFailure('STATE_DICT exceeds the cumulative path limit'); + } + nextPaths.set(id, path); + names.add(path); + } + this.#paths.clear(); + for (const [id, path] of nextPaths) this.#paths.set(id, path); + if (replace) { + this.#dictionarySeen = true; + if (this.#snapshot !== undefined && !this.#snapshot.stale) { + this.#snapshot = Object.freeze({ ...this.#snapshot, stale: true }); + this.#publish(); + } + } + } + + #handleSnapshot(frame: Frame): void { + const epoch = this.#epoch; + if (epoch === undefined || !sameEpoch(epoch, frame.payload[0]) || !this.#dictionarySeen) { + throw browserProtocolFailure('STATE_SNAPSHOT has no matching dictionary'); + } + const revision = integer(frame.payload[1], 'STATE_SNAPSHOT.revision'); + const previous = this.#snapshot; + if (previous !== undefined + && sameEpoch(previous.epoch, epoch) + && revision < previous.revision) { + throw browserProtocolFailure('STATE_SNAPSHOT rolls back the current epoch'); + } + const values: Record = Object.create(null); + for (const raw of entries(frame.payload[2], 'STATE_SNAPSHOT.entries')) { + const tuple = entries(raw, 'STATE_SNAPSHOT entry'); + const path = this.#paths.get(integer(tuple[0], 'STATE_SNAPSHOT.pathId')); + if (path === undefined || Object.hasOwn(values, path)) { + throw browserProtocolFailure('STATE_SNAPSHOT references an unknown or duplicate path'); + } + values[path] = validateProtocolValue(tuple[1], `state.${path}`); + } + this.#pendingResync = undefined; + this.#snapshot = Object.freeze({ + epoch: epoch.slice(), + revision, + values: Object.freeze(values), + stale: false, + }); + this.#publish(); + this.#host.stateSynchronized(); + } + + #handlePatch(frame: Frame): void { + const snapshot = this.#snapshot; + if (snapshot === undefined + || snapshot.stale + || !sameEpoch(this.#epoch, frame.payload[0]) + || integer(frame.payload[1], 'STATE_PATCH.baseRevision') !== snapshot.revision) { + this.#requestResync(); + return; + } + const revision = integer(frame.payload[2], 'STATE_PATCH.revision'); + if (revision <= snapshot.revision) { + this.#requestResync(); + return; + } + const values: Record = Object.create(null); + for (const [path, value] of Object.entries(snapshot.values)) values[path] = value; + for (const raw of entries(frame.payload[3], 'STATE_PATCH.mutations')) { + const tuple = entries(raw, 'STATE_PATCH mutation'); + const path = this.#paths.get(integer(tuple[0], 'STATE_PATCH.pathId')); + if (path === undefined) { + this.#requestResync(); + return; + } + if (tuple[1] === 0) values[path] = validateProtocolValue(tuple[2], `state.${path}`); + else if (tuple[1] === 1) delete values[path]; + else throw browserProtocolFailure('STATE_PATCH mutation kind is invalid'); + } + this.#snapshot = Object.freeze({ + epoch: snapshot.epoch, + revision, + values: Object.freeze(values), + stale: false, + }); + this.#publish(); + } + + #requestResync(): void { + if (this.#snapshot !== undefined && !this.#snapshot.stale) { + this.#snapshot = Object.freeze({ ...this.#snapshot, stale: true }); + this.#publish(); + } + if (this.#pendingResync !== undefined) return; + const requestId = this.#host.nextRequestId(); + this.#pendingResync = requestId; + void this.#host.send({ opcode: Opcode.StateResync, payload: [requestId] }).catch(() => { + if (this.#pendingResync !== requestId) return; + this.#pendingResync = undefined; + this.#host.transportFailure(browserProtocolFailure('STATE_RESYNC transport failed')); + }); + } + + #publish(): void { + if (this.#snapshot === undefined) return; + const value = publicSnapshot(this.#snapshot); + this.#listeners.emit(value, () => this.#host.emitFailure(browserInternalFailure())); + } +} diff --git a/src/internal/validation.ts b/src/internal/validation.ts index dfdd743..c4d4a5d 100644 --- a/src/internal/validation.ts +++ b/src/internal/validation.ts @@ -15,12 +15,19 @@ import type { UserEventAccess, UserStateAccess, } from '../api.js'; +import type { + BrowserCallOptions, + BrowserClientLogger, + BrowserClientOptions, + FirebaseIdTokenProvider, +} from '../browser-api.js'; import { LIMITS } from '../protocol/codec.js'; const UTF8 = new TextEncoder(); const CONTROL_CHARACTER = /\p{Cc}/u; const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); const COORDINATOR_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const HOME_ID = /^[a-z][a-z0-9-]{1,61}[a-z0-9]$/; interface ValueBudget { values: number; @@ -397,6 +404,20 @@ function isCoordinatorLogger(value: unknown): value is CoordinatorLogger { && typeof value.write === 'function'; } +function isFirebaseIdTokenProvider(value: unknown): value is FirebaseIdTokenProvider { + return value !== null + && typeof value === 'object' + && 'getIdToken' in value + && typeof value.getIdToken === 'function'; +} + +function isBrowserClientLogger(value: unknown): value is BrowserClientLogger { + return value !== null + && typeof value === 'object' + && 'write' in value + && typeof value.write === 'function'; +} + export function validateConfiguration(value: unknown): CoordinatorConfiguration { const configuration = exactObject( value, @@ -431,12 +452,27 @@ export function validateCoordinatorOptions(value: unknown): CoordinatorOptions { export function validateAccessToken(value: unknown, now: number): AccessToken { const token = exactObject(value, ['relayUrl', 'token', 'expiresAtMs'], [], 'access token'); - const relayUrl = boundedString(token.relayUrl, 1, 2_048, 'access token relayUrl'); + const relayUrl = validateRelayUrl(token.relayUrl, 'access token relayUrl'); + const expiresAtMs = token.expiresAtMs; + if (!Number.isSafeInteger(expiresAtMs) + || typeof expiresAtMs !== 'number' + || expiresAtMs <= now) { + throw new RangeError('access token expiry must be a future safe integer'); + } + return Object.freeze({ + relayUrl, + token: boundedString(token.token, 1, 16_384, 'access token token', true), + expiresAtMs, + }); +} + +export function validateRelayUrl(value: unknown, label = 'relay URL'): string { + const relayUrl = boundedString(value, 1, 2_048, label); let url: URL; try { url = new URL(relayUrl); } catch { - throw new TypeError('access token relayUrl is invalid'); + throw new TypeError(`${label} is invalid`); } if (url.protocol !== 'wss:' || !url.hostname @@ -445,18 +481,54 @@ export function validateAccessToken(value: unknown, now: number): AccessToken { || url.hash || url.search || !url.pathname.endsWith('/ws')) { - throw new TypeError('access token relayUrl must be a secure WebSocket URL ending in /ws'); + throw new TypeError(`${label} must be a secure WebSocket URL ending in /ws`); } - const expiresAtMs = token.expiresAtMs; - if (!Number.isSafeInteger(expiresAtMs) - || typeof expiresAtMs !== 'number' - || expiresAtMs <= now) { - throw new RangeError('access token expiry must be a future safe integer'); + return url.href; +} + +export function validateFirebaseIdToken(value: unknown): string { + return boundedString(value, 1, 16_384, 'Firebase ID token', true); +} + +export function validateBrowserClientOptions(value: unknown): BrowserClientOptions { + const options = exactObject( + value, + ['homeId', 'relayUrl', 'idTokenProvider'], + ['logger'], + 'options', + ); + const homeId = boundedString(options.homeId, 3, 63, 'options.homeId'); + if (!HOME_ID.test(homeId)) throw new TypeError('options.homeId is invalid'); + if (!isFirebaseIdTokenProvider(options.idTokenProvider)) { + throw new TypeError('options.idTokenProvider must implement getIdToken'); } + if (options.logger !== undefined && !isBrowserClientLogger(options.logger)) { + throw new TypeError('options.logger must implement write'); + } + const base = Object.freeze({ + homeId, + relayUrl: validateRelayUrl(options.relayUrl, 'options.relayUrl'), + idTokenProvider: options.idTokenProvider, + }); + return options.logger === undefined + ? base + : Object.freeze({ ...base, logger: options.logger }); +} + +export function validateBrowserCallOptions(value: unknown): BrowserCallOptions { + const options = exactObject( + value, + ['function', 'arguments', 'timeoutMs'], + ['idempotencyKey', 'signal'], + 'call options', + ); + const validated = validateStartCallOptions(options); return Object.freeze({ - relayUrl: url.href, - token: boundedString(token.token, 1, 16_384, 'access token token', true), - expiresAtMs, + function: validated.function, + arguments: validated.arguments, + timeoutMs: validated.timeoutMs, + ...(validated.idempotencyKey === undefined ? {} : { idempotencyKey: validated.idempotencyKey }), + ...(validated.signal === undefined ? {} : { signal: validated.signal }), }); } diff --git a/src/protocol/user-session.ts b/src/protocol/user-session.ts new file mode 100644 index 0000000..9370be8 --- /dev/null +++ b/src/protocol/user-session.ts @@ -0,0 +1,137 @@ +import { + decodeFrame, + encodeFrame, + Opcode, + type Frame, +} from './codec.js'; + +export type UserProtocolSessionPhase = + | 'fresh' + | 'awaiting_welcome' + | 'active' + | 'draining' + | 'closed'; + +export class UserProtocolSessionError extends Error { + readonly kind: 'wrong_direction' | 'unexpected_frame'; + + constructor(kind: 'wrong_direction' | 'unexpected_frame', message: string) { + super(message); + this.name = 'UserProtocolSessionError'; + this.kind = kind; + } +} + +const ACTIVE_OUTGOING = new Set([ + Opcode.Reauth, + Opcode.StateResync, + Opcode.Subscribe, + Opcode.Unsubscribe, + Opcode.Event, + Opcode.Call, + Opcode.CallCancel, + Opcode.CallCredit, + Opcode.CallError, +]); + +const ACTIVE_INCOMING = new Set([ + Opcode.Error, + Opcode.Fatal, + Opcode.ReauthOk, + Opcode.HomeStatus, + Opcode.Goaway, + Opcode.StateDict, + Opcode.StateSnapshot, + Opcode.StatePatch, + Opcode.TopicDict, + Opcode.SubscribeOk, + Opcode.UnsubscribeOk, + Opcode.Event, + Opcode.FunctionDict, + Opcode.CallDispatch, + Opcode.CallAccepted, + Opcode.CallResult, + Opcode.CallError, + Opcode.CallCancel, + Opcode.CallCredit, +]); + +const DRAINING_OUTGOING = new Set([ + Opcode.CallResult, + Opcode.CallError, +]); + +function wrongDirection(opcode: number, direction: 'outgoing' | 'incoming'): never { + throw new UserProtocolSessionError( + 'wrong_direction', + `Opcode 0x${opcode.toString(16).padStart(2, '0')} is not valid ${direction} user traffic`, + ); +} + +function unexpected(opcode: number, phase: UserProtocolSessionPhase): never { + throw new UserProtocolSessionError( + 'unexpected_frame', + `Opcode 0x${opcode.toString(16).padStart(2, '0')} is not valid during ${phase}`, + ); +} + +function validateEventArity(frame: Frame, direction: 'outgoing' | 'incoming'): void { + if (frame.opcode !== Opcode.Event) return; + const expected = direction === 'outgoing' ? 5 : 6; + if (frame.payload.length !== expected) wrongDirection(frame.opcode, direction); +} + +export class UserProtocolSession { + #phase: UserProtocolSessionPhase = 'fresh'; + + get phase(): UserProtocolSessionPhase { + return this.#phase; + } + + encode(frame: Frame): Uint8Array { + validateEventArity(frame, 'outgoing'); + if (this.#phase === 'fresh') { + if (frame.opcode !== Opcode.Hello) unexpected(frame.opcode, this.#phase); + const encoded = encodeFrame(frame); + this.#phase = 'awaiting_welcome'; + return encoded; + } + if (this.#phase === 'active') { + if (!ACTIVE_OUTGOING.has(frame.opcode)) wrongDirection(frame.opcode, 'outgoing'); + return encodeFrame(frame); + } + if (this.#phase === 'draining') { + if (!DRAINING_OUTGOING.has(frame.opcode)) unexpected(frame.opcode, this.#phase); + return encodeFrame(frame); + } + return unexpected(frame.opcode, this.#phase); + } + + decode(bytes: Uint8Array): Frame { + const frame = decodeFrame(bytes); + validateEventArity(frame, 'incoming'); + if (this.#phase === 'awaiting_welcome') { + if (frame.opcode === Opcode.Welcome) { + this.#phase = 'active'; + return frame; + } + if (frame.opcode === Opcode.Fatal) { + this.#phase = 'closed'; + return frame; + } + return unexpected(frame.opcode, this.#phase); + } + if (this.#phase === 'active' || this.#phase === 'draining') { + if (frame.opcode >= 0x80) return frame; + if (!ACTIVE_INCOMING.has(frame.opcode)) wrongDirection(frame.opcode, 'incoming'); + if (frame.opcode === Opcode.Goaway) this.#phase = 'draining'; + if (frame.opcode === Opcode.Fatal) this.#phase = 'closed'; + return frame; + } + return unexpected(frame.opcode, this.#phase); + } + + close(): void { + this.#phase = 'closed'; + } +} diff --git a/test/browser-lifecycle.test.ts b/test/browser-lifecycle.test.ts new file mode 100644 index 0000000..e7276cd --- /dev/null +++ b/test/browser-lifecycle.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from 'bun:test'; +import type { BrowserClientLogRecord, FirebaseIdTokenRequest } from '../src/browser-api.js'; +import { createBrowserClientWithRuntime } from '../src/browser-client.js'; +import { Opcode } from '../src/protocol/codec.js'; +import { FakeRelay } from './fakes/relay.js'; +import { FakeRuntime, flushMicrotasks } from './fakes/runtime.js'; +import { + createBrowserTestHarness, + sendUserBootstrap, + startBrowserReady, +} from './fakes/user-relay.js'; + +describe('browser lifecycle', () => { + test('binds role, token and home, then reaches readiness after complete bootstrap', async () => { + const harness = createBrowserTestHarness(); + const statuses: string[] = []; + harness.client.subscribe(({ current }) => statuses.push(current)); + const started = harness.client.start(); + const connection = await harness.relay.connectionAt(0); + const hello = await connection.nextClientFrame(Opcode.Hello); + expect(hello.payload).toEqual([1, 0, 0, 1, 'firebase-initial', ['test-home']]); + expect(harness.client.status).toBe('authenticating'); + sendUserBootstrap(connection); + const ready = await started; + expect(ready.enrolled).toBe(true); + expect(ready.coordinators[0]).toEqual({ + name: 'test-coordinator', generation: 4, status: 'connected', + }); + expect(statuses).toEqual(['connecting', 'authenticating', 'synchronizing', 'ready']); + await harness.client.stop(); + }); + + test('publishes current enrollment and coordinator availability changes', async () => { + const harness = createBrowserTestHarness(); + const { connection } = await startBrowserReady(harness); + expect(harness.client.home.snapshot()).toMatchObject({ + enrolled: true, + stale: false, + coordinators: [{ name: 'test-coordinator', generation: 4, status: 'connected' }], + }); + const observed: boolean[] = []; + harness.client.home.subscribe((status) => observed.push(status.enrolled)); + connection.send({ opcode: Opcode.HomeStatus, payload: [false, []] }); + expect(observed).toEqual([true, false]); + expect(harness.client.home.snapshot()).toMatchObject({ + enrolled: false, stale: false, coordinators: [], + }); + connection.close(1006, 'synthetic loss'); + await flushMicrotasks(); + expect(harness.client.home.snapshot()?.stale).toBe(true); + await harness.client.stop(); + }); + + test('rejects a WELCOME outside the offered protocol version', async () => { + const harness = createBrowserTestHarness(); + const failures: string[] = []; + harness.client.errors.subscribe((failure) => failures.push(failure.kind)); + const started = harness.client.start(); + void started.catch(() => undefined); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + connection.send({ + opcode: Opcode.Welcome, + payload: [2, 0, 41, connection.epoch, true, [], [262_144, 128, 256, 1_048_576], 2_000_000], + }); + await flushMicrotasks(); + expect(harness.client.status).toBe('reconnecting'); + expect(failures).toContain('protocol'); + await harness.client.stop(); + }); + + test('reauthenticates on the same socket from the verified lease', async () => { + const harness = createBrowserTestHarness({ expiresAtMs: 1_010_000 }); + const { connection } = await startBrowserReady(harness); + await harness.runtime.advanceBy(5_000); + const reauth = await connection.nextClientFrame(Opcode.Reauth); + expect(reauth.payload).toEqual([1, 'firebase-reauth']); + connection.send({ opcode: Opcode.ReauthOk, payload: [1, 2_000_000] }); + await flushMicrotasks(); + expect(harness.tokenRequests.map(({ reason }) => reason)).toEqual(['initial', 'reauth']); + expect(harness.relay.connectCount).toBe(1); + expect(harness.client.status).toBe('ready'); + await harness.client.stop(); + }); + + test('bounds a missing REAUTH response and reconnects', async () => { + const harness = createBrowserTestHarness({ expiresAtMs: 1_040_000 }); + harness.runtime.queueRandom(0); + const { connection } = await startBrowserReady(harness); + await harness.runtime.advanceBy(20_000); + await connection.nextClientFrame(Opcode.Reauth); + await harness.runtime.advanceBy(10_000); + expect(harness.relay.connectCount).toBe(2); + expect(harness.client.status).toBe('authenticating'); + await harness.client.stop(); + }); + + test('marks state stale and reacquires a token before reconnecting with full jitter', async () => { + const harness = createBrowserTestHarness(); + harness.runtime.queueRandom(0); + const { connection } = await startBrowserReady(harness); + connection.close(1006, 'synthetic loss'); + await flushMicrotasks(); + expect(harness.client.status).toBe('reconnecting'); + expect(harness.client.state.snapshot()?.stale).toBe(true); + await harness.runtime.advanceBy(0); + const next = await harness.relay.connectionAt(1); + const hello = await next.nextClientFrame(Opcode.Hello); + expect(hello.payload[4]).toBe('firebase-reconnect'); + sendUserBootstrap(next, { revision: 1, state: { 'home.temperature': 22 } }); + await flushMicrotasks(); + expect(harness.client.status).toBe('ready'); + expect(harness.client.state.snapshot()?.values['home.temperature']).toBe(22); + expect(harness.tokenRequests.map(({ reason }) => reason)).toEqual(['initial', 'reconnect']); + await harness.client.stop(); + }); + + test('resets reconnect backoff after each accepted WELCOME', async () => { + const harness = createBrowserTestHarness(); + harness.runtime.queueRandom(0.5, 0.5); + const started = harness.client.start(); + void started.catch(() => undefined); + const first = await harness.relay.connectionAt(0); + await first.nextClientFrame(Opcode.Hello); + first.sendWelcome(); + await flushMicrotasks(); + first.close(1006, 'bootstrap interrupted'); + await flushMicrotasks(); + await harness.runtime.advanceBy(500); + expect(harness.relay.connectCount).toBe(2); + const second = await harness.relay.connectionAt(1); + await second.nextClientFrame(Opcode.Hello); + second.sendWelcome(); + await flushMicrotasks(); + second.close(1006, 'bootstrap interrupted again'); + await flushMicrotasks(); + await harness.runtime.advanceBy(500); + expect(harness.relay.connectCount).toBe(3); + await harness.client.stop(); + }); + + test('bounds token acquisition and WELCOME phases', async () => { + const relay = new FakeRelay({ autoWelcome: false }); + const runtime = new FakeRuntime(relay); + let tokenRequests = 0; + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + relayUrl: 'wss://relay.test/ws', + idTokenProvider: { + async getIdToken() { + tokenRequests += 1; + return new Promise(() => undefined); + }, + }, + }, runtime); + const started = client.start(); + void started.catch(() => undefined); + await flushMicrotasks(); + await runtime.advanceBy(10_000); + expect(tokenRequests).toBe(2); + expect(relay.connectCount).toBe(0); + await client.stop(); + + const handshake = createBrowserTestHarness(); + const handshakeStart = handshake.client.start(); + void handshakeStart.catch(() => undefined); + const connection = await handshake.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + await handshake.runtime.advanceBy(10_000); + expect(handshake.relay.connectCount).toBe(2); + expect(handshake.relay.openConnectionCount).toBe(1); + await handshake.client.stop(); + }); + + test('sanitizes provider failures and log records', async () => { + const secret = 'firebase-secret-from-provider'; + const records: BrowserClientLogRecord[] = []; + const relay = new FakeRelay(); + const runtime = new FakeRuntime(relay); + const tokenRequests: FirebaseIdTokenRequest[] = []; + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + relayUrl: 'wss://relay.test/ws', + idTokenProvider: { + async getIdToken(request) { + tokenRequests.push(request); + throw new Error(secret); + }, + }, + logger: { write: (record) => records.push(record) }, + }, runtime); + const failures: Error[] = []; + client.errors.subscribe((failure) => failures.push(failure)); + const started = client.start(); + await flushMicrotasks(); + expect(client.status).toBe('reconnecting'); + expect(JSON.stringify({ records, failures: failures.map((failure) => failure.message) })) + .not.toContain(secret); + expect(tokenRequests).toHaveLength(1); + await client.stop(); + await expect(started).rejects.toMatchObject({ kind: 'cancelled' }); + }); + + test('does not invoke the token provider after a connecting listener stops reentrantly', async () => { + const relay = new FakeRelay(); + const runtime = new FakeRuntime(relay); + let tokenRequests = 0; + const statuses: string[] = []; + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + relayUrl: 'wss://relay.test/ws', + idTokenProvider: { + async getIdToken() { + tokenRequests += 1; + return 'firebase-token'; + }, + }, + }, runtime); + client.subscribe(({ current }) => { + statuses.push(current); + if (current === 'connecting') void client.stop(); + }); + + const started = client.start(); + await expect(started).rejects.toMatchObject({ kind: 'cancelled' }); + await client.stop(); + + expect(tokenRequests).toBe(0); + expect(relay.connectCount).toBe(0); + expect(statuses).toEqual(['connecting', 'stopping', 'stopped']); + }); + + test('does not resume synchronization after a home listener stops reentrantly', async () => { + const harness = createBrowserTestHarness(); + const statuses: string[] = []; + harness.client.subscribe(({ current }) => statuses.push(current)); + harness.client.home.subscribe(() => { void harness.client.stop(); }); + const started = harness.client.start(); + const connection = await harness.relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + sendUserBootstrap(connection); + + await expect(started).rejects.toMatchObject({ kind: 'cancelled' }); + await harness.client.stop(); + + expect(statuses).toEqual(['connecting', 'authenticating', 'stopping', 'stopped']); + expect(statuses).not.toContain('synchronizing'); + expect(statuses).not.toContain('ready'); + }); + + test('does not reconnect after an error listener stops reentrantly', async () => { + const harness = createBrowserTestHarness(); + const statuses: string[] = []; + harness.client.subscribe(({ current }) => statuses.push(current)); + const { connection } = await startBrowserReady(harness); + statuses.length = 0; + harness.client.errors.subscribe(() => { void harness.client.stop(); }); + + connection.close(1006, 'synthetic loss'); + await flushMicrotasks(); + await harness.client.stop(); + + expect(statuses).toEqual(['stopping', 'stopped']); + expect(harness.relay.connectCount).toBe(1); + }); + + test('treats a coordinator-only frame as a protocol failure', async () => { + const harness = createBrowserTestHarness(); + const failures: Array<{ kind: string }> = []; + harness.client.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startBrowserReady(harness); + connection.send({ + opcode: Opcode.StateSetOk, + payload: [1, connection.epoch, 2], + }); + await flushMicrotasks(); + expect(failures.some(({ kind }) => kind === 'protocol')).toBe(true); + await harness.client.stop(); + }); + + test('validates and ignores an optional extension frame', async () => { + const harness = createBrowserTestHarness(); + const failures: Array<{ kind: string }> = []; + harness.client.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startBrowserReady(harness); + connection.send({ opcode: 0x80, payload: [{ optional: true }] }); + await flushMicrotasks(); + expect(harness.client.status).toBe('ready'); + expect(failures).toEqual([]); + await harness.client.stop(); + }); +}); diff --git a/test/browser-public-api.test.ts b/test/browser-public-api.test.ts new file mode 100644 index 0000000..064d75f --- /dev/null +++ b/test/browser-public-api.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import * as browserEntrypoint from '../src/browser.js'; +import { createBrowserClientWithRuntime } from '../src/browser-client.js'; +import { FakeRelay } from './fakes/relay.js'; +import { FakeRuntime, flushMicrotasks } from './fakes/runtime.js'; +import { createBrowserTestHarness, startBrowserReady } from './fakes/user-relay.js'; + +describe('browser public API', () => { + test('exports an isolated browser surface and constructs inertly', () => { + expect(typeof browserEntrypoint.createBrowserClient).toBe('function'); + expect('createCoordinator' in browserEntrypoint).toBe(false); + expect('createHomeKeyAccessTokenProvider' in browserEntrypoint).toBe(false); + + const harness = createBrowserTestHarness(); + expect(harness.client.status).toBe('idle'); + expect(harness.client.state.snapshot()).toBeUndefined(); + expect(harness.relay.connections).toHaveLength(0); + expect(harness.runtime.pendingTimerCount).toBe(0); + }); + + test('rejects invalid and open option shapes without creating resources', () => { + const relay = new FakeRelay(); + const runtime = new FakeRuntime(relay); + const valid = { + homeId: 'test-home', + relayUrl: 'wss://relay.test/ws', + idTokenProvider: { async getIdToken() { return 'token'; } }, + }; + expect(() => createBrowserClientWithRuntime({ ...valid, secret: 'forbidden' } as never, runtime)) + .toThrow(/invalid shape/); + expect(() => createBrowserClientWithRuntime({ ...valid, homeId: '../bad' }, runtime)) + .toThrow(/homeId/); + expect(() => createBrowserClientWithRuntime({ ...valid, relayUrl: 'ws://relay.test/ws' }, runtime)) + .toThrow(/secure WebSocket/); + expect(relay.connections).toHaveLength(0); + }); + + test('supports class providers and idempotent bounded cleanup', async () => { + class Provider { + async getIdToken(): Promise { return 'class-token'; } + } + const relay = new FakeRelay({ autoWelcome: false }); + const runtime = new FakeRuntime(relay); + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + relayUrl: 'wss://relay.test/ws', + idTokenProvider: new Provider(), + }, runtime); + const first = client.stop({ deadlineMs: 0 }); + const second = client.stop({ deadlineMs: 1 }); + expect(second).toBe(first); + await runtime.advanceBy(0); + await first; + expect(client.status).toBe('stopped'); + }); + + test('removes lifecycle and state listeners idempotently', async () => { + const harness = createBrowserTestHarness(); + let lifecycleEvents = 0; + let stateEvents = 0; + const removeLifecycle = harness.client.subscribe(() => { lifecycleEvents += 1; }); + const removeState = harness.client.state.subscribe(() => { stateEvents += 1; }); + removeLifecycle(); + removeLifecycle(); + removeState(); + removeState(); + await startBrowserReady(harness); + await flushMicrotasks(); + expect(lifecycleEvents).toBe(0); + expect(stateEvents).toBe(0); + await harness.client.stop(); + }); +}); diff --git a/test/browser-socket.test.ts b/test/browser-socket.test.ts new file mode 100644 index 0000000..d9b1674 --- /dev/null +++ b/test/browser-socket.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { BrowserSocketFactory } from '../src/internal/browser-socket.js'; +import type { SocketHandlers } from '../src/internal/runtime.js'; + +type NativeListener = (event: never) => void; + +class MockNativeWebSocket { + static instances: MockNativeWebSocket[] = []; + + binaryType = 'blob'; + bufferedAmount = 0; + protocol = 'miakapp'; + readyState = 0; + closeCount = 0; + readonly sent: Uint8Array[] = []; + readonly #listeners = new Map>(); + + constructor( + readonly url: string, + readonly protocols?: string | readonly string[], + ) { + MockNativeWebSocket.instances.push(this); + } + + addEventListener(type: string, listener: NativeListener): void { + const listeners = this.#listeners.get(type) ?? new Set(); + listeners.add(listener); + this.#listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: NativeListener): void { + this.#listeners.get(type)?.delete(listener); + } + + send(data: Uint8Array): void { + this.sent.push(data); + } + + close(): void { + this.closeCount += 1; + this.readyState = 3; + } + + emit(type: string, event: unknown = {}): void { + for (const listener of [...(this.#listeners.get(type) ?? [])]) { + listener(event as never); + } + } + + listenerCount(): number { + let count = 0; + for (const listeners of this.#listeners.values()) count += listeners.size; + return count; + } +} + +const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket'); + +function handlers(overrides: Partial = {}): SocketHandlers { + return { + message() {}, + close() {}, + error() {}, + ...overrides, + }; +} + +describe('browser socket', () => { + beforeEach(() => { + MockNativeWebSocket.instances = []; + Object.defineProperty(globalThis, 'WebSocket', { + configurable: true, + writable: true, + value: MockNativeWebSocket, + }); + }); + + afterEach(() => { + if (originalWebSocket === undefined) { + Reflect.deleteProperty(globalThis, 'WebSocket'); + } else { + Object.defineProperty(globalThis, 'WebSocket', originalWebSocket); + } + }); + + test('cleans up a transport that fails before opening', async () => { + let transportErrors = 0; + const connection = new BrowserSocketFactory().connect( + 'wss://relay.test/ws', + handlers({ error() { transportErrors += 1; } }), + new AbortController().signal, + ); + const socket = MockNativeWebSocket.instances[0]; + expect(socket).toBeDefined(); + + socket?.emit('error'); + + await expect(connection).rejects.toThrow('WebSocket transport error'); + expect(transportErrors).toBe(1); + expect(socket?.closeCount).toBe(1); + expect(socket?.listenerCount()).toBe(0); + }); + + test('terminates a relay that exceeds the rolling inbound byte budget', async () => { + let now = 1_000; + let messages = 0; + const failures: string[] = []; + const connection = new BrowserSocketFactory(() => now).connect( + 'wss://relay.test/ws', + handlers({ + message() { messages += 1; }, + error(error) { failures.push(error.message); }, + }), + new AbortController().signal, + ); + const socket = MockNativeWebSocket.instances[0]; + expect(socket).toBeDefined(); + if (socket === undefined) throw new Error('Expected a native WebSocket'); + socket.readyState = 1; + socket.emit('open'); + await connection; + + for (let index = 0; index < 4; index += 1) { + socket.emit('message', { data: new ArrayBuffer(262_144) }); + } + now += 1_000; + for (let index = 0; index < 4; index += 1) { + socket.emit('message', { data: new ArrayBuffer(262_144) }); + } + socket.emit('message', { data: new ArrayBuffer(1) }); + + expect(messages).toBe(8); + expect(failures).toEqual(['Relay exceeded the browser inbound budget']); + expect(socket.closeCount).toBe(1); + expect(socket.readyState).toBe(3); + + }); +}); diff --git a/test/browser-state-calls.test.ts b/test/browser-state-calls.test.ts new file mode 100644 index 0000000..01a2623 --- /dev/null +++ b/test/browser-state-calls.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from 'bun:test'; +import { Opcode } from '../src/protocol/codec.js'; +import { flushMicrotasks } from './fakes/runtime.js'; +import { createBrowserTestHarness, sendUserBootstrap, startBrowserReady } from './fakes/user-relay.js'; + +describe('browser state and calls', () => { + test('isolates a throwing state listener from relay protocol health', async () => { + const harness = createBrowserTestHarness(); + const failures: string[] = []; + harness.client.errors.subscribe((failure) => failures.push(failure.kind)); + harness.client.state.subscribe(() => { throw new Error('application listener failed'); }); + await startBrowserReady(harness); + expect(harness.client.status).toBe('ready'); + expect(failures).toEqual(['internal']); + await harness.client.stop(); + }); + + test('publishes defensive immutable snapshots and valid patches', async () => { + const harness = createBrowserTestHarness(); + const { connection } = await startBrowserReady(harness, { + state: { 'home.value': { nested: [1, 2], binary: new Uint8Array([3, 4]) } }, + }); + const first = harness.client.state.snapshot(); + expect(first?.revision).toBe(1); + const firstValue = first?.values['home.value']; + if (firstValue === null || Array.isArray(firstValue) + || firstValue instanceof Uint8Array || typeof firstValue !== 'object') { + throw new Error('Expected object state'); + } + const binary = firstValue.binary; + if (!(binary instanceof Uint8Array)) throw new Error('Expected binary state'); + binary[0] = 99; + const again = harness.client.state.snapshot()?.values['home.value']; + if (again === null || Array.isArray(again) + || again instanceof Uint8Array || typeof again !== 'object' + || !(again.binary instanceof Uint8Array)) throw new Error('Expected cloned state'); + expect([...again.binary]).toEqual([3, 4]); + + connection.send({ + opcode: Opcode.StatePatch, + payload: [connection.epoch, 1, 2, [[101, 0, { nested: [5], binary: new Uint8Array([6]) }]]], + }); + expect(harness.client.state.snapshot()?.revision).toBe(2); + connection.send({ + opcode: Opcode.StatePatch, + payload: [connection.epoch, 2, 3, [[101, 1]]], + }); + expect(harness.client.state.snapshot()?.values['home.value']).toBeUndefined(); + await harness.client.stop(); + }); + + test('delivers the authoritative snapshot immediately to a post-start subscriber', async () => { + const harness = createBrowserTestHarness(); + await startBrowserReady(harness, { state: { 'home.temperature': 24 } }); + const observed: number[] = []; + harness.client.state.subscribe((snapshot) => observed.push( + snapshot.values['home.temperature'] as number, + )); + expect(observed).toEqual([24]); + await harness.client.stop(); + }); + + test('coalesces revision and dictionary mismatches into one resync', async () => { + const harness = createBrowserTestHarness(); + const { connection } = await startBrowserReady(harness); + connection.send({ + opcode: Opcode.StatePatch, + payload: [connection.epoch, 99, 100, [[101, 0, 30]]], + }); + connection.send({ + opcode: Opcode.StatePatch, + payload: [connection.epoch, 100, 101, [[999, 0, 31]]], + }); + const resync = await connection.nextClientFrame(Opcode.StateResync); + expect(resync.payload).toEqual([1]); + expect(connection.queuedClientFrameCount).toBe(0); + expect(harness.client.state.snapshot()?.stale).toBe(true); + connection.send({ + opcode: Opcode.StateDict, + payload: [connection.epoch, true, [[101, 'home.temperature']]], + }); + connection.send({ + opcode: Opcode.StateSnapshot, + payload: [connection.epoch, 101, [[101, 31]]], + }); + expect(harness.client.state.snapshot()).toMatchObject({ revision: 101, stale: false }); + expect(harness.client.state.snapshot()?.values['home.temperature']).toBe(31); + await harness.client.stop(); + }); + + test('reconnects when the relay rejects the only state resynchronization path', async () => { + const harness = createBrowserTestHarness(); + harness.runtime.queueRandom(0); + const { connection } = await startBrowserReady(harness); + connection.send({ + opcode: Opcode.StatePatch, + payload: [connection.epoch, 99, 100, [[101, 0, 30]]], + }); + const resync = await connection.nextClientFrame(Opcode.StateResync); + connection.send({ + opcode: Opcode.Error, + payload: [resync.payload[0] ?? 0, Opcode.StateResync, 1500, true, 'Unavailable'], + }); + await flushMicrotasks(); + expect(harness.client.status).toBe('reconnecting'); + await harness.runtime.advanceBy(0); + const next = await harness.relay.connectionAt(1); + await next.nextClientFrame(Opcode.Hello); + sendUserBootstrap(next, { revision: 100, state: { 'home.temperature': 30 } }); + await flushMicrotasks(); + expect(harness.client.status).toBe('ready'); + expect(harness.client.state.snapshot()).toMatchObject({ revision: 100, stale: false }); + await harness.client.stop(); + }); + + test('rejects a snapshot that rolls back the active epoch', async () => { + const harness = createBrowserTestHarness(); + const failures: Array<{ kind: string }> = []; + harness.client.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startBrowserReady(harness, { revision: 5 }); + connection.send({ + opcode: Opcode.StateDict, + payload: [connection.epoch, true, [[101, 'home.temperature']]], + }); + connection.send({ + opcode: Opcode.StateSnapshot, + payload: [connection.epoch, 4, [[101, 19]]], + }); + await flushMicrotasks(); + expect(failures.some(({ kind }) => kind === 'protocol')).toBe(true); + expect(harness.client.state.snapshot()).toMatchObject({ revision: 5, stale: true }); + await harness.client.stop(); + }); + + test('routes one named call with distinct acceptance and result', async () => { + const harness = createBrowserTestHarness(); + const { connection } = await startBrowserReady(harness); + const call = harness.client.calls.start({ + function: 'home.echo', + arguments: { target: 22 }, + timeoutMs: 5_000, + idempotencyKey: 'intent-1', + }); + await flushMicrotasks(); + const outbound = await connection.nextClientFrame(Opcode.Call); + expect(outbound.payload).toEqual([1, 0, null, 301, 5_000, 'intent-1', 0, { target: 22 }]); + connection.send({ opcode: Opcode.CallAccepted, payload: [1] }); + await call.accepted; + connection.send({ opcode: Opcode.CallResult, payload: [1, true, { accepted: true }] }); + await expect(call.result).resolves.toEqual({ accepted: true }); + await harness.client.stop(); + }); + + test('never replays a handed-off call after connection loss', async () => { + const harness = createBrowserTestHarness(); + harness.runtime.queueRandom(0); + const { connection } = await startBrowserReady(harness); + const call = harness.client.calls.start({ + function: 'home.echo', arguments: null, timeoutMs: 5_000, + }); + await connection.nextClientFrame(Opcode.Call); + await flushMicrotasks(); + connection.close(1006, 'lost after handoff'); + const failure = await call.result.catch((error: unknown) => error); + expect(failure).toMatchObject({ outcome: 'outcome_unknown' }); + await harness.runtime.advanceBy(0); + const next = await harness.relay.connectionAt(1); + await next.nextClientFrame(Opcode.Hello); + sendUserBootstrap(next); + await flushMicrotasks(); + expect(next.queuedClientFrameCount).toBe(0); + await harness.client.stop(); + }); + + test('cancels remotely when cancellation interleaves after synchronous handoff', async () => { + const harness = createBrowserTestHarness(); + const { connection } = await startBrowserReady(harness); + const call = harness.client.calls.start({ + function: 'home.echo', arguments: null, timeoutMs: 5_000, + }); + await connection.nextClientFrame(Opcode.Call); + call.cancel(); + const cancellation = await connection.nextClientFrame(Opcode.CallCancel); + expect(cancellation.payload).toEqual([1, 1405]); + connection.send({ opcode: Opcode.CallError, payload: [1, 1405, false, 'Cancelled', null] }); + await expect(call.result).rejects.toMatchObject({ outcome: 'not_dispatched' }); + await harness.client.stop(); + }); + + test('rejects a valid inbound user-session call without dropping the connection', async () => { + const harness = createBrowserTestHarness(); + const { connection } = await startBrowserReady(harness); + connection.send({ + opcode: Opcode.CallDispatch, + payload: [ + 91, + [2, 'integration', 8, 'test-coordinator', null], + 1, + 41, + 301, + 5_000, + null, + 0, + { target: 23 }, + ], + }); + const rejection = await connection.nextClientFrame(Opcode.CallError); + expect(rejection.payload).toEqual([ + 91, 2000, false, 'Browser call handlers are not available', null, + ]); + connection.send({ opcode: Opcode.CallCancel, payload: [91, 1405] }); + await flushMicrotasks(); + expect(harness.client.status).toBe('ready'); + await harness.client.stop(); + }); + + test('settles relay call errors exactly once', async () => { + const harness = createBrowserTestHarness(); + const observed: string[] = []; + harness.client.errors.subscribe((failure) => observed.push(failure.correlation?.localId ?? 'none')); + const { connection } = await startBrowserReady(harness); + const call = harness.client.calls.start({ + function: 'home.echo', arguments: null, timeoutMs: 5_000, + }); + await connection.nextClientFrame(Opcode.Call); + connection.send({ opcode: Opcode.CallError, payload: [1, 1200, false, 'Forbidden', null] }); + const failure = await call.result.catch((error: unknown) => error); + expect(failure).toMatchObject({ kind: 'authorization', outcome: 'not_dispatched' }); + expect(observed).toEqual([call.localId]); + await harness.client.stop(); + }); + + test('ignores a correlated terminal frame after a local deadline settles the call', async () => { + const harness = createBrowserTestHarness(); + const failures: Array<{ kind: string }> = []; + harness.client.errors.subscribe((failure) => failures.push(failure)); + const { connection } = await startBrowserReady(harness); + const call = harness.client.calls.start({ + function: 'home.echo', arguments: null, timeoutMs: 1, + }); + await connection.nextClientFrame(Opcode.Call); + await flushMicrotasks(); + await harness.runtime.advanceBy(1); + await connection.nextClientFrame(Opcode.CallCancel); + await flushMicrotasks(); + await expect(call.result).rejects.toMatchObject({ outcome: 'outcome_unknown' }); + connection.send({ opcode: Opcode.CallAccepted, payload: [1] }); + connection.send({ opcode: Opcode.CallError, payload: [1, 1405, false, 'Cancelled', null] }); + await flushMicrotasks(); + expect(failures).toEqual([]); + expect(harness.client.status).toBe('ready'); + await harness.client.stop(); + }); +}); diff --git a/test/fakes/user-relay.ts b/test/fakes/user-relay.ts new file mode 100644 index 0000000..9254277 --- /dev/null +++ b/test/fakes/user-relay.ts @@ -0,0 +1,90 @@ +import type { + BrowserClient, + BrowserClientLogger, + BrowserReadySession, + FirebaseIdTokenRequest, +} from '../../src/browser-api.js'; +import { createBrowserClientWithRuntime } from '../../src/browser-client.js'; +import { Opcode, type ProtocolValue } from '../../src/protocol/codec.js'; +import { FakeRelay, type FakeRelayConnection, type FakeRelayOptions } from './relay.js'; +import { FakeRuntime } from './runtime.js'; + +export interface UserBootstrapOptions { + readonly revision?: number; + readonly state?: Readonly>; + readonly functions?: readonly string[]; +} + +export interface BrowserTestHarness { + readonly client: BrowserClient; + readonly relay: FakeRelay; + readonly runtime: FakeRuntime; + readonly tokenRequests: FirebaseIdTokenRequest[]; +} + +function dictionary(names: readonly string[], firstId: number): ProtocolValue[] { + return names.map((name, index) => [firstId + index, name]); +} + +export function sendUserBootstrap( + connection: FakeRelayConnection, + options: UserBootstrapOptions = {}, +): void { + const state = options.state ?? { 'home.temperature': 20 }; + const paths = Object.keys(state); + const functions = options.functions ?? ['home.echo']; + connection.sendWelcome(); + connection.send({ + opcode: Opcode.StateDict, + payload: [connection.epoch, true, dictionary(paths, 101)], + }); + connection.send({ + opcode: Opcode.StateSnapshot, + payload: [ + connection.epoch, + options.revision ?? 1, + paths.map((path, index) => [101 + index, state[path] ?? null]), + ], + }); + connection.send({ opcode: Opcode.TopicDict, payload: [connection.epoch, true, []] }); + connection.send({ + opcode: Opcode.FunctionDict, + payload: [connection.epoch, true, dictionary(functions, 301)], + }); +} + +export function createBrowserTestHarness( + relayOptions: FakeRelayOptions = {}, + logger?: BrowserClientLogger, +): BrowserTestHarness { + const relay = new FakeRelay({ ...relayOptions, autoWelcome: false }); + const runtime = new FakeRuntime(relay); + const tokenRequests: FirebaseIdTokenRequest[] = []; + const baseOptions = { + homeId: 'test-home', + relayUrl: 'wss://relay.test/miakapp/ws', + idTokenProvider: { + async getIdToken(request: FirebaseIdTokenRequest): Promise { + tokenRequests.push(request); + return `firebase-${request.reason}`; + }, + }, + }; + const client = createBrowserClientWithRuntime( + logger === undefined ? baseOptions : { ...baseOptions, logger }, + runtime, + ); + return { client, relay, runtime, tokenRequests }; +} + +export async function startBrowserReady( + harness: BrowserTestHarness, + options: UserBootstrapOptions = {}, + connectionIndex = 0, +): Promise<{ connection: FakeRelayConnection; ready: BrowserReadySession }> { + const started = harness.client.start(); + const connection = await harness.relay.connectionAt(connectionIndex); + await connection.nextClientFrame(Opcode.Hello); + sendUserBootstrap(connection, options); + return { connection, ready: await started }; +} diff --git a/test/integration/browser.ts b/test/integration/browser.ts new file mode 100644 index 0000000..50d0fde --- /dev/null +++ b/test/integration/browser.ts @@ -0,0 +1,91 @@ +import type { + BrowserClientFailure, + BrowserClientStatus, + FirebaseIdTokenReason, +} from '../../src/browser.js'; +import { createBrowserClient } from '../../src/browser.js'; + +interface BrowserIntegrationState { + readonly revision: number; + readonly stale: boolean; + readonly temperature: unknown; +} + +interface BrowserIntegrationFailure { + readonly kind: BrowserClientFailure['kind']; + readonly code?: number; + readonly outcome: BrowserClientFailure['outcome']; +} + +interface BrowserIntegration { + start(): Promise<{ enrolled: boolean; coordinatorCount: number }>; + state(): BrowserIntegrationState | undefined; + call(target: number): Promise; + tokenReasons(): readonly FirebaseIdTokenReason[]; + statuses(): readonly BrowserClientStatus[]; + failures(): readonly BrowserIntegrationFailure[]; + stop(): Promise; +} + +interface BrowserGlobal { + readonly location: { readonly host: string }; + miakappIntegration?: BrowserIntegration; +} + +const browserGlobal = globalThis as unknown as BrowserGlobal; +const tokenReasons: FirebaseIdTokenReason[] = []; +const statuses: BrowserClientStatus[] = []; +const failures: BrowserIntegrationFailure[] = []; +const client = createBrowserClient({ + homeId: 'integration-home', + relayUrl: `wss://${browserGlobal.location.host}/ws`, + idTokenProvider: { + async getIdToken({ reason, signal }) { + if (signal.aborted) throw signal.reason; + tokenReasons.push(reason); + return reason === 'initial' + ? 'integration-user-token' + : 'integration-user-token-new'; + }, + }, +}); + +client.subscribe(({ current }) => statuses.push(current)); +client.errors.subscribe((failure) => failures.push(Object.freeze({ + kind: failure.kind, + ...(failure.code === undefined ? {} : { code: failure.code }), + outcome: failure.outcome, +}))); + +browserGlobal.miakappIntegration = Object.freeze({ + async start() { + const ready = await client.start(); + return Object.freeze({ + enrolled: ready.enrolled, + coordinatorCount: ready.coordinators.length, + }); + }, + state() { + const snapshot = client.state.snapshot(); + if (snapshot === undefined) return undefined; + return Object.freeze({ + revision: snapshot.revision, + stale: snapshot.stale, + temperature: snapshot.values['integration.temperature'], + }); + }, + async call(target: number) { + const call = client.calls.start({ + function: 'integration.set', + arguments: { target }, + timeoutMs: 5_000, + idempotencyKey: 'integration-intent', + }); + await call.accepted; + return call.result; + }, + tokenReasons: () => Object.freeze([...tokenReasons]), + statuses: () => Object.freeze([...statuses]), + failures: () => Object.freeze([...failures]), + stop: () => client.stop({ deadlineMs: 2_000 }), +}); diff --git a/test/node-smoke.mjs b/test/node-smoke.mjs index 0f1c2a5..3114a14 100644 --- a/test/node-smoke.mjs +++ b/test/node-smoke.mjs @@ -5,6 +5,7 @@ import { createCoordinator, createHomeKeyAccessTokenProvider, } from '../dist/index.js'; +import { createBrowserClient } from '../dist/browser.js'; assert.equal(typeof createCoordinator, 'function'); assert.equal(typeof createHomeKeyAccessTokenProvider, 'function'); @@ -39,4 +40,17 @@ await new Promise((resolve) => setImmediate(resolve)); await coordinator.stop(); assert.equal(coordinator.status, 'stopped'); +const browser = createBrowserClient({ + homeId: 'node-smoke-home', + relayUrl: 'wss://relay.example.test/miakapp/ws', + idTokenProvider: { + async getIdToken() { + throw new Error('The inert smoke test must not request a Firebase token'); + }, + }, +}); +assert.equal(browser.status, 'idle'); +await browser.stop(); +assert.equal(browser.status, 'stopped'); + console.log(JSON.stringify({ package: 'miakapi', status: 'ok' })); diff --git a/test/type-contract.ts b/test/type-contract.ts index b71c54f..5a28d89 100644 --- a/test/type-contract.ts +++ b/test/type-contract.ts @@ -5,6 +5,12 @@ import type { ProtocolValue, } from '../src/index.js'; import { createCoordinator } from '../src/index.js'; +import type { + BrowserClient, + BrowserClientFactory, + BrowserClientOptions, +} from '../src/browser.js'; +import { createBrowserClient } from '../src/browser.js'; const options: CoordinatorOptions = { name: 'type-contract', @@ -20,6 +26,16 @@ const options: CoordinatorOptions = { }; const moduleSurface: CoordinatorModule = { createCoordinator }; +const browserOptions: BrowserClientOptions = { + homeId: 'type-contract-home', + relayUrl: 'wss://relay.example.test/miakapp/ws', + idTokenProvider: { + async getIdToken() { + return 'firebase-id-token'; + }, + }, +}; +const browserFactory: BrowserClientFactory = createBrowserClient; export function compilePublicSurface(value: ProtocolValue): Coordinator { const coordinator = moduleSurface.createCoordinator(options); @@ -32,3 +48,7 @@ export function compilePublicSurface(value: ProtocolValue): Coordinator { }); return coordinator; } + +export function compileBrowserSurface(): BrowserClient { + return browserFactory(browserOptions); +}