From 4a716ac76080e2f147e963e9fd700d878ccbeac7 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 24 Apr 2025 21:13:37 +0200 Subject: [PATCH 1/5] test: move serverless function invocation implementation into common js module shared by both in-process and sandboxed invocations --- tests/utils/{constants.ts => constants.mjs} | 0 tests/utils/fixture.ts | 112 +----------- tests/utils/helpers.ts | 15 +- tests/utils/index.ts | 1 - tests/utils/lambda-helpers.mjs | 181 ++++++++++++++++++++ tests/utils/sandbox-child.mjs | 128 +++----------- tests/utils/stream-to-buffer.ts | 14 -- 7 files changed, 213 insertions(+), 238 deletions(-) rename tests/utils/{constants.ts => constants.mjs} (100%) create mode 100644 tests/utils/lambda-helpers.mjs delete mode 100644 tests/utils/stream-to-buffer.ts diff --git a/tests/utils/constants.ts b/tests/utils/constants.mjs similarity index 100% rename from tests/utils/constants.ts rename to tests/utils/constants.mjs diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 3ab24f89c9..3c56167e44 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -2,11 +2,9 @@ import { assert, vi } from 'vitest' import { type NetlifyPluginConstants, type NetlifyPluginOptions } from '@netlify/build' import { bundle, serve } from '@netlify/edge-bundler' -import type { LambdaResponse } from '@netlify/serverless-functions-api/dist/lambda/response.js' import { zipFunctions } from '@netlify/zip-it-and-ship-it' import { execaCommand } from 'execa' import getPort from 'get-port' -import { execute } from 'lambda-local' import { spawn } from 'node:child_process' import { createWriteStream, existsSync } from 'node:fs' import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -16,7 +14,7 @@ import { env } from 'node:process' import { fileURLToPath } from 'node:url' import { v4 } from 'uuid' import { LocalServer } from './local-server.js' -import { streamToBuffer } from './stream-to-buffer.js' +import { loadAndInvokeFunctionImpl, type FunctionInvocationOptions } from './lambda-helpers.mjs' import { glob } from 'fast-glob' import { @@ -24,9 +22,8 @@ import { PluginContext, SERVER_HANDLER_NAME, } from '../../src/build/plugin-context.js' -import { BLOB_TOKEN } from './constants.js' +import { BLOB_TOKEN } from './constants.mjs' import { type FixtureTestContext } from './contexts.js' -import { createBlobContext } from './helpers.js' import { setNextVersionInFixture } from './next-version-helpers.mjs' const bootstrapURL = 'https://edge.netlify.com/bootstrap/index-combined.ts' @@ -339,117 +336,16 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) { ) } -const DEFAULT_FLAGS = {} -/** - * Execute the function with the provided parameters - * @param ctx - * @param options - */ export async function invokeFunction( ctx: FixtureTestContext, - options: { - /** - * The http method that is used for the invocation - * @default 'GET' - */ - httpMethod?: string - /** - * The relative path that should be requested - * @default '/' - */ - url?: string - /** The headers used for the invocation*/ - headers?: Record - /** The body that is used for the invocation */ - body?: unknown - /** Environment variables that should be set during the invocation */ - env?: Record - /** Feature flags that should be set during the invocation */ - flags?: Record - } = {}, + options: FunctionInvocationOptions = {}, ) { - const { httpMethod, headers, flags, url, env } = options // now for the execution set the process working directory to the dist entry point const cwdMock = vi .spyOn(process, 'cwd') .mockReturnValue(join(ctx.functionDist, SERVER_HANDLER_NAME)) try { - const { handler } = await import( - join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs') - ) - - // The environment variables available during execution - const environment = { - NODE_ENV: 'production', - NETLIFY_BLOBS_CONTEXT: createBlobContext(ctx), - ...(env || {}), - } - - const envVarsToRestore = {} - - // We are not using lambda-local's environment variable setting because it cleans up - // environment vars to early (before stream is closed) - Object.keys(environment).forEach(function (key) { - if (typeof process.env[key] !== 'undefined') { - envVarsToRestore[key] = process.env[key] - } - process.env[key] = environment[key] - }) - - let resolveInvocation, rejectInvocation - const invocationPromise = new Promise((resolve, reject) => { - resolveInvocation = resolve - rejectInvocation = reject - }) - - const response = (await execute({ - event: { - headers: headers || {}, - httpMethod: httpMethod || 'GET', - rawUrl: new URL(url || '/', 'https://example.netlify').href, - flags: flags ?? DEFAULT_FLAGS, - }, - lambdaFunc: { handler }, - timeoutMs: 4_000, - onInvocationEnd: (error) => { - // lambda-local resolve promise return from execute when response is closed - // but we should wait for tracked background work to finish - // before resolving the promise to allow background work to finish - if (error) { - rejectInvocation(error) - } else { - resolveInvocation() - } - }, - })) as LambdaResponse - - await invocationPromise - - const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce( - (prev, [key, value]) => ({ - ...prev, - [key]: value.length === 1 ? `${value}` : value.join(', '), - }), - response.headers || {}, - ) - - const bodyBuffer = await streamToBuffer(response.body) - - Object.keys(environment).forEach(function (key) { - if (typeof envVarsToRestore[key] !== 'undefined') { - process.env[key] = envVarsToRestore[key] - } else { - delete process.env[key] - } - }) - - return { - statusCode: response.statusCode, - bodyBuffer, - body: bodyBuffer.toString('utf-8'), - headers: responseHeaders, - isBase64Encoded: response.isBase64Encoded, - } + return await loadAndInvokeFunctionImpl(ctx, options) } finally { cwdMock.mockRestore() } diff --git a/tests/utils/helpers.ts b/tests/utils/helpers.ts index 34d9545dfd..b540aea7c3 100644 --- a/tests/utils/helpers.ts +++ b/tests/utils/helpers.ts @@ -8,8 +8,9 @@ import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { assert, vi } from 'vitest' -import { BLOB_TOKEN } from './constants' +import { BLOB_TOKEN } from './constants.mjs' import { type FixtureTestContext } from './contexts' +import { createBlobContext } from './lambda-helpers.mjs' /** * Generates a 24char deploy ID (this is validated in the blob storage so we cant use a uuidv4) @@ -26,18 +27,6 @@ export const generateRandomObjectID = () => { return objectId } -export const createBlobContext = (ctx: FixtureTestContext) => - Buffer.from( - JSON.stringify({ - edgeURL: `http://${ctx.blobStoreHost}`, - uncachedEdgeURL: `http://${ctx.blobStoreHost}`, - token: BLOB_TOKEN, - siteID: ctx.siteID, - deployID: ctx.deployID, - primaryRegion: 'us-test-1', - }), - ).toString('base64') - /** * Starts a new mock blob storage * @param ctx diff --git a/tests/utils/index.ts b/tests/utils/index.ts index 41cca1f32f..fe4aed72ed 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -1,3 +1,2 @@ export * from './helpers.js' export * from './mock-file-system.js' -export * from './stream-to-buffer.js' diff --git a/tests/utils/lambda-helpers.mjs b/tests/utils/lambda-helpers.mjs new file mode 100644 index 0000000000..cab3b2d773 --- /dev/null +++ b/tests/utils/lambda-helpers.mjs @@ -0,0 +1,181 @@ +// @ts-check + +// this is not TS file because it's used both directly inside test process +// as well as child process that lacks TS on-the-fly transpilation + +import { join } from 'node:path' +import { BLOB_TOKEN } from './constants.mjs' +import { execute as untypedExecute } from 'lambda-local' + +const SERVER_HANDLER_NAME = '___netlify-server-handler' + +/** + * @typedef {import('./contexts').FixtureTestContext} FixtureTestContext + * + * @typedef {Awaited>>} LambdaResult + * + * @typedef {Object} FunctionInvocationOptions + * @property {Record} [env] Environment variables that should be set during the invocation + * @property {string} [httpMethod] The http method that is used for the invocation. Defaults to 'GET' + * @property {string} [url] TThe relative path that should be requested. Defaults to '/' + * @property {Record} [headers] The headers used for the invocation + * @property {Record} [flags] Feature flags that should be set during the invocation + */ + +/** + * This is helper to get LambdaLocal's execute to actually provide result type instead of `unknown` + * Because jsdoc doesn't seem to have equivalent of `as` in TS and trying to assign `LambdaResult` type + * to return value of `execute` leading to `Type 'unknown' is not assignable to type 'LambdaResult'` + * error, this types it as `any` instead which allow to later type it as `LambdaResult`. + * @param {Parameters} args + * @returns {Promise} + */ +async function execute(...args) { + /** + * @type {any} + */ + const anyResult = await untypedExecute(...args) + + return anyResult +} + +/** + * @param {FixtureTestContext} ctx + */ +export const createBlobContext = (ctx) => + Buffer.from( + JSON.stringify({ + edgeURL: `http://${ctx.blobStoreHost}`, + uncachedEdgeURL: `http://${ctx.blobStoreHost}`, + token: BLOB_TOKEN, + siteID: ctx.siteID, + deployID: ctx.deployID, + primaryRegion: 'us-test-1', + }), + ).toString('base64') + +/** + * Converts a readable stream to a buffer + * @param {NodeJS.ReadableStream} stream + * @returns {Promise} + */ +function streamToBuffer(stream) { + /** + * @type {Buffer[]} + */ + const chunks = [] + + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + stream.on('error', (err) => reject(err)) + stream.on('end', () => resolve(Buffer.concat(chunks))) + }) +} + +/** + * @param {FixtureTestContext} ctx + * @param {Record} [env] + */ +function temporarilySetEnv(ctx, env) { + const environment = { + NODE_ENV: 'production', + NETLIFY_BLOBS_CONTEXT: createBlobContext(ctx), + ...(env || {}), + } + + const envVarsToRestore = {} + + // We are not using lambda-local's environment variable setting because it cleans up + // environment vars to early (before stream is closed) + Object.keys(environment).forEach(function (key) { + if (typeof process.env[key] !== 'undefined') { + envVarsToRestore[key] = process.env[key] + } + process.env[key] = environment[key] + }) + + return function restoreEnvironment() { + Object.keys(environment).forEach(function (key) { + if (typeof envVarsToRestore[key] !== 'undefined') { + process.env[key] = envVarsToRestore[key] + } else { + delete process.env[key] + } + }) + } +} + +const DEFAULT_FLAGS = {} + +/** + * @param {FixtureTestContext} ctx + * @param {FunctionInvocationOptions} options + */ +export async function loadAndInvokeFunctionImpl( + ctx, + { headers, httpMethod, flags, url, env } = {}, +) { + const { handler } = await import( + 'file:///' + join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs') + ) + + const restoreEnvironment = temporarilySetEnv(ctx, env) + + let resolveInvocation, rejectInvocation + const invocationPromise = new Promise((resolve, reject) => { + resolveInvocation = resolve + rejectInvocation = reject + }) + + const response = await execute({ + event: { + headers: headers || {}, + httpMethod: httpMethod || 'GET', + rawUrl: new URL(url || '/', 'https://example.netlify').href, + flags: flags ?? DEFAULT_FLAGS, + }, + lambdaFunc: { handler }, + timeoutMs: 4_000, + onInvocationEnd: (error) => { + // lambda-local resolve promise return from execute when response is closed + // but we should wait for tracked background work to finish + // before resolving the promise to allow background work to finish + if (error) { + rejectInvocation(error) + } else { + resolveInvocation() + } + }, + }) + + await invocationPromise + + if (!response) { + throw new Error('No response from lambda-local') + } + + const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce( + (prev, [key, value]) => ({ + ...prev, + [key]: value.length === 1 ? `${value}` : value.join(', '), + }), + response.headers || {}, + ) + + const bodyBuffer = await streamToBuffer(response.body) + + restoreEnvironment() + + return { + statusCode: response.statusCode, + bodyBuffer, + body: bodyBuffer.toString('utf-8'), + headers: responseHeaders, + isBase64Encoded: response.isBase64Encoded, + } +} + +/** + * @typedef {typeof loadAndInvokeFunctionImpl} InvokeFunction + * @typedef {Promise>>} InvokeFunctionResult + */ diff --git a/tests/utils/sandbox-child.mjs b/tests/utils/sandbox-child.mjs index 4439a667c3..ec5d781f1e 100644 --- a/tests/utils/sandbox-child.mjs +++ b/tests/utils/sandbox-child.mjs @@ -1,109 +1,33 @@ -import { Buffer } from 'node:buffer' -import { join } from 'node:path' +// @ts-check -import { execute, getLogger } from 'lambda-local' - -const SERVER_HANDLER_NAME = '___netlify-server-handler' -const BLOB_TOKEN = 'secret-token' +import { getLogger } from 'lambda-local' +import { loadAndInvokeFunctionImpl } from './lambda-helpers.mjs' getLogger().level = 'alert' -const createBlobContext = (ctx) => - Buffer.from( - JSON.stringify({ - edgeURL: `http://${ctx.blobStoreHost}`, - uncachedEdgeURL: `http://${ctx.blobStoreHost}`, - token: BLOB_TOKEN, - siteID: ctx.siteID, - deployID: ctx.deployID, - primaryRegion: 'us-test-1', - }), - ).toString('base64') - -function streamToBuffer(stream) { - const chunks = [] - - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) - stream.on('error', (err) => reject(err)) - stream.on('end', () => resolve(Buffer.concat(chunks))) - }) -} - -process.on('message', async (msg) => { - if (msg?.action === 'exit') { - process.exit(0) - } else if (msg?.action === 'invokeFunction') { - try { - const [ctx, options] = msg.args - const { httpMethod, headers, body, url, env } = options - - const { handler } = await import( - 'file:///' + join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs') - ) - - const environment = { - NODE_ENV: 'production', - NETLIFY_BLOBS_CONTEXT: createBlobContext(ctx), - ...(env || {}), - } - - const envVarsToRestore = {} - - // We are not using lambda-local's environment variable setting because it cleans up - // environment vars to early (before stream is closed) - Object.keys(environment).forEach(function (key) { - if (typeof process.env[key] !== 'undefined') { - envVarsToRestore[key] = process.env[key] +process.on( + 'message', + /** + * @param {any} msg + */ + async (msg) => { + if (msg?.action === 'exit') { + process.exit(0) + } else if (msg?.action === 'invokeFunction') { + try { + const [ctx, options] = msg.args + + const result = await loadAndInvokeFunctionImpl(ctx, options) + if (process.send) { + process.send({ + action: 'invokeFunctionResult', + result, + }) } - process.env[key] = environment[key] - }) - - const response = await execute({ - event: { - headers: headers || {}, - httpMethod: httpMethod || 'GET', - rawUrl: new URL(url || '/', 'https://example.netlify').href, - }, - lambdaFunc: { handler }, - timeoutMs: 4_000, - }) - - const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce( - (prev, [key, value]) => ({ - ...prev, - [key]: value.length === 1 ? `${value}` : value.join(', '), - }), - response.headers || {}, - ) - - const bodyBuffer = await streamToBuffer(response.body) - - Object.keys(environment).forEach(function (key) { - if (typeof envVarsToRestore[key] !== 'undefined') { - process.env[key] = envVarsToRestore[key] - } else { - delete process.env[key] - } - }) - - const result = { - statusCode: response.statusCode, - bodyBuffer, - body: bodyBuffer.toString('utf-8'), - headers: responseHeaders, - isBase64Encoded: response.isBase64Encoded, - } - - if (process.send) { - process.send({ - action: 'invokeFunctionResult', - result, - }) + } catch (e) { + console.log('error', e) + process.exit(1) } - } catch (e) { - console.log('error', e) - process.exit(1) } - } -}) + }, +) diff --git a/tests/utils/stream-to-buffer.ts b/tests/utils/stream-to-buffer.ts deleted file mode 100644 index 83c33f7818..0000000000 --- a/tests/utils/stream-to-buffer.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Converts a readable stream to a buffer - * @param stream Node.js Readable stream - * @returns - */ -export function streamToBuffer(stream: NodeJS.ReadableStream) { - const chunks: Buffer[] = [] - - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) - stream.on('error', (err) => reject(err)) - stream.on('end', () => resolve(Buffer.concat(chunks))) - }) -} From 70511b796aade21791912532751da8c5b0ff4bab Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 24 Apr 2025 21:22:06 +0200 Subject: [PATCH 2/5] test: set env before important to allow import-time conditional code paths --- tests/utils/lambda-helpers.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utils/lambda-helpers.mjs b/tests/utils/lambda-helpers.mjs index cab3b2d773..86e4e6b87d 100644 --- a/tests/utils/lambda-helpers.mjs +++ b/tests/utils/lambda-helpers.mjs @@ -115,12 +115,12 @@ export async function loadAndInvokeFunctionImpl( ctx, { headers, httpMethod, flags, url, env } = {}, ) { + const restoreEnvironment = temporarilySetEnv(ctx, env) + const { handler } = await import( 'file:///' + join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs') ) - const restoreEnvironment = temporarilySetEnv(ctx, env) - let resolveInvocation, rejectInvocation const invocationPromise = new Promise((resolve, reject) => { resolveInvocation = resolve From 6e83413ded89cb9967b46894e50ede1b9fa5cc1b Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 24 Apr 2025 21:57:20 +0200 Subject: [PATCH 3/5] test: split loading and invoking function --- tests/utils/fixture.ts | 5 +- tests/utils/lambda-helpers.mjs | 113 ++++++++++++++++++--------------- tests/utils/sandbox-child.mjs | 6 +- 3 files changed, 70 insertions(+), 54 deletions(-) diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 3c56167e44..890f17a33e 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -14,7 +14,7 @@ import { env } from 'node:process' import { fileURLToPath } from 'node:url' import { v4 } from 'uuid' import { LocalServer } from './local-server.js' -import { loadAndInvokeFunctionImpl, type FunctionInvocationOptions } from './lambda-helpers.mjs' +import { loadFunction, type FunctionInvocationOptions } from './lambda-helpers.mjs' import { glob } from 'fast-glob' import { @@ -345,7 +345,8 @@ export async function invokeFunction( .spyOn(process, 'cwd') .mockReturnValue(join(ctx.functionDist, SERVER_HANDLER_NAME)) try { - return await loadAndInvokeFunctionImpl(ctx, options) + const invokeFunctionImpl = await loadFunction(ctx, options) + return await invokeFunctionImpl(options) } finally { cwdMock.mockRestore() } diff --git a/tests/utils/lambda-helpers.mjs b/tests/utils/lambda-helpers.mjs index 86e4e6b87d..7252427b3d 100644 --- a/tests/utils/lambda-helpers.mjs +++ b/tests/utils/lambda-helpers.mjs @@ -20,6 +20,8 @@ const SERVER_HANDLER_NAME = '___netlify-server-handler' * @property {string} [url] TThe relative path that should be requested. Defaults to '/' * @property {Record} [headers] The headers used for the invocation * @property {Record} [flags] Feature flags that should be set during the invocation + * + * @typedef {Pick} LoadFunctionOptions */ /** @@ -109,73 +111,84 @@ const DEFAULT_FLAGS = {} /** * @param {FixtureTestContext} ctx - * @param {FunctionInvocationOptions} options + * @param {LoadFunctionOptions} options */ -export async function loadAndInvokeFunctionImpl( - ctx, - { headers, httpMethod, flags, url, env } = {}, -) { +export async function loadFunction(ctx, { env } = {}) { const restoreEnvironment = temporarilySetEnv(ctx, env) const { handler } = await import( 'file:///' + join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs') ) - let resolveInvocation, rejectInvocation - const invocationPromise = new Promise((resolve, reject) => { - resolveInvocation = resolve - rejectInvocation = reject - }) + /** + * @param {FunctionInvocationOptions} options + */ + async function invokeFunction({ headers, httpMethod, flags, url, env: invokeEnv } = {}) { + const restoreEnvironment = temporarilySetEnv(ctx, { + ...env, + ...invokeEnv, + }) - const response = await execute({ - event: { - headers: headers || {}, - httpMethod: httpMethod || 'GET', - rawUrl: new URL(url || '/', 'https://example.netlify').href, - flags: flags ?? DEFAULT_FLAGS, - }, - lambdaFunc: { handler }, - timeoutMs: 4_000, - onInvocationEnd: (error) => { - // lambda-local resolve promise return from execute when response is closed - // but we should wait for tracked background work to finish - // before resolving the promise to allow background work to finish - if (error) { - rejectInvocation(error) - } else { - resolveInvocation() - } - }, - }) + let resolveInvocation, rejectInvocation + const invocationPromise = new Promise((resolve, reject) => { + resolveInvocation = resolve + rejectInvocation = reject + }) + + const response = await execute({ + event: { + headers: headers || {}, + httpMethod: httpMethod || 'GET', + rawUrl: new URL(url || '/', 'https://example.netlify').href, + flags: flags ?? DEFAULT_FLAGS, + }, + lambdaFunc: { handler }, + timeoutMs: 4_000, + onInvocationEnd: (error) => { + // lambda-local resolve promise return from execute when response is closed + // but we should wait for tracked background work to finish + // before resolving the promise to allow background work to finish + if (error) { + rejectInvocation(error) + } else { + resolveInvocation() + } + }, + }) - await invocationPromise + await invocationPromise - if (!response) { - throw new Error('No response from lambda-local') - } + if (!response) { + throw new Error('No response from lambda-local') + } - const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce( - (prev, [key, value]) => ({ - ...prev, - [key]: value.length === 1 ? `${value}` : value.join(', '), - }), - response.headers || {}, - ) + const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce( + (prev, [key, value]) => ({ + ...prev, + [key]: value.length === 1 ? `${value}` : value.join(', '), + }), + response.headers || {}, + ) - const bodyBuffer = await streamToBuffer(response.body) + const bodyBuffer = await streamToBuffer(response.body) - restoreEnvironment() + restoreEnvironment() - return { - statusCode: response.statusCode, - bodyBuffer, - body: bodyBuffer.toString('utf-8'), - headers: responseHeaders, - isBase64Encoded: response.isBase64Encoded, + return { + statusCode: response.statusCode, + bodyBuffer, + body: bodyBuffer.toString('utf-8'), + headers: responseHeaders, + isBase64Encoded: response.isBase64Encoded, + } } + + restoreEnvironment() + + return invokeFunction } /** - * @typedef {typeof loadAndInvokeFunctionImpl} InvokeFunction + * @typedef {Awaited>} InvokeFunction * @typedef {Promise>>} InvokeFunctionResult */ diff --git a/tests/utils/sandbox-child.mjs b/tests/utils/sandbox-child.mjs index ec5d781f1e..f9bfc7a5f0 100644 --- a/tests/utils/sandbox-child.mjs +++ b/tests/utils/sandbox-child.mjs @@ -1,7 +1,7 @@ // @ts-check import { getLogger } from 'lambda-local' -import { loadAndInvokeFunctionImpl } from './lambda-helpers.mjs' +import { loadFunction } from './lambda-helpers.mjs' getLogger().level = 'alert' @@ -17,7 +17,9 @@ process.on( try { const [ctx, options] = msg.args - const result = await loadAndInvokeFunctionImpl(ctx, options) + const invokeFunctionImpl = await loadFunction(ctx, options) + const result = await invokeFunctionImpl(options) + if (process.send) { process.send({ action: 'invokeFunctionResult', From 87e1dd6bd2e50bb3071d6d136f8e799bf58a274c Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 24 Apr 2025 22:05:43 +0200 Subject: [PATCH 4/5] test: allow invoking sandobxed function multiple times --- tests/utils/fixture.ts | 165 +++++++++++++++++++++++++++------- tests/utils/sandbox-child.mjs | 20 ++++- 2 files changed, 150 insertions(+), 35 deletions(-) diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 890f17a33e..443450a15d 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -14,7 +14,12 @@ import { env } from 'node:process' import { fileURLToPath } from 'node:url' import { v4 } from 'uuid' import { LocalServer } from './local-server.js' -import { loadFunction, type FunctionInvocationOptions } from './lambda-helpers.mjs' +import { + type InvokeFunctionResult, + loadFunction, + type LoadFunctionOptions, + type FunctionInvocationOptions, +} from './lambda-helpers.mjs' import { glob } from 'fast-glob' import { @@ -405,48 +410,140 @@ export async function invokeEdgeFunction( }) } -export async function invokeSandboxedFunction( +/** + * Load function in child process and allow for multiple invocations + */ +export async function loadSandboxedFunction( ctx: FixtureTestContext, - options: Parameters[1] = {}, + options: LoadFunctionOptions = {}, ) { - return new Promise>((resolve, reject) => { - const childProcess = spawn(process.execPath, [import.meta.dirname + '/sandbox-child.mjs'], { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - cwd: process.cwd(), - }) + const childProcess = spawn(process.execPath, [import.meta.dirname + '/sandbox-child.mjs'], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + cwd: join(ctx.functionDist, SERVER_HANDLER_NAME), + env: { + ...process.env, + ...(options.env || {}), + }, + }) - childProcess.stdout?.on('data', (data) => { - console.log(data.toString()) - }) + let isRunning = true + let operationCounter = 1 - childProcess.stderr?.on('data', (data) => { - console.error(data.toString()) - }) + childProcess.stdout?.on('data', (data) => { + console.log(data.toString()) + }) - childProcess.on('message', (msg: any) => { - if (msg?.action === 'invokeFunctionResult') { - resolve(msg.result) - childProcess.send({ action: 'exit' }) - } - }) + childProcess.stderr?.on('data', (data) => { + console.error(data.toString()) + }) + + const onGoingOperationsMap = new Map< + number, + { + resolve: (value?: any) => void + reject: (reason?: any) => void + } + >() + + function createOperation() { + const operationId = operationCounter + operationCounter += 1 - childProcess.on('exit', () => { - reject(new Error('worker exited before returning result')) + let promiseResolve, promiseReject + const promise = new Promise((innerResolve, innerReject) => { + promiseResolve = innerResolve + promiseReject = innerReject }) + function resolve(value: T) { + onGoingOperationsMap.delete(operationId) + promiseResolve?.(value) + } + function reject(reason) { + onGoingOperationsMap.delete(operationId) + promiseReject?.(reason) + } + + onGoingOperationsMap.set(operationId, { resolve, reject }) + return { operationId, promise, resolve, reject } + } + + childProcess.on('exit', () => { + isRunning = false + + const error = new Error('worker exited before returning result') + + for (const { reject } of onGoingOperationsMap.values()) { + reject(error) + } + }) + + function exit() { + if (isRunning) { + childProcess.send({ action: 'exit' }) + } + } + + // make sure to exit the child process when the test is done just in case + ctx.cleanup?.push(async () => exit()) + + const { promise: loadPromise, resolve: loadResolve } = createOperation() + + childProcess.on('message', (msg: any) => { + if (msg?.action === 'invokeFunctionResult') { + onGoingOperationsMap.get(msg.operationId)?.resolve(msg.result) + } else if (msg?.action === 'loadedFunction') { + loadResolve() + } + }) + + // context object is not serializable so we create serializable object + // containing required properties to invoke lambda + const serializableCtx = { + functionDist: ctx.functionDist, + blobStoreHost: ctx.blobStoreHost, + siteID: ctx.siteID, + deployID: ctx.deployID, + } + + childProcess.send({ + action: 'loadFunction', + args: [serializableCtx], + }) + + await loadPromise + + function invokeFunction(options: FunctionInvocationOptions): InvokeFunctionResult { + if (!isRunning) { + throw new Error('worker is not running anymore') + } + + const { operationId, promise } = createOperation>() + childProcess.send({ action: 'invokeFunction', - args: [ - // context object is not serializable so we create serializable object - // containing required properties to invoke lambda - { - functionDist: ctx.functionDist, - blobStoreHost: ctx.blobStoreHost, - siteID: ctx.siteID, - deployID: ctx.deployID, - }, - options, - ], + operationId, + args: [serializableCtx, options], }) - }) + + return promise + } + + return { + invokeFunction, + exit, + } +} + +/** + * Load function in child process and execute single invocation + */ +export async function invokeSandboxedFunction( + ctx: FixtureTestContext, + options: FunctionInvocationOptions = {}, +) { + const { invokeFunction, exit } = await loadSandboxedFunction(ctx, options) + const result = await invokeFunction(options) + exit() + return result } diff --git a/tests/utils/sandbox-child.mjs b/tests/utils/sandbox-child.mjs index f9bfc7a5f0..74c81d0962 100644 --- a/tests/utils/sandbox-child.mjs +++ b/tests/utils/sandbox-child.mjs @@ -5,6 +5,11 @@ import { loadFunction } from './lambda-helpers.mjs' getLogger().level = 'alert' +/** + * @type {import('./lambda-helpers.mjs').InvokeFunction | undefined} + */ +let invokeFunctionImpl + process.on( 'message', /** @@ -13,16 +18,29 @@ process.on( async (msg) => { if (msg?.action === 'exit') { process.exit(0) + } else if (msg?.action === 'loadFunction') { + const [ctx, options] = msg.args + + invokeFunctionImpl = await loadFunction(ctx, options) + + if (process.send) { + process.send({ + action: 'loadedFunction', + }) + } } else if (msg?.action === 'invokeFunction') { try { const [ctx, options] = msg.args - const invokeFunctionImpl = await loadFunction(ctx, options) + if (!invokeFunctionImpl) { + throw new Error('Function not loaded') + } const result = await invokeFunctionImpl(options) if (process.send) { process.send({ action: 'invokeFunctionResult', + operationId: msg.operationId, result, }) } From 6b3a40aab8fe8e2fd49640b8b26a5025036179ac Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 24 Apr 2025 22:06:08 +0200 Subject: [PATCH 5/5] test: bump FUTURE_NEXT_PATCH_VERSION --- tests/utils/next-version-helpers.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/next-version-helpers.mjs b/tests/utils/next-version-helpers.mjs index 69f243cfc3..b462517b21 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -6,7 +6,7 @@ import fg from 'fast-glob' import { coerce, gt, gte, satisfies, valid } from 'semver' import { execaCommand } from 'execa' -const FUTURE_NEXT_PATCH_VERSION = '14.999.0' +const FUTURE_NEXT_PATCH_VERSION = '15.999.0' const NEXT_VERSION_REQUIRES_REACT_19 = '14.3.0-canary.45' const REACT_18_VERSION = '18.2.0'