Skip to content

Commit 1094683

Browse files
authored
fix: handle 304 responses from functions (#373)
* test: add test for function returning 304 response * test: run tests in dev dir * fix: maintain bodyless responses * test: enable watch mode in tests that test modifying functions * chore: update CODEOWNERS to match all nested paths (#378) * refactor: prefer early bails, use Set for more performant lookups
1 parent a186360 commit 1094683

File tree

3 files changed

+66
-8
lines changed

3 files changed

+66
-8
lines changed

packages/functions/dev/main.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('Functions with the v2 API syntax', () => {
2828
settings: {},
2929
timeouts: {},
3030
userFunctionsPath: 'netlify/functions',
31+
watch: true,
3132
})
3233

3334
const req1 = new Request('https://site.netlify/.netlify/functions/foo')
@@ -114,6 +115,39 @@ describe('Functions with the v2 API syntax', () => {
114115
expect(thirdChunk.done).toBeTruthy()
115116
})
116117

118+
test('Handles bodyless responses', async () => {
119+
const source = `
120+
export default async () =>
121+
new Response(null,
122+
{
123+
status: 304,
124+
},
125+
)
126+
127+
export const config = { path: '/bodyless-response' }
128+
`
129+
const fixture = new Fixture().withFile('netlify/functions/bodyless-response.mjs', source)
130+
131+
const directory = await fixture.create()
132+
const destPath = join(directory, 'functions-serve')
133+
const functions = new FunctionsHandler({
134+
accountId: 'account-123',
135+
config: {},
136+
destPath,
137+
geolocation: {},
138+
projectRoot: directory,
139+
settings: {},
140+
timeouts: {},
141+
userFunctionsPath: 'netlify/functions',
142+
})
143+
144+
const req = new Request('https://site.netlify/bodyless-response')
145+
const match = await functions.match(req, destPath)
146+
expect(match).not.toBeUndefined()
147+
const res = await match!.handle(req)
148+
expect(res.status).toBe(304)
149+
})
150+
117151
test('Returns a `preferStatic` property', async () => {
118152
const fixture = new Fixture().withFile(
119153
'netlify/functions/hello.mjs',
@@ -139,6 +173,7 @@ describe('Functions with the v2 API syntax', () => {
139173
settings: {},
140174
timeouts: {},
141175
userFunctionsPath: 'netlify/functions',
176+
watch: true,
142177
})
143178

144179
const req1 = new Request('https://site.netlify/hello')

packages/functions/dev/runtimes/nodejs/worker.js

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { isStream } from 'is-stream'
1010
import lambdaLocal from 'lambda-local'
1111
import sourceMapSupport from 'source-map-support'
1212

13+
// https://github.com/nodejs/undici/blob/a36e299d544863c5ade17d4090181be894366024/lib/web/fetch/constants.js#L6
14+
const nullBodyStatus = new Set([101, 204, 205, 304])
15+
1316
/**
1417
* @typedef HandlerResponse
1518
* @type {import('../../../src/function/handler_response.js').HandlerResponse}
@@ -46,17 +49,33 @@ const invocationResult = /** @type {HandlerResponse} */ (
4649
})
4750
)
4851

49-
/** @type {number | undefined} */
50-
let streamPort
52+
/**
53+
* When the result body is a stream and result status code allow to have a body,
54+
* open up a http server that proxies back to the main thread and resolve with server port.
55+
* Otherwise, resolve with undefined.
56+
*
57+
* @param {HandlerResponse} invocationResult
58+
* @returns {Promise<number | undefined>}
59+
*/
60+
async function getStreamPortForStreamingResponse(invocationResult) {
61+
// if we don't have result or result's body is not a stream, we do not need a stream port
62+
if (!invocationResult || !isStream(invocationResult.body)) {
63+
return undefined
64+
}
5165

52-
// When the result body is a StreamResponse
53-
// we open up a http server that proxies back to the main thread.
54-
if (invocationResult && isStream(invocationResult.body)) {
5566
const { body } = invocationResult
5667

5768
delete invocationResult.body
5869

59-
await new Promise((resolve, reject) => {
70+
// For streaming responses, lambda-local always returns a result with body stream.
71+
// We need to discard it if result's status code does not allow response to have a body.
72+
const shouldNotHaveABody = nullBodyStatus.has(invocationResult.statusCode)
73+
if (shouldNotHaveABody) {
74+
return undefined
75+
}
76+
77+
// create a server that will proxy the body stream back to the main thread
78+
return await new Promise((resolve, reject) => {
6079
const server = createServer((socket) => {
6180
body.pipe(socket).on('end', () => server.close())
6281
})
@@ -66,15 +85,19 @@ if (invocationResult && isStream(invocationResult.body)) {
6685
server.listen({ port: 0, host: 'localhost' }, () => {
6786
const address = server.address()
6887

88+
/** @type {number | undefined} */
89+
let streamPort
6990
if (address && typeof address !== 'string') {
7091
streamPort = address.port
7192
}
7293

73-
resolve(undefined)
94+
resolve(streamPort)
7495
})
7596
})
7697
}
7798

99+
const streamPort = await getStreamPortForStreamingResponse(invocationResult)
100+
78101
if (parentPort) {
79102
/** @type {WorkerResult} */
80103
const message = { ...invocationResult, streamPort }

packages/functions/vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default defineConfig({
55
target: 'esnext',
66
},
77
test: {
8-
include: ['src/**/*.test.ts'],
8+
include: ['(src|dev)/**/*.test.ts'],
99
testTimeout: 30_000,
1010
},
1111
})

0 commit comments

Comments
 (0)