@@ -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,22 @@ 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'
32
+ // import { createBlobContext } from './helpers.js'
30
33
import { setNextVersionInFixture } from './next-version-helpers.mjs'
31
34
32
35
const bootstrapURL = 'https://edge.netlify.com/bootstrap/index-combined.ts'
@@ -339,121 +342,18 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) {
339
342
)
340
343
}
341
344
342
- const DEFAULT_FLAGS = { }
343
- /**
344
- * Execute the function with the provided parameters
345
- * @param ctx
346
- * @param options
347
- */
348
345
export async function invokeFunction (
349
346
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 = { } ,
370
348
) {
371
- const { httpMethod, headers, flags, url, env } = options
372
349
// now for the execution set the process working directory to the dist entry point
373
350
const cwdMock = vi
374
351
. spyOn ( process , 'cwd' )
375
352
. mockReturnValue ( join ( ctx . functionDist , SERVER_HANDLER_NAME ) )
376
353
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 )
395
355
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 )
457
357
} finally {
458
358
cwdMock . mockRestore ( )
459
359
}
@@ -512,48 +412,141 @@ export async function invokeEdgeFunction(
512
412
} )
513
413
}
514
414
515
- export async function invokeSandboxedFunction (
415
+ /**
416
+ * Load function in child process and allow for multiple invocations
417
+ */
418
+ export async function loadSandboxedFunction (
516
419
ctx : FixtureTestContext ,
517
- options : Parameters < typeof invokeFunction > [ 1 ] = { } ,
420
+ options : LoadFunctionOptions = { } ,
518
421
) {
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
+ } )
524
430
525
- childProcess . stdout ?. on ( 'data' , ( data ) => {
526
- console . log ( data . toString ( ) )
527
- } )
431
+ let isRunning = true
432
+ let operationCounter = 1
528
433
529
- childProcess . stderr ?. on ( 'data' , ( data ) => {
530
- console . error ( data . toString ( ) )
531
- } )
434
+ childProcess . stdout ?. on ( 'data' , ( data ) => {
435
+ console . log ( data . toString ( ) )
436
+ } )
532
437
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
539
453
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
542
458
} )
543
459
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
+
544
525
childProcess . send ( {
545
526
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 ] ,
557
529
} )
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
559
552
}
0 commit comments