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.
- 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
package/dist/LambderCaller.js
CHANGED
|
@@ -3,6 +3,7 @@ export default class LambderCaller {
|
|
|
3
3
|
isCorsEnabled;
|
|
4
4
|
apiPath;
|
|
5
5
|
apiVersion;
|
|
6
|
+
timeoutMs;
|
|
6
7
|
fetchTrackerList = [];
|
|
7
8
|
isLoading = false;
|
|
8
9
|
versionExpiredHandler;
|
|
@@ -17,10 +18,11 @@ export default class LambderCaller {
|
|
|
17
18
|
sessionTokenCookieKey = "LMDRSESSIONTKID";
|
|
18
19
|
sessionCsrfCookieKey = "LMDRSESSIONCSTK";
|
|
19
20
|
sessionCookieDomain;
|
|
20
|
-
constructor({ apiPath, apiVersion, isCorsEnabled = false, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }) {
|
|
21
|
+
constructor({ apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }) {
|
|
21
22
|
this.apiPath = apiPath ?? "/api";
|
|
22
23
|
this.apiVersion = apiVersion;
|
|
23
24
|
this.isCorsEnabled = isCorsEnabled;
|
|
25
|
+
this.timeoutMs = timeoutMs;
|
|
24
26
|
this.sessionCookieDomain = sessionCookieDomain;
|
|
25
27
|
this.versionExpiredHandler = versionExpiredHandler;
|
|
26
28
|
this.sessionExpiredHandler = sessionExpiredHandler;
|
|
@@ -37,6 +39,32 @@ export default class LambderCaller {
|
|
|
37
39
|
this.sessionTokenCookieKey = sessionTokenCookieKey;
|
|
38
40
|
this.sessionCsrfCookieKey = sessionCsrfCookieKey;
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Generate an idempotency key for one logical operation. Create it when
|
|
44
|
+
* the operation begins (a form opens, a draft starts), send the same key
|
|
45
|
+
* on every attempt of that operation, and generate a new one after a
|
|
46
|
+
* confirmed success. Uses crypto.randomUUID when available and falls back
|
|
47
|
+
* to a v4 UUID from getRandomValues, because randomUUID only exists in
|
|
48
|
+
* secure contexts (plain-http LAN device testing lacks it).
|
|
49
|
+
*/
|
|
50
|
+
static createIdempotencyKey() {
|
|
51
|
+
const cryptoObj = globalThis.crypto;
|
|
52
|
+
if (cryptoObj?.randomUUID)
|
|
53
|
+
return cryptoObj.randomUUID();
|
|
54
|
+
const bytes = new Uint8Array(16);
|
|
55
|
+
if (cryptoObj?.getRandomValues) {
|
|
56
|
+
cryptoObj.getRandomValues(bytes);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
for (let i = 0; i < 16; i += 1) {
|
|
60
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
64
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
65
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
66
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
67
|
+
}
|
|
40
68
|
clearSessionCookies() {
|
|
41
69
|
const domainOption = this.sessionCookieDomain;
|
|
42
70
|
const hostname = typeof window !== "undefined" ? window.location.hostname : "";
|
|
@@ -48,117 +76,227 @@ export default class LambderCaller {
|
|
|
48
76
|
Cookies.remove(key, { domain: resolvedDomain, path: "/" });
|
|
49
77
|
}
|
|
50
78
|
}
|
|
51
|
-
|
|
79
|
+
/** One call, one outcome. Never throws; every failure path resolves to { ok: false }. */
|
|
80
|
+
async dispatch(apiName, payload, options) {
|
|
81
|
+
// Per-call overrides win over the constructor handlers.
|
|
82
|
+
const versionExpiredHandler = options?.versionExpiredHandler ?? this.versionExpiredHandler;
|
|
83
|
+
const sessionExpiredHandler = options?.sessionExpiredHandler ?? this.sessionExpiredHandler;
|
|
84
|
+
const messageHandler = options?.messageHandler ?? this.messageHandler;
|
|
85
|
+
const errorMessageHandler = options?.errorMessageHandler ?? this.errorMessageHandler;
|
|
86
|
+
const notAuthorizedHandler = options?.notAuthorizedHandler ?? this.notAuthorizedHandler;
|
|
87
|
+
const errorHandler = options?.errorHandler ?? this.errorHandler;
|
|
88
|
+
const apiInputValidationErrorHandler = options?.apiInputValidationErrorHandler ?? this.apiInputValidationErrorHandler;
|
|
89
|
+
const fetchStartedHandler = options?.fetchStartedHandler ?? this.fetchStartedHandler;
|
|
90
|
+
const fetchEndedHandler = options?.fetchEndedHandler ?? this.fetchEndedHandler;
|
|
52
91
|
const headers = options?.headers;
|
|
53
92
|
const fetchTracker = { apiName, done: false, fetchEndCalled: false };
|
|
93
|
+
const fetchEnded = async (fetchResult) => {
|
|
94
|
+
fetchTracker.done = true;
|
|
95
|
+
if (fetchTracker.fetchEndCalled || !fetchEndedHandler)
|
|
96
|
+
return;
|
|
97
|
+
fetchTracker.fetchEndCalled = true;
|
|
98
|
+
await fetchEndedHandler({
|
|
99
|
+
fetchParams: { apiName, payload, headers },
|
|
100
|
+
fetchResult,
|
|
101
|
+
activeFetchList: this.fetchTrackerList.filter(v => !v.done),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
let errorHandlerCalled = false;
|
|
105
|
+
const reportError = async (err) => {
|
|
106
|
+
if (errorHandlerCalled || !errorHandler)
|
|
107
|
+
return;
|
|
108
|
+
errorHandlerCalled = true;
|
|
109
|
+
await errorHandler(err);
|
|
110
|
+
};
|
|
111
|
+
// Timeout / abort wiring: the timeout gets its own controller chained
|
|
112
|
+
// to any external signal, so either source aborts the fetch.
|
|
113
|
+
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
|
114
|
+
const externalSignal = options?.signal;
|
|
115
|
+
let timedOut = false;
|
|
116
|
+
let signal = externalSignal;
|
|
117
|
+
let timeoutId;
|
|
118
|
+
if (timeoutMs !== undefined) {
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
if (externalSignal) {
|
|
121
|
+
if (externalSignal.aborted) {
|
|
122
|
+
controller.abort(externalSignal.reason);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
externalSignal.addEventListener("abort", () => controller.abort(externalSignal.reason), { once: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
|
129
|
+
signal = controller.signal;
|
|
130
|
+
}
|
|
54
131
|
try {
|
|
55
132
|
this.fetchTrackerList.push(fetchTracker);
|
|
56
|
-
if (
|
|
57
|
-
await
|
|
133
|
+
if (fetchStartedHandler)
|
|
134
|
+
await fetchStartedHandler({
|
|
58
135
|
fetchParams: { apiName, payload, headers, },
|
|
59
136
|
activeFetchList: this.fetchTrackerList.filter(v => !v.done)
|
|
60
137
|
});
|
|
61
138
|
const version = this.apiVersion;
|
|
62
139
|
const token = Cookies.get(this.sessionCsrfCookieKey) || "";
|
|
63
140
|
const siteHost = window.location.hostname;
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
141
|
+
let res;
|
|
142
|
+
try {
|
|
143
|
+
res = await fetch(this.apiPath, {
|
|
144
|
+
method: 'POST', cache: 'no-cache',
|
|
145
|
+
// Cross-origin API hosts need CORS mode and included credentials.
|
|
146
|
+
mode: this.isCorsEnabled ? 'cors' : 'same-origin',
|
|
147
|
+
credentials: this.isCorsEnabled ? 'include' : 'same-origin',
|
|
148
|
+
redirect: 'follow', referrerPolicy: 'origin',
|
|
149
|
+
headers: { 'Content-Type': 'application/json', ...(headers || {}) },
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
apiName, version, token, siteHost, payload,
|
|
152
|
+
...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
153
|
+
}),
|
|
154
|
+
...(signal ? { signal } : {}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const wrappedError = err instanceof Error ? err : new Error("Request failed", { cause: err });
|
|
159
|
+
await fetchEnded(wrappedError);
|
|
160
|
+
await reportError(wrappedError);
|
|
161
|
+
return { ok: false, reason: timedOut ? 'timeout' : 'network', error: wrappedError };
|
|
162
|
+
}
|
|
163
|
+
if (res.status >= 500) {
|
|
164
|
+
// Lambder's own 500 fallback is a JSON envelope, but custom
|
|
165
|
+
// error handlers may answer text/HTML: parse defensively.
|
|
166
|
+
let errorMessage;
|
|
167
|
+
try {
|
|
168
|
+
const bodyText = await res.text();
|
|
169
|
+
try {
|
|
170
|
+
errorMessage = JSON.parse(bodyText)?.errorMessage;
|
|
82
171
|
}
|
|
83
|
-
|
|
172
|
+
catch { /* not an envelope */ }
|
|
173
|
+
}
|
|
174
|
+
catch { /* body unavailable */ }
|
|
175
|
+
const wrappedError = new Error("Request failed: " + res.status + " - " + res.statusText);
|
|
176
|
+
await fetchEnded(wrappedError);
|
|
177
|
+
await reportError(wrappedError);
|
|
178
|
+
return { ok: false, reason: 'server', status: res.status, errorMessage, error: wrappedError };
|
|
179
|
+
}
|
|
180
|
+
if (res.status === 422) {
|
|
181
|
+
// A 422 without Lambder's validation body (e.g. a proxy's
|
|
182
|
+
// error page) is a server failure, not a validation result.
|
|
183
|
+
let zodError;
|
|
184
|
+
try {
|
|
185
|
+
zodError = (await res.json())?.zodError;
|
|
84
186
|
}
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
const
|
|
88
|
-
|
|
187
|
+
catch { /* not JSON */ }
|
|
188
|
+
if (zodError === undefined) {
|
|
189
|
+
const wrappedError = new Error("Request failed: 422 without a validation body");
|
|
190
|
+
await fetchEnded(wrappedError);
|
|
191
|
+
await reportError(wrappedError);
|
|
192
|
+
return { ok: false, reason: 'server', status: res.status, error: wrappedError };
|
|
193
|
+
}
|
|
194
|
+
await fetchEnded(null);
|
|
195
|
+
if (apiInputValidationErrorHandler) {
|
|
196
|
+
await apiInputValidationErrorHandler(zodError);
|
|
89
197
|
}
|
|
90
198
|
else {
|
|
91
|
-
|
|
199
|
+
await reportError(new Error("API Input Validation Error", { cause: zodError }));
|
|
92
200
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
await
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
activeFetchList: this.fetchTrackerList.filter(v => !v.done),
|
|
101
|
-
});
|
|
201
|
+
return { ok: false, reason: 'validation', status: res.status, zodError };
|
|
202
|
+
}
|
|
203
|
+
let data;
|
|
204
|
+
try {
|
|
205
|
+
data = await res.json();
|
|
206
|
+
if (data === null || typeof data !== "object")
|
|
207
|
+
throw new Error("Response is not an object");
|
|
102
208
|
}
|
|
103
|
-
|
|
209
|
+
catch (err) {
|
|
210
|
+
// A non-envelope body (e.g. an HTML error page) is a server failure.
|
|
211
|
+
const wrappedError = new Error("Request failed: response is not a valid API envelope (status " + res.status + ")", { cause: err });
|
|
212
|
+
await fetchEnded(wrappedError);
|
|
213
|
+
await reportError(wrappedError);
|
|
214
|
+
return { ok: false, reason: 'server', status: res.status, error: wrappedError };
|
|
215
|
+
}
|
|
216
|
+
await fetchEnded(data);
|
|
217
|
+
if (data.logList?.length) {
|
|
104
218
|
for (const record of data.logList) {
|
|
105
|
-
|
|
219
|
+
console.log("[lambder]", record);
|
|
106
220
|
}
|
|
107
221
|
}
|
|
108
|
-
if (data
|
|
109
|
-
if (
|
|
110
|
-
await
|
|
222
|
+
if (data.versionExpired) {
|
|
223
|
+
if (versionExpiredHandler) {
|
|
224
|
+
await versionExpiredHandler();
|
|
111
225
|
}
|
|
112
|
-
else
|
|
113
|
-
await
|
|
226
|
+
else {
|
|
227
|
+
await reportError(new Error("Version Expired; Please refresh;"));
|
|
114
228
|
}
|
|
115
|
-
return
|
|
229
|
+
return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
116
230
|
}
|
|
117
|
-
if (data
|
|
231
|
+
if (data.sessionExpired) {
|
|
118
232
|
this.clearSessionCookies();
|
|
119
|
-
if (
|
|
120
|
-
await
|
|
233
|
+
if (sessionExpiredHandler) {
|
|
234
|
+
await sessionExpiredHandler();
|
|
121
235
|
}
|
|
122
|
-
else
|
|
123
|
-
await
|
|
236
|
+
else {
|
|
237
|
+
await reportError(new Error("Session Expired; Please log in again;"));
|
|
124
238
|
}
|
|
125
|
-
return
|
|
239
|
+
return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
126
240
|
}
|
|
127
|
-
if (data
|
|
128
|
-
if (
|
|
129
|
-
await
|
|
241
|
+
if (data.notAuthorized) {
|
|
242
|
+
if (notAuthorizedHandler) {
|
|
243
|
+
await notAuthorizedHandler();
|
|
130
244
|
}
|
|
131
|
-
else
|
|
132
|
-
await
|
|
245
|
+
else {
|
|
246
|
+
await reportError(new Error("Not Authorized;"));
|
|
133
247
|
}
|
|
134
|
-
return
|
|
248
|
+
return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
135
249
|
}
|
|
136
|
-
if (data
|
|
137
|
-
await
|
|
250
|
+
if (data.message && messageHandler) {
|
|
251
|
+
await messageHandler(data.message);
|
|
138
252
|
}
|
|
139
|
-
if (data
|
|
140
|
-
|
|
253
|
+
if (data.errorMessage) {
|
|
254
|
+
if (errorMessageHandler) {
|
|
255
|
+
await errorMessageHandler(data.errorMessage);
|
|
256
|
+
}
|
|
257
|
+
return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
141
258
|
}
|
|
142
|
-
return data;
|
|
259
|
+
return { ok: true, payload: data.payload, response: data };
|
|
143
260
|
}
|
|
144
261
|
catch (err) {
|
|
262
|
+
// Escape hatch for anything above (typically an app handler throwing):
|
|
263
|
+
// dispatch never throws, so api()/apiOutcome() call sites never do.
|
|
145
264
|
const wrappedError = err instanceof Error ? err : new Error("Error: ", { cause: err });
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await
|
|
149
|
-
fetchParams: { apiName, payload, headers, },
|
|
150
|
-
fetchResult: wrappedError,
|
|
151
|
-
activeFetchList: this.fetchTrackerList.filter(v => !v.done)
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
if (this.errorHandler) {
|
|
155
|
-
this.errorHandler(wrappedError);
|
|
265
|
+
try {
|
|
266
|
+
await fetchEnded(wrappedError);
|
|
267
|
+
await reportError(wrappedError);
|
|
156
268
|
}
|
|
157
|
-
|
|
269
|
+
catch { /* an app handler threw again; never propagate */ }
|
|
270
|
+
return { ok: false, reason: 'unknown', error: wrappedError };
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
fetchTracker.done = true;
|
|
274
|
+
if (timeoutId !== undefined)
|
|
275
|
+
clearTimeout(timeoutId);
|
|
158
276
|
}
|
|
159
277
|
}
|
|
160
278
|
;
|
|
161
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Full-fidelity call: resolves to a discriminated LambderApiOutcome
|
|
281
|
+
* instead of collapsing every failure to null. Never throws.
|
|
282
|
+
*/
|
|
283
|
+
async apiOutcome(apiName, payload, options) {
|
|
284
|
+
return await this.dispatch(apiName, payload, options);
|
|
285
|
+
}
|
|
286
|
+
;
|
|
287
|
+
/**
|
|
288
|
+
* Legacy shape: the parsed envelope on success (and on structured
|
|
289
|
+
* errorMessage refusals, which carry an envelope), null on every other
|
|
290
|
+
* failure. Prefer apiOutcome() when the call site needs to know why.
|
|
291
|
+
*/
|
|
292
|
+
async apiRaw(apiName, payload, options) {
|
|
293
|
+
const outcome = await this.dispatch(apiName, payload, options);
|
|
294
|
+
if (outcome.ok)
|
|
295
|
+
return outcome.response;
|
|
296
|
+
return outcome.reason === 'errorMessage' ? outcome.response : null;
|
|
297
|
+
}
|
|
298
|
+
;
|
|
299
|
+
/** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
|
|
162
300
|
async api(apiName, payload, options) {
|
|
163
301
|
const result = await this.apiRaw(apiName, payload, options);
|
|
164
302
|
return result?.payload;
|
|
@@ -2,6 +2,8 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
|
2
2
|
export interface LambderDdbCacheOptions {
|
|
3
3
|
tableName: string;
|
|
4
4
|
region?: string;
|
|
5
|
+
/** Partition key prefix, keeps cache items separated from other systems in a shared table. Default: "CACHE". */
|
|
6
|
+
keyPrefix?: string;
|
|
5
7
|
namespace?: string;
|
|
6
8
|
defaultTtlSeconds?: number;
|
|
7
9
|
chunkBytes?: number;
|
|
@@ -26,9 +28,15 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
|
|
|
26
28
|
* chunk succeeds, so readers see either the previous complete version or the
|
|
27
29
|
* new complete version. DynamoDB TTL is cleanup only; every read also checks
|
|
28
30
|
* expiresAt because TTL deletion can lag.
|
|
31
|
+
*
|
|
32
|
+
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
33
|
+
* `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
|
|
34
|
+
* table can be shared with LambderDdbRateLimiter (`RL#`) and
|
|
35
|
+
* LambderDdbIdempotency (`IDEM#`) without key collisions.
|
|
29
36
|
*/
|
|
30
37
|
export declare class LambderDdbCache {
|
|
31
38
|
readonly tableName: string;
|
|
39
|
+
readonly keyPrefix: string;
|
|
32
40
|
readonly namespace: string;
|
|
33
41
|
private readonly client;
|
|
34
42
|
private readonly defaultTtlSeconds;
|
package/dist/LambderDdbCache.js
CHANGED
|
@@ -76,9 +76,15 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
76
76
|
* chunk succeeds, so readers see either the previous complete version or the
|
|
77
77
|
* new complete version. DynamoDB TTL is cleanup only; every read also checks
|
|
78
78
|
* expiresAt because TTL deletion can lag.
|
|
79
|
+
*
|
|
80
|
+
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
81
|
+
* `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
|
|
82
|
+
* table can be shared with LambderDdbRateLimiter (`RL#`) and
|
|
83
|
+
* LambderDdbIdempotency (`IDEM#`) without key collisions.
|
|
79
84
|
*/
|
|
80
85
|
export class LambderDdbCache {
|
|
81
86
|
tableName;
|
|
87
|
+
keyPrefix;
|
|
82
88
|
namespace;
|
|
83
89
|
client;
|
|
84
90
|
defaultTtlSeconds;
|
|
@@ -91,6 +97,7 @@ export class LambderDdbCache {
|
|
|
91
97
|
if (!options.tableName.trim())
|
|
92
98
|
throw new Error("tableName is required");
|
|
93
99
|
this.tableName = options.tableName;
|
|
100
|
+
this.keyPrefix = options.keyPrefix ?? "CACHE";
|
|
94
101
|
this.namespace = options.namespace?.trim() || "default";
|
|
95
102
|
if (Buffer.byteLength(this.namespace, "utf8") > 128) {
|
|
96
103
|
throw new Error("namespace must be at most 128 UTF-8 bytes");
|
|
@@ -492,7 +499,7 @@ export class LambderDdbCache {
|
|
|
492
499
|
return key;
|
|
493
500
|
}
|
|
494
501
|
async partitionKey(key) {
|
|
495
|
-
return `${this.namespace}#${await sha256(key)}`;
|
|
502
|
+
return `${this.keyPrefix}#${this.namespace}#${await sha256(key)}`;
|
|
496
503
|
}
|
|
497
504
|
chunkSortKey(version, index) {
|
|
498
505
|
return `chunk#${version}#${String(index).padStart(6, "0")}`;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
export interface LambderDdbIdempotencyOptions {
|
|
3
|
+
tableName: string;
|
|
4
|
+
region?: string;
|
|
5
|
+
/** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
|
|
6
|
+
keyPrefix?: string;
|
|
7
|
+
client?: DynamoDBClient;
|
|
8
|
+
}
|
|
9
|
+
export type LambderIdempotencyBeginResult = {
|
|
10
|
+
state: "new";
|
|
11
|
+
ownerToken: string;
|
|
12
|
+
} | {
|
|
13
|
+
state: "pending";
|
|
14
|
+
} | {
|
|
15
|
+
state: "done";
|
|
16
|
+
statusCode: number;
|
|
17
|
+
contentType: string | null;
|
|
18
|
+
body: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* DynamoDB-backed idempotency records: one item per (identity, api, key)
|
|
22
|
+
* scope, claimed atomically with a conditional put. The first request claims
|
|
23
|
+
* the scope as "pending"; concurrent duplicates see "pending"; once the
|
|
24
|
+
* response is stored via complete(), replays get it back verbatim until the
|
|
25
|
+
* TTL. Records whose expiresAt has passed count as absent (DynamoDB TTL
|
|
26
|
+
* deletion is lazy, so expiry is enforced in the condition, not left to TTL).
|
|
27
|
+
*
|
|
28
|
+
* Every claim carries a random ownerToken, and complete()/abandon() are
|
|
29
|
+
* conditional on still holding it: an original that outlives its pending TTL
|
|
30
|
+
* and loses the scope to a retry can no longer overwrite or delete the
|
|
31
|
+
* retry's claim (both settle calls become silent no-ops instead).
|
|
32
|
+
*
|
|
33
|
+
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
34
|
+
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
35
|
+
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
|
|
36
|
+
* without key collisions.
|
|
37
|
+
*/
|
|
38
|
+
export declare class LambderDdbIdempotency {
|
|
39
|
+
readonly tableName: string;
|
|
40
|
+
readonly keyPrefix: string;
|
|
41
|
+
private readonly client;
|
|
42
|
+
constructor(options: LambderDdbIdempotencyOptions);
|
|
43
|
+
private itemKey;
|
|
44
|
+
/**
|
|
45
|
+
* Claim the scope. "new" means this request now owns it (proven by the
|
|
46
|
+
* returned ownerToken) and must call complete() or abandon(); "pending"
|
|
47
|
+
* means another request owns it right now; "done" carries the stored
|
|
48
|
+
* response to replay.
|
|
49
|
+
*/
|
|
50
|
+
begin(scopeKey: string, { pendingTtlSeconds }: {
|
|
51
|
+
pendingTtlSeconds: number;
|
|
52
|
+
}): Promise<LambderIdempotencyBeginResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Store the response for replays, overwriting the pending claim. Requires
|
|
55
|
+
* still holding the claim: returns false (storing nothing) when the
|
|
56
|
+
* ownerToken no longer matches, i.e. the claim expired and a retry took
|
|
57
|
+
* the scope over.
|
|
58
|
+
*/
|
|
59
|
+
complete(scopeKey: string, ownerToken: string, { statusCode, contentType, body, ttlSeconds }: {
|
|
60
|
+
statusCode: number;
|
|
61
|
+
contentType: string | null;
|
|
62
|
+
body: string;
|
|
63
|
+
ttlSeconds: number;
|
|
64
|
+
}): Promise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* Release the claim without storing a response (crash, uncacheable
|
|
67
|
+
* response), so a retry can execute. Conditional on still holding the
|
|
68
|
+
* claim; a lost claim makes this a silent no-op.
|
|
69
|
+
*/
|
|
70
|
+
abandon(scopeKey: string, ownerToken: string): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export default LambderDdbIdempotency;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
|
|
3
|
+
/**
|
|
4
|
+
* DynamoDB-backed idempotency records: one item per (identity, api, key)
|
|
5
|
+
* scope, claimed atomically with a conditional put. The first request claims
|
|
6
|
+
* the scope as "pending"; concurrent duplicates see "pending"; once the
|
|
7
|
+
* response is stored via complete(), replays get it back verbatim until the
|
|
8
|
+
* TTL. Records whose expiresAt has passed count as absent (DynamoDB TTL
|
|
9
|
+
* deletion is lazy, so expiry is enforced in the condition, not left to TTL).
|
|
10
|
+
*
|
|
11
|
+
* Every claim carries a random ownerToken, and complete()/abandon() are
|
|
12
|
+
* conditional on still holding it: an original that outlives its pending TTL
|
|
13
|
+
* and loses the scope to a retry can no longer overwrite or delete the
|
|
14
|
+
* retry's claim (both settle calls become silent no-ops instead).
|
|
15
|
+
*
|
|
16
|
+
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
17
|
+
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
18
|
+
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
|
|
19
|
+
* without key collisions.
|
|
20
|
+
*/
|
|
21
|
+
export class LambderDdbIdempotency {
|
|
22
|
+
tableName;
|
|
23
|
+
keyPrefix;
|
|
24
|
+
client;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
if (!options.tableName.trim())
|
|
27
|
+
throw new Error("tableName is required");
|
|
28
|
+
this.tableName = options.tableName;
|
|
29
|
+
this.keyPrefix = options.keyPrefix ?? "IDEM";
|
|
30
|
+
this.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
|
|
31
|
+
}
|
|
32
|
+
itemKey(scopeKey) {
|
|
33
|
+
return { pk: { S: `${this.keyPrefix}#${scopeKey}` }, sk: { S: "idem" } };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Claim the scope. "new" means this request now owns it (proven by the
|
|
37
|
+
* returned ownerToken) and must call complete() or abandon(); "pending"
|
|
38
|
+
* means another request owns it right now; "done" carries the stored
|
|
39
|
+
* response to replay.
|
|
40
|
+
*/
|
|
41
|
+
async begin(scopeKey, { pendingTtlSeconds }) {
|
|
42
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
43
|
+
const ownerToken = crypto.randomBytes(16).toString("hex");
|
|
44
|
+
try {
|
|
45
|
+
await this.client.send(new PutItemCommand({
|
|
46
|
+
TableName: this.tableName,
|
|
47
|
+
Item: {
|
|
48
|
+
...this.itemKey(scopeKey),
|
|
49
|
+
state: { S: "pending" },
|
|
50
|
+
ownerToken: { S: ownerToken },
|
|
51
|
+
expiresAt: { N: String(nowSeconds + pendingTtlSeconds) },
|
|
52
|
+
},
|
|
53
|
+
ConditionExpression: "attribute_not_exists(pk) OR expiresAt <= :now",
|
|
54
|
+
ExpressionAttributeValues: { ":now": { N: String(nowSeconds) } },
|
|
55
|
+
}));
|
|
56
|
+
return { state: "new", ownerToken };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.name !== "ConditionalCheckFailedException")
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const existing = await this.client.send(new GetItemCommand({
|
|
63
|
+
TableName: this.tableName,
|
|
64
|
+
Key: this.itemKey(scopeKey),
|
|
65
|
+
ConsistentRead: true,
|
|
66
|
+
}));
|
|
67
|
+
const item = existing.Item;
|
|
68
|
+
// Deleted between the put and the read: treat as in-flight, the retry resolves it.
|
|
69
|
+
if (!item)
|
|
70
|
+
return { state: "pending" };
|
|
71
|
+
if (item.state?.S === "done") {
|
|
72
|
+
return {
|
|
73
|
+
state: "done",
|
|
74
|
+
statusCode: Number(item.statusCode?.N ?? 200),
|
|
75
|
+
contentType: item.contentType?.S ?? null,
|
|
76
|
+
body: item.body?.S ?? "",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { state: "pending" };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Store the response for replays, overwriting the pending claim. Requires
|
|
83
|
+
* still holding the claim: returns false (storing nothing) when the
|
|
84
|
+
* ownerToken no longer matches, i.e. the claim expired and a retry took
|
|
85
|
+
* the scope over.
|
|
86
|
+
*/
|
|
87
|
+
async complete(scopeKey, ownerToken, { statusCode, contentType, body, ttlSeconds }) {
|
|
88
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
89
|
+
try {
|
|
90
|
+
await this.client.send(new PutItemCommand({
|
|
91
|
+
TableName: this.tableName,
|
|
92
|
+
Item: {
|
|
93
|
+
...this.itemKey(scopeKey),
|
|
94
|
+
state: { S: "done" },
|
|
95
|
+
ownerToken: { S: ownerToken },
|
|
96
|
+
statusCode: { N: String(statusCode) },
|
|
97
|
+
...(contentType ? { contentType: { S: contentType } } : {}),
|
|
98
|
+
body: { S: body },
|
|
99
|
+
expiresAt: { N: String(nowSeconds + ttlSeconds) },
|
|
100
|
+
},
|
|
101
|
+
ConditionExpression: "ownerToken = :owner",
|
|
102
|
+
ExpressionAttributeValues: { ":owner": { S: ownerToken } },
|
|
103
|
+
}));
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.name !== "ConditionalCheckFailedException")
|
|
108
|
+
throw error;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Release the claim without storing a response (crash, uncacheable
|
|
114
|
+
* response), so a retry can execute. Conditional on still holding the
|
|
115
|
+
* claim; a lost claim makes this a silent no-op.
|
|
116
|
+
*/
|
|
117
|
+
async abandon(scopeKey, ownerToken) {
|
|
118
|
+
try {
|
|
119
|
+
await this.client.send(new DeleteItemCommand({
|
|
120
|
+
TableName: this.tableName,
|
|
121
|
+
Key: this.itemKey(scopeKey),
|
|
122
|
+
ConditionExpression: "ownerToken = :owner",
|
|
123
|
+
ExpressionAttributeValues: { ":owner": { S: ownerToken } },
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.name !== "ConditionalCheckFailedException")
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export default LambderDdbIdempotency;
|
|
@@ -13,7 +13,7 @@ export type LambderRateLimitResult = false | LambderRateLimitExceededMap;
|
|
|
13
13
|
export interface LambderDdbRateLimiterOptions {
|
|
14
14
|
tableName: string;
|
|
15
15
|
region?: string;
|
|
16
|
-
/** Partition key prefix, keeps counters separated from other
|
|
16
|
+
/** Partition key prefix, keeps counters separated from other systems in a shared table. Default: "RL". */
|
|
17
17
|
keyPrefix?: string;
|
|
18
18
|
/** Multiplier applied to the window length when setting the item TTL. */
|
|
19
19
|
ttlWindowMultiplier?: number;
|
|
@@ -31,6 +31,9 @@ export interface LambderDdbRateLimiterOptions {
|
|
|
31
31
|
* larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
|
|
32
32
|
*
|
|
33
33
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
|
|
34
|
+
* Items are prefixed `RL#` by default, so the table can be shared with
|
|
35
|
+
* LambderDdbCache (`CACHE#`) and LambderDdbIdempotency (`IDEM#`) without key
|
|
36
|
+
* collisions.
|
|
34
37
|
*/
|
|
35
38
|
export declare class LambderDdbRateLimiter {
|
|
36
39
|
readonly tableName: string;
|
|
@@ -17,6 +17,9 @@ const WINDOW_CONFIG = [
|
|
|
17
17
|
* larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
|
|
18
18
|
*
|
|
19
19
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
|
|
20
|
+
* Items are prefixed `RL#` by default, so the table can be shared with
|
|
21
|
+
* LambderDdbCache (`CACHE#`) and LambderDdbIdempotency (`IDEM#`) without key
|
|
22
|
+
* collisions.
|
|
20
23
|
*/
|
|
21
24
|
export class LambderDdbRateLimiter {
|
|
22
25
|
tableName;
|