@@ -2,11 +2,9 @@ import { assert, vi } from 'vitest'
2
2
3
3
import { type NetlifyPluginConstants , type NetlifyPluginOptions } from '@netlify/build'
4
4
import { bundle , serve } from '@netlify/edge-bundler'
5
- import type { LambdaResponse } from '@netlify/serverless-functions-api/dist/lambda/response.js'
6
5
import { zipFunctions } from '@netlify/zip-it-and-ship-it'
7
6
import { execaCommand } from 'execa'
8
7
import getPort from 'get-port'
9
- import { execute } from 'lambda-local'
10
8
import { spawn } from 'node:child_process'
11
9
import { createWriteStream , existsSync } from 'node:fs'
12
10
import { cp , mkdir , mkdtemp , readFile , rm , writeFile } from 'node:fs/promises'
@@ -16,17 +14,21 @@ import { env } from 'node:process'
16
14
import { fileURLToPath } from 'node:url'
17
15
import { v4 } from 'uuid'
18
16
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'
20
23
21
24
import { glob } from 'fast-glob'
22
25
import {
23
26
EDGE_HANDLER_NAME ,
24
27
PluginContext ,
25
28
SERVER_HANDLER_NAME ,
26
29
} from '../../src/build/plugin-context.js'
27
- import { BLOB_TOKEN } from './constants.js '
30
+ import { BLOB_TOKEN } from './constants.mjs '
28
31
import { type FixtureTestContext } from './contexts.js'
29
- import { createBlobContext } from './helpers.js'
30
32
import { setNextVersionInFixture } from './next-version-helpers.mjs'
31
33
32
34
const bootstrapURL = 'https://edge.netlify.com/bootstrap/index-combined.ts'
@@ -339,117 +341,17 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) {
339
341
)
340
342
}
341
343
342
- const DEFAULT_FLAGS = { }
343
- /**
344
- * Execute the function with the provided parameters
345
- * @param ctx
346
- * @param options
347
- */
348
344
export async function invokeFunction (
349
345
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 = { } ,
370
347
) {
371
- const { httpMethod, headers, flags, url, env } = options
372
348
// now for the execution set the process working directory to the dist entry point
373
349
const cwdMock = vi
374
350
. spyOn ( process , 'cwd' )
375
351
. mockReturnValue ( join ( ctx . functionDist , SERVER_HANDLER_NAME ) )
376
352
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 )
453
355
} finally {
454
356
cwdMock . mockRestore ( )
455
357
}
@@ -508,48 +410,140 @@ export async function invokeEdgeFunction(
508
410
} )
509
411
}
510
412
511
- export async function invokeSandboxedFunction (
413
+ /**
414
+ * Load function in child process and allow for multiple invocations
415
+ */
416
+ export async function loadSandboxedFunction (
512
417
ctx : FixtureTestContext ,
513
- options : Parameters < typeof invokeFunction > [ 1 ] = { } ,
418
+ options : LoadFunctionOptions = { } ,
514
419
) {
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
+ } )
520
428
521
- childProcess . stdout ?. on ( 'data' , ( data ) => {
522
- console . log ( data . toString ( ) )
523
- } )
429
+ let isRunning = true
430
+ let operationCounter = 1
524
431
525
- childProcess . stderr ?. on ( 'data' , ( data ) => {
526
- console . error ( data . toString ( ) )
527
- } )
432
+ childProcess . stdout ?. on ( 'data' , ( data ) => {
433
+ console . log ( data . toString ( ) )
434
+ } )
528
435
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
535
451
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
538
456
} )
539
457
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
+
540
523
childProcess . send ( {
541
524
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 ] ,
553
527
} )
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
555
549
}
0 commit comments