diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f27c76b9..354453ac 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -31,3 +31,9 @@ jobs: - name: Run unit tests run: npm run test:unit + + - name: Check lint and public platform documentation + run: npm run lint && npm run docs:platform-client + + - name: Verify packed entry points + run: npm run test:package diff --git a/README.md b/README.md index 741fe2e2..18faa9d1 100644 --- a/README.md +++ b/README.md @@ -151,3 +151,12 @@ npm run create-docs cd docs mintlify dev ``` + +### Platform browser subscriptions + +The separate `@base44/sdk/platform/client` entry point subscribes to public builder +updates through the white-label socket. It supports typed events, bounded delivery, +and reconnect replay using browser credentials supplied by your backend. +See [setup, public contract and recovery](platform-docs/client.md) and the +[TypeScript example](examples/platform-client.ts). Backend token integration and +workspace rollout are prerequisites; never use an API key in the browser. diff --git a/eslint.config.js b/eslint.config.js index d7d5e1a0..da67e66d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsParser from "@typescript-eslint/parser"; export default [ { - files: ["src/**/*.ts"], + files: ["src/**/*.ts", "platform-src/**/*.ts", "examples/platform-client.ts"], languageOptions: { parser: tsParser, }, diff --git a/examples/platform-client.ts b/examples/platform-client.ts new file mode 100644 index 00000000..3ef9f3c2 --- /dev/null +++ b/examples/platform-client.ts @@ -0,0 +1,34 @@ +import { Base44PlatformClient, type PlatformEvent } from "@base44/sdk/platform/client"; + +const client = new Base44PlatformClient({ + serverUrl: "https://base44.app", + async refreshToken() { + const response = await fetch("/api/platform/browser-token", { method: "POST" }); + if (!response.ok) throw new Error("Unable to obtain browser credential"); + const { token } = await response.json(); + return token; + }, +}); +const builder = client.builder.init({ + onError(error) { console.error(error.code); }, +}); + +const subscription = builder.subscribe("0123456789abcdef01234567", { + async onEvent(event: PlatformEvent) { + if (event.type === "update_model") { + // Apply omitted keys as unchanged and _last_msg as a replacement by id. + console.log(event.data._last_msg?.content); + } + // Await your state update here. The cursor advances only after this returns. + }, + onJoined(boundary) { console.log("Live boundary", boundary.seq); }, + onError(error) { + // For resync_required, reconcile through your backend before a fresh subscription. + console.error(error.code); + }, +}); + +await builder.connect(); +// Later, during teardown: +subscription.unsubscribe(); +builder.close(); diff --git a/package.json b/package.json index 80c89a70..b6a548d1 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "dist" ], "scripts": { - "build": "tsc", - "lint": "eslint src", + "build": "npm run build:runtime && npm run build:platform", + "lint": "eslint src platform-src examples/platform-client.ts", "test": "npm run test:types && vitest run", "test:types": "tsc --noEmit -p tsconfig.type-tests.json", "test:unit": "vitest run tests/unit", @@ -23,7 +23,11 @@ "create-docs-local": "npm run create-docs && npm run copy-docs-local", "copy-docs-local": "node scripts/mintlify-post-processing/copy-to-local-docs.js", "create-docs:generate": "typedoc", - "create-docs:process": "node scripts/mintlify-post-processing/file-processing/file-processing.js" + "create-docs:process": "node scripts/mintlify-post-processing/file-processing/file-processing.js", + "build:runtime": "tsc", + "build:platform": "tsc -p tsconfig.platform.json", + "docs:platform-client": "typedoc --options typedoc.platform-client.json", + "test:package": "npm run build && node --test tests/package/platform-client.test.mjs" }, "dependencies": { "axios": "^1.18.1", @@ -63,5 +67,36 @@ "bugs": { "url": "https://github.com/base44/javascript-sdk/issues" }, - "homepage": "https://github.com/base44/javascript-sdk#readme" + "homepage": "https://github.com/base44/javascript-sdk#readme", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./dist/*.d.ts": "./dist/*.d.ts", + "./dist/*.js": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "./dist/*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "./package.json": "./package.json", + "./*": "./*", + "./platform/client": { + "types": "./dist/platform/client/index.d.ts", + "default": "./dist/platform/client/index.js" + } + }, + "typesVersions": { + "*": { + "platform/client": [ + "dist/platform/client/index.d.ts" + ], + "*": [ + "*" + ] + } + } } diff --git a/platform-docs/client.md b/platform-docs/client.md new file mode 100644 index 00000000..a7e8b902 --- /dev/null +++ b/platform-docs/client.md @@ -0,0 +1,173 @@ +# Platform browser client + +This entry point implements the read-only white-label builder socket contract. +It is independent of the runtime SDK and the proposed platform/server entry point. +The backend browser-token integration and workspace rollout are prerequisites; +this SDK does not mint tokens or make an unavailable endpoint usable. + +```ts +import { Base44PlatformClient } from "@base44/sdk/platform/client"; + +const client = new Base44PlatformClient({ + serverUrl: "https://base44.app", + async refreshToken() { + const response = await fetch("/api/platform/browser-token", { method: "POST" }); + if (!response.ok) throw new Error("Token request failed"); + return (await response.json()).token; + }, +}); +const builder = client.builder.init({ + onError(error) { showConnectionError(error.code); }, +}); +const subscription = builder.subscribe(appId, { + afterSeq: savedState?.cursor, + async onEvent(event) { + // Apply all five event types in this handler. It is awaited before the next event. + await applyAndPersistEvent(event); // persist state and event.seq together + }, + async onJoined(boundary) { + await persistBoundaryWithCurrentState(boundary.seq); + }, + onError(error) { showSubscriptionError(error.code); }, +}); +await builder.connect(); +// On view teardown: +subscription.unsubscribe(); +builder.close(); +``` + +The `/api/platform/browser-token` route is your backend's route, not an SDK endpoint. +Only browser-authorized tokens belong here. Never pass an API key or an elevated +server credential. Tokens go exclusively in CONNECT `auth.token`, never URL/query +parameters or browser persistence managed by this SDK. `refreshToken` is called on +every connection attempt, including automatic reconnects. Token retrieval has a +twenty-second timeout and reports `token_unavailable` on failure. + +## Lifecycle and replay + +`new Base44PlatformClient({ serverUrl, refreshToken })` creates a lightweight module +container: no Socket.IO instance, timers, token refresh or network activity. Its +`builder` module follows the server SDK's module-factory pattern. Shared settings +are copied at construction; adding another module need not initialize builder resources. + +`client.builder.init({ onError })` synchronously creates an independent `BuilderSession` +without connecting. 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 namespace CONNECT; +`onJoined` reports completion of each app's replay. Socket.IO uses `/partner`, +`/ws-whitelabel/socket.io/`, WebSocket only and a dedicated manager. Transport +reconnection uses five attempts, starting at one second and capped at ten seconds +with Socket.IO jitter; each connection attempt has a twenty-second timeout. +After exhaustion or a server/auth rejection, fix the cause and call `builder.connect()`. + +`builder.subscribe(appId, options)` returns a handle with `appId`, `active`, `cursor`, and +idempotent `unsubscribe()`. App IDs are 24 lowercase hexadecimal characters. One +subscription per app and at most eight subscriptions are allowed per builder session. +Subscriptions can be created before connecting. Adding a subscription after removing +one refreshes the connection before joining: the server has no leave acknowledgement, +so a fresh connection prevents late events from an old stream entering a new one. +Other active apps rejoin from their applied cursors. `builder.close()` is terminal for that session and stops +reconnects, subscriptions and listeners; call `client.builder.init(...)` again to start another session. + +Delivery is serialized **per app**, including replay and `onJoined`; a slow app +does not block others. On reconnect the next join waits for queued application +work, then sends the last successfully applied cursor as `after_seq`. Cursors are +opaque and app-specific. The SDK does not parse or compare their ordering. An +immediately repeated cursor is ignored; handlers must still tolerate redelivery +across process restarts and uncertain persistence. This is not exactly-once delivery. +Persist the cursor atomically with the state it describes inside your callbacks; +the handle's cursor advances only after a callback succeeds. Do not persist a +cursor in a separate state store before applying the corresponding event. + +A fresh join starts at the acknowledged live boundary and supplies **no initial +snapshot**. Initial history and recovery beyond retention belong to your backend. +Replay retains up to 2,000 events per app and expires after 3,600 seconds of +inactivity; the `Joined` acknowledgement reports these server values. The client +bounds pending delivery to 1,000 items; overflow stops the subscription explicitly. +Existing branch metadata passes through; subscriptions cover the regular app room. + +On a subscription error, `active` becomes false, delivery stops and the last applied +cursor is retained. No automatic fresh join discards missed history. For +`stream_unavailable`, unsubscribe/re-subscribe with that cursor using bounded +application backoff. For `resync_required`, reconcile state/history through your +backend, then subscribe without `afterSeq`. Coordinate snapshot/recovery with your +backend: a fresh boundary alone cannot close a race between fetching a snapshot +and starting a live subscription. HTTP writes also belong to your partner backend. + +Callbacks must settle; they cannot be forcibly cancelled. Unsubscribe/close prevents +queued callbacks and later cursor advancement, but a callback already running may +finish its own side effects. Error observers are synchronous notifications; their +exceptions are isolated so they cannot interrupt another app's delivery. + +## Public shapes + +- `PlatformClientOptions` (`client.types.ts`): `serverUrl`, `refreshToken`. +- `BuilderModule` (`modules/builder.types.ts`): lazy `init(options)` factory. +- `BuilderInitOptions`: connection-level `onError` observer. +- `BuilderSession`: `connect()`, `subscribe(appId, options)`, `close()`. +- `SubscriptionOptions`: optional `afterSeq` and `onJoined`, required `onEvent` and `onError`. +- `PlatformSubscription`: read-only `appId`, `cursor`, `active`, and `unsubscribe()`. +- Event/message models live in `modules/builder.events.types.ts`; error codes in `errors.types.ts`. + +Every exported shape and field has JSDoc. Run `npm run docs:platform-client` for +validated reference pages in `docs/platform/client`. `PlatformEvent` is a +**discriminated union**: switch on `event.type` to narrow `event.data`. Each delivery +contains `type`, `appId`, opaque `seq`, and decoded `data`. JSON strings are decoded +for `update_model`, `task_update` and `image_ready`. The two flat wire payloads keep +their original fields, excluding `seq`, which is available on the delivery itself. +The SDK validates routing/envelopes; payload schema validation and private-field +filtering are the server's responsibility. Unknown event names are not forwarded. + +| Event | Data contract | +| --- | --- | +| `update_model` | `AppUpdate`: optional `status`, `_last_msg`, `_last_msg_conversation_id`, `_scope_branch_id`, `sandbox_should_reload`, `navigate_preview_to`, `navigate_preview_force_to`. Omission means unchanged; null means clear. `_last_msg` replaces by message ID. | +| `directive` | `Directive`: `room`, `type` (`conversation_changed` or `app_files_changed`), optional `branch_id`. No raw directive payload. | +| `queue_update` | `QueueUpdate`: `app_id`, `items`, `is_paused`, optional `branch_id` and `processed_item_id`. Replaces the entire queue. | +| `task_update` | `TaskUpdate`: `event_type` (`task_started`, `task_progress`, `task_completed`, `task_failed`, `task_cancelled`), optional `tool_call_id`, `message_id`, `branch_id`, numeric `progress`. | +| `image_ready` | `ImageReady`: `placeholder_url`, `status` (`pending`, `completed`, `failed`), optional nullable `image_url`. | + +`ChatMessage` has optional `id`, `role` (`user`/`assistant`), text/null `content`, +`file_urls`, `tool_calls`, timestamp-only `metadata.created_date`, and `checkpoint_id`. +`ToolCall` has optional `id`, `name`, `status`, `requires_user_input`, `auto_approved`, +`mutation_applied`, and the existing nested `waiting_on.kind` +(`approval`/`choice`/`input`/null). `status` is `running`, `success`, `error`, +`stopped`, or `waiting_for_user_input`. Its optional reviewed extensions are: + +| Field | Exact public contract | +| --- | --- | +| `display_projection` | File activity has `file_paths` and optional `content_empty`; execution activity has optional `summary` and `writes_entities`; entity activity has optional `entity_name` and `record_count`. | +| `arguments_string` | JSON for one of `ToolQuestionArguments`, `ToolSecretArguments`, `ToolPackageArguments`, `ToolPlanArguments`, or `ToolMediaArguments`. It is absent for all other tools. | +| `user_input` | `ToolQuestionInput` only, for clarifying-question answers. Secret-form values are never exposed. | +| `results` | One fixed `ToolOutcome` success string. Raw errors, command output, source, diffs, arbitrary tool results, credentials, workspace context, customer data and diagnostics are excluded. | + +Generated-media completion arrives through `image_ready.image_url`; the corresponding +tool arguments include only its label and aspect ratio. `AppUpdate.status` contains optional +`state` (`ready`/`processing`/`error`) and nullable `last_updated_date`. +`QueueItem` contains `id`, `content`, `created_at`, optional nullable `file_urls` and +`branch_id`. Numeric progress fields are optional nullable `current`, `total`, +`percentage`. Branch identifiers are strings or null. Dates remain wire strings. +Structural filtering does not promise redaction of generated prose or user content. + +`Joined` contains `room`, `seq`, `max_entries`, `inactivity_expiry_seconds`. It is an +ordered server event, not a Socket.IO callback acknowledgement. +`PlatformSocketError` extends `Error` with `code` and optional `appId`. Original +exceptions, payloads and credentials are never attached. + +| Error code | Action | +| --- | --- | +| `invalid_room` | Correct the app/room; malformed local IDs are rejected synchronously. | +| `invalid_cursor` | Correct the saved cursor; do not retry it unchanged. | +| `access_denied`, `connection_denied` | Obtain an authorized browser credential/workspace configuration. | +| `subscription_limit` | Release a subscription before adding another. | +| `resync_required` | Reconcile through the backend before a fresh subscription. Includes client buffer overflow. | +| `stream_unavailable` | Retry with bounded backoff and the last applied cursor. | +| `connection_failed` | Transport failure or exhausted reconnect attempts; call `connect()` after fixing connectivity. | +| `token_unavailable` | Fix token retrieval; no provider exception details are forwarded. | +| `protocol_error` | Invalid routing/envelope/JSON. Stop and investigate; affected subscriptions retain their cursor. | +| `handler_failed` | Application callback failed; fix state application before resuming from the saved cursor. | +| `client_closed` | The builder session was explicitly closed; initialize another session. | + +No SDK dependency, package version or lockfile changes are required. Socket.IO +remains the existing SDK dependency. Chat commands and HTTP APIs are outside this entry point. diff --git a/platform-src/client/client.ts b/platform-src/client/client.ts new file mode 100644 index 00000000..ee9888d7 --- /dev/null +++ b/platform-src/client/client.ts @@ -0,0 +1,18 @@ +import type { PlatformClientOptions } from "./client.types.js"; +import { createBuilder } from "./modules/builder.js"; +import type { BuilderModule } from "./modules/builder.types.js"; + +/** Browser platform client. Construction creates no sockets, timers or network requests. */ +export class Base44PlatformClient { + /** Lazy builder subscriptions with independent session lifecycles. */ + readonly builder: BuilderModule; + + /** Configure shared service/auth settings; each module initializes its own resources. */ + constructor(options: PlatformClientOptions) { + const url = new URL(options.serverUrl); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") { + throw new TypeError("serverUrl must be an HTTP(S) origin without credentials, path, query or fragment"); + } + this.builder = Object.freeze(createBuilder({ ...options, serverUrl: url.origin })); + } +} diff --git a/platform-src/client/client.types.ts b/platform-src/client/client.types.ts new file mode 100644 index 00000000..41b27539 --- /dev/null +++ b/platform-src/client/client.types.ts @@ -0,0 +1,7 @@ +/** Shared configuration for browser platform modules. Never supply an API key. */ +export interface PlatformClientOptions { + /** Origin of the platform service, e.g. https://base44.app. No path/query/credentials. */ + serverUrl: string; + /** Refresh a browser credential through your backend; called on every builder connection attempt. */ + refreshToken: () => string | Promise; +} diff --git a/platform-src/client/errors.ts b/platform-src/client/errors.ts new file mode 100644 index 00000000..ab0d8d62 --- /dev/null +++ b/platform-src/client/errors.ts @@ -0,0 +1,17 @@ +import type { PlatformSocketErrorCode } from "./errors.types.js"; + +/** Sanitized failure. Original token-provider, handler and server exceptions are not retained. */ +export class PlatformSocketError extends Error { + /** Stable machine-readable category. */ + readonly code: PlatformSocketErrorCode; + /** Associated app, when the server identifies a valid room. */ + readonly appId?: string; + + /** Create a sanitized error with no credential-bearing cause or payload. */ + constructor(code: PlatformSocketErrorCode, appId?: string) { + super(`Platform socket: ${code}`); + this.name = "PlatformSocketError"; + this.code = code; + this.appId = appId; + } +} diff --git a/platform-src/client/errors.types.ts b/platform-src/client/errors.types.ts new file mode 100644 index 00000000..dd02795a --- /dev/null +++ b/platform-src/client/errors.types.ts @@ -0,0 +1,7 @@ +/** Server subscription failures and client transport/processing failures. */ +export type PlatformSocketErrorCode = + | "invalid_room" | "invalid_cursor" | "access_denied" | "subscription_limit" + | "resync_required" | "stream_unavailable" | "connection_denied" + | "connection_failed" | "token_unavailable" | "protocol_error" + | "handler_failed" | "client_closed"; + diff --git a/platform-src/client/index.ts b/platform-src/client/index.ts new file mode 100644 index 00000000..76225b7a --- /dev/null +++ b/platform-src/client/index.ts @@ -0,0 +1,7 @@ +/** Browser platform modules, separate from the runtime and server SDKs. */ +export { Base44PlatformClient } from "./client.js"; +export { PlatformSocketError } from "./errors.js"; +export type { PlatformSocketErrorCode } from "./errors.types.js"; +export type { PlatformClientOptions } from "./client.types.js"; +export type { BuilderModule, BuilderInitOptions, BuilderSession, PlatformSubscription, SubscriptionOptions } from "./modules/builder.types.js"; +export type { AppUpdate, ChatMessage, ToolCall, ToolDisplayProjection, ToolQuestionOption, ToolQuestion, ToolQuestionArguments, ToolSecretField, ToolSecretArguments, ToolPackageOperation, ToolPackageArguments, ToolPlanUpdate, ToolPlanArguments, ToolMediaArguments, ToolMediaResult, ToolQuestionAnswer, ToolQuestionInput, ToolOutcome, ToolResult, QueueItem, QueueUpdate, TaskUpdate, ImageReady, Directive, PlatformEventMap, PlatformEvent, Joined } from "./modules/builder.events.types.js"; diff --git a/platform-src/client/modules/builder-protocol.ts b/platform-src/client/modules/builder-protocol.ts new file mode 100644 index 00000000..4d2e940d --- /dev/null +++ b/platform-src/client/modules/builder-protocol.ts @@ -0,0 +1,39 @@ +import type { Joined, PlatformEvent, PlatformEventMap } from "./builder.events.types.js"; + +export const eventNames = ["update_model", "directive", "queue_update", "task_update", "image_ready"] as const; +export const errorCodes = ["invalid_room", "invalid_cursor", "access_denied", "subscription_limit", "resync_required", "stream_unavailable"] as const; +export const appPattern = /^[a-f0-9]{24}$/; +export const roomFor = (appId: string) => `/apps/${appId}`; + +export function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid frame"); + return value as Record; +} +export function string(value: unknown): string { + if (typeof value !== "string" || !value) throw new Error("Invalid string"); + return value; +} +export function appFromRoom(value: unknown): string | undefined { + return typeof value === "string" && /^\/apps\/[a-f0-9]{24}$/.test(value) ? value.slice(6) : undefined; +} +export function eventApp(type: keyof PlatformEventMap, raw: unknown): string | undefined { + const frame = object(raw); + return type === "queue_update" + ? typeof frame.app_id === "string" && appPattern.test(frame.app_id) ? frame.app_id : undefined + : appFromRoom(frame.room); +} +export function decode(type: keyof PlatformEventMap, appId: string, raw: unknown): PlatformEvent { + const frame = object(raw); + const seq = string(frame.seq); + const wrapped = type === "update_model" || type === "task_update" || type === "image_ready"; + const { seq: _, ...flat } = frame; + const data = wrapped ? object(JSON.parse(string(frame.data))) : flat; + // Payload schemas are owned by the service; only decode/validate the transport envelope here. + return { type, appId, seq, data } as PlatformEvent; +} +export function decodeJoined(raw: unknown): Joined { + const frame = object(raw); + if (!appFromRoom(frame.room) || !Number.isInteger(frame.max_entries) || !Number.isInteger(frame.inactivity_expiry_seconds)) throw new Error("Invalid boundary"); + string(frame.seq); + return frame as unknown as Joined; +} diff --git a/platform-src/client/modules/builder-socket.ts b/platform-src/client/modules/builder-socket.ts new file mode 100644 index 00000000..5f929a0d --- /dev/null +++ b/platform-src/client/modules/builder-socket.ts @@ -0,0 +1,185 @@ +import { io, type Socket } from "socket.io-client"; +import { PlatformSocketError } from "../errors.js"; +import { appFromRoom, appPattern, decode, decodeJoined, errorCodes, eventApp, eventNames, object, roomFor } 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"; + +/** @internal */ +export class BuilderSocket implements BuilderSession { + private readonly socket: Socket; + private readonly subscriptions = new Map(); + private readonly options: PlatformClientOptions & BuilderInitOptions; + private closed = false; + private needsFreshConnection = false; + private generation = 0; + private authAttempt = 0; + private cancelAuth?: () => void; + private connecting?: Promise; + private resolveConnect?: () => void; + private rejectConnect?: (error: PlatformSocketError) => void; + + constructor(config: PlatformClientOptions, options: BuilderInitOptions) { + this.options = { ...config, onError: options.onError }; + this.socket = io(`${config.serverUrl}/partner`, { + path: "/ws-whitelabel/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", () => { + const generation = ++this.generation; + this.needsFreshConnection = false; + for (const subscription of this.subscriptions.values()) this.join(subscription, generation); + this.resolveConnect?.(); + this.clearConnecting(); + }); + this.socket.on("disconnect", (reason) => { + ++this.generation; + ++this.authAttempt; + if (reason === "io server disconnect") this.connectionError(new PlatformSocketError("connection_failed")); + }); + this.socket.on("connect_error", (error) => { + this.connectionError(new PlatformSocketError((error as Error & { data?: { code?: string } }).data?.code === "connection_denied" ? "connection_denied" : "connection_failed")); + }); + this.socket.io.on("reconnect_failed", () => this.connectionError(new PlatformSocketError("connection_failed"))); + this.socket.on("joined", (raw: unknown) => { + try { + const joined = decodeJoined(raw); + this.subscriptions.get(appFromRoom(joined.room)!)?.joined(joined); + } catch { this.protocolError(raw); } + }); + this.socket.on("error", (raw: unknown) => this.serverError(raw)); + for (const type of eventNames) this.socket.on(type, (raw: unknown) => { + try { + const appId = eventApp(type, raw); + if (!appId) throw new Error("Invalid app"); + this.subscriptions.get(appId)?.event(decode(type, appId, raw)); + } catch { this.protocolError(raw); } + }); + } + + /** Connect using a freshly obtained token. Resolves on CONNECT, not on app replay completion. + * Unexpected transport loss retries up to five times and rejoins active subscriptions. + * Call again after addressing a connection/auth failure; 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.connecting) return this.connecting; + const promise = new Promise((resolve, reject) => { + this.resolveConnect = resolve; + this.rejectConnect = reject; + }); + this.connecting = promise; + this.socket.connect(); + return promise; + } + + /** Subscribe before or after connecting. One subscription per app, maximum eight. + * Events and boundary callbacks are awaited in order per app. Failed application, + * invalid frames or server errors end the subscription without advancing its cursor. + */ + subscribe(appId: string, options: SubscriptionOptions): PlatformSubscription { + if (this.closed) throw new PlatformSocketError("client_closed"); + if (!appPattern.test(appId)) throw new TypeError("appId must be 24 lowercase hexadecimal characters"); + if (options.afterSeq !== undefined && (typeof options.afterSeq !== "string" || !options.afterSeq)) throw new TypeError("afterSeq must be a nonempty opaque cursor"); + if (this.subscriptions.has(appId)) throw new TypeError("An app may only have one subscription per builder session"); + if (this.subscriptions.size >= 8) throw new PlatformSocketError("subscription_limit", appId); + const subscription = new Subscription(appId, { ...options }, () => { + this.subscriptions.delete(appId); + this.needsFreshConnection = true; + if (this.socket.connected) this.socket.emit("leave", roomFor(appId)); + }); + this.subscriptions.set(appId, subscription); + if (this.socket.connected && this.needsFreshConnection) { + // Leave has no acknowledgement; a new transport fences late events from retired streams. + this.socket.disconnect(); + void this.connect().catch(() => {}); // Connection errors are delivered through onError. + } else if (this.socket.connected) { + this.join(subscription, this.generation); + } + return subscription; + } + + /** Stop delivery, cancel reconnection and release all listeners. Idempotent and terminal. */ + close(): void { + if (this.closed) return; + this.closed = true; + ++this.authAttempt; + this.cancelAuth?.(); + ++this.generation; + for (const subscription of this.subscriptions.values()) subscription.unsubscribe(); + this.rejectConnect?.(new PlatformSocketError("client_closed")); + this.clearConnecting(); + this.socket.removeAllListeners(); + this.socket.io.removeAllListeners(); + this.socket.disconnect(); + } + + private join(subscription: Subscription, generation: number): void { + subscription.join((cursor) => { + if (this.socket.connected && generation === this.generation) { + this.socket.emit("join", roomFor(subscription.appId), cursor === undefined ? {} : { after_seq: cursor }); + } + }); + } + + private async authenticate(callback: (auth: { token: string }) => void): Promise { + const attempt = ++this.authAttempt; + this.cancelAuth?.(); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Token timeout")), 20000); + this.cancelAuth = () => { clearTimeout(timer); reject(new Error("Cancelled")); }; + }); + try { + const token = await Promise.race([Promise.resolve().then(() => this.options.refreshToken()), timeout]); + if (this.closed || attempt !== this.authAttempt) return; + if (typeof token !== "string" || !token.trim()) throw new Error("Missing token"); + callback({ token }); + } catch { + if (this.closed || attempt !== this.authAttempt) return; + this.socket.disconnect(); + this.connectionError(new PlatformSocketError("token_unavailable")); + } finally { + clearTimeout(timer); + if (attempt === this.authAttempt) this.cancelAuth = undefined; + } + } + + private clearConnecting(): void { + this.connecting = undefined; + this.resolveConnect = undefined; + this.rejectConnect = undefined; + } + + private connectionError(error: PlatformSocketError): void { + this.rejectConnect?.(error); + this.clearConnecting(); + if (!this.closed) notify(this.options.onError, error); + } + + private serverError(raw: unknown): void { + try { + const frame = object(raw); + const code = errorCodes.find((code) => code === frame.code); + if (!code) throw new Error("Unknown error"); + const appId = appFromRoom(frame.room); + if (appId) this.subscriptions.get(appId)?.fail(code); + else if (frame.room === null) this.connectionError(new PlatformSocketError(code)); + else throw new Error("Invalid room"); + } catch { this.protocolError(raw); } + } + + private protocolError(raw: unknown): void { + const frame = raw && typeof raw === "object" ? raw as Record : {}; + const appId = appFromRoom(frame.room) ?? (typeof frame.app_id === "string" && appPattern.test(frame.app_id) ? frame.app_id : undefined); + if (appId) this.subscriptions.get(appId)?.fail("protocol_error"); + else { + // Unknown routing means no app cursor can safely advance past this frame. + for (const subscription of [...this.subscriptions.values()]) subscription.fail("protocol_error"); + this.connectionError(new PlatformSocketError("protocol_error")); + } + } +} diff --git a/platform-src/client/modules/builder-subscription.ts b/platform-src/client/modules/builder-subscription.ts new file mode 100644 index 00000000..2e4d7935 --- /dev/null +++ b/platform-src/client/modules/builder-subscription.ts @@ -0,0 +1,63 @@ +import type { Joined, PlatformEvent } from "./builder.events.types.js"; +import { PlatformSocketError } from "../errors.js"; +import type { PlatformSocketErrorCode } from "../errors.types.js"; +import type { PlatformSubscription, SubscriptionOptions } from "./builder.types.js"; + +/** @internal */ +export function notify(callback: (error: PlatformSocketError) => void, error: PlatformSocketError): void { + try { callback(error); } catch { /* An error observer cannot interrupt other app subscriptions. */ } +} + +/** @internal */ +export class Subscription implements PlatformSubscription { + cursor: string | undefined; + active = true; + private ready = false; + private pending = 0; + private tail: Promise = Promise.resolve(); + + constructor(readonly appId: string, private options: SubscriptionOptions, private remove: () => void) { + this.cursor = options.afterSeq; + } + + enqueue(work: () => void | Promise): void { + if (!this.active) return; + if (this.pending >= 1000) { this.fail("resync_required"); return; } + this.pending++; + this.tail = this.tail.then(async () => { + if (this.active) await work(); + }).catch(() => this.fail("handler_failed")).finally(() => { this.pending--; }); + } + + join(send: (cursor?: string) => void): void { + this.enqueue(() => { this.ready = false; send(this.cursor); }); + } + + event(event: PlatformEvent): void { + this.enqueue(async () => { + // A fresh subscription starts at joined, not at any old in-flight room events. + if ((!this.ready && this.cursor === undefined) || event.seq === this.cursor) return; + await this.options.onEvent(event); + if (this.active) this.cursor = event.seq; + }); + } + + joined(joined: Joined): void { + this.enqueue(async () => { + await this.options.onJoined?.(joined); + if (this.active) { this.cursor = joined.seq; this.ready = true; } + }); + } + + fail(code: PlatformSocketErrorCode): void { + if (!this.active) return; + this.unsubscribe(); + notify(this.options.onError, new PlatformSocketError(code, this.appId)); + } + + unsubscribe(): void { + if (!this.active) return; + this.active = false; + this.remove(); + } +} diff --git a/platform-src/client/modules/builder.events.types.ts b/platform-src/client/modules/builder.events.types.ts new file mode 100644 index 00000000..8a46dd14 --- /dev/null +++ b/platform-src/client/modules/builder.events.types.ts @@ -0,0 +1,320 @@ +/** A reviewed file, execution, or entity activity summary. */ +export interface ToolDisplayProjection { + /** Changed file paths for a file operation. Source bodies and diffs are never included. */ + file_paths?: string[]; + /** Whether a file write intentionally used empty content. */ + content_empty?: boolean; + /** Reviewed execution activity summary. Commands and execution output are never included. */ + summary?: string; + /** Whether a reviewed execution action changed entity data. */ + writes_entities?: boolean; + /** Entity type affected by an entity operation. Records and query values are never included. */ + entity_name?: string; + /** Number of records affected when supplied by the producer. */ + record_count?: number; +} + +/** A reviewed selectable answer to a builder question. */ +export interface ToolQuestionOption { + /** Visible option label. */ + label: string; +} + +/** A reviewed builder question. Image/HTML source and private design context are excluded. */ +export interface ToolQuestion { + /** Visible question text. */ + question?: string; + /** Existing question category. */ + type?: string; + /** Optional visible supporting text. */ + description?: string; + /** Whether more than one answer may be selected. */ + multi_select?: boolean; + /** Optional existing plan section identifier. */ + covers?: string; + /** Reviewed selectable options. */ + options?: ToolQuestionOption[]; +} + +/** Reviewed question arguments, serialized in `ToolCall.arguments_string`. */ +export interface ToolQuestionArguments { + /** Questions presented by a clarifying-question tool. */ + questions: ToolQuestion[]; +} + +/** A requested secret field. The secret value is never sent over the socket. */ +export interface ToolSecretField { + /** Requested secret name. */ + secretName: string; + /** Optional explanation of where to obtain it. */ + description?: string; +} + +/** Reviewed secret-form arguments, serialized in `ToolCall.arguments_string`. */ +export interface ToolSecretArguments { + /** Requested secret fields. */ + secrets_schema: ToolSecretField[]; +} + +/** A reviewed package operation. Versions and package-manager output are excluded. */ +export interface ToolPackageOperation { + /** Package name. */ + name: string; + /** Requested package operation. */ + action?: string; +} + +/** Reviewed package arguments, serialized in `ToolCall.arguments_string`. */ +export interface ToolPackageArguments { + /** Requested package operations. */ + packages: ToolPackageOperation[]; +} + +/** A reviewed add-only builder plan update. */ +export interface ToolPlanUpdate { + /** Existing update action. */ + action?: string; + /** Plan section key. */ + section?: string; + /** Optional user-facing section label. */ + section_label?: string; + /** Reviewed plan point. */ + text?: string; +} + +/** Reviewed plan arguments, serialized in `ToolCall.arguments_string`. */ +export interface ToolPlanArguments { + /** Plan updates in their emitted order. */ + updates?: ToolPlanUpdate[]; + /** Base plan sections the builder considers sufficiently specified. */ + sections_with_enough?: string[]; +} + +/** Reviewed generated-media arguments, serialized in `ToolCall.arguments_string`. */ +export interface ToolMediaArguments { + /** Visible media label. */ + label?: string; + /** Requested image or video aspect ratio. */ + aspect_ratio?: string; +} + +/** Reviewed state of generated media. Raw generation prompts and IDs are never included. */ +export interface ToolMediaResult { + /** Existing placeholder URL, used to associate an `image_ready` event with this tool. */ + placeholder_url: string; + /** Current media-generation status. */ + status: "pending" | "completed" | "failed"; + /** Approved generated asset URL, once available. */ + image_url: string | null; +} + +/** A reviewed answer to a clarifying question. Secret-form input is never included. */ +export interface ToolQuestionAnswer { + /** Zero-based question index. */ + question_index?: number; + /** Labels selected by the user. */ + selected_labels?: string[]; + /** User-provided free-text answer. Structural filtering does not redact prose. */ + custom_text?: string; +} + +/** Reviewed clarifying-question input. */ +export interface ToolQuestionInput { + /** Answers supplied to the question card. */ + answers: ToolQuestionAnswer[]; +} + +/** Fixed reviewed completion text. Raw tool results and errors are never included. */ +export type ToolOutcome = + | "Secret configuration completed." + | "Package installation completed." + | "Plan updated."; + +/** A fixed reviewed outcome or reviewed generated-media state. */ +export type ToolResult = ToolOutcome | ToolMediaResult; + +/** Public progress of an existing builder tool. */ +export interface ToolCall { + /** Stable tool call identifier, when included in the update. */ + id?: string; + /** Tool name displayed by the builder. */ + name?: string; + /** Current execution state. */ + status?: "running" | "success" | "error" | "stopped" | "waiting_for_user_input"; + /** Whether the tool needs a user response through the partner backend. */ + requires_user_input?: boolean; + /** Whether the builder auto-approved the reviewed operation. */ + auto_approved?: boolean | null; + /** Whether the reviewed mutation was applied. */ + mutation_applied?: boolean | null; + /** Existing serialized interaction category; no raw interaction payload. */ + waiting_on?: { + /** Kind of response expected. */ + kind?: "approval" | "choice" | "input" | null; + } | null; + /** + * JSON containing one reviewed argument shape: ToolQuestionArguments, + * ToolSecretArguments, ToolPackageArguments, ToolPlanArguments, or ToolMediaArguments. + * It is omitted for all other tools and malformed/partial streaming arguments. + */ + arguments_string?: string; + /** Reviewed activity metadata for file, execution, or entity tools. */ + display_projection?: ToolDisplayProjection; + /** Reviewed clarifying-question answers only. */ + user_input?: ToolQuestionInput; + /** Fixed reviewed success outcome or generated-media state only. */ + results?: ToolResult; +} + +/** Public message replacement. Omitted properties are not synthesized by the SDK. */ +export interface ChatMessage { + /** Existing message identifier; replace a message with the same identifier. */ + id?: string; + /** Public message author category. System messages are never delivered. */ + role?: "user" | "assistant"; + /** Generated or user-authored text. Structural filtering is not prose redaction. */ + content?: string | null; + /** Attached file URLs. */ + file_urls?: string[] | null; + /** Public tool progress with optional reviewed interaction details. */ + tool_calls?: ToolCall[] | null; + /** Message timestamp, without author identity. */ + metadata?: { + /** Existing timestamp string. */ + created_date?: string | null; + } | null; + /** Existing checkpoint reference; mutations remain on the partner backend. */ + checkpoint_id?: string | null; +} + +/** Partial app update. Omitted keys mean unchanged; explicit null means clear. */ +export interface AppUpdate { + /** Public builder state, without error diagnostics or billing context. */ + status?: { + /** Current builder state. */ + state?: "ready" | "processing" | "error"; + /** Existing state timestamp. */ + last_updated_date?: string | null; + } | null; + /** Whole-message replacement by identifier, not a recursive message patch. */ + _last_msg?: ChatMessage | null; + /** Conversation containing the replacement message. */ + _last_msg_conversation_id?: string | null; + /** Existing branch scope, if supplied by the producer. */ + _scope_branch_id?: string | null; + /** Whether to reload the app preview. */ + sandbox_should_reload?: boolean | null; + /** Existing preview navigation target. */ + navigate_preview_to?: string | null; + /** Existing forced preview navigation target. */ + navigate_preview_force_to?: string | null; +} + +/** Public queued builder request. */ +export interface QueueItem { + /** Stable queue item identifier. */ + id: string; + /** User-authored request text. */ + content: string; + /** Attached file URLs. */ + file_urls?: string[] | null; + /** Existing creation timestamp. */ + created_at: string; + /** Existing branch scope. */ + branch_id?: string | null; +} + +/** Full public queue snapshot, replacing the previous queue. */ +export interface QueueUpdate { + /** App owning this queue. */ + app_id: string; + /** Existing branch scope. */ + branch_id?: string | null; + /** Current pending items. */ + items: QueueItem[]; + /** Whether queue processing is paused. */ + is_paused: boolean; + /** Identifier of the item just processed, when supplied. */ + processed_item_id?: string | null; +} + +/** Public tool task progress. */ +export interface TaskUpdate { + /** Existing task lifecycle event. */ + event_type: "task_started" | "task_progress" | "task_completed" | "task_failed" | "task_cancelled"; + /** Associated tool call. */ + tool_call_id?: string | null; + /** Associated chat message. */ + message_id?: string | null; + /** Existing branch scope. */ + branch_id?: string | null; + /** Numeric progress only; diagnostic/free-text messages are withheld. */ + progress?: { + /** Completed work units. */ + current?: number | null; + /** Total work units, when known. */ + total?: number | null; + /** Producer-supplied percentage. */ + percentage?: number | null; + } | null; +} + +/** Placeholder resolution or image-generation completion. */ +export interface ImageReady { + /** Placeholder being resolved. */ + placeholder_url: string; + /** Existing generation state. */ + status: "pending" | "completed" | "failed"; + /** Resolved image URL, or null when unavailable. */ + image_url?: string | null; +} + +/** Invalidation notice; fetch current state through the partner backend. */ +export interface Directive { + /** Canonical app room. */ + room: string; + /** Public invalidation category. */ + type: "conversation_changed" | "app_files_changed"; + /** Existing branch scope. */ + branch_id?: string | null; +} + +/** Mapping of wire event names to decoded public payloads. */ +export interface PlatformEventMap { + /** Partial builder/app update. */ + update_model: AppUpdate; + /** Conversation or file invalidation. */ + directive: Directive; + /** Full queue snapshot. */ + queue_update: QueueUpdate; + /** Numeric tool progress. */ + task_update: TaskUpdate; + /** Image placeholder resolution. */ + image_ready: ImageReady; +} + +/** Ordered delivery with decoded data and the original event name and cursor. */ +export type PlatformEvent = { + [K in keyof PlatformEventMap]: { + /** Original socket event name; narrows the payload type. */ + type: K; + /** Authorized app receiving this event. */ + appId: string; + /** Opaque replay cursor. Never parse, compare or increment it. */ + seq: string; + /** Decoded payload; existing field names and omission/null semantics are retained. */ + data: PlatformEventMap[K]; + } +}[keyof PlatformEventMap]; + +/** Server replay boundary, delivered after all retained events through that boundary. */ +export interface Joined { + /** Canonical app room. */ + room: string; + /** Opaque boundary cursor; not an initial app snapshot. */ + seq: string; + /** Server retention limit (currently 2,000 events per app). */ + max_entries: number; + /** Server inactivity expiry (currently 3,600 seconds). */ + inactivity_expiry_seconds: number; +} diff --git a/platform-src/client/modules/builder.ts b/platform-src/client/modules/builder.ts new file mode 100644 index 00000000..c05f7527 --- /dev/null +++ b/platform-src/client/modules/builder.ts @@ -0,0 +1,10 @@ +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 { + return { + init(options) { return new BuilderSocket(config, options); }, + }; +} diff --git a/platform-src/client/modules/builder.types.ts b/platform-src/client/modules/builder.types.ts new file mode 100644 index 00000000..db8c6747 --- /dev/null +++ b/platform-src/client/modules/builder.types.ts @@ -0,0 +1,53 @@ +import type { Joined, PlatformEvent } from "./builder.events.types.js"; +import type { PlatformSocketError } from "../errors.js"; + +/** Options for an independent builder socket session. */ +export interface BuilderInitOptions { + /** Connection-level errors, sanitized to exclude tokens and server exception text. */ + onError: (error: PlatformSocketError) => void; +} + +/** Lazy builder module; no socket exists until init is called. */ +export interface BuilderModule { + /** Create an independent session without connecting. Call connect on the returned session. */ + init(options: BuilderInitOptions): BuilderSession; +} + +/** One builder socket session. Owns its subscriptions and connection lifecycle. */ +export interface BuilderSession { + /** Connect with a refreshed browser token; resolves on CONNECT, before app replay completes. + * Transport loss retries five times and rejoins active subscriptions from applied cursors. + * Call again after fixing connection/auth failures. Concurrent calls share one attempt. + */ + connect(): Promise; + /** Subscribe before or after connecting. One subscription per app, up to eight per session. + * Callbacks run serially per app; errors stop delivery without advancing the cursor. + */ + subscribe(appId: string, options: SubscriptionOptions): PlatformSubscription; + /** Stop this session, its subscriptions and reconnection. Idempotent and terminal. */ + close(): void; +} + +/** One app subscription; at most eight may be active per builder session. */ +export interface SubscriptionOptions { + /** Last successfully applied cursor for this app; omit for a fresh live boundary. */ + afterSeq?: string; + /** Apply each event. Delivery is serial per app; rejection pauses this subscription. */ + onEvent: (event: PlatformEvent) => void | Promise; + /** Handle subscription errors. Reconcile on resync_required; never silently reset a cursor. */ + onError: (error: PlatformSocketError) => void; + /** Optional replay-complete notification, awaited before advancing to the boundary cursor. */ + onJoined?: (joined: Joined) => void | Promise; +} + +/** Subscription lifetime and last successfully applied cursor. */ +export interface PlatformSubscription { + /** App identifier. */ + readonly appId: string; + /** Last applied event/boundary cursor; persist alongside the state it describes. */ + readonly cursor: string | undefined; + /** True while subscribed; false after an error or explicit unsubscribe. */ + readonly active: boolean; + /** Stop this app's delivery and release its subscription slot. Idempotent. */ + unsubscribe(): void; +} diff --git a/tests/package/platform-client.test.mjs b/tests/package/platform-client.test.mjs new file mode 100644 index 00000000..876ded81 --- /dev/null +++ b/tests/package/platform-client.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const scratch = mkdtempSync(path.join(tmpdir(), "base44-sdk-package-")); +after(() => rmSync(scratch, { recursive: true, force: true })); +const run = (command, args, cwd = scratch) => execFileSync(command, args, { cwd, encoding: "utf8", stdio: "pipe" }); +const [packed] = JSON.parse(run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", scratch], root)); +const installed = path.join(scratch, "node_modules/@base44/sdk"); +mkdirSync(installed, { recursive: true }); +run("tar", ["-xzf", path.join(scratch, packed.filename), "--strip-components=1", "-C", installed]); +const manifest = JSON.parse(readFileSync(path.join(installed, "package.json"), "utf8")); +// Use the already-installed, lockfile-pinned dependencies; never resolve or install in this fixture. +for (const name of Object.keys(manifest.dependencies)) { + const destination = path.join(scratch, "node_modules", name); + mkdirSync(path.dirname(destination), { recursive: true }); + symlinkSync(path.join(root, "node_modules", name), destination, "dir"); +} + +function evaluate(code) { + return run(process.execPath, ["--input-type=module", "--eval", code]); +} + +test("the tarball contains both compiled entry points and no source or test trees", () => { + const files = new Set(packed.files.map(file => file.path)); + for (const file of ["dist/index.js", "dist/index.d.ts", "dist/platform/client/index.js", "dist/platform/client/index.d.ts"]) assert.ok(files.has(file), file); + assert.equal([...files].some(file => /^(src|platform-src|tests|examples)\//.test(file)), false); +}); + +test("runtime, platform and legacy deep imports resolve from the installed package", () => { + evaluate(` + import assert from "node:assert/strict"; + import * as runtime from "@base44/sdk"; + import { Base44PlatformClient } from "@base44/sdk/platform/client"; + import { createAxiosClient } from "@base44/sdk/dist/utils/axios-client"; + import { createAxiosClient as explicit } from "@base44/sdk/dist/utils/axios-client.js"; + assert.equal(typeof runtime.createClient, "function"); + assert.equal("Base44PlatformClient" in runtime, false); + assert.equal(createAxiosClient, explicit); + const client = new Base44PlatformClient({ serverUrl: "https://example.test", refreshToken: () => "browser-token" }); + assert.equal(typeof client.builder.init, "function"); + const builder = client.builder.init({ onError() {} }); + builder.close(); + `); +}); + +for (const [entry, forbidden, blockedEntry] of [ + ["@base44/sdk", "/dist/platform/", "@base44/sdk/platform/client"], + ["@base44/sdk/platform/client", "/dist/(?!platform/).*", "@base44/sdk"], +]) { + test(`${entry} does not load the other SDK`, () => { + const loader = path.join(scratch, "isolation-loader.mjs"); + writeFileSync(loader, `export async function load(url, context, next) { + if (url.startsWith(${JSON.stringify(pathToFileURL(realpathSync(installed)).href + "/")}) && new RegExp(${JSON.stringify(forbidden)}).test(url)) throw Error("Unexpected SDK dependency: " + url); + return next(url, context); + }`); + run(process.execPath, ["--experimental-loader", loader, "--input-type=module", "--eval", ` + import assert from "node:assert/strict"; + await import(${JSON.stringify(entry)}); + await assert.rejects(import(${JSON.stringify(blockedEntry)}), /Unexpected SDK dependency/); + `]); + }); +} + +const consumer = ` +import { createClient, type Base44Client } from "@base44/sdk"; +import { Base44PlatformClient, type PlatformEvent } from "@base44/sdk/platform/client"; +import { createAxiosClient } from "@base44/sdk/dist/utils/axios-client"; +import { createAxiosClient as explicit } from "@base44/sdk/dist/utils/axios-client.js"; +const runtime: Base44Client = createClient({ appId: "app" }); +const platform = new Base44PlatformClient({ serverUrl: "https://example.test", refreshToken: async () => "token" }); +platform.builder.init({ onError() {} }).subscribe("a".repeat(24), { + onEvent(event: PlatformEvent) { + if (event.type === "update_model") { + const text: string | null | undefined = event.data._last_msg?.content; + void text; + } + }, + onError() {}, +}); +// @ts-expect-error No browser API key. +new Base44PlatformClient({ apiKey: "private" }); +// @ts-expect-error No write channel. +platform.send("write_file", {}); +void [runtime, createAxiosClient, explicit]; +`; +writeFileSync(path.join(scratch, "consumer.mts"), consumer); +for (const [module, moduleResolution] of [["NodeNext", "NodeNext"], ["ESNext", "Bundler"], ["ESNext", "Node"]]) { + test(`published declarations resolve with ${moduleResolution}`, () => { + run(process.execPath, [path.join(root, "node_modules/typescript/bin/tsc"), "--noEmit", "--strict", "--skipLibCheck", "--target", "ES2022", "--module", module, "--moduleResolution", moduleResolution, "consumer.mts"]); + }); +} diff --git a/tests/types/platform-client.types.ts b/tests/types/platform-client.types.ts new file mode 100644 index 00000000..1b99530d --- /dev/null +++ b/tests/types/platform-client.types.ts @@ -0,0 +1,49 @@ +import { Base44PlatformClient, type PlatformEvent, type ToolCall } from "@base44/sdk/platform/client"; +const client = new Base44PlatformClient({ serverUrl: "https://example.test", refreshToken: async () => "token" }); +const builder = client.builder.init({ onError: error => { void error.code; } }); +const subscription = builder.subscribe("a".repeat(24), { + onEvent(event: PlatformEvent) { + if (event.type === "update_model") { + const content: string | null | undefined = event.data._last_msg?.content; + void content; + // @ts-expect-error Private billing fields are not a public contract. + void event.data.credits; + } + if (event.type === "image_ready") { + const status: "pending" | "completed" | "failed" = event.data.status; + void status; + } + // @ts-expect-error Payload must be narrowed by event.type. + void event.data.items; + }, + onError: error => { void error.appId; }, +}); +// @ts-expect-error No browser mutation channel. +client.send("write_file", {}); +// @ts-expect-error Cursor is read-only. +subscription.cursor = "invented"; +// @ts-expect-error API keys are not browser credentials. +new Base44PlatformClient({ apiKey: "private" }); + +// @ts-expect-error Socket methods belong to the builder session. +client.connect(); +// @ts-expect-error Retired callback name is not accepted. +new Base44PlatformClient({ serverUrl: "https://example.test", getToken: async () => "token" }); + +const tool: ToolCall = { + display_projection: { file_paths: ["src/App.tsx"] }, + arguments_string: JSON.stringify({ questions: [{ question: "Which layout?", options: [{ label: "Cards" }] }] }), + user_input: { answers: [{ question_index: 0, selected_labels: ["Cards"] }] }, + results: "Plan updated.", +}; +void tool; +const generatedMedia: ToolCall = { + results: { + placeholder_url: "/__generating__/hero.png", + status: "completed", + image_url: "https://images.example/hero.png", + }, +}; +void generatedMedia; +// @ts-expect-error Raw command text is not part of reviewed display metadata. +void tool.display_projection?.command; diff --git a/tests/unit/platform-client/client.test.ts b/tests/unit/platform-client/client.test.ts new file mode 100644 index 00000000..e7dd18b5 --- /dev/null +++ b/tests/unit/platform-client/client.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { Base44PlatformClient, type BuilderSession } from "../../../platform-src/client/index.js"; + +const fake = vi.hoisted(() => { + const handlers: Record void> = {}; + const managerHandlers: Record void> = {}; + const 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((name, handler) => { managerHandlers[name] = handler; }), removeAllListeners: vi.fn() }, + }; + return { handlers, managerHandlers, socket, io: vi.fn(() => socket) }; +}); +vi.mock("socket.io-client", () => ({ io: fake.io })); + +const app = "a".repeat(24), other = "b".repeat(24), room = `/apps/${app}`; +const clients: BuilderSession[] = []; +const settle = async () => { for (let i = 0; i < 100; i++) await Promise.resolve(); }; +const joined = (seq = "boundary") => fake.handlers.joined({ room, seq, max_entries: 2000, inactivity_expiry_seconds: 3600 }); +const update = (seq: string, data: unknown = { status: null }) => fake.handlers.update_model({ room, seq, data: JSON.stringify(data) }); +function setup(refreshToken = vi.fn(async () => "browser-token")) { + const onError = vi.fn(); + const platform = new Base44PlatformClient({ serverUrl: "https://api.example.test", refreshToken }); + const client = platform.builder.init({ onError }); + clients.push(client); + return { client, refreshToken, onError }; +} +async function connected(client: BuilderSession) { + const ready = client.connect(); fake.socket.connected = true; fake.handlers.connect(); await ready; await settle(); +} +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 refresh tokens", () => { + const refreshToken = vi.fn(async () => "token"); + const platform = new Base44PlatformClient({ serverUrl: "https://api.example.test", refreshToken }); + expect(fake.io).not.toHaveBeenCalled(); + expect(refreshToken).not.toHaveBeenCalled(); + const first = platform.builder.init({ onError: vi.fn() }); + const second = platform.builder.init({ onError: vi.fn() }); + clients.push(first, second); + expect(first).not.toBe(second); + expect(fake.io).toHaveBeenCalledTimes(2); + expect(fake.socket.connect).not.toHaveBeenCalled(); + expect(refreshToken).not.toHaveBeenCalled(); + }); + test("closing one initialized builder does not close another or the root module", () => { + const platform = new Base44PlatformClient({ serverUrl: "https://api.example.test", refreshToken: async () => "token" }); + 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); + first.close(); + expect(firstSocket.disconnect).toHaveBeenCalledOnce(); + expect(secondSocket.disconnect).not.toHaveBeenCalled(); + const third = platform.builder.init({ onError: vi.fn() }); + clients.push(third); + expect(third).not.toBe(first); + }); + + test("uses only CONNECT auth and the fixed namespace/path, with fresh credentials each attempt", async () => { + const { client, refreshToken } = setup(); + expect(fake.socket.connect).not.toHaveBeenCalled(); + const [url, options] = fake.io.mock.calls[0] as unknown as [string, any]; + expect(url).toBe("https://api.example.test/partner"); + expect(options).toMatchObject({ path: "/ws-whitelabel/socket.io/", transports: ["websocket"], autoConnect: false, forceNew: true, reconnectionAttempts: 5 }); + expect(options.query).toBeUndefined(); + const callback = vi.fn(); options.auth(callback); await settle(); + expect(callback).toHaveBeenLastCalledWith({ token: "browser-token" }); + refreshToken.mockResolvedValue("rotated"); options.auth(callback); await settle(); + expect(callback).toHaveBeenLastCalledWith({ token: "rotated" }); + await connected(client); + }); + + test("shares concurrent connects and rejects a sanitized denial", async () => { + const { client, onError } = setup(); + const a = client.connect(), b = client.connect(); expect(a).toBe(b); + const rejection = expect(a).rejects.toMatchObject({ code: "connection_denied" }); + fake.handlers.connect_error({ message: "secret token", data: { code: "connection_denied" } }); + await rejection; + expect(onError.mock.calls[0][0].message).not.toContain("secret"); + await connected(client); + }); + + test("token failures reject without handing credentials or provider errors to Socket.IO", async () => { + const { client, onError } = setup(vi.fn(async () => { throw new Error("secret"); })); + const rejection = expect(client.connect()).rejects.toMatchObject({ code: "token_unavailable" }); + const callback = vi.fn(); (fake.io.mock.calls[0] as any)[1].auth(callback); + await rejection; + expect(callback).not.toHaveBeenCalled(); + expect(onError.mock.calls[0][0].message).not.toContain("secret"); + }); + + + test("token retrieval has a bounded timeout", async () => { + vi.useFakeTimers(); + const { client } = setup(vi.fn(() => new Promise(() => {}))); + const failure = expect(client.connect()).rejects.toMatchObject({ code: "token_unavailable" }); + const callback = vi.fn(); (fake.io.mock.calls[0] as any)[1].auth(callback); + await vi.advanceTimersByTimeAsync(20000); await failure; + expect(callback).not.toHaveBeenCalled(); + }); + + test("late token results 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("fresh subscription waits for joined; preserves null, omission and message replacement", async () => { + const { client } = setup(); const onEvent = vi.fn(); + const sub = client.subscribe(app, { onEvent, onError: vi.fn() }); + await connected(client); + expect(fake.socket.emit).toHaveBeenCalledWith("join", room, {}); + update("old"); joined(); update("one", { _last_msg: { id: "m", content: "hello" }, status: null }); + await settle(); + expect(onEvent).toHaveBeenCalledExactlyOnceWith({ type: "update_model", appId: app, seq: "one", data: { _last_msg: { id: "m", content: "hello" }, status: null } }); + expect(sub.cursor).toBe("one"); + }); + + test("resumed replay applies before joined and serializes async handlers", async () => { + const { client } = setup(); let finish!: () => void; + const onEvent = vi.fn(() => new Promise(r => { finish = r; })); const onJoined = vi.fn(); + const sub = client.subscribe(app, { afterSeq: "saved", onEvent, onJoined, onError: vi.fn() }); + await connected(client); update("replay"); joined("replay"); await settle(); + expect(sub.cursor).toBe("saved"); expect(onJoined).not.toHaveBeenCalled(); + finish(); await settle(); expect(sub.cursor).toBe("replay"); expect(onJoined).toHaveBeenCalledOnce(); + }); + + test("reconnect waits for in-flight application and rejoins with its resulting cursor", async () => { + const { client } = setup(); let finish!: () => void; + client.subscribe(app, { afterSeq: "saved", onEvent: () => new Promise(r => { finish = r; }), onError: vi.fn() }); + await connected(client); update("applied"); await settle(); + fake.socket.connected = false; fake.handlers.disconnect("transport close"); + fake.socket.connected = true; fake.handlers.connect(); await settle(); + expect(fake.socket.emit.mock.calls.filter(c => c[0] === "join")).toHaveLength(1); + finish(); await settle(); expect(fake.socket.emit).toHaveBeenLastCalledWith("join", room, { after_seq: "applied" }); + }); + + test("callback rejection stops delivery without advancing the cursor", async () => { + const { client } = setup(); const onError = vi.fn(); + const sub = client.subscribe(app, { afterSeq: "saved", onEvent: async () => { throw new Error("private"); }, onError }); + await connected(client); update("failed"); await settle(); + expect(sub.cursor).toBe("saved"); expect(sub.active).toBe(false); + expect(onError.mock.calls[0][0]).toMatchObject({ code: "handler_failed", appId: app }); + expect(fake.socket.emit).toHaveBeenLastCalledWith("leave", room); + }); + + test.each(["invalid_room", "invalid_cursor", "access_denied", "subscription_limit", "resync_required", "stream_unavailable"])("surfaces %s without silently resetting/retrying a subscription", async code => { + const { client } = setup(); const onError = vi.fn(); + const sub = client.subscribe(app, { afterSeq: "saved", onEvent: vi.fn(), onError }); + await connected(client); fake.handlers.error({ room, code }); + expect(sub.active).toBe(false); expect(sub.cursor).toBe("saved"); + expect(onError.mock.calls[0][0].code).toBe(code); + fake.handlers.connect(); await settle(); + expect(fake.socket.emit.mock.calls.filter(c => c[0] === "join")).toHaveLength(1); + }); + + test("routes all five event shapes and keeps app cursors isolated", async () => { + const { client } = setup(); const onEvent = vi.fn(), otherEvent = vi.fn(); + const sub = client.subscribe(app, { afterSeq: "saved", onEvent, onError: vi.fn() }); + const second = client.subscribe(other, { afterSeq: "other", onEvent: otherEvent, onError: vi.fn() }); + await connected(client); + update("1"); + fake.handlers.directive({ room, seq: "2", type: "conversation_changed", branch_id: "feature" }); + fake.handlers.queue_update({ app_id: app, seq: "3", items: [], is_paused: false }); + fake.handlers.task_update({ room, seq: "4", data: '{"event_type":"task_progress","progress":{"current":1}}' }); + fake.handlers.image_ready({ room, seq: "5", data: '{"placeholder_url":"placeholder","status":"completed","image_url":"image"}' }); + await settle(); expect(onEvent.mock.calls.map(c => c[0].type)).toEqual(["update_model", "directive", "queue_update", "task_update", "image_ready"]); + expect(onEvent.mock.calls[1][0].data).toEqual({ room, type: "conversation_changed", branch_id: "feature" }); + expect(sub.cursor).toBe("5"); expect(second.cursor).toBe("other"); expect(otherEvent).not.toHaveBeenCalled(); + }); + + test("duplicate last cursor is not applied twice", async () => { + const { client } = setup(); const onEvent = vi.fn(); + client.subscribe(app, { afterSeq: "saved", onEvent, onError: vi.fn() }); await connected(client); + update("one"); update("one"); await settle(); expect(onEvent).toHaveBeenCalledOnce(); + }); + + test("malformed JSON fails only the identified app without losing its cursor", async () => { + const { client } = setup(); const onError = vi.fn(); + const sub = client.subscribe(app, { afterSeq: "saved", onEvent: vi.fn(), onError }); + const second = client.subscribe(other, { onEvent: vi.fn(), onError: vi.fn() }); await connected(client); + fake.handlers.update_model({ room, seq: "bad", data: "{" }); + expect(sub.cursor).toBe("saved"); expect(sub.active).toBe(false); expect(second.active).toBe(true); + expect(onError.mock.calls[0][0].code).toBe("protocol_error"); + }); + + test("bounded pending delivery overflows explicitly", async () => { + const { client } = setup(); const onError = vi.fn(); + const sub = client.subscribe(app, { afterSeq: "saved", onEvent: () => new Promise(() => {}), onError }); + await connected(client); for (let i = 0; i <= 1000; i++) update(`event-${i}`); + expect(sub.active).toBe(false); expect(sub.cursor).toBe("saved"); expect(onError.mock.calls[0][0].code).toBe("resync_required"); + }); + + test("unsubscribe cancels queued work and close is terminal and idempotent", async () => { + const { client } = setup(); const onEvent = vi.fn(); + const sub = client.subscribe(app, { afterSeq: "saved", onEvent, onError: vi.fn() }); await connected(client); + update("late"); sub.unsubscribe(); sub.unsubscribe(); await settle(); expect(onEvent).not.toHaveBeenCalled(); + client.close(); client.close(); expect(fake.socket.disconnect).toHaveBeenCalledOnce(); + await expect(client.connect()).rejects.toMatchObject({ code: "client_closed" }); + expect(fake.socket.removeAllListeners).toHaveBeenCalledOnce(); + }); + + + test("re-subscribing fences late events with a new connection before joining", async () => { + const { client } = setup(); + const first = client.subscribe(app, { afterSeq: "saved", onEvent: vi.fn(), onError: vi.fn() }); + await connected(client); first.unsubscribe(); + fake.socket.disconnect.mockImplementationOnce(() => { + fake.socket.connected = false; + fake.handlers.disconnect("io client disconnect"); + }); + client.subscribe(app, { afterSeq: "saved", onEvent: vi.fn(), onError: vi.fn() }); + await settle(); + expect(fake.socket.disconnect).toHaveBeenCalledOnce(); + expect(fake.socket.emit.mock.calls.filter(c => c[0] === "join")).toHaveLength(1); + fake.socket.connected = true; fake.handlers.connect(); await settle(); + expect(fake.socket.emit).toHaveBeenLastCalledWith("join", room, { after_seq: "saved" }); + }); + + test("validates origins, app IDs, duplicate subscriptions and limits", () => { + for (const serverUrl of ["https://secret@example.test", "https://example.test?token=secret", "https://example.test/path", "ws://example.test"]) { + expect(() => new Base44PlatformClient({ serverUrl, refreshToken: () => "token" })).toThrow(TypeError); + } + const { client } = setup(); const options = { onEvent: vi.fn(), onError: vi.fn() }; + expect(() => client.subscribe("bad", options)).toThrow(TypeError); + client.subscribe(app, options); expect(() => client.subscribe(app, options)).toThrow(TypeError); + for (let i = 0; i < 7; i++) client.subscribe(String(i).repeat(24), options); + expect(() => client.subscribe(other, options)).toThrowError(expect.objectContaining({ code: "subscription_limit" })); + }); +}); diff --git a/tests/unit/platform-client/socket.test.js b/tests/unit/platform-client/socket.test.js new file mode 100644 index 00000000..72623ea6 --- /dev/null +++ b/tests/unit/platform-client/socket.test.js @@ -0,0 +1,63 @@ +import { createServer } from "node:http"; +import { WebSocketServer } from "ws"; +import { expect, test, vi } from "vitest"; +import { Base44PlatformClient } from "../../../platform-src/client/index.js"; + +// Minimal Engine.IO/Socket.IO peer exercises the real client without a new server dependency. +test("real Socket.IO handshake, app join, reconnect and replay", async () => { + const http = createServer(); + const server = new WebSocketServer({ server: http }); + const appId = "a".repeat(24), room = `/apps/${appId}`; + const handshakes = [], joins = [], peers = [], applied = [], errors = []; + let tokenCalls = 0; + const send = (peer, event, data) => peer.send(`42/partner,${JSON.stringify([event, data])}`); + const boundary = (peer, seq) => send(peer, "joined", { room, seq, max_entries: 2000, inactivity_expiry_seconds: 3600 }); + const update = (peer, seq) => send(peer, "update_model", { room, seq, data: '{"status":{"state":"ready"}}' }); + server.on("connection", (peer, request) => { + peers.push(peer); + const url = new URL(request.url, "http://localhost"); + handshakes.push({ url, token: undefined }); + const handshake = handshakes.at(-1); + peer.send(`0${JSON.stringify({ sid: String(peers.length), upgrades: [], pingInterval: 25000, pingTimeout: 20000, maxPayload: 1000000 })}`); + peer.on("message", bytes => { + const packet = bytes.toString(); + if (packet.startsWith("40/partner,")) { + handshake.token = JSON.parse(packet.slice("40/partner,".length)).token; + peer.send(`40/partner,${JSON.stringify({ sid: `namespace-${peers.length}` })}`); + } else if (packet.startsWith("42/partner,")) { + const [event, ...args] = JSON.parse(packet.slice("42/partner,".length)); + if (event !== "join") return; + joins.push(args); + if (joins.length === 1) { boundary(peer, "start"); update(peer, "one"); } + else { update(peer, "two"); boundary(peer, "two"); } + } + }); + }); + await new Promise(resolve => http.listen(0, "127.0.0.1", resolve)); + const client = new Base44PlatformClient({ + serverUrl: `http://127.0.0.1:${http.address().port}`, + refreshToken: async () => `browser-${++tokenCalls}`, + }); + const builder = client.builder.init({ onError: error => errors.push(error) }); + try { + const sub = builder.subscribe(appId, { onEvent: event => { applied.push(event); }, onError: error => errors.push(error) }); + await builder.connect(); + await vi.waitFor(() => expect(sub.cursor).toBe("one")); + peers[0].terminate(); + await vi.waitFor(() => expect(sub.cursor).toBe("two"), { timeout: 6000 }); + expect(joins).toEqual([[room, {}], [room, { after_seq: "one" }]]); + expect(applied.map(event => event.seq)).toEqual(["one", "two"]); + expect(handshakes.map(item => item.token)).toEqual(["browser-1", "browser-2"]); + for (const { url } of handshakes) { + expect(url.pathname).toBe("/ws-whitelabel/socket.io/"); + expect(url.searchParams.get("transport")).toBe("websocket"); + expect(url.searchParams.has("token")).toBe(false); + } + expect(errors).toEqual([]); + } finally { + builder.close(); + for (const peer of peers) peer.terminate(); + await new Promise(resolve => server.close(resolve)); + await new Promise(resolve => http.close(resolve)); + } +}); diff --git a/tsconfig.platform.json b/tsconfig.platform.json new file mode 100644 index 00000000..881c4886 --- /dev/null +++ b/tsconfig.platform.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "platform-src", + "outDir": "dist/platform" + }, + "include": [ + "platform-src/**/*.ts" + ] +} diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json index 16300acf..0658592e 100644 --- a/tsconfig.type-tests.json +++ b/tsconfig.type-tests.json @@ -1,8 +1,22 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "baseUrl": ".", + "paths": { + "@base44/sdk/platform/client": [ + "platform-src/client/index.ts" + ] + } }, - "include": ["src/**/*", "tests/types/**/*.ts"], - "exclude": ["node_modules", "dist"] + "include": [ + "src/**/*", + "tests/types/**/*.ts", + "platform-src/**/*.ts", + "examples/platform-client.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] } diff --git a/typedoc.platform-client.json b/typedoc.platform-client.json new file mode 100644 index 00000000..18aa0e1f --- /dev/null +++ b/typedoc.platform-client.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "./platform-src/client/index.ts" + ], + "tsconfig": "./tsconfig.platform.json", + "out": "docs/platform/client", + "plugin": [ + "typedoc-plugin-markdown" + ], + "excludePrivate": true, + "excludeProtected": true, + "excludeInternal": true, + "excludeExternals": true, + "readme": "none", + "validation": { + "notDocumented": true + }, + "requiredToBeDocumented": [ + "Class", + "Interface", + "Property", + "Method", + "Function", + "TypeAlias" + ], + "treatWarningsAsErrors": true +} diff --git a/writing-docs.md b/writing-docs.md index e3a6e38d..8e3bca8f 100644 --- a/writing-docs.md +++ b/writing-docs.md @@ -55,4 +55,8 @@ After generating and reviewing the docs, you can push them to the `base44/mintli 1. In the terminal, run `npm run push-docs -- --branch `. If the branch already exists, your changes are added to the ones already on the branch. Otherwise, the script creates a new branch with the chosen name. 1. Open the [docs repo](https://github.com/base44-dev/mintlify-docs) and created a PR for your branch. -1. Preview your docs using the [Mintlify dashboard](https://dashboard.mintlify.com/base44/base44?section=previews). \ No newline at end of file +1. Preview your docs using the [Mintlify dashboard](https://dashboard.mintlify.com/base44/base44?section=previews). +The browser platform entry point is documented separately. Add field-level JSDoc to +all exports in `platform-src/client`, update `platform-docs/client.md`, and run +`npm run docs:platform-client`. TypeDoc treats missing public documentation as an +error and emits its reference under `docs/platform/client`.