import * as v from "valibot" import type { NotionDataSourceId } from "../bridge/ids.js" import type { CreatePageMessage } from "../bridge/messages/createPage.js" import type { CreatePageResultMessage } from "../bridge/messages/createPageResult.js" import type { DataSourcesChangedMessage } from "../bridge/messages/dataSourcesChanged.js" import type { GetPageMessage, GetPageResultMessage, } from "../bridge/messages/getPage.js" import type { GetUserMessage, GetUserResultMessage, } from "../bridge/messages/getUser.js" import type { HostToSandboxMessage } from "../bridge/messages/hostToSandbox.js" import type { CustomBlockInitErrorCode, InitMessage, } from "../bridge/messages/init.js" import type { ListUsersMessage, ListUsersResultMessage, } from "../bridge/messages/listUsers.js" import type { PageChangedMessage } from "../bridge/messages/pageChanged.js" import type { ParentChangedMessage } from "../bridge/messages/parentChanged.js" import type { QueryDataSourceMessage } from "../bridge/messages/queryDataSource.js" import type { QueryDataSourceResultMessage } from "../bridge/messages/queryDataSourceResult.js" import type { ReadyMessage } from "../bridge/messages/ready.js" import { sandboxToHostMessageSchema } from "../bridge/messages/sandboxToHost.js" import type { ThemeChangedMessage } from "../bridge/messages/themeChanged.js" import type { UpdatePageMessage } from "../bridge/messages/updatePage.js" import type { UpdatePageResultMessage } from "../bridge/messages/updatePageResult.js" import type { CustomBlockPage } from "../bridge/pages/page.js" import type { NotionParent } from "../bridge/parent.js" import type { NotionTheme } from "../bridge/theme.js" import type { NotionUser } from "../bridge/users/user.js" import { unreachable } from "../utils.js" import { initErrorForFailureReason } from "./lifecycle/initErrors.js" import { getReadyInitFailure } from "./lifecycle/ready.js" import type { CustomBlockHostDataSourcesPayload, CustomBlockHostInitialState, InitStatus, ReadySuccessMessage, } from "./lifecycle/types.js" import { getInvalidSandboxMessageResponse } from "./messages/invalidSandboxMessage.js" import type { CustomBlockHostHandlers, CustomBlockHostLogDirection, } from "./messages/types.js" import { QuerySubscriptions } from "./queries/querySubscriptions.js" import type { CustomBlockHostQueryDataSourceResult } from "./queries/types.js" export type CustomBlockHostOptions = { iframe: HTMLIFrameElement targetOrigin?: string minBridgeProtocolVersion?: number noReadyTimeoutMs?: number initialState: CustomBlockHostInitialState handlers: CustomBlockHostHandlers onReady?: (message: ReadySuccessMessage) => void onLog?: (direction: CustomBlockHostLogDirection, payload: unknown) => void } export type CustomBlockHostHandle = { stop: () => void post: (message: HostToSandboxMessage) => void setTheme: (theme: NotionTheme) => void setParent: (parent: NotionParent) => void setPage: (page: CustomBlockPage) => void setDataSources: (dataSources: CustomBlockHostDataSourcesPayload) => void setCurrentUser: (currentUser: NotionUser) => void refreshQuery: (args: { dataSourceId: NotionDataSourceId response: CustomBlockHostQueryDataSourceResult }) => void } const DEFAULT_NO_READY_TIMEOUT_MS = 5_000 const DEFAULT_MIN_BRIDGE_PROTOCOL_VERSION = 2 export function createCustomBlockHost( options: CustomBlockHostOptions, ): CustomBlockHostHandle { const { iframe, initialState, handlers, onReady, onLog, targetOrigin = "*", minBridgeProtocolVersion = DEFAULT_MIN_BRIDGE_PROTOCOL_VERSION, noReadyTimeoutMs = DEFAULT_NO_READY_TIMEOUT_MS, } = options let theme = initialState.theme let parent = initialState.parent let page = initialState.page let dataSources = initialState.dataSources let currentUser = initialState.currentUser let initStatus: InitStatus = "pending" let noReadyTimeoutId: number | undefined const querySubscriptions = new QuerySubscriptions() function emit(direction: CustomBlockHostLogDirection, payload: unknown) { onLog?.(direction, payload) } function post(message: HostToSandboxMessage) { emit("out", message) iframe.contentWindow?.postMessage(message, targetOrigin) } function postQueryDataSourceResult(args: { requestId: string snapshotId: string response: CustomBlockHostQueryDataSourceResult }) { const message: QueryDataSourceResultMessage = { type: "queryDataSourceResult", requestId: args.requestId, snapshotId: args.snapshotId, items: args.response.items, hasMore: args.response.hasMore ?? false, error: args.response.error, } post(message) } function clearNoReadyTimeout() { if (noReadyTimeoutId !== undefined) { window.clearTimeout(noReadyTimeoutId) noReadyTimeoutId = undefined } } function resolveDataSourcesPayload( ready: ReadySuccessMessage, ): CustomBlockHostDataSourcesPayload { return typeof dataSources === "function" ? dataSources(ready) : dataSources } function sendInit(ready: ReadySuccessMessage): boolean { if (initStatus !== "pending") { return false } initStatus = "success" clearNoReadyTimeout() const message: InitMessage = { type: "init", status: "success", theme, blockId: initialState.blockId, parent, page, dataSources: resolveDataSourcesPayload(ready), currentUser, } post(message) return true } function sendInitError(code: CustomBlockInitErrorCode, message?: string) { if (initStatus !== "pending") { return } initStatus = "error" clearNoReadyTimeout() const error = initErrorForFailureReason(code) post({ type: "init", status: "error", error: { ...error, message: message ?? error.message, }, }) } function onIframeLoad() { clearNoReadyTimeout() noReadyTimeoutId = window.setTimeout(() => { sendInitError("no_ready") }, noReadyTimeoutMs) } async function handleQuery(message: QueryDataSourceMessage) { querySubscriptions.track(message) const response = await handlers.queryDataSource(message) postQueryDataSourceResult({ requestId: message.requestId, snapshotId: message.snapshotId, response, }) } async function handleCreatePage(message: CreatePageMessage) { if (handlers.createPage === undefined) { return } const response = await handlers.createPage(message) const result: CreatePageResultMessage = { type: "createPageResult", requestId: message.requestId, ...response, } post(result) } async function handleGetPage(message: GetPageMessage) { if (handlers.getPage === undefined) { return } const response = await handlers.getPage(message) const result: GetPageResultMessage = { type: "getPageResult", requestId: message.requestId, ...response, } post(result) } async function handleGetUser(message: GetUserMessage) { if (handlers.getUser === undefined) { return } const response = await handlers.getUser(message) const result: GetUserResultMessage = { type: "getUserResult", requestId: message.requestId, ...response, } post(result) } async function handleListUsers(message: ListUsersMessage) { if (handlers.listUsers === undefined) { return } const response = await handlers.listUsers(message) const result: ListUsersResultMessage = { type: "listUsersResult", requestId: message.requestId, ...response, } post(result) } async function handleUpdatePage(message: UpdatePageMessage) { if (handlers.updatePage === undefined) { return } const response = await handlers.updatePage(message) const result: UpdatePageResultMessage = { type: "updatePageResult", requestId: message.requestId, ...response, } post(result) } function handleReady(message: ReadyMessage) { const failure = getReadyInitFailure(message, minBridgeProtocolVersion) if (failure !== undefined) { sendInitError(failure.code, failure.message) return } if (message.status !== "success") { return } if (sendInit(message)) { onReady?.(message) } } function onInvalidSandboxMessage( data: unknown, issues: readonly v.BaseIssue[], ) { emit("warn", { message: "Ignored unsupported sandbox message", payload: data, issues, }) const response = getInvalidSandboxMessageResponse(data) if (initStatus === "pending" && response.incomingType === "ready") { sendInitError("invalid_ready") } else if (response.nack !== undefined) { post(response.nack) } } function onMessage(event: MessageEvent) { if (event.source !== iframe.contentWindow) { return } const parsed = v.safeParse(sandboxToHostMessageSchema, event.data) if (!parsed.success) { onInvalidSandboxMessage(event.data, parsed.issues) return } const message = parsed.output emit("in", message) switch (message.type) { case "ready": handleReady(message) return case "queryDataSource": void handleQuery(message) return case "createPage": void handleCreatePage(message) return case "getPage": void handleGetPage(message) return case "getUser": void handleGetUser(message) return case "listUsers": void handleListUsers(message) return case "updatePage": void handleUpdatePage(message) return case "resize": handlers.resize?.(message) return case "invalidHostMessage": return default: unreachable(message) } } window.addEventListener("message", onMessage) iframe.addEventListener("load", onIframeLoad) return { stop() { window.removeEventListener("message", onMessage) iframe.removeEventListener("load", onIframeLoad) clearNoReadyTimeout() }, post, setTheme(nextTheme) { theme = nextTheme const message: ThemeChangedMessage = { type: "themeChanged", theme } post(message) }, setParent(nextParent) { parent = nextParent const message: ParentChangedMessage = { type: "parentChanged", parent } post(message) }, setPage(nextPage) { page = nextPage const message: PageChangedMessage = { type: "pageChanged", page } post(message) }, setDataSources(nextDataSources) { dataSources = nextDataSources const message: DataSourcesChangedMessage = { type: "dataSourcesChanged", dataSources: nextDataSources, } post(message) }, setCurrentUser(nextCurrentUser) { currentUser = nextCurrentUser post({ type: "currentUserChanged", currentUser }) }, refreshQuery({ dataSourceId, response }) { const refresh = querySubscriptions.resolveRefresh({ dataSourceId, response, }) if (refresh.status === "missing") { emit("warn", `No active query exists for data source "${dataSourceId}"`) return } postQueryDataSourceResult({ requestId: refresh.requestId, snapshotId: refresh.snapshotId, response: refresh.response, }) }, } }