Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Node v14.x #25

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ v10.x/layer
v10.x/test/lambda.zip
v12.x/layer
v12.x/test/lambda.zip
v14.x/layer
v14.x/test/**/lambda.zip
3 changes: 3 additions & 0 deletions v14.x/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
layer.zip
layer
test
15 changes: 15 additions & 0 deletions v14.x/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM lambci/lambda-base:build

COPY bootstrap.c bootstrap.js package.json esm-loader-hook.mjs /opt/

ARG NODE_VERSION

RUN curl -sSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz | \
tar -xJ -C /opt --strip-components 1 -- node-v${NODE_VERSION}-linux-x64/bin/node && \
strip /opt/bin/node

RUN cd /opt && \
export NODE_MAJOR=$(echo $NODE_VERSION | awk -F. '{print "\""$1"\""}') && \
clang -Wall -Werror -s -O2 -D NODE_MAJOR="$NODE_MAJOR" -o bootstrap bootstrap.c && \
rm bootstrap.c && \
zip -yr /tmp/layer.zip .
43 changes: 43 additions & 0 deletions v14.x/bootstrap.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>

#ifndef NODE_MAJOR
#error Must pass NODE_MAJOR to the compiler (eg "10")
#define NODE_MAJOR ""
#endif

#define AWS_EXECUTION_ENV "AWS_Lambda_nodejs" NODE_MAJOR "_lambci"
#define NODE_PATH "/opt/nodejs/node" NODE_MAJOR "/node_modules:" \
"/opt/nodejs/node_modules:" \
"/var/runtime/node_modules:" \
"/var/runtime:" \
"/var/task"
#define MIN_MEM_SIZE 128
#define ARG_BUF_SIZE 32

int main(void) {
setenv("AWS_EXECUTION_ENV", AWS_EXECUTION_ENV, true);
setenv("NODE_PATH", NODE_PATH, true);

const char *mem_size_str = getenv("AWS_LAMBDA_FUNCTION_MEMORY_SIZE");
int mem_size = mem_size_str != NULL ? atoi(mem_size_str) : MIN_MEM_SIZE;

char max_semi_space_size[ARG_BUF_SIZE];
snprintf(max_semi_space_size, ARG_BUF_SIZE, "--max-semi-space-size=%d", mem_size * 5 / 100);

char max_old_space_size[ARG_BUF_SIZE];
snprintf(max_old_space_size, ARG_BUF_SIZE, "--max-old-space-size=%d", mem_size * 90 / 100);

execv("/opt/bin/node", (char *[]){
"node",
"--experimental-loader=/opt/esm-loader-hook.mjs",
"--expose-gc",
max_semi_space_size,
max_old_space_size,
"/opt/bootstrap.js",
NULL});
perror("Could not execv");
return EXIT_FAILURE;
}
269 changes: 269 additions & 0 deletions v14.x/bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import http from 'http'
import { createRequire } from 'module'
import path from 'path'
import { stat, readFile } from "fs/promises"

const RUNTIME_PATH = '/2018-06-01/runtime'

const CALLBACK_USED = Symbol('CALLBACK_USED')

const {
AWS_LAMBDA_FUNCTION_NAME,
AWS_LAMBDA_FUNCTION_VERSION,
AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
AWS_LAMBDA_LOG_GROUP_NAME,
AWS_LAMBDA_LOG_STREAM_NAME,
LAMBDA_TASK_ROOT,
_HANDLER,
AWS_LAMBDA_RUNTIME_API,
} = process.env

const [HOST, PORT] = AWS_LAMBDA_RUNTIME_API.split(':')

start()

async function start() {
let handler
try {
handler = await getHandler()
} catch (e) {
await initError(e)
return process.exit(1)
}
tryProcessEvents(handler)
}

async function tryProcessEvents(handler) {
try {
await processEvents(handler)
} catch (e) {
console.error(e)
return process.exit(1)
}
}

async function processEvents(handler) {
while (true) {
const { event, context } = await nextInvocation()

let result
try {
result = await handler(event, context)
} catch (e) {
await invokeError(e, context)
continue
}
const callbackUsed = context[CALLBACK_USED]

await invokeResponse(result, context)

if (callbackUsed && context.callbackWaitsForEmptyEventLoop) {
return process.prependOnceListener('beforeExit', () => tryProcessEvents(handler))
}
}
}

function initError(err) {
return postError(`${RUNTIME_PATH}/init/error`, err)
}

async function nextInvocation() {
const res = await request({ path: `${RUNTIME_PATH}/invocation/next` })

if (res.statusCode !== 200) {
throw new Error(`Unexpected /invocation/next response: ${JSON.stringify(res)}`)
}

if (res.headers['lambda-runtime-trace-id']) {
process.env._X_AMZN_TRACE_ID = res.headers['lambda-runtime-trace-id']
} else {
delete process.env._X_AMZN_TRACE_ID
}

const deadlineMs = +res.headers['lambda-runtime-deadline-ms']

const context = {
awsRequestId: res.headers['lambda-runtime-aws-request-id'],
invokedFunctionArn: res.headers['lambda-runtime-invoked-function-arn'],
logGroupName: AWS_LAMBDA_LOG_GROUP_NAME,
logStreamName: AWS_LAMBDA_LOG_STREAM_NAME,
functionName: AWS_LAMBDA_FUNCTION_NAME,
functionVersion: AWS_LAMBDA_FUNCTION_VERSION,
memoryLimitInMB: AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
getRemainingTimeInMillis: () => deadlineMs - Date.now(),
callbackWaitsForEmptyEventLoop: true,
}

if (res.headers['lambda-runtime-client-context']) {
context.clientContext = JSON.parse(res.headers['lambda-runtime-client-context'])
}

if (res.headers['lambda-runtime-cognito-identity']) {
context.identity = JSON.parse(res.headers['lambda-runtime-cognito-identity'])
}

const event = JSON.parse(res.body)

return { event, context }
}

async function invokeResponse(result, context) {
const res = await request({
method: 'POST',
path: `${RUNTIME_PATH}/invocation/${context.awsRequestId}/response`,
body: JSON.stringify(result === undefined ? null : result),
})
if (res.statusCode !== 202) {
throw new Error(`Unexpected /invocation/response response: ${JSON.stringify(res)}`)
}
}

function invokeError(err, context) {
return postError(`${RUNTIME_PATH}/invocation/${context.awsRequestId}/error`, err)
}

async function postError(path, err) {
const lambdaErr = toLambdaErr(err)
const res = await request({
method: 'POST',
path,
headers: {
'Content-Type': 'application/json',
'Lambda-Runtime-Function-Error-Type': lambdaErr.errorType,
},
body: JSON.stringify(lambdaErr),
})
if (res.statusCode !== 202) {
throw new Error(`Unexpected ${path} response: ${JSON.stringify(res)}`)
}
}

async function getHandler() {
const moduleParts = _HANDLER.split('.')
if (moduleParts.length !== 2) {
throw new Error(`Bad handler ${_HANDLER}`)
}

const [modulePath, handlerName] = moduleParts
const {type: moduleLoaderType, ext} = await getModuleLoaderType(`${LAMBDA_TASK_ROOT}/${modulePath}`)

// Let any errors here be thrown as-is to aid debugging
const importPath = `${LAMBDA_TASK_ROOT}/${modulePath}.${ext}`
const module = moduleLoaderType === 'module' ? await import(importPath) : createRequire(import.meta.url)(importPath)

const userHandler = module[handlerName]

if (userHandler === undefined) {
throw new Error(`Handler '${handlerName}' missing on module '${modulePath}'`)
} else if (typeof userHandler !== 'function') {
throw new Error(`Handler '${handlerName}' from '${modulePath}' is not a function`)
}

return (event, context) => new Promise((resolve, reject) => {
const callback = (err, data) => {
context[CALLBACK_USED] = true
if(err) {
reject(err)
} else {
resolve(data)
}
}

let result
try {
result = userHandler(event, context, callback)
} catch (e) {
return reject(e)
}
if (typeof result === 'object' && result != null && typeof result.then === 'function') {
result.then(resolve, reject)
}
})
}

/**
* @param {string} modulePath path to executeable with no file extention
* @returns {Promise<{
* type: 'commonjs' | 'module',
* ext: 'mjs' | 'cjs' | 'js'
* }>} loader type and extention for loading module
*/
async function getModuleLoaderType(modulePath) {
//do all promises async so they dont have to wait on eachother
const [typ, mjsExist, cjsExist] = await Promise.all([
getPackageJsonType(modulePath),
fileExists(modulePath + '.mjs'),
fileExists(modulePath + '.cjs')
])

//priority here is basically cjs -> mjs -> js
//pjson.type defaults to commonjs so always check if 'module' first
if(mjsExist && cjsExist) {
if(typ === 'module') { return {type: 'module', ext: 'mjs'} }
return {type: 'commonjs', ext: 'cjs'}
}
//only one of these exist if any
if(mjsExist) { return {type: 'module', ext: 'mjs'} }
if(cjsExist) { return {type: 'commonjs', ext: 'cjs'} }
//js is the only file, determine type based on pjson
if(typ === 'module') { return {type: 'module', ext: 'js'} }
return {type: 'commonjs', ext: 'js'}
}

async function fileExists(fullPath) {
try {
await stat(fullPath)
return true
} catch {
return false
}
}

/**
* @param {string} modulePath path to executeable with no file extention
* @returns {Promise<'module' | 'commonjs'>}
*/
async function getPackageJsonType(modulePath) {
//try reading pjson until we reach root. i.e. '/' !== path.dirname('/')
//there is probably a way to make it search in parallel, returning the first match in the hierarchy, but it seems more trouble than its worth
for(let dir = path.dirname(modulePath); dir !== path.dirname(dir); dir = path.dirname(dir)) {
try {
const {type} = JSON.parse(await readFile(dir + path.sep + 'package.json', 'utf-8'))
return type || 'commonjs'
} catch {
//do nothing
}
}

//if we reach root, return empty pjson
return 'commonjs'
}

function request(options) {
options.host = HOST
options.port = PORT

return new Promise((resolve, reject) => {
const req = http.request(options, res => {
const bufs = []
res.on('data', data => bufs.push(data))
res.on('end', () => resolve({
statusCode: res.statusCode,
headers: res.headers,
body: Buffer.concat(bufs).toString(),
}))
res.on('error', reject)
})
req.on('error', reject)
req.end(options.body)
})
}

function toLambdaErr(err) {
const { name, message, stack } = err
return {
errorType: name || typeof err,
errorMessage: message || ('' + err),
stackTrace: (stack || '').split('\n').slice(1),
}
}
6 changes: 6 additions & 0 deletions v14.x/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/sh

. ./config.sh

docker build --build-arg NODE_VERSION -t node-provided-lambda-v14.x .
docker run --rm -v "$PWD":/app node-provided-lambda-v14.x cp /tmp/layer.zip /app/
11 changes: 11 additions & 0 deletions v14.x/check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/bash

. ./config.sh

REGIONS="$(aws ssm get-parameters-by-path --path /aws/service/global-infrastructure/services/lambda/regions \
--query 'Parameters[].Value' --output text | tr '[:blank:]' '\n' | grep -v -e ^cn- -e ^us-gov- | sort -r)"

for region in $REGIONS; do
aws lambda list-layer-versions --region $region --layer-name $LAYER_NAME \
--query 'LayerVersions[*].[LayerVersionArn]' --output text
done
2 changes: 2 additions & 0 deletions v14.x/config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export LAYER_NAME=nodejs14
export NODE_VERSION=14.15.1
18 changes: 18 additions & 0 deletions v14.x/esm-loader-hook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {pathToFileURL} from "url"
import path from "path"

const searchPaths = process.env.NODE_PATH.split(path.delimiter).map(path => pathToFileURL(path).href)

export async function resolve(specifier, context, defaultResolve) {
try {
return defaultResolve(specifier, context, defaultResolve)
} catch {}

for (const parentURL of searchPaths) {
try {
return defaultResolve(specifier, {...context, parentURL}, defaultResolve)
} catch {}
}

throw new Error(`Cannot find package '${specifier}': attempted to import from paths [${[context.parentURL, ...searchPaths].join(', ')}]`)
}
Binary file added v14.x/layer.zip
Binary file not shown.
5 changes: 5 additions & 0 deletions v14.x/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "node-custom-lambda-v14.x",
"version": "1.0.0",
"type": "module"
}
Loading