@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 +180 -0
- package/bin/bootstrap +10 -0
- package/package.json +43 -0
- package/src/bootstrap.js +347 -0
- package/src/context.js +158 -0
- package/src/current-context.js +33 -0
- package/src/errors.js +29 -0
- package/src/http.js +76 -0
- package/src/index.js +17 -0
- package/src/response-writer.js +154 -0
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
|
+
}
|
package/src/bootstrap.js
ADDED
|
@@ -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
|
+
}
|