Skip to content
Draft
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
92 changes: 92 additions & 0 deletions .github/workflows/sync-asyncapi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Sync Platform SDK AsyncAPI

# Refreshes packages/platform/asyncapi.json from production and regenerates the event
# types from it. Changes go to one rolling PR; closing it without merging only skips
# that week, since the next run proposes the same changes again.

on:
schedule:
- cron: "0 6 * * 1" # Mondays at 06:00 UTC
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: sync-asyncapi
cancel-in-progress: false

env:
BRANCH: bot/platform-asyncapi-sync
ASYNCAPI_URL: https://app.base44.com/api/asyncapi.json

jobs:
sync:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: main

- name: Wix gateway proxy (mandatory)
uses: ./.github/actions/wix-gateway-proxy

- name: Use Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20.x"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Sync asyncapi.json and regenerate the event types
run: |
npm run sync:asyncapi -w @base44/platform -- "$ASYNCAPI_URL"
npm run gen:events -w @base44/platform

- name: Check for changes
id: changes
run: |
if git diff --quiet -- packages/platform/asyncapi.json packages/platform/src/modules/builder.events.generated.ts; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "✅ asyncapi.json and the event types are up to date"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Close a stale sync PR
if: steps.changes.outputs.changed == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')
if [ -n "$pr" ]; then gh pr close "$pr" --delete-branch --comment "main already matches production."; fi

- name: Build the docs and test the Platform SDK
if: steps.changes.outputs.changed == 'true'
run: |
npm run create-docs -w @base44/platform
npm test -w @base44/platform

- name: Update the sync PR
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add packages/platform/asyncapi.json packages/platform/src/modules/builder.events.generated.ts
git commit -m "chore(platform): sync asyncapi.json and the event types from production"
# Rebuilt from main every run, so the branch never drifts or conflicts.
git push --force origin "$BRANCH"

pr=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')
body="Refreshes \`packages/platform/asyncapi.json\` from $ASYNCAPI_URL and regenerates \`builder.events.generated.ts\`. The docs build and the Platform SDK tests passed on this change. Updated automatically every week."
if [ -z "$pr" ]; then
gh pr create --head "$BRANCH" --base main --title "Sync Platform SDK events from production" --body "$body"
fi
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.

69 changes: 69 additions & 0 deletions packages/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Changelog

## 0.2.0

**Breaking.** The client, its options and the event type names change. Runtime delivery of events
is unchanged.

### Client and session

- `new Base44PlatformClient(options)` is replaced by `createPlatformClient()`, which takes no
options. `serverUrl`, `getSessionToken` and the `PlatformClientOptions` type are removed.
- `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.
- 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 = new Base44PlatformClient({
serverUrl,
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 });
```

### Event types

The event types are generated from the service's AsyncAPI document and take the backend's model
names.

| 0.1.0 | 0.2.0 |
| --- | --- |
| `ChatMessage` | `Message` |
| `AppStatus` | `StatusObject` |
| `Snapshot` | `PlatformSnapshot` |
| `SnapshotQueue` | `QueueState` |
| `ToolGuardApproval` | `GuardApproval`, a union of one `*Approval` per guard |
| `ToolMediaResult` | `ImageResult` |
| `ToolQuestionArguments`, `ToolQuestion`, `ToolQuestionOption` | `QuestionArguments`, `Question`, `QuestionOption` |
| `ToolQuestionInput`, `ToolQuestionAnswer` | `QuestionInput`, `QuestionAnswer` |
| `ToolSecretArguments`, `ToolSecretField` | `SetSecretsArguments`, `SecretField` |
| `ToolPackageArguments`, `ToolPackageOperation` | `InstallPackageArguments`, `PackageOperation` |
| `ToolPlanArguments`, `ToolPlanUpdate` | `UpdatePlanArguments`, `PlanUpdate` |
| `ToolPrdArguments` | `PrdArguments` |
| `ToolMediaArguments` | `MediaArguments` |
| `ToolEntityMutationArguments` | `EntityChangeArguments` |

- `ToolCall` is a union of one `*Call` per tool, such as `AskClarifyingQuestionsCall` and
`WriteFileCall`. Check `name` to narrow it to that tool's `arguments` and `display`.
`ToolArguments` and `ToolDisplay` are removed.
- `BranchScoped` is removed. Each payload that belongs to a branch has its own `branch_id`.
- New: `SessionErrorCode` and `AppErrorCode` split `PlatformSocketErrorCode`, and every schema in
the protocol is exported, with `ServerEventMap` and `ClientMessageMap`.

## 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
Loading
Loading