Skip to content

Commit 871baa7

Browse files
authored
feat: Add resource timing entries for connection, request and response (#2481)
1 parent e1ab8b9 commit 871baa7

File tree

8 files changed

+229
-2
lines changed

8 files changed

+229
-2
lines changed

docs/api/Dispatcher.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo
209209
* **onConnect** `(abort: () => void, context: object) => void` - Invoked before request is dispatched on socket. May be invoked multiple times when a request is retried when the request at the head of the pipeline fails.
210210
* **onError** `(error: Error) => void` - Invoked when an error has occurred. May not throw.
211211
* **onUpgrade** `(statusCode: number, headers: Buffer[], socket: Duplex) => void` (optional) - Invoked when request is upgraded. Required if `DispatchOptions.upgrade` is defined or `DispatchOptions.method === 'CONNECT'`.
212+
* **onResponseStarted** `() => void` (optional) - Invoked when response is received, before headers have been read.
212213
* **onHeaders** `(statusCode: number, headers: Buffer[], resume: () => void, statusText: string) => boolean` - Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. Not required for `upgrade` requests.
213214
* **onData** `(chunk: Buffer) => boolean` - Invoked when response payload data is received. Not required for `upgrade` requests.
214215
* **onComplete** `(trailers: Buffer[]) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests.

lib/client.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ class Parser {
740740
if (!request) {
741741
return -1
742742
}
743+
request.onResponseStarted()
743744
}
744745

745746
onHeaderField (buf) {
@@ -1786,6 +1787,7 @@ function writeH2 (client, session, request) {
17861787

17871788
stream.once('response', headers => {
17881789
const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
1790+
request.onResponseStarted()
17891791

17901792
if (request.onHeaders(Number(statusCode), realHeaders, stream.resume.bind(stream), '') === false) {
17911793
stream.pause()

lib/core/request.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ class Request {
253253
}
254254
}
255255

256+
onResponseStarted () {
257+
return this[kHandler].onResponseStarted?.()
258+
}
259+
256260
onHeaders (statusCode, headers, resume, statusText) {
257261
assert(!this.aborted)
258262
assert(!this.completed)

lib/fetch/index.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const {
4141
urlIsLocal,
4242
urlIsHttpHttpsScheme,
4343
urlHasHttpsScheme,
44+
clampAndCoursenConnectionTimingInfo,
4445
simpleRangeHeaderValue,
4546
buildContentRange
4647
} = require('./util')
@@ -2098,12 +2099,30 @@ async function httpNetworkFetch (
20982099
// TODO (fix): Do we need connection here?
20992100
const { connection } = fetchParams.controller
21002101

2102+
// Set timingInfo’s final connection timing info to the result of calling clamp and coarsen
2103+
// connection timing info with connection’s timing info, timingInfo’s post-redirect start
2104+
// time, and fetchParams’s cross-origin isolated capability.
2105+
// TODO: implement connection timing
2106+
timingInfo.finalConnectionTimingInfo = clampAndCoursenConnectionTimingInfo(undefined, timingInfo.postRedirectStartTime, fetchParams.crossOriginIsolatedCapability)
2107+
21012108
if (connection.destroyed) {
21022109
abort(new DOMException('The operation was aborted.', 'AbortError'))
21032110
} else {
21042111
fetchParams.controller.on('terminated', abort)
21052112
this.abort = connection.abort = abort
21062113
}
2114+
2115+
// Set timingInfo’s final network-request start time to the coarsened shared current time given
2116+
// fetchParams’s cross-origin isolated capability.
2117+
timingInfo.finalNetworkRequestStartTime = coarsenedSharedCurrentTime(fetchParams.crossOriginIsolatedCapability)
2118+
},
2119+
2120+
onResponseStarted () {
2121+
// Set timingInfo’s final network-response start time to the coarsened shared current
2122+
// time given fetchParams’s cross-origin isolated capability, immediately after the
2123+
// user agent’s HTTP parser receives the first byte of the response (e.g., frame header
2124+
// bytes for HTTP/2 or response status line for HTTP/1.x).
2125+
timingInfo.finalNetworkResponseStartTime = coarsenedSharedCurrentTime(fetchParams.crossOriginIsolatedCapability)
21072126
},
21082127

21092128
onHeaders (status, rawHeaders, resume, statusText) {

lib/fetch/util.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,38 @@ function appendRequestOriginHeader (request) {
265265
}
266266
}
267267

268-
function coarsenedSharedCurrentTime (crossOriginIsolatedCapability) {
268+
// https://w3c.github.io/hr-time/#dfn-coarsen-time
269+
function coarsenTime (timestamp, crossOriginIsolatedCapability) {
269270
// TODO
270-
return performance.now()
271+
return timestamp
272+
}
273+
274+
// https://fetch.spec.whatwg.org/#clamp-and-coarsen-connection-timing-info
275+
function clampAndCoursenConnectionTimingInfo (connectionTimingInfo, defaultStartTime, crossOriginIsolatedCapability) {
276+
if (!connectionTimingInfo?.startTime || connectionTimingInfo.startTime < defaultStartTime) {
277+
return {
278+
domainLookupStartTime: defaultStartTime,
279+
domainLookupEndTime: defaultStartTime,
280+
connectionStartTime: defaultStartTime,
281+
connectionEndTime: defaultStartTime,
282+
secureConnectionStartTime: defaultStartTime,
283+
ALPNNegotiatedProtocol: connectionTimingInfo?.ALPNNegotiatedProtocol
284+
}
285+
}
286+
287+
return {
288+
domainLookupStartTime: coarsenTime(connectionTimingInfo.domainLookupStartTime, crossOriginIsolatedCapability),
289+
domainLookupEndTime: coarsenTime(connectionTimingInfo.domainLookupEndTime, crossOriginIsolatedCapability),
290+
connectionStartTime: coarsenTime(connectionTimingInfo.connectionStartTime, crossOriginIsolatedCapability),
291+
connectionEndTime: coarsenTime(connectionTimingInfo.connectionEndTime, crossOriginIsolatedCapability),
292+
secureConnectionStartTime: coarsenTime(connectionTimingInfo.secureConnectionStartTime, crossOriginIsolatedCapability),
293+
ALPNNegotiatedProtocol: connectionTimingInfo.ALPNNegotiatedProtocol
294+
}
295+
}
296+
297+
// https://w3c.github.io/hr-time/#dfn-coarsened-shared-current-time
298+
function coarsenedSharedCurrentTime (crossOriginIsolatedCapability) {
299+
return coarsenTime(performance.now(), crossOriginIsolatedCapability)
271300
}
272301

273302
// https://fetch.spec.whatwg.org/#create-an-opaque-timing-info
@@ -1145,6 +1174,7 @@ module.exports = {
11451174
ReadableStreamFrom,
11461175
toUSVString,
11471176
tryUpgradeRequestToAPotentiallyTrustworthyURL,
1177+
clampAndCoursenConnectionTimingInfo,
11481178
coarsenedSharedCurrentTime,
11491179
determineRequestsReferrer,
11501180
makePolicyContainer,

test/client-dispatch.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const { test } = require('tap')
44
const http = require('http')
55
const { Client, Pool, errors } = require('..')
66
const stream = require('stream')
7+
const { createSecureServer } = require('node:http2')
8+
const pem = require('https-pem')
79

810
test('dispatch invalid opts', (t) => {
911
t.plan(14)
@@ -813,3 +815,104 @@ test('dispatch onBodySent throws error', (t) => {
813815
})
814816
})
815817
})
818+
819+
test('dispatches in expected order', (t) => {
820+
const server = http.createServer((req, res) => {
821+
res.end('ended')
822+
})
823+
t.teardown(server.close.bind(server))
824+
825+
server.listen(0, () => {
826+
const client = new Pool(`http://localhost:${server.address().port}`)
827+
828+
t.plan(1)
829+
t.teardown(client.close.bind(client))
830+
831+
const dispatches = []
832+
833+
client.dispatch({
834+
path: '/',
835+
method: 'POST',
836+
body: 'body'
837+
}, {
838+
onConnect () {
839+
dispatches.push('onConnect')
840+
},
841+
onBodySent () {
842+
dispatches.push('onBodySent')
843+
},
844+
onResponseStarted () {
845+
dispatches.push('onResponseStarted')
846+
},
847+
onHeaders () {
848+
dispatches.push('onHeaders')
849+
},
850+
onData () {
851+
dispatches.push('onData')
852+
},
853+
onComplete () {
854+
dispatches.push('onComplete')
855+
t.same(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders', 'onData', 'onComplete'])
856+
},
857+
onError (err) {
858+
t.error(err)
859+
}
860+
})
861+
})
862+
})
863+
864+
test('dispatches in expected order for http2', (t) => {
865+
const server = createSecureServer(pem)
866+
server.on('stream', (stream) => {
867+
stream.respond({
868+
'content-type': 'text/plain; charset=utf-8',
869+
':status': 200
870+
})
871+
stream.end('ended')
872+
})
873+
874+
t.teardown(server.close.bind(server))
875+
876+
server.listen(0, () => {
877+
const client = new Pool(`https://localhost:${server.address().port}`, {
878+
connect: {
879+
rejectUnauthorized: false
880+
},
881+
allowH2: true
882+
})
883+
884+
t.plan(1)
885+
t.teardown(client.close.bind(client))
886+
887+
const dispatches = []
888+
889+
client.dispatch({
890+
path: '/',
891+
method: 'POST',
892+
body: 'body'
893+
}, {
894+
onConnect () {
895+
dispatches.push('onConnect')
896+
},
897+
onBodySent () {
898+
dispatches.push('onBodySent')
899+
},
900+
onResponseStarted () {
901+
dispatches.push('onResponseStarted')
902+
},
903+
onHeaders () {
904+
dispatches.push('onHeaders')
905+
},
906+
onData () {
907+
dispatches.push('onData')
908+
},
909+
onComplete () {
910+
dispatches.push('onComplete')
911+
t.same(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders', 'onData', 'onComplete'])
912+
},
913+
onError (err) {
914+
t.error(err)
915+
}
916+
})
917+
})
918+
})

test/fetch/resource-timing.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,69 @@ test('should include encodedBodySize in performance entry', { skip }, (t) => {
7070

7171
t.teardown(server.close.bind(server))
7272
})
73+
74+
test('timing entries should be in order', { skip }, (t) => {
75+
t.plan(13)
76+
const obs = new PerformanceObserver(list => {
77+
const [entry] = list.getEntries()
78+
79+
t.ok(entry.startTime > 0)
80+
t.ok(entry.fetchStart >= entry.startTime)
81+
t.ok(entry.domainLookupStart >= entry.fetchStart)
82+
t.ok(entry.domainLookupEnd >= entry.domainLookupStart)
83+
t.ok(entry.connectStart >= entry.domainLookupEnd)
84+
t.ok(entry.connectEnd >= entry.connectStart)
85+
t.ok(entry.requestStart >= entry.connectEnd)
86+
t.ok(entry.responseStart >= entry.requestStart)
87+
t.ok(entry.responseEnd >= entry.responseStart)
88+
t.ok(entry.duration > 0)
89+
90+
t.ok(entry.redirectStart === 0)
91+
t.ok(entry.redirectEnd === 0)
92+
93+
obs.disconnect()
94+
performance.clearResourceTimings()
95+
})
96+
97+
obs.observe({ entryTypes: ['resource'] })
98+
99+
const server = createServer((req, res) => {
100+
res.end('ok')
101+
}).listen(0, async () => {
102+
const body = await fetch(`http://localhost:${server.address().port}/redirect`)
103+
t.strictSame('ok', await body.text())
104+
})
105+
106+
t.teardown(server.close.bind(server))
107+
})
108+
109+
test('redirect timing entries should be included when redirecting', { skip }, (t) => {
110+
t.plan(4)
111+
const obs = new PerformanceObserver(list => {
112+
const [entry] = list.getEntries()
113+
114+
t.ok(entry.redirectStart >= entry.startTime)
115+
t.ok(entry.redirectEnd >= entry.redirectStart)
116+
t.ok(entry.connectStart >= entry.redirectEnd)
117+
118+
obs.disconnect()
119+
performance.clearResourceTimings()
120+
})
121+
122+
obs.observe({ entryTypes: ['resource'] })
123+
124+
const server = createServer((req, res) => {
125+
if (req.url === '/redirect') {
126+
res.statusCode = 307
127+
res.setHeader('location', '/redirect/')
128+
res.end()
129+
return
130+
}
131+
res.end('ok')
132+
}).listen(0, async () => {
133+
const body = await fetch(`http://localhost:${server.address().port}/redirect`)
134+
t.strictSame('ok', await body.text())
135+
})
136+
137+
t.teardown(server.close.bind(server))
138+
})

types/dispatcher.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ declare namespace Dispatcher {
210210
onError?(err: Error): void;
211211
/** Invoked when request is upgraded either due to a `Upgrade` header or `CONNECT` method. */
212212
onUpgrade?(statusCode: number, headers: Buffer[] | string[] | null, socket: Duplex): void;
213+
/** Invoked when response is received, before headers have been read. **/
214+
onResponseStarted?(): void;
213215
/** Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. */
214216
onHeaders?(statusCode: number, headers: Buffer[] | string[] | null, resume: () => void, statusText: string): boolean;
215217
/** Invoked when response payload data is received. */

0 commit comments

Comments
 (0)