lambder 3.4.2 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ import { LambderApiError } from "./LambderApiError.js";
2
+ import { LambderResponse } from "./LambderResponse.js";
3
+ /** A crashed original must not block retries forever: pending claims expire on their own. */
4
+ const IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
5
+ /** Responses above this size skip replay storage (DynamoDB item limit is 400KB). */
6
+ const IDEMPOTENCY_MAX_STORED_BODY_BYTES = 350_000;
7
+ const RATE_LIMIT_WINDOW_KEYS = ["perMin", "per10Min", "perHour", "perDay", "perWeek", "perMonth"];
8
+ const toList = (value) => value === undefined ? [] : typeof value === "string" ? [value] : value;
9
+ /**
10
+ * Runtime side of the declarative API options: holds what the enable/define
11
+ * calls declared, asserts registrations against it at startup, and executes
12
+ * rate limits, guards, and idempotency around handlers at request time.
13
+ * Internal to Lambder; apps interact through enableApiRateLimits(),
14
+ * enableApiIdempotency(), defineApiGuards() and the per-API options.
15
+ */
16
+ export class LambderApiPolicyEngine {
17
+ limiter = null;
18
+ rateLimitPolicies = {};
19
+ guards = {};
20
+ idempotencyStore = null;
21
+ idempotencyDefaultTtlSeconds = 24 * 3600;
22
+ idempotencyFailOpen = true;
23
+ setRateLimits(config) {
24
+ if (this.limiter)
25
+ throw new Error("Lambder: enableApiRateLimits() was already called.");
26
+ for (const [name, policy] of Object.entries(config.policies)) {
27
+ if (!policy.per)
28
+ throw new Error(`Lambder: rate-limit policy "${name}" is missing its "per" key source.`);
29
+ if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
30
+ throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
31
+ }
32
+ }
33
+ this.limiter = config.limiter;
34
+ this.rateLimitPolicies = { ...config.policies };
35
+ }
36
+ addGuards(guards) {
37
+ for (const [name, guardFn] of Object.entries(guards)) {
38
+ if (this.guards[name])
39
+ throw new Error(`Lambder: guard "${name}" is already defined.`);
40
+ this.guards[name] = guardFn;
41
+ }
42
+ }
43
+ setIdempotency(config) {
44
+ if (this.idempotencyStore)
45
+ throw new Error("Lambder: enableApiIdempotency() was already called.");
46
+ this.idempotencyStore = config.store;
47
+ this.idempotencyDefaultTtlSeconds = config.defaultTtlSeconds ?? 24 * 3600;
48
+ this.idempotencyFailOpen = config.failOpen ?? true;
49
+ }
50
+ /** Startup validation of one API registration's declarative options. */
51
+ assertRegistration(apiName, mode, options) {
52
+ for (const name of toList(options.rateLimit)) {
53
+ const policy = this.rateLimitPolicies[name];
54
+ if (!policy) {
55
+ throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it via enableApiRateLimits() before registering the API.`);
56
+ }
57
+ if (policy.per === "session" && mode !== "session") {
58
+ throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
59
+ }
60
+ }
61
+ for (const name of toList(options.guards)) {
62
+ if (!this.guards[name]) {
63
+ throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Define it via defineApiGuards() before registering the API.`);
64
+ }
65
+ }
66
+ if (options.idempotency !== undefined && !this.idempotencyStore) {
67
+ throw new Error(`Lambder: API "${apiName}" declares idempotency but enableApiIdempotency() was not called first.`);
68
+ }
69
+ }
70
+ /** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
71
+ async runPreflight(apiName, ctx, resolver, options) {
72
+ for (const name of toList(options.rateLimit)) {
73
+ const policy = this.rateLimitPolicies[name];
74
+ if (!policy || !this.limiter)
75
+ throw new Error(`Lambder: rate-limit policy "${name}" is not configured.`);
76
+ const key = await this.resolveRateLimitKey(ctx, policy.per);
77
+ const limited = await this.limiter.isRateLimited(`api|${apiName}|${name}|${key}`, policy);
78
+ if (limited) {
79
+ throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}".`, {
80
+ errorMessage: policy.errorMessage ?? "Too many requests. Please try again later.",
81
+ statusCode: 429,
82
+ });
83
+ }
84
+ }
85
+ for (const name of toList(options.guards)) {
86
+ const guardFn = this.guards[name];
87
+ if (!guardFn)
88
+ throw new Error(`Lambder: guard "${name}" is not configured.`);
89
+ await guardFn(ctx, resolver);
90
+ }
91
+ }
92
+ async resolveRateLimitKey(ctx, per) {
93
+ if (per === "ip")
94
+ return `ip:${ctx.ip}`;
95
+ if (per === "session") {
96
+ const sessionKey = ctx.session?.sessionKey;
97
+ if (!sessionKey)
98
+ throw new Error('Lambder: rate-limit per "session" evaluated without a session on the context.');
99
+ return `session:${sessionKey}`;
100
+ }
101
+ return `custom:${await per(ctx)}`;
102
+ }
103
+ /**
104
+ * Idempotency wrapper around validation-passed handler execution. Without
105
+ * a client idempotencyKey the handler just runs; with one, the scope
106
+ * (identity + api + key) is claimed atomically: duplicates of an
107
+ * in-flight original refuse with 409, replays of a completed one return
108
+ * the stored response verbatim, and a crashed original releases its claim
109
+ * so a retry actually retries.
110
+ */
111
+ async withIdempotency(apiName, ctx, config, exec) {
112
+ const store = this.idempotencyStore;
113
+ const rawKey = ctx.post?.idempotencyKey;
114
+ if (!store || rawKey === undefined || rawKey === null)
115
+ return await exec();
116
+ if (typeof rawKey !== "string" || rawKey.length < 1 || rawKey.length > 200) {
117
+ throw new LambderApiError("Invalid idempotency key.", { statusCode: 400 });
118
+ }
119
+ const ttlSeconds = (typeof config === "object" ? config.ttlSeconds : undefined) ?? this.idempotencyDefaultTtlSeconds;
120
+ // Scoped per identity so clients cannot collide with or poison each other's keys.
121
+ const sessionKey = ctx.session?.sessionKey;
122
+ const scopeKey = `${sessionKey ? `s:${sessionKey}` : `ip:${ctx.ip}`}|${apiName}|${rawKey}`;
123
+ let begun;
124
+ try {
125
+ begun = await store.begin(scopeKey, { pendingTtlSeconds: IDEMPOTENCY_PENDING_TTL_SECONDS });
126
+ }
127
+ catch (err) {
128
+ if (this.idempotencyFailOpen)
129
+ return await exec();
130
+ throw err;
131
+ }
132
+ if (begun.state === "pending") {
133
+ throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
134
+ statusCode: 409,
135
+ errorMessage: "This request is already being processed.",
136
+ });
137
+ }
138
+ if (begun.state === "done") {
139
+ return new LambderResponse({
140
+ statusCode: begun.statusCode,
141
+ headers: begun.contentType ? { "Content-Type": begun.contentType } : {},
142
+ body: begun.body,
143
+ });
144
+ }
145
+ const ownerToken = begun.ownerToken;
146
+ // Store the response for replays when it qualifies, release the claim
147
+ // otherwise. Settle failures only surface when failing closed.
148
+ const settleClaim = async (response) => {
149
+ const cacheable = response.statusCode < 500
150
+ && typeof response.body === "string"
151
+ && !response.isBodyBase64
152
+ && response.body.length <= IDEMPOTENCY_MAX_STORED_BODY_BYTES;
153
+ try {
154
+ if (cacheable) {
155
+ await store.complete(scopeKey, ownerToken, {
156
+ statusCode: response.statusCode,
157
+ contentType: response.getHeader("Content-Type")?.[0] ?? null,
158
+ body: response.body,
159
+ ttlSeconds,
160
+ });
161
+ }
162
+ else {
163
+ await store.abandon(scopeKey, ownerToken);
164
+ }
165
+ }
166
+ catch (storeErr) {
167
+ if (!this.idempotencyFailOpen)
168
+ throw storeErr;
169
+ }
170
+ };
171
+ try {
172
+ const response = await exec();
173
+ await settleClaim(response);
174
+ return response;
175
+ }
176
+ catch (err) {
177
+ // A thrown LambderResponse IS the response (res.die.*, throw
178
+ // res.api(...)): settle the claim like a returned one so its side
179
+ // effect replays, then rethrow so the pipeline emits it.
180
+ if (err instanceof LambderResponse) {
181
+ await settleClaim(err);
182
+ throw err;
183
+ }
184
+ // A real crash (or a refusal like LambderApiError) releases the
185
+ // claim so a retry actually retries.
186
+ try {
187
+ await store.abandon(scopeKey, ownerToken);
188
+ }
189
+ catch (cleanupErr) {
190
+ if (!this.idempotencyFailOpen)
191
+ throw cleanupErr;
192
+ }
193
+ throw err;
194
+ }
195
+ }
196
+ }
@@ -24,10 +24,60 @@ type FetchEndEventHandler = (params: {
24
24
  type ErrorHandler = (err: Error) => void | Promise<void>;
25
25
  type ValidationErrorHandler = (zodError: z.ZodError) => (void | false) | Promise<(void | false)>;
26
26
  type MessageHandler = (message: any) => void | Promise<void>;
27
+ export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
28
+ /**
29
+ * Discriminated result of an API call: `ok: true` carries the payload, every
30
+ * failure carries a machine-readable reason, so "the server returned null"
31
+ * and "the request failed" are never conflated.
32
+ */
33
+ export type LambderApiOutcome<T> = {
34
+ ok: true;
35
+ payload: T | null | undefined;
36
+ response: LambderApiResponse<T>;
37
+ } | {
38
+ ok: false;
39
+ reason: LambderApiFailureReason;
40
+ /** HTTP status, when a response was received. */
41
+ status?: number;
42
+ /** Envelope errorMessage, when the server provided one. */
43
+ errorMessage?: any;
44
+ /** Underlying Error for network/timeout/server/unknown failures. */
45
+ error?: Error;
46
+ /** Zod issue detail for 'validation'. */
47
+ zodError?: z.ZodError;
48
+ /** The parsed envelope, when one was received (protocol-level failures). */
49
+ response?: LambderApiResponse<T>;
50
+ };
51
+ /** Per-call options: request extras plus overrides for every constructor handler. */
52
+ export type LambderCallOptions = {
53
+ headers?: Record<string, any>;
54
+ /** Abort the request after this many ms; overrides the constructor default. */
55
+ timeoutMs?: number;
56
+ /** External abort signal, combined with the timeout when both are set. */
57
+ signal?: AbortSignal;
58
+ /**
59
+ * Replay-protection key for APIs declared idempotent on the server.
60
+ * Generate once per logical operation (e.g. crypto.randomUUID() when the
61
+ * form opens) and send the same key on retries: duplicates of an
62
+ * in-flight request refuse, and repeats of a completed one replay its
63
+ * stored response instead of re-executing.
64
+ */
65
+ idempotencyKey?: string;
66
+ versionExpiredHandler?: VoidFunction;
67
+ sessionExpiredHandler?: VoidFunction;
68
+ messageHandler?: MessageHandler;
69
+ errorMessageHandler?: MessageHandler;
70
+ apiInputValidationErrorHandler?: ValidationErrorHandler;
71
+ notAuthorizedHandler?: VoidFunction;
72
+ errorHandler?: ErrorHandler;
73
+ fetchStartedHandler?: FetchStartEventHandler;
74
+ fetchEndedHandler?: FetchEndEventHandler;
75
+ };
27
76
  export default class LambderCaller<TContract extends ApiContractShape = any> {
28
77
  private isCorsEnabled;
29
78
  private apiPath;
30
79
  private apiVersion?;
80
+ private timeoutMs?;
31
81
  fetchTrackerList: FetchTracker[];
32
82
  isLoading: boolean;
33
83
  private versionExpiredHandler?;
@@ -42,10 +92,12 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
42
92
  private sessionTokenCookieKey;
43
93
  private sessionCsrfCookieKey;
44
94
  private sessionCookieDomain?;
45
- constructor({ apiPath, apiVersion, isCorsEnabled, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }: {
95
+ constructor({ apiPath, apiVersion, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }: {
46
96
  apiPath: string;
47
97
  apiVersion?: string;
48
98
  isCorsEnabled: boolean;
99
+ /** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
100
+ timeoutMs?: number;
49
101
  versionExpiredHandler?: VoidFunction;
50
102
  sessionExpiredHandler?: VoidFunction;
51
103
  messageHandler?: MessageHandler;
@@ -59,29 +111,30 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
59
111
  sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
60
112
  });
61
113
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
114
+ /**
115
+ * Generate an idempotency key for one logical operation. Create it when
116
+ * the operation begins (a form opens, a draft starts), send the same key
117
+ * on every attempt of that operation, and generate a new one after a
118
+ * confirmed success. Uses crypto.randomUUID when available and falls back
119
+ * to a v4 UUID from getRandomValues, because randomUUID only exists in
120
+ * secure contexts (plain-http LAN device testing lacks it).
121
+ */
122
+ static createIdempotencyKey(): string;
62
123
  private clearSessionCookies;
63
- apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: {
64
- headers?: Record<string, any>;
65
- versionExpiredHandler?: VoidFunction;
66
- sessionExpiredHandler?: VoidFunction;
67
- messageHandler?: MessageHandler;
68
- errorMessageHandler?: MessageHandler;
69
- apiInputValidationErrorHandler?: ValidationErrorHandler;
70
- notAuthorizedHandler?: VoidFunction;
71
- errorHandler?: ErrorHandler;
72
- fetchStartedHandler?: FetchStartEventHandler;
73
- fetchEndedHandler?: FetchEndEventHandler;
74
- }): Promise<LambderApiResponse<TOutput> | null | undefined>;
75
- api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: {
76
- headers?: Record<string, any>;
77
- versionExpiredHandler?: VoidFunction;
78
- sessionExpiredHandler?: VoidFunction;
79
- messageHandler?: MessageHandler;
80
- errorMessageHandler?: MessageHandler;
81
- notAuthorizedHandler?: VoidFunction;
82
- errorHandler?: ErrorHandler;
83
- fetchStartedHandler?: FetchStartEventHandler;
84
- fetchEndedHandler?: FetchEndEventHandler;
85
- }): Promise<TOutput | null | undefined>;
124
+ /** One call, one outcome. Never throws; every failure path resolves to { ok: false }. */
125
+ private dispatch;
126
+ /**
127
+ * Full-fidelity call: resolves to a discriminated LambderApiOutcome
128
+ * instead of collapsing every failure to null. Never throws.
129
+ */
130
+ apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: LambderCallOptions): Promise<LambderApiOutcome<TOutput>>;
131
+ /**
132
+ * Legacy shape: the parsed envelope on success (and on structured
133
+ * errorMessage refusals, which carry an envelope), null on every other
134
+ * failure. Prefer apiOutcome() when the call site needs to know why.
135
+ */
136
+ apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: LambderCallOptions): Promise<LambderApiResponse<TOutput> | null | undefined>;
137
+ /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
138
+ api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: LambderCallOptions): Promise<TOutput | null | undefined>;
86
139
  }
87
140
  export {};