File: /var/www/indoadvisory_new/webapp/node_modules/undici/lib/mock/snapshot-recorder.js
'use strict'
const { writeFile, readFile, mkdir } = require('node:fs/promises')
const { dirname, resolve } = require('node:path')
const { setTimeout, clearTimeout } = require('node:timers')
const { InvalidArgumentError, UndiciError } = require('../core/errors')
const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require('./snapshot-utils')
/**
* @typedef {Object} SnapshotRequestOptions
* @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
* @property {string} path - Request path
* @property {string} origin - Request origin (base URL)
* @property {import('./snapshot-utils').Headers|import('./snapshot-utils').UndiciHeaders} headers - Request headers
* @property {import('./snapshot-utils').NormalizedHeaders} _normalizedHeaders - Request headers as a lowercase object
* @property {string|Buffer} [body] - Request body (optional)
*/
/**
* @typedef {Object} SnapshotEntryRequest
* @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
* @property {string} url - Full URL of the request
* @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
* @property {string|Buffer} [body] - Request body (optional)
*/
/**
* @typedef {Object} SnapshotEntryResponse
* @property {number} statusCode - HTTP status code of the response
* @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized response headers as a lowercase object
* @property {string} body - Response body as a base64url encoded string
* @property {Object} [trailers] - Optional response trailers
*/
/**
* @typedef {Object} SnapshotEntry
* @property {SnapshotEntryRequest} request - The request object
* @property {Array<SnapshotEntryResponse>} responses - Array of response objects
* @property {number} callCount - Number of times this snapshot has been called
* @property {string} timestamp - ISO timestamp of when the snapshot was created
*/
/**
* @typedef {Object} SnapshotRecorderMatchOptions
* @property {Array<string>} [matchHeaders=[]] - Headers to match (empty array means match all headers)
* @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
* @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
* @property {boolean} [matchBody=true] - Whether to match request body
* @property {boolean} [matchQuery=true] - Whether to match query properties
* @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
*/
/**
* @typedef {Object} SnapshotRecorderOptions
* @property {string} [snapshotPath] - Path to save/load snapshots
* @property {import('./snapshot-utils').SnapshotMode} [mode='record'] - Mode: 'record' or 'playback'
* @property {number} [maxSnapshots=Infinity] - Maximum number of snapshots to keep
* @property {boolean} [autoFlush=false] - Whether to automatically flush snapshots to disk
* @property {number} [flushInterval=30000] - Auto-flush interval in milliseconds (default: 30 seconds)
* @property {Array<string|RegExp>} [excludeUrls=[]] - URLs to exclude from recording
* @property {function} [shouldRecord=null] - Function to filter requests for recording
* @property {function} [shouldPlayback=null] - Function to filter requests
*/
/**
* @typedef {Object} SnapshotFormattedRequest
* @property {string} method - HTTP method (e.g. 'GET', 'POST', etc.)
* @property {string} url - Full URL of the request (with query parameters if matchQuery is true)
* @property {import('./snapshot-utils').NormalizedHeaders} headers - Normalized headers as a lowercase object
* @property {string} body - Request body (optional, only if matchBody is true)
*/
/**
* @typedef {Object} SnapshotInfo
* @property {string} hash - Hash key for the snapshot
* @property {SnapshotEntryRequest} request - The request object
* @property {number} responseCount - Number of responses recorded for this request
* @property {number} callCount - Number of times this snapshot has been called
* @property {string} timestamp - ISO timestamp of when the snapshot was created
*/
/**
* Formats a request for consistent snapshot storage
* Caches normalized headers to avoid repeated processing
*
* @param {SnapshotRequestOptions} opts - Request options
* @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached header sets for performance
* @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers and body
* @returns {SnapshotFormattedRequest} - Formatted request object
*/
function formatRequestKey (opts, headerFilters, matchOptions = {}) {
const url = new URL(opts.path, opts.origin)
// Cache normalized headers if not already done
const normalized = opts._normalizedHeaders || normalizeHeaders(opts.headers)
if (!opts._normalizedHeaders) {
opts._normalizedHeaders = normalized
}
return {
method: opts.method || 'GET',
url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
}
}
/**
* Filters headers based on matching configuration
*
* @param {import('./snapshot-utils').Headers} headers - Headers to filter
* @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
* @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
*/
function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) {
if (!headers || typeof headers !== 'object') return {}
const {
caseSensitive = false
} = matchOptions
const filtered = {}
const { ignore, exclude, match } = headerFilters
for (const [key, value] of Object.entries(headers)) {
const headerKey = caseSensitive ? key : key.toLowerCase()
// Skip if in exclude list (for security)
if (exclude.has(headerKey)) continue
// Skip if in ignore list (for matching)
if (ignore.has(headerKey)) continue
// If matchHeaders is specified, only include those headers
if (match.size !== 0) {
if (!match.has(headerKey)) continue
}
filtered[headerKey] = value
}
return filtered
}
/**
* Filters headers for storage (only excludes sensitive headers)
*
* @param {import('./snapshot-utils').Headers} headers - Headers to filter
* @param {import('./snapshot-utils').HeaderFilters} headerFilters - Cached sets for ignore, exclude, and match headers
* @param {SnapshotRecorderMatchOptions} [matchOptions] - Matching options for headers
*/
function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) {
if (!headers || typeof headers !== 'object') return {}
const {
caseSensitive = false
} = matchOptions
const filtered = {}
const { exclude: excludeSet } = headerFilters
for (const [key, value] of Object.entries(headers)) {
const headerKey = caseSensitive ? key : key.toLowerCase()
// Skip if in exclude list (for security)
if (excludeSet.has(headerKey)) continue
filtered[headerKey] = value
}
return filtered
}
/**
* Creates a hash key for request matching
* Properly orders headers to avoid conflicts and uses crypto hashing when available
*
* @param {SnapshotFormattedRequest} formattedRequest - Request object
* @returns {string} - Base64url encoded hash of the request
*/
function createRequestHash (formattedRequest) {
const parts = [
formattedRequest.method,
formattedRequest.url
]
// Process headers in a deterministic way to avoid conflicts
if (formattedRequest.headers && typeof formattedRequest.headers === 'object') {
const headerKeys = Object.keys(formattedRequest.headers).sort()
for (const key of headerKeys) {
const values = Array.isArray(formattedRequest.headers[key])
? formattedRequest.headers[key]
: [formattedRequest.headers[key]]
// Add header name
parts.push(key)
// Add all values for this header, sorted for consistency
for (const value of values.sort()) {
parts.push(String(value))
}
}
}
// Add body
parts.push(formattedRequest.body)
const content = parts.join('|')
return hashId(content)
}
class SnapshotRecorder {
/** @type {NodeJS.Timeout | null} */
#flushTimeout
/** @type {import('./snapshot-utils').IsUrlExcluded} */
#isUrlExcluded
/** @type {Map<string, SnapshotEntry>} */
#snapshots = new Map()
/** @type {string|undefined} */
#snapshotPath
/** @type {number} */
#maxSnapshots = Infinity
/** @type {boolean} */
#autoFlush = false
/** @type {import('./snapshot-utils').HeaderFilters} */
#headerFilters
/**
* Creates a new SnapshotRecorder instance
* @param {SnapshotRecorderOptions&SnapshotRecorderMatchOptions} [options={}] - Configuration options for the recorder
*/
constructor (options = {}) {
this.#snapshotPath = options.snapshotPath
this.#maxSnapshots = options.maxSnapshots || Infinity
this.#autoFlush = options.autoFlush || false
this.flushInterval = options.flushInterval || 30000 // 30 seconds default
this._flushTimer = null
// Matching configuration
/** @type {Required<SnapshotRecorderMatchOptions>} */
this.matchOptions = {
matchHeaders: options.matchHeaders || [], // empty means match all headers
ignoreHeaders: options.ignoreHeaders || [],
excludeHeaders: options.excludeHeaders || [],
matchBody: options.matchBody !== false, // default: true
matchQuery: options.matchQuery !== false, // default: true
caseSensitive: options.caseSensitive || false
}
// Cache processed header sets to avoid recreating them on every request
this.#headerFilters = createHeaderFilters(this.matchOptions)
// Request filtering callbacks
this.shouldRecord = options.shouldRecord || (() => true) // function(requestOpts) -> boolean
this.shouldPlayback = options.shouldPlayback || (() => true) // function(requestOpts) -> boolean
// URL pattern filtering
this.#isUrlExcluded = isUrlExcludedFactory(options.excludeUrls) // Array of regex patterns or strings
// Start auto-flush timer if enabled
if (this.#autoFlush && this.#snapshotPath) {
this.#startAutoFlush()
}
}
/**
* Records a request-response interaction
* @param {SnapshotRequestOptions} requestOpts - Request options
* @param {SnapshotEntryResponse} response - Response data to record
* @return {Promise<void>} - Resolves when the recording is complete
*/
async record (requestOpts, response) {
// Check if recording should be filtered out
if (!this.shouldRecord(requestOpts)) {
return // Skip recording
}
// Check URL exclusion patterns
const url = new URL(requestOpts.path, requestOpts.origin).toString()
if (this.#isUrlExcluded(url)) {
return // Skip recording
}
const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
const hash = createRequestHash(request)
// Extract response data - always store body as base64
const normalizedHeaders = normalizeHeaders(response.headers)
/** @type {SnapshotEntryResponse} */
const responseData = {
statusCode: response.statusCode,
headers: filterHeadersForStorage(normalizedHeaders, this.#headerFilters, this.matchOptions),
body: Buffer.isBuffer(response.body)
? response.body.toString('base64')
: Buffer.from(String(response.body || '')).toString('base64'),
trailers: response.trailers
}
// Remove oldest snapshot if we exceed maxSnapshots limit
if (this.#snapshots.size >= this.#maxSnapshots && !this.#snapshots.has(hash)) {
const oldestKey = this.#snapshots.keys().next().value
this.#snapshots.delete(oldestKey)
}
// Support sequential responses - if snapshot exists, add to responses array
const existingSnapshot = this.#snapshots.get(hash)
if (existingSnapshot && existingSnapshot.responses) {
existingSnapshot.responses.push(responseData)
existingSnapshot.timestamp = new Date().toISOString()
} else {
this.#snapshots.set(hash, {
request,
responses: [responseData], // Always store as array for consistency
callCount: 0,
timestamp: new Date().toISOString()
})
}
// Auto-flush if enabled
if (this.#autoFlush && this.#snapshotPath) {
this.#scheduleFlush()
}
}
/**
* Finds a matching snapshot for the given request
* Returns the appropriate response based on call count for sequential responses
*
* @param {SnapshotRequestOptions} requestOpts - Request options to match
* @returns {SnapshotEntry&Record<'response', SnapshotEntryResponse>|undefined} - Matching snapshot response or undefined if not found
*/
findSnapshot (requestOpts) {
// Check if playback should be filtered out
if (!this.shouldPlayback(requestOpts)) {
return undefined // Skip playback
}
// Check URL exclusion patterns
const url = new URL(requestOpts.path, requestOpts.origin).toString()
if (this.#isUrlExcluded(url)) {
return undefined // Skip playback
}
const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
const hash = createRequestHash(request)
const snapshot = this.#snapshots.get(hash)
if (!snapshot) return undefined
// Handle sequential responses
const currentCallCount = snapshot.callCount || 0
const responseIndex = Math.min(currentCallCount, snapshot.responses.length - 1)
snapshot.callCount = currentCallCount + 1
return {
...snapshot,
response: snapshot.responses[responseIndex]
}
}
/**
* Loads snapshots from file
* @param {string} [filePath] - Optional file path to load snapshots from
* @return {Promise<void>} - Resolves when snapshots are loaded
*/
async loadSnapshots (filePath) {
const path = filePath || this.#snapshotPath
if (!path) {
throw new InvalidArgumentError('Snapshot path is required')
}
try {
const data = await readFile(resolve(path), 'utf8')
const parsed = JSON.parse(data)
// Convert array format back to Map
if (Array.isArray(parsed)) {
this.#snapshots.clear()
for (const { hash, snapshot } of parsed) {
this.#snapshots.set(hash, snapshot)
}
} else {
// Legacy object format
this.#snapshots = new Map(Object.entries(parsed))
}
} catch (error) {
if (error.code === 'ENOENT') {
// File doesn't exist yet - that's ok for recording mode
this.#snapshots.clear()
} else {
throw new UndiciError(`Failed to load snapshots from ${path}`, { cause: error })
}
}
}
/**
* Saves snapshots to file
*
* @param {string} [filePath] - Optional file path to save snapshots
* @returns {Promise<void>} - Resolves when snapshots are saved
*/
async saveSnapshots (filePath) {
const path = filePath || this.#snapshotPath
if (!path) {
throw new InvalidArgumentError('Snapshot path is required')
}
const resolvedPath = resolve(path)
// Ensure directory exists
await mkdir(dirname(resolvedPath), { recursive: true })
// Convert Map to serializable format
const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
hash,
snapshot
}))
await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true })
}
/**
* Clears all recorded snapshots
* @returns {void}
*/
clear () {
this.#snapshots.clear()
}
/**
* Gets all recorded snapshots
* @return {Array<SnapshotEntry>} - Array of all recorded snapshots
*/
getSnapshots () {
return Array.from(this.#snapshots.values())
}
/**
* Gets snapshot count
* @return {number} - Number of recorded snapshots
*/
size () {
return this.#snapshots.size
}
/**
* Resets call counts for all snapshots (useful for test cleanup)
* @returns {void}
*/
resetCallCounts () {
for (const snapshot of this.#snapshots.values()) {
snapshot.callCount = 0
}
}
/**
* Deletes a specific snapshot by request options
* @param {SnapshotRequestOptions} requestOpts - Request options to match
* @returns {boolean} - True if snapshot was deleted, false if not found
*/
deleteSnapshot (requestOpts) {
const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
const hash = createRequestHash(request)
return this.#snapshots.delete(hash)
}
/**
* Gets information about a specific snapshot
* @param {SnapshotRequestOptions} requestOpts - Request options to match
* @returns {SnapshotInfo|null} - Snapshot information or null if not found
*/
getSnapshotInfo (requestOpts) {
const request = formatRequestKey(requestOpts, this.#headerFilters, this.matchOptions)
const hash = createRequestHash(request)
const snapshot = this.#snapshots.get(hash)
if (!snapshot) return null
return {
hash,
request: snapshot.request,
responseCount: snapshot.responses ? snapshot.responses.length : (snapshot.response ? 1 : 0), // .response for legacy snapshots
callCount: snapshot.callCount || 0,
timestamp: snapshot.timestamp
}
}
/**
* Replaces all snapshots with new data (full replacement)
* @param {Array<{hash: string; snapshot: SnapshotEntry}>|Record<string, SnapshotEntry>} snapshotData - New snapshot data to replace existing ones
* @returns {void}
*/
replaceSnapshots (snapshotData) {
this.#snapshots.clear()
if (Array.isArray(snapshotData)) {
for (const { hash, snapshot } of snapshotData) {
this.#snapshots.set(hash, snapshot)
}
} else if (snapshotData && typeof snapshotData === 'object') {
// Legacy object format
this.#snapshots = new Map(Object.entries(snapshotData))
}
}
/**
* Starts the auto-flush timer
* @returns {void}
*/
#startAutoFlush () {
return this.#scheduleFlush()
}
/**
* Stops the auto-flush timer
* @returns {void}
*/
#stopAutoFlush () {
if (this.#flushTimeout) {
clearTimeout(this.#flushTimeout)
// Ensure any pending flush is completed
this.saveSnapshots().catch(() => {
// Ignore flush errors
})
this.#flushTimeout = null
}
}
/**
* Schedules a flush (debounced to avoid excessive writes)
*/
#scheduleFlush () {
this.#flushTimeout = setTimeout(() => {
this.saveSnapshots().catch(() => {
// Ignore flush errors
})
if (this.#autoFlush) {
this.#flushTimeout?.refresh()
} else {
this.#flushTimeout = null
}
}, 1000) // 1 second debounce
}
/**
* Cleanup method to stop timers
* @returns {void}
*/
destroy () {
this.#stopAutoFlush()
if (this.#flushTimeout) {
clearTimeout(this.#flushTimeout)
this.#flushTimeout = null
}
}
/**
* Async close method that saves all recordings and performs cleanup
* @returns {Promise<void>}
*/
async close () {
// Save any pending recordings if we have a snapshot path
if (this.#snapshotPath && this.#snapshots.size !== 0) {
await this.saveSnapshots()
}
// Perform cleanup
this.destroy()
}
}
module.exports = { SnapshotRecorder, formatRequestKey, createRequestHash, filterHeadersForMatching, filterHeadersForStorage, createHeaderFilters }