Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/src/lib/d2-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/src/lib/d2-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ let d2Promise: Promise<D2Instance> | null = null

function loadD2(): Promise<D2Instance> {
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(() => {
Expand Down
61 changes: 61 additions & 0 deletions packages/app-core/src/lib/d2-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> }
}
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<void>
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()
})
})
126 changes: 125 additions & 1 deletion packages/app-core/src/lib/d2-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<D2> {
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<string, unknown>)[name]
}
return d2
}

/** What `@terrastruct/d2` has at runtime but leaves out of its typings. */
interface Probe {
ready: Promise<void>
version(): Promise<unknown>
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<D2> {
const inWorker = new D2()
try {
await (inWorker as unknown as Probe).version()
return inWorker
} catch {
;(inWorker as unknown as Probe).worker?.terminate?.()
return onThePage()
}
}
2 changes: 1 addition & 1 deletion packages/bridge-contract/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/shared-domain/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-domain",
"private": true,
"version": "2.71.0",
"version": "2.71.1",
"type": "module",
"exports": {
"./*": "./src/*.ts"
Expand Down
Loading