Skip to content

Commit 334f38b

Browse files
authored
Support function entry points (#301)
1 parent e1dab09 commit 334f38b

17 files changed

+309
-107
lines changed

.changeset/fluffy-llamas-fix.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'skuba': patch
3+
---
4+
5+
**template/lambda-sqs-worker:** Add `start` script

.changeset/rich-tools-flow.md

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'skuba': minor
3+
---
4+
5+
**node, start:** Support function entry points
6+
7+
You can now specify an entry point that targets an exported function:
8+
9+
```bash
10+
skuba start --port 12345 src/app.ts#handler
11+
```
12+
13+
This starts up a local HTTP server that you can POST arguments to:
14+
15+
```bash
16+
curl --data '["event", {"awsRequestId": "123"}]' --include localhost:12345
17+
```
18+
19+
You may find this useful to run Lambda function handlers locally.

.changeset/strong-fans-burn.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'skuba': patch
3+
---
4+
5+
**node, start:** Support `--port` option

README.md

+22
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,28 @@ Your entry point can be a simple module that runs on load:
302302
console.log('Hello world!');
303303
```
304304
305+
#### Start a Lambda function handler
306+
307+
Your entry point can target an exported function:
308+
309+
```bash
310+
skuba start --port 12345 src/app.ts#handler
311+
```
312+
313+
```typescript
314+
export const handler = async (event: unknown, ctx: unknown) => {
315+
// ...
316+
317+
return;
318+
};
319+
```
320+
321+
This starts up a local HTTP server that you can POST arguments to:
322+
323+
```bash
324+
curl --data '["event", {"awsRequestId": "123"}]' --include localhost:12345
325+
```
326+
305327
#### Start an HTTP server
306328
307329
Your entry point should export:

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"read-pkg-up": "^7.0.1",
7777
"runtypes": "^5.0.1",
7878
"semantic-release": "^17.2.3",
79+
"serialize-error": "^7.0.1",
7980
"source-map-support": "^0.5.19",
8081
"ts-jest": "^26.4.1",
8182
"ts-node": "^9.0.0",

src/cli/configure/getEntryPoint.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ export const getEntryPoint = ({
3838
name: 'entryPoint',
3939
result: (value) => (value.endsWith('.ts') ? value : `${value}.ts`),
4040
validate: async (value) => {
41-
const exists = await tsFileExists(path.join(destinationRoot, value));
41+
// Support exported function targeting, e.g. `src/module.ts#callMeMaybe`
42+
const [modulePath] = value.split('#', 2);
43+
44+
const exists = await tsFileExists(path.join(destinationRoot, modulePath));
4245

4346
return exists || `${chalk.bold(value)} is not a TypeScript file.`;
4447
},

src/cli/node.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ const parseArgs = () => {
2323
return {
2424
entryPoint,
2525
inspect,
26+
port: Number(yargs.port) || undefined,
2627
};
2728
};
2829

2930
export const node = async () => {
3031
const args = parseArgs();
3132

32-
const [port, isBabel] = await Promise.all([getPort(), isBabelFromManifest()]);
33+
const [availablePort, isBabel] = await Promise.all([
34+
getPort(),
35+
isBabelFromManifest(),
36+
]);
3337

3438
const exec = createExec({
3539
env: isBabel ? undefined : { __SKUBA_REGISTER_MODULE_ALIASES: '1' },
@@ -42,13 +46,13 @@ export const node = async () => {
4246
'--extensions',
4347
['.js', '.json', '.ts'].join(','),
4448
'--require',
45-
path.join('skuba', 'lib', 'register'),
49+
path.posix.join('skuba', 'lib', 'register'),
4650
...(args.entryPoint === null
4751
? []
4852
: [
4953
path.join(__dirname, '..', 'wrapper.js'),
5054
args.entryPoint,
51-
String(port),
55+
String(args.port ?? availablePort),
5256
]),
5357
);
5458
}
@@ -57,10 +61,14 @@ export const node = async () => {
5761
'ts-node',
5862
...args.inspect,
5963
'--require',
60-
path.join('skuba', 'lib', 'register'),
64+
path.posix.join('skuba', 'lib', 'register'),
6165
'--transpile-only',
6266
...(args.entryPoint === null
6367
? []
64-
: [path.join(__dirname, '..', 'wrapper'), args.entryPoint, String(port)]),
68+
: [
69+
path.join(__dirname, '..', 'wrapper'),
70+
args.entryPoint,
71+
String(args.port ?? availablePort),
72+
]),
6573
);
6674
};

src/cli/start.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ const parseArgs = async () => {
2929
return {
3030
entryPoint,
3131
inspect,
32+
port: Number(yargs.port) || undefined,
3233
};
3334
};
3435

3536
export const start = async () => {
36-
const [args, port, isBabel] = await Promise.all([
37+
const [args, availablePort, isBabel] = await Promise.all([
3738
parseArgs(),
3839
getPort(),
3940
isBabelFromManifest(),
@@ -55,22 +56,22 @@ export const start = async () => {
5556
'--extensions',
5657
['.js', '.json', '.ts'].join(','),
5758
'--require',
58-
path.join('skuba', 'lib', 'register'),
59+
path.posix.join('skuba', 'lib', 'register'),
5960
path.join(__dirname, '..', 'wrapper.js'),
6061
args.entryPoint,
61-
String(port),
62+
String(args.port ?? availablePort),
6263
);
6364
}
6465

6566
return execProcess(
6667
'ts-node-dev',
6768
...args.inspect,
6869
'--require',
69-
path.join('skuba', 'lib', 'register'),
70+
path.posix.join('skuba', 'lib', 'register'),
7071
'--respawn',
7172
'--transpile-only',
7273
path.join(__dirname, '..', 'wrapper'),
7374
args.entryPoint,
74-
String(port),
75+
String(args.port ?? availablePort),
7576
);
7677
};

src/utils/validation.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
export const isFunction = (
2+
data: unknown,
3+
): data is (...args: unknown[]) => unknown | Promise<unknown> =>
4+
typeof data === 'function';
5+
16
export const isObject = (
27
value: unknown,
38
): value is Record<PropertyKey, unknown> =>

src/wrapper.ts

-95
This file was deleted.

src/wrapper/functionHandler.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { log } from '../utils/logging';
2+
import { isFunction, isObject } from '../utils/validation';
3+
4+
import {
5+
createRequestListenerFromFunction,
6+
serveRequestListener,
7+
} from './http';
8+
9+
interface Args {
10+
availablePort?: number;
11+
entryPoint: unknown;
12+
functionName: string;
13+
}
14+
15+
/**
16+
* Create an HTTP server that calls into an exported function.
17+
*/
18+
export const runFunctionHandler = ({
19+
availablePort,
20+
entryPoint,
21+
functionName,
22+
}: Args) => {
23+
if (!isObject(entryPoint)) {
24+
log.subtle(log.bold(functionName), 'is not exported');
25+
return;
26+
}
27+
28+
const fn = entryPoint[functionName];
29+
30+
if (!isFunction(fn)) {
31+
log.subtle(log.bold(functionName), 'is not a function');
32+
return;
33+
}
34+
35+
const requestListener = createRequestListenerFromFunction(fn);
36+
37+
return serveRequestListener(requestListener, availablePort);
38+
};

src/wrapper/http.ts

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import http from 'http';
2+
import { AddressInfo } from 'net';
3+
4+
import { serializeError } from 'serialize-error';
5+
6+
import { log } from '../utils/logging';
7+
8+
/**
9+
* Create an HTTP request listener based on the supplied function.
10+
*
11+
* - The request body is JSON parsed and passed into the function as parameters.
12+
* - The function's return value is JSON stringified into the response body.
13+
*/
14+
export const createRequestListenerFromFunction = (
15+
fn: (...args: unknown[]) => unknown | Promise<unknown>,
16+
): http.RequestListener => async (req, res) => {
17+
const writeJsonResponse = (statusCode: number, jsonResponse: unknown) =>
18+
new Promise<void>((resolve) =>
19+
res
20+
.writeHead(statusCode, { 'Content-Type': 'application/json' })
21+
.end(JSON.stringify(jsonResponse, null, 2), 'utf8', resolve),
22+
);
23+
24+
try {
25+
const requestBody = await new Promise<string>((resolve, reject) => {
26+
let data = '';
27+
28+
req
29+
.on('data', (chunk) => (data += chunk))
30+
.on('end', () => resolve(data))
31+
.on('error', (err) => reject(err));
32+
});
33+
34+
const jsonRequest: unknown = JSON.parse(requestBody);
35+
36+
// Pass a non-array request body as the first parameter
37+
const args = Array.isArray(jsonRequest) ? jsonRequest : [jsonRequest];
38+
39+
const response: unknown = await fn(...args);
40+
41+
await writeJsonResponse(200, response);
42+
} catch (err: unknown) {
43+
await writeJsonResponse(500, serializeError(err));
44+
}
45+
};
46+
47+
/**
48+
* Create a HTTP server based on the supplied `http.RequestListener`.
49+
*
50+
* This function resolves when the server is closed.
51+
*/
52+
export const serveRequestListener = (
53+
requestListener: http.RequestListener,
54+
port?: number,
55+
) => {
56+
const server = http.createServer(requestListener);
57+
58+
return new Promise<void>((resolve, reject) =>
59+
server
60+
.listen(port)
61+
.on('close', resolve)
62+
.on('error', reject)
63+
.on('listening', () => {
64+
const address = server.address() as AddressInfo;
65+
66+
log.ok('listening on port', log.bold(address.port));
67+
}),
68+
);
69+
};

0 commit comments

Comments
 (0)