@@ -2,11 +2,9 @@ import { assert, vi } from 'vitest'
22
33import { type NetlifyPluginConstants , type NetlifyPluginOptions } from '@netlify/build'
44import { bundle , serve } from '@netlify/edge-bundler'
5- import type { LambdaResponse } from '@netlify/serverless-functions-api/dist/lambda/response.js'
65import { zipFunctions } from '@netlify/zip-it-and-ship-it'
76import { execaCommand } from 'execa'
87import getPort from 'get-port'
9- import { execute } from 'lambda-local'
108import { spawn } from 'node:child_process'
119import { createWriteStream , existsSync } from 'node:fs'
1210import { cp , mkdir , mkdtemp , readFile , rm , writeFile } from 'node:fs/promises'
@@ -16,17 +14,21 @@ import { env } from 'node:process'
1614import { fileURLToPath } from 'node:url'
1715import { v4 } from 'uuid'
1816import { 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
2124import { glob } from 'fast-glob'
2225import {
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 '
2831import { type FixtureTestContext } from './contexts.js'
29- import { createBlobContext } from './helpers.js'
3032import { setNextVersionInFixture } from './next-version-helpers.mjs'
3133
3234const 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- */
348344export 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}
0 commit comments