import type { ClientConfig } from "@beignet/core/client"; import { createClient } from "@beignet/core/client"; import { type ErrorReporterPort, tryReportException, } from "@beignet/core/error-reporting"; import { type DrainOutboxOptions, type DrainOutboxResult, drainOutbox, type OutboxRegistry, } from "@beignet/core/outbox"; import type { PaymentsPort, PaymentWebhookEvent } from "@beignet/core/payments"; import type { AnyPorts, BestEffortWorkPort, StorageObject, StoragePort, } from "@beignet/core/ports"; import type { InferProviderPorts, ProviderInstrumentationPort, ServiceProvider, } from "@beignet/core/providers"; import { resolveProviderInstrumentationPort } from "@beignet/core/providers"; import { createInlineScheduleRunner, type ScheduleDef, type StandardSchema, } from "@beignet/core/schedules"; import type { AddedCtxFromHooks, AppEnvironment, ContractLike, CreateServerOptions, Handler, HealthConfig, HttpContractConfig, HttpRequestLike, RawRouteInit, ResolveContract, RouteDef, RouteHook, ServerInstance, StandardSchemaV1, } from "@beignet/core/server"; import { createHealthHandler, createServer } from "@beignet/core/server"; import type { TracingPort } from "@beignet/core/tracing"; import type { UploadRouter } from "@beignet/core/uploads"; import { type InferWebhookEvent, type VerifyWebhookInput, verifyWebhook, type WebhookDef, type WebhookEvent, type WebhookEventSchemas, } from "@beignet/core/webhooks"; import { createFetchHandler, toRequestLike, toWebResponse } from "@beignet/web"; export { type BroadcastRouteOptions, createBroadcastRoute } from "@beignet/web"; type AnyProvider = ServiceProvider< unknown, // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level StandardSchemaV1, 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 >; type MaybePromise = T | Promise; type ServerSource = TServer | (() => MaybePromise); function isServerLoader( source: ServerSource, ): source is () => MaybePromise { return typeof source === "function"; } async function resolveServerSource( source: ServerSource, ): Promise { return isServerLoader(source) ? source() : source; } /** * Unified view over `rawRoute` as exposed by a core `ServerInstance` * (framework-neutral request/response) and a `NextServer` (fetch-native). * Both accept a native `Request` structurally and both produce something * `toWebResponse(...)` normalizes, so route factories can run their handler * through either pipeline without knowing which flavor they were given. */ type RawRoutePipeline = (init: RawRouteInit) => { handle: ( fn: (args: { req: HttpRequestLike; ctx: Ctx; }) => MaybePromise, ) => (req: HttpRequestLike) => Promise; }; function rawRoutePipeline( server: NextRouteServer, ): RawRoutePipeline | undefined { const candidate = (server as { rawRoute?: unknown }).rawRoute; return typeof candidate === "function" ? (candidate.bind(server) as RawRoutePipeline) : undefined; } /** * Per-route-factory memo for the pipeline-built handler. The handler is * rebuilt only when the resolved server instance changes (fresh server per * test, hot reload), never per request. */ function createRawPipelineRunner( init: RawRouteInit, fn: (args: { req: HttpRequestLike; ctx: Ctx }) => Promise, ): ( server: NextRouteServer, ) => ((req: Request) => Promise) | undefined { let cached: | { server: NextRouteServer; handler: (req: Request) => Promise; } | undefined; return (server) => { const pipeline = rawRoutePipeline(server); if (!pipeline) return undefined; if (!cached || cached.server !== server) { const built = pipeline(init).handle(fn); cached = { server, handler: async (req) => toWebResponse( (await built(req as unknown as HttpRequestLike)) as Parameters< typeof toWebResponse >[0], ), }; } return cached.handler; }; } /** * Resolve the native `Request` behind a pipeline request. Requests routed * through `createFetchHandler` carry it as `raw`; requests passed straight * into a core handler are the native request itself. */ function nativeRequestOf(req: HttpRequestLike): Request { const raw = (req as { raw?: unknown }).raw; return (raw instanceof Request ? raw : req) as Request; } /** * Beignet client config with Next.js base URL defaults. */ export type NextClientConfig = Omit< ClientConfig, "baseUrl" > & { /** * Explicit base URL for both browser and server calls. * * In browser code, omit this to use same-origin relative requests. */ baseUrl?: string | (() => string | undefined); /** * Server-only fallback base URL for SSR, prefetching, tests, and server actions. * Browser calls ignore this value unless `baseUrl` is also provided. */ serverBaseUrl?: string | (() => string | undefined); }; /** * OpenAPI server object accepted by `createOpenAPIHandler(...)`. */ export type OpenAPIServer = { /** * Server URL. */ url: string; /** * Server description. */ description?: string; /** * Server URL variables. */ variables?: Record< string, { default: string; enum?: string[]; description?: string; } >; }; /** * Options for the Next.js OpenAPI route handler. */ export type CreateOpenAPIHandlerOptions = { /** * API title. */ title: string; /** * API version. */ version: string; /** * API description. */ description?: string; /** * Static servers or a request-aware server factory. */ servers?: OpenAPIServer[] | ((req: Request) => OpenAPIServer[]); /** * JSON media type used for generated content. */ jsonMediaType?: string; /** * OpenAPI security schemes. */ securitySchemes?: Record; /** * Global security requirements. */ security?: Array>; /** * Custom schema introspector for non-Zod schema libraries. */ schemaIntrospector?: unknown; /** * Additional response headers. */ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined); /** * Add the current request origin as the OpenAPI server when `servers` is omitted. * * @default false */ inferServersFromRequest?: boolean; }; /** * Options for the Next.js Swagger UI route handler. */ export type CreateSwaggerUIHandlerOptions = { /** * HTML page title. */ title?: string; /** * URL of the OpenAPI JSON route. */ specUrl?: string | ((req: Request) => string); /** * Additional response headers. */ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined); }; /** * Options for serving public storage objects through a Next.js route. */ export type CreateStorageRouteOptions = { /** * Public path prefix that maps to storage keys. * * @default "/storage" */ basePath?: string; /** * Additional response headers for served objects. */ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined); /** * Content-Disposition policy for public objects. * * `"auto"` serves active content types such as HTML, SVG, XML, and * JavaScript as downloads while leaving other objects inline. * * @default "auto" */ contentDisposition?: | "auto" | "inline" | "attachment" | ((args: { req: Request; object: StorageObject }) => string | undefined); }; /** * Options for creating a Next.js upload route. */ export type CreateUploadRouteOptions = { /** * Static upload name. Omit when using a dynamic `[uploadName]` segment. */ uploadName?: string; /** * Static upload action. Omit when using a dynamic `[action]` segment. */ action?: "prepare" | "complete" | "upload"; }; type OutboxDrainLogger = { error(message: string, metadata?: Record): void; }; type OutboxDrainInstrumentation = { record(event: { type: "custom"; watcher: string; name: string; label: string; summary: string; requestId?: string; traceId?: string; details: unknown; }): unknown; }; type OutboxDrainPorts = { outbox: DrainOutboxOptions["outbox"]; eventBus?: DrainOutboxOptions["eventBus"]; jobs?: DrainOutboxOptions["jobs"]; logger?: OutboxDrainLogger; devtools?: OutboxDrainInstrumentation; instrumentation?: OutboxDrainInstrumentation; tracing?: TracingPort; errorReporter?: ErrorReporterPort; }; type OutboxDrainContext = { requestId?: string; traceId?: string; ports: OutboxDrainPorts; }; type OutboxDrainRuntimeOptions = { batchSize?: number; concurrency?: number; leaseMs?: number; heartbeatMs?: number; maxActiveMs?: number; retryDelayMs?: DrainOutboxOptions["retryDelayMs"]; }; /** * Scheduler compatible with Next.js 15.1+ `after(...)` and equivalent * request-bound deferred-work primitives. */ export type NextDeferredWorkScheduler = ( work: () => MaybePromise, ) => MaybePromise; /** * Options for adapting Next.js 15.1+ `after(...)` to `BestEffortWorkPort`. */ export type CreateNextBestEffortWorkPortOptions = { /** Schedule work after the response, usually with `next/server`'s `after`. */ defer: NextDeferredWorkScheduler; /** * Best-effort observer for scheduler or deferred callback failures. * Observer failures are isolated too. */ onError?: (error: unknown) => MaybePromise; }; /** * Options for scheduling a bounded outbox drain after a successful request * transaction. */ export type CreateNextOutboxDrainTriggerOptions< Ctx extends OutboxDrainContext, > = { /** Schedule work after the response, usually with `next/server`'s `after`. */ defer: NextDeferredWorkScheduler; /** Build the service context used by the deferred drain. */ createContext: () => MaybePromise; /** Static registry or lazy loader used to avoid server/provider cycles. */ registry: OutboxRegistry | (() => MaybePromise); /** Maximum eligible messages to handle in the deferred drain pass. */ batchSize?: number; /** Maximum messages delivered concurrently. Values above one are unordered. */ concurrency?: number; /** Claim lease duration in milliseconds. */ leaseMs?: number; /** Serialized claim-renewal interval. Must remain shorter than `leaseMs`. */ heartbeatMs?: number; /** Maximum time Beignet renews a claim for one delivery. */ maxActiveMs?: number; /** Retry delay override passed through to `drainOutbox(...)`. */ retryDelayMs?: DrainOutboxOptions["retryDelayMs"]; /** * Best-effort observer for scheduler, context, registry, or drain failures. * Observer failures are ignored. */ onError?: (error: unknown) => MaybePromise; }; /** * Minimal server surface required by the Next.js route factories. * * Both a `NextServer` and a core `ServerInstance` satisfy this shape * structurally, so route modules built on these factories are executable * under a standard test runner: construct the route with a fake server exposing * `createRequestContext` and invoke the exported handlers with a plain * `Request`. No Next.js runtime (`next/headers`) is involved. * * When the server also exposes `rawRoute(...)` — real Beignet servers do — * the factories run their handler through the full hooks pipeline (rate * limiting, CORS, logging, error reporting, instrumentation). Minimal fakes * without it keep the direct `createRequestContext` flow. */ export type NextRouteServer = { createRequestContext: (req: HttpRequestLike) => Promise; }; /** * Options for creating a serverless-safe outbox drain route. */ export type CreateOutboxDrainRouteOptions = { /** * Beignet server that owns app context and ports. * * Any object exposing `createRequestContext` works: a `NextServer`, a core * `ServerInstance`, or a test fake driven from a standard test runner. */ server: ServerSource>; /** * Registry of events and jobs that may be delivered from the outbox. */ registry: OutboxRegistry; /** * Shared secret expected in the `Authorization: Bearer ` header. * * Missing secrets fail closed with a 500 response so deployments do not * accidentally expose an unauthenticated drain route. */ secret?: string; /** * Maximum eligible messages to handle in one drain pass. */ batchSize?: number; /** Maximum messages delivered concurrently. Values above one are unordered. */ concurrency?: number; /** * Claim lease duration in milliseconds. */ leaseMs?: number; /** Serialized claim-renewal interval. Must remain shorter than `leaseMs`. */ heartbeatMs?: number; /** Maximum time Beignet renews a claim for one delivery. */ maxActiveMs?: number; /** * Retry delay override passed through to `drainOutbox(...)`. */ retryDelayMs?: DrainOutboxOptions["retryDelayMs"]; /** * Identity and metadata for the hooks pipeline when the server exposes * `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate * limiting and idempotency; `name` and `path` identify the route to * hooks, instrumentation, and devtools. */ pipeline?: { name?: string; path?: string; metadata?: RawRouteInit["metadata"]; }; /** * Additional response headers. */ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined); }; type ScheduleRouteLogger = { error(message: string, metadata?: Record): void; }; type ScheduleRoutePorts = { logger?: ScheduleRouteLogger; devtools?: ProviderInstrumentationPort; instrumentation?: ProviderInstrumentationPort; errorReporter?: ErrorReporterPort; }; type ScheduleRouteContext = { requestId?: string; traceId?: string; ports: ScheduleRoutePorts; }; type PaymentWebhookLogger = { error(message: string, metadata?: Record): void; }; type PaymentWebhookRoutePorts = { payments: PaymentsPort; logger?: PaymentWebhookLogger; }; type PaymentWebhookRouteContext = { requestId?: string; traceId?: string; ports: PaymentWebhookRoutePorts; }; export type PaymentWebhookRouteHandlerResult = | Response | { status?: number; headers?: HeadersInit; body?: unknown; } | undefined; export type PaymentWebhookRouteHandlerArgs< Ctx extends PaymentWebhookRouteContext, > = { req: Request; ctx: Ctx; rawBody: string; signature: string; event: PaymentWebhookEvent; }; type WebhookRouteLogger = { error(message: string, metadata?: Record): void; }; type WebhookRouteContext = { requestId?: string; traceId?: string; ports: { logger?: WebhookRouteLogger; }; }; export type WebhookRouteHandlerResult = | Response | { status?: number; headers?: HeadersInit; body?: unknown; } | undefined; export type WebhookRouteVerifyArgs = { req: Request; ctx: Ctx; rawBody: string; headers: Record; input: VerifyWebhookInput; }; export type WebhookRouteHandlerArgs< Ctx extends WebhookRouteContext, Webhook extends WebhookDef, AllowUnknownEvents extends boolean, > = { req: Request; ctx: Ctx; rawBody: string; event: AllowUnknownEvents extends false ? InferWebhookEvent : InferWebhookEvent | WebhookEvent; }; /** * Options for creating a generic inbound webhook route. */ export type CreateWebhookRouteOptions< Ctx extends WebhookRouteContext, Webhook extends WebhookDef, AllowUnknownEvents extends boolean = false, > = { /** * Beignet server that owns app context and ports. * * Any object exposing `createRequestContext` works: a `NextServer`, a core * `ServerInstance`, or a test fake driven from a standard test runner. */ server: ServerSource>; /** * Inbound webhook definition. */ webhook: Webhook; /** * Optional context-aware verifier. Use this when verification depends on app * ports, such as `ctx.ports.payments.verifyWebhook(...)`. */ verify?: ( args: WebhookRouteVerifyArgs, ) => Promise | WebhookEvent; /** * Whether unknown event types should pass through without payload parsing. * * @default false */ allowUnknownEvents?: AllowUnknownEvents; /** * Maximum raw webhook body size in bytes. * * Bodies above this limit are rejected before server resolution, hooks, * context creation, verification, or fulfillment. * * @default 1048576 */ maxBodyBytes?: number; /** * App-owned fulfillment handler. The webhook is verified before this runs. * * Omit to acknowledge verified events with `{ ok: true }`. */ handle?: ( args: WebhookRouteHandlerArgs, ) => Promise | WebhookRouteHandlerResult; /** * Identity and metadata for the hooks pipeline when the server exposes * `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate * limiting and idempotency; `name` and `path` identify the route to * hooks, instrumentation, and devtools. */ pipeline?: { name?: string; path?: string; metadata?: RawRouteInit["metadata"]; }; /** * Additional response headers. */ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined); }; /** * Options for creating a serverless-safe schedule trigger route. */ export type CreateScheduleRouteOptions = { /** * Beignet server that owns app context and ports. * * Any object exposing `createRequestContext` works: a `NextServer`, a core * `ServerInstance`, or a test fake driven from a standard test runner. */ server: ServerSource>; /** * Registered schedules, usually the `schedules` array from * `server/schedules.ts`. */ schedules: readonly ScheduleDef[]; /** * Name of the schedule this route triggers. Unknown names throw when the * route module loads so misconfigured routes fail at build or boot time. */ schedule: string; /** * Shared secret expected in the `Authorization: Bearer ` header. * * Missing secrets fail closed with a 500 response so deployments do not * accidentally expose an unauthenticated trigger route. */ secret?: string; /** * Run source label recorded on run metadata and devtools events. * * @default "next-cron-route" */ source?: string; /** * Identity and metadata for the hooks pipeline when the server exposes * `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate * limiting and idempotency; `name` and `path` identify the route to * hooks, instrumentation, and devtools. */ pipeline?: { name?: string; path?: string; metadata?: RawRouteInit["metadata"]; }; /** * Additional response headers. */ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined); }; /** * Options for creating a payment webhook route. */ export type CreatePaymentWebhookRouteOptions< Ctx extends PaymentWebhookRouteContext, > = { /** * Beignet server that owns app context and ports. * * Any object exposing `createRequestContext` works: a `NextServer`, a core * `ServerInstance`, or a test fake driven from a standard test runner. */ server: ServerSource>; /** * Provider signature header. Stripe uses `stripe-signature`. * * @default "stripe-signature" */ signatureHeader?: string; /** * Maximum raw webhook body size in bytes. * * Signed bodies above this limit are rejected before provider verification * or fulfillment, and before server resolution, hooks, or context creation. * A missing signature takes precedence and cancels the unread body. * * @default 1048576 */ maxBodyBytes?: number; /** * App-owned fulfillment handler. The webhook is verified before this runs. * * Omit to acknowledge verified events with `{ ok: true }`. */ handle?: ( args: PaymentWebhookRouteHandlerArgs, ) => | Promise | PaymentWebhookRouteHandlerResult; /** * Identity and metadata for the hooks pipeline when the server exposes * `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate * limiting and idempotency; `name` and `path` identify the route to * hooks, instrumentation, and devtools. */ pipeline?: { name?: string; path?: string; metadata?: RawRouteInit["metadata"]; }; /** * Additional response headers. */ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined); }; type UploadRouteContext = { params?: | Promise> | Record; }; /** * Beignet server adapted for Next.js route handlers and server components. */ export interface NextServer< Ctx, Ports extends AnyPorts = AnyPorts, ServiceInput = void, > { /** * Catch-all Next.js route handler. */ api: (req: Request) => Promise; /** * Register and build a single Next.js route handler imperatively. */ route: ( contractLike: CLike, ) => { handle: ( fn: Handler>, ) => (req: Request) => Promise; }; /** * Build a Next.js route handler for a route that cannot be a contract — * webhooks, third-party auth callbacks, streaming endpoints — that still * runs the whole server pipeline: hooks (rate limiting, idempotency, CORS, * logging, error reporting), 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 * read `init.metadata`. Raw routes are not added to the route registry; * export the returned handler from the route file that owns the path. */ rawRoute: < const Hooks extends readonly RouteHook[] = readonly [], >( init: RawRouteInit & { hooks?: Hooks }, ) => { handle: ( fn: Handler, HttpContractConfig>, ) => (req: Request) => Promise; }; /** * Create app context from `next/headers` for Server Components. */ createContextFromNext: () => Promise; /** * Build a fully assembled request context from a framework-neutral request. */ createRequestContext: ServerInstance< Ctx, Ports, ServiceInput >["createRequestContext"]; /** * Build a fully assembled service context for schedules, outbox drains, * commands, and background work. * * Requires `context.service` to be declared in `createNextServer(...)`. */ createServiceContext: ServerInstance< Ctx, Ports, ServiceInput >["createServiceContext"]; /** * Build a service context and run `fn` inside a scoped ambient correlation * frame, returning its result. * * This is the script-safe counterpart to `createServiceContext(...)`. * Requires `context.service` to be declared in `createNextServer(...)`. */ runServiceContext: ServerInstance< Ctx, Ports, ServiceInput >["runServiceContext"]; /** * Registered contract inputs. */ contracts: readonly ContractLike[]; /** * Stop installed providers. */ stop: () => Promise; /** * Final app ports after provider setup. */ ports: Ports; } /** * Lazy, memoized loader for a Beignet Next server. */ export type NextServerLoader = () => Promise; /** * HTTP method handlers for a central Beignet API catch-all route. */ export type NextApiRouteHandlers = { DELETE: (req: Request) => Promise; GET: (req: Request) => Promise; HEAD: (req: Request) => Promise; OPTIONS: (req: Request) => Promise; PATCH: (req: Request) => Promise; POST: (req: Request) => Promise; PUT: (req: Request) => Promise; }; /** * Create a memoized Next server loader for route modules. * * Next.js imports route modules during production builds. Exporting a lazy * loader lets those modules stay cheap to import while still booting providers * before the first real request in each runtime process. */ export function createNextServerLoader( createServer: () => MaybePromise, ): NextServerLoader { let server: Promise | undefined; return () => { if (!server) { const nextServer = Promise.resolve().then(createServer); server = nextServer; void nextServer.catch(() => { if (server === nextServer) { server = undefined; } }); } return server; }; } /** * Create HTTP method exports for a catch-all API route. */ export function createApiRoute< TServer extends Pick, "api">, >(loadServer: NextServerLoader): NextApiRouteHandlers { const handle = async (req: Request) => { const server = await loadServer(); return server.api(req); }; return { DELETE: handle, GET: handle, HEAD: handle, OPTIONS: handle, PATCH: handle, POST: handle, PUT: handle, }; } /** * Create a readiness route whose server is loaded only when the route handles * a request. */ export function createHealthRoute( loadServer: NextServerLoader< Pick, "ports"> >, healthConfig: HealthConfig | undefined, environment: AppEnvironment = "production", ): { GET: (req: Request) => Promise; } { return { GET: async (req) => { const server = await loadServer(); const handler = createHealthHandler( server.ports, healthConfig, environment, ); const response = toWebResponse(await handler(req)); const headers = new Headers(response.headers); if (!headers.has("cache-control")) { headers.set("cache-control", "no-store"); } return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); }, }; } function resolveConfigValue( value: string | (() => string | undefined) | undefined, ): string | undefined { return typeof value === "function" ? value() : value; } /** * Resolve the right Beignet client base URL for Next.js. * * Browser calls default to same-origin relative URLs. Server calls need an * absolute URL, so this helper checks `NEXT_PUBLIC_API_URL`, then Vercel's * deployment URL, then localhost. */ export function resolveNextBaseUrl( options: NextClientConfig = {}, ): string { const explicitBaseUrl = resolveConfigValue(options.baseUrl); if (explicitBaseUrl !== undefined) return explicitBaseUrl; if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; if (typeof window !== "undefined") return ""; const serverBaseUrl = resolveConfigValue(options.serverBaseUrl); if (serverBaseUrl !== undefined) return serverBaseUrl; if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; return `http://localhost:${process.env.PORT || 3000}`; } /** * Create a Beignet client with Next.js-friendly base URL defaults. */ export function createNextClient( config: NextClientConfig = {}, ) { const { baseUrl: _baseUrl, serverBaseUrl: _serverBaseUrl, ...clientConfig } = config; return createClient({ ...clientConfig, baseUrl: resolveNextBaseUrl(config), }); } function resolveHeaders( headers: | HeadersInit | ((req: Request) => HeadersInit | undefined) | undefined, req: Request, ): HeadersInit | undefined { return typeof headers === "function" ? headers(req) : headers; } function hasControlCharacter(value: string): boolean { for (const char of value) { const code = char.charCodeAt(0); if (code <= 31 || code === 127) return true; } return false; } function normalizeStorageBasePath(basePath: string | undefined): string { const path = basePath ?? "/storage"; if (!path.startsWith("/")) { throw new Error("Storage route basePath must start with '/'."); } if (path.includes("?") || path.includes("#")) { throw new Error("Storage route basePath must be a pathname."); } return path.replace(/\/+$/, "") || "/"; } function keyFromStorageRequest(req: Request, basePath: string): string | null { const pathname = new URL(req.url).pathname; const prefix = basePath === "/" ? "/" : `${basePath}/`; if ( basePath !== "/" && pathname !== basePath && !pathname.startsWith(prefix) ) { return null; } const encodedKey = basePath === "/" ? pathname.slice(1) : pathname.slice(prefix.length); if (!encodedKey) return null; try { const key = encodedKey .split("/") .map((segment) => decodeURIComponent(segment)) .join("/"); const segments = key.split("/"); if ( !key || key.includes("\\") || hasControlCharacter(key) || segments.some( (segment) => segment === "" || segment === "." || segment === ".." || segment === ".beignet-storage-meta", ) ) { return null; } return key; } catch { return null; } } function storageNotFoundResponse(): Response { return new Response(null, { status: 404 }); } const activeStorageContentTypes = new Set([ "application/ecmascript", "application/javascript", "application/xhtml+xml", "application/xml", "image/svg+xml", "text/ecmascript", "text/html", "text/javascript", "text/xml", ]); function baseContentType(contentType: string | undefined): string { return contentType?.split(";")[0]?.trim().toLowerCase() ?? ""; } function isActiveStorageContentType(contentType: string | undefined): boolean { const type = baseContentType(contentType); return activeStorageContentTypes.has(type) || type.endsWith("+xml"); } function resolveStorageContentDisposition( options: CreateStorageRouteOptions, req: Request, object: StorageObject, ): string | undefined { const policy = options.contentDisposition ?? "auto"; const value = typeof policy === "function" ? policy({ req, object }) : policy === "auto" ? isActiveStorageContentType(object.contentType) ? "attachment" : undefined : policy; if (value && hasControlCharacter(value)) { throw new Error( "Storage route contentDisposition must not contain control characters.", ); } return value; } function firstParam(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } async function storageResponse( storage: StoragePort, req: Request, basePath: string, options: CreateStorageRouteOptions, includeBody: boolean, ): Promise { const key = keyFromStorageRequest(req, basePath); if (!key) return storageNotFoundResponse(); const object = await storage.get(key); if (!object) { return storageNotFoundResponse(); } if (object.visibility !== "public") { await object.cancel().catch(() => undefined); return storageNotFoundResponse(); } try { const headers = new Headers(resolveHeaders(options.headers, req)); headers.set("content-length", String(object.size)); headers.set("last-modified", object.lastModified.toUTCString()); if (!headers.has("x-content-type-options")) { headers.set("x-content-type-options", "nosniff"); } if (object.contentType && !headers.has("content-type")) { headers.set("content-type", object.contentType); } if (!headers.has("content-disposition")) { const contentDisposition = resolveStorageContentDisposition( options, req, object, ); if (contentDisposition) { headers.set("content-disposition", contentDisposition); } } if (object.cacheControl && !headers.has("cache-control")) { headers.set("cache-control", object.cacheControl); } if (!includeBody) { await object.cancel(); return new Response(null, { status: 200, headers }); } const body = object.stream(); try { return new Response(body, { status: 200, headers }); } catch (error) { await body.cancel(error).catch(() => undefined); throw error; } } catch (error) { if (!object.bodyUsed) { await object.cancel(error).catch(() => undefined); } throw error; } } function currentRequestServer(req: Request): OpenAPIServer { const url = new URL(req.url); return { url: `${url.protocol}//${url.host}`, description: "Current server", }; } /** * Create a Next.js route handler that returns an OpenAPI document. * * Requires `@beignet/core/openapi` and the `zod` peer dependency. */ export function createOpenAPIHandler( contracts: readonly ContractLike[], options: CreateOpenAPIHandlerOptions, ): (req: Request) => Promise { return async (req: Request) => { const { contractsToOpenAPI } = await import("@beignet/core/openapi"); const servers = typeof options.servers === "function" ? options.servers(req) : (options.servers ?? (options.inferServersFromRequest === true ? [currentRequestServer(req)] : undefined)); const spec = contractsToOpenAPI(contracts, { title: options.title, version: options.version, description: options.description, servers, jsonMediaType: options.jsonMediaType, securitySchemes: options.securitySchemes as never, security: options.security, schemaIntrospector: options.schemaIntrospector as never, }); const headers = new Headers(resolveHeaders(options.headers, req)); if (!headers.has("content-type")) { headers.set("content-type", "application/json"); } return Response.json(spec, { headers }); }; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function serializeJsonForHtmlScript(value: string): string { return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => { switch (character) { case "<": return "\\u003c"; case ">": return "\\u003e"; case "&": return "\\u0026"; case "\u2028": return "\\u2028"; case "\u2029": return "\\u2029"; default: return character; } }); } function createSwaggerUIHtml(title: string, specUrl: string): string { return ` ${escapeHtml(title)}
`; } /** * Create a Next.js route handler that serves Swagger UI for an OpenAPI route. */ export function createSwaggerUIHandler( options: CreateSwaggerUIHandlerOptions = {}, ): (req: Request) => Response { return (req: Request) => { const specUrl = typeof options.specUrl === "function" ? options.specUrl(req) : (options.specUrl ?? "/api/openapi"); const headers = new Headers(resolveHeaders(options.headers, req)); if (!headers.has("content-type")) { headers.set("content-type", "text/html; charset=utf-8"); } return new Response( createSwaggerUIHtml(options.title ?? "API Documentation", specUrl), { headers }, ); }; } /** * Create Next.js route handlers that serve public storage objects. * * Missing objects, private objects, invalid keys, and paths outside `basePath` * all return 404 so storage routes do not leak object existence. */ export function createStorageRoute( storage: ServerSource, options: CreateStorageRouteOptions = {}, ): { GET: (req: Request) => Promise; HEAD: (req: Request) => Promise; } { const basePath = normalizeStorageBasePath(options.basePath); return { GET: async (req) => storageResponse( await resolveServerSource(storage), req, basePath, options, true, ), HEAD: async (req) => storageResponse( await resolveServerSource(storage), req, basePath, options, false, ), }; } /** * Create a Next.js POST handler for Beignet upload routes. * * Use this in a dynamic route such as * `app/api/uploads/[uploadName]/[action]/route.ts`, where `action` is * `prepare`, `complete`, or `upload`. */ export function createUploadRoute( router: ServerSource, options: CreateUploadRouteOptions = {}, ): { POST: (req: Request, ctx?: UploadRouteContext) => Promise; } { return { POST: async (req, ctx) => { const params = await ctx?.params; const uploadName = options.uploadName ?? firstParam(params?.uploadName ?? params?.upload); const action = options.action ?? firstParam(params?.action); if (!uploadName) { return Response.json( { code: "UPLOAD_NOT_FOUND", message: "Upload route is missing an upload name.", }, { status: 404 }, ); } if ( action !== "prepare" && action !== "complete" && action !== "upload" ) { return Response.json( { code: "INVALID_UPLOAD_ACTION", message: 'Upload route action must be "prepare", "complete", or "upload".', }, { status: 400 }, ); } const resolvedRouter = await resolveServerSource(router); return resolvedRouter.handleRequest(req, { uploadName, action, }); }, }; } function routeJson( body: unknown, status: number, headers: HeadersInit | undefined, ): Response { return Response.json(body, { status, headers, }); } type OperationalErrorCode = | "CRON_SECRET_NOT_CONFIGURED" | "OUTBOX_DRAIN_FAILED" | "PAYMENT_WEBHOOK_BODY_READ_FAILED" | "PAYMENT_WEBHOOK_BODY_TOO_LARGE" | "PAYMENT_WEBHOOK_CONTEXT_FAILED" | "PAYMENT_WEBHOOK_HANDLER_FAILED" | "PAYMENT_WEBHOOK_SIGNATURE_MISSING" | "PAYMENT_WEBHOOK_VERIFICATION_FAILED" | "SCHEDULE_FAILED" | "UNAUTHORIZED" | "WEBHOOK_BODY_READ_FAILED" | "WEBHOOK_BODY_TOO_LARGE" | "WEBHOOK_CONTEXT_FAILED" | "WEBHOOK_HANDLER_FAILED" | "WEBHOOK_VERIFICATION_FAILED"; function operationalErrorJson( code: OperationalErrorCode, message: string, status: number, headers: HeadersInit | undefined, details?: unknown, ): Response { return routeJson( { code, message, ...(details !== undefined ? { details } : {}), }, status, headers, ); } function toHttpResponseHeaders( headers: HeadersInit | undefined, ): Record | undefined { if (!headers) return undefined; const result: Record = {}; new Headers(headers).forEach((value, key) => { result[key] = value; }); return result; } /** * Rebuild a request whose body was already consumed. * * Webhook routes read the raw body before anything else so signature * verification never races the context factory over one body stream. Context * creation then receives a rehydrated request carrying the same method, URL, * headers, and raw body. */ function rehydrateWebhookRequest(req: Request, rawBody: string): Request { return new Request(req.url, { method: req.method, headers: req.headers, body: rawBody, signal: req.signal, }); } const DEFAULT_WEBHOOK_BODY_MAX_BYTES = 1024 * 1024; class WebhookBodyTooLargeError extends Error { constructor(readonly maxBytes: number) { super("Webhook body exceeds the configured size limit."); this.name = "WebhookBodyTooLargeError"; } } function webhookBodyLimit(maxBytes: number | undefined): number { const resolved = maxBytes ?? DEFAULT_WEBHOOK_BODY_MAX_BYTES; if (!Number.isFinite(resolved) || resolved <= 0) { throw new Error("Webhook maxBodyBytes must be a positive number."); } return resolved; } function cancelWebhookBody( source: { cancel(reason?: unknown): Promise }, reason: unknown, ): void { try { void source.cancel(reason).catch(() => {}); } catch { // Cancellation is best-effort and must not replace the selected response. } } function cancelWebhookRequestBody(req: HttpRequestLike, reason: unknown): void { const body = req.raw?.body ?? nativeRequestOf(req).body; if (body) cancelWebhookBody(body, reason); } async function readWebhookBody( req: HttpRequestLike, maxBytes: number, ): Promise { const body = req.raw?.body ?? nativeRequestOf(req).body; const contentLength = req.headers.get("content-length"); if (contentLength !== null) { const declaredBytes = Number(contentLength); if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) { const error = new WebhookBodyTooLargeError(maxBytes); if (body) cancelWebhookBody(body, error); throw error; } } if (!body) { const text = await req.text(); if (new TextEncoder().encode(text).byteLength > maxBytes) { throw new WebhookBodyTooLargeError(maxBytes); } return text; } const reader = body.getReader(); const decoder = new TextDecoder(); let receivedBytes = 0; let text = ""; try { while (true) { const result = await reader.read(); if (result.done) break; receivedBytes += result.value.byteLength; if (receivedBytes > maxBytes) { const error = new WebhookBodyTooLargeError(maxBytes); cancelWebhookBody(reader, error); throw error; } text += decoder.decode(result.value, { stream: true }); } return text + decoder.decode(); } finally { reader.releaseLock(); } } function headersToRecord(headers: Headers): Record { const result: Record = {}; headers.forEach((value, key) => { result[key.toLowerCase()] = value; }); return result; } /** * Compare two strings without leaking length or prefix timing. * * Both values are hashed with SHA-256 so the XOR comparison always runs over * fixed-size digests, which keeps the check edge-compatible and constant-time. */ async function timingSafeStringEqual(a: string, b: string): Promise { const encoder = new TextEncoder(); const [digestA, digestB] = await Promise.all([ crypto.subtle.digest("SHA-256", encoder.encode(a)), crypto.subtle.digest("SHA-256", encoder.encode(b)), ]); const bytesA = new Uint8Array(digestA); const bytesB = new Uint8Array(digestB); let mismatch = 0; for (let index = 0; index < bytesA.length; index++) { mismatch |= (bytesA[index] ?? 0) ^ (bytesB[index] ?? 0); } return mismatch === 0; } async function isAuthorizedCronRequest( req: Request, secret: string, ): Promise { return timingSafeStringEqual( req.headers.get("authorization") ?? "", `Bearer ${secret}`, ); } async function reportOutboxDrainFailure( ctx: OutboxDrainContext, error: unknown, mechanism: string, ): Promise { logOutboxDrainError(ctx, "Outbox drain failed", { error }); await tryReportException({ reporter: ctx.ports.errorReporter, error, reportOptions: { level: "error", mechanism, handled: false, requestId: ctx.requestId, traceId: ctx.traceId, tags: { "beignet.kind": "outbox", "beignet.outbox.outcome": "drainFailed", }, contexts: { outbox: { outcome: "drainFailed" }, }, }, }); } function logOutboxDrainError( ctx: OutboxDrainContext, message: string, metadata: Record, ): void { try { ctx.ports.logger?.error(message, metadata); } catch { // App logging must not change outbox delivery or recovery behavior. } } async function runOutboxDrainWithContext( ctx: OutboxDrainContext, registry: OutboxRegistry, options: OutboxDrainRuntimeOptions, mechanism: string, ): Promise { const result = await drainOutbox({ outbox: ctx.ports.outbox, registry, eventBus: ctx.ports.eventBus, jobs: ctx.ports.jobs, batchSize: options.batchSize, concurrency: options.concurrency, leaseMs: options.leaseMs, heartbeatMs: options.heartbeatMs, maxActiveMs: options.maxActiveMs, retryDelayMs: options.retryDelayMs, instrumentation: { instrumentation: resolveProviderInstrumentationPort(ctx.ports), tracing: ctx.ports.tracing, }, instrumentationContext: { requestId: ctx.requestId, traceId: ctx.traceId, }, onError(error, message) { logOutboxDrainError(ctx, "Outbox delivery failed", { error, outboxMessageId: message.id, kind: message.kind, name: message.name, attempts: message.attempts, }); }, async onDeadLetter(error, message) { await tryReportException({ reporter: ctx.ports.errorReporter, error, reportOptions: { level: "error", mechanism, handled: false, requestId: ctx.requestId, traceId: ctx.traceId, tags: { "beignet.kind": "outbox", "beignet.outbox.kind": message.kind, "beignet.outbox.name": message.name, "beignet.outbox.outcome": "deadLettered", }, contexts: { outbox: { messageId: message.id, kind: message.kind, name: message.name, attempts: message.attempts, maxAttempts: message.maxAttempts, outcome: "deadLettered", }, }, }, }); }, async onLeaseError(failure) { const outcome = failure.state === "lost" ? "leaseLost" : failure.state === "recovered" ? "leaseRecovered" : "leaseDegraded"; await tryReportException({ reporter: ctx.ports.errorReporter, error: failure.error, reportOptions: { level: failure.state === "lost" ? "error" : "warning", mechanism, handled: failure.state !== "lost", requestId: ctx.requestId, traceId: ctx.traceId, tags: { "beignet.kind": "outbox", "beignet.outbox.kind": failure.message.kind, "beignet.outbox.name": failure.message.name, "beignet.outbox.outcome": outcome, }, contexts: { outbox: { messageId: failure.message.id, kind: failure.message.kind, name: failure.message.name, attempts: failure.message.attempts, maxAttempts: failure.message.maxAttempts, operation: failure.operation, state: failure.state, confirmedLost: failure.confirmedLost, outcome, }, }, }, }); }, async onSettlementError(failure) { await tryReportException({ reporter: ctx.ports.errorReporter, error: failure.error, reportOptions: { level: "error", mechanism, handled: false, requestId: ctx.requestId, traceId: ctx.traceId, tags: { "beignet.kind": "outbox", "beignet.outbox.kind": failure.message.kind, "beignet.outbox.name": failure.message.name, "beignet.outbox.outcome": "settlementFailed", }, contexts: { outbox: { messageId: failure.message.id, kind: failure.message.kind, name: failure.message.name, attempts: failure.message.attempts, maxAttempts: failure.message.maxAttempts, operation: failure.operation, deliverySucceeded: failure.deliverySucceeded, outcome: "settlementFailed", }, }, }, }); }, }); try { await resolveProviderInstrumentationPort(ctx.ports)?.record({ type: "custom", watcher: "outbox", name: "outbox.drain", label: "Outbox drain", summary: `${result.delivered} delivered, ${result.retried} retried, ${result.deadLettered} dead-lettered, ${result.settlementFailed} settlement-uncertain, ${result.leaseLost} lease-lost`, requestId: ctx.requestId, traceId: ctx.traceId, details: result, }); } catch (error) { logOutboxDrainError(ctx, "Outbox drain instrumentation recording failed", { error, }); } return result; } function hasUncertainOutboxResult(result: DrainOutboxResult): boolean { return result.settlementFailed > 0 || result.leaseLost > 0; } function uncertainOutboxError(result: DrainOutboxResult): Error { return new Error( `Outbox drain completed with ${result.settlementFailed} settlement failure(s) and ${result.leaseLost} lost lease(s).`, ); } async function resolveOutboxRegistry( source: OutboxRegistry | (() => MaybePromise), ): Promise { return typeof source === "function" ? source() : source; } async function notifyDeferredWorkError( observer: ((error: unknown) => MaybePromise) | undefined, error: unknown, ): Promise { try { await observer?.(error); } catch { // Best-effort work must not fail because its error observer failed. } } /** * Adapt Next.js 15.1+ `after(...)` to Beignet's non-durable best-effort-work * port. * * Scheduling failures and deferred callback failures are isolated from the * originating operation. Use jobs or an outbox when work must be retried or * survive process termination. This adapter is request-bound: scheduling * outside a Next.js context supported by `after(...)` is passed to `onError` * when configured. */ export function createNextBestEffortWorkPort( options: CreateNextBestEffortWorkPortOptions, ): BestEffortWorkPort { return { defer(work) { const run = async () => { try { await work(); } catch (error) { await notifyDeferredWorkError(options.onError, error); } }; try { const scheduled = options.defer(run); void Promise.resolve(scheduled).catch((error: unknown) => notifyDeferredWorkError(options.onError, error), ); } catch (error) { void notifyDeferredWorkError(options.onError, error); } }, }; } /** * Create a best-effort trigger for one bounded outbox drain. * * Pass Next.js `after` as `defer`. The trigger is an immediate-delivery * optimization only; keep a cron or worker drain for recovery, delayed * messages, and retries. Calls coalesce while a drain is scheduled or running * so transactions started by delivery handlers cannot fan out recursively. */ export function createNextOutboxDrainTrigger( options: CreateNextOutboxDrainTriggerOptions, ): () => void { let activeTrigger: symbol | undefined; return () => { if (activeTrigger) return; const trigger = Symbol("outbox-drain-trigger"); activeTrigger = trigger; let callbackStarted = false; const releaseTrigger = () => { if (activeTrigger === trigger) activeTrigger = undefined; }; try { const scheduled = options.defer(async () => { if (activeTrigger !== trigger) return; callbackStarted = true; let ctx: Ctx | undefined; try { ctx = await options.createContext(); const registry = await resolveOutboxRegistry(options.registry); const result = await runOutboxDrainWithContext( ctx, registry, options, "beignet.outbox.next.after", ); if (hasUncertainOutboxResult(result)) { const error = uncertainOutboxError(result); await notifyDeferredWorkError(options.onError, error); } } catch (error) { if (ctx) { try { await reportOutboxDrainFailure( ctx, error, "beignet.outbox.next.after", ); } catch { // Recovery polling remains authoritative when reporting fails. } } await notifyDeferredWorkError(options.onError, error); } finally { releaseTrigger(); } }); void Promise.resolve(scheduled).catch(async (error) => { if (!callbackStarted) releaseTrigger(); await notifyDeferredWorkError(options.onError, error); }); } catch (error) { if (!callbackStarted) releaseTrigger(); void notifyDeferredWorkError(options.onError, error); } }; } /** * Create Next.js route handlers that drain one durable outbox batch. * * This helper is intended for serverless cron routes. It performs bounded work * and exits; do not start long-running outbox polling loops from provider * lifecycle hooks in serverless apps. */ export function createOutboxDrainRoute( options: CreateOutboxDrainRouteOptions, ): { GET: (req: Request) => Promise; POST: (req: Request) => Promise; } { const drainWithContext = async ({ req, ctx, }: { req: HttpRequestLike; ctx: Ctx; }): Promise => { const nativeReq = nativeRequestOf(req); const headers = resolveHeaders(options.headers, nativeReq); try { const result = await runOutboxDrainWithContext( ctx, options.registry, options, "beignet.outbox.next", ); if (hasUncertainOutboxResult(result)) { return routeJson( { ok: false, error: { code: "OUTBOX_DRAIN_UNCERTAIN", message: "Outbox drain completed with unconfirmed delivery state.", }, result, }, 500, headers, ); } return routeJson( { ok: true, result, }, 200, headers, ); } catch (error) { await reportOutboxDrainFailure(ctx, error, "beignet.outbox.next"); return operationalErrorJson( "OUTBOX_DRAIN_FAILED", "Outbox drain failed.", 500, headers, ); } }; const pipelineInit = { name: options.pipeline?.name ?? "outbox.drain", path: options.pipeline?.path ?? "/outbox/drain", metadata: options.pipeline?.metadata, }; const pipelineRunners = { GET: createRawPipelineRunner( { ...pipelineInit, method: "GET" }, drainWithContext, ), POST: createRawPipelineRunner( { ...pipelineInit, method: "POST" }, drainWithContext, ), }; const drainPendingOutbox = async (req: Request): Promise => { const headers = resolveHeaders(options.headers, req); const secret = options.secret; if (!secret) { return operationalErrorJson( "CRON_SECRET_NOT_CONFIGURED", "CRON_SECRET is not configured.", 500, headers, ); } if (!(await isAuthorizedCronRequest(req, secret))) { return operationalErrorJson("UNAUTHORIZED", "Unauthorized", 401, headers); } const server = await resolveServerSource(options.server); const runner = pipelineRunners[req.method.toUpperCase() === "GET" ? "GET" : "POST"]; const pipelined = runner(server); if (pipelined) return pipelined(req); let ctx: Ctx; try { ctx = await server.createRequestContext(toRequestLike(req)); } catch (error) { console.error("Outbox drain context failed", { error }); return operationalErrorJson( "OUTBOX_DRAIN_FAILED", "Outbox drain failed.", 500, headers, ); } return drainWithContext({ req: toRequestLike(req), ctx }); }; return { GET: drainPendingOutbox, POST: drainPendingOutbox, }; } /** * Create a Next.js POST handler for generic verified inbound webhooks. * * The route bounds and reads the raw request body before resolving the server, * then normalizes request headers and builds app context from a rehydrated * request through * `server.createRequestContext(...)`, verifies the event through the webhook * definition or a context-aware verifier, validates the matching event * payload schema, and delegates fulfillment to app code. */ export function createWebhookRoute< Ctx extends WebhookRouteContext, Webhook extends WebhookDef, AllowUnknownEvents extends boolean = false, >( options: CreateWebhookRouteOptions, ): { POST: (req: Request) => Promise; } { const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes); const preflightBodies = new WeakMap(); const handleWebhookRequest = async ({ req, ctx, rawBody: providedRawBody, }: { req: HttpRequestLike; ctx: Ctx; rawBody?: string; }): Promise => { const nativeReq = nativeRequestOf(req); const headers = resolveHeaders(options.headers, nativeReq); const requestHeaders = headersToRecord(new Headers(req.headers)); let rawBody = providedRawBody ?? preflightBodies.get(nativeReq); preflightBodies.delete(nativeReq); if (rawBody === undefined) { try { rawBody = await readWebhookBody(req, maxBodyBytes); } catch (error) { return error instanceof WebhookBodyTooLargeError ? operationalErrorJson( "WEBHOOK_BODY_TOO_LARGE", "Webhook body exceeds the configured size limit.", 413, headers, ) : operationalErrorJson( "WEBHOOK_BODY_READ_FAILED", "Webhook body read failed.", 400, headers, ); } } let event: WebhookRouteHandlerArgs< Ctx, Webhook, AllowUnknownEvents >["event"]; try { const input: VerifyWebhookInput = { rawBody, headers: requestHeaders, receivedAt: new Date(), }; const routeVerifier = options.verify; const verifier = routeVerifier ? { async verify() { return routeVerifier({ req: nativeReq, ctx, rawBody, headers: requestHeaders, input, }); }, } : undefined; event = (await verifyWebhook(options.webhook, input, { verifier, allowUnknownEvents: options.allowUnknownEvents, })) as WebhookRouteHandlerArgs["event"]; } catch (error) { ctx.ports.logger?.error("Webhook verification failed", { error, webhookName: options.webhook.name, provider: options.webhook.provider, requestId: ctx.requestId, traceId: ctx.traceId, }); return operationalErrorJson( "WEBHOOK_VERIFICATION_FAILED", "Webhook verification failed.", 400, headers, ); } try { const result = await options.handle?.({ req: nativeReq, ctx, rawBody, event, }); if (result instanceof Response) return result; if (result) { return toWebResponse({ status: result.status ?? 200, headers: toHttpResponseHeaders(result.headers ?? headers), body: result.body, }); } return toWebResponse({ status: 200, headers: toHttpResponseHeaders(headers), body: { ok: true, webhookName: options.webhook.name, eventId: event.id, eventType: event.type, }, }); } catch (error) { ctx.ports.logger?.error("Webhook handler failed", { error, webhookName: options.webhook.name, provider: options.webhook.provider, requestId: ctx.requestId, traceId: ctx.traceId, eventId: event.id, eventType: event.type, }); return operationalErrorJson( "WEBHOOK_HANDLER_FAILED", "Webhook handler failed.", 500, headers, ); } }; const runPipeline = createRawPipelineRunner( { name: options.pipeline?.name ?? `webhook.${options.webhook.name}`, method: "POST", path: options.pipeline?.path ?? `/webhooks/${options.webhook.name}`, metadata: options.pipeline?.metadata, }, handleWebhookRequest, ); return { POST: async (req) => { const headers = resolveHeaders(options.headers, req); let rawBody: string; try { rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes); } catch (error) { return error instanceof WebhookBodyTooLargeError ? operationalErrorJson( "WEBHOOK_BODY_TOO_LARGE", "Webhook body exceeds the configured size limit.", 413, headers, ) : operationalErrorJson( "WEBHOOK_BODY_READ_FAILED", "Webhook body read failed.", 400, headers, ); } const contextRequest = rehydrateWebhookRequest(req, rawBody); const server = await resolveServerSource(options.server); const pipelined = runPipeline(server); if (pipelined) { preflightBodies.set(contextRequest, rawBody); return pipelined(contextRequest); } // Minimal servers without rawRoute(...) — usually test fakes — build // context directly and run the same handler outside the hooks pipeline. const requestLike = toRequestLike(contextRequest); let ctx: Ctx; try { ctx = await server.createRequestContext(requestLike); } catch { return operationalErrorJson( "WEBHOOK_CONTEXT_FAILED", "Webhook context failed.", 500, headers, ); } return handleWebhookRequest({ req: requestLike, ctx, rawBody, }); }, }; } /** * Create a Next.js POST handler for provider-verified payment webhooks. * * The route checks the provider signature header and bounds the raw request * body before resolving the server, then builds app context from a rehydrated * request through * `server.createRequestContext(...)`, verifies the event through * `ctx.ports.payments`, and then delegates fulfillment to app code. This is the canonical helper for Beignet * billing flows backed by `PaymentsPort`. Keep this route on the Node.js * runtime when your provider SDK requires Node APIs. */ export function createPaymentWebhookRoute< Ctx extends PaymentWebhookRouteContext, >( options: CreatePaymentWebhookRouteOptions, ): { POST: (req: Request) => Promise; } { const signatureHeader = options.signatureHeader ?? "stripe-signature"; const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes); const preflightBodies = new WeakMap(); const handlePaymentWebhook = async ({ req, ctx, rawBody: providedRawBody, }: { req: HttpRequestLike; ctx: Ctx; rawBody?: string; }): Promise => { const nativeReq = nativeRequestOf(req); const headers = resolveHeaders(options.headers, nativeReq); const signature = new Headers(req.headers).get(signatureHeader); if (!signature) { cancelWebhookRequestBody( req, new Error(`Missing ${signatureHeader} header.`), ); return operationalErrorJson( "PAYMENT_WEBHOOK_SIGNATURE_MISSING", `Missing ${signatureHeader} header.`, 400, headers, ); } let rawBody = providedRawBody ?? preflightBodies.get(nativeReq); preflightBodies.delete(nativeReq); if (rawBody === undefined) { try { rawBody = await readWebhookBody(req, maxBodyBytes); } catch (error) { return error instanceof WebhookBodyTooLargeError ? operationalErrorJson( "PAYMENT_WEBHOOK_BODY_TOO_LARGE", "Payment webhook body exceeds the configured size limit.", 413, headers, ) : operationalErrorJson( "PAYMENT_WEBHOOK_BODY_READ_FAILED", "Payment webhook body read failed.", 400, headers, ); } } let event: PaymentWebhookEvent; try { event = await ctx.ports.payments.verifyWebhook({ rawBody, signature, }); } catch (error) { ctx.ports.logger?.error("Payment webhook verification failed", { error, requestId: ctx.requestId, traceId: ctx.traceId, }); return operationalErrorJson( "PAYMENT_WEBHOOK_VERIFICATION_FAILED", "Payment webhook verification failed.", 400, headers, ); } try { const result = await options.handle?.({ req: nativeReq, ctx, rawBody, signature, event, }); if (result instanceof Response) return result; if (result) { return toWebResponse({ status: result.status ?? 200, headers: toHttpResponseHeaders(result.headers ?? headers), body: result.body, }); } return toWebResponse({ status: 200, headers: toHttpResponseHeaders(headers), body: { ok: true, eventId: event.id, eventType: event.type, }, }); } catch (error) { ctx.ports.logger?.error("Payment webhook handler failed", { error, requestId: ctx.requestId, traceId: ctx.traceId, eventId: event.id, eventType: event.type, }); return operationalErrorJson( "PAYMENT_WEBHOOK_HANDLER_FAILED", "Payment webhook handler failed.", 500, headers, ); } }; const runPipeline = createRawPipelineRunner( { name: options.pipeline?.name ?? "webhook.payments", method: "POST", path: options.pipeline?.path ?? "/webhooks/payments", metadata: options.pipeline?.metadata, }, handlePaymentWebhook, ); return { POST: async (req) => { const headers = resolveHeaders(options.headers, req); if (!req.headers.get(signatureHeader)) { if (req.body) { cancelWebhookBody( req.body, new Error(`Missing ${signatureHeader} header.`), ); } return operationalErrorJson( "PAYMENT_WEBHOOK_SIGNATURE_MISSING", `Missing ${signatureHeader} header.`, 400, headers, ); } let rawBody: string; try { rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes); } catch (error) { return error instanceof WebhookBodyTooLargeError ? operationalErrorJson( "PAYMENT_WEBHOOK_BODY_TOO_LARGE", "Payment webhook body exceeds the configured size limit.", 413, headers, ) : operationalErrorJson( "PAYMENT_WEBHOOK_BODY_READ_FAILED", "Payment webhook body read failed.", 400, headers, ); } const contextRequest = rehydrateWebhookRequest(req, rawBody); const server = await resolveServerSource(options.server); const pipelined = runPipeline(server); if (pipelined) { preflightBodies.set(contextRequest, rawBody); return pipelined(contextRequest); } // Minimal servers without rawRoute(...) — usually test fakes — build // context directly and run the same handler outside the hooks pipeline. const requestLike = toRequestLike(contextRequest); let ctx: Ctx; try { ctx = await server.createRequestContext(requestLike); } catch { return operationalErrorJson( "PAYMENT_WEBHOOK_CONTEXT_FAILED", "Payment webhook context failed.", 500, headers, ); } return handlePaymentWebhook({ req: requestLike, ctx, rawBody, }); }, }; } /** * Create Next.js route handlers that trigger one registered schedule. * * This helper is intended for serverless cron routes. It authenticates the * caller with a timing-safe bearer comparison, builds app context from the * real request through `server.createRequestContext(...)`, runs the schedule * inline, and records `schedule` events through the resolved * provider instrumentation port (`ports.instrumentation`, then * `ports.devtools`) when one is installed. */ export function createScheduleRoute( options: CreateScheduleRouteOptions, ): { GET: (req: Request) => Promise; POST: (req: Request) => Promise; } { const schedule = options.schedules.find( (candidate) => candidate.name === options.schedule, ); if (!schedule) { const available = options.schedules .map((candidate) => candidate.name) .sort(); throw new Error( available.length > 0 ? `Unknown schedule "${options.schedule}". Available schedules: ${available.join(", ")}.` : `Unknown schedule "${options.schedule}". No schedules were passed to createScheduleRoute(...).`, ); } const runSchedule = async ({ req, ctx, }: { req: HttpRequestLike; ctx: Ctx; }): Promise => { const nativeReq = nativeRequestOf(req); const headers = resolveHeaders(options.headers, nativeReq); try { const runner = createInlineScheduleRunner({ ctx, instrumentation: ctx.ports, instrumentationContext: { requestId: ctx.requestId, traceId: ctx.traceId, }, onError({ error }) { ctx.ports.logger?.error("Schedule failed", { error, scheduleName: schedule.name, }); }, }); await runner.run(schedule, { source: options.source ?? "next-cron-route", }); return routeJson( { ok: true, scheduleName: schedule.name, }, 200, headers, ); } catch (error) { await tryReportException({ reporter: ctx.ports.errorReporter, error, reportOptions: { level: "error", mechanism: "beignet.schedule.next", handled: false, requestId: ctx.requestId, traceId: ctx.traceId, tags: { "beignet.kind": "schedule", "beignet.schedule": schedule.name, }, contexts: { schedule: { name: schedule.name, source: options.source ?? "next-cron-route", }, }, }, }); return operationalErrorJson( "SCHEDULE_FAILED", "Schedule failed.", 500, headers, { scheduleName: schedule.name }, ); } }; const pipelineInit = { name: options.pipeline?.name ?? `schedule.${schedule.name}`, path: options.pipeline?.path ?? `/schedules/${schedule.name}`, metadata: options.pipeline?.metadata, }; const pipelineRunners = { GET: createRawPipelineRunner( { ...pipelineInit, method: "GET" }, runSchedule, ), POST: createRawPipelineRunner( { ...pipelineInit, method: "POST" }, runSchedule, ), }; const runScheduleFromRequest = async (req: Request): Promise => { const headers = resolveHeaders(options.headers, req); const secret = options.secret; if (!secret) { return operationalErrorJson( "CRON_SECRET_NOT_CONFIGURED", "CRON_SECRET is not configured.", 500, headers, { scheduleName: schedule.name }, ); } if (!(await isAuthorizedCronRequest(req, secret))) { return operationalErrorJson("UNAUTHORIZED", "Unauthorized", 401, headers); } const server = await resolveServerSource(options.server); const runner = pipelineRunners[req.method.toUpperCase() === "GET" ? "GET" : "POST"]; const pipelined = runner(server); if (pipelined) return pipelined(req); let ctx: Ctx; try { ctx = await server.createRequestContext(toRequestLike(req)); } catch { return operationalErrorJson( "SCHEDULE_FAILED", "Schedule failed.", 500, headers, { scheduleName: schedule.name }, ); } return runSchedule({ req: toRequestLike(req), ctx }); }; return { GET: runScheduleFromRequest, POST: runScheduleFromRequest, }; } /** * Create a Beignet server adapted for Next.js. * * The returned `api` handler is intended for a catch-all route. Focused routes * such as OpenAPI, Swagger UI, devtools, and public storage can use their own * route helpers. */ export async function createNextServer< Ctx, Ports extends AnyPorts = AnyPorts, ServiceInput = void, Routes extends readonly RouteDef[] = readonly RouteDef[], Providers extends readonly AnyProvider[] = readonly AnyProvider[], >( options: CreateServerOptions, ): Promise< NextServer, ServiceInput> > { const runtime = await createServer(options); return { api: createFetchHandler(runtime), route: (contract) => ({ handle: (fn) => createFetchHandler(runtime.route(contract).handle(fn)), }), rawRoute: (init) => ({ handle: (fn) => createFetchHandler(runtime.rawRoute(init).handle(fn)), }), createContextFromNext: async () => { // "next" ships no package exports map, so Node-style ESM resolution // (NodeNext) needs the explicit .js subpath. Bundlers resolve it the same. const { cookies, headers } = await import("next/headers.js"); const h = new Headers(await headers()); const c = await cookies(); if (!h.has("cookie")) { const cookieHeader = c .getAll() .map(({ name, value }) => `${name}=${encodeURIComponent(value)}`) .join("; "); if (cookieHeader) { h.set("cookie", cookieHeader); } } // Minimal Request-like object for Next.js Server Components. // Note: Server Components don't have a real HTTP URL; this is a synthetic placeholder. // Do not rely on `url` for routing, security, or network behavior. // Extended with cookies property for easier access in context factories. // The json() and text() methods return empty values since there's no actual HTTP request body. const req: HttpRequestLike & { cookies?: { get: (key: string) => string | undefined }; } = { method: "GET", // Use a clearly non-real URL to avoid implying a real network endpoint. url: "http://core/server-component.invalid", headers: h, // Server Components don't have a request body, so these return empty values json: async () => ({}), text: async () => "", arrayBuffer: async () => new ArrayBuffer(0), blob: async () => new Blob(), formData: async () => new FormData(), clone: () => req, cookies: { get: (key: string) => c.get(key)?.value, }, }; return runtime.createRequestContext(req); }, createRequestContext: runtime.createRequestContext, createServiceContext: runtime.createServiceContext, runServiceContext: runtime.runServiceContext, contracts: runtime.contracts, stop: () => runtime.stop(), ports: runtime.ports, }; }