-
Notifications
You must be signed in to change notification settings - Fork 407
Expand file tree
/
Copy pathfastify.js
More file actions
383 lines (306 loc) · 12.7 KB
/
Copy pathfastify.js
File metadata and controls
383 lines (306 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
'use strict'
const shimmer = require('../../datadog-shimmer')
const { addHook, channel, createErrorPublisher } = require('./helpers/instrument')
const errorChannel = channel('apm:fastify:middleware:error')
const publishErrorChannel = createErrorPublisher(errorChannel)
const handleChannel = channel('apm:fastify:request:handle')
const routeAddedChannel = channel('apm:fastify:route:added')
const bodyParserReadCh = channel('datadog:fastify:body-parser:finish')
const queryParamsReadCh = channel('datadog:fastify:query-params:finish')
const cookieParserReadCh = channel('datadog:fastify-cookie:read:finish')
const responsePayloadReadCh = channel('datadog:fastify:response:finish')
const pathParamsReadCh = channel('datadog:fastify:path-params:finish')
const finishSetHeaderCh = channel('datadog:fastify:set-header:finish')
// context management channels
const preParsingCh = channel('datadog:fastify:pre-parsing:start')
const preValidationCh = channel('datadog:fastify:pre-validation:start')
const callbackFinishCh = channel('datadog:fastify:callback:execute')
const parsingContexts = new WeakMap()
const cookiesPublished = new WeakSet()
const bodyPublished = new WeakSet()
let lastPublishedError
let lastPublishedReq
/** @typedef {{ length: number, [index: number]: unknown } & Iterable<unknown>} ArgumentsLike */
function wrapFastify (fastify, hasParsingEvents) {
if (typeof fastify !== 'function') return fastify
return function fastifyWithTrace (...args) {
const app = fastify.apply(this, args)
if (!app || typeof app.addHook !== 'function') return app
app.addHook('onRoute', onRoute)
app.addHook('onRequest', onRequest)
app.addHook('preHandler', preHandler)
if (hasParsingEvents) {
app.addHook('preParsing', preParsing)
app.addHook('preValidation', preValidation)
} else {
app.addHook('onRequest', preParsing)
app.addHook('preHandler', preValidation)
}
app.addHook = wrapAddHook(app.addHook)
return app
}
}
function wrapAddHook (addHook) {
return shimmer.wrapFunction(addHook, addHook => function addHookWithTrace (name, fn) {
fn = arguments[arguments.length - 1]
if (typeof fn !== 'function') return addHook.apply(this, arguments)
arguments[arguments.length - 1] = shimmer.wrapFunction(fn, fn => function wrappedHook () {
// Every fastify request invokes each addHook'd handler, so this wrapper runs in the
// user's hot path. When none of the three channels below has a subscriber (the default
// plugin config, and the steady state once appsec / cookie subscribers detach), forward
// `arguments` untouched: no args array is materialised and V8's CallApplyArguments fast
// path stays intact. The slow path copies/indexes the args only when it has work to do.
if (errorChannel.hasSubscribers || cookieParserReadCh.hasSubscribers || callbackFinishCh.hasSubscribers) {
return invokeHookWithContext(name, fn, this, arguments)
}
return fn.apply(this, arguments)
})
return addHook.apply(this, arguments)
})
}
/**
* Slow path of {@link wrapAddHook}; entered only when at least one wrap-fed
* channel has a subscriber. Allocates the per-request context, rewraps `done`,
* and forwards to the user-supplied hook.
*
* @param {string} name Lifecycle phase the hook was registered against.
* @param {Function} fn User-supplied hook.
* @param {unknown} thisArg `this` Fastify passes to the hook.
* @param {ArgumentsLike} args Fastify's positional args; the dispatcher always
* places `done` as the trailing positional (see fastify/lib/hooks.js hookIterator,
* onSendHookRunner, preParsingHookRunner, onRequestAbortHookRunner).
*/
function invokeHookWithContext (name, fn, thisArg, args) {
const request = args[0]
const reply = args[1]
const req = getReq(request)
const ctx = { req }
try {
// `args` is the wrapper's `arguments` object, which has no `Array#at`.
// eslint-disable-next-line unicorn/prefer-at
const lastArg = args[args.length - 1]
if (typeof lastArg === 'function') {
// Copy the args so we can swap the trailing `done` without touching the
// caller's magical arguments object. Fastify hook arities are 2 to 4
// across lifecycle phases, but `done` is always last.
const callArgs = [...args]
callArgs[callArgs.length - 1] = wrapHookDone(ctx, request, reply, req, name, lastArg)
return fn.apply(thisArg, callArgs)
}
const promise = fn.apply(thisArg, args)
if (promise && typeof promise.catch === 'function') {
// Observe the rejection to publish, then hand back the original promise so
// the rejection keeps propagating untouched. Returning the handler's
// promise instead would resolve with `undefined` and swallow the rejection.
promise.catch(error => {
ctx.error = error
publishError(ctx)
})
}
return promise
} catch (error) {
ctx.error = error
publishError(ctx)
throw error
}
}
/**
* Per-request closure invoked when fastify resolves the user hook's `done`.
* Captures `ctx` plus the dispatcher-level fields needed to publish on the
* cookie / callback channels. The closure cannot be hoisted: fastify invokes
* `done` with a single `(err)` arg, so request / reply / req / name / doneCallback
* must close over rather than ride the call signature.
*
* @param {{ req: unknown, [key: string]: unknown }} ctx
* @param {{ cookies?: Record<string, unknown>, [key: string]: unknown }} request
* @param {object} reply
* @param {unknown} req
* @param {string} name
* @param {Function} doneCallback
*/
function wrapHookDone (ctx, request, reply, req, name, doneCallback) {
return function wrappedDone (error) {
ctx.error = error
publishError(ctx)
// eslint-disable-next-line no-restricted-syntax -- arbitrary cookie names; publishing {} sets a WAF address
const hasCookies = request.cookies && Object.keys(request.cookies).length > 0
if (cookieParserReadCh.hasSubscribers && hasCookies && !cookiesPublished.has(req)) {
ctx.res = getRes(reply)
ctx.abortController = new AbortController()
ctx.cookies = request.cookies
cookieParserReadCh.publish(ctx)
cookiesPublished.add(req)
if (ctx.abortController.signal.aborted) return
}
if (name === 'onRequest' || name === 'preParsing') {
parsingContexts.set(req, ctx)
if (callbackFinishCh.hasSubscribers) {
const self = this
const allArgs = arguments
return callbackFinishCh.runStores(ctx, () => doneCallback.apply(self, allArgs))
}
}
return doneCallback.apply(this, arguments)
}
}
function onRequest (request, reply, done) {
if (typeof done !== 'function') return
const req = getReq(request)
const res = getRes(reply)
const routeConfig = getRouteConfig(request)
const ctx = { req, res, routeConfig }
handleChannel.publish(ctx)
return done()
}
function preHandler (request, reply, done) {
if (typeof done !== 'function') return
if (!reply || typeof reply.send !== 'function') return done()
const req = getReq(request)
const res = getRes(reply)
const ctx = { req, res }
// eslint-disable-next-line no-restricted-syntax -- arbitrary body keys; publishing {} sets a WAF address
const hasBody = request.body && Object.keys(request.body).length > 0
// For multipart/form-data, the body is not available until after preValidation hook
if (bodyParserReadCh.hasSubscribers && hasBody && !bodyPublished.has(req)) {
ctx.abortController = new AbortController()
ctx.body = request.body
bodyParserReadCh.publish(ctx)
bodyPublished.add(req)
if (ctx.abortController.signal.aborted) return
}
reply.send = wrapSend(reply.send, req)
done()
}
function preValidation (request, reply, done) {
const req = getReq(request)
const ctx = parsingContexts.get(req)
// No stored context means the onRequest/preParsing fast path ran (no error /
// cookie / callback subscribers), so there is nothing to publish on; forward
// `done` instead of dereferencing a missing ctx in processInContext.
if (!ctx) return done()
ctx.res = getRes(reply)
preValidationCh.runStores(ctx, processInContext, undefined, request, ctx, done, req)
}
/**
* @param {{ query?: object, body?: object, params?: object, [key: string]: unknown }} request
* @param {{ res?: object, abortController?: AbortController, [key: string]: unknown }} ctx
* @param {Function} done
* @param {unknown} req
*/
function processInContext (request, ctx, done, req) {
let abortController
if (queryParamsReadCh.hasSubscribers && request.query) {
abortController ??= new AbortController()
ctx.abortController = abortController
ctx.query = request.query
queryParamsReadCh.publish(ctx)
if (abortController.signal.aborted) return
}
// Analyze body before schema validation
if (bodyParserReadCh.hasSubscribers && request.body && !bodyPublished.has(req)) {
abortController ??= new AbortController()
ctx.abortController = abortController
ctx.body = request.body
bodyParserReadCh.publish(ctx)
bodyPublished.add(req)
if (abortController.signal.aborted) return
}
if (pathParamsReadCh.hasSubscribers && request.params) {
abortController ??= new AbortController()
ctx.abortController = abortController
ctx.params = request.params
pathParamsReadCh.publish(ctx)
if (abortController.signal.aborted) return
}
done()
}
function preParsing (request, reply, payload, done) {
if (typeof done !== 'function') {
done = payload
}
const req = getReq(request)
const ctx = { req }
parsingContexts.set(req, ctx)
preParsingCh.runStores(ctx, () => done())
}
function wrapSend (send, req) {
return function sendWithTrace (payload) {
const ctx = { req }
if (payload instanceof Error) {
ctx.error = payload
publishError(ctx)
} else if (canPublishResponsePayload(payload)) {
const res = getRes(this)
ctx.res = res
ctx.body = payload
responsePayloadReadCh.publish(ctx)
}
return send.apply(this, arguments)
}
}
function getReq (request) {
return request && (request.raw || request.req || request)
}
function getRes (reply) {
return reply && (reply.raw || reply.res || reply)
}
function getRouteConfig (request) {
return request?.routeOptions?.config
}
function publishError (ctx) {
const error = ctx.error
if (!error) return
// avvio's boot loop (`_encapsulateThreeParam`) re-invokes the same encapsulated
// hook after it throws, re-throwing the same error object on every sequential
// re-drive (#9099), recursing the subscriber until boot overflows the stack.
// The subscribers tag once per request, so the guard collapses only a re-drive
// of the same error against the same request; a distinct request reusing a
// cached error object still publishes. Boot hooks carry no request, so their
// re-drives share the same `undefined` req and collapse after the first. The
// re-drive re-throws the one caught error on the trailing hop, so a compare
// against the previous publish bounds it without a per-error side table.
const req = ctx.req
if (error === lastPublishedError && req === lastPublishedReq) return
lastPublishedError = error
lastPublishedReq = req
publishErrorChannel(ctx)
}
function onRoute (routeOptions) {
const ctx = { routeOptions, onRoute }
routeAddedChannel.publish(ctx)
}
// send() payload types: https://fastify.dev/docs/latest/Reference/Reply/#senddata
function canPublishResponsePayload (payload) {
return responsePayloadReadCh.hasSubscribers &&
payload &&
typeof payload === 'object' &&
typeof payload.pipe !== 'function' && // Node streams
typeof payload.body?.pipe !== 'function' && // Response with body stream
!Buffer.isBuffer(payload) && // Buffer
!(payload instanceof ArrayBuffer) && // ArrayBuffer
!ArrayBuffer.isView(payload) // TypedArray
}
addHook({ name: 'fastify', versions: ['>=3'] }, (fastify) => {
const wrapped = shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, true))
wrapped.fastify = wrapped
wrapped.default = wrapped
return wrapped
})
addHook({ name: 'fastify', versions: ['2'] }, (fastify) => {
return shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, true))
})
addHook({ name: 'fastify', versions: ['1'] }, (fastify) => {
return shimmer.wrapFunction(fastify, fastify => wrapFastify(fastify, false))
})
function wrapReplyHeader (Reply) {
shimmer.wrap(Reply.prototype, 'header', header => function (key, value) {
const result = header.apply(this, arguments)
if (finishSetHeaderCh.hasSubscribers && key && value) {
const ctx = { name: key, value, res: getRes(this) }
finishSetHeaderCh.publish(ctx)
}
return result
})
return Reply
}
addHook({ name: 'fastify', file: 'lib/reply.js', versions: ['>=1'] }, wrapReplyHeader)