Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions packages/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 27 additions & 21 deletions packages/platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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`,
Expand Down Expand Up @@ -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()`.
Expand Down Expand Up @@ -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. |
Expand Down
15 changes: 5 additions & 10 deletions packages/platform/examples/client.ts
Original file line number Diff line number Diff line change
@@ -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); },
});

Expand Down
2 changes: 1 addition & 1 deletion packages/platform/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"interfaces/builder": [
"interfaces/BuilderSession",
"interfaces/SocketSession",
"type-aliases/PlatformEvent",
"type-aliases/PlatformSnapshot"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"PlatformEvent",
"PlatformSnapshot",
"PlatformSocketError",
"SessionErrorCode"
"SessionErrorCode",
"SocketSession"
]
31 changes: 13 additions & 18 deletions packages/platform/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,34 @@
import type { PlatformClient, PlatformClientOptions } from "./client.types.js";
import type { PlatformClient } from "./client.types.js";
import { createBuilder } from "./modules/builder.js";

/**
* Creates a platform client.
*
* 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()) });
}
27 changes: 0 additions & 27 deletions packages/platform/src/client.types.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

/**
* The platform client.
*
Expand Down
6 changes: 4 additions & 2 deletions packages/platform/src/errors.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/**
Expand Down
4 changes: 2 additions & 2 deletions packages/platform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
12 changes: 12 additions & 0 deletions packages/platform/src/modules/builder-protocol.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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 };
}
Loading
Loading