lambder 15.0.1 → 15.1.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 CHANGED
@@ -9,6 +9,54 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [15.1.1] - 2026-10-03
13
+
14
+ A minor: every request a route answers writes a call summary line, named by
15
+ the route as registered, and a matcher object can name its route.
16
+
17
+ ### Added
18
+
19
+ - **A call summary line for every route.** A request a route answered
20
+ (`addRoute`, `addSessionRoute`, or an `addAction` on its HTTP side) writes
21
+ the same `lambder.call` line an API call does, so a webhook or a page route
22
+ is counted, timed and queried beside the endpoints. The line's `route`
23
+ names the route as it was registered, its matcher's `name` or its method
24
+ and path pattern, and carries nothing of the request's own path; its
25
+ `outcome` is read from its status (`success` below 400, `crash` from 500,
26
+ `other` between) and `handlerMs` is the route handler's own time. A line
27
+ a hook's answer ends in is the matched route's too. Files, the index page,
28
+ the route fallback and non-HTTP events still write none.
29
+ - **`name` on a matcher object**, what the line names the route by, which a
30
+ bare predicate route needs to be named at all.
31
+
32
+ ### Changed
33
+
34
+ - **`LambderCallSummary.api` is null for a route, and the line gains
35
+ `route`**, null for an API call. A `callSummary` function that read `api`
36
+ as a string narrows it first.
37
+
38
+ ## [15.0.2] - 2026-10-02
39
+
40
+ A patch: two fixes for apps with a large contract.
41
+
42
+ ### Fixed
43
+
44
+ - **Reading a failure compiles for a contract of hundreds of refusal codes.**
45
+ `failure.reason`, `failure.refusal`, `outcome.ok` and every other read of a
46
+ `LambderApiFailure` or `LambderApiOutcome` over a contract's refusal
47
+ messages failed with TS2590 ("union type too complex to represent") once
48
+ the vocabulary held a few hundred codes, and so did a `LambderCaller` whose
49
+ `fetchEndedHandler` or `errorHandler` read its argument: the refusal arm
50
+ declared its message both on the fields every failure shares and on the arm
51
+ itself, and a read of the intersection crossed the message union with
52
+ itself. Each arm now declares it once. The types describe the same values.
53
+ - **`checkApiRefusals` no longer reports a mock guard's unused codes.** A mock
54
+ guard declares exactly its server guard's codes, which `create()` holds it
55
+ to against `guardDeclarations`, so one that raises fewer (a captcha stand-in
56
+ letting every token through) got an `unused` finding it could not act on.
57
+ Its codes are still checked the other way: one it raises and may not send
58
+ is `undeclared`.
59
+
12
60
  ## [15.0.1] - 2026-10-02
13
61
 
14
62
  A major. Fixes for crashes, misconfigurations that passed silently, and places
package/README.md CHANGED
@@ -80,10 +80,10 @@ const company = await caller.companies.get({ slug: "acme" });
80
80
  cookies, and a guard against Lambda's response size cap.
81
81
  - **Hooks and actions.** Lifecycle hooks, plus `addAction()` for the non-HTTP
82
82
  invocations (EventBridge, SQS, custom events) the same function receives.
83
- - **A summary line per call.** Every API call is logged once, as JSON, with
84
- its endpoint, outcome, refusal code, status and timings, and the request
85
- ids that join it to the calls it made; nothing from its input or its
86
- caller.
83
+ - **A summary line per call.** Every API call, and every request a route
84
+ answers, is logged once, as JSON, with its endpoint or route, outcome,
85
+ refusal code, status and timings, and the request ids that join it to the
86
+ calls it made; nothing from its input, its path or its caller.
87
87
  - **Lambda to lambda calls.** `LambderInvokeCaller` invokes a Lambder app in
88
88
  another function directly, with no API Gateway in between, typed from the
89
89
  callee's own contract and carrying its refusals, crash detail and logs back.
@@ -16,7 +16,7 @@ export type LambderRefusalCheckFinding = {
16
16
  at: string;
17
17
  /**
18
18
  * - `undeclared`: a code the handler can reach that it may not send.
19
- * - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises.
19
+ * - `unused`: a code the handler's own `refusals` option names that nothing it reaches raises. Never a mock guard's, whose codes are its server guard's.
20
20
  * - `uncoded`: a refusal with no code it can reach, where codes are required.
21
21
  * - `unreadable`: a refusal it can reach whose code is not a string literal type.
22
22
  * - `untraced`: a handler handed a typed refuse whose function cannot be
@@ -67,6 +67,24 @@ export const checkApiRefusals = async (options) => {
67
67
  return true;
68
68
  return isInside(path, lambderRoot) && !isInside(path, resolve(lambderRoot, "tests")) && !parts.slice(lambderRoot.split(sep).length).includes("node_modules");
69
69
  };
70
+ /**
71
+ * Whether a registration is a mock guard: `guard` read off a mock init,
72
+ * whose builder Lambder declares in its mock entry, where a server init's
73
+ * is declared in its own.
74
+ */
75
+ const isMockGuard = (call, registration) => {
76
+ if (registration !== "guard" || !ts.isPropertyAccessExpression(call.expression))
77
+ return false;
78
+ const declaration = checker.getSymbolAtLocation(call.expression.name)?.declarations?.[0];
79
+ if (!declaration || !isLambderDeclaration(declaration))
80
+ return false;
81
+ const path = realPathOf(ts, declaration.getSourceFile().fileName);
82
+ const parts = path.split(sep);
83
+ const packageAt = parts.findIndex((part, index) => part === "lambder" && parts[index - 1] === "node_modules");
84
+ // The path inside the package: dist/mock/... installed, src/mock/... over its own sources.
85
+ const inside = packageAt >= 0 ? parts.slice(packageAt + 1) : relative(lambderRoot, path).split(sep);
86
+ return inside[1] === "mock";
87
+ };
70
88
  const where = (node) => {
71
89
  const sourceFile = node.getSourceFile();
72
90
  const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
@@ -390,6 +408,12 @@ export const checkApiRefusals = async (options) => {
390
408
  findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
391
409
  }
392
410
  }
411
+ // A mock guard declares its server guard's codes, which create() holds
412
+ // it to against guardDeclarations. Standing in for that guard it may
413
+ // raise fewer of them, which only lets more through, so a code it
414
+ // never raises is no declaration to trim.
415
+ if (isMockGuard(call, registration))
416
+ return;
393
417
  for (const code of ownRefusalsOf(optionsNode) ?? []) {
394
418
  if (!reached.has(code))
395
419
  findings.push({ handler: name, at, problem: "unused", code });
@@ -150,7 +150,7 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
150
150
  private readonly trustedClientIpHeaders;
151
151
  private readonly trustedHostHeaders;
152
152
  private readonly originProof;
153
- /** Where each API call's summary goes (the callSummary option); null writes none. Replaceable through the backend swap alone. */
153
+ /** 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. */
154
154
  private callSummaryWriter;
155
155
  constructor(given?: LambderCreateOptions<TApp["session"]>);
156
156
  setRouteFallbackHandler(routeFallbackHandler: LambderFallbackHandler): this;
@@ -464,8 +464,8 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
464
464
  private resolveRequest;
465
465
  /**
466
466
  * One HTTP invocation, from the event to the finalized response, under
467
- * an invocation record of its own (see LambderInvocationScope): what an
468
- * API call's summary line is written from once the response is final.
467
+ * an invocation record of its own (see LambderInvocationScope): what its
468
+ * call summary line is written from once the response is final.
469
469
  */
470
470
  render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
471
471
  private renderRequest;
@@ -496,9 +496,9 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
496
496
  */
497
497
  private answerCrash;
498
498
  /**
499
- * The summary line of an API call, once its response is final: nothing
500
- * for an invocation that served no API call. A writer that throws costs
501
- * the call its line and nothing else.
499
+ * The summary line of an API call or of a request a route answered, once
500
+ * its response is final: nothing for an invocation that served neither. A
501
+ * writer that throws costs the call its line and nothing else.
502
502
  */
503
503
  private writeCallSummary;
504
504
  /**
@@ -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";
@@ -37,7 +37,7 @@ import { assertCreateOptions, mergeNamedMaps, } from "./LambderCreateOptions.js"
37
37
  /** The refusals of a name no API is registered under: no code, and none required. */
38
38
  const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
39
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;
40
+ const MAX_SUMMARY_NAME_CHARS = 200;
41
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
42
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{1,128}$/;
43
43
  /**
@@ -52,7 +52,11 @@ const parentRequestIdOf = (event) => {
52
52
  return typeof value === "string" && REQUEST_ID_PATTERN.test(value) ? value : null;
53
53
  };
54
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;
55
+ const apiCallOf = (ctx, event) => ctx.api ? { api: ctx.api.apiName, route: null, parentRequestId: parentRequestIdOf(event) } : null;
56
+ /** The call a request a route answered is: the route by its registered name, never by the path asked for. */
57
+ const routeCallOf = (ctx, route) => ({ api: null, route, parentRequestId: parentRequestIdOf(ctx.event) });
58
+ /** A route's outcome, which no envelope names: read from the status it answered with, as an API call's 5xx reads. */
59
+ const outcomeOfStatus = (status) => ({ outcome: status >= 500 ? "crash" : status >= 400 ? "other" : "success", code: null });
56
60
  /**
57
61
  * Main Lambder class for building type-safe serverless APIs. Create
58
62
  * instances with initLambder<SessionData>().create({...}) (see below): the
@@ -157,7 +161,7 @@ export default class Lambder {
157
161
  trustedClientIpHeaders;
158
162
  trustedHostHeaders;
159
163
  originProof;
160
- /** Where each API call's summary goes (the callSummary option); null writes none. Replaceable through the backend swap alone. */
164
+ /** 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
165
  callSummaryWriter;
162
166
  constructor(given = {}) {
163
167
  // The guards and the rate-limit policies as one map each, whether
@@ -314,6 +318,7 @@ export default class Lambder {
314
318
  addRoute(condition, actionFn) {
315
319
  this.actionList.push({
316
320
  match: compileRouteMatcher(condition),
321
+ name: routeNameOf(condition),
317
322
  actionFn: (ctx, resolver) => actionFn(ctx, resolver),
318
323
  });
319
324
  return this;
@@ -321,6 +326,7 @@ export default class Lambder {
321
326
  addSessionRoute(condition, actionFn) {
322
327
  this.actionList.push({
323
328
  match: compileRouteMatcher(condition),
329
+ name: routeNameOf(condition),
324
330
  actionFn: async (ctx, resolver) => {
325
331
  await this.requireSession(ctx, resolver);
326
332
  // requireSession answered already if there was no session, so
@@ -429,6 +435,7 @@ export default class Lambder {
429
435
  const group = ctx.api ? splitApiName(ctx.api.apiName)?.group : undefined;
430
436
  return group !== undefined && names.has(group) ? {} : false;
431
437
  },
438
+ name: null,
432
439
  actionFn: (ctx, resolver) => instance.dispatchApiCall(ctx, resolver),
433
440
  });
434
441
  return instance;
@@ -556,6 +563,7 @@ export default class Lambder {
556
563
  // HTTP side: joins the route/API chain in registration order.
557
564
  this.actionList.push({
558
565
  match: (ctx) => filter(ctx.event, ctx) ? {} : false,
566
+ name: null,
559
567
  actionFn: async (ctx, resolver) => {
560
568
  const result = await actionFn(ctx.event, { ctx, res: resolver, lambdaContext: ctx.lambdaContext });
561
569
  if (!(result instanceof LambderResponse)) {
@@ -919,15 +927,32 @@ export default class Lambder {
919
927
  // Set before the hooks run, so a beforeRender hook on a matched route
920
928
  // sees the route's own path params.
921
929
  ctx.pathParams = matched.params;
930
+ // A route's call is named as soon as it has matched, before the hooks
931
+ // run, so the line a hook's answer ends in is the route's too. An API
932
+ // call keeps the name its path gave it, whatever action answers it.
933
+ const invocation = currentInvocation();
934
+ if (invocation && !invocation.call)
935
+ invocation.call = routeCallOf(ctx, matched.action.name);
922
936
  const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
923
937
  if (beforeRenderResult instanceof LambderResponse)
924
938
  return beforeRenderResult;
925
- return await matched.action.actionFn(beforeRenderResult, resolver);
939
+ // An API call's handler time is the pipeline's to measure, inside its
940
+ // own work; a route's is the whole action.
941
+ if (ctx.api)
942
+ return await matched.action.actionFn(beforeRenderResult, resolver);
943
+ const handlerStarted = performance.now();
944
+ try {
945
+ return await matched.action.actionFn(beforeRenderResult, resolver);
946
+ }
947
+ finally {
948
+ if (invocation)
949
+ invocation.handlerMs = performance.now() - handlerStarted;
950
+ }
926
951
  }
927
952
  /**
928
953
  * 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.
954
+ * an invocation record of its own (see LambderInvocationScope): what its
955
+ * call summary line is written from once the response is final.
931
956
  */
932
957
  async render(event, lambdaContext) {
933
958
  return await runInvocation(lambdaContext, async (invocation) => {
@@ -995,7 +1020,8 @@ export default class Lambder {
995
1020
  // reach it.
996
1021
  ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
997
1022
  this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
998
- invocation.outcome = response.callOutcome ?? { outcome: "other", code: null };
1023
+ // An API answer names its outcome; a route's answer names none, and reads from its status.
1024
+ invocation.outcome = response.callOutcome ?? (ctx.api ? { outcome: "other", code: null } : outcomeOfStatus(response.statusCode));
999
1025
  return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
1000
1026
  }
1001
1027
  catch (err) {
@@ -1101,9 +1127,9 @@ export default class Lambder {
1101
1127
  return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
1102
1128
  }
1103
1129
  /**
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.
1130
+ * The summary line of an API call or of a request a route answered, once
1131
+ * its response is final: nothing for an invocation that served neither. A
1132
+ * writer that throws costs the call its line and nothing else.
1107
1133
  */
1108
1134
  writeCallSummary(invocation, status) {
1109
1135
  const { call } = invocation;
@@ -1112,8 +1138,10 @@ export default class Lambder {
1112
1138
  const summary = {
1113
1139
  kind: "lambder.call",
1114
1140
  // 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),
1141
+ // posts the name in its body, which is whatever it sent. A route's
1142
+ // name is the app's own, cut the same way.
1143
+ api: call.api === null ? null : call.api.slice(0, MAX_SUMMARY_NAME_CHARS),
1144
+ route: call.route === null ? null : call.route.slice(0, MAX_SUMMARY_NAME_CHARS),
1117
1145
  outcome: invocation.outcome?.outcome ?? "other",
1118
1146
  code: invocation.outcome?.code ?? null,
1119
1147
  status,
@@ -1128,7 +1156,7 @@ export default class Lambder {
1128
1156
  this.callSummaryWriter(summary);
1129
1157
  }
1130
1158
  catch (err) {
1131
- console.error(`Lambder: the callSummary writer threw, so the summary of "${summary.api}" was not written. ${coerceToError(err).message}`);
1159
+ console.error(`Lambder: the callSummary writer threw, so the summary of "${summary.api ?? summary.route ?? "a route"}" was not written. ${coerceToError(err).message}`);
1132
1160
  }
1133
1161
  }
1134
1162
  /**
@@ -1,17 +1,19 @@
1
1
  import type { LambderCallOutcome } from "../shared/wire/LambderCallOutcome.js";
2
2
  /**
3
- * One API call as its summary line records it: what was called, how it
4
- * ended, how long it took, and the ids that join it to the rest of the
5
- * logs. Nothing from the call's input, its session, its cookies or its
6
- * caller's address is in it, so the line can be kept and queried as long as
7
- * the app likes.
3
+ * One API call, or one request a route answered, as its summary line records
4
+ * it: what was called, how it ended, how long it took, and the ids that join
5
+ * it to the rest of the logs. Nothing from the call's input, its path, its
6
+ * session, its cookies or its caller's address is in it, so the line can be
7
+ * kept and queried as long as the app likes.
8
8
  */
9
9
  export type LambderCallSummary = {
10
10
  /** Always "lambder.call": what a log query picks the lines out by. */
11
11
  kind: "lambder.call";
12
- /** The endpoint the call's path named (`group.action`), whether or not one is registered under it. */
13
- api: string;
14
- /** How the call ended; see LambderCallOutcome. */
12
+ /** The endpoint an API call's path named (`group.action`), whether or not one is registered under it; null for a route. */
13
+ api: string | null;
14
+ /** The route that answered, as it was registered: its matcher's name, or its method and path pattern; null for an API call, for a predicate route given no name, and for an addAction that answered an HTTP request. */
15
+ route: string | null;
16
+ /** How the call ended; see LambderCallOutcome. A route's is read from its status: success below 400, crash from 500, other between. */
15
17
  outcome: LambderCallOutcome;
16
18
  /** The refusal's code, the framework's (`lambder/rate-limited`) or the app's; null when the answer carries none. */
17
19
  code: string | null;
@@ -19,7 +21,7 @@ export type LambderCallSummary = {
19
21
  status: number;
20
22
  /** From the invocation's start to the answer, in milliseconds. */
21
23
  durationMs: number;
22
- /** How long the API handler itself ran, in milliseconds; null when it did not run (refused before it, or replayed). */
24
+ /** How long the API handler, or the route's handler, itself ran, in milliseconds; null when it did not run (refused before it, replayed, or answered by a hook). */
23
25
  handlerMs: number | null;
24
26
  /** True when a stored idempotent answer was replayed and no handler ran. */
25
27
  replayed: boolean;
@@ -292,14 +292,15 @@ export type LambderCreateOptions<TSessionData = any> = {
292
292
  */
293
293
  originProof?: LambderOriginProof;
294
294
  /**
295
- * One summary per API call, written when the call is answered: the
296
- * endpoint, how it ended (success, a refusal and its code, a flag, a
297
- * validation refusal, a crash), the status, the duration and the
298
- * handler's own time, whether an idempotent answer was replayed, whether
299
- * the invocation was the process's first, and its request id with the
300
- * id of the invocation that called it over invoke. Nothing from the
301
- * input, the session, the cookies or the caller's address. See
302
- * LambderCallSummary.
295
+ * One summary per API call, and per request a route answered, written
296
+ * when it is answered: the endpoint or the route as registered, how it
297
+ * ended (success, a refusal and its code, a flag, a validation refusal, a
298
+ * crash; a route's read from its status), the status, the duration and
299
+ * the handler's own time, whether an idempotent answer was replayed,
300
+ * whether the invocation was the process's first, and its request id
301
+ * with the id of the invocation that called it over invoke. Nothing from
302
+ * the input, the path, the session, the cookies or the caller's address.
303
+ * See LambderCallSummary.
303
304
  *
304
305
  * Default: each summary as one JSON line on stdout, which a Lambda
305
306
  * function's log group keeps and CloudWatch Logs Insights reads field by
@@ -7,13 +7,19 @@ export type LambderInvocationRecord = LambderInvocation & {
7
7
  readonly startedAt: number;
8
8
  /** Whether this is the first invocation this process has run. */
9
9
  readonly coldStart: boolean;
10
- /** How long the API handler itself ran, in milliseconds; null until it has. */
10
+ /** How long the API handler, or the route's handler, itself ran, in milliseconds; null until it has. */
11
11
  handlerMs: number | null;
12
12
  /** Whether an idempotent answer was replayed, so no handler ran. */
13
13
  replayed: boolean;
14
- /** The API call the invocation serves: the endpoint its path named, and the invocation that invoked it, when one did. Null for anything else. */
14
+ /**
15
+ * What the invocation serves, named: an API call by the endpoint its path
16
+ * named, or a request a route answered by the route as registered (null
17
+ * for a route with no name), with the invocation that invoked it, when
18
+ * one did. Null for anything else: a file, the index page, an event.
19
+ */
15
20
  call: {
16
- api: string;
21
+ api: string | null;
22
+ route: string | null;
17
23
  parentRequestId: string | null;
18
24
  } | null;
19
25
  /** What the answer is, once the call has one. */
@@ -15,6 +15,8 @@ export type LambderRouteMatcher = {
15
15
  /** One or more HTTP methods; HEAD requests also match GET routes. */
16
16
  method?: string | string[];
17
17
  condition?: LambderRouteConditionFn;
18
+ /** What the route's call summary line names it by; by default its method and path pattern, which a route with neither has none of. */
19
+ name?: string;
18
20
  };
19
21
  export type LambderRouteCondition = LambderRoutePath | RegExp | LambderRouteConditionFn | LambderRouteMatcher;
20
22
  /** Returns matched path params, or false when the route doesn't match. */
@@ -28,6 +30,13 @@ export type CompiledMatcher = (ctx: LambderRenderContext) => false | Record<stri
28
30
  * this rule, so neighbouring slots cannot disagree about what a method means.
29
31
  */
30
32
  export declare const allowsRequestMethod: (methods: ReadonlySet<string>, requestMethod: string) => boolean;
33
+ /**
34
+ * What a route's call summary line names it by: the name its matcher gives,
35
+ * or its method and path pattern, both fixed at registration, so the line
36
+ * carries nothing of the request's own path. Null for a route with neither,
37
+ * a bare predicate, which the line still counts.
38
+ */
39
+ export declare const routeNameOf: (condition: LambderRouteCondition) => string | null;
31
40
  /** Compile a route condition once at registration time. */
32
41
  export declare const compileRouteMatcher: (condition: LambderRouteCondition) => CompiledMatcher;
33
42
  export {};
@@ -57,6 +57,26 @@ export const allowsRequestMethod = (methods, requestMethod) => {
57
57
  return true;
58
58
  return method === "HEAD" && methods.has("GET");
59
59
  };
60
+ /**
61
+ * What a route's call summary line names it by: the name its matcher gives,
62
+ * or its method and path pattern, both fixed at registration, so the line
63
+ * carries nothing of the request's own path. Null for a route with neither,
64
+ * a bare predicate, which the line still counts.
65
+ */
66
+ export const routeNameOf = (condition) => {
67
+ if (typeof condition === "string")
68
+ return condition;
69
+ if (condition instanceof RegExp)
70
+ return condition.toString();
71
+ if (typeof condition === "function")
72
+ return null;
73
+ if (condition.name !== undefined)
74
+ return condition.name;
75
+ const methods = condition.method === undefined ? [] : (Array.isArray(condition.method) ? condition.method : [condition.method]).map((m) => m.toUpperCase());
76
+ const path = condition.path === undefined ? null : typeof condition.path === "string" ? condition.path : condition.path.toString();
77
+ const name = [methods.join(","), path].filter((part) => part).join(" ");
78
+ return name || null;
79
+ };
60
80
  /** Compile a route condition once at registration time. */
61
81
  export const compileRouteMatcher = (condition) => {
62
82
  if (typeof condition === "string" || condition instanceof RegExp) {
@@ -42,13 +42,17 @@ export type LambderApiSuccessOutcome<T> = {
42
42
  * message the endpoint can answer with (LambderContractRefusalMessage), on
43
43
  * every arm: a refusal flagged notAuthorized arrives as that reason and still
44
44
  * carries its message.
45
+ *
46
+ * The message is declared once per arm, never here and again on the arm that
47
+ * requires it: an intersection reading `refusal` from both would intersect
48
+ * TMessage with itself, a union of every code a contract declares crossed
49
+ * with itself, which for a contract of a few hundred codes is past what the
50
+ * compiler can represent (TS2590 on any read of a failure).
45
51
  */
46
- type LambderApiFailureFields<TMessage extends LambderUncheckedRefusalMessage> = {
52
+ type LambderApiFailureFields = {
47
53
  ok: false;
48
54
  /** HTTP status, when a response was received. */
49
55
  status?: number;
50
- /** The envelope's refusal, when the server provided one: always the message object, a plain string having been read as one (refusalMessageOf). */
51
- refusal?: TMessage;
52
56
  /** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it, and so may a 503). */
53
57
  retryAfterSeconds?: number;
54
58
  /**
@@ -69,21 +73,26 @@ type LambderApiFailureFields<TMessage extends LambderUncheckedRefusalMessage> =
69
73
  * `response` when the server answered with Lambder's own envelope, which is
70
74
  * how a crash detail and a logList arrive with it.
71
75
  */
72
- export type LambderApiCallFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
76
+ export type LambderApiCallFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields & {
73
77
  reason: 'network' | 'timeout' | 'aborted' | 'server' | 'unknown';
78
+ /** The envelope's refusal, when the server provided one: always the message object, a plain string having been read as one (refusalMessageOf). */
79
+ refusal?: TMessage;
74
80
  error: Error;
75
81
  response?: LambderApiRefusalEnvelope;
76
82
  };
77
83
  /** HTTP 422: the server rejected the input against the API's schema. Always carries the issues. */
78
- export type LambderApiValidationFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
84
+ export type LambderApiValidationFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields & {
79
85
  reason: 'validation';
86
+ /** As on a call failure. */
87
+ refusal?: TMessage;
80
88
  zodError: LambderValidationError;
81
89
  };
82
90
  /** The server answered, and the envelope itself says the call is refused. Always carries that envelope, and a `refusal` failure always carries the refusal. */
83
- export type LambderApiEnvelopeFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
91
+ export type LambderApiEnvelopeFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields & {
84
92
  response: LambderApiRefusalEnvelope;
85
93
  } & ({
86
- reason: 'versionExpired' | 'sessionExpired' | 'notAuthorized';
94
+ reason: 'versionExpired' | 'sessionExpired' | 'notAuthorized'; /** As on a call failure. */
95
+ refusal?: TMessage;
87
96
  } | {
88
97
  reason: 'refusal';
89
98
  refusal: TMessage;
@@ -153,9 +153,10 @@ export declare class LambderTestApp<TContract extends LambderApiContractShape =
153
153
  */
154
154
  get crashes(): readonly Error[];
155
155
  /**
156
- * The summary of every API call the app answered since the last reset,
157
- * in order (see LambderCallSummary): what its callSummary option would
158
- * have been handed, collected here instead of written to stdout.
156
+ * The summary of every API call, and every request a route answered,
157
+ * since the last reset, in order (see LambderCallSummary): what the app's
158
+ * callSummary option would have been handed, collected here instead of
159
+ * written to stdout.
159
160
  */
160
161
  get callSummaries(): readonly LambderCallSummary[];
161
162
  /**
@@ -183,9 +183,10 @@ export class LambderTestApp {
183
183
  return this.crashList;
184
184
  }
185
185
  /**
186
- * The summary of every API call the app answered since the last reset,
187
- * in order (see LambderCallSummary): what its callSummary option would
188
- * have been handed, collected here instead of written to stdout.
186
+ * The summary of every API call, and every request a route answered,
187
+ * since the last reset, in order (see LambderCallSummary): what the app's
188
+ * callSummary option would have been handed, collected here instead of
189
+ * written to stdout.
189
190
  */
190
191
  get callSummaries() {
191
192
  return this.callSummaryList;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "15.0.1",
3
+ "version": "15.1.1",
4
4
  "sideEffects": false,
5
5
  "description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
6
6
  "keywords": [