diff --git a/src/actor.ts b/src/actor.ts index 153224cd..28b06dc9 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -11,6 +11,10 @@ import type { Base44Client } from "./client"; +export type ActorConnectionIdentity = + | Readonly<{ type: "authenticated"; userId: string }> + | Readonly<{ type: "anonymous"; anonymousId: string }>; + /** * A single client connection. `Send` is the message type this connection accepts * via {@link send} — the actor's *outgoing* (server→client) messages. @@ -20,6 +24,8 @@ export interface Conn { * receives from `subscribe()`. Identifies a distinct client, so multiple * tabs are separate connections. */ id: string; + /** Identity verified by the Actor Worker. Legacy Actors may not provide it. */ + identity?: ActorConnectionIdentity; send(data: Send): void; reject(code: number, reason: string): void; } @@ -112,9 +118,10 @@ export abstract class Actor { /** * Anonymous Base44 client scoped to this actor instance — no user or service - * auth, so entity access is RLS-gated (same as a logged-out visitor). Always - * operates on production data: an actor runs server-side with no per-connection - * identity, so a Test DB preview selected in the editor does not apply here. + * auth, so entity access is RLS-gated (same as a logged-out visitor). A + * connection's verified `identity` is context only and is never applied to this + * client. Always operates on production data, so a Test DB preview selected in + * the editor does not apply here. * Example: `const rows = await this.client.entities.Score.list();` */ protected get client(): Base44Client { diff --git a/src/client.ts b/src/client.ts index d11f687b..b8586995 100644 --- a/src/client.ts +++ b/src/client.ts @@ -124,6 +124,14 @@ export function createClient(config: CreateClientConfig): Base44Client { onError: options?.onError, }); + // Actors keeps raw responses for 409 routing and owns error reporting so + // response-validation failures use the same onError path as request failures. + const actorConnectionClient = createAxiosClient({ + baseURL: `${serverUrl}/api`, + headers, + interceptResponses: false, + }); + const serviceRoleHeaders = { ...headers, ...(token ? { "on-behalf-of": `Bearer ${token}` } : {}), @@ -167,14 +175,16 @@ export function createClient(config: CreateClientConfig): Base44Client { const actorsModule = createActorsModule({ appId, - // serverUrl is often relative/empty (same-origin app); PartySocket needs an - // absolute host, so fall back to the page origin. + connectionClient: actorConnectionClient, + onError: options?.onError, + // serverUrl is often relative/empty in same-origin apps, while the legacy + // WebSocket fallback needs an absolute host. host: resolveActorsHost( serverUrl, typeof window !== "undefined" ? window.location?.origin : undefined, ), functionsVersion, - getAuthToken: () => token || getAccessToken(), + getAuthToken: () => userAuthModule.getToken(), }); const userModules = { diff --git a/src/client.types.ts b/src/client.types.ts index 3ee96303..77681ecc 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -19,6 +19,8 @@ import type { ActorsModule } from "./modules/actors.types.js"; export interface CreateClientOptions { /** * Optional error handler that will be called whenever an API error occurs. + * Actor connection failures are contextual `ActorConnectionError` instances. + * Retryable Actor failures may be reported more than once. */ onError?: (error: Error) => void; } diff --git a/src/index.ts b/src/index.ts index 0114462b..44414565 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,10 +117,11 @@ export type { ActorNameRegistry, ActorRegistry, } from "./modules/actors.types.js"; +export { ActorConnectionError } from "./modules/actors.error.js"; export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; -export { Actor, type Conn } from "./actor.js"; +export { Actor, type ActorConnectionIdentity, type Conn } from "./actor.js"; export type { ConnectorsModule, diff --git a/src/modules/actors.error.ts b/src/modules/actors.error.ts new file mode 100644 index 00000000..b7207d54 --- /dev/null +++ b/src/modules/actors.error.ts @@ -0,0 +1,28 @@ +/** Contextual error for an Actor bootstrap, WebSocket, or closed-connection failure. */ +export class ActorConnectionError extends Error { + /** HTTP response status, when the failure came from the bootstrap request. */ + readonly status?: number; + /** WebSocket close code, when the far end closed the connection. */ + readonly closeCode?: number; + /** WebSocket close reason, when the far end supplied one. */ + readonly closeReason?: string; + + constructor( + readonly actorName: string, + readonly instanceId: string, + readonly connectionId: string, + readonly cause: unknown, + status?: number, + closeCode?: number, + closeReason?: string, + ) { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + super( + `Actor "${actorName}" instance "${instanceId}" connection "${connectionId}": ${causeMessage}`, + ); + this.name = "ActorConnectionError"; + this.status = status; + this.closeCode = closeCode; + this.closeReason = closeReason; + } +} diff --git a/src/modules/actors.ts b/src/modules/actors.ts index c99a5ace..1f69e34a 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -1,4 +1,8 @@ -import PartySocket from "partysocket"; +import { isAxiosError, type AxiosInstance } from "axios"; +import PartySocket, { WebSocket as ReconnectingWebSocket } from "partysocket"; +import { getAnalyticsSessionId } from "./analytics.js"; +import { generateUuid } from "../utils/common.js"; +import { ActorConnectionError } from "./actors.error.js"; import type { ActorConnectOptions, ActorRef, @@ -8,66 +12,187 @@ import type { interface ActorsConfig { appId: string; - /** Current user access token, if authenticated. Rides the WS query so the - * platform proxy can authenticate the connection; anonymous connects omit it. */ + connectionClient: AxiosInstance; + onError?: (error: ActorConnectionError) => void; + /** Current user access token, if authenticated. */ getAuthToken(): string | null | undefined; /** Same semantics as function calls: editors with a non-prod version get the * draft actor script; everyone else gets the published one. */ functionsVersion?: string; - /** Absolute host PartySocket dials (it strips the scheme and connects wss, ws - * for localhost). Resolved by {@link resolveActorsHost}. */ + /** Absolute host used when a legacy Actor falls back to the Apper proxy. */ host: string; } +interface ActorConnectionTokenResponse { + websocket_url: string; + token: string; +} + +// Both mirror the Actor Worker dispatcher's own validation (see +// workers/base44-dispatcher/src/actor-routing.ts) so a request it would reject +// fails here instead. Rejection happens on the WebSocket *upgrade*, which +// reaches the browser as an opaque 1006 with no status to act on, so failing +// fast client-side is the only way to report the real reason. +const CONNECTION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; +const INSTANCE_ID_RE = /^[\x20-\x2e\x30-\x7e]{1,256}$/; + +const RETRYABLE_BOOTSTRAP_STATUSES = new Set([401, 408, 425, 429]); + // Heartbeat / half-open detection: PartySocket only reconnects on a close/error // event, so ping periodically and force a reconnect if nothing returns in DEAD_MS. const PING_MS = 1_000; const DEAD_MS = 3_000; +// While heartbeat reconnects keep coming back silent, the dead window doubles up +// to this cap. Every heartbeat re-dial buys a connection-token POST against a +// per-app rate limit, so a link that reopens but never delivers a frame must not +// hold the DEAD_MS cadence forever. Any inbound frame resets the window. +const MAX_DEAD_MS = 60_000; + +// PartySocket retries forever (`maxRetries: Infinity`) and every attempt costs a +// connection-token POST, so a connection that can never be admitted needs a +// bound. Close codes cannot provide one: the dispatcher refuses the upgrade over +// HTTP (opaque 1006 on the client), and 1000 arrives both from +// `conn.reject(1000, ...)` and from a graceful worker shutdown. Counting +// attempts measures "retrying is not working" directly instead. +// +// ~25-40s under PartySocket's 1-5s base delay x1.3 growth (10s cap): long enough +// to ride out a deploy, short enough to stop a rejection loop quickly. +const MAX_CONSECUTIVE_FAILURES = 6; +// A socket that holds this long proves retrying works, so the budget resets. +// Matches PartySocket's own `minUptime`. +const STABLE_MS = 5_000; /** - * A live connection to an actor instance. Only obtainable from - * {@link ActorRef.connect}, so `subscribe`/`send` are always valid — the socket - * exists for this object's whole lifetime. + * A connection to an actor instance. Only obtainable from {@link ActorRef.connect}. */ class Connection { - private readonly ws: PartySocket; + private readonly ws: ReconnectingWebSocket; private readonly listeners = new Set<(data: unknown) => void>(); + private readonly errorListeners = new Set<(error: ActorConnectionError) => void>(); + private readonly globalOnError?: (error: ActorConnectionError) => void; private heartbeat: ReturnType | null = null; + private isClosed = false; + private closedError: ActorConnectionError | null = null; + /** Attempts since the last socket that stayed up for {@link STABLE_MS}. */ + private consecutiveFailures = 0; + private stabilityTimer: ReturnType | null = null; /** The client-chosen conn id — becomes _pk → the actor's conn.id. */ readonly id: string; constructor( - actorName: string, - instanceId: string, + private readonly actorName: string, + private readonly instanceId: string, config: ActorsConfig, options: ActorConnectOptions | undefined, + getAnonymousId: () => string, private readonly onClose: () => void, ) { - this.id = options?.id ?? crypto.randomUUID(); - - const ws = new PartySocket({ - host: config.host, - party: actorName, - room: instanceId, - id: this.id, - // Re-read on every (re)connect so a login/logout is picked up. - query: () => { - const token = config.getAuthToken(); - return { - app_id: config.appId, - handler: actorName, - ...(token ? { token } : {}), - ...(config.functionsVersion ? { fv: config.functionsVersion } : {}), - }; + this.id = resolveConnectionId(options?.id); + this.globalOnError = config.onError; + this.addErrorListener(options?.onError); + + let bootstrapErrorAwaitingSocketEvent = false; + + const ws = new ReconnectingWebSocket( + async () => { + if (this.closedError) throw this.closedError; + bootstrapErrorAwaitingSocketEvent = false; + try { + return await resolveActorWebSocketUrl( + actorName, + instanceId, + this.id, + getAnonymousId, + config, + ); + } catch (error) { + const status = isAxiosError(error) ? error.response?.status : undefined; + const connectionError = this.createError(error, status); + bootstrapErrorAwaitingSocketEvent = true; + if (isTerminalBootstrapError(error)) { + this.reportError(connectionError, true); + } else if (status === undefined) { + // The request never reached the server (offline, DNS, aborted), so + // it consumes nothing and will succeed once connectivity returns. + // Charging the budget here would kill the connection over a tunnel. + this.reportError(connectionError); + } else { + this.reportAttemptFailure(connectionError); + } + throw connectionError; + } }, - }); + undefined, + { startClosed: true }, + ); this.ws = ws; - let lastMsg = Date.now(); - const bumpAlive = () => { lastMsg = Date.now(); }; - ws.addEventListener("open", bumpAlive); + let lastActivityAt: number | null = null; + let socketOpened = false; + let reconnectingForHeartbeat = false; + /** Watchdog trips with no inbound frame since: gates the one-per-outage + * silence report and widens the dead window. Reset by any message. */ + let silentReconnects = 0; + let closeSequence = 0; + let suppressedCloseSequence = 0; + const markAlive = () => { lastActivityAt = Date.now(); }; + const markDisconnected = () => { lastActivityAt = null; }; + ws.addEventListener("open", () => { + socketOpened = true; + markAlive(); + this.armStability(); + }); + ws.addEventListener("close", (event) => { + const closedSocketWasOpen = socketOpened; + socketOpened = false; + markDisconnected(); + this.clearStability(); + const currentCloseSequence = ++closeSequence; + // A heartbeat-driven reconnect is not a failed attempt: a legacy Actor + // without the __pong shim reconnects every DEAD_MS by design, so charging + // the budget for those would tear down every legacy connection. + if (this.isClosed || reconnectingForHeartbeat || !closedSocketWasOpen) return; + + // PartySocket emits a synthetic close before its error event. Defer so + // that transport errors are reported once with their real cause. + queueMicrotask(() => { + if (this.isClosed || suppressedCloseSequence === currentCloseSequence) return; + const reason = event.reason + ? `WebSocket closed with code ${event.code}: ${event.reason}` + : `WebSocket closed with code ${event.code}`; + const connectionError = this.createError( + new Error(reason), + undefined, + event.code, + event.reason || undefined, + ); + if (isTerminalSocketCloseCode(event.code)) { + this.reportError(connectionError, true); + } else { + this.reportAttemptFailure(connectionError); + } + }); + }); + ws.addEventListener("error", (event) => { + markDisconnected(); + suppressedCloseSequence = closeSequence; + if (this.isClosed) return; + if (bootstrapErrorAwaitingSocketEvent) { + bootstrapErrorAwaitingSocketEvent = false; + return; + } + const cause = event.error instanceof Error + ? event.error + : new Error(event.message || "Actor WebSocket error"); + // Also the only signal for a refused *upgrade* (bad token, unknown or + // throwing Actor): the dispatcher answers over HTTP, the socket never + // opens, and the close listener above skips it — so the budget applied + // here is what stops a permanently unroutable connection. + this.reportAttemptFailure(this.createError(cause)); + }); ws.addEventListener("message", (ev) => { - bumpAlive(); + markAlive(); + silentReconnects = 0; let data: unknown; try { data = JSON.parse(ev.data); @@ -80,22 +205,38 @@ class Connection { }); this.heartbeat = setInterval(() => { - if (Date.now() - lastMsg > DEAD_MS) { - bumpAlive(); // avoid a reconnect storm while the new socket comes up - ws.reconnect(); + if (lastActivityAt === null || ws.readyState !== ws.OPEN) return; + const deadMs = Math.min(DEAD_MS * 2 ** silentReconnects, MAX_DEAD_MS); + if (Date.now() - lastActivityAt > deadMs) { + lastActivityAt = null; + // One report per silent stretch: reopen → silence → re-dial cycles are + // one outage to the app, not a new failure every window. + if (silentReconnects === 0) { + this.reportError(this.createError(new Error("Actor WebSocket stopped responding"))); + } + silentReconnects += 1; + reconnectingForHeartbeat = true; + try { + ws.reconnect(1000, "heartbeat timeout"); + } finally { + reconnectingForHeartbeat = false; + } return; } try { // The deployed shim echoes __ping → __pong (base44-userapp-bundler - // shim/actor.ts); without that, an idle room reconnects every DEAD_MS. + // shim/actor.ts); without that, an idle room reconnects on a widening + // interval (DEAD_MS up to MAX_DEAD_MS). ws.send(JSON.stringify({ type: "__ping" })); } catch { - // not open; the watchdog above will reconnect + // The next watchdog tick will reconnect if the link stays half-open. } }, PING_MS); + ws.reconnect(); } subscribe(callback: (data: unknown) => void): ActorSubscription { + this.assertNotClosed(); this.listeners.add(callback); return { unsubscribe: () => { this.listeners.delete(callback); }, @@ -103,32 +244,168 @@ class Connection { } send(data: unknown): void { + this.assertNotClosed(); this.ws.send(JSON.stringify(data)); } + get closed(): boolean { + return this.isClosed; + } + + addErrorListener(listener?: (error: ActorConnectionError) => void): ActorSubscription { + if (!listener) return { unsubscribe: () => {} }; + this.assertNotClosed(); + this.errorListeners.add(listener); + return { + unsubscribe: () => { this.errorListeners.delete(listener); }, + }; + } + close(): void { + this.teardown(this.createError(new Error("Connection is closed"))); + } + + private assertNotClosed(): void { + if (!this.closedError) return; + throw this.createError( + this.closedError.cause, + this.closedError.status, + this.closedError.closeCode, + this.closedError.closeReason, + ); + } + + private createError( + cause: unknown, + status?: number, + closeCode?: number, + closeReason?: string, + ): ActorConnectionError { + return new ActorConnectionError( + this.actorName, + this.instanceId, + this.id, + cause, + status, + closeCode, + closeReason, + ); + } + + private reportError(error: ActorConnectionError, terminal = false): void { + const handlers = new Set(this.errorListeners); + if (this.globalOnError) handlers.add(this.globalOnError); + if (terminal) this.teardown(error); + for (const handler of handlers) { + try { + handler(error); + } catch { + // Error observers must not replace the failure PartySocket receives. + } + } + } + + /** + * Report a failed connection attempt, and give up once the budget is spent. + * This is the only bound on retries for failures a close code cannot classify + * — see {@link MAX_CONSECUTIVE_FAILURES}. + */ + private reportAttemptFailure(error: ActorConnectionError): void { + this.clearStability(); + const exhausted = ++this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES; + this.reportError(error, exhausted); + } + + private armStability(): void { + this.clearStability(); + this.stabilityTimer = setTimeout(() => { + this.stabilityTimer = null; + this.consecutiveFailures = 0; + }, STABLE_MS); + } + + private clearStability(): void { + if (this.stabilityTimer) { + clearTimeout(this.stabilityTimer); + this.stabilityTimer = null; + } + } + + private teardown(error: ActorConnectionError): void { + if (this.isClosed) return; + this.isClosed = true; + this.closedError = error; if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } this.listeners.clear(); + this.errorListeners.clear(); + this.clearStability(); this.ws.close(); this.onClose(); } } +/** + * Whether a close code proves a retry can never succeed. + * + * Only 1000 and 3000-4999 can reach us as a deliberate server-side choice — the + * WebSocket API rejects every other code passed to `close()`, so the rest are + * generated by the transport (1006 on a dropped link or a refused upgrade, 1001 + * on shutdown, 1011 on a server error) and are all worth retrying. + * + * Of those two, only 3000-4999 carries app intent: `conn.reject(code, reason)` + * picks it, and the Actor's decision will not differ on the next attempt. 1000 + * is ambiguous — `reject(1000, ...)` and a graceful worker shutdown are + * indistinguishable on the wire — so it is left to the attempt budget, which + * reconnects through a deploy but still gives up on a rejection loop. + */ +function isTerminalSocketCloseCode(code: number): boolean { + return code >= 3000 && code <= 4999; +} + +function isTerminalBootstrapError(error: unknown): boolean { + if (!isAxiosError(error)) return true; + const status = error.response?.status; + return status !== undefined + && status >= 400 + && status < 500 + && !RETRYABLE_BOOTSTRAP_STATUSES.has(status); +} + /** Handle for one actor instance: `connect()` opens the socket (idempotent). */ function makeActorRef( actorName: string, instanceId: string, config: ActorsConfig, + getAnonymousId: () => string, connections: Set, ): ActorRef { + assertValidInstanceId(instanceId); let conn: Connection | null = null; return { connect(options?: ActorConnectOptions) { - if (conn) return conn as unknown as ConnectionType; - const c = new Connection(actorName, instanceId, config, options, () => { + // Before the conflict check below, so a malformed id reports as malformed + // rather than as a mismatch against the live connection's (always valid) id. + if (options?.id !== undefined) assertValidConnectionId(options.id); + if (conn) { + if (options?.id !== undefined && options.id !== conn.id) { + throw new Error( + `Actor connection is already open with id "${conn.id}"; cannot reuse it with id "${options.id}"`, + ); + } + if (options?.onError) { + // Registering here would be unremovable: the subscription that + // addErrorListener returns has nowhere to go, so a handler recreated + // per render would accumulate for the connection's whole life. + throw new Error( + "Actor connection is already open; use connection.addErrorListener() to add an error handler", + ); + } + return conn as unknown as ConnectionType; + } + const c = new Connection(actorName, instanceId, config, options, getAnonymousId, () => { connections.delete(c); if (conn === c) conn = null; // allow a fresh connect() after close }); @@ -140,19 +417,115 @@ function makeActorRef( } /** - * Absolute host for the actor WebSocket. PartySocket needs an absolute host and - * can't resolve a relative/empty `serverUrl` (same-origin apps use a relative - * `/api`, so `serverUrl` is often `""`), so fall back to the page origin. - * PartySocket handles the scheme (https→wss, ws for localhost). + * Absolute host for the legacy Actor WebSocket. A relative/empty `serverUrl` + * cannot identify the Apper proxy, so same-origin apps fall back to the page + * origin. */ export function resolveActorsHost(serverUrl: string, browserOrigin?: string): string { return serverUrl && !serverUrl.startsWith("/") ? serverUrl : browserOrigin ?? serverUrl; } +function assertValidConnectionId(id: string): void { + if (!CONNECTION_ID_RE.test(id)) { + throw new Error( + "Actor connection id must be 1-64 letters, numbers, underscores, or hyphens", + ); + } +} + +function assertValidInstanceId(instanceId: string): void { + if (!INSTANCE_ID_RE.test(instanceId)) { + throw new Error( + `Actor instance id "${instanceId}" must be 1-256 printable ASCII characters and cannot contain "/"`, + ); + } +} + +function resolveConnectionId(id?: string): string { + if (id !== undefined) { + assertValidConnectionId(id); + return id; + } + return globalThis.crypto?.randomUUID?.() ?? generateUuid(); +} + +function legacyActorWebSocketUrl( + actorName: string, + instanceId: string, + connectionId: string, + authToken: string | null | undefined, + config: ActorsConfig, +): string { + const url = new URL(new PartySocket({ + host: config.host, + party: actorName, + room: instanceId, + id: connectionId, + startClosed: true, + }).roomUrl); + url.searchParams.set("_pk", connectionId); + url.searchParams.set("app_id", config.appId); + url.searchParams.set("handler", actorName); + if (authToken) url.searchParams.set("token", authToken); + if (config.functionsVersion) url.searchParams.set("fv", config.functionsVersion); + return url.toString(); +} + +async function resolveActorWebSocketUrl( + actorName: string, + instanceId: string, + connectionId: string, + getAnonymousId: () => string, + config: ActorsConfig, +): Promise { + const authToken = config.getAuthToken(); + const anonymousId = authToken ? null : getAnonymousId(); + const response = await config.connectionClient.post( + `/apps/${encodeURIComponent(config.appId)}/actors/${encodeURIComponent(actorName)}/connection-token`, + { room: instanceId, connection_id: connectionId }, + { + headers: { + Authorization: authToken ? `Bearer ${authToken}` : null, + "X-Base44-Anonymous-Id": anonymousId, + ...(config.functionsVersion + ? { "Base44-Functions-Version": config.functionsVersion } + : {}), + }, + validateStatus: (status) => + (status >= 200 && status < 300) || status === 409, + }, + ); + + if (response.status === 409) { + return legacyActorWebSocketUrl( + actorName, + instanceId, + connectionId, + authToken, + config, + ); + } + + const websocketUrl = response.data?.websocket_url; + const token = response.data?.token; + if (typeof websocketUrl !== "string" || !websocketUrl || typeof token !== "string" || !token) { + throw new Error("Invalid Actor connection response"); + } + const url = new URL(websocketUrl); + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new Error("Invalid Actor WebSocket URL"); + } + url.searchParams.set("_pk", connectionId); + url.searchParams.set("token", token); + return url.toString(); +} + export function createActorsModule(config: ActorsConfig) { // Live connections this client opened, so client.cleanup() can reclaim any the // app forgot to close() (each connection removes itself here on close). const connections = new Set(); + let anonymousId: string | undefined; + const getAnonymousId = () => anonymousId ??= getAnalyticsSessionId(); const module = new Proxy( {} as Record ActorRef>, { @@ -161,7 +534,7 @@ export function createActorsModule(config: ActorsConfig) { // for a thenable when awaited); any string key is an actor name. if (typeof key !== "string" || key === "then") return undefined; return (instanceId: string) => - makeActorRef(key, instanceId, config, connections); + makeActorRef(key, instanceId, config, getAnonymousId, connections); }, }, ); diff --git a/src/modules/actors.types.ts b/src/modules/actors.types.ts index 246b56af..f7ba1e0c 100644 --- a/src/modules/actors.types.ts +++ b/src/modules/actors.types.ts @@ -1,3 +1,5 @@ +import type { ActorConnectionError } from "./actors.error.js"; + /** * Extend this interface to add typed `subscribe` callbacks and `send` payloads * for your deployed Actors. @@ -43,31 +45,49 @@ type ToServerFor = N extends keyof ActorRegistry export interface ActorConnectOptions { /** * The connection id — becomes the actor's `conn.id`. Supply a stable value - * (e.g. persisted per tab) so a reconnect reuses the same server-side - * identity; omit for an auto-generated per-connection id. + * (e.g. persisted per tab) containing 1–64 letters, numbers, underscores, or + * hyphens so a reconnect reuses the same server-side identity; omit for an + * auto-generated per-connection id. */ id?: string; + + /** + * Called when this connection's bootstrap or WebSocket fails. Retryable + * failures may be reported more than once — the connection keeps retrying + * until it either succeeds or exhausts its attempt budget, and the final + * report before it gives up is the one where {@link Connection.closed} + * becomes `true`. + * + * Accepted only on the call that creates the connection: a later + * `connect({ onError })` throws, because a handler registered then could + * never be removed. Use {@link Connection.addErrorListener} for that. + */ + onError?: (error: ActorConnectionError) => void; } -/** Handle for one listener registered via {@link Connection.subscribe}. */ +/** Handle for one connection listener. */ export interface ActorSubscription { - /** Remove this listener; other listeners and the socket stay live. */ + /** Remove this listener; other listeners and the connection stay live. */ unsubscribe(): void; } /** - * A live connection to an actor instance, returned by {@link ActorRef.connect}. - * `subscribe`/`send` are always valid — you only get a `Connection` once the - * socket has been opened, so there's no pre-connect state to guard against. + * A connection to an actor instance, returned by {@link ActorRef.connect}. */ export interface Connection { /** The connection id (the value the actor sees as `conn.id`). */ readonly id: string; - /** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */ + /** Whether this connection has been explicitly or terminally closed. */ + readonly closed: boolean; + + /** Register a detachable connection-error listener. Throws if the connection has closed. */ + addErrorListener(listener?: (error: ActorConnectionError) => void): ActorSubscription; + + /** Register a message listener. Throws if the connection has closed. */ subscribe(callback: (data: ToClientFor) => void): ActorSubscription; - /** Send a message. Buffered by the socket until it's open. */ + /** Send a message. Buffered until open; throws instead of buffering after close. */ send(data: ToServerFor): void; /** Tear down the socket, heartbeat, and all listeners. */ @@ -79,7 +99,11 @@ export interface Connection { * {@link connect} to open the socket and get a {@link Connection}. */ export interface ActorRef { - /** Open the WebSocket and return the {@link Connection}. Idempotent. */ + /** + * Open the WebSocket and return the {@link Connection}. Repeated calls reuse + * the live connection; a conflicting explicit id, or an `onError` handler the + * reused connection could not later detach, throws. + */ connect(options?: ActorConnectOptions): Connection; } diff --git a/src/modules/auth.ts b/src/modules/auth.ts index b9e23747..d8c56810 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -111,11 +111,15 @@ export function createAuthModule( // Tracked here rather than read off `axios.defaults` so the answer stays tied // to the identity transitions below (`setToken`, `logout`) instead of to the // header a caller may have set on the instance directly. - let hasAccessToken = Boolean(options.token); + let accessToken = options.token; return { hasToken() { - return hasAccessToken; + return Boolean(accessToken); + }, + + getToken() { + return accessToken; }, // Get current user information @@ -198,7 +202,7 @@ export function createAuthModule( // flight would otherwise resolve into callers that run after the logout. clearPendingMe(); resetAnalyticsSessionContext(); - hasAccessToken = false; + accessToken = undefined; // Only do the rest if in a browser environment if (typeof window !== "undefined") { @@ -230,7 +234,7 @@ export function createAuthModule( // resolved for the previous one must not be handed to later callers. clearPendingMe(); resetAnalyticsSessionContext(); - hasAccessToken = true; + accessToken = token; // handle token change for axios clients axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; diff --git a/src/modules/auth.types.ts b/src/modules/auth.types.ts index d32b87e2..49ca78cd 100644 --- a/src/modules/auth.types.ts +++ b/src/modules/auth.types.ts @@ -567,4 +567,7 @@ export interface InternalAuthModule extends AuthModule { * could not succeed without a session, not to decide that one is valid. */ hasToken(): boolean; + + /** Current access token used by internal transports. */ + getToken(): string | undefined; } diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index f432e9d4..7ca9e516 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -182,7 +182,10 @@ export function createAxiosClient({ // On unauthenticated requests, attach a stable anonymous visitor id so the // backend can support anonymous agent access (conversation grouping + ownership). // Authenticated requests are identified by their Authorization header instead. - if (!config.headers.get("Authorization")) { + if ( + !config.headers.get("Authorization") && + !config.headers.get("X-Base44-Anonymous-Id") + ) { config.headers.set("X-Base44-Anonymous-Id", getAnalyticsSessionId()); } } diff --git a/tests/types/actor-connection-identity.types.ts b/tests/types/actor-connection-identity.types.ts new file mode 100644 index 00000000..a6416839 --- /dev/null +++ b/tests/types/actor-connection-identity.types.ts @@ -0,0 +1,54 @@ +import type { + ActorConnectOptions, + ActorConnectionError, + ActorConnectionIdentity, + ActorSubscription, + Connection, + Conn, +} from "../../src/index.js"; + +const authenticated: ActorConnectionIdentity = { + type: "authenticated", + userId: "user-1", +}; + +const anonymous: ActorConnectionIdentity = { + type: "anonymous", + anonymousId: "browser-1", +}; + +declare const connection: Conn; + +if (connection.identity?.type === "authenticated") { + connection.identity.userId satisfies string; +} else if (connection.identity?.type === "anonymous") { + connection.identity.anonymousId satisfies string; +} + +// @ts-expect-error Authenticated identities require a userId. +const missingUserId: ActorConnectionIdentity = { type: "authenticated" }; + +// @ts-expect-error Verified identity fields are readonly. +authenticated.userId = "user-2"; + +authenticated satisfies ActorConnectionIdentity; +anonymous satisfies ActorConnectionIdentity; +missingUserId satisfies ActorConnectionIdentity; + +const connectOptions: ActorConnectOptions = { + onError(error) { + error satisfies ActorConnectionError; + error.actorName satisfies string; + error.instanceId satisfies string; + error.connectionId satisfies string; + error.closeCode satisfies number | undefined; + error.closeReason satisfies string | undefined; + }, +}; + +declare const clientConnection: Connection; +clientConnection.closed satisfies boolean; +clientConnection.addErrorListener((error) => { + error satisfies ActorConnectionError; +}) satisfies ActorSubscription; +connectOptions satisfies ActorConnectOptions; diff --git a/tests/unit/actors-partysocket-contract.test.ts b/tests/unit/actors-partysocket-contract.test.ts new file mode 100644 index 00000000..25185184 --- /dev/null +++ b/tests/unit/actors-partysocket-contract.test.ts @@ -0,0 +1,168 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +// The main actors suite replaces PartySocket's WebSocket with a fake that +// hand-models its event ordering — so it would keep passing if PartySocket +// changed that ordering underneath us. These tests drive the REAL +// ReconnectingWebSocket against a stub raw socket instead, pinning the one +// library detail the reporting logic depends on: `_handleError` dispatches a +// synthetic `close` (code 1000) *before* the `error` carrying the real cause, +// so a transport failure must report once, with the cause, and not as a 1000. +// +// `partysocket` is pinned to an exact patch ("^0.0.23" on a 0.0.x version), so +// this can only break on a deliberate bump. That is exactly when it should. +import { createActorsModule } from "../../src/modules/actors.ts"; +import type { ActorConnectionError } from "../../src/modules/actors.error.ts"; + +const rawSockets: StubWebSocket[] = []; + +class StubWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + readyState = 0; + binaryType = "blob"; + private handlers: Record void)[]> = {}; + + constructor(readonly url: string) { + rawSockets.push(this); + } + addEventListener(type: string, fn: (ev: any) => void) { + (this.handlers[type] ??= []).push(fn); + } + removeEventListener(type: string, fn: (ev: any) => void) { + this.handlers[type] = (this.handlers[type] ?? []).filter((h) => h !== fn); + } + send() {} + close() { + this.readyState = 3; + } + private fire(type: string, event: Record = {}) { + for (const handler of [...(this.handlers[type] ?? [])]) { + handler({ type, target: this, ...event }); + } + } + /** Handshake succeeded. */ + open() { + this.readyState = 1; + this.fire("open"); + } + /** The far end sent a close frame (e.g. the actor called conn.reject). */ + remoteClose(code: number, reason = "") { + this.readyState = 3; + this.fire("close", { code, reason }); + } + /** A transport-level failure, as a browser reports it. */ + fail(message: string) { + this.fire("error", { message, error: new Error(message) }); + } +} + +function connect() { + const reported: ActorConnectionError[] = []; + const post = vi.fn().mockResolvedValue({ + status: 200, + data: { websocket_url: "wss://actors.example/v1/actors/a/rooms/r", token: "t" }, + }); + const actors = createActorsModule({ + appId: "app-1", + host: "https://app.example", + connectionClient: { post } as any, + getAuthToken: () => "user-tok", + onError: (error) => reported.push(error), + }).module; + return { conn: actors.GameRoom("r").connect({ id: "c1" }), reported, post }; +} + +/** Let the async bootstrap (POST + PartySocket's queued reconnect) land. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 30)); + +describe("PartySocket event-ordering contract", () => { + beforeEach(() => { + rawSockets.length = 0; + vi.stubGlobal("WebSocket", StubWebSocket); + }); + + test("a transport failure reports once, with its cause, not as a synthetic 1000", async () => { + const { conn, reported } = connect(); + await settle(); + expect(rawSockets).toHaveLength(1); + + rawSockets[0].open(); + rawSockets[0].fail("connection reset"); + await settle(); + + // One report. If the synthetic close leaked through we would see two, and + // the 1000 would have been misread as a clean shutdown. + expect(reported).toHaveLength(1); + expect(reported[0].closeCode).toBeUndefined(); + expect((reported[0].cause as Error).message).toBe("connection reset"); + expect(conn.closed).toBe(false); + + conn.close(); + }); + + test("an actor rejection in the app-defined range terminates on the first close", async () => { + const { conn, reported } = connect(); + await settle(); + + rawSockets[0].open(); + rawSockets[0].remoteClose(4001, "not allowed"); + await settle(); + + expect(reported).toHaveLength(1); + expect(reported[0]).toMatchObject({ closeCode: 4001, closeReason: "not allowed" }); + expect(conn.closed).toBe(true); + // Terminal means no further token purchases. + expect(rawSockets).toHaveLength(1); + }); + + test("a dropped link reports once and keeps retrying", async () => { + vi.useFakeTimers(); + try { + const { conn, reported, post } = connect(); + await vi.advanceTimersByTimeAsync(30); + + rawSockets[0].open(); + rawSockets[0].remoteClose(1006); + await vi.advanceTimersByTimeAsync(30); + + expect(reported).toHaveLength(1); + expect(reported[0].closeCode).toBe(1006); + expect(conn.closed).toBe(false); + + // PartySocket's first retry waits its 1-5s randomized base delay, so step + // past the top of that range to confirm it really did re-bootstrap. + await vi.advanceTimersByTimeAsync(5_200); + expect(post.mock.calls.length).toBeGreaterThan(1); + expect(rawSockets.length).toBeGreaterThan(1); + + conn.close(); + } finally { + vi.useRealTimers(); + } + }); + + test("a heartbeat-forced reconnect reports once and stays open", async () => { + vi.useFakeTimers(); + try { + const { conn, reported } = connect(); + await vi.advanceTimersByTimeAsync(30); + rawSockets[0].open(); + + // No inbound traffic for more than DEAD_MS: the watchdog reconnects. + await vi.advanceTimersByTimeAsync(5_000); + + expect(reported).toHaveLength(1); + expect((reported[0].cause as Error).message).toBe( + "Actor WebSocket stopped responding", + ); + expect(reported[0].closeCode).toBeUndefined(); + expect(conn.closed).toBe(false); + + conn.close(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 79ebed51..6dc3199f 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -1,58 +1,635 @@ -import { describe, test, expect, vi, beforeEach } from "vitest"; +import nock from "nock"; +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -// Mock PartySocket with a controllable fake so we can drive open/message events -// and assert connect/subscribe/send/close behavior without a real socket. +// Mock the reconnecting WebSocket with a controllable fake so we can resolve +// async URLs and drive open/message/reconnect behavior without a real socket. // vi.hoisted so the class/registry exist before the hoisted vi.mock factory runs. -const { sockets, FakeSocket } = vi.hoisted(() => { +const { sockets, FakeSocket, post, getAnalyticsSessionId } = vi.hoisted(() => { class FakeSocket { - opts: any; + readonly OPEN = 1; sent: string[] = []; closed = false; + readyState = 3; + urls: Promise[] = []; private handlers: Record void)[]> = {}; - constructor(opts: any) { - this.opts = opts; + constructor( + private readonly urlProvider: () => string | Promise, + _protocols?: unknown, + options?: { startClosed?: boolean }, + ) { sockets.push(this); + if (!options?.startClosed) this.openNext(); + } + private openNext() { + this.closed = false; + this.readyState = 0; + this.urls.push(Promise.resolve(this.urlProvider())); } addEventListener(type: string, fn: (ev: any) => void) { (this.handlers[type] ??= []).push(fn); } send(data: string) { this.sent.push(data); } - close() { this.closed = true; } - reconnect() {} - emit(type: string, ev: any) { (this.handlers[type] ?? []).forEach((h) => h(ev)); } + close() { + this.closed = true; + this.readyState = 3; + } + reconnect() { this.openNext(); } + emit(type: string, ev: any) { + if (type === "open") this.readyState = this.OPEN; + if (type === "close") this.readyState = 3; + (this.handlers[type] ?? []).forEach((h) => h(ev)); + } message(obj: unknown) { this.emit("message", { data: JSON.stringify(obj) }); } } const sockets: InstanceType[] = []; - return { sockets, FakeSocket }; + const post = vi.fn(); + const getAnalyticsSessionId = vi.fn(() => "anonymoussessionid"); + return { sockets, FakeSocket, post, getAnalyticsSessionId }; }); -vi.mock("partysocket", () => ({ default: FakeSocket })); +vi.mock("partysocket", async () => ({ + ...(await vi.importActual("partysocket")), + WebSocket: FakeSocket, +})); + +vi.mock("../../src/modules/analytics.ts", async () => ({ + ...(await vi.importActual( + "../../src/modules/analytics.ts" + )), + getAnalyticsSessionId, +})); import { createActorsModule, resolveActorsHost } from "../../src/modules/actors.ts"; +import { ActorConnectionError, createClient } from "../../src/index.ts"; describe("Actors Module — connection API", () => { const config = { appId: "app-1", + connectionClient: { post } as any, getAuthToken: () => "user-tok", - functionsVersion: undefined, + onError: undefined as ((error: Error) => void) | undefined, + functionsVersion: undefined as string | undefined, host: "https://app.example", }; // The module (Proxy of actor names). closeAll is separate — see its own tests. - const mod = (c: typeof config = config) => createActorsModule(c).module; + const mod = (overrides: Partial = {}) => + createActorsModule({ ...config, ...overrides }).module; - beforeEach(() => { sockets.length = 0; }); + beforeEach(() => { + sockets.length = 0; + post.mockReset(); + getAnalyticsSessionId.mockClear(); + post.mockResolvedValue({ status: 409, data: {} }); + }); - test("connect() opens exactly one socket with the auth query", () => { + afterEach(() => { + nock.cleanAll(); + }); + + test("connect() probes Apper and uses the legacy proxy only for 409", async () => { const conn = mod().GameRoom("room-1").connect({ id: "conn-1" }); + expect(sockets).toHaveLength(1); expect(conn.id).toBe("conn-1"); - const q = sockets[0].opts.query(); - expect(q).toMatchObject({ app_id: "app-1", handler: "GameRoom", token: "user-tok" }); - expect(sockets[0].opts.room).toBe("room-1"); - expect(sockets[0].opts.id).toBe("conn-1"); - // the resolved host is handed to PartySocket (which swaps the scheme). - expect(sockets[0].opts.host).toBe("https://app.example"); + const url = new URL(await sockets[0].urls[0]); + expect(url.origin).toBe("wss://app.example"); + expect(url.pathname).toBe("/parties/GameRoom/room-1"); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + _pk: "conn-1", + app_id: config.appId, + handler: "GameRoom", + token: "user-tok", + }); + expect(post).toHaveBeenCalledWith( + `/apps/${config.appId}/actors/GameRoom/connection-token`, + { room: "room-1", connection_id: "conn-1" }, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer user-tok" }), + }), + ); + }); + + test("a direct response uses the opaque dispatcher URL and short-lived token", async () => { + post.mockResolvedValue({ + status: 200, + data: { + websocket_url: + "wss://actors.example/v1/actors/actor-p-0123456789abcdef01234567-aaaaaaaaaaaaaaaaaaaaaaaaaa/rooms/room-1?_pk=wrong-id", + token: "actor-token", + expires_at: "2026-08-23T12:00:00Z", + mode: "prod", + }, + }); + + mod().GameRoom("room-1").connect({ id: "conn-1" }); + const url = new URL(await sockets[0].urls[0]); + + expect(url.origin).toBe("wss://actors.example"); + expect(url.searchParams.get("_pk")).toBe("conn-1"); + expect(url.searchParams.get("token")).toBe("actor-token"); + expect(url.toString()).not.toContain("user-tok"); + }); + + test.each([null, undefined])( + "an empty direct response reports the intended validation error for %s data", + async (data) => { + const onError = vi.fn(); + post.mockResolvedValue({ status: 204, data }); + mod({ onError }).GameRoom("r").connect({ id: "c1" }); + + await expect(sockets[0].urls[0]).rejects.toThrow( + "Invalid Actor connection response", + ); + expect(onError).toHaveBeenCalledOnce(); + const reportedError = onError.mock.calls[0][0]; + expect(reportedError).toBeInstanceOf(ActorConnectionError); + expect(reportedError).toMatchObject({ + actorName: "GameRoom", + instanceId: "r", + connectionId: "c1", + }); + expect(reportedError.cause).toEqual( + new Error("Invalid Actor connection response"), + ); + expect(sockets[0].closed).toBe(true); + }, + ); + + test("every reconnect probes again and picks up current auth", async () => { + let authToken = "first-user-token"; + post + .mockResolvedValueOnce({ + status: 200, + data: { + websocket_url: "wss://actors.example/v1/actors/actor-p-id/rooms/r?_pk=c1", + token: "first-actor-token", + }, + }) + .mockResolvedValueOnce({ + status: 200, + data: { + websocket_url: "wss://actors.example/v1/actors/actor-p-id/rooms/r?_pk=c1", + token: "second-actor-token", + }, + }); + + mod({ getAuthToken: () => authToken }).GameRoom("r").connect({ id: "c1" }); + expect(new URL(await sockets[0].urls[0]).searchParams.get("token")).toBe( + "first-actor-token", + ); + + authToken = "second-user-token"; + sockets[0].reconnect(); + expect(new URL(await sockets[0].urls[1]).searchParams.get("token")).toBe( + "second-actor-token", + ); + expect(post).toHaveBeenCalledTimes(2); + expect(post.mock.calls[1][2].headers.Authorization).toBe( + "Bearer second-user-token", + ); + }); + + test("legacy routing is not cached between reconnects", async () => { + mod().GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + sockets[0].reconnect(); + await sockets[0].urls[1]; + + expect(post).toHaveBeenCalledTimes(2); + }); + + test.each([400, 403, 404, 422])( + "permanent status %s is reported once and releases the connection", + async (status) => { + const error = Object.assign(new Error(`Request failed with status code ${status}`), { + isAxiosError: true, + response: { status }, + }); + const onError = vi.fn(); + const connectionOnError = vi.fn(); + post.mockRejectedValueOnce(error).mockResolvedValue({ status: 409, data: {} }); + const ref = mod({ onError }).GameRoom("r"); + const conn = ref.connect({ id: "c1", onError: connectionOnError }); + + await expect(sockets[0].urls[0]).rejects.toThrow(`status code ${status}`); + expect(onError).toHaveBeenCalledOnce(); + const reportedError = onError.mock.calls[0][0]; + expect(reportedError).toBeInstanceOf(ActorConnectionError); + expect(reportedError).toMatchObject({ + actorName: "GameRoom", + instanceId: "r", + connectionId: "c1", + status, + cause: error, + }); + expect(connectionOnError).toHaveBeenCalledOnce(); + expect(connectionOnError).toHaveBeenCalledWith(reportedError); + expect(conn.closed).toBe(true); + expect(sockets[0].closed).toBe(true); + expect(sockets[0].urls).toHaveLength(1); + expect(() => conn.send({ type: "after-close" })).toThrow(reportedError); + expect(() => conn.subscribe(() => {})).toThrow(reportedError); + expect(sockets[0].sent).toHaveLength(0); + const requestOptions = post.mock.calls[0][2]; + expect(requestOptions.validateStatus(409)).toBe(true); + expect(requestOptions.validateStatus(status)).toBe(false); + expect(requestOptions.validateStatus(503)).toBe(false); + + ref.connect({ id: "c2" }); + expect(sockets).toHaveLength(2); + await expect(sockets[1].urls[0]).resolves.toContain("/parties/GameRoom/r"); + }, + ); + + test.each([408, 425, 429, 503])( + "status %s remains retryable under PartySocket backoff", + async (status) => { + const error = Object.assign(new Error(`Request failed with status code ${status}`), { + isAxiosError: true, + response: { status }, + }); + const onError = vi.fn(); + post.mockRejectedValue(error); + mod({ onError }).GameRoom("r").connect({ id: "c1" }); + + await expect(sockets[0].urls[0]).rejects.toThrow(`status code ${status}`); + expect(sockets[0].closed).toBe(false); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0]).toMatchObject({ status, cause: error }); + }, + ); + + test("a 401 retries with refreshed auth instead of closing the connection", async () => { + let authToken = "expired-token"; + const error = Object.assign(new Error("Request failed with status code 401"), { + isAxiosError: true, + response: { status: 401 }, + }); + const connectionOnError = vi.fn(); + post + .mockRejectedValueOnce(error) + .mockResolvedValue({ + status: 200, + data: { + websocket_url: "wss://actors.example/v1/actors/actor-p-id/rooms/r", + token: "actor-token", + }, + }); + const conn = mod({ getAuthToken: () => authToken }) + .GameRoom("r") + .connect({ id: "c1", onError: connectionOnError }); + + await expect(sockets[0].urls[0]).rejects.toThrow("status code 401"); + expect(conn.closed).toBe(false); + expect(connectionOnError.mock.calls[0][0]).toMatchObject({ + actorName: "GameRoom", + instanceId: "r", + connectionId: "c1", + status: 401, + cause: error, + }); + + authToken = "refreshed-token"; + sockets[0].reconnect(); + await expect(sockets[0].urls[1]).resolves.toContain("token=actor-token"); + expect(post.mock.calls[1][2].headers.Authorization).toBe("Bearer refreshed-token"); + }); + + test("network failures remain retryable under PartySocket backoff", async () => { + const onError = vi.fn(); + const error = Object.assign(new Error("Network Error"), { + isAxiosError: true, + }); + post.mockRejectedValue(error); + mod({ onError }).GameRoom("r").connect({ id: "c1" }); + + await expect(sockets[0].urls[0]).rejects.toThrow("Network Error"); + expect(sockets[0].closed).toBe(false); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0]).toMatchObject({ cause: error }); + }); + + // conn.reject() picks a code in the app-defined range, and the actor's verdict + // will not differ next attempt, so one rejection is enough to give up. + test.each([3000, 3001, 4003, 4999])( + "actor rejection code %s is reported and terminates the connection", + async (code) => { + const onError = vi.fn(); + const connectionOnError = vi.fn(); + const conn = mod({ onError }) + .GameRoom("r") + .connect({ id: "c1", onError: connectionOnError }); + await sockets[0].urls[0]; + + sockets[0].emit("open", {}); + sockets[0].emit("close", { code, reason: "connection rejected" }); + await Promise.resolve(); + + expect(conn.closed).toBe(true); + expect(sockets[0].closed).toBe(true); + expect(connectionOnError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(connectionOnError.mock.calls[0][0]); + expect(connectionOnError.mock.calls[0][0]).toMatchObject({ + actorName: "GameRoom", + instanceId: "r", + connectionId: "c1", + closeCode: code, + closeReason: "connection rejected", + }); + + sockets[0].reconnect(); + await expect(sockets[0].urls[1]).rejects.toMatchObject({ closeCode: code }); + expect(post).toHaveBeenCalledOnce(); + }, + ); + + // Transport-generated codes, plus 1000 — which a graceful worker shutdown and + // a rolling deploy both send, so a single one must not be fatal. + test.each([1000, 1001, 1006, 1008, 1011, 1012])( + "remote close code %s is reported without terminating the connection", + async (code) => { + const onError = vi.fn(); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + + sockets[0].emit("open", {}); + sockets[0].emit("close", { code, reason: "connection closed" }); + await Promise.resolve(); + + expect(conn.closed).toBe(false); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0]).toMatchObject({ + closeCode: code, + closeReason: "connection closed", + }); + }, + ); + + test("a repeated clean close that never stabilizes exhausts the attempt budget", async () => { + const onError = vi.fn(); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + + // reject(1000, ...) looks exactly like a graceful shutdown, so only the + // repetition distinguishes them. + for (let attempt = 0; attempt < 6; attempt++) { + expect(conn.closed).toBe(false); + sockets[0].emit("open", {}); + sockets[0].emit("close", { code: 1000, reason: "" }); + await Promise.resolve(); + if (!conn.closed) sockets[0].reconnect(); + } + + expect(conn.closed).toBe(true); + expect(onError).toHaveBeenCalledTimes(6); + expect(onError.mock.calls[5][0]).toMatchObject({ closeCode: 1000 }); + }); + + test("a refused upgrade carrying no close code exhausts the attempt budget", async () => { + // The dispatcher refuses over HTTP (bad token, unknown or throwing Actor), + // so the socket never opens and the browser reports an opaque error. The + // close listener skips it, which leaves the budget as the only bound. + const onError = vi.fn(); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + + for (let attempt = 0; attempt < 6; attempt++) { + expect(conn.closed).toBe(false); + sockets[0].emit("error", { message: "" }); + sockets[0].emit("close", { code: 1006, reason: "" }); + await Promise.resolve(); + if (!conn.closed) sockets[0].reconnect(); + } + + expect(conn.closed).toBe(true); + expect(onError).toHaveBeenCalledTimes(6); + expect(onError.mock.calls[5][0].cause).toBeInstanceOf(Error); + }); + + test("a socket that stays up resets the budget, so flaky links never terminate", async () => { + vi.useFakeTimers(); + try { + const onError = vi.fn(); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await vi.advanceTimersByTimeAsync(1); + + // Twice the budget, but each socket holds past STABLE_MS before dropping. + for (let attempt = 0; attempt < 12; attempt++) { + sockets[0].emit("open", {}); + // Answer the heartbeat while we wait, so the only reports come from the + // drop below rather than from the watchdog. + for (let tick = 0; tick < 6; tick++) { + await vi.advanceTimersByTimeAsync(1_000); + sockets[0].message({ type: "__pong" }); + } + sockets[0].emit("close", { code: 1006, reason: "" }); + await Promise.resolve(); + expect(conn.closed).toBe(false); + sockets[0].reconnect(); + } + + expect(conn.closed).toBe(false); + expect(onError).toHaveBeenCalledTimes(12); + } finally { + vi.useRealTimers(); + } + }); + + test("heartbeat reconnects never charge the budget (legacy actors with no __pong)", async () => { + vi.useFakeTimers(); + try { + const onError = vi.fn(); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await vi.advanceTimersByTimeAsync(1); + sockets[0].emit("open", {}); + + // An actor that never echoes __pong keeps the watchdog cycling by design; + // charging the budget for those cycles would kill every such actor. + for (let cycle = 0; cycle < 12; cycle++) { + await vi.advanceTimersByTimeAsync(4_000); + expect(conn.closed).toBe(false); + sockets[0].emit("open", {}); + } + + expect(conn.closed).toBe(false); + // The whole silent stretch is one outage: one report, not one per cycle. + expect(onError).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + test("a silent link reports one outage and widens its reconnect window", async () => { + vi.useFakeTimers(); + try { + const onError = vi.fn(); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await vi.advanceTimersByTimeAsync(1); + const ws = sockets[0]; + const reconnect = vi.spyOn(ws, "reconnect"); + + ws.emit("open", {}); + await vi.advanceTimersByTimeAsync(4_000); + expect(reconnect).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0].cause).toEqual( + new Error("Actor WebSocket stopped responding"), + ); + + // Reopened but still silent: the dead window doubles and the outage is + // not re-reported — otherwise a permanently silent link costs one + // connection-token POST and one app-visible error per DEAD_MS, forever. + ws.emit("open", {}); + await vi.advanceTimersByTimeAsync(4_000); + expect(reconnect).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(3_000); + expect(reconnect).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenCalledOnce(); + + // An inbound frame ends the outage: the window and report latch reset. + ws.emit("open", {}); + ws.message({ type: "__pong" }); + await vi.advanceTimersByTimeAsync(4_000); + expect(reconnect).toHaveBeenCalledTimes(3); + expect(onError).toHaveBeenCalledTimes(2); + + conn.close(); + } finally { + vi.useRealTimers(); + } + }); + + test("a WebSocket error after bootstrap is reported with connection context", async () => { + const onError = vi.fn(); + const cause = new Error("socket failed"); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + + sockets[0].emit("error", { error: cause, message: cause.message }); + + expect(conn.closed).toBe(false); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0]).toMatchObject({ + actorName: "GameRoom", + instanceId: "r", + connectionId: "c1", + cause, + }); + }); + + test("an error listener can unsubscribe without closing the connection", async () => { + const onError = vi.fn(); + const cause = new Error("socket failed"); + const conn = mod().GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + const subscription = conn.addErrorListener(onError); + + subscription.unsubscribe(); + sockets[0].emit("error", { error: cause, message: cause.message }); + + expect(onError).not.toHaveBeenCalled(); + expect(conn.closed).toBe(false); + }); + + test("PartySocket's synthetic close is replaced by its following error", async () => { + const onError = vi.fn(); + const cause = new Error("socket failed"); + const conn = mod({ onError }).GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + + sockets[0].emit("open", {}); + sockets[0].emit("close", { code: 1000, reason: "" }); + sockets[0].emit("error", { error: cause, message: cause.message }); + await Promise.resolve(); + + expect(conn.closed).toBe(false); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0]).toMatchObject({ + cause, + closeCode: undefined, + }); + }); + + test("a throwing connection error handler cannot replace the connection error", async () => { + const sourceError = Object.assign(new Error("Service unavailable"), { + isAxiosError: true, + response: { status: 503 }, + }); + const handlerError = new Error("handler failed"); + const connectionOnError = vi.fn(() => { throw handlerError; }); + const globalOnError = vi.fn(); + post.mockRejectedValue(sourceError); + + mod({ onError: globalOnError }) + .GameRoom("r") + .connect({ id: "c1", onError: connectionOnError }); + + await expect(sockets[0].urls[0]).rejects.toMatchObject({ + cause: sourceError, + status: 503, + }); + expect(connectionOnError).toHaveBeenCalledOnce(); + expect(globalOnError).toHaveBeenCalledOnce(); + expect(globalOnError.mock.calls[0][0]).toMatchObject({ cause: sourceError }); + }); + + test("anonymous bootstrap uses one stable client correlation id", async () => { + const actors = mod({ getAuthToken: () => null }); + expect(getAnalyticsSessionId).not.toHaveBeenCalled(); + + actors.GameRoom("r").connect({ id: "c1" }); + await sockets[0].urls[0]; + expect(getAnalyticsSessionId).toHaveBeenCalledOnce(); + sockets[0].reconnect(); + await sockets[0].urls[1]; + + const firstHeaders = post.mock.calls[0][2].headers; + const secondHeaders = post.mock.calls[1][2].headers; + expect(firstHeaders.Authorization).toBeNull(); + expect(firstHeaders["X-Base44-Anonymous-Id"]).toMatch(/^[a-z0-9]+$/); + expect(secondHeaders["X-Base44-Anonymous-Id"]).toBe( + firstHeaders["X-Base44-Anonymous-Id"], + ); + expect(getAnalyticsSessionId).toHaveBeenCalledOnce(); + }); + + test("createClient forwards Actor bootstrap errors without allocating an authenticated anonymous id", async () => { + const serverUrl = "https://actors-sdk.example"; + const onError = vi.fn(); + nock(serverUrl) + .post("/api/apps/app-1/actors/GameRoom/connection-token") + .reply(401, { message: "Unauthorized" }); + const client = createClient({ + serverUrl, + appId: "app-1", + token: "user-tok", + options: { onError }, + }); + expect(getAnalyticsSessionId).not.toHaveBeenCalled(); + + client.actors.GameRoom("r").connect({ id: "c1" }); + await expect(sockets[0].urls[0]).rejects.toThrow("status code 401"); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0]).toMatchObject({ + actorName: "GameRoom", + instanceId: "r", + connectionId: "c1", + status: 401, + }); + expect(getAnalyticsSessionId).not.toHaveBeenCalled(); + client.cleanup(); + }); + + test("functionsVersion uses the direct header and legacy fv query", async () => { + mod({ functionsVersion: "draft" }).GameRoom("r").connect({ id: "c1" }); + const url = new URL(await sockets[0].urls[0]); + + expect(post.mock.calls[0][2].headers["Base44-Functions-Version"]).toBe( + "draft", + ); + expect(url.searchParams.get("fv")).toBe("draft"); }); test("connect() is idempotent per handle", () => { @@ -63,6 +640,104 @@ describe("Actors Module — connection API", () => { expect(a).toBe(b); }); + test("repeated connect() rejects an onError it could never detach", async () => { + const sourceError = Object.assign(new Error("Service unavailable"), { + isAxiosError: true, + response: { status: 503 }, + }); + const firstOnError = vi.fn(); + const secondOnError = vi.fn(); + post.mockRejectedValue(sourceError); + const ref = mod().GameRoom("r"); + + const first = ref.connect({ id: "c1", onError: firstOnError }); + expect(() => ref.connect({ id: "c1", onError: secondOnError })).toThrow( + "use connection.addErrorListener()", + ); + // Without a handler the call is still the documented no-op reuse. + expect(ref.connect({ id: "c1" })).toBe(first); + + // The detachable API is how a second observer attaches, and it can leave. + const subscription = first.addErrorListener(secondOnError); + await expect(sockets[0].urls[0]).rejects.toThrow("Service unavailable"); + + expect(firstOnError).toHaveBeenCalledOnce(); + expect(secondOnError).toHaveBeenCalledOnce(); + expect(secondOnError).toHaveBeenCalledWith(firstOnError.mock.calls[0][0]); + + subscription.unsubscribe(); + sockets[0].reconnect(); + await expect(sockets[0].urls[1]).rejects.toThrow("Service unavailable"); + expect(firstOnError).toHaveBeenCalledTimes(2); + expect(secondOnError).toHaveBeenCalledOnce(); + }); + + test.each([ + ["contains a slash", "team/general"], + ["is empty", ""], + ["exceeds 256 characters", "r".repeat(257)], + ["contains a control character", "room\n1"], + ])("an instance id that %s is rejected before any request", (_label, instanceId) => { + // Mirrors the dispatcher's ROOM_RE: it would refuse the upgrade with an + // HTTP error the browser reports as an opaque 1006, so fail fast instead. + expect(() => mod().GameRoom(instanceId)).toThrow("Actor instance id"); + expect(sockets).toHaveLength(0); + expect(post).not.toHaveBeenCalled(); + }); + + test.each([ + ["a 256-character id", "r".repeat(256)], + ["spaces and dots", "match 1.2"], + ["a tilde at the top of the range", "room~"], + ])("an instance id with %s is accepted", (_label, instanceId) => { + expect(() => mod().GameRoom(instanceId).connect({ id: "c1" })).not.toThrow(); + expect(sockets).toHaveLength(1); + }); + + test("repeated connect() rejects a conflicting explicit id", () => { + const ref = mod().GameRoom("r"); + ref.connect({ id: "c1" }); + + expect(() => ref.connect({ id: "c2" })).toThrow( + 'Actor connection is already open with id "c1"; cannot reuse it with id "c2"', + ); + expect(sockets).toHaveLength(1); + }); + + test("explicit connection ids must satisfy the backend contract", () => { + expect(() => mod().GameRoom("r").connect({ id: "bad id" })).toThrow( + "Actor connection id must be 1-64 letters, numbers, underscores, or hyphens", + ); + expect(sockets).toHaveLength(0); + expect(post).not.toHaveBeenCalled(); + }); + + test("omitted connection ids are generated independently", () => { + const actors = mod(); + const first = actors.GameRoom("r").connect(); + const second = actors.GameRoom("r").connect(); + + expect(first.id).toMatch(/^[A-Za-z0-9_-]{1,64}$/); + expect(second.id).toMatch(/^[A-Za-z0-9_-]{1,64}$/); + expect(first.id).not.toBe(second.id); + }); + + test("connection ids remain unique without crypto.randomUUID", () => { + const originalCrypto = globalThis.crypto; + vi.stubGlobal("crypto", undefined); + try { + const actors = mod(); + const first = actors.GameRoom("r").connect(); + const second = actors.GameRoom("r").connect(); + + expect(first.id).toMatch(/^[A-Za-z0-9_-]{1,64}$/); + expect(second.id).toMatch(/^[A-Za-z0-9_-]{1,64}$/); + expect(first.id).not.toBe(second.id); + } finally { + vi.stubGlobal("crypto", originalCrypto); + } + }); + test("multiple listeners all receive; unsubscribe removes only its own", () => { const conn = mod().GameRoom("r").connect(); const a: unknown[] = [], b: unknown[] = []; @@ -99,11 +774,34 @@ describe("Actors Module — connection API", () => { const conn = mod().GameRoom("r").connect(); const got: unknown[] = []; conn.subscribe((m) => got.push(m)); + expect(conn.closed).toBe(false); conn.close(); + expect(conn.closed).toBe(true); expect(sockets[0].closed).toBe(true); // a late message reaches nobody (listeners cleared) sockets[0].message({ type: "tick" }); expect(got).toHaveLength(0); + let sendError: unknown; + let subscribeError: unknown; + try { + conn.send({ type: "after-close" }); + } catch (error) { + sendError = error; + } + try { + conn.subscribe(() => {}); + } catch (error) { + subscribeError = error; + } + expect(sendError).toBeInstanceOf(ActorConnectionError); + expect(subscribeError).toBeInstanceOf(ActorConnectionError); + expect(sendError).not.toBe(subscribeError); + expect(sendError).toMatchObject({ + actorName: "GameRoom", + instanceId: "r", + cause: expect.objectContaining({ message: "Connection is closed" }), + }); + expect(sockets[0].sent).toHaveLength(0); }); test("connect() after close() opens a fresh socket with a new id", () => { @@ -116,12 +814,6 @@ describe("Actors Module — connection API", () => { expect(c2.id).toBe("c2"); }); - test("anonymous connect omits the token", () => { - const conn = mod({ ...config, getAuthToken: () => null }).GameRoom("r").connect(); - expect(conn).toBeDefined(); - expect(sockets[0].opts.query()).not.toHaveProperty("token"); - }); - test("each GameRoom(id) is an independent connection", () => { const actors = mod(); actors.GameRoom("r").connect(); @@ -129,24 +821,41 @@ describe("Actors Module — connection API", () => { expect(sockets).toHaveLength(2); }); - test("functionsVersion rides the query as fv when set, omitted when unset", () => { - mod().GameRoom("r").connect(); - expect(sockets[0].opts.query()).not.toHaveProperty("fv"); - mod({ ...config, functionsVersion: "draft" }).GameRoom("r2").connect(); - expect(sockets[1].opts.query()).toMatchObject({ fv: "draft" }); - }); - test("heartbeat pings periodically and reconnects when the link goes silent", () => { vi.useFakeTimers(); try { mod().GameRoom("r").connect(); const ws = sockets[0]; const reconnect = vi.spyOn(ws, "reconnect"); + ws.emit("open", {}); vi.advanceTimersByTime(1000); // one PING_MS tick, still within DEAD_MS expect(ws.sent).toContain(JSON.stringify({ type: "__ping" })); expect(reconnect).not.toHaveBeenCalled(); vi.advanceTimersByTime(4000); // no inbound message → exceed DEAD_MS - expect(reconnect).toHaveBeenCalled(); + expect(reconnect).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(12_000); + expect(reconnect).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + test("watchdog stays disarmed while bootstrap has never opened", async () => { + vi.useFakeTimers(); + try { + const error = Object.assign(new Error("Service unavailable"), { + isAxiosError: true, + response: { status: 503 }, + }); + post.mockRejectedValue(error); + mod().GameRoom("r").connect(); + const ws = sockets[0]; + const reconnect = vi.spyOn(ws, "reconnect"); + await expect(ws.urls[0]).rejects.toThrow("Service unavailable"); + + vi.advanceTimersByTime(12_000); + expect(reconnect).not.toHaveBeenCalled(); + expect(ws.urls).toHaveLength(1); } finally { vi.useRealTimers(); } diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 3ca6e871..1ad17c12 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -174,12 +174,15 @@ describe("Analytics Module", () => { const auth = client.auth as InternalAuthModule; expect(auth.hasToken()).toBe(false); + expect(auth.getToken()).toBeUndefined(); auth.setToken("some-token", false); expect(auth.hasToken()).toBe(true); + expect(auth.getToken()).toBe("some-token"); auth.logout(); expect(auth.hasToken()).toBe(false); + expect(auth.getToken()).toBeUndefined(); client.cleanup(); }); diff --git a/tests/unit/anonymous-visitor-header.test.ts b/tests/unit/anonymous-visitor-header.test.ts index 689767fb..41219c39 100644 --- a/tests/unit/anonymous-visitor-header.test.ts +++ b/tests/unit/anonymous-visitor-header.test.ts @@ -68,6 +68,24 @@ describe("anonymous visitor header", () => { ).toBe(sessionId); }); + test("preserves an explicit anonymous id supplied by a transport", async () => { + const client = createAxiosClient({ + baseURL: "https://api", + headers: { "X-Base44-Anonymous-Id": "actor-client-id" }, + }); + let captured: any; + client.defaults.adapter = async (config) => { + captured = config; + return { data: {}, status: 200, statusText: "OK", headers: {}, config }; + }; + + await client.get("/actor-connection"); + + expect(captured.headers.get("X-Base44-Anonymous-Id")).toBe( + "actor-client-id", + ); + }); + test("authenticated client sends Authorization, not the anonymous header", async () => { const headers = await captureRequestHeaders("a-real-token"); expect(headers.get("X-Base44-Anonymous-Id")).toBeFalsy();