@notionhq/custom-blocks-host
v0.1.20
Published
Reusable host runtime for Notion custom blocks.
Maintainers
Keywords
Readme
@notionhq/custom-blocks-host
Host runtime for the custom block bridge. This package owns the generic postMessage protocol loop
used by hosts such as the local dev shell. It does not own iframe policy, permissions, persistence,
analytics, or host UI state. Host implementations such as the dev shell and notion-next depend on
this package.
getAutoBoundPropertyIdsByKey()
getAutoBoundPropertyIdsByKey() matches manifest properties to schema properties for initial
property binding. For each manifest property:
- Consider only schema properties of the same type.
- A schema property is a candidate if its ID equals the manifest key (exact string: no trim, no
case folding) or its display name equals the manifest display name after
trim()and lowercasing. - Bind when there is exactly one candidate. Otherwise leave it unmapped.
Wrong-type properties never enter the candidate set.
import { getAutoBoundPropertyIdsByKey } from "@notionhq/custom-blocks-host"
const propertyIdsByKey = getAutoBoundPropertyIdsByKey({
manifestProperties: {
title: { name: "Task", type: "title" },
estimate: { name: "Estimate", type: "number" },
},
propertySchemasById: {
title: { name: "Different label", type: "title" },
"property-1": { name: " estimate ", type: "number" },
},
})
// { title: "title", estimate: "property-1" }createCustomBlockHost()
createCustomBlockHost() attaches a message listener, performs the connect / init /
initResult handshake, validates sandbox messages, routes requests to handlers, manages query
subscriptions, and emits live state updates.
import { createCustomBlockHost } from "@notionhq/custom-blocks-host"
const host = createCustomBlockHost({
iframe,
sandboxOrigin: "https://example.com",
initialState: {
theme,
contrastMode,
blockId,
parent,
page,
currentUser,
manifest,
dataSources: { bindings },
},
handlers: {
queryDataSource: async message => ({
status: "success",
items: await queryRows(message),
hasMore: false,
}),
createPage: async message => createPage(message),
getPage: async message => getPage(message),
openPage: async message => openPage(message),
updatePage: async message => updatePage(message),
getUser: async message => getUser(message),
listUsers: async message => listUsers(message),
resize: message => resizeIframe(message.height),
},
onManifest: manifest => {
console.log("Selected manifest", manifest)
},
onInitialized: state => {
console.log("Initialized manifest", state.manifest)
},
onInitializationFailure: error => {
showInitializationError(error)
},
onRestartRequired: () => {
remountHostAndIframe()
},
onLog: (direction, payload) => {
console.log(direction, payload)
},
})minBridgeProtocolVersion is optional and defaults to the oldest protocol version the host runtime
supports. Set a higher value to retire older SDKs. Lower values cannot enable versions the runtime
no longer supports. The host does not enforce a maximum protocol version.
Each host instance initializes once:
onManifestprovides the selected manifest as soon as it is available afterconnect, before binding validation andonInitialized. Use it to configure bindings even when initialization cannot yet succeed.onInitializedreports success after the sandbox accepts initialization. It provides the selectedmanifestand the sandbox's optionalinitialHeight.onInitializationFailurereports an initialization error or timeout.onRestartRequiredreports a connection attempt after the host accepts its first connection or stops waiting. Recreate the host and iframe together to accept a new connection. This can occur after the iframe document changes or a connection arrives after a timeout.
The host uses the manifest from connect, or initialState.manifest if connect omits it. If
neither exists, initialization fails with manifest_unavailable.
queryDataSource is required. Other request handlers are optional. Every request handler may return
its result synchronously or as a promise. Results use a status discriminator: successful results
carry their response payload, while errors carry { code, message, isRetryable }.
Request types
TypeScript infers request types for handlers passed to createCustomBlockHost(). For separately
defined handlers and helpers, import the corresponding request and page-property types directly from
@notionhq/custom-blocks-host.
Request types such as GetPageMessage describe validated messages that handlers receive. The
corresponding *MessageInput types accept plain string IDs for adapters and test fixtures that
construct requests. These types do not validate values at runtime. The host runtime validates
incoming messages before it calls handlers. Your implementation remains responsible for permissions
and record access.
Query requests
The queryDataSource handler receives raw data source and property IDs. Its optional filter and
sorts fields have already been resolved by the SDK. The host validates that sorts contain at most
10 unique property IDs, checks property IDs and currently supported property types against the bound
collection schema when available, and applies valid sorts in the order provided by the array.
When a data source binding includes collectionSchema, the runtime validates select, multi-select,
and status option names before invoking queryDataSource. Bindings without a collection schema
cannot perform this check, so their query handlers remain responsible for validating option names.
initialState.dataSources may also be a function of the successful connect message. This lets a
host derive bindings from the sandbox-provided manifest.
Set sandboxOrigin to the iframe's exact origin. The runtime targets outbound messages to that
origin and rejects inbound messages from any other origin. The runtime also accepts
noConnectTimeoutMs and noInitResultTimeoutMs; both default to five seconds.
Initialization and malformed messages
createCustomBlockHost() performs one handshake for each host and iframe pair:
sandbox connect → host init → sandbox initResultThe sandbox creates the initializationId. The host copies that ID into init. The sandbox copies
it into initResult. The host sends live state changes only after it receives a matching
initResult.success.
The host must check that the initializationId in initResult exactly matches the ID in the init
message. The host ignores a result with a different ID. If no result arrives, or only a stale valid
result arrives, before the timeout, initialization ends with no_init_result.
The host validates each inbound sandbox message with sandboxToHostMessageSchema before it handles
the message. If parsing fails, the host uses readIncomingType() to identify the attempted message.
It replies with invalidSandboxMessage, a negative acknowledgment (NACK). The host never sends a
NACK in response to a NACK.
Malformed sandbox messages do not change the host's initialization state or timeout, except in these two cases:
- The host expects
connectand receives a malformedconnectwith a readableinitializationId. The host sendsinit.errorwithinvalid_connect_payloadinstead of a NACK, then fails initialization. - The host expects
initResultand receives a malformedinitResultwith the activeinitializationId. The host sends a NACK and fails initialization withinvalid_init_result_payload. This error code describes a local host failure. The host does not send it ininitResult.error.
A malformed initResult with a missing or stale ID does not end initialization or reset its
timeout. Malformed messages do not restart initialization after it succeeds or fails.
The host accepts a valid initResult.error as a terminal initialization failure. It logs and
ignores a valid initResult with a stale initializationId or an unexpected state.
Hosts do not retry malformed messages. A host that receives a connect after initialization has
settled must use onRestartRequired to recreate the host and iframe together.
Updating live state
The returned handle exposes:
setTheme()setContrastMode()setParent()setPage()setDataSources()setCurrentUser()refreshQuery()post()stop()
State updates are sent after initResult.success. Updates made earlier are held until
initialization completes. refreshQuery() re-runs queryDataSource for every active subscription
with the supplied data source ID, preserving each subscription's filter, sorts, and limit.
The runtime removes a subscription when it receives unsubscribeDataSourceQuery. Repeated
unsubscribe messages are safe. The runtime also ignores results from a query that was unsubscribed
while its handler was running.
Call stop() when the iframe is removed. It detaches listeners, clears timeouts, and discards
active query subscriptions.
Wire protocol
createCustomBlockHost() implements the protocol: it validates inbound messages, runs the
initialization handshake, correlates requests, manages query subscriptions, and emits bridge NACKs.
Use its state-update methods and handlers instead of posting bridge messages directly.
The bridge protocol is maintained in this repository's protocol/ package. The published host
package includes a compiled copy of that protocol and does not expose it as a public subpath.
Consumers should use the host APIs above instead of importing protocol implementation files.
Host source in this repository imports protocol schemas and types from the workspace package. The publish build rewrites those imports to the bundled copy.
Handler implementations must preserve message identity: one-shot operations return one result with
the request's requestId, while queryDataSource is a long-lived subscription keyed by
subscriptionId. The runtime handles the connect → init → initResult lifecycle, including
waiting for initResult.success before live updates.
Error payloads use { code, message, isRetryable }. The runtime accepts any string for code,
including codes that this package does not know yet. Host code should preserve unknown codes and use
isRetryable to decide whether a retry is appropriate.
