pi-tldraw 0.1.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/bridge/app-bridge-entry.js +6 -0
  4. package/mcp-app/LICENSE.md +9 -0
  5. package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
  6. package/mcp-app/README.md +129 -0
  7. package/mcp-app/dev-tunnel.sh +51 -0
  8. package/mcp-app/dist/editor-api.json +8493 -0
  9. package/mcp-app/dist/mcp-app.html +643 -0
  10. package/mcp-app/dist/method-map.json +915 -0
  11. package/mcp-app/package.json +42 -0
  12. package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
  13. package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
  14. package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
  15. package/mcp-app/scripts/extract-editor-api.ts +1374 -0
  16. package/mcp-app/server.json +21 -0
  17. package/mcp-app/src/logger.ts +45 -0
  18. package/mcp-app/src/register-tools.ts +368 -0
  19. package/mcp-app/src/shared/generated-data.ts +160 -0
  20. package/mcp-app/src/shared/pending-requests.ts +69 -0
  21. package/mcp-app/src/shared/types.ts +76 -0
  22. package/mcp-app/src/shared/utils.ts +132 -0
  23. package/mcp-app/src/tools/exec.ts +120 -0
  24. package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
  25. package/mcp-app/src/tools/search.ts +150 -0
  26. package/mcp-app/src/widget/app-context.tsx +29 -0
  27. package/mcp-app/src/widget/dev-log.tsx +70 -0
  28. package/mcp-app/src/widget/exec-helpers.ts +232 -0
  29. package/mcp-app/src/widget/export-tldr.ts +35 -0
  30. package/mcp-app/src/widget/focused/defaults.ts +141 -0
  31. package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
  32. package/mcp-app/src/widget/focused/format.ts +366 -0
  33. package/mcp-app/src/widget/focused/to-focused.ts +258 -0
  34. package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
  35. package/mcp-app/src/widget/image-guard.tsx +106 -0
  36. package/mcp-app/src/widget/index.html +33 -0
  37. package/mcp-app/src/widget/mcp-app.css +113 -0
  38. package/mcp-app/src/widget/mcp-app.tsx +857 -0
  39. package/mcp-app/src/widget/persistence.ts +337 -0
  40. package/mcp-app/src/widget/snapshot.ts +157 -0
  41. package/mcp-app/src/worker.ts +305 -0
  42. package/mcp-app/tsconfig.json +23 -0
  43. package/mcp-app/vite.config.ts +13 -0
  44. package/mcp-app/wrangler.toml +45 -0
  45. package/mcp-app-source.json +36 -0
  46. package/package.json +90 -0
  47. package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
  48. package/scripts/assemble-mcp-app.mjs +193 -0
  49. package/scripts/build-bridge.mjs +74 -0
  50. package/scripts/e2e-mcp.mjs +69 -0
  51. package/scripts/e2e-packaged-mcp-app.mjs +79 -0
  52. package/scripts/run-mcp-app-dev.mjs +44 -0
  53. package/scripts/verify-bundle.mjs +41 -0
  54. package/scripts/verify-mcp-app-source.mjs +51 -0
  55. package/scripts/verify-mcp-app.mjs +38 -0
  56. package/scripts/verify-package-files.mjs +50 -0
  57. package/src/canvas/export.ts +164 -0
  58. package/src/canvas/state.ts +117 -0
  59. package/src/canvas/workflow.ts +105 -0
  60. package/src/commands/tldraw-command.ts +48 -0
  61. package/src/diagram/guidance.ts +44 -0
  62. package/src/host/local-host.ts +289 -0
  63. package/src/index.ts +762 -0
  64. package/src/mcp/client.ts +126 -0
  65. package/src/mcp/response.ts +74 -0
  66. package/src/semantic/layer.ts +309 -0
  67. package/src/server/server-manager.ts +153 -0
  68. package/src/store/export-store.ts +33 -0
  69. package/src/store/project-store.ts +251 -0
  70. package/src/ui/tldraw-status.ts +88 -0
  71. package/static/app-bridge-bundle.js +18114 -0
  72. package/static/app-bridge-bundle.meta.json +164 -0
  73. package/static/host.html +390 -0
  74. package/tsconfig.json +13 -0
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.tldraw/tldraw",
4
+ "description": "Draw and visually collaborate with your agents on tldraw's canvas.",
5
+ "repository": {
6
+ "url": "https://github.com/tldraw/tldraw",
7
+ "source": "github",
8
+ "subfolder": "apps/mcp-app"
9
+ },
10
+ "version": "0.1.0",
11
+ "remotes": [
12
+ {
13
+ "type": "streamable-http",
14
+ "url": "https://tldraw-mcp-app.tldraw.workers.dev/mcp"
15
+ },
16
+ {
17
+ "type": "sse",
18
+ "url": "https://tldraw-mcp-app.tldraw.workers.dev/sse"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Structured logger for the tldraw MCP Durable Object.
3
+ *
4
+ * Outputs JSON lines to console.error so that
5
+ * Cloudflare Workers Observability can capture and query them.
6
+ */
7
+
8
+ export class Logger {
9
+ constructor(
10
+ private prefix: string,
11
+ private enabled: boolean
12
+ ) {}
13
+
14
+ info(message: string, data?: Record<string, unknown>) {
15
+ if (!this.enabled) return
16
+ console.error(
17
+ JSON.stringify({ level: 'info', prefix: this.prefix, message, ...data, ts: Date.now() })
18
+ )
19
+ }
20
+
21
+ error(message: string, data?: Record<string, unknown>) {
22
+ if (!this.enabled) return
23
+ console.error(
24
+ JSON.stringify({ level: 'error', prefix: this.prefix, message, ...data, ts: Date.now() })
25
+ )
26
+ }
27
+
28
+ debug(message: string, data?: Record<string, unknown>) {
29
+ if (!this.enabled) return
30
+ console.error(
31
+ JSON.stringify({ level: 'debug', prefix: this.prefix, message, ...data, ts: Date.now() })
32
+ )
33
+ }
34
+
35
+ child(prefix: string): Logger {
36
+ return new Logger(`${this.prefix}:${prefix}`, this.enabled)
37
+ }
38
+
39
+ /** Returns a log function compatible with RegisterToolsOptions.log */
40
+ toLogFn(): (...args: unknown[]) => void {
41
+ return (...args: unknown[]) => {
42
+ this.info(args.map(String).join(' '))
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,368 @@
1
+ import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
4
+ import { z } from 'zod'
5
+ import { CANVAS_RESOURCE_URI } from './shared/types'
6
+ import type { RegisterToolsOptions, ServerDeps } from './shared/types'
7
+ import { errorResponse, parseTlShapes } from './shared/utils'
8
+ import { registerExecTool } from './tools/exec'
9
+ import { registerSearchTool } from './tools/search'
10
+
11
+ /**
12
+ * Shared tool/resource registration logic for the MCP worker runtime.
13
+ *
14
+ * Tools:
15
+ * - search: Query the Editor API spec (server-side)
16
+ * - exec: Execute JS against the live editor in the widget
17
+ * - read_checkpoint: Read checkpoint data (app-only)
18
+ * - save_checkpoint: Save checkpoint data (app-only)
19
+ * - tldraw-canvas: Interactive canvas widget resource
20
+ */
21
+
22
+ // --- Helpers ---
23
+
24
+ function injectBootstrapData(html: string, bootstrap: Record<string, unknown>): string {
25
+ const toBase64 =
26
+ typeof Buffer !== 'undefined' ? (s: string) => Buffer.from(s).toString('base64') : btoa
27
+ const encoded = toBase64(JSON.stringify(bootstrap))
28
+ const bootstrapScript = `<script>window.__TLDRAW_BOOTSTRAP__=JSON.parse(atob("${encoded}"))</script>`
29
+ const lastIdx = html.lastIndexOf('</head>')
30
+ if (lastIdx === -1) return html
31
+ return html.slice(0, lastIdx) + bootstrapScript + html.slice(lastIdx)
32
+ }
33
+
34
+ function parseArrayJson(json: string, fieldName: string): unknown[] {
35
+ const parsed = JSON.parse(json)
36
+ if (!Array.isArray(parsed)) {
37
+ throw new Error(
38
+ `${fieldName} must be a JSON array string. Build an array first, then pass JSON.stringify(array).`
39
+ )
40
+ }
41
+ return parsed
42
+ }
43
+
44
+ async function getWidgetDomain(
45
+ hostName: string | undefined,
46
+ isDev: boolean,
47
+ workerOrigin?: string
48
+ ): Promise<string | undefined> {
49
+ if (isDev) return undefined
50
+ if (hostName === 'chatgpt') return 'https://tldraw.com'
51
+ if (hostName === 'claude' && workerOrigin) {
52
+ const mcpUrl = new URL('/mcp', workerOrigin).toString()
53
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(mcpUrl))
54
+ const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'))
55
+ .join('')
56
+ .slice(0, 32)
57
+ return `${hash}.claudemcpcontent.com`
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ // --- Registration ---
63
+
64
+ export function registerTools(
65
+ server: McpServer,
66
+ deps: ServerDeps,
67
+ opts: RegisterToolsOptions
68
+ ): void {
69
+ const log = opts.log ?? ((...args: unknown[]) => console.error(...args))
70
+ const analytics = opts.analytics
71
+
72
+ // --- search (server-side spec query) ---
73
+
74
+ registerSearchTool(server, {
75
+ analytics,
76
+ log,
77
+ loader: opts.searchWorkerLoader,
78
+ loadSpec: deps.loadEditorApiSpec,
79
+ })
80
+
81
+ // --- exec (client-side code execution) ---
82
+
83
+ let currentExecCanvasId: string | null = null
84
+
85
+ registerExecTool(server, {
86
+ analytics,
87
+ log,
88
+ pendingRequests: opts.pendingRequests,
89
+ setCurrentExecCanvasId: (id) => {
90
+ currentExecCanvasId = id
91
+ },
92
+ })
93
+
94
+ // --- _exec_callback (app-only: widget resolves pending exec via callServerTool) ---
95
+
96
+ const execCallbackSchema = z.object({
97
+ channel: z.string(),
98
+ result: z
99
+ .object({
100
+ success: z.boolean(),
101
+ result: z.unknown().optional(),
102
+ error: z.string().optional(),
103
+ })
104
+ .optional(),
105
+ error: z.string().optional(),
106
+ })
107
+
108
+ server.registerTool(
109
+ '_exec_callback',
110
+ {
111
+ title: 'Exec Callback',
112
+ description: 'App-only: widget calls this to resolve a pending exec request.',
113
+ inputSchema: execCallbackSchema,
114
+ annotations: {
115
+ readOnlyHint: false,
116
+ destructiveHint: false,
117
+ idempotentHint: false,
118
+ openWorldHint: false,
119
+ },
120
+ _meta: { ui: { visibility: ['app'] } },
121
+ },
122
+ async ({
123
+ channel,
124
+ result,
125
+ error,
126
+ }: z.infer<typeof execCallbackSchema>): Promise<CallToolResult> => {
127
+ const handled = error
128
+ ? opts.pendingRequests.reject(channel, error)
129
+ : opts.pendingRequests.resolve(channel, result)
130
+
131
+ if (!handled) {
132
+ log(`[tldraw-mcp] Ignoring exec callback for non-pending channel "${channel}"`)
133
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false }) }] }
134
+ }
135
+
136
+ const canvasId = currentExecCanvasId
137
+ currentExecCanvasId = null
138
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, canvasId }) }] }
139
+ }
140
+ )
141
+
142
+ // --- _get_canvas_state (app-only: widget fetches fork data by canvasId) ---
143
+
144
+ server.registerTool(
145
+ '_get_canvas_state',
146
+ {
147
+ title: 'Get Canvas State',
148
+ description: 'App-only: get the latest checkpoint for a canvas by its canvasId.',
149
+ inputSchema: z.object({ canvasId: z.string().min(1) }),
150
+ annotations: {
151
+ readOnlyHint: true,
152
+ destructiveHint: false,
153
+ idempotentHint: true,
154
+ openWorldHint: false,
155
+ },
156
+ _meta: { ui: { visibility: ['app'] } },
157
+ },
158
+ async ({ canvasId }: { canvasId: string }): Promise<CallToolResult> => {
159
+ const checkpointId = deps.getCanvasCheckpointId(canvasId)
160
+ if (!checkpointId) {
161
+ return {
162
+ content: [
163
+ { type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) },
164
+ ],
165
+ }
166
+ }
167
+ const checkpoint = deps.loadCheckpoint(checkpointId)
168
+ if (!checkpoint) {
169
+ return {
170
+ content: [
171
+ { type: 'text', text: JSON.stringify({ shapes: [], assets: [], bindings: [] }) },
172
+ ],
173
+ }
174
+ }
175
+ const shapes = parseTlShapes(checkpoint.shapes)
176
+ return {
177
+ content: [
178
+ {
179
+ type: 'text',
180
+ text: JSON.stringify({
181
+ checkpointId,
182
+ shapes,
183
+ assets: checkpoint.assets,
184
+ bindings: checkpoint.bindings,
185
+ }),
186
+ },
187
+ ],
188
+ }
189
+ }
190
+ )
191
+
192
+ // --- read_checkpoint (app-only) ---
193
+
194
+ server.registerTool(
195
+ 'read_checkpoint',
196
+ {
197
+ title: 'Read Checkpoint',
198
+ description: 'App-only: read shapes from a checkpoint by ID.',
199
+ inputSchema: z.object({ checkpointId: z.string().min(1) }),
200
+ annotations: {
201
+ readOnlyHint: true,
202
+ destructiveHint: false,
203
+ idempotentHint: true,
204
+ openWorldHint: false,
205
+ },
206
+ _meta: { ui: { visibility: ['app'] } },
207
+ },
208
+ async ({ checkpointId }: { checkpointId: string }): Promise<CallToolResult> => {
209
+ const checkpoint = deps.loadCheckpoint(checkpointId)
210
+ if (!checkpoint) {
211
+ return {
212
+ content: [{ type: 'text', text: 'Not found.' }],
213
+ structuredContent: {
214
+ sessionId: deps.getSessionId(),
215
+ shapes: [],
216
+ assets: [],
217
+ bindings: [],
218
+ },
219
+ }
220
+ }
221
+
222
+ const shapes = parseTlShapes(checkpoint.shapes)
223
+ const assets = checkpoint.assets
224
+ const bindings = checkpoint.bindings
225
+
226
+ return {
227
+ content: [{ type: 'text', text: `${shapes.length} shape(s), ${assets.length} asset(s).` }],
228
+ structuredContent: {
229
+ sessionId: deps.getSessionId(),
230
+ shapes,
231
+ assets,
232
+ bindings,
233
+ },
234
+ }
235
+ }
236
+ )
237
+
238
+ // --- save_checkpoint (app-only) ---
239
+
240
+ server.registerTool(
241
+ 'save_checkpoint',
242
+ {
243
+ title: 'Save Checkpoint',
244
+ description:
245
+ 'App-only: save shapes to a checkpoint (from user edits). shapesJson and assetsJson must be JSON array strings.',
246
+ inputSchema: z.object({
247
+ checkpointId: z.string().min(1),
248
+ shapesJson: z.string(),
249
+ assetsJson: z.string().optional(),
250
+ bindingsJson: z.string().optional(),
251
+ canvasId: z.string().optional(),
252
+ }),
253
+ annotations: {
254
+ readOnlyHint: false,
255
+ destructiveHint: false,
256
+ idempotentHint: false,
257
+ openWorldHint: false,
258
+ },
259
+ _meta: { ui: { visibility: ['app'] } },
260
+ },
261
+ async ({
262
+ checkpointId,
263
+ shapesJson,
264
+ assetsJson,
265
+ bindingsJson,
266
+ canvasId,
267
+ }: {
268
+ checkpointId: string
269
+ shapesJson: string
270
+ assetsJson?: string
271
+ bindingsJson?: string
272
+ canvasId?: string
273
+ }): Promise<CallToolResult> => {
274
+ try {
275
+ log(
276
+ `[tldraw-mcp] save_checkpoint called: checkpointId=${checkpointId}, canvasId=${canvasId ?? 'none'}, prev activeCheckpointId=${deps.getActiveCheckpointId()}`
277
+ )
278
+ const raw = parseArrayJson(shapesJson, 'shapesJson')
279
+ const shapes = parseTlShapes(raw)
280
+ const assets = assetsJson ? parseArrayJson(assetsJson, 'assetsJson') : []
281
+ const bindings = bindingsJson ? parseArrayJson(bindingsJson, 'bindingsJson') : []
282
+ deps.saveCheckpoint(checkpointId, shapes, assets, bindings)
283
+ deps.setActiveCheckpointId(checkpointId)
284
+ if (canvasId) {
285
+ deps.setCanvasCheckpointId(canvasId, checkpointId)
286
+ }
287
+ log(
288
+ `[tldraw-mcp] save_checkpoint done: activeCheckpointId=${deps.getActiveCheckpointId()}, canvasId=${canvasId ?? 'none'}, shapes=${shapes.length}, assets=${assets.length}`
289
+ )
290
+ return {
291
+ content: [
292
+ { type: 'text', text: `Saved ${shapes.length} shape(s), ${assets.length} asset(s).` },
293
+ ],
294
+ structuredContent: {
295
+ checkpointId,
296
+ sessionId: deps.getSessionId(),
297
+ shapesCount: shapes.length,
298
+ assetsCount: assets.length,
299
+ },
300
+ }
301
+ } catch (err) {
302
+ return errorResponse('save_checkpoint', err)
303
+ }
304
+ }
305
+ )
306
+
307
+ // --- canvas resource ---
308
+
309
+ registerAppResource(
310
+ server,
311
+ 'tldraw-canvas',
312
+ CANVAS_RESOURCE_URI,
313
+ {
314
+ title: 'tldraw Canvas',
315
+ description: 'Interactive tldraw canvas.',
316
+ mimeType: RESOURCE_MIME_TYPE,
317
+ },
318
+ async (): Promise<ReadResourceResult> => {
319
+ analytics?.writeDataPoint({
320
+ blobs: ['resource_called', 'tldraw-canvas'],
321
+ })
322
+ let html = await deps.loadWidgetHtml()
323
+
324
+ const sid = deps.getSessionId()
325
+ const hostName = opts.getClientHostName()
326
+
327
+ const bootstrap: Record<string, unknown> = {
328
+ sessionId: sid,
329
+ isDev: opts.isDev,
330
+ workerOrigin: opts.workerOrigin,
331
+ mcpSessionId: deps.getMcpSessionId(),
332
+ methodMap: await deps.loadMethodMap(),
333
+ }
334
+
335
+ html = injectBootstrapData(html, bootstrap)
336
+
337
+ const domain = await getWidgetDomain(hostName, opts.isDev, opts.workerOrigin)
338
+
339
+ log(`[tldraw-mcp] Serving resource to "${hostName}" with domain: ${domain}`)
340
+
341
+ return {
342
+ contents: [
343
+ {
344
+ uri: CANVAS_RESOURCE_URI,
345
+ mimeType: RESOURCE_MIME_TYPE,
346
+ text: html,
347
+ _meta: {
348
+ ui: {
349
+ csp: {
350
+ resourceDomains: [
351
+ 'https://cdn.tldraw.com',
352
+ 'https://fonts.googleapis.com',
353
+ 'https://fonts.gstatic.com',
354
+ ...(opts.extraResourceDomains ?? []),
355
+ 'blob:',
356
+ ],
357
+ connectDomains: opts.extraConnectDomains ?? [],
358
+ },
359
+ permissions: { clipboardWrite: {} },
360
+ ...(domain ? { domain } : {}),
361
+ },
362
+ },
363
+ },
364
+ ],
365
+ }
366
+ }
367
+ )
368
+ }
@@ -0,0 +1,160 @@
1
+ type ArgKind =
2
+ | 'id'
3
+ | 'id-or-shape'
4
+ | 'ids-or-shapes'
5
+ | 'spread-ids'
6
+ | 'shape-partial'
7
+ | 'shape-partials'
8
+ | 'update-partial'
9
+ | 'update-partials'
10
+
11
+ export type RetKind =
12
+ | 'this'
13
+ | 'shape'
14
+ | 'shape-or-null'
15
+ | 'shapes'
16
+ | 'id'
17
+ | 'id-or-null'
18
+ | 'ids'
19
+ | 'id-set'
20
+
21
+ interface MethodSpec {
22
+ args: ArgKind[]
23
+ ret: RetKind
24
+ }
25
+
26
+ export type MethodMap = Record<string, MethodSpec>
27
+
28
+ export interface EditorApiSpec {
29
+ extractedAt: string
30
+ memberCount: number
31
+ categories: string[]
32
+ members: unknown[]
33
+ types: {
34
+ shapeTypes: string[]
35
+ shapes: unknown[]
36
+ }
37
+ helperCount: number
38
+ helpers: unknown[]
39
+ }
40
+
41
+ interface AssetFetcher {
42
+ fetch(input: Request): Promise<Response>
43
+ }
44
+
45
+ const GENERATED_ASSET_BASE_URL = 'https://assets.local/'
46
+
47
+ let cachedEmbeddedMethodMap: MethodMap | null | undefined
48
+
49
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
51
+ }
52
+
53
+ function parseMethodMap(value: unknown): MethodMap {
54
+ if (!isPlainObject(value)) {
55
+ throw new Error('Generated method map is missing or invalid.')
56
+ }
57
+
58
+ const entries = Object.entries(value).map(([methodName, spec]) => {
59
+ if (!isPlainObject(spec) || !Array.isArray(spec.args) || typeof spec.ret !== 'string') {
60
+ throw new Error(`Generated method map entry "${methodName}" is invalid.`)
61
+ }
62
+
63
+ return [
64
+ methodName,
65
+ {
66
+ args: spec.args as ArgKind[],
67
+ ret: spec.ret as RetKind,
68
+ },
69
+ ] as const
70
+ })
71
+
72
+ return Object.fromEntries(entries)
73
+ }
74
+
75
+ function parseEditorApiSpec(value: unknown): EditorApiSpec {
76
+ if (!isPlainObject(value)) {
77
+ throw new Error('Generated editor API spec is missing or invalid.')
78
+ }
79
+
80
+ if (
81
+ !Array.isArray(value.categories) ||
82
+ !Array.isArray(value.members) ||
83
+ !isPlainObject(value.types) ||
84
+ !Array.isArray(value.types.shapeTypes) ||
85
+ !Array.isArray(value.types.shapes) ||
86
+ !Array.isArray(value.helpers)
87
+ ) {
88
+ throw new Error('Generated editor API spec is malformed.')
89
+ }
90
+
91
+ return {
92
+ extractedAt: typeof value.extractedAt === 'string' ? value.extractedAt : '',
93
+ memberCount: typeof value.memberCount === 'number' ? value.memberCount : value.members.length,
94
+ categories: value.categories.filter(
95
+ (category): category is string => typeof category === 'string'
96
+ ),
97
+ members: value.members,
98
+ types: {
99
+ shapeTypes: value.types.shapeTypes.filter(
100
+ (shapeType): shapeType is string => typeof shapeType === 'string'
101
+ ),
102
+ shapes: value.types.shapes,
103
+ },
104
+ helperCount: typeof value.helperCount === 'number' ? value.helperCount : value.helpers.length,
105
+ helpers: value.helpers,
106
+ }
107
+ }
108
+
109
+ async function loadGeneratedJsonFromAssets<T>(
110
+ assets: AssetFetcher,
111
+ filename: string,
112
+ parser: (value: unknown) => T
113
+ ): Promise<T> {
114
+ const response = await assets.fetch(new Request(new URL(filename, GENERATED_ASSET_BASE_URL)))
115
+ if (!response.ok) {
116
+ throw new Error(`Failed to load generated asset "${filename}": ${response.status}`)
117
+ }
118
+
119
+ return parser(await response.json())
120
+ }
121
+
122
+ function readMethodMapFromBootstrap(): MethodMap | null {
123
+ if (typeof window === 'undefined') return null
124
+
125
+ const bootstrap = (window as Window & { __TLDRAW_BOOTSTRAP__?: unknown }).__TLDRAW_BOOTSTRAP__
126
+ if (!isPlainObject(bootstrap) || !('methodMap' in bootstrap)) {
127
+ return null
128
+ }
129
+
130
+ try {
131
+ return parseMethodMap(bootstrap.methodMap)
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ export async function loadEditorApiSpecFromAssets(assets: AssetFetcher): Promise<EditorApiSpec> {
138
+ return loadGeneratedJsonFromAssets(assets, 'editor-api.json', parseEditorApiSpec)
139
+ }
140
+
141
+ export async function loadMethodMapFromAssets(assets: AssetFetcher): Promise<MethodMap> {
142
+ return loadGeneratedJsonFromAssets(assets, 'method-map.json', parseMethodMap)
143
+ }
144
+
145
+ export function primeEmbeddedMethodMap(): void {
146
+ if (cachedEmbeddedMethodMap !== undefined) return
147
+ cachedEmbeddedMethodMap = readMethodMapFromBootstrap()
148
+ }
149
+
150
+ export function getRequiredEmbeddedMethodMap(): MethodMap {
151
+ if (cachedEmbeddedMethodMap === undefined) {
152
+ cachedEmbeddedMethodMap = readMethodMapFromBootstrap()
153
+ }
154
+
155
+ if (!cachedEmbeddedMethodMap) {
156
+ throw new Error('Missing embedded method map. Rebuild the widget assets and reload the app.')
157
+ }
158
+
159
+ return cachedEmbeddedMethodMap
160
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Generic pending-request store for bridging async server↔widget communication.
3
+ *
4
+ * One pending request per named channel. The server creates a pending request
5
+ * and awaits it; the widget resolves or rejects it via `callServerTool('_exec_callback')`.
6
+ *
7
+ * Callbacks without an active pending request are ignored. This prevents late
8
+ * duplicate callbacks from being replayed into a later request on the same channel.
9
+ */
10
+
11
+ interface PendingEntry {
12
+ resolve(value: unknown): void
13
+ reject(reason: Error): void
14
+ timer: ReturnType<typeof setTimeout>
15
+ }
16
+
17
+ export class PendingRequests {
18
+ private pending = new Map<string, PendingEntry>()
19
+
20
+ /**
21
+ * Create a pending request for the given channel.
22
+ * Returns a promise that resolves when `resolve()` is called,
23
+ * or rejects on timeout or if `reject()` is called.
24
+ *
25
+ * Throws if a request is already pending for this channel.
26
+ */
27
+ create(channel: string, timeoutMs = 30_000): Promise<unknown> {
28
+ if (this.pending.has(channel)) {
29
+ throw new Error(`A request is already pending for channel "${channel}"`)
30
+ }
31
+
32
+ return new Promise<unknown>((resolve, reject) => {
33
+ const timer = setTimeout(() => {
34
+ this.pending.delete(channel)
35
+ reject(new Error(`Callback timed out after ${timeoutMs}ms for channel "${channel}"`))
36
+ }, timeoutMs)
37
+
38
+ this.pending.set(channel, { resolve, reject, timer })
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Resolve the pending request for the given channel.
44
+ * Returns false if no request is currently pending.
45
+ */
46
+ resolve(channel: string, value: unknown): boolean {
47
+ const entry = this.pending.get(channel)
48
+ if (!entry) return false
49
+
50
+ clearTimeout(entry.timer)
51
+ this.pending.delete(channel)
52
+ entry.resolve(value)
53
+ return true
54
+ }
55
+
56
+ /**
57
+ * Reject the pending request for the given channel.
58
+ * Returns false if no request is currently pending.
59
+ */
60
+ reject(channel: string, error: string): boolean {
61
+ const entry = this.pending.get(channel)
62
+ if (!entry) return false
63
+
64
+ clearTimeout(entry.timer)
65
+ this.pending.delete(channel)
66
+ entry.reject(new Error(error))
67
+ return true
68
+ }
69
+ }