/ src / interceptor.ts
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  }