lambder 3.4.2 → 3.5.1
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.
- package/Readme.md +108 -2
- package/dist/Lambder.d.ts +44 -5
- package/dist/Lambder.js +108 -2
- package/dist/LambderApiError.d.ts +54 -0
- package/dist/LambderApiError.js +38 -0
- package/dist/LambderApiPolicies.d.ts +95 -0
- package/dist/LambderApiPolicies.js +196 -0
- package/dist/LambderCaller.d.ts +77 -24
- package/dist/LambderCaller.js +212 -74
- package/dist/LambderDdbCache.d.ts +8 -0
- package/dist/LambderDdbCache.js +8 -1
- package/dist/LambderDdbIdempotency.d.ts +72 -0
- package/dist/LambderDdbIdempotency.js +132 -0
- package/dist/LambderDdbRateLimiter.d.ts +4 -1
- package/dist/LambderDdbRateLimiter.js +3 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/LambderCaller.d.ts
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 {};
|