CORE CONCEPTS
Runtime Adapters
Futon itself never opens a port. app.fetch(request) just turns a Request into a Response. @rasenganjs/runtime is the companion package that actually binds an HTTP server, for each supported platform.
@rasenganjs/futon → the request pipeline (router, middleware, ctx) @rasenganjs/runtime → binding a real server around that pipeline
Node
import { Futon } from '@rasenganjs/futon'; import { NodeDevAdapter } from '@rasenganjs/runtime/adapters/node'; const app = new Futon(); app.get('/hello', async () => new Response('Hello!')); const adapter = new NodeDevAdapter({ port: 3000 }); await adapter.serve(app);
@rasenganjs/runtime/adapters/node exports both NodeDevAdapter (file watching, auto-restart) and NodeProdAdapter, plus lower-level pieces (NodeAssets, NodeWatcher, startNodeServer, loadNodeEnvFiles) if you need finer control than the adapter classes provide.
Bun
import { Futon } from '@rasenganjs/futon'; import { BunDevAdapter } from '@rasenganjs/runtime/adapters/bun'; const app = new Futon(); // ... routes const adapter = new BunDevAdapter({ port: 3000 }); await adapter.serve(app);
Cloudflare Workers (workerd)
Workers don't have a long-running dev server process in the same sense. Deployment goes through @rasenganjs/runtime/adapters/workerd's production adapter, paired with toWinterCgHandler() for the actual fetch export.
Choosing the Right Adapter
If you're using
@rasenganjs/server, the
preset field in rasengan.server.ts ('node' | 'bun' | 'workerd') selects
the right runtime adapter automatically. This page is most relevant if you're
using Futon standalone.
RuntimeContext and ServerInfo
Every adapter populates ctx.runtime.server before your first request, so handlers can introspect how they're being served:
app.get('/debug', async (ctx) => { return json({ preset: ctx.runtime.server?.preset, // 'node' | 'bun' | 'workerd' | 'express' | 'wintercg' mode: ctx.runtime.server?.mode, // 'development' | 'production' port: ctx.runtime.server?.port, }); });
Environment Variables
Futon itself is zero-dependency and never touches the filesystem. Reading .env* files is the Node/Bun adapters' job, not Futon's. NodeDevAdapter, NodeProdAdapter, BunDevAdapter, and BunProdAdapter all call loadNodeEnvFiles/loadBunEnvFiles before .serve() returns:
import { loadNodeEnvFiles } from '@rasenganjs/runtime/adapters/node'; const vars = await loadNodeEnvFiles(rootDir, 'development'); // or 'production' app.loadEnv(vars);
This does two things:
- Assigns every loaded key into
process.env, skipping any key already set there. A real environment variable (shell, CI, a platform secret) always wins over a.env*file's value. - Feeds the same values into
app.env/ctx.runtime.env, the portable access path that works the same way across every runtime Futon supports.
app.get('/debug', async (ctx) => { return json({ fromProcessEnv: process.env.DATABASE_URL, // Node/Bun only fromRuntimeContext: ctx.runtime.env?.DATABASE_URL, // works everywhere, including workerd bindings }); });
Four filenames are checked, in this order, later files overriding earlier ones for the same key: .env → .env.local → .env.{mode} → .env.{mode}.local.
WorkerdProdAdapter never calls loadEnv. Cloudflare Workers has no
filesystem at runtime. Environment variables there come from wrangler.toml
bindings instead, passed through the platform's own env argument, a
fundamentally different mechanism from .env* files.
