import * as v from "valibot" import type { CreatePageInput, CreatePageResult, GetPageResult, GetUserResult, ListUsersInput, ListUsersResult, NotionPageId, NotionUserId, UpdatePageInput, UpdatePageResult, UseDataSourceOptions, } from "../types.js" import { unreachable } from "../utils.js" import { NCBLOCK_SDK_VERSION } from "../version.js" import type { NotionDataSource, NotionDataSourceBindings, } from "./dataSources/dataSource.js" import type { NotionDataSourcePageUpdateInput, NotionDataSourcePageUpdateResult, } from "./dataSources/dataSourcePage.js" import { resolveDataSources } from "./dataSources/resolve.js" import { resolvePropertyWriteMapForDataSource } from "./dataSources/resolveProperty.js" import { type CustomBlockHostState, createEmptyDataSourceQueryState, type DataSourceQueryState, } from "./hostState.js" import { readIncomingType } from "./incomingType.js" import type { ManifestLoadResult } from "./loadManifest.js" import type { CustomBlockManifest } from "./manifest.js" import type { CreatePageMessage, CreatePageMessageParent, } from "./messages/createPage.js" import type { CustomBlockCreatePageErrorInfo } from "./messages/createPageResult.js" import type { GetUserMessage } from "./messages/getUser.js" import { hostToSandboxMessageSchema } from "./messages/hostToSandbox.js" import { CustomBlockInitError, type InitMessage } from "./messages/init.js" import type { InvalidHostMessage } from "./messages/invalidHostMessage.js" import type { ListUsersMessage } from "./messages/listUsers.js" import type { QueryDataSourceMessage } from "./messages/queryDataSource.js" import type { CustomBlockQueryDataSourceErrorInfo } from "./messages/queryDataSourceResult.js" import type { ReadyMessage } from "./messages/ready.js" import type { ResizeMessage } from "./messages/resize.js" import type { UpdatePageMessage } from "./messages/updatePage.js" import { PendingRequests } from "./pendingRequests.js" import type { NotionUser } from "./users/user.js" /** * Used to ensure that the host and client are using the same version of the bridge protocol. A * single host needs to support multiple custom blocks built with different versions of the bridge * protocol. Increment this number any time a breaking change is made to the bridge protocol. */ export const CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION = 2 /** * A single entry in the bridge message log. Kept intentionally plain so the log * is copy-pasteable to a local coding agent without needing extra context. */ export type MessageLogEntry = { timestamp: string direction: "sent" | "received" data: unknown } export class SandboxBridge { private hostState: CustomBlockHostState = { status: "uninitialized", theme: "light", } private listeners = new Set<() => void>() private messageLog: MessageLogEntry[] = [] private messageLogListeners = new Set<() => void>() private nextRequestId = 1 private readonly pendingCreatePage = new PendingRequests( "custom-block-create-page", ) private readonly pendingGetPage = new PendingRequests( "custom-block-get-page", ) private readonly pendingGetUser = new PendingRequests( "custom-block-get-user", ) private readonly pendingListUsers = new PendingRequests( "custom-block-list-users", ) private readonly pendingUpdatePage = new PendingRequests( "custom-block-update-page", ) private hasSentReady = false private latestDataSourceBindings: NotionDataSourceBindings = {} private resolveInit: ((message: InitMessage) => void) | undefined private rejectInit: ((reason: Error) => void) | undefined private readonly initMessage: Promise = new Promise( (resolve, reject) => { this.resolveInit = resolve this.rejectInit = reject }, ) private manifest: CustomBlockManifest | null = null constructor() { // `ready` is sent later by `initCustomBlock` (after the manifest fetch // resolves). Top-level / no-iframe rejection is handled there too, so // the constructor just attaches the listener. if (typeof window !== "undefined") { window.addEventListener("message", this.handleMessage) } } private static MAX_LOG_ENTRIES = 100 private logMessage(direction: "sent" | "received", data: unknown) { if (this.messageLog.length >= SandboxBridge.MAX_LOG_ENTRIES) { this.messageLog.shift() } this.messageLog.push({ timestamp: new Date().toISOString(), direction, data, }) for (const listener of this.messageLogListeners) { listener() } } getMessageLog(): readonly MessageLogEntry[] { return this.messageLog } subscribeToMessageLog(listener: () => void) { this.messageLogListeners.add(listener) return () => this.messageLogListeners.delete(listener) } awaitInit(signal?: AbortSignal): Promise { if (!signal) { return this.initMessage } return new Promise((resolve, reject) => { if (signal.aborted) { reject(signal.reason) return } const onAbort = () => reject(signal.reason) signal.addEventListener("abort", onAbort, { once: true }) this.initMessage.then( message => { signal.removeEventListener("abort", onAbort) resolve(message) }, err => { signal.removeEventListener("abort", onAbort) reject(err) }, ) }) } sendReady(manifestResult: ManifestLoadResult) { if (typeof window === "undefined") { return } if (this.hasSentReady) { console.warn("[notion-custom-sdk] ignoring duplicate ready message") return } const { manifest, error } = manifestResult this.hasSentReady = true this.manifest = manifest const readyMessage: ReadyMessage = error !== undefined ? { type: "ready", status: "error", bridgeProtocolVersion: CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION, sdkVersion: NCBLOCK_SDK_VERSION, error, } : { type: "ready", status: "success", bridgeProtocolVersion: CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION, sdkVersion: NCBLOCK_SDK_VERSION, manifest, } this.postToHost(readyMessage) } private postToHost(message: unknown) { console.debug("[notion-custom-sdk] outbound postMessage", message) this.logMessage("sent", message) window.parent.postMessage(message, "*") } private notify = () => { for (const listener of this.listeners) { listener() } } private handleMessage = (event: MessageEvent) => { console.debug("[notion-custom-sdk] incoming postMessage", { data: event.data, fromParent: event.source === window.parent, }) if (event.source !== window.parent) { return } this.logMessage("received", event.data) const parsed = v.safeParse(hostToSandboxMessageSchema, event.data) if (!parsed.success) { console.warn( "[notion-custom-sdk] ignoring malformed host message", parsed.issues, ) const incomingType = readIncomingType(event.data) // Loop guard: never NACK a NACK. Excludes both directions — // `invalidSandboxMessage` is what the host normally sends, but // a buggy host that echoes our own `invalidHostMessage` back // would otherwise spin a NACK feedback loop. if ( incomingType !== "invalidSandboxMessage" && incomingType !== "invalidHostMessage" ) { const nack: InvalidHostMessage = { type: "invalidHostMessage", reason: formatInvalidHostReason(incomingType, parsed.issues), } this.postToHost(nack) } return } const message = parsed.output // If the host couldn't parse one of our outbound sandbox-to-host messages, it sends back // an `invalidSandboxMessage` NACK with a human-readable `reason`. Log it for developer // visibility and stop. We don't retry as the sandbox can't recover from a host-side parse // failure on its own. if (message.type === "invalidSandboxMessage") { console.warn( "[notion-custom-sdk] host reported invalid sandbox message:", message.reason, ) return } // `init` is the only message valid before initialization. Handle it up // front so every later case can assume `status === "initialized"`. if (message.type === "init") { this.applyInit(message) return } // Alias to keep TS's narrowed `InitializedHostState` across the switch // below. Reading `this.hostState` repeatedly would re-widen it. const hostState = this.hostState if (hostState.status !== "initialized") { console.warn(`[notion-custom-sdk] ignoring ${message.type} before init`) return } switch (message.type) { case "themeChanged": { this.hostState = { ...hostState, theme: message.theme, } this.notify() return } case "parentChanged": { this.hostState = { ...hostState, parent: message.parent, } this.notify() return } case "pageChanged": { this.hostState = { ...hostState, page: message.page, } this.notify() return } case "currentUserChanged": { this.hostState = { ...hostState, currentUser: message.currentUser, } this.notify() return } case "dataSourcesChanged": { const nextBindings = message.dataSources.bindings const dataSources = reuseDataSourcesForUnchangedBindings({ previousDataSources: hostState.dataSources, previousBindings: this.latestDataSourceBindings, nextDataSources: resolveDataSources({ manifest: this.manifest, dataSourceBindings: nextBindings, }), nextBindings, }) this.latestDataSourceBindings = nextBindings // Drop cached query state for keys that no longer exist in the mapping. const nextKeys = new Set(dataSources.map(s => s.key)) const prunedState: Record = {} for (const [key, state] of Object.entries(hostState.dataSourceState)) { if (nextKeys.has(key)) { prunedState[key] = state } } this.hostState = { ...hostState, dataSources, dataSourceState: prunedState, } this.notify() return } case "createPageResult": { const result: CreatePageResult = message.status === "success" ? { status: "success", page: message.page } : { status: "error", error: message.error } if (!this.pendingCreatePage.resolve(message.requestId, result)) { console.warn( `[notion-custom-sdk] createPageResult for unknown requestId ${message.requestId}`, ) } return } case "getPageResult": { const result: GetPageResult = message.status === "success" ? { status: "success", page: message.page } : { status: "error", error: message.error } if (!this.pendingGetPage.resolve(message.requestId, result)) { console.warn( `[notion-custom-sdk] getPageResult for unknown requestId ${message.requestId}`, ) } return } case "getUserResult": { const result: GetUserResult = message.status === "success" ? { status: "success", user: message.user as unknown as NotionUser } : { status: "error", error: message.error } if (!this.pendingGetUser.resolve(message.requestId, result)) { console.warn( `[notion-custom-sdk] getUserResult for unknown requestId ${message.requestId}`, ) } return } case "listUsersResult": { const result: ListUsersResult = message.status === "success" ? { status: "success", list: message.list } : { status: "error", error: message.error } if (!this.pendingListUsers.resolve(message.requestId, result)) { console.warn( `[notion-custom-sdk] listUsersResult for unknown requestId ${message.requestId}`, ) } return } case "updatePageResult": { const result: UpdatePageResult = message.status === "success" ? { status: "success", page: message.page } : { status: "error", error: message.error } if (!this.pendingUpdatePage.resolve(message.requestId, result)) { console.warn( `[notion-custom-sdk] updatePageResult for unknown requestId ${message.requestId}`, ) } return } case "queryDataSourceResult": { const queryEntry = Object.entries(hostState.dataSourceState).find( ([, state]) => state.latestSnapshotId === message.snapshotId, ) if (queryEntry === undefined) { return } const [key, currentState] = queryEntry if (currentState.latestRequestId !== message.requestId) { return } this.hostState = { ...hostState, dataSourceState: { ...hostState.dataSourceState, [key]: { items: message.items, isLoading: false, hasMore: message.hasMore, error: message.error, // Keep the request ID so later host-pushed refreshes for the // same subscription still match. latestRequestId: message.requestId, latestSnapshotId: message.snapshotId, latestLimit: currentState.latestLimit, }, }, } this.notify() return } default: { unreachable(message) } } } subscribe(listener: () => void) { this.listeners.add(listener) return () => this.listeners.delete(listener) } getHostState(): CustomBlockHostState { return this.hostState } /** * The author-declared manifest loaded from `custom_blocks.json` and forwarded * to the host in `ready`. `null` when it failed to load/parse and the host * should reject init via `ready.status: "error"`. Static for the lifetime * of the sandbox. */ getManifest(): CustomBlockManifest | null { return this.manifest } /** * Apply an `init` payload as if it had arrived from the host. Lets callers * seed the bridge directly (e.g. the React provider's standalone preview * fallback) without going through `postMessage`. The bridge stays unaware * of why it's being seeded. */ setMockState(message: InitMessage) { this.applyInit(message) } private applyInit(message: InitMessage) { if (message.status === "error") { // The host couldn't construct block location for this block (most commonly the parent record // failed to resolve). Surface the failure through the `awaitInit` promise so callers see // it through `useCustomBlockInit().error` instead of timing out. We also log here // because not every consumer renders the error UI, and we want the failure visible in // the browser console either way. We deliberately don't NACK the host or post anything // back over the bridge since the host has already given up. console.error( `[notion-custom-sdk] host reported init error (${message.error.code}): ${message.error.message}`, ) if (this.rejectInit) { this.rejectInit(new CustomBlockInitError(message.error)) this.resolveInit = undefined this.rejectInit = undefined } return } const { blockId, parent, page } = message this.latestDataSourceBindings = message.dataSources.bindings const dataSources = resolveDataSources({ manifest: this.manifest, dataSourceBindings: this.latestDataSourceBindings, }) this.hostState = { status: "initialized", theme: message.theme, blockId, parent, page, currentUser: message.currentUser, dataSources, dataSourceState: {}, } this.notify() // Resolve the awaitInit promise once. Subsequent `init` messages // (the host shouldn't send these, but be tolerant) update state but // don't re-resolve. if (this.resolveInit) { this.resolveInit(message) this.resolveInit = undefined this.rejectInit = undefined } } queryDataSource(key: string, options: UseDataSourceOptions = {}) { if (this.hostState.status !== "initialized") { return } const dataSource = this.hostState.dataSources.find( entry => entry.key === key, ) const currentState = this.hostState.dataSourceState[key] ?? createEmptyDataSourceQueryState() if (dataSource === undefined) { this.setDataSourceQueryError(key, currentState, { code: "unknown_data_source_key", message: `Unknown data source key "${key}". Known keys: [${this.hostState.dataSources.map(entry => entry.key).join(", ")}].`, isRetryable: false, }) return } if (dataSource.collectionPointer === undefined) { this.setDataSourceQueryError(key, currentState, { code: "unmapped_data_source", message: `Data source "${key}" has not been mapped to a database yet.`, isRetryable: false, }) return } const limit = resolveDataSourceQueryLimit(options.limit) const snapshotId = makeDataSourceSnapshotId({ key, }) if ( currentState.isLoading && currentState.latestSnapshotId === snapshotId && currentState.latestLimit === limit ) { return } const requestId = `custom-block-query-${this.nextRequestId}` this.nextRequestId += 1 this.hostState = { ...this.hostState, dataSourceState: { ...this.hostState.dataSourceState, [key]: { ...currentState, isLoading: true, error: undefined, latestRequestId: requestId, latestSnapshotId: snapshotId, latestLimit: limit, }, }, } this.notify() const outbound: QueryDataSourceMessage = { type: "queryDataSource", requestId, snapshotId, dataSourceId: dataSource.collectionPointer.id, limit, } this.postToHost(outbound) } private setDataSourceQueryError( key: string, currentState: DataSourceQueryState, error: CustomBlockQueryDataSourceErrorInfo, ) { if (this.hostState.status !== "initialized") { return } this.hostState = { ...this.hostState, dataSourceState: { ...this.hostState.dataSourceState, [key]: { ...currentState, isLoading: false, error, latestRequestId: undefined, latestSnapshotId: undefined, latestLimit: undefined, }, }, } this.notify() } postResize(height: number) { if (typeof window === "undefined") { return } const safeHeight = Number.isFinite(height) && height >= 0 ? Math.ceil(height) : 0 const outbound: ResizeMessage = { type: "resize", height: safeHeight, } this.postToHost(outbound) } createPage(input: CreatePageInput): Promise { return new Promise(resolve => { const resolvedParent = this.resolveCreatePageParent(input.parent) if (resolvedParent.status === "error") { resolve(resolvedParent) return } const resolvedProperties = resolvePropertyWriteMapForDataSource({ dataSource: resolvedParent.dataSource, properties: input.properties, operationName: "createPage", }) if (resolvedProperties.status === "error") { resolve(resolvedProperties) return } const requestId = this.pendingCreatePage.allocate(resolve) const outbound: CreatePageMessage = { type: "createPage", requestId, parent: resolvedParent.parent, properties: resolvedProperties.properties, } if (input.icon !== undefined) { outbound.icon = input.icon } if (input.cover !== undefined) { outbound.cover = input.cover } if (input.position !== undefined) { outbound.position = input.position } this.postToHost(outbound) }) } getPage(pageId: NotionPageId): Promise { return new Promise(resolve => { const requestId = this.pendingGetPage.allocate(resolve) const outbound = { type: "getPage", requestId, pageId, } this.postToHost(outbound) }) } getUser(userId: NotionUserId): Promise { return new Promise(resolve => { const requestId = this.pendingGetUser.allocate(resolve) const outbound: GetUserMessage = { type: "getUser", requestId, userId, } this.postToHost(outbound) }) } listUsers(input: ListUsersInput = {}): Promise { return new Promise(resolve => { const requestId = this.pendingListUsers.allocate(resolve) const outbound: ListUsersMessage = { type: "listUsers", requestId, startCursor: input.startCursor, pageSize: input.pageSize, } this.postToHost(outbound) }) } updatePage(input: UpdatePageInput): Promise { return new Promise(resolve => { if ( (input.properties === undefined || Object.keys(input.properties).length === 0) && input.icon === undefined && input.cover === undefined && input.archived === undefined ) { resolve({ status: "error", error: { code: "invalid_page_update", message: "updatePage requires at least one of: properties, icon, cover, archived.", isRetryable: false, }, }) return } const requestId = this.pendingUpdatePage.allocate(resolve) const outbound: UpdatePageMessage = { type: "updatePage", requestId, pageId: input.pageId, } if (input.properties !== undefined) { outbound.properties = input.properties } if (input.icon !== undefined) { outbound.icon = input.icon } if (input.cover !== undefined) { outbound.cover = input.cover } if (input.archived !== undefined) { outbound.archived = input.archived } this.postToHost(outbound) }) } /** * Updates a page on a known data source, resolving any property keys against the data source's * `propertyIdsByKey` before sending the bridge message. Used by the per-row `update` callback * returned from {@link getDataSourceQueryView}. */ updateDataSourcePage(args: { dataSource: NotionDataSource pageId: NotionPageId input: NotionDataSourcePageUpdateInput }): Promise { const { dataSource, pageId, input } = args const resolvedProperties = input.properties === undefined ? undefined : resolvePropertyWriteMapForDataSource({ dataSource, properties: input.properties, operationName: "dataSourcePage.update", }) if (resolvedProperties?.status === "error") { return Promise.resolve(resolvedProperties) } return this.updatePage({ pageId, properties: resolvedProperties?.properties, icon: input.icon, cover: input.cover, archived: input.archived, }) } /** * Translates the public `CreatePageInput["parent"]` into the bridge-native * `CreatePageMessageParent`. The `data_source_key` variant is resolved sandbox-side against * the data source mapping the host delivered in `init` / `dataSourcesChanged`. */ private resolveCreatePageParent(parent: CreatePageInput["parent"]): | { status: "ok" parent: CreatePageMessageParent dataSource: NotionDataSource | undefined } | { status: "error"; error: CustomBlockCreatePageErrorInfo } { switch (parent.type) { case "page_id": return { status: "ok", parent, dataSource: undefined } case "data_source_id": { const dataSource = this.hostState.status === "initialized" ? this.hostState.dataSources.find( entry => entry.collectionPointer?.id === parent.data_source_id, ) : undefined return { status: "ok", parent, dataSource } } case "data_source_key": { if (this.hostState.status !== "initialized") { return { status: "error", error: { code: "unknown_data_source_key", message: `Cannot resolve data source key "${parent.key}" before the host has initialized the SDK.`, isRetryable: false, }, } } const dataSource = this.hostState.dataSources.find( entry => entry.key === parent.key, ) if (dataSource === undefined) { return { status: "error", error: { code: "unknown_data_source_key", message: `Unknown data source key "${parent.key}". Known keys: [${this.hostState.dataSources.map(entry => entry.key).join(", ")}].`, isRetryable: false, }, } } if (dataSource.collectionPointer === undefined) { return { status: "error", error: { code: "unmapped_data_source", message: `Data source "${parent.key}" has not been mapped to a database yet.`, isRetryable: false, }, } } return { status: "ok", parent: { type: "data_source_id", data_source_id: dataSource.collectionPointer.id, }, dataSource, } } default: unreachable(parent) } } } /** * `postMessage` cloning creates new objects even for bindings that did not change. * Preserve their resolved object identity so React effects only run for changed bindings. * * This comparison runs when the host sends an update instead of on every component render. * Bindings are validated JSON-like bridge payloads, so serialization is sufficient here. */ function reuseDataSourcesForUnchangedBindings(args: { previousDataSources: NotionDataSource[] previousBindings: NotionDataSourceBindings nextDataSources: NotionDataSource[] nextBindings: NotionDataSourceBindings }): NotionDataSource[] { const previousDataSourcesByKey = new Map( args.previousDataSources.map(dataSource => [dataSource.key, dataSource]), ) return args.nextDataSources.map(dataSource => { const previousDataSource = previousDataSourcesByKey.get(dataSource.key) if ( previousDataSource !== undefined && JSON.stringify(args.previousBindings[dataSource.key]) === JSON.stringify(args.nextBindings[dataSource.key]) ) { return previousDataSource } return dataSource }) } // The default number of items to return in a live snapshot response if no limit is provided. const DEFAULT_DATA_SOURCE_QUERY_LIMIT = 20 // The maximum number of items to return in a single live snapshot response. const MAX_DATA_SOURCE_QUERY_LIMIT = 999 function resolveDataSourceQueryLimit(limit: number | undefined): number { if (limit === undefined) { return DEFAULT_DATA_SOURCE_QUERY_LIMIT } if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1) { console.warn( `[notion-custom-sdk] useDataSource limit must be a positive integer; using ${DEFAULT_DATA_SOURCE_QUERY_LIMIT}.`, ) return DEFAULT_DATA_SOURCE_QUERY_LIMIT } if (limit > MAX_DATA_SOURCE_QUERY_LIMIT) { console.warn( `[notion-custom-sdk] useDataSource limit is capped at ${MAX_DATA_SOURCE_QUERY_LIMIT}.`, ) return MAX_DATA_SOURCE_QUERY_LIMIT } return limit } function makeDataSourceSnapshotId(args: { key: string }): string { const { key } = args return `data-source:${encodeURIComponent(key)}` } function formatInvalidHostReason( incomingType: string | undefined, issues: readonly v.BaseIssue[], ): string { const labelled = incomingType ? `host message of type "${incomingType}"` : "host message" const first = issues[0] if (!first) { return `Could not parse ${labelled}: unknown error` } const path = first.path ?.map(p => String(p.key ?? "")) .filter(Boolean) .join(".") ?? "" const detail = path ? `${path}: ${first.message}` : first.message const extra = issues.length > 1 ? ` (+${issues.length - 1} more)` : "" return `Could not parse ${labelled}: ${detail}${extra}` }