Skip to content
Merged
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
7 changes: 5 additions & 2 deletions packages/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<string, unknown>`. 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`.

Expand Down
4 changes: 3 additions & 1 deletion packages/platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 6 additions & 2 deletions packages/platform/src/modules/builder-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down
28 changes: 16 additions & 12 deletions packages/platform/src/modules/builder-socket.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
}
Expand Down
18 changes: 17 additions & 1 deletion packages/platform/src/modules/builder.events.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`);
* }
* }
* ```
*/
Expand All @@ -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<string, unknown>;
};

/**
* An app's current state.
Expand Down
3 changes: 2 additions & 1 deletion packages/platform/src/modules/builder.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down
7 changes: 7 additions & 0 deletions packages/platform/tests/types/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 25 additions & 2 deletions packages/platform/tests/unit/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down Expand Up @@ -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 });
Expand All @@ -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", () => {
Expand Down
Loading