UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

345 lines (318 loc) 9.62 kB
/** * Idempotency hooks for @beignet/core/server */ import { type ErrorReporterPort, tryReportException, } from "../../error-reporting/index.js"; import { AppError, httpErrors } from "../../errors/index.js"; import { createIdempotencyFingerprint, IdempotencyConflictError, IdempotencyInProgressError, type IdempotencyMeta, type IdempotencyPort, type IdempotencyScope, } from "../../idempotency/index.js"; import { type ActivityActor, type ActivityTenant, AuthUnauthorizedError, TenantRequiredError, } from "../../ports/index.js"; import { type ResponseFinalizerServerHook, responseFinalizerHook, } from "../internal-hooks.js"; import { finalizeResponse } from "../response-finalization.js"; import type { HttpRequestLike, HttpResponseLike, ServerHook, } from "../types.js"; /** * Ports required by idempotency hooks. */ export type IdempotencyPorts = { idempotency: IdempotencyPort; }; type OptionalErrorReporterPorts = IdempotencyPorts & { errorReporter?: ErrorReporterPort; }; /** * Minimal context shape required for actor- and tenant-scoped idempotency. */ export type CtxWithIdempotency = { ports: IdempotencyPorts; actor?: ActivityActor; tenant?: ActivityTenant; }; /** * Options for `createIdempotencyHooks(...)`. */ export interface IdempotencyHooksOptions<Ctx> { /** * Build the idempotency namespace for a contract. * * Defaults to `http.<contract name>` so HTTP reservations never collide with * use-case `runIdempotently(...)` namespaces. */ namespace?: (args: { contract: { name: string } }) => string; /** * Build the idempotency scope after context exists. * * Defaults to a scope derived from `meta.scope`: omitted metadata scopes by * `ctx.actor?.id` and also `ctx.tenant?.id` when present, `"actor"` scopes by * the actor only, `"global"` stays global, `"tenant"` scopes by the tenant, * and `"actor-tenant"` scopes by both. */ scope?: (args: { ctx: Ctx; req: HttpRequestLike; meta: IdempotencyMeta; }) => IdempotencyScope; /** * Build the fingerprint input from the parsed request. * * Defaults to `{ path, query, body }`. */ fingerprintInput?: (args: { path: unknown; query: unknown; body: unknown; }) => unknown; } /** * Header set on replayed responses. */ const IDEMPOTENCY_REPLAYED_HEADER = "idempotency-replayed"; type PendingReservation = { port: IdempotencyPort; namespace: string; key: string; scope: IdempotencyScope; fingerprint: string; reservationToken: string; }; function defaultIdempotencyScope( ctx: CtxWithIdempotency, meta: IdempotencyMeta, ): IdempotencyScope { const mode = meta.scope ?? (ctx.tenant?.id ? "actor-tenant" : "actor"); switch (mode) { case "global": return "global"; case "actor": if (!ctx.actor?.id) throw new AuthUnauthorizedError(); return { actorId: ctx.actor.id }; case "tenant": if (!ctx.tenant?.id) throw new TenantRequiredError(); return { tenantId: ctx.tenant.id }; case "actor-tenant": if (!ctx.actor?.id) throw new AuthUnauthorizedError(); if (!ctx.tenant?.id) throw new TenantRequiredError(); return { actorId: ctx.actor.id, tenantId: ctx.tenant.id }; } } function isReplayableHttpResponse(value: unknown): value is HttpResponseLike { if (typeof value !== "object" || value === null) return false; const candidate = value as { status?: unknown; headers?: unknown }; if (typeof candidate.status !== "number") return false; if ( candidate.headers !== undefined && (typeof candidate.headers !== "object" || candidate.headers === null || Array.isArray(candidate.headers)) ) { return false; } return true; } /** * Create metadata-driven idempotency hooks. * * The hook reads `contract.metadata.idempotency` and enforces it with * `ctx.ports.idempotency`. In `beforeHandle` it reserves the client key after * request parsing and route hook identity resolution, replays completed matching * responses with an `idempotency-replayed: true` header, and rejects in-progress * or conflicting keys with the framework * `IdempotencyInProgress`/`IdempotencyConflict` catalog errors. After the * response-validation phase, it stores final route-owned 2xx framework-neutral * responses for replay and releases the reservation for framework-owned * responses, errors, non-2xx responses, and native `Response` results, which * are not replayable. * * Use `runIdempotently(...)` from `@beignet/core/idempotency` for non-HTTP * workflows such as jobs, listeners, webhooks, and schedules. * * @param options - Optional namespace, scope, and fingerprint-input builders. * @returns A server hook backed by `ctx.ports.idempotency`. */ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>( options: IdempotencyHooksOptions<Ctx> = {}, ): ServerHook<Ctx, IdempotencyPorts> { const pending = new WeakMap<HttpRequestLike, PendingReservation>(); const hooks: ServerHook<Ctx, IdempotencyPorts> & ResponseFinalizerServerHook<Ctx> = { name: "idempotency", beforeHandle: async ({ ctx, contract, req, path, query, body }) => { const meta = contract.metadata?.idempotency; if (!meta) { return undefined; } const header = (meta.header ?? "idempotency-key").toLowerCase(); const key = req.headers.get(header); if (!key) { if (meta.required) { throw new AppError( httpErrors.BadRequest, { contract: contract.name, header, }, `Missing required idempotency key header "${header}"`, ); } return undefined; } const namespace = options.namespace?.({ contract: { name: contract.name } }) ?? `http.${contract.name}`; const scope = options.scope?.({ ctx, req, meta }) ?? defaultIdempotencyScope(ctx, meta); const fingerprint = await createIdempotencyFingerprint( options.fingerprintInput?.({ path, query, body }) ?? { path, query, body, }, ); const reservation = await ctx.ports.idempotency.reserve({ namespace, key, scope, fingerprint, ttlSec: meta.ttlSec, reservationTtlSec: meta.reservationTtlSec, }); switch (reservation.status) { case "replay": { if (!isReplayableHttpResponse(reservation.result)) { throw new AppError( httpErrors.InternalServerError, { namespace, key }, `Stored idempotency result for "${namespace}" key "${key}" is not a replayable HTTP response`, ); } const validated = await finalizeResponse( contract, reservation.result, ); return { status: validated.status, headers: { ...(validated.headers ?? {}), [IDEMPOTENCY_REPLAYED_HEADER]: "true", }, body: validated.body, }; } case "inProgress": { throw new IdempotencyInProgressError(reservation); } case "conflict": { throw new IdempotencyConflictError(reservation); } case "reserved": { pending.set(req, { port: ctx.ports.idempotency, namespace, key, scope, fingerprint, reservationToken: reservation.reservationToken, }); return undefined; } } }, [responseFinalizerHook]: async ({ req, ctx, response, error, native, owner, responseValidation, }) => { const reservation = pending.get(req); if (!reservation) { return undefined; } pending.delete(req); const { port, namespace, key, scope, fingerprint, reservationToken } = reservation; if ( owner === "route" && responseValidation === "validated" && !native && !error && response.status >= 200 && response.status < 300 ) { await port.complete({ namespace, key, scope, fingerprint, reservationToken, result: { status: response.status, headers: response.headers, body: response.body, }, }); return undefined; } // Framework-owned responses, errors, non-2xx responses, and native // `Response` results release the reservation. Streams are not replayable. try { await port.fail({ namespace, key, scope, fingerprint, reservationToken, error, }); } catch (settlementError) { if (error === undefined) { throw settlementError; } await tryReportException({ reporter: ctx ? (ctx.ports as OptionalErrorReporterPorts).errorReporter : undefined, error: settlementError, reportOptions: { level: "error", mechanism: "beignet.idempotency.settlement", handled: true, tags: { "idempotency.namespace": namespace, }, contexts: { idempotency: { namespace, key, }, }, }, }); } return undefined; }, }; return hooks; }