lambder 8.0.2 → 8.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -1
- package/README.md +7 -2
- package/dist/build/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -0
- package/dist/build/moduleLocation.d.ts +11 -0
- package/dist/build/moduleLocation.js +6 -0
- package/dist/build/writeApiContract.d.ts +78 -0
- package/dist/build/writeApiContract.js +302 -0
- package/dist/build/writeApiSignatures.d.ts +32 -27
- package/dist/build/writeApiSignatures.js +37 -42
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +8 -3
- package/dist/build.js +6 -3
- package/dist/client/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +4 -0
- package/dist/core/Lambder.d.ts +9 -10
- package/dist/core/Lambder.js +9 -10
- package/dist/index.d.ts +11 -1
- package/dist/index.js +8 -0
- package/dist/mock/lambderMockMswHandler.d.ts +10 -4
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +3 -0
- package/dist/mock.js +4 -0
- package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- package/dist/shared/util/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -5
- package/dist/shared/wire/LambderApiContract.d.ts +10 -40
- package/dist/shared/wire/LambderApiRefusal.d.ts +6 -0
- package/dist/shared/wire/LambderApiRefusal.js +6 -0
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
- package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
- package/dist/shared/wire/LambderUploadRefusal.js +18 -0
- package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
- package/dist/shared/wire/LambderUploadSchemas.js +30 -0
- package/dist/stores/LambderDdbSdk.js +1 -5
- package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
- package/dist/stores/LambderS3UploadBucket.js +144 -0
- package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
- package/dist/stores/LambderSdkInstallHint.js +14 -0
- package/dist/testing/LambderTestApp.d.ts +4 -4
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +1 -0
- package/package.json +15 -1
package/dist/build.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build entry point (`import ... from "lambder/build"`).
|
|
3
3
|
*
|
|
4
|
-
* What a generator script runs at build time
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* What a generator script runs at build time to write the files a deployment
|
|
5
|
+
* ships: the signature file both sides read, from the app's own instance, and
|
|
6
|
+
* the contract a client compiles against, from the server's sources. Node-only
|
|
7
|
+
* and imported by nothing else in the package, so no deployment or bundle
|
|
8
|
+
* carries it.
|
|
7
9
|
*/
|
|
8
10
|
export { writeApiSignatures } from "./build/writeApiSignatures.js";
|
|
9
11
|
export type { LambderApiSignatureSource, LambderApiSignatureFileOptions, LambderApiSignatureFileResult, } from "./build/writeApiSignatures.js";
|
|
12
|
+
export { writeApiContract } from "./build/writeApiContract.js";
|
|
13
|
+
export type { LambderApiContractFileOptions, LambderApiContractFileResult, } from "./build/writeApiContract.js";
|
|
14
|
+
export type { LambderModuleLocation } from "./build/moduleLocation.js";
|
package/dist/build.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build entry point (`import ... from "lambder/build"`).
|
|
3
3
|
*
|
|
4
|
-
* What a generator script runs at build time
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* What a generator script runs at build time to write the files a deployment
|
|
5
|
+
* ships: the signature file both sides read, from the app's own instance, and
|
|
6
|
+
* the contract a client compiles against, from the server's sources. Node-only
|
|
7
|
+
* and imported by nothing else in the package, so no deployment or bundle
|
|
8
|
+
* carries it.
|
|
7
9
|
*/
|
|
8
10
|
export { writeApiSignatures } from "./build/writeApiSignatures.js";
|
|
11
|
+
export { writeApiContract } from "./build/writeApiContract.js";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type LambderUploadFileFacts, type LambderUploadRule, type LambderUploadRuleVerdict, type LambderUploadTicket } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
/** Where an upload is, in order. `sentBytes` only moves during `uploading`. */
|
|
3
|
+
export type LambderUploadPhase = "hashing" | "requesting" | "uploading" | "confirming";
|
|
4
|
+
export type LambderUploadProgress = {
|
|
5
|
+
phase: LambderUploadPhase;
|
|
6
|
+
sentBytes: number;
|
|
7
|
+
totalBytes: number;
|
|
8
|
+
};
|
|
9
|
+
export type LambderUploadFailureReason = LambderUploadRuleVerdict
|
|
10
|
+
/** The browser could not read the file (moved, deleted, or a cloud placeholder that never downloaded). */
|
|
11
|
+
| "fileUnreadable"
|
|
12
|
+
/** The app's ticket endpoint would not issue a ticket. */
|
|
13
|
+
| "ticketRefused"
|
|
14
|
+
/** Storage answered and said no, for a reason a retry cannot cure. */
|
|
15
|
+
| "storageRejected"
|
|
16
|
+
/** Storage could not be reached, or kept stalling, through every attempt. */
|
|
17
|
+
| "networkFailed"
|
|
18
|
+
/** The bytes are stored, and the app's confirm endpoint would not confirm them. */
|
|
19
|
+
| "confirmRefused" | "cancelled";
|
|
20
|
+
/** How an upload failed: `reason` for a screen to word, the underlying error as `cause`. */
|
|
21
|
+
export declare class LambderUploadError extends Error {
|
|
22
|
+
readonly reason: LambderUploadFailureReason;
|
|
23
|
+
constructor(reason: LambderUploadFailureReason, options?: {
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
detail?: string;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export type LambderUploadRunnerOptions<Reference, Receipt> = {
|
|
29
|
+
uploadRule: LambderUploadRule;
|
|
30
|
+
/**
|
|
31
|
+
* The app's ticket endpoint. `reference` is whatever its confirm endpoint
|
|
32
|
+
* needs to find this upload again (the id of the record it made), and
|
|
33
|
+
* means nothing to the runner. `signal` is the upload's own, for the call
|
|
34
|
+
* to pass on so a cancel stops it too.
|
|
35
|
+
*/
|
|
36
|
+
requestTicket: (fileFacts: LambderUploadFileFacts, call: {
|
|
37
|
+
signal: AbortSignal | undefined;
|
|
38
|
+
}) => Promise<{
|
|
39
|
+
ticket: LambderUploadTicket;
|
|
40
|
+
reference: Reference;
|
|
41
|
+
}>;
|
|
42
|
+
/** The app's confirm endpoint: the server checks the stored object and answers its record of it. */
|
|
43
|
+
confirmUpload: (reference: Reference, call: {
|
|
44
|
+
signal: AbortSignal | undefined;
|
|
45
|
+
}) => Promise<Receipt>;
|
|
46
|
+
/** The app's way of forgetting a confirmed upload the person removed again. Without one, discard() does nothing. */
|
|
47
|
+
discardUpload?: (receipt: Receipt) => Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* How storage is tried again when it cannot be reached, stalls, or answers
|
|
50
|
+
* a failure a retry can cure (a 5xx, RequestTimeout, SlowDown). Each wait
|
|
51
|
+
* is a random time between `baseDelayMs` and a ceiling that doubles with
|
|
52
|
+
* every failed attempt, never past `maxDelayMs`, so many browsers dropped
|
|
53
|
+
* together do not come back in step. Default: 4 attempts, waits from one
|
|
54
|
+
* second to 15.
|
|
55
|
+
*/
|
|
56
|
+
storageRetry?: {
|
|
57
|
+
attempts?: number;
|
|
58
|
+
baseDelayMs?: number;
|
|
59
|
+
maxDelayMs?: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* How long a post may be open and move nothing before it counts as
|
|
63
|
+
* dropped. Default: 60 seconds. Watched where XMLHttpRequest exists,
|
|
64
|
+
* which reports a body's progress; a runtime with only fetch posts
|
|
65
|
+
* unwatched.
|
|
66
|
+
*/
|
|
67
|
+
stallTimeoutMs?: number;
|
|
68
|
+
};
|
|
69
|
+
export declare class LambderUploadRunner<Reference, Receipt> {
|
|
70
|
+
private readonly options;
|
|
71
|
+
private readonly attempts;
|
|
72
|
+
private readonly baseDelayMs;
|
|
73
|
+
private readonly maxDelayMs;
|
|
74
|
+
private readonly stallTimeoutMs;
|
|
75
|
+
constructor(options: LambderUploadRunnerOptions<Reference, Receipt>);
|
|
76
|
+
/** For a file input's `accept`, so the picker only offers what the rule takes. */
|
|
77
|
+
get acceptedTypes(): string;
|
|
78
|
+
get maxBytes(): number;
|
|
79
|
+
/** The rule's verdict on a file, or null when it may be uploaded. Costs nothing, so a screen can ask on drop. */
|
|
80
|
+
checkFile(file: Blob): LambderUploadRuleVerdict | null;
|
|
81
|
+
/**
|
|
82
|
+
* Uploads one file and answers the app's receipt, or throws a
|
|
83
|
+
* LambderUploadError. Aborting `signal` stops it wherever it is: the
|
|
84
|
+
* runner's own steps at once, and the app's calls as far as they pass
|
|
85
|
+
* the signal on.
|
|
86
|
+
*/
|
|
87
|
+
upload(file: File, { onProgress, signal }?: {
|
|
88
|
+
onProgress?: (progress: LambderUploadProgress) => void;
|
|
89
|
+
signal?: AbortSignal;
|
|
90
|
+
}): Promise<Receipt>;
|
|
91
|
+
/** Forgets a confirmed upload through the app's endpoint, when it declared one. */
|
|
92
|
+
discard(receipt: Receipt): Promise<void>;
|
|
93
|
+
private waitBeforeRetry;
|
|
94
|
+
/** One post of the file to storage. Never throws: every ending is an outcome. */
|
|
95
|
+
private post;
|
|
96
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { checkUploadRule, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
|
|
3
|
+
/** How an upload failed: `reason` for a screen to word, the underlying error as `cause`. */
|
|
4
|
+
export class LambderUploadError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason, options = {}) {
|
|
7
|
+
super(options.detail ? `${reason}: ${options.detail}` : reason, { cause: options.cause });
|
|
8
|
+
this.name = "LambderUploadError";
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* How many times one upload asks for a new ticket because storage called the
|
|
14
|
+
* last one expired. A new ticket is asked for at once and spends no attempt
|
|
15
|
+
* at storage; the bound is for a clock so far off that every ticket arrives
|
|
16
|
+
* expired.
|
|
17
|
+
*/
|
|
18
|
+
const TICKET_RENEWAL_LIMIT = 2;
|
|
19
|
+
/** S3's refusals that are the connection's or the service's fault rather than the file's, which its own SDK retries too. */
|
|
20
|
+
const TRANSIENT_STORAGE_CODES = new Set(["RequestTimeout", "SlowDown", "InternalError", "ServiceUnavailable"]);
|
|
21
|
+
export class LambderUploadRunner {
|
|
22
|
+
options;
|
|
23
|
+
attempts;
|
|
24
|
+
baseDelayMs;
|
|
25
|
+
maxDelayMs;
|
|
26
|
+
stallTimeoutMs;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.attempts = Math.max(1, options.storageRetry?.attempts ?? 4);
|
|
30
|
+
this.baseDelayMs = options.storageRetry?.baseDelayMs ?? 1_000;
|
|
31
|
+
this.maxDelayMs = options.storageRetry?.maxDelayMs ?? 15_000;
|
|
32
|
+
this.stallTimeoutMs = options.stallTimeoutMs ?? 60_000;
|
|
33
|
+
}
|
|
34
|
+
/** For a file input's `accept`, so the picker only offers what the rule takes. */
|
|
35
|
+
get acceptedTypes() {
|
|
36
|
+
return this.options.uploadRule.mimeTypes.join(",");
|
|
37
|
+
}
|
|
38
|
+
get maxBytes() {
|
|
39
|
+
return this.options.uploadRule.maxBytes;
|
|
40
|
+
}
|
|
41
|
+
/** The rule's verdict on a file, or null when it may be uploaded. Costs nothing, so a screen can ask on drop. */
|
|
42
|
+
checkFile(file) {
|
|
43
|
+
return checkUploadRule(this.options.uploadRule, { mimeType: file.type, byteSize: file.size });
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Uploads one file and answers the app's receipt, or throws a
|
|
47
|
+
* LambderUploadError. Aborting `signal` stops it wherever it is: the
|
|
48
|
+
* runner's own steps at once, and the app's calls as far as they pass
|
|
49
|
+
* the signal on.
|
|
50
|
+
*/
|
|
51
|
+
async upload(file, { onProgress, signal } = {}) {
|
|
52
|
+
const rejection = this.checkFile(file);
|
|
53
|
+
if (rejection)
|
|
54
|
+
throw new LambderUploadError(rejection);
|
|
55
|
+
const report = (phase, sentBytes = 0) => onProgress?.({ phase, sentBytes, totalBytes: file.size });
|
|
56
|
+
const stopIfCancelled = () => {
|
|
57
|
+
if (signal?.aborted)
|
|
58
|
+
throw new LambderUploadError("cancelled");
|
|
59
|
+
};
|
|
60
|
+
stopIfCancelled();
|
|
61
|
+
report("hashing");
|
|
62
|
+
let bytes;
|
|
63
|
+
try {
|
|
64
|
+
bytes = new Uint8Array(await file.arrayBuffer());
|
|
65
|
+
}
|
|
66
|
+
catch (cause) {
|
|
67
|
+
throw new LambderUploadError("fileUnreadable", { cause });
|
|
68
|
+
}
|
|
69
|
+
const fileFacts = {
|
|
70
|
+
fileName: file.name,
|
|
71
|
+
mimeType: file.type,
|
|
72
|
+
byteSize: file.size,
|
|
73
|
+
sha256Base64: await sha256Base64Of(bytes),
|
|
74
|
+
};
|
|
75
|
+
stopIfCancelled();
|
|
76
|
+
const requestTicket = async () => {
|
|
77
|
+
report("requesting");
|
|
78
|
+
try {
|
|
79
|
+
return await this.options.requestTicket(fileFacts, { signal });
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
throw new LambderUploadError(signal?.aborted ? "cancelled" : "ticketRefused", { cause });
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
let issued = await requestTicket();
|
|
86
|
+
let failedAttempts = 0;
|
|
87
|
+
let ticketRenewals = 0;
|
|
88
|
+
for (;;) {
|
|
89
|
+
stopIfCancelled();
|
|
90
|
+
report("uploading");
|
|
91
|
+
const outcome = await this.post(issued.ticket, file, signal, (sentBytes) => report("uploading", sentBytes));
|
|
92
|
+
if (outcome.kind === "stored")
|
|
93
|
+
break;
|
|
94
|
+
if (outcome.kind === "cancelled")
|
|
95
|
+
throw new LambderUploadError("cancelled");
|
|
96
|
+
if (outcome.kind === "rejected") {
|
|
97
|
+
// An expired ticket needs no wait and costs no attempt, only a
|
|
98
|
+
// new ticket; any other refusal is the file's, and final.
|
|
99
|
+
if (!outcome.ticketExpired || ++ticketRenewals > TICKET_RENEWAL_LIMIT)
|
|
100
|
+
throw new LambderUploadError("storageRejected", { detail: outcome.detail });
|
|
101
|
+
issued = await requestTicket();
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// The ticket is kept through a network retry, so a flaky
|
|
105
|
+
// connection does not leave the app a record per attempt.
|
|
106
|
+
if (++failedAttempts >= this.attempts)
|
|
107
|
+
throw new LambderUploadError("networkFailed");
|
|
108
|
+
await this.waitBeforeRetry(failedAttempts, signal);
|
|
109
|
+
}
|
|
110
|
+
report("confirming", file.size);
|
|
111
|
+
try {
|
|
112
|
+
return await this.options.confirmUpload(issued.reference, { signal });
|
|
113
|
+
}
|
|
114
|
+
catch (cause) {
|
|
115
|
+
throw new LambderUploadError(signal?.aborted ? "cancelled" : "confirmRefused", { cause });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Forgets a confirmed upload through the app's endpoint, when it declared one. */
|
|
119
|
+
async discard(receipt) {
|
|
120
|
+
await this.options.discardUpload?.(receipt);
|
|
121
|
+
}
|
|
122
|
+
waitBeforeRetry(failedAttempts, signal) {
|
|
123
|
+
const ceiling = Math.max(this.baseDelayMs, Math.min(this.baseDelayMs * 2 ** (failedAttempts - 1), this.maxDelayMs));
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
if (signal?.aborted)
|
|
126
|
+
return reject(new LambderUploadError("cancelled"));
|
|
127
|
+
const cancel = () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
reject(new LambderUploadError("cancelled"));
|
|
130
|
+
};
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
signal?.removeEventListener("abort", cancel);
|
|
133
|
+
resolve();
|
|
134
|
+
}, this.baseDelayMs + Math.random() * (ceiling - this.baseDelayMs));
|
|
135
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** One post of the file to storage. Never throws: every ending is an outcome. */
|
|
139
|
+
post(ticket, file, signal, onSent) {
|
|
140
|
+
if (signal?.aborted)
|
|
141
|
+
return Promise.resolve({ kind: "cancelled" });
|
|
142
|
+
const form = new FormData();
|
|
143
|
+
for (const [name, value] of Object.entries(ticket.formFields))
|
|
144
|
+
form.append(name, value);
|
|
145
|
+
// Storage ignores every field that comes after the file.
|
|
146
|
+
form.append("file", file);
|
|
147
|
+
return typeof XMLHttpRequest === "function"
|
|
148
|
+
? postWithXhr(ticket.uploadUrl, form, file.size, this.stallTimeoutMs, signal, onSent)
|
|
149
|
+
: postWithFetch(ticket.uploadUrl, form, file.size, signal, onSent);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** XMLHttpRequest rather than fetch where it exists: fetch cannot report how much of a request body has been sent. */
|
|
153
|
+
const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => new Promise((resolve) => {
|
|
154
|
+
const request = new XMLHttpRequest();
|
|
155
|
+
let stalled = false;
|
|
156
|
+
let stallTimer;
|
|
157
|
+
const watchForStall = () => {
|
|
158
|
+
clearTimeout(stallTimer);
|
|
159
|
+
stallTimer = setTimeout(() => {
|
|
160
|
+
stalled = true;
|
|
161
|
+
request.abort();
|
|
162
|
+
}, stallTimeoutMs);
|
|
163
|
+
};
|
|
164
|
+
const cancel = () => request.abort();
|
|
165
|
+
const settle = (outcome) => {
|
|
166
|
+
clearTimeout(stallTimer);
|
|
167
|
+
signal?.removeEventListener("abort", cancel);
|
|
168
|
+
resolve(outcome);
|
|
169
|
+
};
|
|
170
|
+
request.upload.onprogress = (event) => {
|
|
171
|
+
watchForStall();
|
|
172
|
+
// `loaded` counts the form's own framing too, a little over the file.
|
|
173
|
+
onSent(Math.min(event.loaded, fileBytes));
|
|
174
|
+
};
|
|
175
|
+
request.onload = () => {
|
|
176
|
+
if (request.status >= 200 && request.status < 300)
|
|
177
|
+
return settle({ kind: "stored" });
|
|
178
|
+
if (request.status >= 500)
|
|
179
|
+
return settle({ kind: "unreachable" });
|
|
180
|
+
settle(rejectedOutcome(request.status, request.responseText));
|
|
181
|
+
};
|
|
182
|
+
request.onerror = () => settle({ kind: "unreachable" });
|
|
183
|
+
request.onabort = () => settle(stalled ? { kind: "unreachable" } : { kind: "cancelled" });
|
|
184
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
185
|
+
watchForStall();
|
|
186
|
+
try {
|
|
187
|
+
request.open("POST", url);
|
|
188
|
+
request.send(form);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// A URL the browser will not open, or a request it will not send, answers nothing.
|
|
192
|
+
settle({ kind: "unreachable" });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const postWithFetch = async (url, form, fileBytes, signal, onSent) => {
|
|
196
|
+
let response;
|
|
197
|
+
try {
|
|
198
|
+
response = await fetch(url, { method: "POST", body: form, signal });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return signal?.aborted ? { kind: "cancelled" } : { kind: "unreachable" };
|
|
202
|
+
}
|
|
203
|
+
if (response.ok) {
|
|
204
|
+
onSent(fileBytes);
|
|
205
|
+
return { kind: "stored" };
|
|
206
|
+
}
|
|
207
|
+
if (response.status >= 500)
|
|
208
|
+
return { kind: "unreachable" };
|
|
209
|
+
return rejectedOutcome(response.status, await response.text().catch(() => ""));
|
|
210
|
+
};
|
|
211
|
+
const XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'" };
|
|
212
|
+
const xmlText = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (entity, name) => {
|
|
213
|
+
if (name[0] !== "#")
|
|
214
|
+
return XML_ENTITIES[name] ?? entity;
|
|
215
|
+
const codePoint = Number(name[1]?.toLowerCase() === "x" ? `0${name.slice(1)}` : name.slice(1));
|
|
216
|
+
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
|
|
217
|
+
});
|
|
218
|
+
/**
|
|
219
|
+
* Storage explains a refusal in XML, `<Error><Code/><Message/></Error>`,
|
|
220
|
+
* read here without a DOM so the runner works wherever fetch does. A
|
|
221
|
+
* transient code is tried again like a dropped connection, and an expired
|
|
222
|
+
* ticket is the one refusal a new ticket cures.
|
|
223
|
+
*/
|
|
224
|
+
const rejectedOutcome = (status, body) => {
|
|
225
|
+
const code = xmlText(/<Code>([^<]*)<\/Code>/.exec(body)?.[1] ?? "") || `HTTP ${status}`;
|
|
226
|
+
const message = xmlText(/<Message>([^<]*)<\/Message>/.exec(body)?.[1] ?? "");
|
|
227
|
+
if (TRANSIENT_STORAGE_CODES.has(code))
|
|
228
|
+
return { kind: "unreachable" };
|
|
229
|
+
return {
|
|
230
|
+
kind: "rejected",
|
|
231
|
+
ticketExpired: code === "AccessDenied" && /expired/i.test(message),
|
|
232
|
+
detail: message ? `${code}: ${message}` : code,
|
|
233
|
+
};
|
|
234
|
+
};
|
package/dist/client.d.ts
CHANGED
|
@@ -36,3 +36,7 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
|
|
|
36
36
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
37
37
|
export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nDictionaryLoader, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
|
|
38
38
|
export type { LambderHttpStatusCode } from "./shared/wire/LambderHttpStatus.js";
|
|
39
|
+
export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
|
|
40
|
+
export type { LambderUploadRunnerOptions, LambderUploadProgress, LambderUploadPhase, LambderUploadFailureReason } from "./client/LambderUploadRunner.js";
|
|
41
|
+
export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
|
|
42
|
+
export type { LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadRuleVerdict } from "./shared/contracts/LambderUploadBucket.js";
|
package/dist/client.js
CHANGED
|
@@ -33,3 +33,7 @@ export { resolveCompressionOption } from "./shared/wire/LambderCompressionOption
|
|
|
33
33
|
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
|
|
34
34
|
// Typed translations (standalone, isomorphic)
|
|
35
35
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
36
|
+
// Direct uploads: the runner that takes a file from the browser straight to
|
|
37
|
+
// storage, and the vocabulary it shares with the server's bucket.
|
|
38
|
+
export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
|
|
39
|
+
export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -68,20 +68,19 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
68
68
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
69
69
|
files: LambderFiles | null;
|
|
70
70
|
/**
|
|
71
|
-
* Type property for extracting the API contract
|
|
72
|
-
*
|
|
71
|
+
* Type property for extracting the API contract: every registered API's
|
|
72
|
+
* input, output, mode and declared options, as a client calls it.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* LambderFlattenContract for the measurements.
|
|
74
|
+
* A small app's client imports it as it is. It is an intersection one
|
|
75
|
+
* member deep per endpoint, so reading it generically costs a large app's
|
|
76
|
+
* client most of its type check; writeApiContract (lambder/build) reads
|
|
77
|
+
* this property off the exported instance and writes the contract out as
|
|
78
|
+
* plain types for such a client to import instead.
|
|
80
79
|
*
|
|
81
80
|
* @example
|
|
82
81
|
* ```typescript
|
|
83
|
-
* const lambder =
|
|
84
|
-
* export
|
|
82
|
+
* export const lambder = initLambder().create({ ... }).addApi(...).addApi(...);
|
|
83
|
+
* export type ApiContractType = typeof lambder.ApiContract;
|
|
85
84
|
* ```
|
|
86
85
|
*/
|
|
87
86
|
readonly ApiContract: _TContract;
|
package/dist/core/Lambder.js
CHANGED
|
@@ -57,20 +57,19 @@ export default class Lambder {
|
|
|
57
57
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
58
58
|
files;
|
|
59
59
|
/**
|
|
60
|
-
* Type property for extracting the API contract
|
|
61
|
-
*
|
|
60
|
+
* Type property for extracting the API contract: every registered API's
|
|
61
|
+
* input, output, mode and declared options, as a client calls it.
|
|
62
62
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* LambderFlattenContract for the measurements.
|
|
63
|
+
* A small app's client imports it as it is. It is an intersection one
|
|
64
|
+
* member deep per endpoint, so reading it generically costs a large app's
|
|
65
|
+
* client most of its type check; writeApiContract (lambder/build) reads
|
|
66
|
+
* this property off the exported instance and writes the contract out as
|
|
67
|
+
* plain types for such a client to import instead.
|
|
69
68
|
*
|
|
70
69
|
* @example
|
|
71
70
|
* ```typescript
|
|
72
|
-
* const lambder =
|
|
73
|
-
* export
|
|
71
|
+
* export const lambder = initLambder().create({ ... }).addApi(...).addApi(...);
|
|
72
|
+
* export type ApiContractType = typeof lambder.ApiContract;
|
|
74
73
|
* ```
|
|
75
74
|
*/
|
|
76
75
|
ApiContract;
|
package/dist/index.d.ts
CHANGED
|
@@ -78,6 +78,16 @@ export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
|
78
78
|
export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
|
|
79
79
|
export { LambderHttpFileSource } from "./stores/LambderHttpFileSource.js";
|
|
80
80
|
export type { LambderHttpFileSourceOptions } from "./stores/LambderHttpFileSource.js";
|
|
81
|
+
export { LambderS3UploadBucket } from "./stores/LambderS3UploadBucket.js";
|
|
82
|
+
export type { LambderS3UploadBucketOptions } from "./stores/LambderS3UploadBucket.js";
|
|
83
|
+
export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
|
|
84
|
+
export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
|
|
85
|
+
export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
|
|
86
|
+
export type { LambderUploadBucket, LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadVerdict, LambderUploadRuleVerdict, LambderUploadObjectOptions, LambderUploadContentDisposition, } from "./shared/contracts/LambderUploadBucket.js";
|
|
87
|
+
export { LambderUploadFileFactsSchema, LambderUploadTicketSchema } from "./shared/wire/LambderUploadSchemas.js";
|
|
88
|
+
export { refuseUnacceptedUpload } from "./shared/wire/LambderUploadRefusal.js";
|
|
89
|
+
export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
|
|
90
|
+
export type { LambderUploadRunnerOptions, LambderUploadProgress, LambderUploadPhase, LambderUploadFailureReason } from "./client/LambderUploadRunner.js";
|
|
81
91
|
export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
|
|
82
92
|
export type { LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
|
|
83
93
|
export { resolveCompressionOption, LAMBDER_ENCODINGS } from "./shared/wire/LambderCompressionOption.js";
|
|
@@ -110,7 +120,7 @@ export type { LambderApiIdempotencyConfig } from "./api/LambderApiIdempotency.js
|
|
|
110
120
|
export type { LambderGuardsOptionValue, LambderRateLimitOverride, LambderRateLimitOptionValue, LambderApiIdempotencyOption, } from "./shared/wire/LambderApiOptionValues.js";
|
|
111
121
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
112
122
|
export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nDictionaryLoader, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
|
|
113
|
-
export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiResponseConfig, LambderApiNullAnswerConfig, LambderContractEntry, LambderMergeContract,
|
|
123
|
+
export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiResponseConfig, LambderApiNullAnswerConfig, LambderContractEntry, LambderMergeContract, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractKeysWithGuard, LambderJsonOf, LambderJsonOutputOf, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractRateLimitNames, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
|
|
114
124
|
export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent, LambderHttpEventFormat } from "./core/LambderContext.js";
|
|
115
125
|
export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
|
|
116
126
|
export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
|
package/dist/index.js
CHANGED
|
@@ -50,6 +50,14 @@ export { LambderFiles } from "./core/LambderFiles.js";
|
|
|
50
50
|
export { LambderLocalFileSource } from "./stores/LambderLocalFileSource.js";
|
|
51
51
|
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
52
52
|
export { LambderHttpFileSource } from "./stores/LambderHttpFileSource.js";
|
|
53
|
+
// Direct uploads: storage a browser posts a file to with a ticket the server
|
|
54
|
+
// signed, the schemas an app's ticket endpoint declares, and the browser runner.
|
|
55
|
+
export { LambderS3UploadBucket } from "./stores/LambderS3UploadBucket.js";
|
|
56
|
+
export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
|
|
57
|
+
export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
|
|
58
|
+
export { LambderUploadFileFactsSchema, LambderUploadTicketSchema } from "./shared/wire/LambderUploadSchemas.js";
|
|
59
|
+
export { refuseUnacceptedUpload } from "./shared/wire/LambderUploadRefusal.js";
|
|
60
|
+
export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
|
|
53
61
|
// Compression: the option every site shares, and the one codec behind them all.
|
|
54
62
|
export { resolveCompressionOption, LAMBDER_ENCODINGS } from "./shared/wire/LambderCompressionOption.js";
|
|
55
63
|
// Brotli/gzip plus the bounded, length-verified restore every compressed
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { type LambderApiRequest } from "../api/LambderApiRequest.js";
|
|
2
2
|
import type { LambderApiAnswer } from "../api/LambderApiAnswer.js";
|
|
3
3
|
import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
4
|
+
/** What a resolver of Lambder's adapters is: msw's, answering a Response, or `undefined` to hand the request on. */
|
|
5
|
+
type LambderMswResolver = (info: {
|
|
6
|
+
request: Request;
|
|
7
|
+
}) => Promise<Response | undefined>;
|
|
4
8
|
/**
|
|
5
|
-
* The parts of the msw module
|
|
9
|
+
* The parts of the msw module Lambder's adapters use, `import * as msw from
|
|
10
|
+
* "msw"`: `http.post` for the API (lambderMockMswHandler) and `http.all` for
|
|
11
|
+
* an upload bucket's storage (lambderMockUploadMswHandler).
|
|
6
12
|
*
|
|
7
13
|
* Written so the real package satisfies it. msw's resolver answers a
|
|
8
14
|
* Response, or `undefined` to hand the request back (its
|
|
@@ -12,9 +18,8 @@ import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
|
12
18
|
*/
|
|
13
19
|
export type LambderMswModule = {
|
|
14
20
|
http: {
|
|
15
|
-
post: (path: string, resolver:
|
|
16
|
-
|
|
17
|
-
}) => Promise<Response | undefined>) => unknown;
|
|
21
|
+
post: (path: string, resolver: LambderMswResolver) => unknown;
|
|
22
|
+
all: (path: string, resolver: LambderMswResolver) => unknown;
|
|
18
23
|
};
|
|
19
24
|
HttpResponse: {
|
|
20
25
|
new (body?: BodyInit | null, init?: ResponseInit): Response;
|
|
@@ -101,3 +106,4 @@ export declare const lambderMockMswHandler: <M extends LambderMswModule>(mockApp
|
|
|
101
106
|
/** The client IP its calls are read as arriving from. Default: the runtime's own defaultClientIp. */
|
|
102
107
|
clientIp?: string;
|
|
103
108
|
}) => ReturnType<M["http"]["post"]>;
|
|
109
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LambderMemoryUploadBucket } from "../stores/LambderMemoryUploadBucket.js";
|
|
2
|
+
import type { LambderMswModule } from "./lambderMockMswHandler.js";
|
|
3
|
+
/**
|
|
4
|
+
* The MSW handler that makes a LambderMemoryUploadBucket the storage a mock
|
|
5
|
+
* app's browser uploads to: every request under the bucket's baseUrl (a
|
|
6
|
+
* LambderUploadRunner's post, a download link's GET) is answered by the
|
|
7
|
+
* bucket, the way S3 would answer it. Register it beside the API handler:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const documents = new LambderMemoryUploadBucket();
|
|
11
|
+
* setupWorker(
|
|
12
|
+
* lambderMockMswHandler(mockApp, { apiPath: "/api", msw }),
|
|
13
|
+
* lambderMockUploadMswHandler(documents, { msw }),
|
|
14
|
+
* );
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The mock's ticket and confirm handlers then call `documents` as the
|
|
18
|
+
* server's call the real bucket, so the runner's whole conversation runs, the
|
|
19
|
+
* checks storage makes on a post included.
|
|
20
|
+
*
|
|
21
|
+
* Lambder never depends on msw: the app installs it and passes the module
|
|
22
|
+
* in. Generic over the module so the handler keeps msw's own handler type.
|
|
23
|
+
*/
|
|
24
|
+
export declare const lambderMockUploadMswHandler: <M extends LambderMswModule>(bucket: LambderMemoryUploadBucket, options: {
|
|
25
|
+
msw: M;
|
|
26
|
+
}) => ReturnType<M["http"]["all"]>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MSW handler that makes a LambderMemoryUploadBucket the storage a mock
|
|
3
|
+
* app's browser uploads to: every request under the bucket's baseUrl (a
|
|
4
|
+
* LambderUploadRunner's post, a download link's GET) is answered by the
|
|
5
|
+
* bucket, the way S3 would answer it. Register it beside the API handler:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const documents = new LambderMemoryUploadBucket();
|
|
9
|
+
* setupWorker(
|
|
10
|
+
* lambderMockMswHandler(mockApp, { apiPath: "/api", msw }),
|
|
11
|
+
* lambderMockUploadMswHandler(documents, { msw }),
|
|
12
|
+
* );
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The mock's ticket and confirm handlers then call `documents` as the
|
|
16
|
+
* server's call the real bucket, so the runner's whole conversation runs, the
|
|
17
|
+
* checks storage makes on a post included.
|
|
18
|
+
*
|
|
19
|
+
* Lambder never depends on msw: the app installs it and passes the module
|
|
20
|
+
* in. Generic over the module so the handler keeps msw's own handler type.
|
|
21
|
+
*/
|
|
22
|
+
export const lambderMockUploadMswHandler = (bucket, options) => {
|
|
23
|
+
const { msw } = options;
|
|
24
|
+
if (!msw?.http?.all) {
|
|
25
|
+
throw new Error('lambderMockUploadMswHandler requires the msw module: lambderMockUploadMswHandler(bucket, { msw: await import("msw") }). Install it with: npm install msw --save-dev');
|
|
26
|
+
}
|
|
27
|
+
return msw.http.all(`${bucket.baseUrl}*`, async ({ request }) => (await bucket.handleStorageRequest(request)) ?? undefined);
|
|
28
|
+
};
|
package/dist/mock.d.ts
CHANGED
|
@@ -14,6 +14,9 @@ export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
|
|
|
14
14
|
export type { LambderMockConsoleLoggerOptions } from "./mock/lambderMockConsoleLogger.js";
|
|
15
15
|
export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
|
|
16
16
|
export type { LambderMswModule, LambderMockMswTarget } from "./mock/lambderMockMswHandler.js";
|
|
17
|
+
export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
|
|
18
|
+
export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
|
|
19
|
+
export { lambderMockUploadMswHandler } from "./mock/lambderMockUploadMswHandler.js";
|
|
17
20
|
export { lambderMockInvokeTransport } from "./mock/lambderMockInvokeTransport.js";
|
|
18
21
|
export type { LambderMockInvokeEvent, LambderMockInvokeResult } from "./mock/lambderMockInvokeTransport.js";
|
|
19
22
|
export { LambderCookieJar } from "./shared/transport/LambderCookieJar.js";
|
package/dist/mock.js
CHANGED
|
@@ -14,6 +14,10 @@ export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
|
|
|
14
14
|
export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
|
|
15
15
|
export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
|
|
16
16
|
export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
|
|
17
|
+
// The storage a mock app's uploads go to: a memory bucket the mock's ticket and
|
|
18
|
+
// confirm handlers call, and the MSW handler that answers its storage requests.
|
|
19
|
+
export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
|
|
20
|
+
export { lambderMockUploadMswHandler } from "./mock/lambderMockUploadMswHandler.js";
|
|
17
21
|
export { lambderMockInvokeTransport } from "./mock/lambderMockInvokeTransport.js";
|
|
18
22
|
// What a mock setup reaches for beside the app: the stores it runs on, the
|
|
19
23
|
// jar its transport carries, and the refusal a handler says no with.
|