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.
- package/CHANGELOG.md +217 -0
- package/README.md +25 -15
- package/dist/api/LambderApiAnswer.d.ts +7 -0
- package/dist/api/LambderApiDeclarations.d.ts +194 -0
- package/dist/api/LambderApiDeclarations.js +52 -0
- package/dist/api/LambderApiDefinition.d.ts +1 -1
- package/dist/api/LambderApiEnvelope.js +4 -1
- package/dist/api/LambderApiGuards.d.ts +21 -22
- package/dist/api/LambderApiGuards.js +1 -1
- package/dist/api/LambderApiRateLimits.d.ts +19 -19
- package/dist/api/LambderApiRateLimits.js +1 -1
- package/dist/api/LambderApiRefusals.d.ts +22 -0
- package/dist/api/LambderApiRefusals.js +13 -0
- package/dist/api/LambderApiRequest.d.ts +15 -6
- package/dist/api/LambderApiRequest.js +8 -9
- package/dist/build/generateApiFiles.d.ts +60 -0
- package/dist/build/generateApiFiles.js +85 -0
- package/dist/build/generatedTables.d.ts +2 -1
- package/dist/build/generatedTables.js +1 -1
- package/dist/build.d.ts +4 -1
- package/dist/build.js +3 -1
- package/dist/client/LambderCaller.d.ts +36 -23
- package/dist/client/LambderCaller.js +88 -67
- package/dist/client/lambderFetchTransport.js +2 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/core/Lambder.d.ts +232 -92
- package/dist/core/Lambder.js +400 -103
- package/dist/core/LambderCallSummary.d.ts +43 -0
- package/dist/core/LambderCallSummary.js +10 -0
- package/dist/core/LambderContext.d.ts +15 -2
- package/dist/core/LambderContext.js +32 -9
- package/dist/core/LambderCreateOptions.d.ts +78 -64
- package/dist/core/LambderCreateOptions.js +39 -21
- 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 +13 -7
- package/dist/index.js +3 -1
- package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
- package/dist/invoke/LambderInvokeCaller.js +110 -94
- package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
- package/dist/invoke/LambderLambdaEvent.js +8 -3
- package/dist/invoke/lambderHandlerTransport.js +2 -1
- package/dist/mock/LambderMockApp.d.ts +39 -33
- package/dist/mock/LambderMockApp.js +66 -55
- package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
- package/dist/mock/LambderMockTypes.d.ts +44 -7
- package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
- package/dist/mock/lambderMockInvokeTransport.js +15 -7
- package/dist/mock/lambderMockMswHandler.js +8 -4
- package/dist/mock.d.ts +2 -2
- 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/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/transport/LambderApiTransport.js +2 -3
- package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
- 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/util/LambderTypeUtilities.d.ts +1 -1
- package/dist/shared/wire/LambderApiContract.d.ts +4 -18
- package/dist/shared/wire/LambderApiContract.js +1 -1
- package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
- package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
- package/dist/shared/wire/LambderApiNames.d.ts +55 -0
- package/dist/shared/wire/LambderApiNames.js +81 -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/shared/wire/LambderUploadSchemas.js +2 -1
- 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 +18 -5
- package/dist/testing/LambderTestApp.js +21 -5
- package/dist/testing/LambderTestVisitor.d.ts +16 -8
- package/dist/testing/LambderTestVisitor.js +33 -25
- 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
|
-
*
|
|
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
|
|
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.
|