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
@@ -36,7 +36,7 @@ import type { LambderResponse } from "../core/LambderResponse.js";
36
36
  * and the handler refuses by throwing (typically refuse()/LambderApiError).
37
37
  * Build with lambderGuard() so the handler's payload/ctx/param types line up.
38
38
  */
39
- export type LambderApiGuard<TInput extends z.ZodTypeAny = z.ZodTypeAny, TParam = any, TOutput = any> = {
39
+ export type LambderApiGuard<TInput extends z.ZodType = z.ZodType, TParam = any, TOutput = any> = {
40
40
  apiInput: TInput;
41
41
  guardInput?: undefined;
42
42
  session?: boolean;
@@ -61,7 +61,7 @@ type GuardSessionCtx = LambderSessionRenderContext<any, any>;
61
61
  * inferred from the handler's 4th argument annotation; the output from its
62
62
  * return type.
63
63
  */
64
- export declare function lambderGuard<TInput extends z.ZodTypeAny, TParam = undefined, TOutput = void>(guard: {
64
+ export declare function lambderGuard<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
65
65
  apiInput: TInput;
66
66
  session: true;
67
67
  handler: (ctx: GuardSessionCtx, payload: z.output<TInput>, res: LambderResolver, param: TParam) => TOutput | Promise<TOutput>;
@@ -71,7 +71,7 @@ export declare function lambderGuard<TInput extends z.ZodTypeAny, TParam = undef
71
71
  session: true;
72
72
  handler: (ctx: GuardSessionCtx, payload: z.output<TInput>, res: LambderResolver, param: TParam) => TOutput | Promise<TOutput>;
73
73
  };
74
- export declare function lambderGuard<TInput extends z.ZodTypeAny, TParam = undefined, TOutput = void>(guard: {
74
+ export declare function lambderGuard<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
75
75
  apiInput: TInput;
76
76
  handler: (ctx: GuardCtx, payload: z.output<TInput>, res: LambderResolver, param: TParam) => TOutput | Promise<TOutput>;
77
77
  }): {
@@ -80,7 +80,7 @@ export declare function lambderGuard<TInput extends z.ZodTypeAny, TParam = undef
80
80
  session?: undefined;
81
81
  handler: (ctx: GuardCtx, payload: z.output<TInput>, res: LambderResolver, param: TParam) => TOutput | Promise<TOutput>;
82
82
  };
83
- export declare function lambderGuard<TInput extends z.ZodTypeAny, TParam = undefined, TOutput = void>(guard: {
83
+ export declare function lambderGuard<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
84
84
  guardInput: TInput;
85
85
  session: true;
86
86
  handler: (ctx: GuardSessionCtx, payload: z.output<TInput>, res: LambderResolver, param: TParam) => TOutput | Promise<TOutput>;
@@ -90,7 +90,7 @@ export declare function lambderGuard<TInput extends z.ZodTypeAny, TParam = undef
90
90
  session: true;
91
91
  handler: (ctx: GuardSessionCtx, payload: z.output<TInput>, res: LambderResolver, param: TParam) => TOutput | Promise<TOutput>;
92
92
  };
93
- export declare function lambderGuard<TInput extends z.ZodTypeAny, TParam = undefined, TOutput = void>(guard: {
93
+ export declare function lambderGuard<TInput extends z.ZodType, TParam = undefined, TOutput = void>(guard: {
94
94
  guardInput: TInput;
95
95
  handler: (ctx: GuardCtx, payload: z.output<TInput>, res: LambderResolver, param: TParam) => TOutput | Promise<TOutput>;
96
96
  }): {
@@ -126,11 +126,11 @@ type LambderGuardOutputOf<G> = G extends {
126
126
  } ? Awaited<R> : never;
127
127
  /** Per-guard metadata carried on the Lambder instance: input mode, session requirement, param type, output type. */
128
128
  export type LambderGuardMeta<G> = (G extends {
129
- apiInput: infer S extends z.ZodTypeAny;
129
+ apiInput: infer S extends z.ZodType;
130
130
  } ? {
131
131
  apiInput: z.output<S>;
132
132
  } : G extends {
133
- guardInput: infer S extends z.ZodTypeAny;
133
+ guardInput: infer S extends z.ZodType;
134
134
  } ? {
135
135
  guardInput: z.output<S>;
136
136
  } : {}) & (G extends {
@@ -237,7 +237,7 @@ export type LambderInputValidationRefusal = (ctx: LambderRenderContext, resolver
237
237
  * same one regular input validation answers. Shared with the rate-limit
238
238
  * engine's apiInput-keyed policies.
239
239
  */
240
- export declare const parsePreflightSlice: (input: z.ZodTypeAny, value: unknown, ctx: LambderRenderContext, resolver: LambderResolver, onInvalid: LambderInputValidationRefusal) => Promise<unknown>;
240
+ export declare const parsePreflightSlice: (input: z.ZodType, value: unknown, ctx: LambderRenderContext, resolver: LambderResolver, onInvalid: LambderInputValidationRefusal) => Promise<unknown>;
241
241
  /**
242
242
  * Runtime side of the guards subsystem: holds the defined guards, asserts
243
243
  * API registrations against them at startup, and executes an API's declared
@@ -14,7 +14,7 @@ import { type LambderInputValidationRefusal } from "./LambderApiGuards.js";
14
14
  * stays the single owner of the field. Build with lambderRateLimitKey() so
15
15
  * the handler's payload type follows `apiInput`.
16
16
  */
17
- export type LambderRateLimitKeyFn<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
17
+ export type LambderRateLimitKeyFn<TInput extends z.ZodType = z.ZodType> = {
18
18
  apiInput: TInput;
19
19
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
20
20
  } | {
@@ -26,7 +26,7 @@ export type LambderRateLimitKeyFn<TInput extends z.ZodTypeAny = z.ZodTypeAny> =
26
26
  * inside one literal. Returns the exact union member so type extraction can
27
27
  * see the schema.
28
28
  */
29
- export declare function lambderRateLimitKey<TInput extends z.ZodTypeAny>(key: {
29
+ export declare function lambderRateLimitKey<TInput extends z.ZodType>(key: {
30
30
  apiInput: TInput;
31
31
  handler: (ctx: LambderRenderContext, payload: z.output<TInput>) => string | Promise<string>;
32
32
  }): {
@@ -78,7 +78,7 @@ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession exten
78
78
  per: "session";
79
79
  } ? (TIncludeSession extends true ? K : never) : TPolicies[K] extends {
80
80
  per: {
81
- apiInput: infer S extends z.ZodTypeAny;
81
+ apiInput: infer S extends z.ZodType;
82
82
  };
83
83
  } ? (TPayload extends z.output<S> ? K : never) : K;
84
84
  }[keyof TPolicies] & string;
@@ -78,7 +78,9 @@ export default class LambderSessionManager {
78
78
  private sessionSalt;
79
79
  private partitionKey;
80
80
  private sortKey;
81
- private ddbDocumentClient;
81
+ private tableRegion;
82
+ /** The document client and the SDK it came from, created the first time the table is touched. */
83
+ private readyPromise;
82
84
  private enableSlidingExpiration;
83
85
  private slidingWriteIntervalSeconds;
84
86
  private dataRefresh;
@@ -94,6 +96,8 @@ export default class LambderSessionManager {
94
96
  dataRefresh?: LambderSessionDataRefreshConfig;
95
97
  compression?: LambderCompressionOption;
96
98
  });
99
+ /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
100
+ private ready;
97
101
  private sessionUserKeyHasher;
98
102
  /**
99
103
  * At-rest hash for the bearer secrets (session sort-key secret, CSRF
@@ -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");