@thms-rmb/nodejs-aws-lambda 0.9.0-0

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/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # nodejs-aws-lambda
2
+
3
+ A Node.js port of the Perl [`AWS::Lambda`](https://metacpan.org/dist/AWS-Lambda)
4
+ custom-runtime client (v0.9.0). It implements the
5
+ [AWS Lambda Runtime API](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html)
6
+ so a plain JavaScript handler can run on the OS-only `provided.*` runtime family.
7
+
8
+ - **Zero runtime dependencies** — built only on Node's `node:http`, `JSON`, etc.
9
+ - **Plain ESM** — no bundler, no build step.
10
+ - **JSDoc-typed** — full editor IntelliSense.
11
+ - Buffered **and** streaming responses.
12
+
13
+ This port covers the runtime core (`Bootstrap`, `Context`, `ResponseWriter`,
14
+ the `bootstrap` entry point). The Perl `AWS::Lambda::PSGI` web adapter and the
15
+ `AL`/`AL2`/`AL2023` layer-ARN tables are intentionally out of scope.
16
+
17
+ ## Handler
18
+
19
+ `_HANDLER` uses the `file.function` format. Given `_HANDLER=handler.handle` and
20
+ `LAMBDA_TASK_ROOT` pointing at your code, the runtime imports `handler.js`
21
+ (`.mjs`/`.cjs` also resolved) and calls its exported `handle`:
22
+
23
+ ```js
24
+ // handler.js
25
+ import { getCurrentContext } from '@thms-rmb/nodejs-aws-lambda';
26
+
27
+ export function handle(payload, context) {
28
+ // context is a Context instance (also available via getCurrentContext()).
29
+ return { echo: payload, requestId: context.awsRequestId };
30
+ }
31
+ ```
32
+
33
+ The handler receives `(payload, context)` and may be `async`. Its return value
34
+ is JSON-encoded and posted as the response. Throwing reports an invocation
35
+ error and continues the loop.
36
+
37
+ ### Streaming responses
38
+
39
+ Return a function to stream the response
40
+ ([response streaming](https://docs.aws.amazon.com/lambda/latest/dg/configuration-response-streaming.html)):
41
+
42
+ ```js
43
+ export function handle(payload, context) {
44
+ return async (responder) => {
45
+ const writer = responder('application/json');
46
+ writer.write('{"foo":');
47
+ writer.write('"bar"}');
48
+ writer.close();
49
+ };
50
+ }
51
+ ```
52
+
53
+ ## The `bootstrap` entry point
54
+
55
+ `bin/bootstrap` is the executable Lambda invokes. It starts the event loop and
56
+ reads the handler from `_HANDLER` (or its first CLI argument). Programmatically:
57
+
58
+ ```js
59
+ import { bootstrap, Bootstrap } from '@thms-rmb/nodejs-aws-lambda';
60
+
61
+ await bootstrap('handler.handle'); // convenience
62
+ // or, for full control:
63
+ await new Bootstrap({ handler: 'handler.handle' }).handleEvents();
64
+ ```
65
+
66
+ ## Context
67
+
68
+ `Context` exposes the per-invocation fields from the Runtime API headers
69
+ (`awsRequestId`, `invokedFunctionArn`, `traceId`, `tenantId`, `deadlineMs`),
70
+ `getRemainingTimeInMillis()`, and environment-backed accessors (`functionName`,
71
+ `functionVersion`, `memoryLimitInMb`, `logGroupName`, `logStreamName`).
72
+
73
+ ## Environment variables
74
+
75
+ | Variable | Purpose |
76
+ |----------|---------|
77
+ | `_HANDLER` | `file.function` of the handler. |
78
+ | `AWS_LAMBDA_RUNTIME_API` | `host:port` of the Runtime API. |
79
+ | `LAMBDA_TASK_ROOT` | Directory containing the handler file. |
80
+ | `AWS_LAMBDA_MAX_CONCURRENCY` | Optional; number of concurrent invocation loops (values > 1 have [gotchas](#concurrency-and-its-gotchas)). |
81
+ | `_X_AMZN_TRACE_ID` | Set by the runtime per invocation from the trace header. |
82
+
83
+ ## Testing
84
+
85
+ ```sh
86
+ npm test # node --test over test/*.test.js — zero dependencies
87
+ ```
88
+
89
+ ## Notable differences from the Perl original
90
+
91
+ - **Async throughout.** `node:http` is non-blocking, so `handleEvent`,
92
+ `lambdaNext`, etc. are `async`; handlers may be `async`.
93
+ - **Concurrency.** `AWS_LAMBDA_MAX_CONCURRENCY > 0` runs N concurrent async
94
+ invocation loops (stopped by `SIGTERM`/`SIGHUP`) instead of Perl's
95
+ `Parallel::Prefork`. This changes the isolation guarantees — see
96
+ [Concurrency and its gotchas](#concurrency-and-its-gotchas) below.
97
+ - **Streaming.** `ResponseWriter` uses `node:http` chunked encoding and native
98
+ trailers rather than hand-framing chunks.
99
+
100
+ ## Concurrency and its gotchas
101
+
102
+ By default (`AWS_LAMBDA_MAX_CONCURRENCY` unset or `0`) the runtime handles **one
103
+ invocation at a time** — this is classic Lambda behavior and has **no gotchas**;
104
+ everything below applies only when you set concurrency above 1.
105
+
106
+ Setting `AWS_LAMBDA_MAX_CONCURRENCY > 1` (used by
107
+ [Lambda Managed Instances](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html))
108
+ runs that many concurrent invocation loops. **The Perl original forks a separate
109
+ OS process per worker, so every invocation gets its own memory, environment, and
110
+ current context for free.** This port instead runs concurrent `async` loops in a
111
+ **single process and thread**, so that per-process isolation does not exist.
112
+
113
+ What that means in practice:
114
+
115
+ | Behavior | Concurrency > 1 |
116
+ |----------|-----------------|
117
+ | Fetching, invoking, and responding to N invocations in parallel | ✅ works |
118
+ | Response routing — each result posted to the correct request ID | ✅ works (payload and `context` are passed as **arguments**) |
119
+ | Reading the `context` **argument** inside your handler | ✅ always correct |
120
+ | `getCurrentContext()` after your handler `await`s | ⚠️ may return another concurrent invocation's context |
121
+ | `process.env._X_AMZN_TRACE_ID` after your handler `await`s | ⚠️ may hold another concurrent invocation's trace id |
122
+
123
+ The reason: `getCurrentContext()` and `_X_AMZN_TRACE_ID` are **process-global
124
+ ambient state**. They are set correctly just before your handler runs, but once
125
+ an `async` handler yields at an `await`, another worker can run and overwrite
126
+ them. Synchronous handlers (and any code that reads these before its first
127
+ `await`) are unaffected.
128
+
129
+ **Guidance when running with concurrency > 1:**
130
+
131
+ - Use the `context` **argument** your handler receives, not `getCurrentContext()`.
132
+ - Don't rely on `_X_AMZN_TRACE_ID` being pinned to your invocation across
133
+ `await`s. (`process.env` is inherently process-wide; there is no per-async
134
+ environment, which is also why the AWS X-Ray SDK propagates trace context
135
+ itself rather than trusting that variable alone.)
136
+
137
+ If you need a correct per-invocation `getCurrentContext()` under concurrency,
138
+ `node:async_hooks`' `AsyncLocalStorage` is the zero-dependency way to add it.
139
+
140
+ ## Releasing
141
+
142
+ **Versioning policy.** The `major.minor.patch` numbers mirror the upstream Perl
143
+ [`AWS::Lambda`](https://metacpan.org/dist/AWS-Lambda) release this port tracks
144
+ (currently `0.9.0`). Changes that are specific to this port and do **not**
145
+ correspond to a new upstream release are published as **prereleases toward the
146
+ next patch** — e.g. after `0.9.0`, port tweaks ship as `0.9.1-1`, `0.9.1-2`, …
147
+ (a prerelease sorts *before* `0.9.1`, so it correctly reads as "on the way to
148
+ the next version"). A clean `X.Y.Z` with no prerelease suffix is reserved for a
149
+ release that faithfully matches upstream `X.Y.Z`.
150
+
151
+ The publish workflow maps this to npm [dist-tags](https://docs.npmjs.com/cli/v10/commands/npm-dist-tag)
152
+ automatically: a prerelease version (one containing a `-`) is published under
153
+ `next`; a clean version is published under `latest`.
154
+
155
+ **Cutting a release.** Version tags drive publishing — the
156
+ [`publish` workflow](.github/workflows/publish.yml) runs on any `v*` tag push:
157
+
158
+ ```sh
159
+ npm version 0.9.1-1 --no-git-tag-version # or edit package.json by hand
160
+ git commit -am "Release 0.9.1-1"
161
+ git tag v0.9.1-1
162
+ git push && git push --tag
163
+ ```
164
+
165
+ The workflow verifies the tag matches `package.json`, runs the tests, and
166
+ publishes. It uses npm **trusted publishing (OIDC)** — there is no `NPM_TOKEN`
167
+ secret, and [provenance](https://docs.npmjs.com/generating-provenance-statements)
168
+ is attached automatically.
169
+
170
+ **One-time trusted-publishing setup** (on [npmjs.com](https://www.npmjs.com)):
171
+ because the trusted-publisher setting lives under an existing package's
172
+ settings, publish once manually to create the package —
173
+ `npm publish` locally (this reads `publishConfig.access: public`) — then in the
174
+ package's **Settings → Trusted Publisher** add a GitHub Actions publisher with
175
+ repository `thms-rmb/nodejs-aws-lambda` and workflow filename `publish.yml`.
176
+ Every subsequent release then flows through the tag-push workflow with no token.
177
+
178
+ ## License
179
+
180
+ MIT (matching the upstream Perl distribution by ICHINOSE Shogo).
package/bin/bootstrap ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // AWS Lambda custom-runtime entry point.
3
+ //
4
+ // Lambda executes the file named `bootstrap` at the root of the deployment
5
+ // package (or in a layer). This starts the Runtime API event loop. The handler
6
+ // is read from the `_HANDLER` environment variable, or the first CLI argument
7
+ // (which is what the container `CMD` provides), matching the Perl `bootstrap`.
8
+ import { bootstrap } from '../src/index.js';
9
+
10
+ bootstrap(process.argv[2]);
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@thms-rmb/nodejs-aws-lambda",
3
+ "version": "0.9.0-0",
4
+ "description": "Node.js port of the Perl AWS::Lambda custom-runtime client. Zero runtime dependencies, plain ESM.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/thms-rmb/nodejs-aws-lambda.git"
13
+ },
14
+ "homepage": "https://github.com/thms-rmb/nodejs-aws-lambda#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/thms-rmb/nodejs-aws-lambda/issues"
17
+ },
18
+ "main": "src/index.js",
19
+ "exports": {
20
+ ".": "./src/index.js"
21
+ },
22
+ "bin": {
23
+ "bootstrap": "bin/bootstrap"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "bin",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test test/*.test.js"
35
+ },
36
+ "keywords": [
37
+ "aws",
38
+ "lambda",
39
+ "custom-runtime",
40
+ "provided",
41
+ "serverless"
42
+ ]
43
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Node.js port of `AWS::Lambda::Bootstrap` — the AWS Lambda custom-runtime
3
+ * event loop. It loads the handler, then repeatedly fetches the next
4
+ * invocation from the Runtime API, invokes the handler, and posts the
5
+ * response or error.
6
+ *
7
+ * @module bootstrap
8
+ */
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+
14
+ import { Context } from './context.js';
15
+ import { ResponseWriter } from './response-writer.js';
16
+ import { request } from './http.js';
17
+ import { errorMessage, errorType } from './errors.js';
18
+ import { _setCurrentContext } from './current-context.js';
19
+
20
+ const API_VERSION = '2018-06-01';
21
+
22
+ /** Handler-file extensions tried, in order, when resolving `_HANDLER`. */
23
+ const HANDLER_EXTENSIONS = ['.js', '.mjs', '.cjs'];
24
+
25
+ /**
26
+ * A Lambda handler function. May be `async`. Returning a function selects
27
+ * {@link https://docs.aws.amazon.com/lambda/latest/dg/configuration-response-streaming.html response streaming}.
28
+ *
29
+ * @callback Handler
30
+ * @param {*} payload The invocation event, JSON-decoded.
31
+ * @param {Context} context The invocation {@link Context}.
32
+ * @returns {*|Promise<*>|StreamingHandler|Promise<StreamingHandler>}
33
+ */
34
+
35
+ /**
36
+ * A response-streaming handler: the value a {@link Handler} returns when it
37
+ * wants to stream. It receives a `responder` and writes the body via the
38
+ * {@link ResponseWriter} the responder returns.
39
+ *
40
+ * @callback StreamingHandler
41
+ * @param {(contentType?: string) => ResponseWriter} responder
42
+ * @returns {void|Promise<void>}
43
+ */
44
+
45
+ /**
46
+ * @typedef {object} BootstrapArgs
47
+ * @property {string} [handler] The handler in `file.function` form. Falls back
48
+ * to `_HANDLER`.
49
+ * @property {string} [runtimeApi] The Runtime API host:port. Falls back to
50
+ * `AWS_LAMBDA_RUNTIME_API`.
51
+ * @property {string} [taskRoot] The directory containing the handler file.
52
+ * Falls back to `LAMBDA_TASK_ROOT`.
53
+ * @property {number|string} [maxWorkers] Number of concurrent invocation loops
54
+ * to run. Falls back to `AWS_LAMBDA_MAX_CONCURRENCY`, then `0`.
55
+ */
56
+
57
+ export class Bootstrap {
58
+ /**
59
+ * @param {BootstrapArgs} [args]
60
+ */
61
+ constructor(args = {}) {
62
+ const envHandler = args.handler ?? process.env._HANDLER;
63
+ if (envHandler === undefined) {
64
+ throw new Error('$_HANDLER is not found');
65
+ }
66
+ const runtimeApi = args.runtimeApi ?? process.env.AWS_LAMBDA_RUNTIME_API;
67
+ if (runtimeApi === undefined) {
68
+ throw new Error('$AWS_LAMBDA_RUNTIME_API is not found');
69
+ }
70
+ const taskRoot = args.taskRoot ?? process.env.LAMBDA_TASK_ROOT;
71
+ if (taskRoot === undefined) {
72
+ throw new Error('$LAMBDA_TASK_ROOT is not found');
73
+ }
74
+ const maxWorkers = args.maxWorkers ?? process.env.AWS_LAMBDA_MAX_CONCURRENCY ?? 0;
75
+ if (!/^\d+$/.test(String(maxWorkers))) {
76
+ throw new Error(`max_workers must be a non-negative integer, got: ${maxWorkers}`);
77
+ }
78
+
79
+ // Split "file.function" on the first dot, matching Perl's split(/[.]/, ..., 2).
80
+ const dot = envHandler.indexOf('.');
81
+ /** @type {string} The handler file name, without extension. */
82
+ this.file = dot === -1 ? envHandler : envHandler.slice(0, dot);
83
+ /** @type {string|undefined} The exported handler function name. */
84
+ this.functionName = dot === -1 ? undefined : envHandler.slice(dot + 1);
85
+
86
+ /** @type {string} */
87
+ this.taskRoot = taskRoot;
88
+ /** @type {string} */
89
+ this.runtimeApi = runtimeApi;
90
+ /** @type {string} */
91
+ this.apiVersion = API_VERSION;
92
+ /** @type {string} */
93
+ this.nextEventUrl = `http://${runtimeApi}/${API_VERSION}/runtime/invocation/next`;
94
+ /** @type {number} */
95
+ this.maxWorkers = Number(maxWorkers);
96
+ /** @type {Handler|undefined} The resolved handler function. */
97
+ this.function = undefined;
98
+ /** @type {boolean} Set by SIGTERM/SIGHUP to stop the worker loops. */
99
+ this._stop = false;
100
+ }
101
+
102
+ /**
103
+ * Import the handler module and resolve the exported function, once. On
104
+ * failure, report an initialization error, install a no-op, and return
105
+ * `null` so the caller can stop.
106
+ *
107
+ * @returns {Promise<Handler|null>}
108
+ */
109
+ async _init() {
110
+ if (this.function) {
111
+ return this.function;
112
+ }
113
+ try {
114
+ const module = await this._importHandler();
115
+ const fn = this.functionName === undefined ? undefined : module[this.functionName];
116
+ if (typeof fn !== 'function') {
117
+ throw new Error(`handler ${this.functionName} is not found`);
118
+ }
119
+ this.function = fn;
120
+ return fn;
121
+ } catch (error) {
122
+ await this.lambdaInitError(error);
123
+ this.function = () => {};
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Dynamically import the handler file, resolving its extension.
130
+ *
131
+ * @returns {Promise<Record<string, unknown>>}
132
+ */
133
+ async _importHandler() {
134
+ const base = join(this.taskRoot, this.file);
135
+ let file = `${base}.js`;
136
+ for (const ext of HANDLER_EXTENSIONS) {
137
+ if (existsSync(base + ext)) {
138
+ file = base + ext;
139
+ break;
140
+ }
141
+ }
142
+ return import(pathToFileURL(file).href);
143
+ }
144
+
145
+ /**
146
+ * Handle a single invocation: fetch the next event, invoke the handler, and
147
+ * post the response, streaming response, or error.
148
+ *
149
+ * @returns {Promise<boolean|undefined>} `true` on a normal response,
150
+ * otherwise a falsy value.
151
+ */
152
+ async handleEvent() {
153
+ if (!(await this._init())) {
154
+ return undefined;
155
+ }
156
+ const [payload, context] = await this.lambdaNext();
157
+
158
+ let result;
159
+ const previousTraceId = process.env._X_AMZN_TRACE_ID;
160
+ try {
161
+ if (context?.traceId === undefined) {
162
+ delete process.env._X_AMZN_TRACE_ID;
163
+ } else {
164
+ process.env._X_AMZN_TRACE_ID = context.traceId;
165
+ }
166
+ _setCurrentContext(context);
167
+ result = await /** @type {Handler} */ (this.function)(payload, context);
168
+ } catch (error) {
169
+ process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : error}\n`);
170
+ await this.lambdaError(error, context);
171
+ return undefined;
172
+ } finally {
173
+ _setCurrentContext(undefined);
174
+ if (previousTraceId === undefined) {
175
+ delete process.env._X_AMZN_TRACE_ID;
176
+ } else {
177
+ process.env._X_AMZN_TRACE_ID = previousTraceId;
178
+ }
179
+ }
180
+
181
+ if (typeof result === 'function') {
182
+ await this.lambdaResponseStreaming(result, context);
183
+ } else {
184
+ await this.lambdaResponse(result, context);
185
+ }
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * Fetch the next invocation and build its {@link Context} from the response
191
+ * headers.
192
+ *
193
+ * @returns {Promise<[unknown, Context]>} `[payload, context]`.
194
+ */
195
+ async lambdaNext() {
196
+ const response = await request(this.nextEventUrl);
197
+ if (!response.success) {
198
+ throw new Error(`failed to retrieve the next event: ${response.status}`);
199
+ }
200
+ const h = response.headers;
201
+ const payload = JSON.parse(response.body);
202
+ const context = new Context({
203
+ deadlineMs: /** @type {string} */ (h['lambda-runtime-deadline-ms']),
204
+ awsRequestId: /** @type {string} */ (h['lambda-runtime-aws-request-id']),
205
+ invokedFunctionArn: /** @type {string} */ (h['lambda-runtime-invoked-function-arn']),
206
+ traceId: /** @type {string} */ (h['lambda-runtime-trace-id']),
207
+ tenantId: /** @type {string} */ (h['lambda-runtime-aws-tenant-id']),
208
+ });
209
+ return [payload, context];
210
+ }
211
+
212
+ /**
213
+ * Post a buffered handler response to the Runtime API.
214
+ *
215
+ * @param {unknown} response The handler's return value.
216
+ * @param {Context} context
217
+ * @returns {Promise<void>}
218
+ */
219
+ async lambdaResponse(response, context) {
220
+ const url = `http://${this.runtimeApi}/${this.apiVersion}/runtime/invocation/${context.awsRequestId}/response`;
221
+ const result = await request(url, { method: 'POST', body: JSON.stringify(response) });
222
+ if (!result.success) {
223
+ throw new Error(`failed to response of execution: ${result.status}`);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Stream a handler response to the Runtime API.
229
+ *
230
+ * @param {StreamingHandler} response The streaming callback the handler
231
+ * returned.
232
+ * @param {Context} context
233
+ * @returns {Promise<void>}
234
+ */
235
+ async lambdaResponseStreaming(response, context) {
236
+ const url = `http://${this.runtimeApi}/${this.apiVersion}/runtime/invocation/${context.awsRequestId}/response`;
237
+ /** @type {ResponseWriter|undefined} */
238
+ let writer;
239
+ try {
240
+ await response((contentType) => {
241
+ writer = new ResponseWriter({ responseUrl: url, contentType });
242
+ writer._request(contentType);
243
+ return writer;
244
+ });
245
+ } catch (error) {
246
+ process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : error}\n`);
247
+ if (writer) {
248
+ writer._closeWithError(error);
249
+ } else {
250
+ await this.lambdaError(error, context);
251
+ }
252
+ }
253
+ if (writer) {
254
+ const result = await writer._handleResponse();
255
+ if (!result.success) {
256
+ throw new Error(`failed to response of execution: ${result.status}`);
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Report an invocation error to the Runtime API.
263
+ *
264
+ * @param {unknown} error
265
+ * @param {Context} context
266
+ * @returns {Promise<void>}
267
+ */
268
+ async lambdaError(error, context) {
269
+ const url = `http://${this.runtimeApi}/${this.apiVersion}/runtime/invocation/${context.awsRequestId}/error`;
270
+ const result = await request(url, {
271
+ method: 'POST',
272
+ body: JSON.stringify({ errorMessage: errorMessage(error), errorType: errorType(error) }),
273
+ });
274
+ if (!result.success) {
275
+ throw new Error(`failed to send error of execution: ${result.status}`);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Report an initialization error to the Runtime API.
281
+ *
282
+ * @param {unknown} error
283
+ * @returns {Promise<void>}
284
+ */
285
+ async lambdaInitError(error) {
286
+ const url = `http://${this.runtimeApi}/${this.apiVersion}/runtime/init/error`;
287
+ const result = await request(url, {
288
+ method: 'POST',
289
+ body: JSON.stringify({ errorMessage: errorMessage(error), errorType: errorType(error) }),
290
+ });
291
+ if (!result.success) {
292
+ throw new Error(`failed to send error of execution: ${result.status}`);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * The inner invocation loop. Runs until {@link Bootstrap#_stop} is set.
298
+ *
299
+ * @returns {Promise<void>}
300
+ */
301
+ async _handleEvents() {
302
+ while (!this._stop) {
303
+ await this.handleEvent();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Initialize the handler and process events. With `maxWorkers > 0`, run that
309
+ * many concurrent invocation loops (the Node analog of Perl's
310
+ * `Parallel::Prefork`), stopping on SIGTERM/SIGHUP.
311
+ *
312
+ * @returns {Promise<void>}
313
+ */
314
+ async handleEvents() {
315
+ if (!(await this._init())) {
316
+ return;
317
+ }
318
+ if (this.maxWorkers > 0) {
319
+ const stop = () => {
320
+ this._stop = true;
321
+ };
322
+ process.on('SIGTERM', stop);
323
+ process.on('SIGHUP', stop);
324
+ try {
325
+ await Promise.all(Array.from({ length: this.maxWorkers }, () => this._handleEvents()));
326
+ } finally {
327
+ process.removeListener('SIGTERM', stop);
328
+ process.removeListener('SIGHUP', stop);
329
+ }
330
+ } else {
331
+ await this._handleEvents();
332
+ }
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Construct a {@link Bootstrap} for `handler` and run its event loop. The
338
+ * convenience entry point used by the `bootstrap` executable.
339
+ *
340
+ * @param {string} [handler] The handler in `file.function` form. Falls back to
341
+ * `_HANDLER`.
342
+ * @returns {Promise<void>}
343
+ */
344
+ export async function bootstrap(handler) {
345
+ const instance = new Bootstrap({ handler });
346
+ await instance.handleEvents();
347
+ }
package/src/context.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Node.js port of `AWS::Lambda::Context`.
3
+ *
4
+ * @module context
5
+ */
6
+
7
+ /**
8
+ * @typedef {object} ContextArgs
9
+ * @property {number|string} deadlineMs The date (in Unix epoch milliseconds)
10
+ * by which the function must exit. Required.
11
+ * @property {string} [awsRequestId] The identifier of the invocation request.
12
+ * @property {string} [invokedFunctionArn] The ARN used to invoke the function.
13
+ * @property {string} [traceId] The AWS X-Ray tracing header.
14
+ * @property {string} [tenantId] The tenant id for the function.
15
+ */
16
+
17
+ /**
18
+ * The Lambda context object, passed to the handler as its second argument.
19
+ *
20
+ * Per-invocation fields come from the response headers of the "next
21
+ * invocation" call; the remaining accessors read process environment
22
+ * variables that Lambda sets once per execution environment.
23
+ */
24
+ export class Context {
25
+ /**
26
+ * @param {ContextArgs} args
27
+ */
28
+ constructor(args = /** @type {ContextArgs} */ ({})) {
29
+ if (args.deadlineMs === undefined || args.deadlineMs === null) {
30
+ throw new Error('deadline_ms is required');
31
+ }
32
+
33
+ /**
34
+ * The date (in Unix epoch milliseconds) by which the function must exit.
35
+ * @type {number}
36
+ */
37
+ this.deadlineMs = Number(args.deadlineMs);
38
+
39
+ /**
40
+ * The ARN used to invoke the function.
41
+ * @type {string}
42
+ */
43
+ this.invokedFunctionArn = args.invokedFunctionArn ?? '';
44
+
45
+ /**
46
+ * The identifier of the invocation request.
47
+ * @type {string}
48
+ */
49
+ this.awsRequestId = args.awsRequestId ?? '';
50
+
51
+ /**
52
+ * The AWS X-Ray tracing header.
53
+ * @type {string|undefined}
54
+ */
55
+ this.traceId = args.traceId;
56
+
57
+ /**
58
+ * The tenant id for the function.
59
+ * @type {string|undefined}
60
+ */
61
+ this.tenantId = args.tenantId;
62
+ }
63
+
64
+ /**
65
+ * The number of milliseconds left before the execution times out.
66
+ *
67
+ * @returns {number}
68
+ */
69
+ getRemainingTimeInMillis() {
70
+ return this.deadlineMs - Date.now();
71
+ }
72
+
73
+ /**
74
+ * The name of the Lambda function (`AWS_LAMBDA_FUNCTION_NAME`).
75
+ *
76
+ * @returns {string}
77
+ * @throws {Error} if the environment variable is not set.
78
+ */
79
+ get functionName() {
80
+ return required('AWS_LAMBDA_FUNCTION_NAME', 'function_name');
81
+ }
82
+
83
+ /**
84
+ * The version of the function (`AWS_LAMBDA_FUNCTION_VERSION`).
85
+ *
86
+ * @returns {string}
87
+ * @throws {Error} if the environment variable is not set.
88
+ */
89
+ get functionVersion() {
90
+ return required('AWS_LAMBDA_FUNCTION_VERSION', 'function_version');
91
+ }
92
+
93
+ /**
94
+ * The amount of memory configured on the function, in MB
95
+ * (`AWS_LAMBDA_FUNCTION_MEMORY_SIZE`).
96
+ *
97
+ * @returns {number}
98
+ * @throws {Error} if the environment variable is not set.
99
+ */
100
+ get memoryLimitInMb() {
101
+ return Number(required('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', 'memory_limit_in_mb'));
102
+ }
103
+
104
+ /**
105
+ * The log group for the function (`AWS_LAMBDA_LOG_GROUP_NAME`).
106
+ *
107
+ * @returns {string}
108
+ * @throws {Error} if the environment variable is not set.
109
+ */
110
+ get logGroupName() {
111
+ return required('AWS_LAMBDA_LOG_GROUP_NAME', 'log_group_name');
112
+ }
113
+
114
+ /**
115
+ * The log stream for the function instance (`AWS_LAMBDA_LOG_STREAM_NAME`).
116
+ *
117
+ * @returns {string}
118
+ * @throws {Error} if the environment variable is not set.
119
+ */
120
+ get logStreamName() {
121
+ return required('AWS_LAMBDA_LOG_STREAM_NAME', 'log_stream_name');
122
+ }
123
+
124
+ /**
125
+ * Cognito identity information. Not implemented (matches the Perl port).
126
+ *
127
+ * @returns {undefined}
128
+ */
129
+ get identity() {
130
+ return undefined; // TODO
131
+ }
132
+
133
+ /**
134
+ * Client context sent by the mobile SDK. Not implemented (matches the Perl
135
+ * port).
136
+ *
137
+ * @returns {undefined}
138
+ */
139
+ get clientContext() {
140
+ return undefined; // TODO
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Read a required environment variable, throwing with the Perl-compatible
146
+ * "<label> is not found" message when it is absent.
147
+ *
148
+ * @param {string} name
149
+ * @param {string} label
150
+ * @returns {string}
151
+ */
152
+ function required(name, label) {
153
+ const value = process.env[name];
154
+ if (value === undefined) {
155
+ throw new Error(`${label} is not found`);
156
+ }
157
+ return value;
158
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The context of the invocation currently being handled.
3
+ *
4
+ * This mirrors the Perl library's global `$AWS::Lambda::context`, which is
5
+ * localized for the duration of each handler call. A handler can read it via
6
+ * {@link getCurrentContext} instead of receiving it as an argument.
7
+ *
8
+ * @module current-context
9
+ */
10
+
11
+ /** @type {import('./context.js').Context | undefined} */
12
+ let current;
13
+
14
+ /**
15
+ * Return the {@link Context} of the invocation currently being handled, or
16
+ * `undefined` when no invocation is in flight.
17
+ *
18
+ * @returns {import('./context.js').Context | undefined}
19
+ */
20
+ export function getCurrentContext() {
21
+ return current;
22
+ }
23
+
24
+ /**
25
+ * Set the current invocation context. Internal: called by `Bootstrap` around
26
+ * each handler invocation.
27
+ *
28
+ * @param {import('./context.js').Context | undefined} context
29
+ * @returns {void}
30
+ */
31
+ export function _setCurrentContext(context) {
32
+ current = context;
33
+ }
package/src/errors.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Helpers that reproduce the Perl library's error reporting shape. The Perl
3
+ * code uses `"$error"` for the message and `blessed($error) // "Error"` for
4
+ * the type; the JS analog inspects `Error` instances.
5
+ *
6
+ * @module errors
7
+ */
8
+
9
+ /**
10
+ * The human-readable error message reported to Lambda.
11
+ *
12
+ * @param {unknown} error
13
+ * @returns {string}
14
+ */
15
+ export function errorMessage(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+
19
+ /**
20
+ * The error type reported to Lambda. For `Error` instances this is the
21
+ * constructor name (e.g. `TypeError`, or a custom subclass name); otherwise
22
+ * it is `"Error"` — matching Perl's `blessed($error) // "Error"`.
23
+ *
24
+ * @param {unknown} error
25
+ * @returns {string}
26
+ */
27
+ export function errorType(error) {
28
+ return error instanceof Error ? (error.constructor?.name ?? 'Error') : 'Error';
29
+ }
package/src/http.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A tiny buffered HTTP client built on `node:http` — the Node analog of the
3
+ * Perl library's use of `HTTP::Tiny`. Kept intentionally minimal: only what
4
+ * the Lambda Runtime API client needs (a long-polling GET and a POST with a
5
+ * string body). Streaming responses use `node:http` directly, see
6
+ * {@link module:response-writer}.
7
+ *
8
+ * @module http
9
+ */
10
+
11
+ import http from 'node:http';
12
+ import https from 'node:https';
13
+
14
+ /**
15
+ * @typedef {object} HttpResponse
16
+ * @property {number} status The HTTP status code.
17
+ * @property {import('node:http').IncomingHttpHeaders} headers Response headers
18
+ * (lower-cased keys, as Node provides them).
19
+ * @property {string} body The response body decoded as UTF-8.
20
+ * @property {boolean} success Whether the status is a 2xx.
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} RequestOptions
25
+ * @property {string} [method] HTTP method. Defaults to `GET`.
26
+ * @property {Record<string, string>} [headers] Request headers.
27
+ * @property {string} [body] Request body, sent as UTF-8.
28
+ * @property {number} [timeout] Socket timeout in milliseconds. Omit to disable
29
+ * (used for the long-poll "next invocation" request).
30
+ */
31
+
32
+ /**
33
+ * Perform a buffered HTTP request and resolve once the whole response body has
34
+ * been read.
35
+ *
36
+ * @param {string} url
37
+ * @param {RequestOptions} [options]
38
+ * @returns {Promise<HttpResponse>}
39
+ */
40
+ export function request(url, options = {}) {
41
+ const { method = 'GET', headers = {}, body, timeout } = options;
42
+ return new Promise((resolve, reject) => {
43
+ const target = new URL(url);
44
+ const lib = target.protocol === 'https:' ? https : http;
45
+
46
+ /** @type {Record<string, string|number>} */
47
+ const outHeaders = { ...headers };
48
+ if (body !== undefined && body !== null && outHeaders['content-length'] === undefined) {
49
+ outHeaders['content-length'] = Buffer.byteLength(body, 'utf8');
50
+ }
51
+
52
+ const req = lib.request(target, { method, headers: outHeaders }, (res) => {
53
+ /** @type {Buffer[]} */
54
+ const chunks = [];
55
+ res.on('data', (chunk) => chunks.push(chunk));
56
+ res.on('end', () => {
57
+ const status = res.statusCode ?? 0;
58
+ resolve({
59
+ status,
60
+ headers: res.headers,
61
+ body: Buffer.concat(chunks).toString('utf8'),
62
+ success: Math.trunc(status / 100) === 2,
63
+ });
64
+ });
65
+ });
66
+
67
+ req.on('error', reject);
68
+ if (timeout !== undefined) {
69
+ req.setTimeout(timeout, () => req.destroy(new Error(`request timed out after ${timeout}ms`)));
70
+ }
71
+ if (body !== undefined && body !== null) {
72
+ req.write(body);
73
+ }
74
+ req.end();
75
+ });
76
+ }
package/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Node.js port of the Perl `AWS::Lambda` distribution: a client for the AWS
3
+ * Lambda custom-runtime (the OS-only `provided.*` runtime family).
4
+ *
5
+ * @example
6
+ * // handler.js, deployed with a `bootstrap` entry point
7
+ * export function handle(payload, context) {
8
+ * return { ok: true, requestId: context.awsRequestId };
9
+ * }
10
+ *
11
+ * @module aws-lambda
12
+ */
13
+
14
+ export { Bootstrap, bootstrap } from './bootstrap.js';
15
+ export { Context } from './context.js';
16
+ export { ResponseWriter } from './response-writer.js';
17
+ export { getCurrentContext } from './current-context.js';
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Node.js port of `AWS::Lambda::ResponseWriter`.
3
+ *
4
+ * Writes a Lambda response-streaming body to the Runtime API using HTTP/1.1
5
+ * chunked transfer encoding. The Perl version hand-frames chunks and trailers
6
+ * on top of `HTTP::Tiny` internals; Node's `http.ClientRequest` performs chunk
7
+ * framing and trailer emission natively, so this implementation delegates to
8
+ * it.
9
+ *
10
+ * @module response-writer
11
+ */
12
+
13
+ import http from 'node:http';
14
+ import https from 'node:https';
15
+
16
+ import { errorMessage, errorType } from './errors.js';
17
+
18
+ /**
19
+ * @typedef {object} ResponseWriterArgs
20
+ * @property {string} responseUrl The Runtime API invocation-response URL.
21
+ * Required.
22
+ * @property {string} [contentType] Default content type. Defaults to
23
+ * `application/json`.
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} StreamingResult
28
+ * @property {number} status The HTTP status code returned by the Runtime API.
29
+ * @property {import('node:http').IncomingHttpHeaders} headers Response headers.
30
+ * @property {string} body The response body decoded as UTF-8.
31
+ * @property {boolean} success Whether the status is a 2xx.
32
+ */
33
+
34
+ export class ResponseWriter {
35
+ /**
36
+ * @param {ResponseWriterArgs} args
37
+ */
38
+ constructor(args) {
39
+ if (!args || !args.responseUrl) {
40
+ throw new Error('response_url is required');
41
+ }
42
+
43
+ /** @type {string} */
44
+ this.responseUrl = args.responseUrl;
45
+ /** @type {string} */
46
+ this.contentType = args.contentType ?? 'application/json';
47
+ /** @type {import('node:http').ClientRequest | undefined} */
48
+ this.request = undefined;
49
+ /** @type {boolean} */
50
+ this.closed = false;
51
+ /** @type {Promise<import('node:http').IncomingMessage> | undefined} */
52
+ this._responsePromise = undefined;
53
+ }
54
+
55
+ /**
56
+ * Open the chunked POST request to the Runtime API and send the request
57
+ * headers. Internal: called by `Bootstrap.lambdaResponseStreaming`.
58
+ *
59
+ * @param {string} [contentType]
60
+ * @returns {void}
61
+ */
62
+ _request(contentType = this.contentType) {
63
+ const target = new URL(this.responseUrl);
64
+ const lib = target.protocol === 'https:' ? https : http;
65
+ const req = lib.request(target, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'content-type': contentType,
69
+ 'transfer-encoding': 'chunked',
70
+ trailer: 'Lambda-Runtime-Function-Error-Type, Lambda-Runtime-Function-Error-Body',
71
+ 'lambda-runtime-function-response-mode': 'streaming',
72
+ },
73
+ });
74
+
75
+ this.request = req;
76
+ this._responsePromise = new Promise((resolve, reject) => {
77
+ req.on('response', resolve);
78
+ req.on('error', reject);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Write a chunk of the response body. Empty data is a no-op.
84
+ *
85
+ * @param {string|Buffer} data
86
+ * @returns {boolean} `true` once the chunk has been queued.
87
+ */
88
+ write(data) {
89
+ if (this.closed) {
90
+ throw new Error('write failed: already closed');
91
+ }
92
+ if (data === undefined || data === null || data.length === 0) {
93
+ return true;
94
+ }
95
+ this.request?.write(data);
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Finish the response by writing the terminating chunk. Idempotent.
101
+ *
102
+ * @returns {void}
103
+ */
104
+ close() {
105
+ if (this.closed) {
106
+ return;
107
+ }
108
+ this.closed = true;
109
+ this.request?.end();
110
+ }
111
+
112
+ /**
113
+ * Abort the stream by attaching error trailers, then finish the request.
114
+ * Used when the handler throws after streaming has started. Idempotent.
115
+ *
116
+ * @param {unknown} error
117
+ * @returns {void}
118
+ */
119
+ _closeWithError(error) {
120
+ if (this.closed) {
121
+ return;
122
+ }
123
+ this.closed = true;
124
+ this.request?.addTrailers({
125
+ 'Lambda-Runtime-Function-Error-Type': errorType(error),
126
+ 'Lambda-Runtime-Function-Error-Body': Buffer.from(errorMessage(error), 'utf8').toString('base64'),
127
+ });
128
+ this.request?.end();
129
+ }
130
+
131
+ /**
132
+ * Close the request if needed and read the Runtime API's response.
133
+ *
134
+ * @returns {Promise<StreamingResult>}
135
+ */
136
+ async _handleResponse() {
137
+ if (!this.closed) {
138
+ this.close();
139
+ }
140
+ const res = await /** @type {Promise<import('node:http').IncomingMessage>} */ (this._responsePromise);
141
+ /** @type {Buffer[]} */
142
+ const chunks = [];
143
+ for await (const chunk of res) {
144
+ chunks.push(chunk);
145
+ }
146
+ const status = res.statusCode ?? 0;
147
+ return {
148
+ status,
149
+ headers: res.headers,
150
+ body: Buffer.concat(chunks).toString('utf8'),
151
+ success: Math.trunc(status / 100) === 2,
152
+ };
153
+ }
154
+ }