Skip to content

Commit 683c262

Browse files
committed
test: refactor sandboxed invocations to allow multiple ones against same instance
1 parent 81bfeb8 commit 683c262

File tree

7 files changed

+387
-266
lines changed

7 files changed

+387
-266
lines changed
File renamed without changes.

tests/utils/fixture.ts

+137-144
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,22 @@ 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'
32+
// import { createBlobContext } from './helpers.js'
3033
import { setNextVersionInFixture } from './next-version-helpers.mjs'
3134

3235
const bootstrapURL = 'https://edge.netlify.com/bootstrap/index-combined.ts'
@@ -339,121 +342,18 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) {
339342
)
340343
}
341344

342-
const DEFAULT_FLAGS = {}
343-
/**
344-
* Execute the function with the provided parameters
345-
* @param ctx
346-
* @param options
347-
*/
348345
export async function invokeFunction(
349346
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-
} = {},
347+
options: FunctionInvocationOptions = {},
370348
) {
371-
const { httpMethod, headers, flags, url, env } = options
372349
// now for the execution set the process working directory to the dist entry point
373350
const cwdMock = vi
374351
.spyOn(process, 'cwd')
375352
.mockReturnValue(join(ctx.functionDist, SERVER_HANDLER_NAME))
376353
try {
377-
// The environment variables available during execution
378-
const environment = {
379-
NODE_ENV: 'production',
380-
NETLIFY_BLOBS_CONTEXT: createBlobContext(ctx),
381-
NEXT_PRIVATE_DEBUG_CACHE: 'true',
382-
...(env || {}),
383-
}
384-
385-
const envVarsToRestore = {}
386-
387-
// We are not using lambda-local's environment variable setting because it cleans up
388-
// environment vars to early (before stream is closed)
389-
Object.keys(environment).forEach(function (key) {
390-
if (typeof process.env[key] !== 'undefined') {
391-
envVarsToRestore[key] = process.env[key]
392-
}
393-
process.env[key] = environment[key]
394-
})
354+
const { invokeFunction } = await loadFunction(ctx, options)
395355

396-
const { handler } = await import(
397-
join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs')
398-
)
399-
400-
let resolveInvocation, rejectInvocation
401-
const invocationPromise = new Promise((resolve, reject) => {
402-
resolveInvocation = resolve
403-
rejectInvocation = reject
404-
})
405-
406-
const response = (await execute({
407-
event: {
408-
headers: {
409-
'x-nf-debug-logging': 1,
410-
...(headers || {}),
411-
},
412-
httpMethod: httpMethod || 'GET',
413-
rawUrl: new URL(url || '/', 'https://example.netlify').href,
414-
flags: flags ?? DEFAULT_FLAGS,
415-
},
416-
lambdaFunc: { handler },
417-
timeoutMs: 4_000,
418-
onInvocationEnd: (error) => {
419-
// lambda-local resolve promise return from execute when response is closed
420-
// but we should wait for tracked background work to finish
421-
// before resolving the promise to allow background work to finish
422-
if (error) {
423-
rejectInvocation(error)
424-
} else {
425-
resolveInvocation()
426-
}
427-
},
428-
})) as LambdaResponse
429-
430-
await invocationPromise
431-
432-
const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce(
433-
(prev, [key, value]) => ({
434-
...prev,
435-
[key]: value.length === 1 ? `${value}` : value.join(', '),
436-
}),
437-
response.headers || {},
438-
)
439-
440-
const bodyBuffer = await streamToBuffer(response.body)
441-
442-
Object.keys(environment).forEach(function (key) {
443-
if (typeof envVarsToRestore[key] !== 'undefined') {
444-
process.env[key] = envVarsToRestore[key]
445-
} else {
446-
delete process.env[key]
447-
}
448-
})
449-
450-
return {
451-
statusCode: response.statusCode,
452-
bodyBuffer,
453-
body: bodyBuffer.toString('utf-8'),
454-
headers: responseHeaders,
455-
isBase64Encoded: response.isBase64Encoded,
456-
}
356+
return await invokeFunction(options)
457357
} finally {
458358
cwdMock.mockRestore()
459359
}
@@ -512,48 +412,141 @@ export async function invokeEdgeFunction(
512412
})
513413
}
514414

515-
export async function invokeSandboxedFunction(
415+
/**
416+
* Load function in child process and allow for multiple invocations
417+
*/
418+
export async function loadSandboxedFunction(
516419
ctx: FixtureTestContext,
517-
options: Parameters<typeof invokeFunction>[1] = {},
420+
options: LoadFunctionOptions = {},
518421
) {
519-
return new Promise<ReturnType<typeof invokeFunction>>((resolve, reject) => {
520-
const childProcess = spawn(process.execPath, [import.meta.dirname + '/sandbox-child.mjs'], {
521-
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
522-
cwd: process.cwd(),
523-
})
422+
const childProcess = spawn(process.execPath, [import.meta.dirname + '/sandbox-child.mjs'], {
423+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
424+
cwd: join(ctx.functionDist, SERVER_HANDLER_NAME),
425+
env: {
426+
...process.env,
427+
...(options.env || {}),
428+
},
429+
})
524430

525-
childProcess.stdout?.on('data', (data) => {
526-
console.log(data.toString())
527-
})
431+
let isRunning = true
432+
let operationCounter = 1
528433

529-
childProcess.stderr?.on('data', (data) => {
530-
console.error(data.toString())
531-
})
434+
childProcess.stdout?.on('data', (data) => {
435+
console.log(data.toString())
436+
})
532437

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

540-
childProcess.on('exit', () => {
541-
reject(new Error('worker exited before returning result'))
454+
let promiseResolve, promiseReject
455+
const promise = new Promise<T>((innerResolve, innerReject) => {
456+
promiseResolve = innerResolve
457+
promiseReject = innerReject
542458
})
543459

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

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)