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.
- package/README.md +8 -3
- package/dist/client/LambderCaller.d.ts +8 -83
- package/dist/client/LambderCaller.js +32 -64
- package/dist/client/LambderMSW.js +4 -4
- package/dist/client.d.ts +5 -3
- package/dist/client.js +3 -1
- package/dist/core/Lambder.js +10 -3
- package/dist/core/LambderContext.d.ts +6 -5
- package/dist/core/LambderContext.js +20 -12
- package/dist/core/LambderResolver.d.ts +7 -5
- package/dist/core/LambderResolver.js +7 -3
- package/dist/core/LambderResponseBuilder.d.ts +17 -3
- package/dist/core/LambderResponseBuilder.js +2 -2
- package/dist/index.d.ts +10 -6
- package/dist/index.js +6 -2
- package/dist/invoke/LambderInvokeCaller.d.ts +322 -0
- package/dist/invoke/LambderInvokeCaller.js +654 -0
- package/dist/session/LambderSessionManager.d.ts +5 -1
- package/dist/session/LambderSessionManager.js +26 -13
- package/dist/shared/LambderApiContract.d.ts +9 -0
- package/dist/shared/LambderApiOutcome.d.ts +69 -0
- package/dist/shared/LambderApiOutcome.js +79 -0
- package/dist/shared/LambderCallOptions.d.ts +71 -0
- package/dist/shared/LambderCallOptions.js +16 -0
- package/dist/shared/LambderCompressionCodec.d.ts +45 -8
- package/dist/shared/LambderCompressionCodec.js +44 -14
- package/dist/shared/LambderCrashDetail.d.ts +48 -0
- package/dist/shared/LambderCrashDetail.js +78 -0
- package/dist/shared/LambderRequestPayload.d.ts +46 -23
- package/dist/shared/LambderRequestPayload.js +47 -31
- package/dist/stores/LambderDdbCache.d.ts +7 -2
- package/dist/stores/LambderDdbCache.js +30 -12
- package/dist/stores/LambderDdbIdempotency.d.ts +7 -2
- package/dist/stores/LambderDdbIdempotency.js +26 -10
- package/dist/stores/LambderDdbRateLimiter.d.ts +7 -2
- package/dist/stores/LambderDdbRateLimiter.js +16 -4
- package/dist/stores/LambderDdbSdk.d.ts +20 -0
- package/dist/stores/LambderDdbSdk.js +31 -0
- package/package.json +11 -6
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
7
|
-
*
|
|
8
|
-
* and they all restore it the same way: the compressed
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
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
|
|
7
|
-
*
|
|
8
|
-
* and they all restore it the same way: the compressed
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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:
|
|
123
|
+
zlib.brotliDecompress(compressed, { maxOutputLength: limit }, done);
|
|
102
124
|
else
|
|
103
|
-
zlib.gunzip(compressed, { maxOutputLength:
|
|
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 !==
|
|
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
|
|
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
|
+
};
|