@beignet/web
v0.0.56
Published
Web Fetch adapter for Beignet
Maintainers
Readme
@beignet/web
Runtime: Beignet requires Node.js 22.12 or newer. Bun is optional.
[!CAUTION] Beignet is experimental alpha software. The
0.0.xpackage line is for early evaluation, and APIs may change between releases while the framework settles.
Web Fetch adapter for Beignet's framework runtime.
Use this package when your runtime accepts a standard Request and returns a
standard Response, such as Cloudflare Workers, Bun, Deno, Node fetch servers,
or tests that should avoid a framework-specific adapter.
Installation
npm install @beignet/web @beignet/coreAgent skill
This package ships the TanStack Intent skill
@beignet/web#fetch-server. Load it when composing a Web Fetch runtime,
configuring the CLI web profile, mounting Bun or serverless handlers, returning
native responses, adding raw routes, or testing through @beignet/web/testing.
Quick start
import { createFetchServer } from "@beignet/web";
import { routes } from "./server/routes";
export const server = await createFetchServer({
ports,
routes,
context: ({ ports, requestId }) => ({
requestId,
ports,
}),
mapUnhandledError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
}),
});
export default {
fetch: server.fetch,
};Bun
import { server } from "./server";
Bun.serve({
fetch: server.fetch,
});Service work
Declare context.service when queues, schedules, tasks, seeds, or other
non-HTTP work needs the same assembled app context as requests. Use
server.runServiceContext(input?, fn) in seeds and one-off scripts so the
ambient correlation frame is scoped to the callback:
await server.runServiceContext(async (ctx) => {
await runSeeds({ seeds, ctx });
});server.createServiceContext(input?) remains available to long-lived servers,
workers, and test processes that own the surrounding async lifetime.
Raw routes
server.rawRoute({ name, method, path, metadata, hooks }).handle(fn) builds a Fetch
handler for a route that cannot be a contract — third-party callbacks,
signature-verified webhooks, streaming endpoints — that still runs the whole
server pipeline: hooks, context creation, instrumentation, and framework
error mapping. Request parsing is skipped and the body stays unconsumed for
the handler; metadata feeds metadata-driven hooks such as rate limiting
exactly like contract metadata. Raw routes are not added to the route
registry — mount the returned handler at the route's own path.
For Server-Sent Events, return
createServerSentEventResponse(...) from @beignet/core/server. The helper
owns portable SSE framing, JSON encoding, heartbeats, request abort through
req.signal, subscription cleanup, a 1 MiB unread-data limit, an optional
maximum connection lifetime, and anti-buffering headers. The start(...)
callback receives a stream-scoped signal for cancellable asynchronous setup;
expected AbortError rejections caused by stream closure are treated as
cancellation. The app still owns authorization, replay, connection limits,
and reconciliation.
Lower-level helpers
import {
createFetchHandler,
toRequestLike,
toWebResponse,
webFetchAdapter,
} from "@beignet/web";createFetchHandler(server)adapts a Beignet server instance or API handler to(req: Request) => Promise<Response>.toRequestLike(req)converts a standardRequestto Beignet's framework-neutral request shape, preserving the native request and its abort signal.toWebResponse(response)converts a Beignet response to a standardResponse, appending every item in an array-valued response header as a separate field. Use this for repeated fields such asSet-Cookie.webFetchAdapteris the concreteHttpAdapter<Request, Response>implementation for Web Fetch runtimes.
Native Response instances returned by handlers are passed through unchanged.
An explicitly returned ReadableStream is also passed to the native response
unchanged, including for streamed application/json and structured +json
responses. Other plain Beignet responses are serialized as JSON when
Content-Type is absent, application/json, or a structured +json media
type. To intentionally return text or binary data, set an explicit non-JSON
Content-Type and return a Web BodyInit value:
return {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
body: "hello",
};Use a native Response when transport-specific behavior such as redirects or
multipart boundary generation needs direct Web API control.
Adapter contract
@beignet/core/server owns framework behavior: route matching, hooks, request
validation, response parsing, error mapping, response ownership, and provider
lifecycle. For framework-neutral route responses, the parsed response-schema
output becomes the body that @beignet/web serializes. @beignet/web owns only
the Web Fetch edge conversion.
The exported webFetchAdapter implements the formal core adapter contract:
import type { HttpAdapter } from "@beignet/core/server";
export const adapter: HttpAdapter<Request, Response> = webFetchAdapter;Use the same shape for a future runtime adapter with non-Fetch native request or response types.
Testing
Use @beignet/web/testing to exercise routes through the same Web Fetch
adapter without opening a network port. Use @beignet/core/testing beside it
when the route needs an app-style context and test ports.
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
import { createTestApp } from "@beignet/web/testing";
import { getTodo } from "./features/todos/contracts";
import { routes } from "./server/routes";
const fixture = createTestPorts<AppContext["ports"]>({
base: initialPorts,
overrides: { todos: createInMemoryTodoRepository() },
});
const createContext = createTestContextFactory<AppContext, AppContext["ports"]>({
ports: fixture.ports,
});
const app = await createTestApp({
ports: fixture.ports,
routes,
context: () => createContext(),
});
const todo = await app.request(getTodo, {
path: { id: "todo_1" },
});app.request(contract, args) uses the same typed call arguments as
@beignet/core/client, while app.safeRequest(contract, args) returns a typed
success/error result instead of throwing.
createTestApp(...) applies two test-friendly defaults, and an explicit
option always wins:
onUnboundPortsdefaults to"ignore", so apps with deferred provider ports boot without installing every provider. Reading an unbound port still throws on use. Production servers created withcreateFetchServer(...)keep the strict"error"default.mapUnhandledErrordefaults to a mapper that returns{ status: 500, body: { code: "INTERNAL_SERVER_ERROR", message: err.message } }, so failing tests show the real error message instead of a generic response.
Metadata-driven behavior needs both halves wired: a contract's
metadata.rateLimit or metadata.idempotency only takes effect when the
matching hook (createRateLimitHooks(...) / createIdempotencyHooks(...))
is in the test app's hooks and its port is bound. createTestApp(...)
warns at creation when registered contracts declare behavior the app cannot
enforce, so "the 429 never happened" fails loudly instead of silently
passing. createTestPorts(...) from @beignet/core/testing already binds
memory rateLimit and idempotency ports, so passing the app's hooks is
usually the only missing piece:
const app = await createTestApp({
ports: fixture.ports,
context: appContext,
trustedProxy: { clientIp: "x-forwarded-for-last" },
hooks: [
createRateLimitHooks(),
createIdempotencyHooks(),
],
routes,
});
// Declared limits now produce real 429s...
const responses = await Promise.all(
Array.from({ length: 61 }, () => app.fetch("http://beignet.test/api/search")),
);
expect(responses.at(-1)?.status).toBe(429);
// ...and repeated idempotency keys replay instead of re-running the handler.
const replay = await app.fetch("http://beignet.test/api/todos", {
method: "POST",
headers: { "idempotency-key": "todo-1", "content-type": "application/json" },
body: "{}",
});
expect(replay.headers.get("idempotency-replayed")).toBe("true");Apps that declare their context blueprint with defineServerContext(...) in
server/context.ts can pass the same value to both the runtime server and
createTestApp(...):
import { appContext } from "../server/context";
const app = await createTestApp({ ports, routes, context: appContext });The underlying app.server retains the blueprint's service-input type, so
tests can call app.server.runServiceContext(input?, fn) for non-HTTP work.
Use createTestRequester(...) when a test suite repeats the same auth,
correlation, or request-shaping headers:
import { createTestApp, createTestRequester } from "@beignet/web/testing";
const app = await createTestApp({ ports, routes, context: createContext });
const authedRequest = createTestRequester(app, {
headers: {
"x-user-id": "user_1",
"x-request-id": "req_1",
},
});
const todo = await authedRequest.request(getTodo, {
path: { id: "todo_1" },
});createFetchServer(...) and createTestApp(...) accept the same
server-level trustedProxy policy. The request context factory and server
hooks receive one resolved requestInfo; forwarding headers remain untrusted
when the option is omitted.
Typed broadcasting
createBroadcastRoute from @beignet/web exposes authorized channels over SSE using
the server's context, metadata, route hooks, and HTTP error pipeline. Raw routes
also accept hooks; fields added by those hooks are inferred in their handlers.
// server/broadcast-route.ts
import { createBroadcastRoute, type FetchServer } from "@beignet/web";
import type { AppContext } from "@/app-context";
import { channels } from "@/server/broadcasts";
export function createAppBroadcastRoute(server: FetchServer<AppContext>) {
return createBroadcastRoute({
server,
channels,
maxLifetimeMs: 60_000,
});
}The CLI web profile generates this factory. Pass the server assembled by your
app's createAppServer() to it and route /api/broadcasts to the returned
handler. For example, merge this dispatch into a Bun host's Fetch entrypoint:
import { createAppServer } from "@/server";
import { createAppBroadcastRoute } from "@/server/broadcast-route";
const server = await createAppServer();
const broadcasts = createAppBroadcastRoute(server);
Bun.serve({
idleTimeout: 60,
fetch(request) {
if (new URL(request.url).pathname === "/api/broadcasts") {
return broadcasts.GET(request);
}
return server.fetch(request);
},
});Keep your app's existing shutdown handling and call server.stop() when the
host stops. The broadcast handler returns 405 for non-GET requests.
Register every channel with an explicit authorization binding, including public
channels. Pass hooks: [auth.required()] when your app owns that hook; otherwise
assert identity in the binding. server accepts an instance or a lazy loader.
Options also include metadata, path (default /api/broadcasts), and
resolveOrigin({ ctx, request }) for authenticated initiating-client exclusion.
Mount GET at the configured path. maxLifetimeMs defaults to 60_000 and
accepts positive safe integers from 1 through 3_600_000 (one hour).
For a host permitting five-minute requests, use maxLifetimeMs: 240_000 and
leave the remaining minute for setup/cleanup. Longer streams reduce renewal
and reconciliation frequency but increase the interval between authorization
checks. Every new connection rechecks access.
The browser honors the advertised lifetime with a five-second watchdog grace
period. Its deadline covers the whole connection; later readiness messages
and heartbeats do not extend it. Heartbeat and initial-readiness timeouts stay
independent. Every readiness message must include a valid maxLifetimeMs;
missing or invalid lifetimes block subscriptions. Normal expiration sends a
connection-wide renewal control frame before closing. Planned renewals still
trigger onSync.
Global hook failures keep their HTTP status. Per-channel denials return sanitized control frames; temporary 429/5xx failures are retryable. The client must refetch after readiness/reconnection because broadcasts have no durable replay. It uses streaming Fetch, supports dynamic headers, and multiplexes up to 20 channels. Use Redis when publication jobs and stream handlers run in different processes.
For serverless hosts, allow streaming and outbound provider connections and set the host request deadline above the stream lifetime with setup/cleanup headroom. For a 60-second stream, a host request deadline of 120 seconds leaves headroom; configure this through your host's settings, subject to its limits. The Fetch adapter does not make Node-specific providers compatible with Edge runtimes. See the broadcasting guide.
Keep host and proxy idle timeouts above the 25-second heartbeat interval. The
Bun example uses idleTimeout: 60; Bun's default 10-second idle timeout is too
short for quiet broadcast streams. Disable proxy buffering for this endpoint.
Reserve a connection slot
The new optional admit({ ctx, request, signal }) hook runs once per physical
multiplexed connection, after context/authentication hooks and before the SSE
response. It may return a release function (synchronous or asynchronous), or
nothing. Throw a catalog error to reject the whole HTTP request. HTTP 429 and
5xx responses are retryable; Retry-After is honored by the browser. Admission
does not replace independent authorization for each channel.
This example uses the existing LocksPort to implement an application-owned
three-slot policy. Declare locks: LocksPort in AppPorts and wire your lock
provider in server/providers.ts. Memory locks limit one process; distributed
limits need a shared, atomic lease store. Redis is one option, not a requirement.
Add BroadcastConnectionLimit to features/shared/errors.ts with status 429,
code BROADCAST_CONNECTION_LIMIT, and message Too many broadcast connections.
Place the policy in server/broadcast-admission.ts:
import type { LocksPort } from "@beignet/core/locks";
import { appError } from "@/features/shared/errors";
export async function reserveBroadcastConnection({
locks, userId, signal,
}: { locks: LocksPort; userId: string; signal: AbortSignal }) {
for (let slot = 0; slot < 3; slot++) {
signal.throwIfAborted();
const result = await locks.acquire(`broadcast:${userId}:${slot}`, {
ttlMs: 300_000,
waitMs: 0,
});
if (result.acquired) {
// Return ownership even if cancelled while awaiting acquisition.
return async () => { await result.lease.release(); };
}
}
throw appError("BroadcastConnectionLimit", { headers: { "Retry-After": "5" } });
}Register it on the endpoint in server/broadcast-route.ts. This example
assumes the authenticated session is available in ctx.auth; requireUserId(ctx)
asserts it before acquisition:
import { createBroadcastRoute, type FetchServer } from "@beignet/web";
import type { AppContext } from "@/app-context";
import { auth } from "@/lib/route-auth";
import { requireUserId } from "@beignet/core/ports";
import { reserveBroadcastConnection } from "@/server/broadcast-admission";
import { channels } from "@/server/broadcasts";
export function createAppBroadcastRoute(server: FetchServer<AppContext>) {
return createBroadcastRoute({
server, channels, hooks: [auth.required()], maxLifetimeMs: 240_000,
admit: ({ ctx, signal }) => reserveBroadcastConnection({
locks: ctx.ports.locks, userId: requireUserId(ctx), signal,
}),
});
}Mount the returned GET at /api/broadcasts. The host owns its request
duration setting; allow five minutes for this four-minute stream.
Beignet calls each acquired release function once on expiration, request/body
cancellation, provider closure, or failed stream setup. If only some channels
fail, their resources are released while accepted channels retain the connection.
If acquisition finishes after cancellation or the ten-second admission setup
timeout, its returned resource is released immediately. Pass signal to an
acquisition API when supported; the example checks it between attempts, then
returns a successful acquisition even if cancellation happened while awaiting
it. Throwing after acquiring without returning release would leak that resource.
Cleanup failures emit broadcast.cleanup-error without skipping other releases.
No application stream event listeners are needed. Each renewal runs fresh
admission and authorization. Distributed leases still need expiry because
abrupt process termination cannot run cleanup. Keep lease TTL above the stream
lifetime plus acquisition/setup/cleanup headroom; this example pairs a
five-minute lease with a four-minute stream. The hosting request limit must also
leave that headroom. Storage, slot count, expiry, and rejection policy remain
application-owned; the framework imposes no connection-limit store.
