@levyer/vite-wasm
v0.0.2
Published
Vite plugin that compiles server entries into WebAssembly components
Maintainers
Readme
@levyer/vite-wasm
Compiles a Vite server entry into a WASI 0.3 WebAssembly component that exports
wasi:http/[email protected], using componentize-qjs
(QuickJS) with the component-model async ABI.
// vite.config.ts
import { defineConfig } from 'vite';
import wasm from '@levyer/vite-wasm';
export default defineConfig({
plugins: [wasm({ entry: 'src/server.ts' })],
});// src/server.ts — any fetch-style handler (Hono, itty-router, plain function, ...)
export default {
fetch(request: Request) {
return new Response('hello');
},
};vite devserves the entry from Vite's SSR environment in Node.vite buildbundles the entry plus the runtime layer intodist/wasm/server.js, then componentizes it intodist/server.wasm.- Run it with
wasmtime serve -Scli dist/server.wasm(wasmtime ≥ 46).
Frameworks
For frameworks with their own SSR build, point entry at the built SSR entry and let the
plugin build that environment as a web-standard bundle. Example: SolidStart v2, whose
server entry is an H3 app with fetch:
// vite.config.ts
export default defineConfig({
plugins: [
solidStart(),
wasm({ entry: 'dist/server/entry-server.js', ssrEnvironment: 'ssr', dev: false }),
],
});ssrEnvironment(build only) gives that environment worker-style export conditions, bundles all dependencies, uses rolldown'sneutralplatform, and fails the build on Node built-ins other than the shimmedasync_hooks, naming the importing module.assetsembeds static files into the component and serves them (GET/HEAD) before the app. It defaults to the client environment's output when one was built (dist/client); hashed file names getimmutablecaching.dev: falseleavesvite devto the framework.httpClient: falseleaveswasi:http/clientout of the component's world (worldlevyer:vite-wasm/service-without-client), for hosts without outgoing HTTP.fetchthen rejects with aTypeErrorfor anything butblob:URLs, importingwasi:http/clientfrom app code fails the build, and so dowitworlds or imports that bring it back.
Additional WIT interfaces
wit adds interfaces to the component's world without replacing the plugin's own:
wasm({
entry: 'src/server.ts',
wit: { path: 'wit', imports: ['wasi:keyvalue/store'] },
devImplementations: { 'wasi:keyvalue/store': './dev/keyvalue.ts' },
});import { open } from 'wasi:keyvalue/[email protected]';pathis a WIT directory:deps/(e.g. fromwkg fetch) and optionally a root package (*.wit). It is merged with the plugin's WIT intonode_modules/.vite-wasm/wit, where a generated world includeslevyer:vite-wasm/serviceplusworldsandimports. A package present in both trees is taken from yours.worldsdefaults to the root package's world when it has exactly one andimportsisn't set. Worlds with exports are rejected: the component only exportswasi:http/handler.- Imports of any interface from the merged packages stay external in the bundle (also in
a framework's
ssrEnvironmentbuild). When a framework imports its SSR output in Node after building it (SvelteKit's route analysis), those imports resolve to stubs. - In
vite dev,devImplementationsmaps interfaces (with or without version) to modules implementing them. Interfaces without one get a stub whose functions and resource constructors throw when called. - TypeScript declarations for
worldsandimports(not for the plugin's own WIT) are generated intowit.types(defaultnode_modules/.vite-wasm/types;falsedisables them) whenever the config is loaded. The plugin's own type entry references the default directory, so any TypeScript program that includesvite.config.tsknows the WIT imports without further setup. Otherwise (or with a customwit.types), reference it from an ambient declaration file:/// <reference types=".vite-wasm/types" />. The plugin owns that directory and deletes stale.d.tsfiles in it. vite devwatcheswit.path: changes regenerate the merged WIT and the declarations, and reload the stubs and the page.
node:async_hooks (AsyncLocalStorage, used e.g. by solid-js/web/storage for
getRequestEvent()) is shimmed. QuickJS cannot propagate async context, so when the shim
is bundled, the component handles one request at a time per instance, each until its body
is fully written. Apps that don't use it (e.g. Hono) stay concurrent.
Runtime layer
QuickJS has no web APIs. src/runtime provides the ECMA-429 (WinterTC Minimum Common
Web API) set on top of WASI 0.3. Per-API conformance against web-platform-tests is
tracked in conformance/WEB-APIS.md.
| API | Backed by |
| --- | --- |
| fetch, Request, Response, Headers | wasi:http/client / wasi:http/types (bodies are stream<u8>); blob: URLs |
| console.* | wasi:cli/stdout, wasi:cli/stderr |
| setTimeout / setInterval / setImmediate, queueMicrotask | wasi:clocks/monotonic-clock wait-for |
| performance (now, timeOrigin, marks/measures) | wasi:clocks/monotonic-clock, wasi:clocks/system-clock |
| crypto.getRandomValues, crypto.randomUUID | wasi:random/random |
| crypto.subtle | @noble/hashes, @noble/ciphers, @noble/curves: SHA-1/2, HMAC, AES-CTR/CBC/GCM/KW, PBKDF2, HKDF, ECDSA/ECDH (P-256/384/521), Ed25519, X25519 |
| process.env | wasi:cli/environment (read on the first request) |
| CompressionStream / DecompressionStream | fflate (gzip, deflate, deflate-raw) |
| URLPattern | urlpattern-polyfill |
| Event, EventTarget (incl. the global scope), AbortController, MessageChannel, reportError, onerror | pure JS |
| TextEncoder/TextDecoder (+ streams), Blob, File, FormData, URL.createObjectURL | pure JS |
| ReadableStream/WritableStream/TransformStream | web-streams-polyfill |
| URL, URLSearchParams | core-js |
| structuredClone, atob/btoa | QuickJS-ng built-ins |
Only what you use
The core APIs above are always bundled. The heavy optional ones — crypto.subtle
(SubtleCrypto, CryptoKey), CompressionStream/DecompressionStream, URLPattern,
MessageChannel/MessagePort — are bundled only when code in the bundle references them:
the plugin configures rolldown's inject, so a reference such as crypto.subtle.digest(...),
typeof CompressionStream or globalThis.URLPattern becomes an import of the module that
installs that API. References in code that tree-shaking removes don't count.
Indirect access is not detected (const { subtle } = crypto, globalThis['URL' + 'Pattern']).
Accessing crypto.subtle when it wasn't bundled throws an error naming the fix:
wasm({ entry: 'src/server.ts', include: ['crypto.subtle'] }); // or 'compression', 'urlpattern', 'messaging'
wasm({ entry: 'src/server.ts', globals: 'all' }); // always bundle everythingModule top-level code runs while the component is snapshotted at build time, so host calls there (timers, logs, env) are deferred until the first request.
ctx.waitUntil(promise) is supported: the response's trailers resolve only after those
promises settle, which keeps the request task alive.
Known gaps:
WebAssembly— QuickJS has no wasm engine.- RSA algorithms in
crypto.subtle; brotli compression;TextDecoderencodings other than UTF-8 and windows-1252. - Pure-JS crypto is slow on QuickJS: PBKDF2 with 100k iterations takes about 2 s.
DecompressionStreamdoesn't detect trailing junk in the same chunk as the end of the stream.unhandledrejectionevents aren't fired (QuickJS exposes no rejection tracker to JS).node:*built-ins, WebSockets.
Development
npm run wit:fetch # wkg fetch → wit/deps
npm run wit:types # jco guest-types → src/runtime/wasi (type declarations)
npm run build