lambder 14.1.1 → 15.0.2

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 (158) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +8 -3
  20. package/dist/build/checkApiRefusals.js +80 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -0,0 +1,265 @@
1
+ import { fork } from "child_process";
2
+ import { existsSync, realpathSync, statSync } from "fs";
3
+ import { extname, join, relative, resolve } from "path";
4
+ import { fileURLToPath, pathToFileURL } from "url";
5
+ import { buildEnvelopeJson, decodeLambdaHttpResult, synthesizeLambdaHttpEvent } from "../invoke/LambderLambdaEvent.js";
6
+ import { LOOPBACK_CLIENT_IP } from "../shared/util/LambderClientIp.js";
7
+ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
8
+ import { apiCallPath } from "../shared/wire/LambderApiNames.js";
9
+ import { resolveApiOutcome } from "../shared/wire/LambderApiOutcome.js";
10
+ import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
11
+ import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
12
+ import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/wire/LambderRequestPayload.js";
13
+ const DEFAULT_HANDLER = "index.handler";
14
+ const DEFAULT_RUNTIME_MODULES = ["@aws-sdk"];
15
+ const DEFAULT_TIMEOUT_MS = 30_000;
16
+ /** The extensions Lambda tries for a handler module, in its order. */
17
+ const HANDLER_MODULE_EXTENSIONS = [".js", ".mjs", ".cjs"];
18
+ /** A package name or a scope, which is what a runtime supplies whole. */
19
+ const PACKAGE_NAME_OR_SCOPE = /^(@[\w.~-]+(\/[\w.~-]+)?|[\w~-][\w.~-]*)$/;
20
+ /** How much of the process's output a failure that killed it quotes. */
21
+ const QUOTED_OUTPUT_LINES = 20;
22
+ /**
23
+ * The boot process's entry, beside this module and in its form: the built
24
+ * `.js` in an installed package, the `.ts` source where this module runs from
25
+ * source on a Node that strips types.
26
+ */
27
+ const BOOT_PROCESS_ENTRY = fileURLToPath(new URL(`./lambdaPackageBootProcess${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
28
+ const isDirectory = (path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
29
+ /**
30
+ * The boot process, seen from here: its reports in the order they came, and
31
+ * how it ended once it has. A report is read off the queue rather than waited
32
+ * for as an event, since the process can report, or die, while this side is
33
+ * still reading the answer before.
34
+ */
35
+ class LambdaPackageBootProcess {
36
+ #child;
37
+ #reports = [];
38
+ #closed;
39
+ #ending = null;
40
+ #wake = null;
41
+ output = "";
42
+ constructor(setup, options) {
43
+ this.#child = fork(BOOT_PROCESS_ENTRY, [JSON.stringify(setup)], {
44
+ cwd: options.cwd,
45
+ env: options.env,
46
+ // Never this process's own flags: the test runner's loaders and
47
+ // conditions are not what Lambda starts node with.
48
+ execArgv: [...options.nodeFlags],
49
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
50
+ });
51
+ // Decoded as a stream, so a character split across two chunks
52
+ // arrives whole.
53
+ const collect = (text) => { this.output += text; };
54
+ this.#child.stdout?.setEncoding("utf8").on("data", collect);
55
+ this.#child.stderr?.setEncoding("utf8").on("data", collect);
56
+ this.#child.on("message", (report) => {
57
+ this.#reports.push(report);
58
+ this.#wake?.();
59
+ });
60
+ let markClosed = () => { };
61
+ this.#closed = new Promise((resolveClosed) => { markClosed = resolveClosed; });
62
+ // "close" rather than "exit": it comes once the output and the
63
+ // channel are drained, so no report or line is still on its way.
64
+ this.#child.on("close", (code, signal) => {
65
+ this.#ending ??= signal ? `was killed by ${signal}` : `exited with code ${code}`;
66
+ this.#wake?.();
67
+ markClosed();
68
+ });
69
+ // A process that never started has nothing to close.
70
+ this.#child.on("error", (error) => {
71
+ if (this.#child.pid !== undefined)
72
+ return;
73
+ this.#ending ??= `could not start: ${error.message}`;
74
+ this.#wake?.();
75
+ markClosed();
76
+ });
77
+ }
78
+ send(request) {
79
+ // A send that fails means the process has ended, which "close"
80
+ // reports with its output; the error itself says less.
81
+ this.#child.send(request, () => { });
82
+ }
83
+ /** The next report, or why none came: the process ended, or `timeoutMs` passed. */
84
+ async nextReport(timeoutMs) {
85
+ const deadline = Date.now() + timeoutMs;
86
+ for (;;) {
87
+ const report = this.#reports.shift();
88
+ if (report)
89
+ return report;
90
+ if (this.#ending !== null) {
91
+ const tail = this.output.trimEnd().split("\n").slice(-QUOTED_OUTPUT_LINES).join("\n");
92
+ return { failure: `the process ${this.#ending}${tail ? `; its output ends:\n${tail}` : " with no output"}` };
93
+ }
94
+ const remaining = deadline - Date.now();
95
+ if (remaining <= 0)
96
+ return { failure: `did not finish within ${timeoutMs} ms` };
97
+ await new Promise((wake) => {
98
+ const timer = setTimeout(wake, remaining);
99
+ this.#wake = () => { clearTimeout(timer); wake(); };
100
+ });
101
+ this.#wake = null;
102
+ }
103
+ }
104
+ /** Ends the process, which the package may be holding open (a pool, a timer), and waits until its output is all in. */
105
+ async stop() {
106
+ this.#ending ??= "was stopped";
107
+ this.#child.kill("SIGKILL");
108
+ await this.#closed;
109
+ }
110
+ }
111
+ /**
112
+ * Boots an assembled deployment package the way Lambda boots it, and hands
113
+ * its handler each call in turn: in a fresh node process, so nothing of the
114
+ * calling test's module graph or environment reaches it, with the package
115
+ * directory as the working directory, and with every import the package
116
+ * makes held to what the package carries. What the Lambda runtime supplies
117
+ * (`runtimeModules`, the AWS SDK by default) resolves from an install
118
+ * outside it instead, since the package rightly leaves it out.
119
+ *
120
+ * Makes no assertions: the result says whether the package imported and
121
+ * answered, what each call answered, what the import cost, and where it
122
+ * failed. Throws, before or after starting the process, for what is not
123
+ * the package's doing: a missing directory, a handler that names no module
124
+ * there or no function exported by it, an option out of range, or a Node
125
+ * without synchronous module hooks.
126
+ *
127
+ * ```typescript
128
+ * const boot = await bootLambdaPackage({ packageDir: "build/server", calls: [{ api: "status.ping" }] });
129
+ * expect(boot.ok, boot.error).toBe(true);
130
+ * assertApiSuccess(boot.calls[0]!.outcome!);
131
+ * ```
132
+ */
133
+ export const bootLambdaPackage = async (options) => {
134
+ const nodeModule = typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("module") : undefined;
135
+ if (typeof nodeModule?.registerHooks !== "function") {
136
+ throw new Error("Lambder: bootLambdaPackage needs Node 22.15 or later, for the synchronous module hooks that hold the package's imports to the package.");
137
+ }
138
+ if (!existsSync(BOOT_PROCESS_ENTRY))
139
+ throw new Error(`Lambder: bootLambdaPackage's process entry is missing from ${BOOT_PROCESS_ENTRY}: is lambder installed whole?`);
140
+ const packageDir = resolve(options.packageDir);
141
+ if (!isDirectory(packageDir))
142
+ throw new Error(`Lambder: bootLambdaPackage packageDir ${packageDir} is not a directory.`);
143
+ // The real path: a module's URL is its real path, so the test of
144
+ // whether an import stays inside the package compares real paths too.
145
+ const packageRoot = realpathSync(packageDir);
146
+ const handler = options.handler ?? DEFAULT_HANDLER;
147
+ const exportDot = handler.indexOf(".", handler.lastIndexOf("/") + 1);
148
+ const modulePath = exportDot === -1 ? "" : handler.slice(0, exportDot);
149
+ const handlerExport = exportDot === -1 ? "" : handler.slice(exportDot + 1);
150
+ if (!modulePath || modulePath.endsWith("/") || !handlerExport) {
151
+ throw new Error(`Lambder: bootLambdaPackage handler "${handler}" is not a module and an export, as in "index.handler".`);
152
+ }
153
+ const handlerFile = HANDLER_MODULE_EXTENSIONS.map((extension) => join(packageRoot, modulePath + extension)).find((file) => existsSync(file));
154
+ if (!handlerFile) {
155
+ throw new Error(`Lambder: bootLambdaPackage found no ${modulePath}.js, ${modulePath}.mjs or ${modulePath}.cjs in ${packageRoot} for the handler "${handler}".`);
156
+ }
157
+ const runtimeModules = options.runtimeModules ?? DEFAULT_RUNTIME_MODULES;
158
+ for (const name of runtimeModules) {
159
+ if (!PACKAGE_NAME_OR_SCOPE.test(name))
160
+ throw new Error(`Lambder: bootLambdaPackage runtimeModules entry "${name}" is not a package name or a scope.`);
161
+ }
162
+ const runtimeModulesFrom = resolve(options.runtimeModulesFrom ?? process.cwd());
163
+ if (!isDirectory(runtimeModulesFrom))
164
+ throw new Error(`Lambder: bootLambdaPackage runtimeModulesFrom ${runtimeModulesFrom} is not a directory.`);
165
+ const importTimeoutMs = assertPositiveInteger(options.importTimeoutMs ?? DEFAULT_TIMEOUT_MS, "bootLambdaPackage importTimeoutMs");
166
+ const callTimeoutMs = assertPositiveInteger(options.callTimeoutMs ?? DEFAULT_TIMEOUT_MS, "bootLambdaPackage callTimeoutMs");
167
+ // Every event is built before the process starts, so a call that names no
168
+ // endpoint throws here rather than after a boot.
169
+ const apiPath = options.apiPath ?? DEFAULT_API_PATH;
170
+ const host = options.host ?? "localhost";
171
+ const calls = (options.calls ?? []).map((call, index) => {
172
+ if ("api" in call && typeof call.api === "string") {
173
+ const event = synthesizeLambdaHttpEvent({
174
+ method: "POST",
175
+ path: apiCallPath(apiPath, call.api),
176
+ host,
177
+ headers: call.headers,
178
+ contentType: "application/json",
179
+ clientIp: LOOPBACK_CLIENT_IP,
180
+ body: buildEnvelopeJson({
181
+ version: options.apiVersion,
182
+ siteHost: host,
183
+ payloadJson: call.payload === undefined ? undefined : JSON.stringify(call.payload),
184
+ }),
185
+ }, { invoke: options.invoke ?? false });
186
+ return { name: call.api, event, isApiCall: true };
187
+ }
188
+ if ("event" in call)
189
+ return { name: call.name ?? `event ${index}`, event: call.event, isApiCall: false };
190
+ throw new Error(`Lambder: bootLambdaPackage call ${index} is neither { api } nor { event }.`);
191
+ });
192
+ const boot = new LambdaPackageBootProcess({
193
+ handlerUrl: pathToFileURL(handlerFile).href,
194
+ handlerExport,
195
+ packageUrl: `${pathToFileURL(packageRoot).href}/`,
196
+ runtimeModules: [...runtimeModules],
197
+ runtimeModulesFrom,
198
+ callTimeoutMs,
199
+ }, {
200
+ cwd: packageRoot,
201
+ // PATH alone of this process's environment: a function sees the
202
+ // variables its configuration sets, and a test's (NODE_ENV, the
203
+ // runner's own, credentials) would let the package pass on something
204
+ // it never has when deployed.
205
+ env: { ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }), ...options.env },
206
+ nodeFlags: options.nodeFlags ?? [],
207
+ });
208
+ const results = [];
209
+ // The result is read once the process is gone, so its output is all in.
210
+ const finish = async (fields) => {
211
+ await boot.stop();
212
+ return { ...fields, calls: results, output: boot.output };
213
+ };
214
+ try {
215
+ const handlerModule = relative(packageRoot, handlerFile);
216
+ const imported = await boot.nextReport(importTimeoutMs);
217
+ if ("failure" in imported)
218
+ return await finish({ ok: false, phase: "import", error: `importing ${handlerModule} failed: ${imported.failure}` });
219
+ if (imported.kind === "importFailed")
220
+ return await finish({ ok: false, phase: "import", error: `importing ${handlerModule} failed:\n${imported.error}` });
221
+ if (imported.kind !== "imported")
222
+ throw new Error(`Lambder: bootLambdaPackage's process reported "${imported.kind}" before its import.`);
223
+ if (imported.handlerType !== "function") {
224
+ const exportNames = imported.exportNames.length ? imported.exportNames.join(", ") : "nothing";
225
+ throw new Error(`Lambder: bootLambdaPackage handler "${handler}": ${handlerModule} exports no function "${handlerExport}" (it exports ${exportNames}).`);
226
+ }
227
+ const measurements = { importMs: imported.importMs, rssBytes: imported.rssBytes };
228
+ for (const [index, call] of calls.entries()) {
229
+ const callName = `call ${index} (${call.name})`;
230
+ boot.send({ index, event: call.event });
231
+ const answer = await boot.nextReport(callTimeoutMs);
232
+ if ("failure" in answer)
233
+ return await finish({ ok: false, phase: "handler", failedCall: call.name, error: `${callName} ${answer.failure}`, measurements });
234
+ if (answer.kind === "threw")
235
+ return await finish({ ok: false, phase: "handler", failedCall: call.name, error: `${callName} threw:\n${answer.error}`, measurements });
236
+ if (answer.kind !== "answered")
237
+ throw new Error(`Lambder: bootLambdaPackage's process reported "${answer.kind}" for ${callName}.`);
238
+ const returned = answer.returned;
239
+ const statusCode = returned?.statusCode;
240
+ const status = typeof statusCode === "number" ? statusCode : undefined;
241
+ let outcome;
242
+ if (call.isApiCall) {
243
+ try {
244
+ const http = await decodeLambdaHttpResult(returned, DEFAULT_MAX_RESTORED_PAYLOAD_BYTES);
245
+ outcome = await resolveApiOutcome({
246
+ status: http.statusCode,
247
+ header: (name) => http.headers[name.toLowerCase()] ?? null,
248
+ json: async () => http.json(),
249
+ text: async () => http.text(),
250
+ });
251
+ }
252
+ catch (error) {
253
+ // An answer no caller could read, as a caller reads it.
254
+ outcome = { ok: false, reason: "server", status, error: coerceToError(error) };
255
+ }
256
+ }
257
+ results.push({ name: call.name, returned, ...(status === undefined ? {} : { status }), ...(outcome ? { outcome } : {}) });
258
+ }
259
+ return await finish({ ok: true, measurements });
260
+ }
261
+ finally {
262
+ // Also on a throw, which leaves the process running otherwise.
263
+ await boot.stop();
264
+ }
265
+ };
@@ -0,0 +1,39 @@
1
+ /** What the process is started with, as JSON in its one argument. */
2
+ export type LambdaPackageBootSetup = {
3
+ /** The handler module's file URL. */
4
+ handlerUrl: string;
5
+ /** The export Lambda calls. */
6
+ handlerExport: string;
7
+ /** The package root's file URL, ending in a slash: what the deployment carries. */
8
+ packageUrl: string;
9
+ /** Package names and scopes the Lambda runtime supplies, so the package leaves them out. */
10
+ runtimeModules: string[];
11
+ /** The directory those resolve from, standing in for the runtime's own copy. */
12
+ runtimeModulesFrom: string;
13
+ /** What getRemainingTimeInMillis counts down from at each call. */
14
+ callTimeoutMs: number;
15
+ };
16
+ /** One call, as the parent sends it. */
17
+ export type LambdaPackageBootCallRequest = {
18
+ index: number;
19
+ event: unknown;
20
+ };
21
+ /** What the process reports, one message per step. */
22
+ export type LambdaPackageBootReport = {
23
+ kind: "imported";
24
+ importMs: number;
25
+ rssBytes: number;
26
+ handlerType: string;
27
+ exportNames: string[];
28
+ } | {
29
+ kind: "importFailed";
30
+ error: string;
31
+ } | {
32
+ kind: "answered";
33
+ index: number;
34
+ returned: unknown;
35
+ } | {
36
+ kind: "threw";
37
+ index: number;
38
+ error: string;
39
+ };
@@ -0,0 +1,130 @@
1
+ import { fileURLToPath, pathToFileURL } from "url";
2
+ const setup = JSON.parse(process.argv[2] ?? "null");
3
+ // The parent kills this process on every way out of its own, but cannot when
4
+ // it dies itself (a test runner tearing down a test that timed out). The
5
+ // channel closing is the one sign of that, and what the package keeps running
6
+ // (a timer, a pool) would otherwise keep this process alive with no parent.
7
+ process.on("disconnect", () => process.exit(1));
8
+ // Taken from getBuiltinModule rather than imported: every built-in the
9
+ // sources import is one package.json's browser field stubs for bundlers, and
10
+ // this file never reaches a bundle, only this process.
11
+ const { createRequire, registerHooks } = process.getBuiltinModule("module");
12
+ const report = (message) => {
13
+ process.send?.(message);
14
+ };
15
+ /** An error as text that survives the channel: its stack, and the chain of causes under it. */
16
+ const describeError = (error) => {
17
+ if (!(error instanceof Error))
18
+ return String(error);
19
+ const text = error.stack ?? `${error.name}: ${error.message}`;
20
+ return error.cause === undefined ? text : `${text}\nCaused by: ${describeError(error.cause)}`;
21
+ };
22
+ const isRuntimeSupplied = (specifier) => setup.runtimeModules.some((name) => specifier === name || specifier.startsWith(`${name}/`));
23
+ // A module in that directory, which need not exist: resolution starts from
24
+ // the directory it would sit in.
25
+ const runtimeModulesParentUrl = `${pathToFileURL(setup.runtimeModulesFrom).href}/runtime-modules.js`;
26
+ /**
27
+ * A specifier naming a place on the function's own filesystem (a layer
28
+ * under /opt, say), which the author wrote on purpose and which is not this
29
+ * machine's to judge, unlike a bare name or a relative path, whose
30
+ * resolution is what decides whether the package carries it.
31
+ */
32
+ const ABSOLUTE_SPECIFIER = /^(file:|\/|[A-Za-z]:[\\/])/;
33
+ // Every import made from inside the package by a bare name or a relative path
34
+ // is held to the package, as on Lambda, where the package directory has no
35
+ // node_modules above it to fall back on: a dependency the package does not
36
+ // carry, or a relative import out of it, would otherwise resolve from
37
+ // whatever install the directory happens to sit in and pass here only to
38
+ // fail when deployed. The one exception is what the runtime supplies, which
39
+ // the package rightly leaves out and which resolves from runtimeModulesFrom
40
+ // instead, unless the package carries its own copy, which wins on Lambda
41
+ // too. Imports made by modules outside the package (those runtime modules'
42
+ // own) resolve as usual. Synchronous hooks, so `require` is held to the same
43
+ // rule as `import`.
44
+ registerHooks({
45
+ resolve(specifier, context, nextResolve) {
46
+ const parentURL = context.parentURL;
47
+ if (!parentURL?.startsWith(setup.packageUrl) || ABSOLUTE_SPECIFIER.test(specifier))
48
+ return nextResolve(specifier, context);
49
+ const runtimeSupplied = isRuntimeSupplied(specifier);
50
+ const fromRuntime = () => {
51
+ try {
52
+ // require() resolves from the module that calls it whatever
53
+ // parentURL says, so it is resolved here; import() follows
54
+ // parentURL.
55
+ if (context.conditions.includes("require")) {
56
+ return { url: pathToFileURL(createRequire(runtimeModulesParentUrl).resolve(specifier)).href, shortCircuit: true };
57
+ }
58
+ return nextResolve(specifier, { ...context, parentURL: runtimeModulesParentUrl });
59
+ }
60
+ catch (error) {
61
+ throw new Error(`"${specifier}" is supplied by the Lambda runtime, so the package leaves it out, and it resolves from ${setup.runtimeModulesFrom} instead, ` +
62
+ "where it is not installed either: install it there, or point runtimeModulesFrom at an install that has it.", { cause: error });
63
+ }
64
+ };
65
+ let resolved;
66
+ try {
67
+ resolved = nextResolve(specifier, context);
68
+ }
69
+ catch (error) {
70
+ if (runtimeSupplied)
71
+ return fromRuntime();
72
+ throw error;
73
+ }
74
+ if (!resolved.url.startsWith("file:") || resolved.url.startsWith(setup.packageUrl))
75
+ return resolved;
76
+ if (runtimeSupplied)
77
+ return fromRuntime();
78
+ throw new Error(`"${specifier}", imported by ${fileURLToPath(parentURL)}, is not in the package: it resolves to ${fileURLToPath(resolved.url)}, ` +
79
+ "outside the package directory, and a deployment carries only what is inside it.");
80
+ },
81
+ });
82
+ let handler;
83
+ try {
84
+ // What a cold start pays before its first request: evaluating the module
85
+ // and everything it imports statically, in a process that loaded none of
86
+ // it before.
87
+ const importStart = performance.now();
88
+ const namespace = await import(setup.handlerUrl);
89
+ const importMs = Math.round(performance.now() - importStart);
90
+ const rssBytes = process.memoryUsage().rss;
91
+ // A CommonJS module's exports arrive as the default export, and as named
92
+ // ones only as far as Node detects them statically.
93
+ const exported = namespace[setup.handlerExport] ?? namespace.default?.[setup.handlerExport];
94
+ handler = exported;
95
+ // Listening before the report goes out, so the first call cannot arrive
96
+ // to nobody.
97
+ if (typeof exported === "function")
98
+ process.on("message", (request) => void answer(request));
99
+ report({ kind: "imported", importMs, rssBytes, handlerType: typeof exported, exportNames: Object.keys(namespace) });
100
+ }
101
+ catch (error) {
102
+ report({ kind: "importFailed", error: describeError(error) });
103
+ }
104
+ async function answer({ index, event }) {
105
+ const deadline = Date.now() + setup.callTimeoutMs;
106
+ const context = {
107
+ callbackWaitsForEmptyEventLoop: true,
108
+ functionName: "lambder-boot-check",
109
+ functionVersion: "$LATEST",
110
+ invokedFunctionArn: "arn:aws:lambda:local:000000000000:function:lambder-boot-check",
111
+ memoryLimitInMB: "128",
112
+ awsRequestId: `lambder-boot-check-${index}`,
113
+ logGroupName: "/aws/lambda/lambder-boot-check",
114
+ logStreamName: "local",
115
+ getRemainingTimeInMillis: () => Math.max(0, deadline - Date.now()),
116
+ done: () => { },
117
+ fail: () => { },
118
+ succeed: () => { },
119
+ };
120
+ try {
121
+ // Through JSON, as Lambda returns an answer: what reaches the parent
122
+ // is what an invoker would receive, and an answer JSON cannot carry
123
+ // fails here as it would there.
124
+ const json = JSON.stringify(await handler(event, context));
125
+ report({ kind: "answered", index, returned: json === undefined ? null : JSON.parse(json) });
126
+ }
127
+ catch (error) {
128
+ report({ kind: "threw", index, error: describeError(error) });
129
+ }
130
+ }
@@ -0,0 +1,26 @@
1
+ import type { LambderCache } from "../shared/contracts/LambderCache.js";
2
+ import { type LambderConformanceRunner, type LambderConformanceSetup } from "./LambderConformanceRunner.js";
3
+ export type LambderCacheConformanceOptions = LambderConformanceRunner & {
4
+ /** A cache holding no entries, built for one case over the case's clock. */
5
+ create: (setup: LambderConformanceSetup) => LambderCache | Promise<LambderCache>;
6
+ };
7
+ /**
8
+ * Registers the cache rules as cases of the runner, one `it` each, against
9
+ * the cache `create` builds:
10
+ *
11
+ * ```ts
12
+ * import { describe, it, expect } from "vitest";
13
+ * import { lambderCacheConformance } from "lambder/testing";
14
+ *
15
+ * describe("RedisCache", () => {
16
+ * lambderCacheConformance({ it, expect, create: async ({ now }) => { await emptyCache(); return new RedisCache({ client, now }); } });
17
+ * });
18
+ * ```
19
+ *
20
+ * The entries a case writes expire by the case's clock, which starts at a
21
+ * fixed moment far in the future and stands still unless a case moves it: a
22
+ * cache that expires entries by the system clock, rather than by `now`, never
23
+ * sees one expire. A cache over a storage with a native TTL keeps it on, since
24
+ * nothing a case writes has expired by the world's clock.
25
+ */
26
+ export declare const lambderCacheConformance: (options: LambderCacheConformanceOptions) => void;