lambder 15.0.2 → 16.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 (37) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +4 -4
  3. package/dist/api/LambderApiDeclarations.d.ts +21 -5
  4. package/dist/api/LambderApiDeclarations.js +21 -3
  5. package/dist/api/LambderApiGuards.d.ts +23 -3
  6. package/dist/api/LambderApiGuards.js +50 -25
  7. package/dist/api/LambderApiModules.d.ts +50 -0
  8. package/dist/api/LambderApiModules.js +36 -0
  9. package/dist/api/LambderApiPipeline.d.ts +27 -4
  10. package/dist/api/LambderApiPipeline.js +71 -5
  11. package/dist/api/LambderApiRateLimits.d.ts +35 -4
  12. package/dist/api/LambderApiRateLimits.js +74 -35
  13. package/dist/core/Lambder.d.ts +68 -93
  14. package/dist/core/Lambder.js +147 -71
  15. package/dist/core/LambderApiBuilders.d.ts +105 -0
  16. package/dist/core/LambderApiBuilders.js +12 -0
  17. package/dist/core/LambderApiModule.d.ts +106 -0
  18. package/dist/core/LambderApiModule.js +45 -0
  19. package/dist/core/LambderCallSummary.d.ts +11 -9
  20. package/dist/core/LambderCreateOptions.d.ts +29 -24
  21. package/dist/core/LambderInvocationScope.d.ts +9 -3
  22. package/dist/core/LambderRouting.d.ts +9 -0
  23. package/dist/core/LambderRouting.js +20 -0
  24. package/dist/index.d.ts +10 -5
  25. package/dist/index.js +2 -0
  26. package/dist/mock/LambderMockApiModule.d.ts +70 -0
  27. package/dist/mock/LambderMockApiModule.js +34 -0
  28. package/dist/mock/LambderMockApp.d.ts +53 -8
  29. package/dist/mock/LambderMockApp.js +104 -28
  30. package/dist/mock/LambderMockCreateOptions.d.ts +18 -18
  31. package/dist/mock/LambderMockTypes.d.ts +46 -26
  32. package/dist/mock.d.ts +4 -1
  33. package/dist/mock.js +2 -0
  34. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  35. package/dist/testing/LambderTestApp.d.ts +4 -3
  36. package/dist/testing/LambderTestApp.js +4 -3
  37. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import LambderResolver from "./LambderResolver.js";
2
2
  import LambderResponseBuilder from "./LambderResponseBuilder.js";
3
3
  import { LambderResponse, finalizeResponse, answerFromResponse, responseFromAnswer, emitResponse, DEFAULT_FINALIZE_OPTIONS, DEFAULT_RESPONSE_COMPRESSION_SETTINGS, } from "./LambderResponse.js";
4
- import { compileRouteMatcher } from "./LambderRouting.js";
4
+ import { compileRouteMatcher, routeNameOf } from "./LambderRouting.js";
5
5
  import { allowedCorsOriginOf, applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
7
  import { resolveCompressionOption } from "../shared/wire/LambderCompressionOption.js";
@@ -13,7 +13,7 @@ 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 { declaredRefusalVocabulary, readRefusalVocabulary, resolveAllowedRefusals, toRefusalCodes, } from "../api/LambderApiRefusals.js";
16
+ import { assertRefusalCodesDeclared, 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";
@@ -22,7 +22,10 @@ import { apiNotFoundAnswer, envelopeAnswer, sessionExpiredAnswer, successEnvelop
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";
25
+ import { apiModuleDeclarationsOf } from "../api/LambderApiModules.js";
26
+ import { apiModuleUsesOf, isRegistrableApiGroup, } from "../api/LambderApiDeclarations.js";
27
+ import { apiBuildersFor, } from "./LambderApiBuilders.js";
28
+ import { LambderApiModule, } from "./LambderApiModule.js";
26
29
  import { splitApiName } from "../shared/wire/LambderApiNames.js";
27
30
  import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
28
31
  import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
@@ -36,8 +39,10 @@ import { policyBuildersFor } from "./LambderPolicyBuilders.js";
36
39
  import { assertCreateOptions, mergeNamedMaps, } from "./LambderCreateOptions.js";
37
40
  /** The refusals of a name no API is registered under: no code, and none required. */
38
41
  const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
42
+ /** The instance's own declaration builders: no module, and nothing of any instance, so every instance hands out the same three functions, each typed on its own app types. */
43
+ const INSTANCE_BUILDERS = apiBuildersFor(null);
39
44
  /** 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;
45
+ const MAX_SUMMARY_NAME_CHARS = 200;
41
46
  /** 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
47
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/;
43
48
  /**
@@ -52,7 +57,11 @@ const parentRequestIdOf = (event) => {
52
57
  return typeof value === "string" && REQUEST_ID_PATTERN.test(value) ? value : null;
53
58
  };
54
59
  /** 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;
60
+ const apiCallOf = (ctx, event) => ctx.api ? { api: ctx.api.apiName, route: null, parentRequestId: parentRequestIdOf(event) } : null;
61
+ /** The call a request a route answered is: the route by its registered name, never by the path asked for. */
62
+ const routeCallOf = (ctx, route) => ({ api: null, route, parentRequestId: parentRequestIdOf(ctx.event) });
63
+ /** A route's outcome, which no envelope names: read from the status it answered with, as an API call's 5xx reads. */
64
+ const outcomeOfStatus = (status) => ({ outcome: status >= 500 ? "crash" : status >= 400 ? "other" : "success", code: null });
56
65
  /**
57
66
  * Main Lambder class for building type-safe serverless APIs. Create
58
67
  * instances with initLambder<SessionData>().create({...}) (see below): the
@@ -124,9 +133,14 @@ export default class Lambder {
124
133
  apiGroupNames = new Set();
125
134
  /** The lazy groups not loaded yet, each with its load in progress, if one is. */
126
135
  lazyApiGroups = new Map();
127
- /** 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. */
136
+ /**
137
+ * The guards given at creation merged into one map, and every registered
138
+ * module's beside them, kept for registration (a guard's session and
139
+ * refusal codes), apiSignatures() (a guard's schema is part of the
140
+ * signature of every endpoint declaring it) and apiOptionEntries().
141
+ */
128
142
  guards;
129
- /** The rate-limit policies given at creation, kept for apiOptionEntries(), which records each one less its key handler. */
143
+ /** The rate-limit policies given at creation, and every registered module's beside them, kept for apiOptionEntries(), which records each one less its key handler. */
130
144
  rateLimitPolicies;
131
145
  /** The refusal vocabulary given at creation: what each API's and each guard's refusal codes resolve against. Null without the option. */
132
146
  refusalVocabulary;
@@ -157,7 +171,7 @@ export default class Lambder {
157
171
  trustedClientIpHeaders;
158
172
  trustedHostHeaders;
159
173
  originProof;
160
- /** Where each API call's summary goes (the callSummary option); null writes none. Replaceable through the backend swap alone. */
174
+ /** Where each call's summary goes, an API call's or a route's (the callSummary option); null writes none. Replaceable through the backend swap alone. */
161
175
  callSummaryWriter;
162
176
  constructor(given = {}) {
163
177
  // The guards and the rate-limit policies as one map each, whether
@@ -166,7 +180,10 @@ export default class Lambder {
166
180
  const options = {
167
181
  ...given,
168
182
  guards: given.guards && mergeNamedMaps(given.guards, "guard"),
169
- rateLimits: given.rateLimits && { ...given.rateLimits, policies: mergeNamedMaps(given.rateLimits.policies, "rate-limit policy") },
183
+ rateLimits: given.rateLimits && {
184
+ ...given.rateLimits,
185
+ policies: given.rateLimits.policies === undefined ? undefined : mergeNamedMaps(given.rateLimits.policies, "rate-limit policy"),
186
+ },
170
187
  };
171
188
  assertCreateOptions(options);
172
189
  this.files = options.files ? new LambderFiles(options.files) : null;
@@ -185,8 +202,8 @@ export default class Lambder {
185
202
  this.corsConfig = options.cors === true ? {} : options.cors;
186
203
  }
187
204
  const session = options.session;
188
- this.guards = options.guards;
189
- this.rateLimitPolicies = options.rateLimits?.policies;
205
+ this.guards = { ...options.guards };
206
+ this.rateLimitPolicies = { ...options.rateLimits?.policies };
190
207
  this.refusalVocabulary = readRefusalVocabulary(options.refusals);
191
208
  this.pipeline = new LambderApiPipeline({
192
209
  apiVersion: this.apiVersion,
@@ -314,6 +331,7 @@ export default class Lambder {
314
331
  addRoute(condition, actionFn) {
315
332
  this.actionList.push({
316
333
  match: compileRouteMatcher(condition),
334
+ name: routeNameOf(condition),
317
335
  actionFn: (ctx, resolver) => actionFn(ctx, resolver),
318
336
  });
319
337
  return this;
@@ -321,6 +339,7 @@ export default class Lambder {
321
339
  addSessionRoute(condition, actionFn) {
322
340
  this.actionList.push({
323
341
  match: compileRouteMatcher(condition),
342
+ name: routeNameOf(condition),
324
343
  actionFn: async (ctx, resolver) => {
325
344
  await this.requireSession(ctx, resolver);
326
345
  // requireSession answered already if there was no session, so
@@ -335,46 +354,56 @@ export default class Lambder {
335
354
  // Declared as values, gathered into named groups, registered in one call.
336
355
  // =====================================================================
337
356
  /**
338
- * Declares one endpoint, typed on this instance's types: its input and
339
- * output schemas, its guards, rate limits, idempotency and refusals, and
340
- * its handler. Nothing is registered until the declaration is put in a
341
- * group (defineApiGroup) and the group is registered (registerApiGroups).
357
+ * Declares one endpoint, typed on this instance's types (see
358
+ * LambderDefineApi). Nothing is registered until the declaration is put
359
+ * in a group (defineApiGroup) and the group is registered
360
+ * (registerApiGroups).
342
361
  *
343
- * The guards say who may call it, and so its mode: an endpoint whose
344
- * guards include one that needs a session (`session: true`) is a session
345
- * endpoint, its session read before the guards run and a call without
346
- * one answered sessionExpired, and its handler's ctx.session is typed
347
- * present. Any other endpoint is public.
348
- *
349
- * An arrow property rather than a method, so an app can hand it out
350
- * detached: `export const { defineApi, defineApiGroup } = lambderApp;`.
362
+ * An arrow property rather than a method, as are the two below, so an
363
+ * app can hand them out detached; handed out under the instance's name
364
+ * (`export const defineApi: AppLambder["defineApi"] = lambderApp.defineApi`),
365
+ * a declaration file names each by the instance's type rather than
366
+ * writing the app's types out again in each.
351
367
  */
352
- defineApi = (options,
353
- /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
354
- handler) => ({ kind: "lambderApi", options: options, handler: handler });
368
+ defineApi = INSTANCE_BUILDERS.defineApi;
369
+ /** Gathers endpoints into a named group (see LambderDefineApiGroup). */
370
+ defineApiGroup = INSTANCE_BUILDERS.defineApiGroup;
371
+ /** A group loaded on the first call to one of its endpoints (see LambderDefineLazyApiGroup). */
372
+ lazyApiGroup = INSTANCE_BUILDERS.lazyApiGroup;
355
373
  /**
356
- * Gathers endpoints into a named group: each is registered as
357
- * `name.action` and called at `{apiPath}/{name}/{action}`, and every
358
- * Lambder caller reaches it as `caller.name.action(input)`. A group's
359
- * name and its actions are identifiers, and a group may not take a name
360
- * a caller already answers for (see LAMBDER_RESERVED_GROUP_NAMES).
374
+ * Defines a module: guards and rate-limit policies a part of the app
375
+ * declares for its own endpoints, beside the ones this instance was
376
+ * created with. Returns the module's builders, whose endpoints may name
377
+ * the instance's guards and policies and the module's own, and nothing
378
+ * else; their groups are registered with registerApiGroups() as any
379
+ * are, and their registration adds the module's guards and policies to
380
+ * the instance that registers them.
361
381
  *
362
- * A group declared across files takes each file's part:
363
- * `defineApiGroup("orders", orderReadApis, orderWriteApis)`. An action
364
- * two parts declare is refused, at compile time and at startup, rather
365
- * than one silently replacing the other as an object spread would.
366
- */
367
- defineApiGroup = (name, ...parts) => buildApiGroup(name, parts);
368
- /**
369
- * A group loaded on the first call to one of its endpoints:
370
- * `lazyApiGroup("orders", () => import("./orders.js").then((m) => m.orderApis))`.
371
- * A cold start then parses none of it, nor anything only it imports,
372
- * until a request calls it; the contract is the loaded group's, read off
373
- * its type. The group loaded must carry the same name, which the type
374
- * checks and loading checks again. `loadApiGroups()` loads every group,
375
- * for a build step or a boot check that has to see them all.
382
+ * ```typescript
383
+ * export const giftCardModule = storeApp.defineApiModule({
384
+ * guards: { cardHolder },
385
+ * rateLimitPolicies: { redeemPerCard: { perHour: 5, per: "session" } },
386
+ * });
387
+ * ```
388
+ *
389
+ * A name the instance declares is refused here, at compile time and at
390
+ * the call; a name two modules declare is refused when the second is
391
+ * registered, as create() refuses a name two maps declare. A module's
392
+ * rate-limit policies count on the instance's limiter, so they need the
393
+ * rateLimits option at creation, whose own policies may then be left out.
376
394
  */
377
- lazyApiGroup = (name, load) => buildLazyApiGroup(name, load);
395
+ defineApiModule(options) {
396
+ const given = options;
397
+ const declarations = apiModuleDeclarationsOf(given, "Lambder");
398
+ for (const [name, guard] of Object.entries(declarations.guards)) {
399
+ assertRefusalCodesDeclared(`guard "${name}"`, (guard?.refusals ?? []), this.refusalVocabulary);
400
+ }
401
+ // Checked against this instance now, so a name it declares fails
402
+ // where the module is defined; registration checks again against
403
+ // whichever instance takes the module, and against the other modules.
404
+ this.pipeline.modulesToAdd([{ module: declarations, usedBy: "being defined" }]);
405
+ return new LambderApiModule(declarations);
406
+ }
378
407
  /**
379
408
  * Registers groups of endpoints, as one contract: `typeof
380
409
  * lambder.ApiContract` then maps every `group.action` to its entry, one
@@ -405,7 +434,6 @@ export default class Lambder {
405
434
  // caller that catches the error and fixes the declaration is told the
406
435
  // real problem on its next try.
407
436
  const names = new Set();
408
- const prepared = [];
409
437
  for (const group of groups) {
410
438
  if (!isRegistrableApiGroup(group)) {
411
439
  throw new Error("Lambder: registerApiGroups takes groups built by defineApiGroup() or lazyApiGroup().");
@@ -414,10 +442,21 @@ export default class Lambder {
414
442
  throw new Error(`Lambder: group "${group.name}" is registered twice. A group is one namespace, so its endpoints belong in one defineApiGroup().`);
415
443
  }
416
444
  names.add(group.name);
445
+ }
446
+ // The modules the groups bring, a lazy group's at once: checked
447
+ // against the instance and each other here, taken only after every
448
+ // endpoint passed against them.
449
+ const registrable = groups;
450
+ const added = instance.pipeline.modulesToAdd(registrable.flatMap(apiModuleUsesOf));
451
+ const prepared = [];
452
+ for (const group of registrable) {
417
453
  if (group.kind === "lambderApiGroup")
418
- prepared.push(...instance.prepareApiGroup(group));
454
+ prepared.push(...instance.prepareApiGroup(group, added));
419
455
  }
420
- for (const group of groups) {
456
+ instance.pipeline.addModules(added);
457
+ instance.guards = { ...instance.guards, ...added.guards };
458
+ instance.rateLimitPolicies = { ...instance.rateLimitPolicies, ...added.rateLimitPolicies };
459
+ for (const group of registrable) {
421
460
  instance.apiGroupNames.add(group.name);
422
461
  if (group.kind === "lambderLazyApiGroup")
423
462
  instance.lazyApiGroups.set(group.name, { group, loading: null });
@@ -429,6 +468,7 @@ export default class Lambder {
429
468
  const group = ctx.api ? splitApiName(ctx.api.apiName)?.group : undefined;
430
469
  return group !== undefined && names.has(group) ? {} : false;
431
470
  },
471
+ name: null,
432
472
  actionFn: (ctx, resolver) => instance.dispatchApiCall(ctx, resolver),
433
473
  });
434
474
  return instance;
@@ -471,9 +511,14 @@ export default class Lambder {
471
511
  await this.loadLazyApiGroup(group);
472
512
  return this.apiDefinitions.get(apiName);
473
513
  }
474
- /** The endpoints of one group, each checked as `group.action`; nothing is registered until they are committed. */
475
- prepareApiGroup(group) {
476
- return Object.entries(group.apis).map(([action, declaration]) => this.prepareApi(`${group.name}.${action}`, declaration.options, declaration.handler));
514
+ /**
515
+ * The endpoints of one group, each checked as `group.action` against the
516
+ * instance's guards and policies and those `adding` holds, of the modules
517
+ * the same registration adds; nothing is registered until they are
518
+ * committed.
519
+ */
520
+ prepareApiGroup(group, adding) {
521
+ return Object.entries(group.apis).map(([action, declaration]) => this.prepareApi(`${group.name}.${action}`, declaration.options, declaration.handler, adding));
477
522
  }
478
523
  /**
479
524
  * A lazy group, loaded once: its import, checked to be the group it was
@@ -494,6 +539,16 @@ export default class Lambder {
494
539
  if (loaded.name !== name) {
495
540
  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.`);
496
541
  }
542
+ // A module's guards and policies are added when its group is
543
+ // registered, at startup, so a collision fails the boot and a
544
+ // route charging one of its policies finds it from the first
545
+ // request; one first met here would do neither.
546
+ for (const { module } of apiModuleUsesOf(loaded)) {
547
+ if (!this.pipeline.hasModule(module)) {
548
+ throw new Error(`Lambder: lazy group "${name}" loaded endpoints a module's builders declared, and was registered with a lazyApiGroup that does not carry that module. ` +
549
+ `Register it with the module's own lazyApiGroup, so its guards and rate-limit policies are added at startup.`);
550
+ }
551
+ }
497
552
  for (const api of this.prepareApiGroup(loaded))
498
553
  this.commitApi(api);
499
554
  this.lazyApiGroups.delete(name);
@@ -510,9 +565,12 @@ export default class Lambder {
510
565
  * digests, beside its handler. Nothing is recorded here (commitApi does
511
566
  * that), so a refusal anywhere in a registration leaves nothing behind.
512
567
  */
513
- prepareApi(name, schema, handler) {
568
+ prepareApi(name, schema, handler, adding) {
514
569
  const guardEntries = toGuardEntries(schema.guards);
515
- const guardNeedsSession = (guard) => !!this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) && this.guards[guard]?.session === true;
570
+ const guardOf = (guard) => Object.prototype.hasOwnProperty.call(this.guards, guard) ? this.guards[guard]
571
+ : adding && Object.prototype.hasOwnProperty.call(adding.guards, guard) ? adding.guards[guard]
572
+ : undefined;
573
+ const guardNeedsSession = (guard) => guardOf(guard)?.session === true;
516
574
  const mode = guardEntries.some(({ name: guard }) => guardNeedsSession(guard)) ? "session" : "public";
517
575
  if (mode === "session" && !this.pipeline.hasSessions) {
518
576
  throw new Error(`Lambder: a guard of API "${name}" needs a session, and the instance was created without the session option.`);
@@ -527,13 +585,10 @@ export default class Lambder {
527
585
  if (schema.refusals !== undefined && ownRefusals.length === 0) {
528
586
  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.`);
529
587
  }
530
- const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, guardEntries.map(({ name: guard }) => ({
531
- guard,
532
- codes: (this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) ? this.guards[guard]?.refusals : undefined) ?? [],
533
- })));
588
+ const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, guardEntries.map(({ name: guard }) => ({ guard, codes: guardOf(guard)?.refusals ?? [] })));
534
589
  const refusals = { codes: allowedCodes, codeRequired: this.requireRefusalCodes };
535
590
  const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output, refusals };
536
- this.pipeline.assertRegistration(definition);
591
+ this.pipeline.assertRegistration(definition, adding);
537
592
  return { definition, refusalsOption: schema.refusals, output: schema.output, compress: schema.compress ?? "auto", handler };
538
593
  }
539
594
  /** Records a checked endpoint: its definition, its refusals option as written, and what answers it. */
@@ -556,6 +611,7 @@ export default class Lambder {
556
611
  // HTTP side: joins the route/API chain in registration order.
557
612
  this.actionList.push({
558
613
  match: (ctx) => filter(ctx.event, ctx) ? {} : false,
614
+ name: null,
559
615
  actionFn: async (ctx, resolver) => {
560
616
  const result = await actionFn(ctx.event, { ctx, res: resolver, lambdaContext: ctx.lambdaContext });
561
617
  if (!(result instanceof LambderResponse)) {
@@ -695,7 +751,7 @@ export default class Lambder {
695
751
  apis[name] = entry;
696
752
  }
697
753
  const rateLimitPolicies = {};
698
- for (const name of Object.keys(this.rateLimitPolicies ?? {}).sort()) {
754
+ for (const name of Object.keys(this.rateLimitPolicies).sort()) {
699
755
  const { per, budget, chargeAt, refusal, ...windows } = this.rateLimitPolicies[name];
700
756
  const entry = {};
701
757
  for (const [window, limit] of Object.entries(windows)) {
@@ -715,7 +771,7 @@ export default class Lambder {
715
771
  rateLimitPolicies[name] = entry;
716
772
  }
717
773
  const guards = {};
718
- for (const name of Object.keys(this.guards ?? {}).sort()) {
774
+ for (const name of Object.keys(this.guards).sort()) {
719
775
  const guard = this.guards[name];
720
776
  guards[name] = {
721
777
  input: guard.apiInput ? "apiInput" : guard.guardInput ? "guardInput" : "none",
@@ -919,15 +975,32 @@ export default class Lambder {
919
975
  // Set before the hooks run, so a beforeRender hook on a matched route
920
976
  // sees the route's own path params.
921
977
  ctx.pathParams = matched.params;
978
+ // A route's call is named as soon as it has matched, before the hooks
979
+ // run, so the line a hook's answer ends in is the route's too. An API
980
+ // call keeps the name its path gave it, whatever action answers it.
981
+ const invocation = currentInvocation();
982
+ if (invocation && !invocation.call)
983
+ invocation.call = routeCallOf(ctx, matched.action.name);
922
984
  const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
923
985
  if (beforeRenderResult instanceof LambderResponse)
924
986
  return beforeRenderResult;
925
- return await matched.action.actionFn(beforeRenderResult, resolver);
987
+ // An API call's handler time is the pipeline's to measure, inside its
988
+ // own work; a route's is the whole action.
989
+ if (ctx.api)
990
+ return await matched.action.actionFn(beforeRenderResult, resolver);
991
+ const handlerStarted = performance.now();
992
+ try {
993
+ return await matched.action.actionFn(beforeRenderResult, resolver);
994
+ }
995
+ finally {
996
+ if (invocation)
997
+ invocation.handlerMs = performance.now() - handlerStarted;
998
+ }
926
999
  }
927
1000
  /**
928
1001
  * One HTTP invocation, from the event to the finalized response, under
929
- * an invocation record of its own (see LambderInvocationScope): what an
930
- * API call's summary line is written from once the response is final.
1002
+ * an invocation record of its own (see LambderInvocationScope): what its
1003
+ * call summary line is written from once the response is final.
931
1004
  */
932
1005
  async render(event, lambdaContext) {
933
1006
  return await runInvocation(lambdaContext, async (invocation) => {
@@ -995,7 +1068,8 @@ export default class Lambder {
995
1068
  // reach it.
996
1069
  ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
997
1070
  this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
998
- invocation.outcome = response.callOutcome ?? { outcome: "other", code: null };
1071
+ // An API answer names its outcome; a route's answer names none, and reads from its status.
1072
+ invocation.outcome = response.callOutcome ?? (ctx.api ? { outcome: "other", code: null } : outcomeOfStatus(response.statusCode));
999
1073
  return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
1000
1074
  }
1001
1075
  catch (err) {
@@ -1101,9 +1175,9 @@ export default class Lambder {
1101
1175
  return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
1102
1176
  }
1103
1177
  /**
1104
- * The summary line of an API call, once its response is final: nothing
1105
- * for an invocation that served no API call. A writer that throws costs
1106
- * the call its line and nothing else.
1178
+ * The summary line of an API call or of a request a route answered, once
1179
+ * its response is final: nothing for an invocation that served neither. A
1180
+ * writer that throws costs the call its line and nothing else.
1107
1181
  */
1108
1182
  writeCallSummary(invocation, status) {
1109
1183
  const { call } = invocation;
@@ -1112,8 +1186,10 @@ export default class Lambder {
1112
1186
  const summary = {
1113
1187
  kind: "lambder.call",
1114
1188
  // The path form is two identifiers; a page built before paths
1115
- // posts the name in its body, which is whatever it sent.
1116
- api: call.api.slice(0, MAX_SUMMARY_API_NAME_CHARS),
1189
+ // posts the name in its body, which is whatever it sent. A route's
1190
+ // name is the app's own, cut the same way.
1191
+ api: call.api === null ? null : call.api.slice(0, MAX_SUMMARY_NAME_CHARS),
1192
+ route: call.route === null ? null : call.route.slice(0, MAX_SUMMARY_NAME_CHARS),
1117
1193
  outcome: invocation.outcome?.outcome ?? "other",
1118
1194
  code: invocation.outcome?.code ?? null,
1119
1195
  status,
@@ -1128,7 +1204,7 @@ export default class Lambder {
1128
1204
  this.callSummaryWriter(summary);
1129
1205
  }
1130
1206
  catch (err) {
1131
- console.error(`Lambder: the callSummary writer threw, so the summary of "${summary.api}" was not written. ${coerceToError(err).message}`);
1207
+ console.error(`Lambder: the callSummary writer threw, so the summary of "${summary.api ?? summary.route ?? "a route"}" was not written. ${coerceToError(err).message}`);
1132
1208
  }
1133
1209
  }
1134
1210
  /**
@@ -0,0 +1,105 @@
1
+ import type { z } from "zod";
2
+ import type { LambderRenderContext, LambderSessionRenderContext } from "./LambderContext.js";
3
+ import type { LambderObjectOutputCheck, LambderPayloadSliceCheck, LambderRequirableGuardsField } from "./LambderCreateOptions.js";
4
+ import type { LambderReadonlyDeep, LambderMaybePromise } from "../shared/util/LambderTypeUtilities.js";
5
+ import type { LambderDeclaredRefuse } from "../shared/wire/LambderApiRefusal.js";
6
+ import type { LambderApiIdempotencyOption } from "../shared/wire/LambderApiOptionValues.js";
7
+ import type { LambderContractEntry, LambderJsonOf } from "../shared/wire/LambderApiContract.js";
8
+ import type { LambderGuardDataOf, LambderGuardInputsOf, LambderGuardRefusalNamesOf, LambderGuardsOption } from "../api/LambderApiGuards.js";
9
+ import type { LambderRateLimitOption } from "../api/LambderApiRateLimits.js";
10
+ import type { LambderHandlerRefusalsOf, LambderRefusalNamesIn, LambderRefusalsOption, LambderWireRefusalsOf } from "../api/LambderApiRefusals.js";
11
+ import type { LambderApiModuleDeclarations } from "../api/LambderApiModules.js";
12
+ import { type LambderActionNamesCheck, type LambderApiDeclaration, type LambderApiDeclarations, type LambderApiGroup, type LambderApiModeOf, type LambderAppTypes, type LambderGroupNameCheck, type LambderGroupPartsCheck, type LambderLazyApiGroup, type LambderMergedParts, type LambderPublicSessionPolicyCheck, type LambderSessionModeCheck } from "../api/LambderApiDeclarations.js";
13
+ /** The codes one API may refuse with, typed: its own refusals option's and its declared guards', as the vocabulary holds them. */
14
+ export type LambderApiRefusalCodes<TVocabulary, TGuards, TRefusalsOpt, TGuardsOpt> = (LambderRefusalNamesIn<TRefusalsOpt> | LambderGuardRefusalNamesOf<TGuards, TGuardsOpt>) & keyof TVocabulary & string;
15
+ /**
16
+ * What an endpoint's handler is handed: the session context when one of its
17
+ * guards needs a session (so ctx.session is typed present), the plain one
18
+ * otherwise, with the payload, the guards' data and a refuse() typed to the
19
+ * codes the endpoint may refuse with.
20
+ */
21
+ export type LambderApiHandlerContext<TApp extends LambderAppTypes, TPayload, TGuardsOpt, TRefusalsOpt> = (LambderApiModeOf<TApp["guards"], TGuardsOpt> extends "session" ? LambderSessionRenderContext<TPayload, TApp["session"], Record<string, string>, LambderGuardDataOf<TApp["guards"], TGuardsOpt>, TApp["policies"]> : LambderRenderContext<TPayload, Record<string, string>, LambderGuardDataOf<TApp["guards"], TGuardsOpt>, TApp["session"], TApp["policies"]>) & {
22
+ refuse: LambderDeclaredRefuse<LambderHandlerRefusalsOf<TApp["refusals"], LambderApiRefusalCodes<TApp["refusals"], TApp["guards"], TRefusalsOpt, TGuardsOpt>>, TApp["refusalCodesRequired"]>;
23
+ };
24
+ /**
25
+ * Declares one endpoint, typed on one set of app types: its input and output
26
+ * schemas, its guards, rate limits, idempotency and refusals, and its
27
+ * handler. Nothing is registered until the declaration is put in a group
28
+ * (defineApiGroup) and the group is registered (registerApiGroups).
29
+ *
30
+ * The guards say who may call it, and so its mode: an endpoint whose guards
31
+ * include one that needs a session (`session: true`) is a session endpoint,
32
+ * its session read before the guards run and a call without one answered
33
+ * sessionExpired, and its handler's ctx.session is typed present. Any other
34
+ * endpoint is public.
35
+ */
36
+ export type LambderDefineApi<TApp extends LambderAppTypes> = <TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<TApp["policies"]> = never, const TGuardsOpt extends LambderGuardsOption<TApp["guards"]> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never, const TRefusalsOpt extends LambderRefusalsOption<TApp["refusals"]> = never>(options: {
37
+ input: TInput;
38
+ output: TOutput;
39
+ } & {
40
+ /** Named rate limits, checked in declared order within their phase (per ip before the session read, per session before the guards, a custom key after the guards and input validation): a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, refusal on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. A per-session policy needs one of the endpoint's guards to need a session. */
41
+ rateLimit?: TRateOpt;
42
+ /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
43
+ idempotency?: TApp["idempotency"] extends true ? TIdempotencyOpt : never;
44
+ /**
45
+ * The refusal codes this API may refuse with, from the vocabulary given at creation: one code or a
46
+ * non-empty list. Its guards' codes join them. The handler raises one with `ctx.refuse(content, { code, data })`,
47
+ * its callers narrow on them, and a refusal with any other code is a crash rather than an answer.
48
+ */
49
+ refusals?: TRefusalsOpt;
50
+ /**
51
+ * Whether this API's answers are compressed for a caller that accepts it: "auto" (the default) when the
52
+ * body is large enough to gain, false never, true always. false suits an answer of base64 bytes: once
53
+ * compressed it leaves the function base64-encoded again, so it is no smaller under Lambda's response
54
+ * cap or to a lambda caller, and a browser gets it only about a quarter smaller for the time spent at
55
+ * both ends. A transport setting of this server's, not part of the API's contract.
56
+ */
57
+ compress?: boolean | "auto";
58
+ } & LambderRequirableGuardsField<TApp["guardsRequired"], TGuardsOpt> & LambderObjectOutputCheck<TOutput> & LambderPayloadSliceCheck<TApp["guards"], TApp["policies"], TGuardsOpt, TRateOpt, z.input<TInput>> & LambderPublicSessionPolicyCheck<LambderApiModeOf<TApp["guards"], TGuardsOpt>, TApp["policies"], TRateOpt> & LambderSessionModeCheck<LambderApiModeOf<TApp["guards"], TGuardsOpt>, TApp["sessions"]>,
59
+ /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
60
+ handler: (ctx: LambderApiHandlerContext<TApp, z.infer<TInput>, TGuardsOpt, TRefusalsOpt>) => LambderMaybePromise<TAnswer>) => LambderApiDeclaration<LambderContractEntry<z.input<TInput>, LambderJsonOf<z.output<TOutput>>, LambderApiModeOf<TApp["guards"], TGuardsOpt>, LambderGuardInputsOf<TApp["guards"], TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt, LambderWireRefusalsOf<TApp["refusals"], LambderApiRefusalCodes<TApp["refusals"], TApp["guards"], TRefusalsOpt, TGuardsOpt>>>>;
61
+ /**
62
+ * Gathers endpoints into a named group: each is registered as `name.action`
63
+ * and called at `{apiPath}/{name}/{action}`, and every Lambder caller
64
+ * reaches it as `caller.name.action(input)`. A group's name and its actions
65
+ * are identifiers, and a group may not take a name a caller already answers
66
+ * for (see LAMBDER_RESERVED_GROUP_NAMES).
67
+ *
68
+ * A group declared across files takes each file's part:
69
+ * `defineApiGroup("orders", orderReadApis, orderWriteApis)`. An action two
70
+ * parts declare is refused, at compile time and at startup, rather than one
71
+ * silently replacing the other as an object spread would. Endpoints a
72
+ * module's builders declared may sit in any group: registering it adds
73
+ * their module.
74
+ */
75
+ export type LambderDefineApiGroup = <const TName extends string, const TParts extends readonly [LambderApiDeclarations, ...LambderApiDeclarations[]]>(name: TName & LambderGroupNameCheck<TName>, ...parts: TParts & LambderGroupPartsCheck<TParts> & {
76
+ [TIndex in keyof TParts]: LambderActionNamesCheck<TParts[TIndex]>;
77
+ }) => LambderApiGroup<TName, LambderMergedParts<TParts>>;
78
+ /**
79
+ * A group loaded on the first call to one of its endpoints:
80
+ * `lazyApiGroup("orders", () => import("./orders.js").then((m) => m.orderApis))`.
81
+ * A cold start then parses none of it, nor anything only it imports, until
82
+ * a request calls it; the contract is the loaded group's, read off its type.
83
+ * The group loaded must carry the same name, which the type checks and
84
+ * loading checks again. `loadApiGroups()` loads every group, for a build
85
+ * step or a boot check that has to see them all.
86
+ *
87
+ * A module's lazyApiGroup carries the module, so its guards and policies
88
+ * are added when the group is registered, at startup, rather than when it
89
+ * loads; a group whose endpoints a module's builders declared is registered
90
+ * lazily with that module's.
91
+ */
92
+ export type LambderDefineLazyApiGroup = <const TName extends string, TGroup extends LambderApiGroup<TName, any>>(name: TName & LambderGroupNameCheck<TName>, load: () => Promise<TGroup>) => LambderLazyApiGroup<TName, NoInfer<TGroup>>;
93
+ /** The three builders, typed on one set of app types. */
94
+ export type LambderApiBuilders<TApp extends LambderAppTypes> = {
95
+ readonly defineApi: LambderDefineApi<TApp>;
96
+ readonly defineApiGroup: LambderDefineApiGroup;
97
+ readonly lazyApiGroup: LambderDefineLazyApiGroup;
98
+ };
99
+ /**
100
+ * The builders as values: the instance's own (module null), or a module's,
101
+ * whose endpoints and lazy groups carry the module for registration to add.
102
+ * Plain functions closing over nothing else, so an app may hand them out
103
+ * detached.
104
+ */
105
+ export declare const apiBuildersFor: <TApp extends LambderAppTypes>(module: LambderApiModuleDeclarations | null) => LambderApiBuilders<TApp>;
@@ -0,0 +1,12 @@
1
+ import { buildApiGroup, buildLazyApiGroup, } from "../api/LambderApiDeclarations.js";
2
+ /**
3
+ * The builders as values: the instance's own (module null), or a module's,
4
+ * whose endpoints and lazy groups carry the module for registration to add.
5
+ * Plain functions closing over nothing else, so an app may hand them out
6
+ * detached.
7
+ */
8
+ export const apiBuildersFor = (module) => ({
9
+ defineApi: ((options, handler) => module ? { kind: "lambderApi", options, handler, module } : { kind: "lambderApi", options, handler }),
10
+ defineApiGroup: ((name, ...parts) => buildApiGroup(name, parts)),
11
+ lazyApiGroup: ((name, load) => buildLazyApiGroup(name, load, module)),
12
+ });