lambder 11.1.1 → 13.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +25 -15
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiDeclarations.d.ts +194 -0
  5. package/dist/api/LambderApiDeclarations.js +52 -0
  6. package/dist/api/LambderApiDefinition.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +4 -1
  8. package/dist/api/LambderApiGuards.d.ts +21 -22
  9. package/dist/api/LambderApiGuards.js +1 -1
  10. package/dist/api/LambderApiRateLimits.d.ts +19 -19
  11. package/dist/api/LambderApiRateLimits.js +1 -1
  12. package/dist/api/LambderApiRefusals.d.ts +22 -0
  13. package/dist/api/LambderApiRefusals.js +13 -0
  14. package/dist/api/LambderApiRequest.d.ts +15 -6
  15. package/dist/api/LambderApiRequest.js +8 -9
  16. package/dist/build/generateApiFiles.d.ts +60 -0
  17. package/dist/build/generateApiFiles.js +85 -0
  18. package/dist/build/generatedTables.d.ts +2 -1
  19. package/dist/build/generatedTables.js +1 -1
  20. package/dist/build.d.ts +4 -1
  21. package/dist/build.js +3 -1
  22. package/dist/client/LambderCaller.d.ts +36 -23
  23. package/dist/client/LambderCaller.js +88 -67
  24. package/dist/client/lambderFetchTransport.js +2 -1
  25. package/dist/client.d.ts +3 -1
  26. package/dist/client.js +2 -0
  27. package/dist/core/Lambder.d.ts +232 -92
  28. package/dist/core/Lambder.js +400 -103
  29. package/dist/core/LambderCallSummary.d.ts +43 -0
  30. package/dist/core/LambderCallSummary.js +10 -0
  31. package/dist/core/LambderContext.d.ts +15 -2
  32. package/dist/core/LambderContext.js +32 -9
  33. package/dist/core/LambderCreateOptions.d.ts +78 -64
  34. package/dist/core/LambderCreateOptions.js +39 -21
  35. package/dist/core/LambderInvocationScope.d.ts +25 -0
  36. package/dist/core/LambderInvocationScope.js +21 -0
  37. package/dist/core/LambderResponse.d.ts +11 -0
  38. package/dist/core/LambderResponse.js +12 -0
  39. package/dist/core/LambderResponseBuilder.js +5 -1
  40. package/dist/index.d.ts +13 -7
  41. package/dist/index.js +3 -1
  42. package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
  43. package/dist/invoke/LambderInvokeCaller.js +110 -94
  44. package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
  45. package/dist/invoke/LambderLambdaEvent.js +8 -3
  46. package/dist/invoke/lambderHandlerTransport.js +2 -1
  47. package/dist/mock/LambderMockApp.d.ts +39 -33
  48. package/dist/mock/LambderMockApp.js +66 -55
  49. package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
  50. package/dist/mock/LambderMockTypes.d.ts +44 -7
  51. package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
  52. package/dist/mock/lambderMockInvokeTransport.js +15 -7
  53. package/dist/mock/lambderMockMswHandler.js +8 -4
  54. package/dist/mock.d.ts +2 -2
  55. package/dist/session/LambderSessionManager.d.ts +46 -3
  56. package/dist/session/LambderSessionManager.js +62 -11
  57. package/dist/shared/LambderI18n.js +49 -4
  58. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  59. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  60. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  61. package/dist/shared/transport/LambderApiTransport.js +2 -3
  62. package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
  63. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  64. package/dist/shared/util/LambderInvocationScope.js +5 -0
  65. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  66. package/dist/shared/util/LambderNamedMaps.js +19 -0
  67. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  68. package/dist/shared/wire/LambderApiContract.d.ts +4 -18
  69. package/dist/shared/wire/LambderApiContract.js +1 -1
  70. package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
  71. package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
  72. package/dist/shared/wire/LambderApiNames.d.ts +55 -0
  73. package/dist/shared/wire/LambderApiNames.js +81 -0
  74. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  75. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  76. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  77. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  78. package/dist/shared/wire/LambderUploadSchemas.js +2 -1
  79. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  80. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  81. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  82. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  83. package/dist/testing/LambderTestApp.d.ts +18 -5
  84. package/dist/testing/LambderTestApp.js +21 -5
  85. package/dist/testing/LambderTestVisitor.d.ts +16 -8
  86. package/dist/testing/LambderTestVisitor.js +33 -25
  87. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ import { isActionName, isGroupName } from "./LambderApiNames.js";
2
+ /**
3
+ * The caller, answering for each group a contract could name besides its
4
+ * own members. A member the caller has (a method, a field) is always the
5
+ * caller's; a name that could be a group (see isGroupName: an identifier no
6
+ * caller member has) is one; anything else a runtime or a framework asks of
7
+ * an object (then, toJSON, a symbol, `__v_isRef`) is what the caller itself
8
+ * answers, so a caller is never mistaken for a promise, a ref or a
9
+ * serializable record.
10
+ *
11
+ * A caller keeps its state in #private fields, which no property name
12
+ * reaches, so a group may take any name its public members leave free. Such
13
+ * a field is readable only on the caller itself, never through this proxy,
14
+ * so a member is read and a method run on the caller; a method that returns
15
+ * the caller (setTransport) hands back the proxy, groups and all.
16
+ *
17
+ * A group answers the same way one level down: an ordinary object's own
18
+ * members (toString, valueOf, hasOwnProperty) are an ordinary object's, so
19
+ * converting a group to a string or asking it what it holds calls no
20
+ * endpoint; any other name that could be an action (see isActionName) is
21
+ * one. A group is read-only, like the type that describes it.
22
+ */
23
+ export const withApiGroupCalls = (caller, call, outcome) => {
24
+ const methods = new WeakMap();
25
+ const methodOnCaller = (method) => {
26
+ let bound = methods.get(method);
27
+ if (!bound) {
28
+ bound = (...args) => {
29
+ const result = Reflect.apply(method, caller, args);
30
+ return result === caller ? withGroups : result;
31
+ };
32
+ methods.set(method, bound);
33
+ }
34
+ return bound;
35
+ };
36
+ const groups = new Map();
37
+ const groupOf = (group) => {
38
+ let calls = groups.get(group);
39
+ if (!calls) {
40
+ const actions = new Map();
41
+ calls = new Proxy(Object.freeze({}), {
42
+ get: (target, action) => {
43
+ if (typeof action !== "string" || action in target || !isActionName(action))
44
+ return Reflect.get(target, action);
45
+ let endpoint = actions.get(action);
46
+ if (!endpoint) {
47
+ const apiName = `${group}.${action}`;
48
+ endpoint = Object.freeze(Object.assign((...args) => call(apiName, args), { outcome: (...args) => outcome(apiName, args) }));
49
+ actions.set(action, endpoint);
50
+ }
51
+ return endpoint;
52
+ },
53
+ });
54
+ groups.set(group, calls);
55
+ }
56
+ return calls;
57
+ };
58
+ const withGroups = new Proxy(caller, {
59
+ get: (target, property) => {
60
+ if (typeof property === "string" && !(property in target) && isGroupName(property))
61
+ return groupOf(property);
62
+ const value = Reflect.get(target, property, target);
63
+ return typeof value === "function" && property !== "constructor" ? methodOnCaller(value) : value;
64
+ },
65
+ set: (target, property, value) => Reflect.set(target, property, value, target),
66
+ });
67
+ return withGroups;
68
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * How an endpoint is named and where it is called. An endpoint belongs to a
3
+ * group, and its name is the two joined by a dot: `orders.place`. Both are
4
+ * identifiers, so the name is also a path: a call posts to
5
+ * `{apiPath}/{group}/{action}`, which a gateway, a CDN and a log can meter,
6
+ * limit and read per endpoint without opening the body.
7
+ *
8
+ * The group is also a property of every Lambder caller
9
+ * (`caller.orders.place(input)`), so a group may not be named like anything a
10
+ * caller already has.
11
+ */
12
+ /** A group's or an action's name: a letter, then letters, digits or underscores. */
13
+ export declare const LAMBDER_API_NAME_SEGMENT: RegExp;
14
+ /**
15
+ * Every public member of the three callers a group becomes a property of
16
+ * (LambderCaller, LambderInvokeCaller, LambderTestVisitor), since a caller
17
+ * looks a group up only after its own members. Their state is #private,
18
+ * which no property name reaches, so only these can stand in a group's way.
19
+ * A test holds this to the callers' public members, in their types and at
20
+ * runtime, both ways.
21
+ */
22
+ export declare const LAMBDER_CALLER_MEMBER_NAMES: readonly ["api", "apiOutcome", "caller", "clientIp", "fetchTrackerList", "host", "isLoading", "jar", "request", "setSessionCookieKey", "setTransport", "signIn"];
23
+ /**
24
+ * Names no group may take: the callers' members, and what a JavaScript
25
+ * runtime, a promise check, a serializer or a framework asks of any object.
26
+ */
27
+ export declare const LAMBDER_RESERVED_GROUP_NAMES: readonly ["api", "apiOutcome", "caller", "clientIp", "fetchTrackerList", "host", "isLoading", "jar", "request", "setSessionCookieKey", "setTransport", "signIn", "constructor", "then", "catch", "finally", "toJSON", "toString", "valueOf", "toLocaleString", "prototype", "inspect", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable"];
28
+ /** A name no group may take; see LAMBDER_RESERVED_GROUP_NAMES. */
29
+ export type LambderReservedGroupName = typeof LAMBDER_RESERVED_GROUP_NAMES[number];
30
+ /**
31
+ * Names no action may take: what a runtime, a promise check or a serializer
32
+ * asks of a group or of the function a caller hands out for an action, and
33
+ * the members every object has (toString, valueOf, hasOwnProperty), which a
34
+ * group keeps as its own.
35
+ */
36
+ export declare const LAMBDER_RESERVED_ACTION_NAMES: readonly ["then", "catch", "finally", "toJSON", "constructor", "prototype", "outcome", "bind", "call", "apply", "length", "name", "arguments", "caller", "toString", "toLocaleString", "valueOf", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable"];
37
+ /** A name no action may take; see LAMBDER_RESERVED_ACTION_NAMES. */
38
+ export type LambderReservedActionName = typeof LAMBDER_RESERVED_ACTION_NAMES[number];
39
+ /** Whether a name can be a group: an identifier segment, and not one a caller already answers for. */
40
+ export declare const isGroupName: (name: string) => boolean;
41
+ /** Whether a name can be an action of a group: an identifier segment, and not one a function already answers for. */
42
+ export declare const isActionName: (name: string) => boolean;
43
+ /** An endpoint's name split into its group and action, or null for a name that is not two segments. */
44
+ export declare const splitApiName: (apiName: string) => {
45
+ group: string;
46
+ action: string;
47
+ } | null;
48
+ /** Where a call to the endpoint goes: `{apiPath}/{group}/{action}`. An apiPath may be absolute (another origin) or end with a slash. */
49
+ export declare const apiCallPath: (apiPath: string, apiName: string) => string;
50
+ /**
51
+ * The endpoint a request path calls, or null when the path is not a call
52
+ * under apiPath: exactly `{apiPath}/{group}/{action}`, with no query, no
53
+ * trailing slash and no further segment.
54
+ */
55
+ export declare const apiNameOfCallPath: (apiPath: string, path: string) => string | null;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * How an endpoint is named and where it is called. An endpoint belongs to a
3
+ * group, and its name is the two joined by a dot: `orders.place`. Both are
4
+ * identifiers, so the name is also a path: a call posts to
5
+ * `{apiPath}/{group}/{action}`, which a gateway, a CDN and a log can meter,
6
+ * limit and read per endpoint without opening the body.
7
+ *
8
+ * The group is also a property of every Lambder caller
9
+ * (`caller.orders.place(input)`), so a group may not be named like anything a
10
+ * caller already has.
11
+ */
12
+ /** A group's or an action's name: a letter, then letters, digits or underscores. */
13
+ export const LAMBDER_API_NAME_SEGMENT = /^[A-Za-z][A-Za-z0-9_]*$/;
14
+ /**
15
+ * Every public member of the three callers a group becomes a property of
16
+ * (LambderCaller, LambderInvokeCaller, LambderTestVisitor), since a caller
17
+ * looks a group up only after its own members. Their state is #private,
18
+ * which no property name reaches, so only these can stand in a group's way.
19
+ * A test holds this to the callers' public members, in their types and at
20
+ * runtime, both ways.
21
+ */
22
+ export const LAMBDER_CALLER_MEMBER_NAMES = [
23
+ "api", "apiOutcome", "caller", "clientIp", "fetchTrackerList", "host", "isLoading", "jar", "request",
24
+ "setSessionCookieKey", "setTransport", "signIn",
25
+ ];
26
+ /**
27
+ * Names no group may take: the callers' members, and what a JavaScript
28
+ * runtime, a promise check, a serializer or a framework asks of any object.
29
+ */
30
+ export const LAMBDER_RESERVED_GROUP_NAMES = [
31
+ ...LAMBDER_CALLER_MEMBER_NAMES,
32
+ "constructor", "then", "catch", "finally", "toJSON", "toString", "valueOf", "toLocaleString", "prototype", "inspect",
33
+ "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable",
34
+ ];
35
+ /**
36
+ * Names no action may take: what a runtime, a promise check or a serializer
37
+ * asks of a group or of the function a caller hands out for an action, and
38
+ * the members every object has (toString, valueOf, hasOwnProperty), which a
39
+ * group keeps as its own.
40
+ */
41
+ export const LAMBDER_RESERVED_ACTION_NAMES = [
42
+ "then", "catch", "finally", "toJSON", "constructor", "prototype", "outcome", "bind", "call", "apply", "length", "name",
43
+ "arguments", "caller", "toString", "toLocaleString", "valueOf", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable",
44
+ ];
45
+ const RESERVED_GROUP_NAMES = new Set(LAMBDER_RESERVED_GROUP_NAMES);
46
+ const RESERVED_ACTION_NAMES = new Set(LAMBDER_RESERVED_ACTION_NAMES);
47
+ /** Whether a name can be a group: an identifier segment, and not one a caller already answers for. */
48
+ export const isGroupName = (name) => LAMBDER_API_NAME_SEGMENT.test(name) && !RESERVED_GROUP_NAMES.has(name);
49
+ /** Whether a name can be an action of a group: an identifier segment, and not one a function already answers for. */
50
+ export const isActionName = (name) => LAMBDER_API_NAME_SEGMENT.test(name) && !RESERVED_ACTION_NAMES.has(name);
51
+ /** An endpoint's name split into its group and action, or null for a name that is not two segments. */
52
+ export const splitApiName = (apiName) => {
53
+ const dot = apiName.indexOf(".");
54
+ if (dot === -1)
55
+ return null;
56
+ const group = apiName.slice(0, dot);
57
+ const action = apiName.slice(dot + 1);
58
+ return LAMBDER_API_NAME_SEGMENT.test(group) && LAMBDER_API_NAME_SEGMENT.test(action) ? { group, action } : null;
59
+ };
60
+ /** Where a call to the endpoint goes: `{apiPath}/{group}/{action}`. An apiPath may be absolute (another origin) or end with a slash. */
61
+ export const apiCallPath = (apiPath, apiName) => {
62
+ const parts = splitApiName(apiName);
63
+ if (!parts)
64
+ throw new Error(`Lambder: "${apiName}" is not an endpoint name. An endpoint is named group.action, both identifiers.`);
65
+ return `${apiPath.replace(/\/+$/, "")}/${parts.group}/${parts.action}`;
66
+ };
67
+ /**
68
+ * The endpoint a request path calls, or null when the path is not a call
69
+ * under apiPath: exactly `{apiPath}/{group}/{action}`, with no query, no
70
+ * trailing slash and no further segment.
71
+ */
72
+ export const apiNameOfCallPath = (apiPath, path) => {
73
+ const base = apiPath.replace(/\/+$/, "");
74
+ if (!path.startsWith(`${base}/`))
75
+ return null;
76
+ const segments = path.slice(base.length + 1).split("/");
77
+ if (segments.length !== 2)
78
+ return null;
79
+ const [group, action] = segments;
80
+ return LAMBDER_API_NAME_SEGMENT.test(group) && LAMBDER_API_NAME_SEGMENT.test(action) ? `${group}.${action}` : null;
81
+ };
@@ -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";
@@ -5,9 +5,10 @@ import { z } from "zod";
5
5
  * posts to the ticket endpoint, and the ticket it answers.
6
6
  *
7
7
  * ```ts
8
- * .addSessionApi("documents.requestUpload", {
8
+ * requestUpload: defineApi({
9
9
  * input: z.object({ folderId: z.uuid(), fileFacts: LambderUploadFileFactsSchema }),
10
10
  * output: z.object({ ticket: LambderUploadTicketSchema, documentId: z.uuid() }),
11
+ * guards: "signedIn",
11
12
  * }, ...)
12
13
  * ```
13
14
  *
@@ -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. Windows are evaluated smallest
24
- * first and evaluation stops at the first exceeded one, which keeps blocked
25
- * requests cheap and spares the larger counters. Attempts count, not
26
- * successes: a counter checked before the refusing one keeps its increment,
27
- * since a compensating decrement would give up the conditional-ADD
28
- * atomicity. Items carry an `expiresAt` attribute for DynamoDB TTL.
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: the
51
- * throttled window's and every capped window's after it, the ones this
52
- * attempt has not been counted against yet, each read with a consistent
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 seen during key-range throttles, by (partition key, window, window start); see the class doc. */
91
- private readonly throttledWindows;
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 first of `uncounted`: that
111
- * window and every capped one after it, the windows this attempt has not
112
- * been counted against. The windows before it counted this attempt and
113
- * allowed it, so reading them could only turn the attempt that filled
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 from throttles at
10
- * once. Only a key seen during a key-range throttle is remembered, for
11
- * THROTTLED_RETRY_SECONDS, so this is reached only by a flood spread over
12
- * that many keys at once, where the ones closest to expiring go first.
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 THROTTLED_WINDOW_MEMORY_ENTRIES = 10_000;
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. Windows are evaluated smallest
27
- * first and evaluation stops at the first exceeded one, which keeps blocked
28
- * requests cheap and spares the larger counters. Attempts count, not
29
- * successes: a counter checked before the refusing one keeps its increment,
30
- * since a compensating decrement would give up the conditional-ADD
31
- * atomicity. Items carry an `expiresAt` attribute for DynamoDB TTL.
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: the
54
- * throttled window's and every capped window's after it, the ones this
55
- * attempt has not been counted against yet, each read with a consistent
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 seen during key-range throttles, by (partition key, window, window start); see the class doc. */
94
- throttledWindows;
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.throttledWindows = new LambderExpiringMap({ now: this.now, maxEntries: THROTTLED_WINDOW_MEMORY_ENTRIES });
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 read at its limit during a throttle refuses
125
- // without touching the table (see the class doc).
126
- const rememberedAtLimit = windows.find((window) => {
127
- const remembered = this.throttledWindows.get(rememberedWindowKeyOf(partitionKey, window));
128
- return remembered !== undefined && "count" in remembered && remembered.count >= window.limit;
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 first of `uncounted`: that
189
- * window and every capped one after it, the windows this attempt has not
190
- * been counted against. The windows before it counted this attempt and
191
- * allowed it, so reading them could only turn the attempt that filled
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.throttledWindows.get(throttledWindowKey);
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.throttledWindows.set(rememberedWindowKeyOf(partitionKey, window), { count: read.value }, rememberUntil);
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.throttledWindows.set(throttledWindowKey, { unreadable: throttle }, rememberUntil);
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 evaluation order as
5
- * LambderDdbRateLimiter, with a Map in place of the table. For tests and
6
- * for the mock runtime; a single-process server could use it too, with the
7
- * caveat that its counters are per process and vanish with it.
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.