UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

478 lines 21.7 kB
import { 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 { isUnboundPort } from "../ports/index.js"; import { createContextFinalizer, resolveServerContext } from "./context.js"; import { resolveContract } from "./contract-like.js"; import { createServerInstrumentation } from "./instrumentation.js"; import { loadProviderConfig } from "./providers/index.js"; import { enterActiveRequestContext, readContextActor, readContextTenant, runWithActiveRequestContext, } from "./request-context.js"; import { buildHandler, createMemoScopeRecorder, createRequestExecutor, } from "./request-executor.js"; import { errorResponse } from "./response-finalization.js"; import { contractsFromRoutes, createRoutes, defineRoutes, } from "./route-definitions.js"; import { compareRouteSpecificity, compilePath, } from "./route-matching.js"; import { runRuntimeIntegrityCheck } from "./runtime-integrity.js"; import { resolveTrustedRequest, } from "./trusted-proxy.js"; import { assertValidTrustedProxyConfig, parseHttpRequestUrl, } from "./trusted-proxy-internal.js"; import { createUseCaseRouteHandler, isUseCaseRouteDef, } from "./use-case-route.js"; export { contractsFromRoutes, createRoutes, defineRoutes }; function copyTrustedRequestInfo(requestInfo) { return { ...requestInfo, url: new URL(requestInfo.url), }; } /** * 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(options) { const registry = []; // 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; const providers = (options.providers ?? []); const env = options.providerEnv ?? process.env; const overrides = options.providerConfig ?? {}; const providerResults = []; const finalPorts = { ...options.ports }; const instrumentation = createServerInstrumentation(options.instrumentation); const hooks = [ instrumentation.hook, ...(options.hooks ?? []), ]; 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(options.context); const finalizeContext = createContextFinalizer(resolvedContext, () => finalPorts); const createRequestContext = async (req, contract, requestInfo = resolveTrustedRequest(req, trustedProxy)) => { const { requestId, trace } = instrumentation.prepareRequest(req); return finalizeContext(await resolvedContext.request({ req, requestInfo: copyTrustedRequestInfo(requestInfo), ports: finalPorts, contract, requestId, trace, })); }; const createServiceContext = async (...args) => { 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 = { 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], requestId, trace, })); ambient.actor = readContextActor(ctx); ambient.tenant = readContextTenant(ctx); return ctx; }; const runServiceContext = async (...args) => { const serviceFactory = resolvedContext.service; if (!serviceFactory) { throw new Error("Define context.service in createServer(...) to create service contexts."); } const fn = args[args.length - 1]; const input = (args.length > 1 ? args[0] : undefined); const { requestId, trace } = instrumentation.createServiceCorrelation(); const ambient = { 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) => resolveTrustedRequest(req, trustedProxy), }; let serviceContextsAvailable = false; const lifecycleCreateServiceContext = async (input) => { if (!serviceContextsAvailable) { throw new Error("Service contexts are unavailable during provider setup and after provider shutdown."); } return createServiceContext(...[input]); }; let stopped = false; const stop = async () => { if (stopped) return; stopped = true; const errors = []; try { 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); } } } finally { serviceContextsAvailable = false; } if (errors.length) { throw new AggregateError(errors, "Provider shutdown errors"); } }; const registeredPaths = new Set(); const registeredShapes = new Map(); const registeredNames = new Map(); const registeredOperationIds = new Map(); const registerRoute = (contract, handler, routeHooks = [], responseValidationExemptStatus) => { 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 }; } const match = compiled.pattern.exec(pathname); if (!match) return { matched: false }; return { matched: true }; }, }); registryNeedsSort = true; }; 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(contract, route); registerRoute(contract, handler, route.hooks, responseValidationExemptStatus); } else { registerRoute(contract, route.handle, route.hooks); } } } 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); } const onUnboundPorts = options.onUnboundPorts ?? "error"; if (onUnboundPorts !== "ignore") { const unboundKeys = Object.keys(finalPorts).filter((key) => isUnboundPort(finalPorts[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}`); } } // Every provider has now contributed its ports and the unbound-port guard // has passed. Start hooks may safely build service contexts while they // activate consumers such as event listeners. serviceContextsAvailable = true; for (const result of providerResults) { if (!result.start) continue; await result.start({ ports: finalPorts, createServiceContext: lifecycleCreateServiceContext, }); } instrumentation.attachPorts(finalPorts); } 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(options, finalPorts, contextRuntime, hooks, [], { skipRoutePreparation: true, }); const fallbackContract = (name, method, path) => ({ kind: "http", name, method: method, path, pathParams: null, query: null, queryTransport: null, body: null, responses: {}, metadata: {}, }); const notFoundHandler = async () => errorResponse(404, "NOT_FOUND", "Not found"); const api = async (req) => { if (registryNeedsSort) { registry.sort((a, b) => compareRouteSpecificity(a.compiled, b.compiled)); registryNeedsSort = false; } const method = req.method.toUpperCase(); let url; try { url = parseHttpRequestUrl(req.url); } catch (error) { return await executeFallback({ contract: fallbackContract("invalidRequestUrl", method, "/"), handler: async () => { throw error; }, }, req, {}); } let pathMatchedMethods; let headCandidate; 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 { handle: (fn) => { const wrapped = buildHandler(options, finalPorts, contextRuntime, contract, fn, hooks); registerRoute(contract, fn); const compiled = compilePath(contract.path); return async (req) => { const method = req.method.toUpperCase(); let url; try { url = parseHttpRequestUrl(req.url); } catch (error) { return await executeFallback({ contract: fallbackContract("invalidRequestUrl", method, "/"), handler: async () => { throw error; }, }, req, {}); } const requestMethod = method; const contractMethod = contract.method.toUpperCase(); const methodMatches = requestMethod === contractMethod || (requestMethod === "HEAD" && contractMethod === "GET"); if (!methodMatches || !compiled.pattern.test(url.pathname)) { const pathname = url.pathname || "/"; return await executeFallback({ contract: fallbackContract("notFound", method, pathname), handler: notFoundHandler, }, req, {}); } return await wrapped(req); }; }, }; }, rawRoute: (init) => ({ handle: (fn) => { const built = buildHandler(options, finalPorts, contextRuntime, rawRouteContract(init), fn, hooks, (init.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) { return { kind: "http", name: init.name, method: init.method, path: init.path, pathParams: null, query: null, queryTransport: null, body: null, responses: {}, metadata: init.metadata ?? {}, }; } //# sourceMappingURL=server.js.map