Skip to content

Commit 68cef57

Browse files
committed
test: move serverless function invocation implementation into common js module shared by both in-process and sandboxed invocations
1 parent 03ee7d9 commit 68cef57

File tree

7 files changed

+216
-238
lines changed

7 files changed

+216
-238
lines changed
File renamed without changes.

tests/utils/fixture.ts

+4-108
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,16 @@ 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 { loadAndInvokeFunctionImpl, type FunctionInvocationOptions } from './lambda-helpers.mjs'
2018

2119
import { glob } from 'fast-glob'
2220
import {
2321
EDGE_HANDLER_NAME,
2422
PluginContext,
2523
SERVER_HANDLER_NAME,
2624
} from '../../src/build/plugin-context.js'
27-
import { BLOB_TOKEN } from './constants.js'
25+
import { BLOB_TOKEN } from './constants.mjs'
2826
import { type FixtureTestContext } from './contexts.js'
29-
import { createBlobContext } from './helpers.js'
3027
import { setNextVersionInFixture } from './next-version-helpers.mjs'
3128

3229
const bootstrapURL = 'https://edge.netlify.com/bootstrap/index-combined.ts'
@@ -339,117 +336,16 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) {
339336
)
340337
}
341338

342-
const DEFAULT_FLAGS = {}
343-
/**
344-
* Execute the function with the provided parameters
345-
* @param ctx
346-
* @param options
347-
*/
348339
export async function invokeFunction(
349340
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-
} = {},
341+
options: FunctionInvocationOptions = {},
370342
) {
371-
const { httpMethod, headers, flags, url, env } = options
372343
// now for the execution set the process working directory to the dist entry point
373344
const cwdMock = vi
374345
.spyOn(process, 'cwd')
375346
.mockReturnValue(join(ctx.functionDist, SERVER_HANDLER_NAME))
376347
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-
}
348+
return await loadAndInvokeFunctionImpl(ctx, options)
453349
} finally {
454350
cwdMock.mockRestore()
455351
}

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'

tests/utils/lambda-helpers.mjs

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// @ts-check
2+
3+
// this is not TS file because it's used both directly inside test process
4+
// as well as child process that lacks TS on-the-fly transpilation
5+
6+
import { join } from 'node:path'
7+
import { BLOB_TOKEN } from './constants.mjs'
8+
import { execute as untypedExecute } from 'lambda-local'
9+
10+
const SERVER_HANDLER_NAME = '___netlify-server-handler'
11+
12+
/**
13+
* @typedef {import('./contexts').FixtureTestContext} FixtureTestContext
14+
*
15+
* @typedef {Awaited<ReturnType<ReturnType<typeof import('@netlify/serverless-functions-api').getLambdaHandler>>>} LambdaResult
16+
*
17+
* @typedef {Object} FunctionInvocationOptions
18+
* @property {Record<string, string>} [env] Environment variables that should be set during the invocation
19+
* @property {string} [httpMethod] The http method that is used for the invocation. Defaults to 'GET'
20+
* @property {string} [url] TThe relative path that should be requested. Defaults to '/'
21+
* @property {Record<string, string>} [headers] The headers used for the invocation
22+
* @property {Record<string, unknown>} [flags] Feature flags that should be set during the invocation
23+
*/
24+
25+
/**
26+
* This is helper to get LambdaLocal's execute to actually provide result type instead of `unknown`
27+
* Because jsdoc doesn't seem to have equivalent of `as` in TS and trying to assign `LambdaResult` type
28+
* to return value of `execute` leading to `Type 'unknown' is not assignable to type 'LambdaResult'`
29+
* error, this types it as `any` instead which allow to later type it as `LambdaResult`.
30+
* @param {Parameters<typeof untypedExecute>} args
31+
* @returns {Promise<LambdaResult>}
32+
*/
33+
async function execute(...args) {
34+
/**
35+
* @type {any}
36+
*/
37+
const anyResult = await untypedExecute(...args)
38+
39+
return anyResult
40+
}
41+
42+
/**
43+
* @param {FixtureTestContext} ctx
44+
*/
45+
export const createBlobContext = (ctx) =>
46+
Buffer.from(
47+
JSON.stringify({
48+
edgeURL: `http://${ctx.blobStoreHost}`,
49+
uncachedEdgeURL: `http://${ctx.blobStoreHost}`,
50+
token: BLOB_TOKEN,
51+
siteID: ctx.siteID,
52+
deployID: ctx.deployID,
53+
primaryRegion: 'us-test-1',
54+
}),
55+
).toString('base64')
56+
57+
/**
58+
* Converts a readable stream to a buffer
59+
* @param {NodeJS.ReadableStream} stream
60+
* @returns {Promise<Buffer>}
61+
*/
62+
function streamToBuffer(stream) {
63+
/**
64+
* @type {Buffer[]}
65+
*/
66+
const chunks = []
67+
68+
return new Promise((resolve, reject) => {
69+
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
70+
stream.on('error', (err) => reject(err))
71+
stream.on('end', () => resolve(Buffer.concat(chunks)))
72+
})
73+
}
74+
75+
/**
76+
* @param {FixtureTestContext} ctx
77+
* @param {Record<string, string>} [env]
78+
*/
79+
function temporarilySetEnv(ctx, env) {
80+
const environment = {
81+
NODE_ENV: 'production',
82+
NETLIFY_BLOBS_CONTEXT: createBlobContext(ctx),
83+
...(env || {}),
84+
}
85+
86+
const envVarsToRestore = {}
87+
88+
// We are not using lambda-local's environment variable setting because it cleans up
89+
// environment vars to early (before stream is closed)
90+
Object.keys(environment).forEach(function (key) {
91+
if (typeof process.env[key] !== 'undefined') {
92+
envVarsToRestore[key] = process.env[key]
93+
}
94+
process.env[key] = environment[key]
95+
})
96+
97+
return function restoreEnvironment() {
98+
Object.keys(environment).forEach(function (key) {
99+
if (typeof envVarsToRestore[key] !== 'undefined') {
100+
process.env[key] = envVarsToRestore[key]
101+
} else {
102+
delete process.env[key]
103+
}
104+
})
105+
}
106+
}
107+
108+
const DEFAULT_FLAGS = {}
109+
110+
/**
111+
* @param {FixtureTestContext} ctx
112+
* @param {FunctionInvocationOptions} options
113+
*/
114+
export async function loadAndInvokeFunctionImpl(
115+
ctx,
116+
{ headers, httpMethod, flags, url, env } = {},
117+
) {
118+
const { handler } = await import(
119+
'file:///' + join(ctx.functionDist, SERVER_HANDLER_NAME, '___netlify-entry-point.mjs')
120+
)
121+
122+
const restoreEnvironment = temporarilySetEnv(ctx, env)
123+
124+
let resolveInvocation, rejectInvocation
125+
const invocationPromise = new Promise((resolve, reject) => {
126+
resolveInvocation = resolve
127+
rejectInvocation = reject
128+
})
129+
130+
const response = await execute({
131+
event: {
132+
headers: {
133+
// 'x-nf-debug-logging': 1,
134+
...(headers || {}),
135+
},
136+
httpMethod: httpMethod || 'GET',
137+
rawUrl: new URL(url || '/', 'https://example.netlify').href,
138+
flags: flags ?? DEFAULT_FLAGS,
139+
},
140+
lambdaFunc: { handler },
141+
timeoutMs: 4_000,
142+
onInvocationEnd: (error) => {
143+
// lambda-local resolve promise return from execute when response is closed
144+
// but we should wait for tracked background work to finish
145+
// before resolving the promise to allow background work to finish
146+
if (error) {
147+
rejectInvocation(error)
148+
} else {
149+
resolveInvocation()
150+
}
151+
},
152+
})
153+
154+
await invocationPromise
155+
156+
if (!response) {
157+
throw new Error('No response from lambda-local')
158+
}
159+
160+
const responseHeaders = Object.entries(response.multiValueHeaders || {}).reduce(
161+
(prev, [key, value]) => ({
162+
...prev,
163+
[key]: value.length === 1 ? `${value}` : value.join(', '),
164+
}),
165+
response.headers || {},
166+
)
167+
168+
const bodyBuffer = await streamToBuffer(response.body)
169+
170+
restoreEnvironment()
171+
172+
return {
173+
statusCode: response.statusCode,
174+
bodyBuffer,
175+
body: bodyBuffer.toString('utf-8'),
176+
headers: responseHeaders,
177+
isBase64Encoded: response.isBase64Encoded,
178+
}
179+
}
180+
181+
/**
182+
* @typedef {typeof loadAndInvokeFunctionImpl} InvokeFunction
183+
* @typedef {Promise<Awaited<ReturnType<InvokeFunction>>>} InvokeFunctionResult
184+
*/

0 commit comments

Comments
 (0)