diff --git a/.claude/skills/sdk-docs-writing/SKILL.md b/.claude/skills/sdk-docs-writing/SKILL.md index 73639725..723f000b 100644 --- a/.claude/skills/sdk-docs-writing/SKILL.md +++ b/.claude/skills/sdk-docs-writing/SKILL.md @@ -81,6 +81,8 @@ Every public method needs: description, `@param` tags, `@returns`, and at least - **Document side effects** explicitly (e.g., "automatically sets the token for subsequent requests"). - **Link method references.** When mentioning another SDK method or module by name in JSDoc prose, always use `{@link}` or `{@linkcode}` to create a cross-reference. - **Never use a colon as a sentence break.** Write "Use this when you sync data from another system. Name the field..." not "...another system: name the field...". Reserve colons for `label: description` bullet-list items, not prose sentences. +- **Name the two SDKs.** Call `@base44/sdk` the "App SDK" and `@base44/platform` the "Platform SDK" wherever a page mentions both. Docs for only one package can say "the SDK". +- **Use a serial comma.** Put a comma before the final "and" or "or" in every series of three or more, as in "snapshots, events, and errors". - **Never start a sentence with a lowercase inline-code term.** If the natural phrasing would open with one (e.g., "`week` is only supported on..."), restructure so a capitalized word leads instead (e.g., "Every date field supports `day`, `month` and `year`. The `created_date` and `updated_date` fields also support `week`."). - **Name the place and the AI correctly.** Write "app editor" (lowercase) for the place where you build, and "the AI" or "the AI chat" for the assistant doing the work ("The AI inserts this ID", "ask the AI chat to fix it"). Never use "builder" for either. It stays only for the Builder plan, the Base44 Builder GitHub App, people who build apps ("builders", "app builders"), and exact UI labels or code names. diff --git a/packages/platform/README.md b/packages/platform/README.md index 189042f0..3d5686db 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -141,7 +141,7 @@ fix the cause and call `chat.connect()`. `chat.subscribe(appId, options)` returns a handle with `appId`, `active` and idempotent `unsubscribe()`. App IDs are 24 lowercase hexadecimal characters. One -subscription per app and at most eight subscriptions are allowed per chat session. +subscription per app and at most eight subscriptions are allowed per session. Subscriptions can be created before connecting. `chat.close()` is terminal for that session; call `client.chat.init(...)` again to start another. @@ -230,7 +230,7 @@ exceptions, payloads and credentials are never attached. | `delivery_overflow` | 1,000 deliveries queued for the app; subscribe again for a fresh snapshot. | | `protocol_error` | Invalid routing or envelope. Stop and investigate. | | `handler_failed` | An application callback failed; fix it and subscribe again. | -| `client_closed` | The chat session was explicitly closed; initialize another session. | +| `client_closed` | The 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/packages/platform/scripts/gen-events.mjs b/packages/platform/scripts/gen-events.mjs index 942ba2fa..9e025c3d 100644 --- a/packages/platform/scripts/gen-events.mjs +++ b/packages/platform/scripts/gen-events.mjs @@ -5,6 +5,11 @@ import { compile } from "json-schema-to-typescript"; const source = new URL("../asyncapi.json", import.meta.url); const target = new URL("../src/client/modules/chat.events.generated.ts", import.meta.url); +const typesTarget = new URL("../src/client/modules/chat.events.types.ts", import.meta.url); +const UNION_BEGIN = " // Generated by scripts/gen-events.mjs, begin\n"; +const UNION_END = " // Generated by scripts/gen-events.mjs, end\n"; +// The client handles these itself, so they are not `PlatformEvent` members. Keep in step with `PlatformEventMap`. +const clientHandled = (name) => name === "app.snapshot" || name === "session.ended" || name.startsWith("room."); const doc = JSON.parse(await readFile(source, "utf8")); const schemas = doc.components.schemas; const refName = (ref) => ref.split("/").pop(); @@ -82,14 +87,23 @@ ${mapEntries(clientMessages)} export const serverEventNames = ${JSON.stringify(serverEvents.map((event) => event.name))} as const; `; +const appEvents = serverEvents.filter((event) => !clientHandled(event.name)); +const unionMembers = appEvents.map((event) => ` | AppEvent<${JSON.stringify(event.name)}>\n`).join(""); +const typesSource = await readFile(typesTarget, "utf8"); +const begin = typesSource.indexOf(UNION_BEGIN); +const end = typesSource.indexOf(UNION_END); +if (begin === -1 || end === -1 || end < begin) throw new Error("Could not find the generated union markers in chat.events.types.ts"); +const typesOutput = typesSource.slice(0, begin + UNION_BEGIN.length) + unionMembers + typesSource.slice(end); + if (process.argv.includes("--check")) { const committed = await readFile(target, "utf8").catch(() => ""); - if (committed !== output) { - console.error("chat.events.generated.ts is out of date with asyncapi.json. Run `npm run gen:events -w @base44/platform` and commit the result."); + if (committed !== output || typesSource !== typesOutput) { + console.error("chat.events.generated.ts or the PlatformEvent union is out of date with asyncapi.json. Run `npm run gen:events -w @base44/platform` and commit the result."); process.exit(1); } - console.log("chat.events.generated.ts is up to date."); + console.log("chat.events.generated.ts and the PlatformEvent union are up to date."); } else { await writeFile(target, output); - console.log(`Wrote ${Object.keys(schemas).length} schemas and ${serverEvents.length} server events.`); + await writeFile(typesTarget, typesOutput); + console.log(`Wrote ${Object.keys(schemas).length} schemas and ${serverEvents.length} server events, ${unionMembers.split("\n").length - 1} in the PlatformEvent union.`); } diff --git a/packages/platform/scripts/mintlify-post-processing/appended-articles.json b/packages/platform/scripts/mintlify-post-processing/appended-articles.json index 154db4dc..0a99eaf3 100644 --- a/packages/platform/scripts/mintlify-post-processing/appended-articles.json +++ b/packages/platform/scripts/mintlify-post-processing/appended-articles.json @@ -3,20 +3,17 @@ "interfaces/ChatSession", "interfaces/SocketSession", "type-aliases/PlatformEvent", - "interfaces/PlatformSnapshot" - ], - "classes/PlatformSocketError": [ + "interfaces/PlatformSnapshot", + "classes/PlatformSocketError", "type-aliases/SessionErrorCode", "type-aliases/AppErrorCode" ], "interfaces/webhooks": [ "type-aliases/WebhookEvent", - "interfaces/UnknownWebhookEvent" - ], - "classes/WebhookVerificationError": [ - "type-aliases/WebhookVerificationErrorCode" - ], - "classes/WebhookConfigurationError": [ + "interfaces/UnknownWebhookEvent", + "classes/WebhookVerificationError", + "type-aliases/WebhookVerificationErrorCode", + "classes/WebhookConfigurationError", "type-aliases/WebhookConfigurationErrorCode" ], "functions/useBase44Chat": [ diff --git a/packages/platform/scripts/mintlify-post-processing/category-map.json b/packages/platform/scripts/mintlify-post-processing/category-map.json index c3b506a8..809b882b 100644 --- a/packages/platform/scripts/mintlify-post-processing/category-map.json +++ b/packages/platform/scripts/mintlify-post-processing/category-map.json @@ -1,6 +1,5 @@ { "functions": "Clients", "interfaces": "Modules", - "type-aliases": "Modules", - "classes": "Errors" + "type-aliases": "Modules" } diff --git a/packages/platform/scripts/mintlify-post-processing/mintlify-target.json b/packages/platform/scripts/mintlify-post-processing/mintlify-target.json index 1ce7a05c..0320fcfd 100644 --- a/packages/platform/scripts/mintlify-post-processing/mintlify-target.json +++ b/packages/platform/scripts/mintlify-post-processing/mintlify-target.json @@ -1,6 +1,6 @@ { "path": "developers/references/platform-sdk/docs", - "dropdown": "Platform SDK", + "dropdown": "SDKs", "group": "Platform SDK Reference", "icon": "brackets-curly" } diff --git a/packages/platform/scripts/mintlify-post-processing/page-sections.json b/packages/platform/scripts/mintlify-post-processing/page-sections.json index a9124098..62cf5576 100644 --- a/packages/platform/scripts/mintlify-post-processing/page-sections.json +++ b/packages/platform/scripts/mintlify-post-processing/page-sections.json @@ -4,6 +4,19 @@ }, "typeDefinitionGroups": [ ["SocketSession", "PlatformEvent", "PlatformSnapshot"], - { "title": "Error codes", "types": ["SessionErrorCode", "AppErrorCode"] } + { + "title": "Errors", + "types": ["PlatformSocketError", "SessionErrorCode", "AppErrorCode"], + "nested": { "SessionErrorCode": "Session error codes", "AppErrorCode": "App error codes" } + }, + ["WebhookEvent", "UnknownWebhookEvent"], + { + "title": "Errors", + "types": ["WebhookVerificationError", "WebhookVerificationErrorCode", "WebhookConfigurationError", "WebhookConfigurationErrorCode"], + "nested": { + "WebhookVerificationErrorCode": "Verification error codes", + "WebhookConfigurationErrorCode": "Configuration error codes" + } + } ] } diff --git a/packages/platform/src/client/client.ts b/packages/platform/src/client/client.ts index 3b9e551a..bc7f137c 100644 --- a/packages/platform/src/client/client.ts +++ b/packages/platform/src/client/client.ts @@ -4,12 +4,16 @@ import { createChat } from "./modules/chat.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 [`chat`](/developers/references/platform-sdk/docs/interfaces/chat). - * It takes no options and opens nothing: each chat session gets its socket session from the - * `getSession` you pass to `init()`. + * This is the entry point of the Platform SDK in the browser. Import it from `@base44/platform`. + * The client gives your white-label app editor access to the platform's modules, such as + * [`chat`](/developers/references/platform-sdk/docs/interfaces/chat). It takes no options and + * doesn't connect to anything. Each call to + * [`client.chat.init()`](/developers/references/platform-sdk/docs/interfaces/chat#init) gets a + * socket session from the `getSession()` function you pass in. On your backend, use + * [`createPlatformServer()`](/developers/references/platform-sdk/docs/functions/createPlatformServer) + * from `@base44/platform/server`. * - * @returns A platform client with access to the platform's modules. + * @returns A new platform client. * * @example * ```typescript @@ -24,7 +28,7 @@ import { createChat } from "./modules/chat.js"; * const response = await fetch('/api/chat-socket-session', { method: 'POST' }); * return response.json(); * }, - * onError: (error) => console.error(error.code), + * onError: (error) => console.error('Live updates error', error.code), * }); * await chat.connect(); * ``` diff --git a/packages/platform/src/client/client.types.ts b/packages/platform/src/client/client.types.ts index 042e2520..a03b4313 100644 --- a/packages/platform/src/client/client.types.ts +++ b/packages/platform/src/client/client.types.ts @@ -1,11 +1,9 @@ import type { ChatModule } from "./modules/chat.types.js"; /** - * The platform client. - * - * Provides access to the platform's modules. + * The platform client, with one property for each module. */ export interface PlatformClient { - /** [Chat module](/developers/references/platform-sdk/docs/interfaces/chat) for following the AI chat in your users' apps live. */ + /** [Chat module](/developers/references/platform-sdk/docs/interfaces/chat) for following the AI chat of your users' apps. */ readonly chat: ChatModule; } diff --git a/packages/platform/src/client/errors.ts b/packages/platform/src/client/errors.ts index a020576c..c8383cd8 100644 --- a/packages/platform/src/client/errors.ts +++ b/packages/platform/src/client/errors.ts @@ -1,21 +1,27 @@ import type { PlatformSocketErrorCode } from "./errors.types.js"; /** - * The error the SDK reports when something goes wrong in a chat session. + * The error the SDK reports when something goes wrong with live updates. * - * You don't create it. The SDK passes it to your `onError` callbacks, `connect()` rejects with it, - * and `subscribe()` throws it. Check `code` to see what happened, then look the code up below: - * - [Session error codes](#sessionerrorcode) go to the `onError` you pass to `init()`. The whole + * You don't create these errors. The SDK passes them to your `onError()` callbacks, rejects + * `connect()` with them, and throws them from `subscribe()`. Check `code` to see what happened, + * then look the code up below: + * - Session error codes go to the `onError()` you pass to `init()`. The whole * session is affected, and `appId` isn't set. - * - [App error codes](#apperrorcode) go to the subscription's `onError`. Only that app is affected, - * and `appId` says which one. + * - App error codes go to the subscription's `onError()`. Only that app is + * affected, and `appId` says which one. * - * The error never carries the session token, a callback's exception, or the server's exception text. + * These errors never carry the session token, a callback's exception, or the server's exception + * text. * * @example * ```typescript * // Handle session and app errors * const chat = client.chat.init({ + * async getSession() { + * const response = await fetch('/api/chat-socket-session', { method: 'POST' }); + * return response.json(); + * }, * // Session error codes: the whole session is affected * onError(error) { * if (error.code === 'session_revoked') showSignedOut(); @@ -33,7 +39,7 @@ import type { PlatformSocketErrorCode } from "./errors.types.js"; * ``` */ export class PlatformSocketError extends Error { - /** What happened. One of the [session error codes](#sessionerrorcode) or [app error codes](#apperrorcode). */ + /** What happened. One of the session error codes or app error codes. */ readonly code: PlatformSocketErrorCode; /** ID of the app the error is about. Set for app error codes, and not set for session error codes. */ readonly appId?: string; diff --git a/packages/platform/src/client/errors.types.ts b/packages/platform/src/client/errors.types.ts index 6dc1d774..cea89ff8 100644 --- a/packages/platform/src/client/errors.types.ts +++ b/packages/platform/src/client/errors.types.ts @@ -1,30 +1,34 @@ /** - * Codes of errors that affect the whole session. They go to the `onError` you pass to `init()`, and - * `connect()` rejects with them. Retryable means the same call can succeed again without changing anything. + * Codes of errors that affect the whole session. They go to the `onError()` you pass to `init()`, + * and `connect()` rejects with them. A retryable error is one where the same call can succeed again + * without changing anything. */ export type SessionErrorCode = /** - * `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). + * The `getSession()` callback threw, took longer than 20 seconds, or returned a session without a + * valid `session_token`, a `socket_url` that is an HTTP or HTTPS origin, or a `socket_path` that is + * an absolute path. * - * Check the endpoint that opens sessions, then call `connect()` again. Retryable, except that an - * invalid field needs a fix on your server first. + * Check the endpoint that opens sessions, then call `connect()` again. Retryable. If a field is + * invalid, fix it on your server first. */ | "session_unavailable" /** - * Base44 refused the session even with a fresh token: the token is invalid, or live updates aren't enabled for the workspace. + * Base44 refused the session even with a fresh token. Either the token is invalid, or live updates + * aren't turned on for the workspace. * * Check that your server opens a new session on every call. Not retryable. */ | "connection_denied" /** - * The socket couldn't connect after five retries, or the server closed it. + * The socket couldn't connect after five retries, or the server closed the connection. * * Call `connect()` again, for example when the tab next gets focus. Retryable. */ | "connection_failed" /** - * Your server or an admin ended the session, or its key lost access. + * Your server or an admin ended the session, or the workspace API key that opened the session lost + * access. * * Open a new session from your server if the page should still follow apps. Not retryable. */ @@ -32,11 +36,12 @@ export type SessionErrorCode = /** * Another socket connected with the same session token. * - * Use one session per tab. Don't reconnect automatically; call `connect()` only to take the session back on purpose. Not retryable. + * Use one session per tab. Don't reconnect automatically. Call `connect()` only to take the + * session back on purpose. Not retryable. */ | "session_replaced" /** - * A session frame didn't match the protocol. + * A message about the whole session didn't match the protocol. * * Update the SDK, then call `connect()` again. Not retryable. */ @@ -49,26 +54,27 @@ export type SessionErrorCode = | "client_closed"; /** - * Codes of errors for one app. They go to the subscription's `onError`, and `subscribe()` throws - * `subscription_limit`. Every one ends the subscription except `snapshot_unavailable`. + * Codes of errors for a single app. They go to the subscription's `onError()`, and `subscribe()` + * throws `subscription_limit`. Every one ends the subscription except `snapshot_unavailable`. */ export type AppErrorCode = /** - * The app isn't on the session's allowlist, or the socket joins too often. + * The session doesn't have access to the app, or the connection subscribed to apps too often. * * Add the app to the session from your server, then subscribe again. Not retryable. */ | "access_denied" /** - * The app left the session's allowlist, or its workspace. + * The session lost access to the app. Your server removed the app from the session, or the app + * left the workspace. * * Stop showing the app's live updates. Not retryable. */ | "access_revoked" /** - * The subscription is active but its snapshot failed. Events keep arriving. + * The subscription is active, but its snapshot failed. Events keep arriving. * - * Load the app's messages over the Apps API instead. + * Load the app's messages from the Apps API instead. */ | "snapshot_unavailable" /** @@ -78,13 +84,13 @@ export type AppErrorCode = */ | "subscription_limit" /** - * More than 1,000 deliveries waited for your callbacks. + * More than 1,000 snapshots and events were waiting for your callbacks. * * Make the callbacks faster, then subscribe again for a fresh snapshot. Retryable. */ | "delivery_overflow" /** - * Your `onSnapshot` or `onEvent` callback threw or rejected. + * Your `onSnapshot()` or `onEvent()` callback threw or rejected. * * Fix the callback, then subscribe again. Not retryable. */ diff --git a/packages/platform/src/client/index.ts b/packages/platform/src/client/index.ts index 0a10f78f..88f74f71 100644 --- a/packages/platform/src/client/index.ts +++ b/packages/platform/src/client/index.ts @@ -4,5 +4,5 @@ export { PlatformSocketError } from "./errors.js"; export type { AppErrorCode, PlatformSocketErrorCode, SessionErrorCode } from "./errors.types.js"; export type { PlatformClient } from "./client.types.js"; export type { ChatModule, ChatInitOptions, ChatSession, PlatformSubscription, SocketSession, SubscriptionOptions } from "./modules/chat.types.js"; -export type { GuardApproval, PlatformEvent, PlatformEventMap, PlatformSnapshot, ToolCall } from "./modules/chat.events.types.js"; +export type { AppEvent, GuardApproval, PlatformEvent, PlatformEventMap, PlatformSnapshot, ToolCall, UnknownEvent } from "./modules/chat.events.types.js"; export type * from "./modules/chat.events.generated.js"; diff --git a/packages/platform/src/client/modules/chat.events.types.ts b/packages/platform/src/client/modules/chat.events.types.ts index 9833d7ae..909e2471 100644 --- a/packages/platform/src/client/modules/chat.events.types.ts +++ b/packages/platform/src/client/modules/chat.events.types.ts @@ -11,16 +11,40 @@ export type PlatformEventMap = { [K in keyof ServerEventMap as K extends "app.snapshot" | "session.ended" | `room.${string}` ? never : K]: ServerEventMap[K]; }; +/** An event the service sends for a subscribed app, named by its `type`. */ +export interface AppEvent { + /** Event name. Narrows the type of `data`. */ + type: K; + /** ID of the app the event belongs to. */ + appId: string; + /** Event payload. */ + data: PlatformEventMap[K]; +} + +/** An event this version of the SDK doesn't know. */ +export interface UnknownEvent { + /** Always `"unknown"`. */ + 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; +} + /** - * One live event for a subscribed app. + * A live event for a subscribed app. * - * Check `type` to narrow `data` to that event's payload. A key missing from `data` means the value - * is unchanged, and an explicit `null` clears it. For every event and its payload, see - * [Socket messages](/developers/references/apps-api/sections/socket-messages). + * Check `type` to narrow `data` to that event's payload. The `data` of an event holds only the + * fields that changed. Keep your value for any field the event leaves out, and clear a field the + * event sets to `null`. + * [Socket messages](/developers/references/apps-api/sections/socket-messages) lists every event + * and its payload. * * 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`. + * event name in `name`, and an unchecked `data`, so check a field before using it. After you + * upgrade, the event arrives under its own `type`. * * @example * ```typescript @@ -30,43 +54,40 @@ export type PlatformEventMap = { * const { message } = event.data; * console.log(`Message ${message.id} changed`); * } + * // A hypothetical event this version of the SDK doesn't know yet * if (event.type === "unknown" && event.name === "app.renamed" && typeof event.data.name === "string") { * console.log(`App renamed to ${event.data.name}`); * } * } * ``` */ -export type PlatformEvent = { - [K in keyof PlatformEventMap]: { - /** Event name. Narrows the type of `data`. */ - type: K; - /** ID of the app the event belongs to. */ - appId: string; - /** Event payload. */ - data: PlatformEventMap[K]; - }; -}[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; -}; +export type PlatformEvent = + // Generated by scripts/gen-events.mjs, begin + | AppEvent<"message.updated"> + | AppEvent<"message.removed"> + | AppEvent<"app.status_changed"> + | AppEvent<"preview.reload_requested"> + | AppEvent<"preview.navigation_requested"> + | AppEvent<"queue.updated"> + | AppEvent<"task.progressed"> + | AppEvent<"image.resolved"> + | AppEvent<"conversation.changed"> + | AppEvent<"files.changed"> + | AppEvent<"branch.deleted"> + | AppEvent<"repository.changed"> + | AppEvent<"pull_request.changed"> + // Generated by scripts/gen-events.mjs, end + | UnknownEvent; /** * An app's current state. * - * Arrives after every join and rejoin, and after a rewrite of the main conversation. Replace - * everything you hold for the app with it. It holds only the last 50 messages. - * - * Its fields are the `data` of [`app.snapshot`](/developers/references/apps-api/sections/socket-messages#app-snapshot), - * plus `room`. + * The snapshot arrives when you subscribe, after every reconnect, and after the main conversation + * is rewritten. Replace everything you hold for the app with the snapshot. A snapshot holds only + * the last 50 messages. */ export interface PlatformSnapshot extends Omit { - /** The app's room, `/apps/{app_id}`. */ + /** Identifies the app the snapshot is for, in the form `/apps/{app_id}`. */ room: string; /** The main branch's prompt queue. Missing when the server sends none. */ queue?: QueueState; diff --git a/packages/platform/src/client/modules/chat.types.ts b/packages/platform/src/client/modules/chat.types.ts index 1f50702a..1653fbfe 100644 --- a/packages/platform/src/client/modules/chat.types.ts +++ b/packages/platform/src/client/modules/chat.types.ts @@ -2,15 +2,14 @@ import type { PlatformEvent, PlatformSnapshot } from "./chat.events.types.js"; import type { PlatformSocketError } from "../errors.js"; /** - * The socket session your server opened, as [Create socket session](/api-reference/create-socket-session) returned it. - * - * Pass the response body through unchanged. The client reads these three fields and ignores the - * rest, such as `session_id` and `expires_in`. + * The response body of [Create socket session](/api-reference/create-socket-session), which your + * `getSession()` callback returns. Pass the response through unchanged. The client reads the three + * fields below. */ export interface SocketSession { /** Token that authenticates the socket. The client keeps it in memory and sends it only when the socket connects. */ session_token: string; - /** Origin of the platform socket, with no path, query, fragment or credentials. */ + /** Origin of the platform socket, with no path, query, fragment, or credentials. */ socket_url: string; /** Absolute path the socket connects on, such as `/ws/socket.io/`. */ socket_path: string; @@ -21,40 +20,45 @@ export interface ChatInitOptions { /** * Returns a new socket session from your server. * - * Your server opens the session with its workspace API key through - * [Create socket session](/api-reference/create-socket-session) and returns the response body, a [`SocketSession`](#socketsession). - * The client calls this on the first `connect()`, and again only when the session expires or - * Base44 rejects its token. Return a new session on every call. If a new session has a different - * `socket_url` or `socket_path`, the client moves to it and rejoins every active subscription. + * Return the response body of [Create socket session](/api-reference/create-socket-session) as a + * [`SocketSession`](#socketsession), and return a new session on every call. The client calls + * `getSession()` on the first {@linkcode ChatSession.connect | connect()}, and again only when + * the session expires or Base44 rejects its token. If a new session has a different `socket_url` + * or `socket_path`, the client moves to it and rejoins every active subscription. * * @returns The session, as your server received it. */ getSession: () => Promise; /** * Called on errors that affect the whole session, such as a failed connection or an ended session. - * Errors for one app go to that subscription's `onError` instead. See the - * [error codes](/developers/references/platform-sdk/docs/classes/PlatformSocketError#platformsocketerrorcode). + * Errors for one app go to that subscription's `onError()` instead. The + * [error codes](#errors) + * say what each error means. */ onError: (error: PlatformSocketError) => void; } /** - * Chat module for following the AI chat in your users' apps live. + * Chat module for following the AI chat of your users' apps in real time. Call + * {@linkcode init | init()} to create a session and start following. * - * Each session holds one socket and up to eight app subscriptions. For each app you get a - * snapshot of its chat, then every change as it happens: + * A session holds one socket and up to eight app subscriptions. For each app, you get a + * snapshot of the app's chat, then every change as it happens: * - New and updated chat messages, with the AI's tool calls. * - The AI's status, from processing to ready. * - Preview reloads and navigation, the prompt queue, and generated images. - * - * The module does nothing until you call {@linkcode init | init()}. */ export interface ChatModule { /** - * Creates a chat session without connecting it. + * Creates a session without connecting it. A [session](#session-methods) is one socket plus the + * app subscriptions that share it. + * + * Pass a `getSession()` callback in the options. The client calls it to get a + * [socket session](/developers/references/apps-api/sections/socket-sessions), which your server + * opens with [Create socket session](/api-reference/create-socket-session) and returns as the + * response body. Each session is independent and gets its own socket session. * * Call {@linkcode ChatSession.connect | connect()} on the returned session to open the socket. - * Each session is independent, with its own socket, socket session and subscriptions. * * @param options - Session options. * @returns The new session. @@ -68,7 +72,7 @@ export interface ChatModule { * return response.json(); * }, * onError(error) { - * console.error('Chat session error', error.code); + * console.error('Live updates error', error.code); * }, * }); * await chat.connect(); @@ -78,21 +82,28 @@ export interface ChatModule { } /** - * A `ChatSession`: one socket with its app subscriptions and its reconnection. Use its methods - * to connect, follow apps and close. + * A session is one socket plus the app subscriptions that share it, and it reconnects automatically + * if the connection drops. Use the methods below to connect the socket, follow apps, and close the + * session. */ export interface ChatSession { /** * Connects the session's socket. * - * Resolves once the socket connects, before any snapshot arrives. If the connection drops, the - * session retries five times and rejoins every active subscription, each with a fresh snapshot. - * The first call fetches a session through `getSession`, and an expired or rejected token is - * replaced once the same way. Concurrent calls share one attempt. Call it again after a failure or a `session_replaced` error. + * The first call gets a socket session from [`getSession()`](#init). If the session has expired + * or Base44 rejects its token, the client calls `getSession()` once more for a new one. + * Concurrent calls share one attempt. + * + * The promise resolves once the socket connects, before any snapshot arrives. If the connection + * drops, the session retries five times and rejoins every active subscription, each with a fresh + * snapshot. * - * @returns Promise that resolves when the socket connects. - * @throws {PlatformSocketError} With `connection_denied`, `connection_failed` or `session_unavailable` - * when the session can't connect, or `client_closed` when the session is closed first. + * Call `connect()` again after a failure or a `session_replaced` error. + * + * @returns A promise that resolves when the socket connects. + * @throws {PlatformSocketError} The promise rejects with a `PlatformSocketError` whose code is + * `connection_denied`, `connection_failed`, or `session_unavailable` when the session can't connect, + * or `client_closed` when the session is closed first. * * @example * ```typescript @@ -106,21 +117,28 @@ export interface ChatSession { */ connect(): Promise; /** - * Subscribes to one app's chat. + * Subscribes to an app's chat. + * + * You can subscribe before or after connecting. A session has one subscription per app, up to + * eight in total. A session can only follow apps your server gave the session access to, either + * in [Create socket session](/api-reference/create-socket-session) or later with + * [Add app to session](/api-reference/add-app-to-session). Subscribing to any other app fails + * with `access_denied`. The subscription first receives a snapshot of the app, then each live + * event. * - * You can subscribe before or after connecting. Each app can have one subscription per session, - * and a session can have up to eight. The app must be on the session's allowlist. The - * subscription first receives a snapshot of the app, then each live event. + * Pass `onSnapshot()`, `onEvent()`, and `onError()` callbacks in the options to receive the app's + * snapshot, live events, and errors. * - * Callbacks for an app run one at a time, in order, and the session waits for a returned promise - * before the next one. A slow app doesn't hold up the others. If more than 1,000 deliveries wait - * for one app, its subscription ends with `delivery_overflow`. + * Callbacks for an app run one at a time, in order. If a callback returns a promise, the session + * waits for it before calling the next callback for that app. Each app has its own queue, so a + * slow callback for one app doesn't delay the others. If more than 1,000 snapshots and events are + * waiting for an app's callbacks, the subscription ends with `delivery_overflow`. * * @param appId - ID of the app to follow, 24 lowercase hexadecimal characters. - * @param options - Callbacks for the app's snapshots, events and errors. + * @param options - Callbacks for the app's snapshots, events, and errors. * @returns The subscription. - * @throws {TypeError} When `appId` isn't a valid app ID or the app already has a subscription. - * @throws {PlatformSocketError} With `subscription_limit` when the session has eight subscriptions. + * @throws {TypeError} The method throws a `TypeError` when `appId` isn't a valid app ID or the app already has a subscription. + * @throws {PlatformSocketError} The method throws a `PlatformSocketError` whose code is `subscription_limit` when the session already has eight subscriptions. * * @example * ```typescript @@ -142,7 +160,8 @@ export interface ChatSession { /** * Closes the session. * - * Ends every subscription, stops reconnecting and disconnects the socket. Calling it again does + * Call `close()` when the page closes or you no longer need live updates. Closing ends every + * subscription, stops reconnecting, and disconnects the socket. Calling `close()` again does * nothing. A closed session can't be reused, so call [`init()`](/developers/references/platform-sdk/docs/interfaces/chat#init) * for a new one. * @@ -158,33 +177,36 @@ export interface ChatSession { /** Callbacks for one app subscription. */ export interface SubscriptionOptions { /** - * Called with the app's current state, a [`PlatformSnapshot`](#platformsnapshot). Replace everything you hold for the app with it. + * Called with the app's current state, a [`PlatformSnapshot`](#platformsnapshot). Replace everything you hold for the app with the snapshot. * - * Arrives after every join and rejoin, and after the main conversation is rewritten by an undo, - * restore or sync. Live events follow it. It holds only the last 50 messages, so merge by message - * ID to keep older history. + * The snapshot arrives when you subscribe, after every reconnect, and after the main conversation + * is rewritten by an undo, restore, or sync. Live events follow it. A snapshot holds only the last + * 50 messages, so load older history again if you show it. */ onSnapshot: (snapshot: PlatformSnapshot) => void | Promise; /** * 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. 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). + * throws or rejects, the subscription ends with `handler_failed`. + * + * New event types can be added at any time, so ignore the types you don't handle. An event newer + * than this SDK arrives with `type: "unknown"`, and its event name is in `name`. + * [Socket messages](/developers/references/apps-api/sections/socket-messages) lists every event + * and its payload. */ onEvent: (event: PlatformEvent) => void | Promise; /** * Called on errors for this app. Every error ends the subscription except `snapshot_unavailable`. - * See the [error codes](/developers/references/platform-sdk/docs/classes/PlatformSocketError#platformsocketerrorcode). + * The [error codes](#errors) + * say what each error means. */ onError: (error: PlatformSocketError) => void; } -/** One app subscription. */ +/** A subscription to an app's chat. */ export interface PlatformSubscription { /** ID of the app. */ readonly appId: string; - /** Whether the subscription still receives updates. It turns false after an error or {@linkcode unsubscribe | unsubscribe()}. */ + /** Whether the subscription still receives updates. The value becomes `false` after an error or a call to {@linkcode unsubscribe | unsubscribe()}. */ readonly active: boolean; /** * Stops updates for the app and frees its subscription slot. Calling it again does nothing. diff --git a/packages/platform/src/server/errors.ts b/packages/platform/src/server/errors.ts index 3e521dba..67e76124 100644 --- a/packages/platform/src/server/errors.ts +++ b/packages/platform/src/server/errors.ts @@ -3,11 +3,11 @@ import type { WebhookConfigurationErrorCode, WebhookVerificationErrorCode } from /** * The error the SDK reports when a webhook delivery can't be trusted. * - * You don't create it. The [`verifyEvent()`](/developers/references/platform-sdk/docs/interfaces/webhooks#verifyevent) method rejects with - * it, and [`createHandler()`](/developers/references/platform-sdk/docs/interfaces/webhooks#createhandler) answers it with a 400 and passes it - * to your `onError`. Check `code` to see what happened, then look the code up below. + * You don't create these errors. The [`verifyEvent()`](/developers/references/platform-sdk/docs/interfaces/webhooks#verifyevent) method rejects with + * them, and [`createHandler()`](/developers/references/platform-sdk/docs/interfaces/webhooks#createhandler) answers them with a 400 and passes + * them to your `onError()`. Check `code` to see what happened, then look the code up below. * - * The error never carries the request body or its signature. + * These errors never carry the request body or its signature. * * @example * ```typescript @@ -23,7 +23,7 @@ import type { WebhookConfigurationErrorCode, WebhookVerificationErrorCode } from * ``` */ export class WebhookVerificationError extends Error { - /** What happened. One of the [verification error codes](#webhookverificationerrorcode). */ + /** What happened. One of the verification error codes. */ readonly code: WebhookVerificationErrorCode; /** @internal */ @@ -37,9 +37,9 @@ export class WebhookVerificationError extends Error { /** * The error the SDK reports when the public keys you passed can't be used, so no delivery can be verified. * - * You don't create it. The [`verifyEvent()`](/developers/references/platform-sdk/docs/interfaces/webhooks#verifyevent) method rejects with - * it, and [`createHandler()`](/developers/references/platform-sdk/docs/interfaces/webhooks#createhandler) answers it with a 500 and passes it - * to your `onError`. Base44 retries the delivery, and the retries succeed once you fix the problem. + * You don't create these errors. The [`verifyEvent()`](/developers/references/platform-sdk/docs/interfaces/webhooks#verifyevent) method rejects with + * them, and [`createHandler()`](/developers/references/platform-sdk/docs/interfaces/webhooks#createhandler) answers them with a 500 and passes + * them to your `onError()`. Base44 retries the delivery, and the retries succeed once you fix the problem. * * @example * ```typescript @@ -53,7 +53,7 @@ export class WebhookVerificationError extends Error { * ``` */ export class WebhookConfigurationError extends Error { - /** What's wrong with the keys. One of the [configuration error codes](#webhookconfigurationerrorcode). */ + /** What's wrong with the keys. One of the configuration error codes. */ readonly code: WebhookConfigurationErrorCode; /** @internal */ diff --git a/packages/platform/src/server/errors.types.ts b/packages/platform/src/server/errors.types.ts index e34e3c52..abae0399 100644 --- a/packages/platform/src/server/errors.types.ts +++ b/packages/platform/src/server/errors.types.ts @@ -4,13 +4,13 @@ */ export type WebhookVerificationErrorCode = /** - * The request is missing one of the `webhook-id`, `webhook-timestamp` or `webhook-signature` headers. + * The request is missing one of the `webhook-id`, `webhook-timestamp`, or `webhook-signature` headers. * * Check that a proxy in front of your endpoint forwards all request headers. */ | "missing_headers" /** - * The `webhook-timestamp` header is more than `toleranceSeconds` (300 by default) away from your server's clock, or isn't a number. + * The `webhook-timestamp` header isn't a number, or is further from your server's clock than `toleranceSeconds`, which defaults to 300. * * Check your server's clock. A replayed old delivery fails this way too. */ @@ -38,7 +38,7 @@ export type WebhookVerificationErrorCode = /** * Codes of errors that stop verification before it starts, because the public keys you passed can't * be used. They're about your deployment, not the request, so answer them with a 500. Base44 retries - * the delivery, as it does for any answer that isn't 2xx, and it succeeds once you fix the problem. + * the delivery, as it does for any answer that isn't 2xx, and the retry succeeds once you fix the problem. */ export type WebhookConfigurationErrorCode = /** diff --git a/packages/platform/src/server/modules/webhooks.types.ts b/packages/platform/src/server/modules/webhooks.types.ts index 2cae2dfa..50fae629 100644 --- a/packages/platform/src/server/modules/webhooks.types.ts +++ b/packages/platform/src/server/modules/webhooks.types.ts @@ -52,9 +52,9 @@ export interface UnknownWebhookEvent extends Omit>; /** @@ -110,7 +110,7 @@ export interface CreateHandlerOptions extends VerifyEventOptions { /** Called for every verified event, before its handler in `on`. */ onEvent?: (event: DeliveredWebhookEvent) => unknown; /** - * Called after `onEvent` and the event's handler finish without throwing. + * Called after `onEvent()` and the event's handler finish without throwing. * * Return a `Response` to replace the default empty 204. */ @@ -119,23 +119,24 @@ export interface CreateHandlerOptions extends VerifyEventOptions { * Called when a delivery fails. * * The handler answers a `WebhookVerificationError` with a 400, and a `WebhookConfigurationError`, a - * failed body read or anything a callback throws with a 500. Base44 retries either way, because it - * retries any answer that isn't 2xx. The status only shows in its delivery log whether the delivery - * or your side failed. The `event` argument is set when the delivery verified and a callback failed. + * failed body read, or anything a callback throws with a 500. Base44 retries both, because it retries + * every answer that isn't 2xx. The two codes only differ in Base44's delivery log, where a 400 + * points to the delivery and a 500 points to your server. The `event` argument is set when the + * delivery verified and a callback failed. * Return a `Response` to replace the default answer. */ onError?: (error: unknown, event?: DeliveredWebhookEvent) => Response | void | Promise; } /** - * Webhooks module for receiving the events Base44 sends to your server. + * Webhooks module for receiving and verifying Base44's webhooks. * - * Base44 sends a signed event to your endpoint when something happens in your workspace: - * - An app is created, published, unpublished, moved to trash or restored. - * - Base44 tests a new endpoint before activating it. + * Base44 sends a signed event to your endpoint: + * - When an app is created, published, unpublished, moved to trash, or restored. + * - When you add the endpoint, to test it. * - * The module verifies each delivery's signature with your workspace's public keys, and gives you - * the event with its payload typed. It runs on Node.js 20 and later and on edge runtimes, and makes + * The module verifies each delivery's signature with your workspace's public keys and returns the + * event with a typed payload. It runs on Node.js 20 and later and on edge runtimes, and makes * no network requests. * * Use {@linkcode createHandler | createHandler()} for a ready-made route handler, or @@ -149,15 +150,17 @@ export interface WebhooksModule { * Pass the request body exactly as received, before any JSON parsing, because the signature covers * its exact bytes. Nothing is parsed until the signature checks out. * - * To activate a new endpoint, answer the `webhook.test.v1` event with - * `{ "challenge": event.data.challenge }` within 5 seconds. Answer every other event with a 2xx. + * Unlike {@linkcode createHandler | createHandler()}, `verifyEvent()` doesn't answer the request for + * you. When you add an endpoint, Base44 sends it a `webhook.test.v1` event and activates the endpoint + * only if you answer with `{ "challenge": event.data.challenge }` within 5 seconds. Answer every + * other event with a 2xx, or Base44 retries it. * * @param body - The raw request body, as text or bytes. * @param headers - The request headers. * @param options - Verification options, including your public keys. - * @returns Promise resolving to the verified event. - * @throws {WebhookVerificationError} When the delivery can't be trusted. Answer with a 400. - * @throws {WebhookConfigurationError} When the public keys are missing or malformed, or the runtime can't use them. Answer with a 500. + * @returns A promise that resolves to the verified event. + * @throws {WebhookVerificationError} The promise rejects with a `WebhookVerificationError` when the delivery can't be trusted. Answer with a 400. + * @throws {WebhookConfigurationError} The promise rejects with a `WebhookConfigurationError` when the public keys are missing or malformed, or the runtime can't use them. Answer with a 500. * * @example * ```typescript @@ -176,6 +179,7 @@ export interface WebhooksModule { * * switch (event.type) { * case 'webhook.test.v1': + * // Answer the test event so Base44 activates the endpoint * return Response.json({ challenge: event.data.challenge }); * case 'app.deleted.v1': * await removeApp(event.data.app_id); @@ -190,24 +194,25 @@ export interface WebhooksModule { /** * Creates a route handler that verifies deliveries and calls your handlers. * - * The handler takes a fetch `Request` and returns a `Response`, so it works as a Next.js route - * handler and in Hono, Cloudflare Workers, Deno and Bun. For each request it reads the raw body, - * verifies it, answers the activation probe itself, and then calls `onEvent`, the event's handler in - * `on`, and `onSuccess`, in that order. + * The returned function takes a standard Fetch API `Request` and returns a `Response`, so you can + * use it with any framework built on the Fetch API, such as Next.js route handlers, Hono, + * Cloudflare Workers, Deno, and Bun. For each request, it reads the raw body and + * verifies it. It answers the test event itself, and for every other event it calls `onEvent()`, + * then the event's handler in `on`, then `onSuccess()`. * * It answers with: - * - 204 when the event was handled, including events with no handler and unknown events. - * - 400 when the delivery doesn't verify, with the error code in the body. - * - 500 when the public keys are misconfigured (`receiver_misconfigured`), the body can't be read - * (`request_failed`) or a callback throws (`handler_failed`). + * - **204**: Event was handled, including events with no handler and unknown events. + * - **400**: Delivery doesn't verify. Body carries the error code. + * - **500**: Public keys are misconfigured (`receiver_misconfigured`), body can't be read + * (`request_failed`), or a callback throws (`handler_failed`). * - * Base44 retries any answer that isn't 2xx, 400 and 500 alike. The status only shows in its delivery - * log whether the delivery or your side failed. + * Base44 retries every answer that isn't 2xx, so it retries both 400 and 500. The two codes only + * differ in Base44's delivery log, where a 400 points to the delivery and a 500 points to your server. * - * Base44 can deliver the same event more than once, so make your handlers safe to run twice for the + * Base44 might deliver the same event more than once, so make your handlers safe to run twice for the * same `event.id`. * - * @param options - Your public keys, handlers and callbacks. + * @param options - Your public keys, handlers, and callbacks. * @returns A function that takes a `Request` and resolves to the `Response` to send. * * @example diff --git a/packages/platform/src/server/server.ts b/packages/platform/src/server/server.ts index 9ad6a3d2..083b0bed 100644 --- a/packages/platform/src/server/server.ts +++ b/packages/platform/src/server/server.ts @@ -9,7 +9,7 @@ import type { PlatformServer } from "./server.types.js"; * such as [`webhooks`](/developers/references/platform-sdk/docs/interfaces/webhooks). It takes no * options and makes no network requests. Each module takes what it needs when you call it. * - * @returns A server client with access to the platform's server modules. + * @returns A new platform server client. * * @example * ```typescript diff --git a/packages/platform/src/server/server.types.ts b/packages/platform/src/server/server.types.ts index a0a83c51..c22b3cf1 100644 --- a/packages/platform/src/server/server.types.ts +++ b/packages/platform/src/server/server.types.ts @@ -1,11 +1,9 @@ import type { WebhooksModule } from "./modules/webhooks.types.js"; /** - * The platform server client. - * - * Provides access to the platform's server modules. + * The platform server client, with one property for each module. */ export interface PlatformServer { - /** [Webhooks module](/developers/references/platform-sdk/docs/interfaces/webhooks) for receiving the events Base44 sends to your server. */ + /** [Webhooks module](/developers/references/platform-sdk/docs/interfaces/webhooks) for receiving and verifying Base44's webhooks. */ readonly webhooks: WebhooksModule; } diff --git a/packages/platform/typedoc.mintlify.json b/packages/platform/typedoc.mintlify.json index 30e709be..caf63650 100644 --- a/packages/platform/typedoc.mintlify.json +++ b/packages/platform/typedoc.mintlify.json @@ -10,11 +10,17 @@ ], "fileExtension": ".mdx", "gitRevision": "main", - "sort": ["source-order"], + "sort": [ + "source-order" + ], "entryFileName": "README.mdx", - "maxTypeConversionDepth": 3, + "maxTypeConversionDepth": 5, "hideBreadcrumbs": true, "disableSources": true, "hidePageTitle": true, - "intentionallyNotExported": ["src/client/modules/chat.events.generated.ts:SecretField"] + "intentionallyNotExported": ["src/client/modules/chat.events.generated.ts:SecretField"], + "validation": { + "notDocumented": true, + "notExported": false + } } diff --git a/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js b/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js index af0523d3..94912589 100644 --- a/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js +++ b/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js @@ -137,7 +137,7 @@ function updateDocsJson(repoDir, sdkFiles) { return Array.from(groupMap.entries()).map(([group, pages]) => ({ group, - expanded: true, + expanded: false, // Sort by basename, not full path: a plain .sort() groups all // "interfaces/..." entries before "type-aliases/..." entries (since // "i" < "t"), instead of interleaving both kinds alphabetically by @@ -172,6 +172,23 @@ function updateDocsJson(repoDir, sdkFiles) { const localePathPrefix = `${prefix}${basePath}/`; const groupReferencesSdkDocs = (group) => JSON.stringify(group).includes(localePathPrefix); + const isGroup = (node) => node !== null && typeof node === "object" && "group" in node; + // The reference group holds the generated subgroups, and those hold only pages. + const holdsOnlyPagesInSubgroups = (group) => + (group.pages ?? []).every( + (page) => !isGroup(page) || (page.pages ?? []).every((leaf) => !isGroup(leaf)) + ); + // The nav can nest the reference group inside a group per SDK, as the SDKs dropdown does, + // so look through the groups that wrap it. + const findReferenceGroup = (groups) => { + for (const group of groups) { + if (!isGroup(group) || !groupReferencesSdkDocs(group)) continue; + if (holdsOnlyPagesInSubgroups(group)) return group; + const nested = findReferenceGroup(group.pages ?? []); + if (nested) return nested; + } + return null; + }; const sdkReferencePages = buildSdkReferencePages(prefix); for (const tab of tabs) { @@ -180,13 +197,12 @@ function updateDocsJson(repoDir, sdkFiles) { tab.anchors?.find((a) => a.anchor === TARGET.dropdown); if (!sdkAnchor?.groups) continue; - let sdkRefIndex = sdkAnchor.groups.findIndex(groupReferencesSdkDocs); - if (sdkRefIndex === -1 && TARGET.group) { - sdkRefIndex = sdkAnchor.groups.push({ group: TARGET.group, icon: TARGET.icon, pages: [] }) - 1; + let existing = findReferenceGroup(sdkAnchor.groups); + if (!existing && TARGET.group) { + existing = { group: TARGET.group, icon: TARGET.icon, pages: [] }; + sdkAnchor.groups.push(existing); } - if (sdkRefIndex === -1) continue; - - const existing = sdkAnchor.groups[sdkRefIndex]; + if (!existing) continue; // Preserve existing subgroup labels (translated per locale) by position. // Falls back to the English category-map label when no existing subgroup @@ -197,13 +213,9 @@ function updateDocsJson(repoDir, sdkFiles) { group: existingSubgroups[i]?.group ?? g.group, })); - sdkAnchor.groups[sdkRefIndex] = { - ...existing, - group: existing.group, - icon: existing.icon ?? "brackets-curly", - expanded: true, - pages: localizedPages, - }; + existing.icon = existing.icon ?? "brackets-curly"; + existing.expanded = false; + existing.pages = localizedPages; updatedCount++; } } diff --git a/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js b/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js index b677e60a..30328e45 100755 --- a/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js +++ b/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js @@ -1557,55 +1557,36 @@ function applyIntroSectionGrouping(dir) { * of "Type Definitions" instead of nested under it. */ function groupTypeDefinitions(content) { - let modified = false; - const typeGroups = TYPE_DEFINITION_GROUPS.map((group) => { - const { title = "Type Definitions", types } = Array.isArray(group) ? { types: group } : group; - return { types, indicator: types[0], title }; + const { title = "Type Definitions", types, nested = [] } = Array.isArray(group) ? { types: group } : group; + return { types, nested, indicator: types[0], title }; }); - - // Find which type group exists in this file - let matchedGroup = null; - for (const group of typeGroups) { - if (content.includes(`## ${group.indicator}`)) { - matchedGroup = group; - break; - } - } - - if (!matchedGroup) { - return { content, modified: false }; - } - - // Find the position of the first type definition section - const firstTypeIndex = content.indexOf(`## ${matchedGroup.indicator}`); - if (firstTypeIndex === -1) { + + // A page can hold several groups, such as its type definitions and then its errors. Each group + // covers the stretch from its first type to the next group's first type. + const matched = typeGroups + .map((group) => ({ ...group, index: content.search(new RegExp(`^## ${group.indicator}$`, "m")) })) + .filter((group) => group.index !== -1) + .sort((a, b) => a.index - b.index); + if (matched.length === 0) { return { content, modified: false }; } - - // Insert "## Type Definitions" heading before the first type - const beforeTypeDefinitions = content.slice(0, firstTypeIndex); - const typeDefinitionsSection = content.slice(firstTypeIndex); - - // Demote all types in this group from ## to ### - let demotedSection = typeDefinitionsSection; - for (const typeName of matchedGroup.types) { - const regex = new RegExp(`^## ${typeName}$`, "m"); - demotedSection = demotedSection.replace(regex, `### ${typeName}`); - } - - // Any "##" heading still left in this slice belongs to one of the types - // above it (e.g. "## Properties" from an appended interface with members, - // since normalizeHeadings() clamps an appended article's own headings to - // the same H2 floor as its title). Bump these so they nest under their - // now-H3 type heading instead of rendering as a sibling of it. - demotedSection = demotedSection.replace(/^## (.+)$/gm, "#### $1"); - - const updatedContent = - beforeTypeDefinitions + - `## ${matchedGroup.title}\n\n` + - demotedSection; - + + let updatedContent = content.slice(0, matched[0].index); + matched.forEach((group, i) => { + let section = content.slice(group.index, i + 1 < matched.length ? matched[i + 1].index : content.length); + for (const typeName of group.types) { + section = section.replace(new RegExp(`^## ${typeName}$`, "m"), `### ${typeName}`); + } + // Any "##" heading still left in this slice belongs to one of the types + // above it (e.g. "## Properties" from an appended interface with members, + // since normalizeHeadings() clamps an appended article's own headings to + // the same H2 floor as its title). Bump these so they nest under their + // now-H3 type heading instead of rendering as a sibling of it. + section = section.replace(/^## (.+)$/gm, "#### $1"); + updatedContent += `## ${group.title}\n\n` + section; + }); + return { content: updatedContent, modified: true }; } @@ -2517,10 +2498,9 @@ function applyOverloadPresentation(dir) { } /** - * Tidy the generated actors page. TypeDoc inlines the ActorSubscription return - * type under `subscribe()`, which adds a duplicate `unsubscribe()` block. This - * drops the duplicate and points links to `ActorRef` at its renamed heading, Actor methods. A page - * without the expected shape is left unchanged. + * Tidy the generated actors page. Points links to `ActorRef` at its renamed heading, Actor methods, + * and drops a duplicate `unsubscribe()` block under `subscribe()` if the ActorSubscription return + * type was inlined there. */ function restructureActorsPage() { const file = path.join(DOCS_DIR, "content", "type-aliases", "actors.mdx"); @@ -2528,15 +2508,10 @@ function restructureActorsPage() { const duplicateUnsubscribe = /\n## Methods\n\n### unsubscribe\(\)[\s\S]*?<\/CodeGroup>\n/; const content = fs.readFileSync(file, "utf-8"); - if (!duplicateUnsubscribe.test(content)) { - console.warn("Warning: actors page has an unexpected structure and was left unchanged"); - return; - } - const tidied = content .replace(duplicateUnsubscribe, "\n") .replace("](#actorref)", "](#actor-methods)"); - fs.writeFileSync(file, tidied, "utf-8"); + if (tidied !== content) fs.writeFileSync(file, tidied, "utf-8"); } /** Class pages: no "Extends Error", which tells a reader nothing. */ @@ -2577,6 +2552,100 @@ function applyPropertiesAsFields(dir) { } } +/** + * TypeDoc writes one Throws heading per `@throws` tag. Consecutive Throws sections become one + * heading with a bullet per error, so a method with several errors doesn't repeat the heading. + */ +function mergeThrowsSections(content) { + const lines = content.split("\n"); + const out = []; + let i = 0; + while (i < lines.length) { + const heading = lines[i].match(/^(#{2,6}) Throws\s*$/); + if (!heading) { + out.push(lines[i++]); + continue; + } + const bodies = []; + while (i < lines.length && lines[i].trim() === `${heading[1]} Throws`) { + i++; + const body = []; + while (i < lines.length && !/^#{1,6} /.test(lines[i]) && lines[i].trim() !== "***") body.push(lines[i++]); + bodies.push(body.map((line) => line.trim()).filter(Boolean).join(" ")); + } + out.push(`${heading[1]} Throws`, ""); + if (bodies.length === 1) out.push(bodies[0], ""); + else out.push(...bodies.map((body) => `- ${body}`), ""); + } + return out.join("\n"); +} + +/** + * A group's nested types, such as an error's code list, move into an accordion under the `code` + * field of the type before them, the way Returns and options show their properties. `nested` maps + * each such type to its accordion title. This runs after the properties become fields, so the + * `code` field exists to attach to. + */ +function nestGroupedTypes(content) { + let nested = content; + for (const group of TYPE_DEFINITION_GROUPS) { + if (Array.isArray(group) || !group.nested) continue; + let host = null; + for (const typeName of group.types) { + const title = group.nested[typeName]; + if (!title) { + host = typeName; + continue; + } + const heading = new RegExp(`^### ${typeName}\\n`, "m"); + const start = nested.search(heading); + if (start === -1 || !host) continue; + const rest = nested.slice(start).replace(heading, ""); + const next = rest.search(/^#{1,3} /m); + const body = (next === -1 ? rest : rest.slice(0, next)).replace(/^\s*\*\*\*\s*/, "").trim(); + nested = nested.slice(0, start) + (next === -1 ? "" : rest.slice(next)); + const hostStart = nested.search(new RegExp(`^### ${host}$`, "m")); + const field = nested.indexOf('", field); + if (hostStart === -1 || fieldEnd === -1) continue; + const insertAt = fieldEnd + "".length; + // Accordions for one field go after any already added there. + let after = insertAt; + while (nested.startsWith("\n\n", after) + "".length; + } + nested = nested.slice(0, after) + `\n\n\n\n${body}\n\n` + nested.slice(after); + // The type no longer has its own section, so a link to it would go nowhere. + nested = nested.replaceAll(`[${typeName}](#${typeName.toLowerCase()})`, typeName); + } + } + return nested; +} + +function applyGroupedTypeNesting(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) applyGroupedTypeNesting(entryPath); + else if (entry.name.endsWith(".mdx")) { + const content = fs.readFileSync(entryPath, "utf-8"); + const nested = nestGroupedTypes(content); + if (nested !== content) fs.writeFileSync(entryPath, nested, "utf-8"); + } + } +} + +function applyThrowsMerging(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) applyThrowsMerging(entryPath); + else if (entry.name.endsWith(".mdx")) { + const content = fs.readFileSync(entryPath, "utf-8"); + const merged = mergeThrowsSections(content); + if (merged !== content) fs.writeFileSync(entryPath, merged, "utf-8"); + } + } +} + function applyClassPageCleanup(dir) { const classesDir = path.join(dir, "content", "classes"); if (!fs.existsSync(classesDir)) return; @@ -2602,10 +2671,15 @@ function linkReturnTypes(content) { const headings = [...Object.entries(TYPES_WITH_OWN_METHODS).map(([type, title]) => [type, `## ${title}`]), ...[...anchors.keys()].map((type) => [type, `### ${type}`])]; for (const [type, heading] of headings) { - const start = content.indexOf(`\n${heading}\n\n***\n\n`); + // Interface sections have a *** under the heading; type alias sections have none. + const separated = `\n${heading}\n\n***\n\n`; + const plain = `\n${heading}\n\n`; + let start = content.indexOf(separated); + if (start !== -1) content = content.replace(separated, plain); + else start = content.indexOf(plain); if (start === -1) continue; - content = content.replace(`\n${heading}\n\n***\n\n`, `\n${heading}\n\n`); - const body = content.slice(start + heading.length + 3); + // A type alias section opens with its `> **Name** = ...` declaration, which isn't description. + const body = content.slice(start + plain.length).replace(/^> \*\*.*\n\n/, ""); descriptions.set(type, body.slice(0, body.search(/\n(#|> |<|```)/)).trim()); } return content.replace(/(^#{4,5} Returns\n\n)`(\w+)`$/gm, (match, heading, type) => @@ -2681,6 +2755,8 @@ function main() { applyClassPageCleanup(DOCS_DIR); applyPropertiesAsFields(DOCS_DIR); + applyThrowsMerging(DOCS_DIR); + applyGroupedTypeNesting(DOCS_DIR); // Reorder methods according to method-order.json applyMethodOrdering(DOCS_DIR); diff --git a/packages/sdk/scripts/mintlify-post-processing/package-paths.js b/packages/sdk/scripts/mintlify-post-processing/package-paths.js index af5d7c39..bdf7d5f5 100644 --- a/packages/sdk/scripts/mintlify-post-processing/package-paths.js +++ b/packages/sdk/scripts/mintlify-post-processing/package-paths.js @@ -16,7 +16,7 @@ export function packagePaths(argv = process.argv.slice(2)) { // Where the reference lands in mintlify-docs. `group` creates the nav group when it is missing. const target = { path: "developers/references/sdk/docs", - dropdown: "SDK", + dropdown: "SDKs", ...(fs.existsSync(targetFile) ? JSON.parse(fs.readFileSync(targetFile, "utf8")) : {}), }; return { packageDir, docsDir: path.join(packageDir, "docs"), configDir, target }; diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js index 3e6d9b13..25ea43c9 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js @@ -479,9 +479,11 @@ function parsePropertiesFromTypeFile(content) { const lines = contentWithoutFrontmatter.split("\n"); - // Collect intro description until Properties section + // Collect the intro description, which ends at the page's first heading. A type alias page + // has no Properties section, so stopping only there pulled its declaration and examples in too. const introLines = []; let descriptionCaptured = false; + let introDone = false; // Extract Indexable section if present (use content without frontmatter) const indexSignature = parseIndexableSection(contentWithoutFrontmatter); @@ -500,7 +502,11 @@ function parsePropertiesFromTypeFile(content) { continue; } - if (!inPropertiesSection) { + if (!inPropertiesSection && line.match(/^#{1,6}\s+/)) { + introDone = true; + } + + if (!inPropertiesSection && !introDone && !line.startsWith("> **")) { if (line.trim()) { introLines.push(line); descriptionCaptured = true; diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js index 4236dff8..7d2296b1 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js @@ -388,6 +388,11 @@ function rewriteReturnSections(content, options) { if (trimmed.match(/^#{2,7}\s+Examples?/i) || trimmed === "***") { return true; } + // A "Throws" section sits beside Returns. Without this stop it was read as part of Returns, + // and a rewritten Returns section replaced it. + if (trimmed.match(/^#{2,7}\s+Throws\b/)) { + return true; + } if (heading !== "## Returns" && trimmed.startsWith("## ")) { return true; }