@beignet/core
Version:
Core framework primitives for Beignet
937 lines (892 loc) • 30.6 kB
text/typescript
import type { StandardSchemaV1 } from "@standard-schema/spec";
import {
type HttpContractConfig,
methodSupportsRequestBody,
} from "../contracts/index.js";
import {
assertValidContractLifecycle,
getContractOperationId,
} from "../contracts/lifecycle.js";
import {
comparePathParamsToTemplate,
formatPathParamsMismatch,
getObjectSchemaShape,
} from "../contracts/schema-shape.js";
import { createErrorResponseBody } from "../errors/index.js";
import { runWithMemoScope } from "../memo/index.js";
import type { AnyPorts } from "../ports/index.js";
import { isUnboundPort } from "../ports/index.js";
import type {
InferProviderPorts,
ProviderSetupResult,
ServiceProvider,
} from "../providers/index.js";
import type {
ServerContextConfig,
ServiceContextInputArgs,
} from "./context.js";
import { createContextFinalizer, resolveServerContext } from "./context.js";
import type { ContractLike, ResolveContract } from "./contract-like.js";
import { resolveContract } from "./contract-like.js";
import type {
Handler,
HttpRequestLike,
HttpResponse,
ResolvedRoute,
RouteHook,
ServerCaughtErrorHook,
ServerHook,
ServerUnhandledErrorMapper,
} from "./http.js";
import type { ServerInstrumentationOptions } from "./instrumentation.js";
import { createServerInstrumentation } from "./instrumentation.js";
import { loadProviderConfig } from "./providers/index.js";
import type { ActiveRequestContext } from "./request-context.js";
import {
enterActiveRequestContext,
readContextActor,
readContextTenant,
runWithActiveRequestContext,
} from "./request-context.js";
import {
buildHandler,
createMemoScopeRecorder,
createRequestExecutor,
} from "./request-executor.js";
import type { RequestBodyOptions } from "./request-preparation.js";
import { errorResponse } from "./response-finalization.js";
import {
contractsFromRoutes,
createRoutes,
defineRoutes,
type HandlerRouteDef,
type RouteDef,
type RouteDefinitionBuilder,
type RouteGroup,
type RouteGroupBuilder,
type Routes,
} from "./route-definitions.js";
import {
type CompiledPath,
compareRouteSpecificity,
compilePath,
} from "./route-matching.js";
import type { RuntimeIntegrityCheck } from "./runtime-integrity.js";
import { runRuntimeIntegrityCheck } from "./runtime-integrity.js";
import {
resolveTrustedRequest,
type TrustedProxyConfig,
type TrustedRequestInfo,
} from "./trusted-proxy.js";
import { assertValidTrustedProxyConfig } from "./trusted-proxy-internal.js";
import {
createUseCaseRouteHandler,
isUseCaseRouteDef,
} from "./use-case-route.js";
export type {
HandlerRouteDef,
RequestBodyOptions,
RouteDef,
RouteDefinitionBuilder,
RouteGroup,
RouteGroupBuilder,
Routes,
};
export { contractsFromRoutes, createRoutes, defineRoutes };
/**
* Loosely typed provider list element.
*
* Required-port, app-context, and service-input generics are erased here so
* providers created with the typed `createProvider<Requires, Context,
* ServiceInput>()` form stay assignable to server provider lists.
*/
type AnyServiceProvider = ServiceProvider<
unknown,
// biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
StandardSchemaV1<any, any>,
AnyPorts,
// biome-ignore lint/suspicious/noExplicitAny: provider context types are erased at this level
any,
// biome-ignore lint/suspicious/noExplicitAny: provider service-input types are erased at this level
any
>;
function copyTrustedRequestInfo(
requestInfo: TrustedRequestInfo,
): TrustedRequestInfo {
return {
...requestInfo,
url: new URL(requestInfo.url),
};
}
/**
* Options for creating a Beignet server instance.
*/
export type CreateServerOptions<
Ctx,
Ports extends AnyPorts,
ServiceInput = void,
// biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
Routes extends readonly RouteDef<any, any>[] = readonly RouteDef<any, any>[],
Providers extends readonly AnyServiceProvider[] = readonly [],
> = {
/**
* App-owned ports available to context creation, hooks, and handlers.
*/
ports: Ports;
/**
* Providers installed during server startup.
*
* Provider ports are merged into `ports` before request handling and are
* stopped in reverse setup order when `server.stop()` runs.
*/
providers?: Providers;
/**
* Runtime env used by providers. Defaults to `process.env`.
*/
providerEnv?: Record<string, string | undefined>;
/**
* Provider config overrides keyed by provider name.
*/
providerConfig?: Record<string, unknown>;
/**
* Context blueprint for request and service contexts.
*
* Gate-less contexts may pass a plain request factory. Contexts with a
* `gate` property must use the blueprint form
* `{ gate: (ports) => ports.gate, request, service }` so the server owns
* gate attachment and identity changes can never go stale.
*
* The `ports` argument includes app ports plus ports provided during server
* startup.
*/
context: ServerContextConfig<
Ctx,
Ports & InferProviderPorts<Providers>,
ServiceInput
>;
/**
* Explicit policy for trusting proxy- or edge-provided request metadata.
*
* Forwarding headers are ignored by default. Configure this only when every
* request reaches the app through a trusted platform or reverse proxy that
* strips or normalizes those headers.
*/
trustedProxy?: TrustedProxyConfig;
/**
* Server hooks that wrap every registered route.
*/
hooks?: ServerHook<Ctx, Ports & InferProviderPorts<Providers>>[];
/**
* Optional pure startup check for app workflow registrations.
*
* Runtime integrity compares app-declared workflow artifacts against the
* registries passed to runtime entrypoints. It performs no filesystem,
* provider, database, network, worker, or background-loop work, so it is safe
* to run during serverless cold starts.
*/
integrity?: RuntimeIntegrityCheck;
/**
* Server-owned request instrumentation.
*
* The server resolves a request ID and W3C trace context for every request
* before user hooks and context creation, writes `x-request-id` and
* `traceparent` response headers, and records request and error events into
* the resolved provider instrumentation port (`ports.instrumentation`, then
* `ports.devtools`) when one is installed.
*
* Pass `false` to disable headers and event recording. Context factories
* still receive `requestId` and `trace` arguments.
*/
instrumentation?: ServerInstrumentationOptions<Ctx> | false;
/**
* Whether route-owned responses are validated against the contract's
* declared statuses and response schemas before they are sent.
*
* Disable this to trade response guarantees for throughput, mirroring the
* client-side `validateResponses` option.
*
* @default true
*/
validateResponses?: boolean;
/**
* Request body parsing limits for JSON/text contract routes.
*/
requestBody?: RequestBodyOptions;
/**
* Route list to register up front.
*/
routes?: Routes;
/**
* How to handle ports that are still unbound after all providers have
* started.
*
* Ports declared as `deferred` in `definePorts(...)` boot as throwing
* placeholders until a provider contributes them. The default `"error"`
* fails startup and lists the unbound port keys. Apps that bind every port
* directly are unaffected.
*
* @default "error"
*/
onUnboundPorts?: "error" | "warn" | "ignore";
/**
* Global caught-error observer.
*/
onCaughtError?: ServerCaughtErrorHook<Ctx>;
/**
* Global mapper for unexpected errors not handled by app error catalogs.
*/
mapUnhandledError?: ServerUnhandledErrorMapper<Ctx>;
};
interface RouteBuilder<Ctx, C extends HttpContractConfig> {
handle: (
fn: Handler<Ctx, C>,
) => (req: HttpRequestLike) => Promise<HttpResponse>;
}
/**
* Identity and hook metadata for a raw route.
*
* Raw routes are mounted by the adapter at their own path, so `method` and
* `path` describe the route to hooks, instrumentation, and error mapping
* instead of driving routing. `metadata` feeds metadata-driven hooks exactly
* like contract metadata does, for example `rateLimit` and `idempotency`.
*/
export type RawRouteInit = {
name: string;
method: HttpContractConfig["method"];
path: string;
metadata?: HttpContractConfig["metadata"];
};
/**
* Builder returned by `server.rawRoute(...)`.
*/
export interface RawRouteBuilder<Ctx> {
handle: (
fn: Handler<Ctx, HttpContractConfig>,
) => (req: HttpRequestLike) => Promise<HttpResponse>;
}
/**
* Runtime server object returned by `createServer(...)`.
*/
export interface ServerInstance<
Ctx,
Ports extends AnyPorts = AnyPorts,
ServiceInput = void,
> {
/**
* Catch-all request handler for platform adapters.
*/
api: (req: HttpRequestLike) => Promise<HttpResponse>;
/**
* Register and build a single route handler imperatively.
*/
route: <CLike extends ContractLike>(
contractLike: CLike,
) => RouteBuilder<Ctx, ResolveContract<CLike>>;
/**
* Build a handler for a route that cannot be a contract — webhooks,
* third-party auth callbacks, streaming endpoints — that still runs the
* whole server pipeline: correlation, `onRequest`, route hooks,
* `beforeHandle`, `beforeSend`, `afterSend`, context creation,
* instrumentation, and framework error mapping.
*
* Request parsing and validation are skipped and the request body is left
* unconsumed, so the handler owns body reading — for example webhook
* signature verification over the exact raw bytes. Metadata-driven hooks
* such as rate limiting and idempotency read `init.metadata`. Raw routes
* are not added to the route registry; mount the returned handler at the
* route's own path.
*/
rawRoute: (init: RawRouteInit) => RawRouteBuilder<Ctx>;
/**
* Build a fully assembled request context from a framework-neutral request.
*
* Use this for adapter entry points outside the route pipeline, such as
* server components or upload routes.
*/
createRequestContext: (req: HttpRequestLike) => Promise<Ctx>;
/**
* Build a fully assembled service context for schedules, outbox drains,
* tasks, and background work.
*
* Requires `context.service` to be declared in `createServer(...)`.
*
* Only call this from long-lived runtimes such as servers, workers, and
* `bun test`. It enters the ambient correlation context with
* `AsyncLocalStorage.enterWith`, and resuming that frame across top-level
* await crashes Bun 1.3.x in plain scripts. Seeds and one-off scripts must
* use `runServiceContext(...)` instead.
*/
createServiceContext: (
...args: ServiceContextInputArgs<ServiceInput>
) => Promise<Ctx>;
/**
* Build a service context and run `fn` inside a scoped ambient correlation
* frame, returning its result.
*
* This is the script-safe counterpart to `createServiceContext(...)`: the
* ambient frame is entered with `AsyncLocalStorage.run`, so plain scripts
* such as seeds and one-off maintenance work stay safe under top-level
* await. Requires `context.service` to be declared in `createServer(...)`.
*/
runServiceContext: <T>(
...args: [
...ServiceContextInputArgs<ServiceInput>,
fn: (ctx: Ctx) => T | Promise<T>,
]
) => Promise<T>;
/**
* Contract configs registered through the `routes` option.
*/
contracts: readonly HttpContractConfig[];
/**
* Stop installed providers in reverse setup order.
*/
stop: () => Promise<void>;
/**
* Final app ports after provider setup.
*/
ports: Ports;
}
/**
* Create a Beignet server instance.
*
* The server owns route registration, provider setup/startup, request
* validation, hook execution, response validation, and framework error mapping.
* Use adapter packages such as `@beignet/next` to expose `server.api` to a
* specific runtime.
*
* @param options - Ports, providers, routes, hooks, context blueprint, and
* error mapping hooks for the server.
* @returns A started server instance with final ports and a catch-all handler.
*/
export async function createServer<
Ctx,
Ports extends AnyPorts,
ServiceInput = void,
// biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
Routes extends readonly RouteDef<any, any>[] = readonly RouteDef<any, any>[],
Providers extends readonly AnyServiceProvider[] = readonly [],
>(
options: CreateServerOptions<Ctx, Ports, ServiceInput, Routes, Providers>,
): Promise<
ServerInstance<Ctx, Ports & InferProviderPorts<Providers>, ServiceInput>
> {
type RegisteredRoute = ResolvedRoute<Ctx, HttpContractConfig> & {
compiled: CompiledPath;
/**
* Uppercased contract method, cached for dispatch.
*/
method: string;
};
const registry: RegisteredRoute[] = [];
// Routes can register after startup via server.route(...), so the registry
// is re-sorted lazily before the next dispatch instead of on every
// registration.
let registryNeedsSort = false;
type FinalPorts = Ports & InferProviderPorts<Providers>;
const providers = (options.providers ?? []) as readonly AnyServiceProvider[];
const env = options.providerEnv ?? process.env;
const overrides = options.providerConfig ?? {};
const providerResults: ProviderSetupResult<AnyPorts>[] = [];
const finalPorts = { ...options.ports } as FinalPorts;
const instrumentation = createServerInstrumentation<Ctx>(
options.instrumentation,
);
const hooks = [
...(instrumentation.hook
? [instrumentation.hook as ServerHook<Ctx, FinalPorts>]
: []),
...((options.hooks ?? []) as ServerHook<Ctx, FinalPorts>[]),
];
const contracts = options.routes ? contractsFromRoutes(options.routes) : [];
const trustedProxy = options.trustedProxy ?? false;
assertValidTrustedProxyConfig(trustedProxy);
// Fail startup on hook misconfiguration before provider setup runs.
for (const hook of hooks) {
hook.validate?.({ contracts, trustedProxy });
}
const resolvedContext = resolveServerContext<Ctx, FinalPorts, ServiceInput>(
options.context,
);
const finalizeContext = createContextFinalizer(
resolvedContext,
() => finalPorts,
);
const createRequestContext = async (
req: HttpRequestLike,
contract?: HttpContractConfig,
requestInfo: TrustedRequestInfo = resolveTrustedRequest(req, trustedProxy),
): Promise<Ctx> => {
const { requestId, trace } = instrumentation.prepareRequest(req);
return finalizeContext(
await resolvedContext.request({
req,
requestInfo: copyTrustedRequestInfo(requestInfo),
ports: finalPorts,
contract,
requestId,
trace,
}),
);
};
const createServiceContext = async (
...args: ServiceContextInputArgs<ServiceInput>
): Promise<Ctx> => {
const serviceFactory = resolvedContext.service;
if (!serviceFactory) {
throw new Error(
"Define context.service in createServer(...) to create service contexts.",
);
}
const { requestId, trace } = instrumentation.createServiceCorrelation();
// Enter the ambient context synchronously (before the factory awaits) so
// it propagates to the caller's continuation. Identity fields are filled
// onto the same object once the context is finalized, so jobs, listeners,
// schedules, and tasks observe the service actor/tenant at record time.
const ambient: ActiveRequestContext = {
requestId,
traceId: trace.traceId,
spanId: trace.spanId,
parentSpanId: trace.parentSpanId,
traceparent: trace.traceparent,
};
enterActiveRequestContext(ambient);
const ctx = finalizeContext(
await serviceFactory({
ports: finalPorts,
input: args[0] as ServiceInput,
requestId,
trace,
}),
);
ambient.actor = readContextActor(ctx);
ambient.tenant = readContextTenant(ctx);
return ctx;
};
const runServiceContext = async <T>(
...args: [
...ServiceContextInputArgs<ServiceInput>,
fn: (ctx: Ctx) => T | Promise<T>,
]
): Promise<T> => {
const serviceFactory = resolvedContext.service;
if (!serviceFactory) {
throw new Error(
"Define context.service in createServer(...) to create service contexts.",
);
}
const fn = args[args.length - 1] as (ctx: Ctx) => T | Promise<T>;
const input = (args.length > 1 ? args[0] : undefined) as ServiceInput;
const { requestId, trace } = instrumentation.createServiceCorrelation();
const ambient: ActiveRequestContext = {
requestId,
traceId: trace.traceId,
spanId: trace.spanId,
parentSpanId: trace.parentSpanId,
traceparent: trace.traceparent,
};
// AsyncLocalStorage.run scopes the ambient frame to this callback, so the
// caller's continuation never resumes through an enterWith frame — the
// pattern that crashes Bun 1.3.x in plain scripts under top-level await.
// The memo scope shares the callback's lifetime, so createMemo(...)
// wrappers dedupe lookups across the context factory and fn. The
// createServiceContext(...) form has no such boundary and stays
// unscoped: memoized functions call through uncached there.
return runWithActiveRequestContext(ambient, () =>
runWithMemoScope(
{ record: createMemoScopeRecorder(finalPorts) },
async () => {
const ctx = finalizeContext(
await serviceFactory({
ports: finalPorts,
input,
requestId,
trace,
}),
);
ambient.actor = readContextActor(ctx);
ambient.tenant = readContextTenant(ctx);
return await fn(ctx);
},
),
);
};
const contextRuntime = {
createRequestContext,
finalizeContext,
resolveRequestInfo: (req: HttpRequestLike) =>
resolveTrustedRequest(req, trustedProxy),
};
let serviceContextsAvailable = false;
const lifecycleCreateServiceContext = async (
input?: unknown,
): Promise<unknown> => {
if (!serviceContextsAvailable) {
throw new Error(
"Service contexts are unavailable until providers have started.",
);
}
return createServiceContext(
...([input] as ServiceContextInputArgs<ServiceInput>),
);
};
let stopped = false;
const stop = async () => {
if (stopped) return;
stopped = true;
const errors: unknown[] = [];
for (let i = providerResults.length - 1; i >= 0; i -= 1) {
const result = providerResults[i];
try {
await result?.stop?.({
ports: finalPorts,
createServiceContext: lifecycleCreateServiceContext,
});
} catch (err) {
errors.push(err);
}
}
if (errors.length) {
throw new AggregateError(errors, "Provider shutdown errors");
}
};
const registeredPaths = new Set<string>();
const registeredShapes = new Map<string, string>();
const registeredNames = new Map<string, string>();
const registeredOperationIds = new Map<string, string>();
const registerRoute = <C extends HttpContractConfig>(
contract: C,
handler: Handler<Ctx, C>,
routeHooks: readonly RouteHook<unknown, object>[] = [],
responseValidationExemptStatus?: number,
): void => {
assertValidContractLifecycle(contract);
if (contract.body && !methodSupportsRequestBody(contract.method)) {
throw new Error(
`Request bodies are not supported for ${contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
);
}
const compiled = compilePath(contract.path);
const normalizedPath = compiled.normalizedPath;
const routeKey = `${contract.method.toUpperCase()} ${normalizedPath}`;
if (registeredPaths.has(routeKey)) {
throw new Error(
`Duplicate route: ${routeKey} is already registered. Each method + path combination must be unique.`,
);
}
const shapeRouteKey = `${contract.method.toUpperCase()} ${compiled.shapeKey}`;
const conflictingRoute = registeredShapes.get(shapeRouteKey);
if (conflictingRoute) {
throw new Error(
`Ambiguous route: ${routeKey} conflicts with ${conflictingRoute}. Dynamic parameter names are ignored during routing, so each method + path shape must be unique.`,
);
}
const conflictingName = registeredNames.get(contract.name);
if (conflictingName) {
throw new Error(
`Duplicate contract name: "${contract.name}" is registered for both ${conflictingName} and ${routeKey}. Contract names must be unique because typed clients, OpenAPI operations, and devtools key on them.`,
);
}
const operationId = getContractOperationId(contract);
const conflictingOperationId = registeredOperationIds.get(operationId);
if (conflictingOperationId) {
throw new Error(
`Duplicate OpenAPI operationId: "${operationId}" is registered for both ${conflictingOperationId} and ${routeKey}. Operation IDs must be unique across the registered route surface.`,
);
}
if (contract.pathParams) {
const shape = getObjectSchemaShape(contract.pathParams);
if (shape) {
const { missingKeys, extraKeys } = comparePathParamsToTemplate({
pathKeys: compiled.keys,
shapeKeys: Object.keys(shape),
});
if (missingKeys.length > 0 || extraKeys.length > 0) {
const details = formatPathParamsMismatch({ missingKeys, extraKeys });
throw new Error(
`Path parameters for contract "${contract.name}" must match "${contract.path}" (${details}). Path templates and pathParams schemas drive routing, clients, and OpenAPI together.`,
);
}
}
}
registeredPaths.add(routeKey);
registeredShapes.set(shapeRouteKey, routeKey);
registeredNames.set(contract.name, routeKey);
registeredOperationIds.set(operationId, routeKey);
const builtHandler = buildHandler(
options,
finalPorts,
contextRuntime,
contract,
handler,
hooks,
routeHooks,
undefined,
responseValidationExemptStatus,
);
registry.push({
contract,
compiled,
method: contract.method.toUpperCase(),
handler: builtHandler,
match: (method, pathname) => {
if (contract.method.toUpperCase() !== method.toUpperCase()) {
return { matched: false as const };
}
const match = compiled.pattern.exec(pathname);
if (!match) return { matched: false as const };
return { matched: true as const };
},
});
registryNeedsSort = true;
};
const createBuilder = <C extends HttpContractConfig>(
contract: C,
shouldRegister: boolean,
): RouteBuilder<Ctx, C> => ({
handle: (fn) => {
const wrapped = buildHandler(
options,
finalPorts,
contextRuntime,
contract,
fn,
hooks,
);
if (shouldRegister) registerRoute(contract, fn);
return wrapped;
},
});
if (options.routes) {
try {
for (const route of options.routes) {
const contract = resolveContract(route.contract);
const hasHandle = typeof route.handle === "function";
const hasUseCase = isUseCaseRouteDef(route);
if (hasHandle && hasUseCase) {
throw new Error(
`Route for contract "${contract.name}" declares both "handle" and "useCase". Bind the contract to exactly one of them.`,
);
}
if (!hasHandle && !hasUseCase) {
throw new Error(
`Route for contract "${contract.name}" declares neither "handle" nor "useCase". Bind the contract to a use case or implement a handler.`,
);
}
if (isUseCaseRouteDef(route)) {
const { handler, responseValidationExemptStatus } =
createUseCaseRouteHandler<Ctx, typeof contract>(contract, route);
registerRoute(
contract,
handler,
route.hooks as readonly RouteHook<unknown, object>[] | undefined,
responseValidationExemptStatus,
);
} else {
registerRoute(
contract,
route.handle as Handler<Ctx, typeof contract>,
route.hooks as readonly RouteHook<unknown, object>[] | undefined,
);
}
}
} catch (error) {
try {
await stop();
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
"Server initialization failed and provider cleanup failed",
);
}
throw error;
}
}
runRuntimeIntegrityCheck(options.integrity);
try {
for (const provider of providers) {
const cfg = await loadProviderConfig(provider, env, overrides);
const result = await provider.setup({
ports: finalPorts,
config: cfg,
createServiceContext: lifecycleCreateServiceContext,
});
if (result.ports) {
Object.assign(finalPorts, result.ports);
}
providerResults.push(result);
}
for (const result of providerResults) {
if (!result.start) continue;
await result.start({
ports: finalPorts,
createServiceContext: lifecycleCreateServiceContext,
});
}
instrumentation.attachPorts(finalPorts);
const onUnboundPorts = options.onUnboundPorts ?? "error";
if (onUnboundPorts !== "ignore") {
const unboundKeys = Object.keys(finalPorts).filter((key) =>
isUnboundPort((finalPorts as AnyPorts)[key]),
);
if (unboundKeys.length > 0) {
const message =
`Unbound ports after provider startup: ${unboundKeys.join(", ")}. ` +
"Each port declared as deferred in definePorts(...) must be contributed " +
"by a provider (server/providers.ts) or bound in infra/port-wiring.ts. " +
'Pass onUnboundPorts: "warn" or "ignore" to change this behavior.';
if (onUnboundPorts === "error") {
throw new Error(message);
}
console.warn(`[beignet] ${message}`);
}
}
serviceContextsAvailable = true;
} catch (error) {
try {
await stop();
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
"Server initialization failed and provider cleanup failed",
);
}
throw error;
}
// The fallback 404/405 pipeline is built once at server creation. Only the
// contract surface that hooks observe (method, path, and the Allow set for
// 405s) is assembled per unmatched request.
const executeFallback = createRequestExecutor<
Ctx,
FinalPorts,
HttpContractConfig
>(options, finalPorts, contextRuntime, hooks, [], {
skipRoutePreparation: true,
});
const fallbackContract = (
name: string,
method: string,
path: string,
): HttpContractConfig => ({
kind: "http",
name,
method: method as HttpContractConfig["method"],
path,
pathParams: null,
query: null,
body: null,
responses: {},
metadata: {},
});
const notFoundHandler: Handler<Ctx, HttpContractConfig> = async () =>
errorResponse(404, "NOT_FOUND", "Not found");
const api = async (req: HttpRequestLike) => {
if (registryNeedsSort) {
registry.sort((a, b) => compareRouteSpecificity(a.compiled, b.compiled));
registryNeedsSort = false;
}
const url = new URL(req.url);
const method = req.method.toUpperCase();
let pathMatchedMethods: Set<string> | undefined;
let headCandidate: RegisteredRoute | undefined;
for (const entry of registry) {
if (!entry.compiled.pattern.test(url.pathname)) continue;
if (method !== "HEAD" && entry.method === method) {
return await entry.handler(req);
}
if (!pathMatchedMethods) {
pathMatchedMethods = new Set();
}
pathMatchedMethods.add(entry.method);
if (method === "HEAD" && entry.method === "HEAD") {
if (
!headCandidate ||
(headCandidate.method === "GET" &&
headCandidate.compiled.shapeKey === entry.compiled.shapeKey)
) {
headCandidate = entry;
}
}
if (entry.method === "GET") {
pathMatchedMethods.add("HEAD");
if (method === "HEAD" && !headCandidate) {
headCandidate = entry;
}
}
}
if (headCandidate) {
return await headCandidate.handler(req);
}
const pathname = url.pathname || "/";
if (pathMatchedMethods) {
const allow = [...pathMatchedMethods].sort().join(", ");
return await executeFallback(
{
contract: fallbackContract("methodNotAllowed", method, pathname),
handler: async () => ({
status: 405,
headers: { allow },
body: createErrorResponseBody({
code: "METHOD_NOT_ALLOWED",
message: `Method ${method} is not allowed for ${pathname}`,
}),
}),
},
req,
{},
);
}
return await executeFallback(
{
contract: fallbackContract("notFound", method, pathname),
handler: notFoundHandler,
},
req,
{},
);
};
return {
api,
route: (contractLike) => {
const contract = resolveContract(contractLike);
return createBuilder(contract, true);
},
rawRoute: (init) => ({
handle: (fn) => {
const built = buildHandler(
options,
finalPorts,
contextRuntime,
rawRouteContract(init),
fn,
hooks,
[],
{ rawRoute: true },
);
// The adapter owns routing for raw routes — the handler is mounted
// at its own path — so path/method matching is skipped and
// `init.path` stays identity for hooks and instrumentation.
return (req) => built(req, {});
},
}),
createRequestContext: (req) => createRequestContext(req),
createServiceContext,
runServiceContext,
contracts,
stop,
ports: finalPorts,
};
}
function rawRouteContract(init: RawRouteInit): HttpContractConfig {
return {
kind: "http",
name: init.name,
method: init.method,
path: init.path,
pathParams: null,
query: null,
body: null,
responses: {},
metadata: init.metadata ?? {},
};
}