SvelteKit deployment adapter for @solcreek/creekd self-host. Produces a runnable HTTP server entry + a .creek-creekd/manifest.json describing the supervised process, so creekctl up --from .creek-creekd/manifest.json knows exactly what to spawn.
Pairs creekd's neutral process supervisor (cgroups, namespaces, dispatch, health probes) with SvelteKit-shaped defaults: prerendered fast-path, hashed-asset immutable caching, X-Forwarded-* aware URL rebuilding, sveltekit:shutdown graceful drain.
@sveltejs/adapter-node /bench/slow ⏤ 191 req/s · 52 ms p50
@solcreek/svelte-adapter /bench/slow ⏤ 191 req/s · 52 ms p50 (zero overhead)
@solcreek/svelte-adapter /bench/cached ⏤ 11,680 req/s · 0.67 ms p50 ← platform.cache
pnpm bench reproduces these numbers on your machine. The cached path uses event.platform.cache (L1 LRU + L2 filesystem, survives restart, tag invalidation) — see Benchmark and platform.cache.
Pre-1.0, targets SvelteKit ≥ 2.0. The adapter is feature-complete vs @sveltejs/adapter-node (same env-var surface, same sveltekit:shutdown contract, same node_modules-on-target deployment model) and adds platform.cache as its differentiator. Self-host, Bun runtime, and creekctl integration are first-class.
pnpm add -D @solcreek/svelte-adapter
// svelte.config.js
import adapter from "@solcreek/svelte-adapter";
export default {
kit: {
adapter: adapter({
runtime: "bun", // default; "node" is the safe fallback
port: 3000,
// healthCheckPath: "/_creek/health", // default
// precompress: true, // default
// env: { FEATURE_X: "1" }, // additive over NODE_ENV=production
}),
},
};
After pnpm build:
build/index.js — the runtime entry (Node or Bun)build/server/, build/client/, build/prerendered/ — SvelteKit output.creek-creekd/manifest.json — what creekd reads to spawn the processSpawn under creekd:
creekctl up --from .creek-creekd/manifest.json
| Option | Default | Notes |
|---|---|---|
runtime |
"bun" |
"bun" | "node". Recorded in the manifest so creekd uses the right executable. |
port |
3000 |
TCP port the entry binds to. Overridable at spawn via PORT. |
out |
"build" |
Output directory. |
env |
{ NODE_ENV: "production" } |
Written into the creekd manifest; KEY=VALUE strings or an object. NODE_ENV defaults to production unless overridden. |
healthCheckPath |
"/_creek/health" |
Always 200 before SvelteKit sees the request. Also recorded in the manifest as creekd's liveness probe. |
precompress |
true |
gzip + brotli on client/ and prerendered/. |
bundle |
false |
false (deps stay in node_modules on target) or "esbuild" (self-contained index.js). See Bundling. |
fallback |
unset | SPA / catch-all HTML filename (e.g. "200.html", "404.html"). See SPA / catch-all fallback. |
platform.cache — persistent KV for SvelteKit (the differentiator vs adapter-node)SvelteKit doesn't ship a first-class ISR or cache-handler primitive — there's no equivalent of Next.js's cacheHandler. This adapter exposes a small persistent cache on event.platform.cache so user code can do tag-invalidated, restart-surviving caching without bolting on Redis just to self-host.
// src/routes/+page.server.ts
export async function load({ platform }) {
return {
feed: await platform.cache.cached(
"homepage-feed:v1",
{ revalidate: 60, tags: ["feed"] },
async () => {
// expensive query — runs once per minute (or after invalidateTag)
return await db.feed.recent();
},
),
};
}
// src/routes/api/publish/+server.ts
export async function POST({ platform, request }) {
await db.posts.insert(await request.json());
await platform.cache.invalidateTag("feed");
return new Response(null, { status: 204 });
}
Implementation:
bun-sqlite on Bun (single cache.sqlite with WAL); fs on Node (one JSON per entry under $CREEK_SVELTE_CACHE_DIR/entries/<hash[0:2]>/<hash>.json, atomic via tmp+rename). auto (default) picks the right one at startup.{ invalidatedAt }; entries are stale if any of their tags was invalidated after entry.createdAt. Stored as a tags row in sqlite, as tags/<safe>.json in fs.cached() returns stale data while a background loader refreshes; coalesces concurrent misses for the same keyadapter.emulate() provides the same cache in vite dev and prerender via event.platform.cache — no if (import.meta.env.DEV) branches neededsveltekit:shutdown listeners runL2 driver microbench (1000 set + 1000 cold-L2 get, Bun 1.3, M-series macOS):
| Driver | set/s | get/s |
|---|---|---|
fs |
4,573 | 31,984 |
bun-sqlite |
28,082 | 232,518 |
~6× write throughput, ~7× read throughput. The fs driver isn't slow in absolute terms — sqlite just avoids the per-entry mkdir/tmp/rename syscall trio and WAL gives durable writes without per-write fsync. The fs driver remains the only choice on Node.
| Var | Default | Effect |
|---|---|---|
CREEK_SVELTE_CACHE_DIR |
.creek/svelte-cache (relative to cwd) |
L2 directory |
CREEK_SVELTE_CACHE_L1 |
2048 |
L1 LRU capacity (entries) |
CREEK_SVELTE_CACHE_DRIVER |
auto |
Force a specific L2 driver: fs, bun-sqlite, or auto. Pinning to fs on Bun is the migration escape hatch if a sqlite issue surfaces. |
CREEK_SVELTE_CACHE_DISABLED |
unset | When =1, skip L2 entirely (in-memory only) |
To get autocomplete on event.platform.cache, declare it in your project's src/app.d.ts:
import type { CreekdSvelteCache } from "@solcreek/svelte-adapter/runtime";
declare global {
namespace App {
interface Platform {
cache: CreekdSvelteCache;
}
}
}
export {};
The generated entry honours the same env vars as @sveltejs/adapter-node, so existing Svelte deployment knowledge transfers directly:
| Var | Default | Effect |
|---|---|---|
PORT |
from adapter({ port }) |
TCP port |
HOST |
0.0.0.0 |
bind address |
ORIGIN |
unset | overrides the request URL's origin entirely (preferred when proxy headers can't be trusted) |
PROTOCOL_HEADER |
unset | header to read for event.url.protocol (typically x-forwarded-proto) |
HOST_HEADER |
unset | header to read for event.url.host (typically x-forwarded-host) |
PORT_HEADER |
unset | header to read for port |
ADDRESS_HEADER |
unset | header to read for getClientAddress() (typically x-forwarded-for) |
XFF_DEPTH |
1 |
trusted-proxy depth when parsing comma-separated forwarded-for chains |
BODY_SIZE_LIMIT |
524288 (512 KB) |
requests with Content-Length above this are rejected with 413 |
SHUTDOWN_TIMEOUT |
30 (seconds) |
grace window after SIGTERM before forcing exit |
The entry emits the sveltekit:shutdown event on SIGINT / SIGTERM with the signal name as the reason; listeners may return promises and they are all awaited before the socket is closed. Use this to close DB pools, flush queues, etc.
// instrumentation.server.ts (or any module loaded at startup)
process.on("sveltekit:shutdown", async (reason) => {
await db.end();
});
For SPA-mode apps (everything client-rendered) or for a styled custom 404 page, pass a fallback filename. The adapter calls builder.generateFallback() to write the SvelteKit root layout as a static HTML shell into the prerendered output, and the server entry serves that file on any SSR 404:
// svelte.config.js
import adapter from "@solcreek/svelte-adapter";
export default {
kit: { adapter: adapter({ fallback: "200.html" }) },
};
Status code follows the filename: 404.html → 404, anything else (200.html, index.html, …) → 200. Prerendered hits and successful SSR responses still win — fallback only fires when SSR returns 404.
If you provide src/instrumentation.server.ts, this adapter wraps the generated entry so the instrumentation module loads before any application code — required for OpenTelemetry auto-instrumentation, logger init, DB pool warm-up, etc. No configuration needed; enable the kit feature in svelte.config.js:
// svelte.config.js
export default {
kit: {
experimental: { instrumentation: { server: true } },
adapter: adapter(),
},
};
// src/instrumentation.server.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
const sdk = new NodeSDK({ instrumentations: [getNodeAutoInstrumentations()] });
sdk.start();
Caveats inherited from kit's builder.instrument(): "live exports" do not work (none in this adapter's entry), and OTel auto-instrumentation needs top-level-await runtime support (Node 14.8+ / Bun — always satisfied here).
@sveltejs/adapter-nodeSame minimal SvelteKit fixture, same Node version, same hardware, 2000 requests at concurrency 10. /bench/slow simulates a 50 ms backend dependency; /bench/cached wraps it in platform.cache.cached.
| Adapter | Endpoint | req/s | p50 (ms) | p95 (ms) | p99 (ms) |
|---|---|---|---|---|---|
@sveltejs/adapter-node |
/bench/slow |
191 | 52.3 | 53.8 | 55.4 |
@solcreek/svelte-adapter |
/bench/slow |
191 | 52.2 | 53.3 | 53.9 |
@solcreek/svelte-adapter |
/bench/cached |
11,680 | 0.67 | 2.1 | 2.8 |
Two takeaways:
adapter-node on the uncached path — identical throughput and latency, so adopting this adapter doesn't make anything slower.platform.cache.cached(...) call.Run the benchmark yourself: pnpm bench (uses the fixture at test/fixtures/real-sveltekit/, takes ~30 s).
Default mode (bundle: false) ships build/ + node_modules to the target, exactly like @sveltejs/adapter-node:
git pull
pnpm install --prod
pnpm build
creekctl up --from .creek-creekd/manifest.json
Opt in with adapter({ bundle: "esbuild" }). The adapter inlines @sveltejs/kit/node + polyfills + cache handler into a single build/index.js so the target needs no node_modules:
// svelte.config.js
import adapter from "@solcreek/svelte-adapter";
export default {
kit: { adapter: adapter({ bundle: "esbuild" }) },
};
bundle: false |
bundle: "esbuild" |
|
|---|---|---|
build/index.js |
12 KB | 108 KB |
build/ total |
748 KB | 832 KB |
node_modules on target |
required (~30 MB) | not needed |
| Effective deploy artifact | ~30 MB | ~832 KB |
pnpm install on target |
needed | skipped |
Numbers from test/fixtures/real-sveltekit/. Inline source maps are included so production stack traces remain readable.
What stays external:
./server/* — kit's Server dynamically imports route modules relative to its own location, so the server bundle must stay on disk under build/server/. Inlining it would break route loading at runtime.bun:sqlite — Bun built-in, resolved at runtime (no npm package).node:* — Node built-ins.What's not handled automatically: native modules outside the kit server bundle (better-sqlite3, sharp, @node-rs/*, prebuilt .node binaries). If your app needs these, stick with bundle: false for now — extension of the externals list is a later iteration.
@sveltejs/adapter-node@sveltejs/adapter-node |
@solcreek/svelte-adapter |
|
|---|---|---|
| Env-var surface (PORT, HOST, ORIGIN, PROTOCOL_HEADER, BODY_SIZE_LIMIT, …) | ✓ | ✓ identical |
sveltekit:shutdown event contract |
✓ | ✓ identical |
node_modules on target |
required | required by default, skippable with bundle: "esbuild" |
| Bun runtime support | — | ✓ first-class (runtime: "bun") |
| Polka HTTP server | yes | — direct node:http / Bun.serve, no extra dep |
Creekd process manifest (.creek-creekd/manifest.json) |
— | ✓ creekctl up --from reads it |
| Configurable health probe path baked into the entry | — | ✓ (default /_creek/health) |
Persistent KV on event.platform (platform.cache) |
— | ✓ L1 LRU + L2 fs, tag invalidation, SWR |
Emulator.platform() for dev parity |
— | ✓ same cache in vite dev / prerender |
On the apples-to-apples path (no caching), throughput and latency are identical — adopting this adapter doesn't make anything slower. See Benchmark for the numbers and methodology.
Apache-2.0