lambder 12.0.1 → 13.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 (50) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +7 -2
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiEnvelope.js +4 -1
  5. package/dist/api/LambderApiRefusals.d.ts +22 -0
  6. package/dist/api/LambderApiRefusals.js +13 -0
  7. package/dist/build/generateApiFiles.d.ts +60 -0
  8. package/dist/build/generateApiFiles.js +85 -0
  9. package/dist/build.d.ts +4 -1
  10. package/dist/build.js +3 -1
  11. package/dist/core/Lambder.d.ts +32 -8
  12. package/dist/core/Lambder.js +135 -17
  13. package/dist/core/LambderCallSummary.d.ts +43 -0
  14. package/dist/core/LambderCallSummary.js +10 -0
  15. package/dist/core/LambderContext.d.ts +14 -1
  16. package/dist/core/LambderContext.js +19 -4
  17. package/dist/core/LambderCreateOptions.d.ts +50 -25
  18. package/dist/core/LambderCreateOptions.js +37 -18
  19. package/dist/core/LambderInvocationScope.d.ts +25 -0
  20. package/dist/core/LambderInvocationScope.js +21 -0
  21. package/dist/core/LambderResponse.d.ts +11 -0
  22. package/dist/core/LambderResponse.js +12 -0
  23. package/dist/core/LambderResponseBuilder.js +5 -1
  24. package/dist/index.d.ts +6 -4
  25. package/dist/index.js +1 -1
  26. package/dist/invoke/LambderLambdaEvent.d.ts +2 -0
  27. package/dist/invoke/LambderLambdaEvent.js +8 -2
  28. package/dist/mock/LambderMockApp.d.ts +8 -8
  29. package/dist/mock/LambderMockApp.js +14 -10
  30. package/dist/mock/LambderMockCreateOptions.d.ts +2 -3
  31. package/dist/session/LambderSessionManager.d.ts +46 -3
  32. package/dist/session/LambderSessionManager.js +62 -11
  33. package/dist/shared/LambderI18n.js +49 -4
  34. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  35. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  36. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  37. package/dist/shared/util/LambderInvocationScope.js +5 -0
  38. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  39. package/dist/shared/util/LambderNamedMaps.js +19 -0
  40. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  41. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  42. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  43. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  44. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  45. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  46. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  47. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  48. package/dist/testing/LambderTestApp.d.ts +13 -4
  49. package/dist/testing/LambderTestApp.js +21 -5
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,86 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [13.0.1] - 2026-09-30
13
+
14
+ A major: session data is checked against a schema on every read, and
15
+ `dataRefresh` requires that schema. Beside it, one summary line per API call
16
+ by default, an origin proof for the trusted forwarding headers, refusal codes
17
+ declared per part of an app, a check that every translation keeps its
18
+ placeholders, one round trip for a rate limit's windows, and every generated
19
+ file in one call.
20
+
21
+ ### Changed (breaking)
22
+
23
+ - **`session.dataRefresh` requires `session.dataSchema`**, the zod schema of
24
+ `session.data`, at compile time and at creation. Every read checks the
25
+ record's data against it and hands the request the schema's output; data
26
+ that does not match (a record written before a deploy changed its shape)
27
+ is refreshed on that read, whatever its deadline, and a refresh whose
28
+ output the schema refuses fails the read as a
29
+ `LambderSessionDataRefreshError`, writing nothing. Without `dataRefresh`
30
+ the schema is optional, and data that does not match ends the session.
31
+ With a schema, `createSession` and `updateSessionData` throw on data it
32
+ refuses and write nothing, rather than store a session its next read
33
+ would end. A record keeps its data as written or refreshed, and requests
34
+ are served the schema's output, so every read parses exactly what its
35
+ write was checked with. The same holds for the mock runtime's sessions.
36
+ - To move: give `dataSchema` beside `dataRefresh`, the schema your
37
+ `SessionData` type is, and derive the type from it
38
+ (`z.infer<typeof sessionDataSchema>`) so the two cannot drift.
39
+ - **The DynamoDB rate limiter sends every window's write at once**, so a
40
+ policy capping several windows costs one round trip rather than one per
41
+ window. A window under its limit now counts an attempt another window
42
+ refuses (the smaller windows were counted first, and the larger ones
43
+ spared, before), and a window the table refused an attempt in is
44
+ remembered by the process until it resets, so a flood's later attempts
45
+ are refused without touching the table and stop raising the other
46
+ windows' counters. The memory limiter checks every window before counting
47
+ any, so an attempt it refuses counts nowhere.
48
+ - **`LambderApiAnswer` and `LambderResponse` carry the answer's outcome**
49
+ (`outcome`, `callOutcome`), which the envelope builders and
50
+ `res.apiRefusal()` set, and `LambderInstanceBackends` takes a
51
+ `callSummary` writer.
52
+
53
+ ### Added
54
+
55
+ - **One summary line per API call** (`callSummary`), written when the call
56
+ is answered: the endpoint, how it ended (`success`, `refusal` and its
57
+ code, a flag, `validation`, `crash`, a failing `created` hook's included),
58
+ the status, the duration and the handler's own time, whether an
59
+ idempotent answer was replayed, whether the invocation was the process's
60
+ first, and its request id with the id of the invocation that invoked it.
61
+ Nothing from the input, the session, the cookies or the caller's address.
62
+ By default each is one JSON line on stdout, which CloudWatch Logs Insights
63
+ reads field by field; a function receives each instead, and `false`
64
+ writes none. A test app collects them in `app.callSummaries`.
65
+ - **The calling invocation's request id travels over invoke**
66
+ (`LAMBDER_PARENT_REQUEST_HEADER`), read by the callee from an invoke
67
+ alone, so the two functions' summary lines join.
68
+ - **`originProof: { header, secrets }`**: the trusted client address and
69
+ host headers are read only from a request carrying the secret the proxy in
70
+ front of the app sets, so a request sent to the origin directly (an
71
+ `execute-api` or `lambda-url` address) is counted by the address it came
72
+ from. The header is taken off `ctx.headers`; `secrets` takes the previous
73
+ secret during a rotation; a secret under 32 characters, a proof guarding
74
+ no trusted header, and a proof header that is also a trusted one are
75
+ refused at creation.
76
+ - **Refusal codes as a list of maps**: `declareRefusals([orderRefusals,
77
+ walletRefusals])`, on the server's init and the mock's alike, so each part
78
+ of an app declares its own codes, as guards and rate-limit policies are
79
+ declared. A code two maps declare is a compile error and a throw.
80
+ - **Every translation keeps the default language's placeholders.** A block
81
+ whose text drops or adds a `{token}` against the default language's text
82
+ of the same key is refused where it arrives: an inline block when the
83
+ instance or an extension is created, a loaded one when its loader answers
84
+ (`loadLanguage` rejects, and the language stays on the default one), a
85
+ registered one at `registerDictionary`. Each error lists every mismatch of
86
+ the block.
87
+ - **`generateApiFiles(config, { check })`** in `lambder/build`: every
88
+ contract, signature file, options file and guard-parameter file of every
89
+ app a generator script names, each app's module given once, in one call
90
+ that names every stale or broken file and does not stop at the first.
91
+
12
92
  ## [12.0.1] - 2026-09-30
13
93
 
14
94
  A major: endpoints are values. An app declares each endpoint with its
package/README.md CHANGED
@@ -65,7 +65,8 @@ const company = await caller.companies.get({ slug: "acme" });
65
65
  - **Simple route and API declaration.** Paths, regexes, predicates and
66
66
  structured matchers, chained fluently.
67
67
  - **Sessions.** Over a store of your choosing (DynamoDB, memory, your own),
68
- with secrets hashed at rest, sliding expiration, data refresh and
68
+ with secrets hashed at rest, sliding expiration, session data checked
69
+ against its schema on every read and refreshed from its source, and
69
70
  cross-subdomain cookies.
70
71
  - **One API core, two runtimes.** The request pipeline (envelope, refusals,
71
72
  sessions, guards, rate limits, idempotency) is one isomorphic class; the
@@ -79,6 +80,10 @@ const company = await caller.companies.get({ slug: "acme" });
79
80
  cookies, and a guard against Lambda's response size cap.
80
81
  - **Hooks and actions.** Lifecycle hooks, plus `addAction()` for the non-HTTP
81
82
  invocations (EventBridge, SQS, custom events) the same function receives.
83
+ - **A summary line per call.** Every API call is logged once, as JSON, with
84
+ its endpoint, outcome, refusal code, status and timings, and the request
85
+ ids that join it to the calls it made; nothing from its input or its
86
+ caller.
82
87
  - **Lambda to lambda calls.** `LambderInvokeCaller` invokes a Lambder app in
83
88
  another function directly, with no API Gateway in between, typed from the
84
89
  callee's own contract and carrying its refusals, crash detail and logs back.
@@ -135,7 +140,7 @@ The package ships five entry points; pick by where the code runs:
135
140
  | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `LambderUploadRunner`, `LambderBackoffTimer`, `LambderSignedClaims`, `html`/`xml` tagged templates, `createLambderI18n` |
136
141
  | `lambder/mock` | Browser and Node, in development and tests | `LambderMockApp`, the mock runtime: your typed contract served from mock handlers over the real API pipeline and memory stores |
137
142
  | `lambder/testing` | Node, in tests | `lambderTestApp`: your real instance under test in this process, memory stores put under it in place, simulated browsers with typed callers in front of it, and the outcome assertions; the store conformance suites, to hold a store you write to the rules Lambder's own meet |
138
- | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiOptions`: every API's declared options, policies and guard declarations as plain data, for the code that decides with them; `writeApiGuardParams`: one guard's parameters and nothing else, what a browser gating on that guard carries; `writeApiContract`: the contract as plain types a client compiles instead of the server |
143
+ | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiOptions`: every API's declared options, policies and guard declarations as plain data, for the code that decides with them; `writeApiGuardParams`: one guard's parameters and nothing else, what a browser gating on that guard carries; `writeApiContract`: the contract as plain types a client compiles instead of the server; `generateApiFiles`: every one of them, for every app a script names, in one call |
139
144
 
140
145
  Frontends and shared isomorphic packages should import from `lambder/client`
141
146
  only; the entry's module graph contains no AWS SDK, Node built-ins, or server
@@ -1,3 +1,4 @@
1
+ import type { LambderCallOutcomeHint } from "../shared/wire/LambderCallOutcome.js";
1
2
  import type { LambderApiHttpAnswer } from "../shared/wire/LambderApiOutcome.js";
2
3
  /**
3
4
  * The core's output for one API call, before any transport-level
@@ -23,6 +24,12 @@ export type LambderApiAnswer = {
23
24
  isBodyBase64?: boolean;
24
25
  compress?: boolean | "auto";
25
26
  etag?: boolean | "auto";
27
+ /**
28
+ * What the answer is, as the code that wrote it knows it, for the call's
29
+ * summary line: set by the envelope and validation builders, dropped by
30
+ * the stores like the other hints (a replay reads it back from the text).
31
+ */
32
+ outcome?: LambderCallOutcomeHint;
26
33
  };
27
34
  /**
28
35
  * An answer in the accessor form resolveApiOutcome() reads (the same view a
@@ -1,3 +1,4 @@
1
+ import { outcomeOfEnvelope } from "../shared/wire/LambderCallOutcome.js";
1
2
  import { LAMBDER_REFUSAL_CODES, isLambderRefusalCode, refusalMessageOf } from "../shared/wire/LambderApiRefusal.js";
2
3
  import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
3
4
  /*
@@ -73,7 +74,8 @@ export const envelopeAnswer = (envelope, options = {}) => {
73
74
  // casing replaces the envelope's own rather than shipping beside it.
74
75
  setAnswerHeader(headers, key, value);
75
76
  }
76
- return { statusCode: options.statusCode ?? 200, headers, body: JSON.stringify(envelope) };
77
+ const statusCode = options.statusCode ?? 200;
78
+ return { statusCode, headers, body: JSON.stringify(envelope), outcome: outcomeOfEnvelope(envelope, statusCode) };
77
79
  };
78
80
  /**
79
81
  * A thrown refusal as an answer: its refusal and flags on the envelope,
@@ -187,6 +189,7 @@ export const validationAnswer = (zodError, logList) => {
187
189
  ...(trimmed ? { issueCount: zodError.issues.length } : {}),
188
190
  ...(logList?.length ? { logList } : {}),
189
191
  }),
192
+ outcome: { outcome: "validation", code: null },
190
193
  };
191
194
  };
192
195
  /** No API is registered under the requested name: a refusal, not a 404, so a typed caller reads it. */
@@ -3,6 +3,7 @@ import type { LambderJsonOf } from "../shared/wire/LambderApiContract.js";
3
3
  import type { LambderRefusalStatusCode } from "../shared/wire/LambderHttpStatus.js";
4
4
  import type { LambderNoExtraKeys } from "../shared/util/LambderTypeUtilities.js";
5
5
  import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
6
+ import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderNoRepeatedNames } from "../shared/util/LambderNamedMaps.js";
6
7
  /**
7
8
  * One code of an app's refusal vocabulary: the schema of the data it carries,
8
9
  * when it carries data, and how every refusal with the code leaves the
@@ -69,6 +70,27 @@ export type LambderRefusalVocabularyChecks<TVocabulary> = {
69
70
  readonly "lambder: a refusal code may not start with lambder/, the prefix of the framework's own codes": never;
70
71
  } : LambderNoExtraKeys<TVocabulary[TCode], LambderRefusalDeclaration> & LambderRefusalDataCheck<TVocabulary[TCode]>;
71
72
  };
73
+ /**
74
+ * The vocabulary as declareRefusals() takes it: one map of codes, or a list of
75
+ * them, so each part of an app declares its own codes beside its APIs. A
76
+ * code two maps declare is refused.
77
+ */
78
+ export type LambderRefusalVocabularyOption = LambderNamedMapsOption<LambderRefusalVocabulary>;
79
+ /** The one vocabulary a declareRefusals() option declares: a list's maps merged, a lone map as it is. */
80
+ export type LambderMergedRefusalVocabulary<TOption> = LambderMergedNamedMaps<TOption> extends infer TMerged extends LambderRefusalVocabulary ? TMerged : never;
81
+ /** A declareRefusals() option checked code by code, in each map of a list, and a list declaring no code twice. */
82
+ export type LambderRefusalVocabularyOptionChecks<TOption> = TOption extends readonly unknown[] ? {
83
+ [TIndex in keyof TOption]: LambderRefusalVocabularyChecks<TOption[TIndex]>;
84
+ } & LambderNoRepeatedNames<TOption> : LambderRefusalVocabularyChecks<TOption>;
85
+ /**
86
+ * A declareRefusals() option as the one vocabulary it declares, checked
87
+ * (see readRefusalVocabulary) and keyed. `declarer` names the init in the
88
+ * errors.
89
+ */
90
+ export declare const declaredRefusalVocabulary: (option: LambderRefusalVocabularyOption, declarer: string) => {
91
+ refusals: LambderRefusalVocabulary;
92
+ vocabulary: Map<string, LambderRefusalDeclaration>;
93
+ };
72
94
  /** One code's declaration as checkedRefusal reads it: how a refusal with the code leaves, and its schema. */
73
95
  export declare const allowedRefusalOf: (declaration: LambderRefusalDeclaration) => LambderApiAllowedRefusal;
74
96
  /**
@@ -1,5 +1,18 @@
1
1
  import { LambderApiRefusal, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
2
2
  import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
3
+ import { mergeNamedMaps } from "../shared/util/LambderNamedMaps.js";
4
+ /**
5
+ * A declareRefusals() option as the one vocabulary it declares, checked
6
+ * (see readRefusalVocabulary) and keyed. `declarer` names the init in the
7
+ * errors.
8
+ */
9
+ export const declaredRefusalVocabulary = (option, declarer) => {
10
+ if (option === null || typeof option !== "object") {
11
+ throw new Error(`${declarer}: declareRefusals() takes the vocabulary, an object of codes or a list of them.`);
12
+ }
13
+ const refusals = mergeNamedMaps(option, "refusal code", "declareRefusals()");
14
+ return { refusals, vocabulary: readRefusalVocabulary(refusals) };
15
+ };
3
16
  /** One code's declaration as checkedRefusal reads it: how a refusal with the code leaves, and its schema. */
4
17
  export const allowedRefusalOf = (declaration) => {
5
18
  const leaves = {
@@ -0,0 +1,60 @@
1
+ import type { LambderModuleLocation } from "./moduleLocation.js";
2
+ import { type LambderApiContractFileOptions } from "./writeApiContract.js";
3
+ import { type LambderApiSignatureFileOptions } from "./writeApiSignatures.js";
4
+ import { type LambderApiOptionsFileOptions } from "./writeApiOptions.js";
5
+ import { type LambderApiGuardParamsFileOptions } from "./writeApiGuardParams.js";
6
+ /** What a file's writer takes beyond where the instance is, which the app gives once for all its files. */
7
+ type LambderAppFile<TOptions> = Omit<TOptions, "module" | "exportName" | "tsconfig" | "check">;
8
+ /** One app: where its instance is, and which files it is written to. Each file is optional. */
9
+ export type LambderApiFilesApp = {
10
+ /** The module that exports the instance, usually the server's entry: a path relative to the working directory, or a file URL. */
11
+ module: LambderModuleLocation;
12
+ /** The export that holds the instance. Default: "default". */
13
+ exportName?: string;
14
+ /** The tsconfig.json the module compiles under, which the contract is read through. Default: the nearest one at or above the module. */
15
+ tsconfig?: string;
16
+ /** The contract a client compiles against, as plain types (writeApiContract). */
17
+ contract?: LambderAppFile<LambderApiContractFileOptions>;
18
+ /** The signature file both sides ship (writeApiSignatures). */
19
+ signatures?: LambderAppFile<LambderApiSignatureFileOptions>;
20
+ /** The declared options as plain data, for a mock and tests (writeApiOptions). */
21
+ options?: LambderAppFile<LambderApiOptionsFileOptions>;
22
+ /** One file per guard whose parameters a browser needs (writeApiGuardParams). */
23
+ guardParams?: readonly LambderAppFile<LambderApiGuardParamsFileOptions>[];
24
+ };
25
+ /** The files generateApiFiles writes: every app's, by a name the output lines use. */
26
+ export type LambderApiFilesConfig = {
27
+ apps: Record<string, LambderApiFilesApp>;
28
+ };
29
+ export type LambderApiFilesResult = {
30
+ /** False when a check found a file stale, or a writer failed. */
31
+ ok: boolean;
32
+ /** What happened, per app and file, as lines to print. */
33
+ lines: string[];
34
+ };
35
+ /**
36
+ * Writes every file the config names, or with `check` writes nothing and
37
+ * answers whether each one on disk is current. Paths are relative to the
38
+ * working directory, as each writer takes them.
39
+ *
40
+ * ```ts
41
+ * import { generateApiFiles } from "lambder/build";
42
+ *
43
+ * const result = await generateApiFiles({
44
+ * apps: {
45
+ * server: {
46
+ * module: "backend/index.ts",
47
+ * exportName: "lambder",
48
+ * contract: { file: "shared/generated/apiContract.generated.ts" },
49
+ * signatures: { file: "shared/generated/apiSignatures.generated.ts" },
50
+ * },
51
+ * },
52
+ * }, { check: process.argv.includes("--check") });
53
+ * console.log(result.lines.join("\n"));
54
+ * process.exit(result.ok ? 0 : 1);
55
+ * ```
56
+ */
57
+ export declare const generateApiFiles: (config: LambderApiFilesConfig, options?: {
58
+ check?: boolean;
59
+ }) => Promise<LambderApiFilesResult>;
60
+ export {};
@@ -0,0 +1,85 @@
1
+ import { writeApiContract } from "./writeApiContract.js";
2
+ import { writeApiSignatures } from "./writeApiSignatures.js";
3
+ import { writeApiOptions } from "./writeApiOptions.js";
4
+ import { writeApiGuardParams } from "./writeApiGuardParams.js";
5
+ const APP_KEYS = ["module", "exportName", "tsconfig", "contract", "signatures", "options", "guardParams"];
6
+ const FILE_KINDS = ["contract", "signatures", "options", "guardParams"];
7
+ /** A config that names apps, each with its module and at least one file, and no key the generator does not read. */
8
+ function assertConfig(config) {
9
+ const apps = config?.apps;
10
+ if (apps === null || typeof apps !== "object" || Array.isArray(apps) || Object.keys(apps).length === 0) {
11
+ throw new Error("generateApiFiles: the config names no apps. Give `apps`, one entry per Lambder instance: { module, exportName, and the files it is written to }.");
12
+ }
13
+ for (const [name, app] of Object.entries(apps)) {
14
+ if (app === null || typeof app !== "object")
15
+ throw new Error(`generateApiFiles: the app "${name}" is not an object.`);
16
+ const unknownKeys = Object.keys(app).filter((key) => !APP_KEYS.includes(key));
17
+ if (unknownKeys.length)
18
+ throw new Error(`generateApiFiles: the app "${name}" has ${unknownKeys.map((key) => `"${key}"`).join(", ")}, which the generator does not read (it reads ${APP_KEYS.join(", ")}).`);
19
+ if (!app.module)
20
+ throw new Error(`generateApiFiles: the app "${name}" names no module to read its instance from.`);
21
+ if (!FILE_KINDS.some((kind) => app[kind] !== undefined)) {
22
+ throw new Error(`generateApiFiles: the app "${name}" is written to no file; give it a contract, signatures, options or guardParams.`);
23
+ }
24
+ }
25
+ }
26
+ /** A writer's run as lines: its own on success or a stale check, the reason when it threw. */
27
+ const settle = async (label, write) => {
28
+ try {
29
+ return await write();
30
+ }
31
+ catch (err) {
32
+ const reasons = [];
33
+ for (let cause = err; cause instanceof Error && reasons.length < 4; cause = cause.cause)
34
+ reasons.push(cause.message);
35
+ return { ok: false, lines: [`✗ ${label} failed: ${reasons.join(": ") || String(err)}`] };
36
+ }
37
+ };
38
+ /**
39
+ * Writes every file the config names, or with `check` writes nothing and
40
+ * answers whether each one on disk is current. Paths are relative to the
41
+ * working directory, as each writer takes them.
42
+ *
43
+ * ```ts
44
+ * import { generateApiFiles } from "lambder/build";
45
+ *
46
+ * const result = await generateApiFiles({
47
+ * apps: {
48
+ * server: {
49
+ * module: "backend/index.ts",
50
+ * exportName: "lambder",
51
+ * contract: { file: "shared/generated/apiContract.generated.ts" },
52
+ * signatures: { file: "shared/generated/apiSignatures.generated.ts" },
53
+ * },
54
+ * },
55
+ * }, { check: process.argv.includes("--check") });
56
+ * console.log(result.lines.join("\n"));
57
+ * process.exit(result.ok ? 0 : 1);
58
+ * ```
59
+ */
60
+ export const generateApiFiles = async (config, options = {}) => {
61
+ assertConfig(config);
62
+ const check = options.check ?? false;
63
+ const apps = Object.entries(config.apps);
64
+ const runs = [];
65
+ // The contracts first: they compile the app's sources and run none of
66
+ // them, so a module that fails to load still gets its contract checked.
67
+ for (const [name, app] of apps) {
68
+ if (!app.contract)
69
+ continue;
70
+ runs.push(await settle(`${name}: the contract`, () => writeApiContract({ ...app.contract, module: app.module, exportName: app.exportName, tsconfig: app.tsconfig, check })));
71
+ }
72
+ // Then what is read off the instance itself, which importing the module
73
+ // builds once for all three.
74
+ for (const [name, app] of apps) {
75
+ const instance = { module: app.module, exportName: app.exportName, check };
76
+ if (app.signatures)
77
+ runs.push(await settle(`${name}: the signatures`, () => writeApiSignatures({ ...app.signatures, ...instance })));
78
+ if (app.options)
79
+ runs.push(await settle(`${name}: the options`, () => writeApiOptions({ ...app.options, ...instance })));
80
+ for (const guardFile of app.guardParams ?? []) {
81
+ runs.push(await settle(`${name}: the "${guardFile.guard}" guard parameters`, () => writeApiGuardParams({ ...guardFile, ...instance })));
82
+ }
83
+ }
84
+ return { ok: runs.every((run) => run.ok), lines: runs.flatMap((run) => run.lines) };
85
+ };
package/dist/build.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * ships: the signature file both sides read, the declared options as plain
6
6
  * data, and one guard's parameters as the least a browser needs of them, all
7
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
8
+ * from the server's sources; and generateApiFiles, every one of them for
9
+ * every app a script names, in one call. Node-only and imported by nothing else
9
10
  * in the package, so no deployment or bundle carries it.
10
11
  */
11
12
  export { writeApiSignatures } from "./build/writeApiSignatures.js";
@@ -17,4 +18,6 @@ export type { LambderApiOptionsFileOptions, LambderApiOptionsFileResult, } from
17
18
  export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
18
19
  export type { LambderApiGuardParamsFileOptions, LambderApiGuardParamsFileResult, } from "./build/writeApiGuardParams.js";
19
20
  export type { LambderApiOptionsSource, LambderNameChanges } from "./build/generatedTables.js";
21
+ export { generateApiFiles } from "./build/generateApiFiles.js";
22
+ export type { LambderApiFilesConfig, LambderApiFilesApp, LambderApiFilesResult } from "./build/generateApiFiles.js";
20
23
  export type { LambderModuleLocation } from "./build/moduleLocation.js";
package/dist/build.js CHANGED
@@ -5,10 +5,12 @@
5
5
  * ships: the signature file both sides read, the declared options as plain
6
6
  * data, and one guard's parameters as the least a browser needs of them, all
7
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
8
+ * from the server's sources; and generateApiFiles, every one of them for
9
+ * every app a script names, in one call. Node-only and imported by nothing else
9
10
  * in the package, so no deployment or bundle carries it.
10
11
  */
11
12
  export { writeApiSignatures } from "./build/writeApiSignatures.js";
12
13
  export { writeApiContract } from "./build/writeApiContract.js";
13
14
  export { writeApiOptions } from "./build/writeApiOptions.js";
14
15
  export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
16
+ export { generateApiFiles } from "./build/generateApiFiles.js";
@@ -12,7 +12,7 @@ import { LambderFiles } from "./LambderFiles.js";
12
12
  import { type LambderPipelineBackends, type LambderPipelineBackendSwap } from "../api/LambderApiPipeline.js";
13
13
  import type { LambderFileSource } from "../shared/contracts/LambderFileSource.js";
14
14
  import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
15
- import { type LambderWireRefusalsOf, type LambderHandlerRefusalsOf, type LambderRefusalNamesIn, type LambderRefusalsOption, type LambderRefusalVocabulary, type LambderRefusalVocabularyChecks } from "../api/LambderApiRefusals.js";
15
+ import { type LambderWireRefusalsOf, type LambderHandlerRefusalsOf, type LambderRefusalNamesIn, type LambderRefusalsOption, type LambderRefusalVocabularyOption, type LambderRefusalVocabularyOptionChecks, type LambderMergedRefusalVocabulary } from "../api/LambderApiRefusals.js";
16
16
  import { type LambderDeclaredRefuse } from "../shared/wire/LambderApiRefusal.js";
17
17
  import { type LambderApiSignatureEntry } from "../api/LambderApiSignature.js";
18
18
  import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
@@ -25,10 +25,13 @@ import type { LambderContractEntry, LambderJsonOf } from "../shared/wire/Lambder
25
25
  import { type LambderActionNamesCheck, type LambderApiDeclaration, type LambderApiDeclarations, type LambderApiGroup, type LambderApiModeOf, type LambderAppTypes, type LambderContractOfGroups, type LambderGroupNameCheck, type LambderGroupPartsCheck, type LambderMergedParts, type LambderLazyApiGroup, type LambderPlainAppTypes, type LambderPublicSessionPolicyCheck, type LambderRegisteredGroupsCheck, type LambderRegistrableApiGroup, type LambderSessionModeCheck } from "../api/LambderApiDeclarations.js";
26
26
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
27
27
  import type { LambderReadonlyDeep, MaybePromise } from "../shared/util/LambderTypeUtilities.js";
28
+ import { type LambderCallSummary } from "./LambderCallSummary.js";
28
29
  import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderRouteHandler, type LambderInputValidationHandler, type LambderFallbackHandler, type LambderGlobalErrorHandler, type LambderAfterRenderHook, type LambderBeforeRenderHook, type LambderFallbackHook, type LambderActionTools, type LambderCreateOptions, type LambderGivenOption, type LambderHandler, type LambderNestedOptionChecks, type LambderObjectOutputCheck, type LambderPayloadSliceCheck, type LambderRequirableGuardsField, type LambderSessionEnabledInstance, type LambderSessionRouteHandler } from "./LambderCreateOptions.js";
29
30
  /** Everything `lambder/testing` may put under a built instance: the pipeline's stores, and the source its files are read from. */
30
31
  export type LambderInstanceBackends = LambderPipelineBackends & {
31
32
  fileSource?: LambderFileSource;
33
+ /** Where call summaries go instead of the app's own callSummary; null writes none. */
34
+ callSummary?: ((summary: LambderCallSummary) => void) | null;
32
35
  };
33
36
  /** What the instance had a place for; see LambderPipelineBackendSwap. `files` is false on an instance created without the files option. */
34
37
  export type LambderInstanceBackendSwap = LambderPipelineBackendSwap & {
@@ -146,6 +149,9 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
146
149
  private readonly contextTools;
147
150
  private readonly trustedClientIpHeaders;
148
151
  private readonly trustedHostHeaders;
152
+ private readonly originProof;
153
+ /** Where each API call's summary goes (the callSummary option); null writes none. Replaceable through the backend swap alone. */
154
+ private callSummaryWriter;
149
155
  constructor(given?: LambderCreateOptions<TApp["session"]>);
150
156
  setRouteFallbackHandler(routeFallbackHandler: LambderFallbackHandler): this;
151
157
  setApiFallbackHandler(apiFallbackHandler: LambderFallbackHandler): this;
@@ -445,7 +451,15 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
445
451
  */
446
452
  private isCorsPreflight;
447
453
  private resolveRequest;
454
+ /**
455
+ * One HTTP invocation, from the event to the finalized response, under
456
+ * an invocation record of its own (see LambderInvocationScope): what an
457
+ * API call's summary line is written from once the response is final.
458
+ */
448
459
  render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
460
+ private renderRequest;
461
+ /** What createContext reads this instance's requests with. */
462
+ private contextOptions;
449
463
  /**
450
464
  * A thrown value that is an answer rather than a crash, as the response;
451
465
  * anything else is rethrown to the crash path.
@@ -470,6 +484,12 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
470
484
  * failed too.
471
485
  */
472
486
  private answerCrash;
487
+ /**
488
+ * The summary line of an API call, once its response is final: nothing
489
+ * for an invocation that served no API call. A writer that throws costs
490
+ * the call its line and nothing else.
491
+ */
492
+ private writeCallSummary;
473
493
  /**
474
494
  * An answer to a crash, carrying what the call wrote and its CORS headers.
475
495
  * As on the success path, headers belong to the call: a call that wrote a
@@ -500,6 +520,7 @@ export default class Lambder<TApp extends LambderAppTypes = LambderPlainAppTypes
500
520
  * so Lambda's retries and dead-letter queues still see the failure.
501
521
  */
502
522
  renderEvent(event: unknown, lambdaContext: Context): Promise<unknown>;
523
+ private renderEventAction;
503
524
  /**
504
525
  * The answer for a rejected input: the app's
505
526
  * setApiInputValidationErrorHandler when set, otherwise the standard 422
@@ -596,7 +617,10 @@ export declare const initLambder: <TSessionData = any>() => {
596
617
  * Declares the app's refusal vocabulary: every code once, with the schema
597
618
  * of its data (`{ data: schema }`) or none (`{}`), the status every
598
619
  * refusal with it leaves with (`status`, 200 by default) and whether it
599
- * sets the notAuthorized flag (`notAuthorized: true`). Returns the init
620
+ * sets the notAuthorized flag (`notAuthorized: true`). One map of codes,
621
+ * or a list of them, as guards and rate-limit policies are declared, so
622
+ * each part of an app declares its own codes; a code two maps declare is
623
+ * refused, at compile time and at the call. Returns the init
600
624
  * bound to it: its guard() types ctx.refuse to a guard's refusals and
601
625
  * refuses a guard naming a code outside the vocabulary, its refuse is
602
626
  * typed to the whole vocabulary, and its create() hands the vocabulary to
@@ -604,7 +628,7 @@ export declare const initLambder: <TSessionData = any>() => {
604
628
  * handler, a guard or a helper is a crash rather than an answer, so every
605
629
  * "no" a client reads names a code.
606
630
  */
607
- declareRefusals<const TRefusals extends LambderRefusalVocabulary, const TRequireCodes extends boolean = false>(refusals: TRefusals & LambderRefusalVocabularyChecks<TRefusals>, options?: {
631
+ declareRefusals<const TRefusals extends LambderRefusalVocabularyOption, const TRequireCodes extends boolean = false>(refusals: TRefusals & LambderRefusalVocabularyOptionChecks<TRefusals>, options?: {
608
632
  requireCodes?: TRequireCodes;
609
633
  }): {
610
634
  /**
@@ -615,8 +639,8 @@ export declare const initLambder: <TSessionData = any>() => {
615
639
  * refuse(). Inside an API handler ctx.refuse is narrower, that
616
640
  * endpoint's codes alone.
617
641
  */
618
- refuse: LambderDeclaredRefuse<LambderHandlerRefusalsOf<TRefusals, keyof TRefusals & string>, TRequireCodes>;
619
- create<const TOptions extends LambderInitCreateOptions<TSessionData>>(options: TOptions & Record<Exclude<keyof TOptions, "session" | "guards" | "idempotency" | "apiVersion" | "cors" | "apiPath" | "apiSignatures" | "trustedClientIpHeaders" | "trustedHostHeaders" | "files" | "etag" | "rateLimits" | "minApiVersion" | "compression" | "maxResponseBytes" | "maxRequestPayloadBytes" | "requireApiGuards" | "crashes">, never> & LambderNestedOptionChecks<TSessionData, TOptions, TRefusals>): Lambder<{
642
+ refuse: LambderDeclaredRefuse<LambderHandlerRefusalsOf<LambderMergedRefusalVocabulary<TRefusals>, keyof LambderMergedRefusalVocabulary<TRefusals> & string>, TRequireCodes>;
643
+ create<const TOptions extends LambderInitCreateOptions<TSessionData>>(options: TOptions & Record<Exclude<keyof TOptions, "session" | "guards" | "idempotency" | "apiVersion" | "cors" | "apiPath" | "apiSignatures" | "trustedClientIpHeaders" | "trustedHostHeaders" | "originProof" | "files" | "etag" | "rateLimits" | "minApiVersion" | "compression" | "maxResponseBytes" | "maxRequestPayloadBytes" | "callSummary" | "requireApiGuards" | "crashes">, never> & LambderNestedOptionChecks<TSessionData, TOptions, LambderMergedRefusalVocabulary<TRefusals>>): Lambder<{
620
644
  session: TSessionData;
621
645
  policies: TOptions["rateLimits"] extends {
622
646
  policies: infer TPolicies;
@@ -625,10 +649,10 @@ export declare const initLambder: <TSessionData = any>() => {
625
649
  idempotency: TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false;
626
650
  guardsRequired: [LambderGivenOption<TOptions, "requireApiGuards">] extends [false | undefined] ? false : true;
627
651
  sessions: [LambderGivenOption<TOptions, "session">] extends [undefined] ? false : true;
628
- refusals: TRefusals;
652
+ refusals: LambderMergedRefusalVocabulary<TRefusals>;
629
653
  refusalCodesRequired: TRequireCodes;
630
654
  }, {}>;
631
- guard: import("../api/LambderApiGuards.js").LambderGuardBuilder<LambderRenderContext<any, Record<string, string>, {}, TSessionData>, LambderSessionRenderContext<any, TSessionData>, TRefusals, TRequireCodes>;
655
+ guard: import("../api/LambderApiGuards.js").LambderGuardBuilder<LambderRenderContext<any, Record<string, string>, {}, TSessionData>, LambderSessionRenderContext<any, TSessionData>, LambderMergedRefusalVocabulary<TRefusals>, TRequireCodes>;
632
656
  rateLimitKey: import("../api/LambderApiRateLimits.js").LambderRateLimitKeyBuilder<LambderRenderContext<any, Record<string, string>, {}, TSessionData>>;
633
657
  };
634
658
  /**
@@ -651,7 +675,7 @@ export declare const initLambder: <TSessionData = any>() => {
651
675
  code?: undefined;
652
676
  data?: undefined;
653
677
  }) | undefined) => never;
654
- create<const TOptions extends LambderInitCreateOptions<TSessionData>>(options: TOptions & Record<Exclude<keyof TOptions, "session" | "guards" | "idempotency" | "apiVersion" | "cors" | "apiPath" | "apiSignatures" | "trustedClientIpHeaders" | "trustedHostHeaders" | "files" | "etag" | "rateLimits" | "minApiVersion" | "compression" | "maxResponseBytes" | "maxRequestPayloadBytes" | "requireApiGuards" | "crashes">, never> & LambderNestedOptionChecks<TSessionData, TOptions, {}>): Lambder<{
678
+ create<const TOptions extends LambderInitCreateOptions<TSessionData>>(options: TOptions & Record<Exclude<keyof TOptions, "session" | "guards" | "idempotency" | "apiVersion" | "cors" | "apiPath" | "apiSignatures" | "trustedClientIpHeaders" | "trustedHostHeaders" | "originProof" | "files" | "etag" | "rateLimits" | "minApiVersion" | "compression" | "maxResponseBytes" | "maxRequestPayloadBytes" | "callSummary" | "requireApiGuards" | "crashes">, never> & LambderNestedOptionChecks<TSessionData, TOptions, {}>): Lambder<{
655
679
  session: TSessionData;
656
680
  policies: TOptions["rateLimits"] extends {
657
681
  policies: infer TPolicies;