@navitech/adapter-bun
v0.2.0
Published
SvelteKit adapter for Bun: one self-contained build/index.js, a Redis ISR cache for pages and remote queries, optional trace spans.
Readme
@navitech/adapter-bun
SvelteKit adapter for Bun. vite build produces one self-contained build/index.js
(run it with bun build/index.js), with:
- static assets and prerendered pages served by the same process, precompressed;
- a Redis ISR cache for pages and for remote queries, stale-while-revalidate, keyed by build so a deploy never serves the previous build's HTML;
- optional gates: canonical host, https, Origin-less POST endpoints;
- a trace span per cache hit when the app uses
@navitech/otel— and nothing when it does not.
It replaces the adapter/ directory that was copied from app to app (the ISR adapter,
svelte-isr-adapter); it grew out of Mazodor's copy, the most evolved one.
Use
bun add -d @navitech/adapter-bun// svelte.config.js
import adapter from '@navitech/adapter-bun';
export default {
kit: { adapter: adapter() }
};CMD ["bun", "build/index.js"]The build bundles the adapter's runtime, so the production image needs nothing from this
package. The app's own dependencies stay external, as before: install them in the
runtime stage.
Options
| option | default | |
| --- | --- | --- |
| out | build | output directory |
| precompress | true | brotli/gzip assets at build time, HTML 200s at runtime |
| envPrefix | '' | prefix for the variables below |
| serveAssets | true | serve client/ and prerendered/ from this process |
| isr | true | the Redis cache; off anyway without REDIS_URL |
| cache | src/cache.server.ts | the cache policy module, if it exists |
| canonical | false | 308 every host but ORIGIN's to ORIGIN |
| forceHttps | false | 308 x-forwarded-proto: http to https |
| sessionCookies | auth-session, auth_session, app_session | a request carrying one never touches the cache |
| trustedPostPaths | [] | POST paths given ORIGIN as their Origin |
| tracing | true | a span per cache hit, if the app has @opentelemetry/api |
| bundleDependencies | false | bundle the app's dependencies too; native ones stay external, listed in build/package.json |
| external | [] | with bundleDependencies: packages to keep external anyway |
Runtime variables: PORT (3000), HOST (0.0.0.0), SOCKET_PATH, ORIGIN,
ADDRESS_HEADER (x-real-ip), XFF_DEPTH (1), BODY_SIZE_LIMIT (10M), IDLE_TIMEOUT (10),
REDIS_URL or VALKEY_URL. NODE_ENV=debug logs every cache decision.
Set ORIGIN in production. The adapter rebuilds each request's URL from it; without
it, absolute URLs (mails, canonical tags, payment return URLs) come from whatever host the
proxy passed through.
Bundled dependencies
With bundleDependencies: true, build/ no longer needs the project's node_modules:
every dependency is bundled, except packages with native code (a .node addon, a
binding.gyp, prebuilt binaries per platform like sharp, libsql, @node-rs/argon2).
Those stay imports, and build/package.json lists them at the versions that were
installed. On the platform that will run it:
cd build && bun install --productionFor NavitechWebsite that is 3 native packages instead of 261 MB of node_modules. A
package that reads its own files at runtime (next to its __dirname) cannot be bundled
either; the detection does not see that, name it in external.
The cache
A page is cached when its render emits x-isr-expiration: <seconds> (the TTL) and the
visitor is anonymous. What else is required depends on src/cache.server.ts:
import { defineCache } from '@navitech/adapter-bun/cache';
const LANG = '(?:/(?:en|ro))?';
export default defineCache({
pages: [
{ match: '/sitemap.xml', expiration: 3600 },
{
match: new RegExp(`^${LANG}/products/?$`),
route: '/[[lang=lang]]/products', // names the trace span of a hit
expiration: 300,
bypass: ['q'], // ?q= disables the cache for that request
query: { page: /^\d{1,3}$/ } // the only param in the key; ?page=99999 renders fresh
}
],
remote: {
getProducts: { expiration: 300, bypass: (field) => Boolean(field('search')) }
}
});- With
pages(recommended), that list is the one gate: a pathname no rule matches is never read from Redis nor written to it. Undeclared query params (utm_*,fbclid) are dropped from the key. - Without
pages(no module, or nopageskey): header mode, the old adapter's behaviour. Every anonymous GET is looked up, keyed by pathname and full query string. remotecaches remote query GETs (/_app/remote/<hash>/<name>) by export name. Only results: errors,nulland redirects are never stored. Only for queries whose result is the same for every anonymous visitor.
The module is bundled on its own, outside Vite: no $lib, no $env.
Keys are isr:<build id>:<pathname>[?query] and isr:<build id>:remote:<hash>/<name>:<payload>,
so invalidation can match isr:*:/products*. Entries are refreshed in the background
before they expire. A Redis outage degrades to "no cache": every call has a deadline, and
after three failures in a row the cache is skipped for 10 s.
Tracing
With or without, by what the app installs:
- Without: nothing to do.
@opentelemetry/apiis not a dependency of this package; without it in the app,tracingdoes nothing. - With (Tracely): install
@navitech/oteland@opentelemetry/api, addsrc/instrumentation.server.tswithimport '@navitech/otel/register';, and turn onkit.experimental.tracing.serverandkit.experimental.instrumentation.server. The adapter makes Kit's facade load the instrumentation before the server (Tracely/docs/integrating.md).
A cache hit never reaches Kit, so the adapter gives it an entry span of its own, named
after the rule's route (GET /[[lang=lang]]/products, GET remote:getProducts) —
the same operation as a render, told apart by isr.cache (hit / stale). Only hits:
on Bun the collector already makes Kit's root span a render's entry. tracing: false
turns these off and leaves Kit's own spans alone.
Moving an app from the copied adapter/
bun add -d @navitech/adapter-bun; deleteadapter/; droprolldown,totalistandmrmimeif nothing else uses them.svelte.config.js:import adapter from '@navitech/adapter-bun'. Keepprecompressandisr; Mazodor-style copies addcanonical: true, forceHttps: trueand theirtrustedPostPaths.registerIsrConfig(...)/registerRemoteCacheConfig(...)calls fromadapter/files/index.tsbecomepages/remoteinsrc/cache.server.ts. None: skip.- The Dockerfile's
CMD ["bun", "build/index.js"]stays;build/index.jsis now real JS.
Develop
bun install
bun run check # tsc over src/ (Node, build time) and runtime/ (Bun)
bun run test # unit tests, then builds test/fixture three ways and runs itThe end-to-end tests need redis-server on PATH for the cache cases (skipped otherwise).
Layout: src/index.ts is the adapter (runs in Vite at build time); runtime/ is the server,
shipped as TypeScript and bundled into each app's build with Kit's output — the
adapter:* imports are resolved there. runtime/isr.ts holds the cache's decisions as
pure functions.
Publishing
Bump version, then npm publish (prepublishOnly runs check and tests). Public, like
@navitech/otel.
