lambder 8.1.2 → 9.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +23 -31
  3. package/dist/api/LambderApiCallContext.d.ts +31 -1
  4. package/dist/api/LambderApiCallContext.js +8 -0
  5. package/dist/api/LambderApiDefinition.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +3 -4
  8. package/dist/api/LambderApiGuards.d.ts +2 -17
  9. package/dist/api/LambderApiIdempotency.js +5 -7
  10. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  11. package/dist/build/generatedTables.d.ts +72 -0
  12. package/dist/build/generatedTables.js +99 -0
  13. package/dist/build/writeApiGuardParams.d.ts +60 -0
  14. package/dist/build/writeApiGuardParams.js +85 -0
  15. package/dist/build/writeApiOptions.d.ts +68 -0
  16. package/dist/build/writeApiOptions.js +102 -0
  17. package/dist/build.d.ts +10 -4
  18. package/dist/build.js +7 -4
  19. package/dist/client/LambderCaller.d.ts +0 -4
  20. package/dist/client/LambderCaller.js +1 -9
  21. package/dist/client/LambderUploadRunner.d.ts +7 -7
  22. package/dist/client/LambderUploadRunner.js +12 -21
  23. package/dist/client.d.ts +7 -0
  24. package/dist/client.js +11 -0
  25. package/dist/core/Lambder.d.ts +71 -12
  26. package/dist/core/Lambder.js +116 -38
  27. package/dist/core/LambderContext.d.ts +9 -6
  28. package/dist/core/LambderContext.js +2 -1
  29. package/dist/core/LambderResolver.d.ts +6 -12
  30. package/dist/core/LambderResolver.js +2 -14
  31. package/dist/core/LambderResponseBuilder.d.ts +15 -70
  32. package/dist/core/LambderResponseBuilder.js +15 -99
  33. package/dist/index.d.ts +16 -3
  34. package/dist/index.js +14 -1
  35. package/dist/invoke/LambderInvokeCaller.js +3 -4
  36. package/dist/mock/LambderMockApp.d.ts +34 -17
  37. package/dist/mock/LambderMockApp.js +69 -24
  38. package/dist/mock/LambderMockCreateOptions.d.ts +70 -7
  39. package/dist/mock/LambderMockTypes.d.ts +31 -21
  40. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  41. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  42. package/dist/mock.d.ts +3 -0
  43. package/dist/mock.js +3 -0
  44. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  45. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  46. package/dist/session/LambderSessionCrypto.js +6 -16
  47. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  48. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  49. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  50. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  51. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  52. package/dist/shared/util/LambderBase64.d.ts +14 -0
  53. package/dist/shared/util/LambderBase64.js +17 -0
  54. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  55. package/dist/shared/util/LambderSignedClaims.js +109 -0
  56. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  57. package/dist/shared/util/LambderTextDigest.js +30 -5
  58. package/dist/shared/util/LambderTypeUtilities.d.ts +18 -0
  59. package/dist/shared/util/assertPlainData.d.ts +9 -0
  60. package/dist/shared/util/assertPlainData.js +41 -0
  61. package/dist/shared/wire/LambderAnswerHeaders.d.ts +3 -2
  62. package/dist/shared/wire/LambderAnswerHeaders.js +3 -2
  63. package/dist/shared/wire/LambderApiContract.d.ts +9 -14
  64. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  65. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  66. package/dist/shared/wire/LambderApiRefusal.d.ts +3 -4
  67. package/dist/shared/wire/LambderApiRefusal.js +3 -4
  68. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  69. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  70. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  71. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  72. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  73. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  74. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  75. package/dist/testing/LambderConformanceRunner.js +21 -0
  76. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  77. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  78. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  79. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  80. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  81. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  82. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  83. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  84. package/dist/testing.d.ts +14 -0
  85. package/dist/testing.js +12 -0
  86. 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
+ };
@@ -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";
@@ -29,7 +29,6 @@ type FetchEndEventHandler = (params: {
29
29
  }) => void | Promise<void>;
30
30
  type ErrorHandler = (err: Error) => void | Promise<void>;
31
31
  type ValidationErrorHandler = (zodError: LambderValidationError) => (void | false) | Promise<(void | false)>;
32
- type MessageHandler = (message: LambderAppRefusalMessage | string) => void | Promise<void>;
33
32
  /** Handed the refusal as its message object, a plain-string errorMessage having been read as one (refusalMessageOf). */
34
33
  type ErrorMessageHandler = (message: LambderAppRefusalMessage) => void | Promise<void>;
35
34
  /** The logListHandler option: an answer's logList, success or failure, when it has entries. The invoke caller's onLogList, for a browser. */
@@ -41,7 +40,6 @@ export type LambderLogListHandler = (apiName: string, logList: unknown[]) => voi
41
40
  export type LambderCallOptions = LambderSharedCallOptions & {
42
41
  versionExpiredHandler?: NotifyHandler;
43
42
  sessionExpiredHandler?: NotifyHandler;
44
- messageHandler?: MessageHandler;
45
43
  errorMessageHandler?: ErrorMessageHandler;
46
44
  apiInputValidationErrorHandler?: ValidationErrorHandler;
47
45
  notAuthorizedHandler?: NotifyHandler;
@@ -73,7 +71,6 @@ type LambderCallerBaseOptions = {
73
71
  timeoutMs?: number;
74
72
  versionExpiredHandler?: NotifyHandler;
75
73
  sessionExpiredHandler?: NotifyHandler;
76
- messageHandler?: MessageHandler;
77
74
  errorMessageHandler?: ErrorMessageHandler;
78
75
  notAuthorizedHandler?: NotifyHandler;
79
76
  errorHandler?: ErrorHandler;
@@ -119,7 +116,6 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
119
116
  get isLoading(): boolean;
120
117
  private versionExpiredHandler?;
121
118
  private sessionExpiredHandler?;
122
- private messageHandler?;
123
119
  private errorMessageHandler?;
124
120
  private notAuthorizedHandler?;
125
121
  private errorHandler?;
@@ -33,7 +33,6 @@ export default class LambderCaller {
33
33
  get isLoading() { return this.fetchTrackerList.length > 0; }
34
34
  versionExpiredHandler;
35
35
  sessionExpiredHandler;
36
- messageHandler;
37
36
  errorMessageHandler;
38
37
  notAuthorizedHandler;
39
38
  errorHandler;
@@ -50,7 +49,7 @@ export default class LambderCaller {
50
49
  constructor(options) {
51
50
  // The conditional provider option is resolved per instantiation;
52
51
  // inside the class it is read through the plain shape.
53
- const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
52
+ const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
54
53
  this.apiPath = apiPath;
55
54
  this.apiVersion = apiVersion;
56
55
  this.apiSignatures = apiSignatures;
@@ -61,7 +60,6 @@ export default class LambderCaller {
61
60
  this.transport = transport ?? lambderFetchTransport({ cors: isCorsEnabled });
62
61
  this.versionExpiredHandler = versionExpiredHandler;
63
62
  this.sessionExpiredHandler = sessionExpiredHandler;
64
- this.messageHandler = messageHandler;
65
63
  this.errorMessageHandler = errorMessageHandler;
66
64
  this.notAuthorizedHandler = notAuthorizedHandler;
67
65
  this.errorHandler = errorHandler;
@@ -100,7 +98,6 @@ export default class LambderCaller {
100
98
  // Per-call overrides win over the constructor handlers.
101
99
  const versionExpiredHandler = options?.versionExpiredHandler ?? this.versionExpiredHandler;
102
100
  const sessionExpiredHandler = options?.sessionExpiredHandler ?? this.sessionExpiredHandler;
103
- const messageHandler = options?.messageHandler ?? this.messageHandler;
104
101
  const errorMessageHandler = options?.errorMessageHandler ?? this.errorMessageHandler;
105
102
  const notAuthorizedHandler = options?.notAuthorizedHandler ?? this.notAuthorizedHandler;
106
103
  const errorHandler = options?.errorHandler ?? this.errorHandler;
@@ -328,11 +325,6 @@ export default class LambderCaller {
328
325
  }
329
326
  return outcome;
330
327
  }
331
- // Presence, not truthiness: the envelope keeps a message an app
332
- // spelled out as the empty string, so the handler runs for it.
333
- if (data.message !== undefined && messageHandler) {
334
- await messageHandler(data.message);
335
- }
336
328
  if (!outcome.ok && outcome.reason === 'errorMessage') {
337
329
  if (errorMessageHandler && outcome.errorMessage !== undefined) {
338
330
  await errorMessageHandler(outcome.errorMessage);
@@ -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 of twice that,
52
- * doubling with every failed attempt and never past `maxDelayMs`, so many
53
- * browsers dropped together do not come back in step, not even the first
54
- * time. Default: 4 attempts, waits from one 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. */
@@ -81,6 +84,7 @@ export class LambderUploadRunner {
81
84
  }
82
85
  };
83
86
  let issued = await requestTicket();
87
+ const backoff = new LambderBackoffTimer(this.backoff);
84
88
  let failedAttempts = 0;
85
89
  let ticketRenewals = 0;
86
90
  for (;;) {
@@ -103,7 +107,10 @@ export class LambderUploadRunner {
103
107
  // connection does not leave the app a record per attempt.
104
108
  if (++failedAttempts >= this.attempts)
105
109
  throw new LambderUploadError("networkFailed");
106
- 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
+ });
107
114
  }
108
115
  report("confirming", file.size);
109
116
  try {
@@ -117,22 +124,6 @@ export class LambderUploadRunner {
117
124
  async discard(receipt) {
118
125
  await this.options.discardUpload?.(receipt);
119
126
  }
120
- waitBeforeRetry(failedAttempts, signal) {
121
- const ceiling = Math.max(this.baseDelayMs, Math.min(this.baseDelayMs * 2 ** failedAttempts, this.maxDelayMs));
122
- return new Promise((resolve, reject) => {
123
- if (signal?.aborted)
124
- return reject(new LambderUploadError("cancelled"));
125
- const cancel = () => {
126
- clearTimeout(timer);
127
- reject(new LambderUploadError("cancelled"));
128
- };
129
- const timer = setTimeout(() => {
130
- signal?.removeEventListener("abort", cancel);
131
- resolve();
132
- }, this.baseDelayMs + Math.random() * (ceiling - this.baseDelayMs));
133
- signal?.addEventListener("abort", cancel, { once: true });
134
- });
135
- }
136
127
  /** One post of the file to storage. Never throws: every ending is an outcome. */
137
128
  post(ticket, file, signal, onSent) {
138
129
  if (signal?.aborted)