diff --git a/packages/platform/CHANGELOG.md b/packages/platform/CHANGELOG.md index 67590b32..da668095 100644 --- a/packages/platform/CHANGELOG.md +++ b/packages/platform/CHANGELOG.md @@ -2,8 +2,8 @@ ## 0.2.0 -**Breaking.** The client, its options and the event type names change. Runtime delivery of events -is unchanged. +**Breaking.** The client, its options and the event type names change, and events the SDK doesn't +know now reach `onEvent`. ### Client and session @@ -61,6 +61,9 @@ names. `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`. +- `PlatformEvent` gains a member for events newer than the SDK: `{ type: "unknown", name, appId, + data }`, where `data` is `Record`. Before, the SDK dropped them. A `switch` on + `event.type` that must cover every case needs a `case "unknown"`. - New: `SessionErrorCode` and `AppErrorCode` split `PlatformSocketErrorCode`, and every schema in the protocol is exported, with `ServerEventMap` and `ClientMessageMap`. diff --git a/packages/platform/README.md b/packages/platform/README.md index a39273ab..e311d789 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -115,7 +115,9 @@ their exceptions are isolated so they cannot interrupt another app's delivery. event is `{room, data}`; the SDK routes by `room` and validates the envelope, while payload schemas and private-field filtering are the server's. Omitted keys mean unchanged and explicit null clears. Events carrying `branch_id` belong to that branch; -null or absent is main. Unknown event names are not forwarded. `PlatformSnapshot` is +null or absent is main. An event name the SDK doesn't know is forwarded as +`{type: "unknown", name, appId, data}` with only the envelope validated, so a new event reaches +partners when the service deploys it; new `room.*` notices are dropped. `PlatformSnapshot` is the service's `Snapshot` plus the app's `room`. Structural filtering does not promise redaction of generated prose or user content. diff --git a/packages/platform/src/modules/builder-protocol.ts b/packages/platform/src/modules/builder-protocol.ts index 91f8f219..7d979923 100644 --- a/packages/platform/src/modules/builder-protocol.ts +++ b/packages/platform/src/modules/builder-protocol.ts @@ -8,6 +8,9 @@ const sessionEvents: readonly string[] = ["app.snapshot", "session.ended"]; export const eventNames = serverEventNames.filter( (name): name is keyof PlatformEventMap & typeof name => !sessionEvents.includes(name) && !name.startsWith("room."), ); +const knownNames: readonly string[] = serverEventNames; +/** A name newer than this SDK's protocol, delivered to `onEvent` as `unknown`. New `room.*` notices have no error code, so they are dropped. */ +export const isUnknownEvent = (name: string) => !knownNames.includes(name) && !name.startsWith("room."); /** Notices that name an app room, by the error code each one reports. A new notice fails to compile until it has one. */ export const roomNotices = { "room.access_denied": "access_denied", @@ -31,8 +34,9 @@ export function appFromRoom(value: unknown): string | undefined { return typeof value === "string" && /^\/apps\/[a-f0-9]{24}$/.test(value) ? value.slice(6) : undefined; } /** Every event is `{room, data}`; payload schemas are the service's, so only the envelope is validated. */ -export function decode(type: keyof PlatformEventMap, appId: string, raw: unknown): PlatformEvent { - return { type, appId, data: object(object(raw).data) } as PlatformEvent; +export function decode(name: string, appId: string, raw: unknown): PlatformEvent { + const data = object(object(raw).data); + return (knownNames.includes(name) ? { type: name, appId, data } : { type: "unknown", name, appId, data }) as PlatformEvent; } export function decodeSnapshot(raw: unknown): PlatformSnapshot { const frame = object(raw); diff --git a/packages/platform/src/modules/builder-socket.ts b/packages/platform/src/modules/builder-socket.ts index 542fb877..08bcf408 100644 --- a/packages/platform/src/modules/builder-socket.ts +++ b/packages/platform/src/modules/builder-socket.ts @@ -1,6 +1,6 @@ import { io, type Socket } from "socket.io-client"; import { PlatformSocketError } from "../errors.js"; -import { appFromRoom, appPattern, decode, decodeSession, decodeSnapshot, eventNames, object, roomFor, roomNotices, sessionEndings } from "./builder-protocol.js"; +import { appFromRoom, appPattern, decode, decodeSession, decodeSnapshot, eventNames, isUnknownEvent, object, roomFor, roomNotices, sessionEndings } from "./builder-protocol.js"; import { notify, Subscription } from "./builder-subscription.js"; import type { BuilderInitOptions, BuilderSession, PlatformSubscription, SocketSession, SubscriptionOptions } from "./builder.types.js"; @@ -113,23 +113,27 @@ export class BuilderSocket implements BuilderSession { }); socket.on("session.ended", (raw: unknown) => this.sessionEnded(raw)); for (const [name, code] of Object.entries(roomNotices)) socket.on(name, (raw: unknown) => this.roomNotice(code, raw)); - for (const type of eventNames) socket.on(type, (raw: unknown) => { - try { - const appId = appFromRoom(object(raw).room); - if (!appId) throw new Error("Invalid app"); - const event = decode(type, appId, raw); - const subscription = this.subscriptions.get(appId); - subscription?.event(event); - // The server does not resend a snapshot after a rewrite of main: rejoining asks for one. - if (subscription && event.type === "conversation.changed" && event.data.branch_id == null) this.join(subscription); - } catch { this.protocolError(raw); } - }); + for (const name of eventNames) socket.on(name, (raw: unknown) => this.appEvent(name, raw)); + socket.onAny((name: string, raw: unknown) => { if (isUnknownEvent(name)) this.appEvent(name, raw); }); socket.connect(); } + private appEvent(name: string, raw: unknown): void { + try { + const appId = appFromRoom(object(raw).room); + if (!appId) throw new Error("Invalid app"); + const event = decode(name, appId, raw); + const subscription = this.subscriptions.get(appId); + subscription?.event(event); + // The server does not resend a snapshot after a rewrite of main: rejoining asks for one. + if (subscription && event.type === "conversation.changed" && event.data.branch_id == null) this.join(subscription); + } catch { this.protocolError(raw); } + } + private discard(): void { if (!this.socket) return; this.socket.removeAllListeners(); + this.socket.offAny(); this.socket.io.removeAllListeners(); this.socket.disconnect(); } diff --git a/packages/platform/src/modules/builder.events.types.ts b/packages/platform/src/modules/builder.events.types.ts index dfc9dc30..60294450 100644 --- a/packages/platform/src/modules/builder.events.types.ts +++ b/packages/platform/src/modules/builder.events.types.ts @@ -18,6 +18,10 @@ export type PlatformEventMap = { * is unchanged, and an explicit `null` clears it. For every event and its payload, see * [Socket messages](/developers/references/apps-api/sections/socket-messages). * + * An event added to the service after this version of the SDK arrives with `type: "unknown"`, its + * name in `name` and an unchecked `data`, so check a field before using it. After you upgrade, it + * arrives under its own `type`. + * * @example * ```typescript * // Narrow an event by type @@ -26,6 +30,9 @@ export type PlatformEventMap = { * const { message } = event.data; * console.log(`Message ${message.id} changed`); * } + * if (event.type === "unknown" && event.name === "app.renamed" && typeof event.data.name === "string") { + * console.log(`App renamed to ${event.data.name}`); + * } * } * ``` */ @@ -38,7 +45,16 @@ export type PlatformEvent = { /** Event payload. */ data: PlatformEventMap[K]; }; -}[keyof PlatformEventMap]; +}[keyof PlatformEventMap] | { + /** An event this version of the SDK doesn't know. */ + type: "unknown"; + /** The event's name on the socket. */ + name: string; + /** ID of the app the event belongs to. */ + appId: string; + /** Event payload, not checked by the SDK. */ + data: Record; +}; /** * An app's current state. diff --git a/packages/platform/src/modules/builder.types.ts b/packages/platform/src/modules/builder.types.ts index 7f55d68f..064ab695 100644 --- a/packages/platform/src/modules/builder.types.ts +++ b/packages/platform/src/modules/builder.types.ts @@ -168,7 +168,8 @@ export interface SubscriptionOptions { /** * Called with each live event, a [`PlatformEvent`](#platformevent). Events for an app arrive one at a time, in order. If the callback * throws or rejects, the subscription ends with `handler_failed`. New event types can be added at - * any time, so ignore types you don't handle. For every event and its payload, + * any time, so ignore types you don't handle. An event newer than this SDK arrives with + * `type: "unknown"` and its name in `name`. For every event and its payload, * see [Socket messages](/developers/references/apps-api/sections/socket-messages). */ onEvent: (event: PlatformEvent) => void | Promise; diff --git a/packages/platform/tests/types/client.types.ts b/packages/platform/tests/types/client.types.ts index 6750e820..a16746b8 100644 --- a/packages/platform/tests/types/client.types.ts +++ b/packages/platform/tests/types/client.types.ts @@ -40,6 +40,13 @@ const subscription = builder.subscribe("a".repeat(24), { const status: "pending" | "completed" | "failed" | null | undefined = event.data.status; void status; } + if (event.type === "unknown") { + const name: string = event.name; + // @ts-expect-error An unknown event's payload is unchecked. + const title: string = event.data.title; + if (typeof event.data.title === "string") void event.data.title.length; + void name; void title; + } // @ts-expect-error Payload must be narrowed by event.type. void event.data.items; // @ts-expect-error There is no replay cursor. diff --git a/packages/platform/tests/unit/client.test.ts b/packages/platform/tests/unit/client.test.ts index 0f4ad838..ce75c2e3 100644 --- a/packages/platform/tests/unit/client.test.ts +++ b/packages/platform/tests/unit/client.test.ts @@ -7,7 +7,8 @@ const fake = vi.hoisted(() => { const socket = { connected: false, on: vi.fn((name, handler) => { handlers[name] = handler; }), - emit: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), removeAllListeners: vi.fn(), + onAny: vi.fn(handler => { handlers["*"] = handler; }), + emit: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), removeAllListeners: vi.fn(), offAny: vi.fn(), io: { on: vi.fn((name, handler) => { managerHandlers[name] = handler; }), removeAllListeners: vi.fn() }, }; return { handlers, managerHandlers, socket, io: vi.fn(() => socket) }; @@ -353,6 +354,28 @@ describe("platform client", () => { expect(onError.mock.calls[0][0].code).toBe("protocol_error"); }); + test("forwards an event the SDK doesn't know to its app only", async () => { + const { client } = setup(); const onEvent = vi.fn(), otherEvent = vi.fn(); + client.subscribe(app, { onSnapshot: vi.fn(), onEvent, onError: vi.fn() }); + client.subscribe(other, { onSnapshot: vi.fn(), onEvent: otherEvent, onError: vi.fn() }); + await connected(client); + for (const name of ["app.renamed", "message.updated", "app.snapshot", "session.ended", "room.access_denied", "room.new_notice"]) { + fake.handlers["*"](name, { room, data: { name: "Shop" } }); + } + await settle(); + expect(onEvent.mock.calls).toEqual([[{ type: "unknown", name: "app.renamed", appId: app, data: { name: "Shop" } }]]); + expect(otherEvent).not.toHaveBeenCalled(); + }); + + test("a malformed unknown event fails only its app", async () => { + const { client } = setup(); const onError = vi.fn(); + const sub = client.subscribe(app, { onSnapshot: vi.fn(), onEvent: vi.fn(), onError }); + const second = client.subscribe(other, { onSnapshot: vi.fn(), onEvent: vi.fn(), onError: vi.fn() }); await connected(client); + fake.handlers["*"]("app.renamed", { room, data: [] }); + 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, { onSnapshot: vi.fn(), onEvent: () => new Promise(() => {}), onError }); @@ -368,7 +391,7 @@ describe("platform client", () => { expect(joins()).toEqual([["join", room], ["join", room]]); client.close(); client.close(); expect(fake.socket.disconnect).toHaveBeenCalledOnce(); await expect(client.connect()).rejects.toMatchObject({ code: "client_closed" }); - expect(fake.socket.removeAllListeners).toHaveBeenCalledOnce(); + expect(fake.socket.removeAllListeners).toHaveBeenCalledOnce(); expect(fake.socket.offAny).toHaveBeenCalledOnce(); }); test("validates app IDs, duplicate subscriptions and limits", () => {