UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

209 lines 8.45 kB
/** * Idempotency hooks for @beignet/core/server */ import { tryReportException, } from "../../error-reporting/index.js"; import { AppError, httpErrors } from "../../errors/index.js"; import { createIdempotencyFingerprint, IdempotencyConflictError, IdempotencyInProgressError, } from "../../idempotency/index.js"; import { AuthUnauthorizedError, TenantRequiredError, } from "../../ports/index.js"; import { responseFinalizerHook, } from "../internal-hooks.js"; import { finalizeResponse } from "../response-finalization.js"; /** * Header set on replayed responses. */ const IDEMPOTENCY_REPLAYED_HEADER = "idempotency-replayed"; function defaultIdempotencyScope(ctx, meta) { 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) { if (typeof value !== "object" || value === null) return false; const candidate = value; 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(options = {}) { const pending = new WeakMap(); const hooks = { 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.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; } //# sourceMappingURL=idempotency.js.map