<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Vivari engineering</title>
        <link>https://vivari.run/blog/</link>
        <description>Teardowns of the browser-side Node runtime behind Vivari.</description>
        <lastBuildDate>Sun, 06 Sep 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>MIT-licensed.</copyright>
        <item>
            <title><![CDATA[A Nuxt dev server in a tab cost 3.46 GB, and the biggest thing we could actually shrink was the filesystem]]></title>
            <link>https://vivari.run/blog/the-memory-budget-of-a-tab</link>
            <guid>https://vivari.run/blog/the-memory-budget-of-a-tab</guid>
            <pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Three findings from profiling a real project in a browser tab, each of which contradicted the obvious guess. The largest addressable object in the process is node_modules, persisting 53 MB was costing 4.7 GB of writes, and the IDE was paying twice for one language service.]]></description>
            <content:encoded><![CDATA[<p>A tab running <code>nuxt dev</code> was using 3.46 GB. That is not a number you optimise
your way out of with a hunch, and the hunch everybody has is the same one:
bundlers are memory hogs, so it must be the bundler.</p>
<p>It was not the bundler, whose entire contribution was 22.5 MB. It was three
other things, and they are not even measured in the same units: a filesystem
holding 929 MB of <code>node_modules</code> as bytes, a persistence layer writing 4.7 GB to
store 53 MB, and an editor spending 621 MB parsing one set of type definitions
twice.</p>
<p>This post is the autopsy rather than a list of tips, because the useful part is
which assumptions turned out to be wrong.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-the-3-gb-actually-was">Where the 3 GB actually was<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#where-the-3-gb-actually-was" class="hash-link" aria-label="Direct link to Where the 3 GB actually was" title="Direct link to Where the 3 GB actually was" translate="no">​</a></h2>
<p>The tab had already come down to 3.09 GB when it was measured properly. The
split, on one machine running one project:</p>
<table><thead><tr><th>what</th><th>resident</th></tr></thead><tbody><tr><td>PID 8, the <code>nuxt dev</code> process worker</td><td>~1.87 GB</td></tr><tr><td>the filesystem worker</td><td>~580 MB</td></tr><tr><td>eight other small process workers</td><td>~175 MB total</td></tr></tbody></table>
<p>Inside that 1.87 GB dev server, the in-process esbuild Go heap was <strong>22.5 MB</strong>.</p>
<p>That single measurement killed the plan everyone arrives with. Isolating
esbuild, tearing it down between builds, moving it to its own worker: all of it
would have saved approximately nothing, and all of it would have been weeks. The
Go heap is 1.2% of the process it lives in.</p>
<p>Meanwhile the filesystem worker, which holds nothing but bytes, was the second
largest thing in the tab and the largest one we could do anything about. The
1.87 GB above it is Nuxt's and Vite's heap, allocated by their code for their
reasons. The 580 MB is ours. That is the finding.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-one-node_modules-is-the-largest-addressable-object-in-the-program">Finding one: node_modules is the largest addressable object in the program<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#finding-one-node_modules-is-the-largest-addressable-object-in-the-program" class="hash-link" aria-label="Direct link to Finding one: node_modules is the largest addressable object in the program" title="Direct link to Finding one: node_modules is the largest addressable object in the program" translate="no">​</a></h2>
<p>Every file in this runtime lives in a Rust virtual filesystem compiled to Wasm,
which means every byte of <code>node_modules</code> is sitting in one linear memory. A
mid-sized project's dependency tree is a few hundred megabytes of text that is
read once at startup and then almost never touched again.</p>
<p>Text compresses. So cold file contents are zlib-compressed in place, behind a
gate of two constants:</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> </span><span class="token constant" style="color:rgb(189, 147, 249)">MIN_COMPRESS_BYTES</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">usize</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token number">4096</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> </span><span class="token constant" style="color:rgb(189, 147, 249)">MIN_COMPRESS_RATIO</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">f64</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token number">0.95</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<p>Below 4096 bytes, do not bother: zlib's framing and the bookkeeping cost more
than the win, and <code>node_modules</code> is full of eleven byte <code>index.js</code> files. Above
a 0.95 ratio, do not bother either: the file is already compressed, storing it
packed saves nothing measurable and every future read pays an inflate.</p>
<p>Measured on Nuxt, in Chrome: VFS content went from <strong>929.0 MB to 273.6 MB</strong>, a
29% ratio and 655 MB saved, and the whole Chrome tab dropped from <strong>2.9 GB to
2.1 GB</strong>.</p>
<p>It is on by default in the SDK, and only an explicit <code>compress: false</code> turns it
off. The Rust struct itself defaults to compression off, which looks like a
contradiction and is deliberate: it keeps the flag A/B testable from a
benchmark without touching the shipped default.</p>
<p>The gate is four lines of arithmetic, and it is the reason the number above is
655 MB rather than something embarrassing. Here it is running against three
kinds of file, using the same deflate from the same Rust crate the filesystem
compresses with:</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">The gate the filesystem runs on every file</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=vfs-compression" title="The gate the filesystem runs on every file" loading="lazy" allow="cross-origin-isolated" style="height:540px"></iframe></div>
<p>The third block is the one to read twice. A megabyte of random bytes deflates to
1,048,752 bytes, which is 176 bytes <strong>larger</strong> than what went in, and the gate
correctly stores it raw. That is the whole case for the ratio test: without it,
every <code>.tgz</code>, <code>.png</code> and <code>.wasm</code> in a dependency tree would be stored slightly
bigger than it arrived and would pay an inflate on every read for the privilege.</p>
<p>One honest note about that demo, because it matters. The virtual filesystem does
not tell guest code what it decided about a file, so the script <strong>recomputes</strong>
the gate in front of you rather than reading its verdict. It is the same test on
the same bytes with the same compressor, and it is not instrumentation.</p>
<p>The edit to make is <code>SIZE</code>. Drop it to 2048 and every sample is stored raw,
including the one that deflates to a thousandth of its size, because it failed
the size test and the size test is the one the VFS checks first. The script
still prints a ratio for each, since it computes both tests rather than
short-circuiting the way the Rust does, and seeing <code>beats 0.95 true</code> sit next to
<code>the VFS keeps it RAW</code> is the clearest possible statement of what the first
constant is for.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-two-persisting-53-mb-cost-47-gb-of-writes">Finding two: persisting 53 MB cost 4.7 GB of writes<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#finding-two-persisting-53-mb-cost-47-gb-of-writes" class="hash-link" aria-label="Direct link to Finding two: persisting 53 MB cost 4.7 GB of writes" title="Direct link to Finding two: persisting 53 MB cost 4.7 GB of writes" translate="no">​</a></h2>
<p>A session survives a page reload by mirroring the filesystem into OPFS, the
browser's origin-private file storage. The steady state is small: about 53 MB
for a real project. Getting there was costing <strong>4.7 GB of writes</strong>.</p>
<p>Three offenders, and none of them is a large file:</p>
<ul>
<li class=""><strong>npm's own cache temp directory.</strong> One <code>_cacache/tmp/&lt;uuid&gt;</code> cost 1,461 MB
across 76 writes, and npm deletes it moments later. We were faithfully
persisting a scratch directory so that it could be faithfully persisted again
as it changed, and then removed.</li>
<li class=""><strong>npm's debug log.</strong> <code>_logs/*-debug-0.log</code> cost 607 MB across 3,799 writes,
because a log file is appended a line at a time and a mirror that does not
understand appends rewrites the file each time.</li>
<li class=""><strong>The manifest.</strong> The index of which paths exist was rewritten 12,847 times,
totalling roughly 2.1 GB, to describe about 3,000 paths. Every write touched
the whole thing.</li>
</ul>
<p>Total after fixing all three: about 144 MB, for the same 53 MB of durable state.</p>
<p>The general shape here is worth naming, because it is not specific to browsers.
Write amplification is invisible in every profiler you would normally reach for:
memory looked fine, the filesystem looked fine, and the only symptom was that
installs felt slower than the network could explain. You have to go and count
the writes.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-three-the-ide-was-paying-twice-for-one-thing">Finding three: the IDE was paying twice for one thing<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#finding-three-the-ide-was-paying-twice-for-one-thing" class="hash-link" aria-label="Direct link to Finding three: the IDE was paying twice for one thing" title="Direct link to Finding three: the IDE was paying twice for one thing" translate="no">​</a></h2>
<p>Monaco runs a <strong>separate full language service for each of its <code>typescript</code> and
<code>javascript</code> modes</strong>. Each one parses the whole dependency <code>.d.ts</code> payload into
roughly 310 MB, so a project with both kinds of file naively pays about 621 MB,
measured, for two services doing identical work over identical inputs.</p>
<p>Mapping <code>.js</code> files to the <code>typescript</code> mode halves it. TypeScript's language
service handles JavaScript perfectly well; it is the same compiler.</p>
<p>That is a configuration change, and it is in this post rather than a footnote
because 310 MB is larger than most of the things people spend a week optimising,
and it was found by reading a memory profile rather than by reasoning about the
code.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-emphatically-not-the-bottleneck">What is emphatically not the bottleneck<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#what-is-emphatically-not-the-bottleneck" class="hash-link" aria-label="Direct link to What is emphatically not the bottleneck" title="Direct link to What is emphatically not the bottleneck" translate="no">​</a></h2>
<p>Two things that look like they should be slow, measured so nobody has to guess:</p>
<p><strong>Filesystem write throughput.</strong> Writing a 12,000 file tree, the first 1,000
files cost 6.9 microseconds each and files 11,000 to 12,000 cost 3.6
microseconds each. The whole tree lands in about 44 milliseconds. Install time
lives in the network, in tar extraction, in npm's own JavaScript, and in the
OPFS mirror. It does not live in the virtual filesystem.</p>
<p><strong>Registry metadata, sort of.</strong> A full install pulled 421 MB of packuments
without an <code>.npmrc</code>, and 108 MB with one that restricts the fields requested.
The registry gzips them about tenfold, so the wire cost is around 45 MB while
the cost of holding and parsing them is the full 421 MB. That is a case where
the network number and the memory number differ by an order of magnitude and
only one of them is the problem.</p>
<p>There is one more measurement in this family, from shipping a prebuilt
<code>node_modules</code> snapshot for a template. Cold origin, Chrome, Starlight: fetch
0.4s, restore 4.0s for 13,459 entries, dev server listening at 31.8s, and OPFS
holding 112 MB instead of 246 MB. The asset is 111.4 MB raw and 26.0 MB gzipped,
and its buffer is transferred rather than copied between workers. The
interesting row is the restore: 4.0s in the browser against 0.1s headless, and
the entire difference is the OPFS mirror. Again the storage layer, not the
compute.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-it-fails-which-is-not-what-you-would-expect">How it fails, which is not what you would expect<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#how-it-fails-which-is-not-what-you-would-expect" class="hash-link" aria-label="Direct link to How it fails, which is not what you would expect" title="Direct link to How it fails, which is not what you would expect" translate="no">​</a></h2>
<p>A Node process that runs out of memory throws, prints a heap trace, and dies
alone. Under a memory ceiling, a browser tab does not do that.</p>
<p>Running the tab in a container with a roughly 1.6 GB ceiling, the kernel
SIGKILLs the <strong>renderer</strong>, with error code 9 and a cgroup failure count
climbing. Not one worker: the whole tab. What the user sees is Chrome's crash
page, not a frozen terminal and not an error in the console, and nothing in the
runtime gets a chance to report anything.</p>
<p>That changes what the memory work is for. It is not about being tidy. Past the
ceiling there is no graceful degradation available, because the process that
would have degraded gracefully no longer exists.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-honest-to-claim">What is honest to claim<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#what-is-honest-to-claim" class="hash-link" aria-label="Direct link to What is honest to claim" title="Direct link to What is honest to claim" translate="no">​</a></h2>
<ul>
<li class=""><strong>Every number here is one machine, one browser, one project.</strong> Nuxt in
Chrome, Starlight in Chrome. They are here to show the shape and the ordering
of the costs, not to be benchmarks anyone should quote.</li>
<li class=""><strong>You cannot reproduce these from inside the VM.</strong> <code>process.memoryUsage()</code> in
a guest process returns fixed constants, so a script running in the sandbox
cannot observe any of this. Every figure above comes from browser task
manager and profiler measurements taken outside the runtime, which is also
why the demo above measures the gate rather than the saving.</li>
<li class=""><strong>Compression is a tradeoff and the ratio test is where it is made.</strong> A cold
file that is read again pays an inflate. The 0.95 constant is a judgement,
arrived at by measurement on one kind of workload, and a workload that reads
its dependency tree constantly would want a different one.</li>
<li class=""><strong>The OPFS numbers are about write volume, not about durability.</strong> Nothing
here changes what survives a reload; it changes how many bytes it costs to
keep it surviving.</li>
<li class=""><strong>The Monaco figure is a Monaco figure.</strong> It is what two language services
cost on one dependency payload, and it will move with the size of your
<code>.d.ts</code> files.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-general-version">The general version<a href="https://vivari.run/blog/the-memory-budget-of-a-tab#the-general-version" class="hash-link" aria-label="Direct link to The general version" title="Direct link to The general version" translate="no">​</a></h2>
<p>Three findings, and the thing they have in common is that none of them is about
the code that was doing the work.</p>
<p>The bundler, the compiler, the dev server: those are what a profile looks like
it should be about, and between them they were a rounding error against a
filesystem full of text, a mirror writing gigabytes to store megabytes, and an
editor holding two copies of the same parse.</p>
<p>In a browser tab, the interesting resources are the ones the platform makes you
implement yourself. On a laptop, <code>node_modules</code> costs page cache that the kernel
reclaims when it feels like it, and nobody counts it. Here it is a Rust <code>HashMap</code>
you allocated, and it is on your bill. Persistence is a mirror you wrote rather
than a filesystem the OS provides, so its write amplification is yours too. The
platform is not doing you any invisible favours, which is inconvenient and,
occasionally, clarifying: everything that costs something is something you can
see.</p>
<p>More on the architecture in
<a href="https://vivari.run/docs/how-it-works" target="_blank" rel="noopener noreferrer" class="">the how-it-works docs</a>, and the post that
explains why the filesystem lives in a Rust module at all is
<a class="" href="https://vivari.run/blog/blocking-in-a-browser">the one about the single blocking primitive</a>.</p>
<hr>
<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no
per-seat fee, self-host every asset. The code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a> and the
<a href="https://vivari.run/studio/" target="_blank" rel="noopener noreferrer" class="">Studio</a> runs in your browser.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Browser platform</category>
            <category>WebAssembly</category>
        </item>
        <item>
            <title><![CDATA[Bun runs in the tab and there is no Bun in it, so the interesting part is what refuses]]></title>
            <link>https://vivari.run/blog/bun-without-bun</link>
            <guid>https://vivari.run/blog/bun-without-bun</guid>
            <pubDate>Sun, 30 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Bun is a Zig binary with no wasm32 build, so bun index.ts in a browser is an emulation over the Node runtime. The engineering worth reading about is bun:sqlite driving a bare sqlite3.wasm, and a refusal policy with two message shapes.]]></description>
            <content:encoded><![CDATA[<p>Bun is a single native binary written in Zig around JavaScriptCore. There is no
<code>wasm32</code> build of it, there is not going to be one soon, and a browser tab
cannot execute a Mach-O or ELF file regardless.</p>
<p>So <code>bun index.ts</code> in a page is not Bun. It is Bun's API, implemented on top of
<a class="" href="https://vivari.run/blog/nodes-real-lib-in-the-browser">the Node runtime these posts keep describing</a>,
and the honest version of that sentence is the whole subject here. A
compatibility shim is only useful if you can tell, from inside it, which parts
are real. Most of this post is about the parts that are not, and how they say
so.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-actually-on-path">What is actually on PATH<a href="https://vivari.run/blog/bun-without-bun#what-is-actually-on-path" class="hash-link" aria-label="Direct link to What is actually on PATH" title="Direct link to What is actually on PATH" translate="no">​</a></h2>
<p><code>bun</code> and <code>bunx</code> are ordinary coreutils, installed eagerly rather than lazily
unpacked on first use, because unlike the
<a class="" href="https://vivari.run/blog/real-package-managers-in-the-browser">real vendored npm, yarn and pnpm CLIs</a>
there is no tarball to unpack: they are purpose-built shims running on the same
runtime your code runs on. <code>bun run</code>, <code>bun test</code>, <code>bun build</code> and the <code>Bun</code>
global are all implemented here. <code>bun install</code> is not: it delegates to the real
npm CLI, because reimplementing a resolver would be a worse lie than borrowing
one. The TypeScript in <code>index.ts</code> is handled by
<a class="" href="https://vivari.run/blog/synchronous-esm">the synchronous stripper</a> that every other <code>.ts</code> file in the
runtime goes through, not by Bun's transpiler.</p>
<p>Most of the surface is uninteresting in the good way. <code>Bun.escapeHTML</code>,
<code>Bun.deepEquals</code>, <code>Bun.stringWidth</code>, <code>Bun.semver</code>, <code>Bun.Glob</code>, <code>Bun.which</code>,
<code>Bun.gzipSync</code> are small pure functions with a specification and a test. There
are two places where that stopped being true.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-one-module-that-could-not-be-shimmed">The one module that could not be shimmed<a href="https://vivari.run/blog/bun-without-bun#the-one-module-that-could-not-be-shimmed" class="hash-link" aria-label="Direct link to The one module that could not be shimmed" title="Direct link to The one module that could not be shimmed" translate="no">​</a></h2>
<p>Every other Bun API had something underneath it to delegate to. <code>bun:sqlite</code> did
not: there is no SQLite in the Node runtime to borrow, and the API is
<strong>synchronous</strong> by design. <code>db.query(sql).all()</code> returns rows, not a promise.
There is nowhere to await an engine booting.</p>
<p>The engine is the official <code>@sqlite.org/sqlite-wasm</code> build, the same C source
SQLite's own test suite covers, compiled by the SQLite authors. It is
<strong>committed</strong> to the repository at 844 KiB alongside a manifest recording the
upstream version and SHA-256, rather than fetched at build time, because both
spike tiers need it on a bare checkout and a spike that skips when its artifact
is missing looks green while proving nothing. The refresh script validates the
binary it pulls: magic bytes, required exports, that its imports are a subset of
what the loader supplies, and that its declared memory minimum still fits. An
upstream build that changed its ABI fails the refresh rather than failing at
somebody's first query.</p>
<p><strong>The Emscripten glue is not used.</strong> The package ships 578 KB of it, and it is
useless here twice over: it is async-init, meaning it fetches and calls
<code>WebAssembly.instantiate</code>, and it routes file I/O through MEMFS or NODEFS,
neither of which exists in this environment. So the loader supplies the module's
36 imports itself and instantiates with a bare <code>new WebAssembly.Module(bytes)</code>
followed by <code>new WebAssembly.Instance(...)</code>. Both are synchronous, which is
illegal on a main thread and legal on a worker, which is where all guest code
runs. It is the same trick
<a class="" href="https://vivari.run/blog/databases-and-the-http-parser">the llhttp binding uses</a>.</p>
<p>The memory is created on this side, 128 pages initial and 2 GiB maximum,
unshared, because the build imports memory rather than exporting it. Growth goes
through <code>emscripten_resize_heap</code>, and every cached typed-array view has to be
re-derived whenever <code>memory.buffer</code> identity changes, since growth detaches the
old <code>ArrayBuffer</code>. That is the sort of detail the glue would normally handle and
the reason people use the glue.</p>
<p><strong>And then the part that makes it worth doing.</strong> A <code>sqlite3_vfs</code> is registered
whose <code>xOpen</code>, <code>xRead</code>, <code>xWrite</code>, <code>xTruncate</code>, <code>xFileSize</code>, <code>xDelete</code>, <code>xAccess</code>
and <code>xFullPathname</code> call the runtime's own <code>fs</code>, which is
<a class="" href="https://vivari.run/blog/blocking-in-a-browser">the SharedArrayBuffer syscall bridge</a>. So a <code>.sqlite</code>
file is an ordinary file in the virtual filesystem. It shows up in the file tree,
it outlives the process that made it, and the next process reads it. The reads
and writes take explicit offsets, which is exactly the <code>pread</code> and <code>pwrite</code> a
SQLite VFS wants.</p>
<p>SQLite needs real C function pointers for those callbacks, and
<code>WebAssembly.Table.prototype.set</code> will not accept a plain JavaScript function.
So each callback is wrapped in a hand-assembled 40-byte Wasm module that imports
the function and re-exports it, and the export goes into
<code>__indirect_function_table</code>. Forty bytes of hand-written Wasm per callback is
either the ugliest thing in this repository or the most satisfying, depending on
the day.</p>
<p>Two semantics are implemented rather than approximated, because approximating
them corrupts data. <code>safeIntegers</code> governs reads: <code>true</code> returns exact <code>BigInt</code>s,
<code>false</code> returns <code>Number</code>s, lossily above 2^53, which is Bun's documented
behaviour and the reason the toggle has to exist. Binding is exact either way, so
a <code>bigint</code> argument goes in through <code>sqlite3_bind_int64</code> and one outside int64
range throws a <code>RangeError</code> naming the value instead of wrapping it. And
<code>db.transaction()</code> nests through SAVEPOINT, with nesting decided by
<code>sqlite3_get_autocommit</code> rather than a counter we keep, so a hand-written
<code>BEGIN</code> in the middle of things does not desync it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-sentences-and-the-difference-between-them-is-load-bearing">Two sentences, and the difference between them is load-bearing<a href="https://vivari.run/blog/bun-without-bun#two-sentences-and-the-difference-between-them-is-load-bearing" class="hash-link" aria-label="Direct link to Two sentences, and the difference between them is load-bearing" title="Direct link to Two sentences, and the difference between them is load-bearing" translate="no">​</a></h2>
<p>Roughly thirty Bun APIs in this shim do nothing but throw. That file is the best
writing in the repository, and its argument is this: there are two reasons an
API can fail, and telling someone the wrong one wastes their afternoon.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">"&lt;api&gt; is not supported in Vivari (browser sandbox): &lt;reason&gt;"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">"&lt;api&gt; is not implemented in the Vivari shim: &lt;reason&gt;"</span><br></div></code></pre></div></div>
<p>The first means the capability does not exist in a page. A raw socket,
<code>dlopen(3)</code>, an OS keychain, engine internals. No amount of shim work changes
it, and the code has to run somewhere else. The second means it could work here
and nobody has written it: a gap, not a limit.</p>
<p>Conflating them is its own kind of dishonesty. "Not supported" tells you to stop
and redesign. "Not implemented" tells you to file an issue or send a patch.
Where an API is half of each, and a TCP client is exactly that, since it can
reach another in-VM process forever but can never reach the internet, the
message says both, in that order.</p>
<p>There is a second rule, and it is the one that keeps projects alive. <strong>The
symbol is always exported. The throw is always on the call.</strong> So
<code>import { dlopen } from "bun:ffi"</code> still loads, and a property read during some
dependency's module-level feature detection still returns a function. A
load-time throw is strictly worse: one unused import at the top of a transitive
dependency takes down a project that never touches the API.</p>
<p>The catalogue covers <code>Bun.listen</code>, <code>Bun.connect</code>, <code>Bun.udpSocket</code>,
<code>Bun.RedisClient</code>, <code>Bun.sql</code>, <code>Bun.postgres</code>, <code>Bun.Terminal</code>, <code>Bun.WebView</code>,
<code>Bun.mmap</code>, <code>Bun.peek</code>, <code>Bun.secrets</code>, <code>Bun.dlopen</code>, the zstd helpers,
<code>Bun.generateHeapSnapshot</code>, <code>Bun.openInEditor</code> and the whole of <code>bun:ffi</code>. The
native addon half of the same file is not Bun-specific at all: <code>require("bcrypt")</code>
from plain Node code hits precisely the same wall, and one catalogue of "impossible
in a browser, and here is what to do instead" beats two that drift apart.</p>
<p>All of that is checkable rather than something to take my word for. The script
below is running Bun's API in this page: a block of one-line results, a visible
pause while argon2id does its 64 MiB of work, real SQLite reporting its own
version number and three rows, and then a refusal.</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">Bun in a tab, including the parts that refuse</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=bun-shim" title="Bun in a tab, including the parts that refuse" loading="lazy" allow="cross-origin-isolated" style="height:580px"></iframe></div>
<p>The block at the bottom is the part to play with. Uncomment any line and run it
again, and you get the exact sentence that API throws. The two message shapes
are visibly different, which is the entire point of there being two.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-ones-that-are-real-and-one-that-was-not">The ones that are real, and one that was not<a href="https://vivari.run/blog/bun-without-bun#the-ones-that-are-real-and-one-that-was-not" class="hash-link" aria-label="Direct link to The ones that are real, and one that was not" title="Direct link to The ones that are real, and one that was not" translate="no">​</a></h2>
<p><strong><code>Bun.password</code> is genuine argon2id</strong> at Bun's own documented cost parameters:
64 MiB of memory, two passes, one lane. Measured in Wasm under Node on one
machine, hashing takes about 97ms and verifying takes about the same, and in a
browser tab it is somewhat slower again, which is the pause you just sat through
in the demo. That number is supposed to be large. A password hash that returns
instantly is a password hash that is not doing its job, and the most common way to get
compatibility wrong here would have been to substitute something cheaper and
call it argon2id.</p>
<p><strong><code>Bun.hash</code> was wrong and is now pinned.</strong> It started as a bespoke
multiply-xor hash that agreed with real Bun on nothing at all, which is a
perfectly good hash function and a completely useless compatibility shim, since
the entire value of <code>Bun.hash</code> is that two systems compute the same number. It
is now wyhash final v3, checked against published vectors.</p>
<p><strong><code>Bun.sleepSync</code> used to spin.</strong> Right duration, one core pinned at 100% for
it. It now parks on <code>Atomics.wait</code>, with the spin left in as a documented
fallback for a browser main thread, where parking is illegal.</p>
<p><strong><code>bun:test</code> is stricter than real Bun in one place.</strong>
<code>expect(settledPromise).rejects.toThrow()</code> returns <code>undefined</code> in real Bun; this
runner always returns a real promise, and drains outstanding async assertions
after each test body. A snapshot file written here has been read and passed by a
real <code>bun test</code>, which is the strongest compatibility evidence in the shim.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-bunbuild-is-not">What <code>Bun.build</code> is not<a href="https://vivari.run/blog/bun-without-bun#what-bunbuild-is-not" class="hash-link" aria-label="Direct link to what-bunbuild-is-not" title="Direct link to what-bunbuild-is-not" translate="no">​</a></h2>
<p>It is not esbuild. There is no tree shaking and no minifier, and <code>minify</code>,
<code>splitting</code>, <code>sourcemap</code>, <code>bytecode</code> and <code>--compile</code> throw rather than silently
producing something that does not match the option you asked for. It bundles,
and that is the extent of the claim.</p>
<p>Streaming is the other place to be careful. A <code>ReadableStream</code> response body is
buffered in full: measured at 25 MB into an unread socket with <code>writableLength</code>
never leaving zero. Backpressure never engages. It is left honestly buffered
rather than half-implemented, which is the right call and is also a real limit
if you were planning to stream a large file out of <code>Bun.serve</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-honest-to-claim">What is honest to claim<a href="https://vivari.run/blog/bun-without-bun#what-is-honest-to-claim" class="hash-link" aria-label="Direct link to What is honest to claim" title="Direct link to What is honest to claim" translate="no">​</a></h2>
<ul>
<li class=""><strong>This is not Bun and will never be byte-for-byte Bun.</strong> It is Bun's API on
the Node runtime, so anything that depends on JavaScriptCore semantics,
Bun's transpiler output, or Bun's process startup is out of scope by
construction.</li>
<li class=""><strong><code>bun:sqlite</code> has three limits, each a sandbox fact rather than a
shortcut.</strong> <code>xSync</code> is a no-op because the runtime's <code>fsync</code> is, so the
rollback journal is still written and replayed and a crash mid-transaction
recovers, but power loss is not survivable the way real SQLite promises.
There is no file locking, so two processes writing one database can corrupt
it, and this matches what upstream ships: the official build's default VFS is
literally <code>unix-none</code>, SQLite's lock-free one. And <code>journal_mode = WAL</code> needs
shared memory across processes, so it is declined with a one-time warning and
SQLite stays in <code>delete</code> mode, which is SQLite's own documented behaviour when
a VFS cannot do WAL. ORMs that set WAL opportunistically therefore keep
working.</li>
<li class=""><strong>The argon2id timing is measured in Wasm under Node on one machine</strong>, and a
tab is slower again. Treat it as an order of magnitude, not a benchmark.</li>
<li class=""><strong>The refusal list is a snapshot.</strong> Several entries are "not implemented"
rather than "not supported", which is a promise that they could move.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-general-version">The general version<a href="https://vivari.run/blog/bun-without-bun#the-general-version" class="hash-link" aria-label="Direct link to The general version" title="Direct link to The general version" translate="no">​</a></h2>
<p>The temptation in a compatibility layer is to make the surface as wide as
possible, because the surface is what a table on a marketing page measures. Every
API you stub to return a plausible empty value makes that table better and makes
the layer worse, because the failure moves from your code to the user's, six
frames deep, with a message that names neither of you.</p>
<p>The two sentences at the top of that refusal file are worth more than any of the
APIs underneath them. A shim's honesty is a feature with a spec: name the API,
say which of the two kinds of failure this is, and say what to do instead. It
costs one string per function and it is the difference between a limitation and
a bug report.</p>
<p>More on the Bun surface in <a href="https://vivari.run/docs/bun" target="_blank" rel="noopener noreferrer" class="">the Bun docs</a>, and the
previous post covers
<a class="" href="https://vivari.run/blog/synchronous-esm">why every import in this runtime is rewritten to CommonJS at load time</a>.</p>
<hr>
<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no
per-seat fee, self-host every asset. The code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a> and the
<a href="https://vivari.run/studio/" target="_blank" rel="noopener noreferrer" class="">Studio</a> runs in your browser.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Runtime</category>
            <category>Bun</category>
        </item>
        <item>
            <title><![CDATA[Node can require() an ES module now, and it refuses two things. We could not refuse either]]></title>
            <link>https://vivari.run/blog/synchronous-esm</link>
            <guid>https://vivari.run/blog/synchronous-esm</guid>
            <pubDate>Sun, 23 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[ESM is asynchronous by design and require() is synchronous by contract. Node resolves that by declining the hard cases. In a browser worker there is no escape hatch, so every import is rewritten to CommonJS by a lexer at load time, and this is what that costs.]]></description>
            <content:encoded><![CDATA[<p>Recent Node versions will let you <code>require()</code> an ES module. It is a genuinely
hard thing to have shipped, and it comes with two documented refusals:
<code>ERR_REQUIRE_ASYNC_MODULE</code> if anything in the required graph uses top-level
await, and <code>ERR_REQUIRE_CYCLE_MODULE</code> if the graph has a cycle that crosses the
CommonJS boundary.</p>
<p>Node can refuse, because <code>import()</code> is always sitting there as an escape hatch.
Tell the user to await it and the problem is theirs.</p>
<p>In a browser worker there is no escape hatch. <code>require()</code> is
<a class="" href="https://vivari.run/blog/blocking-in-a-browser">synchronous all the way down</a> because the filesystem
under it is, and a project's entry point is <code>require</code>d by a loader that cannot
return a promise to anyone. Every <code>import</code> becomes CommonJS at load time or the
program does not start. Refusing was not on the menu.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-rewrite-actually-is">What the rewrite actually is<a href="https://vivari.run/blog/synchronous-esm#what-the-rewrite-actually-is" class="hash-link" aria-label="Direct link to What the rewrite actually is" title="Direct link to What the rewrite actually is" translate="no">​</a></h2>
<p>There is no bundler here and no compile step. Each module is rewritten as it is
read, by <code>es-module-lexer</code>, into the same synchronous CommonJS everything else
in the runtime lives in. <code>import</code> becomes a <code>require</code>, <code>export</code> becomes a
property on an exports object, <code>import.meta</code> becomes a small object built from
the filename, and dynamic <code>import()</code> becomes a helper that returns an already
resolved promise.</p>
<p>Every generated identifier is namespaced: <code>__oc_require</code>, <code>__oc_import</code>,
<code>__oc_exports</code>, <code>__oc_module</code>. That is not tidiness. User code is allowed to
declare its own <code>require</code> and its own <code>module</code>, and a great deal of published
code does.</p>
<p>The interop helpers are all emitted on <strong>one leading line</strong>, which looks like
somebody minifying for no reason. It is so that line numbers in the rewritten
file still match the file you have open, which is what makes
<a class="" href="https://vivari.run/blog/a-debugger-with-no-inspector">breakpoints land on the right line</a>.</p>
<p>That is the easy part. What follows is four years of other people's module
graphs.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="live-bindings-and-the-half-of-them-that-is-not-implemented">Live bindings, and the half of them that is not implemented<a href="https://vivari.run/blog/synchronous-esm#live-bindings-and-the-half-of-them-that-is-not-implemented" class="hash-link" aria-label="Direct link to Live bindings, and the half of them that is not implemented" title="Direct link to Live bindings, and the half of them that is not implemented" translate="no">​</a></h2>
<p>An ESM export is a binding, not a value. If a module exports <code>let count</code> and
increments it later, an importer that already read it sees the new number. Real
ESM gets that by being lazy at both ends: the exporter exposes a binding rather
than a copy, and the importer reads it at the point of use rather than at the
point of import.</p>
<p>This loader is lazy at one end and eager at the other, and the asymmetry is
worth stating before anything else in this section, because it is the one place
where the rewrite is knowingly not ESM.</p>
<p><strong>The export side is getters.</strong> A module's own exports are exposed as accessors
on the exports object, which is how esbuild and rollup model the same thing.</p>
<p><strong>The import side is a snapshot.</strong> A used <code>import { X } from './m'</code> compiles to
<code>const X = __oc_m['X']</code>: one read, at the top of the importing module, and that
value is what the rest of the body sees. Increment <code>X</code> in the source module
afterwards and the importer will not notice.</p>
<p>Most code never sees the difference, because most imported names are functions,
and a function is the same object before and after. It shows up on a mutable
<code>let</code>, and it shows up hard in a cycle, which is the rest of this section.</p>
<p>The load-bearing detail on the export side is not the getters. It is that they
are emitted <strong>before</strong> the import requires.</p>
<p>Consider a cycle, which npm is full of. Module A imports B, B imports A back. B
runs while A's body is still on the stack. If A emitted its export getters after
its own imports, B reads <code>undefined</code> from A, because A has not got to that line
yet. yargs is the canonical case in the wild: <code>command.js</code> imports
<code>isYargsInstance</code> from <code>yargs-factory.js</code>, which imports <code>command.js</code> right
back. Put the getters first and an exported function, which is hoisted anyway,
is reachable through its getter before A's body has run at all.</p>
<p>The failure mode when this is wrong is what makes it expensive. You do not get
"circular import detected". You get Astro's middleware reporting
<code>Function.prototype.apply was called on undefined</code>, four frames from anything
you wrote.</p>
<p>A second, subtler version of the same bug: a barrel file that imports a name and
then re-exports it. Astro's <code>render/index.js</code> does
<code>import { Fragment } from './common.js'</code> and then <code>export { Fragment }</code>. When
<code>common.js</code> is mid-cycle, the eager <code>const Fragment = common.Fragment</code> snapshot
hits a <code>const</code> that is still in its temporal dead zone, and you get
<code>Cannot access 'Fragment' before initialization</code>. So a re-export is compiled to
a lazy live binding to the source module rather than a read of the snapshot,
which defers the read until the cycle has settled, exactly as
<code>export { X } from 'm'</code> already did.</p>
<p>And the getters have to close over their own key. <code>export *</code> copies names in a
loop, and a getter that closes over the shared loop variable resolves every name
to the last key of the object. Vue's <code>index.mjs</code> re-exports everything, so
<code>createApp</code> quietly became <code>withScopeId</code>, and Nuxt's server rendering then read
<code>.config</code> off the wrong object. Per-iteration closure, one line, found the
expensive way.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-fallback-that-buys-back-the-other-half">The fallback that buys back the other half<a href="https://vivari.run/blog/synchronous-esm#the-fallback-that-buys-back-the-other-half" class="hash-link" aria-label="Direct link to The fallback that buys back the other half" title="Direct link to The fallback that buys back the other half" translate="no">​</a></h2>
<p>That leaves the general case. A cycle whose imported name is a <code>const</code>, a class
or a singleton has no barrel shape to exploit: the eager <code>const X = __oc_m['X']</code>
simply runs too early and throws <code>Cannot access 'X' before initialization</code>. Astro's
runtime is full of them, <code>apiContextRoutesSymbol</code>, <code>AstroConfigSchema</code>,
<code>globalContentLayer</code>, <code>telemetry</code>.</p>
<p>So there is a second compiler. When a module's eager attempt throws a
<code>ReferenceError</code> whose message matches "before initialization" or "is not
defined", the loader recompiles <strong>that one module</strong> with every import bound as a
getter on an <code>__oc_live</code> object, and runs the whole body inside
<code>with (__oc_live) { ... }</code>. A bare reference to an imported name then resolves
lazily through the getter, at use, which is what real ESM does, while a local
declaration that shadows the name still wins natively. That is what makes it
scope-correct without rewriting a single reference, and rewriting references
correctly is the part nobody wants to hand-roll.</p>
<p>Two reasons it is a fallback rather than the default. <code>with</code> deoptimises the
whole body and requires sloppy mode, so a normal module should not pay for it.
And it is safe to re-run only because the eager attempt threw in the prelude,
before the body ran: the retry re-defines configurable export getters and
re-runs already cached requires, so there are no double side effects.</p>
<p>Here is a module graph doing all of this, written into the virtual filesystem by
the script itself and then imported. The line to look at is the first
<code>reporter:</code> line, which prints <code>count = 0</code> from inside the cycle while
<code>counter.mjs</code> is still evaluating.</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">A module graph, rewritten to CommonJS at load time</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=esm-live-bindings" title="A module graph, rewritten to CommonJS at load time" loading="lazy" allow="cross-origin-isolated" style="height:560px"></iframe></div>
<p><strong>The last <code>reporter:</code> line is the whole section, and it is worth separating
from the line under it.</strong> That third <code>reporter:</code> line is <code>reporter.mjs</code> printing
its own bare named import, <code>count</code>, after two <code>bump()</code> calls, and it says <code>2</code>.
That looks like a live binding and is not one. It is the fallback:
<code>reporter.mjs</code> reads <code>count</code> while <code>counter.mjs</code>'s <code>let count</code> is still in its
temporal dead zone, that throws, and the recompile is what made the read lazy.</p>
<p>The line below it, <code>read from the namespace</code>, also says <code>2</code> and proves nothing
of the sort. That one is <code>counter.count</code>, a property access on the namespace
object, which goes through the export getter every time it is evaluated. It is
live with a cycle and without one, because the export side was never the
problem.</p>
<p>Take the cycle away and the two lines stop agreeing. A plain non-cyclic module
that does <code>import { count, bump }</code>, calls <code>bump()</code> twice and then logs <code>count</code>,
prints <code>0</code> here and <code>2</code> under Node, because nothing threw, so nothing was
recompiled. Read the same value off a namespace instead and you get <code>2</code> either
way. Nothing warns you which of the two you wrote. That is the sharpest edge in
this loader and it is the reason the honest list at the end of this post has the
bullet it has.</p>
<p>Every module body in the demo is a string you can edit. The other instructive
change is to delete the <code>__esModule</code> line from <code>legacy.cjs</code> and run it again,
for the reason in the next section.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="__esmodule-is-not-proof-of-a-default-export"><code>__esModule</code> is not proof of a default export<a href="https://vivari.run/blog/synchronous-esm#__esmodule-is-not-proof-of-a-default-export" class="hash-link" aria-label="Direct link to __esmodule-is-not-proof-of-a-default-export" title="Direct link to __esmodule-is-not-proof-of-a-default-export" translate="no">​</a></h2>
<p>When you <code>import x from</code> a CommonJS module, what should <code>x</code> be? Node's answer is
simple and total: <code>module.exports</code>, always. Babel's answer, which the entire
transpiled ecosystem is built on, is that a module carrying the <code>__esModule</code>
flag was originally ESM, so <code>x</code> should be its <code>.default</code>.</p>
<p>Both are defensible. The trouble is that <code>tsc --module commonjs</code> stamps
<code>__esModule</code> on every file it emits, including the ones that only ever assign
named exports. The flag means "transpiled", and the unwrap needs "has a
default".</p>
<p><code>@embroider/core</code> is one of those files: flag set, no <code>default</code> key. So
<code>import core from '@embroider/core'</code> handed <code>@embroider/vite</code> <code>undefined</code>, and
Ember's config load died at <code>const { cleanUrl } = core</code>. The fix is to require
the key to exist as well as the flag, which keeps the Babel unwrap that real
<code>export default</code> code depends on and falls back to Node's answer otherwise.</p>
<p>Now the honest part, and it is in the source as a signed confession rather than
something I am volunteering. The dynamic <code>import()</code> helper <strong>deliberately does
not</strong> use that narrower test. It still treats <code>__esModule</code> as proof of an ESM
namespace, so <code>(await import('&lt;tsc-emitted-cjs&gt;')).default</code> is <code>undefined</code> here
where Node gives you <code>module.exports</code>.</p>
<p>That is not laziness. Narrowing it the same way would break the other direction:
our own transpiled ESM sets <code>__esModule</code> too, and a module with no
<code>export default</code> would then get a synthesised <code>ns.default = m</code> that Node never
gives it. Telling those two cases apart needs a marker that <code>__esModule</code> cannot
carry. It is a known divergence, recorded rather than papered over, and it is
the one place in the loader where two correct behaviours cannot both be had.</p>
<p>There is a related constraint pulling the other way. Dynamic <code>import()</code> has to
resolve to a module <strong>namespace</strong>, not the raw <code>require()</code> value. Returning the
bare exports left a CommonJS default import with no <code>default</code> key, which almost
nothing noticed because real code mostly reads it through the static path. Vite's
server-side module runner does notice: it asserts <code>'default' in mod</code> for
externalised CommonJS dependencies, and threw
<code>Named export 'default' not found. The requested module 'cssesc' is a CommonJS module</code>
on Astro.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="top-level-await-and-a-compile-error-that-lies">Top-level await, and a compile error that lies<a href="https://vivari.run/blog/synchronous-esm#top-level-await-and-a-compile-error-that-lies" class="hash-link" aria-label="Direct link to Top-level await, and a compile error that lies" title="Direct link to Top-level await, and a compile error that lies" translate="no">​</a></h2>
<p>Top-level await is the case Node declines outright, and it is not optional here:
Vite's own binary starts with <code>await import('node:inspector')</code>.</p>
<p>The wrapper each module is compiled into is a plain, non-async function, so
<code>new Function</code> rejects the parse. The fix is to recompile the ESM body as an
<code>AsyncFunction</code>, which makes the module evaluate to a promise that gets threaded
through the entry point so the top-level body can await while the loop pumps.</p>
<p>Deciding <em>when</em> to do that is where it got interesting, because the parser will
not tell you. At the top level of a non-async function, <code>await x</code> parses <code>await</code>
as an identifier, so the error names the <strong>next</strong> token. You do not get "await is
only valid in async functions". SvelteKit's <code>core/sync/ts.js</code> does
<code>ts = (await import('ts')).default</code>, which after the import rewrite is
<code>await __oc_import('ts')</code>, and the parser's verdict is
<code>SyntaxError: Unexpected identifier '__oc_import'</code>.</p>
<p>Sniffing that message reliably is hopeless, so the loader does not try. Any
compile failure on an ESM file is retried as an <code>AsyncFunction</code>. Real top-level
await then compiles; a genuine syntax error fails again and is reported with the
filename appended. The retry is on the error path only, so the happy path pays
nothing.</p>
<p><strong>The limit, stated plainly: only the entry module can block on top-level
await.</strong> A dependency deep in the graph that uses it is still not supported, and
that is why <a class="" href="https://vivari.run/blog/databases-and-the-http-parser">PGlite ships in the templates as its CommonJS build</a>
rather than its ESM one. Choosing CJS there avoids the problem instead of
discovering it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="it-is-a-lexer-which-is-fast-and-occasionally-wrong">It is a lexer, which is fast and occasionally wrong<a href="https://vivari.run/blog/synchronous-esm#it-is-a-lexer-which-is-fast-and-occasionally-wrong" class="hash-link" aria-label="Direct link to It is a lexer, which is fast and occasionally wrong" title="Direct link to It is a lexer, which is fast and occasionally wrong" translate="no">​</a></h2>
<p><code>es-module-lexer</code> does not build an AST. It skims for the constructs it cares
about, which is why this is affordable to do on every module of every install
rather than once in a build step.</p>
<p>The cost is that skimming has to be exactly right about where strings, template
literals and comments end. A coarse template skip that ignores <code>${}</code>
interpolation desyncs on modern bundled code: a regex inside an interpolation in
<code>@vitest/pretty-format</code> was misread as a string, which swallowed the matching
brace and lost every top-level <code>export</code> after it. The module then compiled with
no exports and the importer got an empty object.</p>
<p>So the skimmer descends into interpolations properly. It is still a skimmer, and
that is the trade being made: a parse of every file would be correct and would
cost more than the rest of module loading put together.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-typescript-in-front-of-it">The TypeScript in front of it<a href="https://vivari.run/blog/synchronous-esm#the-typescript-in-front-of-it" class="hash-link" aria-label="Direct link to The TypeScript in front of it" title="Direct link to The TypeScript in front of it" translate="no">​</a></h2>
<p>Node's loader is not the only thing that has to be synchronous. A <code>.ts</code> file has
to become JavaScript before the ESM rewrite sees it, with no <code>tsc</code>, no esbuild,
and no await, which means a dependency-free token rewriter whose output has to
parse.</p>
<p>The hard problem in a type stripper is <code>&lt;</code>. Deciding whether it opens a generic
or is a less-than comparison needs the previous token: an identifier, a closing
parenthesis or a closing angle bracket means a generic at a declaration or call
site. A generic <strong>arrow</strong> function is a separate case, because it begins an
expression rather than a declaration.</p>
<p>Three of its bugs made it into a release, and their symptoms are a nice ladder.
The type skipper counted braces only at depth zero, so <code>Array&lt;{ detail: string }&gt;</code>
left <code>}&gt;;</code> behind as live code, which at least fails loudly at load. <code>as</code> and
<code>satisfies</code> were treated as cast keywords after any token, so
<code>Bun.semver.satisfies(...)</code> was eaten as a type assertion: the call vanished, the
importer got <code>undefined</code>, and the process exited zero. A stripper bug that
throws is a bug. A stripper bug that succeeds is a support ticket six months
later.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-honest-to-claim">What is honest to claim<a href="https://vivari.run/blog/synchronous-esm#what-is-honest-to-claim" class="hash-link" aria-label="Direct link to What is honest to claim" title="Direct link to What is honest to claim" translate="no">​</a></h2>
<ul>
<li class=""><strong>This is a rewrite, not an ESM implementation.</strong> There is no module map, no
linking phase, no <code>import.meta.resolve</code> against a real registry of module
records. The semantics that survive are the ones that can be modelled in
CommonJS plus getters, which turns out to be most of them, and it is not all
of them.</li>
<li class=""><strong>Named imports are eager snapshots on the default path.</strong> Import-side
liveness exists, and it is a recompile that only fires when the eager read
throws. So a module that imports a mutable <code>let</code> from a module with no cycle,
and expects to see later writes to it, reads the value it saw at import time
and is given no warning. Two smaller consequences of the fallback itself:
assigning to an imported binding inside it is a silent no-op where real ESM
throws, and an import used at top-level initialisation inside a cycle still
cannot be satisfied, because the source genuinely is not ready.</li>
<li class=""><strong>Top-level await works in the entry module only.</strong></li>
<li class=""><strong><code>(await import(x)).default</code> differs from Node for a <code>tsc</code>-emitted CommonJS
module</strong>, deliberately, for the reason above.</li>
<li class=""><strong>The scanner can be wrong on pathological source.</strong> Every case found so far
is fixed and the fix is tested, which is not the same as a proof.</li>
<li class=""><strong><code>export * from</code> a module that later mutates its own exports object</strong> is
outside what getters copied at load time can model.</li>
</ul>
<p>Every one of the bugs in this post came from running a real project rather than
a test suite, which is a statement about coverage as much as about the bugs.
Yargs, Vue, Astro, Ember, SvelteKit, Vitest and Vite each found something no
fixture had.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-general-version">The general version<a href="https://vivari.run/blog/synchronous-esm#the-general-version" class="hash-link" aria-label="Direct link to The general version" title="Direct link to The general version" translate="no">​</a></h2>
<p>The interesting thing about this subsystem is that it exists because of a
constraint one layer down. Nothing about ESM demanded a lexer and a getter
protocol; the synchronous filesystem did, and the filesystem is synchronous
because Node's <code>require()</code> is, and <code>require()</code> is synchronous because 2009.</p>
<p>Node gets to draw a line and say <code>ERR_REQUIRE_ASYNC_MODULE</code>. That line is a
luxury of having somewhere else to send people. Take it away and you find out
which parts of the module system are semantics and which parts were always just
scheduling.</p>
<p>Mostly it is scheduling. The two exceptions, real top-level await in a
dependency and the <code>__esModule</code> ambiguity, are documented above rather than
hidden, because a loader that is quietly wrong about a module's shape is the
worst possible thing to have underneath a package manager.</p>
<p>More on the runtime in <a href="https://vivari.run/docs/how-it-works" target="_blank" rel="noopener noreferrer" class="">the architecture docs</a>,
and the post that explains why any of this has to be synchronous is
<a class="" href="https://vivari.run/blog/blocking-in-a-browser">the one about the single blocking primitive</a>.</p>
<hr>
<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no
per-seat fee, self-host every asset. The code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a> and the
<a href="https://vivari.run/studio/" target="_blank" rel="noopener noreferrer" class="">Studio</a> runs in your browser.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Runtime</category>
            <category>Node.js</category>
        </item>
        <item>
            <title><![CDATA[A step debugger with no inspector to talk to, and the second SharedArrayBuffer that makes it pause]]></title>
            <link>https://vivari.run/blog/a-debugger-with-no-inspector</link>
            <guid>https://vivari.run/blog/a-debugger-with-no-inspector</guid>
            <pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[There is no V8 inspector in a Web Worker, so a breakpoint had to be built out of acorn probes and a second shared buffer. Then CPython arrived and needed none of it, which is how we found out the protocol was the right thing to have picked.]]></description>
            <content:encoded><![CDATA[<p>A breakpoint is not a feature you write. It is a favour the engine does you.
When you set one in VS Code, nothing in your program changes: V8's inspector
holds the isolate, walks the real call stack, and hands back scopes it already
had. Every step debugger you have used is a thin client in front of that.</p>
<p>A Web Worker has no inspector. There is no <code>--inspect</code> port to open, no
<code>inspector</code> binding to require, no way to ask the engine to stop. So the first
question here was not how to build a debug UI. It was where a pause could
possibly come from.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-pause-has-to-come-from-the-program-itself">The pause has to come from the program itself<a href="https://vivari.run/blog/a-debugger-with-no-inspector#the-pause-has-to-come-from-the-program-itself" class="hash-link" aria-label="Direct link to The pause has to come from the program itself" title="Direct link to The pause has to come from the program itself" translate="no">​</a></h2>
<p>If the engine will not stop your code, your code has to stop itself. That means
the source that runs is not the source you wrote.</p>
<p><code>acorn</code> parses the guest's own file and weaves in probes: <code>__vvdbg.line</code> before
each statement, <code>__vvdbg.brk</code> for a literal <code>debugger;</code>, <code>__vvdbg.push</code> and
<code>__vvdbg.pop</code> around calls so there is a shadow call stack to report. Each
lexical block also gets a <code>__vv_ev</code> eval closure, which is the unglamorous piece
that makes Variables and <code>evaluateOnCallFrame</code> show the block you are actually
standing in rather than the function's outermost scope.</p>
<p>Where this happens in the pipeline is the part that took thought. The
instrumentation runs <strong>after</strong> the TypeScript and JSX strip, so acorn is looking
at plain ES rather than syntax it does not know, and <strong>before</strong>
<a class="" href="https://vivari.run/blog/synchronous-esm">the ESM to CommonJS rewrite</a>, so line numbers still match the
file the reader has open in the editor. One layer earlier and the parse fails on
a type annotation. One layer later and every breakpoint lands on the wrong line.</p>
<p>There is a bailout, and it matters more than it looks. If acorn throws for any
reason, the module self-heals to the original source and runs uninstrumented.
You lose breakpoints in that one file. You do not lose the program.</p>
<p>The whole thing lives in a lazy <code>import()</code> chunk of roughly 195 KB, fetched only
when a debug buffer is present. A normal run never parses a byte of it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-second-sharedarraybuffer">The second SharedArrayBuffer<a href="https://vivari.run/blog/a-debugger-with-no-inspector#the-second-sharedarraybuffer" class="hash-link" aria-label="Direct link to The second SharedArrayBuffer" title="Direct link to The second SharedArrayBuffer" translate="no">​</a></h2>
<p><a class="" href="https://vivari.run/blog/blocking-in-a-browser">Everything else in the runtime</a> rides one 1 MiB
<code>SharedArrayBuffer</code> per process: write a request, <code>Atomics.wait</code>, get notified,
read the response. The debugger cannot use it, and the reason is a nice one.</p>
<p>A process parked at a breakpoint is not sitting in the syscall loop. It is
parked in the middle of a <code>__vvdbg.line</code> probe, halfway down the user's own call
stack. The syscall buffer is a channel for a client that is asking questions. A
paused process is answering them.</p>
<p>So a debug target gets a second, independent buffer, allocated only when
<code>VV_DEBUG</code> is set:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">[ control: Int32 STATE, Int32 LEN ][ data region: JSON CDP command bytes ]</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">STATE values: DBG_STATE_EMPTY = 0, DBG_STATE_CMD = 1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">LEN:          byte length of the command JSON</span><br></div></code></pre></div></div>
<p>The paused worker blocks on <code>Atomics.wait(STATE, EMPTY)</code>. The kernel writes a
CDP command into the data region, stores <code>DBG_STATE_CMD</code>, and notifies. The
worker wakes up inside the probe, with the user's stack still intact above it,
runs the command, and parks again. <code>stepOver</code>, <code>getProperties</code>,
<code>evaluateOnCallFrame</code>, all of it happens on a thread that is technically in the
middle of executing line 41.</p>
<p>That gives two transports for one protocol, and the split is by state rather
than by message type. A running process receives commands by <code>postMessage</code>,
because it is still turning its event loop and can pick them up. A paused
process receives them through the buffer, because it is not turning anything.
There is also a <code>--inspect-brk</code>-style start gate, since a twelve line script
would otherwise be finished before the frontend finished attaching.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-chrome-devtools-protocol-when-nothing-here-is-chrome">Why Chrome DevTools Protocol, when nothing here is Chrome<a href="https://vivari.run/blog/a-debugger-with-no-inspector#why-chrome-devtools-protocol-when-nothing-here-is-chrome" class="hash-link" aria-label="Direct link to Why Chrome DevTools Protocol, when nothing here is Chrome" title="Direct link to Why Chrome DevTools Protocol, when nothing here is Chrome" translate="no">​</a></h2>
<p>Speaking CDP to a runtime with no inspector looks like cargo cult. It was the
best decision in this subsystem, and we only found out later.</p>
<p>The alternative was a bespoke debug API, which would have been smaller and would
have fit the shape of what actually exists here. What CDP bought instead was a
contract that already had two consumers: the studio's debug panel drives Monaco
gutter breakpoints, the paused-line highlight, and a VS Code style Call Stack,
Variables and Watch tree, all in the vocabulary of <code>Debugger.scriptParsed</code>,
<code>Debugger.paused</code> and <code>Runtime.getProperties</code>. None of that had to be invented.</p>
<p>Then came Python.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-second-backend-and-the-file-that-did-not-change">The second backend, and the file that did not change<a href="https://vivari.run/blog/a-debugger-with-no-inspector#the-second-backend-and-the-file-that-did-not-change" class="hash-link" aria-label="Direct link to The second backend, and the file that did not change" title="Direct link to The second backend, and the file that did not change" translate="no">​</a></h2>
<p><code>.py</code> files now get the same pause, step, inspect and evaluate over the same
protocol and the same buffer. The studio is unchanged. It speaks CDP, it keeps
breakpoints per virtual filesystem path, and it never had a reason to know what
language is on the other end of the socket that is not a socket.</p>
<p>Two things differ from the Node backend, and both differ in the same direction:
CPython already has what the JavaScript side had to build.</p>
<p><strong>No instrumentation.</strong> CPython's frames are real. There is no acorn, no probe
weaving, no shadow call stack, no line number preservation problem. What is left
is the protocol and the transport, and those were reused as they stood.</p>
<p><strong>PEP 669, not <code>sys.settrace</code>.</strong> This is the interesting half, because it is the
difference between a debugger you can leave attached and one you have to
remember to turn off. A <code>settrace</code> hook is called on every line of every
function and has no way to say "stop calling me about this one". Measured on
this build, on one machine, with a 300,000 iteration loop:</p>
<table><thead><tr><th>what is attached</th><th>cost</th></tr></thead><tbody><tr><td>nothing</td><td>22ms</td></tr><tr><td><code>sys.settrace</code> with a hook that does a dict lookup and returns</td><td>217ms</td></tr><tr><td><code>sys.monitoring</code> answering <code>DISABLE</code></td><td>23ms</td></tr><tr><td><code>sys.monitoring</code> with a breakpoint on the hot line</td><td>83ms</td></tr></tbody></table>
<p>A debugger that makes the program ten times slower is not observing the program;
it is changing it. <a href="https://peps.python.org/pep-0669/" target="_blank" rel="noopener noreferrer" class="">PEP 669</a> landed in 3.12
and this interpreter is 3.14, so the callback can return
<code>sys.monitoring.DISABLE</code>, which permanently retires that bytecode location. A
line that is not a breakpoint is asked about exactly once and then costs nothing
for the rest of the run. The 83ms row is a breakpoint on the hot line itself,
which is a line you were about to stop on anyway.</p>
<p>That table is the whole argument, and it is the kind of claim that is easy to
make and boring to take on trust. The interpreter below is running in this page,
and it measures all three for itself. Watch the middle number, and then watch
the two counts underneath it:</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">Why a debugger can be left on</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=python-monitoring" title="Why a debugger can be left on" loading="lazy" allow="cross-origin-isolated" style="height:560px"></iframe></div>
<p>The timings depend on your machine and on what else it is doing, and the ratio
between them is the part that holds. The counts do not depend on anything:
<code>settrace</code> is handed 600,006 line events and <code>sys.monitoring</code> is handed 10. That
is the same loop, the same breakpoint table, and five orders of magnitude
between how often the debugger was asked.</p>
<p>The edit worth making is at the bottom of the file. Put a line number from
inside <code>hot()</code> into <code>BREAKPOINTS</code> and run it again: the third number climbs,
because that one location stops answering <code>DISABLE</code> and CPython goes back to
asking about it on every iteration. It does not climb all the way to the
<code>settrace</code> figure, and the gap between the two is exactly what <code>DISABLE</code> is
buying on every other line in the function.</p>
<p>There is a price for <code>DISABLE</code>, and stepping is where you pay it. Once a
location has been retired it never fires again, so single stepping has to call
<code>restart_events()</code> to un-retire everything it disabled. Code outside the user's
project roots is dropped on its first line, which is what keeps a breakpoint
anywhere in your file from tracing all of <code>import pandas</code>.</p>
<p>The hot path stays in Python: a set lookup per candidate line. JavaScript only
gets involved once a pause has been decided, and then it runs the entire CDP
conversation by calling back into the interpreter for frames, scopes, reprs and
<code>eval</code>. JavaScript inside a Python call, calling Python again, is the same
re-entrant shape as the blocking stdin syscall, which is either reassuring or
alarming depending on your temperament.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="which-backend-attaches-is-decided-not-guessed">Which backend attaches is decided, not guessed<a href="https://vivari.run/blog/a-debugger-with-no-inspector#which-backend-attaches-is-decided-not-guessed" class="hash-link" aria-label="Direct link to Which backend attaches is decided, not guessed" title="Direct link to Which backend attaches is decided, not guessed" translate="no">​</a></h2>
<p><code>python</code> and <code>python3</code> used to be on the debug skip list. They now carry a
<code>debugLang: "python"</code> label that travels with the buffer from the kernel worker
through the process worker into the runtime, and exactly one of the two backends
attaches.</p>
<p>Without the label the JavaScript backend does what it is supposed to do, which
is the wrong thing: <code>python</code> is itself a Node program, so it would instrument
our own four thousand line launcher shim and offer you breakpoints in it. That is a
debugger correctly debugging a program nobody asked about.</p>
<p>A related bug took a while to find and is worth writing down because the symptom
was silence. Running <code>python main.py</code> compiled the script under the name
<code>main.py</code>, while breakpoints are keyed on absolute virtual filesystem paths. So
nothing ever matched, no breakpoint ever bound, and the program simply ran to
completion. A debugger that does not stop looks exactly like a debugger that is
not attached.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ctrl-c-into-an-interpreter-that-is-not-in-javascript">Ctrl-C into an interpreter that is not in JavaScript<a href="https://vivari.run/blog/a-debugger-with-no-inspector#ctrl-c-into-an-interpreter-that-is-not-in-javascript" class="hash-link" aria-label="Direct link to Ctrl-C into an interpreter that is not in JavaScript" title="Direct link to Ctrl-C into an interpreter that is not in JavaScript" translate="no">​</a></h2>
<p>Signals are the other half of interrupting a program, and CPython broke the
model. The pending signal bitmask is only ever observed by JavaScript, either at
a syscall park or on an event loop turn. A guest running CPython in Wasm is
doing neither: the worker thread is inside the interpreter's eval loop and will
not return to JavaScript until the Python code finishes. The kernel's only
option was the one it takes for any guest with no handler, which is to kill it.</p>
<p>CPython's Emscripten build already solves its half by polling a byte of shared
memory and raising <code>KeyboardInterrupt</code> at the next bytecode boundary. So SIGINT,
and only SIGINT, is mirrored into the first byte of <code>control[5]</code>, previously
reserved padding in the syscall control block. Wasm is little-endian by
specification, so that is byte 20 everywhere this runs. The interpreter clears
the byte itself when it acts. Measured latency is about 5ms.</p>
<p>The handler is registered only while the interpreter is running user code,
because registering it is what tells the kernel not to kill this process, and
that is a promise you can only keep while there is an interpreter running to
take the interrupt.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-honest-to-claim">What is honest to claim<a href="https://vivari.run/blog/a-debugger-with-no-inspector#what-is-honest-to-claim" class="hash-link" aria-label="Direct link to What is honest to claim" title="Direct link to What is honest to claim" translate="no">​</a></h2>
<p>The Node debugger is verified by a spike with 27 assertions covering
instrumentation, breakpoint binding including conditional breakpoints,
pause and step, scope and <code>evaluateOnCallFrame</code> including the temporal dead
zone, a top-level <code>debugger;</code>, the real buffer channel, and an end-to-end
<code>worker_threads</code> pause, evaluate and resume over that buffer. That is what it is
tested to do.</p>
<p>Four limits, by name:</p>
<ul>
<li class=""><strong>Preview browser JavaScript cannot be debugged this way, and may never be.</strong>
A page in the preview iframe runs on a main thread, where <code>Atomics.wait</code> is
illegal. Pausing it needs a resumable transform, continuation passing or
generators, over the guest's source. Nobody has written that.</li>
<li class=""><strong>A REPL parked at its prompt still cannot be interrupted.</strong> Ctrl-C works
while the interpreter is running your code. Idle, parked in the blocking stdin
read, it keeps its old meaning, because interrupting a park needs the read
itself to return <code>EINTR</code> and it does not do that yet.</li>
<li class=""><strong>Instrumented code is not your code.</strong> Line numbers survive and the bailout
keeps a parse failure from being fatal, but anything that reads its own source
text, or measures its own throughput, is measuring the woven version.</li>
<li class=""><strong>The timings are one machine, one build.</strong> They are here to show that the gap
between 217ms and 23ms is a factor of roughly ten, not to be a benchmark
anyone should quote.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-general-version">The general version<a href="https://vivari.run/blog/a-debugger-with-no-inspector#the-general-version" class="hash-link" aria-label="Direct link to The general version" title="Direct link to The general version" translate="no">​</a></h2>
<p>The Node half of this is a workaround. Instrumenting source to fake a capability
the platform withholds is not elegant, and if a browser worker ever grows an
inspector, most of <code>instrument.js</code> should be deleted that afternoon.</p>
<p>The part worth keeping is the protocol choice. Picking Chrome DevTools Protocol
when there was no Chrome in the picture cost a little up front and paid for
itself the day a second language arrived, because the expensive half of a
debugger is the frontend, and the frontend never learned that Python existed.
Two backends, one of which needs a parser and a shadow stack and one of which
needs neither, meet at the same twenty CDP methods and the same three events.</p>
<p>Picking somebody else's interface when you have no obligation to is usually
overengineering. It is occasionally the cheapest thing you will ever do.</p>
<p>More on the runtime in <a href="https://vivari.run/docs/how-it-works" target="_blank" rel="noopener noreferrer" class="">the architecture docs</a>,
and the previous post covers
<a class="" href="https://vivari.run/blog/cpython-startup-in-a-tab">why every <code>import pandas</code> in a tab used to be the first one</a>.</p>
<hr>
<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no
per-seat fee, self-host every asset. The code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a> and the
<a href="https://vivari.run/studio/" target="_blank" rel="noopener noreferrer" class="">Studio</a> runs in your browser.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Runtime</category>
            <category>Node.js</category>
            <category>Python</category>
        </item>
        <item>
            <title><![CDATA[There was never a second import pandas, and PEP 552 is why there is now]]></title>
            <link>https://vivari.run/blog/cpython-startup-in-a-tab</link>
            <guid>https://vivari.run/blog/cpython-startup-in-a-tab</guid>
            <pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Every python command in the browser boots its own CPython, so __pycache__ never got a second run to be fast on. Fixing it took a memory snapshot and a twenty year old detail of how .pyc files decide they are stale.]]></description>
            <content:encoded><![CDATA[<p>On your laptop, the first <code>import pandas</code> of the day is slow and every one after
it is fast. You have probably never thought about why. CPython compiles the
package's <code>.py</code> files to bytecode, writes that bytecode into <code>__pycache__</code>, and
never does it again.</p>
<p>Run Python inside a browser tab, where every command is its own process with its
own interpreter and a freshly unpacked copy of every package, and something
uncomfortable follows. There is no second time. Every <code>import pandas</code> is the
first one.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whose-work-this-is">Whose work this is<a href="https://vivari.run/blog/cpython-startup-in-a-tab#whose-work-this-is" class="hash-link" aria-label="Direct link to Whose work this is" title="Direct link to Whose work this is" translate="no">​</a></h2>
<p><a href="https://pyodide.org/" target="_blank" rel="noopener noreferrer" class="">Pyodide</a> compiled CPython to WebAssembly, including the C
extension modules that make NumPy and pandas possible at all. That is theirs,
and this post does not improve on it. Vivari runs Pyodide's build of CPython
3.14 unmodified.</p>
<p>What Vivari adds is the operating system around it: a filesystem that outlives a
process, a process table, and the caches described below. The first half of this
post uses a Pyodide API and says so. The second half is a CPython behaviour that
Pyodide deliberately turns off, and turning it back on correctly turned out to
be the interesting part.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-problem-measured">The problem, measured<a href="https://vivari.run/blog/cpython-startup-in-a-tab#the-problem-measured" class="hash-link" aria-label="Direct link to The problem, measured" title="Direct link to The problem, measured" translate="no">​</a></h2>
<p>Every <code>python</code> command in Vivari is a real process with its own worker, and
therefore its own interpreter. That is what makes a crashing script harmless,
and it has an obvious cost: <code>python a.py &amp;&amp; python b.py</code> boots CPython twice.</p>
<p>On the vendored build, booting CPython costs <strong>1843ms</strong>. That is not a slow
import or a slow download. It is the interpreter initialising itself, producing
the same bytes it produced the last time and will produce the next time.</p>
<p>Paying nearly two seconds before a one line script can print anything is the
single worst thing about Python in this environment, and it is worth noticing
that it is a pure waste rather than a tradeoff. Nothing about the result varies.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="saving-the-interpreter-instead-of-rebuilding-it">Saving the interpreter instead of rebuilding it<a href="https://vivari.run/blog/cpython-startup-in-a-tab#saving-the-interpreter-instead-of-rebuilding-it" class="hash-link" aria-label="Direct link to Saving the interpreter instead of rebuilding it" title="Direct link to Saving the interpreter instead of rebuilding it" translate="no">​</a></h2>
<p>Pyodide can serialise a just-booted interpreter's linear memory and start
another interpreter from it. The API is <code>_makeSnapshot</code> on load and
<code>makeMemorySnapshot()</code> afterwards, it is marked experimental, and it does the
hard part. Vivari's contribution is narrower: making one process's snapshot
usable by the next process.</p>
<p>Measured on the same build:</p>
<table><thead><tr><th>step</th><th>cost</th></tr></thead><tbody><tr><td>boot CPython normally</td><td>1843ms</td></tr><tr><td>restore from a snapshot</td><td>205ms</td></tr><tr><td>write the 31 MB snapshot</td><td>71ms</td></tr><tr><td>read it back through the VFS</td><td>47ms</td></tr></tbody></table>
<p>So the first command of a session boots the slow way and keeps the bytes, and
every command after it spends about 250ms getting an interpreter instead of
1843ms. The REPL, <code>pip</code>, <code>pytest</code>, all of them.</p>
<p><strong>Why the filesystem and not memory.</strong> The snapshot has to outlive the process
that made it and be visible to a process that does not exist yet. Those
processes share exactly one thing, which is the virtual filesystem. It goes in
<code>/var/cache</code>, where the kernel already keeps transient caches and which the
filesystem worker excludes from OPFS persistence.</p>
<p>That exclusion is deliberate rather than incidental. A snapshot is only valid
for the interpreter build that made it, and it is 31 MB. Persisting it across
page reloads would spend a real and permanent storage cost to save 1.6 seconds
on one command per session. So a reload starts cold, on purpose.</p>
<p><strong>Why it is safe to share between processes.</strong> Restoring a snapshot in a
different JavaScript realm from the one that made it is the load-bearing
assumption here, and it is tested rather than hoped: the test tier makes a
snapshot in one worker thread and restores it in two others, then imports
packages, writes files and raises a traceback in each. Two Web Workers are two
realms in the same way.</p>
<p><strong>Two guards, because a corrupt interpreter is a terrible failure mode.</strong> The
snapshot bytes are written first and a small JSON sidecar second, which makes
the sidecar a commit record: a half-written cache is one whose sidecar does not
agree with it, and it is ignored rather than restored. Then a restored
interpreter is asked to prove it is one:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token builtin" style="color:rgb(189, 147, 249)">__import__</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'json'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">dumps</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token builtin" style="color:rgb(189, 147, 249)">__import__</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'sys'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">version_info</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token number">1</span><span class="token plain"> </span><span class="token operator">+</span><span class="token plain"> </span><span class="token number">1</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><br></div></code></pre></div></div>
<p>That costs about a millisecond, and it is deliberately not <code>2 + 2</code>. Arithmetic
would survive a restore that had destroyed the import system. This touches the
frozen stdlib and string formatting, which is the machinery a bad restore takes
out. It cannot prove that subtle corruption is absent. What it buys is that an
obviously broken snapshot costs one cold boot rather than a baffling failure
inside somebody's own program.</p>
<p>If anything at all goes wrong, the command boots the slow way and says nothing,
because a cache that has to be explained is a cache with a bug.
<code>VV_PYTHON_SNAPSHOT=0</code> turns it off.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-bigger-number-underneath">The bigger number underneath<a href="https://vivari.run/blog/cpython-startup-in-a-tab#the-bigger-number-underneath" class="hash-link" aria-label="Direct link to The bigger number underneath" title="Direct link to The bigger number underneath" translate="no">​</a></h2>
<p>Removing interpreter start-up exposes the thing it was hiding, which is larger.
On the same build:</p>
<ul>
<li class=""><code>import pandas</code>: <strong>2.3s</strong></li>
<li class=""><code>import matplotlib.pyplot</code>: <strong>1.9s</strong></li>
<li class=""><code>import numpy</code>: <strong>0.5s</strong></li>
</ul>
<p>Almost none of that is the package doing anything. It is CPython compiling
around a thousand <code>.py</code> files to bytecode, having compiled the same files to
byte-identical bytecode a moment earlier in a different process.</p>
<p>CPython solved this decades ago. The reason its solution does not apply here is
one line: Pyodide sets <code>sys.dont_write_bytecode</code>. Which is a perfectly
reasonable default when every interpreter is thrown away, and exactly wrong once
one of them can leave something behind.</p>
<p>Unsetting it is free. Measured on numpy, an import with bytecode writing enabled
costs 423ms against 420ms with it disabled. So there is no compile step anywhere
in what follows. There is only keeping what an import already produced.</p>
<p>Both of those are checkable rather than something to take my word for, so check
them. The interpreter below is running in this page, and it prints its own
version and the two settings this section is about:</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">Real CPython, and the two settings behind the cache</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=python-interpreter" title="Real CPython, and the two settings behind the cache" loading="lazy" allow="cross-origin-isolated" style="height:520px"></iframe></div>
<p>It is the standard library only, which is the stable part of Python here. Run it
once and you pay for fetching the interpreter and a cold boot. Run it a second
time and you are watching the snapshot from the first half of this post do its
job, because that is a new process with a new interpreter in it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-it-goes-wrong-pyc-files-have-opinions-about-time">Where it goes wrong: <code>.pyc</code> files have opinions about time<a href="https://vivari.run/blog/cpython-startup-in-a-tab#where-it-goes-wrong-pyc-files-have-opinions-about-time" class="hash-link" aria-label="Direct link to where-it-goes-wrong-pyc-files-have-opinions-about-time" title="Direct link to where-it-goes-wrong-pyc-files-have-opinions-about-time" translate="no">​</a></h2>
<p>Turning the flag back on and copying the <code>__pycache__</code> tree between processes
does not work, and the reason is the good part of this post.</p>
<p>A <code>.pyc</code> file records the mtime and size of the source it was compiled from. On
import, CPython compares that recorded mtime against the source file's current
mtime, and if they differ it throws the cached bytecode away and recompiles.
This is correct, it is why editing a file takes effect, and it is fatal here.</p>
<p>Pyodide's <code>loadPackage</code> unpacks the wheel afresh into each new interpreter. So
the source files' mtimes are the time of the unpack, which is different on every
single run. Every cached <code>.pyc</code> would be stale the moment it arrived, every
time, and the cache would do nothing except cost disk.</p>
<p><a href="https://peps.python.org/pep-0552/" target="_blank" rel="noopener noreferrer" class="">PEP 552</a> has the answer, and has had it
since Python 3.7. A <code>.pyc</code> can be <strong>hash-based</strong> instead of timestamp-based: it
records a hash of the source rather than its mtime. That is precisely what a
package installer writes, for precisely this reason, because an installer also
cannot promise anything about the mtimes of the files it just wrote.</p>
<p>Converting a timestamp-based <code>.pyc</code> to a hash-based one is header surgery, not
compilation. The marshalled code object, which is the whole expensive part, is
byte-identical. Only the sixteen byte header changes. Harvesting the entire
bytecode tree for numpy and pandas costs <strong>115ms</strong>.</p>
<p>PEP 552 also defines two flavours of hash-based <code>.pyc</code>, checked and unchecked,
and the choice here matters. A checked one re-reads and re-hashes the source on
every import, which is most of the I/O this cache exists to avoid. Unchecked
files are taken on trust.</p>
<p>The claim being made by choosing unchecked is that a wheel's files do not change
while its version stays the same. That is not a shortcut invented here. It is
the same claim pip makes, and the cache is keyed on package name and version so
that the claim stays true.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="three-details-that-were-not-obvious">Three details that were not obvious<a href="https://vivari.run/blog/cpython-startup-in-a-tab#three-details-that-were-not-obvious" class="hash-link" aria-label="Direct link to Three details that were not obvious" title="Direct link to Three details that were not obvious" translate="no">​</a></h2>
<p><strong>The bytecode does not land next to the source.</strong> <code>sys.pycache_prefix</code> puts it
in a tree of its own. Otherwise <code>__pycache__</code> directories appear inside the
user's project, show up in the file explorer, and get mirrored back into the
virtual filesystem as though the script had written them.</p>
<p>That setting has a trap in it worth writing down, because it fails silently.
CPython builds the directory tree under the prefix by walking <strong>up</strong> from the
<code>.pyc</code>'s intended directory until it finds something that already exists. If
that walk runs off the top without finding one, it starts creating directories
relative to the current working directory instead, says nothing, and no bytecode
is ever written where you are looking for it. The prefix's root has to exist
before the first import.</p>
<p><strong>Only installed packages are cached, never your own modules.</strong> The user's own
code gets bytecode too, since it is the same interpreter setting, but theirs
stays in the per-process prefix and dies with the process, keeping CPython's
ordinary mtime checking. A released package's files do not change. Yours change
constantly, and a file you just edited must never be at risk of running as a
stale copy.</p>
<p><strong>The cache is keyed on the interpreter's magic number</strong> as well as on package
name and version, because bytecode from a different CPython is not bytecode.</p>
<p>Like the snapshot, this lives in the session's filesystem and goes when you
reload, and <code>VV_PYTHON_BYTECODE=0</code> turns it off.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-honest-to-claim">What is honest to claim<a href="https://vivari.run/blog/cpython-startup-in-a-tab#what-is-honest-to-claim" class="hash-link" aria-label="Direct link to What is honest to claim" title="Direct link to What is honest to claim" translate="no">​</a></h2>
<p>CPython 3.14, <code>pip</code> and the REPL are shipped as <strong>stable</strong> in Vivari. The
scientific stack that this post uses for its measurements, NumPy, pandas,
Matplotlib, SciPy and scikit-learn, is <strong>experimental</strong>, along with <code>pytest</code> and
the notebook. The caching described here applies to every Python command either
way, and it is not what decides those labels.</p>
<p>Two limits worth stating plainly:</p>
<ul>
<li class=""><strong>Both caches are per session.</strong> A page reload starts cold, by design, for the
reason given above. Neither one is a persistent build cache and neither is
trying to be.</li>
<li class=""><strong>The interpreter snapshot rests on an experimental Pyodide API.</strong> If it
cannot be made or restored, everything still works and simply costs 1843ms.
That fallback is not decoration; it is the reason it was acceptable to build
on an experimental API at all.</li>
</ul>
<p>The numbers in this post are from the vendored build on one machine. They are
here to show the shape of the problem, which is a factor of about seven on
interpreter start-up once the read is counted, not to be a benchmark you should
quote.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-general-version">The general version<a href="https://vivari.run/blog/cpython-startup-in-a-tab#the-general-version" class="hash-link" aria-label="Direct link to The general version" title="Direct link to The general version" translate="no">​</a></h2>
<p>Both halves of this came from the same question, asked twice: what is this
program recomputing that it already computed?</p>
<p>The interpreter produces identical bytes on every boot. Compiling pandas
produces identical bytecode on every run. Neither is a hard problem in
principle, and in both cases the actual work was not making the cache fast but
making it <strong>correct</strong>: a commit record so a half-written snapshot is never
restored, a probe so a broken one is caught, hash-based <code>.pyc</code> files so a
cached compile is never wrongly trusted, and a hard line at the boundary of the
user's own code so an edit always wins.</p>
<p>That last one is the rule the whole thing hangs on. A cache that is occasionally
wrong about your own source is worse than no cache, by a margin that no amount
of saved seconds closes.</p>
<p>More on all of this in <a href="https://vivari.run/docs/python" target="_blank" rel="noopener noreferrer" class="">the Python docs</a>, and
the previous post covers <a class="" href="https://vivari.run/blog/python-web-servers-without-sockets">how Flask, Django and FastAPI serve real requests with
no socket underneath</a>.</p>
<hr>
<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no
per-seat fee, self-host every asset. The code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a> and the
<a href="https://vivari.run/studio/" target="_blank" rel="noopener noreferrer" class="">Studio</a> runs in your browser.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Python</category>
            <category>WebAssembly</category>
        </item>
        <item>
            <title><![CDATA[Flask, Django and FastAPI answering real requests, with no socket underneath]]></title>
            <link>https://vivari.run/blog/python-web-servers-without-sockets</link>
            <guid>https://vivari.run/blog/python-web-servers-without-sockets</guid>
            <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Pyodide gave us CPython in the browser. It did not give us a socket, and a Python web server is mostly socket. Here is the bridge that runs WSGI and ASGI apps anyway, and the Starlette bug that took the longest to find.]]></description>
            <content:encoded><![CDATA[<p>Every Python web framework bottoms out in the same two lines, whatever it calls
them:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">sock</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">bind</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">host</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> port</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">sock</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">listen</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">backlog</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><br></div></code></pre></div></div>
<p>A browser tab does not have that. There is no TCP stack in a page, no file
descriptor to bind, and no amount of WebAssembly changes it. So the interesting
question is not whether you can run Flask's Python in a browser, because you
can. It is what happens when someone types <code>flask run</code>.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-pyodides-and-what-is-not">What is Pyodide's, and what is not<a href="https://vivari.run/blog/python-web-servers-without-sockets#what-is-pyodides-and-what-is-not" class="hash-link" aria-label="Direct link to What is Pyodide's, and what is not" title="Direct link to What is Pyodide's, and what is not" translate="no">​</a></h2>
<p>Worth being exact about this up front, because the rest of the post only makes
sense once the line is drawn.</p>
<p><a href="https://pyodide.org/" target="_blank" rel="noopener noreferrer" class="">Pyodide</a> compiled CPython to WebAssembly. That is their
work, it is a large piece of engineering, and nothing here reimplements any of
it. The interpreter running in Vivari is Pyodide's build of CPython 3.14, with
its standard library and its C extension modules, unmodified. When this post
says "the interpreter", it means theirs.</p>
<p>What Pyodide does not ship, because it cannot, is an operating system: a
filesystem shared with other programs, a process table, a port registry, a
socket. Vivari is the layer that supplies those. This post is about exactly one
of them, and it is the one people ask about first.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-socket-that-accepts-everything-and-carries-nothing">A socket that accepts everything and carries nothing<a href="https://vivari.run/blog/python-web-servers-without-sockets#a-socket-that-accepts-everything-and-carries-nothing" class="hash-link" aria-label="Direct link to A socket that accepts everything and carries nothing" title="Direct link to A socket that accepts everything and carries nothing" translate="no">​</a></h2>
<p>The obvious expectation is that <code>import socket</code> fails and the frameworks fail
loudly with it. That is not what happens, and the real behaviour is the reason
this needed designing rather than documenting.</p>
<p>There is a <code>socket</code> module, inherited from the POSIX layer underneath the
WebAssembly build. <code>connect()</code> succeeds. <code>bind()</code> succeeds. <code>listen()</code> succeeds.
Then no bytes ever move, and <code>select()</code> never reports the socket readable. That
is not a defect anybody introduced; it is what a POSIX shaped API looks like
when there is no network stack beneath it to say no.</p>
<p>Point Django's development server at that and it does the worst possible thing.
It prints its banner:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Starting development server at http://127.0.0.1:8000/</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Quit the server with CONTROL-C.</span><br></div></code></pre></div></div>
<p>and then answers nothing, for as long as you leave it running. Nothing raised,
nothing logged, no clue in the output that the server you are looking at is
incapable of serving. A missing feature that announces itself is a small
problem. A missing feature that looks like a working one costs somebody an
afternoon.</p>
<p>So the design rule for everything below is that a socket must never be the thing
we quietly rely on.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-launcher-is-not-a-python-program">The launcher is not a Python program<a href="https://vivari.run/blog/python-web-servers-without-sockets#the-launcher-is-not-a-python-program" class="hash-link" aria-label="Direct link to The launcher is not a Python program" title="Direct link to The launcher is not a Python program" translate="no">​</a></h2>
<p>Here is the piece that makes the rest possible, and it is an accident of how
Vivari is put together rather than anything clever about Python.</p>
<p><code>python</code> in Vivari is not a Python program. It is a Node program, running on
Vivari's Node-compatible runtime, and it boots Pyodide inside itself. That
runtime has a real <code>require("http")</code>, a real event loop, and a working
<code>server.listen(port)</code>, because Vivari's kernel implements virtual ports: an
Express app in this environment gets a preview tab, and it does so without a TCP
stack either.</p>
<p>Which means the interpreter that cannot bind a port is running inside a process
that already can.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">browser  -&gt;  service worker  -&gt;  kernel virtual port</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                       |</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                       v</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                          guest Node http.createServer()      &lt;- the "socket"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                       |</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                 JSON, base64 body</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                       v</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                    Pyodide</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                       |</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                          WSGI environ / ASGI scope</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                                       v</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                              your Flask or FastAPI app</span><br></div></code></pre></div></div>
<p>Nothing in the Python half of that diagram believes it is talking to a network.
It is handed a request the way a WSGI server hands one over, because that is
precisely what the layer above it has become.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="writing-the-environ-by-hand">Writing the environ by hand<a href="https://vivari.run/blog/python-web-servers-without-sockets#writing-the-environ-by-hand" class="hash-link" aria-label="Direct link to Writing the environ by hand" title="Direct link to Writing the environ by hand" translate="no">​</a></h2>
<p>WSGI is a good specification to be stuck with here, because it was never about
sockets in the first place. PEP 3333 says a server calls
<code>app(environ, start_response)</code> and reads the iterable that comes back. It says
nothing about where the bytes came from.</p>
<p>So the Node side parses the request it received, and the Python side builds the
dict:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">environ </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"REQUEST_METHOD"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"method"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"SCRIPT_NAME"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"root_path"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">""</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"PATH_INFO"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"path"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"QUERY_STRING"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"query"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">""</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"SERVER_PROTOCOL"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"HTTP/"</span><span class="token plain"> </span><span class="token operator">+</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"http_version"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"1.1"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"wsgi.input"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> io</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">BytesIO</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">body</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"wsgi.errors"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> sys</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">stderr</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"wsgi.multithread"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token boolean">False</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"wsgi.multiprocess"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token boolean">False</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">"wsgi.run_once"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token boolean">False</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p><code>wsgi.multithread</code> and <code>wsgi.multiprocess</code> are both <code>False</code> and, unusually, both
are honestly <code>False</code>. There is one interpreter and there are no OS threads, so
an app that checks those flags before deciding whether a cache can be a plain
dict gets a true answer rather than a conservative one.</p>
<p>Request bodies and response bodies cross the JavaScript and Python boundary as
base64 inside a JSON string. That is not elegant and it was chosen anyway: JSON
strings convert to Python <code>str</code> with no ambiguity, whereas handing typed arrays
across the boundary means reasoning about proxy object lifetimes at every call
site. The bridge is on the request path, so the failure mode that matters is a
subtle one, not a slow one.</p>
<p>That is a claim you should not take on trust, so here it is running. The code
below is real CPython executing in this page, and you can edit it and run it
again. It builds the environ above, hands it to a WSGI application, and passes
the whole thing through <code>wsgiref.validate</code>, which is CPython's own PEP 3333
conformance checker. If the standard library's validator returns without
raising, what the bridge gives your app is a real WSGI call and not an
impression of one.</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">A real WSGI call, with no socket</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=python-wsgi" title="A real WSGI call, with no socket" loading="lazy" allow="cross-origin-isolated" style="height:520px"></iframe></div>
<p>Two honest notes about that demo. It uses only the standard library, because
the CPython core is stable here while Flask, FastAPI and Django are shipped as
experimental templates, and a live demo is the wrong place to blur that
distinction. And it exercises the conversion rather than the tunnel: the
environ, the application call and the validation are the real thing, while the
part that carries bytes in from the browser is the guest Node server described
above. The first run also has to fetch the interpreter, so it is slower than
the ones after it, which is the subject of <a class="" href="https://vivari.run/blog/cpython-startup-in-a-tab">the next post</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-asgi-scope-and-the-bug-that-took-longest">The ASGI scope, and the bug that took longest<a href="https://vivari.run/blog/python-web-servers-without-sockets#the-asgi-scope-and-the-bug-that-took-longest" class="hash-link" aria-label="Direct link to The ASGI scope, and the bug that took longest" title="Direct link to The ASGI scope, and the bug that took longest" translate="no">​</a></h2>
<p>ASGI needs more of the same, plus one thing that is easy to get wrong and
produces a symptom that points nowhere near the cause.</p>
<p>Previews in Vivari are served under a path prefix, <code>/preview/&lt;port&gt;/</code>. The
preview tunnel strips that prefix before the request reaches your process and
sets <code>x-forwarded-prefix</code> so the app can learn what it was mounted under. The
Node side reads that header and passes it along as <code>root_path</code>.</p>
<p>The obvious thing to do next is to set <code>scope["root_path"]</code> to the prefix and
<code>scope["path"]</code> to the path the tunnel handed over. That is wrong, and it is
wrong in a way that only shows up on <code>Mount()</code>.</p>
<p>ASGI defines <code>path</code> as the <strong>full</strong> request path, including <code>root_path</code>.
<code>root_path</code> names the prefix, it does not remove it. Starlette's
<code>get_route_path()</code> subtracts <code>root_path</code> from <code>path</code> to get the routable
remainder, so if you hand it a path that has already been stripped, it subtracts
a prefix that is not there. Top-level routes still match, because the subtraction
falls through harmlessly. Every <code>Mount()</code> misses, including the <code>StaticFiles</code>
mount that a FastAPI app usually has, so the app comes up, the JSON endpoints
work, and the CSS 404s.</p>
<p>The fix is to put the prefix back before building the scope:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">_vv_root </span><span class="token operator">=</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"root_path"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">""</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">_vv_path </span><span class="token operator">=</span><span class="token plain"> _vv_root </span><span class="token operator">+</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"path"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> _vv_root </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">else</span><span class="token plain"> d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"path"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><br></div></code></pre></div></div>
<p>WSGI needs no equivalent, and the reason is a small piece of design history
worth appreciating. <code>SCRIPT_NAME</code> and <code>PATH_INFO</code> are already the split form:
the prefix and the remainder are separate keys, so there is nothing to subtract
and nothing to get wrong. ASGI collapsed them into one string plus a length
convention, and this is the bug that convention buys you.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="no-threads-which-fastapi-has-an-opinion-about">No threads, which FastAPI has an opinion about<a href="https://vivari.run/blog/python-web-servers-without-sockets#no-threads-which-fastapi-has-an-opinion-about" class="hash-link" aria-label="Direct link to No threads, which FastAPI has an opinion about" title="Direct link to No threads, which FastAPI has an opinion about" translate="no">​</a></h2>
<p>Starlette and FastAPI let you write <code>def</code> endpoints as well as <code>async def</code> ones.
A sync endpoint cannot be awaited, so Starlette runs it on a threadpool through
<code>anyio.to_thread.run_sync</code>, which ends at <code>threading.Thread</code>, which under Pyodide
raises <code>RuntimeError: can't start new thread</code>.</p>
<p>That would make every synchronous route in a FastAPI app a 500, which is most
routes in most tutorials.</p>
<p>There is one interpreter and nothing else can be running in it, so the
threadpool is not buying isolation here, only a thread that does not exist.
Running the callable inline is the correct answer for this execution model:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">import</span><span class="token plain"> anyio</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">to_thread </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">as</span><span class="token plain"> _vv_att</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">async</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">def</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">_vv_run_sync</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">func</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token operator">*</span><span class="token plain">args</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token operator">**</span><span class="token plain">kwargs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> func</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token operator">*</span><span class="token plain">args</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">_vv_att</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">run_sync </span><span class="token operator">=</span><span class="token plain"> _vv_run_sync</span><br></div></code></pre></div></div>
<p>Starlette reads <code>run_sync</code> at call time rather than binding it at import, so
this takes effect for every sync route and every sync dependency, including ones
defined after the patch.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-entrypoints-do-not-import-what-they-are-named-after">The entrypoints do not import what they are named after<a href="https://vivari.run/blog/python-web-servers-without-sockets#the-entrypoints-do-not-import-what-they-are-named-after" class="hash-link" aria-label="Direct link to The entrypoints do not import what they are named after" title="Direct link to The entrypoints do not import what they are named after" translate="no">​</a></h2>
<p><code>uvicorn</code>, <code>flask</code> and <code>gunicorn</code> exist as commands. None of them imports the
package it is named after.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">uvicorn main:app </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--port</span><span class="token plain"> </span><span class="token number">8000</span><span class="token plain">                   </span><span class="token comment" style="color:rgb(98, 114, 164)"># FastAPI and other ASGI apps</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">flask </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--app</span><span class="token plain"> main run </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--port</span><span class="token plain"> </span><span class="token number">8000</span><span class="token plain">               </span><span class="token comment" style="color:rgb(98, 114, 164)"># Flask</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">gunicorn wsgi:application </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--bind</span><span class="token plain"> </span><span class="token number">0.0</span><span class="token plain">.0.0:8000  </span><span class="token comment" style="color:rgb(98, 114, 164)"># Django and any other WSGI app</span><br></div></code></pre></div></div>
<p>Each one parses argv the way the real tool does, works out which module and
attribute you meant, and hands that to the bridge. Calling them shims
undersells what they have to get right: the observable contract of <code>gunicorn</code> is
"your WSGI app is now served on this port", and that contract is met. What
cannot be met is gunicorn's process model, so those flags say so out loud rather
than being accepted and ignored. <code>-w 4</code> warns that there is exactly one worker.
<code>--worker-class gevent</code> stops, because serving you a different concurrency model
than the one you asked for is not a warning-level event.</p>
<p>Choosing gunicorn as the WSGI entrypoint rather than writing a <code>django</code> command
is the reason Django works at all here. gunicorn is the seam every WSGI
framework already reaches for, so Bottle and Pyramid arrive for free.</p>
<p>The argv parsing has one decision in it that is more interesting than argv
parsing has any right to be. To know whether <code>--log-level debug main:app</code> has
two tokens or three, you need to know which flags take a value. gunicorn's own
<code>--help</code> declares about a dozen store-true flags and several dozen that take a
value, and the shim hardcodes the boolean list rather than the value list.</p>
<p>That is smaller, but the real reason is which way it fails. Mistake a boolean
for a value-taker and it eats the next token, which is the app spec, and the
command exits with <code>no app specified</code>. The user sees that immediately. Mistake a
value-taker for a boolean and its value is left lying in argv to be picked up as
the app spec, and the server cheerfully starts serving something nobody asked
for. Both are bugs. Only one of them is quiet.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="djangos-runserver-is-refused-on-purpose">Django's runserver is refused, on purpose<a href="https://vivari.run/blog/python-web-servers-without-sockets#djangos-runserver-is-refused-on-purpose" class="hash-link" aria-label="Direct link to Django's runserver is refused, on purpose" title="Direct link to Django's runserver is refused, on purpose" translate="no">​</a></h2>
<p><code>python manage.py runserver</code> does not run. It stops and says why, and points at
the command that works.</p>
<p>This is the only refusal in the Python support that blocks something people
demonstrably want, so it is worth defending. Every other entrypoint here hands
you an app object, which is a thing the bridge can serve. <code>runserver</code> binds the
socket itself. Given Pyodide's socket, that means it would start, print its
banner, and answer nothing, which is the failure mode described at the top of
this post.</p>
<p>The rest of <code>manage.py</code> is untouched. <code>migrate</code>, <code>makemigrations</code>, <code>shell</code> and
<code>createsuperuser</code> all run normally, because none of them is a socket.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="keeping-cpythons-own-http-server-minus-the-socket">Keeping CPython's own HTTP server, minus the socket<a href="https://vivari.run/blog/python-web-servers-without-sockets#keeping-cpythons-own-http-server-minus-the-socket" class="hash-link" aria-label="Direct link to Keeping CPython's own HTTP server, minus the socket" title="Direct link to Keeping CPython's own HTTP server, minus the socket" translate="no">​</a></h2>
<p><code>python -m http.server</code> is the neatest case, because it shows what the bridge
makes possible when the thing being served is already in the standard library.</p>
<p>Reimplementing a static file server is easy and would have been the wrong
answer. The value of <code>-m http.server</code> is that it is the directory listing you
know, the <code>mimetypes</code> table you know, the <code>Range</code> and <code>If-Modified-Since</code>
handling you know, and the 404 you know. A lookalike is worth much less than the
real one.</p>
<p>So the handler stays and the socket goes. <code>BaseHTTPRequestHandler</code> does all of
its I/O through <code>self.rfile</code> and <code>self.wfile</code>, which
<code>StreamRequestHandler.setup()</code> builds from <code>self.connection</code> by calling
<code>makefile()</code>. It never touches the socket directly. So a socket, as far as that
class is concerned, is an object with <code>makefile()</code> and <code>sendall()</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">class</span><span class="token plain"> </span><span class="token class-name">_VvConn</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:rgb(255, 121, 198)">"""Everything StreamRequestHandler.setup() asks of a socket, and no more."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">def</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">__init__</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">self</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> data</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        self</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">_data </span><span class="token operator">=</span><span class="token plain"> data</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        self</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">out </span><span class="token operator">=</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">bytearray</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">def</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">makefile</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">self</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> mode</span><span class="token operator">=</span><span class="token string" style="color:rgb(255, 121, 198)">"rb"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> bufsize</span><span class="token operator">=</span><span class="token operator">-</span><span class="token number">1</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token operator">*</span><span class="token plain">a</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token operator">**</span><span class="token plain">k</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> io</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">BytesIO</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">self</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">_data</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"r"</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">in</span><span class="token plain"> mode </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">else</span><span class="token plain"> io</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">BytesIO</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">def</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">sendall</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">self</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> b</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        self</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">out </span><span class="token operator">+=</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">bytes</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">b</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><br></div></code></pre></div></div>
<p>Feed it the raw request bytes, let CPython's own <code>SimpleHTTPRequestHandler</code> do
the work, and collect the raw response bytes out of <code>out</code>. The same guest Node
server carries them that carries Flask's.</p>
<p>Duck typing gets used to defend some questionable things. This is the case it
was invented for.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-things-that-fell-out-for-free">Two things that fell out for free<a href="https://vivari.run/blog/python-web-servers-without-sockets#two-things-that-fell-out-for-free" class="hash-link" aria-label="Direct link to Two things that fell out for free" title="Direct link to Two things that fell out for free" translate="no">​</a></h2>
<p>Neither of these was designed. Both are consequences of the constraints, noticed
afterwards, which is usually a sign the layering is right.</p>
<p><strong>There is an exact moment when the filesystem is consistent.</strong> A served app
that writes a file, an upload or a SQLite commit, needs those writes mirrored
back into the editor. On a normal server there is no clean moment to do that,
because another thread is always mid-write. Here the handler has returned and
there are no threads, so the end of a request is a point where "everything the
app has written" is a complete and correct answer. Persistence happens there,
after the response bytes are already out, and costs nothing on a request that
wrote nothing.</p>
<p><strong><code>--reload</code> works, without a watcher thread or a subprocess.</strong> Real reloaders
need both: something to poll the filesystem, and a process to kill and respawn.
Neither exists here and neither is needed. The virtual filesystem already pushes
change notifications, because that is how Vite's dev server sees your edits. And
there is no server process to restart, because your app is an object imported
into the bridge's process, so a reload re-imports the module and rebinds one
name. A failed re-import puts the previous modules back and the old app keeps
serving, which matters because a syntax error in a file you just saved is the
normal case rather than the exceptional one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-honest-to-claim">What is honest to claim<a href="https://vivari.run/blog/python-web-servers-without-sockets#what-is-honest-to-claim" class="hash-link" aria-label="Direct link to What is honest to claim" title="Direct link to What is honest to claim" translate="no">​</a></h2>
<p>The Python web frameworks here are shipped as <strong>experimental</strong> templates. That
is the project's own word for them, it is what the README says, and this post is
not the place to quietly upgrade it. Vivari's CPython core, <code>pip</code> and the REPL
are stable. Flask, FastAPI and Django sit above them and are not.</p>
<p>The limits worth knowing before you try it:</p>
<ul>
<li class=""><strong>Buffered request and response only.</strong> Each request is converted, run and
returned whole. No streaming responses, no Server-Sent Events, no WebSocket
from Python. This is a property of the bridge, not a bug in it.</li>
<li class=""><strong>One request at a time.</strong> One interpreter, no threads.</li>
<li class=""><strong><code>runserver</code> is refused</strong>, as described above.</li>
<li class=""><strong>Generate your URLs.</strong> The preview is served under a prefix, and the bridge
tells your framework what it is, so <code>url_for()</code>, <code>reverse()</code> and
<code>request.url_for()</code> stay inside the preview. A hardcoded <code>/about</code> escapes it.</li>
<li class=""><strong>Nothing here makes an unbuilt C extension work.</strong> <code>psycopg2</code> still has no
wheel. Streamlit still stops on <code>watchdog</code>. This bridge is about serving an
app you can already import.</li>
</ul>
<p>What is not caveated: a real Flask app, a real FastAPI app with real Starlette
routing, and a real Django app served through real gunicorn argv handling, all
answering real HTTP requests in a browser tab with no server anywhere and no
socket underneath.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-pattern-again">The pattern, again<a href="https://vivari.run/blog/python-web-servers-without-sockets#the-pattern-again" class="hash-link" aria-label="Direct link to The pattern, again" title="Direct link to The pattern, again" translate="no">​</a></h2>
<p>Every one of these posts has ended in the same place from a different direction.
The <a class="" href="https://vivari.run/blog/blocking-in-a-browser">synchronous bridge</a> works because <code>Atomics.wait</code> is
a real blocking primitive and the whole system was built to respect it. <a class="" href="https://vivari.run/blog/nodes-real-lib-in-the-browser">Node's
real <code>lib/</code></a> works because Node already had a
seam and we cut along it.</p>
<p>WSGI and ASGI are that same seam, and Python drew it years before any of this
existed, with none of it in mind. The frameworks were already written against an
interface that says nothing about sockets. All that was missing was something
standing on the other side of it.</p>
<p>The next post is about a different Python problem entirely: why the first
<code>python</code> command in a session costs nearly two seconds, why the second one used
to as well, and what PEP 552 has to do with fixing it. There is more detail on
everything above in <a href="https://vivari.run/docs/python" target="_blank" rel="noopener noreferrer" class="">the Python docs</a>.</p>
<hr>
<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no
per-seat fee, self-host every asset. The code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a> and the
<a href="https://vivari.run/studio/" target="_blank" rel="noopener noreferrer" class="">Studio</a> runs in your browser.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Python</category>
            <category>Runtime</category>
        </item>
        <item>
            <title><![CDATA[Next.js 16 renders React Server Components in a browser tab, and the AsyncLocalStorage trap]]></title>
            <link>https://vivari.run/blog/nextjs-rsc-in-a-tab</link>
            <guid>https://vivari.run/blog/nextjs-rsc-in-a-tab</guid>
            <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[We had written Next.js off as a hard native wall. That verdict was wrong. Getting RSC rendering in-browser came down to one primitive the platform cannot provide.]]></description>
            <content:encoded><![CDATA[<p>For most of this project's life, our notes said Next.js was out of reach. The
reasoning looked solid: Next compiles with SWC, SWC is native Rust, there is no
native code in a browser tab, therefore no Next.js. A hard wall, filed away.</p>
<p>That verdict was wrong, and it was wrong in the most ordinary way: we had
decided something was impossible and then stopped rechecking it.</p>
<p><code>next dev --webpack</code> now boots inside a browser tab, compiles an App Router
page, renders React Server Components, and answers <code>GET / → 200</code> with real HTML.
No server. The kernel, the filesystem, the process model, the dev server and the
React render all live in one tab.</p>
<p>Getting there was mostly unremarkable engineering, plus one problem that has no
correct solution.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-wall-was-not-a-wall">Why the wall was not a wall<a href="https://vivari.run/blog/nextjs-rsc-in-a-tab#why-the-wall-was-not-a-wall" class="hash-link" aria-label="Direct link to Why the wall was not a wall" title="Direct link to Why the wall was not a wall" translate="no">​</a></h2>
<p>Three things had to be true at once, and all three already were:</p>
<p><strong>Next 16 kept the Wasm SWC fallback.</strong> <code>@next/swc-wasm-nodejs</code> still exists,
because Next still has to run in environments without a native binding. Next's
own <code>loadBindings</code> prefers it when <code>process.versions.webcontainer</code> is set,
which our runtime now reports, because that is exactly what we are.</p>
<p><strong>webpack is still selectable.</strong> Turbopack is native Rust with no Wasm build, so
it genuinely is unavailable. But <code>--webpack</code> remains a supported flag, and
webpack is JavaScript.</p>
<p><strong>npm skips the native optional dependencies.</strong> On arch <code>wasm32</code>, the
<code>@next/swc-&lt;platform&gt;</code> optionalDeps do not install, so the Wasm build is not
merely preferred, it is the only binding present.</p>
<p>So the wall was three assumptions that had each expired. Worth remembering next
time something gets filed as impossible.</p>
<p>The rest of the work was the usual: <code>vm.runInNewContext</code> had to make the sandbox
the <em>real</em> global, so that <code>globalThis.__RSC_MANIFEST = ...</code> assignments in
Next's generated manifest files actually land on the context object; without
that the client-reference manifest never loads. <code>child_process.fork</code> needed a
genuine IPC channel, because <code>next dev</code> forks its dev server and gates startup
on <code>process.send</code> existing. <code>pathToFileURL</code> had to resolve relative to absolute
like Node does. A handful of modules had to exist: <code>dns/promises</code>, <code>stream/web</code>,
an <code>inspector</code> stub, <code>module.findSourceMap</code>, and the complete <code>Console</code> method
surface that <code>@edge-runtime/primitives</code> binds.</p>
<p>All generic. None of it Next-specific. Then there was the interesting one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-trap">The trap<a href="https://vivari.run/blog/nextjs-rsc-in-a-tab#the-trap" class="hash-link" aria-label="Direct link to The trap" title="Direct link to The trap" translate="no">​</a></h2>
<p>The App Router's internals rely on <code>AsyncLocalStorage</code>. Its <code>workStore</code> and
<code>workUnitAsyncStorage</code> carry per-request context, and React's server rendering
reads them from deep inside component code. If <code>getStore()</code> returns <code>undefined</code>
at the wrong moment, you get:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Expected workStore to be initialized</span><br></div></code></pre></div></div>
<p>In real Node, <code>AsyncLocalStorage</code> works because V8 exposes a <strong>PromiseHook</strong>.
The engine tells <code>async_hooks</code> when a promise is created, resolved, and
continued, so the context can follow execution across a native <code>await</code>. Our
runtime delegates to the host's <code>async_hooks</code> through the <code>internalBinding</code> seam
when one exists, which is exact.</p>
<p>A browser has no PromiseHook. There is no way to observe a native <code>await</code>. You
cannot know that this continuation belongs to that async context, because the
engine never tells you.</p>
<p>This is worth sitting with, because it is a genuinely different class of problem
from everything else in this series. Every other gap was work: read Node's
source, implement the binding, be precise. This one is a capability the platform
does not have and cannot be made to have.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="three-rules-and-why-each-exists">Three rules, and why each exists<a href="https://vivari.run/blog/nextjs-rsc-in-a-tab#three-rules-and-why-each-exists" class="hash-link" aria-label="Direct link to Three rules, and why each exists" title="Direct link to Three rules, and why each exists" translate="no">​</a></h2>
<p>What we could do was stop trying to solve the general problem. A dev preview
handles one request at a time. That is a much weaker requirement than a
production server, and within it the context can be tracked well enough to be
<em>deterministic</em>, not merely usually correct.</p>
<p>Three rules, each of which exists because a specific thing broke.</p>
<p><strong>Rule 1: a thenable-returning <code>run(store, cb)</code> holds its store until the
promise settles, then pops only if still top, and never back to <code>undefined</code>.</strong></p>
<p>The "only if still top" clause stops out-of-order settling from clobbering a
live nested scope. The "never back to <code>undefined</code>" clause is the one that took
real debugging. A streaming RSC render returns its promise <em>early</em>, as soon as
the stream is created, while React carries on rendering components detached
across native awaits. Zeroing the store when that early promise settles throws
<code>Expected workStore to be initialized</code> in code that is still running. Restoring
a <em>defined</em> parent store is safe and keeps nested scopes correct; restoring
<code>undefined</code> is not.</p>
<p><strong>Rule 2: a plain, non-thenable return does not restore at all.</strong></p>
<p>Next's <code>renderToFlightStream</code> returns a stream synchronously and does the actual
rendering later, across raw awaits, with no promise for us to observe. If the
store is popped when <code>run()</code> returns, all of that detached work runs with no
context. Leaving the store current keeps <code>getStore()</code> correct until the next
<code>run()</code> overwrites it.</p>
<p>This rule is, straightforwardly, a leak. In a general-purpose implementation it
would be wrong. Under one-request-at-a-time it is the behaviour that makes the
detached render work, and the next <code>run()</code> cleans up after it.</p>
<p><strong>Rule 3: propagate a per-hop snapshot of every live store through the
scheduling primitives React uses.</strong></p>
<p>Specifically <code>Promise.prototype.then</code>, <code>queueMicrotask</code>, <code>setImmediate</code> and
<code>setTimeout</code>. React's scheduler hops through these constantly, and each hop is a
place context would otherwise be lost. Snapshotting at schedule time and
restoring at run time recovers most of what a PromiseHook would have given us.</p>
<p>The patches install <strong>once at boot</strong>, and only on the polyfill path. The timing
matters: after the runtime's own timer globals are in place, and before any
framework code loads, so React captures the wrapped primitives rather than the
originals. When the host provides real <code>async_hooks</code>, none of this is active.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="proving-it-given-the-failure-mode">Proving it, given the failure mode<a href="https://vivari.run/blog/nextjs-rsc-in-a-tab#proving-it-given-the-failure-mode" class="hash-link" aria-label="Direct link to Proving it, given the failure mode" title="Direct link to Proving it, given the failure mode" translate="no">​</a></h2>
<p>A context bug that only appears under specific interleavings is the worst kind
to claim you have fixed. "It worked when I tried it" is not evidence when the
mechanism is inherently timing-sensitive.</p>
<p>So the polyfill is tested by <em>forcing</em> it. <code>VV_NO_HOST_ALS=1</code> disables the host
<code>async_hooks</code> delegation even where it is available, which means the headless
test suite exercises the browser path on a machine where the real one exists.
That gives two things: an oracle, and the ability to run the comparison under
load.</p>
<p>The specific case that matters is the RSC refresh render: the App Router's
"on save" re-render, the request with <code>RSC: 1</code> that the HMR flow issues. That is
the path that threw <code>workStore</code> in the studio, so it is the path that has to be
green.</p>
<p>The results we hold it to: <code>GET /</code> returns 200; the refresh render returns 200
with zero invariant errors across repeats; and the output is <strong>byte-identical</strong>
to the host <code>async_hooks</code> path, under heavy-I/O perturbation, which is the
whole point. Byte-identical against a known-correct implementation is a much
stronger claim than "no errors observed", and it is the only reason we are
comfortable describing the behaviour as deterministic rather than lucky.</p>
<p>One more detail that is easy to get wrong: Next resolves the Wasm SWC by
downloading it into its own cache on first compile. That is its intended
behaviour in Wasm environments and real Node does the same thing. The template's
<code>postinstall</code> seeds that cache from the already-installed package so the first
compile is offline, with Next's own on-demand download left in place as the
fallback.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-honest-to-claim">What is honest to claim<a href="https://vivari.run/blog/nextjs-rsc-in-a-tab#what-is-honest-to-claim" class="hash-link" aria-label="Direct link to What is honest to claim" title="Direct link to What is honest to claim" translate="no">​</a></h2>
<p>Next.js is shipped as a <strong>stable</strong> template, in TypeScript and
JavaScript, and the caveats are real:</p>
<ul>
<li class=""><strong>Turbopack does not work and will not.</strong> It is native Rust with no Wasm
build. <code>--webpack</code> is the path.</li>
<li class=""><strong>The <code>AsyncLocalStorage</code> polyfill targets a dev preview.</strong> It is correct for
one request at a time. It is not a general-purpose implementation and we would
not present it as one.</li>
<li class=""><strong>First compile is heavy.</strong> A Wasm SWC compiling an App Router page is not
fast, and you will notice.</li>
</ul>
<p>What is not caveated: this is real Next.js from npm, real webpack, real SWC,
real React Server Components, rendering real HTML, with no server involved
anywhere. You can open the Studio and pick the Next.js template right now.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-thread-running-through-all-of-this">The thread running through all of this<a href="https://vivari.run/blog/nextjs-rsc-in-a-tab#the-thread-running-through-all-of-this" class="hash-link" aria-label="Direct link to The thread running through all of this" title="Direct link to The thread running through all of this" translate="no">​</a></h2>
<p>Every post in this series has landed in the same place, from a different
direction.</p>
<p>The <a class="" href="https://vivari.run/blog/blocking-in-a-browser">synchronous bridge</a> works because
<code>Atomics.wait</code> is a real blocking primitive and we built the entire system
around respecting it. <a class="" href="https://vivari.run/blog/nodes-real-lib-in-the-browser">Node's real <code>lib/</code></a>
works because Node already had a seam and we cut along it.
<a class="" href="https://vivari.run/blog/real-package-managers-in-the-browser">npm, yarn and pnpm</a> work because
the layers underneath implemented real specifications instead of the subset our
demos needed.</p>
<p><code>AsyncLocalStorage</code> is the exception that proves the rule. There is no seam to
cut, no specification to implement completely, no primitive to respect. V8's
PromiseHook simply is not there. So the only honest move was to narrow the
problem until it was solvable, be explicit about the boundary, and test against
the real implementation rather than against our own expectations.</p>
<p>Sometimes the interesting engineering is admitting exactly how much of the
problem you actually solved.</p>
<hr>
<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no
per-seat fee, self-host every asset. The code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a> and the
<a href="https://vivari.run/studio/" target="_blank" rel="noopener noreferrer" class="">Studio</a> runs in your browser.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Runtime</category>
            <category>Node.js</category>
        </item>
        <item>
            <title><![CDATA[llhttp in Wasm, and a real Postgres in the tab]]></title>
            <link>https://vivari.run/blog/databases-and-the-http-parser</link>
            <guid>https://vivari.run/blog/databases-and-the-http-parser</guid>
            <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Two smaller teardowns. Replacing a hand-written HTTP parser with the same llhttp Node ships, and running PostgreSQL 18 with no server and no native addon.]]></description>
            <content:encoded><![CDATA[<p>Two shorter pieces this time, connected by a theme:
<a class="" href="https://vivari.run/blog/nodes-real-lib-in-the-browser">running Node's real source</a> keeps paying
out in places you did not plan for, and WebAssembly turns out to cover a lot of
what "you need a native binary for that" used to mean.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="part-one-the-http-parser">Part one: the HTTP parser<a href="https://vivari.run/blog/databases-and-the-http-parser#part-one-the-http-parser" class="hash-link" aria-label="Direct link to Part one: the HTTP parser" title="Direct link to Part one: the HTTP parser" translate="no">​</a></h2>
<p>Node's <code>lib/http</code> does not parse HTTP. It delegates to
<code>internalBinding('http_parser')</code>, which in real Node is llhttp, a C parser
compiled into the binary.</p>
<p>We had written a pure-JavaScript HTTP/1.1 parser to fill that binding. It
worked, in the sense that ordinary requests went in and ordinary responses came
out. It also had the failure profile every hand-written protocol parser has: the
common path is fine and the edges are a long tail of things you have not thought
about yet. Trailers. <code>HEAD</code> responses, which have <code>Content-Length</code> but no body.
<code>204</code>, which has neither. Chunked encoding with extensions. Pipelining. Upgrade
and <code>CONNECT</code> hand-off, where the parser has to stop parsing mid-stream and hand
the raw socket over.</p>
<p>The right answer is to run the same parser Node runs.</p>
<p><strong>We did not build a toolchain to do it.</strong> Standing up wasi-sdk and clang just
to recompile llhttp would produce an artifact essentially identical to one that
already ships publicly. undici bundles a prebuilt <code>llhttp.wasm</code> from the same
upstream project, under the same MIT licence. So a vendoring script pins the
undici version and regenerates a binding module with the binary base64-embedded,
about 54 KB. No fetch at runtime, no build dependency.</p>
<p>Two details made it interesting.</p>
<p><strong>It has to compile synchronously.</strong> The binding is constructed at process
bootstrap, inside the
<a class="" href="https://vivari.run/blog/blocking-in-a-browser">synchronous world</a> everything else lives in, so
the module is built with <code>new WebAssembly.Module()</code> rather than the async
<code>compile</code>. That is allowed on a worker thread, which is where guest processes
run. On the main thread there is a 4 KB size cap on synchronous compilation, and
a 54 KB module throws, which turns out to be a convenient way to detect the
environment. Exceeding the cap is precisely what trips the pure-JS fallback, so
the JS parser stays in the tree as the main-thread path rather than as dead
code. <code>VV_HTTP_PARSER=js|wasm</code> forces either side, with <code>wasm</code> failing loudly
instead of falling back, so tests can assert which one they exercised.</p>
<p><strong>The bridge has to be numerically exact.</strong> llhttp reports progress through span
callbacks: <code>on_url</code>, <code>on_status</code>, <code>on_header_field</code>, <code>on_header_value</code>,
<code>on_body</code>, <code>on_headers_complete</code>, <code>on_message_complete</code>. Node's
<code>lib/_http_common.js</code> does not consume those; it expects a specific set of
numeric <code>kOn*</code> slots. The binding mirrors what Node's own <code>node_http_parser.cc</code>
does: drive llhttp's callbacks, fold them onto the exact <code>kOn*</code> contract, for
both requests and responses. <code>allMethods</code> follows llhttp's method enum, so
<code>allMethods[llhttp_get_method()]</code> round-trips the way callers assume.</p>
<p>When the Wasm backend is live it advertises <code>process.versions.llhttp</code>, exactly
as real Node does. Twenty offline checks guard it in CI, plus an extended HTTP
case covering <code>HEAD</code>, <code>204</code>, chunked requests and responses, trailers and
keep-alive, run against both backends, because a fallback nobody tests is a
fallback that does not work.</p>
<p>You can watch it parse. The server below binds a port inside this tab and then
fetches from itself:</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">An in-VM HTTP server</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=http-parser" title="An in-VM HTTP server" loading="lazy" allow="cross-origin-isolated" style="height:500px"></iframe></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="part-two-databases">Part two: databases<a href="https://vivari.run/blog/databases-and-the-http-parser#part-two-databases" class="hash-link" aria-label="Direct link to Part two: databases" title="Direct link to Part two: databases" translate="no">​</a></h2>
<p>"No native database" is the limitation every in-browser runtime lists, and the
documentation usually stops at suggesting workarounds.</p>
<p>It is worth asking why the limitation exists. The blocker is not SQL. It is
that database clients are native addons, and there is no compiler in the tab. So
the question becomes: what do you need in order for a Wasm-compiled engine to
work, and do we already have it?</p>
<p>The answer was yes, and it had nothing to do with databases. A Wasm SQL engine
needs a real <code>fs</code> to read its data files, a working <code>url</code> module, and the host's
<code>WebAssembly</code>, all of which exist because of decisions made for other reasons.
So this was less a feature than a discovery.</p>
<p><strong>SQLite via sql.js.</strong> SQLite compiled to Wasm. <code>initSqlJs()</code> finds its <code>.wasm</code>
next to itself with <code>locateFile: (f) =&gt; require.resolve('sql.js/dist/' + f)</code>,
which resolves over the virtual filesystem like any other module path.</p>
<p><strong>PostgreSQL via PGlite.</strong> This is real PostgreSQL, currently 18, compiled to
Wasm. About 16 MB of <code>pglite.wasm</code> and <code>pglite.data</code>, read out of
<code>node_modules</code> through the virtual filesystem: the package resolves them from
<code>__filename</code>, builds a <code>new URL('./pglite.wasm', ...)</code>, and calls
<code>fs.readFile</code>. Every step of that is ordinary Node behaviour, which is the whole
point.</p>
<p>One deliberate choice: we use PGlite's <strong>CommonJS</strong> build. The ESM build relies
on top-level await, and in-VM only the entry module can block on TLA. Choosing
CJS avoids the problem entirely.</p>
<p><strong>And one we deliberately did not ship.</strong> libSQL is not available as an in-VM
template, and the reason is worth stating rather than leaving as a gap in a
table. <code>@libsql/client</code> in local mode is a native N-API addon with no <code>wasm32</code>
build. <code>@libsql/client/web</code> works, but only talks to a remote Turso server,
which is a network client, not a database in the tab. Neither is
self-contained, so neither belongs in a list of things that run with no server.
sql.js remains the local SQLite path.</p>
<p>Both engines were confirmed end-to-end in plain Node first, against the same
<code>fs</code>, <code>url</code> and <code>WebAssembly</code> primitives the runtime exposes, before either was
wired into a template. Both are then gated by network spikes in CI that install
the dependency, bind a port, and assert that the API reports the right engine
version and returns seeded rows, with a longer budget for PGlite, whose install
and first-boot Wasm compile are genuinely heavy.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-pattern">The pattern<a href="https://vivari.run/blog/databases-and-the-http-parser#the-pattern" class="hash-link" aria-label="Direct link to The pattern" title="Direct link to The pattern" translate="no">​</a></h2>
<p>Both of these landed for the same reason, and it is not cleverness.</p>
<p>The HTTP parser worked because Node had already defined the seam
(<code>internalBinding('http_parser')</code>) and someone had already compiled the C to
Wasm. The databases worked because the runtime implemented <code>fs</code> and <code>url</code>
properly rather than implementing the subset our own demos needed.</p>
<p>Neither was planned. Both fell out of building the layer underneath correctly
and then discovering what it supported. That is a much better position to be in
than the alternative, and it is most of the argument for
<a href="https://vivari.run/docs/how-it-works" target="_blank" rel="noopener noreferrer" class="">the architecture</a> these posts
keep coming back to.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>WebAssembly</category>
            <category>Runtime</category>
        </item>
        <item>
            <title><![CDATA[Three ways to isolate a preview, and the Cloudflare wildcard trick]]></title>
            <link>https://vivari.run/blog/three-ways-to-isolate-a-preview</link>
            <guid>https://vivari.run/blog/three-ways-to-isolate-a-preview</guid>
            <pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Serving a preview from the same origin as the IDE hands preview code your cookies, your storage and your OPFS. Fixing it runs into the Public Suffix List, storage partitioning and a TLS wildcard rule.]]></description>
            <content:encoded><![CDATA[<p>When an in-browser IDE runs your dev server and shows you the result, the
result has to be served from <em>somewhere</em>. The easy answer is the origin you
already have: put the preview at <code>/preview/5173/</code> on the IDE's own domain, let
the Service Worker route by path, ship it.</p>
<p>That is what we did, and it is a security problem.</p>
<p>Same origin means the same cookie jar, the same <code>localStorage</code>, the same
IndexedDB, the same OPFS, the same Cache Storage, the same Service Worker scope.
Preview code, which includes every npm package the project installed and
anything an AI assistant just generated, sits inside the IDE's origin. It can
read the editor's session state, corrupt its persistence, and call its
same-origin APIs. Previews are not isolated from the IDE, and they are not
isolated from each other.</p>
<p>Fixing this properly took three attempts, and each one ran into a different
piece of web platform trivia.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mode-a-same-origin">Mode A: same origin<a href="https://vivari.run/blog/three-ways-to-isolate-a-preview#mode-a-same-origin" class="hash-link" aria-label="Direct link to Mode A: same origin" title="Direct link to Mode A: same origin" translate="no">​</a></h2>
<p>The default, and the one to move away from.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">https://ide.example.com/preview/5173/</span><br></div></code></pre></div></div>
<p>The Service Worker intercepts requests under <code>/preview/&lt;port&gt;/</code>, strips the
prefix, and relays them to the kernel, which it can find directly because it
is same-origin with the tab holding it.</p>
<p>Zero extra infrastructure, and it works. But beyond the storage problem, path
routing quietly breaks things that a real server would get right:</p>
<table><thead><tr><th>Keyed on origin</th><th>Breaks under shared-origin path routing</th></tr></thead><tbody><tr><td>Cookie jar (session, CSRF, <code>SameSite</code>)</td><td>frontend and backend cookies collide at <code>/</code></td></tr><tr><td>localStorage / IndexedDB / OPFS / Cache</td><td>services share one store, state bleeds</td></tr><tr><td>CORS and <code>fetch</code> credentials</td><td>cross-service calls look same-origin, wrongly</td></tr><tr><td>Service Worker scope</td><td>the app's own SW registrations collide</td></tr><tr><td>Absolute paths</td><td>router basename, <code>/asset.png</code> and <code>&lt;base&gt;</code> all break</td></tr></tbody></table>
<p>That last row is the one users actually report. Any app that assumes it is
served from <code>/</code> needs a prefix hack somewhere: in the SW, in the URL rewriter,
or in the app's own config.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mode-b-a-second-origin">Mode B: a second origin<a href="https://vivari.run/blog/three-ways-to-isolate-a-preview#mode-b-a-second-origin" class="hash-link" aria-label="Direct link to Mode B: a second origin" title="Direct link to Mode B: a second origin" translate="no">​</a></h2>
<p>Move previews to a different origin entirely and the browser's same-origin
policy does the work for you. The kernel still lives in the IDE tab, so the
preview's Service Worker reaches it through a hidden bridge iframe and a
<code>MessagePort</code>.</p>
<p>Here the hosting platform pushes back. A Cloudflare Pages project only gets
<code>&lt;project&gt;.pages.dev</code>, and you cannot mint <code>preview.myproject.pages.dev</code>. A
second origin means a second Pages project.</p>
<p>Which sounds fine, and introduces the first real trap.</p>
<p><strong><code>pages.dev</code> is on the Public Suffix List.</strong> That makes <code>myproject.pages.dev</code>
and <code>myproject-preview.pages.dev</code> <em>different sites</em>, not merely different
origins. As an isolation boundary that is stronger than you asked for: cookies
cannot be shared even deliberately.</p>
<p>It also breaks popping a preview out into its own tab, in a way that cannot be
worked around.</p>
<p>Chrome storage-partitions cross-site contexts. The kernel is reached through a
bridge iframe living in the editor tab; for a standalone preview tab to use that
bridge's Service Worker registration and <code>MessagePort</code>, both have to be in the
same storage partition. Cross-site, they are not. And <code>requestStorageAccess()</code>
un-partitions <strong>cookies</strong>, not Service Worker registrations, so the "connect
this tab to its project" gate can never actually bridge the two partitions. The
gate appears, you grant it, and nothing changes.</p>
<p><strong>Two subdomains of one registrable domain fix it.</strong> Serve the IDE at
<code>ide.example.com</code> and previews at <code>preview.example.com</code>, both CNAMEd to their
Pages projects, and the two are <em>same-site</em>: no partition wall exists, so the
popped-out tab shares the bridge's Service Worker and reaches the kernel with no
gate at all. Storage is still origin-scoped, so preview code still cannot touch
IDE storage.</p>
<table><thead><tr><th>Deploy</th><th>Same-site?</th><th>Partitioned?</th><th>Pop-out</th></tr></thead><tbody><tr><td>Two <code>*.pages.dev</code> projects</td><td>No, PSL cuts <code>pages.dev</code></td><td>Yes</td><td>Gate appears, cannot be granted</td></tr><tr><td>Two subdomains of one domain</td><td>Yes</td><td>No</td><td>Connects immediately, no gate</td></tr><tr><td>StackBlitz (<code>stackblitz.com</code> / <code>webcontainer.io</code>)</td><td>No, different domains</td><td>Yes</td><td>Gate, accepted deliberately</td></tr></tbody></table>
<p>The residual leak with same-site subdomains is domain-wide cookies: anything set
with <code>Domain=example.com</code> is visible to both. So do not set domain-wide cookies
on the IDE. For trusted first-party code this is the right trade. For untrusted
code at scale you want the cross-site boundary and you accept the gate, which
is exactly the choice StackBlitz made, and it is a reasonable one for their
threat model, not an oversight.</p>
<p>There is one important caveat: you have to open the editor at
<code>ide.example.com</code>. Loading it via the raw <code>.pages.dev</code> hostname reintroduces
cross-site and the gate comes back.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mode-c-one-origin-per-port">Mode C: one origin per port<a href="https://vivari.run/blog/three-ways-to-isolate-a-preview#mode-c-one-origin-per-port" class="hash-link" aria-label="Direct link to Mode C: one origin per port" title="Direct link to Mode C: one origin per port" translate="no">​</a></h2>
<p>Mode B isolates previews from the IDE, but every preview still shares one origin
with every other preview, and the port is still in the path. Mode C gives each
in-VM port its own origin:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">https://k3f9a2xh--5173-vv.example.com/</span><br></div></code></pre></div></div>
<p>The port moves into the hostname, so each preview gets genuine
<code>localhost:&lt;port&gt;</code> semantics, with its own cookies, its own storage and its own
CORS behaviour, and previews are isolated from each other as well as from the IDE.
Everything on the "breaks under path routing" list above stops being a problem,
and the prefix hacks come out of the codebase.</p>
<p>This is the model StackBlitz uses, and their URLs decode neatly:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">https://vitejsvitelqrjey5b-c0kn--5173--87cf54cd.local-credentialless.webcontainer.io/</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        └────── instance / project id ────┘  └port┘ └session hash┘ └ COEP mode ┘ └base┘</span><br></div></code></pre></div></div>
<p>Getting there involves three constraints that each look arbitrary until they
bite.</p>
<p><strong>Cloudflare Pages cannot do wildcard custom domains.</strong> They are exact hostnames
only. So the wildcard origin has to be a Worker bound to a route, serving the
static Service Worker runtime and the bridge document. It runs no kernel and no
IDE; it is pure static hosting for an origin that has to exist per port.</p>
<p><strong>The wildcard must be a prefix, so the tag must be a suffix.</strong> Cloudflare
routes only allow <code>*</code> at the <em>start</em> of a hostname. <code>vv-*.example.com</code> is an
infix wildcard and is rejected. That is why the marker is a suffix and the route
reads <code>*-vv.example.com/*</code>, which has the pleasant side effect of being narrow:
it matches Vivari preview hosts and nothing else on the zone. Anything else that
reaches the Worker is passed straight through untouched.</p>
<p><strong>Free TLS covers exactly one label.</strong> Cloudflare's Universal SSL issues a
certificate for the apex plus a single-level wildcard: <code>*.example.com</code> matches
<code>abc.example.com</code> but <em>not</em> <code>abc.def.example.com</code>. So a scheme like
<code>*.preview.example.com</code> is two levels deep, is not covered, and produces a TLS
error rather than a helpful message. Paid Advanced Certificate Manager fixes it;
staying free means keeping preview hostnames one level under the apex and
packing the port into that single label, hence <code>&lt;token&gt;--&lt;port&gt;-vv</code>, all in one
label.</p>
<p>Each preview response is then stamped with <code>COOP: same-origin</code>,
<code>COEP: credentialless</code> and <code>CORP: cross-origin</code>, so the IDE (which is
<code>require-corp</code>) can embed the bridge iframe, and the Service Worker is allowed
to claim root scope.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-limitation-nobody-can-engineer-away">The limitation nobody can engineer away<a href="https://vivari.run/blog/three-ways-to-isolate-a-preview#the-limitation-nobody-can-engineer-away" class="hash-link" aria-label="Direct link to The limitation nobody can engineer away" title="Direct link to The limitation nobody can engineer away" translate="no">​</a></h2>
<p>One thing worth being honest about, because it applies to every client-side
container including this one.</p>
<p>A preview URL is not a network address. It is a <strong>capability ticket that only
works inside the browser holding a kernel-connected Service Worker</strong>. The
Service Worker is a per-origin proxy running in <em>your</em> browser, and its live
link to the kernel is a <code>MessagePort</code> held in its memory.</p>
<p>The consequences are observable on StackBlitz and we inherit all of them:</p>
<ul>
<li class="">Paste the URL into a new tab on the same browser and it works, because that
tab is claimed by the same Service Worker, which already holds the port.</li>
<li class="">"Open in new tab" sometimes needs a popup and a reload. That happens when the
Service Worker's in-memory port has lapsed because it was killed while idle,
and the popup provides a <code>window.opener</code> channel to re-handshake.</li>
<li class="">Open it on another machine and it fails, even with the project still open
elsewhere. The kernel lives in the first machine's tab RAM. <code>postMessage</code> does
not cross the network.</li>
<li class="">Close the editor tab and the preview dies.</li>
</ul>
<p>None of that is fixable within the no-server model, because the thing serving
the preview genuinely is not a server. A persistent, shareable preview URL
requires a real backend, which is a different product decision, not a bug to
file.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="choosing">Choosing<a href="https://vivari.run/blog/three-ways-to-isolate-a-preview#choosing" class="hash-link" aria-label="Direct link to Choosing" title="Direct link to Choosing" translate="no">​</a></h2>
<table><thead><tr><th></th><th>A. same-origin</th><th>B. shared preview origin</th><th>C. wildcard per port</th></tr></thead><tbody><tr><td>Extra infrastructure</td><td>none</td><td>one more Pages project</td><td>custom domain + wildcard DNS + Worker</td></tr><tr><td>Port encoded in</td><td>path</td><td>path</td><td>hostname</td></tr><tr><td>Isolates IDE from preview</td><td>no</td><td>yes</td><td>yes</td></tr><tr><td>Isolates previews from each other</td><td>no</td><td>no</td><td>yes</td></tr><tr><td>Real per-port semantics</td><td>no</td><td>no</td><td>yes</td></tr></tbody></table>
<p>The modes are a deploy-time choice rather than a runtime toggle, which keeps the
core simple. Start at A if you are running your own trusted code and want zero
infrastructure. Move to B with same-site subdomains as soon as anyone else's
code runs in your previews. Go to C when previews need to be isolated from each
other, or when apps genuinely need to believe they are on their own host.</p>
<p>The <a href="https://vivari.run/docs/deployment" target="_blank" rel="noopener noreferrer" class="">deployment guide</a> has the full
setup for each.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Deployment</category>
            <category>Browser platform</category>
        </item>
        <item>
            <title><![CDATA[Real npm, yarn and pnpm in the browser, and how each one broke the runtime]]></title>
            <link>https://vivari.run/blog/real-package-managers-in-the-browser</link>
            <guid>https://vivari.run/blog/real-package-managers-in-the-browser</guid>
            <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Not reimplementations. The actual npm, yarn, pnpm and corepack CLIs, vendored and run in a tab. Each one leaned on a different corner of Node, and fixing what it broke improved the whole runtime.]]></description>
            <content:encoded><![CDATA[<p>Once you are <a class="" href="https://vivari.run/blog/nodes-real-lib-in-the-browser">running Node's real <code>lib/</code></a> in a
tab, the interesting question stops being "can we reimplement npm?" and becomes
"why would we?". The package managers are just Node programs. If the runtime
underneath them is honest, the real CLIs should run unmodified.</p>
<p>They do. <code>npm</code>, <code>yarn</code>, <code>pnpm</code> and <code>corepack</code> in Vivari are the actual published
releases, vendored and executed as-is. But "should run" and "runs" are separated
by every Node API each tool happens to touch, and the four of them touch almost
disjoint sets. Each one, in turn, walked into a different corner of the runtime
and found the wall.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="not-a-reimplementation">Not a reimplementation<a href="https://vivari.run/blog/real-package-managers-in-the-browser#not-a-reimplementation" class="hash-link" aria-label="Direct link to Not a reimplementation" title="Direct link to Not a reimplementation" translate="no">​</a></h2>
<p>The retired approach was a Turbo-style installer that resolved a lockfile and
wrote <code>node_modules</code> itself. It was fine for a demo and wrong for a product: it
was not npm, so it did not behave like npm, and every gap between the two was a
support burden. The shipped studio boots the real thing instead.</p>
<p>Delivering a real CLI to a tab is a packaging problem. Each package manager is
installed at a pinned version on the build host, its file tree is walked, and
the whole thing is written into one archive that ships as a static asset under
<code>packages/studio/public/vendor/</code>. The archive is a deliberately boring custom
format rather than a tarball:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">[u32le headerLen][header JSON][file bytes ...]      then gzip the whole lot</span><br></div></code></pre></div></div>
<p>Two decisions in that one line earned their comments. The format is custom
because we control both ends and would rather not meet tar's long-path and
GNU-extension edge cases, and npm's <code>@npmcli/*</code> paths are long enough to hit them.
And the gzipped output is named <code>*-pack.bin</code>, <strong>not</strong> <code>*.gz</code>, on purpose:</p>
<blockquote>
<p>Static servers (Vite's sirv, many CDNs) treat a <code>.gz</code> file as
TRANSFER-encoded and serve it with <code>Content-Encoding: gzip</code>, so the browser
transparently decompresses it before our fetch sees it, and our own gunzip
then fails on already-decompressed bytes. A neutral extension is served verbatim.</p>
</blockquote>
<p>At boot the kernel worker fetches <code>npm-pack.bin</code>, gunzips it with the browser's
<code>DecompressionStream</code>, and writes the tree into the virtual filesystem at
<code>/usr/lib/node_modules/npm</code> in one batched transfer. A three-line shim lands on
<code>PATH</code> at <code>/bin/npm.js</code> and does nothing but <code>require</code> the real
<code>bin/npm-cli.js</code>. npm is loaded eagerly because almost every project needs it;
yarn, pnpm and corepack are registered as lazy loaders and only fetched the
first time you actually spawn them, so the sizes below are costs you opt into.</p>
<table><thead><tr><th>Tool</th><th>Version</th><th>Delivered</th><th>Files</th></tr></thead><tbody><tr><td>npm</td><td>10.9.2</td><td>~2.8 MB gz</td><td>~2400</td></tr><tr><td>yarn (classic)</td><td>1.22.22</td><td>~1.2 MB gz</td><td>11</td></tr><tr><td>pnpm</td><td>9.15.9</td><td>~3.7 MB gz</td><td>~898</td></tr><tr><td>corepack</td><td>0.35.0</td><td>~0.12 MB gz</td><td>54</td></tr></tbody></table>
<p>None of that is the hard part. The hard part is that a real CLI calls real Node.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="npm-the-fidelity-pass">npm: the fidelity pass<a href="https://vivari.run/blog/real-package-managers-in-the-browser#npm-the-fidelity-pass" class="hash-link" aria-label="Direct link to npm: the fidelity pass" title="Direct link to npm: the fidelity pass" translate="no">​</a></h2>
<p>npm booted first, and getting <code>npm -v</code> to print <code>10.9.2</code> and exit <code>0</code> closed
three gaps that a reimplementation would never have surfaced, because they are
about <em>being Node</em>, not about installing packages. <code>process</code> had to be a genuine
<code>EventEmitter</code> (npm's <code>proc-log</code> attaches listeners to it). A dynamic <code>import()</code>
inside a CommonJS module had to route through our loader (npm does
<code>await import('chalk')</code>). And <code>stdout.write(cb)</code>, <code>process.exitCode</code> and a single
<code>'exit'</code> event all had to behave the way npm's exit-handler assumes. Fixing those
was less "supporting npm" and more "finishing Node".</p>
<p>Then there is the thing every in-browser runtime has to answer for: native
addons. There is no compiler in a tab, and a <code>.node</code> binary could not be loaded
if there were, because we run Wasm. But real npm runs a package's
<code>install</code>/<code>rebuild</code> lifecycle script, which for a native package is
<code>node-gyp rebuild</code>, and a non-zero exit there aborts the entire install. So
<code>node-gyp</code> is stubbed to a non-fatal no-op:</p>
<blockquote>
<p>To keep installs working we make node-gyp a non-fatal no-op: the build is
skipped and the script "succeeds". This mirrors how browser WebContainers
handle native deps: the package's JS fallback (or its wasm32-wasi build,
auto-selected via optionalDependencies) is what actually loads at runtime.</p>
</blockquote>
<p>The rest of npm's needs are environmental. Registry requests go through a
fetcher that strips the non-safelisted headers a browser would otherwise
preflight and reject; downloads run through an async fetch op so npm's parallel
tarball fetches are actually parallel; large writes bypass the shared buffer
pool; and <code>npm_config_audit</code>, <code>npm_config_fund</code> and the update-notifier are
switched off, with the cache pointed at an OPFS-backed directory so it survives
a reload.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="yarn-five-gaps-in-one-bundle">yarn: five gaps in one bundle<a href="https://vivari.run/blog/real-package-managers-in-the-browser#yarn-five-gaps-in-one-bundle" class="hash-link" aria-label="Direct link to yarn: five gaps in one bundle" title="Direct link to yarn: five gaps in one bundle" translate="no">​</a></h2>
<p>Yarn classic is trivial to <em>deliver</em> (a <code>bin/yarn.js</code> entry and a ~5 MB
webpack <code>cli.js</code>, eleven files total) and instructive to <em>run</em>. It exercises a
set of Node internals npm simply never reached, and lighting it up filled five
more compatibility gaps, all fixed down in <code>packages/runtime/</code> where they help
every program and not just yarn.</p>
<p>The memorable one is <code>graceful-fs</code>, which yarn bundles through <code>fs-extra</code>.
<code>graceful-fs</code> patches <code>fs</code> by subclassing <code>fs.WriteStream</code> with
<code>fs$WriteStream.apply(this, arguments)</code>, the old prototypal-inheritance move,
which throws against a modern <code>class</code>. Alongside it: <code>process.memoryUsage()</code>
(yarn's reporter tracks peak memory), and <code>internal/fs/dir</code> for <code>fs.opendir</code>,
which yarn trips indirectly because <code>thenify-all</code> runs <code>promisifyAll(fs)</code> over
<em>every</em> method it can find. A package manager that reflects over the whole <code>fs</code>
module is an excellent conformance test you did not have to write.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="pnpm-worker-threads-symlinks-and-shell-shims">pnpm: worker threads, symlinks, and shell shims<a href="https://vivari.run/blog/real-package-managers-in-the-browser#pnpm-worker-threads-symlinks-and-shell-shims" class="hash-link" aria-label="Direct link to pnpm: worker threads, symlinks, and shell shims" title="Direct link to pnpm: worker threads, symlinks, and shell shims" translate="no">​</a></h2>
<p>pnpm was the one we expected to be hardest, and it was, because it uses the
features the others avoid. It drives real <code>worker_threads</code> for fetch and
extract. It builds a <strong>symlinked</strong> <code>node_modules</code>: a content-addressable store
plus symlinks into it, which means the virtual filesystem has to implement
<code>symlink</code>, <code>readlink</code> and <code>lstat</code> as first-class operations rather than
approximations. The store's packages are shared via hard links, so the VFS also
grew a real <code>link(2)</code>. What it does <em>not</em> get is reflink/copy-on-write, so the
prebuilt <code>*.node</code> reflink addons, which only exist for macOS and Windows, are
dropped at vendor time rather than shipped as dead weight on a Linux target.</p>
<p>The subtle failure was in how pnpm writes the executables in <code>node_modules/.bin</code>.
npm makes them POSIX symlinks to the real <code>.js</code>; pnpm writes a <code>#!/bin/sh</code>
cmd-shim that <code>exec node "$basedir/../vite/bin/vite.js" "$@"</code>. Our loader cannot
run a shell script, so without help it hands that shell wrapper to the JS
compiler and gets <code>SyntaxError: missing ) after argument list</code> the first time
you run a pnpm-installed binary. The fix is a small, unit-tested unwrapper that
parses the target <code>.js</code> out of the shell shim before <code>runMain</code> execs it. The
only genuinely missing runtime primitive was <code>util.types.isBoxedPrimitive</code>,
which pnpm's JSON path uses; it went in with the rest of the boxed-primitive and
typed-array predicates.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="corepack-a-package-manager-for-package-managers">corepack: a package manager for package managers<a href="https://vivari.run/blog/real-package-managers-in-the-browser#corepack-a-package-manager-for-package-managers" class="hash-link" aria-label="Direct link to corepack: a package manager for package managers" title="Direct link to corepack: a package manager for package managers" translate="no">​</a></h2>
<p>corepack is the odd one out, because it is not a package manager at all. It is a
version manager: it reads a project's <code>packageManager</code> field, downloads that
exact yarn or pnpm release, verifies it, and execs it. So it gets only a
<code>/bin/corepack.js</code> shim and deliberately leaves the direct <code>npm</code>/<code>yarn</code>/<code>pnpm</code>
shims alone: those stay the defaults, and corepack is the extra "run the
project-pinned version" path.</p>
<p>Its download-then-extract-then-exec pipeline surfaced five more gaps, again
fixed generically: <code>require('module').runMain</code>, which corepack uses to exec the
downloaded manager in-process; <code>Readable.fromWeb</code>, so it can stream the tarball
out of the global <code>fetch()</code> response body; WHATWG stream readers whose
<code>read()</code>/<code>cancel()</code> promises properly ref the event loop, so a download does not
race the loop to exit; and <code>crypto.Hash</code> extending <code>stream.Writable</code>, so the
idiomatic <code>stream.pipe(createHash(algo))</code> works and the sha512 integrity check
passes. The one thing our crypto layer cannot do is corepack's registry <strong>ECDSA
signature</strong> check (there is no <code>crypto.verify</code>), so the shell sets
<code>COREPACK_INTEGRITY_KEYS=0</code>, which is corepack's own supported escape hatch. The
tarball's sha512 integrity is still checked.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-pattern-again">The pattern, again<a href="https://vivari.run/blog/real-package-managers-in-the-browser#the-pattern-again" class="hash-link" aria-label="Direct link to The pattern, again" title="Direct link to The pattern, again" translate="no">​</a></h2>
<p>None of these four required code that knows anything about installing packages.
npm needed <code>process</code> to be a real <code>EventEmitter</code>; yarn needed <code>graceful-fs</code> to
be able to subclass <code>fs.WriteStream</code>; pnpm needed symlinks and hard links to be
real filesystem operations; corepack needed WHATWG streams to ref the loop.
Every one of those fixes lives in the runtime, not in a shim, so it is there for
the next program too, which is exactly why
<a class="" href="https://vivari.run/blog/nextjs-rsc-in-a-tab">the next thing to run</a> took less work than this did.</p>
<p>That is the whole bet behind <a class="" href="https://vivari.run/blog/nodes-real-lib-in-the-browser">running Node's real source</a>:
you do not implement the tools, you implement the platform, and then the tools
run because they were always just programs. The argument is spelled out in more
detail in <a href="https://vivari.run/docs/how-it-works" target="_blank" rel="noopener noreferrer" class="">the architecture docs</a>.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Runtime</category>
            <category>Node.js</category>
        </item>
        <item>
            <title><![CDATA[Running Node's real lib/ in a browser tab]]></title>
            <link>https://vivari.run/blog/nodes-real-lib-in-the-browser</link>
            <guid>https://vivari.run/blog/nodes-real-lib-in-the-browser</guid>
            <pubDate>Thu, 25 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[We spent months hand-writing Node's core modules, then took apart a 2.1 MB StackBlitz bundle and threw the approach away. Here is what we found and why we pivoted.]]></description>
            <content:encoded><![CDATA[<p>There are two ways to give a browser a Node-compatible runtime, and for a long
time we were confidently building the wrong one.</p>
<p><strong>Path A</strong> is the obvious one: hand-write the core modules. Implement <code>fs</code> on
top of your virtual filesystem, implement <code>path</code> as string manipulation,
implement <code>events</code> as a small emitter, and keep going. It feels productive
immediately. <code>path</code> takes an afternoon. <code>events</code> takes a morning. <code>fs</code> takes a
week and mostly works.</p>
<p>Then you reach <code>stream</code>, and progress stops.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-path-a-dies">Where Path A dies<a href="https://vivari.run/blog/nodes-real-lib-in-the-browser#where-path-a-dies" class="hash-link" aria-label="Direct link to Where Path A dies" title="Direct link to Where Path A dies" translate="no">​</a></h2>
<p><code>stream</code> is not a big module because Node's authors were verbose. It is big
because backpressure is genuinely hard, and because fifteen years of packages
have come to depend on the exact observable behaviour of that difficulty.</p>
<p>You can write something called <code>Readable</code> in a day. Making it emit <code>'readable'</code>
at the right moments, respect <code>highWaterMark</code>, handle a <code>pipe</code> target that
returns <code>false</code> from <code>write</code>, unpipe cleanly on error, support both flowing and
paused modes, implement <code>readableEnded</code> versus <code>readableFinished</code>, and behave
correctly when a subclass calls the constructor without <code>new</code>: that is not a
day. And when you get one of those wrong, you do not get a clean error. You get
a dev server that hangs at 40% of a build, in a package four levels deep in
someone's dependency tree.</p>
<p><code>http</code> is worse, because it is a protocol parser plus a connection agent plus a
stream implementation. <code>crypto</code> is worse still, because it is an ABI over
OpenSSL. <code>zlib</code> needs a compression codec.</p>
<p>We had a runtime that could run a small Express app and could not run anything
real, with a queue of modules ahead of us that each represented months of work
and would still be, at the end of all that effort, an imitation.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="taking-apart-the-competition">Taking apart the competition<a href="https://vivari.run/blog/nodes-real-lib-in-the-browser#taking-apart-the-competition" class="hash-link" aria-label="Direct link to Taking apart the competition" title="Direct link to Taking apart the competition" translate="no">​</a></h2>
<p>StackBlitz's WebContainer had clearly solved this. So we did the obvious thing
and read their bundle, a ~2.1 MB file called <code>builtins.2896b7f3.js</code>.</p>
<p>The finding reframed the entire project:</p>
<p><strong>They do not hand-write Node's core modules. They ship Node's actual <code>lib/</code>
JavaScript.</strong></p>
<p>The evidence is not subtle once you look. The bundle exports an object with
roughly 300 keys, and those keys are not a curated public API. They are Node's
internal module tree. Alongside <code>fs</code>, <code>http</code>, <code>stream</code>, <code>crypto</code>, <code>zlib</code>, <code>net</code>,
<code>tls</code> and <code>worker_threads</code> sit <code>internal/streams/readable</code>, <code>_http_agent</code>,
<code>internal/crypto/*</code>, and <code>internal/bootstrap/realm</code>. Every module is wrapped in
a function taking <code>(exports, require, module, process, internalBinding, primordials)</code>.</p>
<p><code>internalBinding</code> and <code>primordials</code> are internal-only Node machinery. You do not
end up with those identifiers by writing a compatibility layer. You end up with
them by shipping Node's source.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-seam">The seam<a href="https://vivari.run/blog/nodes-real-lib-in-the-browser#the-seam" class="hash-link" aria-label="Direct link to The seam" title="Direct link to The seam" translate="no">​</a></h2>
<p>Once you see it, the architecture of real Node becomes the architecture of the
solution.</p>
<p>Node is two layers. On top is <code>lib/*.js</code>, tens of thousands of lines of
JavaScript implementing streams, HTTP, crypto, the module loader, everything a
package actually touches. Underneath is C++, reached through exactly one door:</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> binding </span><span class="token operator">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">internalBinding</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"fs"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<p><code>lib/fs.js</code> does not know what happens inside <code>internalBinding('fs')</code>. It knows
the shape of what comes back. That is a seam, and a seam is something you can
cut along.</p>
<p>So: keep Node's JavaScript layer verbatim, and replace the C++ layer underneath
with your own implementation. When <code>lib/fs.js</code> calls <code>internalBinding('fs')</code>, it
gets an object backed by a Rust/Wasm virtual filesystem and a
<a class="" href="https://vivari.run/blog/blocking-in-a-browser">synchronous shared-memory bridge</a> instead of libuv.</p>
<p>The economics are what make this decisive. <code>internal/bootstrap/realm</code> lists the
bindings a running Node needs, and the list is short:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">buffer, cares_wrap, config, constants, contextify, fs, fs_event_wrap,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">icu, inspector, js_stream, os, pipe_wrap, process_wrap, spawn_sync,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">stream_wrap, tcp_wrap, tls_wrap, tty_wrap, udp_wrap, uv, zlib</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">(+ async_wrap, crypto, http_parser, signal_wrap, url, v8)</span><br></div></code></pre></div></div>
<p>Roughly twenty-five bindings, against hundreds of JavaScript modules. Everything
above the line is Node's own code, already correct, already battle-tested
against the entire npm ecosystem. All the remaining work is below the line.</p>
<p>That is the whole trade. Path A means writing hundreds of modules and getting
them approximately right. Path B means writing twenty-five bindings and getting
them <em>exactly</em> right, because Node's internal callers are unforgiving about
shapes. Harder in a smaller place.</p>
<p>Here is the result. Every module below is Node's own source, unmodified,
executing in this page:</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">Node's own core modules</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=real-lib" title="Node's own core modules" loading="lazy" allow="cross-origin-isolated" style="height:480px"></iframe></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-foundation-survived-the-pivot">Why the foundation survived the pivot<a href="https://vivari.run/blog/nodes-real-lib-in-the-browser#why-the-foundation-survived-the-pivot" class="hash-link" aria-label="Direct link to Why the foundation survived the pivot" title="Direct link to Why the foundation survived the pivot" translate="no">​</a></h2>
<p>The uncomfortable question when you decide to throw away months of work is how
much of the rest goes with it. In this case, almost none, and understanding why
is the most useful part of the story.</p>
<p>The realisation was that our existing <code>fs-client.js</code>, the thing user code called
to reach the virtual filesystem, was already an <code>internalBinding('fs')</code> in
everything but name. It took a syscall opcode and arguments, packed them into
shared memory, parked the thread, and returned bytes or an errno. That is
precisely the contract Node's C++ fs binding fulfils.</p>
<p>The same held everywhere. The Rust virtual filesystem is what <code>internalBinding('fs')</code>
needs to sit on. The PID table and process supervisor are what <code>process_wrap</code>
and <code>spawn_sync</code> need. The virtual network is what <code>tcp_wrap</code> needs. The
Atomics bridge is what makes any of them able to be synchronous.</p>
<p>So Path B was a pivot in the upper layers, not a rewrite. Everything below the
binding line, the part that had been genuinely hard to build, was the part
worth keeping. Path A had not been wasted either: writing the hand-rolled
builtins is how we learned what the binding contract actually needed to be.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-it-costs">What it costs<a href="https://vivari.run/blog/nodes-real-lib-in-the-browser#what-it-costs" class="hash-link" aria-label="Direct link to What it costs" title="Direct link to What it costs" translate="no">​</a></h2>
<p>Being honest about the other side of the ledger:</p>
<p><strong>The internal ABI is undocumented and unstable.</strong> You are writing against
Node's private contract. Nobody upstream owes you compatibility, and pinning to
a Node version is not optional.</p>
<p><strong>Failures are opaque.</strong> When a binding returns a subtly wrong shape, the error
surfaces somewhere in <code>internal/streams/*</code> with a stack trace full of Node
internals and no mention of your code. Debugging means reading Node's source,
which is a genuine skill investment.</p>
<p><strong>Delivery gets heavier.</strong> Node's <code>lib/</code> is a lot of JavaScript to ship into a
tab, which pushes you into lazy loading and compression decisions you would
rather not think about.</p>
<p>Against that: <code>http</code>, <code>stream</code> and <code>crypto</code> work, correctly, for real packages,
today. That trade is not close.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-real-test">The real test<a href="https://vivari.run/blog/nodes-real-lib-in-the-browser#the-real-test" class="hash-link" aria-label="Direct link to The real test" title="Direct link to The real test" translate="no">​</a></h2>
<p>Compatibility claims are cheap. The honest test of whether you have a Node
runtime is not whether <code>hello world</code> prints. It is whether software written by
people who assumed a real Node install runs unmodified.</p>
<p>The hardest such software is the package managers. npm, yarn and pnpm are large,
old, gnarly programs that touch every corner of the runtime and were absolutely
not written with charity toward reimplementations. Getting them to run is the
subject of the <a class="" href="https://vivari.run/blog/real-package-managers-in-the-browser">next post</a>, and
each one broke the runtime in a different, instructive way.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Runtime</category>
            <category>Node.js</category>
        </item>
        <item>
            <title><![CDATA[The one browser API that makes a Node runtime possible]]></title>
            <link>https://vivari.run/blog/blocking-in-a-browser</link>
            <guid>https://vivari.run/blog/blocking-in-a-browser</guid>
            <pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Node's API is synchronous and browsers refuse to block. Everything Vivari does rests on the single exception to that rule, and on a 1 MiB window.]]></description>
            <content:encoded><![CDATA[<p>Every browser-based Node runtime runs into the same wall on day one, and it is
not the filesystem, the module resolver, or the process model. It is one line of
code:</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> config </span><span class="token operator">=</span><span class="token plain"> fs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">readFileSync</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"/app/package.json"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"utf8"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<p>That call has to return bytes. Not a promise, not a callback: bytes, on the
next line. And reading those bytes means asking something else for them, which
in a browser means waiting. Browsers are built on exactly one promise to the
user: nothing blocks. So the very first thing Node requires is the one thing the
platform refuses to do.</p>
<p>There is precisely one exception, and this post is about building on top of it.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-you-cannot-just-make-it-async">Why you cannot just make it async<a href="https://vivari.run/blog/blocking-in-a-browser#why-you-cannot-just-make-it-async" class="hash-link" aria-label="Direct link to Why you cannot just make it async" title="Direct link to Why you cannot just make it async" translate="no">​</a></h2>
<p>The obvious dodge is to give up on synchronous I/O: rewrite the runtime around
<code>fs.promises</code>, tell users to <code>await</code> everything, and move on. It does not work,
for a reason that has nothing to do with taste.</p>
<p>Synchronous I/O is not a convenience in Node; it is load-bearing. <code>require()</code>
is synchronous all the way down: resolving a specifier means <code>statSync</code> on a
dozen candidate paths, then <code>readFileSync</code> on the winner, then compiling and
executing it, before the calling module's next statement runs. <code>execSync</code> blocks
a parent until its child exits. <code>zlib.gunzipSync</code>, <code>child_process.spawnSync</code>,
<code>crypto.randomBytes</code> in its sync form. The whole ecosystem sits on this.</p>
<p>You cannot make <code>require()</code> async without breaking every CommonJS package ever
published, which is most of npm. And you certainly cannot ship the <em>real</em> npm
CLI, which is what we actually wanted to do. So the synchronous surface is not
negotiable. Something has to genuinely block.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-exception-atomicswait-on-a-worker">The exception: <code>Atomics.wait</code> on a worker<a href="https://vivari.run/blog/blocking-in-a-browser#the-exception-atomicswait-on-a-worker" class="hash-link" aria-label="Direct link to the-exception-atomicswait-on-a-worker" title="Direct link to the-exception-atomicswait-on-a-worker" translate="no">​</a></h2>
<p>The main thread may not block. A <strong>Web Worker</strong> may.</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)">// Only legal off the main thread. This parks the OS thread (no spinning, no</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">// event loop, nothing runs on it) until someone notifies or it times out.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token maybe-class-name">Atomics</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">wait</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">control</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token constant" style="color:rgb(189, 147, 249)">STATE</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token constant" style="color:rgb(189, 147, 249)">REQUEST</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<p><code>Atomics.wait</code> puts the calling thread to sleep on a word of shared memory. It
is not a busy-loop and not a trick with <code>XMLHttpRequest</code>; the thread is actually
parked, and it resumes when another thread calls <code>Atomics.notify</code> on the same
word. Browsers allow it off the main thread precisely because a blocked worker
cannot freeze anyone's tab.</p>
<p>That single primitive is the whole foundation. Run user code on a worker, give
that worker a <code>SharedArrayBuffer</code>, and a synchronous call can become: write a
request into shared memory, park, let another thread do the async work, get
notified, read the response out. From inside user code, <code>readFileSync</code> returned
bytes. Nothing async leaked.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">process code (Web Worker thread)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   │  fs.readFileSync("/x")        ← looks synchronous to user code</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"> write request into the SAB, Atomics.store(STATE, REQUEST), ring a doorbell</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   │</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   ▼  Atomics.wait(STATE, REQUEST)  (the thread genuinely blocks)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"> ...another worker services it against the Rust/Wasm VFS...</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   ▲</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   └─ writes the response into the SAB, Atomics.notify(STATE)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   ▼</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"> returns bytes (still synchronous, no async leaked to user code)</span><br></div></code></pre></div></div>
<p>Here it is running for real. Nothing below is awaited, and the round-trip is
timed with <code>performance.now()</code> so you can see what a blocking syscall costs
inside a browser tab:</p>
<div class="vv-playground"><div class="vv-playground__bar"><span class="vv-playground__dot" aria-hidden="true"></span><span class="vv-playground__label">A synchronous syscall, for real</span><span style="flex:1"></span><a class="vv-playground__link" href="https://vivari.run/studio/" target="_blank" rel="noreferrer">Open in Studio ↗</a></div><iframe class="vv-playground__frame" src="/embed/?scenario=sync-fs" title="A synchronous syscall, for real" loading="lazy" allow="cross-origin-isolated" style="height:480px"></iframe></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-tax-cross-origin-isolation">The tax: cross-origin isolation<a href="https://vivari.run/blog/blocking-in-a-browser#the-tax-cross-origin-isolation" class="hash-link" aria-label="Direct link to The tax: cross-origin isolation" title="Direct link to The tax: cross-origin isolation" translate="no">​</a></h2>
<p><code>SharedArrayBuffer</code> was restricted after Spectre, and getting it back requires
the page to prove it is not sharing a browsing context group with anything it
does not trust. Concretely, every page that hosts the runtime must be served
with:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Cross-Origin-Opener-Policy:   same-origin</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Cross-Origin-Embedder-Policy: require-corp</span><br></div></code></pre></div></div>
<p>Miss either header and <code>SharedArrayBuffer</code> is simply <code>undefined</code>. There is no
degraded mode to fall back to; the runtime does not start.</p>
<p>This propagates further than it first appears. <code>require-corp</code> means every
subresource on the page must opt in to being embedded, so a third-party image
without a <code>Cross-Origin-Resource-Policy</code> header stops loading. An iframe is only
cross-origin isolated if its embedder is too, which is why the page you are
reading carries the headers as well: the live example above is an iframe, and it
would not have <code>SharedArrayBuffer</code> otherwise.</p>
<p>It is worth being blunt about this cost, because it is the part people discover
late. Adopting a browser Node runtime is not just an npm install; it is a
decision about your document's headers, and therefore about every third-party
script, font, and pixel on the page.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-layout-and-the-invariant-that-bites">The layout, and the invariant that bites<a href="https://vivari.run/blog/blocking-in-a-browser#the-layout-and-the-invariant-that-bites" class="hash-link" aria-label="Direct link to The layout, and the invariant that bites" title="Direct link to The layout, and the invariant that bites" translate="no">​</a></h2>
<p>Every client (the kernel and each process) gets one <code>SharedArrayBuffer</code>, laid
out as a small control block followed by a data region:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">[ control: 4 × Int32 = 16 bytes ][ data region: 1 MiB ]</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">control[0] = STATE    (Atomics.wait / notify on this word)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">control[1] = OPCODE   (which syscall)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">control[2] = REQ_LEN  (request bytes in the data region)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">control[3] = RES_LEN  (response bytes in the data region)</span><br></div></code></pre></div></div>
<p><code>STATE</code> moves between <code>IDLE</code>, <code>REQUEST</code>, <code>RESPONSE_OK</code> and <code>RESPONSE_ERR</code>; the
error case carries a UTF-8 errno like <code>ENOENT</code> so that Node's own error
construction upstack behaves normally. The request frame itself is
self-describing (<code>[flags:u32][fieldCount:u32]([len:u32][bytes])*</code>) with
scalars packed little-endian and everything else as raw bytes.</p>
<p>The interesting part is not the encoding. It is <code>DATA_BYTES = 1 &lt;&lt; 20</code>.</p>
<p><strong>Every request and every response must fit in one megabyte.</strong> That single
constant has caused more bugs than anything else in the system, because it is
invisible until a payload crosses it, and payloads cross it constantly:</p>
<ul>
<li class=""><strong>File I/O is chunked.</strong> Reads and writes loop at a 512 KiB chunk size, so
arbitrarily large files transfer in pieces. There is a separate <code>writeLarge</code>
path that skips the shared buffer entirely and transfers an <code>ArrayBuffer</code>
instead. That becomes necessary the moment you try to write something like
yarn's 5 MB bundled <code>cli.js</code> into the filesystem.</li>
<li class=""><strong>HTTP responses are chunked.</strong> A Vite dev server happily serves a 2.8 MB
pre-bundled dependency file. That body cannot cross in one message, so it is
split into sequential frames reassembled by request id. It also travels as a
raw length-prefixed field rather than inside JSON: escaping quotes and
newlines inflates a body unpredictably, and the failure mode is a silent
overflow rather than a clean error.</li>
<li class=""><strong>Downloads bypass the window entirely.</strong> A fetch streams its body straight
into the virtual filesystem through a dedicated worker; the caller then reads
it back with ordinary chunked file reads. This is why npm tarballs of any size
work.</li>
</ul>
<p>If there is one lesson here, it is that a fixed-size shared window is not a
detail of the transport. It is a constraint that reaches up through every layer
above it, and every subsystem eventually has to answer for how it handles a
payload larger than the window.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="who-is-actually-awake">Who is actually awake<a href="https://vivari.run/blog/blocking-in-a-browser#who-is-actually-awake" class="hash-link" aria-label="Direct link to Who is actually awake" title="Direct link to Who is actually awake" translate="no">​</a></h2>
<p>Blocking is only safe if the thread you blocked is not the one that has to
answer you. So the work is split:</p>
<ul>
<li class="">The <strong>main thread</strong> runs the UI and no runtime work at all. It never blocks,
because it never participates.</li>
<li class="">A <strong>kernel worker</strong> owns the PID table, process supervision, the virtual port
registry, and HTTP routing.</li>
<li class="">A <strong>filesystem worker</strong> owns the Rust/Wasm virtual filesystem. Every client
registers its shared buffer with it and wakes it through a <code>MessagePort</code>
doorbell.</li>
<li class="">A <strong>fetcher worker</strong> performs all real outbound network requests, so
downloading and decompressing a large tarball never stalls syscall servicing.</li>
<li class="">Each <strong>process</strong> is its own worker with its own shared buffer, and its own
event loop.</li>
</ul>
<p>Some syscalls are <em>deferred</em> rather than serviced immediately: <code>accept</code>,
<code>spawn</code> and blocking <code>fetch</code> leave the caller parked until the awaited event
actually arrives. That is not a workaround, it is the point: it is how
blocking <code>accept()</code> and <code>execSync()</code> get their semantics. A parked worker
costs nothing while it waits.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-buys">What this buys<a href="https://vivari.run/blog/blocking-in-a-browser#what-this-buys" class="hash-link" aria-label="Direct link to What this buys" title="Direct link to What this buys" translate="no">​</a></h2>
<p>None of the above is exotic on its own. <code>Atomics.wait</code> is a documented API and
<code>SharedArrayBuffer</code> has shipped for years. What is interesting is how much falls
out of taking the one available blocking primitive seriously and building the
entire system to respect it: real <code>require()</code>, real <code>execSync</code>, real child
processes, and, because the synchronous surface is honest, the ability to run
Node's own source code rather than an approximation of it.</p>
<p>That last point is the subject of the next post: <a class="" href="https://vivari.run/blog/nodes-real-lib-in-the-browser">running Node's real <code>lib/</code> in
a browser tab</a>, and why hand-writing
<code>stream</code> and <code>http</code> was never going to work.</p>
<p>Vivari is MIT-licensed and the code is on
<a href="https://github.com/maitrungduc1410/vivari" target="_blank" rel="noopener noreferrer" class="">GitHub</a>.</p>]]></content:encoded>
            <category>Teardown</category>
            <category>Runtime</category>
            <category>Browser platform</category>
        </item>
    </channel>
</rss>