Skip to content

Commit

Permalink
Support function entry points (#301)
Browse files Browse the repository at this point in the history
  • Loading branch information
72636c authored Dec 14, 2020
1 parent e1dab09 commit 334f38b
Show file tree
Hide file tree
Showing 17 changed files with 309 additions and 107 deletions.
5 changes: 5 additions & 0 deletions .changeset/fluffy-llamas-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'skuba': patch
---

**template/lambda-sqs-worker:** Add `start` script
19 changes: 19 additions & 0 deletions .changeset/rich-tools-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'skuba': minor
---

**node, start:** Support function entry points

You can now specify an entry point that targets an exported function:

```bash
skuba start --port 12345 src/app.ts#handler
```

This starts up a local HTTP server that you can POST arguments to:

```bash
curl --data '["event", {"awsRequestId": "123"}]' --include localhost:12345
```

You may find this useful to run Lambda function handlers locally.
5 changes: 5 additions & 0 deletions .changeset/strong-fans-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'skuba': patch
---

**node, start:** Support `--port` option
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,28 @@ Your entry point can be a simple module that runs on load:
console.log('Hello world!');
```
#### Start a Lambda function handler
Your entry point can target an exported function:
```bash
skuba start --port 12345 src/app.ts#handler
```
```typescript
export const handler = async (event: unknown, ctx: unknown) => {
// ...

return;
};
```
This starts up a local HTTP server that you can POST arguments to:
```bash
curl --data '["event", {"awsRequestId": "123"}]' --include localhost:12345
```
#### Start an HTTP server
Your entry point should export:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"read-pkg-up": "^7.0.1",
"runtypes": "^5.0.1",
"semantic-release": "^17.2.3",
"serialize-error": "^7.0.1",
"source-map-support": "^0.5.19",
"ts-jest": "^26.4.1",
"ts-node": "^9.0.0",
Expand Down
5 changes: 4 additions & 1 deletion src/cli/configure/getEntryPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export const getEntryPoint = ({
name: 'entryPoint',
result: (value) => (value.endsWith('.ts') ? value : `${value}.ts`),
validate: async (value) => {
const exists = await tsFileExists(path.join(destinationRoot, value));
// Support exported function targeting, e.g. `src/module.ts#callMeMaybe`
const [modulePath] = value.split('#', 2);

const exists = await tsFileExists(path.join(destinationRoot, modulePath));

return exists || `${chalk.bold(value)} is not a TypeScript file.`;
},
Expand Down
18 changes: 13 additions & 5 deletions src/cli/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ const parseArgs = () => {
return {
entryPoint,
inspect,
port: Number(yargs.port) || undefined,
};
};

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

const [port, isBabel] = await Promise.all([getPort(), isBabelFromManifest()]);
const [availablePort, isBabel] = await Promise.all([
getPort(),
isBabelFromManifest(),
]);

const exec = createExec({
env: isBabel ? undefined : { __SKUBA_REGISTER_MODULE_ALIASES: '1' },
Expand All @@ -42,13 +46,13 @@ export const node = async () => {
'--extensions',
['.js', '.json', '.ts'].join(','),
'--require',
path.join('skuba', 'lib', 'register'),
path.posix.join('skuba', 'lib', 'register'),
...(args.entryPoint === null
? []
: [
path.join(__dirname, '..', 'wrapper.js'),
args.entryPoint,
String(port),
String(args.port ?? availablePort),
]),
);
}
Expand All @@ -57,10 +61,14 @@ export const node = async () => {
'ts-node',
...args.inspect,
'--require',
path.join('skuba', 'lib', 'register'),
path.posix.join('skuba', 'lib', 'register'),
'--transpile-only',
...(args.entryPoint === null
? []
: [path.join(__dirname, '..', 'wrapper'), args.entryPoint, String(port)]),
: [
path.join(__dirname, '..', 'wrapper'),
args.entryPoint,
String(args.port ?? availablePort),
]),
);
};
11 changes: 6 additions & 5 deletions src/cli/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ const parseArgs = async () => {
return {
entryPoint,
inspect,
port: Number(yargs.port) || undefined,
};
};

export const start = async () => {
const [args, port, isBabel] = await Promise.all([
const [args, availablePort, isBabel] = await Promise.all([
parseArgs(),
getPort(),
isBabelFromManifest(),
Expand All @@ -55,22 +56,22 @@ export const start = async () => {
'--extensions',
['.js', '.json', '.ts'].join(','),
'--require',
path.join('skuba', 'lib', 'register'),
path.posix.join('skuba', 'lib', 'register'),
path.join(__dirname, '..', 'wrapper.js'),
args.entryPoint,
String(port),
String(args.port ?? availablePort),
);
}

return execProcess(
'ts-node-dev',
...args.inspect,
'--require',
path.join('skuba', 'lib', 'register'),
path.posix.join('skuba', 'lib', 'register'),
'--respawn',
'--transpile-only',
path.join(__dirname, '..', 'wrapper'),
args.entryPoint,
String(port),
String(args.port ?? availablePort),
);
};
5 changes: 5 additions & 0 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export const isFunction = (
data: unknown,
): data is (...args: unknown[]) => unknown | Promise<unknown> =>
typeof data === 'function';

export const isObject = (
value: unknown,
): value is Record<PropertyKey, unknown> =>
Expand Down
95 changes: 0 additions & 95 deletions src/wrapper.ts

This file was deleted.

38 changes: 38 additions & 0 deletions src/wrapper/functionHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { log } from '../utils/logging';
import { isFunction, isObject } from '../utils/validation';

import {
createRequestListenerFromFunction,
serveRequestListener,
} from './http';

interface Args {
availablePort?: number;
entryPoint: unknown;
functionName: string;
}

/**
* Create an HTTP server that calls into an exported function.
*/
export const runFunctionHandler = ({
availablePort,
entryPoint,
functionName,
}: Args) => {
if (!isObject(entryPoint)) {
log.subtle(log.bold(functionName), 'is not exported');
return;
}

const fn = entryPoint[functionName];

if (!isFunction(fn)) {
log.subtle(log.bold(functionName), 'is not a function');
return;
}

const requestListener = createRequestListenerFromFunction(fn);

return serveRequestListener(requestListener, availablePort);
};
69 changes: 69 additions & 0 deletions src/wrapper/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import http from 'http';
import { AddressInfo } from 'net';

import { serializeError } from 'serialize-error';

import { log } from '../utils/logging';

/**
* Create an HTTP request listener based on the supplied function.
*
* - The request body is JSON parsed and passed into the function as parameters.
* - The function's return value is JSON stringified into the response body.
*/
export const createRequestListenerFromFunction = (
fn: (...args: unknown[]) => unknown | Promise<unknown>,
): http.RequestListener => async (req, res) => {
const writeJsonResponse = (statusCode: number, jsonResponse: unknown) =>
new Promise<void>((resolve) =>
res
.writeHead(statusCode, { 'Content-Type': 'application/json' })
.end(JSON.stringify(jsonResponse, null, 2), 'utf8', resolve),
);

try {
const requestBody = await new Promise<string>((resolve, reject) => {
let data = '';

req
.on('data', (chunk) => (data += chunk))
.on('end', () => resolve(data))
.on('error', (err) => reject(err));
});

const jsonRequest: unknown = JSON.parse(requestBody);

// Pass a non-array request body as the first parameter
const args = Array.isArray(jsonRequest) ? jsonRequest : [jsonRequest];

const response: unknown = await fn(...args);

await writeJsonResponse(200, response);
} catch (err: unknown) {
await writeJsonResponse(500, serializeError(err));
}
};

/**
* Create a HTTP server based on the supplied `http.RequestListener`.
*
* This function resolves when the server is closed.
*/
export const serveRequestListener = (
requestListener: http.RequestListener,
port?: number,
) => {
const server = http.createServer(requestListener);

return new Promise<void>((resolve, reject) =>
server
.listen(port)
.on('close', resolve)
.on('error', reject)
.on('listening', () => {
const address = server.address() as AddressInfo;

log.ok('listening on port', log.bold(address.port));
}),
);
};
Loading

0 comments on commit 334f38b

Please sign in to comment.