@@ -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,22 @@ 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'
32+ // import { createBlobContext } from './helpers.js'
3033import { setNextVersionInFixture } from './next-version-helpers.mjs'
3134
3235const 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- */
348345export 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}
0 commit comments