lambder 11.1.1 → 12.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 (54) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +18 -13
  3. package/dist/api/LambderApiDeclarations.d.ts +194 -0
  4. package/dist/api/LambderApiDeclarations.js +52 -0
  5. package/dist/api/LambderApiDefinition.d.ts +1 -1
  6. package/dist/api/LambderApiGuards.d.ts +21 -22
  7. package/dist/api/LambderApiGuards.js +1 -1
  8. package/dist/api/LambderApiRateLimits.d.ts +19 -19
  9. package/dist/api/LambderApiRateLimits.js +1 -1
  10. package/dist/api/LambderApiRequest.d.ts +15 -6
  11. package/dist/api/LambderApiRequest.js +8 -9
  12. package/dist/build/generatedTables.d.ts +2 -1
  13. package/dist/build/generatedTables.js +1 -1
  14. package/dist/client/LambderCaller.d.ts +36 -23
  15. package/dist/client/LambderCaller.js +88 -67
  16. package/dist/client/lambderFetchTransport.js +2 -1
  17. package/dist/client.d.ts +3 -1
  18. package/dist/client.js +2 -0
  19. package/dist/core/Lambder.d.ts +203 -87
  20. package/dist/core/Lambder.js +265 -86
  21. package/dist/core/LambderContext.d.ts +1 -1
  22. package/dist/core/LambderContext.js +13 -5
  23. package/dist/core/LambderCreateOptions.d.ts +28 -39
  24. package/dist/core/LambderCreateOptions.js +2 -3
  25. package/dist/index.d.ts +7 -3
  26. package/dist/index.js +2 -0
  27. package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
  28. package/dist/invoke/LambderInvokeCaller.js +110 -94
  29. package/dist/invoke/LambderLambdaEvent.d.ts +0 -1
  30. package/dist/invoke/LambderLambdaEvent.js +0 -1
  31. package/dist/invoke/lambderHandlerTransport.js +2 -1
  32. package/dist/mock/LambderMockApp.d.ts +31 -25
  33. package/dist/mock/LambderMockApp.js +52 -45
  34. package/dist/mock/LambderMockCreateOptions.d.ts +6 -7
  35. package/dist/mock/LambderMockTypes.d.ts +44 -7
  36. package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
  37. package/dist/mock/lambderMockInvokeTransport.js +15 -7
  38. package/dist/mock/lambderMockMswHandler.js +8 -4
  39. package/dist/mock.d.ts +2 -2
  40. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  41. package/dist/shared/transport/LambderApiTransport.js +2 -3
  42. package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
  43. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  44. package/dist/shared/wire/LambderApiContract.d.ts +4 -18
  45. package/dist/shared/wire/LambderApiContract.js +1 -1
  46. package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
  47. package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
  48. package/dist/shared/wire/LambderApiNames.d.ts +55 -0
  49. package/dist/shared/wire/LambderApiNames.js +81 -0
  50. package/dist/shared/wire/LambderUploadSchemas.js +2 -1
  51. package/dist/testing/LambderTestApp.d.ts +5 -1
  52. package/dist/testing/LambderTestVisitor.d.ts +16 -8
  53. package/dist/testing/LambderTestVisitor.js +33 -25
  54. package/package.json +1 -1
@@ -18,10 +18,12 @@ 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";
@@ -34,26 +36,22 @@ const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
34
36
  * Main Lambder class for building type-safe serverless APIs. Create
35
37
  * instances with initLambder<SessionData>().create({...}) (see below): the
36
38
  * whole configuration, including the typed policy layer, is given at
37
- * construction, and only registration (routes, apis, hooks, use) chains.
39
+ * construction. Endpoints are declared as values on the instance
40
+ * (defineApi, defineApiGroup) and registered in one registerApiGroups()
41
+ * call; routes, hooks and actions chain.
38
42
  *
39
- * @typeParam TSessionData - Type of session data stored in DynamoDB
40
- * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
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)
43
+ * @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)
44
+ * @typeParam _TContract - @internal The contract of the groups registered so far (do not pass manually)
49
45
  *
50
46
  * @example
51
47
  * ```typescript
52
48
  * interface SessionData { userId: string; role: string; }
53
49
  *
54
- * const lambder = initLambder<SessionData>().create({ apiPath: '/api' })
55
- * .addApi('getUser', { input: z.object({...}), output: z.object({...}) }, handler)
56
- * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
50
+ * const app = initLambder<SessionData>().create({ apiPath: '/api', session, guards });
51
+ * const userApis = app.defineApiGroup("users", {
52
+ * get: app.defineApi({ input: z.object({...}), output: z.object({...}), guards: "signedIn" }, handler),
53
+ * });
54
+ * const lambder = app.registerApiGroups(userApis);
57
55
  * ```
58
56
  */
59
57
  export default class Lambder {
@@ -70,24 +68,35 @@ export default class Lambder {
70
68
  * Type property for extracting the API contract: every registered API's
71
69
  * input, output, mode and declared options, as a client calls it.
72
70
  *
73
- * A small app's client imports it as it is. It is an intersection one
74
- * member deep per endpoint, so reading it generically costs a large app's
75
- * client most of its type check; writeApiContract (lambder/build) reads
76
- * this property off the exported instance and writes the contract out as
77
- * plain types for such a client to import instead.
71
+ * A small app's client imports it as it is. A large app's client reads
72
+ * the plain types writeApiContract (lambder/build) prints from this
73
+ * property instead, which cost it nothing to resolve.
78
74
  *
79
75
  * @example
80
76
  * ```typescript
81
- * export const lambder = initLambder().create({ ... }).addApi(...).addApi(...);
77
+ * export const lambder = lambderApp.registerApiGroups(userApis, orderApis);
82
78
  * export type ApiContractType = typeof lambder.ApiContract;
83
79
  * ```
84
80
  */
85
81
  ApiContract;
82
+ /**
83
+ * Type property for what create() configured (LambderAppTypes): the
84
+ * session data, policies, guards and the rest, for code that takes any
85
+ * instance and needs one of them (lambderTestApp reads the session data
86
+ * type here). Like ApiContract, it has no value.
87
+ */
88
+ AppTypes;
86
89
  actionList = [];
87
90
  /** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
88
91
  pipeline;
89
- /** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
92
+ /** Every registered API by name: what dispatch, apiSignatures() and apiOptionEntries() read. */
90
93
  apiDefinitions = new Map();
94
+ /** What answers each registered API: its output schema, compression setting and handler. */
95
+ apiHandlers = new Map();
96
+ /** Every group name registered, lazy or not: the duplicate-group check. */
97
+ apiGroupNames = new Set();
98
+ /** The lazy groups not loaded yet, each with its load in progress, if one is. */
99
+ lazyApiGroups = new Map();
91
100
  /** 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
101
  guards;
93
102
  /** The rate-limit policies given at creation, kept for apiOptionEntries(), which records each one less its key handler. */
@@ -109,7 +118,8 @@ export default class Lambder {
109
118
  eventActionList = [];
110
119
  corsConfig = null;
111
120
  finalizeOptions;
112
- requireSessionApiGuards;
121
+ /** Whether every endpoint has to declare guards (create()'s requireApiGuards). */
122
+ requireApiGuards;
113
123
  /** Whether every refusal an API answers with has to name a code (declareRefusals's requireCodes). */
114
124
  requireRefusalCodes;
115
125
  /** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
@@ -120,7 +130,6 @@ export default class Lambder {
120
130
  contextTools;
121
131
  trustedClientIpHeaders;
122
132
  trustedHostHeaders;
123
- requirePublicApiGuards;
124
133
  constructor(given = {}) {
125
134
  // The guards and the rate-limit policies as one map each, whether
126
135
  // they were given as one or as a list; a name two maps declare
@@ -179,19 +188,19 @@ export default class Lambder {
179
188
  });
180
189
  this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
181
190
  this.trustedHostHeaders = options.trustedHostHeaders ?? [];
182
- this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
191
+ this.requireApiGuards = options.requireApiGuards ?? false;
183
192
  this.requireRefusalCodes = options.requireRefusalCodes ?? false;
184
- this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
185
193
  this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
186
194
  this.contextTools = {
187
195
  sessionControllerFor: (ctx) => this.getSessionController(ctx),
188
196
  chargeRateLimit: async (ctx, policy, key, refuse) => {
197
+ // A per-API budget counts per registered API. The posted name
198
+ // of a call no API matched (a hook or the fallback charging
199
+ // it) is the caller's choice, and a fresh name per request
200
+ // would be a fresh counter.
201
+ const apiName = ctx.api && await this.registeredDefinitionOf(ctx.api.apiName) ? ctx.api.apiName : null;
189
202
  const { checkResult, refusal: refusalToThrow } = await this.pipeline.chargeRateLimit(policy, {
190
- // A per-API budget counts per registered API. The posted
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,
203
+ apiName,
195
204
  ip: ctx.ip,
196
205
  session: ctx.session,
197
206
  key,
@@ -287,45 +296,196 @@ export default class Lambder {
287
296
  });
288
297
  return this;
289
298
  }
290
- // Typed API with Zod
291
- addApi(name, schema,
299
+ // =====================================================================
300
+ // Endpoints
301
+ // Declared as values, gathered into named groups, registered in one call.
302
+ // =====================================================================
303
+ /**
304
+ * Declares one endpoint, typed on this instance's types: its input and
305
+ * output schemas, its guards, rate limits, idempotency and refusals, and
306
+ * its handler. Nothing is registered until the declaration is put in a
307
+ * group (defineApiGroup) and the group is registered (registerApiGroups).
308
+ *
309
+ * The guards say who may call it, and so its mode: an endpoint whose
310
+ * guards include one that needs a session (`session: true`) is a session
311
+ * endpoint, its session read before the guards run and a call without
312
+ * one answered sessionExpired, and its handler's ctx.session is typed
313
+ * present. Any other endpoint is public.
314
+ *
315
+ * An arrow property rather than a method, so an app can hand it out
316
+ * detached: `export const { defineApi, defineApiGroup } = lambderApp;`.
317
+ */
318
+ defineApi = (options,
292
319
  /** 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
- this.registerApi(name, "public", schema, handler);
295
- return this;
320
+ handler) => ({ kind: "lambderApi", options: options, handler: handler });
321
+ /**
322
+ * Gathers endpoints into a named group: each is registered as
323
+ * `name.action` and called at `{apiPath}/{name}/{action}`, and every
324
+ * Lambder caller reaches it as `caller.name.action(input)`. A group's
325
+ * name and its actions are identifiers, and a group may not take a name
326
+ * a caller already answers for (see LAMBDER_RESERVED_GROUP_NAMES).
327
+ *
328
+ * A group declared across files takes each file's part:
329
+ * `defineApiGroup("orders", orderReadApis, orderWriteApis)`. An action
330
+ * two parts declare is refused, at compile time and at startup, rather
331
+ * than one silently replacing the other as an object spread would.
332
+ */
333
+ defineApiGroup = (name, ...parts) => buildApiGroup(name, parts);
334
+ /**
335
+ * A group loaded on the first call to one of its endpoints:
336
+ * `lazyApiGroup("orders", () => import("./orders.js").then((m) => m.orderApis))`.
337
+ * A cold start then parses none of it, nor anything only it imports,
338
+ * until a request calls it; the contract is the loaded group's, read off
339
+ * its type. The group loaded must carry the same name, which the type
340
+ * checks and loading checks again. `loadApiGroups()` loads every group,
341
+ * for a build step or a boot check that has to see them all.
342
+ */
343
+ lazyApiGroup = (name, load) => buildLazyApiGroup(name, load);
344
+ /**
345
+ * Registers groups of endpoints, as one contract: `typeof
346
+ * lambder.ApiContract` then maps every `group.action` to its entry, one
347
+ * flat object type the api-contract generator prints as it is. Register
348
+ * every group in one call where you can: the contract of one call is one
349
+ * mapped type, and a second call intersects it with the first.
350
+ *
351
+ * The call's groups take their place in the first-match chain here, as
352
+ * a route does: a route or action registered before them sees their calls
353
+ * first, one registered after them never does. The beforeRender hooks run
354
+ * before a lazy group loads, so a hook that answers a request spares it
355
+ * the import; one that throws a refusal or charges a per-API budget loads
356
+ * it (see registeredDefinitionOf).
357
+ *
358
+ * Refused at compile time: a group typed `any`, or a lazy one loading a
359
+ * group typed `any`, or a group whose endpoints are (its endpoints would
360
+ * be `any` to every client), and a group name given twice. Refused at
361
+ * registration, which is at startup for a group and at its first call for
362
+ * a lazy one: everything registration refuses of one endpoint (an unknown
363
+ * guard or policy, a missing guard where requireApiGuards is on, a
364
+ * refusal code outside the vocabulary).
365
+ */
366
+ registerApiGroups(...groups) {
367
+ const instance = this;
368
+ // Every group and every endpoint is checked before anything is
369
+ // claimed, so a registration refused anywhere leaves the instance as
370
+ // it was: no group name taken, no endpoint half registered, and a
371
+ // caller that catches the error and fixes the declaration is told the
372
+ // real problem on its next try.
373
+ const names = new Set();
374
+ const prepared = [];
375
+ for (const group of groups) {
376
+ if (!isRegistrableApiGroup(group)) {
377
+ throw new Error("Lambder: registerApiGroups takes groups built by defineApiGroup() or lazyApiGroup().");
378
+ }
379
+ if (instance.apiGroupNames.has(group.name) || names.has(group.name)) {
380
+ throw new Error(`Lambder: group "${group.name}" is registered twice. A group is one namespace, so its endpoints belong in one defineApiGroup().`);
381
+ }
382
+ names.add(group.name);
383
+ if (group.kind === "lambderApiGroup")
384
+ prepared.push(...instance.prepareApiGroup(group));
385
+ }
386
+ for (const group of groups) {
387
+ instance.apiGroupNames.add(group.name);
388
+ if (group.kind === "lambderLazyApiGroup")
389
+ instance.lazyApiGroups.set(group.name, { group, loading: null });
390
+ }
391
+ for (const api of prepared)
392
+ instance.commitApi(api);
393
+ instance.actionList.push({
394
+ match: (ctx) => {
395
+ const group = ctx.api ? splitApiName(ctx.api.apiName)?.group : undefined;
396
+ return group !== undefined && names.has(group) ? {} : false;
397
+ },
398
+ actionFn: (ctx, resolver) => instance.dispatchApiCall(ctx, resolver),
399
+ });
400
+ return instance;
296
401
  }
297
- // Typed Session API with Zod
298
- addSessionApi(name, schema,
299
- /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
300
- handler) {
301
- this.registerApi(name, "session", schema, handler);
302
- return this;
402
+ /**
403
+ * Loads every lazy group not loaded yet, registering its endpoints: what
404
+ * a build step digesting the signatures, a generator writing the options
405
+ * or a boot check has to do before it sees every endpoint. A group whose
406
+ * registration fails rejects here, which is the point of calling it.
407
+ */
408
+ async loadApiGroups() {
409
+ for (const name of [...this.lazyApiGroups.keys()])
410
+ await this.loadLazyApiGroup(name);
303
411
  }
304
412
  /**
305
- * What registering an API is, for addApi and addSessionApi alike: the
306
- * checks that can refuse it, then its definition recorded (what
307
- * apiSignatures() digests) and its action appended to the first-match
308
- * chain. The two public methods differ only in the mode and in the types
309
- * they give the handler.
413
+ * An API call to a registered group, its hooks run: its group loaded
414
+ * first when it is a lazy one no call has reached yet, then its endpoint
415
+ * run. An action the group does not have is answered as any unmatched API
416
+ * call is, fallback hooks first.
310
417
  */
311
- registerApi(name, mode, schema, handler) {
312
- if (this.apiDefinitions.has(name)) {
313
- throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
418
+ async dispatchApiCall(ctx, resolver) {
419
+ const apiName = ctx.api.apiName;
420
+ const definition = await this.registeredDefinitionOf(apiName);
421
+ const endpoint = this.apiHandlers.get(apiName);
422
+ if (!definition || !endpoint)
423
+ return await this.answerUnmatched(ctx, resolver);
424
+ return await this.runApi(ctx, definition, endpoint.output, endpoint.compress, endpoint.handler);
425
+ }
426
+ /**
427
+ * The definition of the endpoint a call names, its group loaded first
428
+ * when it is a lazy one no call has reached yet; undefined for a name no
429
+ * registered group declares. Everything that reads an endpoint's
430
+ * declarations for a call asks here (its dispatch, a refusal a hook
431
+ * throws on the way to it, a per-API budget a hook charges), so a lazy
432
+ * group answers each of them as an eager one does.
433
+ */
434
+ async registeredDefinitionOf(apiName) {
435
+ const group = splitApiName(apiName)?.group;
436
+ if (group !== undefined && this.lazyApiGroups.has(group))
437
+ await this.loadLazyApiGroup(group);
438
+ return this.apiDefinitions.get(apiName);
439
+ }
440
+ /** The endpoints of one group, each checked as `group.action`; nothing is registered until they are committed. */
441
+ prepareApiGroup(group) {
442
+ return Object.entries(group.apis).map(([action, declaration]) => this.prepareApi(`${group.name}.${action}`, declaration.options, declaration.handler));
443
+ }
444
+ /**
445
+ * A lazy group, loaded once: its import, checked to be the group it was
446
+ * registered as, then registered. Concurrent first calls share one load;
447
+ * a failed load is forgotten, so the next call tries again rather than
448
+ * answering every later call with the first failure.
449
+ */
450
+ async loadLazyApiGroup(name) {
451
+ const lazy = this.lazyApiGroups.get(name);
452
+ if (!lazy)
453
+ return;
454
+ if (!lazy.loading) {
455
+ const loading = (async () => {
456
+ const loaded = await lazy.group.load();
457
+ if (!isRegistrableApiGroup(loaded) || loaded.kind !== "lambderApiGroup") {
458
+ throw new Error(`Lambder: the loader of lazy group "${name}" resolved to something that is not a group. Resolve to the value defineApiGroup() built.`);
459
+ }
460
+ if (loaded.name !== name) {
461
+ 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.`);
462
+ }
463
+ for (const api of this.prepareApiGroup(loaded))
464
+ this.commitApi(api);
465
+ this.lazyApiGroups.delete(name);
466
+ })();
467
+ lazy.loading = loading;
468
+ loading.catch(() => { if (lazy.loading === loading)
469
+ lazy.loading = null; });
314
470
  }
315
- // Everything that can refuse the registration runs before the name is
316
- // claimed below: a refusal the app catches and fixes would otherwise
317
- // leave the name taken, and the retry would report a duplicate
318
- // instead of the problem it was fixing.
471
+ await lazy.loading;
472
+ }
473
+ /**
474
+ * What registering an endpoint takes: the checks that can refuse it and
475
+ * its mode read off its guards, into the definition apiSignatures()
476
+ * digests, beside its handler. Nothing is recorded here (commitApi does
477
+ * that), so a refusal anywhere in a registration leaves nothing behind.
478
+ */
479
+ prepareApi(name, schema, handler) {
480
+ const guardEntries = toGuardEntries(schema.guards);
481
+ const guardNeedsSession = (guard) => !!this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) && this.guards[guard]?.session === true;
482
+ const mode = guardEntries.some(({ name: guard }) => guardNeedsSession(guard)) ? "session" : "public";
319
483
  if (mode === "session" && !this.pipeline.hasSessions) {
320
- throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
484
+ throw new Error(`Lambder: a guard of API "${name}" needs a session, and the instance was created without the session option.`);
321
485
  }
322
- const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
323
- if (guardsRequired && schema.guards === undefined) {
324
- const optOut = mode === "session"
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}.`);
486
+ if (this.requireApiGuards && schema.guards === undefined) {
487
+ throw new Error(`Lambder: API "${name}" declares no guards, and requireApiGuards is on. ` +
488
+ `Declare the guard that authorizes it, or the named no-op guard that records why it needs nothing more.`);
329
489
  }
330
490
  // The option, like guards and rateLimit, is a declaration or absent:
331
491
  // an empty list would read as declaring codes while declaring none.
@@ -333,20 +493,22 @@ export default class Lambder {
333
493
  if (schema.refusals !== undefined && ownRefusals.length === 0) {
334
494
  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
495
  }
336
- const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, toGuardEntries(schema.guards).map(({ name: guard }) => ({
496
+ const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, guardEntries.map(({ name: guard }) => ({
337
497
  guard,
338
498
  codes: (this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) ? this.guards[guard]?.refusals : undefined) ?? [],
339
499
  })));
340
500
  const refusals = { codes: allowedCodes, codeRequired: this.requireRefusalCodes };
341
501
  const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output, refusals };
342
502
  this.pipeline.assertRegistration(definition);
343
- this.apiDefinitions.set(name, definition);
344
- if (schema.refusals !== undefined)
345
- this.refusalOptions.set(name, schema.refusals);
346
- this.actionList.push({
347
- match: (ctx) => ctx.apiName === name ? {} : false,
348
- actionFn: (ctx) => this.runApi(ctx, definition, schema.output, schema.compress ?? "auto", handler),
349
- });
503
+ return { definition, refusalsOption: schema.refusals, output: schema.output, compress: schema.compress ?? "auto", handler };
504
+ }
505
+ /** Records a checked endpoint: its definition, its refusals option as written, and what answers it. */
506
+ commitApi(api) {
507
+ const { name } = api.definition;
508
+ this.apiDefinitions.set(name, api.definition);
509
+ if (api.refusalsOption !== undefined)
510
+ this.refusalOptions.set(name, api.refusalsOption);
511
+ this.apiHandlers.set(name, { output: api.output, compress: api.compress, handler: api.handler });
350
512
  }
351
513
  addHook(hookEvent, hookFn, priority = 0) {
352
514
  if (hookEvent === "created") {
@@ -378,16 +540,15 @@ export default class Lambder {
378
540
  });
379
541
  return this;
380
542
  }
381
- // Plugin system
382
- // The policy generics are `any` in the plugin signature on purpose: a
383
- // module may annotate its parameter as the bare Lambder<SessionData> or
384
- // as the app's narrowed alias, and both must chain. Registration-time
385
- // assertions still check every referenced policy/guard name. Every
386
- // policy generic must be listed: a missing one falls back to its default,
387
- // making an instance with a non-default value unassignable to its own
388
- // plugins.
543
+ /**
544
+ * Hands the instance to a function that registers on it (routes, hooks,
545
+ * actions) and continues the chain with what it returns. Endpoints are
546
+ * not registered this way: they are values, registered by
547
+ * registerApiGroups(), so a plugin adds nothing to the contract.
548
+ */
389
549
  use(plugin) {
390
- return plugin(this);
550
+ plugin(this);
551
+ return this;
391
552
  }
392
553
  // =====================================================================
393
554
  // Accessors
@@ -451,6 +612,7 @@ export default class Lambder {
451
612
  * unless the generator writes it there.
452
613
  */
453
614
  async apiSignatureEntries() {
615
+ await this.loadApiGroups();
454
616
  const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
455
617
  name: definition.name,
456
618
  key: await apiNameKeyOf(definition.name),
@@ -478,7 +640,8 @@ export default class Lambder {
478
640
  * same object. Every table is sorted by name, so the module diffs by
479
641
  * endpoint and never moves when registrations are reordered.
480
642
  */
481
- apiOptionEntries() {
643
+ async apiOptionEntries() {
644
+ await this.loadApiGroups();
482
645
  const apis = {};
483
646
  for (const name of [...this.apiDefinitions.keys()].sort()) {
484
647
  const { mode, guards, rateLimit, idempotency } = this.apiDefinitions.get(name);
@@ -624,11 +787,22 @@ export default class Lambder {
624
787
  const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
625
788
  if (beforeRenderResult instanceof LambderResponse)
626
789
  return beforeRenderResult;
627
- const currentCtx = beforeRenderResult;
790
+ return await this.answerUnmatched(beforeRenderResult, resolver);
791
+ }
792
+ /**
793
+ * What answers a request nothing matched, its beforeRender hooks run:
794
+ * the fallback hooks, then the API fallback for an API call, and
795
+ * otherwise the public files, the shell and the route fallback in turn.
796
+ */
797
+ async answerUnmatched(currentCtx, resolver) {
628
798
  for (const hook of this.hookList["fallback"]) {
629
799
  await hook.hookFn(currentCtx, resolver);
630
800
  }
631
- const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath;
801
+ // A request under apiPath is the API's to answer, whatever it asked
802
+ // for. A root apiPath shares every path with the site, so there only
803
+ // apiPath itself and the calls are.
804
+ const apiArea = this.apiPath.replace(/\/+$/, "");
805
+ const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath || (apiArea !== "" && currentCtx.path.startsWith(`${apiArea}/`));
632
806
  if (isAPI) {
633
807
  if (this.apiFallbackHandler)
634
808
  return await this.apiFallbackHandler(currentCtx, resolver);
@@ -661,6 +835,10 @@ export default class Lambder {
661
835
  if (this.isCorsPreflight(ctx))
662
836
  return new LambderResponse({ statusCode: 204, body: null });
663
837
  if (ctx.api) {
838
+ // A page built before endpoints had paths: its call cannot be
839
+ // answered, and telling it its version expired is what reloads it.
840
+ if (ctx.api.retiredPath)
841
+ return responseFromAnswer(versionExpiredAnswer(this.apiVersion));
664
842
  // The protocol's own pre-pass, run here rather than left to the
665
843
  // pipeline so that hooks and route matching see a plain payload,
666
844
  // and so a stale client is answered before any of them, whether or
@@ -781,7 +959,7 @@ export default class Lambder {
781
959
  if (thrown instanceof LambderResponse)
782
960
  return thrown;
783
961
  if (isLambderApiRefusal(thrown) && ctx.api)
784
- return this.apiErrorResponse(thrown, ctx);
962
+ return await this.apiErrorResponse(thrown, ctx);
785
963
  if (thrown instanceof LambderSessionNotFoundError)
786
964
  return await this.sessionMissingResponse(ctx, resolver);
787
965
  throw thrown;
@@ -985,13 +1163,14 @@ export default class Lambder {
985
1163
  /**
986
1164
  * A thrown LambderApiRefusal (from a hook, say) as the structured API
987
1165
  * envelope: the core's one mapping, after the same check the pipeline
988
- * applies, against the endpoint the call names. A name no API is
1166
+ * applies, against the endpoint the call names, its lazy group loaded if
1167
+ * the refusal came before the call reached it. A name no API is
989
1168
  * registered under declares no code, so only an uncoded or a framework
990
1169
  * refusal goes out for it.
991
1170
  */
992
- apiErrorResponse(err, ctx) {
1171
+ async apiErrorResponse(err, ctx) {
993
1172
  const apiName = ctx.apiName ?? "";
994
- const refusal = checkedRefusal(apiName, this.apiDefinitions.get(apiName)?.refusals ?? NO_DECLARED_REFUSALS, err);
1173
+ const refusal = checkedRefusal(apiName, (await this.registeredDefinitionOf(apiName))?.refusals ?? NO_DECLARED_REFUSALS, err);
995
1174
  return responseFromAnswer(refusalAnswer(refusal, this.apiVersion, ctx.logList));
996
1175
  }
997
1176
  }
@@ -25,7 +25,7 @@ export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxy
25
25
  * clearCookie) that write onto whatever answer the request ends with.
26
26
  *
27
27
  * TRateLimitPolicies is the app's policies map on a handler registered with
28
- * addApi, addSessionApi, addRoute or addSessionRoute, so a policy name is
28
+ * defineApi, addRoute or addSessionRoute, so a policy name is
29
29
  * checked where it is charged; anywhere else (a hook, a guard) the names are
30
30
  * any string.
31
31
  */
@@ -1,4 +1,5 @@
1
1
  import { readApiEnvelope, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
2
+ import { apiNameOfCallPath } from "../shared/wire/LambderApiNames.js";
2
3
  import { resolveClientIp } from "../shared/util/LambderClientIp.js";
3
4
  import { base64ToText } from "../shared/util/LambderBase64.js";
4
5
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
@@ -162,13 +163,20 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
162
163
  post[key] = value;
163
164
  }
164
165
  }
165
- // A JSON POST to the API path whose body names an API is an API call;
166
+ // A JSON POST to `{apiPath}/{group}/{action}` is a call to that endpoint;
166
167
  // the core reads the envelope, and everything downstream reads ctx.api.
167
168
  // JSON only (isApiCallContentType): any site can submit a plain HTML form
168
- // to this path, and enctype="text/plain" lays out a JSON body exactly.
169
- const api = method === "POST" && !!apiPath && path === apiPath && isApiCallContentType(lowercasedHeaders)
170
- ? readApiEnvelope(post, { headers: lowercasedHeaders, cookies: cookieList, ip, host })
171
- : null;
169
+ // to this path, and enctype="text/plain" lays out a JSON body exactly. A
170
+ // JSON POST to apiPath itself whose body names an endpoint comes from a
171
+ // page built before endpoints had paths, and is read as a call on the
172
+ // retired path, which the core answers with versionExpired.
173
+ const isJsonPost = method === "POST" && !!apiPath && isApiCallContentType(lowercasedHeaders);
174
+ const calledName = isJsonPost ? apiNameOfCallPath(apiPath, path) : null;
175
+ const requestInfo = { headers: lowercasedHeaders, cookies: cookieList, ip, host };
176
+ const api = calledName !== null ? readApiEnvelope(post, requestInfo, calledName)
177
+ : isJsonPost && path === apiPath && typeof post.apiName === "string" && post.apiName !== ""
178
+ ? readApiEnvelope(post, requestInfo, post.apiName, { retiredPath: true })
179
+ : null;
172
180
  return bindContextTools({
173
181
  host, path, rawPath, pathParams: {}, method,
174
182
  get, post, cookie, cookieList, event,