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