- NEW: src/interceptor.ts — unified XHR/Fetch interceptor (was duplicated 3x) - NEW: src/version.ts — centralized PKG_VERSION (was duplicated 2x) - NEW: src/constants.ts — shared VOLATILE_PARAMS, FIELD_ROLES etc. - NEW: src/engine.test.ts, src/registry.test.ts — 14 new unit tests - browser.ts: use shared normalizeEval, interceptor, withTimeoutMs, PKG_VERSION - intercept.ts, tap.ts: use shared interceptor generators - cascade.ts: extract shared buildFetchProbeJs (90% dedup) - engine.ts: use InternalCliCommand (no more 'as any' casts) - executor.ts: remove all 15 'as StepHandler' type casts - runtime.ts: add withTimeoutMs, IBrowserFactory interface - registry.ts: add InternalCliCommand type for internal fields - validate.ts: add pipeline step name validation - output.ts: remove 'null as any' and '.filter(() => true)' hacks - explore.ts, synthesize.ts: use shared constants - docs: fix V2EX commands (3→6), SKILL.md version, verify example Tests: 88 passed (was 74), tsc --noEmit: 0 errors
154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
/**
|
|
* Shared XHR/Fetch interceptor JavaScript generators.
|
|
*
|
|
* Provides a single source of truth for monkey-patching browser
|
|
* fetch() and XMLHttpRequest to capture API responses matching
|
|
* a URL pattern. Used by:
|
|
* - Page.installInterceptor() (browser.ts)
|
|
* - stepIntercept (pipeline/steps/intercept.ts)
|
|
* - stepTap (pipeline/steps/tap.ts)
|
|
*/
|
|
|
|
/**
|
|
* Generate JavaScript source that installs a fetch/XHR interceptor.
|
|
* Captured responses are pushed to `window.__opencli_intercepted`.
|
|
*
|
|
* @param patternExpr - JS expression resolving to a URL substring to match (e.g. a JSON.stringify'd string)
|
|
* @param opts.arrayName - Global array name for captured data (default: '__opencli_intercepted')
|
|
* @param opts.patchGuard - Global boolean name to prevent double-patching (default: '__opencli_interceptor_patched')
|
|
*/
|
|
export function generateInterceptorJs(
|
|
patternExpr: string,
|
|
opts: { arrayName?: string; patchGuard?: string } = {},
|
|
): string {
|
|
const arr = opts.arrayName ?? '__opencli_intercepted';
|
|
const guard = opts.patchGuard ?? '__opencli_interceptor_patched';
|
|
|
|
return `
|
|
() => {
|
|
window.${arr} = window.${arr} || [];
|
|
const __pattern = ${patternExpr};
|
|
|
|
if (!window.${guard}) {
|
|
const __checkMatch = (url) => __pattern && url.includes(__pattern);
|
|
|
|
// ── Patch fetch ──
|
|
const __origFetch = window.fetch;
|
|
window.fetch = async function(...args) {
|
|
const reqUrl = typeof args[0] === 'string' ? args[0]
|
|
: (args[0] && args[0].url) || '';
|
|
const response = await __origFetch.apply(this, args);
|
|
if (__checkMatch(reqUrl)) {
|
|
try {
|
|
const clone = response.clone();
|
|
const json = await clone.json();
|
|
window.${arr}.push(json);
|
|
} catch(e) {}
|
|
}
|
|
return response;
|
|
};
|
|
|
|
// ── Patch XMLHttpRequest ──
|
|
const __XHR = XMLHttpRequest.prototype;
|
|
const __origOpen = __XHR.open;
|
|
const __origSend = __XHR.send;
|
|
__XHR.open = function(method, url) {
|
|
this.__opencli_url = String(url);
|
|
return __origOpen.apply(this, arguments);
|
|
};
|
|
__XHR.send = function() {
|
|
if (__checkMatch(this.__opencli_url)) {
|
|
this.addEventListener('load', function() {
|
|
try {
|
|
window.${arr}.push(JSON.parse(this.responseText));
|
|
} catch(e) {}
|
|
});
|
|
}
|
|
return __origSend.apply(this, arguments);
|
|
};
|
|
|
|
window.${guard} = true;
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Generate JavaScript source to read and clear intercepted data.
|
|
*/
|
|
export function generateReadInterceptedJs(arrayName: string = '__opencli_intercepted'): string {
|
|
return `
|
|
() => {
|
|
const data = window.${arrayName} || [];
|
|
window.${arrayName} = [];
|
|
return data;
|
|
}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Generate a self-contained tap interceptor for store-action bridge.
|
|
* Unlike the global interceptor, this one:
|
|
* - Installs temporarily, restores originals in finally block
|
|
* - Resolves a promise on first capture (for immediate await)
|
|
* - Returns captured data directly
|
|
*/
|
|
export function generateTapInterceptorJs(patternExpr: string): {
|
|
setupVar: string;
|
|
capturedVar: string;
|
|
promiseVar: string;
|
|
resolveVar: string;
|
|
fetchPatch: string;
|
|
xhrPatch: string;
|
|
restorePatch: string;
|
|
} {
|
|
return {
|
|
setupVar: `
|
|
let captured = null;
|
|
let captureResolve;
|
|
const capturePromise = new Promise(r => { captureResolve = r; });
|
|
const capturePattern = ${patternExpr};
|
|
`,
|
|
capturedVar: 'captured',
|
|
promiseVar: 'capturePromise',
|
|
resolveVar: 'captureResolve',
|
|
fetchPatch: `
|
|
const origFetch = window.fetch;
|
|
window.fetch = async function(...fetchArgs) {
|
|
const resp = await origFetch.apply(this, fetchArgs);
|
|
try {
|
|
const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0]
|
|
: fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]);
|
|
if (capturePattern && url.includes(capturePattern) && !captured) {
|
|
try { captured = await resp.clone().json(); captureResolve(); } catch {}
|
|
}
|
|
} catch {}
|
|
return resp;
|
|
};
|
|
`,
|
|
xhrPatch: `
|
|
const origXhrOpen = XMLHttpRequest.prototype.open;
|
|
const origXhrSend = XMLHttpRequest.prototype.send;
|
|
XMLHttpRequest.prototype.open = function(method, url) {
|
|
this.__tapUrl = String(url);
|
|
return origXhrOpen.apply(this, arguments);
|
|
};
|
|
XMLHttpRequest.prototype.send = function(body) {
|
|
if (capturePattern && this.__tapUrl?.includes(capturePattern)) {
|
|
this.addEventListener('load', function() {
|
|
if (!captured) {
|
|
try { captured = JSON.parse(this.responseText); captureResolve(); } catch {}
|
|
}
|
|
});
|
|
}
|
|
return origXhrSend.apply(this, arguments);
|
|
};
|
|
`,
|
|
restorePatch: `
|
|
window.fetch = origFetch;
|
|
XMLHttpRequest.prototype.open = origXhrOpen;
|
|
XMLHttpRequest.prototype.send = origXhrSend;
|
|
`,
|
|
};
|
|
}
|