@notionhq/custom-blocks-host 0.1.30 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,20 @@
1
- import {
2
- DEFAULT_CONTRAST_MODE,
3
- type NotionContrastMode,
4
- } from "@notionhq/custom-blocks-protocol/contrast.js"
1
+ import type { NotionContrastMode } from "@notionhq/custom-blocks-protocol/contrast.js"
5
2
  import type { NotionCollectionSchema } from "@notionhq/custom-blocks-protocol/dataSources/dataSource.js"
6
3
  import type { NotionDataSourceId } from "@notionhq/custom-blocks-protocol/ids.js"
7
4
  import type { CustomBlockManifest } from "@notionhq/custom-blocks-protocol/manifest.js"
8
- import type { ContrastModeChangedMessage } from "@notionhq/custom-blocks-protocol/messages/contrastModeChanged.js"
9
- import type { DataSourcesChangedMessage } from "@notionhq/custom-blocks-protocol/messages/dataSourcesChanged.js"
10
5
  import type { HostToSandboxMessage } from "@notionhq/custom-blocks-protocol/messages/hostToSandbox.js"
11
- import type { CustomBlockInitErrorCode } from "@notionhq/custom-blocks-protocol/messages/init.js"
6
+ import type {
7
+ CustomBlockInitErrorCode,
8
+ InitMessage,
9
+ } from "@notionhq/custom-blocks-protocol/messages/init.js"
10
+ import type { InvalidSandboxMessage } from "@notionhq/custom-blocks-protocol/messages/invalidSandboxMessage.js"
12
11
  import type { BridgeMessagePayload } from "@notionhq/custom-blocks-protocol/messages/messagePayload.js"
13
- import type { PageChangedMessage } from "@notionhq/custom-blocks-protocol/messages/pageChanged.js"
14
- import type { ParentChangedMessage } from "@notionhq/custom-blocks-protocol/messages/parentChanged.js"
15
12
  import type { QueryDataSourceMessage } from "@notionhq/custom-blocks-protocol/messages/queryDataSource.js"
16
13
  import type {
17
14
  CustomBlockQueryDataSourceErrorInfo,
18
15
  QueryDataSourceResultMessage,
19
16
  } from "@notionhq/custom-blocks-protocol/messages/queryDataSourceResult.js"
20
17
  import type { SandboxToHostMessage } from "@notionhq/custom-blocks-protocol/messages/sandboxToHost.js"
21
- import type { ThemeChangedMessage } from "@notionhq/custom-blocks-protocol/messages/themeChanged.js"
22
18
  import type { CustomBlockPage } from "@notionhq/custom-blocks-protocol/pages/page.js"
23
19
  import type { NotionParent } from "@notionhq/custom-blocks-protocol/parent.js"
24
20
  import { LATEST_BRIDGE_PROTOCOL_VERSION } from "@notionhq/custom-blocks-protocol/protocolVersion.js"
@@ -29,7 +25,9 @@ import { createInitializationController } from "./lifecycle/initializationContro
29
25
  import { normalizeHostToSandboxMessage } from "./lifecycle/normalizeHostToSandboxMessage.js"
30
26
  import type {
31
27
  CustomBlockHostDataSourcesPayload,
28
+ CustomBlockHostGetInitialState,
32
29
  CustomBlockHostInitialState,
30
+ CustomBlockHostInitialStateResult,
33
31
  } from "./lifecycle/types.js"
34
32
  import { createSandboxMessageRouter } from "./messages/sandboxMessageRouter.js"
35
33
  import type {
@@ -45,6 +43,7 @@ import {
45
43
  import type { CustomBlockHostQueryDataSourceResult } from "./queries/types.js"
46
44
  import { validateQueryDataSourceFilter } from "./queries/validateQueryDataSourceFilter.js"
47
45
  import { validateQueryDataSourceSorts } from "./queries/validateQueryDataSourceSorts.js"
46
+ import { unreachable } from "./utils.js"
48
47
 
49
48
  // Bridge protocol version that introduced page: { type: "unavailable" }.
50
49
  // Earlier SDKs cannot represent this context, so the host reports context_unavailable.
@@ -54,19 +53,15 @@ export type CustomBlockHostOptions = {
54
53
  iframe: HTMLIFrameElement
55
54
  sandboxOrigin: string
56
55
  /**
57
- * Minimum sandbox protocol version to accept. Defaults to the host runtime minimum. Higher
58
- * values retire older SDKs; lower values cannot enable protocol versions the runtime no longer
59
- * supports.
56
+ * Minimum sandbox protocol version to accept. Defaults to the host runtime
57
+ * minimum. Higher values retire older SDKs; lower values cannot enable
58
+ * protocol versions the runtime no longer supports.
60
59
  */
61
60
  minBridgeProtocolVersion?: number
62
61
  noConnectTimeoutMs?: number
63
62
  noInitResultTimeoutMs?: number
64
- initialState: CustomBlockHostInitialState
65
63
  handlers: CustomBlockHostHandlers
66
- /**
67
- * Called as soon as the `manifest` is available after `connect`, before `onInitialized` and
68
- * binding validation.
69
- */
64
+ /** Called as soon as the `manifest` is available after `connect`, before `onInitialized` and binding validation. */
70
65
  onManifest?: (manifest: CustomBlockManifest) => void
71
66
  /** Callback fired when the initialization handshake with the sandbox completed successfully. */
72
67
  onInitialized?: (state: {
@@ -81,11 +76,12 @@ export type CustomBlockHostOptions = {
81
76
  */
82
77
  onFailure?: (error: CustomBlockHostFailure) => void
83
78
  onLog?: (direction: CustomBlockHostLogDirection, payload: unknown) => void
79
+ getInitialState: CustomBlockHostGetInitialState
80
+ initialStateTimeoutMs?: number
84
81
  }
85
82
 
86
83
  export type CustomBlockHostHandle = {
87
84
  stop: () => void
88
- post: (message: HostToSandboxMessage) => void
89
85
  setTheme: (theme: NotionTheme) => void
90
86
  setContrastMode: (contrastMode: NotionContrastMode) => void
91
87
  setParent: (parent: NotionParent) => void
@@ -123,7 +119,7 @@ export function createCustomBlockHost(
123
119
  ): CustomBlockHostHandle {
124
120
  const {
125
121
  iframe,
126
- initialState,
122
+ getInitialState,
127
123
  handlers,
128
124
  onManifest,
129
125
  onInitialized,
@@ -132,41 +128,46 @@ export function createCustomBlockHost(
132
128
  sandboxOrigin,
133
129
  minBridgeProtocolVersion: configuredMinBridgeProtocolVersion,
134
130
  noConnectTimeoutMs,
131
+ initialStateTimeoutMs,
135
132
  noInitResultTimeoutMs,
136
133
  } = options
137
- // Callers can retire older SDKs, but cannot restore support for protocols the runtime no longer
138
- // implements. Omission uses the runtime minimum.
134
+ // Callers can retire older SDKs, but cannot restore support for protocols
135
+ // the runtime no longer implements. Omission uses the runtime minimum.
139
136
  const minBridgeProtocolVersion = Math.max(
140
137
  configuredMinBridgeProtocolVersion ??
141
138
  MIN_HOST_SUPPORTED_BRIDGE_PROTOCOL_VERSION,
142
139
  MIN_HOST_SUPPORTED_BRIDGE_PROTOCOL_VERSION,
143
140
  )
144
141
 
145
- let theme = initialState.theme
146
- let contrastMode = initialState.contrastMode ?? DEFAULT_CONTRAST_MODE
147
- let parent = initialState.parent
148
- let page = initialState.page
149
- let dataSources = initialState.dataSources
142
+ type HostStateUpdates = Partial<
143
+ Omit<CustomBlockHostInitialState, "blockId" | "manifest">
144
+ >
145
+ let pendingState: HostStateUpdates = {}
146
+ let hasPreparedInitialState = false
147
+ let manifest: CustomBlockManifest | undefined
150
148
  let activeDataSources: CustomBlockHostDataSourcesPayload | undefined
151
149
  let sandboxBridgeProtocolVersion = LATEST_BRIDGE_PROTOCOL_VERSION
152
- let manifest = initialState.manifest
153
- let currentUser = initialState.currentUser
154
- let pendingThemeChange = false
155
- let pendingContrastModeChange = false
156
- let pendingParentChange = false
157
- let pendingPageChange = false
158
- let pendingDataSourcesChange: CustomBlockHostDataSourcesPayload | undefined
159
- let pendingCurrentUserChange = false
160
150
  const querySubscriptions = new QuerySubscriptions()
161
151
 
162
152
  function emit(direction: CustomBlockHostLogDirection, payload: unknown) {
163
153
  onLog?.(direction, payload)
164
154
  }
165
155
 
166
- function post(message: HostToSandboxMessage) {
167
- if (initializationController.status === "stopped") {
168
- // The parent page can stop this host. Do not send messages after the host stops.
169
- return
156
+ function postToSandbox(message: HostToSandboxMessage) {
157
+ const status = initializationController.status
158
+ switch (status) {
159
+ case "waitingForConnect":
160
+ case "preparingInit":
161
+ case "waitingForInitResult":
162
+ case "initialized":
163
+ case "error":
164
+ break
165
+ // A stopped host must not send messages, including pending responses.
166
+ // Return from the function to skip delivery below.
167
+ case "stopped":
168
+ return
169
+ default:
170
+ return unreachable(status)
170
171
  }
171
172
  const sandboxMessage = normalizeHostToSandboxMessage(
172
173
  message,
@@ -176,6 +177,37 @@ export function createCustomBlockHost(
176
177
  iframe.contentWindow?.postMessage(sandboxMessage, sandboxOrigin)
177
178
  }
178
179
 
180
+ function sendInitMessage(message: InitMessage) {
181
+ postToSandbox(message)
182
+ }
183
+
184
+ function sendNack(message: InvalidSandboxMessage) {
185
+ postToSandbox(message)
186
+ }
187
+
188
+ function sendInitializedMessage(
189
+ message: Exclude<
190
+ HostToSandboxMessage,
191
+ { type: "init" | "invalidSandboxMessage" }
192
+ >,
193
+ ) {
194
+ const status = initializationController.status
195
+ switch (status) {
196
+ case "initialized":
197
+ postToSandbox(message)
198
+ return
199
+ // Send state updates and handler results only after initialization succeeds.
200
+ case "waitingForConnect":
201
+ case "preparingInit":
202
+ case "waitingForInitResult":
203
+ case "error":
204
+ case "stopped":
205
+ return
206
+ default:
207
+ unreachable(status)
208
+ }
209
+ }
210
+
179
211
  /**
180
212
  * Invokes a one-shot handler and posts its result with the request's ID.
181
213
  * Each result type is named `<request type>Result` by the bridge protocol.
@@ -192,8 +224,8 @@ export function createCustomBlockHost(
192
224
  type: `${message.type}Result` as const,
193
225
  requestId: message.requestId,
194
226
  ...response,
195
- } as HostToSandboxMessage
196
- post(result)
227
+ } as ResultForRequest<OneShotRequest>
228
+ sendInitializedMessage(result)
197
229
  }
198
230
 
199
231
  function postQueryDataSourceResult(args: {
@@ -215,7 +247,7 @@ export function createCustomBlockHost(
215
247
  items: args.response.items,
216
248
  hasMore: args.response.hasMore,
217
249
  }
218
- post(message)
250
+ sendInitializedMessage(message)
219
251
  }
220
252
 
221
253
  async function handleQuery(
@@ -246,95 +278,69 @@ export function createCustomBlockHost(
246
278
  })
247
279
  }
248
280
 
249
- /**
250
- * Sends any pending host state changes to the sandbox. It is possible for these to change
251
- * between when the host sends `init` and when the sandbox responds with `initResult`. The host
252
- * does send any pending state changes until hearing back from the sandbox.
253
- */
281
+ function updateHostState(update: HostStateUpdates) {
282
+ if (
283
+ initializationController.status === "stopped" ||
284
+ initializationController.status === "error"
285
+ ) {
286
+ return
287
+ }
288
+ Object.assign(pendingState, update)
289
+ flushPendingHostStateChanges()
290
+ }
291
+
254
292
  function flushPendingHostStateChanges() {
255
- if (pendingThemeChange) {
256
- pendingThemeChange = false
257
- post({ type: "themeChanged", theme })
293
+ // Keep updates queued until the sandbox acknowledges initialization.
294
+ // Updates received before init is sent are included in its payload instead.
295
+ if (initializationController.status !== "initialized") {
296
+ return
258
297
  }
259
- if (pendingContrastModeChange) {
260
- pendingContrastModeChange = false
261
- post({ type: "contrastModeChanged", contrastMode })
298
+ const changes = pendingState
299
+ pendingState = {}
300
+ if (changes.theme !== undefined) {
301
+ sendInitializedMessage({ type: "themeChanged", theme: changes.theme })
262
302
  }
263
- if (pendingParentChange) {
264
- pendingParentChange = false
265
- post({ type: "parentChanged", parent })
303
+ if (changes.contrastMode !== undefined) {
304
+ sendInitializedMessage({
305
+ type: "contrastModeChanged",
306
+ contrastMode: changes.contrastMode,
307
+ })
308
+ }
309
+ if (changes.parent !== undefined) {
310
+ sendInitializedMessage({ type: "parentChanged", parent: changes.parent })
266
311
  }
267
- if (pendingPageChange) {
268
- pendingPageChange = false
269
- post({ type: "pageChanged", page })
312
+ if (changes.page !== undefined) {
313
+ sendInitializedMessage({ type: "pageChanged", page: changes.page })
270
314
  }
271
- if (pendingDataSourcesChange !== undefined) {
272
- const nextDataSources = pendingDataSourcesChange
273
- pendingDataSourcesChange = undefined
274
- post({
315
+ if (changes.dataSources !== undefined) {
316
+ sendInitializedMessage({
275
317
  type: "dataSourcesChanged",
276
- dataSources: nextDataSources,
318
+ dataSources: changes.dataSources,
277
319
  })
278
320
  }
279
- if (pendingCurrentUserChange) {
280
- pendingCurrentUserChange = false
281
- post({ type: "currentUserChanged", currentUser })
321
+ if (changes.currentUser !== undefined) {
322
+ sendInitializedMessage({
323
+ type: "currentUserChanged",
324
+ currentUser: changes.currentUser,
325
+ })
282
326
  }
283
327
  }
284
328
 
285
329
  const initializationController = createInitializationController({
286
- blockId: initialState.blockId,
287
- getState: () => ({
288
- theme,
289
- contrastMode,
290
- parent,
291
- page,
292
- currentUser,
293
- }),
294
- prepareInit(connect) {
330
+ async prepareInit(connect) {
295
331
  sandboxBridgeProtocolVersion = connect.bridgeProtocolVersion
296
- if (
297
- sandboxBridgeProtocolVersion <
298
- BRIDGE_PROTOCOL_VERSION_INTRODUCING_UNAVAILABLE_PAGE &&
299
- page.type === "unavailable"
300
- ) {
301
- return {
302
- status: "error",
303
- error: initErrorForFailureReason("context_unavailable"),
304
- }
305
- }
306
- manifest = connect.manifest ?? initialState.manifest
307
- if (manifest === undefined) {
308
- return {
309
- status: "error",
310
- error: initErrorForFailureReason("manifest_unavailable"),
311
- }
312
- }
313
- onManifest?.(manifest)
314
- const resolvedDataSources =
315
- typeof dataSources === "function" ? dataSources(connect) : dataSources
316
- const bindingErrorCode = getBindingErrorCode(
317
- manifest,
318
- resolvedDataSources,
319
- )
320
- if (bindingErrorCode !== undefined) {
321
- return {
322
- status: "error",
323
- error: initErrorForFailureReason(bindingErrorCode),
324
- }
325
- }
326
- activeDataSources = resolvedDataSources
327
- return {
328
- status: "success",
329
- manifest,
330
- dataSources: resolvedDataSources,
332
+ const result = await getInitialState(connect)
333
+ if (initializationController.status !== "preparingInit") {
334
+ return result
331
335
  }
336
+ return prepareInitialState(result)
332
337
  },
333
- post,
338
+ sendInitMessage,
334
339
  warn: payload => emit("warn", payload),
335
340
  flushPendingHostStateChanges,
336
341
  minBridgeProtocolVersion,
337
342
  noConnectTimeoutMs,
343
+ initialStateTimeoutMs,
338
344
  noInitResultTimeoutMs,
339
345
  stopHostWithFailure: stopWithFailure,
340
346
  onInitResult(message) {
@@ -345,12 +351,47 @@ export function createCustomBlockHost(
345
351
  onFailure,
346
352
  })
347
353
 
354
+ function prepareInitialState(
355
+ result: CustomBlockHostInitialStateResult,
356
+ ): CustomBlockHostInitialStateResult {
357
+ if (result.status === "error") {
358
+ return result
359
+ }
360
+ manifest = result.initialState.manifest
361
+ onManifest?.(manifest)
362
+ const initialState = { ...result.initialState, ...pendingState }
363
+ pendingState = {}
364
+ if (
365
+ sandboxBridgeProtocolVersion <
366
+ BRIDGE_PROTOCOL_VERSION_INTRODUCING_UNAVAILABLE_PAGE &&
367
+ initialState.page.type === "unavailable"
368
+ ) {
369
+ return {
370
+ status: "error",
371
+ error: initErrorForFailureReason("context_unavailable"),
372
+ }
373
+ }
374
+ const bindingErrorCode = getBindingErrorCode(
375
+ initialState.manifest,
376
+ initialState.dataSources,
377
+ )
378
+ if (bindingErrorCode !== undefined) {
379
+ return {
380
+ status: "error",
381
+ error: initErrorForFailureReason(bindingErrorCode),
382
+ }
383
+ }
384
+ activeDataSources = initialState.dataSources
385
+ hasPreparedInitialState = true
386
+ return { status: "success", initialState }
387
+ }
388
+
348
389
  const onMessage = createSandboxMessageRouter({
349
390
  iframe,
350
391
  sandboxOrigin,
351
392
  getInitStatus: () => initializationController.status,
352
393
  emit,
353
- post,
394
+ sendNack,
354
395
  onInvalidConnect: initializationController.handleInvalidConnect,
355
396
  onInvalidInitResult: initializationController.handleInvalidInitResult,
356
397
  onConnect: initializationController.handleConnect,
@@ -385,102 +426,50 @@ export function createCustomBlockHost(
385
426
 
386
427
  return {
387
428
  stop,
388
- post,
389
- setTheme(nextTheme) {
390
- theme = nextTheme
391
- if (initializationController.status !== "success") {
392
- if (initializationController.status === "waitingForInitResult") {
393
- pendingThemeChange = true
394
- }
395
- return
396
- }
397
- const message: ThemeChangedMessage = { type: "themeChanged", theme }
398
- post(message)
429
+ setTheme(theme) {
430
+ updateHostState({ theme })
399
431
  },
400
- setContrastMode(nextContrastMode) {
401
- contrastMode = nextContrastMode
402
- if (initializationController.status !== "success") {
403
- if (initializationController.status === "waitingForInitResult") {
404
- pendingContrastModeChange = true
405
- }
406
- return
407
- }
408
- const message: ContrastModeChangedMessage = {
409
- type: "contrastModeChanged",
410
- contrastMode,
411
- }
412
- post(message)
432
+ setContrastMode(contrastMode) {
433
+ updateHostState({ contrastMode })
413
434
  },
414
- setParent(nextParent) {
415
- parent = nextParent
416
- if (initializationController.status !== "success") {
417
- if (initializationController.status === "waitingForInitResult") {
418
- pendingParentChange = true
419
- }
420
- return
421
- }
422
- const message: ParentChangedMessage = { type: "parentChanged", parent }
423
- post(message)
435
+ setParent(parent) {
436
+ updateHostState({ parent })
424
437
  },
425
- setPage(nextPage) {
426
- page = nextPage
438
+ setPage(page) {
427
439
  if (
428
- nextPage.type === "unavailable" &&
440
+ page.type === "unavailable" &&
429
441
  sandboxBridgeProtocolVersion <
430
442
  BRIDGE_PROTOCOL_VERSION_INTRODUCING_UNAVAILABLE_PAGE &&
431
- (initializationController.status === "waitingForInitResult" ||
432
- initializationController.status === "success")
443
+ ((initializationController.status === "preparingInit" &&
444
+ hasPreparedInitialState) ||
445
+ initializationController.status === "waitingForInitResult" ||
446
+ initializationController.status === "initialized")
433
447
  ) {
434
448
  stopWithFailure(initErrorForFailureReason("context_unavailable"))
435
449
  return
436
450
  }
437
- if (initializationController.status !== "success") {
438
- if (initializationController.status === "waitingForInitResult") {
439
- pendingPageChange = true
440
- }
441
- return
442
- }
443
- const message: PageChangedMessage = { type: "pageChanged", page }
444
- post(message)
451
+ updateHostState({ page })
445
452
  },
446
- setDataSources(nextDataSources) {
453
+ setCurrentUser(currentUser) {
454
+ updateHostState({ currentUser })
455
+ },
456
+ setDataSources(dataSources) {
447
457
  if (
448
458
  initializationController.hasConnectedSandbox &&
449
459
  manifest !== undefined &&
450
- getBindingErrorCode(manifest, nextDataSources) !== undefined
460
+ getBindingErrorCode(manifest, dataSources) !== undefined
451
461
  ) {
452
462
  emit("warn", {
453
463
  message: "Ignored invalid data source bindings update",
454
- payload: nextDataSources,
464
+ payload: dataSources,
455
465
  })
456
466
  return
457
467
  }
458
- dataSources = nextDataSources
459
- activeDataSources = nextDataSources
460
- if (initializationController.status !== "success") {
461
- if (initializationController.status === "waitingForInitResult") {
462
- pendingDataSourcesChange = nextDataSources
463
- }
464
- return
465
- }
466
- const message: DataSourcesChangedMessage = {
467
- type: "dataSourcesChanged",
468
- dataSources: nextDataSources,
469
- }
470
- post(message)
471
- },
472
- setCurrentUser(nextCurrentUser) {
473
- currentUser = nextCurrentUser
474
- if (initializationController.status !== "success") {
475
- if (initializationController.status === "waitingForInitResult") {
476
- pendingCurrentUserChange = true
477
- }
478
- return
479
- }
480
- post({ type: "currentUserChanged", currentUser })
468
+ activeDataSources = dataSources
469
+ updateHostState({ dataSources })
481
470
  },
482
471
  refreshQuery({ dataSourceId }) {
483
- if (initializationController.status !== "success") {
472
+ if (initializationController.status !== "initialized") {
484
473
  return
485
474
  }
486
475
  const refreshes = querySubscriptions.refresh(dataSourceId)
package/src/index.ts CHANGED
@@ -23,8 +23,9 @@ export {
23
23
  export type {
24
24
  ConnectSuccessMessage,
25
25
  CustomBlockHostDataSourcesPayload,
26
- CustomBlockHostInitialDataSources,
26
+ CustomBlockHostGetInitialState,
27
27
  CustomBlockHostInitialState,
28
+ CustomBlockHostInitialStateResult,
28
29
  } from "./lifecycle/types.js"
29
30
  export type {
30
31
  CreatePageMessageInput,
@@ -90,6 +90,12 @@ export function initErrorForFailureReason(
90
90
  message: "Current user is unavailable.",
91
91
  isRetryable: true,
92
92
  }
93
+ case "initial_state_timeout":
94
+ return {
95
+ code: reason,
96
+ message: "Host did not prepare initial state before the timeout.",
97
+ isRetryable: true,
98
+ }
93
99
  case "missing_data_source_binding":
94
100
  return {
95
101
  code: reason,