Skip to content
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
9 changes: 6 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
shouldServe,
glob,
download,
Lambda,
NodejsLambda,
BuildV3,
PrepareCache,
getNodeVersion
Expand Down Expand Up @@ -116,15 +116,18 @@ export const build: BuildV3 = async ({
console.log('🐘 Creating lambda');
const nodeVersion = await getNodeVersion(workPath);

const lambda = new Lambda({
const lambda = new NodejsLambda({
files: {
// Located at /var/task/user
...harverstedFiles,
// Located at /var/task/php (php bins + ini + modules)
// Located at /var/task/lib (shared libs)
...runtimeFiles
},
handler: 'launcher.launcher',
handler: 'launcher.js',
awsLambdaHandler: 'launcher.launcher',
shouldAddHelpers: false,
shouldAddSourcemapSupport: false,
runtime: nodeVersion.runtime,
environment: {
NOW_ENTRYPOINT: entrypoint,
Expand Down
2 changes: 1 addition & 1 deletion src/launchers/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function whenPortOpens(port: number, attempts: number): Promise<void> {
});
}

async function launcher(event: Event): Promise<AwsResponse> {
async function launcher(event: unknown): Promise<AwsResponse> {
const awsRequest = normalizeEvent(event);
const input = await transformFromAwsRequest(awsRequest);
const output = await query(input);
Expand Down
2 changes: 1 addition & 1 deletion src/launchers/cgi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ function query({ entrypoint, path, host, headers, method, body }: PhpInput): Pro
})
}

async function launcher(event: Event): Promise<AwsResponse> {
async function launcher(event: unknown): Promise<AwsResponse> {
const awsRequest = normalizeEvent(event);
const input = await transformFromAwsRequest(awsRequest);
const output = await query(input);
Expand Down
2 changes: 1 addition & 1 deletion src/launchers/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function query({ entrypoint, body }: PhpInput): Promise<PhpOutput> {
})
}

async function launcher(event: Event): Promise<AwsResponse> {
async function launcher(event: unknown): Promise<AwsResponse> {
const awsRequest = normalizeEvent(event);
const input = await transformFromAwsRequest(awsRequest);
const output = await query(input);
Expand Down
106 changes: 72 additions & 34 deletions src/launchers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,87 @@
import { join as pathJoin } from 'path';

export const getUserDir = (): string => pathJoin(process.env.LAMBDA_TASK_ROOT || '/', 'user');
export const getPhpDir = (): string => pathJoin(process.env.LAMBDA_TASK_ROOT || '/', 'php');
// The Node bootstrap may omit LAMBDA_TASK_ROOT. PHP's bundled ini and shared
// library paths also use /var/task, so keep the fallback consistent with them.
const getTaskRoot = (): string => process.env.LAMBDA_TASK_ROOT || '/var/task';
export const getUserDir = (): string => pathJoin(getTaskRoot(), 'user');
export const getPhpDir = (): string => pathJoin(getTaskRoot(), 'php');
export const isDev = (): boolean => process.env.NOW_PHP_DEV === '1';

export function normalizeEvent(event: Event): AwsRequest {
if (event.Action === 'Invoke') {
const invokeEvent = JSON.parse(event.body);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string');
}

const {
method, path, host, headers = {}, encoding,
}: InvokedEvent = invokeEvent;
function isByteArray(value: unknown): value is number[] {
return Array.isArray(value) && Array.from(value).every(item =>
typeof item === 'number' && Number.isInteger(item) && item >= 0 && item <= 255
);
}

let { body } = invokeEvent;
function normalizeBody(body: unknown, encoding: unknown): Buffer | undefined {
if (encoding !== undefined && encoding !== null && encoding !== 'utf8' && encoding !== 'base64') {
throw new Error(`Unsupported encoding: ${encoding}`);
}
if (body === undefined || body === null) return undefined;
// Buffers and byte arrays are already decoded, even if the encoding flag is set.
if (Buffer.isBuffer(body)) return body;
if (isByteArray(body)) return Buffer.from(body);
if (typeof body === 'string') {
return Buffer.from(body, encoding === 'base64' ? 'base64' : 'utf8');
}
throw new TypeError('Request body must be a string, Buffer, or array of bytes');
}

if (body) {
if (encoding === 'base64') {
body = Buffer.from(body, encoding);
} else if (encoding === undefined) {
body = Buffer.from(body);
} else {
throw new Error(`Unsupported encoding: ${encoding}`);
}
function normalizeHeaders(value: unknown): Headers {
if (value === undefined || value === null) return {};
if (!isRecord(value)) throw new TypeError('Request headers must be an object');
const headers: Headers = {};
for (const [name, header] of Object.entries(value)) {
if (header !== undefined && typeof header !== 'string' && !isStringArray(header)) {
throw new TypeError(`Invalid request header: ${name}`);
}
Object.defineProperty(headers, name, { value: header, enumerable: true });
}
return headers;
}

function getHeader(headers: Headers, name: string): string | undefined {
const key = Object.keys(headers).find(key => key.toLowerCase() === name);
const value = key === undefined ? undefined : headers[key];
return Array.isArray(value) ? value[0] : value;
}

return {
method,
path,
host,
headers,
body,
};
export function normalizeEvent(event: unknown): AwsRequest {
if (!isRecord(event)) throw new TypeError('Request event must be an object');
const isInvoke = event.Action === 'Invoke';
let request = event;
if (isInvoke) {
if (typeof event.body !== 'string') throw new TypeError('Invoke body must be JSON text');
const parsed: unknown = JSON.parse(event.body);
if (!isRecord(parsed)) throw new TypeError('Invoke body must contain a request object');
request = parsed;
}

const {
httpMethod: method, path, host, headers = {}, body,
} = event;
const method = isInvoke ? request.method : request.httpMethod;
const { path } = request;
if (typeof method !== 'string' || typeof path !== 'string') {
throw new TypeError('Request method and path must be strings');
}
if (request.host !== undefined && request.host !== null && typeof request.host !== 'string') {
throw new TypeError('Request host must be a string');
}
const headers = normalizeHeaders(request.headers);
const host = request.host || getHeader(headers, 'x-forwarded-host') || getHeader(headers, 'host') || '';

return {
method,
path,
host,
headers,
body,
};
if (!isInvoke && request.isBase64Encoded !== undefined &&
request.isBase64Encoded !== null && typeof request.isBase64Encoded !== 'boolean') {
throw new TypeError('isBase64Encoded must be a boolean');
}
const encoding = isInvoke ? request.encoding : request.isBase64Encoded === true ? 'base64' : 'utf8';
return { method, path, host, headers, body: normalizeBody(request.body, encoding) };
}

export async function transformFromAwsRequest({
Expand Down
22 changes: 2 additions & 20 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface AwsRequest {
path: string,
host: string,
headers: Headers,
body: string,
body: Buffer | undefined,
}

interface AwsResponse {
Expand All @@ -31,24 +31,6 @@ interface AwsResponse {
encoding?: string
}

interface Event {
Action: string,
body: string,
httpMethod: string,
path: string,
host: string,
headers: Headers,
encoding: string | undefined | null,
}

interface InvokedEvent {
method: string,
path: string,
host: string,
headers: Headers,
encoding: string | undefined | null,
}

interface CgiInput {
entrypoint: string,
path: string,
Expand All @@ -64,7 +46,7 @@ interface PhpInput {
host: string,
method: string,
headers: Headers,
body: string,
body: Buffer | undefined,
}

interface PhpOutput {
Expand Down
101 changes: 101 additions & 0 deletions test/spec/launchers/builtin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const http = require('http');
const path = require('path');
const { EventEmitter } = require('events');
const childProcess = require('child_process');

// Replace only the unavailable PHP process with a local HTTP server. The launcher
// still uses its real readiness probe, request forwarding and response conversion.
let upstream;
let spawn;
let launcher;
let received;
let originalEnv;
let exitListeners;

beforeAll(async () => {
originalEnv = { ...process.env };
exitListeners = process.listeners('exit');
delete process.env.LAMBDA_TASK_ROOT;
process.env.NOW_ENTRYPOINT = 'api/index.php';
process.env.NOW_PHP_DEV = '0';
upstream = http.createServer((req, res) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
received = { path: req.url, headers: req.headers, method: req.method, body: Buffer.concat(chunks) };
res.writeHead(200, { 'set-cookie': ['session=abc; HttpOnly', 'csrf=xyz'], 'content-type': 'application/octet-stream' });
res.end(Buffer.from([0, 128, 255]));
});
});
await new Promise((resolve, reject) => {
upstream.once('error', reject);
upstream.listen(3000, '127.0.0.1', resolve);
});
spawn = jest.spyOn(childProcess, 'spawn').mockImplementation(() => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
return child;
});
launcher = require('../../../dist/launchers/builtin').launcher;
});

afterAll(async () => {
if (spawn) spawn.mockRestore();
for (const listener of process.listeners('exit')) {
if (!exitListeners.includes(listener)) process.removeListener('exit', listener);
}
process.env = originalEnv;
if (upstream) {
upstream.closeAllConnections();
await new Promise(resolve => upstream.close(resolve));
}
});

test('direct GET keeps query and cookies with the missing task-root fallback', async () => {
const response = await launcher({
httpMethod: 'GET', path: '/login?return=%2Fhome', body: null,
headers: { host: 'example.com', cookie: 'session=abc' },
});
expect(received).toMatchObject({
method: 'GET', path: '/login?return=%2Fhome', body: Buffer.alloc(0),
headers: { cookie: 'session=abc', host: 'example.com' },
});
expect(spawn).toHaveBeenCalledTimes(1);
const [command, args, options] = spawn.mock.calls[0];
expect(command).toBe('php');
expect(args).toEqual([
'-c', 'php.ini', '-S', '0.0.0.0:3000', '-t', path.join('/var/task', 'user'),
path.join('/var/task', 'user', 'api/index.php'),
]);
expect(options.cwd).toBe(path.join('/var/task', 'php'));
expect(options.env.PATH).toBe(`${path.join('/var/task', 'php')}:${process.env.PATH}`);
expect(options.env.LD_LIBRARY_PATH).toBe(`/var/task/lib:${process.env.LD_LIBRARY_PATH}`);
expect(options.env).not.toHaveProperty('LAMBDA_TASK_ROOT');
expect(response.headers['set-cookie']).toEqual(['session=abc; HttpOnly', 'csrf=xyz']);
expect(response.encoding).toBe('base64');
expect(Buffer.from(response.body, 'base64')).toEqual(Buffer.from([0, 128, 255]));
});

test.each([
['direct bytes', { httpMethod: 'POST', body: [0, 65, 255] }, Buffer.from([0, 65, 255])],
['direct base64', { httpMethod: 'POST', body: 'AEH/', isBase64Encoded: true }, Buffer.from([0, 65, 255])],
['direct Buffer', { httpMethod: 'POST', body: Buffer.from([0, 65, 255]) }, Buffer.from([0, 65, 255])],
['legacy Invoke', { Action: 'Invoke', body: JSON.stringify({
method: 'POST', path: '/submit?q=1', headers: { host: 'example.com' }, body: 'AEH/', encoding: 'base64',
}) }, Buffer.from([0, 65, 255])],
['form', { httpMethod: 'POST', body: 'name=h%C3%A9llo', headers: {
host: 'example.com', 'content-type': 'application/x-www-form-urlencoded',
} }, Buffer.from('name=h%C3%A9llo')],
['multipart', { httpMethod: 'POST', body: '--boundary\r\nContent-Disposition: form-data; name="x"\r\n\r\nvalue\r\n--boundary--\r\n',
headers: { host: 'example.com', 'content-type': 'multipart/form-data; boundary=boundary' },
}, Buffer.from('--boundary\r\nContent-Disposition: form-data; name="x"\r\n\r\nvalue\r\n--boundary--\r\n')],
])('%s forwards bytes through the real HTTP client', async (_label, event, expected) => {
const response = await launcher({ path: '/submit?q=1', headers: { host: 'example.com' }, ...event });
expect(response.statusCode).toBe(200);
expect(received.method).toBe('POST');
expect(received.path).toBe('/submit?q=1');
expect(received.body).toEqual(expected);
expect(spawn).toHaveBeenCalledTimes(1);
});
5 changes: 3 additions & 2 deletions test/spec/launchers/cgi.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const cgi = require('./../../../dist/launchers/cgi');
const { getUserDir } = require('./../../../dist/launchers/helpers');

test('create CGI request', () => {
const request = {
Expand All @@ -11,8 +12,8 @@ test('create CGI request', () => {
process.env.CUSTOM_VALUE = "custom-value";
const { env } = cgi.createCGIReq(request);

expect(env).toHaveProperty("SERVER_ROOT", "/user");
expect(env).toHaveProperty("DOCUMENT_ROOT", "/user");
expect(env).toHaveProperty("SERVER_ROOT", getUserDir());
expect(env).toHaveProperty("DOCUMENT_ROOT", getUserDir());
expect(env).toHaveProperty("SERVER_NAME", request.host);
expect(env).toHaveProperty("SERVER_PORT", 443);
expect(env).toHaveProperty("HTTPS", 'On');
Expand Down
Loading