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.
Files changed (53) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +7 -2
  3. package/dist/build/ContractTypePrinter.d.ts +85 -0
  4. package/dist/build/ContractTypePrinter.js +402 -0
  5. package/dist/build/moduleLocation.d.ts +11 -0
  6. package/dist/build/moduleLocation.js +6 -0
  7. package/dist/build/writeApiContract.d.ts +78 -0
  8. package/dist/build/writeApiContract.js +302 -0
  9. package/dist/build/writeApiSignatures.d.ts +32 -27
  10. package/dist/build/writeApiSignatures.js +37 -42
  11. package/dist/build/writeFileAtomically.d.ts +8 -0
  12. package/dist/build/writeFileAtomically.js +22 -0
  13. package/dist/build.d.ts +8 -3
  14. package/dist/build.js +6 -3
  15. package/dist/client/LambderUploadRunner.d.ts +96 -0
  16. package/dist/client/LambderUploadRunner.js +234 -0
  17. package/dist/client.d.ts +4 -0
  18. package/dist/client.js +4 -0
  19. package/dist/core/Lambder.d.ts +9 -10
  20. package/dist/core/Lambder.js +9 -10
  21. package/dist/index.d.ts +11 -1
  22. package/dist/index.js +8 -0
  23. package/dist/mock/lambderMockMswHandler.d.ts +10 -4
  24. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  25. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  26. package/dist/mock.d.ts +3 -0
  27. package/dist/mock.js +4 -0
  28. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  29. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  30. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  31. package/dist/shared/util/LambderContentDisposition.js +13 -0
  32. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  33. package/dist/shared/util/LambderTextDigest.js +11 -5
  34. package/dist/shared/wire/LambderApiContract.d.ts +10 -40
  35. package/dist/shared/wire/LambderApiRefusal.d.ts +6 -0
  36. package/dist/shared/wire/LambderApiRefusal.js +6 -0
  37. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  38. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  39. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  40. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  41. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  42. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  43. package/dist/stores/LambderDdbSdk.js +1 -5
  44. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  45. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  46. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  47. package/dist/stores/LambderS3UploadBucket.js +144 -0
  48. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  49. package/dist/stores/LambderSdkInstallHint.js +14 -0
  50. package/dist/testing/LambderTestApp.d.ts +4 -4
  51. package/dist/testing.d.ts +2 -0
  52. package/dist/testing.js +1 -0
  53. 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 over the app's own instance to
5
- * write the signature file both sides ship. Node-only and imported by nothing
6
- * else in the package, so no deployment or bundle carries it.
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 over the app's own instance to
5
- * write the signature file both sides ship. Node-only and imported by nothing
6
- * else in the package, so no deployment or bundle carries it.
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";
@@ -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, to export your API
72
- * types to the frontend.
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
- * Export it as an interface extending LambderFlattenContract, not as a
75
- * type alias. Chaining builds the contract as an intersection one member
76
- * deep per endpoint; an interface collapses that into one declared set of
77
- * members, which every generic read of the contract (a mock registry, a
78
- * needs map, the typed caller) checks far more cheaply. See
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 = new Lambder().addApi(...).addApi(...);
84
- * export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
82
+ * export const lambder = initLambder().create({ ... }).addApi(...).addApi(...);
83
+ * export type ApiContractType = typeof lambder.ApiContract;
85
84
  * ```
86
85
  */
87
86
  readonly ApiContract: _TContract;
@@ -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, to export your API
61
- * types to the frontend.
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
- * Export it as an interface extending LambderFlattenContract, not as a
64
- * type alias. Chaining builds the contract as an intersection one member
65
- * deep per endpoint; an interface collapses that into one declared set of
66
- * members, which every generic read of the contract (a mock registry, a
67
- * needs map, the typed caller) checks far more cheaply. See
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 = new Lambder().addApi(...).addApi(...);
73
- * export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
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, LambderFlattenContract, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractKeysWithGuard, LambderJsonOf, LambderJsonOutputOf, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractRateLimitNames, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
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 the adapter uses: `import * as msw from "msw"`.
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: (info: {
16
- request: Request;
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.