lambder 5.1.3 → 6.0.2

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 (39) 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.js +10 -3
  8. package/dist/core/LambderContext.d.ts +6 -5
  9. package/dist/core/LambderContext.js +20 -12
  10. package/dist/core/LambderResolver.d.ts +7 -5
  11. package/dist/core/LambderResolver.js +7 -3
  12. package/dist/core/LambderResponseBuilder.d.ts +17 -3
  13. package/dist/core/LambderResponseBuilder.js +2 -2
  14. package/dist/index.d.ts +10 -6
  15. package/dist/index.js +6 -2
  16. package/dist/invoke/LambderInvokeCaller.d.ts +322 -0
  17. package/dist/invoke/LambderInvokeCaller.js +654 -0
  18. package/dist/session/LambderSessionManager.d.ts +5 -1
  19. package/dist/session/LambderSessionManager.js +26 -13
  20. package/dist/shared/LambderApiContract.d.ts +9 -0
  21. package/dist/shared/LambderApiOutcome.d.ts +69 -0
  22. package/dist/shared/LambderApiOutcome.js +79 -0
  23. package/dist/shared/LambderCallOptions.d.ts +71 -0
  24. package/dist/shared/LambderCallOptions.js +16 -0
  25. package/dist/shared/LambderCompressionCodec.d.ts +45 -8
  26. package/dist/shared/LambderCompressionCodec.js +44 -14
  27. package/dist/shared/LambderCrashDetail.d.ts +48 -0
  28. package/dist/shared/LambderCrashDetail.js +78 -0
  29. package/dist/shared/LambderRequestPayload.d.ts +46 -23
  30. package/dist/shared/LambderRequestPayload.js +47 -31
  31. package/dist/stores/LambderDdbCache.d.ts +7 -2
  32. package/dist/stores/LambderDdbCache.js +30 -12
  33. package/dist/stores/LambderDdbIdempotency.d.ts +7 -2
  34. package/dist/stores/LambderDdbIdempotency.js +26 -10
  35. package/dist/stores/LambderDdbRateLimiter.d.ts +7 -2
  36. package/dist/stores/LambderDdbRateLimiter.js +16 -4
  37. package/dist/stores/LambderDdbSdk.d.ts +20 -0
  38. package/dist/stores/LambderDdbSdk.js +31 -0
  39. package/package.json +11 -6
@@ -1,7 +1,6 @@
1
1
  import crypto from "crypto";
2
- import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
3
- import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
4
- import { compressText, restoreBoundedText } from "../shared/LambderCompressionCodec.js";
2
+ import { loadDynamoClientSdk, loadDynamoDocumentSdk } from "../stores/LambderDdbSdk.js";
3
+ import { compressText, restoreText } from "../shared/LambderCompressionCodec.js";
5
4
  import { resolveCompressionOption, } from "../shared/LambderCompressionOption.js";
6
5
  /**
7
6
  * Session compression defaults: every record compressed (see
@@ -42,7 +41,9 @@ export default class LambderSessionManager {
42
41
  sessionSalt;
43
42
  partitionKey;
44
43
  sortKey;
45
- ddbDocumentClient;
44
+ tableRegion;
45
+ /** The document client and the SDK it came from, created the first time the table is touched. */
46
+ readyPromise;
46
47
  enableSlidingExpiration;
47
48
  slidingWriteIntervalSeconds;
48
49
  dataRefresh;
@@ -56,8 +57,14 @@ export default class LambderSessionManager {
56
57
  this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds ?? null;
57
58
  this.dataRefresh = dataRefresh ?? null;
58
59
  this.compression = resolveCompressionOption(compression, SESSION_COMPRESSION_DEFAULTS);
59
- const ddbClient = new DynamoDBClient({ region: tableRegion });
60
- this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
60
+ this.tableRegion = tableRegion;
61
+ }
62
+ /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
63
+ ready() {
64
+ this.readyPromise ??= Promise.all([loadDynamoClientSdk("LambderSessionManager"), loadDynamoDocumentSdk("LambderSessionManager")])
65
+ .then(([clientSdk, sdk]) => ({ sdk, client: sdk.DynamoDBDocumentClient.from(new clientSdk.DynamoDBClient({ region: this.tableRegion })) }))
66
+ .catch((error) => { this.readyPromise = undefined; throw error; });
67
+ return this.readyPromise;
61
68
  }
62
69
  sessionUserKeyHasher(password) {
63
70
  return crypto.createHash("sha256")
@@ -81,7 +88,8 @@ export default class LambderSessionManager {
81
88
  return crypto.timingSafeEqual(new Uint8Array(bufferA), new Uint8Array(bufferB));
82
89
  }
83
90
  async ddbGetItem(key) {
84
- const response = await this.ddbDocumentClient.send(new GetCommand({ TableName: this.tableName, Key: key, ConsistentRead: true }));
91
+ const { client, sdk } = await this.ready();
92
+ const response = await client.send(new sdk.GetCommand({ TableName: this.tableName, Key: key, ConsistentRead: true }));
85
93
  if (response.Item)
86
94
  return response.Item;
87
95
  return null;
@@ -102,11 +110,13 @@ export default class LambderSessionManager {
102
110
  else {
103
111
  item.data = data;
104
112
  }
105
- return await this.ddbDocumentClient.send(new PutCommand({ TableName: this.tableName, Item: item, }));
113
+ const { client, sdk } = await this.ready();
114
+ return await client.send(new sdk.PutCommand({ TableName: this.tableName, Item: item, }));
106
115
  }
107
116
  ;
108
117
  async ddbDeleteItem(key) {
109
- return await this.ddbDocumentClient.send(new DeleteCommand({ TableName: this.tableName, Key: key, }));
118
+ const { client, sdk } = await this.ready();
119
+ return await client.send(new sdk.DeleteCommand({ TableName: this.tableName, Key: key, }));
110
120
  }
111
121
  ;
112
122
  /** Sort keys of every session under a partition (the callers only need the keys). */
@@ -120,7 +130,8 @@ export default class LambderSessionManager {
120
130
  };
121
131
  const queryResults = [];
122
132
  do {
123
- const { Items, LastEvaluatedKey } = await this.ddbDocumentClient.send(new QueryCommand(params));
133
+ const { client, sdk } = await this.ready();
134
+ const { Items, LastEvaluatedKey } = await client.send(new sdk.QueryCommand(params));
124
135
  if (Items)
125
136
  queryResults.push(...Items);
126
137
  params.ExclusiveStartKey = LastEvaluatedKey;
@@ -133,7 +144,8 @@ export default class LambderSessionManager {
133
144
  async ddbDeleteAllByPartitionKey(partitionValue) {
134
145
  const queryResults = await this.ddbQueryAllByPartitionKey(partitionValue);
135
146
  for (const item of queryResults) {
136
- await this.ddbDocumentClient.send(new DeleteCommand({
147
+ const { client, sdk } = await this.ready();
148
+ await client.send(new sdk.DeleteCommand({
137
149
  TableName: this.tableName,
138
150
  Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] }
139
151
  }));
@@ -202,7 +214,7 @@ export default class LambderSessionManager {
202
214
  // that fails to decode throws, which the controller treats like any
203
215
  // malformed record: no session.
204
216
  if (session.dataBr) {
205
- session.data = JSON.parse(await restoreBoundedText(session.dataBr, session.dataBytes, "br"));
217
+ session.data = JSON.parse(await restoreText(session.dataBr, "br", { declaredBytes: session.dataBytes }));
206
218
  delete session.dataBr;
207
219
  delete session.dataBytes;
208
220
  }
@@ -360,7 +372,8 @@ export default class LambderSessionManager {
360
372
  const now = Math.floor(Date.now() / 1000);
361
373
  for (const item of await this.ddbQueryAllByPartitionKey(partitionValue)) {
362
374
  try {
363
- await this.ddbDocumentClient.send(new UpdateCommand({
375
+ const { client, sdk } = await this.ready();
376
+ await client.send(new sdk.UpdateCommand({
364
377
  TableName: this.tableName,
365
378
  Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] },
366
379
  UpdateExpression: "SET #dataExpiresAt = :now",
@@ -19,6 +19,7 @@ export type ApiContractShape = Record<string, {
19
19
  */
20
20
  guards?: any;
21
21
  }>;
22
+ import type { LambderCrashDetail } from "./LambderCrashDetail.js";
22
23
  /** Envelope flags/channels the server may set beside (or instead of) the payload. */
23
24
  export type LambderApiResponseConfig = {
24
25
  versionExpired?: boolean;
@@ -27,6 +28,14 @@ export type LambderApiResponseConfig = {
27
28
  message?: any;
28
29
  errorMessage?: any;
29
30
  logList?: any[];
31
+ /**
32
+ * A crash described in full (name, message, stack, cause chain, where it
33
+ * happened), for a caller that is allowed to see it: a global error
34
+ * handler answering a trusted invoker sets it with describeCrash().
35
+ * LambderInvokeCaller reads it back as the cause of the error it throws;
36
+ * the browser caller ignores it.
37
+ */
38
+ crash?: LambderCrashDetail;
30
39
  };
31
40
  /** The API wire envelope both sides speak: res.api() emits it, LambderCaller parses it. */
32
41
  export type LambderApiResponse<T> = LambderApiResponseConfig & {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The one mapping from an HTTP answer to an API outcome.
3
+ *
4
+ * LambderCaller (a browser, over fetch) and LambderInvokeCaller (a server,
5
+ * over a direct Lambda invoke) receive the same envelope and must read it
6
+ * the same way: which status is a crash, which is a rejected input, in what
7
+ * order the envelope flags are honoured, what a non-envelope body means.
8
+ * Both hand their answer to resolveApiOutcome and act on the result; the
9
+ * side effects each has (handlers, cookie clearing, error reporting) stay
10
+ * with the caller that owns them. Pure and dependency-free, so the browser
11
+ * entry resolves it.
12
+ */
13
+ import type { z } from "zod";
14
+ import type { LambderApiResponse } from "./LambderApiContract.js";
15
+ /**
16
+ * The 422 body's `zodError` as it survives JSON: a ZodError's name and
17
+ * message, and its issues spelled out. Not a ZodError instance (it has no
18
+ * methods on this side of the wire), which is why it is not typed as one.
19
+ */
20
+ export type LambderValidationError = {
21
+ name: string;
22
+ message: string;
23
+ issues: z.core.$ZodIssue[];
24
+ };
25
+ export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
26
+ /**
27
+ * Discriminated result of an API call: `ok: true` carries the payload, every
28
+ * failure carries a machine-readable reason, so "the server returned null"
29
+ * and "the request failed" are never conflated.
30
+ */
31
+ export type LambderApiOutcome<T> = {
32
+ ok: true;
33
+ payload: T | null | undefined;
34
+ response: LambderApiResponse<T>;
35
+ } | {
36
+ ok: false;
37
+ reason: LambderApiFailureReason;
38
+ /** HTTP status, when a response was received. */
39
+ status?: number;
40
+ /** Envelope errorMessage, when the server provided one. */
41
+ errorMessage?: any;
42
+ /** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it). */
43
+ retryAfterSeconds?: number;
44
+ /** Underlying Error for network/timeout/server/unknown failures. */
45
+ error?: Error;
46
+ /** The issues for 'validation'. */
47
+ zodError?: LambderValidationError;
48
+ /** The parsed envelope, when one was received (protocol-level failures, and a 5xx that answered with Lambder's own envelope). */
49
+ response?: LambderApiResponse<T>;
50
+ };
51
+ /** What the mapping needs from an HTTP answer, whichever transport produced it. */
52
+ export type LambderApiHttpAnswer = {
53
+ status: number;
54
+ statusText?: string;
55
+ /** Case-insensitive header lookup; null or undefined when absent. */
56
+ header: (name: string) => string | null | undefined;
57
+ /** The body parsed as JSON; rejects when it is not JSON. */
58
+ json: () => Promise<unknown>;
59
+ /** The body as text. */
60
+ text: () => Promise<string>;
61
+ };
62
+ /**
63
+ * Reads one HTTP answer into an outcome. A 5xx is a server failure that keeps
64
+ * the envelope when the server sent one (Lambder's own 500 body carries
65
+ * errorMessage, and a global error handler may add crash and logList); a
66
+ * 422 is a validation failure only with Lambder's validation body; anything
67
+ * else must be a JSON envelope, whose flags are honoured in a fixed order.
68
+ */
69
+ export declare const resolveApiOutcome: <T>(answer: LambderApiHttpAnswer) => Promise<LambderApiOutcome<T>>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The one mapping from an HTTP answer to an API outcome.
3
+ *
4
+ * LambderCaller (a browser, over fetch) and LambderInvokeCaller (a server,
5
+ * over a direct Lambda invoke) receive the same envelope and must read it
6
+ * the same way: which status is a crash, which is a rejected input, in what
7
+ * order the envelope flags are honoured, what a non-envelope body means.
8
+ * Both hand their answer to resolveApiOutcome and act on the result; the
9
+ * side effects each has (handlers, cookie clearing, error reporting) stay
10
+ * with the caller that owns them. Pure and dependency-free, so the browser
11
+ * entry resolves it.
12
+ */
13
+ /**
14
+ * Reads one HTTP answer into an outcome. A 5xx is a server failure that keeps
15
+ * the envelope when the server sent one (Lambder's own 500 body carries
16
+ * errorMessage, and a global error handler may add crash and logList); a
17
+ * 422 is a validation failure only with Lambder's validation body; anything
18
+ * else must be a JSON envelope, whose flags are honoured in a fixed order.
19
+ */
20
+ export const resolveApiOutcome = async (answer) => {
21
+ const status = answer.status;
22
+ if (status >= 500) {
23
+ // Lambder's own 500 fallback is a JSON envelope, but custom error
24
+ // handlers may answer text/HTML: parse defensively.
25
+ let envelope;
26
+ try {
27
+ const bodyText = await answer.text();
28
+ try {
29
+ const parsed = JSON.parse(bodyText);
30
+ if (parsed !== null && typeof parsed === "object")
31
+ envelope = parsed;
32
+ }
33
+ catch { /* not an envelope */ }
34
+ }
35
+ catch { /* body unavailable */ }
36
+ return {
37
+ ok: false, reason: 'server', status,
38
+ errorMessage: envelope?.errorMessage,
39
+ ...(envelope ? { response: envelope } : {}),
40
+ error: new Error("Request failed: " + status + " - " + (answer.statusText ?? "")),
41
+ };
42
+ }
43
+ if (status === 422) {
44
+ // A 422 without Lambder's validation body (e.g. a proxy's error page)
45
+ // is a server failure, not a validation result.
46
+ let zodError;
47
+ try {
48
+ zodError = (await answer.json())?.zodError;
49
+ }
50
+ catch { /* not JSON */ }
51
+ if (zodError === undefined) {
52
+ return { ok: false, reason: 'server', status, error: new Error("Request failed: 422 without a validation body") };
53
+ }
54
+ return { ok: false, reason: 'validation', status, zodError };
55
+ }
56
+ // Retry-After (delta-seconds) rides every refusal that knows its reset
57
+ // time, e.g. a rate limit; absent or unreadable is undefined.
58
+ const retryAfterValue = Number(answer.header("retry-after") ?? NaN);
59
+ const retryAfter = Number.isFinite(retryAfterValue) && retryAfterValue >= 0 ? { retryAfterSeconds: retryAfterValue } : {};
60
+ let data;
61
+ try {
62
+ data = await answer.json();
63
+ if (data === null || typeof data !== "object")
64
+ throw new Error("Response is not an object");
65
+ }
66
+ catch (err) {
67
+ // A non-envelope body (e.g. an HTML error page) is a server failure.
68
+ return { ok: false, reason: 'server', status, error: new Error("Request failed: response is not a valid API envelope (status " + status + ")", { cause: err }) };
69
+ }
70
+ if (data.versionExpired)
71
+ return { ok: false, reason: 'versionExpired', status, errorMessage: data.errorMessage, response: data, ...retryAfter };
72
+ if (data.sessionExpired)
73
+ return { ok: false, reason: 'sessionExpired', status, errorMessage: data.errorMessage, response: data, ...retryAfter };
74
+ if (data.notAuthorized)
75
+ return { ok: false, reason: 'notAuthorized', status, errorMessage: data.errorMessage, response: data, ...retryAfter };
76
+ if (data.errorMessage)
77
+ return { ok: false, reason: 'errorMessage', status, errorMessage: data.errorMessage, response: data, ...retryAfter };
78
+ return { ok: true, payload: data.payload, response: data };
79
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The contract-driven typing of a call's options argument, and the runtime
3
+ * merge of guard inputs, shared by the browser caller (LambderCaller) and the
4
+ * server-side invoke caller (LambderInvokeCaller). Both speak the same
5
+ * envelope to the same kind of contract, so what an API demands of its caller
6
+ * (a guardInput-mode guard's value, say) is decided here once and the two
7
+ * callers cannot drift on it. Pure types and one dependency-free function,
8
+ * so the browser entry resolves it.
9
+ */
10
+ export type IsAny<T> = 0 extends (1 & T) ? true : false;
11
+ export type GuardInputsOf<TEntry> = TEntry extends {
12
+ guardInputs: infer G;
13
+ } ? G : never;
14
+ /** Input type of guard G on one contract entry; never when that API does not declare it. */
15
+ type GuardInputOf<TEntry, G extends string> = GuardInputsOf<TEntry> extends infer I ? (G extends keyof I ? I[G] : never) : never;
16
+ /**
17
+ * What guardInputsProvider returns: for every provided guard name, the value
18
+ * the contract's APIs expect for it (a union across APIs when they differ).
19
+ * Naming a guard no API declares in guardInput mode resolves to never, so a
20
+ * typo fails the provider's return type instead of going missing at runtime.
21
+ */
22
+ export type LambderProvidedGuardInputs<TContract, TProvided extends string> = IsAny<TContract> extends true ? Record<TProvided, unknown> : {
23
+ [G in TProvided]: {
24
+ [K in keyof TContract]: GuardInputOf<TContract[K], G>;
25
+ }[keyof TContract];
26
+ };
27
+ /**
28
+ * Supplies guardInputs for every call from one place (the organization the
29
+ * UI is on, a device token), keyed by guard name; per-call guardInputs
30
+ * merge on top. Name the guards it covers in the caller's second type
31
+ * parameter, `new LambderCaller<Contract, "orgPermission">`, and calls to
32
+ * APIs whose guardInput guards are all covered no longer require the
33
+ * options argument. May be async; a throw fails the call as an unknown
34
+ * error before anything is sent.
35
+ */
36
+ export type LambderGuardInputsProvider<TContract, TProvided extends string> = (apiName: keyof TContract & string) => LambderProvidedGuardInputs<TContract, TProvided> | Promise<LambderProvidedGuardInputs<TContract, TProvided>>;
37
+ /** Optional until the caller names provided guards: naming them without a provider would send nothing. */
38
+ export type LambderGuardInputsProviderOption<TContract, TProvided extends string> = [
39
+ TProvided
40
+ ] extends [never] ? {
41
+ guardInputsProvider?: LambderGuardInputsProvider<TContract, TProvided>;
42
+ } : {
43
+ guardInputsProvider: LambderGuardInputsProvider<TContract, TProvided>;
44
+ };
45
+ /** An API's guardInput guards the provider does not cover: those the call must still pass. */
46
+ type RemainingGuardInputs<TEntry, TProvided extends string> = Omit<GuardInputsOf<TEntry>, TProvided>;
47
+ /**
48
+ * The options argument of one call: optional normally, REQUIRED (with
49
+ * guardInputs) when the API's contract declares guardInput-mode guards the
50
+ * provider does not cover, so forgetting to send a guard's value is a
51
+ * compile error at the call site. Provided guards may still be overridden
52
+ * per call. TOptions is the caller's own per-call options type; the
53
+ * guardInputs requirement is layered on top of it.
54
+ */
55
+ export type LambderCallOptionsArg<TContract, TApiName, TProvided extends string, TOptions extends {
56
+ guardInputs?: Record<string, unknown>;
57
+ }> = IsAny<TContract> extends true ? [options?: TOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: TOptions] : [keyof RemainingGuardInputs<TContract[TApiName], TProvided>] extends [never] ? [options?: TOptions & {
58
+ guardInputs?: Partial<GuardInputsOf<TContract[TApiName]>>;
59
+ }] : [
60
+ options: TOptions & {
61
+ guardInputs: RemainingGuardInputs<TContract[TApiName], TProvided> & Partial<GuardInputsOf<TContract[TApiName]>>;
62
+ }
63
+ ] : [options?: TOptions];
64
+ /**
65
+ * Provider values underneath, per-call values on top; undefined when neither
66
+ * side supplied any. Synchronous on purpose: a caller awaits its provider
67
+ * only when it has one, so a call without a provider still issues its
68
+ * request in the same tick it was made.
69
+ */
70
+ export declare const mergeGuardInputs: (provided: Record<string, unknown> | undefined, perCall: Record<string, unknown> | undefined) => Record<string, unknown> | undefined;
71
+ export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The contract-driven typing of a call's options argument, and the runtime
3
+ * merge of guard inputs, shared by the browser caller (LambderCaller) and the
4
+ * server-side invoke caller (LambderInvokeCaller). Both speak the same
5
+ * envelope to the same kind of contract, so what an API demands of its caller
6
+ * (a guardInput-mode guard's value, say) is decided here once and the two
7
+ * callers cannot drift on it. Pure types and one dependency-free function,
8
+ * so the browser entry resolves it.
9
+ */
10
+ /**
11
+ * Provider values underneath, per-call values on top; undefined when neither
12
+ * side supplied any. Synchronous on purpose: a caller awaits its provider
13
+ * only when it has one, so a call without a provider still issues its
14
+ * request in the same tick it was made.
15
+ */
16
+ export const mergeGuardInputs = (provided, perCall) => provided !== undefined || perCall !== undefined ? { ...provided, ...perCall } : undefined;
@@ -3,10 +3,15 @@
3
3
  *
4
4
  * Five things compress: sessions, LambderDdbCache and LambderDdbIdempotency
5
5
  * (Brotli at rest in DynamoDB), HTTP responses (Brotli or gzip, negotiated)
6
- * and request payloads (gzip, because the browser's CompressionStream
7
- * offers nothing else). They all compress text, so TEXT mode throughout,
8
- * and they all restore it the same way: the compressed bytes beside the
9
- * text's original UTF-8 byte length.
6
+ * and request payloads (gzip from a browser, whose CompressionStream offers
7
+ * nothing else; Brotli from a Node caller). They all compress text, so TEXT
8
+ * mode throughout, and they all restore it the same way: the compressed
9
+ * bytes beside the text's original UTF-8 byte length. The one restore
10
+ * without a declared length is a compressed HTTP answer read by
11
+ * LambderInvokeCaller, which passes a ceiling instead; both restores take
12
+ * either bound. That answer is also the one restore whose bytes may not be
13
+ * text at all, so the restore comes in two: restoreBytes returns the buffer
14
+ * and restoreText decodes it.
10
15
  *
11
16
  * That length is the safety mechanism, not bookkeeping. It bounds the
12
17
  * decompression, so a body that would expand without limit is cut off
@@ -48,8 +53,40 @@ export declare class LambderCompressionError extends Error {
48
53
  */
49
54
  export declare const compressText: (input: Buffer, encoding: LambderEncoding, quality: number) => Promise<Buffer>;
50
55
  /**
51
- * Restores text from compressed bytes beside the declared UTF-8 byte length
52
- * of the original, bounded and verified by that length. Throws
53
- * LambderCompressionError on anything it cannot vouch for.
56
+ * What bounds a restore: the text's declared UTF-8 byte length, stored
57
+ * beside the bytes (the decompression stops there and the result must match
58
+ * it exactly), or a ceiling alone, for bytes whose sender recorded no length
59
+ * (a compressed HTTP answer): the decompression stops there and nothing is
60
+ * verified.
54
61
  */
55
- export declare const restoreBoundedText: (compressed: Uint8Array, declaredBytes: number, encoding: LambderEncoding) => Promise<string>;
62
+ export type LambderRestoreBound = {
63
+ declaredBytes: number;
64
+ } | {
65
+ maxBytes: number;
66
+ };
67
+ /**
68
+ * Restores the original bytes from compressed bytes under a bound. With
69
+ * `declaredBytes` (records at rest, request payloads) the length both bounds
70
+ * the decompression and verifies it, so a body that would expand without
71
+ * limit is cut off rather than allocated, and a truncated or tampered input
72
+ * fails instead of decoding to something merely plausible. With `maxBytes`
73
+ * only the ceiling holds; truncation and corruption are what zlib's
74
+ * stream-end check and gzip's CRC catch. Throws LambderCompressionError on
75
+ * anything it cannot vouch for. A nonsense ceiling is the caller's
76
+ * configuration error and throws a plain Error.
77
+ *
78
+ * This is the restore for bytes that are not text: a compressed binary
79
+ * answer (a wasm module, an image a route forced compression on) read by
80
+ * LambderInvokeCaller. Text callers use restoreText, which is this plus the
81
+ * UTF-8 decode; going through a string would replace every byte that is not
82
+ * valid UTF-8 and hand back a body that is silently not what was sent.
83
+ */
84
+ export declare const restoreBytes: (compressed: Uint8Array, encoding: LambderEncoding, bound: LambderRestoreBound) => Promise<Buffer>;
85
+ /**
86
+ * Restores text: restoreBytes plus the UTF-8 decode. What every text caller
87
+ * uses (sessions, the DynamoDB stores, request payloads). The declared byte
88
+ * length a `declaredBytes` bound carries is the text's UTF-8 byte length,
89
+ * which is the restored buffer's length, so the verification is the same one
90
+ * either way.
91
+ */
92
+ export declare const restoreText: (compressed: Uint8Array, encoding: LambderEncoding, bound: LambderRestoreBound) => Promise<string>;
@@ -3,10 +3,15 @@
3
3
  *
4
4
  * Five things compress: sessions, LambderDdbCache and LambderDdbIdempotency
5
5
  * (Brotli at rest in DynamoDB), HTTP responses (Brotli or gzip, negotiated)
6
- * and request payloads (gzip, because the browser's CompressionStream
7
- * offers nothing else). They all compress text, so TEXT mode throughout,
8
- * and they all restore it the same way: the compressed bytes beside the
9
- * text's original UTF-8 byte length.
6
+ * and request payloads (gzip from a browser, whose CompressionStream offers
7
+ * nothing else; Brotli from a Node caller). They all compress text, so TEXT
8
+ * mode throughout, and they all restore it the same way: the compressed
9
+ * bytes beside the text's original UTF-8 byte length. The one restore
10
+ * without a declared length is a compressed HTTP answer read by
11
+ * LambderInvokeCaller, which passes a ceiling instead; both restores take
12
+ * either bound. That answer is also the one restore whose bytes may not be
13
+ * text at all, so the restore comes in two: restoreBytes returns the buffer
14
+ * and restoreText decodes it.
10
15
  *
11
16
  * That length is the safety mechanism, not bookkeeping. It bounds the
12
17
  * decompression, so a body that would expand without limit is cut off
@@ -79,13 +84,30 @@ export const compressText = async (input, encoding, quality) => {
79
84
  });
80
85
  };
81
86
  /**
82
- * Restores text from compressed bytes beside the declared UTF-8 byte length
83
- * of the original, bounded and verified by that length. Throws
84
- * LambderCompressionError on anything it cannot vouch for.
87
+ * Restores the original bytes from compressed bytes under a bound. With
88
+ * `declaredBytes` (records at rest, request payloads) the length both bounds
89
+ * the decompression and verifies it, so a body that would expand without
90
+ * limit is cut off rather than allocated, and a truncated or tampered input
91
+ * fails instead of decoding to something merely plausible. With `maxBytes`
92
+ * only the ceiling holds; truncation and corruption are what zlib's
93
+ * stream-end check and gzip's CRC catch. Throws LambderCompressionError on
94
+ * anything it cannot vouch for. A nonsense ceiling is the caller's
95
+ * configuration error and throws a plain Error.
96
+ *
97
+ * This is the restore for bytes that are not text: a compressed binary
98
+ * answer (a wasm module, an image a route forced compression on) read by
99
+ * LambderInvokeCaller. Text callers use restoreText, which is this plus the
100
+ * UTF-8 decode; going through a string would replace every byte that is not
101
+ * valid UTF-8 and hand back a body that is silently not what was sent.
85
102
  */
86
- export const restoreBoundedText = async (compressed, declaredBytes, encoding) => {
87
- if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
88
- throw new LambderCompressionError(LAMBDER_RESTORE_FAILURES.missingLength, "compressed value is missing its byte length");
103
+ export const restoreBytes = async (compressed, encoding, bound) => {
104
+ const verified = "declaredBytes" in bound;
105
+ const limit = verified ? bound.declaredBytes : bound.maxBytes;
106
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
107
+ if (verified) {
108
+ throw new LambderCompressionError(LAMBDER_RESTORE_FAILURES.missingLength, "compressed value is missing its byte length");
109
+ }
110
+ throw new Error("restoreBytes: maxBytes must be a positive integer");
89
111
  }
90
112
  const zlib = await requireZlib();
91
113
  let output;
@@ -98,16 +120,24 @@ export const restoreBoundedText = async (compressed, declaredBytes, encoding) =>
98
120
  resolve(result); };
99
121
  // maxOutputLength is the bound: zlib stops rather than allocating past it.
100
122
  if (encoding === "br")
101
- zlib.brotliDecompress(compressed, { maxOutputLength: declaredBytes }, done);
123
+ zlib.brotliDecompress(compressed, { maxOutputLength: limit }, done);
102
124
  else
103
- zlib.gunzip(compressed, { maxOutputLength: declaredBytes }, done);
125
+ zlib.gunzip(compressed, { maxOutputLength: limit }, done);
104
126
  });
105
127
  }
106
128
  catch (err) {
107
129
  throw new LambderCompressionError(LAMBDER_RESTORE_FAILURES.undecodable, "compressed value could not be decompressed", { cause: err });
108
130
  }
109
- if (output.length !== declaredBytes) {
131
+ if (verified && output.length !== limit) {
110
132
  throw new LambderCompressionError(LAMBDER_RESTORE_FAILURES.lengthMismatch, "decompressed length does not match the declared length");
111
133
  }
112
- return output.toString("utf8");
134
+ return output;
113
135
  };
136
+ /**
137
+ * Restores text: restoreBytes plus the UTF-8 decode. What every text caller
138
+ * uses (sessions, the DynamoDB stores, request payloads). The declared byte
139
+ * length a `declaredBytes` bound carries is the text's UTF-8 byte length,
140
+ * which is the restored buffer's length, so the verification is the same one
141
+ * either way.
142
+ */
143
+ export const restoreText = async (compressed, encoding, bound) => (await restoreBytes(compressed, encoding, bound)).toString("utf8");
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A crash, described for a caller that is allowed to see it.
3
+ *
4
+ * A global error handler decides what a failed request learns about the
5
+ * failure. A browser gets a generic message; a trusted caller (another
6
+ * lambda invoking this one, a developer holding a debug cookie) can be
7
+ * handed the whole thing: the error's name, message and stack, its cause
8
+ * chain, and where it happened, so the caller can store it in its own
9
+ * error log and point at the right CloudWatch stream. The envelope carries
10
+ * it in the `crash` field beside errorMessage; LambderInvokeCaller reads it
11
+ * back and rebuilds an Error from it as the `cause` of the error it throws,
12
+ * so an error reporter that walks causes sees the callee's stack without
13
+ * being taught anything.
14
+ *
15
+ * Dependency-free and isomorphic: the type is part of the envelope both
16
+ * entries export, and describeCrash needs nothing from Node.
17
+ */
18
+ export type LambderCrashCause = {
19
+ name: string;
20
+ message: string;
21
+ stack?: string | null;
22
+ };
23
+ export type LambderCrashDetail = LambderCrashCause & {
24
+ /** The Error `cause` chain, outermost first, a few levels deep. */
25
+ causeList?: LambderCrashCause[];
26
+ /** The failed invocation's own awsRequestId, from the render context's lambdaContext. */
27
+ requestId?: string | null;
28
+ /** The function it happened in, from the same place. */
29
+ functionName?: string | null;
30
+ };
31
+ /**
32
+ * Describes whatever was thrown. Pass the render context (the global error
33
+ * handler's second argument) so the detail names the invocation it came
34
+ * from; it is optional because the handler receives null when the context
35
+ * itself could not be built.
36
+ */
37
+ export declare const describeCrash: (error: unknown, ctx?: {
38
+ lambdaContext?: {
39
+ awsRequestId?: string;
40
+ functionName?: string;
41
+ } | null;
42
+ } | null) => LambderCrashDetail;
43
+ /**
44
+ * Rebuilds an Error (with its cause chain) from a crash detail, so a caller
45
+ * can chain it as the `cause` of its own error and reporters that walk
46
+ * causes see the callee's stack as it was.
47
+ */
48
+ export declare const errorFromCrashDetail: (crash: LambderCrashDetail) => Error;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A crash, described for a caller that is allowed to see it.
3
+ *
4
+ * A global error handler decides what a failed request learns about the
5
+ * failure. A browser gets a generic message; a trusted caller (another
6
+ * lambda invoking this one, a developer holding a debug cookie) can be
7
+ * handed the whole thing: the error's name, message and stack, its cause
8
+ * chain, and where it happened, so the caller can store it in its own
9
+ * error log and point at the right CloudWatch stream. The envelope carries
10
+ * it in the `crash` field beside errorMessage; LambderInvokeCaller reads it
11
+ * back and rebuilds an Error from it as the `cause` of the error it throws,
12
+ * so an error reporter that walks causes sees the callee's stack without
13
+ * being taught anything.
14
+ *
15
+ * Dependency-free and isomorphic: the type is part of the envelope both
16
+ * entries export, and describeCrash needs nothing from Node.
17
+ */
18
+ const MAX_NAME_CHARS = 200;
19
+ const MAX_MESSAGE_CHARS = 2000;
20
+ const MAX_STACK_CHARS = 8000;
21
+ const MAX_CAUSE_DEPTH = 3;
22
+ const clamp = (value, max) => (value.length > max ? value.slice(0, max) : value);
23
+ const describeCause = (error) => ({
24
+ name: clamp(error.name || "Error", MAX_NAME_CHARS),
25
+ message: clamp(error.message || String(error), MAX_MESSAGE_CHARS),
26
+ stack: error.stack ? clamp(error.stack, MAX_STACK_CHARS) : null,
27
+ });
28
+ /**
29
+ * Describes whatever was thrown. Pass the render context (the global error
30
+ * handler's second argument) so the detail names the invocation it came
31
+ * from; it is optional because the handler receives null when the context
32
+ * itself could not be built.
33
+ */
34
+ export const describeCrash = (error, ctx) => {
35
+ const where = {
36
+ requestId: ctx?.lambdaContext?.awsRequestId ?? null,
37
+ functionName: ctx?.lambdaContext?.functionName ?? null,
38
+ };
39
+ if (error instanceof Error) {
40
+ const causeList = [];
41
+ let cause = error.cause;
42
+ let depth = 0;
43
+ while (cause instanceof Error && depth < MAX_CAUSE_DEPTH) {
44
+ causeList.push(describeCause(cause));
45
+ cause = cause.cause;
46
+ depth += 1;
47
+ }
48
+ return { ...describeCause(error), ...(causeList.length ? { causeList } : {}), ...where };
49
+ }
50
+ if (typeof error === "string") {
51
+ return { name: "Error", message: clamp(error, MAX_MESSAGE_CHARS), stack: null, ...where };
52
+ }
53
+ let message;
54
+ try {
55
+ message = JSON.stringify(error) ?? String(error);
56
+ }
57
+ catch {
58
+ message = String(error);
59
+ }
60
+ return { name: "UnknownError", message: clamp(message, MAX_MESSAGE_CHARS), stack: null, ...where };
61
+ };
62
+ /**
63
+ * Rebuilds an Error (with its cause chain) from a crash detail, so a caller
64
+ * can chain it as the `cause` of its own error and reporters that walk
65
+ * causes see the callee's stack as it was.
66
+ */
67
+ export const errorFromCrashDetail = (crash) => {
68
+ const build = (entry, cause) => {
69
+ const error = new Error(entry.message, cause ? { cause } : undefined);
70
+ error.name = entry.name;
71
+ error.stack = entry.stack ?? `${entry.name}: ${entry.message}`;
72
+ return error;
73
+ };
74
+ let cause;
75
+ for (const entry of [...(crash.causeList ?? [])].reverse())
76
+ cause = build(entry, cause);
77
+ return build(crash, cause);
78
+ };