lambder 8.1.1 → 8.3.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 (73) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +8 -7
  3. package/dist/api/LambderApiGuards.d.ts +2 -17
  4. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  5. package/dist/build/ContractTypePrinter.d.ts +39 -9
  6. package/dist/build/ContractTypePrinter.js +89 -31
  7. package/dist/build/generatedTables.d.ts +72 -0
  8. package/dist/build/generatedTables.js +99 -0
  9. package/dist/build/writeApiContract.d.ts +2 -1
  10. package/dist/build/writeApiContract.js +2 -1
  11. package/dist/build/writeApiGuardParams.d.ts +60 -0
  12. package/dist/build/writeApiGuardParams.js +85 -0
  13. package/dist/build/writeApiOptions.d.ts +68 -0
  14. package/dist/build/writeApiOptions.js +102 -0
  15. package/dist/build.d.ts +10 -4
  16. package/dist/build.js +7 -4
  17. package/dist/client/LambderUploadRunner.d.ts +7 -7
  18. package/dist/client/LambderUploadRunner.js +23 -32
  19. package/dist/client.d.ts +7 -0
  20. package/dist/client.js +11 -0
  21. package/dist/core/Lambder.d.ts +21 -0
  22. package/dist/core/Lambder.js +69 -0
  23. package/dist/index.d.ts +13 -0
  24. package/dist/index.js +13 -0
  25. package/dist/mock/LambderMockApp.d.ts +34 -17
  26. package/dist/mock/LambderMockApp.js +67 -21
  27. package/dist/mock/LambderMockCreateOptions.d.ts +68 -5
  28. package/dist/mock/LambderMockTypes.d.ts +29 -10
  29. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  30. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  31. package/dist/mock.d.ts +3 -0
  32. package/dist/mock.js +3 -0
  33. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  34. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  35. package/dist/session/LambderSessionCrypto.js +6 -16
  36. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  37. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  38. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  39. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  40. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  41. package/dist/shared/util/LambderBase64.d.ts +14 -0
  42. package/dist/shared/util/LambderBase64.js +17 -0
  43. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  44. package/dist/shared/util/LambderSignedClaims.js +109 -0
  45. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  46. package/dist/shared/util/LambderTextDigest.js +30 -5
  47. package/dist/shared/util/assertPlainData.d.ts +9 -0
  48. package/dist/shared/util/assertPlainData.js +41 -0
  49. package/dist/shared/util/escapeXmlText.d.ts +8 -0
  50. package/dist/shared/util/escapeXmlText.js +8 -0
  51. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  52. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  53. package/dist/shared/wire/LambderUploadObjectFields.js +2 -2
  54. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  55. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  56. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  57. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  58. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  59. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  60. package/dist/stores/LambderMemoryUploadBucket.js +2 -2
  61. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  62. package/dist/testing/LambderConformanceRunner.js +21 -0
  63. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  64. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  65. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  66. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  67. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  68. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  69. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  70. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  71. package/dist/testing.d.ts +14 -0
  72. package/dist/testing.js +12 -0
  73. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ import { canonicalJson } from "../shared/util/canonicalJson.js";
2
+ import { moduleUrlOf } from "./moduleLocation.js";
3
+ import { writeFileAtomically } from "./writeFileAtomically.js";
4
+ /** Imports the module, finds the instance under its export, and answers what it reports. `generator` names the caller in the errors. */
5
+ export const loadApiOptionEntries = async (location, generator) => {
6
+ const moduleUrl = moduleUrlOf(location.module);
7
+ const exportName = location.exportName ?? "default";
8
+ let namespace;
9
+ try {
10
+ namespace = await import(moduleUrl);
11
+ }
12
+ catch (err) {
13
+ throw new Error(`${generator} could not load ${moduleUrl}`, { cause: err });
14
+ }
15
+ const source = namespace[exportName];
16
+ if (typeof source?.apiOptionEntries !== "function") {
17
+ throw new Error(`${moduleUrl} has no export "${exportName}" that reports API options: name the export holding the instance in exportName`);
18
+ }
19
+ return source.apiOptionEntries();
20
+ };
21
+ /** One table of a generated file, read back as data, or null for a file that does not hold it as written. */
22
+ export const readGeneratedTable = (contents, exportName) => {
23
+ const match = new RegExp(`export const ${exportName}\\s*=\\s*([\\s\\S]*?)\\s+as const\\b`).exec(contents);
24
+ if (!match)
25
+ return null;
26
+ try {
27
+ const table = JSON.parse(match[1]);
28
+ return table !== null && typeof table === "object" && !Array.isArray(table) ? table : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ };
34
+ /** How one table's entries differ from the ones a file held, by name, compared as canonical JSON so key order is not a change. */
35
+ export const nameChangesOf = (current, previous) => {
36
+ const same = (name) => canonicalJson(current[name]) === canonicalJson(previous[name]);
37
+ return {
38
+ changed: Object.keys(current).filter((name) => name in previous && !same(name)).sort(),
39
+ added: Object.keys(current).filter((name) => !(name in previous)).sort(),
40
+ removed: Object.keys(previous).filter((name) => !(name in current)).sort(),
41
+ };
42
+ };
43
+ /** One line per name that moved, marked `~` changed, `+` added or `-` removed, under the table's name. */
44
+ const movedLinesOf = (tableName, changes) => [
45
+ ...changes.changed.map((name) => ` ~ ${tableName} ${name}`),
46
+ ...changes.added.map((name) => ` + ${tableName} ${name}`),
47
+ ...changes.removed.map((name) => ` - ${tableName} ${name}`),
48
+ ];
49
+ /** The opening comment of a generated file, one `//` line per line of the header. */
50
+ export const headerLinesOf = (header) => header.split("\n").map((line) => line ? `// ${line}` : "//");
51
+ /**
52
+ * One table as a statement: its doc comment, the `// prettier-ignore` that
53
+ * keeps a formatter off the JSON a check reads back, and the table itself,
54
+ * `as const` with whatever `satisfies` clause the caller gives.
55
+ */
56
+ export const tableStatementLines = (table) => [
57
+ `/** ${table.doc} */`,
58
+ "// prettier-ignore",
59
+ `export const ${table.name} = ${JSON.stringify(table.value, null, 4)} as const${table.satisfies ? ` satisfies ${table.satisfies}` : ""}${table.semicolon}`,
60
+ ];
61
+ /**
62
+ * The end of a generator's run. With `check`, it answers whether the file
63
+ * holds what the instance reports and writes nothing. Otherwise it writes
64
+ * the file unless it already holds these tables, however it is formatted, so
65
+ * a watcher or an incremental build sees no change where there is none (a
66
+ * change of header or semicolons shows the next time a table changes).
67
+ * Either way the lines say what moved.
68
+ */
69
+ export const settleGeneratedFile = (run) => {
70
+ const movedLines = run.tables.flatMap((table) => movedLinesOf(table.name, table.changes));
71
+ const summary = run.tables.map(({ name, count, changes }) => `${name}: ${changes.changed.length} changed, ${changes.added.length} added, ${changes.removed.length} removed (${count - changes.changed.length - changes.added.length} unchanged)`).join("; ");
72
+ const unchanged = run.readBack && movedLines.length === 0;
73
+ if (run.check) {
74
+ if (unchanged)
75
+ return { ok: true, written: false, lines: [`✓ ${run.name} matches the ${run.held}`] };
76
+ return {
77
+ ok: false,
78
+ written: false,
79
+ lines: [
80
+ run.previousText !== null && !run.readBack
81
+ ? `✗ ${run.name} does not hold ${run.tablesNoun} as written: regenerate it`
82
+ : `✗ ${run.name} is stale: regenerate it`,
83
+ ` ${summary}`,
84
+ ...movedLines,
85
+ ],
86
+ };
87
+ }
88
+ const written = !unchanged;
89
+ if (written)
90
+ writeFileAtomically(run.file, run.render(), run.previousText !== null);
91
+ return {
92
+ ok: true,
93
+ written,
94
+ lines: [
95
+ written ? `✓ Wrote ${run.name} (${run.held})` : `✓ ${run.name} is up to date (${run.held})`,
96
+ ...(movedLines.length ? [` ${summary}`, ...movedLines] : [` ${run.unmovedLine}`]),
97
+ ],
98
+ };
99
+ };
@@ -47,7 +47,8 @@ export type LambderApiContractFileResult = {
47
47
  * written module imports nothing, not even lambder, and exports one type
48
48
  * alias, `typeName`. Only the default library's interfaces (Date) are printed
49
49
  * by name. A non-generic named type is printed once, as a declaration of its
50
- * own that the entries refer to.
50
+ * own that the entries refer to; two that want one name are numbered by where
51
+ * each is declared, never by the order the APIs were registered in.
51
52
  *
52
53
  * Anything with no plain form fails the call and names where it sits: a
53
54
  * function, a symbol-keyed property, an enum, a class's private member, or a
@@ -24,7 +24,8 @@ const DIAGNOSTIC_LIMIT = 20;
24
24
  * written module imports nothing, not even lambder, and exports one type
25
25
  * alias, `typeName`. Only the default library's interfaces (Date) are printed
26
26
  * by name. A non-generic named type is printed once, as a declaration of its
27
- * own that the entries refer to.
27
+ * own that the entries refer to; two that want one name are numbered by where
28
+ * each is declared, never by the order the APIs were registered in.
28
29
  *
29
30
  * Anything with no plain form fails the call and names where it sits: a
30
31
  * function, a symbol-keyed property, an enum, a class's private member, or a
@@ -0,0 +1,60 @@
1
+ import type { LambderModuleLocation } from "./moduleLocation.js";
2
+ import { type LambderNameChanges } from "./generatedTables.js";
3
+ export type LambderApiGuardParamsFileOptions = {
4
+ /**
5
+ * The module that exports the instance, usually the server's entry: a
6
+ * path relative to the working directory, or a file URL. It is imported
7
+ * in this process, so a TypeScript module needs the process's loader
8
+ * (`tsx`, `node --import tsx`), as the generator script itself does.
9
+ */
10
+ module: LambderModuleLocation;
11
+ /** The export that holds the instance. Default: "default", the module's default export. */
12
+ exportName?: string;
13
+ /** The guard whose parameters the file holds, as the server declares it. A name the server declares no guard under throws. */
14
+ guard: string;
15
+ /** The TypeScript module to write, exporting `guardParams`. Relative to the working directory. */
16
+ file: string;
17
+ /** Write nothing, and answer whether the file on disk holds what the instance reports now. Default: false. */
18
+ check?: boolean;
19
+ /** The comment the file opens with, one `//` line per line. It should name what generates the file. Default: a note naming writeApiGuardParams() and the guard. */
20
+ header?: string;
21
+ /** End the generated statement with a semicolon. Default: true. The table itself is JSON, double quotes included, whatever the project's style: that is what lets a check read it back. */
22
+ semicolons?: boolean;
23
+ };
24
+ export type LambderApiGuardParamsFileResult = {
25
+ /** False when a check found the file stale or unreadable. */
26
+ ok: boolean;
27
+ /** The absolute path. */
28
+ file: string;
29
+ /** True when the file was (re)written; a file that already holds this table is left untouched, however it is formatted. */
30
+ written: boolean;
31
+ /** How many APIs declare the guard. */
32
+ count: number;
33
+ /** Which APIs moved against the file that was on disk. */
34
+ changes: LambderNameChanges;
35
+ /** What happened, as lines to print: a summary, then one line per name that moved. */
36
+ lines: string[];
37
+ };
38
+ /**
39
+ * Writes one guard's parameters as a module of plain data, or checks the one
40
+ * on disk, and says which APIs moved.
41
+ *
42
+ * ```ts
43
+ * import { writeApiGuardParams } from "lambder/build";
44
+ *
45
+ * const result = await writeApiGuardParams({
46
+ * module: "server/src/index.ts",
47
+ * exportName: "lambder",
48
+ * guard: "store",
49
+ * file: "web/src/generated/storeGuardParams.generated.ts",
50
+ * check: process.argv.includes("--check"),
51
+ * });
52
+ * ```
53
+ *
54
+ * The module exports `guardParams`, `as const`, so a client reads its types
55
+ * straight off it: `keyof typeof guardParams` is the APIs behind the guard,
56
+ * and `(typeof guardParams)[K]` the literal API K declared, `true` for a
57
+ * guard named without a parameter (the string and list forms). A parameter
58
+ * that is not plain data fails the write, as it does for writeApiOptions.
59
+ */
60
+ export declare const writeApiGuardParams: (options: LambderApiGuardParamsFileOptions) => Promise<LambderApiGuardParamsFileResult>;
@@ -0,0 +1,85 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { apiGuardParam } from "../shared/wire/LambderApiOptionEntries.js";
4
+ import { headerLinesOf, loadApiOptionEntries, nameChangesOf, readGeneratedTable, settleGeneratedFile, tableStatementLines, } from "./generatedTables.js";
5
+ /*
6
+ * One guard's parameters as a generated module a browser may carry: for each
7
+ * API that declares the guard, the parameter it gives it, and nothing else
8
+ * about any API.
9
+ *
10
+ * The file writeApiOptions writes holds every declaration, because a mock
11
+ * and a test need every declaration. A screen that decides whether to offer
12
+ * a control by the permission an endpoint's guard asks needs one guard's
13
+ * parameter, and a browser that imports the whole table as a value carries
14
+ * every API name, every mode and every guard's parameter, a reviewer's
15
+ * reason beside an open endpoint included. This is the least that screen
16
+ * needs: the names of the APIs behind the guard (which the client calls, so
17
+ * its own code names them already) and what each asks.
18
+ */
19
+ const TABLE_NAME = "guardParams";
20
+ const defaultHeader = (guard) => [
21
+ "Generated by writeApiGuardParams() from lambder/build. Do not edit.",
22
+ "",
23
+ `The parameter each API the server guards with "${guard}" gives that guard,`,
24
+ "and nothing else about any API: what a client decides with it, without",
25
+ "carrying the server's other declarations.",
26
+ ].join("\n");
27
+ /**
28
+ * Writes one guard's parameters as a module of plain data, or checks the one
29
+ * on disk, and says which APIs moved.
30
+ *
31
+ * ```ts
32
+ * import { writeApiGuardParams } from "lambder/build";
33
+ *
34
+ * const result = await writeApiGuardParams({
35
+ * module: "server/src/index.ts",
36
+ * exportName: "lambder",
37
+ * guard: "store",
38
+ * file: "web/src/generated/storeGuardParams.generated.ts",
39
+ * check: process.argv.includes("--check"),
40
+ * });
41
+ * ```
42
+ *
43
+ * The module exports `guardParams`, `as const`, so a client reads its types
44
+ * straight off it: `keyof typeof guardParams` is the APIs behind the guard,
45
+ * and `(typeof guardParams)[K]` the literal API K declared, `true` for a
46
+ * guard named without a parameter (the string and list forms). A parameter
47
+ * that is not plain data fails the write, as it does for writeApiOptions.
48
+ */
49
+ export const writeApiGuardParams = async (options) => {
50
+ const file = resolve(options.file);
51
+ const entries = await loadApiOptionEntries(options, "writeApiGuardParams");
52
+ if (!Object.prototype.hasOwnProperty.call(entries.guards, options.guard)) {
53
+ const declared = Object.keys(entries.guards);
54
+ throw new Error(`writeApiGuardParams: the server declares no guard "${options.guard}" (it declares ${declared.length ? declared.map((name) => `"${name}"`).join(", ") : "none"}).`);
55
+ }
56
+ const params = {};
57
+ for (const name of Object.keys(entries.apis).sort()) {
58
+ const param = apiGuardParam(entries.apis, name, options.guard);
59
+ if (param !== undefined)
60
+ params[name] = param;
61
+ }
62
+ const previousText = existsSync(file) ? readFileSync(file, "utf8") : null;
63
+ const previous = previousText === null ? null : readGeneratedTable(previousText, TABLE_NAME);
64
+ const changes = nameChangesOf(params, previous ?? {});
65
+ const count = Object.keys(params).length;
66
+ const settled = settleGeneratedFile({
67
+ name: options.file, file, check: options.check, previousText, readBack: previous !== null,
68
+ tables: [{ name: TABLE_NAME, count, changes }],
69
+ held: `${count} APIs guarded by "${options.guard}"`,
70
+ tablesNoun: "the table",
71
+ unmovedLine: "no parameters changed",
72
+ render: () => [
73
+ ...headerLinesOf(options.header ?? defaultHeader(options.guard)),
74
+ "",
75
+ ...tableStatementLines({
76
+ name: TABLE_NAME,
77
+ doc: `The parameter each API guarded by "${options.guard}" gives it: the value as declared, or true for the guard named without one.`,
78
+ value: params,
79
+ semicolon: options.semicolons === false ? "" : ";",
80
+ }),
81
+ "",
82
+ ].join("\n"),
83
+ });
84
+ return { ...settled, file, count, changes };
85
+ };
@@ -0,0 +1,68 @@
1
+ import type { LambderApiOptionEntries } from "../shared/wire/LambderApiOptionEntries.js";
2
+ import type { LambderModuleLocation } from "./moduleLocation.js";
3
+ import { type LambderNameChanges } from "./generatedTables.js";
4
+ type TableKey = keyof LambderApiOptionEntries;
5
+ export type LambderApiOptionsFileOptions = {
6
+ /**
7
+ * The module that exports the instance, usually the server's entry: a
8
+ * path relative to the working directory, or a file URL. It is imported
9
+ * in this process, so a TypeScript module needs the process's loader
10
+ * (`tsx`, `node --import tsx`), as the generator script itself does.
11
+ */
12
+ module: LambderModuleLocation;
13
+ /** The export that holds the instance. Default: "default", the module's default export. */
14
+ exportName?: string;
15
+ /** The TypeScript module to write, exporting `apiOptions`, `rateLimitPolicies` and `guardDeclarations`. Relative to the working directory. */
16
+ file: string;
17
+ /** Write nothing, and answer whether the file on disk holds what the instance reports now. Default: false. */
18
+ check?: boolean;
19
+ /** The comment the file opens with, one `//` line per line. It should name what generates the file. Default: a note naming writeApiOptions(). */
20
+ header?: string;
21
+ /** End the generated statements with semicolons. Default: true. The tables themselves are JSON, double quotes included, whatever the project's style: that is what lets a check read them back. */
22
+ semicolons?: boolean;
23
+ };
24
+ export type LambderApiOptionsFileResult = {
25
+ /** False when a check found the file stale or unreadable. */
26
+ ok: boolean;
27
+ /** The absolute path. */
28
+ file: string;
29
+ /** True when the file was (re)written; a file that already holds these tables is left untouched, however it is formatted. */
30
+ written: boolean;
31
+ /** How many APIs, policies and guards the tables hold. */
32
+ counts: Record<TableKey, number>;
33
+ /** What moved, per table, against the file that was on disk. */
34
+ changes: Record<TableKey, LambderNameChanges>;
35
+ /** What happened, as lines to print: a summary, then one line per name that moved. */
36
+ lines: string[];
37
+ };
38
+ /** The tables a generated file holds, read back as data, or null for a file that does not hold all three as written (never written, or rewritten by hand). */
39
+ export declare const readOptionTables: (contents: string) => LambderApiOptionEntries | null;
40
+ /**
41
+ * Writes the declared options of an app's APIs as a module of plain data, or
42
+ * checks the one on disk, and says which APIs, policies and guards moved.
43
+ *
44
+ * Call it from a generator script beside writeApiSignatures, naming the
45
+ * module that exports the app's instance:
46
+ *
47
+ * ```ts
48
+ * import { writeApiOptions } from "lambder/build";
49
+ *
50
+ * const result = await writeApiOptions({
51
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
52
+ * exportName: "lambder",
53
+ * file: "shared/generated/apiOptions.generated.ts",
54
+ * check: process.argv.includes("--check"),
55
+ * });
56
+ * console.log(result.lines.join("\n"));
57
+ * process.exit(result.ok ? 0 : 1);
58
+ * ```
59
+ *
60
+ * The tables are compared as the data the file holds, so a checkout that
61
+ * rewrote its line endings or a formatter that re-indented it is neither
62
+ * stale nor rewritten, and a file whose data is current is left as it is.
63
+ * There is no fresh-process check: nothing here is digested, so nothing can
64
+ * differ per process. A module that does not load, an export that is not an
65
+ * instance, or a guard parameter that is not plain data throws.
66
+ */
67
+ export declare const writeApiOptions: (options: LambderApiOptionsFileOptions) => Promise<LambderApiOptionsFileResult>;
68
+ export {};
@@ -0,0 +1,102 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { headerLinesOf, loadApiOptionEntries, nameChangesOf, readGeneratedTable, settleGeneratedFile, tableStatementLines, } from "./generatedTables.js";
4
+ /*
5
+ * The declared options of an app's APIs as a generated module of plain data:
6
+ * every API's mode and its guards, rateLimit and idempotency options as
7
+ * written, every rate-limit policy less its key handler, and every guard's
8
+ * input mode, session flag and place in the call. The contract already
9
+ * carries the options as types; this is the same fact as a value, for the
10
+ * code that decides something at runtime with it: a mock restating the
11
+ * server's declarations, a test walking the public surface. Neither imports
12
+ * the server for it. The file names every endpoint, so a production bundle
13
+ * imports none of it: a screen that gates on a guard reads that guard's
14
+ * parameters alone (writeApiGuardParams).
15
+ *
16
+ * Plain data by construction: the instance refuses to report a guard
17
+ * parameter that is code or a class instance (see Lambder.apiOptionEntries),
18
+ * a policy's key handler is reduced to `per: "custom"`, and a guard's schema
19
+ * to its input mode. No secret can reach the file, because nothing that could
20
+ * hold one is written.
21
+ */
22
+ const DEFAULT_HEADER = [
23
+ "Generated by writeApiOptions() from lambder/build. Do not edit.",
24
+ "",
25
+ "Every API the server registers with its declared options, every rate-limit",
26
+ "policy less its key handler, and every guard's input mode: the server's",
27
+ "declarations as plain data, for the code that decides something with them",
28
+ "without importing the server.",
29
+ ].join("\n");
30
+ /** The three tables the module exports, in the order they are written. */
31
+ const TABLES = [
32
+ { name: "apiOptions", key: "apis", type: "LambderApiOptionEntry", doc: "Every API the server registers: its mode and its guards, rateLimit and idempotency options as written." },
33
+ { name: "rateLimitPolicies", key: "rateLimitPolicies", type: "LambderRateLimitPolicyEntry", doc: "Every rate-limit policy the server declares, its key reduced to what it counts: an address, a session, or a key the app derives (\"custom\")." },
34
+ { name: "guardDeclarations", key: "guards", type: "LambderGuardDeclarationEntry", doc: "Every guard the server declares: how it is fed, whether it needs a session, and when it runs." },
35
+ ];
36
+ /** The tables a generated file holds, read back as data, or null for a file that does not hold all three as written (never written, or rewritten by hand). */
37
+ export const readOptionTables = (contents) => {
38
+ const tables = {};
39
+ for (const table of TABLES) {
40
+ const read = readGeneratedTable(contents, table.name);
41
+ if (!read)
42
+ return null;
43
+ tables[table.key] = read;
44
+ }
45
+ return tables;
46
+ };
47
+ const renderOptionsFile = (entries, options) => {
48
+ const semicolon = options.semicolons === false ? "" : ";";
49
+ return [
50
+ ...headerLinesOf(options.header ?? DEFAULT_HEADER),
51
+ `import type { ${TABLES.map((table) => table.type).join(", ")} } from "lambder/client"${semicolon}`,
52
+ ...TABLES.flatMap((table) => [
53
+ "",
54
+ ...tableStatementLines({ name: table.name, doc: table.doc, value: entries[table.key], satisfies: `Record<string, ${table.type}>`, semicolon }),
55
+ ]),
56
+ "",
57
+ ].join("\n");
58
+ };
59
+ /**
60
+ * Writes the declared options of an app's APIs as a module of plain data, or
61
+ * checks the one on disk, and says which APIs, policies and guards moved.
62
+ *
63
+ * Call it from a generator script beside writeApiSignatures, naming the
64
+ * module that exports the app's instance:
65
+ *
66
+ * ```ts
67
+ * import { writeApiOptions } from "lambder/build";
68
+ *
69
+ * const result = await writeApiOptions({
70
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
71
+ * exportName: "lambder",
72
+ * file: "shared/generated/apiOptions.generated.ts",
73
+ * check: process.argv.includes("--check"),
74
+ * });
75
+ * console.log(result.lines.join("\n"));
76
+ * process.exit(result.ok ? 0 : 1);
77
+ * ```
78
+ *
79
+ * The tables are compared as the data the file holds, so a checkout that
80
+ * rewrote its line endings or a formatter that re-indented it is neither
81
+ * stale nor rewritten, and a file whose data is current is left as it is.
82
+ * There is no fresh-process check: nothing here is digested, so nothing can
83
+ * differ per process. A module that does not load, an export that is not an
84
+ * instance, or a guard parameter that is not plain data throws.
85
+ */
86
+ export const writeApiOptions = async (options) => {
87
+ const file = resolve(options.file);
88
+ const entries = await loadApiOptionEntries(options, "writeApiOptions");
89
+ const previousText = existsSync(file) ? readFileSync(file, "utf8") : null;
90
+ const previous = previousText === null ? null : readOptionTables(previousText);
91
+ const changes = Object.fromEntries(TABLES.map((table) => [table.key, nameChangesOf(entries[table.key], previous?.[table.key] ?? {})]));
92
+ const counts = Object.fromEntries(TABLES.map((table) => [table.key, Object.keys(entries[table.key]).length]));
93
+ const settled = settleGeneratedFile({
94
+ name: options.file, file, check: options.check, previousText, readBack: previous !== null,
95
+ tables: TABLES.map((table) => ({ name: table.name, count: counts[table.key], changes: changes[table.key] })),
96
+ held: `${counts.apis} APIs, ${counts.rateLimitPolicies} policies, ${counts.guards} guards`,
97
+ tablesNoun: "the three tables",
98
+ unmovedLine: "no options changed",
99
+ render: () => renderOptionsFile(entries, options),
100
+ });
101
+ return { ...settled, file, counts, changes };
102
+ };
package/dist/build.d.ts CHANGED
@@ -2,13 +2,19 @@
2
2
  * Build entry point (`import ... from "lambder/build"`).
3
3
  *
4
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.
5
+ * ships: the signature file both sides read, the declared options as plain
6
+ * data, and one guard's parameters as the least a browser needs of them, all
7
+ * from the app's own instance; and the contract a client compiles against,
8
+ * from the server's sources. Node-only and imported by nothing else
9
+ * in the package, so no deployment or bundle carries it.
9
10
  */
10
11
  export { writeApiSignatures } from "./build/writeApiSignatures.js";
11
12
  export type { LambderApiSignatureSource, LambderApiSignatureFileOptions, LambderApiSignatureFileResult, } from "./build/writeApiSignatures.js";
12
13
  export { writeApiContract } from "./build/writeApiContract.js";
13
14
  export type { LambderApiContractFileOptions, LambderApiContractFileResult, } from "./build/writeApiContract.js";
15
+ export { writeApiOptions } from "./build/writeApiOptions.js";
16
+ export type { LambderApiOptionsFileOptions, LambderApiOptionsFileResult, } from "./build/writeApiOptions.js";
17
+ export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
18
+ export type { LambderApiGuardParamsFileOptions, LambderApiGuardParamsFileResult, } from "./build/writeApiGuardParams.js";
19
+ export type { LambderApiOptionsSource, LambderNameChanges } from "./build/generatedTables.js";
14
20
  export type { LambderModuleLocation } from "./build/moduleLocation.js";
package/dist/build.js CHANGED
@@ -2,10 +2,13 @@
2
2
  * Build entry point (`import ... from "lambder/build"`).
3
3
  *
4
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.
5
+ * ships: the signature file both sides read, the declared options as plain
6
+ * data, and one guard's parameters as the least a browser needs of them, all
7
+ * from the app's own instance; and the contract a client compiles against,
8
+ * from the server's sources. Node-only and imported by nothing else
9
+ * in the package, so no deployment or bundle carries it.
9
10
  */
10
11
  export { writeApiSignatures } from "./build/writeApiSignatures.js";
11
12
  export { writeApiContract } from "./build/writeApiContract.js";
13
+ export { writeApiOptions } from "./build/writeApiOptions.js";
14
+ export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
@@ -48,10 +48,11 @@ export type LambderUploadRunnerOptions<Reference, Receipt> = {
48
48
  /**
49
49
  * How storage is tried again when it cannot be reached, stalls, or answers
50
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.
51
+ * is `baseDelayMs` plus a random share of a ceiling that starts at
52
+ * `baseDelayMs` and doubles with every failed attempt, the whole never
53
+ * past `maxDelayMs` (the ladder of LambderBackoffTimer), so many browsers
54
+ * dropped together do not come back in step, not even the first time.
55
+ * Default: 4 attempts, waits from one second to 15.
55
56
  */
56
57
  storageRetry?: {
57
58
  attempts?: number;
@@ -69,8 +70,8 @@ export type LambderUploadRunnerOptions<Reference, Receipt> = {
69
70
  export declare class LambderUploadRunner<Reference, Receipt> {
70
71
  private readonly options;
71
72
  private readonly attempts;
72
- private readonly baseDelayMs;
73
- private readonly maxDelayMs;
73
+ /** The ladder one upload's waits climb; each upload() builds a timer of its own from it, so two uploads never share a count. */
74
+ private readonly backoff;
74
75
  private readonly stallTimeoutMs;
75
76
  constructor(options: LambderUploadRunnerOptions<Reference, Receipt>);
76
77
  /** For a file input's `accept`, so the picker only offers what the rule takes. */
@@ -90,7 +91,6 @@ export declare class LambderUploadRunner<Reference, Receipt> {
90
91
  }): Promise<Receipt>;
91
92
  /** Forgets a confirmed upload through the app's endpoint, when it declared one. */
92
93
  discard(receipt: Receipt): Promise<void>;
93
- private waitBeforeRetry;
94
94
  /** One post of the file to storage. Never throws: every ending is an outcome. */
95
95
  private post;
96
96
  }
@@ -1,4 +1,5 @@
1
1
  import { checkUploadRule, } from "../shared/contracts/LambderUploadBucket.js";
2
+ import { LambderBackoffTimer } from "../shared/util/LambderBackoffTimer.js";
2
3
  import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
3
4
  /** How an upload failed: `reason` for a screen to word, the underlying error as `cause`. */
4
5
  export class LambderUploadError extends Error {
@@ -21,14 +22,16 @@ const TRANSIENT_STORAGE_CODES = new Set(["RequestTimeout", "SlowDown", "Internal
21
22
  export class LambderUploadRunner {
22
23
  options;
23
24
  attempts;
24
- baseDelayMs;
25
- maxDelayMs;
25
+ /** The ladder one upload's waits climb; each upload() builds a timer of its own from it, so two uploads never share a count. */
26
+ backoff;
26
27
  stallTimeoutMs;
27
28
  constructor(options) {
28
29
  this.options = options;
29
30
  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;
31
+ const baseDelayMs = options.storageRetry?.baseDelayMs ?? 1_000;
32
+ const maxDelayMs = options.storageRetry?.maxDelayMs ?? 15_000;
33
+ // A longest wait below the shortest is every wait at the shortest.
34
+ this.backoff = { baseMs: baseDelayMs, maxMs: Math.max(baseDelayMs, maxDelayMs) };
32
35
  this.stallTimeoutMs = options.stallTimeoutMs ?? 60_000;
33
36
  }
34
37
  /** For a file input's `accept`, so the picker only offers what the rule takes. */
@@ -59,18 +62,16 @@ export class LambderUploadRunner {
59
62
  };
60
63
  stopIfCancelled();
61
64
  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
65
  const fileFacts = {
70
66
  fileName: file.name,
71
67
  mimeType: file.type,
72
68
  byteSize: file.size,
73
- sha256Base64: await sha256Base64Of(bytes),
69
+ // Read straight into the digest, never into a variable: the buffer
70
+ // is the size of the file, and a local would keep it through
71
+ // every await of the upload that follows.
72
+ sha256Base64: await sha256Base64Of(new Uint8Array(await file.arrayBuffer().catch((cause) => {
73
+ throw new LambderUploadError("fileUnreadable", { cause });
74
+ }))),
74
75
  };
75
76
  stopIfCancelled();
76
77
  const requestTicket = async () => {
@@ -83,6 +84,7 @@ export class LambderUploadRunner {
83
84
  }
84
85
  };
85
86
  let issued = await requestTicket();
87
+ const backoff = new LambderBackoffTimer(this.backoff);
86
88
  let failedAttempts = 0;
87
89
  let ticketRenewals = 0;
88
90
  for (;;) {
@@ -105,7 +107,10 @@ export class LambderUploadRunner {
105
107
  // connection does not leave the app a record per attempt.
106
108
  if (++failedAttempts >= this.attempts)
107
109
  throw new LambderUploadError("networkFailed");
108
- await this.waitBeforeRetry(failedAttempts, signal);
110
+ // The wait rejects only for the signal: nothing else cancels it.
111
+ await backoff.wait(signal).catch((cause) => {
112
+ throw new LambderUploadError("cancelled", { cause });
113
+ });
109
114
  }
110
115
  report("confirming", file.size);
111
116
  try {
@@ -119,22 +124,6 @@ export class LambderUploadRunner {
119
124
  async discard(receipt) {
120
125
  await this.options.discardUpload?.(receipt);
121
126
  }
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
127
  /** One post of the file to storage. Never throws: every ending is an outcome. */
139
128
  post(ticket, file, signal, onSent) {
140
129
  if (signal?.aborted)
@@ -218,8 +207,10 @@ const xmlText = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (entity, na
218
207
  /**
219
208
  * Storage explains a refusal in XML, `<Error><Code/><Message/></Error>`,
220
209
  * 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.
210
+ * transient code is tried again like a dropped connection. An expired ticket
211
+ * is the one refusal a new ticket cures, and so is ExpiredToken: the
212
+ * temporary credentials that signed the ticket ran out before it did, and
213
+ * the next one is signed with fresh ones.
223
214
  */
224
215
  const rejectedOutcome = (status, body) => {
225
216
  const code = xmlText(/<Code>([^<]*)<\/Code>/.exec(body)?.[1] ?? "") || `HTTP ${status}`;
@@ -228,7 +219,7 @@ const rejectedOutcome = (status, body) => {
228
219
  return { kind: "unreachable" };
229
220
  return {
230
221
  kind: "rejected",
231
- ticketExpired: code === "AccessDenied" && /expired/i.test(message),
222
+ ticketExpired: code === "ExpiredToken" || (code === "AccessDenied" && /expired/i.test(message)),
232
223
  detail: message ? `${code}: ${message}` : code,
233
224
  };
234
225
  };