Skip to content

Commit d2e4347

Browse files
authored
test: refactor serverless invocations so in-process and sandbox implementation use shared code and ability to run multiple invocations in same sandbox (#2871)
* test: move serverless function invocation implementation into common js module shared by both in-process and sandboxed invocations * test: set env before important to allow import-time conditional code paths * test: split loading and invoking function * test: allow invoking sandobxed function multiple times * test: bump FUTURE_NEXT_PATCH_VERSION
1 parent e96cd18 commit d2e4347

8 files changed

+372
-266
lines changed
File renamed without changes.

tests/utils/fixture.ts

+135-141
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import { assert, vi } from 'vitest'
22

33
import { type NetlifyPluginConstants, type NetlifyPluginOptions } from '@netlify/build'
44
import { bundle, serve } from '@netlify/edge-bundler'
5-
import type { LambdaResponse } from '@netlify/serverless-functions-api/dist/lambda/response.js'
65
import { zipFunctions } from '@netlify/zip-it-and-ship-it'
76
import { execaCommand } from 'execa'
87
import getPort from 'get-port'
9-
import { execute } from 'lambda-local'
108
import { spawn } from 'node:child_process'
119
import { createWriteStream, existsSync } from 'node:fs'
1210
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -16,17 +14,21 @@ import { env } from 'node:process'
1614
import { fileURLToPath } from 'node:url'
1715
import { v4 } from 'uuid'
1816
import { LocalServer } from './local-server.js'
19-
import { streamToBuffer } from './stream-to-buffer.js'
17+
import {
18+
type InvokeFunctionResult,
19+
loadFunction,
20+
type LoadFunctionOptions,
21+
type FunctionInvocationOptions,
22+
} from './lambda-helpers.mjs'
2023

2124
import { glob } from 'fast-glob'
2225
import {
2326
EDGE_HANDLER_NAME,
2427
PluginContext,
2528
SERVER_HANDLER_NAME,
2629
} from '../../src/build/plugin-context.js'
27-
import { BLOB_TOKEN } from './constants.js'
30+
import { BLOB_TOKEN } from './constants.mjs'
2831
import { type FixtureTestContext } from './contexts.js'
29-
import { createBlobContext } from './helpers.js'
3032
import { setNextVersionInFixture } from './next-version-helpers.mjs'
3133

3234
const bootstrapURL = 'https://edge.netlify.com/bootstrap/index-combined.ts'
@@ -339,117 +341,17 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) {
339341
)
340342
}
341343

342-
const DEFAULT_FLAGS = {}
343-
/**
344-
* Execute the function with the provided parameters
345-
* @param ctx
346-
* @param options
347-
*/
348344
export async function invokeFunction(
349345
ctx: FixtureTestContext,
350-
options: {
351-
/**
352-
* The http method that is used for the invocation
353-
* @default 'GET'
354-
*/
355-
httpMethod?: string
356-
/**
357-
* The relative path that should be requested
358-
* @default '/'
359-
*/
360-
url?: string
361-
/** The headers used for the invocation*/
362-
headers?: Record<string, string>
363-
/** The body that is used for the invocation */
364-
body?: unknown
365-
/** Environment variables that should be set during the invocation */
366-
env?: Record<string, string | number>
367-
/** Feature flags that should be set during the invocation */
368-
flags?: Record<string, unknown>
369-
} = {},
346+
options: FunctionInvocationOptions = {},
370347
) {
371-
const { httpMethod, headers, flags, url, env } = options
372348
// now for the execution set the process working directory to the dist entry point
373349
const cwdMock = vi
374350
.spyOn(process, 'cwd')
375351
.mockReturnValue(join(ctx.functionDist, SERVER_HANDLER_NAME))
376352
try {
377-
const { handler } = await import(
378-
join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs')
379-
)
380-
381-
// The environment variables available during execution
382-
const environment = {
383-
NODE_ENV: 'production',
384-
NETLIFY_BLOBS_CONTEXT: createBlobContext(ctx),
385-
...(env || {}),
386-
}
387-
388-
const envVarsToRestore = {}
389-
390-
// We are not using lambda-local's environment variable setting because it cleans up
391-
// environment vars to early (before stream is closed)
392-
Object.keys(environment).forEach(function (key) {
393-
if (typeof process.env[key] !== 'undefined') {
394-
envVarsToRestore[key] = process.env[key]
395-
}
396-
process.env[key] = environment[key]
397-
})
398-
399-
let resolveInvocation, rejectInvocation
400-
const invocationPromise = new Promise((resolve, reject) => {
401-
resolveInvocation = resolve
402-
rejectInvocation = reject
403-
})
404-
405-
const response = (await execute({
406-
event: {
407-
headers: headers || {},
408-
httpMethod: httpMethod || 'GET',
409-
rawUrl: new URL(url || '/', 'https://example.netlify').href,
410-
flags: flags ?? DEFAULT_FLAGS,
411-
},
412-
lambdaFunc: { handler },
413-
timeoutMs: 4_000,
414-
onInvocationEnd: (error) => {
415-
// lambda-local resolve promise return from execute when response is closed
416-
// but we should wait for tracked background work to finish
417-
// before resolving the promise to allow background work to finish
418-
if (error) {
419-
rejectInvocation(error)
420-
} else {
421-
resolveInvocation()
422-
}
423-
},
424-
})) as LambdaResponse
425-
426-
await invocationPromise
427-
428-
const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce(
429-
(prev, [key, value]) => ({
430-
...prev,
431-
[key]: value.length === 1 ? `${value}` : value.join(', '),
432-
}),
433-
response.headers || {},
434-
)
435-
436-
const bodyBuffer = await streamToBuffer(response.body)
437-
438-
Object.keys(environment).forEach(function (key) {
439-
if (typeof envVarsToRestore[key] !== 'undefined') {
440-
process.env[key] = envVarsToRestore[key]
441-
} else {
442-
delete process.env[key]
443-
}
444-
})
445-
446-
return {
447-
statusCode: response.statusCode,
448-
bodyBuffer,
449-
body: bodyBuffer.toString('utf-8'),
450-
headers: responseHeaders,
451-
isBase64Encoded: response.isBase64Encoded,
452-
}
353+
const invokeFunctionImpl = await loadFunction(ctx, options)
354+
return await invokeFunctionImpl(options)
453355
} finally {
454356
cwdMock.mockRestore()
455357
}
@@ -508,48 +410,140 @@ export async function invokeEdgeFunction(
508410
})
509411
}
510412

511-
export async function invokeSandboxedFunction(
413+
/**
414+
* Load function in child process and allow for multiple invocations
415+
*/
416+
export async function loadSandboxedFunction(
512417
ctx: FixtureTestContext,
513-
options: Parameters<typeof invokeFunction>[1] = {},
418+
options: LoadFunctionOptions = {},
514419
) {
515-
return new Promise<ReturnType<typeof invokeFunction>>((resolve, reject) => {
516-
const childProcess = spawn(process.execPath, [import.meta.dirname + '/sandbox-child.mjs'], {
517-
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
518-
cwd: process.cwd(),
519-
})
420+
const childProcess = spawn(process.execPath, [import.meta.dirname + '/sandbox-child.mjs'], {
421+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
422+
cwd: join(ctx.functionDist, SERVER_HANDLER_NAME),
423+
env: {
424+
...process.env,
425+
...(options.env || {}),
426+
},
427+
})
520428

521-
childProcess.stdout?.on('data', (data) => {
522-
console.log(data.toString())
523-
})
429+
let isRunning = true
430+
let operationCounter = 1
524431

525-
childProcess.stderr?.on('data', (data) => {
526-
console.error(data.toString())
527-
})
432+
childProcess.stdout?.on('data', (data) => {
433+
console.log(data.toString())
434+
})
528435

529-
childProcess.on('message', (msg: any) => {
530-
if (msg?.action === 'invokeFunctionResult') {
531-
resolve(msg.result)
532-
childProcess.send({ action: 'exit' })
533-
}
534-
})
436+
childProcess.stderr?.on('data', (data) => {
437+
console.error(data.toString())
438+
})
439+
440+
const onGoingOperationsMap = new Map<
441+
number,
442+
{
443+
resolve: (value?: any) => void
444+
reject: (reason?: any) => void
445+
}
446+
>()
447+
448+
function createOperation<T>() {
449+
const operationId = operationCounter
450+
operationCounter += 1
535451

536-
childProcess.on('exit', () => {
537-
reject(new Error('worker exited before returning result'))
452+
let promiseResolve, promiseReject
453+
const promise = new Promise<T>((innerResolve, innerReject) => {
454+
promiseResolve = innerResolve
455+
promiseReject = innerReject
538456
})
539457

458+
function resolve(value: T) {
459+
onGoingOperationsMap.delete(operationId)
460+
promiseResolve?.(value)
461+
}
462+
function reject(reason) {
463+
onGoingOperationsMap.delete(operationId)
464+
promiseReject?.(reason)
465+
}
466+
467+
onGoingOperationsMap.set(operationId, { resolve, reject })
468+
return { operationId, promise, resolve, reject }
469+
}
470+
471+
childProcess.on('exit', () => {
472+
isRunning = false
473+
474+
const error = new Error('worker exited before returning result')
475+
476+
for (const { reject } of onGoingOperationsMap.values()) {
477+
reject(error)
478+
}
479+
})
480+
481+
function exit() {
482+
if (isRunning) {
483+
childProcess.send({ action: 'exit' })
484+
}
485+
}
486+
487+
// make sure to exit the child process when the test is done just in case
488+
ctx.cleanup?.push(async () => exit())
489+
490+
const { promise: loadPromise, resolve: loadResolve } = createOperation<void>()
491+
492+
childProcess.on('message', (msg: any) => {
493+
if (msg?.action === 'invokeFunctionResult') {
494+
onGoingOperationsMap.get(msg.operationId)?.resolve(msg.result)
495+
} else if (msg?.action === 'loadedFunction') {
496+
loadResolve()
497+
}
498+
})
499+
500+
// context object is not serializable so we create serializable object
501+
// containing required properties to invoke lambda
502+
const serializableCtx = {
503+
functionDist: ctx.functionDist,
504+
blobStoreHost: ctx.blobStoreHost,
505+
siteID: ctx.siteID,
506+
deployID: ctx.deployID,
507+
}
508+
509+
childProcess.send({
510+
action: 'loadFunction',
511+
args: [serializableCtx],
512+
})
513+
514+
await loadPromise
515+
516+
function invokeFunction(options: FunctionInvocationOptions): InvokeFunctionResult {
517+
if (!isRunning) {
518+
throw new Error('worker is not running anymore')
519+
}
520+
521+
const { operationId, promise } = createOperation<Awaited<InvokeFunctionResult>>()
522+
540523
childProcess.send({
541524
action: 'invokeFunction',
542-
args: [
543-
// context object is not serializable so we create serializable object
544-
// containing required properties to invoke lambda
545-
{
546-
functionDist: ctx.functionDist,
547-
blobStoreHost: ctx.blobStoreHost,
548-
siteID: ctx.siteID,
549-
deployID: ctx.deployID,
550-
},
551-
options,
552-
],
525+
operationId,
526+
args: [serializableCtx, options],
553527
})
554-
})
528+
529+
return promise
530+
}
531+
532+
return {
533+
invokeFunction,
534+
exit,
535+
}
536+
}
537+
538+
/**
539+
* Load function in child process and execute single invocation
540+
*/
541+
export async function invokeSandboxedFunction(
542+
ctx: FixtureTestContext,
543+
options: FunctionInvocationOptions = {},
544+
) {
545+
const { invokeFunction, exit } = await loadSandboxedFunction(ctx, options)
546+
const result = await invokeFunction(options)
547+
exit()
548+
return result
555549
}

tests/utils/helpers.ts

+2-13
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { mkdtemp } from 'node:fs/promises'
88
import { tmpdir } from 'node:os'
99
import { join } from 'node:path'
1010
import { assert, vi } from 'vitest'
11-
import { BLOB_TOKEN } from './constants'
11+
import { BLOB_TOKEN } from './constants.mjs'
1212
import { type FixtureTestContext } from './contexts'
13+
import { createBlobContext } from './lambda-helpers.mjs'
1314

1415
/**
1516
* 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 = () => {
2627
return objectId
2728
}
2829

29-
export const createBlobContext = (ctx: FixtureTestContext) =>
30-
Buffer.from(
31-
JSON.stringify({
32-
edgeURL: `http://${ctx.blobStoreHost}`,
33-
uncachedEdgeURL: `http://${ctx.blobStoreHost}`,
34-
token: BLOB_TOKEN,
35-
siteID: ctx.siteID,
36-
deployID: ctx.deployID,
37-
primaryRegion: 'us-test-1',
38-
}),
39-
).toString('base64')
40-
4130
/**
4231
* Starts a new mock blob storage
4332
* @param ctx

tests/utils/index.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export * from './helpers.js'
22
export * from './mock-file-system.js'
3-
export * from './stream-to-buffer.js'

0 commit comments

Comments
 (0)