lambder 7.2.4 → 7.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 (35) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +4 -2
  3. package/dist/api/LambderApiIdempotency.d.ts +7 -0
  4. package/dist/api/LambderApiIdempotency.js +12 -0
  5. package/dist/api/LambderApiPipeline.d.ts +30 -1
  6. package/dist/api/LambderApiPipeline.js +14 -0
  7. package/dist/api/LambderApiPolicyEngine.d.ts +11 -0
  8. package/dist/api/LambderApiPolicyEngine.js +8 -0
  9. package/dist/api/LambderApiRateLimits.d.ts +7 -0
  10. package/dist/api/LambderApiRateLimits.js +12 -0
  11. package/dist/core/Lambder.d.ts +35 -1
  12. package/dist/core/Lambder.js +32 -1
  13. package/dist/core/LambderFiles.d.ts +7 -0
  14. package/dist/core/LambderFiles.js +12 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/invoke/LambderLambdaEvent.d.ts +23 -9
  17. package/dist/invoke/LambderLambdaEvent.js +41 -16
  18. package/dist/invoke/lambderHandlerTransport.d.ts +3 -0
  19. package/dist/invoke/lambderHandlerTransport.js +1 -1
  20. package/dist/mock.d.ts +2 -0
  21. package/dist/mock.js +3 -0
  22. package/dist/session/LambderSessionManager.d.ts +12 -1
  23. package/dist/session/LambderSessionManager.js +19 -3
  24. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  25. package/dist/shared/util/LambderTestingDoors.js +29 -0
  26. package/dist/shared/wire/LambderApiContract.d.ts +50 -0
  27. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +80 -0
  28. package/dist/shared/wire/LambderOutcomeAssertions.js +113 -0
  29. package/dist/testing/LambderTestApp.d.ts +178 -0
  30. package/dist/testing/LambderTestApp.js +206 -0
  31. package/dist/testing/LambderTestVisitor.d.ts +155 -0
  32. package/dist/testing/LambderTestVisitor.js +154 -0
  33. package/dist/testing.d.ts +26 -0
  34. package/dist/testing.js +23 -0
  35. package/package.json +9 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,94 @@ 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
+ ## [7.3.1] - 2026-09-21
13
+
14
+ ### Added
15
+
16
+ - **`lambder/testing`: a real app under test, in one call.** `lambderTestApp(lambder)`
17
+ takes the instance an app already exports and returns a `LambderTestApp`:
18
+ memory stores put under the instance, and simulated browsers in front of it,
19
+ with no HTTP and no AWS. Every piece existed before (the in-process handler
20
+ transport, the cookie jar, the memory stores), but each app had to assemble
21
+ them, and none could do the one thing that needs the framework's help: an
22
+ app is a module-level instance whose handlers and guards close over it, so a
23
+ test cannot be handed a copy over other stores, because the copy's closures
24
+ still reach the original and the production table under it. The stores are
25
+ therefore replaced **in place**, through a door keyed by a symbol no entry
26
+ point exports, so nothing on an instance's typed surface offers it to a
27
+ serving app. Nothing about the app is restructured, its guards, policies,
28
+ session model, hooks and error handlers all run as written, and from the
29
+ moment the test app exists the stores the app was created with are out of
30
+ the instance's reach.
31
+ - **Visitors.** `app.visitor()` is one simulated browser: its own cookie
32
+ jar, its own address (so a `per: "ip"` limit counts visitors apart, as it
33
+ does for two people in production), a typed `api` / `apiOutcome` over the
34
+ real pipeline, and `request(method, path)` for everything that is not an
35
+ API call (routes, redirects, session routes, public files), sharing the
36
+ same jar. `app.signIn(sessionKey, data)` returns a visitor already signed
37
+ in, minted by the app's own session model, so no login endpoint has to
38
+ exist. It throws, and says why, when the app's cookie domain does not
39
+ cover the visitor's host, instead of leaving every later call to answer
40
+ `sessionExpired`. `guardInputsProvider`, `headers`, `host`, `clientIp`,
41
+ `apiVersion` and `apiSignatures` are per visitor.
42
+ - **The mock app's verbs**, over the real handlers: `signIn`, `signOut`,
43
+ `expireSessionData`, `reset`, plus `event()` for what is not an HTTP
44
+ request (a schedule, SNS, SQS) and the stores themselves for assertions.
45
+ - **What the app threw.** A crash is answered by the app's global error
46
+ handler or by the framework's 500, and either answer deliberately says
47
+ nothing about the error, which leaves a test author with "status 500". The
48
+ test app watches what the instance throws without changing what it
49
+ answers: `visitor.apiOutcome` sets the thrown error as the failure's
50
+ `error.cause`, per call even with others in flight, and `app.crashes`
51
+ lists every one since the last reset.
52
+ - **Options** sit where `create()` takes them (`session.store`,
53
+ `rateLimits.limiter`, `idempotency.store`, `files`), each defaulting to a
54
+ fresh memory store, or for `files` to the app's own source.
55
+ - **`eventFormat: "v1" | "v2"`** (default `"v2"`): the gateway shape the
56
+ handler is called with, so an app behind a REST API can run its suite on
57
+ the event production delivers. `synthesizeLambdaHttpEvent` and
58
+ `lambderHandlerTransport` take the same option; a REST API event carries
59
+ its cookies in the Cookie header and every header in both delivery forms,
60
+ and the asserted client address stays the gateway's observation
61
+ (`requestContext.identity.sourceIp`) rather than a header, as on 2.0.
62
+ - The test app owns no clock, on purpose: faking `Date` with the test runner
63
+ moves the framework, the memory stores and the app's own handlers
64
+ together, which a clock of the test app's could not.
65
+ - **`assertApiSuccess` and `assertApiFailure`**, from `lambder/testing` and
66
+ `lambder/mock`. They narrow an outcome through an `asserts` signature, so
67
+ what the proved arm carries reads on the next line, and they throw a plain
68
+ Error naming what the outcome actually was, with the failure's own error as
69
+ its cause. `assertApiFailure(outcome, reason?, { code?, status? })` types
70
+ the reason against the union it was handed, so a misspelled one does not
71
+ compile, and narrows per arm, since `server` shares its arm with three other
72
+ reasons and `Extract` would have dropped it. Typed structurally over `ok`
73
+ and `reason`, so a `LambderInvokeOutcome` narrows through the same two
74
+ functions, and no test runner is imported.
75
+ - **[docs/testing.md](./docs/testing.md)**: testing a Lambder app in one page.
76
+ It was spread across ten pages as asides before.
77
+
78
+ ### Changed
79
+
80
+ - The session manager asks "may this crypto sit in front of this store" of
81
+ every store it is given, a swapped one included, rather than only in its
82
+ constructor.
83
+ - **The suite uses its own tools where the subject is what an app does.**
84
+ The routing, hook, plugin, action, redirect and thrown-response tests run
85
+ on the test app: a visitor's `request()` in place of a hand-built event, a
86
+ context and a body decoder, `app.event()` for the non-HTTP actions, a typed
87
+ `api()` for the plugin tests (which now also prove the contract flows
88
+ through `use()` to a caller), and `signIn()` in place of session records
89
+ forged with an SDK mock and hand-made hashes. Seven files lost their private
90
+ copies of the event and context builders. The hand-written `if(!outcome.ok)`
91
+ narrowing in the mock app's, the invoke caller's and the transports' tests
92
+ became the two assertions.
93
+ - The tests whose subject is the wire stay on hand-built events, because the
94
+ event or the raw answer is what they test: event parsing, response
95
+ finalization, compression, cookies and CORS headers, the policy and refusal
96
+ answers (status, envelope, Retry-After), error answers, the two gateway
97
+ formats, and the transports themselves. `tests/helpers.ts` says which is
98
+ which.
99
+
12
100
  ## [7.2.4] - 2026-09-19
13
101
 
14
102
  ### Changed
package/README.md CHANGED
@@ -99,13 +99,14 @@ so if you need a specific one, install it and bundle it yourself.
99
99
 
100
100
  ## Package entry points
101
101
 
102
- The package ships three entry points; pick by where the code runs:
102
+ The package ships four entry points; pick by where the code runs:
103
103
 
104
104
  | Entry | Runs in | Carries |
105
105
  | --- | --- | --- |
106
106
  | `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus the API core's building blocks and everything from `lambder/client` except the two request-compression helpers, `compressPayloadGzip` and `isRequestCompressionAvailable`, which stay on the client entry where a payload is compressed |
107
107
  | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
108
108
  | `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 |
109
+ | `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 |
109
110
 
110
111
  Frontends and shared isomorphic packages should import from `lambder/client`
111
112
  only; the entry's module graph contains no AWS SDK, Node built-ins, or server
@@ -142,6 +143,7 @@ guide that matches what you are building. The full index lives in
142
143
  | [Sessions](./docs/sessions.md) | Sessions over a store, cookie scope, secrets at rest, `dataRefresh`, the controller API |
143
144
  | [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
144
145
  | [Calling another lambda](./docs/invoke.md) | `LambderInvokeCaller`: invoking a Lambder app in another function, its contract, failures and compression |
146
+ | [Testing](./docs/testing.md) | `lambderTestApp`: the real instance under test with no HTTP and no AWS, visitors, sign-in without a login endpoint, outcome assertions, crashes, time |
145
147
  | [The API core](./docs/api-core.md) | `LambderApiPipeline`: the one pipeline the server and the mock runtime run, the store interfaces, the transports |
146
148
  | [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
147
149
  | [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
@@ -149,7 +151,7 @@ guide that matches what you are building. The full index lives in
149
151
  | [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, on-demand languages, runtime dictionaries |
150
152
  | [The mock runtime](./docs/mock.md) | `LambderMockApp`: the typed contract served from mock handlers over the real pipeline, in the browser and in tests |
151
153
  | [DynamoDB tables](./docs/dynamodb-tables.md) | Table shapes, TTL and IAM for sessions, cache, rate limits and idempotency |
152
- | [Exports reference](./docs/exports.md) | Every name the three entry points export, grouped by purpose |
154
+ | [Exports reference](./docs/exports.md) | Every name the four entry points export, grouped by purpose |
153
155
 
154
156
  ## Standalone modules
155
157
 
@@ -3,6 +3,7 @@ import type { LambderApiCallContext, LambderApiCallTrace } from "./LambderApiCal
3
3
  import type { LambderApiAnswer } from "./LambderApiAnswer.js";
4
4
  import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
5
5
  import type { LambderApiIdempotencyOption } from "../shared/wire/LambderApiOptionValues.js";
6
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
6
7
  export type LambderApiIdempotencyConfig = {
7
8
  /** Your idempotency store instance; may share the rate limiter's table (distinct key prefix). */
8
9
  store: LambderIdempotencyStore;
@@ -66,6 +67,12 @@ export declare class LambderApiIdempotencyEngine {
66
67
  */
67
68
  private readonly scopeByCall;
68
69
  configure(config: LambderApiIdempotencyConfig): void;
70
+ /**
71
+ * Puts the engine over another store, for `lambder/testing`; the replay
72
+ * TTLs, failOpen and callerIdentity stay as configured. False when
73
+ * idempotency was never configured.
74
+ */
75
+ [LAMBDER_BACKEND_SWAP](store: LambderIdempotencyStore): boolean;
69
76
  /** Startup validation of one API registration's idempotency option. */
70
77
  assertRegistration(apiName: string, config: LambderApiIdempotencyOption): void;
71
78
  /** True once the idempotency option was configured; registration asserts check it. */
@@ -2,6 +2,7 @@ import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
2
2
  import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
3
3
  import { joinKeyFields } from "../shared/util/LambderKeyFields.js";
4
4
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
5
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
5
6
  /**
6
7
  * A crashed original must not block retries forever, so a pending claim
7
8
  * expires on its own. The default is five minutes, which covers the great
@@ -102,6 +103,17 @@ export class LambderApiIdempotencyEngine {
102
103
  this.failOpen = config.failOpen ?? true;
103
104
  this.callerIdentity = config.callerIdentity;
104
105
  }
106
+ /**
107
+ * Puts the engine over another store, for `lambder/testing`; the replay
108
+ * TTLs, failOpen and callerIdentity stay as configured. False when
109
+ * idempotency was never configured.
110
+ */
111
+ [LAMBDER_BACKEND_SWAP](store) {
112
+ if (!this.store)
113
+ return false;
114
+ this.store = store;
115
+ return true;
116
+ }
105
117
  /** Startup validation of one API registration's idempotency option. */
106
118
  assertRegistration(apiName, config) {
107
119
  if (typeof config !== "object")
@@ -8,7 +8,10 @@ import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.
8
8
  import type { LambderApiGuard } from "./LambderApiGuards.js";
9
9
  import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
10
10
  import type { LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
11
- import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
11
+ import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
12
+ import type { LambderRateLimiter } from "../shared/contracts/LambderRateLimiter.js";
13
+ import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
14
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
12
15
  import type LambderSessionManager from "../session/LambderSessionManager.js";
13
16
  import LambderSessionController, { type LambderSessionCookieOptions, type LambderSessionRequestInfo } from "../session/LambderSessionController.js";
14
17
  import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
@@ -57,6 +60,26 @@ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessio
57
60
  }>>;
58
61
  idempotency?: LambderApiIdempotencyConfig;
59
62
  };
63
+ /** The stores `lambder/testing` puts under a built pipeline. One the pipeline has no subsystem for is left aside. */
64
+ export type LambderPipelineBackends = {
65
+ sessionStore?: LambderSessionStore<any>;
66
+ rateLimiter?: LambderRateLimiter;
67
+ idempotencyStore?: LambderIdempotencyStore;
68
+ };
69
+ /**
70
+ * What a backend swap found: whether the rate-limit and idempotency
71
+ * subsystems exist to take a store, and, when sessions are configured, the
72
+ * cookie names a caller over this pipeline has to be told (they are the
73
+ * app's own choice and nothing else hands them out).
74
+ */
75
+ export type LambderPipelineBackendSwap = {
76
+ sessions: {
77
+ tokenCookieKey: string;
78
+ csrfCookieKey: string;
79
+ } | null;
80
+ rateLimits: boolean;
81
+ idempotency: boolean;
82
+ };
60
83
  /** What one run produced, beside the answer: what an adapter may want to report. */
61
84
  export type LambderApiRunResult = LambderApiCallTrace & {
62
85
  answer: LambderApiAnswer;
@@ -105,6 +128,12 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
105
128
  * (its cookies and posted CSRF token) or a route's (cookies and no CSRF).
106
129
  */
107
130
  sessionController(ctx: TCtx, request: LambderSessionRequestInfo): LambderSessionController<TSessionData>;
131
+ /**
132
+ * The backend swap: each store given goes under the subsystem that holds
133
+ * one, and everything the app configured around it (the session model,
134
+ * the named policies, the replay TTLs) stays in force.
135
+ */
136
+ [LAMBDER_BACKEND_SWAP](backends: LambderPipelineBackends): LambderPipelineBackendSwap;
108
137
  /** The session request info of an API request: its cookies, and the CSRF token it posted. */
109
138
  static sessionInfoOf(request: LambderApiRequest): LambderSessionRequestInfo;
110
139
  /** Registration-time checks of one definition's declarative options; the same messages on the server and in the mock. */
@@ -7,6 +7,7 @@ import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/wire/LambderReques
7
7
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
8
8
  import { compareDottedVersions, isDottedVersion } from "../shared/wire/LambderVersionOrder.js";
9
9
  import { LambderApiPolicyEngine } from "./LambderApiPolicyEngine.js";
10
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
10
11
  import LambderSessionController, { assertSessionCookiePrefixes, } from "../session/LambderSessionController.js";
11
12
  import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
12
13
  /**
@@ -109,6 +110,19 @@ export class LambderApiPipeline {
109
110
  request,
110
111
  });
111
112
  }
113
+ /**
114
+ * The backend swap: each store given goes under the subsystem that holds
115
+ * one, and everything the app configured around it (the session model,
116
+ * the named policies, the replay TTLs) stays in force.
117
+ */
118
+ [LAMBDER_BACKEND_SWAP](backends) {
119
+ if (this.sessions && backends.sessionStore)
120
+ this.sessions.manager[LAMBDER_BACKEND_SWAP](backends.sessionStore);
121
+ return {
122
+ sessions: this.sessions ? { tokenCookieKey: this.sessions.tokenCookieKey, csrfCookieKey: this.sessions.csrfCookieKey } : null,
123
+ ...this.policies[LAMBDER_BACKEND_SWAP](backends),
124
+ };
125
+ }
112
126
  /** The session request info of an API request: its cookies, and the CSRF token it posted. */
113
127
  static sessionInfoOf(request) {
114
128
  return { host: request.host, cookies: request.cookies, csrfToken: request.token };
@@ -5,6 +5,9 @@ import type { LambderApiDefinition } from "./LambderApiDefinition.js";
5
5
  import { type LambderApiGuard } from "./LambderApiGuards.js";
6
6
  import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
7
7
  import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
8
+ import type { LambderRateLimiter } from "../shared/contracts/LambderRateLimiter.js";
9
+ import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
10
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
8
11
  /**
9
12
  * Runtime side of the declarative API options: composes the three policy
10
13
  * subsystems (rate limits in ./LambderApiRateLimits.ts, guards in
@@ -23,6 +26,14 @@ export declare class LambderApiPolicyEngine {
23
26
  configureRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
24
27
  configureGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
25
28
  configureIdempotency(config: LambderApiIdempotencyConfig): void;
29
+ /** The backend swap, handed on to the two subsystems that hold a store. Each answers whether it had a place for one. */
30
+ [LAMBDER_BACKEND_SWAP](backends: {
31
+ rateLimiter?: LambderRateLimiter;
32
+ idempotencyStore?: LambderIdempotencyStore;
33
+ }): {
34
+ rateLimits: boolean;
35
+ idempotency: boolean;
36
+ };
26
37
  /** Startup validation of one API's declarative options. */
27
38
  assertRegistration(definition: LambderApiDefinition): void;
28
39
  /** The rate-limit policies that can be checked before the session is read: see LambderApiRateLimitsEngine.run. */
@@ -1,6 +1,7 @@
1
1
  import { LambderApiGuardsEngine } from "./LambderApiGuards.js";
2
2
  import { LambderApiRateLimitsEngine } from "./LambderApiRateLimits.js";
3
3
  import { LambderApiIdempotencyEngine } from "./LambderApiIdempotency.js";
4
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
4
5
  /** An API that asks for idempotency: declared, and not the explicit `false` opt-out. */
5
6
  const usesIdempotency = (definition) => definition.idempotency !== undefined && definition.idempotency !== false;
6
7
  /**
@@ -29,6 +30,13 @@ export class LambderApiPolicyEngine {
29
30
  configureIdempotency(config) {
30
31
  this.idempotency.configure(config);
31
32
  }
33
+ /** The backend swap, handed on to the two subsystems that hold a store. Each answers whether it had a place for one. */
34
+ [LAMBDER_BACKEND_SWAP](backends) {
35
+ return {
36
+ rateLimits: backends.rateLimiter ? this.rateLimits[LAMBDER_BACKEND_SWAP](backends.rateLimiter) : false,
37
+ idempotency: backends.idempotencyStore ? this.idempotency[LAMBDER_BACKEND_SWAP](backends.idempotencyStore) : false,
38
+ };
39
+ }
32
40
  /** Startup validation of one API's declarative options. */
33
41
  assertRegistration(definition) {
34
42
  const { name, mode } = definition;
@@ -6,6 +6,7 @@ import type { LambderApiCallContext } from "./LambderApiCallContext.js";
6
6
  import { type LambderRateLimiter, type LambderRateLimitPolicy } from "../shared/contracts/LambderRateLimiter.js";
7
7
  import { LambderApiRefusal, type LambderAppRefusalMessage } from "../shared/wire/LambderApiRefusal.js";
8
8
  import type { LambderNonEmptyOptionMap } from "../shared/util/LambderTypeUtilities.js";
9
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
9
10
  /** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
10
11
  export declare const DEFAULT_RATE_LIMIT_REFUSAL: {
11
12
  type: "warning";
@@ -184,6 +185,12 @@ export declare class LambderApiRateLimitsEngine {
184
185
  /** True once rateLimits were configured. */
185
186
  get isConfigured(): boolean;
186
187
  configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
188
+ /**
189
+ * Puts the engine over another limiter, for `lambder/testing`; the named
190
+ * policies and failOpen stay as configured. False when rateLimits were
191
+ * never configured: there is nothing for a limiter to sit under.
192
+ */
193
+ [LAMBDER_BACKEND_SWAP](limiter: LambderRateLimiter): boolean;
187
194
  /** Startup validation of one API registration's rateLimit option. */
188
195
  assertRegistration(apiName: string, mode: LambderApiMode, rateLimitOption?: LambderRateLimitOptionValue): void;
189
196
  /**
@@ -4,6 +4,7 @@ import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
4
4
  import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
5
5
  import { parsePreflightSlice } from "./LambderApiValidationRefusal.js";
6
6
  import { assertNonNegativeInteger } from "../shared/util/LambderOptionChecks.js";
7
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
7
8
  const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
8
9
  /** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
9
10
  export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", code: LAMBDER_REFUSAL_CODES.rateLimited, content: "Too many requests. Please try again later." };
@@ -139,6 +140,17 @@ export class LambderApiRateLimitsEngine {
139
140
  this.failOpen = config.failOpen ?? true;
140
141
  this.policies = new Map(Object.entries(config.policies));
141
142
  }
143
+ /**
144
+ * Puts the engine over another limiter, for `lambder/testing`; the named
145
+ * policies and failOpen stay as configured. False when rateLimits were
146
+ * never configured: there is nothing for a limiter to sit under.
147
+ */
148
+ [LAMBDER_BACKEND_SWAP](limiter) {
149
+ if (!this.limiter)
150
+ return false;
151
+ this.limiter = limiter;
152
+ return true;
153
+ }
142
154
  /** Startup validation of one API registration's rateLimit option. */
143
155
  assertRegistration(apiName, mode, rateLimitOption) {
144
156
  const entries = toRateLimitEntries(rateLimitOption);
@@ -9,6 +9,9 @@ import type LambderSessionController from "../session/LambderSessionController.j
9
9
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
10
10
  import { type LambderIndexHtmlOptions } from "./LambderIndexHtml.js";
11
11
  import { LambderFiles } from "./LambderFiles.js";
12
+ import { type LambderPipelineBackends, type LambderPipelineBackendSwap } from "../api/LambderApiPipeline.js";
13
+ import type { LambderFileSource } from "../shared/contracts/LambderFileSource.js";
14
+ import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
12
15
  import { type LambderApiSignatureEntry } from "../api/LambderApiSignature.js";
13
16
  import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
14
17
  import type { LambderApiIdempotencyOption } from "../shared/wire/LambderApiOptionValues.js";
@@ -25,6 +28,14 @@ import { type LambderRouteHandler, type LambderInputValidationHandler, type Lamb
25
28
  * because its parameter is the class, and an options module that names the
26
29
  * class cannot be read without it.
27
30
  */
31
+ /** Everything `lambder/testing` may put under a built instance: the pipeline's stores, and the source its files are read from. */
32
+ export type LambderInstanceBackends = LambderPipelineBackends & {
33
+ fileSource?: LambderFileSource;
34
+ };
35
+ /** What the instance had a place for; see LambderPipelineBackendSwap. `files` is false on an instance created without the files option. */
36
+ export type LambderInstanceBackendSwap = LambderPipelineBackendSwap & {
37
+ files: boolean;
38
+ };
28
39
  export type LambderCreatedHook = (lambderInstance: Lambder<any, any, any, any, any, any, any, any>) => void | Promise<void>;
29
40
  /**
30
41
  * Main Lambder class for building type-safe serverless APIs. Create
@@ -60,10 +71,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
60
71
  * Type property for extracting the API contract
61
72
  * Use this to export your API types to the frontend
62
73
  *
74
+ * Export it as an interface extending LambderFlattenContract, not as a
75
+ * type alias. Chaining builds the contract as an intersection one member
76
+ * deep per endpoint, and an interface collapses that into one declared
77
+ * set of members, which every generic read of the contract (a mock
78
+ * registry, a needs map, the typed caller) is then far cheaper against.
79
+ * See LambderFlattenContract for the measurements.
80
+ *
63
81
  * @example
64
82
  * ```typescript
65
83
  * const lambder = new Lambder().addApi(...).addApi(...);
66
- * export type ApiContractType = typeof lambder.ApiContract;
84
+ * export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
67
85
  * ```
68
86
  */
69
87
  readonly ApiContract: _TContract;
@@ -88,6 +106,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
88
106
  private corsConfig;
89
107
  private finalizeOptions;
90
108
  private requireSessionApiGuards;
109
+ /** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
110
+ private crashWatcher;
91
111
  private readonly trustedClientIpHeaders;
92
112
  private requirePublicApiGuards;
93
113
  constructor(options?: LambderCreateOptions<TSessionData>);
@@ -171,6 +191,20 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
171
191
  getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
172
192
  /** The session manager, for code that works on sessions outside a request (maintenance, tests). */
173
193
  getSessionManager(): LambderSessionManager<TSessionData>;
194
+ /**
195
+ * The backend swap `lambder/testing` performs: the stores given go under
196
+ * this instance in place, so every handler and guard that closed over it
197
+ * reaches them, and the production ones are out of reach from then on.
198
+ * Keyed by a symbol no entry point exports, so it is not part of what an
199
+ * app can call; see LAMBDER_BACKEND_SWAP.
200
+ */
201
+ [LAMBDER_BACKEND_SWAP](backends: LambderInstanceBackends): LambderInstanceBackendSwap;
202
+ /**
203
+ * The crash watch `lambder/testing` sets: told every error a request
204
+ * throws past the framework's own handling, before the global error
205
+ * handler or the last-resort 500 answers it. The answer is unchanged.
206
+ */
207
+ [LAMBDER_CRASH_WATCH](watcher: (error: Error) => void): void;
174
208
  /**
175
209
  * Every registered endpoint's signature, keyed by its hashed name: the
176
210
  * LambderApiSignatureMap both sides ship with. A generator imports the
@@ -10,6 +10,7 @@ import { LambderIndexHtmlHandler } from "./LambderIndexHtml.js";
10
10
  import { LambderFiles } from "./LambderFiles.js";
11
11
  import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
12
12
  import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
13
+ import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
13
14
  import { apiSignatureOf } from "../api/LambderApiSignature.js";
14
15
  import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
15
16
  import { apiNotFoundAnswer, crashAnswer, refusalAnswer, } from "../api/LambderApiEnvelope.js";
@@ -55,10 +56,17 @@ export default class Lambder {
55
56
  * Type property for extracting the API contract
56
57
  * Use this to export your API types to the frontend
57
58
  *
59
+ * Export it as an interface extending LambderFlattenContract, not as a
60
+ * type alias. Chaining builds the contract as an intersection one member
61
+ * deep per endpoint, and an interface collapses that into one declared
62
+ * set of members, which every generic read of the contract (a mock
63
+ * registry, a needs map, the typed caller) is then far cheaper against.
64
+ * See LambderFlattenContract for the measurements.
65
+ *
58
66
  * @example
59
67
  * ```typescript
60
68
  * const lambder = new Lambder().addApi(...).addApi(...);
61
- * export type ApiContractType = typeof lambder.ApiContract;
69
+ * export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
62
70
  * ```
63
71
  */
64
72
  ApiContract;
@@ -83,6 +91,8 @@ export default class Lambder {
83
91
  corsConfig = null;
84
92
  finalizeOptions;
85
93
  requireSessionApiGuards;
94
+ /** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
95
+ crashWatcher = null;
86
96
  trustedClientIpHeaders;
87
97
  requirePublicApiGuards;
88
98
  constructor(options = {}) {
@@ -287,6 +297,26 @@ export default class Lambder {
287
297
  getSessionManager() {
288
298
  return this.pipeline.sessionManager;
289
299
  }
300
+ /**
301
+ * The backend swap `lambder/testing` performs: the stores given go under
302
+ * this instance in place, so every handler and guard that closed over it
303
+ * reaches them, and the production ones are out of reach from then on.
304
+ * Keyed by a symbol no entry point exports, so it is not part of what an
305
+ * app can call; see LAMBDER_BACKEND_SWAP.
306
+ */
307
+ [LAMBDER_BACKEND_SWAP](backends) {
308
+ if (this.files && backends.fileSource)
309
+ this.files[LAMBDER_BACKEND_SWAP](backends.fileSource);
310
+ return { ...this.pipeline[LAMBDER_BACKEND_SWAP](backends), files: this.files !== null };
311
+ }
312
+ /**
313
+ * The crash watch `lambder/testing` sets: told every error a request
314
+ * throws past the framework's own handling, before the global error
315
+ * handler or the last-resort 500 answers it. The answer is unchanged.
316
+ */
317
+ [LAMBDER_CRASH_WATCH](watcher) {
318
+ this.crashWatcher = watcher;
319
+ }
290
320
  /**
291
321
  * Every registered endpoint's signature, keyed by its hashed name: the
292
322
  * LambderApiSignatureMap both sides ship with. A generator imports the
@@ -545,6 +575,7 @@ export default class Lambder {
545
575
  // client could parse. coerceToError is the shared version of that
546
576
  // care, the one every site in the framework now uses.
547
577
  const wrappedError = coerceToError(err, "an unstringifiable thrown value");
578
+ this.crashWatcher?.(wrappedError);
548
579
  // ctx may be null (createContext failed): derive the format from the raw event.
549
580
  const eventFormat = ctx?.eventFormat ?? (isV2HttpEvent(event) ? "v2" : "v1");
550
581
  try {
@@ -1,5 +1,6 @@
1
1
  import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
2
2
  import type { LambderFileSource } from "../shared/contracts/LambderFileSource.js";
3
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
3
4
  /** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. false disables it. */
4
5
  export type LambderFileMemoryCacheOption = false | {
5
6
  maxBytes?: number;
@@ -30,6 +31,12 @@ export declare class LambderFiles {
30
31
  private maxFileBytes;
31
32
  private templates;
32
33
  constructor(option: LambderFilesOption);
34
+ /**
35
+ * Puts the reader over another source, for `lambder/testing`. In place,
36
+ * because servePublicFiles holds this reader rather than the instance's
37
+ * field; both caches go with the source they were filled from.
38
+ */
39
+ [LAMBDER_BACKEND_SWAP](source: LambderFileSource): void;
33
40
  /**
34
41
  * The file at a request or handler path (leading slash optional), mime
35
42
  * type resolved; null when the path is invalid or the source has none.
@@ -1,5 +1,6 @@
1
1
  import mimeTypeResolver from "mime-types";
2
2
  import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
3
+ import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
3
4
  const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
4
5
  const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
5
6
  /**
@@ -45,6 +46,17 @@ export class LambderFiles {
45
46
  this.maxBytes = memoryCache === false ? 0 : (memoryCache?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES);
46
47
  this.maxFileBytes = memoryCache === false ? 0 : (memoryCache?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES);
47
48
  }
49
+ /**
50
+ * Puts the reader over another source, for `lambder/testing`. In place,
51
+ * because servePublicFiles holds this reader rather than the instance's
52
+ * field; both caches go with the source they were filled from.
53
+ */
54
+ [LAMBDER_BACKEND_SWAP](source) {
55
+ this.source = source;
56
+ this.cache?.clear();
57
+ this.cacheBytes = 0;
58
+ this.templates.clear();
59
+ }
48
60
  /**
49
61
  * The file at a request or handler path (leading slash optional), mime
50
62
  * type resolved; null when the path is invalid or the source has none.
package/dist/index.d.ts CHANGED
@@ -104,7 +104,7 @@ export type { LambderApiIdempotencyConfig } from "./api/LambderApiIdempotency.js
104
104
  export type { LambderGuardsOptionValue, LambderRateLimitOverride, LambderRateLimitOptionValue, LambderApiIdempotencyOption, } from "./shared/wire/LambderApiOptionValues.js";
105
105
  export { createLambderI18n } from "./shared/LambderI18n.js";
106
106
  export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nDictionaryLoader, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
107
- export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiResponseConfig, LambderApiNullAnswerConfig, LambderContractEntry, LambderMergeContract, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractRateLimitNames, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
107
+ export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiResponseConfig, LambderApiNullAnswerConfig, LambderContractEntry, LambderMergeContract, LambderFlattenContract, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractRateLimitNames, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
108
108
  export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent, LambderHttpEventFormat } from "./core/LambderContext.js";
109
109
  export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
110
110
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
@@ -8,7 +8,8 @@
8
8
  * its body envelope, and a carried session as the cookie pair it travels as.
9
9
  * Server-only: Buffer and the codec's zlib restore.
10
10
  */
11
- import type { APIGatewayProxyEventV2, Context } from "aws-lambda";
11
+ import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, Context } from "aws-lambda";
12
+ import type { LambderHttpEventFormat } from "../core/LambderContext.js";
12
13
  import type { LambderCompressedBrotliPayload, LambderCompressedGzipPayload } from "../shared/wire/LambderRequestPayload.js";
13
14
  /** Marks a synthesized request as an invoke, for guards and hooks that want to tell. Not an authorization. */
14
15
  export declare const LAMBDER_INVOKE_HEADER = "x-lambder-invoke";
@@ -34,19 +35,32 @@ export type LambderSynthesizedRequest = {
34
35
  body?: string | Buffer;
35
36
  };
36
37
  /**
37
- * The payload-format-2.0 event API Gateway would deliver for this request.
38
+ * The event API Gateway would deliver for this request: payload format 2.0
39
+ * (an HTTP API, a Function URL) unless `eventFormat: "v1"` asks for the REST
40
+ * API's. An invoke is always 2.0; the other format is for an in-process call
41
+ * that wants the handler to meet the shape its own deployment delivers.
38
42
  * `invoke: true` adds the invoke marker headers a server-to-server call
39
43
  * carries; a browser-shaped request (the handler transport) leaves them off.
40
44
  *
41
- * The client address is `clientIp` and reaches the callee as
42
- * requestContext.http.sourceIp only. Writing it as x-forwarded-for as well
43
- * would put the same fact on a channel a callee may be configured to trust
44
- * (trustedClientIpHeaders), and the header is the one the caller's own
45
- * `headers` could otherwise have set.
45
+ * The client address is `clientIp` and reaches the callee as the gateway's
46
+ * observed source address only (requestContext.http.sourceIp, or
47
+ * requestContext.identity.sourceIp on a REST API event). Writing it as
48
+ * x-forwarded-for as well would put the same fact on a channel a callee may
49
+ * be configured to trust (trustedClientIpHeaders), and the header is the one
50
+ * the caller's own `headers` could otherwise have set.
46
51
  */
47
- export declare const synthesizeLambdaHttpEvent: (request: LambderSynthesizedRequest, options: {
52
+ export declare function synthesizeLambdaHttpEvent(request: LambderSynthesizedRequest, options: {
48
53
  invoke: boolean;
49
- }) => APIGatewayProxyEventV2;
54
+ eventFormat?: "v2";
55
+ }): APIGatewayProxyEventV2;
56
+ export declare function synthesizeLambdaHttpEvent(request: LambderSynthesizedRequest, options: {
57
+ invoke: boolean;
58
+ eventFormat: "v1";
59
+ }): APIGatewayProxyEvent;
60
+ export declare function synthesizeLambdaHttpEvent(request: LambderSynthesizedRequest, options: {
61
+ invoke: boolean;
62
+ eventFormat?: LambderHttpEventFormat;
63
+ }): APIGatewayProxyEventV2 | APIGatewayProxyEvent;
50
64
  /**
51
65
  * The body envelope LambderCaller sends, minus the fields only a browser has
52
66
  * a value for, as JSON. A plain payload arrives already serialized (the