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
package/dist/core/Lambder.js
CHANGED
|
@@ -13,47 +13,66 @@ import { LambderFiles } from "./LambderFiles.js";
|
|
|
13
13
|
import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
14
14
|
import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
|
|
15
15
|
import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
|
|
16
|
-
import { checkedRefusal, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
|
|
16
|
+
import { checkedRefusal, declaredRefusalVocabulary, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
|
|
17
17
|
import { refuse } from "../shared/wire/LambderApiRefusal.js";
|
|
18
18
|
import { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
19
19
|
import { apiNameKeyOf } from "../shared/wire/LambderApiSignatureMap.js";
|
|
20
20
|
import { assertPlainData } from "../shared/util/assertPlainData.js";
|
|
21
|
-
import { apiNotFoundAnswer, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, successEnvelope, } from "../api/LambderApiEnvelope.js";
|
|
21
|
+
import { apiNotFoundAnswer, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, successEnvelope, versionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
|
|
22
22
|
import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
|
|
23
23
|
import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
|
|
24
24
|
import { toGuardEntries } from "../api/LambderApiGuards.js";
|
|
25
|
+
import { buildApiGroup, buildLazyApiGroup, isRegistrableApiGroup, } from "../api/LambderApiDeclarations.js";
|
|
26
|
+
import { splitApiName } from "../shared/wire/LambderApiNames.js";
|
|
25
27
|
import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
|
|
26
28
|
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
|
|
27
29
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
28
30
|
import { LambderCrashHandling } from "./LambderCrashHandling.js";
|
|
31
|
+
import { currentInvocation, runInvocation } from "./LambderInvocationScope.js";
|
|
32
|
+
import { roundedMilliseconds, writeCallSummaryLine } from "./LambderCallSummary.js";
|
|
33
|
+
import { outcomeOfAnswerText } from "../shared/wire/LambderCallOutcome.js";
|
|
34
|
+
import { LAMBDER_INVOKE_API_ID, LAMBDER_PARENT_REQUEST_HEADER } from "../shared/wire/LambderInvokeApiId.js";
|
|
29
35
|
import { policyBuildersFor } from "./LambderPolicyBuilders.js";
|
|
30
36
|
import { assertCreateOptions, mergeNamedMaps, } from "./LambderCreateOptions.js";
|
|
31
37
|
/** The refusals of a name no API is registered under: no code, and none required. */
|
|
32
38
|
const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
|
|
39
|
+
/** Longest endpoint name a call summary carries: a page built before paths posts the name in its body, and a line is no place for a long one. */
|
|
40
|
+
const MAX_SUMMARY_API_NAME_CHARS = 200;
|
|
41
|
+
/** A request id as the parent header may carry one: what Lambda's own ids look like, and no longer than any of them. */
|
|
42
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/;
|
|
43
|
+
/**
|
|
44
|
+
* The request id of the invocation that invoked this one, from an invoke's
|
|
45
|
+
* event alone: over HTTP the header is whatever a client wrote, and an
|
|
46
|
+
* invoke's apiId is one no gateway lets a client set.
|
|
47
|
+
*/
|
|
48
|
+
const parentRequestIdOf = (event) => {
|
|
49
|
+
if (event.requestContext?.apiId !== LAMBDER_INVOKE_API_ID)
|
|
50
|
+
return null;
|
|
51
|
+
const value = Object.entries(event.headers ?? {}).find(([name]) => name.toLowerCase() === LAMBDER_PARENT_REQUEST_HEADER)?.[1];
|
|
52
|
+
return typeof value === "string" && REQUEST_ID_PATTERN.test(value) ? value : null;
|
|
53
|
+
};
|
|
54
|
+
/** The API call a request is, as its invocation records it; null for a request that is no API call. */
|
|
55
|
+
const apiCallOf = (ctx, event) => ctx.api ? { api: ctx.api.apiName, parentRequestId: parentRequestIdOf(event) } : null;
|
|
33
56
|
/**
|
|
34
57
|
* Main Lambder class for building type-safe serverless APIs. Create
|
|
35
58
|
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
36
59
|
* whole configuration, including the typed policy layer, is given at
|
|
37
|
-
* construction
|
|
60
|
+
* construction. Endpoints are declared as values on the instance
|
|
61
|
+
* (defineApi, defineApiGroup) and registered in one registerApiGroups()
|
|
62
|
+
* call; routes, hooks and actions chain.
|
|
38
63
|
*
|
|
39
|
-
* @typeParam
|
|
40
|
-
* @typeParam _TContract - @internal
|
|
41
|
-
* @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
|
|
42
|
-
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
43
|
-
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
44
|
-
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
45
|
-
* @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
|
|
46
|
-
* @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually). Defaults to true, unlike its siblings, so a plugin annotating its parameter as the bare Lambder<SessionData> can still register session APIs. create() knows the option and supplies the false; `new Lambder(...)` relies on the registration-time throw alone.
|
|
47
|
-
* @typeParam _TRefusals - @internal The refusal vocabulary declareRefusals() gave the init (do not pass manually)
|
|
48
|
-
* @typeParam _TRefusalCodesRequired - @internal True when declareRefusals() received requireCodes (do not pass manually)
|
|
64
|
+
* @typeParam TApp - @internal What create() configured, as LambderAppTypes: the session data, policies, guards, idempotency, the guard requirement, sessions and the refusal vocabulary (do not pass manually)
|
|
65
|
+
* @typeParam _TContract - @internal The contract of the groups registered so far (do not pass manually)
|
|
49
66
|
*
|
|
50
67
|
* @example
|
|
51
68
|
* ```typescript
|
|
52
69
|
* interface SessionData { userId: string; role: string; }
|
|
53
70
|
*
|
|
54
|
-
* const
|
|
55
|
-
*
|
|
56
|
-
*
|
|
71
|
+
* const app = initLambder<SessionData>().create({ apiPath: '/api', session, guards });
|
|
72
|
+
* const userApis = app.defineApiGroup("users", {
|
|
73
|
+
* get: app.defineApi({ input: z.object({...}), output: z.object({...}), guards: "signedIn" }, handler),
|
|
74
|
+
* });
|
|
75
|
+
* const lambder = app.registerApiGroups(userApis);
|
|
57
76
|
* ```
|
|
58
77
|
*/
|
|
59
78
|
export default class Lambder {
|
|
@@ -70,24 +89,35 @@ export default class Lambder {
|
|
|
70
89
|
* Type property for extracting the API contract: every registered API's
|
|
71
90
|
* input, output, mode and declared options, as a client calls it.
|
|
72
91
|
*
|
|
73
|
-
* A small app's client imports it as it is.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* this property off the exported instance and writes the contract out as
|
|
77
|
-
* plain types for such a client to import instead.
|
|
92
|
+
* A small app's client imports it as it is. A large app's client reads
|
|
93
|
+
* the plain types writeApiContract (lambder/build) prints from this
|
|
94
|
+
* property instead, which cost it nothing to resolve.
|
|
78
95
|
*
|
|
79
96
|
* @example
|
|
80
97
|
* ```typescript
|
|
81
|
-
* export const lambder =
|
|
98
|
+
* export const lambder = lambderApp.registerApiGroups(userApis, orderApis);
|
|
82
99
|
* export type ApiContractType = typeof lambder.ApiContract;
|
|
83
100
|
* ```
|
|
84
101
|
*/
|
|
85
102
|
ApiContract;
|
|
103
|
+
/**
|
|
104
|
+
* Type property for what create() configured (LambderAppTypes): the
|
|
105
|
+
* session data, policies, guards and the rest, for code that takes any
|
|
106
|
+
* instance and needs one of them (lambderTestApp reads the session data
|
|
107
|
+
* type here). Like ApiContract, it has no value.
|
|
108
|
+
*/
|
|
109
|
+
AppTypes;
|
|
86
110
|
actionList = [];
|
|
87
111
|
/** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
|
|
88
112
|
pipeline;
|
|
89
|
-
/** Every registered API by name:
|
|
113
|
+
/** Every registered API by name: what dispatch, apiSignatures() and apiOptionEntries() read. */
|
|
90
114
|
apiDefinitions = new Map();
|
|
115
|
+
/** What answers each registered API: its output schema, compression setting and handler. */
|
|
116
|
+
apiHandlers = new Map();
|
|
117
|
+
/** Every group name registered, lazy or not: the duplicate-group check. */
|
|
118
|
+
apiGroupNames = new Set();
|
|
119
|
+
/** The lazy groups not loaded yet, each with its load in progress, if one is. */
|
|
120
|
+
lazyApiGroups = new Map();
|
|
91
121
|
/** The guards given at creation, merged into one map, kept for apiSignatures(): a guard's schema is part of the signature of every endpoint declaring it. */
|
|
92
122
|
guards;
|
|
93
123
|
/** The rate-limit policies given at creation, kept for apiOptionEntries(), which records each one less its key handler. */
|
|
@@ -109,7 +139,8 @@ export default class Lambder {
|
|
|
109
139
|
eventActionList = [];
|
|
110
140
|
corsConfig = null;
|
|
111
141
|
finalizeOptions;
|
|
112
|
-
|
|
142
|
+
/** Whether every endpoint has to declare guards (create()'s requireApiGuards). */
|
|
143
|
+
requireApiGuards;
|
|
113
144
|
/** Whether every refusal an API answers with has to name a code (declareRefusals's requireCodes). */
|
|
114
145
|
requireRefusalCodes;
|
|
115
146
|
/** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
|
|
@@ -120,7 +151,9 @@ export default class Lambder {
|
|
|
120
151
|
contextTools;
|
|
121
152
|
trustedClientIpHeaders;
|
|
122
153
|
trustedHostHeaders;
|
|
123
|
-
|
|
154
|
+
originProof;
|
|
155
|
+
/** Where each API call's summary goes (the callSummary option); null writes none. Replaceable through the backend swap alone. */
|
|
156
|
+
callSummaryWriter;
|
|
124
157
|
constructor(given = {}) {
|
|
125
158
|
// The guards and the rate-limit policies as one map each, whether
|
|
126
159
|
// they were given as one or as a list; a name two maps declare
|
|
@@ -165,7 +198,9 @@ export default class Lambder {
|
|
|
165
198
|
sessionSalt: session.sessionSalt,
|
|
166
199
|
enableSlidingExpiration: session.enableSlidingExpiration,
|
|
167
200
|
slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
|
|
168
|
-
|
|
201
|
+
// As given: the manager refuses dataRefresh without
|
|
202
|
+
// the schema, which an untyped caller can still pass.
|
|
203
|
+
...{ dataRefresh: session.dataRefresh, dataSchema: session.dataSchema },
|
|
169
204
|
crypto: session.crypto,
|
|
170
205
|
}),
|
|
171
206
|
tokenCookieKey: session.tokenCookieKey,
|
|
@@ -179,19 +214,21 @@ export default class Lambder {
|
|
|
179
214
|
});
|
|
180
215
|
this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
|
|
181
216
|
this.trustedHostHeaders = options.trustedHostHeaders ?? [];
|
|
182
|
-
this.
|
|
217
|
+
this.originProof = options.originProof ?? null;
|
|
218
|
+
this.callSummaryWriter = options.callSummary === false ? null : options.callSummary ?? writeCallSummaryLine;
|
|
219
|
+
this.requireApiGuards = options.requireApiGuards ?? false;
|
|
183
220
|
this.requireRefusalCodes = options.requireRefusalCodes ?? false;
|
|
184
|
-
this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
|
|
185
221
|
this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
|
|
186
222
|
this.contextTools = {
|
|
187
223
|
sessionControllerFor: (ctx) => this.getSessionController(ctx),
|
|
188
224
|
chargeRateLimit: async (ctx, policy, key, refuse) => {
|
|
225
|
+
// A per-API budget counts per registered API. The posted name
|
|
226
|
+
// of a call no API matched (a hook or the fallback charging
|
|
227
|
+
// it) is the caller's choice, and a fresh name per request
|
|
228
|
+
// would be a fresh counter.
|
|
229
|
+
const apiName = ctx.api && await this.registeredDefinitionOf(ctx.api.apiName) ? ctx.api.apiName : null;
|
|
189
230
|
const { checkResult, refusal: refusalToThrow } = await this.pipeline.chargeRateLimit(policy, {
|
|
190
|
-
|
|
191
|
-
// name of a call no API matched (a hook or the fallback
|
|
192
|
-
// charging it) is the caller's choice, and a fresh name
|
|
193
|
-
// per request would be a fresh counter.
|
|
194
|
-
apiName: ctx.api && this.apiDefinitions.has(ctx.api.apiName) ? ctx.api.apiName : null,
|
|
231
|
+
apiName,
|
|
195
232
|
ip: ctx.ip,
|
|
196
233
|
session: ctx.session,
|
|
197
234
|
key,
|
|
@@ -287,45 +324,196 @@ export default class Lambder {
|
|
|
287
324
|
});
|
|
288
325
|
return this;
|
|
289
326
|
}
|
|
290
|
-
//
|
|
291
|
-
|
|
327
|
+
// =====================================================================
|
|
328
|
+
// Endpoints
|
|
329
|
+
// Declared as values, gathered into named groups, registered in one call.
|
|
330
|
+
// =====================================================================
|
|
331
|
+
/**
|
|
332
|
+
* Declares one endpoint, typed on this instance's types: its input and
|
|
333
|
+
* output schemas, its guards, rate limits, idempotency and refusals, and
|
|
334
|
+
* its handler. Nothing is registered until the declaration is put in a
|
|
335
|
+
* group (defineApiGroup) and the group is registered (registerApiGroups).
|
|
336
|
+
*
|
|
337
|
+
* The guards say who may call it, and so its mode: an endpoint whose
|
|
338
|
+
* guards include one that needs a session (`session: true`) is a session
|
|
339
|
+
* endpoint, its session read before the guards run and a call without
|
|
340
|
+
* one answered sessionExpired, and its handler's ctx.session is typed
|
|
341
|
+
* present. Any other endpoint is public.
|
|
342
|
+
*
|
|
343
|
+
* An arrow property rather than a method, so an app can hand it out
|
|
344
|
+
* detached: `export const { defineApi, defineApiGroup } = lambderApp;`.
|
|
345
|
+
*/
|
|
346
|
+
defineApi = (options,
|
|
292
347
|
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
|
|
293
|
-
handler) {
|
|
294
|
-
|
|
295
|
-
|
|
348
|
+
handler) => ({ kind: "lambderApi", options: options, handler: handler });
|
|
349
|
+
/**
|
|
350
|
+
* Gathers endpoints into a named group: each is registered as
|
|
351
|
+
* `name.action` and called at `{apiPath}/{name}/{action}`, and every
|
|
352
|
+
* Lambder caller reaches it as `caller.name.action(input)`. A group's
|
|
353
|
+
* name and its actions are identifiers, and a group may not take a name
|
|
354
|
+
* a caller already answers for (see LAMBDER_RESERVED_GROUP_NAMES).
|
|
355
|
+
*
|
|
356
|
+
* A group declared across files takes each file's part:
|
|
357
|
+
* `defineApiGroup("orders", orderReadApis, orderWriteApis)`. An action
|
|
358
|
+
* two parts declare is refused, at compile time and at startup, rather
|
|
359
|
+
* than one silently replacing the other as an object spread would.
|
|
360
|
+
*/
|
|
361
|
+
defineApiGroup = (name, ...parts) => buildApiGroup(name, parts);
|
|
362
|
+
/**
|
|
363
|
+
* A group loaded on the first call to one of its endpoints:
|
|
364
|
+
* `lazyApiGroup("orders", () => import("./orders.js").then((m) => m.orderApis))`.
|
|
365
|
+
* A cold start then parses none of it, nor anything only it imports,
|
|
366
|
+
* until a request calls it; the contract is the loaded group's, read off
|
|
367
|
+
* its type. The group loaded must carry the same name, which the type
|
|
368
|
+
* checks and loading checks again. `loadApiGroups()` loads every group,
|
|
369
|
+
* for a build step or a boot check that has to see them all.
|
|
370
|
+
*/
|
|
371
|
+
lazyApiGroup = (name, load) => buildLazyApiGroup(name, load);
|
|
372
|
+
/**
|
|
373
|
+
* Registers groups of endpoints, as one contract: `typeof
|
|
374
|
+
* lambder.ApiContract` then maps every `group.action` to its entry, one
|
|
375
|
+
* flat object type the api-contract generator prints as it is. Register
|
|
376
|
+
* every group in one call where you can: the contract of one call is one
|
|
377
|
+
* mapped type, and a second call intersects it with the first.
|
|
378
|
+
*
|
|
379
|
+
* The call's groups take their place in the first-match chain here, as
|
|
380
|
+
* a route does: a route or action registered before them sees their calls
|
|
381
|
+
* first, one registered after them never does. The beforeRender hooks run
|
|
382
|
+
* before a lazy group loads, so a hook that answers a request spares it
|
|
383
|
+
* the import; one that throws a refusal or charges a per-API budget loads
|
|
384
|
+
* it (see registeredDefinitionOf).
|
|
385
|
+
*
|
|
386
|
+
* Refused at compile time: a group typed `any`, or a lazy one loading a
|
|
387
|
+
* group typed `any`, or a group whose endpoints are (its endpoints would
|
|
388
|
+
* be `any` to every client), and a group name given twice. Refused at
|
|
389
|
+
* registration, which is at startup for a group and at its first call for
|
|
390
|
+
* a lazy one: everything registration refuses of one endpoint (an unknown
|
|
391
|
+
* guard or policy, a missing guard where requireApiGuards is on, a
|
|
392
|
+
* refusal code outside the vocabulary).
|
|
393
|
+
*/
|
|
394
|
+
registerApiGroups(...groups) {
|
|
395
|
+
const instance = this;
|
|
396
|
+
// Every group and every endpoint is checked before anything is
|
|
397
|
+
// claimed, so a registration refused anywhere leaves the instance as
|
|
398
|
+
// it was: no group name taken, no endpoint half registered, and a
|
|
399
|
+
// caller that catches the error and fixes the declaration is told the
|
|
400
|
+
// real problem on its next try.
|
|
401
|
+
const names = new Set();
|
|
402
|
+
const prepared = [];
|
|
403
|
+
for (const group of groups) {
|
|
404
|
+
if (!isRegistrableApiGroup(group)) {
|
|
405
|
+
throw new Error("Lambder: registerApiGroups takes groups built by defineApiGroup() or lazyApiGroup().");
|
|
406
|
+
}
|
|
407
|
+
if (instance.apiGroupNames.has(group.name) || names.has(group.name)) {
|
|
408
|
+
throw new Error(`Lambder: group "${group.name}" is registered twice. A group is one namespace, so its endpoints belong in one defineApiGroup().`);
|
|
409
|
+
}
|
|
410
|
+
names.add(group.name);
|
|
411
|
+
if (group.kind === "lambderApiGroup")
|
|
412
|
+
prepared.push(...instance.prepareApiGroup(group));
|
|
413
|
+
}
|
|
414
|
+
for (const group of groups) {
|
|
415
|
+
instance.apiGroupNames.add(group.name);
|
|
416
|
+
if (group.kind === "lambderLazyApiGroup")
|
|
417
|
+
instance.lazyApiGroups.set(group.name, { group, loading: null });
|
|
418
|
+
}
|
|
419
|
+
for (const api of prepared)
|
|
420
|
+
instance.commitApi(api);
|
|
421
|
+
instance.actionList.push({
|
|
422
|
+
match: (ctx) => {
|
|
423
|
+
const group = ctx.api ? splitApiName(ctx.api.apiName)?.group : undefined;
|
|
424
|
+
return group !== undefined && names.has(group) ? {} : false;
|
|
425
|
+
},
|
|
426
|
+
actionFn: (ctx, resolver) => instance.dispatchApiCall(ctx, resolver),
|
|
427
|
+
});
|
|
428
|
+
return instance;
|
|
296
429
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Loads every lazy group not loaded yet, registering its endpoints: what
|
|
432
|
+
* a build step digesting the signatures, a generator writing the options
|
|
433
|
+
* or a boot check has to do before it sees every endpoint. A group whose
|
|
434
|
+
* registration fails rejects here, which is the point of calling it.
|
|
435
|
+
*/
|
|
436
|
+
async loadApiGroups() {
|
|
437
|
+
for (const name of [...this.lazyApiGroups.keys()])
|
|
438
|
+
await this.loadLazyApiGroup(name);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* An API call to a registered group, its hooks run: its group loaded
|
|
442
|
+
* first when it is a lazy one no call has reached yet, then its endpoint
|
|
443
|
+
* run. An action the group does not have is answered as any unmatched API
|
|
444
|
+
* call is, fallback hooks first.
|
|
445
|
+
*/
|
|
446
|
+
async dispatchApiCall(ctx, resolver) {
|
|
447
|
+
const apiName = ctx.api.apiName;
|
|
448
|
+
const definition = await this.registeredDefinitionOf(apiName);
|
|
449
|
+
const endpoint = this.apiHandlers.get(apiName);
|
|
450
|
+
if (!definition || !endpoint)
|
|
451
|
+
return await this.answerUnmatched(ctx, resolver);
|
|
452
|
+
return await this.runApi(ctx, definition, endpoint.output, endpoint.compress, endpoint.handler);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The definition of the endpoint a call names, its group loaded first
|
|
456
|
+
* when it is a lazy one no call has reached yet; undefined for a name no
|
|
457
|
+
* registered group declares. Everything that reads an endpoint's
|
|
458
|
+
* declarations for a call asks here (its dispatch, a refusal a hook
|
|
459
|
+
* throws on the way to it, a per-API budget a hook charges), so a lazy
|
|
460
|
+
* group answers each of them as an eager one does.
|
|
461
|
+
*/
|
|
462
|
+
async registeredDefinitionOf(apiName) {
|
|
463
|
+
const group = splitApiName(apiName)?.group;
|
|
464
|
+
if (group !== undefined && this.lazyApiGroups.has(group))
|
|
465
|
+
await this.loadLazyApiGroup(group);
|
|
466
|
+
return this.apiDefinitions.get(apiName);
|
|
467
|
+
}
|
|
468
|
+
/** The endpoints of one group, each checked as `group.action`; nothing is registered until they are committed. */
|
|
469
|
+
prepareApiGroup(group) {
|
|
470
|
+
return Object.entries(group.apis).map(([action, declaration]) => this.prepareApi(`${group.name}.${action}`, declaration.options, declaration.handler));
|
|
303
471
|
}
|
|
304
472
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* they give the handler.
|
|
473
|
+
* A lazy group, loaded once: its import, checked to be the group it was
|
|
474
|
+
* registered as, then registered. Concurrent first calls share one load;
|
|
475
|
+
* a failed load is forgotten, so the next call tries again rather than
|
|
476
|
+
* answering every later call with the first failure.
|
|
310
477
|
*/
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
478
|
+
async loadLazyApiGroup(name) {
|
|
479
|
+
const lazy = this.lazyApiGroups.get(name);
|
|
480
|
+
if (!lazy)
|
|
481
|
+
return;
|
|
482
|
+
if (!lazy.loading) {
|
|
483
|
+
const loading = (async () => {
|
|
484
|
+
const loaded = await lazy.group.load();
|
|
485
|
+
if (!isRegistrableApiGroup(loaded) || loaded.kind !== "lambderApiGroup") {
|
|
486
|
+
throw new Error(`Lambder: the loader of lazy group "${name}" resolved to something that is not a group. Resolve to the value defineApiGroup() built.`);
|
|
487
|
+
}
|
|
488
|
+
if (loaded.name !== name) {
|
|
489
|
+
throw new Error(`Lambder: lazy group "${name}" loaded the group "${loaded.name}". The loader must resolve to the group of the name it was registered under.`);
|
|
490
|
+
}
|
|
491
|
+
for (const api of this.prepareApiGroup(loaded))
|
|
492
|
+
this.commitApi(api);
|
|
493
|
+
this.lazyApiGroups.delete(name);
|
|
494
|
+
})();
|
|
495
|
+
lazy.loading = loading;
|
|
496
|
+
loading.catch(() => { if (lazy.loading === loading)
|
|
497
|
+
lazy.loading = null; });
|
|
314
498
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
499
|
+
await lazy.loading;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* What registering an endpoint takes: the checks that can refuse it and
|
|
503
|
+
* its mode read off its guards, into the definition apiSignatures()
|
|
504
|
+
* digests, beside its handler. Nothing is recorded here (commitApi does
|
|
505
|
+
* that), so a refusal anywhere in a registration leaves nothing behind.
|
|
506
|
+
*/
|
|
507
|
+
prepareApi(name, schema, handler) {
|
|
508
|
+
const guardEntries = toGuardEntries(schema.guards);
|
|
509
|
+
const guardNeedsSession = (guard) => !!this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) && this.guards[guard]?.session === true;
|
|
510
|
+
const mode = guardEntries.some(({ name: guard }) => guardNeedsSession(guard)) ? "session" : "public";
|
|
319
511
|
if (mode === "session" && !this.pipeline.hasSessions) {
|
|
320
|
-
throw new Error(`Lambder:
|
|
512
|
+
throw new Error(`Lambder: a guard of API "${name}" needs a session, and the instance was created without the session option.`);
|
|
321
513
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
? "the named no-op guard that marks the session itself as the whole authorization"
|
|
326
|
-
: "the named no-op guard that records why anyone may call it";
|
|
327
|
-
throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
|
|
328
|
-
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
514
|
+
if (this.requireApiGuards && schema.guards === undefined) {
|
|
515
|
+
throw new Error(`Lambder: API "${name}" declares no guards, and requireApiGuards is on. ` +
|
|
516
|
+
`Declare the guard that authorizes it, or the named no-op guard that records why it needs nothing more.`);
|
|
329
517
|
}
|
|
330
518
|
// The option, like guards and rateLimit, is a declaration or absent:
|
|
331
519
|
// an empty list would read as declaring codes while declaring none.
|
|
@@ -333,20 +521,22 @@ export default class Lambder {
|
|
|
333
521
|
if (schema.refusals !== undefined && ownRefusals.length === 0) {
|
|
334
522
|
throw new Error(`Lambder: API "${name}" declares an empty refusals option, which declares no code. Name the codes it refuses with, or omit the option entirely.`);
|
|
335
523
|
}
|
|
336
|
-
const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals,
|
|
524
|
+
const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, guardEntries.map(({ name: guard }) => ({
|
|
337
525
|
guard,
|
|
338
526
|
codes: (this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) ? this.guards[guard]?.refusals : undefined) ?? [],
|
|
339
527
|
})));
|
|
340
528
|
const refusals = { codes: allowedCodes, codeRequired: this.requireRefusalCodes };
|
|
341
529
|
const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output, refusals };
|
|
342
530
|
this.pipeline.assertRegistration(definition);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
531
|
+
return { definition, refusalsOption: schema.refusals, output: schema.output, compress: schema.compress ?? "auto", handler };
|
|
532
|
+
}
|
|
533
|
+
/** Records a checked endpoint: its definition, its refusals option as written, and what answers it. */
|
|
534
|
+
commitApi(api) {
|
|
535
|
+
const { name } = api.definition;
|
|
536
|
+
this.apiDefinitions.set(name, api.definition);
|
|
537
|
+
if (api.refusalsOption !== undefined)
|
|
538
|
+
this.refusalOptions.set(name, api.refusalsOption);
|
|
539
|
+
this.apiHandlers.set(name, { output: api.output, compress: api.compress, handler: api.handler });
|
|
350
540
|
}
|
|
351
541
|
addHook(hookEvent, hookFn, priority = 0) {
|
|
352
542
|
if (hookEvent === "created") {
|
|
@@ -378,16 +568,15 @@ export default class Lambder {
|
|
|
378
568
|
});
|
|
379
569
|
return this;
|
|
380
570
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
// making an instance with a non-default value unassignable to its own
|
|
388
|
-
// plugins.
|
|
571
|
+
/**
|
|
572
|
+
* Hands the instance to a function that registers on it (routes, hooks,
|
|
573
|
+
* actions) and continues the chain with what it returns. Endpoints are
|
|
574
|
+
* not registered this way: they are values, registered by
|
|
575
|
+
* registerApiGroups(), so a plugin adds nothing to the contract.
|
|
576
|
+
*/
|
|
389
577
|
use(plugin) {
|
|
390
|
-
|
|
578
|
+
plugin(this);
|
|
579
|
+
return this;
|
|
391
580
|
}
|
|
392
581
|
// =====================================================================
|
|
393
582
|
// Accessors
|
|
@@ -418,6 +607,8 @@ export default class Lambder {
|
|
|
418
607
|
[LAMBDER_BACKEND_SWAP](backends) {
|
|
419
608
|
if (this.files && backends.fileSource)
|
|
420
609
|
this.files[LAMBDER_BACKEND_SWAP](backends.fileSource);
|
|
610
|
+
if (backends.callSummary !== undefined)
|
|
611
|
+
this.callSummaryWriter = backends.callSummary;
|
|
421
612
|
return { ...this.pipeline[LAMBDER_BACKEND_SWAP](backends), files: this.files !== null };
|
|
422
613
|
}
|
|
423
614
|
/**
|
|
@@ -451,6 +642,7 @@ export default class Lambder {
|
|
|
451
642
|
* unless the generator writes it there.
|
|
452
643
|
*/
|
|
453
644
|
async apiSignatureEntries() {
|
|
645
|
+
await this.loadApiGroups();
|
|
454
646
|
const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
|
|
455
647
|
name: definition.name,
|
|
456
648
|
key: await apiNameKeyOf(definition.name),
|
|
@@ -478,7 +670,8 @@ export default class Lambder {
|
|
|
478
670
|
* same object. Every table is sorted by name, so the module diffs by
|
|
479
671
|
* endpoint and never moves when registrations are reordered.
|
|
480
672
|
*/
|
|
481
|
-
apiOptionEntries() {
|
|
673
|
+
async apiOptionEntries() {
|
|
674
|
+
await this.loadApiGroups();
|
|
482
675
|
const apis = {};
|
|
483
676
|
for (const name of [...this.apiDefinitions.keys()].sort()) {
|
|
484
677
|
const { mode, guards, rateLimit, idempotency } = this.apiDefinitions.get(name);
|
|
@@ -624,11 +817,22 @@ export default class Lambder {
|
|
|
624
817
|
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
|
|
625
818
|
if (beforeRenderResult instanceof LambderResponse)
|
|
626
819
|
return beforeRenderResult;
|
|
627
|
-
|
|
820
|
+
return await this.answerUnmatched(beforeRenderResult, resolver);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* What answers a request nothing matched, its beforeRender hooks run:
|
|
824
|
+
* the fallback hooks, then the API fallback for an API call, and
|
|
825
|
+
* otherwise the public files, the shell and the route fallback in turn.
|
|
826
|
+
*/
|
|
827
|
+
async answerUnmatched(currentCtx, resolver) {
|
|
628
828
|
for (const hook of this.hookList["fallback"]) {
|
|
629
829
|
await hook.hookFn(currentCtx, resolver);
|
|
630
830
|
}
|
|
631
|
-
|
|
831
|
+
// A request under apiPath is the API's to answer, whatever it asked
|
|
832
|
+
// for. A root apiPath shares every path with the site, so there only
|
|
833
|
+
// apiPath itself and the calls are.
|
|
834
|
+
const apiArea = this.apiPath.replace(/\/+$/, "");
|
|
835
|
+
const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath || (apiArea !== "" && currentCtx.path.startsWith(`${apiArea}/`));
|
|
632
836
|
if (isAPI) {
|
|
633
837
|
if (this.apiFallbackHandler)
|
|
634
838
|
return await this.apiFallbackHandler(currentCtx, resolver);
|
|
@@ -661,6 +865,10 @@ export default class Lambder {
|
|
|
661
865
|
if (this.isCorsPreflight(ctx))
|
|
662
866
|
return new LambderResponse({ statusCode: 204, body: null });
|
|
663
867
|
if (ctx.api) {
|
|
868
|
+
// A page built before endpoints had paths: its call cannot be
|
|
869
|
+
// answered, and telling it its version expired is what reloads it.
|
|
870
|
+
if (ctx.api.retiredPath)
|
|
871
|
+
return responseFromAnswer(versionExpiredAnswer(this.apiVersion));
|
|
664
872
|
// The protocol's own pre-pass, run here rather than left to the
|
|
665
873
|
// pipeline so that hooks and route matching see a plain payload,
|
|
666
874
|
// and so a stale client is answered before any of them, whether or
|
|
@@ -694,7 +902,19 @@ export default class Lambder {
|
|
|
694
902
|
return beforeRenderResult;
|
|
695
903
|
return await matched.action.actionFn(beforeRenderResult, resolver);
|
|
696
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* One HTTP invocation, from the event to the finalized response, under
|
|
907
|
+
* an invocation record of its own (see LambderInvocationScope): what an
|
|
908
|
+
* API call's summary line is written from once the response is final.
|
|
909
|
+
*/
|
|
697
910
|
async render(event, lambdaContext) {
|
|
911
|
+
return await runInvocation(lambdaContext, async (invocation) => {
|
|
912
|
+
const response = await this.renderRequest(event, lambdaContext, invocation);
|
|
913
|
+
this.writeCallSummary(invocation, response.statusCode);
|
|
914
|
+
return response;
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
async renderRequest(event, lambdaContext, invocation) {
|
|
698
918
|
let ctx = null;
|
|
699
919
|
let started = false;
|
|
700
920
|
// Settled as soon as the context exists and reused by every answer,
|
|
@@ -703,11 +923,8 @@ export default class Lambder {
|
|
|
703
923
|
try {
|
|
704
924
|
await this.ensureInitialized();
|
|
705
925
|
started = true;
|
|
706
|
-
ctx = bindContextTools(createContext(event, lambdaContext,
|
|
707
|
-
|
|
708
|
-
trustedClientIpHeaders: this.trustedClientIpHeaders,
|
|
709
|
-
trustedHostHeaders: this.trustedHostHeaders,
|
|
710
|
-
}), this.contextTools);
|
|
926
|
+
ctx = bindContextTools(createContext(event, lambdaContext, this.contextOptions()), this.contextTools);
|
|
927
|
+
invocation.call = apiCallOf(ctx, event);
|
|
711
928
|
if (this.corsConfig)
|
|
712
929
|
allowedOrigin = allowedCorsOriginOf(this.corsConfig, ctx);
|
|
713
930
|
const resolver = this.getResolver(ctx);
|
|
@@ -756,12 +973,33 @@ export default class Lambder {
|
|
|
756
973
|
// reach it.
|
|
757
974
|
ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
|
|
758
975
|
this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
|
|
976
|
+
invocation.outcome = response.callOutcome ?? { outcome: "other", code: null };
|
|
759
977
|
return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
|
|
760
978
|
}
|
|
761
979
|
catch (err) {
|
|
980
|
+
// A crash before the context existed (a created hook that failed)
|
|
981
|
+
// still answers an API call, and its summary line is the one a
|
|
982
|
+
// dashboard counts the crash by. The context is read from the
|
|
983
|
+
// event and the instance's options alone, so it can be read here;
|
|
984
|
+
// an event it cannot be read from has no call to name.
|
|
985
|
+
if (!ctx) {
|
|
986
|
+
try {
|
|
987
|
+
invocation.call = apiCallOf(createContext(event, lambdaContext, this.contextOptions()), event);
|
|
988
|
+
}
|
|
989
|
+
catch { /* no call to name */ }
|
|
990
|
+
}
|
|
762
991
|
return await this.answerCrash(err, ctx, allowedOrigin, started, event, lambdaContext);
|
|
763
992
|
}
|
|
764
993
|
}
|
|
994
|
+
/** What createContext reads this instance's requests with. */
|
|
995
|
+
contextOptions() {
|
|
996
|
+
return {
|
|
997
|
+
apiPath: this.apiPath,
|
|
998
|
+
trustedClientIpHeaders: this.trustedClientIpHeaders,
|
|
999
|
+
trustedHostHeaders: this.trustedHostHeaders,
|
|
1000
|
+
originProof: this.originProof,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
765
1003
|
/**
|
|
766
1004
|
* A thrown value that is an answer rather than a crash, as the response;
|
|
767
1005
|
* anything else is rethrown to the crash path.
|
|
@@ -781,7 +1019,7 @@ export default class Lambder {
|
|
|
781
1019
|
if (thrown instanceof LambderResponse)
|
|
782
1020
|
return thrown;
|
|
783
1021
|
if (isLambderApiRefusal(thrown) && ctx.api)
|
|
784
|
-
return this.apiErrorResponse(thrown, ctx);
|
|
1022
|
+
return await this.apiErrorResponse(thrown, ctx);
|
|
785
1023
|
if (thrown instanceof LambderSessionNotFoundError)
|
|
786
1024
|
return await this.sessionMissingResponse(ctx, resolver);
|
|
787
1025
|
throw thrown;
|
|
@@ -800,6 +1038,10 @@ export default class Lambder {
|
|
|
800
1038
|
// invocation would reject with a 502 no client can parse.
|
|
801
1039
|
const error = coerceToError(thrown, "an unstringifiable thrown value");
|
|
802
1040
|
this.crashWatcher?.(error);
|
|
1041
|
+
// A crash, whatever the global error handler then answers with.
|
|
1042
|
+
const invocation = currentInvocation();
|
|
1043
|
+
if (invocation)
|
|
1044
|
+
invocation.outcome = { outcome: "crash", code: null };
|
|
803
1045
|
const site = !started ? { kind: "startup", lambdaContext }
|
|
804
1046
|
: ctx?.api ? { kind: "api", ctx, lambdaContext }
|
|
805
1047
|
: { kind: "route", ctx, lambdaContext };
|
|
@@ -836,6 +1078,37 @@ export default class Lambder {
|
|
|
836
1078
|
const crashResponse = this.withCallHeaders(ctx, allowedOrigin, await this.crashHandling.frameworkResponse(error, ctx, errorHandlerCrash));
|
|
837
1079
|
return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
|
|
838
1080
|
}
|
|
1081
|
+
/**
|
|
1082
|
+
* The summary line of an API call, once its response is final: nothing
|
|
1083
|
+
* for an invocation that served no API call. A writer that throws costs
|
|
1084
|
+
* the call its line and nothing else.
|
|
1085
|
+
*/
|
|
1086
|
+
writeCallSummary(invocation, status) {
|
|
1087
|
+
const { call } = invocation;
|
|
1088
|
+
if (!call || !this.callSummaryWriter)
|
|
1089
|
+
return;
|
|
1090
|
+
const summary = {
|
|
1091
|
+
kind: "lambder.call",
|
|
1092
|
+
// The path form is two identifiers; a page built before paths
|
|
1093
|
+
// posts the name in its body, which is whatever it sent.
|
|
1094
|
+
api: call.api.slice(0, MAX_SUMMARY_API_NAME_CHARS),
|
|
1095
|
+
outcome: invocation.outcome?.outcome ?? "other",
|
|
1096
|
+
code: invocation.outcome?.code ?? null,
|
|
1097
|
+
status,
|
|
1098
|
+
durationMs: roundedMilliseconds(performance.now() - invocation.startedAt),
|
|
1099
|
+
handlerMs: invocation.handlerMs === null ? null : roundedMilliseconds(invocation.handlerMs),
|
|
1100
|
+
replayed: invocation.replayed,
|
|
1101
|
+
coldStart: invocation.coldStart,
|
|
1102
|
+
requestId: invocation.requestId,
|
|
1103
|
+
parentRequestId: call.parentRequestId,
|
|
1104
|
+
};
|
|
1105
|
+
try {
|
|
1106
|
+
this.callSummaryWriter(summary);
|
|
1107
|
+
}
|
|
1108
|
+
catch (err) {
|
|
1109
|
+
console.error(`Lambder: the callSummary writer threw, so the summary of "${summary.api}" was not written. ${coerceToError(err).message}`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
839
1112
|
/**
|
|
840
1113
|
* An answer to a crash, carrying what the call wrote and its CORS headers.
|
|
841
1114
|
* As on the success path, headers belong to the call: a call that wrote a
|
|
@@ -892,6 +1165,11 @@ export default class Lambder {
|
|
|
892
1165
|
* so Lambda's retries and dead-letter queues still see the failure.
|
|
893
1166
|
*/
|
|
894
1167
|
async renderEvent(event, lambdaContext) {
|
|
1168
|
+
// Under an invocation record, like an HTTP invocation, so the calls an
|
|
1169
|
+
// action makes over invoke carry its request id.
|
|
1170
|
+
return await runInvocation(lambdaContext, () => this.renderEventAction(event, lambdaContext));
|
|
1171
|
+
}
|
|
1172
|
+
async renderEventAction(event, lambdaContext) {
|
|
895
1173
|
let started = false;
|
|
896
1174
|
try {
|
|
897
1175
|
await this.ensureInitialized();
|
|
@@ -923,7 +1201,8 @@ export default class Lambder {
|
|
|
923
1201
|
*/
|
|
924
1202
|
async inputValidationRefusal(ctx, zodError) {
|
|
925
1203
|
if (this.apiInputValidationErrorHandler) {
|
|
926
|
-
|
|
1204
|
+
// A validation refusal, however the app words it.
|
|
1205
|
+
return { ...answerFromResponse(await this.apiInputValidationErrorHandler(ctx, this.getResolver(ctx), zodError)), outcome: { outcome: "validation", code: null } };
|
|
927
1206
|
}
|
|
928
1207
|
// null asks the pipeline for the standard 422, so that answer is
|
|
929
1208
|
// written once, in the core, rather than here as well.
|
|
@@ -962,9 +1241,18 @@ export default class Lambder {
|
|
|
962
1241
|
const request = ctx.api;
|
|
963
1242
|
if (!request)
|
|
964
1243
|
throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
|
|
965
|
-
const
|
|
1244
|
+
const invocation = currentInvocation();
|
|
1245
|
+
const { answer, replayed } = await this.pipeline.run(request, ctx, definition, async () => {
|
|
966
1246
|
ctx.apiPayload = request.payload;
|
|
967
|
-
const
|
|
1247
|
+
const handlerStarted = performance.now();
|
|
1248
|
+
let returned;
|
|
1249
|
+
try {
|
|
1250
|
+
returned = await handler(ctx);
|
|
1251
|
+
}
|
|
1252
|
+
finally {
|
|
1253
|
+
if (invocation)
|
|
1254
|
+
invocation.handlerMs = performance.now() - handlerStarted;
|
|
1255
|
+
}
|
|
968
1256
|
let parsed;
|
|
969
1257
|
try {
|
|
970
1258
|
parsed = output.safeParse(returned);
|
|
@@ -979,19 +1267,23 @@ export default class Lambder {
|
|
|
979
1267
|
return envelopeAnswer(successEnvelope(this.apiVersion, parsed.data, ctx.logList));
|
|
980
1268
|
});
|
|
981
1269
|
// The API's compress option, on whatever answer the call ended with:
|
|
982
|
-
// a replayed one comes back from its store without the
|
|
983
|
-
|
|
1270
|
+
// a replayed one comes back from its store without the hints, and
|
|
1271
|
+
// its outcome is read back from its text, which only a replay costs.
|
|
1272
|
+
if (invocation)
|
|
1273
|
+
invocation.replayed = replayed;
|
|
1274
|
+
return responseFromAnswer({ ...answer, compress, outcome: answer.outcome ?? outcomeOfAnswerText(answer.statusCode, answer.body) });
|
|
984
1275
|
}
|
|
985
1276
|
/**
|
|
986
1277
|
* A thrown LambderApiRefusal (from a hook, say) as the structured API
|
|
987
1278
|
* envelope: the core's one mapping, after the same check the pipeline
|
|
988
|
-
* applies, against the endpoint the call names
|
|
1279
|
+
* applies, against the endpoint the call names, its lazy group loaded if
|
|
1280
|
+
* the refusal came before the call reached it. A name no API is
|
|
989
1281
|
* registered under declares no code, so only an uncoded or a framework
|
|
990
1282
|
* refusal goes out for it.
|
|
991
1283
|
*/
|
|
992
|
-
apiErrorResponse(err, ctx) {
|
|
1284
|
+
async apiErrorResponse(err, ctx) {
|
|
993
1285
|
const apiName = ctx.apiName ?? "";
|
|
994
|
-
const refusal = checkedRefusal(apiName, this.
|
|
1286
|
+
const refusal = checkedRefusal(apiName, (await this.registeredDefinitionOf(apiName))?.refusals ?? NO_DECLARED_REFUSALS, err);
|
|
995
1287
|
return responseFromAnswer(refusalAnswer(refusal, this.apiVersion, ctx.logList));
|
|
996
1288
|
}
|
|
997
1289
|
}
|
|
@@ -1025,7 +1317,10 @@ export const initLambder = () => ({
|
|
|
1025
1317
|
* Declares the app's refusal vocabulary: every code once, with the schema
|
|
1026
1318
|
* of its data (`{ data: schema }`) or none (`{}`), the status every
|
|
1027
1319
|
* refusal with it leaves with (`status`, 200 by default) and whether it
|
|
1028
|
-
* sets the notAuthorized flag (`notAuthorized: true`).
|
|
1320
|
+
* sets the notAuthorized flag (`notAuthorized: true`). One map of codes,
|
|
1321
|
+
* or a list of them, as guards and rate-limit policies are declared, so
|
|
1322
|
+
* each part of an app declares its own codes; a code two maps declare is
|
|
1323
|
+
* refused, at compile time and at the call. Returns the init
|
|
1029
1324
|
* bound to it: its guard() types ctx.refuse to a guard's refusals and
|
|
1030
1325
|
* refuses a guard naming a code outside the vocabulary, its refuse is
|
|
1031
1326
|
* typed to the whole vocabulary, and its create() hands the vocabulary to
|
|
@@ -1034,9 +1329,11 @@ export const initLambder = () => ({
|
|
|
1034
1329
|
* "no" a client reads names a code.
|
|
1035
1330
|
*/
|
|
1036
1331
|
declareRefusals(refusals, options = {}) {
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1332
|
+
const declared = declaredRefusalVocabulary(refusals, "Lambder");
|
|
1333
|
+
return lambderInitOf({
|
|
1334
|
+
refusals: declared.refusals,
|
|
1335
|
+
vocabulary: declared.vocabulary,
|
|
1336
|
+
requireCodes: (options.requireCodes ?? false),
|
|
1337
|
+
});
|
|
1041
1338
|
},
|
|
1042
1339
|
});
|