snapshot-recorder.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. 'use strict'
  2. const { writeFile, readFile, mkdir } = require('node:fs/promises')
  3. const { dirname, resolve } = require('node:path')
  4. const { setTimeout, clearTimeout } = require('node:timers')
  5. const { InvalidArgumentError, UndiciError } = require('../core/errors')
  6. const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require('./snapshot-utils')
  7. /**
  8. * @typedef {Object} SnapshotRequestOptions
  9. * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
  10. * @property {string} path - Request path
  11. * @property {string} origin - Request origin (base URL)
  12. * @property {import('./snapshot-utils').Headers|import('./snapshot-utils').UndiciHeaders} headers - Request headers
  13. * @property {import('./snapshot-utils').NormalizedHeaders} _normalizedHeaders - Request headers as a lowercase object
  14. * @property {string|Buffer} [body] - Request body (optional)
  15. */
  16. /**
  17. * @typedef {Object} SnapshotEntryRequest
  18. * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
  19. * @property {string} url - Full URL of the request
  20. * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
  21. * @property {string|Buffer} [body] - Request body (optional)
  22. */
  23. /**
  24. * @typedef {Object} SnapshotEntryResponse
  25. * @property {number} statusCode - HTTP status code of the response
  26. * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized response headers as a lowercase object
  27. * @property {string} body - Response body as a base64url encoded string
  28. * @property {Object} [trailers] - Optional response trailers
  29. */
  30. /**
  31. * @typedef {Object} SnapshotEntry
  32. * @property {SnapshotEntryRequest} request - The request object
  33. * @property {Array<SnapshotEntryResponse>} responses - Array of response objects
  34. * @property {number} callCount - Number of times this snapshot has been called
  35. * @property {string} timestamp - ISO timestamp of when the snapshot was created
  36. */
  37. /**
  38. * @typedef {Object} SnapshotRecorderMatchOptions
  39. * @property {Array<string>} [matchHeaders=[]] - Headers to match (empty array means match all headers)
  40. * @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
  41. * @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
  42. * @property {boolean} [matchBody=true] - Whether to match request body
  43. * @property {boolean} [matchQuery=true] - Whether to match query properties
  44. * @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
  45. */
  46. /**
  47. * @typedef {Object} SnapshotRecorderOptions
  48. * @property {string} [snapshotPath] - Path to save/load snapshots
  49. * @property {import('./snapshot-utils').SnapshotMode} [mode='record'] - Mode: 'record' or 'playback'
  50. * @property {number} [maxSnapshots=Infinity] - Maximum number of snapshots to keep
  51. * @property {boolean} [autoFlush=false] - Whether to automatically flush snapshots to disk
  52. * @property {number} [flushInterval=30000] - Auto-flush interval in milliseconds (default: 30 seconds)
  53. * @property {Array<string|RegExp>} [excludeUrls=[]] - URLs to exclude from recording
  54. * @property {function} [shouldRecord=null] - Function to filter requests for recording
  55. * @property {function} [shouldPlayback=null] - Function to filter requests
  56. */
  57. /**
  58. * @typedef {Object} SnapshotFormattedRequest
  59. * @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
  60. * @property {string} url - Full URL of the request (with query parameters if matchQuery is true)
  61. * @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
  62. * @property {string} body - Request body (optional, only if matchBody is true)
  63. */
  64. /**
  65. * @typedef {Object} SnapshotInfo
  66. * @property {string} hash - Hash key for the snapshot
  67. * @property {SnapshotEntryRequest} request - The request object
  68. * @property {number} responseCount - Number of responses recorded for this request
  69. * @property {number} callCount - Number of times this snapshot has been called
  70. * @property {string} timestamp - ISO timestamp of when the snapshot was created
  71. */
  72. /**
  73. * Formats a request for consistent snapshot storage
  74. * Caches normalized headers to avoid repeated processing
  75. *
  76. * @param {SnapshotRequestOptions} opts - Request options
  77. * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached header sets for performance
  78. * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers and body
  79. * @returns {SnapshotFormattedRequest} - Formatted request object
  80. */
  81. function formatRequestKey (opts, headerFilters, matchOptions = {}) {
  82. const url = new URL(opts.path, opts.origin)
  83. // Cache normalized headers if not already done
  84. const normalized = opts._normalizedHeaders || normalizeHeaders(opts.headers)
  85. if (!opts._normalizedHeaders) {
  86. opts._normalizedHeaders = normalized
  87. }
  88. return {
  89. method: opts.method || 'GET',
  90. url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
  91. headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
  92. body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
  93. }
  94. }
  95. /**
  96. * Filters headers based on matching configuration
  97. *
  98. * @param {import('./snapshot-utils').Headers} headers - Headers to filter
  99. * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
  100. * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
  101. */
  102. function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) {
  103. if (!headers || typeof headers !== 'object') return {}
  104. const {
  105. caseSensitive = false
  106. } = matchOptions
  107. const filtered = {}
  108. const { ignore, exclude, match } = headerFilters
  109. for (const [key, value] of Object.entries(headers)) {
  110. const headerKey = caseSensitive ? key : key.toLowerCase()
  111. // Skip if in exclude list (for security)
  112. if (exclude.has(headerKey)) continue
  113. // Skip if in ignore list (for matching)
  114. if (ignore.has(headerKey)) continue
  115. // If matchHeaders is specified, only include those headers
  116. if (match.size !== 0) {
  117. if (!match.has(headerKey)) continue
  118. }
  119. filtered[headerKey] = value
  120. }
  121. return filtered
  122. }
  123. /**
  124. * Filters headers for storage (only excludes sensitive headers)
  125. *
  126. * @param {import('./snapshot-utils').Headers} headers - Headers to filter
  127. * @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
  128. * @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
  129. */
  130. function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) {
  131. if (!headers || typeof headers !== 'object') return {}
  132. const {
  133. caseSensitive = false
  134. } = matchOptions
  135. const filtered = {}
  136. const { exclude: excludeSet } = headerFilters
  137. for (const [key, value] of Object.entries(headers)) {
  138. const headerKey = caseSensitive ? key : key.toLowerCase()
  139. // Skip if in exclude list (for security)
  140. if (excludeSet.has(headerKey)) continue
  141. filtered[headerKey] = value
  142. }
  143. return filtered
  144. }
  145. /**
  146. * Creates a hash key for request matching
  147. * Properly orders headers to avoid conflicts and uses crypto hashing when available
  148. *
  149. * @param {SnapshotFormattedRequest} formattedRequest - Request object
  150. * @returns {string} - Base64url encoded hash of the request
  151. */
  152. function createRequestHash (formattedRequest) {
  153. const parts = [
  154. formattedRequest.method,
  155. formattedRequest.url
  156. ]
  157. // Process headers in a deterministic way to avoid conflicts
  158. if (formattedRequest.headers && typeof formattedRequest.headers === 'object') {
  159. const headerKeys = Object.keys(formattedRequest.headers).sort()
  160. for (const key of headerKeys) {
  161. const values = Array.isArray(formattedRequest.headers[key])
  162. ? formattedRequest.headers[key]
  163. : [formattedRequest.headers[key]]
  164. // Add header name
  165. parts.push(key)
  166. // Add all values for this header, sorted for consistency
  167. for (const value of values.sort()) {
  168. parts.push(String(value))
  169. }
  170. }
  171. }
  172. // Add body
  173. parts.push(formattedRequest.body)
  174. const content = parts.join('|')
  175. return hashId(content)
  176. }
  177. class SnapshotRecorder {
  178. /** @type {NodeJS.Timeout | null} */
  179. #flushTimeout
  180. /** @type {import('./snapshot-utils').IsUrlExcluded} */
  181. #isUrlExcluded
  182. /** @type {Map<string, SnapshotEntry>} */
  183. #snapshots = new Map()
  184. /** @type {string|undefined} */
  185. #snapshotPath
  186. /** @type {number} */
  187. #maxSnapshots = Infinity
  188. /** @type {boolean} */
  189. #autoFlush = false
  190. /** @type {import('./snapshot-utils').HeaderFilters} */
  191. #headerFilters
  192. /**
  193. * Creates a new SnapshotRecorder instance
  194. * @param {SnapshotRecorderOptions&SnapshotRecorderMatchOptions} [options={}] - Configuration options for the recorder
  195. */
  196. constructor (options = {}) {
  197. this.#snapshotPath = options.snapshotPath
  198. this.#maxSnapshots = options.maxSnapshots || Infinity
  199. this.#autoFlush = options.autoFlush || false
  200. this.flushInterval = options.flushInterval || 30000 // 30 seconds default
  201. this._flushTimer = null
  202. // Matching configuration
  203. /** @type {Required<SnapshotRecorderMatchOptions>} */
  204. this.matchOptions = {
  205. matchHeaders: options.matchHeaders || [], // empty means match all headers
  206. ignoreHeaders: options.ignoreHeaders || [],
  207. excludeHeaders: options.excludeHeaders || [],
  208. matchBody: options.matchBody !== false, // default: true
  209. matchQuery: options.matchQuery !== false, // default: true
  210. caseSensitive: options.caseSensitive || false
  211. }
  212. // Cache processed header sets to avoid recreating them on every request
  213. this.#headerFilters = createHeaderFilters(this.matchOptions)
  214. // Request filtering callbacks
  215. this.shouldRecord = options.shouldRecord || (() => true) // function(requestOpts) -> boolean
  216. this.shouldPlayback = options.shouldPlayback || (() => true) // function(requestOpts) -> boolean
  217. // URL pattern filtering
  218. this.#isUrlExcluded = isUrlExcludedFactory(options.excludeUrls) // Array of regex patterns or strings
  219. // Start auto-flush timer if enabled
  220. if (this.#autoFlush && this.#snapshotPath) {
  221. this.#startAutoFlush()
  222. }
  223. }
  224. /**
  225. * Records a request-response interaction
  226. * @param {SnapshotRequestOptions} requestOpts - Request options
  227. * @param {SnapshotEntryResponse} response - Response data to record
  228. * @return {Promise<void>} - Resolves when the recording is complete
  229. */
  230. async record (requestOpts, response) {
  231. // Check if recording should be filtered out
  232. if (!this.shouldRecord(requestOpts)) {
  233. return // Skip recording
  234. }
  235. // Check URL exclusion patterns
  236. if (this.isUrlExcluded(requestOpts)) {
  237. return // Skip recording
  238. }
  239. const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
  240. const hash = createRequestHash(request)
  241. // Extract response data - always store body as base64
  242. const normalizedHeaders = normalizeHeaders(response.headers)
  243. /** @type {SnapshotEntryResponse} */
  244. const responseData = {
  245. statusCode: response.statusCode,
  246. headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilters, this.matchOptions),
  247. body: Buffer.isBuffer(response.body)
  248. ? response.body.toString('base64')
  249. : Buffer.from(String(response.body || '')).toString('base64'),
  250. trailers: response.trailers
  251. }
  252. // Remove oldest snapshot if we exceed maxSnapshots limit
  253. if (this.#snapshots.size >= this.#maxSnapshots && !this.#snapshots.has(hash)) {
  254. const oldestKey = this.#snapshots.keys().next().value
  255. this.#snapshots.delete(oldestKey)
  256. }
  257. // Support sequential responses - if snapshot exists, add to responses array
  258. const existingSnapshot = this.#snapshots.get(hash)
  259. if (existingSnapshot && existingSnapshot.responses) {
  260. existingSnapshot.responses.push(responseData)
  261. existingSnapshot.timestamp = new Date().toISOString()
  262. } else {
  263. this.#snapshots.set(hash, {
  264. request,
  265. responses: [responseData], // Always store as array for consistency
  266. callCount: 0,
  267. timestamp: new Date().toISOString()
  268. })
  269. }
  270. // Auto-flush if enabled
  271. if (this.#autoFlush && this.#snapshotPath) {
  272. this.#scheduleFlush()
  273. }
  274. }
  275. /**
  276. * Checks if a URL should be excluded from recording/playback
  277. * @param {SnapshotRequestOptions} requestOpts - Request options to check
  278. * @returns {boolean} - True if URL is excluded
  279. */
  280. isUrlExcluded (requestOpts) {
  281. const url = new URL(requestOpts.path, requestOpts.origin).toString()
  282. return this.#isUrlExcluded(url)
  283. }
  284. /**
  285. * Finds a matching snapshot for the given request
  286. * Returns the appropriate response based on call count for sequential responses
  287. *
  288. * @param {SnapshotRequestOptions} requestOpts - Request options to match
  289. * @returns {SnapshotEntry&Record<'response', SnapshotEntryResponse>|undefined} - Matching snapshot response or undefined if not found
  290. */
  291. findSnapshot (requestOpts) {
  292. // Check if playback should be filtered out
  293. if (!this.shouldPlayback(requestOpts)) {
  294. return undefined // Skip playback
  295. }
  296. // Check URL exclusion patterns
  297. if (this.isUrlExcluded(requestOpts)) {
  298. return undefined // Skip playback
  299. }
  300. const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
  301. const hash = createRequestHash(request)
  302. const snapshot = this.#snapshots.get(hash)
  303. if (!snapshot) return undefined
  304. // Handle sequential responses
  305. const currentCallCount = snapshot.callCount || 0
  306. const responseIndex = Math.min(currentCallCount, snapshot.responses.length - 1)
  307. snapshot.callCount = currentCallCount + 1
  308. return {
  309. ...snapshot,
  310. response: snapshot.responses[responseIndex]
  311. }
  312. }
  313. /**
  314. * Loads snapshots from file
  315. * @param {string} [filePath] - Optional file path to load snapshots from
  316. * @return {Promise<void>} - Resolves when snapshots are loaded
  317. */
  318. async loadSnapshots (filePath) {
  319. const path = filePath || this.#snapshotPath
  320. if (!path) {
  321. throw new InvalidArgumentError('Snapshot path is required')
  322. }
  323. try {
  324. const data = await readFile(resolve(path), 'utf8')
  325. const parsed = JSON.parse(data)
  326. // Convert array format back to Map
  327. if (Array.isArray(parsed)) {
  328. this.#snapshots.clear()
  329. for (const { hash, snapshot } of parsed) {
  330. this.#snapshots.set(hash, snapshot)
  331. }
  332. } else {
  333. // Legacy object format
  334. this.#snapshots = new Map(Object.entries(parsed))
  335. }
  336. } catch (error) {
  337. if (error.code === 'ENOENT') {
  338. // File doesn't exist yet - that's ok for recording mode
  339. this.#snapshots.clear()
  340. } else {
  341. throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error })
  342. }
  343. }
  344. }
  345. /**
  346. * Saves snapshots to file
  347. *
  348. * @param {string} [filePath] - Optional file path to save snapshots
  349. * @returns {Promise<void>} - Resolves when snapshots are saved
  350. */
  351. async saveSnapshots (filePath) {
  352. const path = filePath || this.#snapshotPath
  353. if (!path) {
  354. throw new InvalidArgumentError('Snapshot path is required')
  355. }
  356. const resolvedPath = resolve(path)
  357. // Ensure directory exists
  358. await mkdir(dirname(resolvedPath), { recursive: true })
  359. // Convert Map to serializable format
  360. const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
  361. hash,
  362. snapshot
  363. }))
  364. await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true })
  365. }
  366. /**
  367. * Clears all recorded snapshots
  368. * @returns {void}
  369. */
  370. clear () {
  371. this.#snapshots.clear()
  372. }
  373. /**
  374. * Gets all recorded snapshots
  375. * @return {Array<SnapshotEntry>} - Array of all recorded snapshots
  376. */
  377. getSnapshots () {
  378. return Array.from(this.#snapshots.values())
  379. }
  380. /**
  381. * Gets snapshot count
  382. * @return {number} - Number of recorded snapshots
  383. */
  384. size () {
  385. return this.#snapshots.size
  386. }
  387. /**
  388. * Resets call counts for all snapshots (useful for test cleanup)
  389. * @returns {void}
  390. */
  391. resetCallCounts () {
  392. for (const snapshot of this.#snapshots.values()) {
  393. snapshot.callCount = 0
  394. }
  395. }
  396. /**
  397. * Deletes a specific snapshot by request options
  398. * @param {SnapshotRequestOptions} requestOpts - Request options to match
  399. * @returns {boolean} - True if snapshot was deleted, false if not found
  400. */
  401. deleteSnapshot (requestOpts) {
  402. const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
  403. const hash = createRequestHash(request)
  404. return this.#snapshots.delete(hash)
  405. }
  406. /**
  407. * Gets information about a specific snapshot
  408. * @param {SnapshotRequestOptions} requestOpts - Request options to match
  409. * @returns {SnapshotInfo|null} - Snapshot information or null if not found
  410. */
  411. getSnapshotInfo (requestOpts) {
  412. const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
  413. const hash = createRequestHash(request)
  414. const snapshot = this.#snapshots.get(hash)
  415. if (!snapshot) return null
  416. return {
  417. hash,
  418. request: snapshot.request,
  419. responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots
  420. callCount: snapshot.callCount || 0,
  421. timestamp: snapshot.timestamp
  422. }
  423. }
  424. /**
  425. * Replaces all snapshots with new data (full replacement)
  426. * @param {Array<{hash: string; snapshot: SnapshotEntry}>|Record<string, SnapshotEntry>} snapshotData - New snapshot data to replace existing ones
  427. * @returns {void}
  428. */
  429. replaceSnapshots (snapshotData) {
  430. this.#snapshots.clear()
  431. if (Array.isArray(snapshotData)) {
  432. for (const { hash, snapshot } of snapshotData) {
  433. this.#snapshots.set(hash, snapshot)
  434. }
  435. } else if (snapshotData && typeof snapshotData === 'object') {
  436. // Legacy object format
  437. this.#snapshots = new Map(Object.entries(snapshotData))
  438. }
  439. }
  440. /**
  441. * Starts the auto-flush timer
  442. * @returns {void}
  443. */
  444. #startAutoFlush () {
  445. return this.#scheduleFlush()
  446. }
  447. /**
  448. * Stops the auto-flush timer
  449. * @returns {void}
  450. */
  451. #stopAutoFlush () {
  452. if (this.#flushTimeout) {
  453. clearTimeout(this.#flushTimeout)
  454. // Ensure any pending flush is completed
  455. this.saveSnapshots().catch(() => {
  456. // Ignore flush errors
  457. })
  458. this.#flushTimeout = null
  459. }
  460. }
  461. /**
  462. * Schedules a flush (debounced to avoid excessive writes)
  463. */
  464. #scheduleFlush () {
  465. this.#flushTimeout = setTimeout(() => {
  466. this.saveSnapshots().catch(() => {
  467. // Ignore flush errors
  468. })
  469. if (this.#autoFlush) {
  470. this.#flushTimeout?.refresh()
  471. } else {
  472. this.#flushTimeout = null
  473. }
  474. }, 1000) // 1 second debounce
  475. }
  476. /**
  477. * Cleanup method to stop timers
  478. * @returns {void}
  479. */
  480. destroy () {
  481. this.#stopAutoFlush()
  482. if (this.#flushTimeout) {
  483. clearTimeout(this.#flushTimeout)
  484. this.#flushTimeout = null
  485. }
  486. }
  487. /**
  488. * Async close method that saves all recordings and performs cleanup
  489. * @returns {Promise<void>}
  490. */
  491. async close () {
  492. // Save any pending recordings if we have a snapshot path
  493. if (this.#snapshotPath && this.#snapshots.size !== 0) {
  494. await this.saveSnapshots()
  495. }
  496. // Perform cleanup
  497. this.destroy()
  498. }
  499. }
  500. module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters }