Platforms
Platforms in the Deploy guide.
Compare adapter capabilities, configuration and build output.
Each platform has an adapter. The first table lists what each one emits; Support levels says how each one was checked.
| Target | Adapter | Emits |
|---|---|---|
| Node.js | node() from @shadow-js/dusk/node |
dist/node/server.mjs |
| Bun | bun() from @shadow-js/dusk/bun |
dist/bun/server.mjs |
| Deno | deno() from @shadow-js/dusk/deno |
dist/deno/server.mjs |
| Cloudflare Workers | cloudflare() from @shadow-js/dusk/cloudflare |
dist/cloudflare/worker.mjs, wrangler.toml |
| Vercel | vercel() from @shadow-js/dusk/vercel |
.vercel/output (Build Output API v3) |
| Netlify | netlify() from @shadow-js/dusk/netlify |
dist/netlify (netlify.toml, functions or edge functions) |
| Tarout | tarout() from @shadow-js/dusk/tarout |
dist/tarout/server.mjs |
| Static | staticSite() from @shadow-js/dusk/static |
dist/static |
Every adapter checks its configuration when it is created (a bad name, an outDir outside the project, a runtime it does
not have) and throws a TypeError that names the option. The build also checks the server bundle against the platform:
an edge runtime fails the build when the bundle imports a Node built-in it does not run ([ADAPTER_BUNDLE], with the file
and the import), and a self-contained output fails when a package is left external. Cloudflare Workers runs the built-ins
its nodejs_compat supports (crypto, async_hooks, buffer, stream, url, util, zlib, and the rest of the list
the adapter carries); it refuses node:fs (its filesystem holds only the bundle and /tmp) and the import-only stubs
(child_process, worker_threads, vm, and the like), each by name.
Adapter capabilities#
The table below is generated from the adapter factories when Dusk is built. It describes the emitted runtime
bridges; the support-level table separately records where those bridges have been exercised.
Tarout emits the Node layout, so these declarations do not establish Tarout proxy behavior.
Async context requires the runtime's process.getBuiltinModule bridge; pass the event explicitly
on the Vercel and Netlify edge targets. Workers require the adapter's emitted nodejs_compat setup.
| Adapter | WebSockets | SSE | Static-file middleware | Streaming | Async context | Early Hints | Static assets |
|---|---|---|---|---|---|---|---|
| Node.js | yes | yes | yes | yes | yes | yes | adapter |
| Bun | yes | yes | yes | yes | yes | no | adapter |
| Deno | yes | yes | yes | yes | yes | no | adapter |
| Cloudflare Workers | yes | yes | yes | yes | yes | no | platform |
| Vercel Node | no | yes | no | yes | yes | no | adapter |
| Vercel Edge | no | yes | no | yes | no | no | adapter |
| Netlify Functions | no | yes | no | yes | yes | no | adapter |
| Netlify Edge | no | yes | no | yes | no | no | adapter |
| Tarout Node layout | yes | yes | yes | yes | yes | yes | adapter |
| Static export | no | no | no | no | no | no | platform |
Image optimization and ISR are opt-in handler features rather than declared adapter capabilities.
Image optimization requires a supported image backend; the built-in optimizer loads sharp.
ISR requires a suitable store for the deployment, with shared storage when multiple instances
must share cache state. Static export has no request handler for either feature.
ISR store prerequisites. The default page cache is a bounded memory store of the process. On a single long-lived Node, Bun
or Deno server that is enough. On a platform that runs many short-lived instances (Workers isolates, Vercel and Netlify functions),
each instance has a memory store of its own, so a page is rendered once per instance and a purge reaches only the instance that
calls it: give the handler a shared store instead, cache: { store: redisStore(client) } (Redis, or an Upstash REST client, which
works from Workers) or cache: { kv } over a KVStore you adapt from the platform's key-value binding. The background refresh of a
stale page goes to the platform's waitUntil when the adapter provides one (Workers, Vercel and Netlify do), so the instance stays
alive until it ends. What a shared store guarantees across instances, and clock skew, are in
Scaling the caches.
Support levels#
Choose by the runtime and the platform limits, not only by the output format.
| Target | Output and runtime | Deployment limits |
|---|---|---|
| Node.js | Standalone Node server | Set the host allow-list and the static-file middleware policy. |
| Bun | Standalone Bun server | Native frame handling may reject a WebSocket payload before Dusk sees it. |
| Deno | Standalone Deno server | Native heartbeat and shutdown behavior apply; a real Deno deployment has not been established here. |
| Cloudflare Workers | Worker plus static assets | Deployed and checked on Cloudflare (pages, assets, WebSockets over WSS). CDN rules, bindings and placement depend on your Cloudflare project. |
| Vercel | Build Output API v3 | A production app has run through the adapter. Public files are served before function code; function duration limits apply to streams. |
| Netlify | Function or edge function plus static assets | Deployed and checked on Netlify (function target). Static-file middleware cannot gate CDN-served files. |
| Tarout | Node layout | Deployed and checked on Tarout. Its health check calls GET / on 127.0.0.1: allow that host (see the Tarout section). |
| Static hosts | Prerendered HTML and browser assets | No server functions, API routes, request middleware or file gate. |
Environment variables reach the handler in one of two ways, chosen by the adapter. Where the platform sets
process.env (Node, Bun, Deno, Vercel, Netlify functions, Tarout), createHandler validates the schema when the module
loads, as on the Node server. Where the platform passes bindings to each request (Cloudflare Workers, Netlify edge
functions), the schema is validated on the first request, and a missing variable is a 500 that names the variable and
not its value. The request's client address comes from the header the platform sets and overwrites
(cf-connecting-ip on Cloudflare, x-real-ip on Vercel, x-nf-client-connection-ip on Netlify), or from the socket on
Bun, Deno and Node. waitUntil(promise) from @shadow-js/dusk/server hands background work to the platform (Workers'
ctx.waitUntil); elsewhere the work continues and a failure is logged.
Compression is the one place the runtimes differ. Bun and Deno gzip buffered text responses (those with a
Content-Length) when the client accepts gzip. Streamed responses are never gzipped by the runtime: the gzip stream holds
every chunk until the end, which turned a streamed page's first byte from 5 ms into 1235 ms in the measurement that set
this rule. Put a proxy or the platform's CDN in front for compression of streamed pages.
Node.js#
import { node } from "@shadow-js/dusk/node";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk({ adapter: node() })] });
npx dusk build
node dist/node/server.mjs # PORT (default 3000), HOST (default 0.0.0.0)
The emitted server.mjs is the Node server of the first section, with PORT and HOST read from the environment. It
imports @shadow-js/dusk at run time, so the production install must include it (npm ci --omit=dev).
Bun#
import { bun } from "@shadow-js/dusk/bun";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk({ adapter: bun() })] });
npx dusk build
bun dist/bun/server.mjs # PORT, HOST; runs on Bun.serve
serve(handler, options) from @shadow-js/dusk/bun is the same runtime for your own entry. It serves the client folder
first (exact paths, validators, 304, no dot files, no traversal), gzips buffered text, and on SIGTERM or SIGINT
waits for requests in flight (up to gracefulShutdownMs, default 10 s) and exits 0. WebSocket routes use
Bun.serve upgrades after the shared origin, protocol and connection guards. Bun exposes control frames
to Dusk's ping/pong and rate checks. Its native frame ceiling can reject messages before Dusk sees them.
The adapter's output is checked on a real Bun process: a static file, the file gate (a header on the file, and a refused file), an API route, a page, a missing path, WebSocket echo, origin rejection, message-size limits, control frames and close code 1001 on SIGTERM. Only Bun 1.4.2 was exercised.
Deno#
import { deno } from "@shadow-js/dusk/deno";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk({ adapter: deno() })] });
npx dusk build
deno run --allow-read --allow-env --allow-net dist/deno/server.mjs
The Deno runtime uses Deno.serve and the same static, compression and shutdown rules as Bun. Deno's server has no
forced stop: a shutdown waits for requests in flight, and gracefulShutdownMs only bounds how long the process waits
before it exits. WebSocket routes use Deno.upgradeWebSocket after the shared upgrade guards.
Deno owns heartbeat framing and its native idle timeout, so wsPongTimeoutMs cannot be configured
there. Shutdown asks upgraded connections to close with code 1001 and waits for their close
handshakes until the deadline.
Cloudflare Workers#
import { cloudflare } from "@shadow-js/dusk/cloudflare";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk({ adapter: cloudflare({ name: "my-app" }) })] });
npx dusk build
npx wrangler deploy -c dist/cloudflare/wrangler.toml
The adapter writes dist/cloudflare/worker.mjs, a { fetch(request, env, ctx) } module over the server bundle, and a
wrangler.toml with nodejs_compat (so getRequestEvent() works) and an [assets] table that serves dist/client
(binding = "ASSETS"). The client folder also gets a _headers file with the immutable rule for the assets folder and
vendored fonts. env is the Worker's bindings: defineEnv validates them on the first request, and a missing variable is a
500 that names it. The client address is cf-connecting-ip.
The [assets] table uses the Wrangler 4 syntax, and Wrangler 3 refuses it. The output is checked under workerd with wrangler dev --local, which never logs in and never deploys, and the Worker is bundled with wrangler deploy --dry-run. Workers Pages (functions/ folders) is not supported by this adapter.
With runMiddlewareForStaticFiles: true in the handler options, run_worker_first is true (every request runs the Worker)
and the Worker asks the handler's gate about each file the assets binding has: a refusal is sent, and a file is sent with the
headers and cookies the middleware set. A file the binding does not have goes to the handler, and a POST is never gated.
Prerendered pages that a rule of the app applies to are listed in run_worker_first in wrangler.toml, so the Worker answers
them and the assets binding does not serve their files. The headers of the other prerendered pages are in the assets
_headers file. The adapter is marked edge, so the build refuses the Node built-ins Workers does not run, although
nodejs_compat is on: an OG route that reads its wasm with node:fs fails the build here, because the Workers filesystem
holds only /bundle and /tmp, not the node_modules the route would read from. Import the wasm as a module instead.
The adapter was also deployed to a Cloudflare account with wrangler deploy and requested over HTTPS and WSS: the page, a public file behind the file gate, an API route, a missing path, and the WebSocket echo, with its size limit and origin refusal, all answered as they do under workerd.
Vercel#
import { dusk } from "@shadow-js/dusk/vite";
import { vercel } from "@shadow-js/dusk/vercel";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk({ adapter: vercel() })] });
npx dusk build
vercel deploy --prebuilt
Vercel's project setting "Automatically expose System Environment Variables" (on by default) copies each VERCEL_* variable under the
public prefix of every framework it supports, for example VITE_VERCEL_ENV and NEXT_PUBLIC_VERCEL_URL. Those copies are public by
contract, so the build's secret scan does not count their values as leaks; a name that looks like a secret, such as
VITE_VERCEL_API_TOKEN, is still scanned.
The adapter writes .vercel/output in the Build Output API v3 format. Vercel reads headers from routes only, so the immutable
Cache-Control rule for the assets folder and fonts/google/ is a route too (step 3), and config.json has no top-level
headers key. The routes, in this order:
- For each prerendered page that a rule of the app can apply to, a route to the function. A rule applies when a redirect or
a rewrite matches the path, a header rule has a condition (a host, cookie, header or query), a route redirects it, or a
route or
_middlewaremodule runs for it. The entry's globalmiddlewareapplies to every page, and so does an entry option the build cannot read (see which pages the handler answers). The handler renders that page per request, so the host rewrites and the middleware run, and the prerendered file is not served. - For each other prerendered page with headers, a route that only adds them and continues to the file. No function runs for that page.
- For the assets folder and
fonts/google/, a route that addsCache-Control: public, max-age=31536000, immutableand continues to the file. { "handle": "filesystem" }. Then, for those folders, a missing file is a404withCache-Control: no-store, so the year-long rule never sticks to a fall-through response.- The catch-all
{ "src": "/(.*)", "dest": "/render" }.
static/ holds the client build without the Vite manifest. functions/render.func/ holds .vc-config.json, package.json
("type": "module"), index.mjs and a copy of the server bundle. The packages the server loads by name at run time (satori
for ImageResponse, @resvg/resvg-wasm for its rasterizer, sharp) are copied, with the packages they need, into the
function's own node_modules, so an OG image runs in the function. Build on Linux: sharp copies the native binary of the
machine that builds it.
A prerendered page's file carries the app's security headers: the secure defaults, security.headers, the Content-Security-
Policy without its {nonce} sources, the header rules that match its path, and HSTS over https. Its inline scripts and styles
are allowed by their SHA-256 hashes, which are fixed when the page is prerendered, so the page needs no nonce. What a file
cannot carry is per request: the nonce of a dynamic page, x-request-id, cookies and the handler's cache-control default.
The generated .vercel/output tree follows Build Output API v3. Deploy the folder with vercel deploy --prebuilt.
The adapter output was also deployed to a Vercel production project with vercel deploy --prebuilt --prod and requested: the page, a prerendered page, /_dusk/health, a missing path, the security headers and the Content-Security-Policy with its nonce. A production app is deployed on Vercel through this adapter (Git build with framework: null, npm run build, no dashboard output directory). The build and the deployment, pages and API routes, the OG PNG, redirects and the security headers on the landing page were checked on the live site; the route order, the headers, the function folder and the OG PNG were also checked from a standalone copy. Not checked live: how Vercel applies a header route with continue to a file, and that a host condition of a rule reaches the function when one deployment serves several hosts. On a first preview check that / on each host shows the right site, that the landing page carries Content-Security-Policy and X-Frame-Options, and that /opengraph-image answers a PNG. The Node.js function exports a Web-standard { fetch } object; check the first deployment's logs for that export.
Netlify#
import { netlify } from "@shadow-js/dusk/netlify";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
dusk({
adapter: netlify({
target: "functions",
redirects: [{ from: "/old", to: "/posts/1", status: 301 }],
}),
}),
],
});
npx dusk build
cd dist/netlify && npx netlify deploy --prod # netlify.toml is in this folder
The output folder has netlify.toml (the publish folder is client/), client/_headers (hashed assets, under the
build's assets folder, and vendored fonts are
immutable), client/_redirects (from the redirects option), and either functions/render.mjs (a Netlify Function, v2,
on path = "/*") or edge-functions/render.mjs with target: "edge" (a Deno Edge Function). Static files win over the
function, as on Netlify. checkNetlifyOutput checks the files; the function runs in-process. The output was run under netlify dev, and the function target was deployed to a Netlify site and requested: the page, a prerendered page, /_dusk/health, a missing path, the security headers and the refusal of a foreign host. Set APP_URL to the site's address (netlify env:set APP_URL https://<site>.netlify.app) before the deploy. A team with login protection answers every request with a 401 login page until the protection is turned off for the site. From a script, deploy with netlify deploy --prod --no-build --dir client --functions functions from dist/netlify, so the CLI asks no question.
Prerendered pages that a rule of the app applies to are rewritten to the function with a forced rewrite (200!) at the end of
_redirects, after your redirects, and the headers of the other prerendered pages are in _headers.
Tarout#
import { dusk } from "@shadow-js/dusk/vite";
import { tarout } from "@shadow-js/dusk/tarout";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk({ adapter: tarout() })] });
Tarout's documentation describes a Node app with a start script that listens on process.env.PORT; it is
deployed from the project folder, and Tarout builds it. The adapter writes the Node layout, dist/tarout/server.mjs,
which meets that contract. Set the project's start script to:
{
"scripts": {
"build": "dusk build",
"start": "node dist/tarout/server.mjs"
}
}
Deployed to a Tarout account (a Dusk app made with --adapter tarout, uploaded with tarout deploy), this is what it does, and what an
app has to allow for:
- Tarout sets
PORT(3000) and runsnpm start. The Node layout listens onPORTand, with noHOST, on0.0.0.0, which is what the platform's router needs. - The health check is a
GET /from inside the container, to127.0.0.1:<PORT>, and it wants a200. ItsHostheader is127.0.0.1:<PORT>, so an app that answers only for its public host (as the generatedsrc/entry-server.tsdoes, fromAPP_URL) fails it, and the deploy ends with "New container is unhealthy". An app made with--adapter taroutalso allows127.0.0.1; add"127.0.0.1"tosecurity.allowedHostsin an existing one. No other loopback name is needed. - Set
APP_URLto the address Tarout gives the app (tarout env <app> set APP_URL=https://<name>.tarout.app --no-secret, then deploy again); the app answers only for that host. A request for any other host is refused (403 at the platform, 400 by the handler). - The check runs
wgetorcurlinside the container. If the builder's image has neither, the deploy is reported unhealthy although the app started: build from a Dockerfile (tarout apps build <app> --type dockerfile --dockerfile Dockerfile) on an image that has them, for examplenode:22-bookworm-slimwithapt-get install -y wget curl.
The page, a prerendered page, /_dusk/health (always 200, whatever the host), a missing path (404), the security headers and the
refusal of a foreign host were checked on the deployed app. Tarout has no step that Dusk needs configured: the adapter writes no
tarout.json, and the Node layout serves dist/client itself.
Static hosts#
import { staticSite } from "@shadow-js/dusk/static";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk({ adapter: staticSite() })] });
staticSite() copies the client build to dist/static. Pages that are prerendered (see
Prerender at build time) are there as index.html files; a page that needs the server is not.
Upload the folder to any host that serves files.