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/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() + } +} 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"