@beignet/core
Version:
Core framework primitives for Beignet
232 lines (216 loc) • 7 kB
text/typescript
import type { HttpContractConfig } from "../contracts/index.js";
import type {
AnyPorts,
BoundGate,
GatePort,
PolicyDefinition,
} from "../ports/index.js";
import type { TraceContext } from "../tracing/index.js";
import type { HttpRequestLike, MaybePromise } from "./http.js";
import type { TrustedRequestInfo } from "./trusted-proxy.js";
/**
* Arguments passed to the `context.request` factory after a route is matched.
*/
export type RequestContextArgs<Ports extends AnyPorts = AnyPorts> = {
/**
* Framework-neutral request.
*/
req: HttpRequestLike;
/**
* Request metadata resolved from the server's explicit trusted-proxy policy.
* Forwarding headers never affect this value unless
* `createServer({ trustedProxy })` opts into them.
*/
requestInfo: TrustedRequestInfo;
/**
* Final app ports, including ports contributed by providers.
*/
ports: Ports;
/**
* Matched contract, when the request resolved to a registered route.
*/
contract?: HttpContractConfig;
/**
* Request correlation ID resolved by the server from the configured request
* ID header, or generated when the request did not send one.
*/
requestId: string;
/**
* W3C trace context resolved by the server from the incoming `traceparent`
* header, or generated when the request did not send one. Spread this into
* the context (`...trace`) to correlate downstream activity.
*/
trace: TraceContext;
};
/**
* Arguments passed to the `context.service` factory.
*/
export type ServiceContextArgs<
Ports extends AnyPorts = AnyPorts,
ServiceInput = void,
> = {
/**
* Final app ports, including ports contributed by providers.
*/
ports: Ports;
/**
* Caller-provided input such as the service actor or tenant.
*/
input: ServiceInput;
/**
* Fresh correlation ID generated for this service context.
*/
requestId: string;
/**
* Fresh W3C trace context generated for this service context. Spread this
* into the context (`...trace`) to correlate downstream activity.
*/
trace: TraceContext;
};
/**
* App context without its `gate` property.
*
* Context factories return seeds; the server attaches the gate declared by
* the blueprint's `gate` selector, so hand-binding `gate` in a factory is a
* type error.
*/
export type ContextSeed<Ctx> = Omit<Ctx, "gate"> & { gate?: never };
type ContextGateOption<Ctx, Ports extends AnyPorts> = [Ctx] extends [
{ gate: BoundGate<infer TPolicies extends readonly PolicyDefinition[]> },
]
? {
/**
* Select the gate port the server attaches to every context it builds.
*/
gate: (ports: Ports) => GatePort<ContextSeed<Ctx>, TPolicies>;
}
: {
/**
* Gate selection is only available when the context type declares a
* `gate` property.
*/
gate?: never;
};
/**
* Context blueprint accepted by `createServer(...)`.
*
* The server owns context assembly: `request` and `service` return context
* seeds, and the server attaches the gate declared by `gate` so identity
* changes can never authorize against a stale context.
*/
export type ServerContextOptions<
Ctx,
Ports extends AnyPorts = AnyPorts,
ServiceInput = void,
> = {
/**
* Build the per-request context seed.
*/
request: (args: RequestContextArgs<Ports>) => MaybePromise<ContextSeed<Ctx>>;
/**
* Build a service context seed for schedules, outbox drains, tasks, and
* background work. Required before `server.createServiceContext(...)` can
* be called.
*/
service?: (
args: ServiceContextArgs<Ports, ServiceInput>,
) => MaybePromise<ContextSeed<Ctx>>;
} & ContextGateOption<Ctx, Ports>;
/**
* Context configuration accepted by `createServer(...)`.
*
* Contexts without a `gate` property may use the plain request-factory
* shorthand. Contexts with a `gate` must use the blueprint form so the server
* owns gate attachment.
*/
export type ServerContextConfig<
Ctx,
Ports extends AnyPorts = AnyPorts,
ServiceInput = void,
> = [Ctx] extends [{ gate: unknown }]
? ServerContextOptions<Ctx, Ports, ServiceInput>
:
| ((args: RequestContextArgs<Ports>) => MaybePromise<Ctx>)
| ServerContextOptions<Ctx, Ports, ServiceInput>;
/**
* Argument tuple for `createServiceContext(...)`.
*
* Servers without a declared service input are callable with no arguments;
* optional inputs stay optional at the call site.
*/
export type ServiceContextInputArgs<ServiceInput> = [ServiceInput] extends [
// biome-ignore lint/suspicious/noConfusingVoidType: void marks "no declared service input" here
void,
]
? []
: undefined extends ServiceInput
? [input?: ServiceInput]
: [input: ServiceInput];
type AnyGateSelector<Ports extends AnyPorts> = (
ports: Ports,
) => Pick<GatePort<unknown>, "attach">;
/**
* Normalized runtime view of a server context configuration.
*/
export type ResolvedServerContext<Ctx, Ports extends AnyPorts, ServiceInput> = {
request: (
args: RequestContextArgs<Ports>,
) => MaybePromise<ContextSeed<Ctx> | Ctx>;
service?: (
args: ServiceContextArgs<Ports, ServiceInput>,
) => MaybePromise<ContextSeed<Ctx>>;
gate?: AnyGateSelector<Ports>;
};
/**
* Normalize a server context configuration into its runtime parts.
*/
export function resolveServerContext<Ctx, Ports extends AnyPorts, ServiceInput>(
config: ServerContextConfig<Ctx, Ports, ServiceInput>,
): ResolvedServerContext<Ctx, Ports, ServiceInput> {
const value = config as
| ((args: RequestContextArgs<Ports>) => MaybePromise<Ctx>)
| (ResolvedServerContext<Ctx, Ports, ServiceInput> & {
gate?: AnyGateSelector<Ports>;
});
if (typeof value === "function") {
return { request: value };
}
return {
request: value.request,
service: value.service,
gate: value.gate,
};
}
/**
* Create the context finalizer for a resolved server context.
*
* When the blueprint declares a gate, the finalizer strips hand-assigned
* `gate` values from seeds and attaches the live gate. Without a gate
* declaration the seed is the context.
*/
export function createContextFinalizer<
Ctx,
Ports extends AnyPorts,
ServiceInput,
>(
resolved: ResolvedServerContext<Ctx, Ports, ServiceInput>,
getPorts: () => Ports,
): (seed: ContextSeed<Ctx> | Ctx) => Ctx {
const gateSelector = resolved.gate;
if (!gateSelector) {
return (seed) => seed as Ctx;
}
return (seed) => {
const target = seed as object;
const existing = Object.getOwnPropertyDescriptor(target, "gate");
if (existing && existing.get === undefined) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[beignet] Ignoring a hand-assigned ctx.gate value. The server context blueprint owns gate attachment; remove `gate` from context factories and hook additions.",
);
}
delete (target as { gate?: unknown }).gate;
}
return gateSelector(getPorts()).attach(target) as Ctx;
};
}