interceptor.ts
1 /** 2 * Shared XHR/Fetch interceptor JavaScript generators. 3 * 4 * Provides a single source of truth for monkey-patching browser 5 * fetch() and XMLHttpRequest to capture API responses matching 6 * a URL pattern. Used by: 7 * - Page.installInterceptor() (browser.ts) 8 * - stepIntercept (pipeline/steps/intercept.ts) 9 * - stepTap (pipeline/steps/tap.ts) 10 */ 11 12 /** 13 * Helper: define a non-enumerable property on window. 14 * Avoids detection via Object.keys(window) or for..in loops. 15 */ 16 const DEFINE_HIDDEN = ` 17 function __defHidden(obj, key, val) { 18 try { 19 Object.defineProperty(obj, key, { value: val, writable: true, enumerable: false, configurable: true }); 20 } catch { obj[key] = val; } 21 }`; 22 23 /** 24 * Helper: disguise a patched function so toString() returns native code signature. 25 */ 26 const DISGUISE_FN = ` 27 function __disguise(fn, name) { 28 const nativeStr = 'function ' + name + '() { [native code] }'; 29 // Override toString on the instance AND patch Function.prototype.toString 30 // to handle Function.prototype.toString.call(fn) bypasses. 31 const _origToString = Function.prototype.toString; 32 const _patchedFns = window.__dFns || (function() { 33 const m = new Map(); 34 Object.defineProperty(window, '__dFns', { value: m, enumerable: false, configurable: true }); 35 // Patch Function.prototype.toString once to consult the map 36 Object.defineProperty(Function.prototype, 'toString', { 37 value: function() { 38 const override = m.get(this); 39 return override !== undefined ? override : _origToString.call(this); 40 }, 41 writable: true, configurable: true 42 }); 43 return m; 44 })(); 45 _patchedFns.set(fn, nativeStr); 46 try { Object.defineProperty(fn, 'name', { value: name, configurable: true }); } catch {} 47 return fn; 48 }`; 49 50 /** 51 * Generate JavaScript source that installs a fetch/XHR interceptor. 52 * Captured responses are pushed to `window.__opencli_intercepted`. 53 * 54 * @param patternExpr - JS expression resolving to a URL substring to match (e.g. a JSON.stringify'd string) 55 * @param opts.arrayName - Global array name for captured data (default: '__opencli_intercepted') 56 * @param opts.patchGuard - Global boolean name to prevent double-patching (default: '__opencli_interceptor_patched') 57 */ 58 export function generateInterceptorJs( 59 patternExpr: string, 60 opts: { arrayName?: string; patchGuard?: string } = {}, 61 ): string { 62 const arr = opts.arrayName ?? '__opencli_intercepted'; 63 const guard = opts.patchGuard ?? '__opencli_interceptor_patched'; 64 65 // Store the current pattern in a separate global so it can be updated 66 // without re-patching fetch/XHR (the patchGuard only prevents double-patching). 67 const patternVar = `${guard}_pattern`; 68 69 return ` 70 () => { 71 ${DEFINE_HIDDEN} 72 ${DISGUISE_FN} 73 74 if (!window.${arr}) __defHidden(window, '${arr}', []); 75 if (!window.${arr}_errors) __defHidden(window, '${arr}_errors', []); 76 __defHidden(window, '${patternVar}', ${patternExpr}); 77 const __checkMatch = (url) => window.${patternVar} && url.includes(window.${patternVar}); 78 79 if (!window.${guard}) { 80 // ── Patch fetch ── 81 const __origFetch = window.fetch; 82 window.fetch = __disguise(async function(...args) { 83 const reqUrl = typeof args[0] === 'string' ? args[0] 84 : (args[0] && args[0].url) || ''; 85 const response = await __origFetch.apply(this, args); 86 if (__checkMatch(reqUrl)) { 87 try { 88 const clone = response.clone(); 89 const json = await clone.json(); 90 window.${arr}.push(json); 91 } catch(e) { window.${arr}_errors.push({ url: reqUrl, error: String(e) }); } 92 } 93 return response; 94 }, 'fetch'); 95 96 // ── Patch XMLHttpRequest ── 97 const __XHR = XMLHttpRequest.prototype; 98 const __origOpen = __XHR.open; 99 const __origSend = __XHR.send; 100 __XHR.open = __disguise(function(method, url) { 101 Object.defineProperty(this, '__iurl', { value: String(url), writable: true, enumerable: false, configurable: true }); 102 return __origOpen.apply(this, arguments); 103 }, 'open'); 104 __XHR.send = __disguise(function() { 105 if (__checkMatch(this.__iurl)) { 106 this.addEventListener('load', function() { 107 try { 108 window.${arr}.push(JSON.parse(this.responseText)); 109 } catch(e) { window.${arr}_errors.push({ url: this.__iurl, error: String(e) }); } 110 }); 111 } 112 return __origSend.apply(this, arguments); 113 }, 'send'); 114 115 __defHidden(window, '${guard}', true); 116 } 117 } 118 `; 119 } 120 121 /** 122 * Generate JavaScript source to read and clear intercepted data. 123 */ 124 export function generateReadInterceptedJs(arrayName: string = '__opencli_intercepted'): string { 125 return ` 126 () => { 127 const data = window.${arrayName} || []; 128 window.${arrayName} = []; 129 return data; 130 } 131 `; 132 } 133 134 /** 135 * Generate a self-contained tap interceptor for store-action bridge. 136 * Unlike the global interceptor, this one: 137 * - Installs temporarily, restores originals in finally block 138 * - Resolves a promise on first capture (for immediate await) 139 * - Returns captured data directly 140 * 141 * Reuses the shared DISGUISE_FN for consistent toString() disguising. 142 */ 143 export function generateTapInterceptorJs(patternExpr: string): { 144 setupVar: string; 145 capturedVar: string; 146 promiseVar: string; 147 resolveVar: string; 148 fetchPatch: string; 149 xhrPatch: string; 150 restorePatch: string; 151 } { 152 return { 153 setupVar: ` 154 let captured = null; 155 let captureResolve; 156 const capturePromise = new Promise(r => { captureResolve = r; }); 157 const capturePattern = ${patternExpr}; 158 ${DISGUISE_FN} 159 `, 160 capturedVar: 'captured', 161 promiseVar: 'capturePromise', 162 resolveVar: 'captureResolve', 163 fetchPatch: ` 164 const origFetch = window.fetch; 165 window.fetch = __disguise(async function(...fetchArgs) { 166 const resp = await origFetch.apply(this, fetchArgs); 167 try { 168 const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0] 169 : fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]); 170 if (capturePattern && url.includes(capturePattern) && !captured) { 171 try { captured = await resp.clone().json(); captureResolve(); } catch {} 172 } 173 } catch {} 174 return resp; 175 }, 'fetch'); 176 `, 177 xhrPatch: ` 178 const origXhrOpen = XMLHttpRequest.prototype.open; 179 const origXhrSend = XMLHttpRequest.prototype.send; 180 XMLHttpRequest.prototype.open = __disguise(function(method, url) { 181 Object.defineProperty(this, '__iurl', { value: String(url), writable: true, enumerable: false, configurable: true }); 182 return origXhrOpen.apply(this, arguments); 183 }, 'open'); 184 XMLHttpRequest.prototype.send = __disguise(function(body) { 185 if (capturePattern && this.__iurl?.includes(capturePattern)) { 186 this.addEventListener('load', function() { 187 if (!captured) { 188 try { captured = JSON.parse(this.responseText); captureResolve(); } catch {} 189 } 190 }); 191 } 192 return origXhrSend.apply(this, arguments); 193 }, 'send'); 194 `, 195 restorePatch: ` 196 window.fetch = origFetch; 197 XMLHttpRequest.prototype.open = origXhrOpen; 198 XMLHttpRequest.prototype.send = origXhrSend; 199 `, 200 }; 201 }