lambder 5.1.2 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +8 -3
  2. package/dist/client/LambderCaller.d.ts +8 -83
  3. package/dist/client/LambderCaller.js +32 -64
  4. package/dist/client/LambderMSW.js +4 -4
  5. package/dist/client.d.ts +5 -3
  6. package/dist/client.js +3 -1
  7. package/dist/core/Lambder.d.ts +2 -2
  8. package/dist/core/Lambder.js +10 -3
  9. package/dist/core/LambderContext.d.ts +6 -5
  10. package/dist/core/LambderContext.js +20 -12
  11. package/dist/core/LambderResolver.d.ts +7 -5
  12. package/dist/core/LambderResolver.js +7 -3
  13. package/dist/core/LambderResponseBuilder.d.ts +17 -3
  14. package/dist/core/LambderResponseBuilder.js +2 -2
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -2
  17. package/dist/invoke/LambderInvokeCaller.d.ts +322 -0
  18. package/dist/invoke/LambderInvokeCaller.js +654 -0
  19. package/dist/policies/LambderApiGuards.d.ts +8 -8
  20. package/dist/policies/LambderApiRateLimits.d.ts +3 -3
  21. package/dist/session/LambderSessionManager.d.ts +5 -1
  22. package/dist/session/LambderSessionManager.js +26 -13
  23. package/dist/shared/LambderApiContract.d.ts +9 -0
  24. package/dist/shared/LambderApiOutcome.d.ts +69 -0
  25. package/dist/shared/LambderApiOutcome.js +79 -0
  26. package/dist/shared/LambderCallOptions.d.ts +71 -0
  27. package/dist/shared/LambderCallOptions.js +16 -0
  28. package/dist/shared/LambderCompressionCodec.d.ts +45 -8
  29. package/dist/shared/LambderCompressionCodec.js +44 -14
  30. package/dist/shared/LambderCrashDetail.d.ts +48 -0
  31. package/dist/shared/LambderCrashDetail.js +78 -0
  32. package/dist/shared/LambderRequestPayload.d.ts +46 -23
  33. package/dist/shared/LambderRequestPayload.js +47 -31
  34. package/dist/stores/LambderDdbCache.d.ts +7 -2
  35. package/dist/stores/LambderDdbCache.js +30 -12
  36. package/dist/stores/LambderDdbIdempotency.d.ts +7 -2
  37. package/dist/stores/LambderDdbIdempotency.js +26 -10
  38. package/dist/stores/LambderDdbRateLimiter.d.ts +7 -2
  39. package/dist/stores/LambderDdbRateLimiter.js +16 -4
  40. package/dist/stores/LambderDdbSdk.d.ts +20 -0
  41. package/dist/stores/LambderDdbSdk.js +31 -0
  42. package/package.json +8 -3
package/README.md CHANGED
@@ -53,6 +53,9 @@ const company = await caller.api("getCompany", { slug: "acme" });
53
53
  cookies, and a guard against Lambda's response size cap.
54
54
  - **Hooks and actions.** Lifecycle hooks, plus `addAction()` for the non-HTTP
55
55
  invocations (EventBridge, SQS, custom events) the same function receives.
56
+ - **Lambda to lambda calls.** `LambderInvokeCaller` invokes a Lambder app in
57
+ another function directly, with no API Gateway in between, typed from the
58
+ callee's own contract and carrying its refusals, crash detail and logs back.
56
59
  - **Frontend hosting.** Serve a build from a folder, S3, R2 or any HTTP
57
60
  origin, with an app shell rendered through a build-pipeline-safe template
58
61
  engine.
@@ -74,8 +77,9 @@ import needs:
74
77
  | --- | --- |
75
78
  | `lambder/client` (browser, shared isomorphic code) | `zod` |
76
79
  | `lambder` on AWS Lambda (`nodejs18.x` and later) | `zod`. The runtime already provides the AWS SDK v3, so mark the SDK packages as dev dependencies and keep them out of the deployment package |
77
- | `lambder` anywhere else (a long-running server, a container, local tests) | `zod`, `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
80
+ | `lambder` anywhere else (a long-running server, a container, local tests) | `zod`, plus `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` when sessions or the DynamoDB stores are used; both are loaded on the first table access, so an app that uses neither needs neither |
78
81
  | `LambderS3FileSource` | `@aws-sdk/client-s3`, loaded on first read |
82
+ | `LambderInvokeCaller` | `@aws-sdk/client-lambda`, loaded on the first call |
79
83
  | `lambder/testing` | `msw` |
80
84
 
81
85
  The SDK and its `@smithy` tree are roughly 21MB installed, which is why they are
@@ -101,8 +105,8 @@ tree-shaking.
101
105
 
102
106
  Source layout mirrors this: `src/core/` (request pipeline), `src/policies/`
103
107
  (declarative rate limits, guards, idempotency), `src/session/`, `src/stores/`
104
- (DynamoDB primitives), `src/client/`, and `src/shared/` (isomorphic modules
105
- both entries re-export).
108
+ (DynamoDB primitives), `src/client/`, `src/invoke/` (the lambda-to-lambda
109
+ caller), and `src/shared/` (isomorphic modules both entries re-export).
106
110
 
107
111
  ## Documentation
108
112
 
@@ -119,6 +123,7 @@ guide that matches what you are building. The full index lives in
119
123
  | [Responses](./docs/responses.md) | The render context, resolver methods, cookies, compression, ETag and the size cap |
120
124
  | [Sessions](./docs/sessions.md) | DynamoDB sessions, cookie scope, secrets at rest, `dataRefresh`, the controller API |
121
125
  | [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
126
+ | [Calling another lambda](./docs/invoke.md) | `LambderInvokeCaller`: invoking a Lambder app in another function, its contract, failures and compression |
122
127
  | [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression |
123
128
  | [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
124
129
  | [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
@@ -1,57 +1,9 @@
1
1
  import { type LambderRequestCompressionOption } from '../shared/LambderRequestPayload.js';
2
- import type { LambderApiResponse } from '../shared/LambderApiContract.js';
3
2
  import type { ApiContractShape } from '../shared/LambderApiContract.js';
4
- import type { z } from "zod";
5
- type IsAny<T> = 0 extends (1 & T) ? true : false;
6
- type GuardInputsOf<TEntry> = TEntry extends {
7
- guardInputs: infer G;
8
- } ? G : never;
9
- /** Input type of guard G on one contract entry; never when that API does not declare it. */
10
- type GuardInputOf<TEntry, G extends string> = GuardInputsOf<TEntry> extends infer I ? (G extends keyof I ? I[G] : never) : never;
11
- /**
12
- * What guardInputsProvider returns: for every provided guard name, the value
13
- * the contract's APIs expect for it (a union across APIs when they differ).
14
- * Naming a guard no API declares in guardInput mode resolves to never, so a
15
- * typo fails the provider's return type instead of going missing at runtime.
16
- */
17
- export type LambderProvidedGuardInputs<TContract, TProvided extends string> = IsAny<TContract> extends true ? Record<TProvided, unknown> : {
18
- [G in TProvided]: {
19
- [K in keyof TContract]: GuardInputOf<TContract[K], G>;
20
- }[keyof TContract];
21
- };
22
- /**
23
- * Supplies guardInputs for every call from one place (the organization the
24
- * UI is on, a device token), keyed by guard name; per-call guardInputs
25
- * merge on top. Name the guards it covers in the caller's second type
26
- * parameter, `new LambderCaller<Contract, "orgPermission">`, and calls to
27
- * APIs whose guardInput guards are all covered no longer require the
28
- * options argument. May be async; a throw fails the call as an unknown
29
- * error before anything is sent.
30
- */
31
- export type LambderGuardInputsProvider<TContract, TProvided extends string> = (apiName: keyof TContract & string) => LambderProvidedGuardInputs<TContract, TProvided> | Promise<LambderProvidedGuardInputs<TContract, TProvided>>;
32
- /** Optional until the caller names provided guards: naming them without a provider would send nothing. */
33
- type GuardInputsProviderOption<TContract, TProvided extends string> = [
34
- TProvided
35
- ] extends [never] ? {
36
- guardInputsProvider?: LambderGuardInputsProvider<TContract, TProvided>;
37
- } : {
38
- guardInputsProvider: LambderGuardInputsProvider<TContract, TProvided>;
39
- };
40
- /** An API's guardInput guards the provider does not cover: those the call must still pass. */
41
- type RemainingGuardInputs<TEntry, TProvided extends string> = Omit<GuardInputsOf<TEntry>, TProvided>;
42
- /**
43
- * The options argument: optional normally, REQUIRED (with guardInputs) when
44
- * the API's contract declares guardInput-mode guards the provider does not
45
- * cover, so forgetting to send a guard's value is a compile error at the
46
- * call site. Provided guards may still be overridden per call.
47
- */
48
- type CallOptionsArg<TContract, TApiName, TProvided extends string> = IsAny<TContract> extends true ? [options?: LambderCallOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: LambderCallOptions] : [keyof RemainingGuardInputs<TContract[TApiName], TProvided>] extends [never] ? [options?: LambderCallOptions & {
49
- guardInputs?: Partial<GuardInputsOf<TContract[TApiName]>>;
50
- }] : [
51
- options: LambderCallOptions & {
52
- guardInputs: RemainingGuardInputs<TContract[TApiName], TProvided> & Partial<GuardInputsOf<TContract[TApiName]>>;
53
- }
54
- ] : [options?: LambderCallOptions];
3
+ import { type LambderApiOutcome, type LambderValidationError } from '../shared/LambderApiOutcome.js';
4
+ import { type LambderCallOptionsArg, type LambderGuardInputsProviderOption } from '../shared/LambderCallOptions.js';
5
+ export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError } from '../shared/LambderApiOutcome.js';
6
+ export type { LambderProvidedGuardInputs, LambderGuardInputsProvider } from '../shared/LambderCallOptions.js';
55
7
  type VoidFunction = () => void | Promise<void>;
56
8
  type FetchTracker = {
57
9
  apiName: string;
@@ -73,7 +25,7 @@ type FetchEndEventHandler = (params: {
73
25
  activeFetchList: FetchTracker[];
74
26
  }) => void | Promise<void>;
75
27
  type ErrorHandler = (err: Error) => void | Promise<void>;
76
- type ValidationErrorHandler = (zodError: z.ZodError) => (void | false) | Promise<(void | false)>;
28
+ type ValidationErrorHandler = (zodError: LambderValidationError) => (void | false) | Promise<(void | false)>;
77
29
  type MessageHandler = (message: any) => void | Promise<void>;
78
30
  /** One logical operation's rotating idempotency key: see LambderCaller.createIdempotencyKeyScope(). */
79
31
  export type LambderIdempotencyKeyScope = {
@@ -82,32 +34,6 @@ export type LambderIdempotencyKeyScope = {
82
34
  /** Call after a confirmed success: the next operation is a new intent. Returns the new key. */
83
35
  rotate(): string;
84
36
  };
85
- export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
86
- /**
87
- * Discriminated result of an API call: `ok: true` carries the payload, every
88
- * failure carries a machine-readable reason, so "the server returned null"
89
- * and "the request failed" are never conflated.
90
- */
91
- export type LambderApiOutcome<T> = {
92
- ok: true;
93
- payload: T | null | undefined;
94
- response: LambderApiResponse<T>;
95
- } | {
96
- ok: false;
97
- reason: LambderApiFailureReason;
98
- /** HTTP status, when a response was received. */
99
- status?: number;
100
- /** Envelope errorMessage, when the server provided one. */
101
- errorMessage?: any;
102
- /** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it). */
103
- retryAfterSeconds?: number;
104
- /** Underlying Error for network/timeout/server/unknown failures. */
105
- error?: Error;
106
- /** Zod issue detail for 'validation'. */
107
- zodError?: z.ZodError;
108
- /** The parsed envelope, when one was received (protocol-level failures). */
109
- response?: LambderApiResponse<T>;
110
- };
111
37
  /** Per-call options: request extras plus overrides for every constructor handler. */
112
38
  export type LambderCallOptions = {
113
39
  headers?: Record<string, any>;
@@ -179,7 +105,7 @@ type LambderCallerBaseOptions = {
179
105
  requestCompression?: LambderRequestCompressionOption;
180
106
  };
181
107
  /** Constructor options: the base options plus guardInputsProvider, mandatory once TProvided names guards. */
182
- export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions & GuardInputsProviderOption<TContract, TProvided>;
108
+ export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions & LambderGuardInputsProviderOption<TContract, TProvided>;
183
109
  /**
184
110
  * @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
185
111
  * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
@@ -238,8 +164,7 @@ export default class LambderCaller<TContract extends ApiContractShape = any, TPr
238
164
  * Full-fidelity call: resolves to a discriminated LambderApiOutcome
239
165
  * instead of collapsing every failure to null. Never throws.
240
166
  */
241
- apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName, TProvidedGuards>): Promise<LambderApiOutcome<TOutput>>;
167
+ apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: LambderCallOptionsArg<TContract, TApiName, TProvidedGuards, LambderCallOptions>): Promise<LambderApiOutcome<TOutput>>;
242
168
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
243
- api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName, TProvidedGuards>): Promise<TOutput | null | undefined>;
169
+ api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: LambderCallOptionsArg<TContract, TApiName, TProvidedGuards, LambderCallOptions>): Promise<TOutput | null | undefined>;
244
170
  }
245
- export {};
@@ -1,6 +1,8 @@
1
1
  import Cookies from 'js-cookie';
2
- import { compressPayloadJson, isRequestCompressionAvailable, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from '../shared/LambderRequestPayload.js';
2
+ import { compressPayloadGzip, isRequestCompressionAvailable, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from '../shared/LambderRequestPayload.js';
3
3
  import { resolveCompressionOption } from '../shared/LambderCompressionOption.js';
4
+ import { resolveApiOutcome } from '../shared/LambderApiOutcome.js';
5
+ import { mergeGuardInputs, } from '../shared/LambderCallOptions.js';
4
6
  /**
5
7
  * @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
6
8
  * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
@@ -180,9 +182,7 @@ export default class LambderCaller {
180
182
  const providedGuardInputs = this.guardInputsProvider
181
183
  ? await this.guardInputsProvider(apiName)
182
184
  : undefined;
183
- const guardInputs = providedGuardInputs !== undefined || options?.guardInputs !== undefined
184
- ? { ...providedGuardInputs, ...options?.guardInputs }
185
- : undefined;
185
+ const guardInputs = mergeGuardInputs(providedGuardInputs, options?.guardInputs);
186
186
  // Compressed when enabled and the payload's JSON reaches the
187
187
  // threshold; `compressRequest` overrides both ways, and a runtime
188
188
  // without CompressionStream always sends the payload plainly.
@@ -192,7 +192,7 @@ export default class LambderCaller {
192
192
  : options?.compressRequest === false ? null
193
193
  : this.requestCompression?.minBytes ?? null;
194
194
  const compressedPayload = compressionMinBytes !== null && payload !== undefined && isRequestCompressionAvailable()
195
- ? await compressPayloadJson(JSON.stringify(payload), compressionMinBytes)
195
+ ? await compressPayloadGzip(JSON.stringify(payload), compressionMinBytes)
196
196
  : null;
197
197
  let res;
198
198
  try {
@@ -218,79 +218,47 @@ export default class LambderCaller {
218
218
  await reportError(wrappedError);
219
219
  return { ok: false, reason: timedOut ? 'timeout' : 'network', error: wrappedError };
220
220
  }
221
- if (res.status >= 500) {
222
- // Lambder's own 500 fallback is a JSON envelope, but custom
223
- // error handlers may answer text/HTML: parse defensively.
224
- let errorMessage;
225
- try {
226
- const bodyText = await res.text();
227
- try {
228
- errorMessage = JSON.parse(bodyText)?.errorMessage;
229
- }
230
- catch { /* not an envelope */ }
231
- }
232
- catch { /* body unavailable */ }
233
- const wrappedError = new Error("Request failed: " + res.status + " - " + res.statusText);
234
- await fetchEnded(wrappedError);
235
- await reportError(wrappedError);
236
- return { ok: false, reason: 'server', status: res.status, errorMessage, error: wrappedError };
221
+ // The reading of the answer is shared with LambderInvokeCaller;
222
+ // only what to do about each outcome is this caller's.
223
+ const outcome = await resolveApiOutcome({
224
+ status: res.status,
225
+ statusText: res.statusText,
226
+ header: (name) => res.headers?.get?.(name) ?? null,
227
+ json: () => res.json(),
228
+ text: () => res.text(),
229
+ });
230
+ if (!outcome.ok && outcome.reason === 'server') {
231
+ await fetchEnded(outcome.error);
232
+ await reportError(outcome.error);
233
+ return outcome;
237
234
  }
238
- if (res.status === 422) {
239
- // A 422 without Lambder's validation body (e.g. a proxy's
240
- // error page) is a server failure, not a validation result.
241
- let zodError;
242
- try {
243
- zodError = (await res.json())?.zodError;
244
- }
245
- catch { /* not JSON */ }
246
- if (zodError === undefined) {
247
- const wrappedError = new Error("Request failed: 422 without a validation body");
248
- await fetchEnded(wrappedError);
249
- await reportError(wrappedError);
250
- return { ok: false, reason: 'server', status: res.status, error: wrappedError };
251
- }
235
+ if (!outcome.ok && outcome.reason === 'validation') {
252
236
  await fetchEnded(null);
253
237
  if (apiInputValidationErrorHandler) {
254
- await apiInputValidationErrorHandler(zodError);
238
+ await apiInputValidationErrorHandler(outcome.zodError);
255
239
  }
256
240
  else {
257
- await reportError(new Error("API Input Validation Error", { cause: zodError }));
241
+ await reportError(new Error("API Input Validation Error", { cause: outcome.zodError }));
258
242
  }
259
- return { ok: false, reason: 'validation', status: res.status, zodError };
260
- }
261
- // Retry-After (delta-seconds) rides every refusal that knows its
262
- // reset time, e.g. a rate limit; absent or unreadable is undefined.
263
- const retryAfterValue = Number(res.headers.get("retry-after") ?? NaN);
264
- const retryAfter = Number.isFinite(retryAfterValue) && retryAfterValue >= 0 ? { retryAfterSeconds: retryAfterValue } : {};
265
- let data;
266
- try {
267
- data = await res.json();
268
- if (data === null || typeof data !== "object")
269
- throw new Error("Response is not an object");
270
- }
271
- catch (err) {
272
- // A non-envelope body (e.g. an HTML error page) is a server failure.
273
- const wrappedError = new Error("Request failed: response is not a valid API envelope (status " + res.status + ")", { cause: err });
274
- await fetchEnded(wrappedError);
275
- await reportError(wrappedError);
276
- return { ok: false, reason: 'server', status: res.status, error: wrappedError };
243
+ return outcome;
277
244
  }
245
+ const data = outcome.response;
278
246
  await fetchEnded(data);
279
247
  if (data.logList?.length) {
280
248
  for (const record of data.logList) {
281
249
  console.log("[lambder]", record);
282
250
  }
283
251
  }
284
- if (data.versionExpired) {
252
+ if (!outcome.ok && outcome.reason === 'versionExpired') {
285
253
  if (versionExpiredHandler) {
286
254
  await versionExpiredHandler();
287
255
  }
288
256
  else {
289
257
  await reportError(new Error("Version Expired; Please refresh;"));
290
258
  }
291
- return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
259
+ return outcome;
292
260
  }
293
- if (data.sessionExpired) {
261
+ if (!outcome.ok && outcome.reason === 'sessionExpired') {
294
262
  this.clearSessionCookies();
295
263
  if (sessionExpiredHandler) {
296
264
  await sessionExpiredHandler();
@@ -298,27 +266,27 @@ export default class LambderCaller {
298
266
  else {
299
267
  await reportError(new Error("Session Expired; Please log in again;"));
300
268
  }
301
- return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
269
+ return outcome;
302
270
  }
303
- if (data.notAuthorized) {
271
+ if (!outcome.ok && outcome.reason === 'notAuthorized') {
304
272
  if (notAuthorizedHandler) {
305
273
  await notAuthorizedHandler();
306
274
  }
307
275
  else {
308
276
  await reportError(new Error("Not Authorized;"));
309
277
  }
310
- return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
278
+ return outcome;
311
279
  }
312
280
  if (data.message && messageHandler) {
313
281
  await messageHandler(data.message);
314
282
  }
315
- if (data.errorMessage) {
283
+ if (!outcome.ok && outcome.reason === 'errorMessage') {
316
284
  if (errorMessageHandler) {
317
285
  await errorMessageHandler(data.errorMessage);
318
286
  }
319
- return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
287
+ return outcome;
320
288
  }
321
- return { ok: true, payload: data.payload, response: data };
289
+ return outcome;
322
290
  }
323
291
  catch (err) {
324
292
  // Escape hatch for anything above (typically an app handler throwing):
@@ -1,4 +1,4 @@
1
- import { COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, decompressPayloadJson } from '../shared/LambderRequestPayload.js';
1
+ import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, decompressPayloadGzip } from '../shared/LambderRequestPayload.js';
2
2
  export default class LambderMSW {
3
3
  apiPath;
4
4
  apiVersion;
@@ -46,15 +46,15 @@ export default class LambderMSW {
46
46
  // A caller with requestCompression on sends the payload gzipped;
47
47
  // mock handlers still receive the payload itself, and the wire
48
48
  // fields are consumed the way the server consumes them.
49
- if (typeof body[COMPRESSED_PAYLOAD_FIELD] === 'string') {
49
+ if (typeof body[COMPRESSED_PAYLOAD_GZ_FIELD] === 'string') {
50
50
  try {
51
- body.payload = await decompressPayloadJson(body[COMPRESSED_PAYLOAD_FIELD]);
51
+ body.payload = await decompressPayloadGzip(body[COMPRESSED_PAYLOAD_GZ_FIELD]);
52
52
  }
53
53
  catch {
54
54
  console.warn("LambderMSW: Failed to decompress the request payload");
55
55
  return;
56
56
  }
57
- delete body[COMPRESSED_PAYLOAD_FIELD];
57
+ delete body[COMPRESSED_PAYLOAD_GZ_FIELD];
58
58
  delete body[COMPRESSED_PAYLOAD_BYTES_FIELD];
59
59
  }
60
60
  try {
package/dist/client.d.ts CHANGED
@@ -7,12 +7,14 @@
7
7
  * the root entry (`"lambder"`) is the server surface.
8
8
  */
9
9
  export { default as LambderCaller } from "./client/LambderCaller.js";
10
- export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
10
+ export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
11
11
  export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
12
12
  export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
13
13
  export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
14
- export { compressPayloadJson, decompressPayloadJson, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
15
- export type { LambderCompressedPayload, LambderRequestCompressionOption, LambderRequestCompressionSettings, } from "./shared/LambderRequestPayload.js";
14
+ export { describeCrash, errorFromCrashDetail } from "./shared/LambderCrashDetail.js";
15
+ export type { LambderCrashDetail, LambderCrashCause } from "./shared/LambderCrashDetail.js";
16
+ export { compressPayloadGzip, decompressPayloadGzip, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
17
+ export type { LambderCompressedGzipPayload, LambderCompressedBrotliPayload, LambderRequestCompressionOption, LambderRequestCompressionSettings, } from "./shared/LambderRequestPayload.js";
16
18
  export { resolveCompressionOption } from "./shared/LambderCompressionOption.js";
17
19
  export type { LambderCompressionOption, LambderCompressionSettingsBase } from "./shared/LambderCompressionOption.js";
18
20
  export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
package/dist/client.js CHANGED
@@ -11,8 +11,10 @@ export { default as LambderCaller } from "./client/LambderCaller.js";
11
11
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
12
12
  // in the browser they are plain Errors).
13
13
  export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
14
+ // A crash described for a caller allowed to see it (the envelope's `crash` field; pure, no Node built-ins).
15
+ export { describeCrash, errorFromCrashDetail } from "./shared/LambderCrashDetail.js";
14
16
  // Request payload compression (browser-safe: gzip via CompressionStream, no Node built-ins).
15
- export { compressPayloadJson, decompressPayloadJson, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
17
+ export { compressPayloadGzip, decompressPayloadGzip, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
16
18
  // The compression option vocabulary every Lambder surface shares (pure: no zlib).
17
19
  export { resolveCompressionOption } from "./shared/LambderCompressionOption.js";
18
20
  // Type-safe templating (tagged templates with auto-escaping)
@@ -297,7 +297,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
297
297
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
298
298
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
299
299
  use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
300
- addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
300
+ addApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
301
301
  input: TInput;
302
302
  output: TOutput;
303
303
  } & {
@@ -308,7 +308,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
308
308
  ttlSeconds?: number;
309
309
  }) : never;
310
310
  } & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt>, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
311
- addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
311
+ addSessionApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
312
312
  input: TInput;
313
313
  output: TOutput;
314
314
  } & {
@@ -11,7 +11,7 @@ import { LambderFiles } from "./LambderFiles.js";
11
11
  import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
12
12
  import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
13
13
  import { createContext, isV2HttpEvent, restoreCompressedApiPayload } from "./LambderContext.js";
14
- import { DEFAULT_MAX_REQUEST_PAYLOAD_BYTES } from "../shared/LambderRequestPayload.js";
14
+ import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/LambderRequestPayload.js";
15
15
  /**
16
16
  * Main Lambder class for building type-safe serverless APIs. Create
17
17
  * instances with initLambder<SessionData>().create({...}) (see below): the
@@ -85,7 +85,7 @@ export default class Lambder {
85
85
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
86
86
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
87
87
  };
88
- this.maxRequestPayloadBytes = options.maxRequestPayloadBytes ?? DEFAULT_MAX_REQUEST_PAYLOAD_BYTES;
88
+ this.maxRequestPayloadBytes = options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES;
89
89
  if (!Number.isSafeInteger(this.maxRequestPayloadBytes) || this.maxRequestPayloadBytes <= 0) {
90
90
  throw new Error("maxRequestPayloadBytes must be a positive integer");
91
91
  }
@@ -211,7 +211,14 @@ export default class Lambder {
211
211
  async inputValidationRefusal(ctx, resolver, zodError) {
212
212
  if (this.apiInputValidationErrorHandler)
213
213
  return await this.apiInputValidationErrorHandler(ctx, resolver, zodError);
214
- return resolver.json({ error: "Input validation failed", zodError }, { statusCode: 422 });
214
+ // Spelled out rather than serialized as-is: zod 4 keeps `issues` as a
215
+ // non-enumerable property, so JSON.stringify(zodError) would carry the
216
+ // issues only inside the message string, and a client's validation
217
+ // handler would receive a ZodError with nothing to branch on.
218
+ return resolver.json({
219
+ error: "Input validation failed",
220
+ zodError: { name: zodError.name, message: zodError.message, issues: zodError.issues },
221
+ }, { statusCode: 422 });
215
222
  }
216
223
  /** Registration-time checks shared by addApi/addSessionApi. */
217
224
  assertApiRegistration(name, mode, options) {
@@ -66,11 +66,12 @@ export type LambderRestorePayloadResult = {
66
66
  message: string;
67
67
  };
68
68
  /**
69
- * Restores a request payload the caller sent gzipped (`payloadGz` +
70
- * `payloadBytes`) onto ctx.post.payload and ctx.apiPayload, so every later
71
- * stage (rate-limit key slices, guards, input validation, the handler) reads
72
- * an ordinary payload and needs no awareness of the wire format. A request
73
- * that sent a plain payload passes through untouched.
69
+ * Restores a request payload the caller sent compressed (`payloadGz` or
70
+ * `payloadBr`, beside `payloadBytes`) onto ctx.post.payload and
71
+ * ctx.apiPayload, so every later stage (rate-limit key slices, guards, input
72
+ * validation, the handler) reads an ordinary payload and needs no awareness
73
+ * of the wire format. The field names the encoding; a request carrying both
74
+ * is refused. A request that sent a plain payload passes through untouched.
74
75
  *
75
76
  * Every failure answers with a message instead of throwing: a malformed body
76
77
  * is a client error, not a crash. The declared byte length both bounds the
@@ -1,6 +1,6 @@
1
1
  import cookieParser from "cookie";
2
- import { COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, } from "../shared/LambderRequestPayload.js";
3
- import { restoreBoundedText, LambderCompressionError, LAMBDER_RESTORE_FAILURES, } from "../shared/LambderCompressionCodec.js";
2
+ import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, } from "../shared/LambderRequestPayload.js";
3
+ import { restoreText, LambderCompressionError, LAMBDER_RESTORE_FAILURES, } from "../shared/LambderCompressionCodec.js";
4
4
  /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
5
5
  export const isV2HttpEvent = (event) => !!event && typeof event === "object"
6
6
  && event.version === "2.0"
@@ -101,11 +101,12 @@ export const createContext = (event, lambdaContext, apiPath) => {
101
101
  };
102
102
  };
103
103
  /**
104
- * Restores a request payload the caller sent gzipped (`payloadGz` +
105
- * `payloadBytes`) onto ctx.post.payload and ctx.apiPayload, so every later
106
- * stage (rate-limit key slices, guards, input validation, the handler) reads
107
- * an ordinary payload and needs no awareness of the wire format. A request
108
- * that sent a plain payload passes through untouched.
104
+ * Restores a request payload the caller sent compressed (`payloadGz` or
105
+ * `payloadBr`, beside `payloadBytes`) onto ctx.post.payload and
106
+ * ctx.apiPayload, so every later stage (rate-limit key slices, guards, input
107
+ * validation, the handler) reads an ordinary payload and needs no awareness
108
+ * of the wire format. The field names the encoding; a request carrying both
109
+ * is refused. A request that sent a plain payload passes through untouched.
109
110
  *
110
111
  * Every failure answers with a message instead of throwing: a malformed body
111
112
  * is a client error, not a crash. The declared byte length both bounds the
@@ -114,11 +115,18 @@ export const createContext = (event, lambdaContext, apiPath) => {
114
115
  */
115
116
  export const restoreCompressedApiPayload = async (ctx, maxPayloadBytes) => {
116
117
  const post = ctx.post;
117
- const compressed = post[COMPRESSED_PAYLOAD_FIELD];
118
- if (compressed === undefined)
118
+ const hasGzip = post[COMPRESSED_PAYLOAD_GZ_FIELD] !== undefined;
119
+ const hasBrotli = post[COMPRESSED_PAYLOAD_BR_FIELD] !== undefined;
120
+ if (!hasGzip && !hasBrotli)
119
121
  return { ok: true };
122
+ if (hasGzip && hasBrotli) {
123
+ return { ok: false, message: `Request carries both ${COMPRESSED_PAYLOAD_GZ_FIELD} and ${COMPRESSED_PAYLOAD_BR_FIELD}; send one.` };
124
+ }
125
+ const field = hasGzip ? COMPRESSED_PAYLOAD_GZ_FIELD : COMPRESSED_PAYLOAD_BR_FIELD;
126
+ const encoding = hasGzip ? "gzip" : "br";
127
+ const compressed = post[field];
120
128
  if (typeof compressed !== "string") {
121
- return { ok: false, message: `Request ${COMPRESSED_PAYLOAD_FIELD} must be a base64 string.` };
129
+ return { ok: false, message: `Request ${field} must be a base64 string.` };
122
130
  }
123
131
  const declaredBytes = post[COMPRESSED_PAYLOAD_BYTES_FIELD];
124
132
  if (typeof declaredBytes !== "number" || !Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
@@ -131,7 +139,7 @@ export const restoreCompressedApiPayload = async (ctx, maxPayloadBytes) => {
131
139
  // ones a stored record gets; only the wording of the refusal is ours.
132
140
  let json;
133
141
  try {
134
- json = await restoreBoundedText(Buffer.from(compressed, "base64"), declaredBytes, "gzip");
142
+ json = await restoreText(Buffer.from(compressed, "base64"), encoding, { declaredBytes });
135
143
  }
136
144
  catch (err) {
137
145
  const reason = err instanceof LambderCompressionError ? err.reason : null;
@@ -146,7 +154,7 @@ export const restoreCompressedApiPayload = async (ctx, maxPayloadBytes) => {
146
154
  catch {
147
155
  return { ok: false, message: "Compressed request payload is not valid JSON." };
148
156
  }
149
- delete post[COMPRESSED_PAYLOAD_FIELD];
157
+ delete post[field];
150
158
  delete post[COMPRESSED_PAYLOAD_BYTES_FIELD];
151
159
  post.payload = payload;
152
160
  ctx.apiPayload = payload;
@@ -1,4 +1,4 @@
1
- import LambderResponseBuilder, { type LambderApiResponseConfig, type LambderResponseOptions } from "./LambderResponseBuilder.js";
1
+ import LambderResponseBuilder, { type LambderApiAnswer, type LambderApiResponseConfig, type LambderResponseOptions } from "./LambderResponseBuilder.js";
2
2
  import type { LambderResponse } from "./LambderResponse.js";
3
3
  type SyncDie<T extends (...args: any[]) => LambderResponse> = (...args: Parameters<T>) => never;
4
4
  type AsyncDie<T extends (...args: any[]) => Promise<LambderResponse>> = (...args: Parameters<T>) => Promise<never>;
@@ -13,8 +13,8 @@ export interface DieResolverMethods<TOutput> {
13
13
  redirect: SyncDie<LambderResponseBuilder["redirect"]>;
14
14
  versionExpired: SyncDie<LambderResponseBuilder["versionExpired"]>;
15
15
  fileBase64: SyncDie<LambderResponseBuilder["fileBase64"]>;
16
- api: (payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions) => never;
17
- apiBinary: (payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions) => never;
16
+ api: LambderApiAnswer<TOutput, never>;
17
+ apiBinary: LambderApiAnswer<TOutput, never>;
18
18
  file: AsyncDie<LambderResponseBuilder["file"]>;
19
19
  templateFile: AsyncDie<LambderResponseBuilder["templateFile"]>;
20
20
  }
@@ -29,7 +29,9 @@ export interface DieResolverMethods<TOutput> {
29
29
  export default class LambderResolver<TOutput = any> extends LambderResponseBuilder<TOutput> {
30
30
  die: DieResolverMethods<TOutput>;
31
31
  constructor(...args: ConstructorParameters<typeof LambderResponseBuilder>);
32
- api(payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
33
- apiBinary(payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
32
+ api(payload: TOutput, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
33
+ api(payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
34
+ apiBinary(payload: TOutput, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
35
+ apiBinary(payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
34
36
  }
35
37
  export {};
@@ -22,13 +22,17 @@ export default class LambderResolver extends LambderResponseBuilder {
22
22
  redirect: (...a) => { throw this.redirect(...a); },
23
23
  versionExpired: (...a) => { throw this.versionExpired(...a); },
24
24
  fileBase64: (...a) => { throw this.fileBase64(...a); },
25
- api: (...a) => { throw this.api(...a); },
26
- apiBinary: (...a) => { throw this.apiBinary(...a); },
25
+ // Overloaded on the payload (see LambderApiAnswer); the implementation takes both shapes.
26
+ api: ((payload, config, options) => {
27
+ throw this.api(payload, config, options);
28
+ }),
29
+ apiBinary: ((payload, config, options) => {
30
+ throw this.apiBinary(payload, config, options);
31
+ }),
27
32
  file: async (...a) => { throw await this.file(...a); },
28
33
  templateFile: async (...a) => { throw await this.templateFile(...a); },
29
34
  };
30
35
  }
31
- // Override api method with proper output typing
32
36
  api(payload, config, options) {
33
37
  return super.api(payload, config, options);
34
38
  }
@@ -16,6 +16,18 @@ export type LambderResponseOptions = {
16
16
  /** "auto" (default): ETag on GET/HEAD 200 when globally enabled. true: force. false: never. */
17
17
  etag?: boolean | "auto";
18
18
  };
19
+ /**
20
+ * The two shapes of an API answer: the output the contract declares, or
21
+ * `null` beside a config that says why (a refusal flag, an `errorMessage`, a
22
+ * `message`). A bare `res.api(null)` compiles only when the output type
23
+ * itself allows null, so a success payload is always the declared output,
24
+ * which is what lets a typed caller (LambderInvokeCaller.api) promise it.
25
+ * Untyped resolvers (`TOutput = any`) accept anything, as before.
26
+ */
27
+ export type LambderApiAnswer<TOutput, TResult> = {
28
+ (payload: TOutput, config?: LambderApiResponseConfig, options?: LambderResponseOptions): TResult;
29
+ (payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): TResult;
30
+ };
19
31
  export type LambderRawResponseInit = {
20
32
  statusCode: HttpStatusCode;
21
33
  headers?: LambderHeadersInput;
@@ -76,7 +88,9 @@ export default class LambderResponseBuilder<TResponse = any> {
76
88
  templateFile(filePath: string, data?: LambderTemplateData, options?: LambderResponseOptions & {
77
89
  htmlVirtualSlots?: boolean;
78
90
  }): Promise<LambderResponse>;
79
- api(payload: TResponse | null, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, }?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
80
- /** Same as api() but forces gzip compression of the response body. */
81
- apiBinary(payload: TResponse | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
91
+ api(payload: TResponse, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
92
+ api(payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
93
+ /** Same as api() but forces compression of the response body. */
94
+ apiBinary(payload: TResponse, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
95
+ apiBinary(payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
82
96
  }