@beignet/web

中文文档
0.0.65发布于 2026/11/08MITTypeScriptES Module近 30 天下载 2011

Web Fetch adapter for Beignet

原始使用文档
文档目录14 节
  1. Installation
  2. Agent skill
  3. Quick start
  4. Bun
  5. Service work
  6. Raw routes
  7. Operational HTTP handlers
  8. Private downloads
  9. Deferred outbox delivery
  10. Lower-level helpers
  11. Adapter contract
  12. Testing
  13. Typed broadcasting
  14. Reserve a connection slot

@beignet/web

Runtime: Beignet requires Node.js 22.12 or newer. Bun is optional.

[!CAUTION] Beignet is experimental alpha software. The 0.0.x package 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/core

Agent 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.

Operational HTTP handlers

@beignet/web supplies createHealthRoute, createStorageRoute, createUploadRoute, createWebhookRoute, createPaymentWebhookRoute, createOutboxDrainRoute, createScheduleRoute, createOpenAPIHandler, and createSwaggerUIHandler. These use standard Request and Response objects. Next apps use the same implementations through @beignet/next.

Mount the returned method handlers at explicit host paths. Creating a handler does not register it with server.fetch, add it to the contract registry, or include it in contract-derived OpenAPI. Unsupported methods return 405 with Allow; readiness and documentation GET handlers also support bodyless HEAD responses. Schedule and outbox triggers accept only GET and POST.

For example, mount a bounded, authenticated outbox drain on a Bun host:

import { createOutboxDrainRoute } from "@beignet/web";
import { createAppServer } from "@/server";
import { outboxRegistry } from "@/server/outbox";
import { env } from "@/lib/env";

const server = await createAppServer();
const drain = createOutboxDrainRoute({
  server,
  registry: outboxRegistry,
  secret: env.CRON_SECRET,
  batchSize: 50,
  pipeline: { path: "/api/cron/outbox/drain" },
});

Bun.serve({
  fetch(request) {
    if (new URL(request.url).pathname === "/api/cron/outbox/drain") {
      return request.method === "GET" ? drain.GET(request) : drain.POST(request);
    }
    return server.fetch(request);
  },
});

The app must bind outbox and the dispatch ports used by its registry. Keep the host's shutdown handling and call server.stop() when it stops. Schedule handlers similarly execute one registered schedule per authenticated request; neither helper starts a background loop. Missing secrets fail closed, and unauthorized requests are rejected before loading the server or creating context.

Webhook, payment-webhook, outbox, and schedule handlers use the server's rawRoute pipeline when available, preserving its hooks, metadata, context, error mapping, and instrumentation. pipeline.path identifies the mounted path to hooks. Minimal RouteServer<Ctx> test doubles may expose only createRequestContext, in which case the helpers execute directly without hooks. Health, public storage, and documentation handlers are standalone; upload authorization and context belong to the upload router.

Webhooks bound and capture the raw body before loading the server. Verification receives that captured body even if context code reads the request. Payment webhooks require the signature header before reading the body. Use a runtime supported by the installed provider SDK; Fetch handlers alone do not imply Edge compatibility.

For uploads, pass static { uploadName, action } options or host-resolved parameters to POST(request, { uploadName, action }). The Web handler does not infer parameters from a URL. Next's createUploadRoute adapts its App Router context.params to this same handler. Build request context from the current request when constructing an upload router; never reuse one user's context for later uploads.

createHealthRoute(serverOrLoader, healthConfig, environment?) returns GET with Cache-Control: no-store. createStorageRoute(storageOrLoader, options?) returns GET and HEAD and serves only public objects; private and missing objects both return 404.

Private downloads

Call createStorageDownloadResponse({ storage, key, fileName?, method? }) inside an authenticated route after its use case resolves and authorizes the file record. Import it from this package. Keep tenant and ownership checks in the use case; the helper does not authorize access or infer ownership from a key.

It returns a streamed attachment Response, or null for missing bytes so the route can throw its app catalog error. Storage failures throw. Responses use Cache-Control: private, no-store, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer. fileName defaults to the key's final segment; Unicode filenames are preserved, while empty names, path separators, and control characters are rejected. Content type, length, and modification time come from storage, with an application/octet-stream content-type fallback. Normalize original upload filenames before passing them to the helper.

method defaults to "GET"; "HEAD" sends headers and releases the unread body. The helper does not implement range requests or conditional GET. Register the route through the app's normal contract or raw-route pipeline so authentication hooks run. createStorageRoute(...) serves public objects only.

For direct object-store downloads, authorize first and call storage.temporaryUrl(key, { expiresInSeconds, fileName }). Handle ready, not_found, and unsupported explicitly; unsupported providers can use this streaming helper. See the private download guide for route composition and temporary-link semantics.

Deferred outbox delivery

createOutboxDrainTrigger({ defer, createContext, registry, ... }) is the optional host-scheduled, best-effort delivery optimization. Call it only after a successful transaction, supply the host's deferred-work primitive, and keep a durable cron or worker drain for recovery. Next adapts it through createNextOutboxDrainTrigger({ defer: after, ... }). HTTP drain and schedule error reports use beignet.outbox.http and beignet.schedule.http; deferred drains use beignet.outbox.deferred. The default schedule run source is http-schedule-route; apps can set source explicitly.

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 standard Request to Beignet's framework-neutral request shape, preserving the native request and its abort signal.
  • toWebResponse(response) converts a Beignet response to a standard Response, appending every item in an array-valued response header as a separate field. Use this for repeated fields such as Set-Cookie.
  • webFetchAdapter is the concrete HttpAdapter<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 Web Fetch edge conversion and shared operational HTTP handlers.

Router-generated 404/405 responses skip request parsing and application context creation. onRequest, beforeSend, and afterSend still run, including correlation, CORS, and security headers; response hooks receive ctx: undefined. Guard context access in custom hooks. A 405 includes Allow, and a matched route returning an application 404 runs context and authorization normally.

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:

  • onUnboundPorts defaults to "ignore", so apps with deferred provider ports boot without installing every provider. Reading an unbound port still throws on use. Production servers created with createFetchServer(...) keep the strict "error" default.
  • mapUnhandledError defaults 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.

版本与发布记录

@beignet/web 共发布 63 个版本,首次发布于 2026/06/01;过去 12 个月发布了 63 个版本。

版本发布日期距今
0.0.65latest2026/11/08今天
0.0.642026/11/034 天前
0.0.632026/11/026 天前
0.0.622026/11/017 天前
0.0.612026/11/017 天前
0.0.602026/11/017 天前

关于 @beignet/web 的常见问题

@beignet/web 是什么?

Web Fetch adapter for Beignet

如何安装 @beignet/web?

在项目目录运行 npm install @beignet/web,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@beignet/core(>=0.0.3 <1.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@beignet/web 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@beignet/web 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@beignet/web 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@beignet/web 可以商用吗?

@beignet/web 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@beignet/web 的下载量有多少?

根据 npm 官方下载统计,@beignet/web 在 2026/10/08 – 2026/11/06 这 30 天内被下载 2,011 次,日均约 67 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@beignet/web 还在维护吗?

最新版本 0.0.65 发布于 2026/11/08(今天);至今共发布 63 个版本,其中过去 12 个月发布了 63 个;首次发布于 2026/06/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。