garfish-wasm-esm-plugin
v0.4.0
Published
Build-time compiler and Garfish runtime backed by an OXC WebAssembly transformer.
Readme
garfish-wasm-esm-plugin
Garfish plugin for running <script type="module"> resources through a browser
WebAssembly transformer. The wasm core uses OXC to parse ESM syntax and rewrites
imports/exports into the Garfish runtime helpers.
Usage
import Garfish from 'garfish';
import { GarfishEsModule } from 'garfish-wasm-esm-plugin';
Garfish.run({
plugins: [
GarfishEsModule(),
],
});The plugin only handles scripts that Garfish already marks as module scripts.
For Vite-style sub applications, keep using an HTML entry with
<script type="module">.
Module load concurrency
// Default: at most 24 concurrent loads per Runtime.
GarfishEsModule({ limitConcurrency: true });
// Start loads without the Runtime concurrency queue delaying them.
GarfishEsModule({ limitConcurrency: false });limitConcurrency defaults to true and also works with new Runtime(...).
The limit applies to concurrent Loader.load() calls per subapp
Runtime in both batch and streaming scheduling modes, including cache reads.
It does not limit compilation or evaluation concurrency. Disabling it removes
only this Runtime limit; dependency scheduling and browser/Loader behavior
still apply.
Build-time compilation
The compiler entry can turn an emitted ESM module into a JavaScript artifact that the runtime consumes without loading the wasm transformer:
import { compileGarfishModule } from 'garfish-wasm-esm-plugin/compiler';
const artifact = await compileGarfishModule(
emittedChunk.code,
emittedChunk.fileName,
);
// Emit `artifact` into the Garfish build using the same relative file name.The artifact is a JavaScript file whose leading comment carries the static import and export metadata produced by OXC. The transformed module body still runs through the existing Garfish sandbox execution path, so no separate manifest is required.
For dual output, keep matching directory structures so relative module specifiers resolve without a runtime URL convention:
dist/esm/assets/main.js
dist/esm/assets/dependency.js
dist/garfish/assets/main.js
dist/garfish/assets/dependency.jsImport the browser runtime from the runtime-only entry and disable fallback compilation after the Garfish tree is complete:
import Garfish from 'garfish';
import { GarfishEsModule } from 'garfish-wasm-esm-plugin/runtime';
Garfish.run({
plugins: [
GarfishEsModule({
runtimeCompile: false,
}),
],
});runtimeCompile defaults to true for compatibility. Precompiled artifacts
always bypass wasm transformation. When the option is true, a plain ESM
module can still fall back to browser compilation; when it is false, loading
a plain ESM module fails with an explicit error.
Module evaluation failures are retained for the lifetime of a Runtime.
Repeated imports rethrow the original value, including its stack and cause,
and failed module instances and their static importers are invalidated.
Successful independent dependencies remain reusable. Download and compilation
failures retain their existing retry behavior; evaluation can be retried in a
new Runtime. Clearing the shared compilation cache does not reset evaluation
state, and cached Garfish apps keep the state of their existing runtime.
Vite plugin
The Vite entry keeps the original ESM output and emits a precompiled Garfish mirror during the same build:
import { defineConfig } from 'vite';
import { garfishPrecompile } from 'garfish-wasm-esm-plugin/vite';
export default defineConfig({
plugins: [
garfishPrecompile({
outDir: 'garfish',
htmlEntries: ['subapp.html'],
}),
],
});For every JavaScript chunk, the plugin emits a compiled asset under the same
relative path below garfish/. Non-HTML assets are mirrored by default so
relative asset URLs keep working. Listed HTML entries are also mirrored and
their absolute Vite asset URLs are redirected to the Garfish tree. Original
ESM chunks and HTML remain unchanged.
dist/assets/main.js
dist/subapp.html
dist/garfish/assets/main.js
dist/garfish/subapp.htmlThe generated Garfish HTML keeps its module script tags, so load it with
GarfishEsModule({ runtimeCompile: false }). Generated sourcemaps are not
supported yet because the precompiled code needs a new mapping rather than a
copy of Vite's ESM map.
Supported Resolution
This version supports both HTML import maps and Garfish externals at runtime.
HTML import maps are read from the sub application's HTML entry:
<script type="importmap">
{
"imports": {
"@scope/shared": "https://cdn.example.com/shared/index.js"
}
}
</script>Bare imports that are not provided by Garfish externals are resolved with
@jspm/import-map against the current module URL.
Garfish externals are read from Garfish.externals. The matching rule follows
import map semantics:
- keys without a trailing slash match only the exact module id;
- keys with a trailing slash match that full prefix, so
@abc/def/externalizes imports such as@abc/def/test.js; - the longest matching external prefix wins when multiple prefix keys match. Every matching subpath reads from the external module value registered under that prefix key.
import React from 'react';
import * as sharedWidgets from '@abc/def';
import Garfish from 'garfish';
import { GarfishEsModule } from 'garfish-wasm-esm-plugin';
Garfish.externals = {
react: React,
'@abc/def/': sharedWidgets,
};
Garfish.run({
plugins: [
GarfishEsModule({
garfishExternals: ['react', '@abc/def/'],
}),
],
});With the config above, import React from 'react' is exact-matched, while
import { Button } from '@abc/def/button.js' is treated as external because it
matches the @abc/def/ prefix. @abc/defx/button.js does not match that prefix.
Runtime-generated namespace modules stay live: exported getters read the current value from the backing module object instead of capturing an initial snapshot.
Wasm Size
The generated transformer artifact is
pkg/garfish_wasm_esm_plugin_bg.wasm.
| Artifact | Size | | --- | ---: | | Raw wasm | 857,617 bytes (837.5 KiB) | | Gzip | 326,597 bytes (318.9 KiB) |
The size comes from bundling OXC parser and semantic analysis into the browser runtime. The semantic pass is intentional because imported bindings need symbol aware rewriting to preserve ESM live binding behavior after the code is lowered to Garfish runtime helpers.
Why This Plugin Exists
Garfish already knows when an HTML entry contains <script type="module">, but
the module graph still needs browser-runtime compilation before it can run
inside Garfish's sandboxed execution model. A build-time transform is not enough
for dynamically loaded sub applications because the host may only see the module
source after Garfish has fetched the HTML entry and its scripts.
This package keeps that work in a Garfish plugin boundary:
- wasm runs in the browser and parses the fetched module source on demand;
- OXC AST and semantic data are used instead of regex or text-only rewriting;
- imports, exports,
import.meta, dynamicimport(), import maps, and Garfish externals are handled by the same runtime; - live bindings survive the CommonJS-like helper lowering used by the plugin.
Build
pnpm install
pnpm buildpnpm build first runs wasm-pack build --target web --out-dir pkg, then builds
the TypeScript Garfish wrapper into dist.
Test
pnpm test
pnpm test:coveragepnpm test runs the Rust unit tests, builds the wasm transformer, and runs
Vitest in Node.
pnpm test:coverage writes coverage/coverage-summary.json and
coverage/lcov.info; CI uploads that report to Codecov.
On pull requests, GitHub Actions uploads the coverage report with
codecov/codecov-action; Codecov owns the coverage PR comment and status checks.
The workflow also updates a repository PR comment with Codecov report links and
the latest benchmark table.
Benchmark
Run pnpm benchmark:browser to compare native ESM execution with this plugin's
precompiled artifacts in an existing browser. It measures import-to-ready and
instrumented module-body time with paired trials, warmups, correctness checks,
median/P75 summaries and JSON export. Network preparation and precompilation are
outside the timed interval. See the browser benchmark guide.
pnpm benchmark
pnpm benchmark:updatepnpm benchmark measures the wasm transform path against fixed ESM fixtures.
pnpm benchmark:update refreshes both benchmarks/transform.md and the table
below.
| Fixture | Source bytes | Mean | p75 | p99 | Throughput | Samples |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| small-live-bindings | 255 | 0.011 ms | 0.011 ms | 0.014 ms | 90,876 ops/sec | 90,215 |
| medium-dashboard | 1,247 | 0.064 ms | 0.063 ms | 0.078 ms | 15,850 ops/sec | 15,707 |
| large-re-export | 5,314 | 0.250 ms | 0.250 ms | 0.348 ms | 4,010 ops/sec | 3,993 |
Measured on Node v22.23.1 with BENCH_TIME_MS=1000 and BENCH_WARMUP_MS=250.
Vite Example
pnpm example:devThe example starts a Garfish host page that loads subapp.html as an HTML entry
with a <script type="module"> sub application. It imports this package through
the local source alias so changes in src/ can be exercised without publishing.
Release
This package uses Changesets. Add a changeset for user-facing package changes:
pnpm changesetGitHub Actions expects an NPM_TOKEN repository secret with permission to publish
garfish-wasm-esm-plugin to npm.
When a same-repository PR includes a releasable changeset, CI consumes that
changeset in the runner workspace and publishes a beta package with the npm
beta dist tag. The beta version format is:
<next-version>-pr-<pr-number>-<utc-YYYYMMDDHHMMSS>When the PR is merged into master, CI consumes the changeset, commits the
version/changelog metadata, publishes the formal npm package with the default
dist tag, then pushes the release commit and tags back to master.
