@notionhq/custom-blocks-host 0.3.3 → 0.3.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.
Files changed (44) hide show
  1. package/README.md +11 -4
  2. package/dist/browser.js +1 -1
  3. package/dist/createCustomBlockHost.d.ts.map +1 -1
  4. package/dist/createCustomBlockHost.js +68 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/lifecycle/legacyPage.d.ts +68 -0
  8. package/dist/lifecycle/legacyPage.d.ts.map +1 -0
  9. package/dist/lifecycle/legacyPage.js +159 -0
  10. package/dist/lifecycle/normalizeHostToSandboxMessage.d.ts +2 -2
  11. package/dist/lifecycle/normalizeHostToSandboxMessage.d.ts.map +1 -1
  12. package/dist/lifecycle/normalizeHostToSandboxMessage.js +4 -32
  13. package/dist/lifecycle/normalizeQueryPage.d.ts +3 -0
  14. package/dist/lifecycle/normalizeQueryPage.d.ts.map +1 -0
  15. package/dist/lifecycle/normalizeQueryPage.js +13 -0
  16. package/dist/messages/parseExternalUrl.d.ts +3 -0
  17. package/dist/messages/parseExternalUrl.d.ts.map +1 -0
  18. package/dist/messages/parseExternalUrl.js +15 -0
  19. package/dist/messages/sandboxMessageRouter.d.ts +2 -0
  20. package/dist/messages/sandboxMessageRouter.d.ts.map +1 -1
  21. package/dist/messages/sandboxMessageRouter.js +19 -4
  22. package/dist/messages/types.d.ts +7 -0
  23. package/dist/messages/types.d.ts.map +1 -1
  24. package/dist/protocol/dataSources/dataSourcePage.d.ts +1 -2
  25. package/dist/protocol/dataSources/dataSourcePage.js +1 -2
  26. package/dist/protocol/index.d.ts +1 -0
  27. package/dist/protocol/index.js +1 -0
  28. package/dist/protocol/messages/hostToSandbox.d.ts +1649 -241
  29. package/dist/protocol/messages/hostToSandbox.js +15 -6
  30. package/dist/protocol/messages/openExternalUrl.d.ts +32 -0
  31. package/dist/protocol/messages/openExternalUrl.js +26 -0
  32. package/dist/protocol/messages/queryDataSourceResult.d.ts +174 -0
  33. package/dist/protocol/messages/queryDataSourceResult.js +15 -0
  34. package/dist/protocol/messages/sandboxToHost.d.ts +4 -0
  35. package/dist/protocol/messages/sandboxToHost.js +2 -0
  36. package/package.json +1 -1
  37. package/src/createCustomBlockHost.ts +79 -1
  38. package/src/index.ts +4 -0
  39. package/src/lifecycle/legacyPage.ts +183 -0
  40. package/src/lifecycle/normalizeHostToSandboxMessage.ts +9 -37
  41. package/src/lifecycle/normalizeQueryPage.ts +25 -0
  42. package/src/messages/parseExternalUrl.ts +18 -0
  43. package/src/messages/sandboxMessageRouter.ts +24 -3
  44. package/src/messages/types.ts +23 -0
@@ -0,0 +1,183 @@
1
+ import type { NotionDataSourcePageBridge } from "@notionhq/custom-blocks-protocol/dataSources/dataSourcePage.js"
2
+ import type { NotionDataSourceValue } from "@notionhq/custom-blocks-protocol/dataSources/dataSourceValue.js"
3
+ import type { NotionPagePropertyValue } from "@notionhq/custom-blocks-protocol/pages/page.js"
4
+ import { unreachable } from "../utils.js"
5
+
6
+ export function toLegacyQueryPage(page: NotionDataSourcePageBridge) {
7
+ const propertiesById: Record<string, NotionDataSourceValue | undefined> = {}
8
+ for (const property of Object.values(page.properties)) {
9
+ if (property) {
10
+ propertiesById[property.id] = toLegacyProperty(property)
11
+ }
12
+ }
13
+ return {
14
+ id: page.id,
15
+ is_archived: page.is_archived,
16
+ in_trash: page.in_trash,
17
+ propertiesById,
18
+ }
19
+ }
20
+
21
+ function plainText(items: Record<string, unknown>[]): string {
22
+ return items
23
+ .map(item => {
24
+ if (typeof item.plain_text === "string") {
25
+ return item.plain_text
26
+ }
27
+ const text = item.text
28
+ return text &&
29
+ typeof text === "object" &&
30
+ "content" in text &&
31
+ typeof text.content === "string"
32
+ ? text.content
33
+ : ""
34
+ })
35
+ .join("")
36
+ }
37
+
38
+ function timestamp(
39
+ value: string | number | null | undefined,
40
+ ): NotionDataSourceValue | undefined {
41
+ if (value === undefined || value === null || value === "") {
42
+ return undefined
43
+ }
44
+ const date = new Date(value)
45
+ if (!Number.isFinite(date.getTime())) {
46
+ return undefined
47
+ }
48
+ return {
49
+ type: "datetime",
50
+ start_date: date.toISOString().slice(0, 10),
51
+ start_time: date.toISOString().slice(11, 16),
52
+ time_zone: "UTC",
53
+ }
54
+ }
55
+
56
+ function legacyDate(value: {
57
+ start: string
58
+ end?: string | null
59
+ time_zone?: string | null
60
+ }): NotionDataSourceValue {
61
+ const timed = value.start.includes("T") || value.time_zone != null
62
+ const offset = value.start.match(/([+-])(\d{2}):(\d{2})$/)
63
+ const offsetMinutes = offset
64
+ ? (Number(offset[2]) * 60 + Number(offset[3])) *
65
+ (offset[1] === "-" ? -1 : 1)
66
+ : 0
67
+ const parts = (input: string) => {
68
+ if (!input.includes("T")) {
69
+ return { date: input, time: "00:00" }
70
+ }
71
+ const wallClock = input.match(
72
+ /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?$/,
73
+ )
74
+ if (wallClock) {
75
+ return { date: wallClock[1], time: wallClock[2] }
76
+ }
77
+ if (!value.time_zone) {
78
+ const local = new Date(
79
+ new Date(input).getTime() + offsetMinutes * 60_000,
80
+ ).toISOString()
81
+ return { date: local.slice(0, 10), time: local.slice(11, 16) }
82
+ }
83
+ const entries = new Intl.DateTimeFormat("en-CA", {
84
+ timeZone: value.time_zone,
85
+ year: "numeric",
86
+ month: "2-digit",
87
+ day: "2-digit",
88
+ hour: "2-digit",
89
+ minute: "2-digit",
90
+ hourCycle: "h23",
91
+ }).formatToParts(new Date(input))
92
+ const get = (type: string) =>
93
+ entries.find(part => part.type === type)?.value
94
+ return {
95
+ date: `${get("year")}-${get("month")}-${get("day")}`,
96
+ time: `${get("hour")}:${get("minute")}`,
97
+ }
98
+ }
99
+ const start = parts(value.start)
100
+ const end = value.end ? parts(value.end) : undefined
101
+ if (!timed) {
102
+ return end
103
+ ? { type: "daterange", start_date: start.date, end_date: end.date }
104
+ : { type: "date", start_date: start.date }
105
+ }
106
+ const zone =
107
+ value.time_zone ??
108
+ (offsetMinutes === 0
109
+ ? "UTC"
110
+ : `UTC${offsetMinutes < 0 ? "-" : "+"}${Math.floor(Math.abs(offsetMinutes) / 60)}${Math.abs(offsetMinutes) % 60 === 0 ? "" : `:${String(Math.abs(offsetMinutes) % 60).padStart(2, "0")}`}`)
111
+ return end
112
+ ? {
113
+ type: "datetimerange",
114
+ start_date: start.date,
115
+ start_time: start.time,
116
+ end_date: end.date,
117
+ end_time: end.time,
118
+ time_zone: zone,
119
+ }
120
+ : {
121
+ type: "datetime",
122
+ start_date: start.date,
123
+ start_time: start.time,
124
+ time_zone: zone,
125
+ }
126
+ }
127
+
128
+ function toLegacyProperty(
129
+ property: NotionPagePropertyValue,
130
+ ): NotionDataSourceValue | undefined {
131
+ switch (property.type) {
132
+ case "title":
133
+ return plainText(property.title)
134
+ case "rich_text":
135
+ return plainText(property.rich_text)
136
+ case "number":
137
+ return property.number ?? undefined
138
+ case "checkbox":
139
+ return property.checkbox
140
+ case "url":
141
+ return property.url ?? ""
142
+ case "email":
143
+ return property.email ?? ""
144
+ case "phone_number":
145
+ return property.phone_number ?? ""
146
+ case "select":
147
+ return property.select?.name
148
+ case "status":
149
+ return property.status?.name
150
+ case "multi_select":
151
+ return property.multi_select.flatMap(option => option.name ?? [])
152
+ case "date":
153
+ return property.date ? legacyDate(property.date) : undefined
154
+ case "people":
155
+ return property.people.map(person =>
156
+ person.object === "group"
157
+ ? {
158
+ id: person.id,
159
+ table: "space_permission_group",
160
+ spaceId: person.id.split("|")[1],
161
+ }
162
+ : { id: person.id, table: "notion_user" },
163
+ )
164
+ case "relation":
165
+ return property.relation.map(page => ({ id: page.id, table: "block" }))
166
+ case "files":
167
+ return property.files.map(file => file.name).join(",")
168
+ case "place":
169
+ return ""
170
+ case "created_time":
171
+ return timestamp(property.created_time)
172
+ case "last_edited_time":
173
+ return timestamp(property.last_edited_time)
174
+ case "last_visited_time":
175
+ return timestamp(property.last_visited_time)
176
+ case "created_by":
177
+ return property.created_by ? [property.created_by] : undefined
178
+ case "last_edited_by":
179
+ return property.last_edited_by ? [property.last_edited_by] : undefined
180
+ default:
181
+ return unreachable(property)
182
+ }
183
+ }
@@ -1,46 +1,18 @@
1
- import { NOTION_BUILTIN_PROPERTY_SCHEMAS } from "@notionhq/custom-blocks-protocol/dataSources/propertySchema.js"
2
- import type { HostToSandboxMessage } from "@notionhq/custom-blocks-protocol/messages/hostToSandbox.js"
1
+ import type {
2
+ HostToSandboxMessage,
3
+ HostToSandboxMessageLegacy,
4
+ } from "@notionhq/custom-blocks-protocol/messages/hostToSandbox.js"
5
+ import { normalizeQueryPage } from "./normalizeQueryPage.js"
3
6
 
4
7
  /**
5
8
  * Normalizes an outgoing message for the bridge protocol version requested by the sandbox.
6
9
  */
7
10
  // TODO(custom-blocks): Remove this function when MIN_HOST_SUPPORTED_BRIDGE_PROTOCOL_VERSION
8
- // reaches 5.
11
+ // reaches 6.
9
12
  export function normalizeHostToSandboxMessage(
10
13
  message: HostToSandboxMessage,
11
14
  bridgeProtocolVersion: number,
12
- ): HostToSandboxMessage {
13
- // The latest SDK version does not need any normalization.
14
- if (bridgeProtocolVersion >= 5) {
15
- return message
16
- }
17
- const containsDataSources =
18
- (message.type === "init" && message.status === "success") ||
19
- message.type === "dataSourcesChanged"
20
- if (!containsDataSources) {
21
- return message
22
- }
23
- // Protocol 5 excludes synthetic IDs such as created_time from collectionSchema.propertiesById.
24
- // Older SDKs derive read schemas from that map. Restore those entries in outgoing copies so
25
- // host query validation still uses the actual database schema.
26
- return {
27
- ...message,
28
- dataSources: {
29
- bindings: Object.fromEntries(
30
- Object.entries(message.dataSources.bindings).map(([key, binding]) => [
31
- key,
32
- {
33
- ...binding,
34
- collectionSchema: {
35
- ...binding.collectionSchema,
36
- propertiesById: {
37
- ...binding.collectionSchema?.propertiesById,
38
- ...NOTION_BUILTIN_PROPERTY_SCHEMAS,
39
- },
40
- },
41
- },
42
- ]),
43
- ),
44
- },
45
- }
15
+ ): HostToSandboxMessageLegacy {
16
+ const normalizedMessage = normalizeQueryPage(message, bridgeProtocolVersion)
17
+ return normalizedMessage
46
18
  }
@@ -0,0 +1,25 @@
1
+ import type {
2
+ HostToSandboxMessage,
3
+ HostToSandboxMessageLegacy,
4
+ } from "@notionhq/custom-blocks-protocol/messages/hostToSandbox.js"
5
+ import { LEGACY_PROPERTY_SHAPE_PROTOCOL_VERSION } from "@notionhq/custom-blocks-protocol/protocolVersion.js"
6
+ import { toLegacyQueryPage } from "./legacyPage.js"
7
+
8
+ export function normalizeQueryPage(
9
+ message: HostToSandboxMessageLegacy | HostToSandboxMessage,
10
+ bridgeProtocolVersion: number,
11
+ ): HostToSandboxMessageLegacy {
12
+ if (
13
+ bridgeProtocolVersion < LEGACY_PROPERTY_SHAPE_PROTOCOL_VERSION &&
14
+ message.type === "queryDataSourceResult" &&
15
+ message.status === "success"
16
+ ) {
17
+ return {
18
+ ...message,
19
+ items: message.items.map(page =>
20
+ "properties" in page ? toLegacyQueryPage(page) : page,
21
+ ),
22
+ }
23
+ }
24
+ return message
25
+ }
@@ -0,0 +1,18 @@
1
+ const MAX_EXTERNAL_URL_LENGTH = 2048
2
+
3
+ /** Returns a normalized, credential-free HTTP or HTTPS URL. */
4
+ export function parseExternalUrl(input: string): string | undefined {
5
+ if (input.length > MAX_EXTERNAL_URL_LENGTH || !URL.canParse(input)) {
6
+ return undefined
7
+ }
8
+ const parsed = new URL(input)
9
+ if (
10
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
11
+ parsed.username !== "" ||
12
+ parsed.password !== "" ||
13
+ parsed.href.length > MAX_EXTERNAL_URL_LENGTH
14
+ ) {
15
+ return undefined
16
+ }
17
+ return parsed.href
18
+ }
@@ -6,6 +6,7 @@ import type { InitResultMessage } from "@notionhq/custom-blocks-protocol/message
6
6
  import type { InvalidSandboxMessage } from "@notionhq/custom-blocks-protocol/messages/invalidSandboxMessage.js"
7
7
  import { isInitHandshakeMessage } from "@notionhq/custom-blocks-protocol/messages/isInitHandshakeMessage.js"
8
8
  import type { ListUsersMessage } from "@notionhq/custom-blocks-protocol/messages/listUsers.js"
9
+ import type { OpenExternalUrlMessage } from "@notionhq/custom-blocks-protocol/messages/openExternalUrl.js"
9
10
  import type { OpenPageMessage } from "@notionhq/custom-blocks-protocol/messages/openPage.js"
10
11
  import type { QueryDataSourceMessage } from "@notionhq/custom-blocks-protocol/messages/queryDataSource.js"
11
12
  import type { ResizeMessage } from "@notionhq/custom-blocks-protocol/messages/resize.js"
@@ -49,6 +50,7 @@ export type SandboxMessageRouterOptions = {
49
50
  ) => void
50
51
  onCreatePage: (message: CreatePageMessage) => void | Promise<void>
51
52
  onOpenPage: (message: OpenPageMessage) => void | Promise<void>
53
+ onOpenExternalUrl: (message: OpenExternalUrlMessage) => void | Promise<void>
52
54
  onGetPage: (message: GetPageMessage) => void | Promise<void>
53
55
  onGetUser: (message: GetUserMessage) => void | Promise<void>
54
56
  onListUsers: (message: ListUsersMessage) => void | Promise<void>
@@ -74,6 +76,7 @@ export function createSandboxMessageRouter(
74
76
  onCreatePage,
75
77
  onGetPage,
76
78
  onOpenPage,
79
+ onOpenExternalUrl,
77
80
  onGetUser,
78
81
  onListUsers,
79
82
  onUpdatePage,
@@ -86,7 +89,7 @@ export function createSandboxMessageRouter(
86
89
  ) {
87
90
  emit("warn", {
88
91
  message: "Ignored unsupported sandbox message",
89
- payload: data,
92
+ payload: redactExternalUrl(data),
90
93
  issues,
91
94
  })
92
95
  const response = getInvalidSandboxMessageResponse(data)
@@ -145,12 +148,12 @@ export function createSandboxMessageRouter(
145
148
  if (!isInitHandshakeMessage(message) && getInitStatus() !== "initialized") {
146
149
  emit("warn", {
147
150
  message: `Ignored ${message.type} before initialization completed`,
148
- payload: message,
151
+ payload: redactExternalUrl(message),
149
152
  })
150
153
  return
151
154
  }
152
155
 
153
- emit("in", message)
156
+ emit("in", redactExternalUrl(message))
154
157
  switch (message.type) {
155
158
  case "connect":
156
159
  onConnect(message)
@@ -170,6 +173,9 @@ export function createSandboxMessageRouter(
170
173
  case "openPage":
171
174
  void onOpenPage(message)
172
175
  return
176
+ case "openExternalUrl":
177
+ void onOpenExternalUrl(message)
178
+ return
173
179
  case "getPage":
174
180
  void onGetPage(message)
175
181
  return
@@ -193,3 +199,18 @@ export function createSandboxMessageRouter(
193
199
  }
194
200
  }
195
201
  }
202
+
203
+ function redactExternalUrl(data: unknown): unknown {
204
+ if (
205
+ typeof data === "object" &&
206
+ data !== null &&
207
+ "type" in data &&
208
+ data.type === "openExternalUrl"
209
+ ) {
210
+ return {
211
+ type: "openExternalUrl",
212
+ ...("requestId" in data ? { requestId: data.requestId } : {}),
213
+ }
214
+ }
215
+ return data
216
+ }
@@ -26,6 +26,12 @@ import type {
26
26
  listUsersResultMessageSchema,
27
27
  } from "@notionhq/custom-blocks-protocol/messages/listUsers.js"
28
28
  import type { BridgeMessagePayload } from "@notionhq/custom-blocks-protocol/messages/messagePayload.js"
29
+ import type {
30
+ CustomBlockOpenExternalUrlErrorInfo,
31
+ OpenExternalUrlMessage,
32
+ OpenExternalUrlResultMessage,
33
+ openExternalUrlMessageSchema,
34
+ } from "@notionhq/custom-blocks-protocol/messages/openExternalUrl.js"
29
35
  import type {
30
36
  CustomBlockOpenPageErrorInfo,
31
37
  OpenPageMessage,
@@ -88,6 +94,15 @@ export type CustomBlockHostOpenPageResult = BridgeMessagePayload<
88
94
  CustomBlockOpenPageErrorInfo
89
95
  >
90
96
 
97
+ export type CustomBlockHostOpenExternalUrlResult = BridgeMessagePayload<
98
+ OpenExternalUrlResultMessage,
99
+ CustomBlockOpenExternalUrlErrorInfo
100
+ >
101
+
102
+ export type CustomBlockHostNavigationContext = {
103
+ signal: AbortSignal
104
+ }
105
+
91
106
  export type CustomBlockHostHandlers = {
92
107
  queryDataSource: (
93
108
  message: QueryDataSourceMessage,
@@ -107,6 +122,10 @@ export type CustomBlockHostHandlers = {
107
122
  openPage?: (
108
123
  message: OpenPageMessage,
109
124
  ) => MaybePromise<CustomBlockHostOpenPageResult>
125
+ openExternalUrl?: (
126
+ message: OpenExternalUrlMessage,
127
+ context: CustomBlockHostNavigationContext,
128
+ ) => MaybePromise<CustomBlockHostOpenExternalUrlResult>
110
129
  updatePage?: (
111
130
  message: UpdatePageMessage,
112
131
  ) => MaybePromise<CustomBlockHostUpdatePageResult>
@@ -139,6 +158,10 @@ export type ListUsersMessageInput = InferInput<typeof listUsersMessageSchema>
139
158
 
140
159
  export type OpenPageMessageInput = InferInput<typeof openPageMessageSchema>
141
160
 
161
+ export type OpenExternalUrlMessageInput = InferInput<
162
+ typeof openExternalUrlMessageSchema
163
+ >
164
+
142
165
  export type QueryDataSourceMessageInput = InferInput<
143
166
  typeof queryDataSourceMessageSchema
144
167
  >