UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

937 lines (892 loc) 30.6 kB
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 ?? {}, }; }