From 3d6feedb8cb3f42c51e1c18adf3e27d9828e22b2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 18 Oct 2026 20:10:34 -0500 Subject: [PATCH 1/2] Fix(diagrams): D2 diagrams draw on iPhone and in Safari @terrastruct/d2 starts its Go compiler in a module worker. In WebKit (the iPhone app's WKWebView, Safari on the self-hosted web app) the Go runtime overflows the worker's smaller stack while it starts: the WebAssembly loads, the worker still reports ready, and every d2 fence then failed with "undefined is not an object (evaluating 'd2.compile')". The same runtime starts fine on the page's own thread. createD2 now asks the package's worker for its version first. Chromium (the desktop app, Android, Chrome on the web app) answers and keeps the worker, so nothing changes there. When the worker cannot answer, the worker script the package builds runs on the page instead: its Blob is read as text while the D2 is constructed, its two module exports are dropped, and its message handler is given an in-page port. Nothing fetches or imports a blob URL, so the self-hosted server's Content-Security-Policy (script-src 'self' 'unsafe-eval') allows it as it is. The Go runtime's process and path stubs leave the page once it has started, so no library mistakes the page for Node; fs stays, since the runtime writes its output through it. It deliberately does not patch the package or move the compiler for everyone: a compile on the page holds the UI while it runs, which only WebKit has to accept. A package update whose worker script no longer ends the way this expects fails loudly instead of running something else, and a test pins that against the installed version. Checked in a WKWebView under the server's exact policy (two diagrams compiled and rendered; the unfixed path fails on the same page) and in Chromium, where the worker still does the work and nothing lands on the page. --- packages/app-core/src/lib/d2-render.test.ts | 2 +- packages/app-core/src/lib/d2-render.ts | 2 +- packages/app-core/src/lib/d2-runtime.test.ts | 61 +++++++++ packages/app-core/src/lib/d2-runtime.ts | 126 ++++++++++++++++++- 4 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 packages/app-core/src/lib/d2-runtime.test.ts diff --git a/packages/app-core/src/lib/d2-render.test.ts b/packages/app-core/src/lib/d2-render.test.ts index cecb1234..84bab306 100644 --- a/packages/app-core/src/lib/d2-render.test.ts +++ b/packages/app-core/src/lib/d2-render.test.ts @@ -36,7 +36,7 @@ const runtime = vi.hoisted(() => { return { calls, D2 } }) -vi.mock('./d2-runtime', () => ({ D2: runtime.D2 })) +vi.mock('./d2-runtime', () => ({ D2: runtime.D2, createD2: async () => new runtime.D2() })) import { d2SourceSetsTheme, formatD2Error, peekD2Svg, renderD2Svg, sanitizeD2Svg, withUniqueD2Ids } from './d2-render' diff --git a/packages/app-core/src/lib/d2-render.ts b/packages/app-core/src/lib/d2-render.ts index 33e12871..11f53c2f 100644 --- a/packages/app-core/src/lib/d2-render.ts +++ b/packages/app-core/src/lib/d2-render.ts @@ -32,7 +32,7 @@ let d2Promise: Promise | null = null function loadD2(): Promise { if (!d2Promise) { - d2Promise = import('./d2-runtime').then((module) => new module.D2()) + d2Promise = import('./d2-runtime').then((module) => module.createD2()) // A failed load (an offline web client that never fetched the chunk) must // not poison every later diagram. d2Promise.catch(() => { diff --git a/packages/app-core/src/lib/d2-runtime.test.ts b/packages/app-core/src/lib/d2-runtime.test.ts new file mode 100644 index 00000000..002c2032 --- /dev/null +++ b/packages/app-core/src/lib/d2-runtime.test.ts @@ -0,0 +1,61 @@ +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { pageWorkerScript } from './d2-runtime' + +describe('pageWorkerScript', () => { + it('drops the module exports and hands the handler the port', () => { + const source = [ + 'export let BrotliDecode = (() => 1)();', + 'export function setupMessageHandler(isNode, port, initWasm) {}', + 'async function initWasmBrowser() { return self.d2 }', + 'setupMessageHandler(false, self, initWasmBrowser);' + ].join('\n') + const script = pageWorkerScript(source) + expect(script).not.toBeNull() + expect(script).not.toMatch(/^\s*export\s/m) + expect(script).toContain('setupMessageHandler(false, port, initWasmBrowser);') + expect(() => new Function('port', script!)).not.toThrow() + }) + + it('refuses a script without the entry it expects', () => { + expect(pageWorkerScript('setupMessageHandler(true, parentPort, initWasmNode);')).toBeNull() + }) + + it('turns the worker script of the installed @terrastruct/d2 into a valid function body', async () => { + // The browser build assembles its worker from a Blob of strings as a D2 + // is constructed. Record that Blob, then refuse the Worker, so nothing + // starts and the compiler is never decoded. + const require = createRequire(import.meta.url) + const browserBuild = join(dirname(require.resolve('@terrastruct/d2')), '..', 'browser', 'index.js') + const { D2 } = (await import(/* @vite-ignore */ pathToFileURL(browserBuild).href)) as { + D2: new () => { ready: Promise } + } + let source: string | null = null + const saved = { Blob: globalThis.Blob, Worker: globalThis.Worker } + globalThis.Blob = class extends saved.Blob { + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options) + if (parts?.every((part) => typeof part === 'string')) source = parts.join('') + } + } + globalThis.Worker = function NoWorker() { + throw new Error('not started in tests') + } as unknown as typeof Worker + let ready: Promise + try { + ready = new D2().ready + } finally { + globalThis.Blob = saved.Blob + globalThis.Worker = saved.Worker + } + await expect(ready).rejects.toThrow() + expect(source).not.toBeNull() + const script = pageWorkerScript(source!) + expect(script).not.toBeNull() + expect(script).not.toMatch(/^\s*export\s/m) + // Parsing only: the Function is built, never called. + expect(() => new Function('port', script!)).not.toThrow() + }) +}) diff --git a/packages/app-core/src/lib/d2-runtime.ts b/packages/app-core/src/lib/d2-runtime.ts index d87dd8d7..adcdd217 100644 --- a/packages/app-core/src/lib/d2-runtime.ts +++ b/packages/app-core/src/lib/d2-runtime.ts @@ -7,5 +7,129 @@ * about 8 MB with the compiler's WebAssembly inside it, and the perf harness * and the packaged-web browser test assert that this chunk is not fetched * before a note needs a D2 diagram. + * + * It also decides where the compiler runs. `@terrastruct/d2` starts it in a + * worker, and in WebKit (the iPhone app, Safari on the self-hosted web app) + * the Go runtime overflows a worker's smaller stack while it starts: the + * WebAssembly loads, the worker still reports ready, and every diagram then + * fails with "undefined is not an object (evaluating 'd2.compile')". On the + * page's own thread it starts fine. So a D2 whose worker cannot answer is + * replaced by one whose worker script runs on the page. + */ +import { D2 } from '@terrastruct/d2' + +export { D2 } + +/** The worker script's last line: it hands the message handler the worker's + * own global as the port it talks through. */ +const WORKER_ENTRY = 'setupMessageHandler(false, self, initWasmBrowser);' + +/** + * The worker script `@terrastruct/d2` builds, rewritten to run as a function + * on the page: its two `export` keywords dropped (it is a module script, and + * a function body cannot export) and its handler given the `port` argument + * instead of the worker's global. No blob URL is fetched or imported, which + * the self-hosted server's Content-Security-Policy would refuse; running it + * needs only the 'unsafe-eval' the script already needs for its layout + * library. Null when the script no longer has the entry this expects, so an + * update of the package fails loudly instead of running something else. */ -export { D2 } from '@terrastruct/d2' +export function pageWorkerScript(source: string): string | null { + if (!source.includes(WORKER_ENTRY)) return null + return source + .replace(/^(\s*)export\s+(?=(let|const|function|class)\b)/gm, '$1') + .replace(WORKER_ENTRY, 'setupMessageHandler(false, port, initWasmBrowser);') +} + +interface MessageTarget { + postMessage(data: unknown): void + onmessage: ((event: { data: unknown }) => void) | null +} + +/** Stands in for the Worker `@terrastruct/d2` creates: runs its script on the + * page and passes messages both ways a tick later, as a worker would. */ +class PageWorker { + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: ((event: unknown) => void) | null = null + private readonly port: MessageTarget + + constructor(script: string) { + this.port = { + postMessage: (data) => { + setTimeout(() => this.onmessage?.({ data })) + }, + onmessage: null + } + new Function('port', script)(this.port) + } + + postMessage(data: unknown): void { + setTimeout(() => this.port.onmessage?.({ data })) + } + + terminate(): void {} +} + +/** Globals the Go runtime's `wasm_exec` stubs out when the page lacks them. + * Go reads them once while it starts and keeps its own references, so they + * can leave the page afterwards; a `process` without `env` would otherwise + * mislead every library that checks for Node. `fs` stays: the runtime writes + * its output through the bare name. */ +const GO_STUBS = ['process', 'path'] as const + +/** A D2 whose worker script runs on the page. `new D2()` builds its worker + * from a Blob of the script and starts it in the same synchronous step, so + * Blob and Worker are swapped only for that step: the Blob keeps the + * script's text, and the Worker runs it here. */ +async function onThePage(): Promise { + const OriginalBlob = globalThis.Blob + const OriginalWorker = globalThis.Worker + const absent = GO_STUBS.filter((name) => !(name in globalThis)) + let script: string | null = null + globalThis.Blob = class extends OriginalBlob { + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options) + if (parts?.every((part) => typeof part === 'string')) { + script = pageWorkerScript(parts.join('')) ?? script + } + } + } + globalThis.Worker = function PageWorkerFactory() { + if (!script) throw new Error('The D2 worker script is not the one this version expects') + return new PageWorker(script) + } as unknown as typeof Worker + let d2: D2 + try { + d2 = new D2() + } finally { + globalThis.Blob = OriginalBlob + globalThis.Worker = OriginalWorker + } + try { + await (d2 as unknown as Probe).ready + } finally { + for (const name of absent) delete (globalThis as Record)[name] + } + return d2 +} + +/** What `@terrastruct/d2` has at runtime but leaves out of its typings. */ +interface Probe { + ready: Promise + version(): Promise + worker?: { terminate?(): void } +} + +/** A D2 that answers: the package's own, in its worker, when that worker + * starts; otherwise one on the page. The probe is `version()`, the cheapest + * call that reaches the compiler. */ +export async function createD2(): Promise { + const inWorker = new D2() + try { + await (inWorker as unknown as Probe).version() + return inWorker + } catch { + ;(inWorker as unknown as Probe).worker?.terminate?.() + return onThePage() + } +} From ae28816697a8bed4c402f95af25aa81216b809b9 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 18 Oct 2026 20:10:34 -0500 Subject: [PATCH 2/2] Release(core): 2.71.1 carries D2 on WebKit to the phones 3d6feedb lets D2 diagrams draw in WebKit, so the shared packages move to 2.71.1 and the prepared iPhone 1.27.0 adopts that instead of 2.71.0. The self-hosted web client gets it with the next server pin. Android 1.1.41 ships on 2.71.0, where D2 already draws, and the desktop app stays at 2.71.0 until its next release. --- package-lock.json | 6 +++--- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e4ca36f..85e5b356 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.71.0", + "version": "2.71.1", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.71.0", + "version": "2.71.1", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.71.0", + "version": "2.71.1", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", diff --git a/packages/app-core/package.json b/packages/app-core/package.json index a94b271c..21e23d94 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.71.0", + "version": "2.71.1", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 8ccc5da5..9d1311e9 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.71.0", + "version": "2.71.1", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index baddf73f..2146969c 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.71.0", + "version": "2.71.1", "type": "module", "exports": { "./*": "./src/*.ts"