UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

539 lines (506 loc) 15 kB
import type { HttpContractConfig, InferHeaderSchemaOutput, InferOutput, StandardSchema, } from "../contracts/index.js"; import type { AnyPorts } from "../ports/index.js"; import type { TrustedProxyConfig, TrustedRequestInfo, } from "./trusted-proxy.js"; /** * Framework-neutral request shape consumed by Beignet server adapters. * * Platform adapters should convert their native request into this shape before * passing it to `server.api(...)` or a single route handler. */ export interface HttpRequestLike { /** * HTTP method as received from the platform. */ method: string; /** * Absolute request URL. */ url: string; /** * Request headers. */ headers: Headers; /** * Abort signal for the request lifecycle when the platform exposes one. * * Streaming handlers should pass this to resources that must close when * the client disconnects. Adapters without cancellation support may omit * it. */ signal?: AbortSignal; /** * The platform request when an adapter has one available. * * Use this as an escape hatch for platform-specific APIs. Prefer the * framework-agnostic methods below when possible. */ raw?: Request; /** * Parse the request body as JSON. */ json(): Promise<unknown>; /** * Parse the request body as text. */ text(): Promise<string>; /** * Parse the request body as an array buffer when the platform supports it. */ arrayBuffer?(): Promise<ArrayBuffer>; /** * Parse the request body as a Blob when the platform supports it. */ blob?(): Promise<Blob>; /** * Parse the request body as form data when the platform supports it. */ formData?(): Promise<FormData>; /** * Clone the request when the platform supports replaying the body. */ clone?(): HttpRequestLike; } /** * Header values accepted by a framework-neutral Beignet response. * * Use an array when the field must be emitted more than once, such as * `Set-Cookie`. Adapters append every array item as a separate field value. */ export type HttpResponseHeaderValue = string | readonly string[]; /** * Framework-neutral response headers. */ export type HttpResponseHeaders = Record<string, HttpResponseHeaderValue>; /** * Framework-neutral response object returned by route handlers and hooks. */ export interface HttpResponseLike { /** * HTTP status code. */ status: number; /** * Response headers. Array values are emitted as repeated header fields. */ headers?: HttpResponseHeaders; /** * JSON-serializable body or an adapter-specific body value. */ body?: unknown; } /** * Response accepted by Beignet handlers. * * Use `HttpResponseLike` for framework-neutral responses. Return a native * `Response` only when the current adapter can pass it through unchanged. */ export type HttpResponse = HttpResponseLike | Response; /** * Framework-neutral Beignet API handler consumed by HTTP adapters. */ export type HttpAdapterApiHandler = ( req: HttpRequestLike, ) => Promise<HttpResponse>; /** * Native handler shape produced by an HTTP adapter. */ export type HttpAdapterHandler<NativeRequest, NativeResponse> = ( req: NativeRequest, ) => Promise<NativeResponse>; /** * Contract implemented by packages that adapt Beignet's framework-neutral * server runtime to a platform HTTP API. * * Core owns request parsing, hooks, route matching, validation, error mapping, * response ownership, and provider lifecycle. Adapters own only the conversion * between the platform request/response types and Beignet's `HttpRequestLike` * / `HttpResponse` boundary. */ export interface HttpAdapter<NativeRequest, NativeResponse> { /** * Human-readable adapter name for diagnostics and documentation. */ name: string; /** * Convert a platform request into Beignet's framework-neutral request shape. */ toRequestLike(req: NativeRequest): HttpRequestLike; /** * Convert a Beignet response into the platform response type. */ toNativeResponse(res: HttpResponse): NativeResponse | Promise<NativeResponse>; /** * Wrap a Beignet API handler in the platform's native handler shape. */ createHandler( handler: HttpAdapterApiHandler, ): HttpAdapterHandler<NativeRequest, NativeResponse>; } type InferSchemaOrFallback< T extends StandardSchema | null, Fallback, > = T extends StandardSchema ? InferOutput<T> : Fallback; /** * Infer the handler path parameter type for a contract. */ export type InferPath<C extends HttpContractConfig> = InferSchemaOrFallback< C["pathParams"], Record<string, string> >; /** * Infer the handler query parameter type for a contract. */ export type InferQuery<C extends HttpContractConfig> = InferSchemaOrFallback< C["query"], Record<string, string | string[]> >; /** * Infer the handler request body type for a contract. */ export type InferBody<C extends HttpContractConfig> = InferSchemaOrFallback< C["body"], unknown >; /** * Infer the merged request header type for a contract. */ export type InferHeaders<C extends HttpContractConfig> = InferHeaderSchemaOutput<Exclude<C["headers"], undefined>> extends undefined ? Record<string, string> : InferHeaderSchemaOutput<Exclude<C["headers"], undefined>>; /** * Arguments passed to a route handler after request parsing and validation. */ export interface HandlerArgs<Ctx, C extends HttpContractConfig> { /** * Framework-neutral request. */ req: HttpRequestLike; /** * Application context assembled by the server context blueprint. */ ctx: Ctx; /** * Matched contract config. */ contract: C; /** * Parsed path parameters. */ path: InferPath<C>; /** * Parsed query parameters. */ query: InferQuery<C>; /** * Parsed request headers. */ headers: InferHeaders<C>; /** * Parsed request body. */ body: InferBody<C>; } /** * Route handler function for a contract. */ export type Handler<Ctx, C extends HttpContractConfig> = ( args: HandlerArgs<Ctx, C>, ) => Promise<HttpResponse> | HttpResponse; /** * Value or promise of that value. */ export type MaybePromise<T> = T | Promise<T>; /** * Arguments passed to a route-scoped hook after request parsing and context * creation. */ export type RouteHookArgs< Ctx, C extends HttpContractConfig = HttpContractConfig, > = HandlerArgs<Ctx, C>; /** * Hook that runs only for the route or route group where it is attached. * * Route hooks are for scoped policy and context enrichment such as * authentication, tenant resolution, feature gates, and idempotency. They add * fields to the handler context instead of replacing the app context. * * Hook additions must not include `gate`: the server re-attaches the gate * declared by the context blueprint after every hook, so identity changes are * picked up automatically. */ export interface RouteHook< Ctx, AddedCtx extends object & { gate?: never } = Record<string, never>, > { /** * Optional name used in diagnostics and devtools. */ name?: string; /** * Resolve additional context for this route or throw to stop handling. */ resolve: (args: RouteHookArgs<Ctx>) => MaybePromise<AddedCtx | undefined>; } type AddedCtxFromHook<Hook> = Hook extends RouteHook<infer _Ctx, infer AddedCtx> ? AddedCtx : unknown; type UnionToIntersection<Union> = ( Union extends unknown ? (value: Union) => void : never ) extends (value: infer Intersection) => void ? Intersection : never; /** * Intersection of the context fields added by a route hook list. */ export type AddedCtxFromHooks<Hooks extends readonly unknown[]> = Hooks extends readonly [] ? unknown : UnionToIntersection<AddedCtxFromHook<Hooks[number]>>; /** * Hook that runs after a route is matched but before request parsing and * context creation. * * Returning a response short-circuits the rest of the request pipeline. */ export type OnRequestHook< Ports extends AnyPorts = AnyPorts, C extends HttpContractConfig = HttpContractConfig, > = (args: { req: HttpRequestLike; requestInfo: TrustedRequestInfo; ports: Ports; contract: C; params: Record<string, string>; }) => MaybePromise<HttpResponse | undefined>; /** * Result from a `beforeHandle` hook. * * Returning a plain response short-circuits the handler. Returning an object can * replace the context, short-circuit with a response, or do both. */ export type BeforeHandleResult<Ctx> = | undefined | HttpResponse | { ctx?: Ctx; response?: HttpResponse; }; /** * Hook that runs after request parsing/context creation and before the handler. */ export type BeforeHandleHook< Ctx, C extends HttpContractConfig = HttpContractConfig, > = (args: { req: HttpRequestLike; requestInfo: TrustedRequestInfo; ctx: Ctx; contract: C; path: InferPath<C>; query: InferQuery<C>; headers: InferHeaders<C>; body: InferBody<C>; }) => MaybePromise<BeforeHandleResult<Ctx>>; /** * Hook that runs before the response is returned. * * Return a response to replace or decorate the outgoing response. Hooks run in * declaration order. For native web `Response` results the hook receives a * headers-only view and only header changes are applied. */ export type BeforeSendHook< Ctx, C extends HttpContractConfig = HttpContractConfig, > = (args: { req: HttpRequestLike; requestInfo: TrustedRequestInfo; ctx?: Ctx; contract: C; path?: InferPath<C>; query?: InferQuery<C>; headers?: InferHeaders<C>; body?: InferBody<C>; response: HttpResponseLike; error?: unknown; /** * True when the route returned a native web Response. The response argument * is a headers-only view ({ status, headers }); the body is not readable and * returned body/status changes are ignored. Header changes are merged onto * the native Response. */ native?: boolean; }) => MaybePromise<HttpResponseLike | undefined>; /** * Per-stage timing breakdown of one request, in milliseconds. * * Stages are measured around the pipeline phases in execution order: * `onRequest` hooks, request parsing/validation, context creation, route * hooks plus `beforeHandle` hooks, the route handler, and response * preparation (`beforeSend`, response validation, and finalizers). Stages * that did not run for a request — parsing on raw routes, the handler after * a hook short-circuit — report `0`. The stages do not sum exactly to the * request `durationMs`; routing and bookkeeping live in the gaps. */ export type RequestStageTimings = { onRequestMs: number; parseMs: number; contextMs: number; beforeHandleMs: number; handlerMs: number; sendMs: number; }; /** * Hook that runs after the response has been prepared. * * This is for logging and observability. Errors thrown by `afterSend` hooks are * ignored by the server pipeline. */ export type AfterSendHook< Ctx, C extends HttpContractConfig = HttpContractConfig, > = (args: { req: HttpRequestLike; requestInfo: TrustedRequestInfo; ctx?: Ctx; contract: C; path?: InferPath<C>; query?: InferQuery<C>; headers?: InferHeaders<C>; body?: InferBody<C>; response: HttpResponseLike; error?: unknown; durationMs: number; /** * Per-stage timing breakdown of this request. */ stages: RequestStageTimings; }) => MaybePromise<void>; /** * Hook notified when the framework catches an error while handling a request. */ export type ServerCaughtErrorHook< Ctx, C extends HttpContractConfig = HttpContractConfig, > = (args: { err: unknown; req: HttpRequestLike; requestInfo?: TrustedRequestInfo; ctx?: Ctx; contract: C; path?: InferPath<C>; query?: InferQuery<C>; headers?: InferHeaders<C>; body?: InferBody<C>; }) => MaybePromise<void>; /** * Hook that may map an unexpected error to a custom response. * * Return `undefined` to let the server's default unhandled-error mapper create * the response. */ export type ServerUnhandledErrorMapper< Ctx, C extends HttpContractConfig = HttpContractConfig, > = (args: { err: unknown; req: HttpRequestLike; requestInfo?: TrustedRequestInfo; ctx?: Ctx; contract: C; path?: InferPath<C>; query?: InferQuery<C>; headers?: InferHeaders<C>; body?: InferBody<C>; }) => MaybePromise<HttpResponse | undefined>; /** * Server lifecycle hook collection. * * Hooks run in the order they are registered within each server-hook phase. * `onRequest` can short-circuit before context creation; route hooks resolve * route-scoped context before server `beforeHandle`; `beforeHandle` can replace * context or short-circuit; `beforeSend` can replace the outgoing response, and * route-owned replacements are contract-validated before send; `afterSend` * observes the final response. */ export interface ServerHook<Ctx, Ports extends AnyPorts = AnyPorts> { /** * Optional name used in diagnostics and devtools. */ name?: string; /** * Validates the hook configuration against the registered contracts. * * Invoked once at startup, right after `createServer(...)` collects the * contracts from the `routes` option and before provider setup. Throw to * fail startup instead of degrading silently at request time. Contracts * registered later through `server.route(...)` are not seen here, so hooks * that need full coverage should keep a runtime check as a backstop. */ validate?: (args: { contracts: readonly HttpContractConfig[]; /** * Server-level trusted-proxy policy. Request phases receive its resolved * value as `requestInfo`. */ trustedProxy?: TrustedProxyConfig; }) => void; /** * Runs after route matching and before body/query/header parsing. */ onRequest?: OnRequestHook<Ports>; /** * Runs after request parsing, context creation, and route hook resolution. */ beforeHandle?: BeforeHandleHook<Ctx>; /** * Runs before the response is returned. Native web `Response` results get a * headers-only view with `native: true`. */ beforeSend?: BeforeSendHook<Ctx>; /** * Observes the final response after send preparation. */ afterSend?: AfterSendHook<Ctx>; /** * Observes framework-caught errors. */ onCaughtError?: ServerCaughtErrorHook<Ctx>; /** * Maps unexpected errors to responses. */ mapUnhandledError?: ServerUnhandledErrorMapper<Ctx>; } /** * Compiled route entry used by server internals and adapter helpers. */ export type ResolvedRoute<_Ctx, C extends HttpContractConfig> = { /** * Contract config for the route. */ contract: C; /** * Handler that receives the raw request and optional path params. */ handler: ( req: HttpRequestLike, params?: Record<string, string>, ) => Promise<HttpResponse>; /** * Test whether the route matches an incoming method and pathname. */ match: ( method: string, pathname: string, ) => { matched: true } | { matched: false }; };