@ethercorps/svelte-adapter-universal
v0.5.0
Published
SvelteKit adapter targeting Node, Bun, and Deno via H3 v2 + srvx
Maintainers
Readme
@ethercorps/svelte-adapter-universal
SvelteKit adapter that builds one output which runs natively on Node, Bun, and Deno, deploys to Cloudflare Workers and AWS Lambda, and embeds in any WinterTC/fetch-native runtime. Built on H3 v2 and srvx.
Think "adapter-node, but portable — with an escape hatch for H3 middleware."
- One build, every runtime: srvx picks
node:http/Bun.serve/Deno.serveat boot; dedicated entries cover Workers and Lambda. - Self-contained output —
h3,srvx,std-env,mrmimeare bundled; no install step insidebuild/. - adapter-node environment-variable parity, verified by a cross-runtime test suite.
- Static serving ~1.5× faster than adapter-node's sirv (methodology below).
Stability: H3 v2 is in RC. This adapter pins
[email protected]and[email protected]exactly. Expect possible API movement until H3 v2 stable; upgrade deliberately.
Quickstart
pnpm add -D @ethercorps/svelte-adapter-universal// svelte.config.js
import adapter from '@ethercorps/svelte-adapter-universal';
export default {
kit: {
adapter: adapter({
out: 'build',
precompress: true,
hooks: './src/server.hooks.js' // optional H3 escape hatch
})
}
};Build once, run anywhere:
vite build
node build/server/index.js
bun build/server/index.js
deno run --allow-net --allow-read --allow-env build/server/index.jsIn production set ORIGIN (e.g. ORIGIN=https://my.site) — see ORIGIN and proxy headers.
Build outputs
| File | Shape | Use when |
| --- | --- | --- |
| server/index.js | self-starting server | node/bun/deno run directly; reads PORT, HOST, all env vars; graceful shutdown, socket activation |
| server/app.js | H3 app instance | you want to serve() it yourself or mount it inside a bigger H3 app |
| server/handler.js | (Request) => Promise<Response> | embedding in your own Bun.serve, Deno.serve, Hono, etc. — includes fs static serving |
| server/handler-generic.js | (Request) => Promise<Response> | fetch-native runtimes with no filesystem; platform must serve client/ itself |
| server/cloudflare.js | export default { fetch } | Cloudflare Workers (see Cloudflare) |
| server/lambda.js | handler + streamHandler | AWS Lambda (see Lambda) |
The build/ directory layout:
build/
├── client/ every static asset — including prerendered pages
├── server/ the bundled server (flat chunk layout, sourcemaps)
└── package.json { "type": "module" } markerPrerendered pages are deliberately written into client/ so a single directory can be handed to any static host, CDN, or Workers assets config.
Options
| Option | Default | Description |
| --- | --- | --- |
| out | 'build' | Output directory (ignored for vercel, which writes .vercel/output) |
| precompress | fs-served: true | Write .br/.gz siblings; defaults off for CDN-served targets (they'd just be uploaded clutter) |
| envPrefix | '' | Prefix for all runtime env vars (except LISTEN_PID/LISTEN_FDS) |
| hooks | null | Path (relative to project root) to a module exporting extend(app) — bundled into the server |
| runtime | 'all' | Build target (below). Also settable via the ADAPTER_RUNTIME env var |
| staticCache | { fileLimit: 512KB, totalLimit: 64MB } | Boot-time buffer cache bounds for fs-served static files |
Per-runtime step-by-step guides (dev + prod commands, platform specifics): docs/runtimes/. H3 extension recipes (auth, logging, Cloudflare bindings in hooks, waitUntil, mounting Hono): docs/extending.md.
runtime targets
'all' (default) emits every entry with boot-time runtime detection — one build runs anywhere. A specific target emits only that platform's entries and layout: smaller output (a node build carries no Bun/Deno/Workers/Lambda code; a cloudflare build carries no fs static layer), and it's how the platform-shaped layouts (cloudflare-pages, netlify, vercel) are produced at all.
| runtime | Output | Deploy |
| --- | --- | --- |
| all | client/ + server/ (all entries) | any of the below, manually wired |
| node / bun / deno | client/ + server/ (index/handler/app, srvx pinned) | run server/index.js |
| cloudflare | client/ + server/cloudflare.js | wrangler (Workers) |
| cloudflare-pages | assets at root + _worker.js/ + _routes.json | wrangler pages deploy build |
| netlify | client/ (publish) + functions/sveltekit.mjs | netlify.toml below |
| vercel | .vercel/output (Build Output API v3) | vercel deploy --prebuilt |
| lambda | client/ + server/lambda.js | Lambda node runtime |
| generic | client/ + server/handler-generic.js | any fetch-native platform |
Environment variables
Same semantics as @sveltejs/adapter-node unless flagged. All prefixed by envPrefix except LISTEN_PID/LISTEN_FDS. With envPrefix set, unknown prefixed variables throw at boot (adapter-node parity — catches typos).
| Variable | Default | Runtime | Notes |
| --- | --- | --- | --- |
| PORT | 3000 | all servers | 0 = random port |
| HOST | 0.0.0.0 | all servers | |
| SOCKET_PATH | — | Node | unix socket instead of TCP |
| ORIGIN | — | all | validated: http/https URLs only, throws otherwise |
| PROTOCOL_HEADER | — | all | e.g. x-forwarded-proto; value must not contain : (header-injection guard) |
| HOST_HEADER | — | all | e.g. x-forwarded-host |
| PORT_HEADER | — | all | must be numeric if present |
| ADDRESS_HEADER | — | all | e.g. x-forwarded-for; throws 500 if configured but absent from a request |
| XFF_DEPTH | 1 | all | with x-forwarded-for: entries counted from the right; throws if < 1 or deeper than the list |
| BODY_SIZE_LIMIT | 512K | all | K/M/G suffix or Infinity; see body size |
| SHUTDOWN_TIMEOUT | 30 | Node/Bun/Deno | seconds of graceful drain before force-close |
| IDLE_TIMEOUT | 0 | Node | only meaningful with socket activation; exit after N idle seconds |
| LISTEN_PID, LISTEN_FDS | — | Node | systemd socket activation on fd 3 |
| KEEP_ALIVE_TIMEOUT | node default | Node | seconds; sets server.keepAliveTimeout |
| HEADERS_TIMEOUT | node default | Node | seconds; sets server.headersTimeout |
ORIGIN and proxy headers
When ORIGIN is unset, the origin is derived per request: protocol from PROTOCOL_HEADER defaulting to https, host from HOST_HEADER → Host header → the URL srvx derived. This is exact adapter-node behavior and its most common footgun:
- Plain-http local runs without
ORIGINmake SvelteKit seehttps://…URLs → form actions fail the CSRF origin check (403-ish "Cross-site POST" errors). SetORIGIN=http://localhost:3000locally. - Behind a reverse proxy, either set
ORIGINorPROTOCOL_HEADER/HOST_HEADER— never trust forwarded headers from the open internet without a proxy in front that overwrites them.
BODY_SIZE_LIMIT semantics
Enforced by wrapping the request body stream and counting bytes — the 413 is raised at read time through kit's own SvelteKitError, so kit renders a proper 413 error page and handleError runs.
Deliberate divergence from adapter-node: the limit also applies to chunked bodies with no content-length. adapter-node's stream counter compares against content-length (NaN when chunked), so chunked uploads bypass its limit; here they don't.
413 responses carry connection: close. The unread remainder of the body is never drained (see the Node stream-cancel crash), so the socket must not be reused — the header makes both sides drop it.
The H3 escape hatch
// src/server.hooks.js
export function extend(app) {
// routes — static routes beat SvelteKit's catch-all wildcard (rou3 routing)
app.get('/healthz', () => 'ok');
// middleware — runs before SvelteKit, can short-circuit
app.use(async (event, next) => {
if (!(await authorized(event.req))) {
return new Response('nope', { status: 401 });
}
return next();
});
}event.reqis the srvxServerRequest— a realRequestplus.ipand.runtime(per-runtime context: node req/res, Bun server, Deno info, CF env/ctx).- Anything H3 v2-compatible works:
app.use,app.get/post/...,app.mount('/sub', otherFetchApp), H3 plugins. - User hooks run in every serving entry: the standalone server,
app.js, and the Cloudflare worker. They do not run through the barehandler.js/handler-generic.jsexports (those are the raw SvelteKit handler; compose them yourself). - Ordering: middleware (yours) → route match (your routes win over the catch-all) → SvelteKit.
Runtime guides
Node
Full feature set: socket activation,
SOCKET_PATH,IDLE_TIMEOUT, keep-alive/headers timeouts,sveltekit:shutdownprocess event (process.on('sveltekit:shutdown', (reason) => …), reasons:SIGINT|SIGTERM|IDLE).systemd unit example:
[Socket] ListenStream=3000 [Service] ExecStart=node /app/build/server/index.js Environment=IDLE_TIMEOUT=60Known Node platform issue (the reason for two internal workarounds): cancelling a web stream that wraps a node stream (
Readable.toWeb) can throwERR_INVALID_STATE: Controller is already closedfrom inside Node internals, crashing the process. Consequences baked into this adapter:- the body-limit wrapper never cancels the srvx source stream — it stops reading and lets the HTTP layer kill the socket after the 413 (
connection: close); - don't
.cancel()request bodies in your own hooks/handlers on Node either.
- the body-limit wrapper never cancels the srvx source stream — it stops reading and lets the HTTP layer kill the socket after the 413 (
HTTP/2: srvx supports it (
node: { http2: true }) but the adapter doesn't expose the option yet. Open an issue if you need it.
Bun
- Same build, no flags:
bun build/server/index.js. srvx usesBun.servenatively. - Static files are served through
Bun.file()(zero-copysendfile, including ranged slices) when not buffer-cached. - Engine caveat — JavaScriptCore vs V8: Bun runs JSC, not V8. Subtle spec-legal differences can break dependencies that work on Node. Real example: JSC serializes
RegExp.prototype.sourcewith non-ASCII escaped (¶→¶), which silently breaks parsel-js/ultrahtml/satori-html (every:where()/:is()/:not()selector loses its argument →undefined is not an object (evaluating 'selector.type')). If something "works on Node, dies on Bun", suspect app dependencies before the adapter — reproduce with a 3-line standalone script first. Fix that case viapnpm patchon ultrahtml (make its.source.replace(...)tolerate both serializations). SHUTDOWN_TIMEOUTdrain works; socket activation /IDLE_TIMEOUT/SOCKET_PATHare Node-only.
Deno
- Needs
--allow-net --allow-read --allow-env(or-A).--allow-envis required even without custom env vars — kit's env snapshot callsDeno.env.toObject(). - Deno 2 node-compat covers everything the fs static path uses (
node:fsstreams,readdirSync); no shims required. - Deno Deploy is untested. It should work via
handler-generic.js(no fs) but has no static-asset story wired up — treat as experimental.
Cloudflare Workers
// wrangler.jsonc
{
"main": "build/server/cloudflare.js",
"compatibility_date": "2026-04-28",
"compatibility_flags": ["nodejs_compat"],
"assets": { "binding": "ASSETS", "directory": "build/client" },
"vars": { "ORIGIN": "https://your-app.example.com" }
}- workerd is V8 — Node-tested dependency behavior generally carries over (unlike Bun/JSC).
- Static assets: Workers Static Assets answers matching GETs before the worker runs; the
ASSETSbinding coversrun_worker_firstsetups and misses. Prerendered pages are insideclient/, so they're served as static files — the worker never renders them. - Never point
assets.directoryatbuild/— that would make your server bundle (and sourcemaps) publicly downloadable. Onlybuild/client. - kit's
$env/dynamic/privateis populated from the workerenvat the first request (Workers only expose env at fetch time — server init is lazy). platform.request.runtime.cloudflare→{ env, context };context.waitUntilavailable asplatform.request.waitUntil. This differs from@sveltejs/adapter-cloudflare'splatform.env— see migration.read()from$app/serverworks, backed by the ASSETS binding (assets are fetched from Workers Static Assets, not disk).BODY_SIZE_LIMITand hooks work too.- Local dev:
wrangler dev --var ORIGIN:http://localhost:8787— without it, form posts fail CSRF (https-default origin, see above). - Not covered by CI; verified manually with
wrangler dev. The officialadapter-cloudflareremains the battle-tested option — this entry exists so one adapter covers every target.
Cloudflare Pages
adapter({ runtime: 'cloudflare-pages' })Everything lands in build/: static assets at the root, the worker as a _worker.js/ directory, and a generated _routes.json that keeps /_app/* and prerendered pages off the worker entirely (served pure-static, no invocation cost). Deploy with wrangler pages deploy build. Same runtime notes as Workers above (env at first request, read() via ASSETS, set ORIGIN in the Pages project env). Don't keep a Workers-mode wrangler.jsonc next to a Pages project — wrangler pages dev will pick it up and fight the Pages layout.
Netlify
adapter({ runtime: 'netlify' })# netlify.toml
[build]
command = "vite build"
publish = "build/client"
[functions]
directory = "build/functions"
node_bundler = "esbuild"Emits a Functions v2 handler (functions/sveltekit.mjs, path: '/*' with preferStatic) — the CDN serves static assets and prerendered pages, the function gets the rest. Client IP comes from the Netlify context; platform.request.runtime.netlify.context carries the rest. read() self-fetches using ORIGIN → URL → DEPLOY_URL (Netlify sets the latter two automatically). Verified by direct function invocation; not tested on Netlify infrastructure yet.
Vercel
adapter({ runtime: 'vercel' })Writes the Build Output API v3 tree to .vercel/output (the out option is ignored — the path is fixed by Vercel): static/ for assets + prerendered pages, one render function (Node 22 runtime, response streaming enabled), and routes that serve the filesystem first with immutable cache headers on /_app/immutable/*. Deploy with vercel deploy --prebuilt or let the Vercel build run vite build. The function is fs-free; read() self-fetches via ORIGIN → VERCEL_URL. Verified through srvx's node bridge locally (same invocation path Vercel uses); not tested on Vercel infrastructure yet.
AWS Lambda
// deploy build/ as-is (node runtime); point the function at:
import { handler } from './build/server/lambda.js'; // buffered (API Gateway v1/v2, Function URLs)
import { streamHandler } from './build/server/lambda.js'; // streaming (Function URLs, RESPONSE_STREAM mode)- Static assets are read from the deployed
build/clientvianode:fs— works, but a CDN in front (CloudFront → S3 for/client/*) is the sane production setup. streamHandleris only defined inside the Lambda runtime (it needs theawslambdaglobal fromstreamifyResponse); it'sundefinedlocally.- Cold starts pay the buffer-cache scan of
client/(see below) — keep an eye on it if your asset set is huge;read()works. - Buffered handler is tested with synthetic events; streaming handler is untested against real RESPONSE_STREAM infrastructure. Timeboxed nice-to-have, treat accordingly.
Generic WinterTC / fetch-native
server/handler-generic.js default-exports a pure (Request) => Promise<Response> with zero filesystem access on its import path. Upload build/client/ to whatever serves your static files, wire the handler into the platform's fetch entry. Notes:
- static paths that reach the handler fall through to kit (404 page), not disk;
read()from$app/serverworks whenORIGINis set: assets are fetched back from your own origin (where the platform servesclient/). WithoutORIGIN,read()throws kit's "no read implementation" error;- kit env comes from
process.env/Deno.envif the platform has one, else{}— there's no per-platform env plumbing in this entry; - kit's
getRequestEvent()after anawaitneedsAsyncLocalStorage; kit degrades gracefully wherenode:async_hooksis missing (sync calls still work).
Static serving internals (and their caveats)
The deployed build is treated as immutable. At boot the adapter scans client/ once and precomputes a Map of metadata (size, mtime, weak etag, mime type, .br/.gz variants) — request-time serving is a map lookup with zero syscalls.
- Files ≤ 512 KB are cached in memory as buffers, up to a 64 MB total budget; larger files stream (
Bun.fileon Bun, node streams piped natively by srvx on Node). Both caps are currently constants — open an issue if your build exceeds them meaningfully. - Consequence of immutability: files changed in place after boot are served stale (metadata + cached bytes from boot). Blue-green / symlink-switch / container deploys are fine; hot-patching files inside a running
build/is not. Restart after changing assets. - ETags are weak,
W/"<size>-<mtime>"(sirv-compatible).if-none-match→ 304. cache-control: public,max-age=31536000,immutableonly for/_app/immutable/*; other assets get no cache-control (bring a CDN/proxy for anything fancier).- Range requests: single ranges only (
bytes=a-b,bytes=a-,bytes=-n) → 206; unsatisfiable → 416; multipart ranges are ignored (full 200). Ranges always apply to the identity encoding — precompressed variants are skipped for ranged requests. - Precompressed negotiation is a substring check on
accept-encoding(brbeforegzip), withvary: accept-encoding. - Dynamic (SSR) responses are never compressed — same stance as adapter-node; terminate compression at your proxy/CDN.
Caveats & known issues
The honest list. Items marked (inherited) match adapter-node behavior on purpose.
- H3 v2 RC / srvx pre-1.0, pinned exactly. APIs have moved between RCs before. Don't loosen the pins in a fork without rerunning the whole suite.
- https-default derived origin (inherited) — local form posts fail CSRF without
ORIGIN. Most-reported issue class. - Chunked-body limit is stricter than adapter-node — deliberate; documented above.
- Oversized request bodies are not drained. After a 413 the connection is closed rather than reading the remainder. A client mid-upload sees a reset instead of a polite full read (curl may report "connection reset by peer" after receiving the 413).
- Node stream-cancel crash — never
.cancel()a request body stream on Node (adapter avoids it internally; your hook code should too). Upstream NodeReadable.toWebissue. platformshape differs from adapter-node/adapter-cloudflare:platform.request(srvx ServerRequest) instead ofplatform.req/platform.env. Migration note below.- In-place asset mutation invisible after boot (static map + buffer cache). Restart on asset change.
read()is fetch-backed off-disk: ASSETS binding on Workers,ORIGINself-fetch inhandler-generic.js(unavailable there withoutORIGIN). One extra internal request perread()call on those entries; fs-backed and free elsewhere.- No dynamic compression (inherited); multipart ranges unsupported; kit's
instrumentation.server.jsnot wired. IDLE_TIMEOUTrequires socket activation (inherited) — it's a no-op on a normal TCP listen.- Workers/Pages & Lambda-streaming lack CI — Workers and Pages are verified with
wrangler dev/wrangler pages devlocally; Netlify/Vercel layouts are verified by direct function invocation but not yet deployed to real infrastructure. Node/Bun/Deno run the full suite in CI. - Bun/JSC dependency hazards — engine differences (e.g.
RegExp.sourceescaping) break some packages that are fine on Node/workerd/Deno (all V8). Triage app deps first; details in the Bun section. - duplicate
@sveltejs/kitinstances would break kit'sinstanceoferror handling (413s become 500s). The adapter defends by resolving@sveltejs/kit/*from your project during bundling — but exotic setups with two kit versions in one app are still on you. envPrefixboot validation throws on unexpected prefixed vars (inherited) — intentional, surprising the first time.
Migrating from adapter-node
- Swap the import;
out,precompress,envPrefixcarry over unchanged. - Env vars are identical (see table). New stricter behavior: chunked-body limits.
platform.req/platform.res→ gone. Useplatform.request(aRequestwith.ip,.runtime); raw node objects live atplatform.request.runtime.nodewhen running on Node.- Custom
server.jswrappers around adapter-node'shandler.js→ either thehooksmodule (preferred) or embedserver/handler.jsin your own server. pnpm remove @sveltejs/adapter-nodeonce green.
From adapter-cloudflare: platform.env → platform.request.runtime.cloudflare.env; platform.context → …cloudflare.context; caches is the global. Static assets config moves to assets.directory: "build/client".
Benchmarks
Same playground app, Node 22.16, Apple Silicon, autocannon 64 connections × 8 s (node tests/bench.mjs in the repo reproduces):
| adapter | SSR req/s | small static req/s | immutable asset req/s | | --- | --- | --- | --- | | this adapter | 14 819 | 52 780 | 50 744 | | adapter-node | 14 925 | 38 256 | 37 540 |
Static wins come from the boot-time file map + buffer cache. SSR is within ~1%: when the resolved origin matches the incoming URL (i.e. ORIGIN is set correctly — the normal production setup), the request is passed to kit without copying.
Development (monorepo)
pnpm install
pnpm build # builds apps/playground with this adapter
pnpm test # Node: smoke (21) + socket-activation + entry tests
pnpm test:bun # same smoke suite on Bun
pnpm test:deno # same smoke suite on Deno
pnpm bench # adapter-node comparison
pnpm package # tarball for testing in other projectsLicense
MIT
