lambder 8.3.1 → 9.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,78 @@ 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
+ ## [9.0.1] - 2026-09-27
13
+
14
+ A major that gives an API handler one shape. It takes its context and returns
15
+ its output; it says no with `refuse()`; it writes headers, cookies and log
16
+ entries through the context. The response builder stays with routes, hooks,
17
+ fallbacks and error handlers, which build HTTP responses. An API is typed data
18
+ in and typed data out, and a handler that has no response builder cannot
19
+ answer any other way, so the two ways a handler used to refuse (throwing, or
20
+ answering null beside a reason) are one, and the untyped side channels are
21
+ gone. A server handler and its mock twin now read alike.
22
+
23
+ ### Changed (breaking)
24
+
25
+ - **An API handler returns its output.** `addApi` and `addSessionApi` take
26
+ `async (ctx) => output` instead of `async (ctx, res) => res.api(output)`.
27
+ The return is checked against the output schema's input form and parsed
28
+ through the schema before it is sent, as `res.api()`'s payload was:
29
+ undeclared fields are stripped, defaults filled, transforms run once, and
30
+ an output the schema rejects is answered as a crash
31
+ (`LambderApiOutputValidationError`). A literal in a returned object keeps
32
+ its type (`{ status: "open" }` is checked as `"open"`, not `string`), which
33
+ is why the handler's return is its own `const` type parameter; arrays in a
34
+ returned literal are read as readonly, which is all an answer needs.
35
+ - To move: drop the second parameter and return what was passed to
36
+ `res.api()`. An answer that was `res.api(null, { errorMessage })`,
37
+ `{ notAuthorized }` or `{ sessionExpired }` becomes `refuse(content, {
38
+ notAuthorized, sessionExpired, code, type })`. A handler that answered
39
+ null where the output does not allow null no longer compiles; it refuses
40
+ instead, or the output becomes nullable.
41
+ - **A refusal is never stored for replay.** Only an answer (a returned
42
+ output) is stored under an idempotency key; a refusal is thrown, the claim
43
+ is released, and a retry runs the handler again, which decides afresh. A
44
+ corrected request after a refusal goes through under the same key instead
45
+ of being refused as a reused key.
46
+ - **Response headers and cookies are written through the context.**
47
+ `ctx.setResponseHeader(key, value)`, `ctx.addResponseHeader(key, value)`,
48
+ `ctx.setCookie(name, value, options?)` and `ctx.clearCookie(name,
49
+ options?)` (the type `LambderResponseTools`) are on every context: routes,
50
+ API handlers, hooks, and mock handlers alike. `res.setHeader`,
51
+ `res.addHeader`, `res.setCookie` and `res.clearCookie` are removed. The
52
+ writers say "Response" because `ctx.header(name)` reads a request header.
53
+ - **The log channel is `ctx.logList`.** `res.logToApiResponse(entry)` is
54
+ removed; push the entry onto `ctx.logList`, which the mock's context has
55
+ always had.
56
+ - **Answer compression is declared per API.** `addApi` and `addSessionApi`
57
+ take `compress?: boolean | "auto"` beside the other options: "auto" (the
58
+ default) compresses for an accepting caller when the body is large enough,
59
+ false never (an answer carrying base64 bytes), true always. It covers
60
+ refusals and replayed answers too, and is a transport setting of the
61
+ server, not part of the contract. `res.apiBinary()` and the per-answer
62
+ response options of an API handler (status code, compression, cache
63
+ headers) are removed; a header goes through `ctx.setResponseHeader`.
64
+ - **`res.api()` writes an envelope by hand, for code outside an API
65
+ handler.** A hook, the input validation handler or a global error handler
66
+ answering an API call still uses it; the payload goes out as given. The
67
+ typed overloads and `LambderResolver`'s output type parameter are removed,
68
+ with the types `LambderResolverApiMethod` and `LambderApiNullAnswerConfig`.
69
+ A binary or file answer is a route's job.
70
+
71
+ ### Removed
72
+
73
+ - **The `message` envelope channel.** The untyped `message` field of the
74
+ envelope and of `LambderApiResponseConfig`, `LambderCaller`'s
75
+ `messageHandler` option and per-call override, and the mock's
76
+ `ctx.envelope`. What a success says belongs in the output schema, where it
77
+ is typed and parsed.
78
+
79
+ ### Added
80
+
81
+ - `LambderResponseTools`, the type of the four response writers every
82
+ context carries, exported from `lambder`.
83
+
12
84
  ## [8.3.1] - 2026-09-27
13
85
 
14
86
  Six additions, each a thing an app otherwise writes for itself once per kind
package/README.md CHANGED
@@ -17,15 +17,17 @@ const lambder = initLambder<SessionData>().create({
17
17
  }).addApi("getCompany", {
18
18
  input: z.object({ slug: z.string() }),
19
19
  output: z.object({ id: z.string(), name: z.string() }),
20
- }, async ({ apiPayload }, res) => res.api(await loadCompany(apiPayload.slug)));
20
+ }, async ({ apiPayload }) => await loadCompany(apiPayload.slug));
21
21
 
22
22
  export type ApiContractType = typeof lambder.ApiContract;
23
23
  export const handler = lambder.getHandler();
24
24
  ```
25
25
 
26
- Registration chains onto the creation call: every `addApi` returns an instance
27
- carrying the contract so far, so the whole backend is one declaration and
28
- `lambder.ApiContract` is the accumulated type.
26
+ A handler returns its output, which is parsed through the output schema before
27
+ it is sent, and says no by throwing `refuse()`. Registration chains onto the
28
+ creation call: every `addApi` returns an instance carrying the contract so
29
+ far, so the whole backend is one declaration and `lambder.ApiContract` is the
30
+ accumulated type.
29
31
 
30
32
  The frontend imports that contract type and gets autocomplete, typed payloads
31
33
  and typed results with no hand-written client:
@@ -153,7 +155,7 @@ guide that matches what you are building. The full index lives in
153
155
  | [Configuration](./docs/configuration.md) | Every `initLambder().create({...})` option, in one reference |
154
156
  | [Routing and actions](./docs/routing.md) | Routes, matchers, hooks, fallbacks, crash reporting, and non-HTTP invocations |
155
157
  | [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `LambderApiRefusal`, the signature file |
156
- | [Responses](./docs/responses.md) | The render context, resolver methods, cookies, compression, ETag and the size cap |
158
+ | [Responses](./docs/responses.md) | The render context and its response tools (headers, cookies, log entries), the response builder routes and hooks use, compression, ETag and the size cap |
157
159
  | [Sessions](./docs/sessions.md) | Sessions over a store, cookie scope, secrets at rest, `dataRefresh`, the controller API |
158
160
  | [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
159
161
  | [Calling another lambda](./docs/invoke.md) | `LambderInvokeCaller`: invoking a Lambder app in another function, its contract, failures and compression |
@@ -185,25 +187,14 @@ framework:
185
187
  ## Versioning and changes
186
188
 
187
189
  Released versions and what each one changed are in
188
- [CHANGELOG.md](./CHANGELOG.md). The current major is v8, which came out of a
189
- review of 7.3.1: session writes that cannot undo a logout, API calls that must
190
- be JSON, output schemas applied at runtime, rate limits that count IPv6 callers
191
- by their /64 and custom keys after the guards, idempotency keys bound to the
192
- request they were first sent with, and the same behavior on every gateway and
193
- in the mock. Every break and what to do about it is in the 8.0.2 entry. The
194
- compiler finds most of them. Fourteen it cannot are named there: hand-built
195
- calls without a JSON Content-Type, hand-built answers without `apiVersion`,
196
- handlers whose payload does not match their output schema, code that decoded
197
- `ctx.path` itself, string routes that match case-sensitively, compression
198
- behind a REST API, two more IAM actions (`UpdateItem` on the session table,
199
- `GetItem` on the rate-limit table), custom-key limits charged after the
200
- guards, idempotency keys bound to the request they were first sent with,
201
- `errorMessage` always being an object, `credentials: true` needing named
202
- origins, template slots and `html` interpolations refused or checked in
203
- more attribute positions, `refreshSessionData()` throwing where it answered
204
- null, and `z.ZodType<T>` annotations leaving a field unchecked. Every live
205
- session is signed out once by the
206
- upgrade. An app still on v6 goes through the 7.0.0 entry first.
190
+ [CHANGELOG.md](./CHANGELOG.md). The current major is v9, which gives an API
191
+ handler one shape: it takes its context and returns its output, says no with
192
+ `refuse()`, and writes headers, cookies and log entries through the context,
193
+ while the response builder stays with routes, hooks and error handlers. Every
194
+ break and what to do about it is in the 9.0.1 entry, and the compiler finds
195
+ most of them. An app still on v7 goes through the 8.0.2 entry first, which
196
+ names the breaks of v8 the compiler cannot find, and one on v6 through the
197
+ 7.0.0 entry before that.
207
198
 
208
199
  ## Contributing
209
200
 
@@ -1,5 +1,6 @@
1
1
  import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
2
2
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
3
+ import { type LambderCookieOptions, type LambderClearCookieOptions } from "../shared/wire/LambderCookie.js";
3
4
  /**
4
5
  * The context the API core needs from whoever runs it. The server's render
5
6
  * context and the mock runtime's handler context both extend it; the
@@ -12,7 +13,7 @@ import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
12
13
  * - `responseHeaders` collects headers written during the call, applied
13
14
  * onto the answer by the pipeline.
14
15
  * - `logList` collects entries for the envelope's logList channel
15
- * (`res.logToApiResponse` on the server).
16
+ * (`ctx.logList.push(entry)` from a handler).
16
17
  */
17
18
  export type LambderApiCallContext<TSessionData = any> = {
18
19
  session: LambderSessionRecord<TSessionData> | null;
@@ -22,6 +23,35 @@ export type LambderApiCallContext<TSessionData = any> = {
22
23
  };
23
24
  /** A fresh call context: no session, no guard data, nothing pending. */
24
25
  export declare const createApiCallContext: <TSessionData = any>() => LambderApiCallContext<TSessionData>;
26
+ /**
27
+ * What a handler writes onto its answer beside the body: headers and cookies,
28
+ * collected on `responseHeaders` and applied to whatever answer the request
29
+ * ends with. On every context a handler receives, the server's and the
30
+ * mock's alike, since an API handler returns its output and has no response
31
+ * builder to write them on.
32
+ */
33
+ export type LambderResponseTools = {
34
+ /** Replaces a response header. */
35
+ setResponseHeader(key: string, value: string | string[]): void;
36
+ /** Appends a response header value (repeatable for one key). */
37
+ addResponseHeader(key: string, value: string): void;
38
+ /**
39
+ * Adds a Set-Cookie header. A function-form `domain` is resolved against
40
+ * the request host. Defaults: Path=/, SameSite=Lax, Secure, not HttpOnly,
41
+ * browser-session lifetime.
42
+ */
43
+ setCookie(name: string, value: string, options?: LambderCookieOptions): void;
44
+ /**
45
+ * Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and
46
+ * `path` it was set with: a cookie's identity is (name, domain, path), and
47
+ * a deletion under another scope deletes nothing.
48
+ */
49
+ clearCookie(name: string, options?: LambderClearCookieOptions): void;
50
+ };
51
+ /** The response tools of one context, writing into its own responseHeaders; `host` resolves a function-form cookie domain. */
52
+ export declare const responseToolsOf: (ctx: {
53
+ responseHeaders: LambderAnswerHeaders;
54
+ }, host: string) => LambderResponseTools;
25
55
  /**
26
56
  * Binds an adapter's tools onto one call context: `getters` run when read
27
57
  * (`ctx.sessionController` is built over the object it was read from),
@@ -1,4 +1,5 @@
1
1
  import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
2
+ import { serializeCookie, serializeClearCookie } from "../shared/wire/LambderCookie.js";
2
3
  /** A fresh call context: no session, no guard data, nothing pending. */
3
4
  export const createApiCallContext = () => ({
4
5
  session: null,
@@ -10,6 +11,13 @@ export const createApiCallContext = () => ({
10
11
  responseHeaders: new LambderAnswerHeaders(),
11
12
  logList: [],
12
13
  });
14
+ /** The response tools of one context, writing into its own responseHeaders; `host` resolves a function-form cookie domain. */
15
+ export const responseToolsOf = (ctx, host) => ({
16
+ setResponseHeader: (key, value) => { ctx.responseHeaders.set(key, value); },
17
+ addResponseHeader: (key, value) => { ctx.responseHeaders.add(key, value); },
18
+ setCookie: (name, value, options) => { ctx.responseHeaders.add("Set-Cookie", serializeCookie(name, value, options, host)); },
19
+ clearCookie: (name, options) => { ctx.responseHeaders.add("Set-Cookie", serializeClearCookie(name, options, host)); },
20
+ });
13
21
  /**
14
22
  * Binds an adapter's tools onto one call context: `getters` run when read
15
23
  * (`ctx.sessionController` is built over the object it was read from),
@@ -8,8 +8,8 @@ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRate
8
8
  * because the mock has none; when input is present, validation runs and the
9
9
  * handler sees the parsed payload. Output is part of the endpoint's
10
10
  * signature (apiSignatureOf), which is what a client's build is checked
11
- * against; the server's resolver also parses every payload a handler
12
- * answers through it before it is sent.
11
+ * against; the server also parses every output a handler returns through it
12
+ * before it is sent.
13
13
  */
14
14
  export type LambderApiDefinition = {
15
15
  name: string;
@@ -13,7 +13,7 @@ export type LambderApiEnvelopeConfig = LambderApiResponseConfig & {
13
13
  * plain success is `{ apiVersion, payload }` and nothing else; an empty
14
14
  * logList is omitted.
15
15
  */
16
- export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, crash, }?: LambderApiEnvelopeConfig) => LambderApiEnvelopeBody<T>;
16
+ export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, }?: LambderApiEnvelopeConfig) => LambderApiEnvelopeBody<T>;
17
17
  /** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
18
18
  export declare const envelopeAnswer: (envelope: LambderApiEnvelopeBody<unknown>, options?: {
19
19
  statusCode?: number;
@@ -4,8 +4,8 @@ import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
4
4
  * The one place the API envelope is written, and the one mapping from each
5
5
  * kind of protocol outcome onto an answer: a success, a thrown refusal, a
6
6
  * rejected input, an unknown name, a missing session, a stale client, a
7
- * malformed compressed payload, and the last-resort crash. The server's
8
- * res.api(), the mock runtime and the pipeline all build through these
7
+ * malformed compressed payload, and the last-resort crash. The server's API
8
+ * path and res.api(), the mock runtime and the pipeline all build through these
9
9
  * functions, so server and mock cannot drift on a single byte of the wire
10
10
  * format. Pure: no Node built-ins, no response classes.
11
11
  */
@@ -15,7 +15,7 @@ export const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
15
15
  * plain success is `{ apiVersion, payload }` and nothing else; an empty
16
16
  * logList is omitted.
17
17
  */
18
- export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, crash, } = {}) => ({
18
+ export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, } = {}) => ({
19
19
  apiVersion: apiVersion ?? null,
20
20
  payload,
21
21
  ...(versionExpired ? { versionExpired } : {}),
@@ -26,7 +26,6 @@ export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionE
26
26
  // flags above are booleans, where false and absent mean the same. An
27
27
  // errorMessage goes out as a message object whatever form it was written
28
28
  // in, so every reader meets one shape.
29
- ...(message !== undefined ? { message } : {}),
30
29
  ...(errorMessage !== undefined ? { errorMessage: refusalMessageOf(errorMessage) } : {}),
31
30
  ...(crash !== undefined ? { crash } : {}),
32
31
  ...(logList?.length ? { logList } : {}),
@@ -386,13 +386,11 @@ export class LambderApiIdempotencyEngine {
386
386
  }
387
387
  // A crash or a thrown refusal (LambderApiRefusal, refuse())
388
388
  // releases the claim so a retry retries. The rule is deliberate:
389
- // ANSWERS are stored and replayed, returned refusal envelopes
390
- // included; EXCEPTIONS are not, so a thrown refusal re-executes
391
- // on retry and the handler decides afresh. (On the server,
392
- // res.die.api() is an answer: the adapter catches it before it
393
- // reaches here.) The handler's own error is what gets rethrown,
394
- // since a cleanup error in its place would hide why the call
395
- // failed.
389
+ // ANSWERS (the output a handler returned) are stored and
390
+ // replayed; EXCEPTIONS are not, so a thrown refusal re-executes
391
+ // on retry and the handler decides afresh. The handler's own
392
+ // error is what gets rethrown, since a cleanup error in its place
393
+ // would hide why the call failed.
396
394
  try {
397
395
  await store.abandon(scopeKey, ownerToken);
398
396
  }
@@ -29,7 +29,6 @@ type FetchEndEventHandler = (params: {
29
29
  }) => void | Promise<void>;
30
30
  type ErrorHandler = (err: Error) => void | Promise<void>;
31
31
  type ValidationErrorHandler = (zodError: LambderValidationError) => (void | false) | Promise<(void | false)>;
32
- type MessageHandler = (message: LambderAppRefusalMessage | string) => void | Promise<void>;
33
32
  /** Handed the refusal as its message object, a plain-string errorMessage having been read as one (refusalMessageOf). */
34
33
  type ErrorMessageHandler = (message: LambderAppRefusalMessage) => void | Promise<void>;
35
34
  /** The logListHandler option: an answer's logList, success or failure, when it has entries. The invoke caller's onLogList, for a browser. */
@@ -41,7 +40,6 @@ export type LambderLogListHandler = (apiName: string, logList: unknown[]) => voi
41
40
  export type LambderCallOptions = LambderSharedCallOptions & {
42
41
  versionExpiredHandler?: NotifyHandler;
43
42
  sessionExpiredHandler?: NotifyHandler;
44
- messageHandler?: MessageHandler;
45
43
  errorMessageHandler?: ErrorMessageHandler;
46
44
  apiInputValidationErrorHandler?: ValidationErrorHandler;
47
45
  notAuthorizedHandler?: NotifyHandler;
@@ -73,7 +71,6 @@ type LambderCallerBaseOptions = {
73
71
  timeoutMs?: number;
74
72
  versionExpiredHandler?: NotifyHandler;
75
73
  sessionExpiredHandler?: NotifyHandler;
76
- messageHandler?: MessageHandler;
77
74
  errorMessageHandler?: ErrorMessageHandler;
78
75
  notAuthorizedHandler?: NotifyHandler;
79
76
  errorHandler?: ErrorHandler;
@@ -119,7 +116,6 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
119
116
  get isLoading(): boolean;
120
117
  private versionExpiredHandler?;
121
118
  private sessionExpiredHandler?;
122
- private messageHandler?;
123
119
  private errorMessageHandler?;
124
120
  private notAuthorizedHandler?;
125
121
  private errorHandler?;
@@ -33,7 +33,6 @@ export default class LambderCaller {
33
33
  get isLoading() { return this.fetchTrackerList.length > 0; }
34
34
  versionExpiredHandler;
35
35
  sessionExpiredHandler;
36
- messageHandler;
37
36
  errorMessageHandler;
38
37
  notAuthorizedHandler;
39
38
  errorHandler;
@@ -50,7 +49,7 @@ export default class LambderCaller {
50
49
  constructor(options) {
51
50
  // The conditional provider option is resolved per instantiation;
52
51
  // inside the class it is read through the plain shape.
53
- const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
52
+ const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
54
53
  this.apiPath = apiPath;
55
54
  this.apiVersion = apiVersion;
56
55
  this.apiSignatures = apiSignatures;
@@ -61,7 +60,6 @@ export default class LambderCaller {
61
60
  this.transport = transport ?? lambderFetchTransport({ cors: isCorsEnabled });
62
61
  this.versionExpiredHandler = versionExpiredHandler;
63
62
  this.sessionExpiredHandler = sessionExpiredHandler;
64
- this.messageHandler = messageHandler;
65
63
  this.errorMessageHandler = errorMessageHandler;
66
64
  this.notAuthorizedHandler = notAuthorizedHandler;
67
65
  this.errorHandler = errorHandler;
@@ -100,7 +98,6 @@ export default class LambderCaller {
100
98
  // Per-call overrides win over the constructor handlers.
101
99
  const versionExpiredHandler = options?.versionExpiredHandler ?? this.versionExpiredHandler;
102
100
  const sessionExpiredHandler = options?.sessionExpiredHandler ?? this.sessionExpiredHandler;
103
- const messageHandler = options?.messageHandler ?? this.messageHandler;
104
101
  const errorMessageHandler = options?.errorMessageHandler ?? this.errorMessageHandler;
105
102
  const notAuthorizedHandler = options?.notAuthorizedHandler ?? this.notAuthorizedHandler;
106
103
  const errorHandler = options?.errorHandler ?? this.errorHandler;
@@ -328,11 +325,6 @@ export default class LambderCaller {
328
325
  }
329
326
  return outcome;
330
327
  }
331
- // Presence, not truthiness: the envelope keeps a message an app
332
- // spelled out as the empty string, so the handler runs for it.
333
- if (data.message !== undefined && messageHandler) {
334
- await messageHandler(data.message);
335
- }
336
328
  if (!outcome.ok && outcome.reason === 'errorMessage') {
337
329
  if (errorMessageHandler && outcome.errorMessage !== undefined) {
338
330
  await errorMessageHandler(outcome.errorMessage);
@@ -21,7 +21,7 @@ import type { LambderApiRateLimitPolicyConfig, LambderRateLimitOption } from "..
21
21
  import type { LambderApiIdempotencyConfig } from "../api/LambderApiIdempotency.js";
22
22
  import type { LambderContractEntry, LambderJsonOutputOf, LambderMergeContract } from "../shared/wire/LambderApiContract.js";
23
23
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
24
- import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
24
+ import type { LambderReadonlyDeep, MaybePromise } from "../shared/util/LambderTypeUtilities.js";
25
25
  import { type LambderRouteHandler, type LambderInputValidationHandler, type LambderFallbackHandler, type LambderGlobalErrorHandler, type LambderAfterRenderHook, type LambderBeforeRenderHook, type LambderFallbackHook, type LambderActionTools, type LambderCreateOptions, type LambderGivenOption, type LambderHandler, type LambderNestedOptionChecks, type LambderNoExtraKeys, type LambderRequirableGuardsField, type LambderSessionEnabledInstance, type LambderSessionRouteHandler } from "./LambderCreateOptions.js";
26
26
  /** Everything `lambder/testing` may put under a built instance: the pipeline's stores, and the source its files are read from. */
27
27
  export type LambderInstanceBackends = LambderPipelineBackends & {
@@ -155,7 +155,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
155
155
  addRoute(condition: RegExp | LambderRouteConditionFn | LambderRouteMatcher, actionFn: LambderRouteHandler): this;
156
156
  addSessionRoute<TPath extends LambderRoutePath>(condition: TPath, actionFn: ((ctx: LambderSessionRenderContext<any, TSessionData, LambderPathParamsOf<TPath>, {}, _TRateLimitPolicies>, resolver: LambderResolver) => MaybePromise<LambderResponse>) & LambderSessionEnabledInstance<_TSessionsEnabled>): this;
157
157
  addSessionRoute(condition: RegExp | LambderRouteConditionFn | LambderRouteMatcher, actionFn: LambderSessionRouteHandler<TSessionData> & LambderSessionEnabledInstance<_TSessionsEnabled>): this;
158
- addApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.input<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.input<TInput>, false> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never>(name: TName, schema: {
158
+ addApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.input<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.input<TInput>, false> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never>(name: TName, schema: {
159
159
  input: TInput;
160
160
  output: TOutput;
161
161
  } & {
@@ -163,8 +163,18 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
163
163
  rateLimit?: TRateOpt;
164
164
  /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
165
165
  idempotency?: _TIdempotencyEnabled extends true ? TIdempotencyOpt : never;
166
- } & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt>, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, TSessionData, _TRateLimitPolicies>, resolver: LambderResolver<z.input<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, LambderMergeContract<_TContract, TName, LambderContractEntry<z.input<TInput>, LambderJsonOutputOf<z.output<TOutput>>, "public", LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired, _TSessionsEnabled>;
167
- addSessionApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.input<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.input<TInput>, true> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never>(name: TName, schema: {
166
+ /**
167
+ * Whether this API's answers are compressed for a caller that accepts it: "auto" (the default) when the
168
+ * body is large enough to gain, false never, true always. false suits an answer of base64 bytes: once
169
+ * compressed it leaves the function base64-encoded again, so it is no smaller under Lambda's response
170
+ * cap or to a lambda caller, and a browser gets it only about a quarter smaller for the time spent at
171
+ * both ends. A transport setting of this server's, not part of the API's contract.
172
+ */
173
+ compress?: boolean | "auto";
174
+ } & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt>,
175
+ /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
176
+ handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, TSessionData, _TRateLimitPolicies>) => MaybePromise<TAnswer>): Lambder<TSessionData, LambderMergeContract<_TContract, TName, LambderContractEntry<z.input<TInput>, LambderJsonOutputOf<z.output<TOutput>>, "public", LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired, _TSessionsEnabled>;
177
+ addSessionApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.input<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.input<TInput>, true> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never>(name: TName, schema: {
168
178
  input: TInput;
169
179
  output: TOutput;
170
180
  } & {
@@ -172,7 +182,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
172
182
  rateLimit?: TRateOpt;
173
183
  /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
174
184
  idempotency?: _TIdempotencyEnabled extends true ? TIdempotencyOpt : never;
175
- } & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt> & LambderSessionEnabledInstance<_TSessionsEnabled>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, _TRateLimitPolicies>, resolver: LambderResolver<z.input<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, LambderMergeContract<_TContract, TName, LambderContractEntry<z.input<TInput>, LambderJsonOutputOf<z.output<TOutput>>, "session", LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired, _TSessionsEnabled>;
185
+ /**
186
+ * Whether this API's answers are compressed for a caller that accepts it: "auto" (the default) when the
187
+ * body is large enough to gain, false never, true always. false suits an answer of base64 bytes: once
188
+ * compressed it leaves the function base64-encoded again, so it is no smaller under Lambda's response
189
+ * cap or to a lambda caller, and a browser gets it only about a quarter smaller for the time spent at
190
+ * both ends. A transport setting of this server's, not part of the API's contract.
191
+ */
192
+ compress?: boolean | "auto";
193
+ } & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt> & LambderSessionEnabledInstance<_TSessionsEnabled>,
194
+ /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
195
+ handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, _TRateLimitPolicies>) => MaybePromise<TAnswer>): Lambder<TSessionData, LambderMergeContract<_TContract, TName, LambderContractEntry<z.input<TInput>, LambderJsonOutputOf<z.output<TOutput>>, "session", LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired, _TSessionsEnabled>;
176
196
  /**
177
197
  * What registering an API is, for addApi and addSessionApi alike: the
178
198
  * checks that can refuse it, then its definition recorded (what
@@ -267,7 +287,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
267
287
  * and never moves when registrations are reordered.
268
288
  */
269
289
  apiOptionEntries(): LambderApiOptionEntries;
270
- getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
290
+ getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder;
271
291
  private getResolver;
272
292
  getHandler(): LambderHandler;
273
293
  /** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
@@ -365,12 +385,30 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
365
385
  private inputValidationRefusal;
366
386
  /**
367
387
  * One API call through the core: the pipeline runs the protocol steps and
368
- * calls back for the handler, whose LambderResponse (returned, or thrown
369
- * via res.die.*) becomes the answer the pipeline stores and hands back.
370
- * The context is the pipeline's context, so a session it fetched is on
371
- * ctx.session and the validated payload is on ctx.apiPayload when the
372
- * handler runs. The handler's resolver knows the API's output schema, so
373
- * every success payload is parsed through it before it is sent.
388
+ * calls back for the handler, whose returned output becomes the answer
389
+ * the pipeline stores and hands back. The context is the pipeline's
390
+ * context, so a session it fetched is on ctx.session and the validated
391
+ * payload is on ctx.apiPayload when the handler runs.
392
+ *
393
+ * The output goes out as the API's schema declares it. The type system
394
+ * accepts a value that carries more than the schema (a row read straight
395
+ * from a table is assignable to a narrower object type), and without the
396
+ * parse the extra fields, a password hash included, would reach the
397
+ * client. zod strips what the schema does not declare, fills its defaults
398
+ * and applies its transforms, so the wire and the idempotency store only
399
+ * see the declared shape. The handler returns the schema's input form, so
400
+ * a transform runs exactly once.
401
+ *
402
+ * An output the schema rejects is the handler breaking its contract,
403
+ * answered as a crash rather than sent (LambderApiOutputValidationError,
404
+ * which an idempotency key records as its answer, since the handler has
405
+ * already run). The parse is synchronous, so an output schema cannot be
406
+ * async: zod throws from a synchronous parse that meets an async
407
+ * refinement or transform, and a transform may throw of its own accord.
408
+ * Either throw becomes the same error, carrying what was thrown as its
409
+ * cause. Left to escape as it is, it would read as the handler crashing
410
+ * before its answer: the idempotency engine would release the key's claim
411
+ * and every retry would run the operation again.
374
412
  */
375
413
  private runApi;
376
414
  /** A thrown LambderApiRefusal (from a hook, say) as the structured API envelope: the core's one mapping. */
@@ -16,7 +16,8 @@ import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/Lambde
16
16
  import { apiSignatureOf } from "../api/LambderApiSignature.js";
17
17
  import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
18
18
  import { assertPlainData } from "../shared/util/assertPlainData.js";
19
- import { apiNotFoundAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
19
+ import { apiNotFoundAnswer, buildApiEnvelope, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
20
+ import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
20
21
  import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
21
22
  import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
22
23
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
@@ -263,12 +264,16 @@ export default class Lambder {
263
264
  return this;
264
265
  }
265
266
  // Typed API with Zod
266
- addApi(name, schema, handler) {
267
+ addApi(name, schema,
268
+ /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
269
+ handler) {
267
270
  this.registerApi(name, "public", schema, handler);
268
271
  return this;
269
272
  }
270
273
  // Typed Session API with Zod
271
- addSessionApi(name, schema, handler) {
274
+ addSessionApi(name, schema,
275
+ /** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
276
+ handler) {
272
277
  this.registerApi(name, "session", schema, handler);
273
278
  return this;
274
279
  }
@@ -303,7 +308,7 @@ export default class Lambder {
303
308
  this.apiDefinitions.set(name, definition);
304
309
  this.actionList.push({
305
310
  match: (ctx) => ctx.apiName === name ? {} : false,
306
- actionFn: (ctx) => this.runApi(ctx, definition, handler),
311
+ actionFn: (ctx) => this.runApi(ctx, definition, schema.output, schema.compress ?? "auto", handler),
307
312
  });
308
313
  }
309
314
  addHook(hookEvent, hookFn, priority = 0) {
@@ -700,8 +705,8 @@ export default class Lambder {
700
705
  catch (err) {
701
706
  response = (await this.answerThrown(err, ctx, resolver)).copy();
702
707
  }
703
- // Only what the hooks themselves wrote (res.setHeader inside a
704
- // hook) is left to apply, which leaves their overrides standing.
708
+ // Only what the hooks themselves wrote (ctx.setResponseHeader
709
+ // inside a hook) is left to apply, which leaves their overrides standing.
705
710
  // A hook that answered with a different response takes the whole
706
711
  // set instead: headers belong to the call, not to the response
707
712
  // that first carried them, so the call's session cookie must
@@ -883,48 +888,52 @@ export default class Lambder {
883
888
  }
884
889
  /**
885
890
  * One API call through the core: the pipeline runs the protocol steps and
886
- * calls back for the handler, whose LambderResponse (returned, or thrown
887
- * via res.die.*) becomes the answer the pipeline stores and hands back.
888
- * The context is the pipeline's context, so a session it fetched is on
889
- * ctx.session and the validated payload is on ctx.apiPayload when the
890
- * handler runs. The handler's resolver knows the API's output schema, so
891
- * every success payload is parsed through it before it is sent.
891
+ * calls back for the handler, whose returned output becomes the answer
892
+ * the pipeline stores and hands back. The context is the pipeline's
893
+ * context, so a session it fetched is on ctx.session and the validated
894
+ * payload is on ctx.apiPayload when the handler runs.
895
+ *
896
+ * The output goes out as the API's schema declares it. The type system
897
+ * accepts a value that carries more than the schema (a row read straight
898
+ * from a table is assignable to a narrower object type), and without the
899
+ * parse the extra fields, a password hash included, would reach the
900
+ * client. zod strips what the schema does not declare, fills its defaults
901
+ * and applies its transforms, so the wire and the idempotency store only
902
+ * see the declared shape. The handler returns the schema's input form, so
903
+ * a transform runs exactly once.
904
+ *
905
+ * An output the schema rejects is the handler breaking its contract,
906
+ * answered as a crash rather than sent (LambderApiOutputValidationError,
907
+ * which an idempotency key records as its answer, since the handler has
908
+ * already run). The parse is synchronous, so an output schema cannot be
909
+ * async: zod throws from a synchronous parse that meets an async
910
+ * refinement or transform, and a transform may throw of its own accord.
911
+ * Either throw becomes the same error, carrying what was thrown as its
912
+ * cause. Left to escape as it is, it would read as the handler crashing
913
+ * before its answer: the idempotency engine would release the key's claim
914
+ * and every retry would run the operation again.
892
915
  */
893
- async runApi(ctx, definition, handler) {
916
+ async runApi(ctx, definition, output, compress, handler) {
894
917
  const request = ctx.api;
895
918
  if (!request)
896
919
  throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
897
- const resolver = new LambderResolver({ files: this.files, apiVersion: this.apiVersion, ctx, apiOutput: definition.output });
898
- // What the handler produced, in both forms: the answer went to the
899
- // pipeline, and the response is kept so it can carry on unchanged.
900
- const handled = { output: null };
901
920
  const { answer } = await this.pipeline.run(request, ctx, definition, async () => {
902
921
  ctx.apiPayload = request.payload;
903
- let response;
922
+ const returned = await handler(ctx);
923
+ let parsed;
904
924
  try {
905
- response = await handler(ctx, resolver);
925
+ parsed = output.safeParse(returned);
906
926
  }
907
- catch (err) {
908
- // A thrown LambderResponse IS the response (res.die.*): an
909
- // answer like a returned one, stored and replayed alike.
910
- if (err instanceof LambderResponse)
911
- response = err;
912
- else
913
- throw err;
927
+ catch (thrown) {
928
+ throw new LambderApiOutputValidationError(definition.name, { thrown });
914
929
  }
915
- handled.output = { response, answer: answerFromResponse(response) };
916
- return handled.output.answer;
930
+ if (!parsed.success)
931
+ throw new LambderApiOutputValidationError(definition.name, { zodError: parsed.error });
932
+ return envelopeAnswer(buildApiEnvelope(this.apiVersion, parsed.data, { logList: ctx.logList }));
917
933
  });
918
- // When the pipeline answered with the handler's own answer, its
919
- // response carries on rather than a rebuild. An answer holds a Buffer
920
- // body base64-encoded (the plain shape the idempotency store
921
- // persists), and a response rebuilt from it would hand finalization a
922
- // base64 string it must pass through uncompressed. Identity decides,
923
- // since the pipeline may have answered with a stored replay or a
924
- // refusal instead.
925
- if (handled.output?.answer === answer)
926
- return handled.output.response;
927
- return responseFromAnswer(answer);
934
+ // The API's compress option, on whatever answer the call ended with:
935
+ // a replayed one comes back from its store without the hint.
936
+ return responseFromAnswer({ ...answer, compress });
928
937
  }
929
938
  /** A thrown LambderApiRefusal (from a hook, say) as the structured API envelope: the core's one mapping. */
930
939
  apiErrorResponse(err, ctx) {