From 0cc41c522fd33aa26e1f823395590ab4dee7640e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:20:53 +0200 Subject: [PATCH 01/18] fix(client): modernize JWT payload decoder with base64url support Replace the legacy hand-rolled base64 decoder in signing.ts with atob (or Buffer in Node) and explicit base64url normalization. The previous decoder did not recognise the base64url alphabet, so JWTs whose payload contained '-' or '_' characters were silently mis-decoded and yielded the wrong user_id. The function contract is preserved: getUserFromToken returns '' on any failure and the resolved user_id otherwise. Adds unit tests covering malformed tokens, invalid base64, missing user_id, and base64url payloads with both '-' and '_' characters. --- .../connection/__tests__/signing.test.ts | 63 +++++++++++++++++++ .../src/coordinator/connection/signing.ts | 53 +++++----------- 2 files changed, 80 insertions(+), 36 deletions(-) create mode 100644 packages/client/src/coordinator/connection/__tests__/signing.test.ts diff --git a/packages/client/src/coordinator/connection/__tests__/signing.test.ts b/packages/client/src/coordinator/connection/__tests__/signing.test.ts new file mode 100644 index 0000000000..3a5e57b3ff --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/signing.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { getUserFromToken } from '../signing'; + +const encodeBase64Url = (input: string): string => { + const b64 = + typeof btoa === 'function' + ? btoa(input) + : Buffer.from(input, 'utf8').toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const buildJwt = (payload: object): string => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = encodeBase64Url(JSON.stringify(payload)); + return `${header}.${body}.signature`; +}; + +describe('getUserFromToken', () => { + it('returns "" for malformed (non-3-part) tokens', () => { + expect(getUserFromToken('a.b')).toBe(''); + expect(getUserFromToken('a')).toBe(''); + expect(getUserFromToken('a.b.c.d')).toBe(''); + expect(getUserFromToken('')).toBe(''); + }); + + it('returns "" for tokens whose payload is invalid base64', () => { + expect(getUserFromToken('header.@@@invalid@@@.sig')).toBe(''); + }); + + it('returns "" for tokens whose payload is not valid JSON', () => { + const notJson = encodeBase64Url('not-json'); + expect(getUserFromToken(`header.${notJson}.sig`)).toBe(''); + }); + + it('returns user_id for a valid token', () => { + const token = buildJwt({ user_id: 'jane', sub: 'jane' }); + expect(getUserFromToken(token)).toBe('jane'); + }); + + it('returns "" when payload lacks user_id', () => { + const token = buildJwt({ sub: 'jane' }); + expect(getUserFromToken(token)).toBe(''); + }); + + it('decodes payloads whose base64url contains "_" (legacy decoder bug)', () => { + // user_id "???" forces the third 6-bit group of one segment to be 63 (= "/" in + // standard base64, "_" in base64url). The pre-F9 decoder did not recognise "_" + // and therefore mangled the payload. + const token = buildJwt({ user_id: '???' }); + const segment = token.split('.')[1]; + expect(segment.includes('_')).toBe(true); + expect(getUserFromToken(token)).toBe('???'); + }); + + it('decodes payloads whose base64url contains "-"', () => { + // user_id ">>>" forces a 6-bit group of 62 (= "+" in standard base64, "-" in + // base64url) and exercises the same code path with the other base64url char. + const token = buildJwt({ user_id: '>>>' }); + const segment = token.split('.')[1]; + expect(segment.includes('-')).toBe(true); + expect(getUserFromToken(token)).toBe('>>>'); + }); +}); diff --git a/packages/client/src/coordinator/connection/signing.ts b/packages/client/src/coordinator/connection/signing.ts index 48e01cd4ba..5860e87bf5 100644 --- a/packages/client/src/coordinator/connection/signing.ts +++ b/packages/client/src/coordinator/connection/signing.ts @@ -1,39 +1,20 @@ -export function getUserFromToken(token: string) { - const fragments = token.split('.'); - if (fragments.length !== 3) { - return ''; - } - const b64Payload = fragments[1]; - const payload = decodeBase64(b64Payload); - const data = JSON.parse(payload); - return data.user_id as string | undefined; -} +type JwtPayload = { user_id?: string }; -// base-64 decoder throws exception if encoded string is not padded by '=' to make string length -// in multiples of 4. So gonna use our own method for this purpose to keep backwards compatibility -// https://github.com/beatgammit/base64-js/blob/master/index.js#L26 -const decodeBase64 = (s: string): string => { - const e = {} as { [key: string]: number }, - w = String.fromCharCode, - L = s.length; - let i, - b = 0, - c, - x, - l = 0, - a, - r = ''; - const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - for (i = 0; i < 64; i++) { - e[A.charAt(i)] = i; - } - for (x = 0; x < L; x++) { - c = e[s.charAt(x)]; - b = (b << 6) + c; - l += 6; - while (l >= 8) { - if ((a = (b >>> (l -= 8)) & 0xff) || x < L - 2) r += w(a); - } +const decodeJwtPayload = (token: string): JwtPayload | undefined => { + const parts = token.split('.'); + if (parts.length !== 3) return undefined; + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '=='.slice(0, (4 - (b64.length % 4)) % 4); + try { + const json = + typeof atob === 'function' + ? atob(padded) + : Buffer.from(padded, 'base64').toString('utf8'); + return JSON.parse(json) as JwtPayload; + } catch { + return undefined; } - return r; }; + +export const getUserFromToken = (token: string): string => + decodeJwtPayload(token)?.user_id ?? ''; From ca9614f54175f93810738c9b75d0d50fa5492818 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:22:42 +0200 Subject: [PATCH 02/18] fix(client): dedupe concurrent loadToken calls Concurrent callers of TokenManager.loadToken now share the same in-flight promise so the user-supplied token provider is invoked exactly once per cycle. The slot is cleared after settlement (success or rejection) so a subsequent call triggers a fresh provider call. Also tightens the public surface: getToken now returns the cached token (or undefined) rather than throwing when no token is loaded. The single legacy caller in StreamClient already handled undefined by omitting the Authorization header, so the relaxed contract surfaces misuse paths as a server 401 instead of a sync throw. Adds unit tests covering the dedupe, the post-settlement slot clear, provider rejection recovery, reset semantics, and tokenReady visibility during a load. --- .../connection/__tests__/TokenManager.test.ts | 144 ++++++++++++++++++ .../coordinator/connection/token_manager.ts | 129 +++++++--------- 2 files changed, 200 insertions(+), 73 deletions(-) create mode 100644 packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts diff --git a/packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts b/packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts new file mode 100644 index 0000000000..5b654e15e8 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TokenManager } from '../token_manager'; +import { promiseWithResolvers } from '../../../helpers/promise'; +import type { UserWithId } from '../types'; + +const encodeBase64Url = (input: string): string => { + const b64 = + typeof btoa === 'function' + ? btoa(input) + : Buffer.from(input, 'utf8').toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const createValidJwtFor = (userId: string): string => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = encodeBase64Url(JSON.stringify({ user_id: userId })); + return `${header}.${body}.signature`; +}; + +const user = (id: string): UserWithId => ({ id }); + +describe('TokenManager', () => { + it('accepts a static string token and reports static mode', async () => { + const tm = new TokenManager(); + const token = createValidJwtFor('jane'); + await tm.setTokenOrProvider(token, user('jane'), false); + expect(tm.getToken()).toBe(token); + expect(tm.isStatic()).toBe(true); + }); + + it('accepts a token provider and reports provider mode', async () => { + const tm = new TokenManager(); + const validToken = createValidJwtFor('jane'); + const provider = vi.fn(async () => validToken); + await tm.setTokenOrProvider(provider, user('jane'), false); + expect(tm.getToken()).toBe(validToken); + expect(tm.isStatic()).toBe(false); + expect(provider).toHaveBeenCalledTimes(1); + }); + + it('rejects an empty static token for a non-anonymous user', () => { + const tm = new TokenManager(); + expect(() => tm.validateToken('')).toThrowError( + 'User token can not be empty', + ); + }); + + it('rejects a token whose user_id does not match user.id', async () => { + const tm = new TokenManager(); + const mismatchedToken = createValidJwtFor('not-jane'); + await expect( + tm.setTokenOrProvider(mismatchedToken, user('jane'), false), + ).rejects.toThrow(/does not have a user_id or is not matching/); + }); + + it('accepts an empty static token for an anonymous user', async () => { + const tm = new TokenManager(); + await tm.setTokenOrProvider('', user('!anon'), true); + expect(tm.getToken()).toBe(''); + expect(tm.isStatic()).toBe(true); + }); + + it('dedupes concurrent loadToken calls (F10)', async () => { + const { promise: providerP, resolve: providerResolve } = + promiseWithResolvers(); + const provider = vi.fn(() => providerP); + const tm = new TokenManager(); + const validToken = createValidJwtFor('jane'); + + const setupP = tm.setTokenOrProvider(provider, user('jane'), false); + const concurrent = tm.loadToken(); + providerResolve(validToken); + await Promise.all([setupP, concurrent]); + + expect(provider).toHaveBeenCalledTimes(1); + expect(tm.getToken()).toBe(validToken); + }); + + it('clears the in-flight slot after settlement so the next loadToken re-invokes the provider', async () => { + const tm = new TokenManager(); + const tokenA = createValidJwtFor('jane'); + const tokenB = createValidJwtFor('jane'); + const provider = vi + .fn() + .mockResolvedValueOnce(tokenA) + .mockResolvedValueOnce(tokenB); + await tm.setTokenOrProvider(provider, user('jane'), false); + expect(provider).toHaveBeenCalledTimes(1); + await tm.loadToken(); + expect(provider).toHaveBeenCalledTimes(2); + expect(tm.getToken()).toBe(tokenB); + }); + + it('clears the in-flight slot when the provider rejects', async () => { + const tm = new TokenManager(); + const validToken = createValidJwtFor('jane'); + const provider = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(validToken); + + await expect( + tm.setTokenOrProvider(provider, user('jane'), false), + ).rejects.toThrow(/Call to tokenProvider failed/); + // After rejection, loadInFlight is cleared, so the next call retries. + expect(tm.tokenReady()).toBeNull(); + await tm.loadToken(); + expect(provider).toHaveBeenCalledTimes(2); + expect(tm.getToken()).toBe(validToken); + }); + + it('reset clears all state and allows re-setup with a different user', async () => { + const tm = new TokenManager(); + const janeToken = createValidJwtFor('jane'); + const johnToken = createValidJwtFor('john'); + await tm.setTokenOrProvider(janeToken, user('jane'), false); + expect(tm.getToken()).toBe(janeToken); + + tm.reset(); + expect(tm.getToken()).toBeUndefined(); + expect(tm.tokenReady()).toBeNull(); + expect(tm.isStatic()).toBe(true); + + await tm.setTokenOrProvider(johnToken, user('john'), false); + expect(tm.getToken()).toBe(johnToken); + }); + + it('tokenReady returns the live in-flight promise during a load', async () => { + const { promise: providerP, resolve: providerResolve } = + promiseWithResolvers(); + const provider = vi.fn(() => providerP); + const tm = new TokenManager(); + const validToken = createValidJwtFor('jane'); + + const setupP = tm.setTokenOrProvider(provider, user('jane'), false); + const ready = tm.tokenReady(); + expect(ready).not.toBeNull(); + providerResolve(validToken); + await ready; + await setupP; + // Settled — slot is cleared. + expect(tm.tokenReady()).toBeNull(); + }); +}); diff --git a/packages/client/src/coordinator/connection/token_manager.ts b/packages/client/src/coordinator/connection/token_manager.ts index 019a61bf15..d8eb3737f9 100644 --- a/packages/client/src/coordinator/connection/token_manager.ts +++ b/packages/client/src/coordinator/connection/token_manager.ts @@ -1,5 +1,4 @@ import { getUserFromToken } from './signing'; -import { isFunction } from './utils'; import type { TokenOrProvider, UserWithId } from './types'; /** @@ -8,41 +7,31 @@ import type { TokenOrProvider, UserWithId } from './types'; * Handles all the operations around user token. */ export class TokenManager { - private loadTokenPromise: Promise | null = null; - private type: 'static' | 'provider' = 'static'; private readonly secret?: string; + private type: 'static' | 'provider' = 'static'; private token?: string; private tokenProvider?: TokenOrProvider; private user?: UserWithId; - private isAnonymous?: boolean; + private isAnonymous = false; + private loadInFlight: Promise | null = null; constructor(secret?: string) { this.secret = secret; } - /** - * Set the static string token or token provider. - * Token provider should return a token string or a promise which resolves to string token. - * - * @param {TokenOrProvider} tokenOrProvider - the token or token provider. - * @param {UserResponse} user - the user object. - * @param {boolean} isAnonymous - whether the user is anonymous or not. - */ setTokenOrProvider = async ( tokenOrProvider: TokenOrProvider, user: UserWithId, isAnonymous: boolean, - ) => { + ): Promise => { this.user = user; this.isAnonymous = isAnonymous; this.validateToken(tokenOrProvider); - if (isFunction(tokenOrProvider)) { + if (typeof tokenOrProvider === 'function') { this.tokenProvider = tokenOrProvider; this.type = 'provider'; - } - - if (typeof tokenOrProvider === 'string') { + } else if (typeof tokenOrProvider === 'string') { this.token = tokenOrProvider; this.type = 'static'; } @@ -54,30 +43,72 @@ export class TokenManager { * Resets the token manager. * Useful for client disconnection or switching user. */ - reset = () => { + reset = (): void => { this.token = undefined; this.tokenProvider = undefined; this.type = 'static'; this.user = undefined; - this.loadTokenPromise = null; + this.loadInFlight = null; + }; + + /** + * Resolves when token is ready. Returns the in-flight promise (or null when no + * load is in progress). Callers may `await` the return value directly — `await null` + * resolves to null, which preserves the legacy contract. + */ + tokenReady = (): Promise | null => this.loadInFlight; + + /** + * Fetches a token from tokenProvider function and sets it in the manager. + * For static tokens, resolves to the cached token immediately. + * + * Concurrent calls share the same in-flight promise (the provider is invoked + * exactly once per cycle). The in-flight slot is cleared after settlement so a + * subsequent call triggers a fresh provider invocation. + */ + loadToken = (): Promise => { + if (this.loadInFlight) return this.loadInFlight; + this.loadInFlight = (async () => { + if (this.type === 'static') return this.token; + if (!this.tokenProvider || typeof this.tokenProvider !== 'function') { + return undefined; + } + try { + const token = await this.tokenProvider(); + this.validateToken(token); + this.token = token; + return token; + } catch (e) { + throw new Error(`Call to tokenProvider failed with message: ${e}`, { + cause: e, + }); + } + })().finally(() => { + this.loadInFlight = null; + }); + return this.loadInFlight; }; - // Validates the user token. - validateToken = (tokenOrProvider: TokenOrProvider) => { - // allow empty token for anon user + /** Returns the current cached token, or undefined when none has been loaded. */ + getToken = (): string | undefined => this.token; + + isStatic = (): boolean => this.type === 'static'; + + validateToken = (tokenOrProvider: TokenOrProvider): void => { if (this.user && this.isAnonymous && !tokenOrProvider) return; - // Don't allow empty token for non-server side client. if (!this.secret && !tokenOrProvider) { throw new Error('User token can not be empty'); } - if (typeof tokenOrProvider !== 'string' && !isFunction(tokenOrProvider)) { + if ( + typeof tokenOrProvider !== 'string' && + typeof tokenOrProvider !== 'function' + ) { throw new Error('User token should either be a string or a function'); } if (typeof tokenOrProvider === 'string') { - // Allow empty token for anonymous users if (this.isAnonymous && tokenOrProvider === '') return; const tokenUserId = getUserFromToken(tokenOrProvider); @@ -93,52 +124,4 @@ export class TokenManager { } } }; - - // Resolves when token is ready. This function is simply to check if loadToken is in progress, in which - // case a function should wait. - tokenReady = () => this.loadTokenPromise; - - // Fetches a token from tokenProvider function and sets in tokenManager. - // In case of static token, it will simply resolve to static token. - loadToken = () => { - this.loadTokenPromise = new Promise(async (resolve, reject) => { - if (this.type === 'static') { - return resolve(this.token as string); - } - - if (this.tokenProvider && typeof this.tokenProvider !== 'string') { - try { - const token = await this.tokenProvider(); - this.validateToken(token); - this.token = token; - } catch (e) { - return reject( - new Error(`Call to tokenProvider failed with message: ${e}`, { - cause: e, - }), - ); - } - resolve(this.token); - } - }); - - return this.loadTokenPromise; - }; - - // Returns a current token - getToken = () => { - if (this.token) { - return this.token; - } - - if (this.user && !this.token) { - return this.token; - } - - throw new Error( - `User token is not set. Either client.connectUser wasn't called or client.disconnect was called`, - ); - }; - - isStatic = () => this.type === 'static'; } From 39daa12ba58bebf46d084013b3f53a4b10b1da3f Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:23:33 +0200 Subject: [PATCH 03/18] feat(client): add WebSocketConnectionError and rollout/timeout options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive type changes that the coordinator-client rewrite depends on: - WebSocketConnectionError class. Replaces the legacy pattern of stuffing JSON-stringified metadata into Error.message — consumers can now read code, StatusCode, isWSFailure, reason and wasClean directly off the error. - StreamClientOptions gains four optional fields: * useLegacyCoordinator (temporary): routes traffic through the legacy StableWSConnection during the parallel-rollout window. * authHandshakeTimeoutMs: bounds the auth phase explicitly so a stuck server-side handshake fails fast instead of waiting for the outer connect-poll budget. * restConnectionIdTimeoutMs: end-to-end deadline for the auth-gating phase of a non-public REST request — prevents hangs when the connection-id gate never settles. * tokenExpiryRetryLimit: caps the per-request token-expired retry depth so a broken token provider can no longer loop forever. All four default to behaviour-equivalent values, so existing apps see no change unless they opt in. --- .../src/coordinator/connection/types.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/client/src/coordinator/connection/types.ts b/packages/client/src/coordinator/connection/types.ts index 204f4041a2..46d8a42982 100644 --- a/packages/client/src/coordinator/connection/types.ts +++ b/packages/client/src/coordinator/connection/types.ts @@ -42,6 +42,37 @@ export type APIErrorResponse = { unrecoverable?: boolean; }; +/** + * Typed WebSocket connection error. Replaces the legacy practice of + * JSON-stringifying connection metadata into Error.message — consumers can now + * read `code`, `StatusCode`, `isWSFailure`, etc. directly from the error. + */ +export class WebSocketConnectionError extends Error { + public readonly code: string | number; + /** Capital-S preserved for parity with the backend payload field name. */ + public readonly StatusCode: string | number; + public readonly isWSFailure: boolean; + public readonly reason?: string; + public readonly wasClean?: boolean; + public name = 'WebSocketConnectionError'; + + constructor(input: { + code: string | number; + StatusCode: string | number; + message: string; + isWSFailure: boolean; + reason?: string; + wasClean?: boolean; + }) { + super(input.message); + this.code = input.code; + this.StatusCode = input.StatusCode; + this.isWSFailure = input.isWSFailure; + this.reason = input.reason; + this.wasClean = input.wasClean; + } +} + export class ErrorFromResponse extends Error { public code: number | null; public status: number; @@ -287,6 +318,38 @@ export type StreamClientOptions = Partial & { * Device persistence preference options (web only). */ devicePersistence?: DevicePersistenceOptions; + + /** + * When true, route coordinator traffic through the legacy + * StreamClient/StableWSConnection implementation instead of the rewritten + * coordinator-client. Temporary flag used during the parallel rollout — will + * be removed once the new implementation is validated. + * + * @internal + */ + useLegacyCoordinator?: boolean; + + /** + * Maximum time (ms) the coordinator socket waits between transport-open and + * the first `connection.ok` / `connection.error` server reply before giving + * up on the auth handshake. Defaults to `defaultWsTimeout` (15s). + */ + authHandshakeTimeoutMs?: number; + + /** + * End-to-end deadline (ms) for the auth-gating phase of a non-public REST + * request. Wraps both `gate.await()` calls and the optional + * `socket.waitForHealthy()` fallback under a single budget. Defaults to + * `defaultWsTimeout` (15s) so today's effective behaviour is preserved. + */ + restConnectionIdTimeoutMs?: number; + + /** + * Maximum number of times a single REST request will retry after the + * coordinator returns `code: 40` (token expired). Each retry refreshes the + * token via the configured token provider. Defaults to 2. + */ + tokenExpiryRetryLimit?: number; }; export type ClientAppIdentifier = { From 37fef0c3dd063f34b1d97c009499ff0a3fa846d0 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:24:55 +0200 Subject: [PATCH 04/18] feat(client): add EventDispatcher with per-listener error isolation Introduce a new internal EventDispatcher that mirrors the dispatch semantics of today's StreamClient (all-listeners then type-specific listeners, in subscription order) but isolates each invocation in a try/catch. A throwing listener no longer breaks delivery to subsequent listeners; the error is logged at error level and dispatch continues. Also snapshot the listener arrays at dispatch time so a listener that unsubscribes a peer during iteration cannot skip the peer's invocation. Adds unit tests covering on/off, ordering across "all" and typed listeners, error isolation, clear(), late subscription, and the self-unsubscribe-during-dispatch case. --- .../__tests__/EventDispatcher.test.ts | 113 ++++++++++++++++++ .../__tests__/helpers/fakeLogger.ts | 20 ++++ .../connection/internal/EventDispatcher.ts | 71 +++++++++++ 3 files changed, 204 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/EventDispatcher.test.ts create mode 100644 packages/client/src/coordinator/connection/__tests__/helpers/fakeLogger.ts create mode 100644 packages/client/src/coordinator/connection/internal/EventDispatcher.ts diff --git a/packages/client/src/coordinator/connection/__tests__/EventDispatcher.test.ts b/packages/client/src/coordinator/connection/__tests__/EventDispatcher.test.ts new file mode 100644 index 0000000000..6ddf123fa9 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/EventDispatcher.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventDispatcher } from '../internal/EventDispatcher'; +import { createFakeLogger } from './helpers/fakeLogger'; +import type { StreamVideoEvent } from '../types'; + +const networkChanged = (online: boolean): StreamVideoEvent => ({ + type: 'network.changed', + online, +}); + +const connectionChanged = (online: boolean): StreamVideoEvent => ({ + type: 'connection.changed', + online, +}); + +describe('EventDispatcher', () => { + it('on() and off() round-trip correctly', () => { + const logger = createFakeLogger(); + const dispatcher = new EventDispatcher({ logger }); + const cb = vi.fn(); + const off = dispatcher.on('network.changed', cb); + dispatcher.dispatch(networkChanged(true)); + expect(cb).toHaveBeenCalledTimes(1); + off(); + dispatcher.dispatch(networkChanged(false)); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('off() with the same callback stops further deliveries', () => { + const dispatcher = new EventDispatcher({ logger: createFakeLogger() }); + const cb = vi.fn(); + dispatcher.on('network.changed', cb); + dispatcher.off('network.changed', cb); + dispatcher.dispatch(networkChanged(true)); + expect(cb).not.toHaveBeenCalled(); + }); + + it('dispatch invokes "all" listeners before type-specific listeners (in subscription order)', () => { + const dispatcher = new EventDispatcher({ logger: createFakeLogger() }); + const order: string[] = []; + dispatcher.on('all', () => order.push('all-1')); + dispatcher.on('network.changed', () => order.push('typed-1')); + dispatcher.on('all', () => order.push('all-2')); + dispatcher.on('network.changed', () => order.push('typed-2')); + dispatcher.dispatch(networkChanged(true)); + expect(order).toEqual(['all-1', 'all-2', 'typed-1', 'typed-2']); + }); + + it('a throwing listener is logged at error level and does not break delivery (F2)', () => { + const logger = createFakeLogger(); + const dispatcher = new EventDispatcher({ logger }); + const after = vi.fn(); + dispatcher.on('network.changed', () => { + throw new Error('boom'); + }); + dispatcher.on('network.changed', after); + + expect(() => dispatcher.dispatch(networkChanged(true))).not.toThrow(); + expect(after).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalled(); + }); + + it('a throwing "all" listener does not stop "all" or typed deliveries', () => { + const logger = createFakeLogger(); + const dispatcher = new EventDispatcher({ logger }); + const allAfter = vi.fn(); + const typed = vi.fn(); + dispatcher.on('all', () => { + throw new Error('boom'); + }); + dispatcher.on('all', allAfter); + dispatcher.on('network.changed', typed); + + dispatcher.dispatch(networkChanged(true)); + + expect(allAfter).toHaveBeenCalledTimes(1); + expect(typed).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalled(); + }); + + it('clear() removes all listeners', () => { + const dispatcher = new EventDispatcher({ logger: createFakeLogger() }); + const cb = vi.fn(); + dispatcher.on('all', cb); + dispatcher.on('connection.changed', cb); + dispatcher.clear(); + dispatcher.dispatch(connectionChanged(true)); + expect(cb).not.toHaveBeenCalled(); + }); + + it('subscribers added after the first dispatch are appended (regression)', () => { + const dispatcher = new EventDispatcher({ logger: createFakeLogger() }); + const order: string[] = []; + dispatcher.on('network.changed', () => order.push('first')); + dispatcher.dispatch(networkChanged(true)); + dispatcher.on('network.changed', () => order.push('second')); + dispatcher.dispatch(networkChanged(false)); + expect(order).toEqual(['first', 'first', 'second']); + }); + + it('snapshotting the listener list before iteration: removing a listener during dispatch still calls peers', () => { + const dispatcher = new EventDispatcher({ logger: createFakeLogger() }); + const peer = vi.fn(); + let off: () => void = () => {}; + dispatcher.on('network.changed', () => { + // self-unsubscribe during dispatch + off(); + }); + off = dispatcher.on('network.changed', peer); + dispatcher.dispatch(networkChanged(true)); + expect(peer).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/client/src/coordinator/connection/__tests__/helpers/fakeLogger.ts b/packages/client/src/coordinator/connection/__tests__/helpers/fakeLogger.ts new file mode 100644 index 0000000000..64d24f9903 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/helpers/fakeLogger.ts @@ -0,0 +1,20 @@ +import { vi } from 'vitest'; +import type { ScopedLogger } from '../../../../logger'; + +/** + * Minimal Logger fake for unit tests. Returns the same instance from + * `withExtraTags` so chained tag calls do not allocate. Each level method is a + * `vi.fn()` so tests can assert on calls. + */ +export const createFakeLogger = (): ScopedLogger => { + const logger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + getLogLevel: () => 'trace' as const, + withExtraTags: () => logger, + }; + return logger as unknown as ScopedLogger; +}; diff --git a/packages/client/src/coordinator/connection/internal/EventDispatcher.ts b/packages/client/src/coordinator/connection/internal/EventDispatcher.ts new file mode 100644 index 0000000000..56b478ea85 --- /dev/null +++ b/packages/client/src/coordinator/connection/internal/EventDispatcher.ts @@ -0,0 +1,71 @@ +import type { ScopedLogger } from '../../../logger'; +import type { + AllClientEvents, + AllClientEventTypes, + ClientEventListener, + StreamVideoEvent, +} from '../types'; + +type ListenerMap = Partial< + Record[]> +>; + +/** + * Typed event dispatcher with per-listener error isolation. A listener that + * throws is logged at error level and never aborts delivery to subsequent + * listeners. + */ +export class EventDispatcher { + private logger: ScopedLogger; + private listeners: ListenerMap = {}; + + constructor(args: { logger: ScopedLogger }) { + this.logger = args.logger; + } + + on = ( + name: E, + cb: ClientEventListener, + ): (() => void) => { + const list = (this.listeners[name] ??= []) as ClientEventListener[]; + list.push(cb); + this.logger.debug(`Adding listener for ${String(name)} event`); + return () => this.off(name, cb); + }; + + off = ( + name: E, + cb: ClientEventListener, + ): void => { + this.logger.debug(`Removing listener for ${String(name)} event`); + const current = this.listeners[name]; + if (!current) return; + this.listeners[name] = current.filter( + (l) => l !== cb, + ) as ClientEventListener[]; + }; + + dispatch = (event: StreamVideoEvent): void => { + this.logger.debug(`Dispatching event: ${event.type}`, event); + const all = (this.listeners.all ?? []).slice(); + const typed = (this.listeners[event.type] ?? []).slice(); + for (const listener of all) { + try { + listener(event); + } catch (e) { + this.logger.error(`listener for 'all' threw on ${event.type}`, e); + } + } + for (const listener of typed) { + try { + listener(event); + } catch (e) { + this.logger.error(`listener for '${event.type}' threw`, e); + } + } + }; + + clear = (): void => { + this.listeners = {}; + }; +} From 24ac28b0c2d9ab88c711caee40d66412d6b6bf06 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:25:53 +0200 Subject: [PATCH 05/18] feat(client): add ConnectionIdGate replacing connectionIdPromise + setup pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a single source of truth for "is the connection_id available yet?". Lifecycle owners (CoordinatorSocket, openConnection, connectAnonymousUser) call arm() to start a new cycle. The handshake calls resolve() or reject(). REST callers only ever call await() — they must not arm, so they can never rotate the gate into a fresh pending state with no one to resolve it. F1 fix: reject() settles the in-flight promise BEFORE a subsequent arm() rotates the state, so previously-captured await() references always settle and never hang. The legacy code rotated the resolver pair on the catch path of _connect(), which left old awaiters pending forever. Tests cover the F1 ordering invariant, the rotate-after-settled path, and full lifecycle pending/settled state transitions. --- .../__tests__/ConnectionIdGate.test.ts | 103 ++++++++++++++++++ .../connection/internal/ConnectionIdGate.ts | 74 +++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/ConnectionIdGate.test.ts create mode 100644 packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts diff --git a/packages/client/src/coordinator/connection/__tests__/ConnectionIdGate.test.ts b/packages/client/src/coordinator/connection/__tests__/ConnectionIdGate.test.ts new file mode 100644 index 0000000000..7543e437c3 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/ConnectionIdGate.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { ConnectionIdGate } from '../internal/ConnectionIdGate'; + +describe('ConnectionIdGate', () => { + it('await() before arm() throws', () => { + const gate = new ConnectionIdGate(); + expect(() => gate.await()).toThrow(/not armed/); + }); + + it('arm() is idempotent while pending; second await() returns the same promise', () => { + const gate = new ConnectionIdGate(); + gate.arm(); + const first = gate.await(); + gate.arm(); + const second = gate.await(); + expect(first).toBe(second); + }); + + it('resolve(id) settles all pending awaiters with id', async () => { + const gate = new ConnectionIdGate(); + gate.arm(); + const a = gate.await(); + const b = gate.await(); + gate.resolve('conn-id'); + await expect(a).resolves.toBe('conn-id'); + await expect(b).resolves.toBe('conn-id'); + }); + + it('reject(err) settles all pending awaiters with the error', async () => { + const gate = new ConnectionIdGate(); + gate.arm(); + const a = gate.await(); + const err = new Error('boom'); + gate.reject(err); + await expect(a).rejects.toThrow('boom'); + }); + + it('reset() discards the gate; subsequent await() throws until next arm()', async () => { + const gate = new ConnectionIdGate(); + gate.arm(); + const inflight = gate.await(); + gate.resolve('conn-id'); + await inflight; // drain + gate.reset(); + expect(() => gate.await()).toThrow(/not armed/); + gate.arm(); + expect(gate.isPending()).toBe(true); + }); + + it('resolve()/reject() after settlement is a no-op', async () => { + const gate = new ConnectionIdGate(); + gate.arm(); + const inflight = gate.await(); + gate.resolve('first'); + gate.resolve('second'); + gate.reject(new Error('ignored')); + await expect(inflight).resolves.toBe('first'); + }); + + it('isPending / isSettled reflect state correctly across the lifecycle', () => { + const gate = new ConnectionIdGate(); + expect(gate.isPending()).toBe(false); + expect(gate.isSettled()).toBe(false); + gate.arm(); + expect(gate.isPending()).toBe(true); + expect(gate.isSettled()).toBe(false); + gate.resolve('id'); + expect(gate.isPending()).toBe(false); + expect(gate.isSettled()).toBe(true); + gate.reset(); + expect(gate.isPending()).toBe(false); + expect(gate.isSettled()).toBe(false); + }); + + it('F1: rejects in-flight awaiter BEFORE subsequent arm() rotates state', async () => { + const gate = new ConnectionIdGate(); + gate.arm(); + const awaiter = gate.await(); // captures P1 + gate.reject(new Error('boom')); // settles P1 + gate.arm(); // P1 already settled, so this rotates to fresh P2 + const newAwaiter = gate.await(); + await expect(awaiter).rejects.toThrow('boom'); + expect(awaiter).not.toBe(newAwaiter); + }); + + it('arm() after settlement rotates to a fresh pending promise', async () => { + const gate = new ConnectionIdGate(); + gate.arm(); + gate.resolve('id-1'); + gate.arm(); + expect(gate.isPending()).toBe(true); + const fresh = gate.await(); + let resolved = false; + fresh.then(() => { + resolved = true; + }); + // Give microtasks a tick + await Promise.resolve(); + expect(resolved).toBe(false); + gate.resolve('id-2'); + await expect(fresh).resolves.toBe('id-2'); + }); +}); diff --git a/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts b/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts new file mode 100644 index 0000000000..e60dd7d0cd --- /dev/null +++ b/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts @@ -0,0 +1,74 @@ +type GateState = { + promise: Promise; + resolve: (value?: string) => void; + reject: (err: Error) => void; + settled: boolean; +}; + +/** + * Single source of truth for "is the connection_id available yet?". + * + * Lifecycle owners (CoordinatorSocket, StreamClient.openConnection, + * StreamClient.connectAnonymousUser) call `arm()` to start a new gate cycle. + * The handshake calls `resolve()` on success or `reject()` on failure. REST + * callers only ever call `await()` — they MUST NOT call `arm()` so they can + * never rotate the gate into a fresh pending state with no one to resolve it. + * + * F1 fix: `reject()` settles the in-flight promise BEFORE a subsequent + * `arm()` rotates the state, so previously-captured `await()` references + * always resolve or reject (never hang). + */ +export class ConnectionIdGate { + private state: GateState | null = null; + + /** Lifecycle owners only. Idempotent while pending; rotates if settled or absent. */ + arm = (): void => { + if (this.state && !this.state.settled) return; + let resolve!: (value?: string) => void; + let reject!: (err: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + this.state = { promise, resolve, reject, settled: false }; + }; + + /** + * Returns the current armed promise. Throws synchronously if the gate has + * never been armed (programmer error). REST callers should call this only. + */ + await = (): Promise => { + if (!this.state) { + throw new Error( + 'ConnectionIdGate.await(): gate not armed (call arm() first)', + ); + } + return this.state.promise; + }; + + /** Settles the in-flight promise. No-op if absent or already settled. */ + resolve = (connectionId?: string): void => { + if (!this.state || this.state.settled) return; + this.state.settled = true; + this.state.resolve(connectionId); + }; + + /** Settles the in-flight promise with an error. No-op if absent or already settled. */ + reject = (err: Error): void => { + if (!this.state || this.state.settled) return; + this.state.settled = true; + this.state.reject(err); + }; + + /** + * Discards the gate. Subsequent `await()` throws until a new `arm()`. Used + * by `StreamClient.disconnectUser()` after the close path has run. + */ + reset = (): void => { + this.state = null; + }; + + isPending = (): boolean => !!this.state && !this.state.settled; + + isSettled = (): boolean => !!this.state && this.state.settled; +} From 2d3d2b1f1a0112737ec80fca6363ad45f4e70559 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:26:46 +0200 Subject: [PATCH 06/18] feat(client): add NetworkStatusBridge to collapse online/offline registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single owner around addConnectionEventListeners / removeConnectionEventListeners. The legacy code registered two parallel handlers — one in StreamClient to dispatch network.changed events, another inside StableWSConnection to drive reconnect — so we paid for two listeners and split the policy across files. The bridge takes onOnline/onOffline callbacks. Its parent decides what they do: dispatching events to the EventDispatcher AND notifying the CoordinatorSocket are both done in the parent's wiring rather than inside the bridge. Defaults pull the production register/unregister helpers from utils.ts; tests inject their own registry to avoid depending on a real `window` (no `happy-dom` required). --- .../__tests__/NetworkStatusBridge.test.ts | 92 +++++++++++++++++++ .../__tests__/helpers/networkRegistry.ts | 21 +++++ .../internal/NetworkStatusBridge.ts | 53 +++++++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/NetworkStatusBridge.test.ts create mode 100644 packages/client/src/coordinator/connection/__tests__/helpers/networkRegistry.ts create mode 100644 packages/client/src/coordinator/connection/internal/NetworkStatusBridge.ts diff --git a/packages/client/src/coordinator/connection/__tests__/NetworkStatusBridge.test.ts b/packages/client/src/coordinator/connection/__tests__/NetworkStatusBridge.test.ts new file mode 100644 index 0000000000..82db1389c0 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/NetworkStatusBridge.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NetworkStatusBridge } from '../internal/NetworkStatusBridge'; +import { createTestNetworkRegistry } from './helpers/networkRegistry'; + +describe('NetworkStatusBridge', () => { + it('attach() registers the internal handler', () => { + const registry = createTestNetworkRegistry(); + const onOnline = vi.fn(); + const onOffline = vi.fn(); + const bridge = new NetworkStatusBridge({ + register: registry.register, + unregister: registry.unregister, + onOnline, + onOffline, + }); + expect(registry.hasHandler()).toBe(false); + bridge.attach(); + expect(registry.hasHandler()).toBe(true); + }); + + it('attach() is idempotent', () => { + const registry = createTestNetworkRegistry(); + const register = vi.fn(registry.register); + const bridge = new NetworkStatusBridge({ + register, + unregister: registry.unregister, + onOnline: vi.fn(), + onOffline: vi.fn(), + }); + bridge.attach(); + bridge.attach(); + expect(register).toHaveBeenCalledTimes(1); + }); + + it('detach() unregisters the same handler reference', () => { + const registry = createTestNetworkRegistry(); + const bridge = new NetworkStatusBridge({ + register: registry.register, + unregister: registry.unregister, + onOnline: vi.fn(), + onOffline: vi.fn(), + }); + bridge.attach(); + bridge.detach(); + expect(registry.hasHandler()).toBe(false); + }); + + it('detach() before attach() is a no-op', () => { + const registry = createTestNetworkRegistry(); + const unregister = vi.fn(registry.unregister); + const bridge = new NetworkStatusBridge({ + register: registry.register, + unregister, + onOnline: vi.fn(), + onOffline: vi.fn(), + }); + bridge.detach(); + expect(unregister).not.toHaveBeenCalled(); + }); + + it('online events route to onOnline', () => { + const registry = createTestNetworkRegistry(); + const onOnline = vi.fn(); + const onOffline = vi.fn(); + const bridge = new NetworkStatusBridge({ + register: registry.register, + unregister: registry.unregister, + onOnline, + onOffline, + }); + bridge.attach(); + registry.fireOnline(); + expect(onOnline).toHaveBeenCalledTimes(1); + expect(onOffline).not.toHaveBeenCalled(); + }); + + it('offline events route to onOffline', () => { + const registry = createTestNetworkRegistry(); + const onOnline = vi.fn(); + const onOffline = vi.fn(); + const bridge = new NetworkStatusBridge({ + register: registry.register, + unregister: registry.unregister, + onOnline, + onOffline, + }); + bridge.attach(); + registry.fireOffline(); + expect(onOffline).toHaveBeenCalledTimes(1); + expect(onOnline).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/src/coordinator/connection/__tests__/helpers/networkRegistry.ts b/packages/client/src/coordinator/connection/__tests__/helpers/networkRegistry.ts new file mode 100644 index 0000000000..54793e5b1a --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/helpers/networkRegistry.ts @@ -0,0 +1,21 @@ +type Handler = (event: Event) => void; + +/** + * In-memory replacement for the browser's window online/offline event + * registration used by NetworkStatusBridge. Lets tests `fireOnline()` and + * `fireOffline()` without depending on `happy-dom`. + */ +export const createTestNetworkRegistry = () => { + let handler: Handler | null = null; + return { + register: (cb: Handler) => { + handler = cb; + }, + unregister: (cb: Handler) => { + if (handler === cb) handler = null; + }, + fireOnline: () => handler?.(new Event('online')), + fireOffline: () => handler?.(new Event('offline')), + hasHandler: () => handler !== null, + }; +}; diff --git a/packages/client/src/coordinator/connection/internal/NetworkStatusBridge.ts b/packages/client/src/coordinator/connection/internal/NetworkStatusBridge.ts new file mode 100644 index 0000000000..728d9098b9 --- /dev/null +++ b/packages/client/src/coordinator/connection/internal/NetworkStatusBridge.ts @@ -0,0 +1,53 @@ +import { + addConnectionEventListeners, + removeConnectionEventListeners, +} from '../utils'; + +export type NetworkStatusBridgeArgs = { + /** Defaults to addConnectionEventListeners. Injected for testability. */ + register?: (cb: (event: Event) => void) => void; + /** Defaults to removeConnectionEventListeners. Injected for testability. */ + unregister?: (cb: (event: Event) => void) => void; + onOnline: () => void; + onOffline: () => void; +}; + +/** + * Single owner around addConnectionEventListeners / removeConnectionEventListeners. + * + * The legacy code registered two parallel handlers (one in StreamClient for + * `network.changed`, another inside StableWSConnection to drive reconnect). + * The bridge collapses those into one registration; the parent decides what + * to do in the supplied callbacks. + */ +export class NetworkStatusBridge { + private register: (cb: (event: Event) => void) => void; + private unregister: (cb: (event: Event) => void) => void; + private onOnline: () => void; + private onOffline: () => void; + private attached = false; + + constructor(args: NetworkStatusBridgeArgs) { + this.register = args.register ?? addConnectionEventListeners; + this.unregister = args.unregister ?? removeConnectionEventListeners; + this.onOnline = args.onOnline; + this.onOffline = args.onOffline; + } + + private handler = (event: Event): void => { + if (event.type === 'online') this.onOnline(); + else if (event.type === 'offline') this.onOffline(); + }; + + attach = (): void => { + if (this.attached) return; + this.register(this.handler); + this.attached = true; + }; + + detach = (): void => { + if (!this.attached) return; + this.unregister(this.handler); + this.attached = false; + }; +} From 418bcdd02e334459394a15afdb8571110217479d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:28:10 +0200 Subject: [PATCH 07/18] feat(client): add WebSocketTransport plus Mock/Manual test doubles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small wrapper around the WebSocket constructor that holds no application state. Production code passes the URL and a WebSocketImpl constructor (defaulting to the global WebSocket); tests pass the MockWebSocket double so handler invocations can be driven explicitly without a real network. The send() helper returns a boolean so call sites that care about delivery (the upcoming auth-handshake) can branch on send failure rather than rely on the side-channel close event. The close() helper is idempotent and resolves either on onclose or after a graceful timeout, removing duplicate logic that today's StableWSConnection re-implements every disconnect. Also lands MockWebSocket (auto-fires onclose when close() is called) and ManualWebSocket (does not — for races where the test wants to fire close itself) so subsequent CoordinatorSocket tests have a stable substrate. --- .../__tests__/WebSocketTransport.test.ts | 151 ++++++++++++++++++ .../__tests__/helpers/ManualWebSocket.ts | 56 +++++++ .../__tests__/helpers/MockWebSocket.ts | 71 ++++++++ .../connection/internal/WebSocketTransport.ts | 76 +++++++++ 4 files changed, 354 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts create mode 100644 packages/client/src/coordinator/connection/__tests__/helpers/ManualWebSocket.ts create mode 100644 packages/client/src/coordinator/connection/__tests__/helpers/MockWebSocket.ts create mode 100644 packages/client/src/coordinator/connection/internal/WebSocketTransport.ts diff --git a/packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts b/packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts new file mode 100644 index 0000000000..aea3bf6aa1 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebSocketTransport } from '../internal/WebSocketTransport'; +import { MockWebSocket } from './helpers/MockWebSocket'; +import { ManualWebSocket } from './helpers/ManualWebSocket'; + +describe('WebSocketTransport', () => { + beforeEach(() => { + MockWebSocket.reset(); + ManualWebSocket.reset(); + }); + + it('open() instantiates the WebSocket via the injected impl', () => { + const t = new WebSocketTransport({ + url: 'wss://x', + WebSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + t.open({ + onOpen: vi.fn(), + onMessage: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + }); + expect(MockWebSocket.instances).toHaveLength(1); + expect(MockWebSocket.instances[0].url).toBe('wss://x'); + }); + + it('forwards open/message/close/error to the supplied handlers', () => { + const onOpen = vi.fn(); + const onMessage = vi.fn(); + const onClose = vi.fn(); + const onError = vi.fn(); + const t = new WebSocketTransport({ + url: 'wss://x', + WebSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + t.open({ onOpen, onMessage, onClose, onError }); + const ws = MockWebSocket.instances[0]; + + ws.fireOpen(); + ws.fireMessage({ type: 'health.check' }); + ws.fireError(); + ws.fireClose(1006); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('send() returns true on success, false when the underlying call throws', () => { + const t = new WebSocketTransport({ + url: 'wss://x', + WebSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + t.open({ + onOpen: vi.fn(), + onMessage: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + }); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + expect(t.send('payload')).toBe(true); + ws.send.mockImplementationOnce(() => { + throw new Error('boom'); + }); + expect(t.send('payload')).toBe(false); + }); + + it('close() sends a close frame and resolves on onclose', async () => { + const t = new WebSocketTransport({ + url: 'wss://x', + WebSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + t.open({ + onOpen: vi.fn(), + onMessage: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + }); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + await expect(t.close(1000, 'manual')).resolves.toBeUndefined(); + expect(ws.close).toHaveBeenCalledWith(1000, 'manual'); + }); + + it('close() resolves after the graceful timeout when the server never replies', async () => { + vi.useFakeTimers(); + try { + const t = new WebSocketTransport({ + url: 'wss://x', + WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket, + }); + t.open({ + onOpen: vi.fn(), + onMessage: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + }); + ManualWebSocket.instances[0].fireOpen(); + const closing = t.close(1000, 'manual', 100); + let resolved = false; + closing.then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(50); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(60); + expect(resolved).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('close() is idempotent — second call returns the same promise', async () => { + const t = new WebSocketTransport({ + url: 'wss://x', + WebSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + t.open({ + onOpen: vi.fn(), + onMessage: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + }); + MockWebSocket.instances[0].fireOpen(); + const a = t.close(1000, 'manual'); + const b = t.close(1000, 'manual'); + expect(a).toBe(b); + await a; + }); + + it('close() on an already-closed (or never-opened) WS resolves immediately', async () => { + const t = new WebSocketTransport({ + url: 'wss://x', + WebSocketImpl: MockWebSocket as unknown as typeof WebSocket, + }); + t.open({ + onOpen: vi.fn(), + onMessage: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + }); + // never fired open — readyState stays CONNECTING + await expect(t.close(1000, 'manual')).resolves.toBeUndefined(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); +}); diff --git a/packages/client/src/coordinator/connection/__tests__/helpers/ManualWebSocket.ts b/packages/client/src/coordinator/connection/__tests__/helpers/ManualWebSocket.ts new file mode 100644 index 0000000000..c7f0717be2 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/helpers/ManualWebSocket.ts @@ -0,0 +1,56 @@ +import { vi } from 'vitest'; + +/** + * Test double for the WebSocket constructor that does NOT auto-fire `onclose` + * when `close()` is called. Tests can drive the close manually via + * `fireClose()` to exercise races (e.g. `disconnect()` while the WS is + * mid-flight). + */ +export class ManualWebSocket { + static instances: ManualWebSocket[] = []; + static reset = (): void => { + ManualWebSocket.instances = []; + }; + + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readonly CONNECTING = ManualWebSocket.CONNECTING; + readonly OPEN = ManualWebSocket.OPEN; + readonly CLOSING = ManualWebSocket.CLOSING; + readonly CLOSED = ManualWebSocket.CLOSED; + + url: string; + readyState: number = ManualWebSocket.CONNECTING; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + send = vi.fn(); + close = vi.fn(); + + constructor(url: string) { + this.url = url; + ManualWebSocket.instances.push(this); + } + + fireOpen = (): void => { + this.readyState = ManualWebSocket.OPEN; + this.onopen?.(new Event('open')); + }; + + fireMessage = (data: unknown): void => { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + }; + + fireClose = (code = 1006, reason = '', wasClean = false): void => { + this.readyState = ManualWebSocket.CLOSED; + this.onclose?.({ code, reason, wasClean } as CloseEvent); + }; + + fireError = (): void => { + this.onerror?.(new Event('error')); + }; +} diff --git a/packages/client/src/coordinator/connection/__tests__/helpers/MockWebSocket.ts b/packages/client/src/coordinator/connection/__tests__/helpers/MockWebSocket.ts new file mode 100644 index 0000000000..42c685a367 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/helpers/MockWebSocket.ts @@ -0,0 +1,71 @@ +import { vi } from 'vitest'; + +/** + * Test double for the WebSocket constructor. Each instance is recorded on + * `MockWebSocket.instances` so tests can drive lifecycle events via the + * `fireOpen / fireMessage / fireClose / fireError` helpers. + * + * Call `MockWebSocket.reset()` in `beforeEach` to clear the instance list. + */ +export class MockWebSocket { + static instances: MockWebSocket[] = []; + static reset = (): void => { + MockWebSocket.instances = []; + }; + + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + // mirror the static enum on the prototype for `ws.readyState !== ws.OPEN` + // checks inside the production code under test. + readonly CONNECTING = MockWebSocket.CONNECTING; + readonly OPEN = MockWebSocket.OPEN; + readonly CLOSING = MockWebSocket.CLOSING; + readonly CLOSED = MockWebSocket.CLOSED; + + url: string; + readyState: number = MockWebSocket.CONNECTING; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + send = vi.fn(); + + close = vi.fn((code?: number, reason?: string) => { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ + code: code ?? 1000, + reason: reason ?? '', + wasClean: true, + } as CloseEvent); + }); + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + fireOpen = (): void => { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(new Event('open')); + }; + + fireMessage = (data: unknown): void => { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + }; + + fireRawMessage = (data: unknown): void => { + this.onmessage?.({ data } as MessageEvent); + }; + + fireClose = (code = 1006, reason = '', wasClean = false): void => { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ code, reason, wasClean } as CloseEvent); + }; + + fireError = (): void => { + this.onerror?.(new Event('error')); + }; +} diff --git a/packages/client/src/coordinator/connection/internal/WebSocketTransport.ts b/packages/client/src/coordinator/connection/internal/WebSocketTransport.ts new file mode 100644 index 0000000000..d706c7187d --- /dev/null +++ b/packages/client/src/coordinator/connection/internal/WebSocketTransport.ts @@ -0,0 +1,76 @@ +export type WebSocketHandlers = { + onOpen: () => void; + onMessage: (event: MessageEvent) => void; + onClose: (event: CloseEvent) => void; + onError: (event: Event) => void; +}; + +/** + * Minimal WebSocket wrapper holding no application state. Construction takes + * the URL and a `WebSocketImpl` constructor (defaults to the global + * WebSocket); `open()` actually creates the underlying socket and binds + * handlers. + */ +export class WebSocketTransport { + private url: string; + private WebSocketImpl: typeof WebSocket; + private ws?: WebSocket; + private closePromise?: Promise; + + constructor(args: { url: string; WebSocketImpl: typeof WebSocket }) { + this.url = args.url; + this.WebSocketImpl = args.WebSocketImpl; + } + + open = (handlers: WebSocketHandlers): void => { + const ws = new this.WebSocketImpl(this.url); + this.ws = ws; + ws.onopen = () => handlers.onOpen(); + ws.onmessage = (event) => handlers.onMessage(event); + ws.onclose = (event) => handlers.onClose(event); + ws.onerror = (event) => handlers.onError(event); + }; + + /** Returns true if `send()` succeeded; false if the underlying call threw. */ + send = (payload: string): boolean => { + try { + this.ws?.send(payload); + return true; + } catch { + return false; + } + }; + + /** + * Closes the underlying socket and resolves when the close completes (or + * after `gracefulTimeoutMs` if the server never replies). Idempotent — a + * second call returns the same promise. + */ + close = ( + code: number, + reason: string, + gracefulTimeoutMs = 1000, + ): Promise => { + if (this.closePromise) return this.closePromise; + const ws = this.ws; + this.closePromise = new Promise((resolve) => { + if (!ws || ws.readyState !== ws.OPEN) { + resolve(); + return; + } + const done = () => resolve(); + ws.onclose = done; + setTimeout(done, gracefulTimeoutMs); + try { + ws.close(code, reason); + } catch { + // already closed — fall through + } + }); + return this.closePromise; + }; + + get readyState(): number { + return this.ws?.readyState ?? WebSocket.CLOSED; + } +} From 4e355e3328a5792574d4c23637ac1dad65a0ec58 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:29:30 +0200 Subject: [PATCH 08/18] chore(client): replace em-dashes with plain ASCII punctuation Per the project convention, comments and JSDoc should not use em-dash. This is a no-op text-only change across the new coordinator-connection internal modules and their tests. --- .../src/coordinator/connection/__tests__/TokenManager.test.ts | 2 +- .../connection/__tests__/WebSocketTransport.test.ts | 4 ++-- .../src/coordinator/connection/internal/ConnectionIdGate.ts | 2 +- .../src/coordinator/connection/internal/WebSocketTransport.ts | 4 ++-- packages/client/src/coordinator/connection/token_manager.ts | 4 ++-- packages/client/src/coordinator/connection/types.ts | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts b/packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts index 5b654e15e8..883d68f1c9 100644 --- a/packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts +++ b/packages/client/src/coordinator/connection/__tests__/TokenManager.test.ts @@ -138,7 +138,7 @@ describe('TokenManager', () => { providerResolve(validToken); await ready; await setupP; - // Settled — slot is cleared. + // Settled: slot is cleared. expect(tm.tokenReady()).toBeNull(); }); }); diff --git a/packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts b/packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts index aea3bf6aa1..6ef123f070 100644 --- a/packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts +++ b/packages/client/src/coordinator/connection/__tests__/WebSocketTransport.test.ts @@ -112,7 +112,7 @@ describe('WebSocketTransport', () => { } }); - it('close() is idempotent — second call returns the same promise', async () => { + it('close() is idempotent: second call returns the same promise', async () => { const t = new WebSocketTransport({ url: 'wss://x', WebSocketImpl: MockWebSocket as unknown as typeof WebSocket, @@ -141,7 +141,7 @@ describe('WebSocketTransport', () => { onClose: vi.fn(), onError: vi.fn(), }); - // never fired open — readyState stays CONNECTING + // never fired open: readyState stays CONNECTING await expect(t.close(1000, 'manual')).resolves.toBeUndefined(); }); diff --git a/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts b/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts index e60dd7d0cd..fc025e2048 100644 --- a/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts +++ b/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts @@ -11,7 +11,7 @@ type GateState = { * Lifecycle owners (CoordinatorSocket, StreamClient.openConnection, * StreamClient.connectAnonymousUser) call `arm()` to start a new gate cycle. * The handshake calls `resolve()` on success or `reject()` on failure. REST - * callers only ever call `await()` — they MUST NOT call `arm()` so they can + * callers only ever call `await()`; they MUST NOT call `arm()` so they can * never rotate the gate into a fresh pending state with no one to resolve it. * * F1 fix: `reject()` settles the in-flight promise BEFORE a subsequent diff --git a/packages/client/src/coordinator/connection/internal/WebSocketTransport.ts b/packages/client/src/coordinator/connection/internal/WebSocketTransport.ts index d706c7187d..5954a196a0 100644 --- a/packages/client/src/coordinator/connection/internal/WebSocketTransport.ts +++ b/packages/client/src/coordinator/connection/internal/WebSocketTransport.ts @@ -43,7 +43,7 @@ export class WebSocketTransport { /** * Closes the underlying socket and resolves when the close completes (or - * after `gracefulTimeoutMs` if the server never replies). Idempotent — a + * after `gracefulTimeoutMs` if the server never replies). Idempotent: a * second call returns the same promise. */ close = ( @@ -64,7 +64,7 @@ export class WebSocketTransport { try { ws.close(code, reason); } catch { - // already closed — fall through + // already closed: fall through } }); return this.closePromise; diff --git a/packages/client/src/coordinator/connection/token_manager.ts b/packages/client/src/coordinator/connection/token_manager.ts index d8eb3737f9..c4891c2c31 100644 --- a/packages/client/src/coordinator/connection/token_manager.ts +++ b/packages/client/src/coordinator/connection/token_manager.ts @@ -53,8 +53,8 @@ export class TokenManager { /** * Resolves when token is ready. Returns the in-flight promise (or null when no - * load is in progress). Callers may `await` the return value directly — `await null` - * resolves to null, which preserves the legacy contract. + * load is in progress). Callers may `await` the return value directly: + * `await null` resolves to null, which preserves the legacy contract. */ tokenReady = (): Promise | null => this.loadInFlight; diff --git a/packages/client/src/coordinator/connection/types.ts b/packages/client/src/coordinator/connection/types.ts index 46d8a42982..ee89af3aa9 100644 --- a/packages/client/src/coordinator/connection/types.ts +++ b/packages/client/src/coordinator/connection/types.ts @@ -44,7 +44,7 @@ export type APIErrorResponse = { /** * Typed WebSocket connection error. Replaces the legacy practice of - * JSON-stringifying connection metadata into Error.message — consumers can now + * JSON-stringifying connection metadata into Error.message: consumers can now * read `code`, `StatusCode`, `isWSFailure`, etc. directly from the error. */ export class WebSocketConnectionError extends Error { @@ -322,7 +322,7 @@ export type StreamClientOptions = Partial & { /** * When true, route coordinator traffic through the legacy * StreamClient/StableWSConnection implementation instead of the rewritten - * coordinator-client. Temporary flag used during the parallel rollout — will + * coordinator-client. Temporary flag used during the parallel rollout; will * be removed once the new implementation is validated. * * @internal From 2e3d1fea92e0150cd745d23eaf2bea7d141c1979 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:33:45 +0200 Subject: [PATCH 09/18] feat(client): add HeartbeatController with worker-aware health watchdog Owns the ping cadence and the silence watchdog for a single coordinator socket. Both timers go through the injected WorkerTimer (F5) so worker-timer mode is honored end to end. Today only the ping uses the worker; the watchdog uses raw setTimeout and is therefore throttled in background tabs. Note on the watchdog comparison: the plan documents the legacy strict "now - lastEvent > healthTimeoutMs" predicate, but in production timers wake with non-zero jitter so the comparison is effectively ">=". Use ">=" directly so the behaviour matches reality and fake-timer tests become deterministic. Adds unit tests covering the ping cycle, the watchdog firing and false fire guard, stop() cancellation, and the get-client-id-undefined branch. --- .../__tests__/HeartbeatController.test.ts | 131 ++++++++++++++++++ .../__tests__/helpers/fakeTimers.ts | 28 ++++ .../internal/HeartbeatController.ts | 77 ++++++++++ 3 files changed, 236 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/HeartbeatController.test.ts create mode 100644 packages/client/src/coordinator/connection/__tests__/helpers/fakeTimers.ts create mode 100644 packages/client/src/coordinator/connection/internal/HeartbeatController.ts diff --git a/packages/client/src/coordinator/connection/__tests__/HeartbeatController.test.ts b/packages/client/src/coordinator/connection/__tests__/HeartbeatController.test.ts new file mode 100644 index 0000000000..6d162e4df1 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/HeartbeatController.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HeartbeatController } from '../internal/HeartbeatController'; +import { createFakeWorkerTimer } from './helpers/fakeTimers'; + +describe('HeartbeatController', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('start() arms the ping for the configured interval', async () => { + const sendPing = vi.fn(); + const hc = new HeartbeatController({ + options: { pingIntervalMs: 25000, healthTimeoutMs: 35000 }, + timers: createFakeWorkerTimer(), + sendPing, + onUnhealthy: vi.fn(), + getClientId: () => 'client-1', + }); + hc.start(); + expect(sendPing).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(24999); + expect(sendPing).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2); + expect(sendPing).toHaveBeenCalledWith('client-1'); + }); + + it('notePingReply re-arms the ping; sendPing fires once per cycle', async () => { + const sendPing = vi.fn(); + const hc = new HeartbeatController({ + options: { pingIntervalMs: 1000, healthTimeoutMs: 35000 }, + timers: createFakeWorkerTimer(), + sendPing, + onUnhealthy: vi.fn(), + getClientId: () => 'client-1', + }); + hc.start(); + await vi.advanceTimersByTimeAsync(1001); + expect(sendPing).toHaveBeenCalledTimes(1); + hc.notePingReply(); + await vi.advanceTimersByTimeAsync(1001); + expect(sendPing).toHaveBeenCalledTimes(2); + }); + + it('watchdog fires after healthTimeoutMs of silence and calls onUnhealthy', async () => { + const onUnhealthy = vi.fn(); + const hc = new HeartbeatController({ + options: { pingIntervalMs: 25000, healthTimeoutMs: 1000 }, + timers: createFakeWorkerTimer(), + sendPing: vi.fn(), + onUnhealthy, + getClientId: () => 'client-1', + }); + hc.start(); + await vi.advanceTimersByTimeAsync(1000); + expect(onUnhealthy).toHaveBeenCalledTimes(1); + }); + + it('watchdog does NOT fire if noteEventReceived is called before the timeout', async () => { + const onUnhealthy = vi.fn(); + const hc = new HeartbeatController({ + options: { pingIntervalMs: 25000, healthTimeoutMs: 1000 }, + timers: createFakeWorkerTimer(), + sendPing: vi.fn(), + onUnhealthy, + getClientId: () => 'client-1', + }); + hc.start(); + await vi.advanceTimersByTimeAsync(500); + hc.noteEventReceived(); + await vi.advanceTimersByTimeAsync(800); + expect(onUnhealthy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + expect(onUnhealthy).toHaveBeenCalledTimes(1); + }); + + it('watchdog handler false-fire guard via lastEventAt', async () => { + // Fire two notes back-to-back: the first watchdog handler races into the + // second note. Only one onUnhealthy call should happen, and only after the + // most recent note is older than healthTimeoutMs. + const onUnhealthy = vi.fn(); + const hc = new HeartbeatController({ + options: { pingIntervalMs: 25000, healthTimeoutMs: 1000 }, + timers: createFakeWorkerTimer(), + sendPing: vi.fn(), + onUnhealthy, + getClientId: () => 'client-1', + }); + hc.noteEventReceived(); + await vi.advanceTimersByTimeAsync(500); + hc.noteEventReceived(); + await vi.advanceTimersByTimeAsync(900); + expect(onUnhealthy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + expect(onUnhealthy).toHaveBeenCalledTimes(1); + }); + + it('stop() cancels both timers', async () => { + const sendPing = vi.fn(); + const onUnhealthy = vi.fn(); + const hc = new HeartbeatController({ + options: { pingIntervalMs: 1000, healthTimeoutMs: 1000 }, + timers: createFakeWorkerTimer(), + sendPing, + onUnhealthy, + getClientId: () => 'client-1', + }); + hc.start(); + hc.stop(); + await vi.advanceTimersByTimeAsync(2000); + expect(sendPing).not.toHaveBeenCalled(); + expect(onUnhealthy).not.toHaveBeenCalled(); + }); + + it('sendPing is not called if getClientId returns undefined', async () => { + const sendPing = vi.fn(); + const hc = new HeartbeatController({ + options: { pingIntervalMs: 1000, healthTimeoutMs: 35000 }, + timers: createFakeWorkerTimer(), + sendPing, + onUnhealthy: vi.fn(), + getClientId: () => undefined, + }); + hc.start(); + await vi.advanceTimersByTimeAsync(1001); + expect(sendPing).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/src/coordinator/connection/__tests__/helpers/fakeTimers.ts b/packages/client/src/coordinator/connection/__tests__/helpers/fakeTimers.ts new file mode 100644 index 0000000000..894da9096a --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/helpers/fakeTimers.ts @@ -0,0 +1,28 @@ +import type { WorkerTimer } from '@stream-io/worker-timer'; + +/** + * In-memory replacement for WorkerTimer that delegates to the global + * setTimeout / clearTimeout / setInterval / clearInterval. Designed for + * Vitest's `vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })` + * so a single advanceTimersByTimeAsync drives both production timers and any + * test-side scheduling. + */ +export const createFakeWorkerTimer = (): WorkerTimer => { + const timer = { + setTimeout: (cb: () => void, ms: number): number => + setTimeout(cb, ms) as unknown as number, + clearTimeout: (id?: number): void => { + if (id != null) clearTimeout(id); + }, + setInterval: (cb: () => void, ms: number): number => + setInterval(cb, ms) as unknown as number, + clearInterval: (id?: number): void => { + if (id != null) clearInterval(id); + }, + destroy: () => {}, + get ready() { + return true; + }, + }; + return timer as unknown as WorkerTimer; +}; diff --git a/packages/client/src/coordinator/connection/internal/HeartbeatController.ts b/packages/client/src/coordinator/connection/internal/HeartbeatController.ts new file mode 100644 index 0000000000..2702d3af24 --- /dev/null +++ b/packages/client/src/coordinator/connection/internal/HeartbeatController.ts @@ -0,0 +1,77 @@ +import type { WorkerTimer } from '@stream-io/worker-timer'; + +export type HeartbeatOptions = { + pingIntervalMs?: number; + healthTimeoutMs?: number; +}; + +export type HeartbeatControllerArgs = { + options?: HeartbeatOptions; + timers: WorkerTimer; + sendPing: (clientId: string) => void; + onUnhealthy: () => void; + getClientId: () => string | undefined; +}; + +/** + * Owns the ping cadence and the silence watchdog for a single coordinator + * socket. Both timers go through the injected WorkerTimer (F5) so worker-timer + * mode is honored end to end (today only the ping uses the worker; the + * watchdog uses raw setTimeout and is throttled in background tabs). + */ +export class HeartbeatController { + private pingIntervalMs: number; + private healthTimeoutMs: number; + private timers: WorkerTimer; + private sendPing: (clientId: string) => void; + private onUnhealthy: () => void; + private getClientId: () => string | undefined; + private pingHandle?: number; + private watchdogHandle?: number; + private lastEventAt = 0; + + constructor(args: HeartbeatControllerArgs) { + this.pingIntervalMs = args.options?.pingIntervalMs ?? 25000; + this.healthTimeoutMs = args.options?.healthTimeoutMs ?? 35000; + this.timers = args.timers; + this.sendPing = args.sendPing; + this.onUnhealthy = args.onUnhealthy; + this.getClientId = args.getClientId; + } + + start = (): void => { + this.notePingReply(); + this.noteEventReceived(); + }; + + stop = (): void => { + if (this.pingHandle != null) this.timers.clearTimeout(this.pingHandle); + if (this.watchdogHandle != null) + this.timers.clearTimeout(this.watchdogHandle); + this.pingHandle = undefined; + this.watchdogHandle = undefined; + }; + + notePingReply = (): void => { + if (this.pingHandle != null) this.timers.clearTimeout(this.pingHandle); + this.pingHandle = this.timers.setTimeout(() => { + const id = this.getClientId(); + if (id) this.sendPing(id); + }, this.pingIntervalMs); + }; + + noteEventReceived = (): void => { + this.lastEventAt = Date.now(); + if (this.watchdogHandle != null) + this.timers.clearTimeout(this.watchdogHandle); + this.watchdogHandle = this.timers.setTimeout(() => { + // Plan documents the legacy strict `>` here, but in production timers + // wake up with non-zero jitter so the comparison is effectively `>=`. + // Use `>=` directly: it matches real-world behavior and makes + // fake-timer tests deterministic. + if (Date.now() - this.lastEventAt >= this.healthTimeoutMs) { + this.onUnhealthy(); + } + }, this.healthTimeoutMs); + }; +} From fd345c749c7e89357fe83894edeefa4c3e46303b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:45:13 +0200 Subject: [PATCH 10/18] feat(client): add CoordinatorSocket lifecycle (F5,F7,F8,F12,F13,F14) Composes WebSocketTransport, HeartbeatController and ConnectionIdGate, and runs the inline reconnect logic that previously lived inside StableWSConnection. The plan's behavioural deltas land here: - F5: every owned timer (ping, watchdog, unhealthy-dispatch delay, auth-handshake watchdog) goes through the injected WorkerTimer. - F7: on-message expired-token branch logs the corrected "onMessage(): WS failure due to expired token..." wording. - F8: pingIntervalMs / healthTimeoutMs / unhealthyDispatchDelayMs / disconnectTimeoutMs / defaultWsTimeoutMs / authHandshakeTimeoutMs are constructor options. Defaults match today. - F12: in-flight REST callers fail fast on a non-graceful close. The helper invalidateGate rotates a resolved gate (reset + arm + reject) so subsequent gate.await() callers see the typed error and the next successful handshake re-arms via runHandshake's arm(). Manual disconnect() does NOT touch the gate. - F13: connection.ok does NOT set isConnectionOpenResolved. Today's quirk (first mid-stream connection.error silently consumed) is preserved verbatim. Test exercises both halves. - F14: explicit auth-handshake watchdog. Armed in onOpen after the auth message send succeeds; cleared on connection.ok, on the handshake-error branch, on onClose, on onError, and on disconnect(). On fire it rejects the handshake and the gate with WebSocketConnectionError({ code: 'AUTH_HANDSHAKE_TIMEOUT' }) and schedules a reconnect. Also lands a small ConnectionIdGate guard: arm() attaches a no-op catch handler to the internal promise so reject() landing before any caller has attached a handler does not surface as an unhandled rejection. Subsequent await() chains still observe the rejection. --- .../__tests__/CoordinatorSocket.test.ts | 394 +++++++++++ .../connection/internal/ConnectionIdGate.ts | 4 + .../connection/internal/CoordinatorSocket.ts | 642 ++++++++++++++++++ 3 files changed, 1040 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts create mode 100644 packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts diff --git a/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts b/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts new file mode 100644 index 0000000000..bda9a5da28 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts @@ -0,0 +1,394 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CoordinatorSocket } from '../internal/CoordinatorSocket'; +import { ConnectionIdGate } from '../internal/ConnectionIdGate'; +import { EventDispatcher } from '../internal/EventDispatcher'; +import { WebSocketTransport } from '../internal/WebSocketTransport'; +import { TokenManager } from '../token_manager'; +import { WebSocketConnectionError } from '../types'; +import { MockWebSocket } from './helpers/MockWebSocket'; +import { ManualWebSocket } from './helpers/ManualWebSocket'; +import { createFakeLogger } from './helpers/fakeLogger'; +import { createFakeWorkerTimer } from './helpers/fakeTimers'; + +const setupSocket = (overrides?: { + WebSocketImpl?: typeof WebSocket; + authMessage?: string; + staticToken?: boolean; + authHandshakeTimeoutMs?: number; + defaultWsTimeoutMs?: number; + unhealthyDispatchDelayMs?: number; + pingIntervalMs?: number; + healthTimeoutMs?: number; +}) => { + const logger = createFakeLogger(); + const eventDispatcher = new EventDispatcher({ logger }); + const gate = new ConnectionIdGate(); + const tokenManager = new TokenManager( + overrides?.staticToken ? 'server-secret' : undefined, + ); + // Force tokenManager to either static or pretend a token is loaded. + if (overrides?.staticToken) { + // Skip validation by setting state directly. + (tokenManager as unknown as { token: string }).token = 'static-token'; + (tokenManager as unknown as { type: string }).type = 'static'; + } else { + (tokenManager as unknown as { token: string }).token = 'jwt-token'; + (tokenManager as unknown as { type: string }).type = 'provider'; + ( + tokenManager as unknown as { tokenProvider: () => Promise } + ).tokenProvider = async () => 'jwt-token'; + } + + const WebSocketImpl = + overrides?.WebSocketImpl ?? (MockWebSocket as unknown as typeof WebSocket); + + const transportFactory = (url: string) => + new WebSocketTransport({ url, WebSocketImpl }); + + const socket = new CoordinatorSocket({ + urlBuilder: () => 'wss://coordinator/connect', + authMessageBuilder: () => overrides?.authMessage ?? '{"auth":"msg"}', + tokenManager, + eventDispatcher, + gate, + transportFactory, + timers: createFakeWorkerTimer(), + getClientId: () => 'client-1', + logger, + options: { + pingIntervalMs: overrides?.pingIntervalMs ?? 25000, + healthTimeoutMs: overrides?.healthTimeoutMs ?? 35000, + unhealthyDispatchDelayMs: overrides?.unhealthyDispatchDelayMs ?? 5000, + defaultWsTimeoutMs: overrides?.defaultWsTimeoutMs ?? 1000, + authHandshakeTimeoutMs: overrides?.authHandshakeTimeoutMs ?? 1000, + disconnectTimeoutMs: 100, + }, + }); + + return { socket, eventDispatcher, gate, tokenManager, logger }; +}; + +const connectedEvent = (id = 'conn-1') => ({ + type: 'connection.ok', + connection_id: id, + created_at: new Date().toISOString(), + me: { id: 'jane' }, +}); + +describe('CoordinatorSocket', () => { + beforeEach(() => { + MockWebSocket.reset(); + ManualWebSocket.reset(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + vi.setSystemTime(0); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('connect resolves with ConnectedEvent when server replies connection.ok', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const connectPromise = socket.connect(); + // Yield so runHandshake's async prologue runs and creates the WS. + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-id-A')); + const result = await connectPromise; + expect(result?.connection_id).toBe('conn-id-A'); + expect(socket.isHealthy()).toBe(true); + expect(socket.getConnectionId()).toBe('conn-id-A'); + await expect(gate.await()).resolves.toBe('conn-id-A'); + }); + + it('connect rejects with isWSFailure=false when server sends connection.error during handshake', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const connectPromise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 4, message: 'auth failed', StatusCode: 0 }, + }); + await expect(connectPromise).rejects.toBeInstanceOf( + WebSocketConnectionError, + ); + await expect(gate.await()).rejects.toBeInstanceOf(WebSocketConnectionError); + }); + + it('connection.changed:true is dispatched BEFORE the connection.ok event (review fix)', async () => { + const { socket, eventDispatcher, gate } = setupSocket(); + gate.arm(); + const order: string[] = []; + eventDispatcher.on('connection.changed', (e) => + order.push(`changed:${e.online}`), + ); + eventDispatcher.on('connection.ok', () => order.push('ok')); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + expect(order).toEqual(['changed:true', 'ok']); + }); + + it('WS_CLOSED_SUCCESS rejects the handshake but does NOT call gate.reject directly (close path)', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireClose(1000, 'auth rejected', true); + // The handshake error propagates via runHandshake's catch which rejects the gate. + await expect(promise).rejects.toBeInstanceOf(WebSocketConnectionError); + await expect(gate.await()).rejects.toBeInstanceOf(WebSocketConnectionError); + }); + + it('abnormal close rejects the gate with isWSFailure:true (F12)', async () => { + const { socket, eventDispatcher, gate } = setupSocket(); + gate.arm(); + const connection = socket.connect().catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-A')); + await connection; + expect(socket.isHealthy()).toBe(true); + + const onChanged = vi.fn(); + eventDispatcher.on('connection.changed', onChanged); + + ws.fireClose(1006, 'abnormal'); + // gate.reject is called synchronously during onclose. + await expect(gate.await()).rejects.toMatchObject({ isWSFailure: true }); + }); + + it('handleOnline triggers reconnect only when not healthy', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const connection = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-A')); + await connection; + + socket.handleOnline(); + // Healthy path: no new transport. + expect(MockWebSocket.instances).toHaveLength(1); + + // Force unhealthy and call online again: it should schedule a reconnect. + ws.fireClose(1006, 'abnormal'); + socket.handleOnline(); + // Allow the scheduled 10ms reconnect to fire. + await vi.advanceTimersByTimeAsync(20); + expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2); + }); + + it('handleOffline marks unhealthy and dispatches connection.changed:false immediately', async () => { + const { socket, eventDispatcher, gate } = setupSocket(); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + const onChanged = vi.fn(); + eventDispatcher.on('connection.changed', onChanged); + socket.handleOffline(); + expect(onChanged).toHaveBeenCalledWith({ + type: 'connection.changed', + online: false, + }); + expect(socket.isHealthy()).toBe(false); + }); + + it('mid-stream connection.error with code 40 (non-static) schedules reconnect with refreshToken (F7)', async () => { + const { socket, eventDispatcher, gate, tokenManager, logger } = setupSocket( + { staticToken: false }, + ); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + // Spy: provider invocation count on tokenManager.loadToken + const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); + + // Mid-stream connection.error (code 40) expected to fire refresh. + // Note: today's quirk (F13) means the FIRST mid-stream connection.error is + // silently consumed by the handshake-error guard (because + // isConnectionOpenResolved is still false after connection.ok). Send TWO + // errors to exercise the reconnect path. + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 40, message: 'expired', StatusCode: 0 }, + }); + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 40, message: 'expired', StatusCode: 0 }, + }); + // F7 log message wording check. + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining( + 'onMessage(): WS failure due to expired token, scheduling reconnect with refreshed token', + ), + ); + // Allow the scheduled reconnect to fire (random retryInterval; cap 5s). + await vi.advanceTimersByTimeAsync(6000); + expect(loadTokenSpy).toHaveBeenCalled(); + expect(eventDispatcher).toBeDefined(); + }); + + it('non-string message data is parsed as null (binary frame ignored)', async () => { + const { socket, gate, eventDispatcher } = setupSocket(); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + const all = vi.fn(); + eventDispatcher.on('all', all); + ws.fireRawMessage(new ArrayBuffer(8)); + expect(all).not.toHaveBeenCalled(); + }); + + it('disconnect() during a healthy connection does NOT reject the gate (F12 carve-out)', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + const awaiter = gate.await(); + await socket.disconnect(); + // The gate stays settled with the connection_id from before. + await expect(awaiter).resolves.toBe('conn-1'); + }); + + it('disconnect() is idempotent', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + await socket.disconnect(); + await expect(socket.disconnect()).resolves.toBeUndefined(); + expect(socket.isDisconnected()).toBe(true); + }); + + it('auth-handshake watchdog rejects the gate with AUTH_HANDSHAKE_TIMEOUT (F14)', async () => { + const { socket, gate } = setupSocket({ + WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket, + authHandshakeTimeoutMs: 200, + defaultWsTimeoutMs: 500, + }); + gate.arm(); + // A server that never completes the handshake puts the socket into a + // reconnect loop in this fake-timer setup. We verify the typed gate + // rejection and then let the outer connect() resolve via its 500 ms + // poll timeout (well before the random 250-500 ms reconnect delay). + const connectP = socket.connect().catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + const ws = ManualWebSocket.instances[0]; + ws.fireOpen(); + await vi.advanceTimersByTimeAsync(250); + await expect(gate.await()).rejects.toMatchObject({ + code: 'AUTH_HANDSHAKE_TIMEOUT', + isWSFailure: true, + }); + await vi.advanceTimersByTimeAsync(900); + await connectP; + }); + + it('auth-handshake watchdog is cleared on connection.ok (no double reject)', async () => { + const { socket, gate } = setupSocket({ + authHandshakeTimeoutMs: 100, + }); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + const result = await promise; + expect(result?.connection_id).toBe('conn-1'); + // Past the watchdog: must not fire / must not change state. + await vi.advanceTimersByTimeAsync(500); + expect(socket.isHealthy()).toBe(true); + }); + + it('listener that throws in dispatch does not break socket flow (F2 from socket perspective)', async () => { + const { socket, eventDispatcher, gate } = setupSocket(); + gate.arm(); + eventDispatcher.on('connection.ok', () => { + throw new Error('boom'); + }); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await expect(promise).resolves.toBeDefined(); + expect(socket.isHealthy()).toBe(true); + }); + + it('F13 quirk: first mid-stream connection.error is silently consumed; second triggers reconnect', async () => { + const { socket, gate, tokenManager } = setupSocket({ staticToken: false }); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); + + // First mid-stream error: handshake-error guard fires (isConnectionOpenResolved + // was still false), early-return. No reconnect scheduled. + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 40, message: 'expired', StatusCode: 0 }, + }); + expect(loadTokenSpy).not.toHaveBeenCalled(); + + // Second mid-stream error: now isConnectionOpenResolved=true, falls through + // to the reconnect-handler branch. + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 40, message: 'expired', StatusCode: 0 }, + }); + await vi.advanceTimersByTimeAsync(6000); + expect(loadTokenSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts b/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts index fc025e2048..9e0d7a22f8 100644 --- a/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts +++ b/packages/client/src/coordinator/connection/internal/ConnectionIdGate.ts @@ -30,6 +30,10 @@ export class ConnectionIdGate { resolve = res; reject = rej; }); + // Suppress unhandled-rejection warnings when reject() lands before any + // caller has attached a handler. The runtime still surfaces the rejection + // to subsequent `await()` callers via the state.promise chain. + promise.catch(() => {}); this.state = { promise, resolve, reject, settled: false }; }; diff --git a/packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts b/packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts new file mode 100644 index 0000000000..69eb947f42 --- /dev/null +++ b/packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts @@ -0,0 +1,642 @@ +import type { WorkerTimer } from '@stream-io/worker-timer'; +import type { + ConnectedEvent, + ConnectionErrorEvent, +} from '../../../gen/coordinator'; +import { makeSafePromise, type SafePromise } from '../../../helpers/promise'; +import type { ScopedLogger } from '../../../logger'; +import { APIErrorCodes } from '../errors'; +import type { TokenManager } from '../token_manager'; +import { StreamVideoEvent, WebSocketConnectionError } from '../types'; +import { isCloseEvent, KnownCodes, retryInterval, sleep } from '../utils'; +import { ConnectionIdGate } from './ConnectionIdGate'; +import { EventDispatcher } from './EventDispatcher'; +import { HeartbeatController } from './HeartbeatController'; +import { WebSocketTransport } from './WebSocketTransport'; + +export type CoordinatorSocketOptions = { + pingIntervalMs?: number; + healthTimeoutMs?: number; + unhealthyDispatchDelayMs?: number; + disconnectTimeoutMs?: number; + defaultWsTimeoutMs?: number; + authHandshakeTimeoutMs?: number; +}; + +export type CoordinatorSocketArgs = { + urlBuilder: () => string; + authMessageBuilder: () => string; + tokenManager: TokenManager; + eventDispatcher: EventDispatcher; + gate: ConnectionIdGate; + transportFactory: (url: string) => WebSocketTransport; + timers: WorkerTimer; + getClientId: () => string | undefined; + logger: ScopedLogger; + options?: CoordinatorSocketOptions; +}; + +type ResolvedOptions = Required; + +const resolveOptions = ( + options: CoordinatorSocketOptions | undefined, +): ResolvedOptions => { + const defaultWsTimeoutMs = options?.defaultWsTimeoutMs ?? 15000; + return { + pingIntervalMs: options?.pingIntervalMs ?? 25000, + healthTimeoutMs: options?.healthTimeoutMs ?? 35000, + unhealthyDispatchDelayMs: options?.unhealthyDispatchDelayMs ?? 5000, + disconnectTimeoutMs: options?.disconnectTimeoutMs ?? 1000, + defaultWsTimeoutMs, + authHandshakeTimeoutMs: + options?.authHandshakeTimeoutMs ?? defaultWsTimeoutMs, + }; +}; + +const isWebSocketConnectionError = ( + err: unknown, +): err is WebSocketConnectionError => err instanceof WebSocketConnectionError; + +/** + * Lifecycle owner for one logical coordinator WebSocket. Composes + * WebSocketTransport, HeartbeatController and ConnectionIdGate, and runs the + * inline reconnect logic that previously lived in StableWSConnection. + */ +export class CoordinatorSocket { + private urlBuilder: () => string; + private authMessageBuilder: () => string; + private tokenManager: TokenManager; + private eventDispatcher: EventDispatcher; + private gate: ConnectionIdGate; + private transportFactory: (url: string) => WebSocketTransport; + private timers: WorkerTimer; + private getClientId: () => string | undefined; + private logger: ScopedLogger; + private options: ResolvedOptions; + + private wsId = 1; + private transport?: WebSocketTransport; + private connectionId?: string; + private healthy = false; + private connecting = false; + private disconnected = false; + private lastEvent: Date | null = null; + private connectionOpenSafe?: SafePromise; + private resolveConnectionOpen?: (event: ConnectedEvent) => void; + private rejectConnectionOpen?: (err: Error) => void; + private isConnectionOpenResolved = false; + private unhealthyDispatchHandle?: number; + private authHandshakeHandle?: number; + private wsConsecutiveFailures = 0; + private totalFailures = 0; + + private heartbeat: HeartbeatController; + + constructor(args: CoordinatorSocketArgs) { + this.urlBuilder = args.urlBuilder; + this.authMessageBuilder = args.authMessageBuilder; + this.tokenManager = args.tokenManager; + this.eventDispatcher = args.eventDispatcher; + this.gate = args.gate; + this.transportFactory = args.transportFactory; + this.timers = args.timers; + this.getClientId = args.getClientId; + this.logger = args.logger; + this.options = resolveOptions(args.options); + + this.heartbeat = new HeartbeatController({ + options: { + pingIntervalMs: this.options.pingIntervalMs, + healthTimeoutMs: this.options.healthTimeoutMs, + }, + timers: this.timers, + sendPing: this.sendHealthCheck, + onUnhealthy: this.onWatchdogUnhealthy, + getClientId: this.getClientId, + }); + } + + // public ---------------------------------------------------------------- + + connect = async (timeout?: number): Promise => { + if (this.connecting) { + throw new Error( + "You've called connect twice, can only attempt 1 connection at the time", + ); + } + this.disconnected = false; + + try { + const handshake = await this.runHandshake(); + this.wsConsecutiveFailures = 0; + this.logger.info( + `connect() established ws connection with healthcheck: ${handshake?.connection_id}`, + ); + } catch (err) { + this.healthy = false; + this.wsConsecutiveFailures += 1; + const code = (err as { code?: string | number } | undefined)?.code; + const isWSFailure = isWebSocketConnectionError(err) + ? err.isWSFailure + : Boolean((err as { isWSFailure?: boolean })?.isWSFailure); + if (code === KnownCodes.TOKEN_EXPIRED && !this.tokenManager.isStatic()) { + // Fire-and-forget so waitForHealthy() can poll for the reconnect attempt. + this.scheduleReconnect({ refreshToken: true }); + } else if (!isWSFailure) { + if (isWebSocketConnectionError(err)) throw err; + const fallbackMessage = + err instanceof Error ? err.message : String(err); + throw new WebSocketConnectionError({ + code: code ?? '', + StatusCode: + (err as { StatusCode?: string | number } | undefined)?.StatusCode ?? + '', + message: fallbackMessage, + isWSFailure: false, + }); + } + // wsFailure: fall through to waitForHealthy poll + } + + return await this.waitForHealthy( + timeout ?? this.options.defaultWsTimeoutMs, + ); + }; + + disconnect = async (timeout?: number): Promise => { + this.logger.info(`disconnect() closing ws ${this.wsId}`); + this.wsId += 1; + this.connecting = false; + this.disconnected = true; + this.heartbeat.stop(); + this.clearAuthHandshakeWatchdog(); + if (this.unhealthyDispatchHandle != null) { + this.timers.clearTimeout(this.unhealthyDispatchHandle); + this.unhealthyDispatchHandle = undefined; + } + this.healthy = false; + const transport = this.transport; + this.transport = undefined; + if (!transport) return; + await transport.close( + KnownCodes.WS_CLOSED_SUCCESS, + 'Manually closed connection by calling client.disconnect()', + timeout ?? this.options.disconnectTimeoutMs, + ); + }; + + /** + * Polls the in-flight connection promise until it resolves with the + * connected event, or rejects after `timeout` with a typed + * WebSocketConnectionError. + */ + waitForHealthy = async ( + timeout = this.options.defaultWsTimeoutMs, + ): Promise => { + return Promise.race([this.poll(timeout), this.outerTimeout(timeout)]); + }; + + getConnectionId = (): string | undefined => this.connectionId; + isHealthy = (): boolean => this.healthy; + isConnecting = (): boolean => this.connecting; + isDisconnected = (): boolean => this.disconnected; + + handleOnline = (): void => { + this.logger.info('online: checking reconnect', { healthy: this.healthy }); + if (!this.healthy) this.scheduleReconnect({ interval: 10 }); + }; + + handleOffline = (): void => { + this.logger.info('offline: marking unhealthy'); + this.setHealth(false, true); + }; + + // private --------------------------------------------------------------- + + private poll = async ( + timeout: number, + ): Promise => { + const interval = 50; + for (let i = 0; i <= timeout; i += interval) { + const safe = this.connectionOpenSafe; + if (!safe) { + await sleep(interval); + continue; + } + try { + return await safe(); + } catch (err) { + if (i === timeout) { + if (isWebSocketConnectionError(err)) throw err; + const e = err as { + code?: string | number; + StatusCode?: string | number; + message?: string; + isWSFailure?: boolean; + }; + throw new WebSocketConnectionError({ + code: e?.code ?? '', + StatusCode: e?.StatusCode ?? '', + message: e?.message ?? 'WS handshake failed', + isWSFailure: !!e?.isWSFailure, + }); + } + await sleep(interval); + } + } + return undefined; + }; + + private outerTimeout = async (timeout: number): Promise => { + await sleep(timeout); + this.connecting = false; + throw new WebSocketConnectionError({ + code: '', + StatusCode: '', + message: 'initial WS connection could not be established', + isWSFailure: true, + }); + }; + + private setupConnectionPromise = (): void => { + this.isConnectionOpenResolved = false; + const promise = new Promise((resolve, reject) => { + this.resolveConnectionOpen = resolve; + this.rejectConnectionOpen = reject; + }); + this.connectionOpenSafe = makeSafePromise(promise); + }; + + private runHandshake = async (): Promise => { + try { + await this.tokenManager.tokenReady(); + } catch { + // swallow: loadToken below will retry + } + if (!this.tokenManager.getToken() && !this.tokenManager.isStatic()) { + await this.tokenManager.loadToken(); + } + + this.connecting = true; + this.gate.arm(); + this.setupConnectionPromise(); + const url = this.urlBuilder(); + const myWsId = this.wsId; + this.transport = this.transportFactory(url); + this.transport.open({ + onOpen: () => this.onOpen(myWsId), + onMessage: (event) => this.onMessage(myWsId, event), + onClose: (event) => this.onClose(myWsId, event), + onError: (event) => this.onError(myWsId, event), + }); + + try { + const safe = this.connectionOpenSafe!; + const response = await safe(); + this.connecting = false; + if (response) { + this.connectionId = response.connection_id; + this.gate.resolve(this.connectionId); + this.heartbeat.start(); + return response; + } + return undefined; + } catch (err) { + this.gate.reject(err as Error); + this.connecting = false; + this.logger.error('runHandshake error', err); + throw err; + } + }; + + private scheduleReconnect = (opts?: { + interval?: number; + refreshToken?: boolean; + }): void => { + if (this.connecting || this.healthy) { + this.logger.debug('reconnect: abort (1) already connecting or healthy'); + return; + } + const delay = opts?.interval ?? retryInterval(this.wsConsecutiveFailures); + void this.runScheduledReconnect(delay, opts?.refreshToken); + }; + + private runScheduledReconnect = async ( + delay: number, + refreshToken?: boolean, + ): Promise => { + await sleep(delay); + if (this.connecting || this.healthy) return; + if (this.disconnected) return; + await this.handleReconnectAttempt({ refreshToken }); + }; + + private handleReconnectAttempt = async (opts: { + refreshToken?: boolean; + }): Promise => { + this.destroyCurrentTransport(); + if (opts.refreshToken) { + try { + await this.tokenManager.loadToken(); + } catch (e) { + this.logger.error('reconnect: token refresh failed', e); + return; + } + } + try { + await this.runHandshake(); + this.wsConsecutiveFailures = 0; + } catch (err) { + const code = (err as { code?: string | number } | undefined)?.code; + const isWSFailure = isWebSocketConnectionError(err) + ? err.isWSFailure + : Boolean((err as { isWSFailure?: boolean })?.isWSFailure); + if (code === KnownCodes.TOKEN_EXPIRED && !this.tokenManager.isStatic()) { + this.scheduleReconnect({ refreshToken: true }); + return; + } + if (isWSFailure) { + this.scheduleReconnect(); + return; + } + this.logger.error( + 'handleReconnectAttempt: non-WS failure, giving up silently', + err, + ); + } + }; + + private destroyCurrentTransport = (): void => { + this.wsId += 1; + const transport = this.transport; + this.transport = undefined; + if (!transport) return; + void transport.close( + KnownCodes.WS_CLOSED_SUCCESS, + 'reconnect: closing previous transport', + this.options.disconnectTimeoutMs, + ); + }; + + // event handlers -------------------------------------------------------- + + private onOpen = (myWsId: number): void => { + if (myWsId !== this.wsId) return; + let authMessage: string; + try { + authMessage = this.authMessageBuilder(); + } catch (err) { + this.logger.error( + 'onopen() failed to build auth message; not sending', + err, + ); + return; + } + this.logger.trace('onopen() sending auth message', { authMessage }); + const ok = this.transport!.send(authMessage); + if (!ok) { + this.logger.error('onopen() auth message send failed'); + return; + } + this.logger.info('onopen() onopen callback', { wsId: myWsId }); + this.armAuthHandshakeWatchdog(myWsId); + }; + + private onMessage = (myWsId: number, event: MessageEvent): void => { + if (myWsId !== this.wsId) return; + + const data = + typeof event.data === 'string' + ? (JSON.parse(event.data) as StreamVideoEvent) + : null; + + if ( + !this.isConnectionOpenResolved && + data && + data.type === 'connection.error' + ) { + this.isConnectionOpenResolved = true; + this.clearAuthHandshakeWatchdog(); + const errEvent = data as unknown as ConnectionErrorEvent; + if (errEvent.error) { + this.rejectConnectionOpen?.(this.errorFromWSEvent(errEvent, false)); + return; + } + } + + this.lastEvent = new Date(); + + if ( + data && + (data.type === 'health.check' || data.type === 'connection.ok') + ) { + this.heartbeat.notePingReply(); + } + + if (data && data.type === 'connection.ok') { + this.clearAuthHandshakeWatchdog(); + this.resolveConnectionOpen?.(data); + this.setHealth(true); + } + + if (data && data.type === 'connection.error') { + const errEvent = data as unknown as ConnectionErrorEvent; + const code = errEvent.error?.code; + this.healthy = false; + this.connecting = false; + this.wsConsecutiveFailures += 1; + if (code === KnownCodes.TOKEN_EXPIRED && !this.tokenManager.isStatic()) { + this.heartbeat.stop(); + this.logger.info( + 'onMessage(): WS failure due to expired token, scheduling reconnect with refreshed token', + ); + this.scheduleReconnect({ refreshToken: true }); + } + } + + if (data) { + data.received_at = new Date(); + this.eventDispatcher.dispatch(data); + } + this.heartbeat.noteEventReceived(); + }; + + private onClose = (myWsId: number, event: CloseEvent): void => { + if (myWsId !== this.wsId) return; + this.logger.info(`onclose() code ${event.code}`, { event, wsId: myWsId }); + this.clearAuthHandshakeWatchdog(); + + if (event.code === KnownCodes.WS_CLOSED_SUCCESS) { + const err = new WebSocketConnectionError({ + code: event.code, + StatusCode: 0, + message: `WS connection reject with error ${event.reason}`, + isWSFailure: false, + reason: event.reason, + wasClean: event.wasClean, + }); + this.rejectConnectionOpen?.(err); + this.logger.info(`onclose() WS connection rejected: ${event.reason}`, { + event, + }); + return; + } + + this.wsConsecutiveFailures += 1; + this.totalFailures += 1; + this.setHealth(false); + this.connecting = false; + const wsErr = this.errorFromWSEvent(event, true); + this.rejectConnectionOpen?.(wsErr); + this.invalidateGate(wsErr); + this.logger.info('onclose() abnormal close, reconnecting', { event }); + this.scheduleReconnect(); + }; + + private onError = (myWsId: number, event: Event): void => { + if (myWsId !== this.wsId) return; + this.clearAuthHandshakeWatchdog(); + this.wsConsecutiveFailures += 1; + this.totalFailures += 1; + this.setHealth(false); + this.connecting = false; + const wsErr = new WebSocketConnectionError({ + code: '', + StatusCode: 0, + message: 'WebSocket connection error', + isWSFailure: true, + }); + this.logger.warn('onerror() WS connection error', { event }); + this.rejectConnectionOpen?.(wsErr); + this.invalidateGate(wsErr); + this.scheduleReconnect(); + }; + + // helpers --------------------------------------------------------------- + + private setHealth = (healthy: boolean, dispatchImmediately = false): void => { + if (healthy === this.healthy) return; + this.healthy = healthy; + + if (this.unhealthyDispatchHandle != null) { + this.timers.clearTimeout(this.unhealthyDispatchHandle); + this.unhealthyDispatchHandle = undefined; + } + + if (this.healthy || dispatchImmediately) { + this.eventDispatcher.dispatch({ + type: 'connection.changed', + online: this.healthy, + }); + return; + } + + this.unhealthyDispatchHandle = this.timers.setTimeout(() => { + if (this.healthy) return; + this.eventDispatcher.dispatch({ + type: 'connection.changed', + online: false, + }); + }, this.options.unhealthyDispatchDelayMs); + }; + + private sendHealthCheck = (clientId: string): void => { + const payload = JSON.stringify([ + { type: 'health.check', client_id: clientId }, + ]); + this.transport?.send(payload); + }; + + private onWatchdogUnhealthy = (): void => { + this.logger.info('watchdog: marking connection unhealthy and reconnecting'); + this.setHealth(false); + this.scheduleReconnect(); + }; + + /** + * Reject the gate after a non-graceful close so callers awaiting the gate + * see the typed error. If the gate is currently pending, reject directly. If + * it has a resolved connection_id, rotate it: reset + arm + reject. New + * callers then see the rejection; the next successful handshake re-arms via + * runHandshake's `arm()`. + */ + private invalidateGate = (err: Error): void => { + if (this.gate.isPending()) { + this.gate.reject(err); + return; + } + if (this.gate.isSettled()) { + this.gate.reset(); + this.gate.arm(); + this.gate.reject(err); + } + }; + + private armAuthHandshakeWatchdog = (myWsId: number): void => { + if (this.authHandshakeHandle != null) { + this.timers.clearTimeout(this.authHandshakeHandle); + } + this.authHandshakeHandle = this.timers.setTimeout(() => { + if (myWsId !== this.wsId) return; + if (this.isConnectionOpenResolved) return; + if (this.connectionId) return; + const err = new WebSocketConnectionError({ + code: 'AUTH_HANDSHAKE_TIMEOUT', + StatusCode: 0, + message: 'auth handshake did not complete in time', + isWSFailure: true, + }); + this.logger.warn( + 'auth handshake timed out: rejecting handshake and scheduling reconnect', + { wsId: myWsId }, + ); + this.isConnectionOpenResolved = true; + this.rejectConnectionOpen?.(err); + this.gate.reject(err); + this.connecting = false; + this.scheduleReconnect(); + }, this.options.authHandshakeTimeoutMs); + }; + + private clearAuthHandshakeWatchdog = (): void => { + if (this.authHandshakeHandle != null) { + this.timers.clearTimeout(this.authHandshakeHandle); + this.authHandshakeHandle = undefined; + } + }; + + private errorFromWSEvent = ( + event: CloseEvent | ConnectionErrorEvent, + isWSFailure = true, + ): WebSocketConnectionError => { + let code: number | string; + let statusCode: number | string; + let message: string; + let reason: string | undefined; + let wasClean: boolean | undefined; + if (isCloseEvent(event)) { + code = event.code; + message = event.reason; + statusCode = 0; + reason = event.reason; + wasClean = event.wasClean; + } else { + const apiError = event.error; + code = apiError.code; + message = apiError.message; + statusCode = apiError.StatusCode; + } + const friendly = `WS failed with code: ${code}: ${ + typeof code === 'number' ? APIErrorCodes[code] || code : code + } and reason: ${message}`; + this.logger.warn(friendly, { event }); + return new WebSocketConnectionError({ + code, + StatusCode: statusCode, + message: friendly, + isWSFailure, + reason, + wasClean, + }); + }; +} From 8a43534621d96b8087a846bcb9625bf07a40dfbc Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:48:43 +0200 Subject: [PATCH 11/18] feat(client): add RestClient with bounded retries and shared timeout (F4,F14) Owns the axios call site, header/param enrichment, and the auth-gating phase of every non-public REST request. The plan's behavioural deltas land here: - F4: cap the per-request token-expired retry depth via tokenExpiryRetryLimit (default 2). After the cap, the typed ErrorFromResponse propagates instead of looping forever. - F14: wrap the entire auth-gating phase (gate.await + optional socket.waitForHealthy + second gate.await) in a single restConnectionIdTimeoutMs deadline. Defaults to defaultWsTimeoutMs. On exhaustion, throws WebSocketConnectionError with code CONNECTION_ID_TIMEOUT instead of hanging. - Lazy socket binding (Arch#1): RestClient takes getSocket() as a callback, so it can be constructed before openConnection runs and picks up the live socket per-request. Header / param shape is preserved byte-for-byte: user_id, connection_id, api_key params; Authorization (omitted for public without user), stream-auth-type, X-Stream-Client, x-client-request-id headers; axios-config overrides applied last. Adds unit tests covering: - public vs private enrichment - gate-blocked private requests - token-expired retry once and the F4 cap (3 attempts when limit=2) - token-expired with isStatic() (no retry) - gate rejection plus waitForHealthy fallback - F14 timeout: pending past 99 ms, rejected at 101 ms - public endpoints exempt from the timeout - lazy socket binding (Arch#1) --- .../connection/__tests__/RestClient.test.ts | 359 ++++++++++++++++++ .../connection/internal/RestClient.ts | 332 ++++++++++++++++ 2 files changed, 691 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/RestClient.test.ts create mode 100644 packages/client/src/coordinator/connection/internal/RestClient.ts diff --git a/packages/client/src/coordinator/connection/__tests__/RestClient.test.ts b/packages/client/src/coordinator/connection/__tests__/RestClient.test.ts new file mode 100644 index 0000000000..0f785130d9 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/RestClient.test.ts @@ -0,0 +1,359 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest'; +import { RestClient } from '../internal/RestClient'; +import { ConnectionIdGate } from '../internal/ConnectionIdGate'; +import { TokenManager } from '../token_manager'; +import { + ErrorFromResponse, + WebSocketConnectionError, + type StreamClientOptions, + type UserWithId, +} from '../types'; +import { createFakeLogger } from './helpers/fakeLogger'; +import type { CoordinatorSocket } from '../internal/CoordinatorSocket'; + +const encodeBase64Url = (input: string): string => { + const b64 = + typeof btoa === 'function' + ? btoa(input) + : Buffer.from(input, 'utf8').toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const jwtFor = (userId: string): string => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = encodeBase64Url(JSON.stringify({ user_id: userId })); + return `${header}.${body}.signature`; +}; + +type AxiosFn = Mock; + +const createAxiosMock = () => { + const get: AxiosFn = vi.fn(); + const post: AxiosFn = vi.fn(); + const put: AxiosFn = vi.fn(); + const patch: AxiosFn = vi.fn(); + const del: AxiosFn = vi.fn(); + const options: AxiosFn = vi.fn(); + return { + instance: { + get, + post, + put, + patch, + delete: del, + options, + } as unknown as Parameters[0]['axiosInstance'], + fns: { get, post, put, patch, delete: del, options }, + }; +}; + +const setupClient = (overrides?: { + user?: UserWithId; + staticToken?: boolean; + socket?: CoordinatorSocket; + guestUserCreatePromise?: Promise; + options?: StreamClientOptions; + tokenExpiryRetryLimit?: number; + restConnectionIdTimeoutMs?: number; +}) => { + const axios = createAxiosMock(); + const gate = new ConnectionIdGate(); + const tokenManager = new TokenManager( + overrides?.staticToken ? 'server-secret' : undefined, + ); + const userId = overrides?.user?.id ?? 'jane'; + const validToken = jwtFor(userId); + if (overrides?.staticToken) { + (tokenManager as unknown as { token: string }).token = 'static-token'; + (tokenManager as unknown as { type: string }).type = 'static'; + } else { + (tokenManager as unknown as { token: string }).token = validToken; + (tokenManager as unknown as { type: string }).type = 'provider'; + (tokenManager as unknown as { user: UserWithId | undefined }).user = + overrides?.user; + ( + tokenManager as unknown as { tokenProvider: () => Promise } + ).tokenProvider = async () => validToken; + } + const user = overrides?.user; + const socket = overrides?.socket; + const client = new RestClient({ + axiosInstance: axios.instance, + tokenManager, + options: overrides?.options ?? {}, + getApiKey: () => 'api-key', + getUserId: () => user?.id, + getUser: () => user, + getAuthType: () => (user?.id === '!anon' ? 'anonymous' : 'jwt'), + getUserAgent: () => 'stream-video-js-vTEST|client_bundle=node', + gate, + getSocket: () => socket, + getGuestUserCreatePromise: () => overrides?.guestUserCreatePromise, + logger: createFakeLogger(), + tokenExpiryRetryLimit: overrides?.tokenExpiryRetryLimit, + restConnectionIdTimeoutMs: overrides?.restConnectionIdTimeoutMs, + defaultWsTimeoutMs: 1000, + }); + return { client, axios: axios.fns, gate, tokenManager }; +}; + +const tokenExpiredResponse = { + data: { + code: 40, + message: 'token expired', + StatusCode: 401, + }, + status: 401, +}; + +describe('RestClient', () => { + it('enriches public requests without Authorization and with anonymous auth-type when no user', async () => { + const { client, axios } = setupClient(); + axios.get.mockResolvedValueOnce({ data: { ok: true } }); + await client.request<{ ok: boolean }>('get', '/foo', undefined, { + publicEndpoint: true, + }); + const config = axios.get.mock.calls[0][1]; + expect(config.headers.Authorization).toBeUndefined(); + expect(config.headers['stream-auth-type']).toBe('anonymous'); + expect(config.params.api_key).toBe('api-key'); + expect(config.params.connection_id).toBeUndefined(); + }); + + it('enriches private requests with Authorization and connection_id from the socket', async () => { + const fakeSocket = { + getConnectionId: () => 'conn-A', + waitForHealthy: vi.fn(), + } as unknown as CoordinatorSocket; + const { client, axios, gate, tokenManager } = setupClient({ + user: { id: 'jane' }, + socket: fakeSocket, + }); + gate.arm(); + gate.resolve('conn-A'); + axios.get.mockResolvedValueOnce({ data: { ok: true } }); + await client.request('get', '/foo'); + const config = axios.get.mock.calls[0][1]; + expect(config.headers.Authorization).toBe(tokenManager.getToken()); + expect(config.headers['stream-auth-type']).toBe('jwt'); + expect(config.params.connection_id).toBe('conn-A'); + expect(config.params.user_id).toBe('jane'); + }); + + it('attaches a generated x-client-request-id when none is supplied', async () => { + const { client, axios, gate } = setupClient({ user: { id: 'jane' } }); + gate.arm(); + gate.resolve('conn-A'); + axios.get.mockResolvedValueOnce({ data: { ok: true } }); + await client.request('get', '/foo'); + const config = axios.get.mock.calls[0][1]; + expect(config.headers['x-client-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it('blocks on gate.await for non-public requests', async () => { + const { client, axios, gate } = setupClient({ user: { id: 'jane' } }); + gate.arm(); + axios.get.mockResolvedValueOnce({ data: { ok: true } }); + const inflight = client.request('get', '/foo'); + let settled = false; + inflight.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + expect(axios.get).not.toHaveBeenCalled(); + gate.resolve('conn-A'); + await inflight; + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('skips the gate for public requests', async () => { + const { client, axios } = setupClient(); + axios.post.mockResolvedValueOnce({ data: { ok: true } }); + await client.request( + 'post', + '/guest', + { user: { id: 'jane' } }, + { + publicEndpoint: true, + }, + ); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + it('retries once on token-expired then succeeds (refreshes the token)', async () => { + const { client, axios, gate, tokenManager } = setupClient({ + user: { id: 'jane' }, + }); + gate.arm(); + gate.resolve('conn-A'); + const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); + axios.get + .mockRejectedValueOnce({ response: tokenExpiredResponse }) + .mockResolvedValueOnce({ data: { ok: true } }); + const result = await client.request<{ ok: boolean }>('get', '/foo'); + expect(result).toEqual({ ok: true }); + expect(loadTokenSpy).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + it('caps token-expired retry depth via tokenExpiryRetryLimit (F4)', async () => { + const { client, axios, gate } = setupClient({ + user: { id: 'jane' }, + tokenExpiryRetryLimit: 2, + }); + gate.arm(); + gate.resolve('conn-A'); + axios.get.mockRejectedValue({ response: tokenExpiredResponse }); + await expect(client.request('get', '/foo')).rejects.toBeInstanceOf( + ErrorFromResponse, + ); + // initial attempt + 2 retries = 3 axios calls + expect(axios.get).toHaveBeenCalledTimes(3); + }); + + it('does NOT retry on token-expired when tokenManager.isStatic()', async () => { + const { client, axios, gate } = setupClient({ + user: { id: 'jane' }, + staticToken: true, + }); + gate.arm(); + gate.resolve('conn-A'); + axios.get.mockRejectedValueOnce({ response: tokenExpiredResponse }); + await expect(client.request('get', '/foo')).rejects.toBeInstanceOf( + ErrorFromResponse, + ); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('attaches client_request_id when the network rejects without a response', async () => { + const { client, axios, gate } = setupClient({ user: { id: 'jane' } }); + gate.arm(); + gate.resolve('conn-A'); + const error: { response?: unknown; client_request_id?: string } = {}; + axios.get.mockRejectedValueOnce(error); + await expect(client.request('get', '/foo')).rejects.toBe(error); + expect(error.client_request_id).toBeTypeOf('string'); + }); + + it('uses fallback waitForHealthy when the gate rejects, then re-awaits the gate (F1 fallback)', async () => { + const waitForHealthy = vi.fn(async () => undefined); + const fakeSocket = { + getConnectionId: () => 'conn-B', + waitForHealthy, + } as unknown as CoordinatorSocket; + const { client, axios, gate } = setupClient({ + user: { id: 'jane' }, + socket: fakeSocket, + }); + gate.arm(); + // Reject the gate to simulate broken close. + gate.reject( + new WebSocketConnectionError({ + code: 1006, + StatusCode: 0, + message: 'closed', + isWSFailure: true, + }), + ); + axios.get.mockResolvedValueOnce({ data: { ok: true } }); + // The fallback runs waitForHealthy(remaining). Inside it, simulate that + // a fresh gate.arm() ran (mimicking the next handshake), then resolve. + waitForHealthy.mockImplementationOnce(async () => { + gate.arm(); + gate.resolve('conn-B'); + return undefined; + }); + const result = await client.request<{ ok: boolean }>('get', '/foo'); + expect(result).toEqual({ ok: true }); + expect(waitForHealthy).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + describe('connection-id timeout (F14)', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('rejects with CONNECTION_ID_TIMEOUT when the gate never resolves', async () => { + const { client, gate } = setupClient({ + user: { id: 'jane' }, + restConnectionIdTimeoutMs: 100, + }); + gate.arm(); + const promise = client.request('get', '/foo').catch((e) => e); + await vi.advanceTimersByTimeAsync(99); + const pending = await Promise.race([ + promise, + Promise.resolve('still-pending'), + ]); + expect(pending).toBe('still-pending'); + await vi.advanceTimersByTimeAsync(2); + const result = await promise; + expect(result).toBeInstanceOf(WebSocketConnectionError); + expect((result as WebSocketConnectionError).code).toBe( + 'CONNECTION_ID_TIMEOUT', + ); + }); + + it('public-endpoint requests are NOT subject to the connection-id timeout', async () => { + const { client, axios } = setupClient({ + user: { id: 'jane' }, + restConnectionIdTimeoutMs: 100, + }); + axios.get.mockResolvedValueOnce({ data: { ok: true } }); + const promise = client.request('get', '/health', undefined, { + publicEndpoint: true, + }); + await vi.advanceTimersByTimeAsync(200); + await expect(promise).resolves.toEqual({ ok: true }); + }); + }); + + it('is constructable with getSocket() returning undefined and only connects to a socket later (Arch#1)', async () => { + let socket: CoordinatorSocket | undefined; + const axios = createAxiosMock(); + const tokenManager = new TokenManager('server-secret'); + (tokenManager as unknown as { token: string }).token = 'static-token'; + (tokenManager as unknown as { type: string }).type = 'static'; + const gate = new ConnectionIdGate(); + const client = new RestClient({ + axiosInstance: axios.instance, + tokenManager, + options: {}, + getApiKey: () => 'api-key', + getUserId: () => 'jane', + getUser: () => ({ id: 'jane' }), + getAuthType: () => 'jwt', + getUserAgent: () => 'ua', + gate, + getSocket: () => socket, + getGuestUserCreatePromise: () => undefined, + logger: createFakeLogger(), + defaultWsTimeoutMs: 1000, + }); + socket = { + getConnectionId: () => 'conn-Z', + waitForHealthy: vi.fn(), + } as unknown as CoordinatorSocket; + gate.arm(); + gate.resolve('conn-Z'); + axios.fns.get.mockResolvedValueOnce({ data: { ok: true } }); + await client.request('get', '/foo'); + const config = axios.fns.get.mock.calls[0][1]; + expect(config.params.connection_id).toBe('conn-Z'); + }); +}); diff --git a/packages/client/src/coordinator/connection/internal/RestClient.ts b/packages/client/src/coordinator/connection/internal/RestClient.ts new file mode 100644 index 0000000000..d70297d1fe --- /dev/null +++ b/packages/client/src/coordinator/connection/internal/RestClient.ts @@ -0,0 +1,332 @@ +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { ScopedLogger } from '../../../logger'; +import { + APIErrorResponse, + ErrorFromResponse, + StreamClientOptions, + UserWithId, + WebSocketConnectionError, +} from '../types'; +import { + generateUUIDv4, + isErrorResponse, + KnownCodes, + retryInterval, + sleep, +} from '../utils'; +import type { TokenManager } from '../token_manager'; +import type { ConnectionIdGate } from './ConnectionIdGate'; +import type { CoordinatorSocket } from './CoordinatorSocket'; + +type AuthType = 'jwt' | 'anonymous'; + +export type RestClientDependencies = { + axiosInstance: AxiosInstance; + tokenManager: TokenManager; + options: StreamClientOptions; + getApiKey: () => string; + getUserId: () => string | undefined; + getUser: () => UserWithId | undefined; + getAuthType: () => AuthType; + getUserAgent: () => string; + gate: ConnectionIdGate; + /** Re-read each request so the socket can be created lazily in openConnection. */ + getSocket: () => CoordinatorSocket | undefined; + getGuestUserCreatePromise: () => Promise | undefined; + logger: ScopedLogger; + /** F4: cap the per-request token-expired retry depth. Defaults to 2. */ + tokenExpiryRetryLimit?: number; + /** Default 15000. Used as the initial budget when no per-call timeout is set. */ + defaultWsTimeoutMs?: number; + /** + * F14: end-to-end deadline (ms) for the auth-gating phase of a non-public + * REST request. Defaults to defaultWsTimeoutMs. + */ + restConnectionIdTimeoutMs?: number; +}; + +type RequestType = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options'; + +type RequestOptions = AxiosRequestConfig & { + config?: AxiosRequestConfig; + publicEndpoint?: boolean; +}; + +const newConnectionIdTimeoutError = (): WebSocketConnectionError => + new WebSocketConnectionError({ + code: 'CONNECTION_ID_TIMEOUT', + StatusCode: 0, + message: 'connection id not available within timeout', + isWSFailure: true, + }); + +/** + * Race a promise against a timeout. If `ms <= 0`, throws `errFactory()` + * immediately. If the timeout wins, throws `errFactory()` and clears the + * scheduled timer. + */ +async function raceWithTimeout( + promise: Promise, + ms: number, + errFactory: () => Error, +): Promise { + if (ms <= 0) throw errFactory(); + let timeoutHandle: ReturnType | undefined; + const timeoutP = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(errFactory()), ms); + }); + try { + return await Promise.race([promise, timeoutP]); + } finally { + if (timeoutHandle != null) clearTimeout(timeoutHandle); + } +} + +/** + * REST adapter around axios. Owns header/param enrichment, the auth-gating + * phase deadline (F14), and the per-request token-expired retry cap (F4). + * Does NOT depend on CoordinatorSocket at construction time: receives a + * `getSocket()` callback that's re-read at request time. + */ +export class RestClient { + private deps: RestClientDependencies; + private retryLimit: number; + private defaultWsTimeoutMs: number; + private connectionIdTimeoutMs: number; + consecutiveFailures = 0; + + constructor(deps: RestClientDependencies) { + this.deps = deps; + this.retryLimit = deps.tokenExpiryRetryLimit ?? 2; + this.defaultWsTimeoutMs = deps.defaultWsTimeoutMs ?? 15000; + this.connectionIdTimeoutMs = + deps.restConnectionIdTimeoutMs ?? this.defaultWsTimeoutMs; + } + + request = async ( + type: RequestType, + url: string, + data?: D, + options: RequestOptions = {}, + tokenExpiryAttempt = 0, + ): Promise => { + if (!options.publicEndpoint) { + await Promise.all([ + this.deps.tokenManager.tokenReady(), + this.deps.getGuestUserCreatePromise(), + ]); + const deadline = Date.now() + this.connectionIdTimeoutMs; + const remaining = () => Math.max(0, deadline - Date.now()); + try { + await raceWithTimeout( + this.deps.gate.await(), + remaining(), + newConnectionIdTimeoutError, + ); + } catch (err) { + if ( + err instanceof WebSocketConnectionError && + err.code === 'CONNECTION_ID_TIMEOUT' + ) { + throw err; + } + if (remaining() <= 0) throw newConnectionIdTimeoutError(); + const socket = this.deps.getSocket(); + if (socket) { + try { + await raceWithTimeout( + socket.waitForHealthy(remaining()), + remaining(), + newConnectionIdTimeoutError, + ); + } catch (e) { + if ( + e instanceof WebSocketConnectionError && + e.code === 'CONNECTION_ID_TIMEOUT' + ) { + throw e; + } + // any other waitForHealthy error: swallow and try the gate again + } + } + if (remaining() <= 0) throw newConnectionIdTimeoutError(); + await raceWithTimeout( + this.deps.gate.await(), + remaining(), + newConnectionIdTimeoutError, + ); + } + } + + const requestConfig = this.enrichOptions(options); + try { + this.logRequest(type, url, data, requestConfig); + const response = await this.dispatch( + type, + url, + data, + requestConfig, + ); + this.logResponse(type, url, response); + this.consecutiveFailures = 0; + return response.data; + } catch (e: unknown) { + const err = e as { + client_request_id?: string; + response?: AxiosResponse; + }; + err.client_request_id = ( + requestConfig.headers as Record | undefined + )?.['x-client-request-id']; + this.consecutiveFailures += 1; + const { response } = err; + if (!response || !isErrorResponse(response)) { + this.deps.logger.error(`client:${type} url: ${url}`, e); + throw e; + } + const { data: responseData, status } = response; + const isTokenExpired = responseData.code === KnownCodes.TOKEN_EXPIRED; + if ( + isTokenExpired && + !this.deps.tokenManager.isStatic() && + tokenExpiryAttempt < this.retryLimit + ) { + this.deps.logger.warn(`client:${type} url: ${url}`, response); + if (this.consecutiveFailures > 1) { + await sleep(retryInterval(this.consecutiveFailures)); + } + await this.deps.tokenManager.loadToken(); + return await this.request( + type, + url, + data, + options, + tokenExpiryAttempt + 1, + ); + } + this.deps.logger.error(`client:${type} url: ${url}`, response); + throw new ErrorFromResponse({ + message: `Stream error code ${responseData.code}: ${responseData.message}`, + code: responseData.code ?? null, + unrecoverable: responseData.unrecoverable ?? null, + response, + status, + }); + } + }; + + get = (url: string, params?: AxiosRequestConfig['params']) => + this.request('get', url, undefined, { params }); + post = ( + url: string, + data?: D, + params?: AxiosRequestConfig['params'], + ) => this.request('post', url, data, { params }); + put = ( + url: string, + data?: D, + params?: AxiosRequestConfig['params'], + ) => this.request('put', url, data, { params }); + patch = ( + url: string, + data?: D, + params?: AxiosRequestConfig['params'], + ) => this.request('patch', url, data, { params }); + delete = (url: string, params?: AxiosRequestConfig['params']) => + this.request('delete', url, undefined, { params }); + + /** Header / param enrichment. Preserves the legacy _enrichAxiosOptions byte-for-byte. */ + private enrichOptions = (options: RequestOptions): AxiosRequestConfig => { + const user = this.deps.getUser(); + const token = + options.publicEndpoint && !user + ? undefined + : this.deps.tokenManager.getToken(); + const authorization = token ? { Authorization: token } : undefined; + const headers = { ...(options.headers ?? {}) } as Record; + if (!headers['x-client-request-id']) { + headers['x-client-request-id'] = generateUUIDv4(); + } + + const axiosOptions = this.deps.options.axiosRequestConfig ?? {}; + const { + params: axiosConfigParams, + headers: axiosConfigHeaders, + ...axiosRequestConfig + } = axiosOptions; + + const connectionId = this.deps.getSocket()?.getConnectionId(); + + return { + params: { + user_id: this.deps.getUserId(), + connection_id: connectionId, + api_key: this.deps.getApiKey(), + ...options.params, + ...axiosConfigParams, + }, + headers: { + ...authorization, + 'stream-auth-type': + options.publicEndpoint && !user + ? 'anonymous' + : this.deps.getAuthType(), + 'X-Stream-Client': this.deps.getUserAgent(), + ...headers, + ...axiosConfigHeaders, + }, + ...options.config, + ...axiosRequestConfig, + }; + }; + + private dispatch = async ( + type: RequestType, + url: string, + data: D | undefined, + requestConfig: AxiosRequestConfig, + ): Promise> => { + const { axiosInstance } = this.deps; + switch (type) { + case 'get': + return await axiosInstance.get(url, requestConfig); + case 'delete': + return await axiosInstance.delete(url, requestConfig); + case 'post': + return await axiosInstance.post(url, data, requestConfig); + case 'put': + return await axiosInstance.put(url, data, requestConfig); + case 'patch': + return await axiosInstance.patch(url, data, requestConfig); + case 'options': + return await axiosInstance.options(url, requestConfig); + default: + throw new Error(`Invalid request type: ${type}`); + } + }; + + private logRequest = ( + type: RequestType, + url: string, + data: unknown, + config: AxiosRequestConfig, + ): void => { + if (this.deps.logger.getLogLevel() !== 'trace') return; + this.deps.logger.trace(`client: ${type} - Request - ${url}`, { + payload: data, + config, + }); + }; + + private logResponse = ( + type: RequestType, + url: string, + response: AxiosResponse, + ): void => { + if (this.deps.logger.getLogLevel() !== 'trace') return; + this.deps.logger.trace( + `client:${type} - Response - url: ${url} > status ${response.status}`, + { response }, + ); + }; +} From 56e47aca54593254889f51859d07bd7dfdf4a28d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:51:48 +0200 Subject: [PATCH 12/18] feat(client): add coordinator-client StreamClient facade The new facade composes the modules under internal/ and exposes the same public surface as the legacy StreamClient. Both classes share the name `StreamClient`; the factory in helpers/clientUtils.ts decides which one to construct via the useLegacyCoordinator flag during the A/B validation window. Highlights: - single ownership of NetworkStatusBridge (F3) so online/offline is registered exactly once and routed to both the EventDispatcher and the (lazy) CoordinatorSocket via callbacks. - the ConnectionIdGate is owned by the facade. openConnection arms it, the socket resolves on connection.ok, the close path invalidates it, and disconnectUser resets it. - @deprecated _hasConnectionID / _getConnectionID aliases (F11) keep Call.ts and the existing test passing during the validation window; they will be deleted in the post-rollout cleanup commit. - buildWsUrl / buildAuthMessage are pulled out as private helpers so the wireFormat tests can lock the byte-level shape the coordinator backend depends on. Adds: - StreamClient.test.ts: facade composition tests (setBaseURL / auth type / getUserAgent / connectAnonymousUser / connectUser duplicate detection / disconnect idempotency / F11 alias parity). - wireFormat.test.ts: locks buildWsUrl, getUserAgent and buildAuthMessage byte-for-byte across the JWT, anonymous, extras, and CLIENT_BUNDLE-override cases. --- .../connection/__tests__/StreamClient.test.ts | 135 ++++++ .../connection/__tests__/wireFormat.test.ts | 125 +++++ .../connection/coordinator-client.ts | 430 ++++++++++++++++++ 3 files changed, 690 insertions(+) create mode 100644 packages/client/src/coordinator/connection/__tests__/StreamClient.test.ts create mode 100644 packages/client/src/coordinator/connection/__tests__/wireFormat.test.ts create mode 100644 packages/client/src/coordinator/connection/coordinator-client.ts diff --git a/packages/client/src/coordinator/connection/__tests__/StreamClient.test.ts b/packages/client/src/coordinator/connection/__tests__/StreamClient.test.ts new file mode 100644 index 0000000000..02924d1ae7 --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/StreamClient.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { StreamClient } from '../coordinator-client'; + +vi.mock('../location', () => ({ + getLocationHint: () => Promise.resolve('AMS'), +})); + +const encodeBase64Url = (input: string): string => { + const b64 = + typeof btoa === 'function' + ? btoa(input) + : Buffer.from(input, 'utf8').toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const jwtFor = (userId: string): string => { + const header = encodeBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = encodeBase64Url(JSON.stringify({ user_id: userId })); + return `${header}.${body}.signature`; +}; + +describe('StreamClient (new facade)', () => { + beforeEach(() => { + vi.stubEnv('PKG_VERSION', '1.0.0-test'); + vi.stubEnv('CLIENT_BUNDLE', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('setBaseURL rewrites the wsBaseURL', () => { + const client = new StreamClient('api-key', { browser: true }); + client.setBaseURL('https://video.example.com/video'); + expect(client.wsBaseURL).toBe('wss://video.example.com/video'); + client.setBaseURL('http://localhost:3030/video'); + expect(client.wsBaseURL).toBe('ws://localhost:8800/video'); + }); + + it('getAuthType reflects anonymous flag', () => { + const client = new StreamClient('api-key', { browser: true }); + expect(client.getAuthType()).toBe('jwt'); + client.anonymous = true; + expect(client.getAuthType()).toBe('anonymous'); + }); + + it('getUserAgent encodes sdk name, version, extras and bundle', () => { + const client = new StreamClient('api-key', { + browser: true, + clientAppIdentifier: { + sdkName: 'react', + sdkVersion: '99.0.0', + app: 'my-app', + os: 'macos', + }, + }); + const ua = client.getUserAgent(); + expect(ua).toBe( + 'stream-video-react-v99.0.0|app=my-app|os=macos|client_bundle=browser', + ); + }); + + it('getUserAgent falls back to defaults and node bundle when no extras supplied', () => { + const client = new StreamClient('api-key'); + const ua = client.getUserAgent(); + expect(ua).toBe('stream-video-js-v1.0.0-test|client_bundle=node'); + }); + + it('connectAnonymousUser resolves the gate immediately and skips opening a socket', async () => { + const client = new StreamClient('api-key', { browser: true }); + await client.connectAnonymousUser({ id: '!anon' }, ''); + expect(client.anonymous).toBe(true); + expect(client.user?.id).toBe('!anon'); + // hasConnectionId stays false because no socket is created. + expect(client.hasConnectionId()).toBe(false); + // The gate resolved with undefined, so a public-style request can still + // proceed without hanging. + const tokenManager = client.tokenManager; + expect(tokenManager.isStatic()).toBe(true); + }); + + it('axiosInstance is the same reference the RestClient sees (regression for spyOn)', async () => { + const client = new StreamClient('api-key', { browser: true }); + // The legacy integration test spies on client.axiosInstance.post to assert + // request shape. Confirm the field is the same reference the new + // RestClient was wired with. + const spy = vi.spyOn(client.axiosInstance, 'post'); + expect(spy).toBeDefined(); + }); + + it('legacy F11 alias _hasConnectionID/_getConnectionID resolves to the same as the canonical names', () => { + const client = new StreamClient('api-key', { browser: true }); + expect(client._hasConnectionID()).toBe(client.hasConnectionId()); + expect(client._getConnectionID()).toBe(client.getConnectionId()); + }); + + it('connectUser without an id throws', async () => { + const client = new StreamClient('api-key', { browser: true }); + await expect( + client.connectUser({ id: '' }, jwtFor('jane')), + ).rejects.toThrow(/missing/); + }); + + it('connectUser twice with different ids throws on the second call', async () => { + const client = new StreamClient('api-key', { browser: true }); + // Stub openConnection so we don't actually open a WS. + client.openConnection = vi.fn(async () => undefined); + await client.connectUser({ id: 'jane' }, jwtFor('jane')); + await expect( + client.connectUser({ id: 'john' }, jwtFor('john')), + ).rejects.toThrow(/Use client.disconnect/); + }); + + it('connectUser with the same id returns the cached task and warns', async () => { + const client = new StreamClient('api-key', { browser: true }); + let openCount = 0; + client.openConnection = vi.fn(async () => { + openCount += 1; + return undefined; + }); + await client.connectUser({ id: 'jane' }, jwtFor('jane')); + expect(openCount).toBe(1); + await client.connectUser({ id: 'jane' }, jwtFor('jane')); + expect(openCount).toBe(1); + }); + + it('disconnectUser clears state and is idempotent', async () => { + const client = new StreamClient('api-key', { browser: true }); + client.openConnection = vi.fn(async () => undefined); + await client.connectUser({ id: 'jane' }, jwtFor('jane')); + await client.disconnectUser(); + expect(client.user).toBeUndefined(); + expect(client.userID).toBeUndefined(); + await expect(client.disconnectUser()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/client/src/coordinator/connection/__tests__/wireFormat.test.ts b/packages/client/src/coordinator/connection/__tests__/wireFormat.test.ts new file mode 100644 index 0000000000..d76a7c870f --- /dev/null +++ b/packages/client/src/coordinator/connection/__tests__/wireFormat.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { StreamClient } from '../coordinator-client'; + +vi.mock('../location', () => ({ + getLocationHint: () => Promise.resolve('AMS'), +})); + +const buildClient = ( + overrides?: ConstructorParameters[1], +) => new StreamClient('test-api-key', { browser: true, ...overrides }); + +const accessPrivate = (c: StreamClient, name: string): T => + (c as unknown as Record)[name]; + +describe('wire format parity', () => { + beforeEach(() => { + vi.stubEnv('PKG_VERSION', '1.0.0-test'); + vi.stubEnv('CLIENT_BUNDLE', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('buildWsUrl()', () => { + it('encodes api_key, stream-auth-type and X-Stream-Client for a JWT user', () => { + const client = buildClient(); + client.userID = 'jane'; + client.anonymous = false; + const url = accessPrivate<() => string>(client, 'buildWsUrl')(); + const parsed = new URL(url); + expect(parsed.protocol).toBe('wss:'); + expect(parsed.pathname.endsWith('/connect')).toBe(true); + expect(parsed.searchParams.get('api_key')).toBe('test-api-key'); + expect(parsed.searchParams.get('stream-auth-type')).toBe('jwt'); + expect(parsed.searchParams.get('X-Stream-Client')).toBe( + client.getUserAgent(), + ); + }); + + it('encodes anonymous when client.anonymous is true', () => { + const client = buildClient(); + client.anonymous = true; + const url = accessPrivate<() => string>(client, 'buildWsUrl')(); + const parsed = new URL(url); + expect(parsed.searchParams.get('stream-auth-type')).toBe('anonymous'); + }); + }); + + describe('getUserAgent()', () => { + it('default: js sdk, env-supplied version, node bundle when CLIENT_BUNDLE is unset', () => { + const client = new StreamClient('k'); + expect(client.getUserAgent()).toBe( + 'stream-video-js-v1.0.0-test|client_bundle=node', + ); + }); + + it('with extras: app, app_version, os, device_model interleaved before client_bundle', () => { + const client = new StreamClient('k', { + browser: true, + clientAppIdentifier: { + sdkName: 'react-native', + sdkVersion: '0.0.42', + app: 'mobile', + app_version: '1.2.3', + os: 'ios', + device_model: 'iPhone', + }, + }); + expect(client.getUserAgent()).toBe( + 'stream-video-react-native-v0.0.42|app=mobile|app_version=1.2.3|os=ios|device_model=iPhone|client_bundle=browser', + ); + }); + + it('honors CLIENT_BUNDLE env override', () => { + vi.stubEnv('CLIENT_BUNDLE', 'browser-esm'); + const client = new StreamClient('k', { browser: true }); + expect(client.getUserAgent()).toBe( + 'stream-video-js-v1.0.0-test|client_bundle=browser-esm', + ); + }); + }); + + describe('buildAuthMessage()', () => { + it('serializes token + user_details for a fully populated user', () => { + const client = buildClient(); + client.user = { + id: 'jane', + name: 'Jane', + image: 'jane.png', + custom: { role: 'host' }, + }; + // Set a token directly via TokenManager state. + (client.tokenManager as unknown as { token: string }).token = + 'jwt-token-value'; + const json = accessPrivate<() => string>(client, 'buildAuthMessage')(); + const parsed = JSON.parse(json); + expect(parsed.token).toBe('jwt-token-value'); + expect(parsed.user_details).toEqual({ + id: 'jane', + name: 'Jane', + image: 'jane.png', + custom: { role: 'host' }, + }); + }); + + it('omits name/image/custom when not set, but keeps the keys with undefined values', () => { + const client = buildClient(); + client.user = { id: 'jane' }; + (client.tokenManager as unknown as { token: string }).token = + 'jwt-token-value'; + const json = accessPrivate<() => string>(client, 'buildAuthMessage')(); + // JSON.stringify drops keys with undefined values: confirm the wire + // payload contains only id under user_details. + const parsed = JSON.parse(json); + expect(parsed.user_details).toEqual({ id: 'jane' }); + }); + + it('throws when user or token is missing (caught by onOpen in production)', () => { + const client = buildClient(); + expect(() => + accessPrivate<() => string>(client, 'buildAuthMessage')(), + ).toThrow(/user or token missing/); + }); + }); +}); diff --git a/packages/client/src/coordinator/connection/coordinator-client.ts b/packages/client/src/coordinator/connection/coordinator-client.ts new file mode 100644 index 0000000000..b671dc8abd --- /dev/null +++ b/packages/client/src/coordinator/connection/coordinator-client.ts @@ -0,0 +1,430 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import https from 'https'; +import { ScopedLogger, videoLoggerSystem } from '../../logger'; +import { + ConnectedEvent, + CreateGuestRequest, + CreateGuestResponse, +} from '../../gen/coordinator'; +import { makeSafePromise, type SafePromise } from '../../helpers/promise'; +import { getTimers } from '../../timers'; +import { ConnectionIdGate } from './internal/ConnectionIdGate'; +import { CoordinatorSocket } from './internal/CoordinatorSocket'; +import { EventDispatcher } from './internal/EventDispatcher'; +import { NetworkStatusBridge } from './internal/NetworkStatusBridge'; +import { RestClient } from './internal/RestClient'; +import { WebSocketTransport } from './internal/WebSocketTransport'; +import { TokenManager } from './token_manager'; +import { getLocationHint } from './location'; +import { + AllClientEvents, + ClientEventListener, + ConnectAPIResponse, + StreamClientOptions, + StreamVideoEvent, + TokenOrProvider, + User, + UserWithId, +} from './types'; +import { generateUUIDv4 } from './utils'; + +/** + * Coordinator client (rewrite). Composes the modules under `internal/` and + * exposes the same public surface as the legacy StreamClient. Both classes + * are named `StreamClient`; the factory in `helpers/clientUtils.ts` decides + * which one to construct via the `useLegacyCoordinator` flag during the A/B + * validation window. + */ +export class StreamClient { + // public ---------------------------------------------------------------- + key: string; + secret?: string; + options: StreamClientOptions; + browser: boolean; + node: boolean; + baseURL?: string; + wsBaseURL?: string; + axiosInstance: AxiosInstance; + user?: UserWithId; + userID?: string; + anonymous = false; + persistUserOnConnectionFailure?: boolean; + defaultWSTimeout: number; + clientID?: string; + connectUserTask: ConnectAPIResponse | null = null; + guestUserCreatePromise?: Promise; + logger: ScopedLogger; + tokenManager: TokenManager; + + /** Mirrors the legacy `consecutiveFailures` (REST counter) for back-compat readers. */ + get consecutiveFailures(): number { + return this.restClient.consecutiveFailures; + } + + // private composition --------------------------------------------------- + private eventDispatcher: EventDispatcher; + private gate: ConnectionIdGate; + private restClient: RestClient; + private networkBridge: NetworkStatusBridge; + private socket?: CoordinatorSocket; + private locationHint?: Promise; + private cachedUserAgent?: string; + private wsPromiseSafe: SafePromise | null = null; + // ts-ignore-style alias preserved by the legacy class: request-time copy of + // the user (for parity with the legacy `_user` field). + private _user?: UserWithId; + + constructor(key: string, options?: StreamClientOptions) { + this.key = key; + this.secret = options?.secret; + const inputOptions = + options ?? + ({ + browser: typeof window !== 'undefined', + } as Partial); + this.browser = inputOptions.browser || typeof window !== 'undefined'; + this.node = !this.browser; + if (this.browser) { + this.locationHint = getLocationHint( + options?.locationHintUrl, + options?.locationHintTimeout, + options?.locationHintMaxAttempts, + ); + } + this.options = { + timeout: 5000, + withCredentials: false, + ...inputOptions, + }; + if (this.node && !this.options.httpsAgent) { + this.options.httpsAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 3000, + }); + } + this.setBaseURL( + this.options.baseURL ?? 'https://video.stream-io-api.com/video', + ); + this.axiosInstance = axios.create({ + ...this.options, + baseURL: this.baseURL, + }); + this.persistUserOnConnectionFailure = + this.options?.persistUserOnConnectionFailure; + this.tokenManager = new TokenManager(this.secret); + this.defaultWSTimeout = this.options.defaultWsTimeout ?? 15000; + this.logger = videoLoggerSystem.getLogger('coordinator'); + + this.eventDispatcher = new EventDispatcher({ logger: this.logger }); + this.gate = new ConnectionIdGate(); + this.restClient = new RestClient({ + axiosInstance: this.axiosInstance, + tokenManager: this.tokenManager, + options: this.options, + getApiKey: () => this.key, + getUserId: () => this.userID, + getUser: () => this.user, + getAuthType: this.getAuthType, + getUserAgent: this.getUserAgent, + gate: this.gate, + getSocket: () => this.socket, + getGuestUserCreatePromise: () => this.guestUserCreatePromise, + logger: this.logger, + tokenExpiryRetryLimit: this.options.tokenExpiryRetryLimit ?? 2, + defaultWsTimeoutMs: this.defaultWSTimeout, + restConnectionIdTimeoutMs: + this.options.restConnectionIdTimeoutMs ?? this.defaultWSTimeout, + }); + + this.networkBridge = new NetworkStatusBridge({ + onOnline: () => { + this.eventDispatcher.dispatch({ + type: 'network.changed', + online: true, + }); + this.socket?.handleOnline(); + }, + onOffline: () => { + this.eventDispatcher.dispatch({ + type: 'network.changed', + online: false, + }); + this.socket?.handleOffline(); + }, + }); + } + + setBaseURL = (baseURL: string): void => { + this.baseURL = baseURL; + this.wsBaseURL = baseURL.replace('http', 'ws').replace(':3030', ':8800'); + }; + + getAuthType = (): 'jwt' | 'anonymous' => + this.anonymous ? 'anonymous' : 'jwt'; + + getUserAgent = (): string => { + if (!this.cachedUserAgent) { + const { clientAppIdentifier = {} } = this.options; + const { + sdkName = 'js', + sdkVersion = process.env.PKG_VERSION || '0.0.0', + ...extras + } = clientAppIdentifier; + this.cachedUserAgent = [ + `stream-video-${sdkName}-v${sdkVersion}`, + ...Object.entries(extras).map(([key, value]) => `${key}=${value}`), + `client_bundle=${process.env.CLIENT_BUNDLE || (this.node ? 'node' : 'browser')}`, + ].join('|'); + } + return this.cachedUserAgent; + }; + + getLocationHint = async ( + hintUrl?: string, + timeout?: number, + ): Promise => { + const hint = await this.locationHint; + if (!hint || hint === 'ERR') { + this.locationHint = getLocationHint( + hintUrl ?? this.options.locationHintUrl, + timeout ?? this.options.locationHintTimeout, + ); + return this.locationHint; + } + return hint; + }; + + connectUser = async ( + user: UserWithId, + tokenOrProvider: TokenOrProvider, + ): ConnectAPIResponse => { + if (!user.id) { + throw new Error('The "id" field on the user is missing'); + } + if (this.userID === user.id && this.connectUserTask) { + this.logger.warn( + 'Consecutive calls to connectUser is detected, ideally you should only call this function once in your app.', + ); + return this.connectUserTask; + } + if (this.userID) { + throw new Error( + 'Use client.disconnect() before trying to connect as a different user. connectUser was called twice.', + ); + } + if ((this.secret || this.node) && !this.options.allowServerSideConnect) { + this.logger.warn( + 'Please do not use connectUser server side. Use our @stream-io/node-sdk instead: https://getstream.io/video/docs/api/', + ); + } + + this.userID = user.id; + this.anonymous = false; + await this.tokenManager.setTokenOrProvider(tokenOrProvider, user, false); + this._setUser(user); + + this.connectUserTask = this.openConnection(); + this.networkBridge.attach(); + + try { + return await this.connectUserTask; + } catch (err) { + if (this.persistUserOnConnectionFailure) { + await this.closeConnection(); + } else { + await this.disconnectUser(); + } + throw err; + } + }; + + connectAnonymousUser = async ( + user: UserWithId, + tokenOrProvider: TokenOrProvider, + ): Promise => { + this.networkBridge.attach(); + this.gate.arm(); + this.anonymous = true; + await this.tokenManager.setTokenOrProvider(tokenOrProvider, user, true); + this._setUser(user); + // Anonymous users do not open a WS connection; resolve the gate so REST + // calls can proceed without a connection_id. + this.gate.resolve(undefined); + }; + + connectGuestUser = async ( + user: User & { type: 'guest' }, + ): ConnectAPIResponse => { + this.guestUserCreatePromise = this.restClient.request< + CreateGuestResponse, + CreateGuestRequest + >('post', '/guest', { user }, { publicEndpoint: true }); + + const response = await this.guestUserCreatePromise; + this.guestUserCreatePromise.finally( + () => (this.guestUserCreatePromise = undefined), + ); + return this.connectUser(response.user, response.access_token); + }; + + closeConnection = async (timeout?: number): Promise => { + await this.socket?.disconnect(timeout); + this.connectUserTask = null; + }; + + disconnectUser = async (timeout?: number): Promise => { + this.logger.info('client:disconnect() Disconnecting the client'); + delete this.user; + delete this._user; + delete this.userID; + this.anonymous = false; + await this.closeConnection(timeout); + this.networkBridge.detach(); + this.tokenManager.reset(); + this.gate.reset(); + }; + + openConnection = async (): Promise => { + if (!this.userID) { + throw new Error( + 'UserWithId is not set on client, use client.connectUser or client.connectAnonymousUser instead', + ); + } + + if (this.socket?.isConnecting() && this.wsPromiseSafe?.checkPending()) { + this.logger.info( + 'client:openConnection() connection already in progress', + ); + return await this.wsPromiseSafe(); + } + + if (this.socket?.isHealthy() && this.hasConnectionId()) { + this.logger.info( + 'client:openConnection() openConnection called twice, healthy connection already exists', + ); + return undefined; + } + + this.gate.arm(); + this.clientID = `${this.userID}--${generateUUIDv4()}`; + + this.socket = new CoordinatorSocket({ + urlBuilder: () => this.buildWsUrl(), + authMessageBuilder: () => this.buildAuthMessage(), + tokenManager: this.tokenManager, + eventDispatcher: this.eventDispatcher, + gate: this.gate, + transportFactory: (url) => + new WebSocketTransport({ + url, + WebSocketImpl: this.options.WebSocketImpl ?? WebSocket, + }), + timers: getTimers(), + getClientId: () => this.clientID, + logger: videoLoggerSystem.getLogger('coordinator-socket'), + options: { + defaultWsTimeoutMs: this.defaultWSTimeout, + authHandshakeTimeoutMs: + this.options.authHandshakeTimeoutMs ?? this.defaultWSTimeout, + }, + }); + + const newWsPromise = this.socket.connect(this.defaultWSTimeout); + this.wsPromiseSafe = makeSafePromise(newWsPromise); + return await newWsPromise; + }; + + // event bus ------------------------------------------------------------- + + on = (name: E, cb: ClientEventListener) => + this.eventDispatcher.on(name, cb); + + off = ( + name: E, + cb: ClientEventListener, + ) => this.eventDispatcher.off(name, cb); + + dispatchEvent = (event: StreamVideoEvent): void => + this.eventDispatcher.dispatch(event); + + // REST passthrough ------------------------------------------------------ + + doAxiosRequest = ( + type: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options', + url: string, + data?: D, + options: AxiosRequestConfig & { + config?: AxiosRequestConfig; + publicEndpoint?: boolean; + } = {}, + ) => this.restClient.request(type, url, data, options); + + get = (url: string, params?: AxiosRequestConfig['params']) => + this.restClient.get(url, params); + post = ( + url: string, + data?: D, + params?: AxiosRequestConfig['params'], + ) => this.restClient.post(url, data, params); + put = ( + url: string, + data?: D, + params?: AxiosRequestConfig['params'], + ) => this.restClient.put(url, data, params); + patch = ( + url: string, + data?: D, + params?: AxiosRequestConfig['params'], + ) => this.restClient.patch(url, data, params); + delete = (url: string, params?: AxiosRequestConfig['params']) => + this.restClient.delete(url, params); + + // connection-id accessors ---------------------------------------------- + + hasConnectionId = (): boolean => Boolean(this.socket?.getConnectionId()); + getConnectionId = (): string | undefined => this.socket?.getConnectionId(); + + /** + * @deprecated Use {@link hasConnectionId}. Alias kept for the validation + * window; remove after the legacy implementation is deleted. + */ + _hasConnectionID = (): boolean => this.hasConnectionId(); + + /** + * @deprecated Use {@link getConnectionId}. Alias kept for the validation + * window; remove after the legacy implementation is deleted. + */ + _getConnectionID = (): string | undefined => this.getConnectionId(); + + // private --------------------------------------------------------------- + + private _setUser = (user: UserWithId): void => { + this.user = user; + this.userID = user.id; + this._user = { ...user }; + }; + + private buildWsUrl = (): string => { + const params = new URLSearchParams(); + params.set('api_key', this.key); + params.set('stream-auth-type', this.getAuthType()); + params.set('X-Stream-Client', this.getUserAgent()); + return `${this.wsBaseURL}/connect?${params.toString()}`; + }; + + private buildAuthMessage = (): string => { + const token = this.tokenManager.getToken(); + if (!this.user || !token) { + throw new Error('user or token missing'); + } + return JSON.stringify({ + token, + user_details: { + id: this.user.id, + name: this.user.name, + image: this.user.image, + custom: this.user.custom, + }, + }); + }; +} From 68b266921a21178c5740c035fd21caaee13ad83e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:52:38 +0200 Subject: [PATCH 13/18] feat(client): wire createCoordinatorClient to the new coordinator-client The factory now branches on options.useLegacyCoordinator (F15). When true, it constructs the legacy StreamClient at coordinator/connection/ client.ts. Otherwise it constructs the rewrite at coordinator/ connection/coordinator-client.ts. Both classes are named StreamClient; only this factory imports both, via LegacyStreamClient / NewStreamClient aliases. Every other consumer keeps importing from the legacy path unchanged because the new class is structurally assignable (F11 alias methods cover Call.ts and the existing test). The single `as unknown as LegacyStreamClient` cast in this file is the entire TS contract during the rollout window. After the validation window (Phase 3), the legacy client is deleted, the alias imports collapse to a direct import, and the cast goes away. --- packages/client/src/helpers/clientUtils.ts | 26 +++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/client/src/helpers/clientUtils.ts b/packages/client/src/helpers/clientUtils.ts index f266f72418..dd8dd47430 100644 --- a/packages/client/src/helpers/clientUtils.ts +++ b/packages/client/src/helpers/clientUtils.ts @@ -4,7 +4,8 @@ import type { TokenOrProvider, User, } from '../coordinator/connection/types'; -import { StreamClient } from '../coordinator/connection/client'; +import { StreamClient as LegacyStreamClient } from '../coordinator/connection/client'; +import { StreamClient as NewStreamClient } from '../coordinator/connection/coordinator-client'; import { getSdkInfo } from './client-details'; import { SdkType } from '../gen/video/sfu/models/models'; import type { StreamVideoClientOptions } from '../types'; @@ -43,18 +44,33 @@ const getClientAppIdentifier = ( /** * Creates a coordinator client. + * + * Returns a {@link LegacyStreamClient}-typed value so existing call sites + * (Call.ts, StreamSfuClient.ts, StreamVideoClient.ts) keep their type + * annotations unchanged. During the F15 validation window the new client at + * `coordinator-client.ts` is structurally assignable to the legacy class + * because (a) the public surface matches and (b) the F11 deprecated aliases + * are kept on the new class. The `as unknown` cast is the single TS gymnastic + * required to land both implementations behind one factory. */ export const createCoordinatorClient = ( apiKey: string, options: StreamClientOptions | undefined, -) => { +): LegacyStreamClient => { const clientAppIdentifier = getClientAppIdentifier(options); - - return new StreamClient(apiKey, { + const baseOptions: StreamClientOptions = { persistUserOnConnectionFailure: true, ...options, clientAppIdentifier, - }); + }; + + if (options?.useLegacyCoordinator) { + return new LegacyStreamClient(apiKey, baseOptions); + } + return new NewStreamClient( + apiKey, + baseOptions, + ) as unknown as LegacyStreamClient; }; /** From 4482ff67009639259b3a2be603bef861d7b1efb3 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:53:44 +0200 Subject: [PATCH 14/18] test(client): add parametrized parity test across legacy and new coordinator Wraps the existing integration scenarios in a describe.each that runs once with useLegacyCoordinator=false (new path) and once with useLegacyCoordinator=true (legacy path). The same assertions must hold under both implementations. Skips when STREAM_API_KEY / STREAM_SECRET are absent so local test runs do not flake; CI provides the secrets. --- .../StreamVideoClient.parity.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/client/src/__tests__/StreamVideoClient.parity.test.ts diff --git a/packages/client/src/__tests__/StreamVideoClient.parity.test.ts b/packages/client/src/__tests__/StreamVideoClient.parity.test.ts new file mode 100644 index 0000000000..0a22e4bcc8 --- /dev/null +++ b/packages/client/src/__tests__/StreamVideoClient.parity.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import 'dotenv/config'; +import { StreamVideoClient } from '../StreamVideoClient'; +import { generateUUIDv4 } from '../coordinator/connection/utils'; +import { StreamClient } from '@stream-io/node-sdk'; + +const apiKey = process.env.STREAM_API_KEY; +const secret = process.env.STREAM_SECRET; + +const haveCredentials = Boolean(apiKey && secret); +const describeIfCreds = haveCredentials ? describe : describe.skip; + +describeIfCreds('StreamVideoClient parity (legacy vs. new coordinator)', () => { + let serverClient: StreamClient; + let tokenProvider: (userId: string) => () => Promise; + + beforeEach(() => { + serverClient = new StreamClient(apiKey!, secret!); + tokenProvider = (userId: string) => async () => + new Promise((resolve) => { + setTimeout(() => { + resolve(serverClient.generateUserToken({ user_id: userId })); + }, 50); + }); + }); + + describe.each([ + { mode: 'new', useLegacyCoordinator: false }, + { mode: 'legacy', useLegacyCoordinator: true }, + ])('mode=$mode', ({ useLegacyCoordinator }) => { + let client: StreamVideoClient; + + beforeEach(() => { + client = new StreamVideoClient(apiKey!, { + browser: true, + timeout: 15000, + useLegacyCoordinator, + }); + }); + afterEach(async () => { + await client.disconnectUser(); + }); + + it('private endpoint waits for connection then succeeds', async () => { + client.connectUser({ id: 'jane' }, tokenProvider('jane')); + const response = await client.queryCalls({}); + expect(response).toBeDefined(); + }); + + it('connection_id is enriched on REST requests', async () => { + client.connectUser({ id: 'jane' }, tokenProvider('jane')); + const spy = vi.spyOn(client.streamClient.axiosInstance, 'post'); + await client.call('default', generateUUIDv4()).getOrCreate(); + const requestConfig: { params: Record } = spy.mock.calls[ + spy.mock.calls.length - 1 + ][2] as { + params: Record; + }; + const params = requestConfig.params; + const connectionId = client.streamClient._getConnectionID(); + expect(connectionId).toBeDefined(); + expect(params.connection_id).toBe(connectionId); + }); + + it('private endpoint throws after disconnectUser', async () => { + await client.connectUser({ id: 'jane' }, tokenProvider('jane')); + await client.disconnectUser(); + await expect(() => client.queryCalls({})).rejects.toThrowError(); + }); + + it('public endpoint does not require connectUser', async () => { + const userId = `guest-${generateUUIDv4()}`; + const response = await client.createGuestUser({ user: { id: userId } }); + expect(response.user.id).toContain(userId); + }); + }); +}); From 1cdee2c9667abd4fa6011b7ae2d71e0042535598 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 12:54:40 +0200 Subject: [PATCH 15/18] feat(react-dogfood): forward ?coordinator=legacy|new to useLegacyCoordinator Adds the F15 A/B switch on the dogfood app: when the URL contains ?coordinator=legacy, getClient passes useLegacyCoordinator=true to StreamVideoClient. Otherwise (default and ?coordinator=new), the new coordinator implementation is used. Also tags Sentry events with coordinator_mode so the validation-window error rates of legacy and new can be compared in one dashboard. Switching modes requires a page reload because getClient is a singleton, which matches the natural session-level model for an A/B. --- sample-apps/react/react-dogfood/helpers/client.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sample-apps/react/react-dogfood/helpers/client.ts b/sample-apps/react/react-dogfood/helpers/client.ts index 9c9a4740be..8cc9a472c9 100644 --- a/sample-apps/react/react-dogfood/helpers/client.ts +++ b/sample-apps/react/react-dogfood/helpers/client.ts @@ -1,4 +1,5 @@ import { StreamVideoClient, User } from '@stream-io/video-react-sdk'; +import * as Sentry from '@sentry/nextjs'; import { isRecentDeviceSelectionEnabled } from '../hooks/useDeviceSelectionPreference'; import type { AppEnvironment } from '../lib/environmentConfig'; import { @@ -13,6 +14,14 @@ import { customSentryLogger } from './logger'; let client: StreamVideoClient | undefined; +type CoordinatorMode = 'legacy' | 'new'; + +const getCoordinatorMode = (): CoordinatorMode => { + if (typeof window === 'undefined') return 'new'; + const param = new URL(window.location.href).searchParams.get('coordinator'); + return param === 'legacy' ? 'legacy' : 'new'; +}; + /** * Lazily initializes video client. Credentials are captured on the first * call, and ignored for subsequent calls. @@ -27,6 +36,7 @@ export const getClient = ( environment: AppEnvironment, ) => { if (!client) { + const mode = getCoordinatorMode(); const options = { baseURL: creds.coordinatorUrl || process.env.NEXT_PUBLIC_STREAM_API_URL, logLevel: 'debug' as const, @@ -37,7 +47,10 @@ export const getClient = ( enabled: isRecentDeviceSelectionEnabled(), storageKey: '@pronto/device-preferences', }, + useLegacyCoordinator: mode === 'legacy', }; + console.info(`[stream-video] coordinator mode: ${mode}`); + Sentry.setTag('coordinator_mode', mode); if (creds.user.type === 'guest' || creds.user.type === 'anonymous') { client = new StreamVideoClient({ apiKey: creds.apiKey, From 7f5941b8ce44264b1fd9c6161ed9339c9aa61e52 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 13:14:19 +0200 Subject: [PATCH 16/18] test(client): fill in remaining CoordinatorSocket scenarios from the plan Adds the test list items that the initial CoordinatorSocket.test.ts skipped: - code 40 during the handshake schedules a refreshToken reconnect - mid-stream code 40 with a static token is a no-op - health.check is sent every pingIntervalMs after connection.ok - watchdog firing dispatches connection.changed:false after the unhealthyDispatchDelayMs delay (never sooner) - stale onclose from a prior transport is dropped by the wsId guard - received_at is stamped on the event before dispatch - onOpen does not crash when the auth-message builder throws - connect() after disconnect() succeeds with a fresh transport - concurrent close + REST: gate transitions resolved -> rejected -> resolved across the close + reconnect cycle - auth-handshake watchdog is cleared on a handshake-time connection.error - auth-handshake watchdog is cleared on onClose - auth send failure does NOT arm the watchdog - stale auth watchdog from a prior transport is a no-op (wsId guard) 114 tests now pass across the coordinator/connection suite. --- .../__tests__/CoordinatorSocket.test.ts | 311 +++++++++++++++++- 1 file changed, 310 insertions(+), 1 deletion(-) diff --git a/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts b/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts index bda9a5da28..c0a29c1e69 100644 --- a/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts +++ b/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts @@ -13,6 +13,7 @@ import { createFakeWorkerTimer } from './helpers/fakeTimers'; const setupSocket = (overrides?: { WebSocketImpl?: typeof WebSocket; authMessage?: string; + authMessageBuilder?: () => string; staticToken?: boolean; authHandshakeTimeoutMs?: number; defaultWsTimeoutMs?: number; @@ -47,7 +48,9 @@ const setupSocket = (overrides?: { const socket = new CoordinatorSocket({ urlBuilder: () => 'wss://coordinator/connect', - authMessageBuilder: () => overrides?.authMessage ?? '{"auth":"msg"}', + authMessageBuilder: + overrides?.authMessageBuilder ?? + (() => overrides?.authMessage ?? '{"auth":"msg"}'), tokenManager, eventDispatcher, gate, @@ -391,4 +394,310 @@ describe('CoordinatorSocket', () => { await vi.advanceTimersByTimeAsync(6000); expect(loadTokenSpy).toHaveBeenCalled(); }); + + it('mid-stream connection.error code 40 with isStatic() does NOT trigger reconnect', async () => { + const { socket, gate, tokenManager } = setupSocket({ staticToken: true }); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); + // Two mid-stream errors (per F13: first is silently consumed). + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 40, message: 'expired', StatusCode: 0 }, + }); + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 40, message: 'expired', StatusCode: 0 }, + }); + await vi.advanceTimersByTimeAsync(6000); + expect(loadTokenSpy).not.toHaveBeenCalled(); + }); + + it('health.check is sent every pingIntervalMs after connection.ok', async () => { + const { socket, gate } = setupSocket({ + pingIntervalMs: 1000, + healthTimeoutMs: 60000, + }); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + // The first ping fires pingIntervalMs after connection.ok. + expect(ws.send).toHaveBeenCalledTimes(1); // auth message + await vi.advanceTimersByTimeAsync(1000); + expect(ws.send).toHaveBeenCalledTimes(2); + const payload = ws.send.mock.calls[1][0]; + const parsed = JSON.parse(payload as string); + expect(parsed[0]).toMatchObject({ + type: 'health.check', + client_id: 'client-1', + }); + }); + + it('watchdog firing dispatches connection.changed:false after unhealthyDispatchDelayMs', async () => { + const { socket, eventDispatcher, gate } = setupSocket({ + pingIntervalMs: 60000, + healthTimeoutMs: 100, + unhealthyDispatchDelayMs: 5000, + }); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + + const onChanged = vi.fn(); + eventDispatcher.on('connection.changed', onChanged); + + // Trigger the watchdog: advance past healthTimeoutMs (100 ms) of silence. + await vi.advanceTimersByTimeAsync(150); + // Watchdog set health to false but the dispatch is deferred 5 s. + expect(onChanged).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(4900); + expect(onChanged).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(150); + expect(onChanged).toHaveBeenCalledWith({ + type: 'connection.changed', + online: false, + }); + }); + + it('stale onclose from a prior transport is dropped via wsId guard', async () => { + const { socket, eventDispatcher, gate } = setupSocket(); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const firstWs = MockWebSocket.instances[0]; + firstWs.fireOpen(); + firstWs.fireMessage(connectedEvent('conn-1')); + await promise; + + // Bump wsId by triggering disconnect (does not reject the gate). + await socket.disconnect(); + const onChanged = vi.fn(); + eventDispatcher.on('connection.changed', onChanged); + + // Fire an "old" onclose against the first ws AFTER disconnect bumped wsId. + // The new wsId guard must drop it: setHealth must not run, no new + // connection.changed dispatch, no scheduled reconnect. + firstWs.onclose?.({ + code: 1006, + reason: '', + wasClean: false, + } as CloseEvent); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('received_at is stamped on the event before dispatch', async () => { + const { socket, eventDispatcher, gate } = setupSocket(); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + let captured: { received_at?: Date | string } | undefined; + eventDispatcher.on('connection.ok', (event) => { + captured = event as unknown as { received_at?: Date | string }; + }); + ws.fireMessage(connectedEvent('conn-1')); + await promise; + expect(captured?.received_at).toBeInstanceOf(Date); + }); + + it('onOpen does not crash when the auth message builder throws (user/token missing)', async () => { + const builder = vi.fn(() => { + throw new Error('user or token missing'); + }); + const { socket, gate } = setupSocket({ authMessageBuilder: builder }); + gate.arm(); + // The connect promise hangs in this scenario because no event ever + // settles the in-flight handshake. We only assert the synchronous + // contract: fireOpen exercises onOpen, the builder throws inside, the + // socket logs + returns without sending the auth message. + void socket.connect().catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + expect(() => ws.fireOpen()).not.toThrow(); + expect(builder).toHaveBeenCalled(); + expect(ws.send).not.toHaveBeenCalled(); + }); + + it('connect() after disconnect() succeeds with a fresh transport', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const first = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + MockWebSocket.instances[0].fireOpen(); + MockWebSocket.instances[0].fireMessage(connectedEvent('conn-A')); + await first; + await socket.disconnect(); + expect(socket.isDisconnected()).toBe(true); + + // Re-arm the gate (the StreamClient facade does this in openConnection). + gate.arm(); + const second = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + expect(MockWebSocket.instances).toHaveLength(2); + MockWebSocket.instances[1].fireOpen(); + MockWebSocket.instances[1].fireMessage(connectedEvent('conn-B')); + const result = await second; + expect(result?.connection_id).toBe('conn-B'); + expect(socket.isDisconnected()).toBe(false); + }); + + it('concurrent close + REST: gate rejects on close, then resolves on next handshake', async () => { + const { socket, gate } = setupSocket(); + gate.arm(); + const first = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws1 = MockWebSocket.instances[0]; + ws1.fireOpen(); + ws1.fireMessage(connectedEvent('conn-A')); + await first; + expect(await gate.await()).toBe('conn-A'); + + // Abnormal close: invalidates the gate (rotates settled -> fresh rejected). + ws1.fireClose(1006, 'abnormal'); + await expect(gate.await()).rejects.toBeInstanceOf(WebSocketConnectionError); + + // Drive the inline reconnect cycle by advancing fake timers (the random + // retryInterval for failures=1 falls in [250 ms, 2500 ms]), then drive + // the new transport to connection.ok. The next handshake's gate.arm() + // rotates the rejected state to fresh pending; gate.resolve sets new id. + await vi.advanceTimersByTimeAsync(3000); + const ws2 = MockWebSocket.instances[1]; + expect(ws2).toBeDefined(); + ws2.fireOpen(); + ws2.fireMessage(connectedEvent('conn-B')); + expect(await gate.await()).toBe('conn-B'); + }); + + it('auth-handshake watchdog is cleared on a handshake-time connection.error', async () => { + const { socket, gate } = setupSocket({ authHandshakeTimeoutMs: 200 }); + gate.arm(); + const promise = socket.connect(); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 4, message: 'auth failed', StatusCode: 0 }, + }); + await expect(promise).rejects.toBeInstanceOf(WebSocketConnectionError); + // Past the watchdog window: must not fire (rejection.error already + // surfaced; double-reject would be a regression). + await vi.advanceTimersByTimeAsync(500); + // gate is rejected with the connection.error, NOT AUTH_HANDSHAKE_TIMEOUT. + await expect(gate.await()).rejects.not.toMatchObject({ + code: 'AUTH_HANDSHAKE_TIMEOUT', + }); + }); + + it('auth-handshake watchdog is cleared on onClose', async () => { + const { socket, gate } = setupSocket({ authHandshakeTimeoutMs: 200 }); + gate.arm(); + const promise = socket.connect().catch((e) => e); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + // Fire close BEFORE the watchdog deadline. + ws.fireClose(1006, 'abnormal'); + // Past the watchdog window: must not fire AUTH_HANDSHAKE_TIMEOUT. + await vi.advanceTimersByTimeAsync(500); + await expect(gate.await()).rejects.not.toMatchObject({ + code: 'AUTH_HANDSHAKE_TIMEOUT', + }); + await vi.advanceTimersByTimeAsync(2000); + await promise; + }); + + it('connect() with handshake-time code 40 schedules a refreshToken reconnect', async () => { + const { socket, gate, tokenManager } = setupSocket({ + staticToken: false, + authHandshakeTimeoutMs: 60000, + defaultWsTimeoutMs: 600, + }); + gate.arm(); + const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); + const promise = socket.connect().catch((e) => e); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.fireOpen(); + ws.fireMessage({ + type: 'connection.error', + connection_id: 'x', + created_at: new Date().toISOString(), + error: { code: 40, message: 'expired', StatusCode: 0 }, + }); + // The handshake-time refresh path is fire-and-forget; advancing past the + // random retryInterval for failures=1 ([250, 2500] ms) drives the reconnect. + await vi.advanceTimersByTimeAsync(3000); + expect(loadTokenSpy).toHaveBeenCalled(); + // Drain the outer poll timeout so the connect promise settles. + await vi.advanceTimersByTimeAsync(700); + await promise; + }); + + it('auth send failure does NOT arm the watchdog', async () => { + const { socket, gate } = setupSocket({ authHandshakeTimeoutMs: 200 }); + gate.arm(); + void socket.connect().catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + const ws = MockWebSocket.instances[0]; + ws.send.mockImplementationOnce(() => { + throw new Error('send failed'); + }); + ws.fireOpen(); + // Past the watchdog deadline: gate must NOT have an AUTH_HANDSHAKE_TIMEOUT + // rejection because onOpen returned early and never armed the watchdog. + await vi.advanceTimersByTimeAsync(500); + expect(gate.isPending()).toBe(true); + }); + + it('stale auth watchdog from a prior transport is a no-op (wsId guard)', async () => { + const { socket, gate } = setupSocket({ + WebSocketImpl: ManualWebSocket as unknown as typeof WebSocket, + authHandshakeTimeoutMs: 200, + defaultWsTimeoutMs: 60000, + }); + gate.arm(); + void socket.connect().catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + const firstWs = ManualWebSocket.instances[0]; + firstWs.fireOpen(); + // The watchdog is now armed for wsId=1. Bump wsId via disconnect; the + // watchdog body checks `myWsId !== this.wsId` and must early-return. + // Don't await disconnect: ManualWebSocket.close() does not auto-fire + // onclose, so the awaited promise depends on the graceful-close timer. + void socket.disconnect(); + // Drain the disconnect's graceful timer (100 ms) plus advance past the + // original auth watchdog deadline (200 ms). The stale watchdog body + // runs but no-ops because of the wsId guard. + await vi.advanceTimersByTimeAsync(500); + expect(socket.isDisconnected()).toBe(true); + // gate must NOT carry an AUTH_HANDSHAKE_TIMEOUT rejection. + if (gate.isSettled()) { + await expect(gate.await()).rejects.not.toMatchObject({ + code: 'AUTH_HANDSHAKE_TIMEOUT', + }); + } + }); }); From 1392bfbdfdf6b272b84a27f7e958aa635e9a7be9 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 14:36:09 +0200 Subject: [PATCH 17/18] fix(client): mark handshake resolved on connection.ok (revert F13 quirk) The legacy implementation never set isConnectionOpenResolved=true on connection.ok, so the FIRST mid-stream connection.error after the handshake was silently consumed by the handshake-error guard. In production this can leave the socket on a stale connection until a SECOND error arrives - notably, a single token-expired (code 40) event no longer triggers a refresh + reconnect. The plan deliberately preserved this quirk under F13. Codex's adversarial review flagged it correctly: the trade-off is wrong. Set isConnectionOpenResolved=true in the connection.ok branch so a single mid-stream connection.error takes the proper code path (failures++, mark unhealthy, on code 40 schedule a refreshToken reconnect). The F13 quirk test is inverted into a regression test that asserts the new behaviour. Two existing tests that fired duplicate errors as a workaround for the quirk now pass with a single error. --- .../__tests__/CoordinatorSocket.test.ts | 45 +++++-------------- .../connection/internal/CoordinatorSocket.ts | 6 +++ 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts b/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts index c0a29c1e69..0b98e0ba2b 100644 --- a/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts +++ b/packages/client/src/coordinator/connection/__tests__/CoordinatorSocket.test.ts @@ -230,30 +230,20 @@ describe('CoordinatorSocket', () => { // Spy: provider invocation count on tokenManager.loadToken const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); - // Mid-stream connection.error (code 40) expected to fire refresh. - // Note: today's quirk (F13) means the FIRST mid-stream connection.error is - // silently consumed by the handshake-error guard (because - // isConnectionOpenResolved is still false after connection.ok). Send TWO - // errors to exercise the reconnect path. + // Single mid-stream connection.error (code 40) MUST trigger a token + // refresh + reconnect. Regression test for the legacy "first mid-stream + // error is silently consumed" quirk that this rewrite removes. ws.fireMessage({ type: 'connection.error', connection_id: 'x', created_at: new Date().toISOString(), error: { code: 40, message: 'expired', StatusCode: 0 }, }); - ws.fireMessage({ - type: 'connection.error', - connection_id: 'x', - created_at: new Date().toISOString(), - error: { code: 40, message: 'expired', StatusCode: 0 }, - }); - // F7 log message wording check. expect(logger.info).toHaveBeenCalledWith( expect.stringContaining( 'onMessage(): WS failure due to expired token, scheduling reconnect with refreshed token', ), ); - // Allow the scheduled reconnect to fire (random retryInterval; cap 5s). await vi.advanceTimersByTimeAsync(6000); expect(loadTokenSpy).toHaveBeenCalled(); expect(eventDispatcher).toBeDefined(); @@ -361,7 +351,13 @@ describe('CoordinatorSocket', () => { expect(socket.isHealthy()).toBe(true); }); - it('F13 quirk: first mid-stream connection.error is silently consumed; second triggers reconnect', async () => { + it('regression: first mid-stream connection.error after connection.ok takes the mid-stream branch', async () => { + // Inverts the legacy F13 quirk: in the legacy implementation, + // isConnectionOpenResolved was never set on connection.ok, so the FIRST + // mid-stream connection.error was silently consumed by the handshake-error + // guard. The new implementation marks the handshake resolved on + // connection.ok, so a single mid-stream code-40 error correctly triggers + // a token refresh + reconnect. const { socket, gate, tokenManager } = setupSocket({ staticToken: false }); gate.arm(); const promise = socket.connect(); @@ -372,19 +368,6 @@ describe('CoordinatorSocket', () => { await promise; const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); - - // First mid-stream error: handshake-error guard fires (isConnectionOpenResolved - // was still false), early-return. No reconnect scheduled. - ws.fireMessage({ - type: 'connection.error', - connection_id: 'x', - created_at: new Date().toISOString(), - error: { code: 40, message: 'expired', StatusCode: 0 }, - }); - expect(loadTokenSpy).not.toHaveBeenCalled(); - - // Second mid-stream error: now isConnectionOpenResolved=true, falls through - // to the reconnect-handler branch. ws.fireMessage({ type: 'connection.error', connection_id: 'x', @@ -393,6 +376,7 @@ describe('CoordinatorSocket', () => { }); await vi.advanceTimersByTimeAsync(6000); expect(loadTokenSpy).toHaveBeenCalled(); + expect(socket.isHealthy()).toBe(false); }); it('mid-stream connection.error code 40 with isStatic() does NOT trigger reconnect', async () => { @@ -406,13 +390,6 @@ describe('CoordinatorSocket', () => { await promise; const loadTokenSpy = vi.spyOn(tokenManager, 'loadToken'); - // Two mid-stream errors (per F13: first is silently consumed). - ws.fireMessage({ - type: 'connection.error', - connection_id: 'x', - created_at: new Date().toISOString(), - error: { code: 40, message: 'expired', StatusCode: 0 }, - }); ws.fireMessage({ type: 'connection.error', connection_id: 'x', diff --git a/packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts b/packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts index 69eb947f42..9198173e6f 100644 --- a/packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts +++ b/packages/client/src/coordinator/connection/internal/CoordinatorSocket.ts @@ -434,6 +434,12 @@ export class CoordinatorSocket { } if (data && data.type === 'connection.ok') { + // Mark the handshake phase as resolved so any subsequent + // connection.error takes the mid-stream branch (and triggers reconnect + // / token refresh on code 40) instead of falling into the + // handshake-error guard. This deliberately deviates from the legacy + // "first mid-stream connection.error is silently consumed" quirk. + this.isConnectionOpenResolved = true; this.clearAuthHandshakeWatchdog(); this.resolveConnectionOpen?.(data); this.setHealth(true); From dffb3a15284de9638a81d5328be15e800d0264c6 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 6 May 2026 14:45:13 +0200 Subject: [PATCH 18/18] fix(client): self-contained base64 decoder in signing.ts (RN 0.73+ safe) Replace the atob / Buffer fallback with a runtime-independent base64 decoder. Hermes only shipped atob in React Native 0.74 (Hermes commit from January 2024), and React Native does not ship Buffer at all - so on the project peer-dep floor (RN >= 0.73) both globals are absent and JWT decoding fails silently. The fallback's try/catch swallows the ReferenceError and returns undefined, so TokenManager rejects the provider's token as not matching user.id and authenticated users cannot connect. The new decoder mirrors atob's contract (returns a Latin1 binary string) and skips invalid characters the same way. Base64url to standard base64 normalisation still happens in the caller. Tests: - delete globalThis.atob and globalThis.Buffer, then verify decoding still succeeds for a valid JWT. - regression guard that scans signing.ts (excluding comments) for any `atob` or `Buffer` reference, so a future change that reintroduces the dependency fails CI rather than RN runtime. --- .../connection/__tests__/signing.test.ts | 39 ++++++++++++++++ .../src/coordinator/connection/signing.ts | 45 ++++++++++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/client/src/coordinator/connection/__tests__/signing.test.ts b/packages/client/src/coordinator/connection/__tests__/signing.test.ts index 3a5e57b3ff..28ed7832e7 100644 --- a/packages/client/src/coordinator/connection/__tests__/signing.test.ts +++ b/packages/client/src/coordinator/connection/__tests__/signing.test.ts @@ -60,4 +60,43 @@ describe('getUserFromToken', () => { expect(segment.includes('-')).toBe(true); expect(getUserFromToken(token)).toBe('>>>'); }); + + it('decodes correctly when neither atob nor Buffer globals are present (RN 0.73 floor)', () => { + // Hermes shipped atob in RN 0.74 (Jan 2024 commit). Users on the project + // peer-dep floor (RN >= 0.73) do not have atob, and React Native does not + // ship Buffer either. The decoder is self-contained, but verify here that + // a call still succeeds when both globals are stripped. + const g = globalThis as { atob?: unknown; Buffer?: unknown }; + const originalAtob = g.atob; + const originalBuffer = g.Buffer; + try { + delete g.atob; + delete g.Buffer; + const token = buildJwt({ user_id: 'jane', sub: 'jane' }); + expect(getUserFromToken(token)).toBe('jane'); + } finally { + g.atob = originalAtob; + g.Buffer = originalBuffer; + } + }); + + it('signing.ts code (excluding comments) contains no atob/Buffer reference (regression guard)', async () => { + // If a future change reintroduces a runtime dependency on atob or Buffer, + // this test catches it before users on RN 0.73 hit the silent failure + // mode. Comments may mention either name freely; only executable code + // is checked. + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const raw = await fs.readFile( + path.resolve(__dirname, '../signing.ts'), + 'utf8', + ); + const codeOnly = raw + // strip /* ... */ block comments + .replace(/\/\*[\s\S]*?\*\//g, '') + // strip // line comments + .replace(/\/\/[^\n]*/g, ''); + expect(codeOnly).not.toMatch(/\batob\b/); + expect(codeOnly).not.toMatch(/\bBuffer\b/); + }); }); diff --git a/packages/client/src/coordinator/connection/signing.ts b/packages/client/src/coordinator/connection/signing.ts index 5860e87bf5..9e1f859df6 100644 --- a/packages/client/src/coordinator/connection/signing.ts +++ b/packages/client/src/coordinator/connection/signing.ts @@ -1,15 +1,48 @@ type JwtPayload = { user_id?: string }; +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** + * Self-contained standard-base64 decoder. Returns a Latin1-style binary string + * (one byte per output character), mirroring `atob`'s contract. Used because: + * + * - `atob` is a Hermes built-in only since React Native 0.74; users on the + * project's peer-dep floor (RN 0.73) do not have it. + * - `Buffer` is not shipped by React Native at all. + * + * Returning a self-contained decoder keeps the decoder's behaviour identical + * across Node, browsers, and React Native without requiring a polyfill. + * + * The input must already be standard base64 (the `-`/`_` to `+`/`/` + * normalisation happens in the caller). Padding is tolerated but not required. + * Invalid characters are skipped, matching `atob`'s lenient behaviour. + */ +const decodeStandardBase64 = (input: string): string => { + let output = ''; + let buffer = 0; + let bits = 0; + for (let i = 0; i < input.length; i++) { + const ch = input.charAt(i); + if (ch === '=') break; + const value = BASE64_ALPHABET.indexOf(ch); + if (value === -1) continue; + buffer = (buffer << 6) | value; + bits += 6; + if (bits >= 8) { + bits -= 8; + output += String.fromCharCode((buffer >> bits) & 0xff); + } + } + return output; +}; + const decodeJwtPayload = (token: string): JwtPayload | undefined => { const parts = token.split('.'); if (parts.length !== 3) return undefined; - const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - const padded = b64 + '=='.slice(0, (4 - (b64.length % 4)) % 4); + const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/'); try { - const json = - typeof atob === 'function' - ? atob(padded) - : Buffer.from(padded, 'base64').toString('utf8'); + const json = decodeStandardBase64(normalized); return JSON.parse(json) as JwtPayload; } catch { return undefined;