import { resolveStamp } from '../../core'; import { adoptJourneyStepStamp, type JourneyStepStamp } from '../../core/step-tag'; import { getInstrumentedJquerySet, registerInstrumentRecord, type InstrumentRecord, } from '../../global'; import { onHttpComplete, type HttpCompleteReport } from '../http-report'; import { captureRequestCorrelation } from '../request-correlation'; import { requestKey, type JourneyTimedMeta } from '../request-key'; import { acceptInstrumentTarget } from '../soft-skip'; export interface JqueryXHR { status: number; statusText?: string; stamp?: unknown; journeyStart?: number; getResponseHeader?(name: string): string | null; always?(callback: () => void): unknown; } export interface JqueryAjaxSettings extends JourneyTimedMeta { url?: string; type?: string; method?: string; } const jqueryStamps = new WeakMap(); export interface JqueryStatic { ajaxPrefilter( handler: ( options: JqueryAjaxSettings, originalOptions: JqueryAjaxSettings, jqXHR: JqueryXHR ) => void ): void; (selector: Document): { ajaxComplete( handler: (event: unknown, jqXHR: JqueryXHR, settings: JqueryAjaxSettings) => void ): void; }; } function timingReport( jqXHR: JqueryXHR, settings: JqueryAjaxSettings ): HttpCompleteReport | undefined { const stamp = jqueryStamps.get(jqXHR) ?? adoptJourneyStepStamp(jqXHR.stamp); if (!stamp || stamp.ignore || settings.ignore === true) { return; } const start = jqXHR.journeyStart ?? globalThis.performance.now(); const aborted = jqXHR.statusText === 'abort'; // jQuery uses status 0 for network / abort failures — treat like a missing status. const status = aborted || jqXHR.status === 0 ? undefined : jqXHR.status; return { stamp, status, durationMs: globalThis.performance.now() - start, requestKey: requestKey(settings.url, undefined, undefined), aborted, url: settings.url, method: settings.type ?? settings.method, meta: settings, request: settings, response: jqXHR, }; } function isJqueryStatic(value: unknown): value is JqueryStatic { return ( typeof value === 'function' && typeof (value as JqueryStatic).ajaxPrefilter === 'function' ); } /** Resolve `globalThis.$`, then `globalThis.jQuery`. */ export function resolveGlobalJquery(): JqueryStatic | null | undefined { const g = globalThis as typeof globalThis & { $?: unknown; jQuery?: unknown }; return (g.$ ?? g.jQuery) as JqueryStatic | null | undefined; } /** Live record per $ — handlers look this up so restore+rewire does not stack prefilters. */ const jqueryLiveRecords = new WeakMap(); /** jQuery cannot unregister these; track each so a partial-wire retry does not stack. */ const jqueryPrefiltersInstalled = new WeakSet(); const jqueryCompletesInstalled = new WeakSet(); function stampAjax(jqXHR: JqueryXHR, options: JqueryAjaxSettings): void { if (options.ignore === true) { return; } const stamp = resolveStamp(jqXHR.stamp ?? options.stamp); if (stamp) { jqueryStamps.set(jqXHR, stamp); delete jqXHR.stamp; delete options.stamp; } if (stamp && !stamp.ignore) { jqXHR.journeyStart = globalThis.performance.now(); } } /** * Wire a jQuery static into the journey engine. Call once per $ (idempotent). Observation-only. * Soft-skips when `$` is missing or not a real jQuery (no `ajaxPrefilter`) so hosts can pass `$` as-is. * Returns a restore that disposes handlers (they no-op after restore). */ export function instrumentJquery($: JqueryStatic | null | undefined): () => void { const jquery = acceptInstrumentTarget('Jquery', $, isJqueryStatic); const instrumented = getInstrumentedJquerySet(); if (!jquery || instrumented.has(jquery as object)) { return () => {}; } const key = jquery as object; const record: InstrumentRecord = { disposed: false }; try { if (!jqueryPrefiltersInstalled.has(key)) { jquery.ajaxPrefilter((options, _originalOptions, jqXHR) => { const current = jqueryLiveRecords.get(key); if (!current || current.disposed) { return; } stampAjax(jqXHR, options); /* * Prefilters run before application success/error callbacks; ajaxComplete runs after. * Capture headers here, but keep verdict scoring in ajaxComplete as before. */ try { if (jqueryStamps.has(jqXHR)) { jqXHR.always?.(() => { if (current.disposed) { return; } try { const report = timingReport(jqXHR, options); if (report) { captureRequestCorrelation(report); } } catch { // This observer must never interrupt application callbacks. } }); } } catch { // Partial jQuery-compatible clients may not support response observers. } }); jqueryPrefiltersInstalled.add(key); } if (!jqueryCompletesInstalled.has(key)) { jquery(document).ajaxComplete((_event, jqXHR, settings) => { const current = jqueryLiveRecords.get(key); if (!current || current.disposed) { return; } const report = timingReport(jqXHR, settings); if (report) { onHttpComplete(report); } }); jqueryCompletesInstalled.add(key); } jqueryLiveRecords.set(key, record); instrumented.add(key); } catch (err) { /* * Keep per-handler installed flags so a retry cannot double-register * prefilters. Restore deletes the shared instrumented slot so teardown / * retry can finish whichever handler never succeeded. */ record.disposed = true; instrumented.add(key); const restore = () => { record.disposed = true; if (jqueryLiveRecords.get(key) === record) { jqueryLiveRecords.delete(key); } instrumented.delete(key); }; record.restore = restore; registerInstrumentRecord(record); // eslint-disable-next-line no-console -- soft-fail host wiring console.error('[journey] instrumentJquery failed', err); return restore; } const restore = () => { record.disposed = true; if (jqueryLiveRecords.get(key) === record) { jqueryLiveRecords.delete(key); } instrumented.delete(key); }; record.restore = restore; registerInstrumentRecord(record); return restore; }