lambder 15.0.2 → 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,32 @@ 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
+
12
38
  ## [15.0.2] - 2026-10-02
13
39
 
14
40
  A patch: two fixes for apps with a large contract.
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.
@@ -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) {
@@ -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.2",
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": [