nothing-browser 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import { dirname } from "path";
5
5
  import { platform } from "os";
6
6
  import logger from "../logger";
7
7
 
8
- const SOCKET_PATH = platform() === 'win32'
8
+ const SOCKET_PATH = platform() === 'win32'
9
9
  ? '\\\\.\\pipe\\piggy'
10
10
  : '/tmp/piggy';
11
11
 
@@ -18,6 +18,9 @@ export class PiggyClient {
18
18
  private eventBuffer = "";
19
19
  private eventHandlers = new Map<string, Map<string, (data: any) => Promise<any>>>();
20
20
 
21
+ // ── Global event emitter for navigate / etc ───────────────────────────────
22
+ private globalEventHandlers = new Map<string, Set<(data: any) => void>>();
23
+
21
24
  constructor(socketPath = SOCKET_PATH) {
22
25
  this.socketPath = socketPath;
23
26
  this.eventHandlers.set("default", new Map());
@@ -39,17 +42,17 @@ export class PiggyClient {
39
42
  this.eventBuffer += chunk;
40
43
  const lines = this.eventBuffer.split("\n");
41
44
  this.eventBuffer = lines.pop()!;
42
-
45
+
43
46
  for (const line of lines) {
44
47
  if (!line.trim()) continue;
45
48
  try {
46
49
  const msg = JSON.parse(line);
47
-
50
+
48
51
  if (msg.type === "event") {
49
52
  this.handleEvent(msg);
50
53
  continue;
51
54
  }
52
-
55
+
53
56
  const p = this.pending.get(msg.id);
54
57
  if (p) {
55
58
  this.pending.delete(msg.id);
@@ -75,31 +78,23 @@ export class PiggyClient {
75
78
  }
76
79
 
77
80
  private handleEvent(event: any) {
81
+ // ── exposed_call ──────────────────────────────────────────────────────────
78
82
  if (event.event === "exposed_call") {
79
83
  const { tabId, name, callId, data } = event;
80
84
  const effectiveTabId = tabId || "default";
81
85
  const handlers = this.eventHandlers.get(effectiveTabId);
82
86
  const handler = handlers?.get(name);
83
-
87
+
84
88
  if (handler) {
85
89
  Promise.resolve(handler(JSON.parse(data || "null")))
86
90
  .then(response => {
87
91
  if (response && typeof response === "object" && "success" in response) {
88
- if (response.success) {
89
- this.send("exposed.result", {
90
- tabId: effectiveTabId,
91
- callId,
92
- result: JSON.stringify(response.result),
93
- isError: false
94
- }).catch(e => logger.error(`Failed to send exposed result: ${e}`));
95
- } else {
96
- this.send("exposed.result", {
97
- tabId: effectiveTabId,
98
- callId,
99
- result: response.error || "Unknown error",
100
- isError: true
101
- }).catch(e => logger.error(`Failed to send exposed error: ${e}`));
102
- }
92
+ this.send("exposed.result", {
93
+ tabId: effectiveTabId,
94
+ callId,
95
+ result: response.success ? JSON.stringify(response.result) : (response.error || "Unknown error"),
96
+ isError: !response.success
97
+ }).catch(e => logger.error(`Failed to send exposed result: ${e}`));
103
98
  } else {
104
99
  this.send("exposed.result", {
105
100
  tabId: effectiveTabId,
@@ -120,7 +115,37 @@ export class PiggyClient {
120
115
  } else {
121
116
  logger.warn(`No handler for exposed function: ${name} in tab ${effectiveTabId}`);
122
117
  }
118
+ return;
119
+ }
120
+
121
+ // ── navigate ──────────────────────────────────────────────────────────────
122
+ if (event.event === "navigate") {
123
+ const handlers = this.globalEventHandlers.get(`navigate:${event.tabId}`);
124
+ if (handlers) {
125
+ for (const h of handlers) {
126
+ try { h(event.url); } catch (e) { logger.error(`navigate handler error: ${e}`); }
127
+ }
128
+ }
129
+ // Also fire wildcard listeners (no tabId filter)
130
+ const wildcard = this.globalEventHandlers.get("navigate:*");
131
+ if (wildcard) {
132
+ for (const h of wildcard) {
133
+ try { h({ url: event.url, tabId: event.tabId }); } catch {}
134
+ }
135
+ }
136
+ return;
137
+ }
138
+ }
139
+
140
+ // ── Global event subscription ─────────────────────────────────────────────
141
+ onEvent(eventName: string, tabId: string, handler: (data: any) => void): () => void {
142
+ const key = `${eventName}:${tabId}`;
143
+ if (!this.globalEventHandlers.has(key)) {
144
+ this.globalEventHandlers.set(key, new Set());
123
145
  }
146
+ this.globalEventHandlers.get(key)!.add(handler);
147
+ // Return unsubscribe fn
148
+ return () => this.globalEventHandlers.get(key)?.delete(handler);
124
149
  }
125
150
 
126
151
  disconnect() {
@@ -128,7 +153,7 @@ export class PiggyClient {
128
153
  this.socket = null;
129
154
  }
130
155
 
131
- private send<T = any>(cmd: string, payload: Record<string, any> = {}): Promise<T> {
156
+ send<T = any>(cmd: string, payload: Record<string, any> = {}): Promise<T> {
132
157
  return new Promise((resolve, reject) => {
133
158
  if (!this.socket) return reject(new Error("Not connected"));
134
159
  const id = String(++this.reqId);
@@ -137,118 +162,43 @@ export class PiggyClient {
137
162
  });
138
163
  }
139
164
 
140
- // ── Tabs ─────────────────────────────────────────────────────────────────────
141
-
142
- async newTab(): Promise<string> {
143
- return this.send<string>("tab.new", {});
144
- }
145
-
146
- async closeTab(tabId: string): Promise<void> {
147
- await this.send("tab.close", { tabId });
148
- }
149
-
150
- async listTabs(): Promise<string[]> {
151
- return this.send<string[]>("tab.list", {});
152
- }
153
-
154
- // ── Navigation ───────────────────────────────────────────────────────────────
155
-
156
- async navigate(url: string, tabId = "default"): Promise<void> {
157
- await this.send("navigate", { url, tabId });
158
- }
159
-
160
- async reload(tabId = "default"): Promise<void> {
161
- await this.send("reload", { tabId });
162
- }
163
-
164
- async goBack(tabId = "default"): Promise<void> {
165
- await this.send("go.back", { tabId });
166
- }
167
-
168
- async goForward(tabId = "default"): Promise<void> {
169
- await this.send("go.forward", { tabId });
170
- }
171
-
172
- // ── Page info ─────────────────────────────────────────────────────────────────
173
-
174
- async getTitle(tabId = "default"): Promise<string> {
175
- return this.send<string>("page.title", { tabId });
176
- }
177
-
178
- async getUrl(tabId = "default"): Promise<string> {
179
- return this.send<string>("page.url", { tabId });
180
- }
181
-
182
- async content(tabId = "default"): Promise<string> {
183
- return this.send<string>("page.content", { tabId });
184
- }
185
-
186
- // ── Eval / JS ─────────────────────────────────────────────────────────────────
187
-
188
- async evaluate(js: string, tabId = "default"): Promise<any> {
189
- return this.send("evaluate", { js, tabId });
190
- }
191
-
192
- // ── Init Script ───────────────────────────────────────────────────────────────
193
- // HERE IT IS - ADD THIS METHOD TO THE CLIENT
194
- async addInitScript(js: string, tabId = "default"): Promise<void> {
195
- await this.send("addInitScript", { js, tabId });
196
- }
197
-
198
- // ── Interactions ──────────────────────────────────────────────────────────────
199
-
200
- async click(selector: string, tabId = "default"): Promise<boolean> {
201
- return this.send<boolean>("click", { selector, tabId });
202
- }
203
-
204
- async doubleClick(selector: string, tabId = "default"): Promise<boolean> {
205
- return this.send<boolean>("dblclick", { selector, tabId });
206
- }
207
-
208
- async hover(selector: string, tabId = "default"): Promise<boolean> {
209
- return this.send<boolean>("hover", { selector, tabId });
210
- }
211
-
212
- async type(selector: string, text: string, tabId = "default"): Promise<boolean> {
213
- return this.send<boolean>("type", { selector, text, tabId });
214
- }
215
-
216
- async select(selector: string, value: string, tabId = "default"): Promise<boolean> {
217
- return this.send<boolean>("select", { selector, value, tabId });
218
- }
219
-
220
- async keyPress(key: string, tabId = "default"): Promise<boolean> {
221
- return this.send<boolean>("keyboard.press", { key, tabId });
222
- }
223
-
224
- async keyCombo(combo: string, tabId = "default"): Promise<boolean> {
225
- return this.send<boolean>("keyboard.combo", { combo, tabId });
226
- }
227
-
228
- async mouseMove(x: number, y: number, tabId = "default"): Promise<boolean> {
229
- return this.send<boolean>("mouse.move", { x, y, tabId });
230
- }
231
-
232
- async mouseDrag(from: { x: number; y: number }, to: { x: number; y: number }, tabId = "default"): Promise<boolean> {
233
- return this.send<boolean>("mouse.drag", { from, to, tabId });
234
- }
235
-
236
- // ── Scroll ────────────────────────────────────────────────────────────────────
237
-
238
- async scrollTo(selector: string, tabId = "default"): Promise<boolean> {
239
- return this.send<boolean>("scroll.to", { selector, tabId });
240
- }
241
-
242
- async scrollBy(px: number, tabId = "default"): Promise<boolean> {
243
- return this.send<boolean>("scroll.by", { px, tabId });
244
- }
245
-
246
- // ── Fetch ─────────────────────────────────────────────────────────────────────
247
-
248
- async fetchText(query: string, tabId = "default"): Promise<string | null> {
249
- return this.send<string | null>("fetch.text", { query, tabId });
250
- }
251
-
165
+ // ── Tabs ──────────────────────────────────────────────────────────────────
166
+ async newTab(): Promise<string> { return this.send<string>("tab.new", {}); }
167
+ async closeTab(tabId: string): Promise<void> { await this.send("tab.close", { tabId }); }
168
+ async listTabs(): Promise<string[]> { return this.send<string[]>("tab.list", {}); }
169
+
170
+ // ── Navigation ────────────────────────────────────────────────────────────
171
+ async navigate(url: string, tabId = "default"): Promise<void> { await this.send("navigate", { url, tabId }); }
172
+ async reload(tabId = "default"): Promise<void> { await this.send("reload", { tabId }); }
173
+ async goBack(tabId = "default"): Promise<void> { await this.send("go.back", { tabId }); }
174
+ async goForward(tabId = "default"): Promise<void> { await this.send("go.forward", { tabId }); }
175
+
176
+ // ── Page info ─────────────────────────────────────────────────────────────
177
+ async getTitle(tabId = "default"): Promise<string> { return this.send<string>("page.title", { tabId }); }
178
+ async getUrl(tabId = "default"): Promise<string> { return this.send<string>("page.url", { tabId }); }
179
+ async content(tabId = "default"): Promise<string> { return this.send<string>("page.content", { tabId }); }
180
+
181
+ // ── Eval / JS ─────────────────────────────────────────────────────────────
182
+ async evaluate(js: string, tabId = "default"): Promise<any> { return this.send("evaluate", { js, tabId }); }
183
+ async addInitScript(js: string, tabId = "default"): Promise<void> { await this.send("addInitScript", { js, tabId }); }
184
+
185
+ // ── Interactions ──────────────────────────────────────────────────────────
186
+ async click(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("click", { selector, tabId }); }
187
+ async doubleClick(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("dblclick", { selector, tabId }); }
188
+ async hover(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("hover", { selector, tabId }); }
189
+ async type(selector: string, text: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("type", { selector, text, tabId }); }
190
+ async select(selector: string, value: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("select", { selector, value, tabId }); }
191
+ async keyPress(key: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("keyboard.press", { key, tabId }); }
192
+ async keyCombo(combo: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("keyboard.combo", { combo, tabId }); }
193
+ async mouseMove(x: number, y: number, tabId = "default"): Promise<boolean> { return this.send<boolean>("mouse.move", { x, y, tabId }); }
194
+ async mouseDrag(from: { x: number; y: number }, to: { x: number; y: number }, tabId = "default"): Promise<boolean> { return this.send<boolean>("mouse.drag", { from, to, tabId }); }
195
+
196
+ // ── Scroll ────────────────────────────────────────────────────────────────
197
+ async scrollTo(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("scroll.to", { selector, tabId }); }
198
+ async scrollBy(px: number, tabId = "default"): Promise<boolean> { return this.send<boolean>("scroll.by", { px, tabId }); }
199
+
200
+ // ── Fetch ─────────────────────────────────────────────────────────────────
201
+ async fetchText(query: string, tabId = "default"): Promise<string | null> { return this.send<string | null>("fetch.text", { query, tabId }); }
252
202
  async fetchLinks(query: string, tabId = "default"): Promise<string[]> {
253
203
  if (query === "a" || query === "body") {
254
204
  const result = await this.send<string[]>("fetch.links.all", { tabId });
@@ -257,148 +207,68 @@ export class PiggyClient {
257
207
  const result = await this.send<string[]>("fetch.links", { query, tabId });
258
208
  return Array.isArray(result) ? result : [];
259
209
  }
260
-
261
210
  async fetchImages(query: string, tabId = "default"): Promise<string[]> {
262
211
  const result = await this.send<string[]>("fetch.image", { query, tabId });
263
212
  return Array.isArray(result) ? result : [];
264
213
  }
265
214
 
266
- // ── Search ────────────────────────────────────────────────────────────────────
267
-
268
- async searchCss(query: string, tabId = "default"): Promise<any> {
269
- return this.send("search.css", { query, tabId });
270
- }
271
-
272
- async searchId(query: string, tabId = "default"): Promise<any> {
273
- return this.send("search.id", { query, tabId });
274
- }
215
+ // ── Search ────────────────────────────────────────────────────────────────
216
+ async searchCss(query: string, tabId = "default"): Promise<any> { return this.send("search.css", { query, tabId }); }
217
+ async searchId(query: string, tabId = "default"): Promise<any> { return this.send("search.id", { query, tabId }); }
275
218
 
276
- // ── Wait ──────────────────────────────────────────────────────────────────────
277
-
278
- async waitForSelector(selector: string, timeout = 30000, tabId = "default"): Promise<void> {
279
- await this.send("wait.selector", { selector, timeout, tabId });
280
- }
281
-
282
- async waitForNavigation(tabId = "default"): Promise<void> {
283
- await this.send("wait.navigation", { tabId });
284
- }
285
-
286
- async waitForResponse(urlPattern: string, timeout = 30000, tabId = "default"): Promise<void> {
287
- await this.send("wait.response", { url: urlPattern, timeout, tabId });
288
- }
289
-
290
- // ── Screenshot ────────────────────────────────────────────────────────────────
219
+ // ── Wait ──────────────────────────────────────────────────────────────────
220
+ async waitForSelector(selector: string, timeout = 30000, tabId = "default"): Promise<void> { await this.send("wait.selector", { selector, timeout, tabId }); }
221
+ async waitForNavigation(tabId = "default"): Promise<void> { await this.send("wait.navigation", { tabId }); }
222
+ async waitForResponse(urlPattern: string, timeout = 30000, tabId = "default"): Promise<void> { await this.send("wait.response", { url: urlPattern, timeout, tabId }); }
291
223
 
224
+ // ── Screenshot / PDF ──────────────────────────────────────────────────────
292
225
  async screenshot(filePath?: string, tabId = "default"): Promise<string> {
293
226
  const b64 = await this.send<string>("screenshot", { tabId });
294
- if (filePath) {
295
- mkdirSync(dirname(filePath), { recursive: true });
296
- writeFileSync(filePath, Buffer.from(b64, "base64"));
297
- }
227
+ if (filePath) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, Buffer.from(b64, "base64")); }
298
228
  return filePath ?? b64;
299
229
  }
300
-
301
- // ── PDF ───────────────────────────────────────────────────────────────────────
302
-
303
230
  async pdf(filePath?: string, tabId = "default"): Promise<string> {
304
231
  const b64 = await this.send<string>("pdf", { tabId });
305
- if (filePath) {
306
- mkdirSync(dirname(filePath), { recursive: true });
307
- writeFileSync(filePath, Buffer.from(b64, "base64"));
308
- }
232
+ if (filePath) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, Buffer.from(b64, "base64")); }
309
233
  return filePath ?? b64;
310
234
  }
311
235
 
312
- // ── Image blocking ────────────────────────────────────────────────────────────
313
-
314
- async blockImages(tabId = "default"): Promise<void> {
315
- await this.send("intercept.block.images", { tabId });
316
- }
317
-
318
- async unblockImages(tabId = "default"): Promise<void> {
319
- await this.send("intercept.unblock.images", { tabId });
320
- }
321
-
322
- // ── Cookies ───────────────────────────────────────────────────────────────────
236
+ // ── Image blocking ────────────────────────────────────────────────────────
237
+ async blockImages(tabId = "default"): Promise<void> { await this.send("intercept.block.images", { tabId }); }
238
+ async unblockImages(tabId = "default"): Promise<void> { await this.send("intercept.unblock.images", { tabId }); }
323
239
 
324
- async setCookie(name: string, value: string, domain: string, path = "/", tabId = "default"): Promise<void> {
325
- await this.send("cookie.set", { name, value, domain, path, tabId });
326
- }
327
-
328
- async getCookie(name: string, tabId = "default"): Promise<any> {
329
- return this.send("cookie.get", { name, tabId });
330
- }
331
-
332
- async deleteCookie(name: string, tabId = "default"): Promise<void> {
333
- await this.send("cookie.delete", { name, tabId });
334
- }
335
-
336
- async listCookies(tabId = "default"): Promise<any[]> {
337
- return this.send<any[]>("cookie.list", { tabId });
338
- }
339
-
340
- // ── Interception ──────────────────────────────────────────────────────────────
240
+ // ── Cookies ───────────────────────────────────────────────────────────────
241
+ async setCookie(name: string, value: string, domain: string, path = "/", tabId = "default"): Promise<void> { await this.send("cookie.set", { name, value, domain, path, tabId }); }
242
+ async getCookie(name: string, tabId = "default"): Promise<any> { return this.send("cookie.get", { name, tabId }); }
243
+ async deleteCookie(name: string, tabId = "default"): Promise<void> { await this.send("cookie.delete", { name, tabId }); }
244
+ async listCookies(tabId = "default"): Promise<any[]> { return this.send<any[]>("cookie.list", { tabId }); }
341
245
 
246
+ // ── Interception ──────────────────────────────────────────────────────────
342
247
  async addInterceptRule(action: "block" | "redirect" | "modifyHeaders", pattern: string, options: { redirectUrl?: string; headers?: Record<string, string> } = {}, tabId = "default"): Promise<void> {
343
248
  await this.send("intercept.rule.add", { action, pattern, ...options, tabId });
344
249
  }
250
+ async clearInterceptRules(tabId = "default"): Promise<void> { await this.send("intercept.rule.clear", { tabId }); }
345
251
 
346
- async clearInterceptRules(tabId = "default"): Promise<void> {
347
- await this.send("intercept.rule.clear", { tabId });
348
- }
349
-
350
- // ── Network capture ───────────────────────────────────────────────────────────
351
-
352
- async captureStart(tabId = "default"): Promise<void> {
353
- await this.send("capture.start", { tabId });
354
- }
252
+ // ── Network capture ───────────────────────────────────────────────────────
253
+ async captureStart(tabId = "default"): Promise<void> { await this.send("capture.start", { tabId }); }
254
+ async captureStop(tabId = "default"): Promise<void> { await this.send("capture.stop", { tabId }); }
255
+ async captureRequests(tabId = "default"): Promise<any[]> { return this.send<any[]>("capture.requests", { tabId }); }
256
+ async captureWs(tabId = "default"): Promise<any[]> { return this.send<any[]>("capture.ws", { tabId }); }
257
+ async captureCookies(tabId = "default"): Promise<any[]> { return this.send<any[]>("capture.cookies", { tabId }); }
258
+ async captureStorage(tabId = "default"): Promise<any> { return this.send("capture.storage", { tabId }); }
259
+ async captureClear(tabId = "default"): Promise<void> { await this.send("capture.clear", { tabId }); }
355
260
 
356
- async captureStop(tabId = "default"): Promise<void> {
357
- await this.send("capture.stop", { tabId });
358
- }
359
-
360
- async captureRequests(tabId = "default"): Promise<any[]> {
361
- return this.send<any[]>("capture.requests", { tabId });
362
- }
363
-
364
- async captureWs(tabId = "default"): Promise<any[]> {
365
- return this.send<any[]>("capture.ws", { tabId });
366
- }
367
-
368
- async captureCookies(tabId = "default"): Promise<any[]> {
369
- return this.send<any[]>("capture.cookies", { tabId });
370
- }
371
-
372
- async captureStorage(tabId = "default"): Promise<any> {
373
- return this.send("capture.storage", { tabId });
374
- }
375
-
376
- async captureClear(tabId = "default"): Promise<void> {
377
- await this.send("capture.clear", { tabId });
378
- }
379
-
380
- // ── Session ───────────────────────────────────────────────────────────────────
381
-
382
- async sessionExport(tabId = "default"): Promise<any> {
383
- return this.send("session.export", { tabId });
384
- }
385
-
386
- async sessionImport(data: any, tabId = "default"): Promise<void> {
387
- await this.send("session.import", { data, tabId });
388
- }
389
-
390
- // ── Expose Function ───────────────────────────────────────────────────────────
261
+ // ── Session ───────────────────────────────────────────────────────────────
262
+ async sessionExport(tabId = "default"): Promise<any> { return this.send("session.export", { tabId }); }
263
+ async sessionImport(data: any, tabId = "default"): Promise<void> { await this.send("session.import", { data, tabId }); }
391
264
 
265
+ // ── Expose Function ───────────────────────────────────────────────────────
392
266
  async exposeFunction(name: string, handler: (data: any) => Promise<any> | any, tabId = "default"): Promise<void> {
393
- if (!this.eventHandlers.has(tabId)) {
394
- this.eventHandlers.set(tabId, new Map());
395
- }
267
+ if (!this.eventHandlers.has(tabId)) this.eventHandlers.set(tabId, new Map());
396
268
  this.eventHandlers.get(tabId)!.set(name, async (data: any) => {
397
269
  try {
398
270
  const result = await handler(data);
399
- if (result && typeof result === "object" && ("success" in result || "error" in result)) {
400
- return result;
401
- }
271
+ if (result && typeof result === "object" && ("success" in result || "error" in result)) return result;
402
272
  return { success: true, result };
403
273
  } catch (err: any) {
404
274
  return { success: false, error: err.message || String(err) };
@@ -0,0 +1,153 @@
1
+ // piggy/intercept/scripts.ts
2
+ // JS injection helpers for intercept.respond and intercept.modifyResponse.
3
+ // Both work purely in the browser's JS layer — no C++ changes needed.
4
+
5
+ /**
6
+ * Generates a script that short-circuits matching fetch/XHR requests
7
+ * and returns a static fake response — the request never hits the network.
8
+ */
9
+ export function buildRespondScript(
10
+ pattern: string,
11
+ status: number,
12
+ contentType: string,
13
+ body: string
14
+ ): string {
15
+ const safePattern = pattern.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
16
+ const safeBody = body.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
17
+ const safeContentType = contentType.replace(/'/g, "\\'");
18
+
19
+ return `
20
+ (function() {
21
+ 'use strict';
22
+ if (!window.__PIGGY_RESPOND_RULES__) window.__PIGGY_RESPOND_RULES__ = [];
23
+ window.__PIGGY_RESPOND_RULES__.push({
24
+ pattern: '${safePattern}',
25
+ status: ${status},
26
+ contentType: '${safeContentType}',
27
+ body: \`${safeBody}\`
28
+ });
29
+
30
+ function _piggyMatchUrl(url, pattern) {
31
+ try { return url.includes(pattern) || new RegExp(pattern).test(url); }
32
+ catch { return url.includes(pattern); }
33
+ }
34
+
35
+ // Only install wrappers once per page
36
+ if (window.__PIGGY_RESPOND_INSTALLED__) return;
37
+ window.__PIGGY_RESPOND_INSTALLED__ = true;
38
+
39
+ // ── fetch wrapper ──────────────────────────────────────────────────────────
40
+ const _origFetch = window.fetch;
41
+ window.fetch = function(input, init) {
42
+ const url = typeof input === 'string' ? input : (input?.url ?? String(input));
43
+ const rules = window.__PIGGY_RESPOND_RULES__ || [];
44
+ for (const rule of rules) {
45
+ if (_piggyMatchUrl(url, rule.pattern)) {
46
+ return Promise.resolve(new Response(rule.body, {
47
+ status: rule.status,
48
+ headers: { 'Content-Type': rule.contentType }
49
+ }));
50
+ }
51
+ }
52
+ return _origFetch.apply(this, arguments);
53
+ };
54
+
55
+ // ── XHR wrapper ────────────────────────────────────────────────────────────
56
+ const _origOpen = XMLHttpRequest.prototype.open;
57
+ const _origSend = XMLHttpRequest.prototype.send;
58
+
59
+ XMLHttpRequest.prototype.open = function(method, url) {
60
+ this.__piggy_url__ = String(url);
61
+ return _origOpen.apply(this, arguments);
62
+ };
63
+
64
+ XMLHttpRequest.prototype.send = function() {
65
+ const url = this.__piggy_url__ || '';
66
+ const rules = window.__PIGGY_RESPOND_RULES__ || [];
67
+ for (const rule of rules) {
68
+ if (_piggyMatchUrl(url, rule.pattern)) {
69
+ const self = this;
70
+ Object.defineProperty(self, 'readyState', { get: () => 4, configurable: true });
71
+ Object.defineProperty(self, 'status', { get: () => rule.status, configurable: true });
72
+ Object.defineProperty(self, 'responseText', { get: () => rule.body, configurable: true });
73
+ Object.defineProperty(self, 'response', { get: () => rule.body, configurable: true });
74
+ setTimeout(() => {
75
+ if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
76
+ self.dispatchEvent(new Event('readystatechange'));
77
+ self.dispatchEvent(new Event('load'));
78
+ self.dispatchEvent(new Event('loadend'));
79
+ }, 0);
80
+ return;
81
+ }
82
+ }
83
+ return _origSend.apply(this, arguments);
84
+ };
85
+ })();
86
+ `;
87
+ }
88
+
89
+ /**
90
+ * Generates a script that lets the request hit the network, then calls
91
+ * an exposed function with { body, status, headers }.
92
+ * The exposed function returns { body?, status?, headers? } modifications
93
+ * or an empty object {} to pass through unchanged.
94
+ */
95
+ export function buildModifyResponseScript(pattern: string, exposedFnName: string): string {
96
+ const safePattern = pattern.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
97
+ const safeFnName = exposedFnName.replace(/'/g, "\\'");
98
+
99
+ return `
100
+ (function() {
101
+ 'use strict';
102
+ if (!window.__PIGGY_MODIFY_RULES__) window.__PIGGY_MODIFY_RULES__ = [];
103
+ window.__PIGGY_MODIFY_RULES__.push({ pattern: '${safePattern}', fn: '${safeFnName}' });
104
+
105
+ function _piggyMatchUrl(url, pattern) {
106
+ try { return url.includes(pattern) || new RegExp(pattern).test(url); }
107
+ catch { return url.includes(pattern); }
108
+ }
109
+
110
+ // Only install wrappers once per page
111
+ if (window.__PIGGY_MODIFY_INSTALLED__) return;
112
+ window.__PIGGY_MODIFY_INSTALLED__ = true;
113
+
114
+ const _origFetch = window.fetch;
115
+ window.fetch = async function(input, init) {
116
+ const url = typeof input === 'string' ? input : (input?.url ?? String(input));
117
+ const rules = window.__PIGGY_MODIFY_RULES__ || [];
118
+
119
+ let matchedFn = null;
120
+ for (const rule of rules) {
121
+ if (_piggyMatchUrl(url, rule.pattern)) { matchedFn = rule.fn; break; }
122
+ }
123
+
124
+ // No match — pass through untouched
125
+ const resp = await _origFetch.apply(this, arguments);
126
+ if (!matchedFn) return resp;
127
+
128
+ try {
129
+ const bodyText = await resp.clone().text();
130
+ const headers = {};
131
+ resp.headers.forEach((v, k) => { headers[k] = v; });
132
+
133
+ const handlerFn = window[matchedFn];
134
+ if (typeof handlerFn !== 'function') return resp;
135
+
136
+ // Call Node.js handler via exposeFunction bridge
137
+ const mod = await handlerFn({ body: bodyText, status: resp.status, headers });
138
+ if (!mod || typeof mod !== 'object' || Object.keys(mod).length === 0) return resp;
139
+
140
+ return new Response(
141
+ mod.body !== undefined ? mod.body : bodyText,
142
+ {
143
+ status: mod.status !== undefined ? mod.status : resp.status,
144
+ headers: mod.headers !== undefined ? mod.headers : headers,
145
+ }
146
+ );
147
+ } catch {
148
+ return resp; // On any error, pass through original response
149
+ }
150
+ };
151
+ })();
152
+ `;
153
+ }