lambder 6.0.1 → 7.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 (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +26 -24
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
@@ -0,0 +1,815 @@
1
+ import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
2
+ import { readApiEnvelope, cookieValuesByName, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
3
+ import { createApiCallContext } from "../api/LambderApiCallContext.js";
4
+ import { toHttpAnswer } from "../api/LambderApiAnswer.js";
5
+ import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
6
+ import { buildApiEnvelope, envelopeAnswer, crashAnswer, } from "../api/LambderApiEnvelope.js";
7
+ import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
8
+ import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
9
+ import { buildTransportEnvelope } from "../shared/transport/LambderApiTransport.js";
10
+ import { lambderCookieJarTransport } from "../shared/transport/lambderCookieJarTransport.js";
11
+ import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
12
+ import { serializeClearCookie } from "../shared/wire/LambderCookie.js";
13
+ import { LOOPBACK_CLIENT_IP, normalizeClientIp } from "../shared/util/LambderClientIp.js";
14
+ import { lambderGuardBuilder } from "../api/LambderApiGuards.js";
15
+ import { lambderRateLimitKeyBuilder } from "../api/LambderApiRateLimits.js";
16
+ import { LambderMockFailureInjector, LambderMockTransportError } from "./LambderMockFailureInjector.js";
17
+ import { LambderMockCallRecorder } from "./LambderMockCallRecorder.js";
18
+ import { LambderMockEntryRegistry } from "./LambderMockEntryRegistry.js";
19
+ import { LambderMockBrowserCookies } from "./LambderMockBrowserCookies.js";
20
+ import { LambderMemoryRateLimiter } from "../stores/LambderMemoryRateLimiter.js";
21
+ import { LambderMemoryIdempotencyStore } from "../stores/LambderMemoryIdempotencyStore.js";
22
+ import { LambderMemorySessionStore } from "../stores/LambderMemorySessionStore.js";
23
+ import LambderSessionManager from "../session/LambderSessionManager.js";
24
+ import { isWebCryptoAvailable, LambderPlainSessionCrypto } from "../session/LambderSessionCrypto.js";
25
+ import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
26
+ // ---------------------------------------------------------------------------
27
+ // Construction
28
+ // ---------------------------------------------------------------------------
29
+ /** The transport with its jar attached, which is what makes the jar reachable without a second creation call. */
30
+ const transportCarrying = (transport, jar) => Object.assign(transport, { cookieJar: jar });
31
+ const DEFAULT_CALL_LOG_SIZE = 200;
32
+ const DEFAULT_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
33
+ /**
34
+ * The host the runtime's cookies belong to when the app names none: the page
35
+ * the mock is running in, or "localhost" where there is no page (a Node test).
36
+ */
37
+ const defaultCookieHost = () => globalThis.location?.host || "localhost";
38
+ /**
39
+ * The mock runtime: the API core (LambderApiPipeline, the same class the
40
+ * Lambda server runs) over memory stores, with a registry of typed mock
41
+ * handlers where the server has app handlers, and mock guards where it has
42
+ * app guards. Everything the protocol does (envelope, refusals, sessions
43
+ * and their cookies, guards, rate limits, idempotency, the version gate)
44
+ * happens in the core; this class only resolves a name to an entry, wraps
45
+ * the handler's return into the envelope, and adds what a mock needs on
46
+ * top: failure injection, latency, a subscription, a call log, reset.
47
+ *
48
+ * Create one with initLambderMock<Contract, SessionData>().create(...),
49
+ * which fixes the contract and session types first so everything else is
50
+ * inferred from the options.
51
+ */
52
+ export class LambderMockApp {
53
+ apiVersion;
54
+ /** The memory stores, for assertions and reset; null for a subsystem that is off or backed by a store of yours. */
55
+ sessionStore;
56
+ rateLimiter;
57
+ idempotencyStore;
58
+ tokenCookieKey;
59
+ csrfCookieKey;
60
+ /**
61
+ * The client IP a transport request carrying none is read as. Public
62
+ * because an adapter has to read the same default the direct transport
63
+ * uses: the MSW adapter had a hardcoded "127.0.0.1" of its own, so an app
64
+ * that set defaultClientIp saw one address through the transport and
65
+ * another through the service worker, and a per-IP rate limit counted two
66
+ * clients where there was one.
67
+ */
68
+ defaultClientIp;
69
+ /** The host this runtime's cookies belong to (see the cookieHost option). */
70
+ cookieHost;
71
+ /**
72
+ * The API core, every protocol step of it. Private: the mock's surface is
73
+ * the app, and a consumer reaching past it would be configuring the
74
+ * server's pipeline through a development tool. The three adapters take
75
+ * what they need from the app's own methods (handleRequest,
76
+ * requestFromTransport), which is why none of them names this.
77
+ */
78
+ pipeline;
79
+ /** The cookie scope signIn plants under, so signOut can name the same one when it clears them. */
80
+ sessionCookieOptions;
81
+ sessionTtlSeconds;
82
+ onReset;
83
+ revealHandlerErrors;
84
+ /** Injected failures, the offline switch and the configured latency (see LambderMockFailureInjector). */
85
+ failures;
86
+ /** Subscriptions and the bounded call log (see LambderMockCallRecorder). */
87
+ recorder;
88
+ /** Registered entries and the overrides over them (see LambderMockEntryRegistry). */
89
+ registry = new LambderMockEntryRegistry();
90
+ /** The jars the runtime owns and what it planted in document.cookie (see LambderMockBrowserCookies). */
91
+ browserCookies = new LambderMockBrowserCookies();
92
+ constructor(options) {
93
+ this.apiVersion = options.apiVersion ?? null;
94
+ this.failures = new LambderMockFailureInjector({ apiVersion: this.apiVersion, latency: options.latency ?? 0 });
95
+ this.recorder = new LambderMockCallRecorder({ callLogSize: options.callLogSize ?? DEFAULT_CALL_LOG_SIZE });
96
+ // The loopback address when nothing names a client, as a request from
97
+ // the page itself, and the same constant the in-process handler
98
+ // transport defaults to. Normalized here rather than at every reader:
99
+ // one textual form per address is what makes a per-IP rate limit's
100
+ // counter one counter, and this value is read by the direct transport,
101
+ // by the MSW adapter and by every request that names no client.
102
+ this.defaultClientIp = normalizeClientIp(options.defaultClientIp ?? LOOPBACK_CLIENT_IP);
103
+ this.cookieHost = options.cookieHost ?? defaultCookieHost();
104
+ this.onReset = options.onReset ?? null;
105
+ this.revealHandlerErrors = options.revealHandlerErrors ?? true;
106
+ const sessionOptions = options.sessions === true ? {} : options.sessions || null;
107
+ this.sessionTtlSeconds = sessionOptions?.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS;
108
+ this.tokenCookieKey = sessionOptions?.tokenCookieKey ?? DEFAULT_SESSION_TOKEN_COOKIE_KEY;
109
+ this.csrfCookieKey = sessionOptions?.csrfCookieKey ?? DEFAULT_SESSION_CSRF_COOKIE_KEY;
110
+ this.sessionCookieOptions = sessionOptions?.cookieOptions ?? {};
111
+ const memorySessionStore = sessionOptions && !sessionOptions.store ? new LambderMemorySessionStore() : null;
112
+ this.sessionStore = memorySessionStore;
113
+ const rateLimits = options.rateLimits;
114
+ const memoryLimiter = rateLimits && !rateLimits.limiter ? new LambderMemoryRateLimiter() : null;
115
+ this.rateLimiter = memoryLimiter;
116
+ const idempotencyOptions = options.idempotency === true ? {} : options.idempotency || null;
117
+ const memoryIdempotency = idempotencyOptions && !idempotencyOptions.store ? new LambderMemoryIdempotencyStore() : null;
118
+ this.idempotencyStore = memoryIdempotency;
119
+ this.pipeline = new LambderApiPipeline({
120
+ apiVersion: this.apiVersion,
121
+ maxRequestPayloadBytes: options.maxRequestPayloadBytes,
122
+ sessions: sessionOptions
123
+ ? {
124
+ manager: new LambderSessionManager({
125
+ store: sessionOptions.store ?? memorySessionStore,
126
+ sessionSalt: sessionOptions.sessionSalt ?? "lambder-mock",
127
+ enableSlidingExpiration: sessionOptions.enableSlidingExpiration,
128
+ slidingWriteIntervalSeconds: sessionOptions.slidingWriteIntervalSeconds,
129
+ dataRefresh: sessionOptions.dataRefresh,
130
+ // A plain-http page (device testing on a LAN) has no
131
+ // crypto.subtle, and a memory-only store is nothing
132
+ // anyone can leak, so hashing there protects nothing.
133
+ // Keyed on the store's own isMemoryOnly rather than on
134
+ // "did this runtime create it": an app that passes its
135
+ // own LambderMemorySessionStore got WebCrypto and threw
136
+ // on the first session call where the default path
137
+ // degrades, and a store that outlives the process still
138
+ // gets real hashing, which is what the declaration is
139
+ // there to say.
140
+ crypto: sessionOptions.crypto
141
+ ?? (!isWebCryptoAvailable() && (sessionOptions.store?.isMemoryOnly ?? true) ? new LambderPlainSessionCrypto() : undefined),
142
+ }),
143
+ tokenCookieKey: this.tokenCookieKey,
144
+ csrfCookieKey: this.csrfCookieKey,
145
+ cookieOptions: sessionOptions.cookieOptions,
146
+ }
147
+ : undefined,
148
+ rateLimits: rateLimits
149
+ ? { limiter: rateLimits.limiter ?? memoryLimiter, policies: rateLimits.policies, failOpen: rateLimits.failOpen }
150
+ : undefined,
151
+ guards: options.guards,
152
+ idempotency: idempotencyOptions
153
+ ? {
154
+ store: idempotencyOptions.store ?? memoryIdempotency,
155
+ defaultTtlSeconds: idempotencyOptions.defaultTtlSeconds,
156
+ defaultPendingTtlSeconds: idempotencyOptions.defaultPendingTtlSeconds,
157
+ failOpen: idempotencyOptions.failOpen,
158
+ // The engine's own option is typed for the base call
159
+ // context, being the one thing every adapter shares; what
160
+ // it actually hands the function is the context of the
161
+ // adapter running it, which here is the mock's. So the
162
+ // option is declared for the mock context, where a reader
163
+ // can use ctx.request, and widened at the one place that
164
+ // knows both sides.
165
+ callerIdentity: idempotencyOptions.callerIdentity,
166
+ }
167
+ : undefined,
168
+ });
169
+ }
170
+ // -----------------------------------------------------------------------
171
+ // Registry
172
+ // -----------------------------------------------------------------------
173
+ /**
174
+ * The registration-time checks every entry goes through, mocked or not:
175
+ * the ones the server runs on a definition, and the mock's own "a session
176
+ * endpoint needs the sessions option".
177
+ *
178
+ * One place, so no registration path can skip either check. A session
179
+ * endpoint on a mock without sessions would otherwise register silently
180
+ * and answer the first call with a 500 from inside the pipeline, naming
181
+ * the SERVER's option name, for a mistake whose fix is one option at
182
+ * create().
183
+ */
184
+ assertEntryRegistration(definition) {
185
+ this.pipeline.assertRegistration(definition);
186
+ if (definition.mode === "session" && !this.pipeline.hasSessions) {
187
+ throw new Error(`LambderMockApp: session endpoint "${definition.name}" needs the sessions option at creation.`);
188
+ }
189
+ }
190
+ buildEntry(name, mode, input) {
191
+ const options = (typeof input === "function" ? { handler: input } : input);
192
+ const definition = {
193
+ name, mode,
194
+ guards: options.guards,
195
+ rateLimit: options.rateLimit,
196
+ idempotency: options.idempotency,
197
+ // No cast: the entry's schema is a z.ZodType, the same type the
198
+ // definition holds. A structural { safeParse } here would let a
199
+ // validator that is not a zod schema reach the 422 body as
200
+ // `zodError: { name: undefined, message: undefined, issues:
201
+ // undefined }`, a refusal a client cannot read.
202
+ input: options.input,
203
+ };
204
+ this.assertEntryRegistration(definition);
205
+ const handler = options.handler;
206
+ return { name, mode, definition, handler: async (ctx) => await handler(ctx), notMockedReason: null };
207
+ }
208
+ /** A mock for a public endpoint: a handler, or the handler with the endpoint's declarations restated. */
209
+ publicApi(name, entry) {
210
+ return this.buildEntry(name, "public", entry);
211
+ }
212
+ /** A mock for a session endpoint: the pipeline fetches the session before the handler runs, and refuses without one. */
213
+ sessionApi(name, entry) {
214
+ return this.buildEntry(name, "session", entry);
215
+ }
216
+ /**
217
+ * A public endpoint deliberately left without a mock; a call answers the
218
+ * notMocked refusal carrying the reason.
219
+ *
220
+ * Public and session have separate builders for the same reason publicApi
221
+ * and sessionApi do: the refusal runs through the pipeline so that the
222
+ * steps BEFORE dispatch still happen, and the session read is one of them.
223
+ * Declaring every not-mocked endpoint public switched that step off, so a
224
+ * session endpoint with no session answered "not mocked" where the server
225
+ * answers sessionExpired, and the mode on its events and call log was
226
+ * wrong too. The mode cannot be recovered at runtime, because the contract
227
+ * is a type, so the builder is where it has to be said.
228
+ */
229
+ notMocked(name, reason) {
230
+ return this.buildNotMockedEntry(name, "public", reason);
231
+ }
232
+ /** A session endpoint deliberately left without a mock: the session is still read, and refused before the notMocked refusal. */
233
+ sessionNotMocked(name, reason) {
234
+ return this.buildNotMockedEntry(name, "session", reason);
235
+ }
236
+ /**
237
+ * "Everything I did not register is not mocked, for this reason", as an
238
+ * argument to the same register() call:
239
+ *
240
+ * ```ts
241
+ * mockApp.register(userMocks, billingMocks, mockApp.restNotMocked("not mocked yet"));
242
+ * ```
243
+ *
244
+ * What it buys is adoption over a contract the mocks do not cover yet:
245
+ * register() stays exhaustive by construction, and the endpoints nothing
246
+ * claims answer the notMocked refusal carrying this reason instead of
247
+ * apiNotFound, so a screen that reaches one says "not mocked yet" rather
248
+ * than "unknown error". Strays and duplicates in the explicit slices are
249
+ * refused exactly as they are without it, and an entry registered later
250
+ * (registerPartial, or a second register) takes the endpoint back from the
251
+ * rest.
252
+ *
253
+ * The one thing it cannot do is the session read. A call it answers is
254
+ * processed as a public endpoint: the protocol's pre-pass still runs, so a
255
+ * stale client still hears versionExpired, but the mode of a name nothing
256
+ * registered is not knowable at runtime, the contract being a type. So a
257
+ * signed-out call to an unmocked session endpoint is answered "not mocked"
258
+ * where the server answers sessionExpired, and the endpoint whose
259
+ * signed-out path a test cares about is the one to declare with
260
+ * sessionNotMocked instead.
261
+ */
262
+ restNotMocked(reason) {
263
+ return { restNotMockedReason: reason };
264
+ }
265
+ buildNotMockedEntry(name, mode, reason) {
266
+ const definition = { name, mode };
267
+ this.assertEntryRegistration(definition);
268
+ return { name, mode, definition, handler: null, notMockedReason: reason };
269
+ }
270
+ /** The entries of one module as a slice, keyed by name. Two entries for one endpoint is an error here. */
271
+ apiSlice(...entries) {
272
+ const slice = {};
273
+ for (const entry of entries) {
274
+ if (slice[entry.name])
275
+ throw new Error(`LambderMockApp: endpoint "${entry.name}" appears twice in one slice.`);
276
+ slice[entry.name] = entry;
277
+ }
278
+ return slice;
279
+ }
280
+ addSlices(slices) {
281
+ this.registry.addSlices(slices);
282
+ }
283
+ /**
284
+ * Registers the whole contract: every endpoint in exactly one slice, or
285
+ * in the reach of a restNotMocked entry passed beside them.
286
+ * Completeness, strays and overlap are checked by the compiler against
287
+ * the contract type; overlap and key-to-name agreement are checked again
288
+ * at runtime for slices built dynamically, and a second rest entry is
289
+ * refused there the way a duplicate name is.
290
+ */
291
+ register(...slices) {
292
+ this.addSlices(slices);
293
+ return this;
294
+ }
295
+ /** Registers some endpoints, for a test that wants three and not three hundred. Overlap is still an error. */
296
+ registerPartial(...slices) {
297
+ this.addSlices(slices);
298
+ return this;
299
+ }
300
+ /**
301
+ * Replaces one endpoint's handler until restored: returns its own undo,
302
+ * which a test scopes with try/finally. The entry's declarations (mode,
303
+ * guards, rate limit, idempotency) stay as registered; only the handler
304
+ * changes.
305
+ *
306
+ * Overrides nest. A second override over the same endpoint stands on the
307
+ * first, and restoring it uncovers the first rather than the registry, so
308
+ * an override one `it` scoped cannot drop the one a describe put in place
309
+ * around it.
310
+ */
311
+ override(name, handler) {
312
+ const base = this.registry.registered(name);
313
+ // A name with no entry has no declarations to keep, and inventing a
314
+ // public one would answer a session endpoint with no session, no
315
+ // guards and no rate limit: a test would read that as a pass for a
316
+ // call the server refuses. Register it first, then override it.
317
+ if (!base) {
318
+ throw new Error(`LambderMockApp: override("${name}") has nothing to override. Register the endpoint first (register or registerPartial), then override its handler.`);
319
+ }
320
+ const entry = { ...base, handler: async (ctx) => await handler(ctx), notMockedReason: null };
321
+ return this.registry.pushOverride(name, entry);
322
+ }
323
+ /** Puts every overridden handler back, however deeply they were stacked. */
324
+ restoreOverrides() {
325
+ this.registry.restoreOverrides();
326
+ }
327
+ /** The registered endpoint names. */
328
+ get registeredNames() {
329
+ return this.registry.names;
330
+ }
331
+ /**
332
+ * Whether a call to this name would be answered from the registry, which
333
+ * is what an adapter asks before passing one on.
334
+ *
335
+ * True for every name once a rest entry is registered, because the rest
336
+ * entry is what answers the names nothing else claimed. That is what makes
337
+ * a rest entry and the MSW adapter's `onUnmocked: "passthrough"`
338
+ * alternatives rather than layers: with one registered, the runtime
339
+ * answers everything itself and nothing is handed on to the network.
340
+ */
341
+ hasRegisteredEntry(apiName) {
342
+ return this.entryFor(apiName) !== null || this.registry.restNotMockedReason !== null;
343
+ }
344
+ entryFor(apiName) {
345
+ return this.registry.entryFor(apiName);
346
+ }
347
+ /**
348
+ * The entry that answers a name nothing registered, when register() was
349
+ * given a rest entry: the notMocked refusal carrying its reason, run
350
+ * through the pipeline as a public endpoint.
351
+ *
352
+ * Public because the mode of an unregistered name cannot be recovered at
353
+ * runtime, the contract being a type. Everything that precedes dispatch
354
+ * still runs (the version gate, the payload restore); the session read is
355
+ * the one step this answer cannot have, which is the fidelity limit
356
+ * restNotMocked documents.
357
+ */
358
+ restNotMockedEntry(apiName) {
359
+ const reason = this.registry.restNotMockedReason;
360
+ if (reason === null)
361
+ return null;
362
+ return { name: apiName, mode: "public", definition: { name: apiName, mode: "public" }, handler: null, notMockedReason: reason };
363
+ }
364
+ // -----------------------------------------------------------------------
365
+ // Control surface
366
+ // -----------------------------------------------------------------------
367
+ /** The next call to the endpoint fails this way; several calls queue in order. */
368
+ failNext(apiName, failure) {
369
+ this.failures.failNext(apiName, failure);
370
+ }
371
+ /** Every call to the endpoint fails this way until cleared with null. */
372
+ setFailure(apiName, failure) {
373
+ this.failures.setFailure(apiName, failure);
374
+ }
375
+ /** Every call rejects at the transport, as with no network at all. */
376
+ setOffline(offline) {
377
+ this.failures.setOffline(offline);
378
+ }
379
+ setLatency(latency) {
380
+ this.failures.setLatency(latency);
381
+ }
382
+ /**
383
+ * Rewinds the runtime: sessions, rate-limit counters, replay records,
384
+ * overrides, injected failures, the offline switch, the configured
385
+ * latency, the call log and its numbering, the cookies its own transports
386
+ * hold, then onReset, so the app rewinds its own data too.
387
+ *
388
+ * The cookies matter as much as the sessions do: emptying the session
389
+ * store while a jar still holds the token for one of them leaves the next
390
+ * call carrying a session that no longer exists, which reads as signed in
391
+ * until the answer says sessionExpired. So every jar transport() built
392
+ * for itself is emptied, and the cookies a "document" transport mirrored
393
+ * are expired again.
394
+ *
395
+ * The registry survives, being what the runtime was configured with
396
+ * rather than what it accumulated. Subscriptions survive too, because
397
+ * they are how a test watches the runtime rather than state it is
398
+ * testing; a listener muted for throwing is unmuted, so one bad call does
399
+ * not silence it for the rest of the run. A session store or a cookie jar
400
+ * the app supplied itself survives: the runtime did not create it and
401
+ * does not know what else holds it.
402
+ */
403
+ reset() {
404
+ this.sessionStore?.reset();
405
+ this.rateLimiter?.reset();
406
+ this.idempotencyStore?.reset();
407
+ this.registry.restoreOverrides();
408
+ this.failures.reset();
409
+ this.recorder.reset();
410
+ this.browserCookies.reset();
411
+ this.onReset?.();
412
+ }
413
+ // -----------------------------------------------------------------------
414
+ // Sessions
415
+ // -----------------------------------------------------------------------
416
+ /**
417
+ * The four session members refuse in the mock's own words, naming the
418
+ * option a mock is created with.
419
+ *
420
+ * The pipeline's guard says "Configure the session option at creation",
421
+ * which is the SERVER's option name: the mock's is `sessions`, and a
422
+ * reader who goes looking for `session` on create() does not find it.
423
+ * The registration path was fixed for exactly this one method over.
424
+ */
425
+ assertSessionsConfigured(member) {
426
+ if (!this.pipeline.hasSessions)
427
+ throw new Error(`LambderMockApp: ${member} needs the sessions option at creation.`);
428
+ }
429
+ /** The session manager, for tests that inspect or manipulate sessions directly. Throws when sessions are off. */
430
+ get sessionManager() {
431
+ this.assertSessionsConfigured("sessionManager");
432
+ return this.pipeline.sessionManager;
433
+ }
434
+ /**
435
+ * Starts a session without a login endpoint: creates it through the
436
+ * session controller, the way a login handler does, plants its cookies
437
+ * into the jar when one is given, so the jar's transport is signed in
438
+ * from its next call, and mirrors the readable ones into document.cookie
439
+ * the way an answer's cookies are. Returns the raw tokens too.
440
+ */
441
+ async signIn(sessionKey, data, options = {}) {
442
+ this.assertSessionsConfigured("signIn()");
443
+ // The app's one cookie host unless this call names another: planted
444
+ // under a host the transport does not read them back at, the cookies
445
+ // are simply never sent, and every session call answers sessionExpired
446
+ // with a full jar.
447
+ const host = options.host ?? this.cookieHost;
448
+ const ctx = createApiCallContext();
449
+ const controller = this.pipeline.sessionController(ctx, { host, cookies: {}, csrfToken: null });
450
+ const created = await controller.issueSession(sessionKey, data, options.ttlSeconds ?? this.sessionTtlSeconds);
451
+ const headers = {};
452
+ ctx.responseHeaders.applyInto(headers);
453
+ const setCookies = getAnswerHeader(headers, "Set-Cookie") ?? [];
454
+ // The host these cookies came from, which is the same one the
455
+ // controller wrote them for. A jar checks every Domain against the
456
+ // sending host and refuses one it cannot check, so planting them
457
+ // unscoped dropped the session cookie of any app that configures a
458
+ // cookie domain, silently.
459
+ options.jar?.storeSetCookies(setCookies, { host });
460
+ // Through the same mirror every other cookie writer uses. Behind the
461
+ // MSW adapter the jar is not where a page reads its CSRF token: the
462
+ // browser caller reads document.cookie and posts what it finds, so a
463
+ // signIn that only filled a jar left the token empty and every one of
464
+ // the session endpoints answered sessionExpired.
465
+ this.browserCookies.mirrorSetCookies(setCookies);
466
+ return created;
467
+ }
468
+ /**
469
+ * Ends every session of the subject ("log this subject out everywhere")
470
+ * and clears what signIn planted: the cookies in the jar given, and the
471
+ * copies in document.cookie.
472
+ *
473
+ * Symmetric on purpose, the way reset() is. The records alone leave the
474
+ * jar and the page carrying a token for a session that no longer exists,
475
+ * which reads as signed in until an answer says otherwise.
476
+ */
477
+ async signOut(sessionKey, options = {}) {
478
+ this.assertSessionsConfigured("signOut()");
479
+ await this.pipeline.sessionManager.deleteSessionAllByKey(sessionKey);
480
+ const host = options.host ?? this.cookieHost;
481
+ // The scope signIn planted under: a deletion only reaches a cookie
482
+ // carrying the same Domain and Path, so it is built from the app's own
483
+ // cookie options rather than from defaults.
484
+ const cleared = [
485
+ serializeClearCookie(this.tokenCookieKey, { ...this.sessionCookieOptions, httpOnly: true }, host),
486
+ serializeClearCookie(this.csrfCookieKey, this.sessionCookieOptions, host),
487
+ ];
488
+ options.jar?.storeSetCookies(cleared, { host });
489
+ this.browserCookies.mirrorSetCookies(cleared);
490
+ }
491
+ /** Marks the subject's session data stale, so the next read renews it through dataRefresh. */
492
+ async expireSessionData(sessionKey) {
493
+ this.assertSessionsConfigured("expireSessionData()");
494
+ await this.pipeline.sessionManager.expireSessionDataAllByKey(sessionKey);
495
+ }
496
+ // -----------------------------------------------------------------------
497
+ // Observation
498
+ // -----------------------------------------------------------------------
499
+ /**
500
+ * Listens to every call, both phases. Keyed, so a hot-reloaded module
501
+ * replaces its own listener instead of stacking a duplicate. Returns the
502
+ * unsubscribe.
503
+ */
504
+ subscribe(key, listener) {
505
+ return this.recorder.subscribe(key, listener);
506
+ }
507
+ /** The completed calls, oldest first, bounded by callLogSize. */
508
+ get calls() {
509
+ return this.recorder.calls;
510
+ }
511
+ emit(event) {
512
+ this.recorder.emit(event);
513
+ }
514
+ // -----------------------------------------------------------------------
515
+ // Handling a call
516
+ // -----------------------------------------------------------------------
517
+ /** The context one call runs on: the core's call context plus what mock guards and handlers see. */
518
+ createContext(request) {
519
+ const pipeline = this.pipeline;
520
+ const base = createApiCallContext();
521
+ const ctx = Object.assign(base, {
522
+ apiName: request.apiName,
523
+ request,
524
+ signal: request.signal ?? new AbortController().signal,
525
+ envelope: {},
526
+ payload: request.payload,
527
+ guardInputs: request.guardInputs,
528
+ // The key as a handler can use it. A non-string is not a key: the
529
+ // engine refuses one with a 400 on every endpoint that declares
530
+ // idempotency, and on one that does not it is a value nothing
531
+ // reads, so handing it over typed as a string would be the lie.
532
+ idempotencyKey: typeof request.idempotencyKey === "string" ? request.idempotencyKey : undefined,
533
+ sessions: undefined,
534
+ });
535
+ // The controller reads and writes this very context, so it is built
536
+ // after it. Without sessions configured, touching it says why.
537
+ Object.defineProperty(ctx, "sessions", {
538
+ enumerable: true,
539
+ get() {
540
+ if (!pipeline.hasSessions)
541
+ throw new Error(`LambderMockApp: ctx.sessions on "${request.apiName}" needs the sessions option at creation.`);
542
+ return pipeline.sessionController(ctx, LambderApiPipeline.sessionInfoOf(request));
543
+ },
544
+ });
545
+ return ctx;
546
+ }
547
+ /** What a call looks like on the way in, for the runtime's own calls and for one an adapter passes on. */
548
+ requestEvent(id, request, mode, at) {
549
+ return {
550
+ phase: "request", id, apiName: request.apiName, mode,
551
+ payload: request.payload, guardInputs: request.guardInputs, idempotencyKey: request.idempotencyKey,
552
+ version: request.version, headers: request.headers,
553
+ hasSessionCookie: (request.cookies[this.tokenCookieKey]?.length ?? 0) > 0,
554
+ at,
555
+ };
556
+ }
557
+ /**
558
+ * Records a call an adapter handed on instead of answering: the MSW
559
+ * adapter's passthrough. Without it a name the registry does not know
560
+ * leaves no trace at all, and a mistyped endpoint reaches the real
561
+ * network with nothing in the call log or on the subscription to say so,
562
+ * which is the one failure the log exists to make visible.
563
+ */
564
+ notePassthrough(request) {
565
+ const facts = this.callFacts(this.recorder.nextCallId(), request, null);
566
+ this.emit(this.requestEvent(facts.id, request, null, facts.startedAt));
567
+ this.recorder.settle(facts, { answer: null, outcome: "passthrough", guardsRun: [] });
568
+ }
569
+ /** What every record of one call repeats (see LambderMockCallFacts). */
570
+ callFacts(id, request, mode) {
571
+ return { id, apiName: request.apiName, mode, startedAt: Date.now(), request };
572
+ }
573
+ /** One call from a parsed request to its answer, events included. The entry point every transport and adapter shares. */
574
+ async handleRequest(request) {
575
+ const id = this.recorder.nextCallId();
576
+ const registered = this.entryFor(request.apiName);
577
+ // The rest entry answers whatever nothing registered, when register()
578
+ // was given one. The mode reported stays the registered entry's, so it
579
+ // is null here exactly as it is for a name the registry does not know:
580
+ // the rest answer is processed as public, which is a property of the
581
+ // answer rather than a claim about the endpoint.
582
+ const entry = registered ?? this.restNotMockedEntry(request.apiName);
583
+ const mode = registered?.mode ?? null;
584
+ const facts = this.callFacts(id, request, mode);
585
+ const startedAt = facts.startedAt;
586
+ const ctx = this.createContext(request);
587
+ // The pipeline's own trace, handed in rather than read off what run()
588
+ // returns: a crash unwinds past the return, and the guards that ran
589
+ // before it are exactly what a developer reading the call log is
590
+ // looking for. Written as the call goes, so it survives the throw.
591
+ const trace = { guardsRun: [], replayed: false };
592
+ let answer;
593
+ let outcome;
594
+ let error;
595
+ try {
596
+ // The protocol's pre-pass, run before the name is resolved, which
597
+ // is where the server runs it. Two things depended on it: an
598
+ // unknown name reached the notFound refusal without the version
599
+ // gate or the payload restore, so a stale client or a malformed
600
+ // compressed payload was answered differently here than on the
601
+ // server; and the request event carried the wire fields instead of
602
+ // the payload, so a dev panel watching calls in flight showed
603
+ // nothing for exactly the compressed calls someone opens a panel
604
+ // for. run() calls prepare again, which is safe by construction.
605
+ const prepared = await this.pipeline.prepare(request);
606
+ this.emit(this.requestEvent(id, request, mode, startedAt));
607
+ await this.failures.wait(this.failures.latencyFor(request.apiName), request.signal);
608
+ if (this.failures.offline)
609
+ throw new LambderMockTransportError("offline");
610
+ // After the wait and the offline check, both of which end the call
611
+ // before it reaches a handler: taking the failure first spent a
612
+ // queued failNext on a call that never got to be failed by it, and
613
+ // the next call, the one the test was arranging for, then answered
614
+ // normally.
615
+ const failure = this.failures.take(request.apiName);
616
+ if (failure) {
617
+ answer = await this.failures.answerFor(failure, request);
618
+ outcome = "injected";
619
+ }
620
+ else if (prepared) {
621
+ answer = prepared;
622
+ }
623
+ else if (!entry) {
624
+ answer = this.pipeline.answerUnknownApi(request, ctx);
625
+ outcome = "unknownApi";
626
+ }
627
+ else {
628
+ const handler = entry.handler;
629
+ const result = await this.pipeline.run(request, ctx, entry.definition, handler
630
+ ? async (callCtx) => {
631
+ // The payload the handler sees is the restored one.
632
+ callCtx.payload = request.payload;
633
+ const payload = await handler(callCtx);
634
+ return envelopeAnswer(buildApiEnvelope(this.apiVersion, payload === undefined ? null : payload, {
635
+ message: callCtx.envelope.message,
636
+ logList: callCtx.logList,
637
+ }));
638
+ }
639
+ // A notMocked entry refuses where a handler would run, not
640
+ // ahead of the pipeline: answering it directly skipped the
641
+ // steps that precede dispatch, so a stale client heard
642
+ // "not mocked" from a mock the server would have answered
643
+ // versionExpired to, and a compressed payload never reached
644
+ // the events at all.
645
+ : async () => {
646
+ throw new LambderApiRefusal(`Not mocked: ${entry.notMockedReason}`, {
647
+ errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.notMocked, content: `"${request.apiName}" is not mocked: ${entry.notMockedReason}` },
648
+ });
649
+ }, trace);
650
+ answer = result.answer;
651
+ if (result.replayed)
652
+ outcome = "replayed";
653
+ }
654
+ }
655
+ catch (err) {
656
+ if (err instanceof LambderMockTransportError) {
657
+ this.recorder.settle(facts, { answer: null, outcome: "injected", guardsRun: trace.guardsRun, error: err });
658
+ throw err;
659
+ }
660
+ error = coerceToError(err);
661
+ // A mock runtime is a development tool: the point of a handler
662
+ // that threw is the message it threw, and replacing it with the
663
+ // server's wording sends a developer looking through a call log
664
+ // for what could have been on the screen. Apps that want the
665
+ // production shape turn it off.
666
+ answer = this.revealHandlerErrors
667
+ ? envelopeAnswer(buildApiEnvelope(this.apiVersion, null, { errorMessage: error.message }), { statusCode: 500 })
668
+ : crashAnswer(this.apiVersion);
669
+ outcome = "crash";
670
+ }
671
+ // Every header written during the call belongs on the answer, whichever
672
+ // way the call ended. The pipeline does this for the answers it
673
+ // produces itself, but a crash unwinds past it and an injected failure
674
+ // never reaches it, and those are the two that hurt most in
675
+ // development: a handler that created a session and then threw would
676
+ // otherwise leave the jar with no cookie and nothing to explain it.
677
+ // applyInto is idempotent, so the answers the pipeline already handled
678
+ // are unaffected.
679
+ ctx.responseHeaders.applyInto(answer.headers);
680
+ this.recorder.settle(facts, { answer, guardsRun: trace.guardsRun, ...(outcome ? { outcome } : {}), ...(error ? { error } : {}) });
681
+ return answer;
682
+ }
683
+ /**
684
+ * A transport request as the core's request: the envelope read the way the
685
+ * server reads it. Public because the adapters call it, which is what
686
+ * keeps them from each reading a transport request their own way.
687
+ */
688
+ requestFromTransport(transportRequest) {
689
+ const info = {
690
+ headers: lowercaseHeaderNames(transportRequest.headers),
691
+ cookies: cookieValuesByName(transportRequest.cookies ?? []),
692
+ // One textual form per address, as the server's own context
693
+ // resolves it: a `per: "ip"` limit keyed on whatever spelling a
694
+ // caller wrote is a limit that does not limit, and a test written
695
+ // over it proves less than it looks like it does.
696
+ ip: normalizeClientIp(transportRequest.clientIp ?? this.defaultClientIp),
697
+ host: transportRequest.siteHost || this.cookieHost,
698
+ ...(transportRequest.signal ? { signal: transportRequest.signal } : {}),
699
+ };
700
+ const request = readApiEnvelope(buildTransportEnvelope(transportRequest), info);
701
+ if (!request)
702
+ throw new Error("LambderMockApp: the transport request names no api.");
703
+ return request;
704
+ }
705
+ /** One call from a transport request to its answer: what the mock transport and the adapters call. */
706
+ async handle(transportRequest) {
707
+ return await this.handleRequest(this.requestFromTransport(transportRequest));
708
+ }
709
+ // -----------------------------------------------------------------------
710
+ // Transports
711
+ // -----------------------------------------------------------------------
712
+ /**
713
+ * The direct transport: a caller's request into handle(), the answer
714
+ * back in the form the caller reads, cookies carried by a jar the way
715
+ * a browser carries them. Each transport gets its own jar unless one is
716
+ * given, so two transports hold two sessions; the jar is on the returned
717
+ * transport as `cookieJar`, so a test can read or clear the one it did
718
+ * not create itself, and reset() empties it.
719
+ */
720
+ transport(options = {}) {
721
+ const clientIp = options.clientIp ?? this.defaultClientIp;
722
+ const direct = async (request) => toHttpAnswer(await this.handle({ ...request, clientIp: request.clientIp ?? clientIp }));
723
+ const cookies = options.cookies ?? "memory";
724
+ if (cookies === false)
725
+ return transportCarrying(direct, null);
726
+ const jar = cookies instanceof LambderCookieJar ? cookies : new LambderCookieJar();
727
+ // A jar this runtime built is this runtime's to empty on reset; one
728
+ // the caller passed is the caller's, the way an app-supplied session
729
+ // store is.
730
+ if (jar !== cookies)
731
+ this.browserCookies.adoptJar(jar);
732
+ // The runtime's one cookie host, so the jar scopes what it sends the
733
+ // way the browser this transport stands in for would. Without it the
734
+ // scope was whatever host the caller happened to name, which is the
735
+ // page's, and cookies signIn planted went unsent.
736
+ const withJar = lambderCookieJarTransport(direct, { jar, csrfCookieKey: this.csrfCookieKey, host: this.cookieHost });
737
+ if (cookies !== "document")
738
+ return transportCarrying(withJar, jar);
739
+ // The page's own cookie storage sees the non-HttpOnly cookies (the
740
+ // CSRF token), so the caller's cookie read and clear paths run for
741
+ // real; the HttpOnly session cookie stays in the jar, as a browser
742
+ // would keep it out of document.cookie.
743
+ return transportCarrying(async (request) => {
744
+ const answer = await withJar(request);
745
+ this.mirrorCookiesIntoDocument(answer.setCookies ?? []);
746
+ return answer;
747
+ }, jar);
748
+ }
749
+ /**
750
+ * Mirrors an answer's non-HttpOnly cookies into document.cookie and
751
+ * remembers them, so reset() expires them again. The direct transport's
752
+ * "document" mode and the MSW adapter both come through here: one
753
+ * implementation of the mirror, one record of what was planted.
754
+ */
755
+ mirrorCookiesIntoDocument(setCookies) {
756
+ this.browserCookies.mirrorSetCookies(setCookies);
757
+ }
758
+ /**
759
+ * Takes a jar an adapter built for itself as the runtime's own, so reset()
760
+ * empties it with the rest. The MSW adapter's jar holds the session
761
+ * cookies of calls that never touch transport(), and a reset that leaves
762
+ * it full is the same stale-session bug: the store is empty and the next
763
+ * request still carries a token for one of its sessions.
764
+ */
765
+ adoptCookieJar(jar) {
766
+ this.browserCookies.adoptJar(jar);
767
+ }
768
+ /** caller.setTransport(mockApp.transport(options)); returns the transport, its jar on it. */
769
+ attach(caller, options = {}) {
770
+ const transport = this.transport(options);
771
+ caller.setTransport(transport);
772
+ return transport;
773
+ }
774
+ }
775
+ /**
776
+ * Fixes the contract and session data types, then hands out the guard
777
+ * builder bound to the mock's contexts and the create() that infers
778
+ * everything else (the guard map, the rate-limit policies) from its options.
779
+ * Curried for the same reason initLambder is: TypeScript type arguments are
780
+ * all-or-nothing per call.
781
+ *
782
+ * ```ts
783
+ * const mock = initLambderMock<ApiContractType, SessionData>();
784
+ * const mockApp = mock.create({
785
+ * apiVersion: "1.4.0",
786
+ * sessions: true,
787
+ * guards: { tenant: mock.guard({ guardInput: z.object({ tenantId: z.uuid() }), session: true, handler: ... }) },
788
+ * });
789
+ * ```
790
+ */
791
+ export const initLambderMock = () => ({
792
+ /** Builds a mock guard: the server guard's shape, the handler seeing the mock's contexts. */
793
+ guard: lambderGuardBuilder(),
794
+ /**
795
+ * Builds a mock rate-limit key, the counterpart of `guard`. Bound to the
796
+ * mock's own call context, because the server's lambderRateLimitKey() is
797
+ * bound to the render context and a handler written with it compiles here
798
+ * while reading fields the mock context does not have.
799
+ */
800
+ rateLimitKey: lambderRateLimitKeyBuilder(),
801
+ /**
802
+ * The mock app, with the guard map and the rate-limit policies inferred
803
+ * from the options.
804
+ *
805
+ * `const` on each of them is what pins a restatement to the contract, and
806
+ * it has one cost: inferring a generic from an object literal switches
807
+ * excess-property checking off for the whole literal, nested objects
808
+ * included, so a typo inside `rateLimits.policies` or `idempotency`
809
+ * compiled and was dropped in silence. `I` exists for the same reason `P`
810
+ * does, and LambderMockSurplusKeys puts the error back on the key.
811
+ */
812
+ create(options) {
813
+ return new LambderMockApp(options);
814
+ },
815
+ });