# ncblock/host

Host-side entrypoint. Use this when implementing the host end of the custom block bridge — the Notion app, the dev shell, tests, mocks, anything that talks to a sandboxed custom block over `postMessage`. Nothing here is meant for use inside a custom block and everything here should be considered a private API. The main author-facing API surface lives in `ncblock`.

Most exports here are thin re-exports of internal `bridge/*` modules. Hosts validate inbound sandbox traffic against the same valibot schemas the SDK uses on the sandbox side, so both ends of the bridge agree on shape.

```ts
import {
  pageChangedMessageSchema,
  parentChangedMessageSchema,
  readIncomingType,
  sandboxToHostMessageSchema,
  type PageChangedMessage,
  type ParentChangedMessage,
  type SandboxToHostMessage,
} from "ncblock/host";
```

`readIncomingType()` is intentionally best-effort. Use it after validation fails so the host can avoid NACK loops and include a useful reason in `invalidSandboxMessage`.

`createCustomBlockHost()` is an optional protocol loop for lightweight hosts. It owns iframe message listener setup, `ready` / `init`, protocol version checks, malformed-message NACKs, request routing, auto-resize callbacks, state-change messages, cleanup, and latest-query refresh bookkeeping. It deliberately does not know about any host's data model, permissions, iframe policy, analytics, or UI state.

```ts
import { createCustomBlockHost } from "ncblock/host";

const host = createCustomBlockHost({
  iframe,
  initialState: {
    theme,
    blockId,
    parent,
    page,
    currentUser,
    dataSources: { bindings },
  },
  handlers: {
    queryDataSource: async (message) => ({ items: [], hasMore: false }),
  },
});
```

Use it when the generic protocol loop is helpful. Hosts with more specialized runtime needs can keep using the schemas and message types directly.

Related types: `CustomBlockHostOptions`, `CustomBlockHostInitialState`, `CustomBlockHostInitialDataSources`, `CustomBlockHostDataSourcesPayload`, `CustomBlockHostHandlers`, `CustomBlockHostHandle`, `CustomBlockHostLogDirection`, `CustomBlockHostQueryDataSourceResult`, `CustomBlockHostCreatePageResult`, `CustomBlockHostGetPageResult`, `CustomBlockHostUpdatePageResult`, `CustomBlockHostGetUserResult`, and `CustomBlockHostListUsersResult`.

## Bridge protocol

### Lifecycle

The bridge is established with a handshake: the sandbox posts a `ready` when `initCustomBlock()` starts, and the host validates it and replies exactly once with `init`. Hosts should log and ignore duplicate `ready` messages instead of re-running the handshake. After that, narrower messages stream live state updates.

The sandbox starts by sending `ready`. It declares the bridge protocol version, includes the ncblock package semver as `sdkVersion` when available, and tells the host whether manifest discovery produced a usable manifest. Hosts should reject init when `ready` reports a manifest error, echoing the sandbox's error code:

```ts
// sandbox → host
{ type: "ready", status: "success", bridgeProtocolVersion, sdkVersion, manifest: { /* custom_blocks.json */ } }

// sandbox → host, no manifest / no declared data requirements
{ type: "ready", status: "success", bridgeProtocolVersion, sdkVersion, manifest: null }

// sandbox → host, manifest failed to load or validate
{ type: "ready", status: "error", bridgeProtocolVersion, sdkVersion, error: { code: "manifest_invalid", message: "..." } }
```

The host replies with exactly one `init`, which has two statuses. On success it carries the full initial state — `theme`, `blockId`, `parent`, `page`, `currentUser` (the `NotionUser` shape from the user result messages), and `dataSources: { bindings }`:

```ts
// host → sandbox
{ type: "init", status: "success", theme, blockId, parent, page: { id }, currentUser, dataSources: { bindings } }
```

On failure it carries `error: { code, message, isRetryable }` (`CustomBlockInitErrorInfo` / `customBlockInitErrorInfoSchema`, with `code` drawn from `CustomBlockInitErrorCode` / `customBlockInitErrorCodeSchema`). The SDK surfaces this as a `CustomBlockInitError`:

```ts
// host → sandbox
{ type: "init", status: "error", error: { code: "context_unavailable", message: "...", isRetryable: true } }
```

After `init`, narrower messages update live state without re-running the handshake — `themeChanged`, `parentChanged`, `pageChanged`, `currentUserChanged`, and `dataSourcesChanged`. Each replaces just its slice of state:

```ts
// host → sandbox, any time after init
{ type: "themeChanged", theme }
{ type: "parentChanged", parent }
{ type: "pageChanged", page: { id } }
{ type: "dataSourcesChanged", dataSources: { bindings } }
```

### Versioning

`ready` includes `bridgeProtocolVersion` (the host <-> sandbox version) and `sdkVersion` (the package semver). Hosts should reject or fail `init` when the sandbox reports a `bridgeProtocolVersion` the host does not implement, but should treat `sdkVersion` as purely analytics metadata.

If the sandbox reports a version below the host's supported minimum, reply with `init.status: "error"` and `error.code: "unsupported_protocol_version"`. If the reported version is structurally invalid (not a positive integer — e.g. `0`, negative, fractional, `NaN`, or `Infinity`), reply with `error.code: "invalid_protocol_version"` instead.

### Conventions

Every request/response pair uses a string `requestId`. The sender tracks pending requests by id; the receiver echoes the id on the matching result. The sandbox drops stale results that no longer match the latest outstanding request for that operation.

Both sides validate inbound messages with valibot schemas. Failed parses are logged and NACKed with `invalidHostMessage` or `invalidSandboxMessage`. Never answer a NACK with another NACK.

Block-author APIs use semantic data-source keys, but sandbox-to-host object references are resolved to raw IDs before posting.

Every bridge error payload uses the same `{ code, message, isRetryable }` shape. `CustomBlockErrorInfo<TCode>` is generic so each bridge API can expose its own code union while preserving one wire format. Hosts should pick stable codes and put user/developer-readable detail in `message`; sandboxes should branch on `code` and `isRetryable`, not message text.

`code` is any string on the wire. Schemas validate only the error _shape_ (`code`/`message`/`isRetryable` types); per-API code unions are TypeScript open-enums for autocomplete. Receivers MUST accept well-shaped errors with unknown codes so newer senders can add codes without breaking older receivers.

Host implementers can use the API-specific aliases and schemas when shaping outbound failures: `CustomBlockCreatePageErrorCode` / `CustomBlockCreatePageErrorInfo` / `customBlockCreatePageErrorCodeSchema` / `customBlockCreatePageErrorInfoSchema`, `CustomBlockGetPageErrorCode` / `CustomBlockGetPageErrorInfo` / `customBlockGetPageErrorCodeSchema` / `customBlockGetPageErrorInfoSchema`, `CustomBlockGetUserErrorCode` / `CustomBlockGetUserErrorInfo` / `customBlockGetUserErrorCodeSchema` / `customBlockGetUserErrorInfoSchema`, `CustomBlockListUsersErrorCode` / `CustomBlockListUsersErrorInfo` / `customBlockListUsersErrorCodeSchema` / `customBlockListUsersErrorInfoSchema`, `CustomBlockQueryDataSourceErrorCode` / `CustomBlockQueryDataSourceErrorInfo` / `customBlockQueryDataSourceErrorCodeSchema` / `customBlockQueryDataSourceErrorInfoSchema`, and `CustomBlockUpdatePageErrorCode` / `CustomBlockUpdatePageErrorInfo` / `customBlockUpdatePageErrorCodeSchema` / `customBlockUpdatePageErrorInfoSchema`. Shared categories are exported as `CustomBlockPropertyErrorCode` / `CustomBlockPropertyErrorInfo` / `customBlockPropertyErrorCodeSchema` and `CustomBlockDataSourceResolutionErrorCode` / `CustomBlockDataSourceResolutionErrorInfo` / `customBlockDataSourceResolutionErrorCodeSchema`. The broad fallback schema remains `customBlockErrorInfoSchema`.

`queryDataSource` carries the resolved raw `dataSourceId` and a sandbox-generated `snapshotId`; semantic data-source keys never cross the bridge for queries. `createPage` arrives with `parent` already resolved to `page_id` or `data_source_id`.

## Data sources

Types:

- `CustomBlockPage` — the current page slice carried in `init.page` and `pageChanged`.
- `NotionDataSourceBinding` — a single binding (collection pointer + schema + property mapping).
- `NotionDataSourceBindings` — keyed-by-semantic-key map of bindings, the shape carried in `init.dataSources` and `dataSourcesChanged`.
- `NotionDataSourcePageBridge` — wire shape for a single page (raw property IDs in `propertiesById`). The SDK derives the consumer-facing `NotionDataSourcePage` from it.

Schemas:

- `notionBlockIdSchema`
- `customBlockPageSchema`
- `notionDataSourceIdSchema`
- `notionDataSourceBindingSchema`
- `notionDataSourceBindingsSchema`
- `notionDataSourcePageBridgeSchema`
- `notionPageIdSchema`
- `notionParentSchema`

## Sandbox → host messages

Messages sent from the sandbox to the host. Parse `window` `message` events with `sandboxToHostMessageSchema` (or per-message schemas). The type / schema column points to the payload shape.

| Wire type            | Type / schema                                             | Behavior                                                                                                                                                                                                                                |
| -------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready`              | `ReadyMessage` / `readyMessageSchema`                     | One-shot handshake; carries `bridgeProtocolVersion`, `sdkVersion`, and the manifest.                                                                                                                                                     |
| `queryDataSource`    | `QueryDataSourceMessage` / `queryDataSourceMessageSchema` | `requestId`-keyed request for the current rows in a raw `dataSourceId`. `snapshotId` names the SDK result slot to update, so later refreshes for the same `useDataSource` call replace the same snapshot instead of creating a new one. |
| `createPage`         | `CreatePageMessage` / `createPageMessageSchema`           | `requestId`-keyed page creation; parent is `page_id` or `data_source_id`.                                                                                                                                                               |
| `getPage`            | `GetPageMessage` / `getPageMessageSchema`                 | `requestId`-keyed page fetch by page id.                                                                                                                                                                                                |
| `updatePage`         | `UpdatePageMessage` / `updatePageMessageSchema`           | `requestId`-keyed patch (properties, icon, cover, or `archived`).                                                                                                                                                                       |
| `getUser`            | `GetUserMessage` / `getUserMessageSchema`                 | `requestId`-keyed user fetch by user id.                                                                                                                                                                                                |
| `listUsers`          | `ListUsersMessage` / `listUsersMessageSchema`             | `requestId`-keyed user list with optional `startCursor` and `pageSize`.                                                                                                                                                                 |
| `resize`             | `ResizeMessage` / `resizeMessageSchema`                   | Latest measured content height from auto-resize.                                                                                                                                                                                        |
| `invalidHostMessage` | `InvalidHostMessage` / `invalidHostMessageSchema`         | Sandbox-side NACK for a host message it could not parse.                                                                                                                                                                                |

`SandboxToHostMessage` / `sandboxToHostMessageSchema` is the discriminated union over all of the above.

## Host → sandbox messages

Messages sent from the host to the sandbox. Same `{ wire type, type / schema, behavior }` shape; the type / schema column points to the payload shape.

| Wire type               | Type / schema                                                         | Behavior                                                                                                                  |
| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `init`                  | `InitMessage` / `initMessageSchema`                                   | Sent exactly once in response to `ready`; carries the success/error payload described under [Lifecycle](#lifecycle).      |
| `themeChanged`          | `ThemeChangedMessage` / `themeChangedMessageSchema`                   | Replaces the current theme.                                                                                               |
| `parentChanged`         | `ParentChangedMessage` / `parentChangedMessageSchema`                 | Replaces the custom block parent without disturbing theme, block ID, page ID, user, or query state.                       |
| `pageChanged`           | `PageChangedMessage` / `pageChangedMessageSchema`                     | Replaces the nearest page ancestor without disturbing theme, block ID, parent, user, or query state.                      |
| `currentUserChanged`    | `CurrentUserChangedMessage` / `currentUserChangedMessageSchema`       | Replaces the current viewer record. Send when any viewer field changes (name, avatar, email).                             |
| `dataSourcesChanged`    | `DataSourcesChangedMessage` / `dataSourcesChangedMessageSchema`       | Replaces data-source bindings; the sandbox preserves cached query state for keys that still exist and drops removed keys. |
| `queryDataSourceResult` | `QueryDataSourceResultMessage` / `queryDataSourceResultMessageSchema` | `requestId` / `snapshotId`-keyed response with `items`, `hasMore`, and optional `error`.                                  |
| `createPageResult`      | `CreatePageResultMessage` / `createPageResultMessageSchema`           | `requestId`-keyed page response with `status: "success"` or `status: "error"`.                                            |
| `getPageResult`         | `GetPageResultMessage` / `getPageResultMessageSchema`                 | Same success/error shape as `createPageResult`.                                                                           |
| `updatePageResult`      | `UpdatePageResultMessage` / `updatePageResultMessageSchema`           | Same success/error shape as `createPageResult`.                                                                           |
| `getUserResult`         | `GetUserResultMessage` / `getUserResultMessageSchema`                 | `requestId`-keyed user response with `status: "success"` or `status: "error"`.                                            |
| `listUsersResult`       | `ListUsersResultMessage` / `listUsersResultMessageSchema`             | Same success/error shape as `getUserResult`.                                                                              |
| `invalidSandboxMessage` | `InvalidSandboxMessage` / `invalidSandboxMessageSchema`               | Host-side NACK for a sandbox message it could not parse.                                                                  |

`HostToSandboxMessage` / `hostToSandboxMessageSchema` is the discriminated union over all of the above.

For result messages with `status: "error"` and for `queryDataSourceResult.error`, `error` always has the `CustomBlockErrorInfo<TCode>` shape. The concrete result types narrow `TCode` to the API-specific union, such as `CustomBlockCreatePageErrorCode`, `CustomBlockUpdatePageErrorCode`, or `CustomBlockQueryDataSourceErrorCode`.
