diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index 4e344628565..55179000eba 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -1264,6 +1264,7 @@ function writeH2 (client, request) { // become unreachable once the stream closes, so plain `on` avoids the // per-listener `once` wrapper allocation. stream.on('response', onResponse) + stream.on('headers', onInterimResponse) stream.on('end', onEnd) stream.on('error', onError) stream.on('frameError', onFrameError) @@ -1284,6 +1285,7 @@ function removeRequestStreamListeners (stream) { stream.off('error', noop) stream.off('continue', writeBodyH2) stream.off('response', onResponse) + stream.off('headers', onInterimResponse) stream.off('end', onEnd) stream.off('error', onError) stream.off('frameError', onFrameError) @@ -1347,6 +1349,30 @@ function onData (chunk) { } } +function onInterimResponse (headers) { + const stream = this + const state = stream[kRequestStreamState] + + if (state == null) { + return + } + + const { request } = state + + if (request.aborted || request.completed) { + return + } + + // node http2 emits 'headers' for interim (1xx) informational responses, + // while the final response arrives via 'response'. Forward these to the + // handler so that onInfo is invoked, matching the HTTP/1 behaviour and the + // documented onInfo contract. + const statusCode = headers[HTTP2_HEADER_STATUS] + delete headers[HTTP2_HEADER_STATUS] + + request.onResponseStart(Number(statusCode), headers, noop, '') +} + function onResponse (headers) { const stream = this const state = stream[kRequestStreamState] diff --git a/test/http2-informational.js b/test/http2-informational.js new file mode 100644 index 00000000000..ca9f403be2d --- /dev/null +++ b/test/http2-informational.js @@ -0,0 +1,58 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { createSecureServer } = require('node:http2') +const { once } = require('node:events') + +const pem = require('@metcoder95/https-pem') + +const { Client } = require('..') + +// https://github.com/nodejs/undici/blob/main/docs/docs/api/Dispatcher.md +// `onInfo` is documented as "Invoked for each informational (1xx) response". +// HTTP/1 already forwards 1xx responses (e.g. 103 Early Hints) to `onInfo`; +// HTTP/2 must do the same via the http2 stream `headers` event. +test('h2 forwards 1xx informational responses to onInfo', async t => { + t = tspl(t, { plan: 4 }) + + const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } })) + + server.on('stream', (stream) => { + stream.additionalHeaders({ + ':status': 103, + link: '; rel=preload; as=style' + }) + stream.respond({ + ':status': 200, + 'content-type': 'text/plain' + }) + stream.end('hello h2!') + }) + + after(() => server.close()) + await once(server.listen(0), 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + after(() => client.close()) + + const infos = [] + const response = await client.request({ + path: '/', + method: 'GET', + onInfo: (info) => infos.push(info) + }) + + t.strictEqual(response.statusCode, 200) + t.strictEqual(infos.length, 1) + t.strictEqual(infos[0].statusCode, 103) + t.strictEqual(infos[0].headers.link, '; rel=preload; as=style') + + await response.body.text() + await t.completed +})