@beignet/core
Version:
Core framework primitives for Beignet
478 lines • 21.7 kB
JavaScript
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