npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ethercorps/svelte-adapter-universal

v0.5.0

Published

SvelteKit adapter targeting Node, Bun, and Deno via H3 v2 + srvx

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.serve at boot; dedicated entries cover Workers and Lambda.
  • Self-contained output — h3, srvx, std-env, mrmime are bundled; no install step inside build/.
  • 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.js

In 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" } marker

Prerendered 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_HEADERHost header → the URL srvx derived. This is exact adapter-node behavior and its most common footgun:

  • Plain-http local runs without ORIGIN make SvelteKit see https://… URLs → form actions fail the CSRF origin check (403-ish "Cross-site POST" errors). Set ORIGIN=http://localhost:3000 locally.
  • Behind a reverse proxy, either set ORIGIN or PROTOCOL_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.req is the srvx ServerRequest — a real Request plus .ip and .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 bare handler.js/handler-generic.js exports (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:shutdown process 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=60
  • Known Node platform issue (the reason for two internal workarounds): cancelling a web stream that wraps a node stream (Readable.toWeb) can throw ERR_INVALID_STATE: Controller is already closed from 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.
  • 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 uses Bun.serve natively.
  • Static files are served through Bun.file() (zero-copy sendfile, 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.source with 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 via pnpm patch on ultrahtml (make its .source.replace(...) tolerate both serializations).
  • SHUTDOWN_TIMEOUT drain works; socket activation / IDLE_TIMEOUT / SOCKET_PATH are Node-only.

Deno

  • Needs --allow-net --allow-read --allow-env (or -A). --allow-env is required even without custom env vars — kit's env snapshot calls Deno.env.toObject().
  • Deno 2 node-compat covers everything the fs static path uses (node:fs streams, 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 ASSETS binding covers run_worker_first setups and misses. Prerendered pages are inside client/, so they're served as static files — the worker never renders them.
  • Never point assets.directory at build/ — that would make your server bundle (and sourcemaps) publicly downloadable. Only build/client.
  • kit's $env/dynamic/private is populated from the worker env at the first request (Workers only expose env at fetch time — server init is lazy).
  • platform.request.runtime.cloudflare{ env, context }; context.waitUntil available as platform.request.waitUntil. This differs from @sveltejs/adapter-cloudflare's platform.env — see migration.
  • read() from $app/server works, backed by the ASSETS binding (assets are fetched from Workers Static Assets, not disk). BODY_SIZE_LIMIT and 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 official adapter-cloudflare remains 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 ORIGINURLDEPLOY_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 ORIGINVERCEL_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/client via node:fs — works, but a CDN in front (CloudFront → S3 for /client/*) is the sane production setup.
  • streamHandler is only defined inside the Lambda runtime (it needs the awslambda global from streamifyResponse); it's undefined locally.
  • 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/server works when ORIGIN is set: assets are fetched back from your own origin (where the platform serves client/). Without ORIGIN, read() throws kit's "no read implementation" error;
  • kit env comes from process.env / Deno.env if the platform has one, else {} — there's no per-platform env plumbing in this entry;
  • kit's getRequestEvent() after an await needs AsyncLocalStorage; kit degrades gracefully where node:async_hooks is 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.file on 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,immutable only 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 (br before gzip), with vary: 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.

  1. 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.
  2. https-default derived origin (inherited) — local form posts fail CSRF without ORIGIN. Most-reported issue class.
  3. Chunked-body limit is stricter than adapter-node — deliberate; documented above.
  4. 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).
  5. Node stream-cancel crash — never .cancel() a request body stream on Node (adapter avoids it internally; your hook code should too). Upstream Node Readable.toWeb issue.
  6. platform shape differs from adapter-node/adapter-cloudflare: platform.request (srvx ServerRequest) instead of platform.req / platform.env. Migration note below.
  7. In-place asset mutation invisible after boot (static map + buffer cache). Restart on asset change.
  8. read() is fetch-backed off-disk: ASSETS binding on Workers, ORIGIN self-fetch in handler-generic.js (unavailable there without ORIGIN). One extra internal request per read() call on those entries; fs-backed and free elsewhere.
  9. No dynamic compression (inherited); multipart ranges unsupported; kit's instrumentation.server.js not wired.
  10. IDLE_TIMEOUT requires socket activation (inherited) — it's a no-op on a normal TCP listen.
  11. Workers/Pages & Lambda-streaming lack CI — Workers and Pages are verified with wrangler dev / wrangler pages dev locally; 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.
  12. Bun/JSC dependency hazards — engine differences (e.g. RegExp.source escaping) break some packages that are fine on Node/workerd/Deno (all V8). Triage app deps first; details in the Bun section.
  13. duplicate @sveltejs/kit instances would break kit's instanceof error 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.
  14. envPrefix boot validation throws on unexpected prefixed vars (inherited) — intentional, surprising the first time.

Migrating from adapter-node

  1. Swap the import; out, precompress, envPrefix carry over unchanged.
  2. Env vars are identical (see table). New stricter behavior: chunked-body limits.
  3. platform.req/platform.res → gone. Use platform.request (a Request with .ip, .runtime); raw node objects live at platform.request.runtime.node when running on Node.
  4. Custom server.js wrappers around adapter-node's handler.js → either the hooks module (preferred) or embed server/handler.js in your own server.
  5. pnpm remove @sveltejs/adapter-node once green.

From adapter-cloudflare: platform.envplatform.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 projects

License

MIT