Summary
@terrastruct/d2 (the d2.js wrapper) spawns a node:worker_threads.Worker for the WASM on the first compile() call and exposes no public API to terminate it. As a result, any Node process that uses D2 cannot exit on its own after the rendering work is done — the worker sits idle on a message channel and keeps the event loop alive indefinitely.
This bites build tools that import the package as a library, most notably remark/rehype plugins running inside Astro / Vite / Next pipelines. The build emits its files and then hangs forever (or until CI cancels). Several layers downstream have run into this without realising the root cause is here:
Minimal repro
// repro.mjs
import { D2 } from '@terrastruct/d2';
const d2 = new D2();
const result = await d2.compile('a -> b');
const svg = await d2.render(result.diagram, result.renderOptions);
console.log('svg length:', svg.length);
console.log('process should exit now, but it does not...');
$ time timeout --signal=KILL 10 node repro.mjs
svg length: 10029
process should exit now, but it does not...
real 0m10.001s # hits the 10s SIGKILL
Versions:
@terrastruct/d2@0.1.33
- Node 22.x (also reproduces under bun 1.3.x)
- Linux
Current workaround in the wild
Reaching into the (private) worker field on the D2 instance and calling .terminate():
await d2.ready;
await d2.worker.terminate();
That works but relies on internals and the terminate() is unconditional — there's no graceful drain.
In an Astro project where I hit this, I ended up reverting from a runtime remark plugin to pre-rendering D2 sources to SVG at author time and committing the SVGs. That side-steps the issue entirely but loses the inline ```d2 fence DX.
Asks
A small, public API for cleanup would be enough. Some options the team could pick from:
D2.prototype.dispose() / terminate() — explicit, lets callers manage lifecycle.
worker.unref() on the internal Worker so the process doesn't keep it alive by default — callers that want to keep it referenced can call ref(). (worker.unref() is the standard Node idiom for "this worker shouldn't block process exit".)
- Document the workaround in the README until either of the above lands.
Happy to send a PR if there's interest, with a preference for option 2 since it's the least disruptive — most callers don't want the worker to outlive their script.
Why this matters
Build pipelines that integrate d2js will silently lock up in CI environments. The symptom is "build hangs after the last log line", which is hard to attribute back to the worker without inspecting orphan-process output on cancellation. Anyone using d2js as a library in a non-interactive context (build tools, CLI utilities, batch renderers) will hit this.
Summary
@terrastruct/d2(the d2.js wrapper) spawns anode:worker_threads.Workerfor the WASM on the firstcompile()call and exposes no public API to terminate it. As a result, any Node process that usesD2cannot exit on its own after the rendering work is done — the worker sits idle on a message channel and keeps the event loop alive indefinitely.This bites build tools that import the package as a library, most notably remark/rehype plugins running inside Astro / Vite / Next pipelines. The build emits its files and then hangs forever (or until CI cancels). Several layers downstream have run into this without realising the root cause is here:
Minimal repro
Versions:
@terrastruct/d2@0.1.33Current workaround in the wild
Reaching into the (private)
workerfield on theD2instance and calling.terminate():That works but relies on internals and the
terminate()is unconditional — there's no graceful drain.In an Astro project where I hit this, I ended up reverting from a runtime remark plugin to pre-rendering D2 sources to SVG at author time and committing the SVGs. That side-steps the issue entirely but loses the inline
```d2fence DX.Asks
A small, public API for cleanup would be enough. Some options the team could pick from:
D2.prototype.dispose()/terminate()— explicit, lets callers manage lifecycle.worker.unref()on the internal Worker so the process doesn't keep it alive by default — callers that want to keep it referenced can callref(). (worker.unref()is the standard Node idiom for "this worker shouldn't block process exit".)Happy to send a PR if there's interest, with a preference for option 2 since it's the least disruptive — most callers don't want the worker to outlive their script.
Why this matters
Build pipelines that integrate d2js will silently lock up in CI environments. The symptom is "build hangs after the last log line", which is hard to attribute back to the worker without inspecting orphan-process output on cancellation. Anyone using d2js as a library in a non-interactive context (build tools, CLI utilities, batch renderers) will hit this.