diff --git a/src/index.ts b/src/index.ts index 1eb51f25..96f54dbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { shouldServe, glob, download, - Lambda, + NodejsLambda, BuildV3, PrepareCache, getNodeVersion @@ -116,7 +116,7 @@ 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, @@ -124,7 +124,10 @@ export const build: BuildV3 = async ({ // 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, diff --git a/src/launchers/builtin.ts b/src/launchers/builtin.ts index 1b53d3cc..ba43fea7 100644 --- a/src/launchers/builtin.ts +++ b/src/launchers/builtin.ts @@ -151,7 +151,7 @@ function whenPortOpens(port: number, attempts: number): Promise { }); } -async function launcher(event: Event): Promise { +async function launcher(event: unknown): Promise { const awsRequest = normalizeEvent(event); const input = await transformFromAwsRequest(awsRequest); const output = await query(input); diff --git a/src/launchers/cgi.ts b/src/launchers/cgi.ts index ec0b9c67..6d1186a3 100644 --- a/src/launchers/cgi.ts +++ b/src/launchers/cgi.ts @@ -187,7 +187,7 @@ function query({ entrypoint, path, host, headers, method, body }: PhpInput): Pro }) } -async function launcher(event: Event): Promise { +async function launcher(event: unknown): Promise { const awsRequest = normalizeEvent(event); const input = await transformFromAwsRequest(awsRequest); const output = await query(input); diff --git a/src/launchers/cli.ts b/src/launchers/cli.ts index b0c235fb..d793a173 100644 --- a/src/launchers/cli.ts +++ b/src/launchers/cli.ts @@ -90,7 +90,7 @@ function query({ entrypoint, body }: PhpInput): Promise { }) } -async function launcher(event: Event): Promise { +async function launcher(event: unknown): Promise { const awsRequest = normalizeEvent(event); const input = await transformFromAwsRequest(awsRequest); const output = await query(input); diff --git a/src/launchers/helpers.ts b/src/launchers/helpers.ts index e278a1e2..32724033 100644 --- a/src/launchers/helpers.ts +++ b/src/launchers/helpers.ts @@ -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 { + 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({ diff --git a/src/types.d.ts b/src/types.d.ts index c58de0c6..792cf61a 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -21,7 +21,7 @@ interface AwsRequest { path: string, host: string, headers: Headers, - body: string, + body: Buffer | undefined, } interface AwsResponse { @@ -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, @@ -64,7 +46,7 @@ interface PhpInput { host: string, method: string, headers: Headers, - body: string, + body: Buffer | undefined, } interface PhpOutput { diff --git a/test/spec/launchers/builtin.js b/test/spec/launchers/builtin.js new file mode 100644 index 00000000..cf202ca9 --- /dev/null +++ b/test/spec/launchers/builtin.js @@ -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); +}); diff --git a/test/spec/launchers/cgi.js b/test/spec/launchers/cgi.js index 2328b6f4..005820e9 100644 --- a/test/spec/launchers/cgi.js +++ b/test/spec/launchers/cgi.js @@ -1,4 +1,5 @@ const cgi = require('./../../../dist/launchers/cgi'); +const { getUserDir } = require('./../../../dist/launchers/helpers'); test('create CGI request', () => { const request = { @@ -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'); diff --git a/test/spec/launchers/helpers.js b/test/spec/launchers/helpers.js new file mode 100644 index 00000000..4e08c86f --- /dev/null +++ b/test/spec/launchers/helpers.js @@ -0,0 +1,131 @@ +const path = require('path'); +const { + getUserDir, getPhpDir, normalizeEvent, transformFromAwsRequest, transformToAwsResponse, +} = require('../../../dist/launchers/helpers'); + +const binary = [0, 65, 128, 255]; +const request = { method: 'POST', path: '/submit?a=1&b=%2F', headers: { host: 'example.com' } }; +const direct = (body, extra = {}) => ({ + httpMethod: request.method, path: request.path, headers: request.headers, body, ...extra, +}); +const invoke = (body, extra = {}) => ({ + Action: 'Invoke', body: JSON.stringify({ ...request, body, ...extra }), +}); + +for (const [name, event] of [['direct', direct], ['Invoke', invoke]]) { + describe(`${name} normalization`, () => { + test.each([ + ['UTF-8', 'héllo', {}, Buffer.from('héllo')], + ['bytes', binary, {}, Buffer.from(binary)], + ['empty string', '', {}, Buffer.alloc(0)], + ['empty array', [], {}, Buffer.alloc(0)], + ['null', null, {}, undefined], + ['missing', undefined, {}, undefined], + ['base64', Buffer.from(binary).toString('base64'), + name === 'direct' ? { isBase64Encoded: true } : { encoding: 'base64' }, Buffer.from(binary)], + ['decoded bytes with base64 flag', binary, + name === 'direct' ? { isBase64Encoded: true } : { encoding: 'base64' }, Buffer.from(binary)], + ])('%s body', (_label, body, extra, expected) => { + const normalized = normalizeEvent(event(body, extra)); + expect(normalized).toEqual({ ...request, host: 'example.com', body: expected }); + expect(normalized.path).toBe(request.path); + }); + + test.each([{}, 1, true, [-1], [256], [1.5], ['65'], [null]])('rejects invalid body %j', body => { + expect(() => normalizeEvent(event(body))).toThrow('Request body'); + }); + + test('host fallback is case insensitive and prefers forwarded host', () => { + const normalized = normalizeEvent(event(null, { + headers: { Host: 'internal', 'X-Forwarded-Host': ['public.example', 'other'] }, + })); + expect(normalized.host).toBe('public.example'); + }); + + test('explicit host wins and missing headers are accepted', () => { + expect(normalizeEvent(event(null, { host: 'explicit', headers: null })).host).toBe('explicit'); + expect(normalizeEvent(event(null, { headers: undefined })).headers).toEqual({}); + }); + }); +} + +test('preserves Buffers, including when base64 flag is set', () => { + const body = Buffer.from(binary); + expect(normalizeEvent(direct(body)).body).toBe(body); + expect(normalizeEvent(direct(body, { isBase64Encoded: true })).body).toBe(body); +}); + +test('rejects sparse byte arrays rather than converting holes to zeros', () => { + expect(() => normalizeEvent(direct(new Array(2)))).toThrow('Request body'); +}); + +test.each([NaN, Infinity, -Infinity])('rejects non-finite bytes %j', byte => { + expect(() => normalizeEvent(direct([byte]))).toThrow('Request body'); +}); + +test.each([false, null, undefined])('direct base64 flag %j keeps text as UTF-8', isBase64Encoded => { + expect(normalizeEvent(direct('héllo', { isBase64Encoded })).body).toEqual(Buffer.from('héllo')); +}); + +test.each([null, [], 'text', {}, { httpMethod: 1, path: '/' }, { httpMethod: 'GET', path: 1 }])( + 'rejects malformed event %j', event => { + expect(() => normalizeEvent(event)).toThrow(TypeError); + } +); + +test.each([ + { headers: [] }, { headers: { host: 123 } }, { headers: { host: [null] } }, + { host: {} }, { isBase64Encoded: 'true' }, +])('rejects invalid request fields %j', fields => { + expect(() => normalizeEvent(direct(null, fields))).toThrow(TypeError); +}); + +test.each([null, {}, 'null', '[]', '"text"'])('rejects malformed Invoke envelope %j', body => { + expect(() => normalizeEvent({ Action: 'Invoke', body })).toThrow(TypeError); +}); + +test('rejects malformed Invoke JSON', () => { + expect(() => normalizeEvent({ Action: 'Invoke', body: '{' })).toThrow(SyntaxError); +}); + +test.each([undefined, null, 'utf8'])('accepts legacy UTF-8 encoding %j', encoding => { + expect(normalizeEvent(invoke('héllo', { encoding })).body).toEqual(Buffer.from('héllo')); +}); + +test('rejects unsupported legacy encoding even for an empty body', () => { + expect(() => normalizeEvent(invoke('', { encoding: 'hex' }))).toThrow('Unsupported encoding'); +}); + +describe('task root', () => { + const originalRoot = process.env.LAMBDA_TASK_ROOT; + const originalEntrypoint = process.env.NOW_ENTRYPOINT; + afterEach(() => { + if (originalRoot === undefined) delete process.env.LAMBDA_TASK_ROOT; + else process.env.LAMBDA_TASK_ROOT = originalRoot; + if (originalEntrypoint === undefined) delete process.env.NOW_ENTRYPOINT; + else process.env.NOW_ENTRYPOINT = originalEntrypoint; + }); + + test.each([undefined, '', '/custom/task'])('root %j resolves PHP and user paths', async root => { + if (root === undefined) delete process.env.LAMBDA_TASK_ROOT; + else process.env.LAMBDA_TASK_ROOT = root; + const expectedRoot = root || '/var/task'; + expect(getUserDir()).toBe(path.join(expectedRoot, 'user')); + expect(getPhpDir()).toBe(path.join(expectedRoot, 'php')); + process.env.NOW_ENTRYPOINT = 'api/index.php'; + const normalized = normalizeEvent(direct(Buffer.from(binary))); + const input = await transformFromAwsRequest(normalized); + expect(input.entrypoint).toBe(path.join(expectedRoot, 'user', 'api/index.php')); + expect(input.uri).toBe('example.com' + request.path); + expect(input.body).toBe(normalized.body); + expect(process.env.LAMBDA_TASK_ROOT).toBe(root); + }); +}); + +test('preserves response cookies and binary bytes', () => { + const headers = { 'set-cookie': ['a=1; HttpOnly', 'b=2; Secure'] }; + const response = transformToAwsResponse({ statusCode: 200, headers, body: Buffer.from(binary) }); + expect(response.headers).toBe(headers); + expect(response.encoding).toBe('base64'); + expect(Buffer.from(response.body, response.encoding)).toEqual(Buffer.from(binary)); +}); diff --git a/test/spec/node-launcher-output.js b/test/spec/node-launcher-output.js new file mode 100644 index 00000000..ee1cd661 --- /dev/null +++ b/test/spec/node-launcher-output.js @@ -0,0 +1,68 @@ +const fs = require('fs'); +const path = require('path'); +const { createRequire } = require('module'); +const vm = require('vm'); +const builder = require('../../dist/index'); +const utils = require('../../dist/utils'); +const { FileBlob } = require('@vercel/build-utils'); + +// Optional offline integration with the real CLI serializer, without executing +// the CLI entrypoint, reading authentication, or contacting Vercel. Install a CLI +// separately and set VERCEL_CLI_ENTRY to its dist/index.js. Tested with 50.4.5. +const cliEntry = process.env.VERCEL_CLI_ENTRY; +const integrationTest = cliEntry ? test : test.skip; + +integrationTest('actual CLI BuildV3 writer preserves launcher configuration', async () => { + const entry = path.resolve(cliEntry); + const cliRequire = createRequire(entry); + const source = fs.readFileSync(entry, 'utf8'); + const start = source.indexOf('async function writeBuildResult(args2)'); + const end = source.indexOf('var import_fs_extra13,', start); + if (start < 0 || end < 0) throw new Error('Unsupported CLI bundle layout; update the integration loader'); + // Load the unchanged writer functions and supply their real dependencies. + // This intentionally does not substitute JSON.stringify for the CLI writer. + const buildUtils = cliRequire('@vercel/build-utils'); + const writeBuildResult = vm.runInNewContext( + source.slice(start, end) + '\nwriteBuildResult;', + { + ...buildUtils, + join12: path.join, relative5: path.relative, resolve6: path.resolve, + basename6: path.basename, dirname6: path.dirname, extname: path.extname, + normalize2: path.posix.normalize, normalizePath2: buildUtils.normalizePath, + import_fs_extra13: { default: cliRequire('fs-extra') }, + }, + { filename: entry } + ); + const workPath = fs.mkdtempSync(path.join(__dirname, 'output-fixture-')); + const outputDir = path.join(workPath, '.vercel', 'output'); + const phpFiles = jest.spyOn(utils, 'getPhpFiles').mockResolvedValue({ + 'php/php': new FileBlob({ data: 'PHP fixture' }), + }); + try { + fs.writeFileSync(path.join(workPath, 'index.php'), ' { + // PHP binary collection is outside this regression and currently fails on Windows. + // Keep the real builder, Node version selection, file collection and Lambda classes. + const phpFiles = jest.spyOn(utils, 'getPhpFiles').mockResolvedValue({ + 'php/php': new FileBlob({ data: 'PHP fixture' }), + }); + const workPath = fs.mkdtempSync(path.join(__dirname, 'launcher-fixture-')); + try { + fs.writeFileSync(path.join(workPath, 'index.php'), ' { + const output = new Lambda({ + files: {}, runtime: 'nodejs22.x', handler: 'launcher.js', + launcherType: 'Nodejs', awsLambdaHandler: 'launcher.launcher', + }); + expect(output).not.toHaveProperty('launcherType'); + expect(output).not.toHaveProperty('awsLambdaHandler'); +});