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.
- package/CHANGELOG.md +429 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +8 -3
- package/dist/build/checkApiRefusals.js +80 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- 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;
|