diff --git a/package-lock.json b/package-lock.json index b10aa8f..6113bf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5978,7 +5978,7 @@ }, "packages/platform": { "name": "@base44/platform", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "socket.io-client": "^4.8.3" diff --git a/packages/platform/CHANGELOG.md b/packages/platform/CHANGELOG.md new file mode 100644 index 0000000..dbce83d --- /dev/null +++ b/packages/platform/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 0.2.0 + +**Breaking.** The browser passes one function instead of two values. + +- `builder.init()` takes `getSession`, which returns the body your server received from + `POST /api/service/socket-sessions`, unchanged. The SDK reads `session_token`, `socket_url` and + `socket_path`, and ignores other keys. +- `createPlatformClient()` takes no options. `socketUrl` and `getSessionToken` are removed, along + with the `PlatformClientOptions` type. +- The socket connects on the session's `socket_path` instead of a hard-coded `/ws/socket.io/`, and + is created on the first `connect()`. When a renewed session has a different address, the SDK + moves the socket and rejoins every active subscription. +- An invalid `session_token`, `socket_url` or `socket_path` rejects `connect()` with + `session_unavailable` instead of throwing a `TypeError`. + +Before: + +```ts +const client = createPlatformClient({ socketUrl, getSessionToken: async () => (await fetchSession()).session_token }); +const builder = client.builder.init({ onError }); +``` + +After: + +```ts +const client = createPlatformClient(); +const builder = client.builder.init({ getSession: fetchSession, onError }); +``` + +## 0.1.0 + +First release. diff --git a/packages/platform/README.md b/packages/platform/README.md index 754f5ce..5cdf2ec 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -12,15 +12,14 @@ The workspace must have white-label sockets enabled; this SDK does not open sess ```ts import { createPlatformClient } from "@base44/platform"; -const client = createPlatformClient({ - socketUrl, // the session's socket_url, from POST /api/service/socket-sessions - async getSessionToken() { +const client = createPlatformClient(); +const builder = client.builder.init({ + // Returns the body of POST /api/service/socket-sessions, unchanged. + async getSession() { const response = await fetch("/api/builder-socket-session", { method: "POST" }); if (!response.ok) throw new Error("Session request failed"); - return (await response.json()).session_token; + return response.json(); }, -}); -const builder = client.builder.init({ onError(error) { showConnectionError(error.code); }, }); const subscription = builder.subscribe(appId, { @@ -38,34 +37,40 @@ builder.close(); `/api/builder-socket-session` is your backend's route, not an SDK endpoint. It opens a socket session with its workspace key (`apps:watch` scope) through -`POST /api/service/socket-sessions` with `{ app_ids }`, and returns only the -`session_token` and `socket_url` to the browser. Pass `socket_url` as `socketUrl`. Never pass the workspace key or any other server -credential to this client. Subscribe only to apps on the session's allowlist; your +`POST /api/service/socket-sessions` with `{ app_ids }`, and returns the response body +to the browser. `getSession` returns that body unchanged: the SDK reads `session_token`, +`socket_url` and `socket_path` and ignores other keys such as `session_id` and +`expires_in`. Never pass the workspace key or any other server credential to this client. Subscribe only to apps on the session's allowlist; your backend adds or removes apps with `PUT`/`DELETE /api/service/socket-sessions/{session_id}/rooms/{app_id}`. A session lasts one hour and is never extended. The token goes exclusively in CONNECT `auth.session_token`, never a URL, and the SDK keeps it in memory only. It -is reused across automatic reconnects. `getSessionToken` is called when a builder +is reused across automatic reconnects. `getSession` is called when a builder session first connects, and again only when the server rejects the cached token or -expires the session; the SDK then reconnects once with the new token. Retrieval has -a twenty-second timeout and reports `session_unavailable` on failure. +expires the session; the SDK then reconnects once with the new session. If the new +session has a different `socket_url` or `socket_path`, the SDK closes the old socket, +opens one at the new address and rejoins every active subscription. Retrieval has a +twenty-second timeout. A failure, a timeout, or a session without a token, an +HTTP(S) origin as `socket_url` (no credentials, path, query or fragment) or an +absolute `socket_path` reports `session_unavailable`. One live socket per session: a newer connection with the same token replaces the older one, which stops with `session_replaced`. Open one session per page. ## Lifecycle -`createPlatformClient({ socketUrl, getSessionToken })` creates a lightweight module -container: no Socket.IO instance, timers, token request or network activity. Its +`createPlatformClient()` creates a lightweight module container: no Socket.IO +instance, timers, session request or network activity. It takes no options. Its `builder` module follows the server SDK's module-factory pattern. -`client.builder.init({ onError })` synchronously creates an independent `BuilderSession` -without connecting. Repeated calls create separate sessions, each owning its own +`client.builder.init({ getSession, onError })` synchronously creates an independent +`BuilderSession` without connecting or creating a socket. Repeated calls create separate sessions, each owning its own socket, listeners, subscriptions and cleanup. Close the returned session when its view is disposed; the root client and other sessions remain usable. -`builder.connect()` resolves on CONNECT, before any snapshot. The socket uses the -default namespace on `/ws/socket.io/`, WebSocket only, with a dedicated manager. +`builder.connect()` fetches the first session, creates the socket at its address and +resolves on CONNECT, before any snapshot. The socket uses the default namespace on the +session's `socket_path`, WebSocket only, with a dedicated manager. Transport reconnection uses five attempts, starting at one second and capped at ten seconds with jitter; each attempt has a twenty-second timeout. A temporary server refusal is retried the same way. After exhaustion, a refusal or `session_replaced`, @@ -94,9 +99,10 @@ their exceptions are isolated so they cannot interrupt another app's delivery. ## Public shapes -- `PlatformClientOptions` (`client.types.ts`): `socketUrl`, `getSessionToken`. +- `PlatformClient` (`client.types.ts`): the `builder` module. - `BuilderModule` (`modules/builder.types.ts`): lazy `init(options)` factory. -- `BuilderInitOptions`: connection-level `onError` observer. +- `BuilderInitOptions`: `getSession` and the connection-level `onError` observer. +- `SocketSession`: the `session_token`, `socket_url` and `socket_path` that `getSession` returns. - `BuilderSession`: `connect()`, `subscribe(appId, options)`, `close()`. - `SubscriptionOptions`: required `onSnapshot`, `onEvent` and `onError`. - `PlatformSubscription`: read-only `appId`, `active`, and `unsubscribe()`. @@ -148,7 +154,7 @@ exceptions, payloads and credentials are never attached. | Error code | Action | | --- | --- | | `connection_denied` | The session was rejected even with a fresh token: the workspace, key or session is not usable. | -| `session_unavailable` | `getSessionToken` failed or timed out; no provider details are forwarded. | +| `session_unavailable` | `getSession` failed, timed out or returned an invalid session; no provider details are forwarded. | | `session_replaced` | Another connection with the same session took over. Stop, or open a new session. | | `connection_failed` | Transport failure or exhausted retries; call `connect()` after fixing connectivity. | | `access_denied` | The join was refused: the app is not on the session's allowlist, or joins were rate limited. | diff --git a/packages/platform/examples/client.ts b/packages/platform/examples/client.ts index a3f9d3f..3eae618 100644 --- a/packages/platform/examples/client.ts +++ b/packages/platform/examples/client.ts @@ -1,18 +1,13 @@ import { createPlatformClient, type PlatformEvent } from "@base44/platform"; -declare const socketUrl: string; - -const client = createPlatformClient({ - socketUrl, // the session's socket_url, from POST /api/service/socket-sessions - // Your backend opens the session with its workspace key and returns only the session token. - async getSessionToken() { +const client = createPlatformClient(); +const builder = client.builder.init({ + // Your backend opens the session with its workspace key and returns the Create socket session body. + async getSession() { const response = await fetch("/api/builder-socket-session", { method: "POST" }); if (!response.ok) throw new Error("Unable to open a socket session"); - const { session_token } = await response.json(); - return session_token; + return response.json(); }, -}); -const builder = client.builder.init({ onError(error) { console.error(error.code); }, }); diff --git a/packages/platform/package.json b/packages/platform/package.json index b9efb03..094ca5f 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -1,6 +1,6 @@ { "name": "@base44/platform", - "version": "0.1.0", + "version": "0.2.0", "description": "Browser client for building on the Base44 platform", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/platform/scripts/mintlify-post-processing/appended-articles.json b/packages/platform/scripts/mintlify-post-processing/appended-articles.json index 668fe41..4415bc1 100644 --- a/packages/platform/scripts/mintlify-post-processing/appended-articles.json +++ b/packages/platform/scripts/mintlify-post-processing/appended-articles.json @@ -1,6 +1,7 @@ { "interfaces/builder": [ "interfaces/BuilderSession", + "interfaces/SocketSession", "type-aliases/PlatformEvent", "type-aliases/PlatformSnapshot" ], diff --git a/packages/platform/scripts/mintlify-post-processing/types-to-expose.json b/packages/platform/scripts/mintlify-post-processing/types-to-expose.json index 0c67779..93a2d85 100644 --- a/packages/platform/scripts/mintlify-post-processing/types-to-expose.json +++ b/packages/platform/scripts/mintlify-post-processing/types-to-expose.json @@ -5,5 +5,6 @@ "PlatformEvent", "PlatformSnapshot", "PlatformSocketError", - "SessionErrorCode" + "SessionErrorCode", + "SocketSession" ] diff --git a/packages/platform/src/client.ts b/packages/platform/src/client.ts index d3912c2..a3cf659 100644 --- a/packages/platform/src/client.ts +++ b/packages/platform/src/client.ts @@ -1,4 +1,4 @@ -import type { PlatformClient, PlatformClientOptions } from "./client.types.js"; +import type { PlatformClient } from "./client.types.js"; import { createBuilder } from "./modules/builder.js"; /** @@ -6,34 +6,29 @@ import { createBuilder } from "./modules/builder.js"; * * This is the entry point of the Platform SDK. The client gives your white-label app editor * access to the platform's modules, such as [`builder`](/developers/references/platform-sdk/docs/interfaces/builder). + * It takes no options and opens nothing: each builder session gets its socket session from the + * `getSession` you pass to `init()`. * - * @param options - Configuration object for the client. - * @returns A configured platform client with access to the platform's modules. - * @throws {TypeError} When `socketUrl` isn't an HTTP or HTTPS origin. + * @returns A platform client with access to the platform's modules. * * @example * ```typescript * // Create a client for your app editor * import { createPlatformClient } from '@base44/platform'; * - * const client = createPlatformClient({ - * socketUrl, - * async getSessionToken() { + * const client = createPlatformClient(); + * + * // Use the client to follow the AI chat in an app + * const builder = client.builder.init({ + * async getSession() { * const response = await fetch('/api/builder-socket-session', { method: 'POST' }); - * const { session_token } = await response.json(); - * return session_token; + * return response.json(); * }, + * onError: (error) => console.error(error.code), * }); - * - * // Use the client to follow the AI chat in an app - * const builder = client.builder.init({ onError: (error) => console.error(error.code) }); * await builder.connect(); * ``` */ -export function createPlatformClient(options: PlatformClientOptions): PlatformClient { - const url = new URL(options.socketUrl); - if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") { - throw new TypeError("socketUrl must be an HTTP(S) origin without credentials, path, query or fragment"); - } - return Object.freeze({ builder: Object.freeze(createBuilder({ ...options, socketUrl: url.origin })) }); +export function createPlatformClient(): PlatformClient { + return Object.freeze({ builder: Object.freeze(createBuilder()) }); } diff --git a/packages/platform/src/client.types.ts b/packages/platform/src/client.types.ts index d5e9baf..6ed309d 100644 --- a/packages/platform/src/client.types.ts +++ b/packages/platform/src/client.types.ts @@ -1,32 +1,5 @@ import type { BuilderModule } from "./modules/builder.types.js"; -/** - * Options for {@linkcode createPlatformClient | createPlatformClient()}. - * - * The client runs in the browser and never sees your workspace API key. Your server opens a socket - * session with the key and hands the browser only the session's token and socket URL. - */ -export interface PlatformClientOptions { - /** - * Origin of the platform socket. - * - * Use the `socket_url` your server receives from [Create socket session](/api-reference/create-socket-session). It's the same - * for every session in an environment, so your server can pass it to the page once. It must be an - * origin with no path, query, fragment, or credentials. - */ - socketUrl: string; - /** - * Returns a socket session token from your server. - * - * Your server opens a session with [Create socket session](/api-reference/create-socket-session) and returns its - * `session_token`. The client calls this when it connects, and again when the token expires. - * Return a new token on every call. - * - * @returns The session token, or a promise resolving to it. - */ - getSessionToken: () => string | Promise; -} - /** * The platform client. * diff --git a/packages/platform/src/errors.types.ts b/packages/platform/src/errors.types.ts index 2ac3fa1..6dc1d77 100644 --- a/packages/platform/src/errors.types.ts +++ b/packages/platform/src/errors.types.ts @@ -4,9 +4,11 @@ */ export type SessionErrorCode = /** - * `getSessionToken` threw, took longer than 20 seconds or returned no token. + * `getSession` threw, took longer than 20 seconds, or returned a session without a valid + * `session_token`, `socket_url` (an HTTP or HTTPS origin) or `socket_path` (an absolute path). * - * Check the endpoint that opens sessions, then call `connect()` again. Retryable. + * Check the endpoint that opens sessions, then call `connect()` again. Retryable, except that an + * invalid field needs a fix on your server first. */ | "session_unavailable" /** diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index f5703a9..b8f93fb 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -2,7 +2,7 @@ export { createPlatformClient } from "./client.js"; export { PlatformSocketError } from "./errors.js"; export type { AppErrorCode, PlatformSocketErrorCode, SessionErrorCode } from "./errors.types.js"; -export type { PlatformClient, PlatformClientOptions } from "./client.types.js"; -export type { BuilderModule, BuilderInitOptions, BuilderSession, PlatformSubscription, SubscriptionOptions } from "./modules/builder.types.js"; +export type { PlatformClient } from "./client.types.js"; +export type { BuilderModule, BuilderInitOptions, BuilderSession, PlatformSubscription, SocketSession, SubscriptionOptions } from "./modules/builder.types.js"; export type { GuardApproval, PlatformEvent, PlatformEventMap, PlatformSnapshot, ToolCall } from "./modules/builder.events.types.js"; export type * from "./modules/builder.events.generated.js"; diff --git a/packages/platform/src/modules/builder-protocol.ts b/packages/platform/src/modules/builder-protocol.ts index b9c9cb1..91f8f21 100644 --- a/packages/platform/src/modules/builder-protocol.ts +++ b/packages/platform/src/modules/builder-protocol.ts @@ -1,6 +1,7 @@ import { serverEventNames, type ServerEventMap, type SessionEnded } from "./builder.events.generated.js"; import type { PlatformSocketErrorCode } from "../errors.types.js"; import type { PlatformEvent, PlatformEventMap, PlatformSnapshot } from "./builder.events.types.js"; +import type { SocketSession } from "./builder.types.js"; const sessionEvents: readonly string[] = ["app.snapshot", "session.ended"]; /** App events delivered to `onEvent`: every server event except the session events the socket handles. */ @@ -42,3 +43,14 @@ export function decodeSnapshot(raw: unknown): PlatformSnapshot { const queue = data.queue !== null && typeof data.queue === "object" ? { queue: data.queue } : {}; return { room: frame.room, status: data.status, messages: data.messages, ...queue } as PlatformSnapshot; } +/** Keeps only the fields the socket needs; a URL with credentials, a path, query or fragment is refused. */ +export function decodeSession(raw: unknown): SocketSession { + const { session_token, socket_url, socket_path } = object(raw); + if (typeof session_token !== "string" || !session_token.trim()) throw new Error("Invalid session token"); + if (typeof socket_url !== "string" || typeof socket_path !== "string" || !/^\/(?!\/)[^?#\s]*$/.test(socket_path)) throw new Error("Invalid socket address"); + const url = new URL(socket_url); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") { + throw new Error("Invalid socket URL"); + } + return { session_token, socket_url: url.origin, socket_path }; +} diff --git a/packages/platform/src/modules/builder-socket.ts b/packages/platform/src/modules/builder-socket.ts index e8c0755..542fb87 100644 --- a/packages/platform/src/modules/builder-socket.ts +++ b/packages/platform/src/modules/builder-socket.ts @@ -1,20 +1,20 @@ import { io, type Socket } from "socket.io-client"; import { PlatformSocketError } from "../errors.js"; -import { appFromRoom, appPattern, decode, decodeSnapshot, eventNames, object, roomFor, roomNotices, sessionEndings } from "./builder-protocol.js"; +import { appFromRoom, appPattern, decode, decodeSession, decodeSnapshot, eventNames, object, roomFor, roomNotices, sessionEndings } from "./builder-protocol.js"; import { notify, Subscription } from "./builder-subscription.js"; -import type { PlatformClientOptions } from "../client.types.js"; -import type { BuilderInitOptions, BuilderSession, PlatformSubscription, SubscriptionOptions } from "./builder.types.js"; +import type { BuilderInitOptions, BuilderSession, PlatformSubscription, SocketSession, SubscriptionOptions } from "./builder.types.js"; const MAX_DENIAL_RETRIES = 5; /** @internal */ export class BuilderSocket implements BuilderSession { - private readonly socket: Socket; + private socket?: Socket; + private address?: string; private readonly subscriptions = new Map(); - private readonly options: PlatformClientOptions & BuilderInitOptions; + private readonly options: BuilderInitOptions; private closed = false; - // The session token outlives reconnects; it is replaced only when the server rejects or expires it. - private token?: string; + // The session outlives reconnects; it is replaced only when the server rejects or expires its token. + private session?: SocketSession; private tokenFresh = false; private ending?: (typeof sessionEndings)[keyof typeof sessionEndings]; private denialRetries = 0; @@ -25,66 +25,26 @@ export class BuilderSocket implements BuilderSession { private resolveConnect?: () => void; private rejectConnect?: (error: PlatformSocketError) => void; - constructor(config: PlatformClientOptions, options: BuilderInitOptions) { - this.options = { ...config, onError: options.onError }; - this.socket = io(config.socketUrl, { - path: "/ws/socket.io/", transports: ["websocket"], autoConnect: false, - forceNew: true, reconnectionAttempts: 5, reconnectionDelay: 1000, reconnectionDelayMax: 10000, - timeout: 20000, - auth: (callback) => { void this.authenticate(callback); }, - }); - this.socket.on("connect", () => { - this.tokenFresh = false; - this.denialRetries = 0; - for (const subscription of this.subscriptions.values()) this.join(subscription); - this.resolveConnect?.(); - this.clearConnecting(); - }); - this.socket.on("disconnect", (reason) => { - ++this.authAttempt; - if (this.closed || reason !== "io server disconnect") return; // Transport loss reconnects by itself. - const ending = this.ending; - this.ending = undefined; - if (ending === "session_expired") this.socket.connect(); // Renew through getSessionToken. - else this.connectionError(new PlatformSocketError(ending ?? "connection_failed")); - }); - this.socket.on("connect_error", (error) => this.refused(error as Error & { data?: { retryable?: boolean } })); - this.socket.io.on("reconnect_failed", () => this.connectionError(new PlatformSocketError("connection_failed"))); - this.socket.on("app.snapshot", (raw: unknown) => { - try { - const snapshot = decodeSnapshot(raw); - this.subscriptions.get(appFromRoom(snapshot.room)!)?.snapshot(snapshot); - } catch { this.protocolError(raw); } - }); - this.socket.on("session.ended", (raw: unknown) => this.sessionEnded(raw)); - for (const [name, code] of Object.entries(roomNotices)) this.socket.on(name, (raw: unknown) => this.roomNotice(code, raw)); - for (const type of eventNames) this.socket.on(type, (raw: unknown) => { - try { - const appId = appFromRoom(object(raw).room); - if (!appId) throw new Error("Invalid app"); - const event = decode(type, appId, raw); - const subscription = this.subscriptions.get(appId); - subscription?.event(event); - // The server does not resend a snapshot after a rewrite of main: rejoining asks for one. - if (subscription && event.type === "conversation.changed" && event.data.branch_id == null) this.join(subscription); - } catch { this.protocolError(raw); } - }); + constructor(options: BuilderInitOptions) { + this.options = { getSession: options.getSession, onError: options.onError }; } - /** Connect with the session token. Resolves on CONNECT, before snapshots arrive. + /** Connect with the session's token. Resolves on CONNECT, before snapshots arrive. + * The first call fetches the session, whose address the socket opens on. * Transport loss retries up to five times and rejoins active subscriptions. * Call again after a failure or `session_replaced`; concurrent calls share one attempt. */ connect(): Promise { if (this.closed) return Promise.reject(new PlatformSocketError("client_closed")); - if (this.socket.connected) return Promise.resolve(); + if (this.socket?.connected) return Promise.resolve(); if (this.connecting) return this.connecting; const promise = new Promise((resolve, reject) => { this.resolveConnect = resolve; this.rejectConnect = reject; }); this.connecting = promise; - this.socket.connect(); + if (this.socket) this.socket.connect(); + else void this.authenticate(); return promise; } @@ -99,10 +59,10 @@ export class BuilderSocket implements BuilderSession { if (this.subscriptions.size >= 8) throw new PlatformSocketError("subscription_limit", appId); const subscription = new Subscription(appId, { ...options }, () => { this.subscriptions.delete(appId); - if (this.socket.connected) this.socket.emit("leave", roomFor(appId)); + if (this.socket?.connected) this.socket.emit("leave", roomFor(appId)); }); this.subscriptions.set(appId, subscription); - if (this.socket.connected) this.join(subscription); + if (this.socket?.connected) this.join(subscription); return subscription; } @@ -116,35 +76,90 @@ export class BuilderSocket implements BuilderSession { for (const subscription of this.subscriptions.values()) subscription.unsubscribe(); this.rejectConnect?.(new PlatformSocketError("client_closed")); this.clearConnecting(); + this.discard(); + } + + private open(url: string, path: string): void { + this.discard(); + const socket = this.socket = io(url, { + path, transports: ["websocket"], autoConnect: false, + forceNew: true, reconnectionAttempts: 5, reconnectionDelay: 1000, reconnectionDelayMax: 10000, + timeout: 20000, + auth: (callback) => { void this.authenticate(callback); }, + }); + this.address = url + path; + socket.on("connect", () => { + this.tokenFresh = false; + this.denialRetries = 0; + for (const subscription of this.subscriptions.values()) this.join(subscription); + this.resolveConnect?.(); + this.clearConnecting(); + }); + socket.on("disconnect", (reason) => { + ++this.authAttempt; + if (this.closed || reason !== "io server disconnect") return; // Transport loss reconnects by itself. + const ending = this.ending; + this.ending = undefined; + if (ending === "session_expired") socket.connect(); // Renew through getSession. + else this.connectionError(new PlatformSocketError(ending ?? "connection_failed")); + }); + socket.on("connect_error", (error) => this.refused(error as Error & { data?: { retryable?: boolean } })); + socket.io.on("reconnect_failed", () => this.connectionError(new PlatformSocketError("connection_failed"))); + socket.on("app.snapshot", (raw: unknown) => { + try { + const snapshot = decodeSnapshot(raw); + this.subscriptions.get(appFromRoom(snapshot.room)!)?.snapshot(snapshot); + } catch { this.protocolError(raw); } + }); + socket.on("session.ended", (raw: unknown) => this.sessionEnded(raw)); + for (const [name, code] of Object.entries(roomNotices)) socket.on(name, (raw: unknown) => this.roomNotice(code, raw)); + for (const type of eventNames) socket.on(type, (raw: unknown) => { + try { + const appId = appFromRoom(object(raw).room); + if (!appId) throw new Error("Invalid app"); + const event = decode(type, appId, raw); + const subscription = this.subscriptions.get(appId); + subscription?.event(event); + // The server does not resend a snapshot after a rewrite of main: rejoining asks for one. + if (subscription && event.type === "conversation.changed" && event.data.branch_id == null) this.join(subscription); + } catch { this.protocolError(raw); } + }); + socket.connect(); + } + + private discard(): void { + if (!this.socket) return; this.socket.removeAllListeners(); this.socket.io.removeAllListeners(); this.socket.disconnect(); } private join(subscription: Subscription): void { - this.socket.emit("join", roomFor(subscription.appId)); + this.socket?.emit("join", roomFor(subscription.appId)); } - private async authenticate(callback: (auth: { session_token: string }) => void): Promise { + /** Without a callback, or when a new session moves the socket, opens a socket at the session's address. */ + private async authenticate(callback?: (auth: { session_token: string }) => void): Promise { const attempt = ++this.authAttempt; this.cancelAuth?.(); let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("Session token timeout")), 20000); + timer = setTimeout(() => reject(new Error("Session timeout")), 20000); this.cancelAuth = () => { clearTimeout(timer); reject(new Error("Cancelled")); }; }); try { - if (!this.token) { - const token = await Promise.race([Promise.resolve().then(() => this.options.getSessionToken()), timeout]); + if (!this.session) { + const session = await Promise.race([Promise.resolve().then(() => this.options.getSession()), timeout]); if (this.closed || attempt !== this.authAttempt) return; - if (typeof token !== "string" || !token.trim()) throw new Error("Missing session token"); - this.token = token; + this.session = decodeSession(session); this.tokenFresh = true; } - callback({ session_token: this.token }); + const { session_token, socket_url, socket_path } = this.session; + if (callback && this.address === socket_url + socket_path) callback({ session_token }); + else this.open(socket_url, socket_path); } catch { if (this.closed || attempt !== this.authAttempt) return; - this.socket.disconnect(); + this.socket?.disconnect(); this.connectionError(new PlatformSocketError("session_unavailable")); } finally { clearTimeout(timer); @@ -158,8 +173,8 @@ export class BuilderSocket implements BuilderSession { if (error.data?.retryable === true) { this.retryDenied(); return; } // A token this attempt did not just fetch may have expired: renew it once. const renew = !this.tokenFresh; - this.token = undefined; - if (renew) this.socket.connect(); + this.session = undefined; + if (renew) this.socket?.connect(); else this.connectionError(new PlatformSocketError("connection_denied")); } @@ -170,7 +185,7 @@ export class BuilderSocket implements BuilderSession { return; } const delay = Math.min(1000 * 2 ** (this.denialRetries - 1), 10000) * (0.5 + Math.random() / 2); - this.retryTimer = setTimeout(() => { this.retryTimer = undefined; if (!this.closed) this.socket.connect(); }, delay); + this.retryTimer = setTimeout(() => { this.retryTimer = undefined; if (!this.closed) this.socket?.connect(); }, delay); } private clearConnecting(): void { @@ -192,7 +207,7 @@ export class BuilderSocket implements BuilderSession { ? sessionEndings[reason as keyof typeof sessionEndings] : undefined; if (!ending) throw new Error("Unknown reason"); // The server disconnects next; the disconnect handler renews or stops. - this.token = undefined; + this.session = undefined; this.ending = ending; } catch { this.protocolError(raw); } } diff --git a/packages/platform/src/modules/builder.ts b/packages/platform/src/modules/builder.ts index c05f752..4e71199 100644 --- a/packages/platform/src/modules/builder.ts +++ b/packages/platform/src/modules/builder.ts @@ -1,10 +1,9 @@ -import type { PlatformClientOptions } from "../client.types.js"; import { BuilderSocket } from "./builder-socket.js"; import type { BuilderModule } from "./builder.types.js"; /** @internal */ -export function createBuilder(config: PlatformClientOptions): BuilderModule { +export function createBuilder(): BuilderModule { return { - init(options) { return new BuilderSocket(config, options); }, + init(options) { return new BuilderSocket(options); }, }; } diff --git a/packages/platform/src/modules/builder.types.ts b/packages/platform/src/modules/builder.types.ts index fa6feed..0a6b102 100644 --- a/packages/platform/src/modules/builder.types.ts +++ b/packages/platform/src/modules/builder.types.ts @@ -1,8 +1,35 @@ import type { PlatformEvent, PlatformSnapshot } from "./builder.events.types.js"; import type { PlatformSocketError } from "../errors.js"; +/** + * The socket session your server opened, as [Create socket session](/api-reference/create-socket-session) returned it. + * + * Pass the response body through unchanged. The client reads these three fields and ignores the + * rest, such as `session_id` and `expires_in`. + */ +export interface SocketSession { + /** Token that authenticates the socket. The client keeps it in memory and sends it only when the socket connects. */ + session_token: string; + /** Origin of the platform socket, with no path, query, fragment or credentials. */ + socket_url: string; + /** Absolute path the socket connects on, such as `/ws/socket.io/`. */ + socket_path: string; +} + /** Options for {@linkcode BuilderModule.init | init()}. */ export interface BuilderInitOptions { + /** + * Returns a new socket session from your server. + * + * Your server opens the session with its workspace API key through + * [Create socket session](/api-reference/create-socket-session) and returns the response body, a [`SocketSession`](#socketsession). + * The client calls this on the first `connect()`, and again only when the session expires or + * Base44 rejects its token. Return a new session on every call. If a new session has a different + * `socket_url` or `socket_path`, the client moves to it and rejoins every active subscription. + * + * @returns The session, as your server received it. + */ + getSession: () => Promise; /** * Called on errors that affect the whole session, such as a failed connection or an ended session. * Errors for one app go to that subscription's `onError` instead. See the @@ -27,7 +54,7 @@ export interface BuilderModule { * Creates a builder session without connecting it. * * Call {@linkcode BuilderSession.connect | connect()} on the returned session to open the socket. - * Each session is independent, with its own socket, token and subscriptions. + * Each session is independent, with its own socket, socket session and subscriptions. * * @param options - Session options. * @returns The new session. @@ -36,6 +63,10 @@ export interface BuilderModule { * ```typescript * // Create and connect a session * const builder = client.builder.init({ + * async getSession() { + * const response = await fetch('/api/builder-socket-session', { method: 'POST' }); + * return response.json(); + * }, * onError(error) { * console.error('Builder session error', error.code); * }, @@ -56,8 +87,8 @@ export interface BuilderSession { * * Resolves once the socket connects, before any snapshot arrives. If the connection drops, the * session retries five times and rejoins every active subscription, each with a fresh snapshot. - * An expired or rejected token is replaced once through `getSessionToken`. Concurrent calls share - * one attempt. Call it again after a failure or a `session_replaced` error. + * The first call fetches a session through `getSession`, and an expired or rejected token is + * replaced once the same way. Concurrent calls share one attempt. Call it again after a failure or a `session_replaced` error. * * @returns Promise that resolves when the socket connects. * @throws {PlatformSocketError} With `connection_denied`, `connection_failed` or `session_unavailable` diff --git a/packages/platform/tests/package.test.mjs b/packages/platform/tests/package.test.mjs index 389fd9b..9bfb5ed 100644 --- a/packages/platform/tests/package.test.mjs +++ b/packages/platform/tests/package.test.mjs @@ -35,16 +35,16 @@ test("the client resolves from the installed package without the runtime SDK", ( run(process.execPath, ["--input-type=module", "--eval", ` import assert from "node:assert/strict"; import { createPlatformClient } from "@base44/platform"; - const client = createPlatformClient({ socketUrl: "https://example.test", getSessionToken: () => "session-token" }); + const client = createPlatformClient(); assert.equal(typeof client.builder.init, "function"); - client.builder.init({ onError() {} }).close(); + client.builder.init({ getSession: async () => ({ session_token: "t", socket_url: "https://example.test", socket_path: "/ws/socket.io/" }), onError() {} }).close(); `]); }); const consumer = ` import { createPlatformClient, type PlatformEvent } from "@base44/platform"; -const platform = createPlatformClient({ socketUrl: "https://example.test", getSessionToken: async () => "token" }); -platform.builder.init({ onError() {} }).subscribe("a".repeat(24), { +const platform = createPlatformClient(); +platform.builder.init({ getSession: async () => (await fetch("/session")).json(), onError() {} }).subscribe("a".repeat(24), { onSnapshot(snapshot) { void snapshot.messages; }, onEvent(event: PlatformEvent) { if (event.type === "message.updated") { diff --git a/packages/platform/tests/types/client.types.ts b/packages/platform/tests/types/client.types.ts index 5963101..32aabc9 100644 --- a/packages/platform/tests/types/client.types.ts +++ b/packages/platform/tests/types/client.types.ts @@ -1,6 +1,19 @@ -import { createPlatformClient, type PlatformEvent, type PlatformEventMap, type PlatformSnapshot, type ToolCall, type GuardApproval, type Message, type StatusObject } from "@base44/platform"; -const client = createPlatformClient({ socketUrl: "https://example.test", getSessionToken: async () => "session-token" }); -const builder = client.builder.init({ onError: error => { void error.code; } }); +import { createPlatformClient, type SocketSession, type PlatformEvent, type PlatformEventMap, type PlatformSnapshot, type ToolCall, type GuardApproval, type Message, type StatusObject } from "@base44/platform"; +const client = createPlatformClient(); +const builder = client.builder.init({ + // The Create socket session body passes through, extra keys included. + getSession: async () => ({ session_id: "s1", session_token: "t", socket_url: "https://example.test", socket_path: "/ws/socket.io/", expires_in: 3600 }), + onError: error => { void error.code; }, +}); +client.builder.init({ getSession: async () => (await fetch("/session")).json(), onError() {} }); +const body: SocketSession = { session_token: "t", socket_url: "https://example.test", socket_path: "/ws/socket.io/" }; +void body; +// @ts-expect-error The session needs its socket path. +client.builder.init({ getSession: async () => ({ session_token: "t", socket_url: "https://example.test" }), onError() {} }); +// @ts-expect-error The session comes from init, not the client. +createPlatformClient({ socketUrl: "https://example.test", getSessionToken: async () => "token" }); +// @ts-expect-error A session is required. +client.builder.init({ onError() {} }); const subscription = builder.subscribe("a".repeat(24), { onSnapshot(snapshot: PlatformSnapshot) { const state: "ready" | "processing" | "error" | null | undefined = snapshot.status?.state; @@ -43,8 +56,6 @@ client.send("write_file", {}); createPlatformClient({ apiKey: "private" }); // @ts-expect-error Socket methods belong to the builder session. client.connect(); -// @ts-expect-error The retired token callback is not accepted. -createPlatformClient({ socketUrl: "https://example.test", refreshToken: async () => "token" }); const tool: ToolCall = { name: "ask_clarifying_questions", diff --git a/packages/platform/tests/unit/client.test.ts b/packages/platform/tests/unit/client.test.ts index c4e2915..0f4ad83 100644 --- a/packages/platform/tests/unit/client.test.ts +++ b/packages/platform/tests/unit/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createPlatformClient, type BuilderSession } from "../../src/index.js"; +import { createPlatformClient, PlatformSocketError, type BuilderSession } from "../../src/index.js"; const fake = vi.hoisted(() => { const handlers: Record void> = {}; @@ -21,75 +21,88 @@ const snapshot = (messages: unknown[] = []) => fake.handlers["app.snapshot"]({ r const update = (data: unknown = { status: null }) => fake.handlers["app.status_changed"]({ room, data }); const notice = (name: string, data: unknown = {}, at: string | null = room) => fake.handlers[name]({ room: at, data }); const denied = (retryable: boolean) => fake.handlers.connect_error(Object.assign(new Error("connection_denied"), { data: { retryable } })); +const session = (fields: Record = {}) => ({ + session_id: "s1", session_token: "session-token", socket_url: "https://api.example.test", socket_path: "/ws/socket.io/", expires_in: 3600, ...fields, +}); const auth = async () => { const callback = vi.fn(); (fake.io.mock.calls.at(-1) as any)[1].auth(callback); await settle(); return callback; }; -function setup(getSessionToken = vi.fn(async () => "session-token")) { +function setup(getSession = vi.fn(async (): Promise => session())) { const onError = vi.fn(); - const platform = createPlatformClient({ socketUrl: "https://api.example.test", getSessionToken }); - const client = platform.builder.init({ onError }); + const client = createPlatformClient().builder.init({ getSession, onError }); clients.push(client); - return { client, getSessionToken, onError }; + return { client, getSession, onError }; +} +async function begin(client: BuilderSession) { + client.connect().catch(() => {}); await settle(); } async function connected(client: BuilderSession) { - const ready = client.connect(); fake.socket.connected = true; fake.handlers.connect(); await ready; await settle(); + const ready = client.connect(); await settle(); fake.socket.connected = true; fake.handlers.connect(); await ready; await settle(); } const joins = () => fake.socket.emit.mock.calls.filter(c => c[0] === "join"); beforeEach(() => { vi.clearAllMocks(); fake.socket.connected = false; }); afterEach(() => { clients.splice(0).forEach(client => client.close()); vi.useRealTimers(); }); describe("platform client", () => { - test("constructing the root client does not initialize sockets or fetch session tokens", () => { - const getSessionToken = vi.fn(async () => "token"); - const platform = createPlatformClient({ socketUrl: "https://api.example.test", getSessionToken }); - expect(fake.io).not.toHaveBeenCalled(); - const first = platform.builder.init({ onError: vi.fn() }); - const second = platform.builder.init({ onError: vi.fn() }); + test("creating the client and initializing sessions opens no socket and fetches no session", () => { + const getSession = vi.fn(async () => session()); + const platform = createPlatformClient(); + const first = platform.builder.init({ getSession, onError: vi.fn() }); + const second = platform.builder.init({ getSession, onError: vi.fn() }); clients.push(first, second); expect(first).not.toBe(second); - expect(fake.io).toHaveBeenCalledTimes(2); - expect(fake.socket.connect).not.toHaveBeenCalled(); - expect(getSessionToken).not.toHaveBeenCalled(); + expect(fake.io).not.toHaveBeenCalled(); + expect(getSession).not.toHaveBeenCalled(); }); - test("closing one initialized builder does not close another or the root module", () => { - const platform = createPlatformClient({ socketUrl: "https://api.example.test", getSessionToken: async () => "token" }); + test("closing one initialized builder does not close another or the root module", async () => { const firstSocket = { ...fake.socket, disconnect: vi.fn() }; const secondSocket = { ...fake.socket, disconnect: vi.fn() }; fake.io.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket); - const first = platform.builder.init({ onError: vi.fn() }); - const second = platform.builder.init({ onError: vi.fn() }); - clients.push(first, second); + const { client: first } = setup(), { client: second } = setup(); + await begin(first); await begin(second); first.close(); expect(firstSocket.disconnect).toHaveBeenCalledOnce(); expect(secondSocket.disconnect).not.toHaveBeenCalled(); }); - test("connects to the shared platform socket with the session token in CONNECT auth only", async () => { - const { getSessionToken } = setup(); + test("opens the socket at the session's URL and path, with the token in CONNECT auth only", async () => { + const { client, getSession } = setup(vi.fn(async () => session({ socket_url: "https://live.example.test/", socket_path: "/partner/socket.io/" }))); + await begin(client); const [url, options] = fake.io.mock.calls[0] as unknown as [string, any]; - expect(url).toBe("https://api.example.test"); - expect(options).toMatchObject({ path: "/ws/socket.io/", transports: ["websocket"], autoConnect: false, forceNew: true, reconnectionAttempts: 5 }); + expect(url).toBe("https://live.example.test"); + expect(options).toMatchObject({ path: "/partner/socket.io/", transports: ["websocket"], autoConnect: false, forceNew: true, reconnectionAttempts: 5 }); expect(options.query).toBeUndefined(); + expect(fake.socket.connect).toHaveBeenCalledOnce(); expect(await auth()).toHaveBeenLastCalledWith({ session_token: "session-token" }); - expect(getSessionToken).toHaveBeenCalledOnce(); + expect(getSession).toHaveBeenCalledOnce(); + }); + + test("ignores the session body's other keys", async () => { + const { client } = setup(vi.fn(async () => session({ future_field: { nested: true } }))); + await begin(client); + const [, options] = fake.io.mock.calls[0] as unknown as [string, any]; + for (const key of ["session_id", "expires_in", "future_field"]) expect(options).not.toHaveProperty(key); + const callback = await auth(); + expect(callback.mock.calls[0][0]).toStrictEqual({ session_token: "session-token" }); }); - test("transport reconnects reuse the session token instead of opening a new session", async () => { - const { client, getSessionToken } = setup(); - await auth(); await connected(client); + test("transport reconnects reuse the session instead of opening a new one", async () => { + const { client, getSession } = setup(); + await connected(client); fake.handlers.disconnect("transport close"); expect(await auth()).toHaveBeenLastCalledWith({ session_token: "session-token" }); - expect(getSessionToken).toHaveBeenCalledOnce(); + expect(getSession).toHaveBeenCalledOnce(); + expect(fake.io).toHaveBeenCalledOnce(); }); test("a rejected cached token is renewed once, and a rejected fresh one is reported", async () => { - const { client, getSessionToken, onError } = setup(); - await auth(); await connected(client); + const { client, getSession, onError } = setup(); + await connected(client); fake.handlers.disconnect("transport close"); fake.socket.connected = false; fake.socket.connect.mockClear(); denied(false); expect(fake.socket.connect).toHaveBeenCalledOnce(); - getSessionToken.mockResolvedValue("renewed"); + getSession.mockResolvedValue(session({ session_token: "renewed" })); expect(await auth()).toHaveBeenLastCalledWith({ session_token: "renewed" }); denied(false); expect(fake.socket.connect).toHaveBeenCalledOnce(); @@ -100,27 +113,56 @@ describe("platform client", () => { vi.useFakeTimers(); const { client, onError } = setup(); const failure = expect(client.connect()).rejects.toMatchObject({ code: "connection_failed" }); - fake.socket.connect.mockClear(); + await settle(); fake.socket.connect.mockClear(); for (let i = 0; i < 5; i++) { denied(true); await vi.advanceTimersByTimeAsync(10000); } expect(fake.socket.connect).toHaveBeenCalledTimes(5); denied(true); await failure; expect(onError.mock.calls[0][0].code).toBe("connection_failed"); }); - test("an expired session reconnects with a new session token", async () => { - const { client, getSessionToken, onError } = setup(); - await auth(); await connected(client); fake.socket.connect.mockClear(); + test("an expired session reconnects with a new session", async () => { + const { client, getSession, onError } = setup(); + await connected(client); fake.socket.connect.mockClear(); notice("session.ended", { reason: "expired" }, null); fake.handlers.disconnect("io server disconnect"); expect(fake.socket.connect).toHaveBeenCalledOnce(); - getSessionToken.mockResolvedValue("next-session"); + getSession.mockResolvedValue(session({ session_token: "next-session" })); expect(await auth()).toHaveBeenLastCalledWith({ session_token: "next-session" }); + expect(fake.io).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); + }); + + test("a renewed session at a new address moves the socket and rejoins every subscription", async () => { + const { client, getSession, onError } = setup(); + client.subscribe(app, { onSnapshot: vi.fn(), onEvent: vi.fn(), onError: vi.fn() }); + client.subscribe(other, { onSnapshot: vi.fn(), onEvent: vi.fn(), onError: vi.fn() }); + await connected(client); + const handlers: Record void> = {}; + const moved = { + ...fake.socket, connected: false, on: vi.fn((name, handler) => { handlers[name] = handler; }), + emit: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), removeAllListeners: vi.fn(), + io: { on: vi.fn(), removeAllListeners: vi.fn() }, + }; + fake.io.mockReturnValueOnce(moved); + getSession.mockResolvedValue(session({ session_token: "moved", socket_url: "https://eu.example.test", socket_path: "/eu/socket.io/" })); + notice("session.ended", { reason: "expired" }, null); + fake.handlers.disconnect("io server disconnect"); + expect(await auth()).not.toHaveBeenCalled(); + expect(fake.socket.removeAllListeners).toHaveBeenCalledOnce(); + expect(fake.socket.disconnect).toHaveBeenCalledOnce(); + const [url, options] = fake.io.mock.calls.at(-1) as unknown as [string, any]; + expect([url, options.path]).toEqual(["https://eu.example.test", "/eu/socket.io/"]); + expect(moved.connect).toHaveBeenCalledOnce(); + expect(await auth()).toHaveBeenLastCalledWith({ session_token: "moved" }); + moved.connected = true; handlers.connect(); + expect(moved.emit.mock.calls).toEqual([["join", room], ["join", `/apps/${other}`]]); + expect(getSession).toHaveBeenCalledTimes(2); expect(onError).not.toHaveBeenCalled(); }); test("a replaced session stops without reconnecting", async () => { const { client, onError } = setup(); - await auth(); await connected(client); fake.socket.connect.mockClear(); + await connected(client); fake.socket.connect.mockClear(); notice("session.ended", { reason: "replaced" }, null); fake.handlers.disconnect("io server disconnect"); expect(fake.socket.connect).not.toHaveBeenCalled(); @@ -128,39 +170,79 @@ describe("platform client", () => { }); test("a revoked session stops without asking for a new one", async () => { - const { client, getSessionToken, onError } = setup(); - await auth(); await connected(client); fake.socket.connect.mockClear(); getSessionToken.mockClear(); + const { client, getSession, onError } = setup(); + await connected(client); fake.socket.connect.mockClear(); getSession.mockClear(); notice("session.ended", { reason: "revoked" }, null); fake.handlers.disconnect("io server disconnect"); expect(fake.socket.connect).not.toHaveBeenCalled(); - expect(getSessionToken).not.toHaveBeenCalled(); + expect(getSession).not.toHaveBeenCalled(); expect(onError.mock.calls[0][0].code).toBe("session_revoked"); }); - test("shares concurrent connects; session-token failures never reach Socket.IO", async () => { + test("shares concurrent connects; session failures never reach Socket.IO", async () => { const { client, onError } = setup(vi.fn(async () => { throw new Error("secret"); })); const a = client.connect(), b = client.connect(); expect(a).toBe(b); - const rejection = expect(a).rejects.toMatchObject({ code: "session_unavailable" }); - const callback = await auth(); - await rejection; - expect(callback).not.toHaveBeenCalled(); + await expect(a).rejects.toMatchObject({ code: "session_unavailable" }); + expect(fake.io).not.toHaveBeenCalled(); expect(onError.mock.calls[0][0].message).not.toContain("secret"); }); - test("session-token retrieval has a bounded timeout", async () => { + test("a failed renewal stops the socket without authenticating it", async () => { + const { client, getSession, onError } = setup(); + await connected(client); + getSession.mockRejectedValue(new Error("secret")); + notice("session.ended", { reason: "expired" }, null); + fake.handlers.disconnect("io server disconnect"); + expect(await auth()).not.toHaveBeenCalled(); + expect(fake.socket.disconnect).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0][0].code).toBe("session_unavailable"); + }); + + test("session retrieval has a bounded timeout", async () => { vi.useFakeTimers(); - const { client } = setup(vi.fn(() => new Promise(() => {}))); + const { client } = setup(vi.fn(() => new Promise(() => {}))); const failure = expect(client.connect()).rejects.toMatchObject({ code: "session_unavailable" }); - const callback = vi.fn(); (fake.io.mock.calls[0] as any)[1].auth(callback); await vi.advanceTimersByTimeAsync(20000); await failure; + expect(fake.io).not.toHaveBeenCalled(); + }); + + test("a late session opens no socket after close", async () => { + let resolve!: (value: unknown) => void; + const { client } = setup(vi.fn(() => new Promise(r => { resolve = r; }))); + await begin(client); client.close(); resolve(session()); await settle(); + expect(fake.io).not.toHaveBeenCalled(); + }); + + test("a late renewed session cannot authenticate after close", async () => { + let resolve!: (value: unknown) => void; + const { client, getSession } = setup(); + await connected(client); + getSession.mockImplementation(() => new Promise(r => { resolve = r; })); + notice("session.ended", { reason: "expired" }, null); + fake.handlers.disconnect("io server disconnect"); + const callback = await auth(); + client.close(); resolve(session({ session_token: "secret" })); await settle(); expect(callback).not.toHaveBeenCalled(); + expect(fake.io).toHaveBeenCalledOnce(); }); - test("late session tokens cannot authenticate after close", async () => { - let resolve!: (token: string) => void; - const { client } = setup(vi.fn(() => new Promise(r => { resolve = r; }))); - const callback = vi.fn(); (fake.io.mock.calls[0] as any)[1].auth(callback); - await settle(); client.close(); resolve("secret"); await settle(); expect(callback).not.toHaveBeenCalled(); + test.each([ + { session_token: "", socket_url: "https://example.test", socket_path: "/ws/socket.io/" }, + { session_token: "t", socket_url: "https://secret@example.test", socket_path: "/ws/socket.io/" }, + { session_token: "t", socket_url: "https://example.test?token=secret", socket_path: "/ws/socket.io/" }, + { session_token: "t", socket_url: "https://example.test/path", socket_path: "/ws/socket.io/" }, + { session_token: "t", socket_url: "ws://example.test", socket_path: "/ws/socket.io/" }, + { session_token: "t", socket_url: "example.test", socket_path: "/ws/socket.io/" }, + { session_token: "t", socket_url: "https://example.test", socket_path: "ws/socket.io/" }, + { session_token: "t", socket_url: "https://example.test", socket_path: "//evil.test/socket.io/" }, + { session_token: "t", socket_url: "https://example.test", socket_path: "/ws/socket.io/?token=secret" }, + { session_token: "t", socket_url: "https://example.test" }, + "not a session", + ])("an invalid session is session_unavailable: %j", async body => { + const { client, onError } = setup(vi.fn(async () => body)); + await expect(client.connect()).rejects.toBeInstanceOf(PlatformSocketError); + expect(onError.mock.calls[0][0].code).toBe("session_unavailable"); + expect(fake.io).not.toHaveBeenCalled(); }); test("joins without metadata and delivers the snapshot, then events in order", async () => { @@ -289,10 +371,7 @@ describe("platform client", () => { expect(fake.socket.removeAllListeners).toHaveBeenCalledOnce(); }); - test("validates origins, app IDs, duplicate subscriptions and limits", () => { - for (const socketUrl of ["https://secret@example.test", "https://example.test?token=secret", "https://example.test/path", "ws://example.test"]) { - expect(() => createPlatformClient({ socketUrl, getSessionToken: () => "token" })).toThrow(TypeError); - } + test("validates app IDs, duplicate subscriptions and limits", () => { const { client } = setup(); const options = { onSnapshot: vi.fn(), onEvent: vi.fn(), onError: vi.fn() }; expect(() => client.subscribe("bad", options)).toThrow(TypeError); client.subscribe(app, options); expect(() => client.subscribe(app, options)).toThrow(TypeError); diff --git a/packages/platform/tests/unit/socket.test.js b/packages/platform/tests/unit/socket.test.js index bfc0e19..e98bfe1 100644 --- a/packages/platform/tests/unit/socket.test.js +++ b/packages/platform/tests/unit/socket.test.js @@ -30,11 +30,13 @@ test("real Socket.IO handshake, join, snapshot, reconnect and session renewal", }); }); await new Promise(resolve => http.listen(0, "127.0.0.1", resolve)); - const client = createPlatformClient({ - socketUrl: `http://127.0.0.1:${http.address().port}`, - getSessionToken: async () => `session-${++tokenCalls}`, + const builder = createPlatformClient().builder.init({ + getSession: async () => ({ + session_id: `id-${tokenCalls}`, session_token: `session-${++tokenCalls}`, expires_in: 3600, + socket_url: `http://127.0.0.1:${http.address().port}`, socket_path: "/live/socket.io/", + }), + onError: error => errors.push(error), }); - const builder = client.builder.init({ onError: error => errors.push(error) }); try { builder.subscribe(appId, { onSnapshot: s => { snapshots.push(s.messages[0].id); }, onEvent: vi.fn(), onError: error => errors.push(error) }); await builder.connect(); @@ -48,7 +50,7 @@ test("real Socket.IO handshake, join, snapshot, reconnect and session renewal", expect(joins).toEqual([[room], [room], [room]]); expect(handshakes.map(item => item.token)).toEqual(["session-1", "session-1", "session-2"]); for (const { url } of handshakes) { - expect(url.pathname).toBe("/ws/socket.io/"); + expect(url.pathname).toBe("/live/socket.io/"); expect(url.searchParams.get("transport")).toBe("websocket"); expect([...url.searchParams.keys()].some(key => /token/i.test(key))).toBe(false); }