ncblock 0.0.3 → 0.0.5
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.
- package/HOST.md +68 -0
- package/README.md +3 -0
- package/bridge/SandboxBridge.ts +659 -0
- package/bridge/context.ts +58 -0
- package/bridge/dataSources/dataSource.ts +63 -0
- package/bridge/dataSources/dataSourcePage.ts +69 -0
- package/bridge/dataSources/dataSourceValue.ts +19 -0
- package/bridge/dataSources/dateValue.ts +96 -0
- package/bridge/dataSources/propertySchema.ts +186 -0
- package/bridge/dataSources/recordPointer.ts +13 -0
- package/bridge/dataSources/resolve.ts +96 -0
- package/bridge/dataSources/resolveProperty.ts +96 -0
- package/bridge/hostState.ts +146 -0
- package/bridge/ids.ts +30 -0
- package/bridge/incomingType.ts +19 -0
- package/bridge/loadManifest.ts +54 -0
- package/bridge/manifest.ts +53 -0
- package/bridge/messages/contextChanged.ts +15 -0
- package/bridge/messages/createPage.ts +64 -0
- package/bridge/messages/createPageResult.ts +25 -0
- package/bridge/messages/dataSourcesChanged.ts +18 -0
- package/bridge/messages/getPage.ts +32 -0
- package/bridge/messages/getUser.ts +32 -0
- package/bridge/messages/hostToSandbox.ts +33 -0
- package/bridge/messages/init.ts +20 -0
- package/bridge/messages/invalidHostMessage.ts +16 -0
- package/bridge/messages/invalidSandboxMessage.ts +18 -0
- package/bridge/messages/listUsers.ts +33 -0
- package/bridge/messages/queryDataSource.ts +16 -0
- package/bridge/messages/queryDataSourceResult.ts +18 -0
- package/bridge/messages/ready.ts +25 -0
- package/bridge/messages/resize.ts +13 -0
- package/bridge/messages/sandboxToHost.ts +30 -0
- package/bridge/messages/themeChanged.ts +15 -0
- package/bridge/messages/updatePage.ts +21 -0
- package/bridge/messages/updatePageResult.ts +24 -0
- package/bridge/pages/page.ts +314 -0
- package/bridge/pendingRequests.ts +28 -0
- package/bridge/sandboxClient.ts +112 -0
- package/bridge/theme.ts +5 -0
- package/bridge/users/user.ts +31 -0
- package/docs/context.md +45 -0
- package/docs/data-sources.md +161 -0
- package/docs/lifecycle.md +92 -0
- package/docs/manifest.md +42 -0
- package/docs/pages.md +143 -0
- package/docs/users.md +61 -0
- package/host.ts +67 -0
- package/index.ts +86 -0
- package/init.ts +92 -0
- package/package.json +15 -5
- package/react.tsx +418 -0
- package/types.ts +157 -0
- package/users.ts +26 -0
- package/utils.ts +13 -0
- package/vite-plugin/index.d.ts +46 -0
- package/vite-plugin/index.js +115 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import * as v from "valibot"
|
|
2
|
+
import type {
|
|
3
|
+
CreatePageInput,
|
|
4
|
+
CreatePageResult,
|
|
5
|
+
GetPageResult,
|
|
6
|
+
GetUserResult,
|
|
7
|
+
ListUsersInput,
|
|
8
|
+
ListUsersResult,
|
|
9
|
+
NotionPageId,
|
|
10
|
+
NotionUserId,
|
|
11
|
+
UpdatePageInput,
|
|
12
|
+
UpdatePageResult,
|
|
13
|
+
} from "../types"
|
|
14
|
+
import { unreachable } from "../utils"
|
|
15
|
+
import type { NotionCustomBlockContext } from "./context"
|
|
16
|
+
import type { NotionDataSource } from "./dataSources/dataSource"
|
|
17
|
+
import type {
|
|
18
|
+
NotionDataSourcePageUpdateInput,
|
|
19
|
+
NotionDataSourcePageUpdateResult,
|
|
20
|
+
} from "./dataSources/dataSourcePage"
|
|
21
|
+
import { resolveDataSources } from "./dataSources/resolve"
|
|
22
|
+
import { resolvePropertyWriteMapForDataSource } from "./dataSources/resolveProperty"
|
|
23
|
+
import {
|
|
24
|
+
type CustomBlockHostState,
|
|
25
|
+
createEmptyDataSourceQueryState,
|
|
26
|
+
type DataSourceQueryState,
|
|
27
|
+
} from "./hostState"
|
|
28
|
+
import { readIncomingType } from "./incomingType"
|
|
29
|
+
import type { CustomBlockManifest } from "./manifest"
|
|
30
|
+
import type {
|
|
31
|
+
CreatePageMessage,
|
|
32
|
+
CreatePageMessageParent,
|
|
33
|
+
} from "./messages/createPage"
|
|
34
|
+
import type { GetUserMessage } from "./messages/getUser"
|
|
35
|
+
import { hostToSandboxMessageSchema } from "./messages/hostToSandbox"
|
|
36
|
+
import type { InitMessage } from "./messages/init"
|
|
37
|
+
import type { InvalidHostMessage } from "./messages/invalidHostMessage"
|
|
38
|
+
import type { ListUsersMessage } from "./messages/listUsers"
|
|
39
|
+
import type { ReadyMessage } from "./messages/ready"
|
|
40
|
+
import type { ResizeMessage } from "./messages/resize"
|
|
41
|
+
import type { UpdatePageMessage } from "./messages/updatePage"
|
|
42
|
+
import type { NotionPage } from "./pages/page"
|
|
43
|
+
import { PendingRequests } from "./pendingRequests"
|
|
44
|
+
import type { NotionUser } from "./users/user"
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Used to ensure that the host and client are using the same version of the bridge protocol. A
|
|
48
|
+
* single host needs to support multiple custom blocks built with different versions of the bridge
|
|
49
|
+
* protocol. Increment this number any time a breaking change is made to the bridge protocol.
|
|
50
|
+
*/
|
|
51
|
+
export const CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION = 1
|
|
52
|
+
|
|
53
|
+
export class SandboxBridge {
|
|
54
|
+
private hostState: CustomBlockHostState = {
|
|
55
|
+
status: "uninitialized",
|
|
56
|
+
theme: "light",
|
|
57
|
+
}
|
|
58
|
+
private listeners = new Set<() => void>()
|
|
59
|
+
private nextRequestId = 1
|
|
60
|
+
private readonly pendingCreatePage = new PendingRequests<CreatePageResult>(
|
|
61
|
+
"custom-block-create-page",
|
|
62
|
+
)
|
|
63
|
+
private readonly pendingGetPage = new PendingRequests<GetPageResult>(
|
|
64
|
+
"custom-block-get-page",
|
|
65
|
+
)
|
|
66
|
+
private readonly pendingGetUser = new PendingRequests<GetUserResult>(
|
|
67
|
+
"custom-block-get-user",
|
|
68
|
+
)
|
|
69
|
+
private readonly pendingListUsers = new PendingRequests<ListUsersResult>(
|
|
70
|
+
"custom-block-list-users",
|
|
71
|
+
)
|
|
72
|
+
private readonly pendingUpdatePage = new PendingRequests<UpdatePageResult>(
|
|
73
|
+
"custom-block-update-page",
|
|
74
|
+
)
|
|
75
|
+
private resolveInit: ((message: InitMessage) => void) | undefined
|
|
76
|
+
private readonly initMessage: Promise<InitMessage> = new Promise(resolve => {
|
|
77
|
+
this.resolveInit = resolve
|
|
78
|
+
})
|
|
79
|
+
private manifest: CustomBlockManifest | null = null
|
|
80
|
+
|
|
81
|
+
constructor() {
|
|
82
|
+
// `ready` is sent later by `initCustomBlock` (after the manifest fetch
|
|
83
|
+
// resolves). Top-level / no-iframe rejection is handled there too, so
|
|
84
|
+
// the constructor just attaches the listener.
|
|
85
|
+
if (typeof window !== "undefined") {
|
|
86
|
+
window.addEventListener("message", this.handleMessage)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
awaitInit(signal?: AbortSignal): Promise<InitMessage> {
|
|
91
|
+
if (!signal) {
|
|
92
|
+
return this.initMessage
|
|
93
|
+
}
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
if (signal.aborted) {
|
|
96
|
+
reject(signal.reason)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const onAbort = () => reject(signal.reason)
|
|
100
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
101
|
+
this.initMessage.then(
|
|
102
|
+
message => {
|
|
103
|
+
signal.removeEventListener("abort", onAbort)
|
|
104
|
+
resolve(message)
|
|
105
|
+
},
|
|
106
|
+
err => {
|
|
107
|
+
signal.removeEventListener("abort", onAbort)
|
|
108
|
+
reject(err)
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
sendReady(manifest: CustomBlockManifest | null) {
|
|
115
|
+
if (typeof window === "undefined") {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
this.manifest = manifest
|
|
119
|
+
const readyMessage: ReadyMessage = {
|
|
120
|
+
type: "ready",
|
|
121
|
+
bridgeProtocolVersion: CUSTOM_BLOCK_BRIDGE_PROTOCOL_VERSION,
|
|
122
|
+
manifest,
|
|
123
|
+
}
|
|
124
|
+
console.debug("[notion-custom-sdk] outbound postMessage", readyMessage)
|
|
125
|
+
window.parent.postMessage(readyMessage, "*")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private notify = () => {
|
|
129
|
+
for (const listener of this.listeners) {
|
|
130
|
+
listener()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private handleMessage = (event: MessageEvent) => {
|
|
135
|
+
console.debug("[notion-custom-sdk] incoming postMessage", {
|
|
136
|
+
data: event.data,
|
|
137
|
+
fromParent: event.source === window.parent,
|
|
138
|
+
})
|
|
139
|
+
if (event.source !== window.parent) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parsed = v.safeParse(hostToSandboxMessageSchema, event.data)
|
|
144
|
+
if (!parsed.success) {
|
|
145
|
+
console.warn(
|
|
146
|
+
"[notion-custom-sdk] ignoring malformed host message",
|
|
147
|
+
parsed.issues,
|
|
148
|
+
)
|
|
149
|
+
const incomingType = readIncomingType(event.data)
|
|
150
|
+
// Loop guard: never NACK a NACK. Excludes both directions —
|
|
151
|
+
// `invalidSandboxMessage` is what the host normally sends, but
|
|
152
|
+
// a buggy host that echoes our own `invalidHostMessage` back
|
|
153
|
+
// would otherwise spin a NACK feedback loop.
|
|
154
|
+
if (
|
|
155
|
+
incomingType !== "invalidSandboxMessage" &&
|
|
156
|
+
incomingType !== "invalidHostMessage"
|
|
157
|
+
) {
|
|
158
|
+
const nack: InvalidHostMessage = {
|
|
159
|
+
type: "invalidHostMessage",
|
|
160
|
+
reason: formatInvalidHostReason(incomingType, parsed.issues),
|
|
161
|
+
}
|
|
162
|
+
console.debug("[notion-custom-sdk] outbound postMessage", nack)
|
|
163
|
+
window.parent.postMessage(nack, "*")
|
|
164
|
+
}
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const message = parsed.output
|
|
168
|
+
|
|
169
|
+
// If the host couldn't parse one of our outbound sandbox-to-host messages, it sends back
|
|
170
|
+
// an `invalidSandboxMessage` NACK with a human-readable `reason`. Log it for developer
|
|
171
|
+
// visibility and stop. We don't retry as the sandbox can't recover from a host-side parse
|
|
172
|
+
// failure on its own.
|
|
173
|
+
if (message.type === "invalidSandboxMessage") {
|
|
174
|
+
console.warn(
|
|
175
|
+
"[notion-custom-sdk] host reported invalid sandbox message:",
|
|
176
|
+
message.reason,
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `init` is the only message valid before initialization. Handle it up
|
|
182
|
+
// front so every later case can assume `status === "initialized"`.
|
|
183
|
+
if (message.type === "init") {
|
|
184
|
+
this.applyInit(message)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Alias to keep TS's narrowed `InitializedHostState` across the switch
|
|
189
|
+
// below. Reading `this.hostState` repeatedly would re-widen it.
|
|
190
|
+
const hostState = this.hostState
|
|
191
|
+
|
|
192
|
+
if (hostState.status !== "initialized") {
|
|
193
|
+
console.warn(`[notion-custom-sdk] ignoring ${message.type} before init`)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
switch (message.type) {
|
|
198
|
+
case "themeChanged": {
|
|
199
|
+
this.hostState = {
|
|
200
|
+
...hostState,
|
|
201
|
+
theme: message.theme,
|
|
202
|
+
}
|
|
203
|
+
this.notify()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case "contextChanged": {
|
|
208
|
+
this.hostState = {
|
|
209
|
+
...hostState,
|
|
210
|
+
context: message.context as NotionCustomBlockContext,
|
|
211
|
+
}
|
|
212
|
+
this.notify()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case "dataSourcesChanged": {
|
|
217
|
+
const dataSources = resolveDataSources({
|
|
218
|
+
manifest: this.manifest,
|
|
219
|
+
dataSourceBindings: message.dataSources.bindings,
|
|
220
|
+
})
|
|
221
|
+
// Drop cached query state for keys that no longer exist in the mapping.
|
|
222
|
+
const nextKeys = new Set(dataSources.map(s => s.key))
|
|
223
|
+
const prunedState: Record<string, DataSourceQueryState> = {}
|
|
224
|
+
for (const [key, state] of Object.entries(hostState.dataSourceState)) {
|
|
225
|
+
if (nextKeys.has(key)) {
|
|
226
|
+
prunedState[key] = state
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.hostState = {
|
|
230
|
+
...hostState,
|
|
231
|
+
dataSources,
|
|
232
|
+
dataSourceState: prunedState,
|
|
233
|
+
}
|
|
234
|
+
this.notify()
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case "createPageResult": {
|
|
239
|
+
const result: CreatePageResult =
|
|
240
|
+
message.status === "success"
|
|
241
|
+
? { status: "success", page: message.page as unknown as NotionPage }
|
|
242
|
+
: { status: "error", error: message.error }
|
|
243
|
+
if (!this.pendingCreatePage.resolve(message.requestId, result)) {
|
|
244
|
+
console.warn(
|
|
245
|
+
`[notion-custom-sdk] createPageResult for unknown requestId ${message.requestId}`,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case "getPageResult": {
|
|
252
|
+
const result: GetPageResult =
|
|
253
|
+
message.status === "success"
|
|
254
|
+
? { status: "success", page: message.page as unknown as NotionPage }
|
|
255
|
+
: { status: "error", error: message.error }
|
|
256
|
+
if (!this.pendingGetPage.resolve(message.requestId, result)) {
|
|
257
|
+
console.warn(
|
|
258
|
+
`[notion-custom-sdk] getPageResult for unknown requestId ${message.requestId}`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case "getUserResult": {
|
|
265
|
+
const result: GetUserResult =
|
|
266
|
+
message.status === "success"
|
|
267
|
+
? { status: "success", user: message.user as unknown as NotionUser }
|
|
268
|
+
: { status: "error", error: message.error }
|
|
269
|
+
if (!this.pendingGetUser.resolve(message.requestId, result)) {
|
|
270
|
+
console.warn(
|
|
271
|
+
`[notion-custom-sdk] getUserResult for unknown requestId ${message.requestId}`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
case "listUsersResult": {
|
|
278
|
+
const result: ListUsersResult =
|
|
279
|
+
message.status === "success"
|
|
280
|
+
? { status: "success", list: message.list }
|
|
281
|
+
: { status: "error", error: message.error }
|
|
282
|
+
if (!this.pendingListUsers.resolve(message.requestId, result)) {
|
|
283
|
+
console.warn(
|
|
284
|
+
`[notion-custom-sdk] listUsersResult for unknown requestId ${message.requestId}`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case "updatePageResult": {
|
|
291
|
+
const result: UpdatePageResult =
|
|
292
|
+
message.status === "success"
|
|
293
|
+
? { status: "success", page: message.page as unknown as NotionPage }
|
|
294
|
+
: { status: "error", error: message.error }
|
|
295
|
+
if (!this.pendingUpdatePage.resolve(message.requestId, result)) {
|
|
296
|
+
console.warn(
|
|
297
|
+
`[notion-custom-sdk] updatePageResult for unknown requestId ${message.requestId}`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case "queryDataSourceResult": {
|
|
304
|
+
const currentState =
|
|
305
|
+
hostState.dataSourceState[message.key] ??
|
|
306
|
+
createEmptyDataSourceQueryState()
|
|
307
|
+
if (currentState.latestRequestId !== message.requestId) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.hostState = {
|
|
312
|
+
...hostState,
|
|
313
|
+
dataSourceState: {
|
|
314
|
+
...hostState.dataSourceState,
|
|
315
|
+
[message.key]: {
|
|
316
|
+
items: message.items,
|
|
317
|
+
isLoading: false,
|
|
318
|
+
hasMore: message.hasMore,
|
|
319
|
+
error: message.error,
|
|
320
|
+
// Keep the request ID so later host-pushed refreshes for the
|
|
321
|
+
// same subscription still match.
|
|
322
|
+
latestRequestId: message.requestId,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
this.notify()
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
default: {
|
|
331
|
+
unreachable(message)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
subscribe(listener: () => void) {
|
|
337
|
+
this.listeners.add(listener)
|
|
338
|
+
return () => this.listeners.delete(listener)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
getHostState(): CustomBlockHostState {
|
|
342
|
+
return this.hostState
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Apply an `init` payload as if it had arrived from the host. Lets callers
|
|
347
|
+
* seed the bridge directly (e.g. the React provider's standalone preview
|
|
348
|
+
* fallback) without going through `postMessage`. The bridge stays unaware
|
|
349
|
+
* of why it's being seeded.
|
|
350
|
+
*/
|
|
351
|
+
setMockState(message: InitMessage) {
|
|
352
|
+
this.applyInit(message)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private applyInit(message: InitMessage) {
|
|
356
|
+
const dataSources = resolveDataSources({
|
|
357
|
+
manifest: this.manifest,
|
|
358
|
+
dataSourceBindings: message.dataSources.bindings,
|
|
359
|
+
})
|
|
360
|
+
this.hostState = {
|
|
361
|
+
status: "initialized",
|
|
362
|
+
theme: message.theme,
|
|
363
|
+
context: message.context as NotionCustomBlockContext,
|
|
364
|
+
dataSources,
|
|
365
|
+
dataSourceState: {},
|
|
366
|
+
}
|
|
367
|
+
this.notify()
|
|
368
|
+
// Resolve the awaitInit promise once. Subsequent `init` messages
|
|
369
|
+
// (the host shouldn't send these, but be tolerant) update state but
|
|
370
|
+
// don't re-resolve.
|
|
371
|
+
if (this.resolveInit) {
|
|
372
|
+
this.resolveInit(message)
|
|
373
|
+
this.resolveInit = undefined
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
queryDataSource(key: string, limit: number) {
|
|
378
|
+
if (this.hostState.status !== "initialized") {
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const currentState =
|
|
383
|
+
this.hostState.dataSourceState[key] ?? createEmptyDataSourceQueryState()
|
|
384
|
+
|
|
385
|
+
if (currentState.isLoading) {
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const requestId = `custom-block-query-${this.nextRequestId}`
|
|
390
|
+
this.nextRequestId += 1
|
|
391
|
+
|
|
392
|
+
this.hostState = {
|
|
393
|
+
...this.hostState,
|
|
394
|
+
dataSourceState: {
|
|
395
|
+
...this.hostState.dataSourceState,
|
|
396
|
+
[key]: {
|
|
397
|
+
...currentState,
|
|
398
|
+
isLoading: true,
|
|
399
|
+
error: undefined,
|
|
400
|
+
latestRequestId: requestId,
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
this.notify()
|
|
405
|
+
|
|
406
|
+
const outbound = {
|
|
407
|
+
type: "queryDataSource",
|
|
408
|
+
requestId,
|
|
409
|
+
key,
|
|
410
|
+
limit,
|
|
411
|
+
}
|
|
412
|
+
console.debug("[notion-custom-sdk] outbound postMessage", outbound)
|
|
413
|
+
window.parent.postMessage(outbound, "*")
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
postResize(height: number) {
|
|
417
|
+
if (typeof window === "undefined") {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const safeHeight =
|
|
421
|
+
Number.isFinite(height) && height >= 0 ? Math.ceil(height) : 0
|
|
422
|
+
const outbound: ResizeMessage = {
|
|
423
|
+
type: "resize",
|
|
424
|
+
height: safeHeight,
|
|
425
|
+
}
|
|
426
|
+
console.debug("[notion-custom-sdk] outbound postMessage", outbound)
|
|
427
|
+
window.parent.postMessage(outbound, "*")
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
createPage(input: CreatePageInput): Promise<CreatePageResult> {
|
|
431
|
+
return new Promise(resolve => {
|
|
432
|
+
const resolvedParent = this.resolveCreatePageParent(input.parent)
|
|
433
|
+
if (resolvedParent.status === "error") {
|
|
434
|
+
resolve(resolvedParent)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
const resolvedProperties = resolvePropertyWriteMapForDataSource({
|
|
438
|
+
dataSource: resolvedParent.dataSource,
|
|
439
|
+
properties: input.properties,
|
|
440
|
+
operationName: "createPage",
|
|
441
|
+
})
|
|
442
|
+
if (resolvedProperties.status === "error") {
|
|
443
|
+
resolve(resolvedProperties)
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
const requestId = this.pendingCreatePage.allocate(resolve)
|
|
447
|
+
const outbound: CreatePageMessage = {
|
|
448
|
+
type: "createPage",
|
|
449
|
+
requestId,
|
|
450
|
+
parent: resolvedParent.parent,
|
|
451
|
+
properties: resolvedProperties.properties,
|
|
452
|
+
}
|
|
453
|
+
if (input.icon !== undefined) {
|
|
454
|
+
outbound.icon = input.icon
|
|
455
|
+
}
|
|
456
|
+
if (input.cover !== undefined) {
|
|
457
|
+
outbound.cover = input.cover
|
|
458
|
+
}
|
|
459
|
+
if (input.position !== undefined) {
|
|
460
|
+
outbound.position = input.position
|
|
461
|
+
}
|
|
462
|
+
console.debug("[notion-custom-sdk] outbound postMessage", outbound)
|
|
463
|
+
window.parent.postMessage(outbound, "*")
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
getPage(pageId: NotionPageId): Promise<GetPageResult> {
|
|
468
|
+
return new Promise(resolve => {
|
|
469
|
+
const requestId = this.pendingGetPage.allocate(resolve)
|
|
470
|
+
const outbound = {
|
|
471
|
+
type: "getPage",
|
|
472
|
+
requestId,
|
|
473
|
+
pageId,
|
|
474
|
+
}
|
|
475
|
+
console.debug("[notion-custom-sdk] outbound postMessage", outbound)
|
|
476
|
+
window.parent.postMessage(outbound, "*")
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
getUser(userId: NotionUserId): Promise<GetUserResult> {
|
|
481
|
+
return new Promise(resolve => {
|
|
482
|
+
const requestId = this.pendingGetUser.allocate(resolve)
|
|
483
|
+
const outbound: GetUserMessage = {
|
|
484
|
+
type: "getUser",
|
|
485
|
+
requestId,
|
|
486
|
+
userId,
|
|
487
|
+
}
|
|
488
|
+
console.debug("[notion-custom-sdk] outbound postMessage", outbound)
|
|
489
|
+
window.parent.postMessage(outbound, "*")
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
listUsers(input: ListUsersInput = {}): Promise<ListUsersResult> {
|
|
494
|
+
return new Promise(resolve => {
|
|
495
|
+
const requestId = this.pendingListUsers.allocate(resolve)
|
|
496
|
+
const outbound: ListUsersMessage = {
|
|
497
|
+
type: "listUsers",
|
|
498
|
+
requestId,
|
|
499
|
+
startCursor: input.startCursor,
|
|
500
|
+
pageSize: input.pageSize,
|
|
501
|
+
}
|
|
502
|
+
console.debug("[notion-custom-sdk] outbound postMessage", outbound)
|
|
503
|
+
window.parent.postMessage(outbound, "*")
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
updatePage(input: UpdatePageInput): Promise<UpdatePageResult> {
|
|
508
|
+
return new Promise(resolve => {
|
|
509
|
+
if (
|
|
510
|
+
(input.properties === undefined ||
|
|
511
|
+
Object.keys(input.properties).length === 0) &&
|
|
512
|
+
input.icon === undefined &&
|
|
513
|
+
input.cover === undefined &&
|
|
514
|
+
input.archived === undefined
|
|
515
|
+
) {
|
|
516
|
+
resolve({
|
|
517
|
+
status: "error",
|
|
518
|
+
error:
|
|
519
|
+
"updatePage requires at least one of: properties, icon, cover, archived.",
|
|
520
|
+
})
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const requestId = this.pendingUpdatePage.allocate(resolve)
|
|
525
|
+
const outbound: UpdatePageMessage = {
|
|
526
|
+
type: "updatePage",
|
|
527
|
+
requestId,
|
|
528
|
+
pageId: input.pageId,
|
|
529
|
+
}
|
|
530
|
+
if (input.properties !== undefined) {
|
|
531
|
+
outbound.properties = input.properties
|
|
532
|
+
}
|
|
533
|
+
if (input.icon !== undefined) {
|
|
534
|
+
outbound.icon = input.icon
|
|
535
|
+
}
|
|
536
|
+
if (input.cover !== undefined) {
|
|
537
|
+
outbound.cover = input.cover
|
|
538
|
+
}
|
|
539
|
+
if (input.archived !== undefined) {
|
|
540
|
+
outbound.archived = input.archived
|
|
541
|
+
}
|
|
542
|
+
console.debug("[notion-custom-sdk] outbound postMessage", outbound)
|
|
543
|
+
window.parent.postMessage(outbound, "*")
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Updates a page on a known data source, resolving any property keys against the data source's
|
|
549
|
+
* `propertyIdsByKey` before sending the bridge message. Used by the per-row `update` callback
|
|
550
|
+
* returned from {@link getDataSourceQueryView}.
|
|
551
|
+
*/
|
|
552
|
+
updateDataSourcePage(args: {
|
|
553
|
+
dataSource: NotionDataSource
|
|
554
|
+
pageId: NotionPageId
|
|
555
|
+
input: NotionDataSourcePageUpdateInput
|
|
556
|
+
}): Promise<NotionDataSourcePageUpdateResult> {
|
|
557
|
+
const { dataSource, pageId, input } = args
|
|
558
|
+
const resolvedProperties =
|
|
559
|
+
input.properties === undefined
|
|
560
|
+
? undefined
|
|
561
|
+
: resolvePropertyWriteMapForDataSource({
|
|
562
|
+
dataSource,
|
|
563
|
+
properties: input.properties,
|
|
564
|
+
operationName: "dataSourcePage.update",
|
|
565
|
+
})
|
|
566
|
+
if (resolvedProperties?.status === "error") {
|
|
567
|
+
return Promise.resolve(resolvedProperties)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return this.updatePage({
|
|
571
|
+
pageId,
|
|
572
|
+
properties: resolvedProperties?.properties,
|
|
573
|
+
icon: input.icon,
|
|
574
|
+
cover: input.cover,
|
|
575
|
+
archived: input.archived,
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Translates the public `CreatePageInput["parent"]` into the bridge-native
|
|
581
|
+
* `CreatePageMessageParent`. The `data_source_key` variant is resolved sandbox-side against
|
|
582
|
+
* the data source mapping the host delivered in `init` / `dataSourcesChanged`.
|
|
583
|
+
*/
|
|
584
|
+
private resolveCreatePageParent(parent: CreatePageInput["parent"]):
|
|
585
|
+
| {
|
|
586
|
+
status: "ok"
|
|
587
|
+
parent: CreatePageMessageParent
|
|
588
|
+
dataSource: NotionDataSource | undefined
|
|
589
|
+
}
|
|
590
|
+
| { status: "error"; error: string } {
|
|
591
|
+
switch (parent.type) {
|
|
592
|
+
case "page_id":
|
|
593
|
+
return { status: "ok", parent, dataSource: undefined }
|
|
594
|
+
case "data_source_id": {
|
|
595
|
+
const dataSource =
|
|
596
|
+
this.hostState.status === "initialized"
|
|
597
|
+
? this.hostState.dataSources.find(
|
|
598
|
+
entry => entry.collectionPointer?.id === parent.data_source_id,
|
|
599
|
+
)
|
|
600
|
+
: undefined
|
|
601
|
+
return { status: "ok", parent, dataSource }
|
|
602
|
+
}
|
|
603
|
+
case "data_source_key": {
|
|
604
|
+
if (this.hostState.status !== "initialized") {
|
|
605
|
+
return {
|
|
606
|
+
status: "error",
|
|
607
|
+
error: `Cannot resolve data source key "${parent.key}" before the host has initialized the SDK.`,
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const dataSource = this.hostState.dataSources.find(
|
|
611
|
+
entry => entry.key === parent.key,
|
|
612
|
+
)
|
|
613
|
+
if (dataSource === undefined) {
|
|
614
|
+
return {
|
|
615
|
+
status: "error",
|
|
616
|
+
error: `Unknown data source key "${parent.key}". Known keys: [${this.hostState.dataSources.map(entry => entry.key).join(", ")}].`,
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (dataSource.collectionPointer === undefined) {
|
|
620
|
+
return {
|
|
621
|
+
status: "error",
|
|
622
|
+
error: `Data source "${parent.key}" has not been mapped to a database yet.`,
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
status: "ok",
|
|
627
|
+
parent: {
|
|
628
|
+
type: "data_source_id",
|
|
629
|
+
data_source_id: dataSource.collectionPointer.id,
|
|
630
|
+
},
|
|
631
|
+
dataSource,
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
default:
|
|
635
|
+
unreachable(parent)
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function formatInvalidHostReason(
|
|
641
|
+
incomingType: string | undefined,
|
|
642
|
+
issues: readonly v.BaseIssue<unknown>[],
|
|
643
|
+
): string {
|
|
644
|
+
const labelled = incomingType
|
|
645
|
+
? `host message of type "${incomingType}"`
|
|
646
|
+
: "host message"
|
|
647
|
+
const first = issues[0]
|
|
648
|
+
if (!first) {
|
|
649
|
+
return `Could not parse ${labelled}: unknown error`
|
|
650
|
+
}
|
|
651
|
+
const path =
|
|
652
|
+
first.path
|
|
653
|
+
?.map(p => String(p.key ?? ""))
|
|
654
|
+
.filter(Boolean)
|
|
655
|
+
.join(".") ?? ""
|
|
656
|
+
const detail = path ? `${path}: ${first.message}` : first.message
|
|
657
|
+
const extra = issues.length > 1 ? ` (+${issues.length - 1} more)` : ""
|
|
658
|
+
return `Could not parse ${labelled}: ${detail}${extra}`
|
|
659
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as v from "valibot"
|
|
2
|
+
import type { NotionPageId } from "./pages/page"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Context about the custom block and its location in the block tree.
|
|
6
|
+
* Delivered as part of the `init` message and then re-sent via `contextChanged`
|
|
7
|
+
* whenever the host detects a change (e.g. the block moves into a different
|
|
8
|
+
* container or its enclosing page changes).
|
|
9
|
+
*/
|
|
10
|
+
export const notionCustomBlockContextSchema = v.object({
|
|
11
|
+
/**
|
|
12
|
+
* ID of the custom block itself, the one hosting this sandboxed iframe.
|
|
13
|
+
*/
|
|
14
|
+
customBlockId: v.string(),
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The block that directly parents this custom block.
|
|
18
|
+
*/
|
|
19
|
+
parent: v.object({
|
|
20
|
+
/**
|
|
21
|
+
* The ID of the block that directly parents this custom block.
|
|
22
|
+
*/
|
|
23
|
+
id: v.string(),
|
|
24
|
+
/**
|
|
25
|
+
* The block type of the block that directly parents this custom block. This could be many
|
|
26
|
+
* different types, including: page, toggle, column, and callout.
|
|
27
|
+
*/
|
|
28
|
+
type: v.string(),
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The nearest page ancestor to this custom block.
|
|
33
|
+
*/
|
|
34
|
+
page: v.object({
|
|
35
|
+
/**
|
|
36
|
+
* The ID of the nearest page ancestor to this custom block.
|
|
37
|
+
*/
|
|
38
|
+
id: v.string(),
|
|
39
|
+
}),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export type NotionCustomBlockContext = Omit<
|
|
43
|
+
v.InferOutput<typeof notionCustomBlockContextSchema>,
|
|
44
|
+
"page"
|
|
45
|
+
> & {
|
|
46
|
+
page: {
|
|
47
|
+
id: NotionPageId
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseNotionCustomBlockContext(
|
|
52
|
+
value: unknown,
|
|
53
|
+
): NotionCustomBlockContext | undefined {
|
|
54
|
+
const parsed = v.safeParse(notionCustomBlockContextSchema, value)
|
|
55
|
+
return parsed.success
|
|
56
|
+
? (parsed.output as NotionCustomBlockContext)
|
|
57
|
+
: undefined
|
|
58
|
+
}
|