lambder 12.0.1 → 14.0.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/CHANGELOG.md +119 -0
- package/README.md +7 -2
- package/dist/api/LambderApiAnswer.d.ts +7 -0
- package/dist/api/LambderApiEnvelope.js +4 -1
- package/dist/api/LambderApiRefusals.d.ts +22 -0
- package/dist/api/LambderApiRefusals.js +13 -0
- package/dist/build/generateApiFiles.d.ts +60 -0
- package/dist/build/generateApiFiles.js +85 -0
- package/dist/build/generatedTables.d.ts +1 -1
- package/dist/build/writeApiGuardParams.d.ts +11 -10
- package/dist/build/writeApiGuardParams.js +135 -31
- package/dist/build.d.ts +4 -1
- package/dist/build.js +3 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +32 -8
- package/dist/core/Lambder.js +135 -17
- package/dist/core/LambderCallSummary.d.ts +43 -0
- package/dist/core/LambderCallSummary.js +10 -0
- package/dist/core/LambderContext.d.ts +14 -1
- package/dist/core/LambderContext.js +19 -4
- package/dist/core/LambderCreateOptions.d.ts +50 -25
- package/dist/core/LambderCreateOptions.js +37 -18
- package/dist/core/LambderInvocationScope.d.ts +25 -0
- package/dist/core/LambderInvocationScope.js +21 -0
- package/dist/core/LambderResponse.d.ts +11 -0
- package/dist/core/LambderResponse.js +12 -0
- package/dist/core/LambderResponseBuilder.js +5 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +2 -2
- package/dist/invoke/LambderLambdaEvent.d.ts +2 -0
- package/dist/invoke/LambderLambdaEvent.js +8 -2
- package/dist/mock/LambderMockApp.d.ts +8 -8
- package/dist/mock/LambderMockApp.js +14 -10
- package/dist/mock/LambderMockCreateOptions.d.ts +2 -3
- package/dist/session/LambderSessionManager.d.ts +46 -3
- package/dist/session/LambderSessionManager.js +62 -11
- package/dist/shared/LambderI18n.js +49 -4
- package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
- package/dist/shared/contracts/LambderRateLimiter.js +2 -2
- package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
- package/dist/shared/util/LambderInvocationScope.js +5 -0
- package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
- package/dist/shared/util/LambderNamedMaps.js +19 -0
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +24 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +13 -0
- package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
- package/dist/shared/wire/LambderCallOutcome.js +47 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
- package/dist/shared/wire/LambderInvokeApiId.js +10 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
- package/dist/stores/LambderDdbRateLimiter.js +64 -45
- package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
- package/dist/stores/LambderMemoryRateLimiter.js +20 -15
- package/dist/testing/LambderTestApp.d.ts +13 -4
- package/dist/testing/LambderTestApp.js +21 -5
- package/package.json +1 -1
|
@@ -112,6 +112,38 @@ const interpolate = (text, params) => {
|
|
|
112
112
|
return text;
|
|
113
113
|
return text.replace(/\{([^{}]+)\}/g, (token, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token);
|
|
114
114
|
};
|
|
115
|
+
/** The `{name}` tokens a text carries, as interpolate reads them. */
|
|
116
|
+
const placeholdersOf = (text) => new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]));
|
|
117
|
+
/** A set of placeholders as a message shows it: `{count}, {name}`, or "none". */
|
|
118
|
+
const describePlaceholders = (placeholders) => placeholders.size ? [...placeholders].sort().map((name) => `{${name}}`).join(", ") : "none";
|
|
119
|
+
/** How many mismatched translations an error lists before it only counts the rest. */
|
|
120
|
+
const PLACEHOLDER_MISMATCH_LIMIT = 20;
|
|
121
|
+
/**
|
|
122
|
+
* Refuses translations that do not carry the placeholders of the text they
|
|
123
|
+
* translate. The default language's text is the contract the translator's
|
|
124
|
+
* type takes its parameters from, so a translation that drops one loses the
|
|
125
|
+
* value a caller passed, and one that adds one shows its `{token}` as it
|
|
126
|
+
* is. `lookupDefault` answers the default language's text for a key, and a
|
|
127
|
+
* key it has none for is left to the other checks.
|
|
128
|
+
*/
|
|
129
|
+
const assertPlaceholdersKept = (lookupDefault, lang, dict, label) => {
|
|
130
|
+
const mismatches = [];
|
|
131
|
+
for (const [key, text] of Object.entries(dict)) {
|
|
132
|
+
const original = lookupDefault(key);
|
|
133
|
+
if (original === undefined || typeof text !== "string")
|
|
134
|
+
continue;
|
|
135
|
+
const expected = placeholdersOf(original);
|
|
136
|
+
const found = placeholdersOf(text);
|
|
137
|
+
if (expected.size === found.size && [...expected].every((name) => found.has(name)))
|
|
138
|
+
continue;
|
|
139
|
+
mismatches.push(`"${key}" has ${describePlaceholders(found)} where the default language has ${describePlaceholders(expected)}`);
|
|
140
|
+
}
|
|
141
|
+
if (mismatches.length === 0)
|
|
142
|
+
return;
|
|
143
|
+
const listed = mismatches.slice(0, PLACEHOLDER_MISMATCH_LIMIT).map((line) => ` ${line}`);
|
|
144
|
+
const more = mismatches.length > PLACEHOLDER_MISMATCH_LIMIT ? [` ... and ${mismatches.length - PLACEHOLDER_MISMATCH_LIMIT} more`] : [];
|
|
145
|
+
throw new Error([`LambderI18n: in ${label}, these "${lang}" translations carry other placeholders than the default language's text:`, ...listed, ...more].join("\n"));
|
|
146
|
+
};
|
|
115
147
|
const layerLookup = (layer, lang, key) => {
|
|
116
148
|
for (let node = layer; node; node = node.parent) {
|
|
117
149
|
const value = node.dicts[lang]?.[key];
|
|
@@ -133,7 +165,12 @@ const assertInlineDefaultBlock = (dict, defaultLanguage, label) => {
|
|
|
133
165
|
throw new Error(`LambderI18n: ${label} must give the default language "${defaultLanguage}" inline, not as a loader.`);
|
|
134
166
|
}
|
|
135
167
|
};
|
|
136
|
-
|
|
168
|
+
/**
|
|
169
|
+
* A layer over its parent, from a dictionary set as configured. The inline
|
|
170
|
+
* blocks' placeholders are checked here, against the default language's
|
|
171
|
+
* texts the layer reaches; a loader's are checked when it has run.
|
|
172
|
+
*/
|
|
173
|
+
const createLayer = (core, sources, parent, label) => {
|
|
137
174
|
const layer = { dicts: {}, loaders: new Map(), inFlight: new Map(), parent };
|
|
138
175
|
for (const [lang, source] of Object.entries(sources)) {
|
|
139
176
|
if (typeof source === "function")
|
|
@@ -141,6 +178,10 @@ const createLayer = (core, sources, parent) => {
|
|
|
141
178
|
else if (source)
|
|
142
179
|
layer.dicts[lang] = source;
|
|
143
180
|
}
|
|
181
|
+
for (const [lang, dict] of Object.entries(layer.dicts)) {
|
|
182
|
+
if (lang !== core.defaultLanguage && dict)
|
|
183
|
+
assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, label);
|
|
184
|
+
}
|
|
144
185
|
if (layer.loaders.size > 0)
|
|
145
186
|
core.lazyLayers.add(layer);
|
|
146
187
|
return layer;
|
|
@@ -172,6 +213,7 @@ const startLayerLoad = (core, layer, lang, loader) => {
|
|
|
172
213
|
const dict = dictionaryFromLoaded(loaded, lang);
|
|
173
214
|
if (layer.parent)
|
|
174
215
|
assertNoRedeclaredKeys(layer.parent, core.defaultLanguage, dict, `the "${lang}" loader`);
|
|
216
|
+
assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, `the "${lang}" loader`);
|
|
175
217
|
// Translations registered while the loader ran override what it brought.
|
|
176
218
|
layer.dicts[lang] = { ...dict, ...layer.dicts[lang] };
|
|
177
219
|
})
|
|
@@ -254,11 +296,11 @@ const buildInstance = (core, layer) => {
|
|
|
254
296
|
},
|
|
255
297
|
extend(dict) {
|
|
256
298
|
validateExtension(dict, core.languageList, "extend() dictionary");
|
|
257
|
-
return buildInstance(core, createLayer(core, dict, layer));
|
|
299
|
+
return buildInstance(core, createLayer(core, dict, layer, "extend() dictionary"));
|
|
258
300
|
},
|
|
259
301
|
extendPartial(dict) {
|
|
260
302
|
validateExtension(dict, core.enforced, "extendPartial() dictionary");
|
|
261
|
-
return buildInstance(core, createLayer(core, dict, layer));
|
|
303
|
+
return buildInstance(core, createLayer(core, dict, layer, "extendPartial() dictionary"));
|
|
262
304
|
},
|
|
263
305
|
loadLanguage(code) {
|
|
264
306
|
const lang = code ?? core.state.resolve();
|
|
@@ -269,6 +311,9 @@ const buildInstance = (core, layer) => {
|
|
|
269
311
|
registerDictionary(code, dict) {
|
|
270
312
|
if (!core.isCode(code))
|
|
271
313
|
throw new Error(`LambderI18n: unsupported language code "${code}".`);
|
|
314
|
+
if (code !== core.defaultLanguage) {
|
|
315
|
+
assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), code, dict, `registerDictionary("${code}")`);
|
|
316
|
+
}
|
|
272
317
|
layer.dicts[code] = { ...layer.dicts[code], ...dict };
|
|
273
318
|
core.state.emitChange();
|
|
274
319
|
},
|
|
@@ -350,5 +395,5 @@ export const createLambderI18n = (config) => {
|
|
|
350
395
|
isCode,
|
|
351
396
|
lazyLayers: new Set(),
|
|
352
397
|
};
|
|
353
|
-
return buildInstance(core, createLayer(core, config.base, null));
|
|
398
|
+
return buildInstance(core, createLayer(core, config.base, null, "base dictionary"));
|
|
354
399
|
};
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* dependency-free, so the mock runtime and the browser entry can resolve it.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* The fixed windows a policy may cap, smallest first (the
|
|
13
|
-
* with their length. The policy type derives from this table, so the two can
|
|
12
|
+
* The fixed windows a policy may cap, smallest first (the order a refusal
|
|
13
|
+
* names the smallest window by), with their length. The policy type derives from this table, so the two can
|
|
14
14
|
* never drift.
|
|
15
15
|
*/
|
|
16
16
|
export declare const RATE_LIMIT_WINDOWS: readonly [{
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* dependency-free, so the mock runtime and the browser entry can resolve it.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* The fixed windows a policy may cap, smallest first (the
|
|
13
|
-
* with their length. The policy type derives from this table, so the two can
|
|
12
|
+
* The fixed windows a policy may cap, smallest first (the order a refusal
|
|
13
|
+
* names the smallest window by), with their length. The policy type derives from this table, so the two can
|
|
14
14
|
* never drift.
|
|
15
15
|
*/
|
|
16
16
|
export const RATE_LIMIT_WINDOWS = [
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
/** What any layer may ask of the invocation it runs under. */
|
|
3
|
+
export type LambderInvocation = {
|
|
4
|
+
/** The invocation's own request id (the Lambda context's awsRequestId); null when the context has none. */
|
|
5
|
+
readonly requestId: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** The async context the server's handler opens once per invocation. */
|
|
8
|
+
export declare const invocationScope: AsyncLocalStorage<LambderInvocation>;
|
|
9
|
+
/** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
|
|
10
|
+
export declare const currentRequestId: () => string | null;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
/** The async context the server's handler opens once per invocation. */
|
|
3
|
+
export const invocationScope = new AsyncLocalStorage();
|
|
4
|
+
/** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
|
|
5
|
+
export const currentRequestId = () => invocationScope.getStore()?.requestId ?? null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One map of named declarations, or a list of them. An app made of parts,
|
|
3
|
+
* each declaring its own guards, rate-limit policies or refusal codes beside
|
|
4
|
+
* its APIs, hands the list over, and every name in it is declared. A name two
|
|
5
|
+
* maps declare is refused rather than one quietly replacing the other.
|
|
6
|
+
*/
|
|
7
|
+
export type LambderNamedMapsOption<TMap> = TMap | readonly TMap[];
|
|
8
|
+
/** The one map a named-maps option declares: a list's members intersected, a lone map as it is. */
|
|
9
|
+
export type LambderMergedNamedMaps<T> = T extends readonly [] ? {} : T extends readonly [infer TFirst, ...infer TRest] ? TFirst & LambderMergedNamedMaps<TRest> : T extends readonly (infer TMember)[] ? TMember : T;
|
|
10
|
+
/** The names a list declares in more than one of its maps: never for a lone map, or for a list whose members are not known one by one. */
|
|
11
|
+
type LambderRepeatedNames<T, TSeen extends PropertyKey = never> = T extends readonly [infer TFirst, ...infer TRest] ? (keyof TFirst & TSeen) | LambderRepeatedNames<TRest, TSeen | keyof TFirst> : never;
|
|
12
|
+
/** Intersected onto a list: a name declared in two of its maps is a compile error, and the property name is the message. */
|
|
13
|
+
export type LambderNoRepeatedNames<T> = [LambderRepeatedNames<T>] extends [never] ? unknown : {
|
|
14
|
+
readonly "lambder: a name is declared in more than one of these maps. Declare each name once.": LambderRepeatedNames<T>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A named-maps option as one map: a lone map as it is, a list merged in
|
|
18
|
+
* order. A name declared in two maps of the list is refused; `what` names a
|
|
19
|
+
* declaration and `where` the call that took the list, for that error.
|
|
20
|
+
*/
|
|
21
|
+
export declare const mergeNamedMaps: <TMap extends Record<string, unknown>>(option: LambderNamedMapsOption<TMap>, what: "guard" | "rate-limit policy" | "refusal code", where?: string) => TMap;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A named-maps option as one map: a lone map as it is, a list merged in
|
|
3
|
+
* order. A name declared in two maps of the list is refused; `what` names a
|
|
4
|
+
* declaration and `where` the call that took the list, for that error.
|
|
5
|
+
*/
|
|
6
|
+
export const mergeNamedMaps = (option, what, where = "create()") => {
|
|
7
|
+
if (!Array.isArray(option))
|
|
8
|
+
return option;
|
|
9
|
+
const merged = {};
|
|
10
|
+
for (const map of option) {
|
|
11
|
+
for (const [name, declaration] of Object.entries(map)) {
|
|
12
|
+
if (Object.prototype.hasOwnProperty.call(merged, name)) {
|
|
13
|
+
throw new Error(`Lambder: the ${what} "${name}" is declared in two of the maps given to ${where}. Declare each name once.`);
|
|
14
|
+
}
|
|
15
|
+
merged[name] = declaration;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return merged;
|
|
19
|
+
};
|
|
@@ -153,3 +153,27 @@ export type LambderGuardParamOf<TEntry, N extends string> = TEntry extends {
|
|
|
153
153
|
* reads instead of the source.
|
|
154
154
|
*/
|
|
155
155
|
export declare const apiGuardParam: <TOptions extends Record<string, LambderApiOptionEntry>, K extends keyof TOptions & string, N extends string>(options: TOptions, name: K, guard: N) => LambderGuardParamOf<TOptions[K], N>;
|
|
156
|
+
/**
|
|
157
|
+
* The identifier writeApiGuardParams exports an API's parameter under: its
|
|
158
|
+
* group, its action capitalized, then `GuardParam` (`orders.list` exports
|
|
159
|
+
* `ordersListGuardParam`). What code that reads a generated module by API
|
|
160
|
+
* name (a test comparing it with the server) looks each export up by.
|
|
161
|
+
*/
|
|
162
|
+
export declare const apiGuardParamExportName: (apiName: string) => string;
|
|
163
|
+
/** The key a generated guard parameter's tag sits under: declared and never created, so it exists in types alone. */
|
|
164
|
+
declare const API_GUARD_PARAM_TAG: unique symbol;
|
|
165
|
+
/**
|
|
166
|
+
* One API's parameter for one guard, as writeApiGuardParams exports it: the
|
|
167
|
+
* value TParam as declared (`true` for a guard named without one), tagged
|
|
168
|
+
* with the API's name and the guard's. The tag exists in types alone, so the
|
|
169
|
+
* names reach no bundle. A function typed to a guard's tagged parameters
|
|
170
|
+
* takes only generated ones: a hand-written literal, which would restate the
|
|
171
|
+
* server's declaration and could drift from it, does not compile.
|
|
172
|
+
*/
|
|
173
|
+
export type LambderApiGuardParam<TApi extends string, TGuard extends string, TParam> = TParam & {
|
|
174
|
+
readonly [API_GUARD_PARAM_TAG]: {
|
|
175
|
+
readonly api: TApi;
|
|
176
|
+
readonly guard: TGuard;
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
export {};
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* (when a guard runs, what a budget spans, when a custom key is charged) are
|
|
16
16
|
* declared here too and re-exported by the engines that read them.
|
|
17
17
|
*/
|
|
18
|
+
import { splitApiName } from "./LambderApiNames.js";
|
|
18
19
|
/**
|
|
19
20
|
* The parameter an API's guards option gives guard N, read off a generated
|
|
20
21
|
* `apiOptions` table with the type the table pins: the literal a permission
|
|
@@ -33,3 +34,15 @@ export const apiGuardParam = (options, name, guard) => {
|
|
|
33
34
|
param = guards[guard];
|
|
34
35
|
return param;
|
|
35
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* The identifier writeApiGuardParams exports an API's parameter under: its
|
|
39
|
+
* group, its action capitalized, then `GuardParam` (`orders.list` exports
|
|
40
|
+
* `ordersListGuardParam`). What code that reads a generated module by API
|
|
41
|
+
* name (a test comparing it with the server) looks each export up by.
|
|
42
|
+
*/
|
|
43
|
+
export const apiGuardParamExportName = (apiName) => {
|
|
44
|
+
const parts = splitApiName(apiName);
|
|
45
|
+
if (!parts)
|
|
46
|
+
throw new Error(`Lambder: "${apiName}" is not an endpoint name, so its guard parameter has no export name.`);
|
|
47
|
+
return `${parts.group}${parts.action[0].toUpperCase()}${parts.action.slice(1)}GuardParam`;
|
|
48
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an API call ended in, as the server names it in the call's summary
|
|
3
|
+
* line: the reasons a caller reads the same answer by (resolveApiOutcome),
|
|
4
|
+
* with the server's side of a 5xx, a crash, and `other` for an answer that
|
|
5
|
+
* was not written as an API answer (a hook answering an API call with a page
|
|
6
|
+
* of its own).
|
|
7
|
+
*/
|
|
8
|
+
export type LambderCallOutcome = "success" | "refusal" | "notAuthorized" | "sessionExpired" | "versionExpired" | "validation" | "crash" | "other";
|
|
9
|
+
/** An answer's outcome and refusal code, as the code that wrote the answer knows them. */
|
|
10
|
+
export type LambderCallOutcomeHint = {
|
|
11
|
+
outcome: LambderCallOutcome;
|
|
12
|
+
/** The refusal's code, a framework code (`lambder/rate-limited`) or an app's; null when the answer carries none. */
|
|
13
|
+
code: string | null;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* An envelope's outcome, read in the order a caller honours it: a 5xx is a
|
|
17
|
+
* crash whatever the envelope says, then the three flags, then a refusal,
|
|
18
|
+
* and anything else is the handler's answer.
|
|
19
|
+
*/
|
|
20
|
+
export declare const outcomeOfEnvelope: (written: object, statusCode: number) => LambderCallOutcomeHint;
|
|
21
|
+
/**
|
|
22
|
+
* The outcome of an answer written as text, for an answer that arrived with
|
|
23
|
+
* no hint (a replayed one, which its store keeps as text): a 422 is a
|
|
24
|
+
* validation refusal, an envelope is read as outcomeOfEnvelope reads it, and
|
|
25
|
+
* anything else is known by its status alone.
|
|
26
|
+
*/
|
|
27
|
+
export declare const outcomeOfAnswerText: (statusCode: number, body: string) => LambderCallOutcomeHint;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const codeOf = (refusal) => {
|
|
2
|
+
if (refusal === null || typeof refusal !== "object")
|
|
3
|
+
return null;
|
|
4
|
+
const code = refusal.code;
|
|
5
|
+
return typeof code === "string" ? code : null;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* An envelope's outcome, read in the order a caller honours it: a 5xx is a
|
|
9
|
+
* crash whatever the envelope says, then the three flags, then a refusal,
|
|
10
|
+
* and anything else is the handler's answer.
|
|
11
|
+
*/
|
|
12
|
+
export const outcomeOfEnvelope = (written, statusCode) => {
|
|
13
|
+
const envelope = written;
|
|
14
|
+
if (statusCode >= 500)
|
|
15
|
+
return { outcome: "crash", code: null };
|
|
16
|
+
if (envelope.versionExpired)
|
|
17
|
+
return { outcome: "versionExpired", code: null };
|
|
18
|
+
if (envelope.sessionExpired)
|
|
19
|
+
return { outcome: "sessionExpired", code: null };
|
|
20
|
+
if (envelope.notAuthorized)
|
|
21
|
+
return { outcome: "notAuthorized", code: codeOf(envelope.refusal) };
|
|
22
|
+
if (envelope.refusal !== undefined)
|
|
23
|
+
return { outcome: "refusal", code: codeOf(envelope.refusal) };
|
|
24
|
+
return { outcome: "success", code: null };
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The outcome of an answer written as text, for an answer that arrived with
|
|
28
|
+
* no hint (a replayed one, which its store keeps as text): a 422 is a
|
|
29
|
+
* validation refusal, an envelope is read as outcomeOfEnvelope reads it, and
|
|
30
|
+
* anything else is known by its status alone.
|
|
31
|
+
*/
|
|
32
|
+
export const outcomeOfAnswerText = (statusCode, body) => {
|
|
33
|
+
if (statusCode >= 500)
|
|
34
|
+
return { outcome: "crash", code: null };
|
|
35
|
+
if (statusCode === 422)
|
|
36
|
+
return { outcome: "validation", code: null };
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(body);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { outcome: "other", code: null };
|
|
43
|
+
}
|
|
44
|
+
if (parsed === null || typeof parsed !== "object" || !("payload" in parsed))
|
|
45
|
+
return { outcome: "other", code: null };
|
|
46
|
+
return outcomeOfEnvelope(parsed, statusCode);
|
|
47
|
+
};
|
|
@@ -25,3 +25,13 @@ export declare const LAMBDER_INVOKE_API_ID = "lambder-invoke";
|
|
|
25
25
|
* it.
|
|
26
26
|
*/
|
|
27
27
|
export declare const LAMBDER_LOCAL_API_ID = "lambder-local";
|
|
28
|
+
/**
|
|
29
|
+
* The request id of the invocation that made an invoke, when it runs under a
|
|
30
|
+
* Lambder handler: the invoke event builder writes it, and the callee's call
|
|
31
|
+
* summary records it as its parentRequestId, so a query over both functions'
|
|
32
|
+
* logs joins a call to the calls it made. The server reads it only from an
|
|
33
|
+
* invoke (LAMBDER_INVOKE_API_ID), whose event no gateway lets a client write.
|
|
34
|
+
* Declared here, beside the ids, because the builder writes it and the
|
|
35
|
+
* server reads it.
|
|
36
|
+
*/
|
|
37
|
+
export declare const LAMBDER_PARENT_REQUEST_HEADER = "x-lambder-parent-request-id";
|
|
@@ -25,3 +25,13 @@ export const LAMBDER_INVOKE_API_ID = "lambder-invoke";
|
|
|
25
25
|
* it.
|
|
26
26
|
*/
|
|
27
27
|
export const LAMBDER_LOCAL_API_ID = "lambder-local";
|
|
28
|
+
/**
|
|
29
|
+
* The request id of the invocation that made an invoke, when it runs under a
|
|
30
|
+
* Lambder handler: the invoke event builder writes it, and the callee's call
|
|
31
|
+
* summary records it as its parentRequestId, so a query over both functions'
|
|
32
|
+
* logs joins a call to the calls it made. The server reads it only from an
|
|
33
|
+
* invoke (LAMBDER_INVOKE_API_ID), whose event no gateway lets a client write.
|
|
34
|
+
* Declared here, beside the ids, because the builder writes it and the
|
|
35
|
+
* server reads it.
|
|
36
|
+
*/
|
|
37
|
+
export const LAMBDER_PARENT_REQUEST_HEADER = "x-lambder-parent-request-id";
|
|
@@ -20,12 +20,21 @@ export interface LambderDdbRateLimiterOptions {
|
|
|
20
20
|
* Fixed-window rate limiter backed by DynamoDB.
|
|
21
21
|
*
|
|
22
22
|
* Each window is one item counted with a conditional `ADD`, so the increment
|
|
23
|
-
* and the limit check are one atomic request
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* and the limit check are one atomic request, and every window's request is
|
|
24
|
+
* sent at once: a policy capping three windows costs one round trip on the
|
|
25
|
+
* request's critical path, not three. Attempts count, not successes: a window
|
|
26
|
+
* under its limit counts an attempt another window refuses, since a
|
|
27
|
+
* compensating decrement would give up the conditional-ADD atomicity. The
|
|
28
|
+
* refusal names the smallest window that refused. Items carry an `expiresAt`
|
|
29
|
+
* attribute for DynamoDB TTL.
|
|
30
|
+
*
|
|
31
|
+
* A window the table refused an attempt in stays at its limit until it
|
|
32
|
+
* resets, since a count only rises within its window. So the process
|
|
33
|
+
* remembers it until then, and refuses the key's next attempts from memory
|
|
34
|
+
* without touching the table: a flood costs each process one round of
|
|
35
|
+
* writes per window it fills, and the counters of its other windows stop
|
|
36
|
+
* rising once it is refused. Another process asks the table once and then
|
|
37
|
+
* remembers too.
|
|
29
38
|
*
|
|
30
39
|
* The tracker key is caller data (an address, a session key, whatever a
|
|
31
40
|
* policy handler returned), so a key whose partition key would pass
|
|
@@ -47,9 +56,9 @@ export interface LambderDdbRateLimiterOptions {
|
|
|
47
56
|
* means the partition holding this counter is flooded. A partition holds a
|
|
48
57
|
* range of keys, though, not one: a flood on one address, or a session or
|
|
49
58
|
* cache spike on a shared table, throttles every counter on the same
|
|
50
|
-
* partition. The key's own counts tell the flood from its neighbours:
|
|
51
|
-
*
|
|
52
|
-
*
|
|
59
|
+
* partition. The key's own counts tell the flood from its neighbours: those
|
|
60
|
+
* of the windows whose write was throttled, the ones this attempt has not
|
|
61
|
+
* been counted against, each read with a consistent
|
|
53
62
|
* GetItem, in parallel (reads have their own throughput, which the throttled
|
|
54
63
|
* writes leave alone). A key at or over the limit of any of them is the
|
|
55
64
|
* flood: it is refused, since passed on as a failure `failOpen` would wave
|
|
@@ -87,8 +96,8 @@ export declare class LambderDdbRateLimiter implements LambderRateLimiter {
|
|
|
87
96
|
private readonly ready;
|
|
88
97
|
private readonly ttlWindowMultiplier;
|
|
89
98
|
private readonly now;
|
|
90
|
-
/** The windows
|
|
91
|
-
private readonly
|
|
99
|
+
/** The windows found at their limit, and those whose read a throttle refused, by (partition key, window, window start); see the class doc. */
|
|
100
|
+
private readonly rememberedWindows;
|
|
92
101
|
constructor(options: LambderDdbRateLimiterOptions);
|
|
93
102
|
/** The clock the windows are computed against, for the engine's Retry-After. */
|
|
94
103
|
clockMilliseconds(): number;
|
|
@@ -107,11 +116,10 @@ export declare class LambderDdbRateLimiter implements LambderRateLimiter {
|
|
|
107
116
|
*/
|
|
108
117
|
private incrementWindow;
|
|
109
118
|
/**
|
|
110
|
-
* The answer to a key-range throttle on the
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* one into a refusal.
|
|
119
|
+
* The answer to a key-range throttle on `uncounted`, the windows whose
|
|
120
|
+
* write the throttle refused and which have not counted this attempt.
|
|
121
|
+
* The others counted it and allowed it, so reading them could only turn
|
|
122
|
+
* the attempt that filled one into a refusal.
|
|
115
123
|
*
|
|
116
124
|
* Each count is read strongly consistent: the count that decides is the
|
|
117
125
|
* one the throttled writes were racing to raise, and a replica lagging
|
|
@@ -6,12 +6,14 @@ import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
|
|
|
6
6
|
/** How long a key refused during a throttle is told to wait: long enough to let the partition recover, short enough for a legitimate caller. */
|
|
7
7
|
const THROTTLED_RETRY_SECONDS = 5;
|
|
8
8
|
/**
|
|
9
|
-
* Most (tracker key, window) pairs the limiter remembers
|
|
10
|
-
*
|
|
11
|
-
* THROTTLED_RETRY_SECONDS
|
|
12
|
-
* that many keys at once, where the
|
|
9
|
+
* Most (tracker key, window) pairs the limiter remembers at once. Only a
|
|
10
|
+
* window found at its limit is remembered (until it resets, or for
|
|
11
|
+
* THROTTLED_RETRY_SECONDS when a throttle's read found it there), so this is
|
|
12
|
+
* reached only by refusals spread over that many keys at once, where the
|
|
13
|
+
* ones closest to expiring go first; a key forgotten early is asked of the
|
|
14
|
+
* table again, which answers the same.
|
|
13
15
|
*/
|
|
14
|
-
const
|
|
16
|
+
const REMEMBERED_WINDOW_ENTRIES = 10_000;
|
|
15
17
|
/** The table key of one window's counter. */
|
|
16
18
|
const counterKeyOf = (partitionKey, window) => ({
|
|
17
19
|
pk: { S: partitionKey },
|
|
@@ -23,12 +25,21 @@ const rememberedWindowKeyOf = (partitionKey, window) => joinKeyFields(partitionK
|
|
|
23
25
|
* Fixed-window rate limiter backed by DynamoDB.
|
|
24
26
|
*
|
|
25
27
|
* Each window is one item counted with a conditional `ADD`, so the increment
|
|
26
|
-
* and the limit check are one atomic request
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* and the limit check are one atomic request, and every window's request is
|
|
29
|
+
* sent at once: a policy capping three windows costs one round trip on the
|
|
30
|
+
* request's critical path, not three. Attempts count, not successes: a window
|
|
31
|
+
* under its limit counts an attempt another window refuses, since a
|
|
32
|
+
* compensating decrement would give up the conditional-ADD atomicity. The
|
|
33
|
+
* refusal names the smallest window that refused. Items carry an `expiresAt`
|
|
34
|
+
* attribute for DynamoDB TTL.
|
|
35
|
+
*
|
|
36
|
+
* A window the table refused an attempt in stays at its limit until it
|
|
37
|
+
* resets, since a count only rises within its window. So the process
|
|
38
|
+
* remembers it until then, and refuses the key's next attempts from memory
|
|
39
|
+
* without touching the table: a flood costs each process one round of
|
|
40
|
+
* writes per window it fills, and the counters of its other windows stop
|
|
41
|
+
* rising once it is refused. Another process asks the table once and then
|
|
42
|
+
* remembers too.
|
|
32
43
|
*
|
|
33
44
|
* The tracker key is caller data (an address, a session key, whatever a
|
|
34
45
|
* policy handler returned), so a key whose partition key would pass
|
|
@@ -50,9 +61,9 @@ const rememberedWindowKeyOf = (partitionKey, window) => joinKeyFields(partitionK
|
|
|
50
61
|
* means the partition holding this counter is flooded. A partition holds a
|
|
51
62
|
* range of keys, though, not one: a flood on one address, or a session or
|
|
52
63
|
* cache spike on a shared table, throttles every counter on the same
|
|
53
|
-
* partition. The key's own counts tell the flood from its neighbours:
|
|
54
|
-
*
|
|
55
|
-
*
|
|
64
|
+
* partition. The key's own counts tell the flood from its neighbours: those
|
|
65
|
+
* of the windows whose write was throttled, the ones this attempt has not
|
|
66
|
+
* been counted against, each read with a consistent
|
|
56
67
|
* GetItem, in parallel (reads have their own throughput, which the throttled
|
|
57
68
|
* writes leave alone). A key at or over the limit of any of them is the
|
|
58
69
|
* flood: it is refused, since passed on as a failure `failOpen` would wave
|
|
@@ -90,8 +101,8 @@ export class LambderDdbRateLimiter {
|
|
|
90
101
|
ready;
|
|
91
102
|
ttlWindowMultiplier;
|
|
92
103
|
now;
|
|
93
|
-
/** The windows
|
|
94
|
-
|
|
104
|
+
/** The windows found at their limit, and those whose read a throttle refused, by (partition key, window, window start); see the class doc. */
|
|
105
|
+
rememberedWindows;
|
|
95
106
|
constructor(options) {
|
|
96
107
|
if (!options.tableName.trim())
|
|
97
108
|
throw new Error("tableName is required");
|
|
@@ -99,7 +110,7 @@ export class LambderDdbRateLimiter {
|
|
|
99
110
|
this.keyPrefix = options.keyPrefix ?? "RL";
|
|
100
111
|
this.ttlWindowMultiplier = assertNumberAtLeast(options.ttlWindowMultiplier ?? 2, 1, "ttlWindowMultiplier");
|
|
101
112
|
this.now = options.now ?? (() => Date.now());
|
|
102
|
-
this.
|
|
113
|
+
this.rememberedWindows = new LambderExpiringMap({ now: this.now, maxEntries: REMEMBERED_WINDOW_ENTRIES });
|
|
103
114
|
this.ready = createDynamoClientLoader({ user: "LambderDdbRateLimiter", region: options.region, client: options.client });
|
|
104
115
|
}
|
|
105
116
|
/** The clock the windows are computed against, for the engine's Retry-After. */
|
|
@@ -121,28 +132,37 @@ export class LambderDdbRateLimiter {
|
|
|
121
132
|
const windows = RATE_LIMIT_WINDOWS
|
|
122
133
|
.filter(({ key }) => policy[key])
|
|
123
134
|
.map(({ key, seconds }) => ({ key, seconds, limit: policy[key], start: Math.floor(nowSeconds / seconds) * seconds }));
|
|
124
|
-
// A window this process
|
|
125
|
-
//
|
|
126
|
-
const
|
|
127
|
-
const remembered = this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (rememberedAtLimit)
|
|
131
|
-
return { window: rememberedAtLimit.key, limit: rememberedAtLimit.limit, resetAt: nowSeconds + THROTTLED_RETRY_SECONDS };
|
|
132
|
-
for (const [index, window] of windows.entries()) {
|
|
133
|
-
try {
|
|
134
|
-
if (await this.incrementWindow(partitionKey, window, nowSeconds))
|
|
135
|
-
return { window: window.key, limit: window.limit, resetAt: window.start + window.seconds };
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
// A key-range throttle may be the limiter's own answer (see
|
|
139
|
-
// the class doc); anything else is the table failing, which
|
|
140
|
-
// only the caller can decide what to do about.
|
|
141
|
-
if (!isKeyRangeThrottle(error))
|
|
142
|
-
throw error;
|
|
143
|
-
return await this.answerKeyRangeThrottle(partitionKey, windows.slice(index), nowSeconds, error);
|
|
135
|
+
// A window this process has found at its limit refuses without
|
|
136
|
+
// touching the table (see the class doc).
|
|
137
|
+
for (const window of windows) {
|
|
138
|
+
const remembered = this.rememberedWindows.get(rememberedWindowKeyOf(partitionKey, window));
|
|
139
|
+
if (remembered !== undefined && "count" in remembered && remembered.count >= window.limit) {
|
|
140
|
+
return { window: window.key, limit: window.limit, resetAt: remembered.resetAt ?? nowSeconds + THROTTLED_RETRY_SECONDS };
|
|
144
141
|
}
|
|
145
142
|
}
|
|
143
|
+
const attempts = await Promise.allSettled(windows.map((window) => this.incrementWindow(partitionKey, window, nowSeconds)));
|
|
144
|
+
// A refusal is the answer whatever the other windows did: a window at
|
|
145
|
+
// its limit refuses the attempt whether the others counted it, failed
|
|
146
|
+
// or were throttled.
|
|
147
|
+
const refusedAt = attempts.findIndex((attempt) => attempt.status === "fulfilled" && attempt.value);
|
|
148
|
+
if (refusedAt !== -1) {
|
|
149
|
+
const refusing = windows[refusedAt];
|
|
150
|
+
const resetAt = refusing.start + refusing.seconds;
|
|
151
|
+
this.rememberedWindows.set(rememberedWindowKeyOf(partitionKey, refusing), { count: refusing.limit, resetAt }, resetAt);
|
|
152
|
+
return { window: refusing.key, limit: refusing.limit, resetAt };
|
|
153
|
+
}
|
|
154
|
+
// A failure that is not the key's range being throttled is the table
|
|
155
|
+
// failing, which only the caller can decide what to do about.
|
|
156
|
+
const failure = attempts.find((attempt) => attempt.status === "rejected" && !isKeyRangeThrottle(attempt.reason));
|
|
157
|
+
if (failure)
|
|
158
|
+
throw failure.reason;
|
|
159
|
+
// A key-range throttle may be the limiter's own answer (see the class
|
|
160
|
+
// doc), read from the windows the throttle kept the attempt from.
|
|
161
|
+
const throttled = windows.filter((_, index) => attempts[index].status === "rejected");
|
|
162
|
+
if (throttled.length) {
|
|
163
|
+
const throttle = attempts.find((attempt) => attempt.status === "rejected").reason;
|
|
164
|
+
return await this.answerKeyRangeThrottle(partitionKey, throttled, nowSeconds, throttle);
|
|
165
|
+
}
|
|
146
166
|
return false;
|
|
147
167
|
}
|
|
148
168
|
/** The item's partition key, refused when the tracker key makes it one DynamoDB will not take. */
|
|
@@ -185,11 +205,10 @@ export class LambderDdbRateLimiter {
|
|
|
185
205
|
}
|
|
186
206
|
}
|
|
187
207
|
/**
|
|
188
|
-
* The answer to a key-range throttle on the
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* one into a refusal.
|
|
208
|
+
* The answer to a key-range throttle on `uncounted`, the windows whose
|
|
209
|
+
* write the throttle refused and which have not counted this attempt.
|
|
210
|
+
* The others counted it and allowed it, so reading them could only turn
|
|
211
|
+
* the attempt that filled one into a refusal.
|
|
193
212
|
*
|
|
194
213
|
* Each count is read strongly consistent: the count that decides is the
|
|
195
214
|
* one the throttled writes were racing to raise, and a replica lagging
|
|
@@ -201,7 +220,7 @@ export class LambderDdbRateLimiter {
|
|
|
201
220
|
*/
|
|
202
221
|
async answerKeyRangeThrottle(partitionKey, uncounted, nowSeconds, throttle) {
|
|
203
222
|
const throttledWindowKey = rememberedWindowKeyOf(partitionKey, uncounted[0]);
|
|
204
|
-
const remembered = this.
|
|
223
|
+
const remembered = this.rememberedWindows.get(throttledWindowKey);
|
|
205
224
|
if (remembered !== undefined && "unreadable" in remembered)
|
|
206
225
|
throw remembered.unreadable;
|
|
207
226
|
const { client, sdk } = await this.ready();
|
|
@@ -214,14 +233,14 @@ export class LambderDdbRateLimiter {
|
|
|
214
233
|
for (const [index, read] of reads.entries()) {
|
|
215
234
|
const window = uncounted[index];
|
|
216
235
|
if (read.status === "fulfilled" && read.value >= window.limit) {
|
|
217
|
-
this.
|
|
236
|
+
this.rememberedWindows.set(rememberedWindowKeyOf(partitionKey, window), { count: read.value }, rememberUntil);
|
|
218
237
|
refusing ??= window;
|
|
219
238
|
}
|
|
220
239
|
}
|
|
221
240
|
if (refusing)
|
|
222
241
|
return { window: refusing.key, limit: refusing.limit, resetAt: rememberUntil };
|
|
223
242
|
if (reads.some((read) => read.status === "rejected" && isKeyRangeThrottle(read.reason))) {
|
|
224
|
-
this.
|
|
243
|
+
this.rememberedWindows.set(throttledWindowKey, { unreadable: throttle }, rememberUntil);
|
|
225
244
|
}
|
|
226
245
|
throw throttle;
|
|
227
246
|
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { type LambderRateLimiter, type LambderRateLimitPolicy, type LambderRateLimitResult, type LambderRateLimitWindow } from "../shared/contracts/LambderRateLimiter.js";
|
|
2
2
|
/**
|
|
3
3
|
* Fixed-window rate limiter held in memory: the same windows, the same
|
|
4
|
-
* attempts-count semantics and the same
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* attempts-count semantics and the same refusals as LambderDdbRateLimiter,
|
|
5
|
+
* with a Map in place of the table. For tests and for the mock runtime; a
|
|
6
|
+
* single-process server could use it too, with the caveat that its counters
|
|
7
|
+
* are per process and vanish with it.
|
|
8
|
+
*
|
|
9
|
+
* Every counter is in hand, so an attempt a window refuses is counted in no
|
|
10
|
+
* window: what the DynamoDB limiter reaches once it remembers the refusing
|
|
11
|
+
* window, which the first refused attempt in each of its processes still
|
|
12
|
+
* counts in the others.
|
|
8
13
|
*
|
|
9
14
|
* `now` is injectable so a test can move time forward and watch a window
|
|
10
15
|
* reset without waiting for it.
|