nothing-browser 0.0.10 → 0.0.12

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
 
@@ -17,6 +17,7 @@ export class PiggyClient {
17
17
  private buf = "";
18
18
  private eventBuffer = "";
19
19
  private eventHandlers = new Map<string, Map<string, (data: any) => Promise<any>>>();
20
+ private globalEventHandlers = new Map<string, Set<(data: any) => void>>();
20
21
 
21
22
  constructor(socketPath = SOCKET_PATH) {
22
23
  this.socketPath = socketPath;
@@ -39,17 +40,17 @@ export class PiggyClient {
39
40
  this.eventBuffer += chunk;
40
41
  const lines = this.eventBuffer.split("\n");
41
42
  this.eventBuffer = lines.pop()!;
42
-
43
+
43
44
  for (const line of lines) {
44
45
  if (!line.trim()) continue;
45
46
  try {
46
47
  const msg = JSON.parse(line);
47
-
48
+
48
49
  if (msg.type === "event") {
49
50
  this.handleEvent(msg);
50
51
  continue;
51
52
  }
52
-
53
+
53
54
  const p = this.pending.get(msg.id);
54
55
  if (p) {
55
56
  this.pending.delete(msg.id);
@@ -75,31 +76,31 @@ export class PiggyClient {
75
76
  }
76
77
 
77
78
  private handleEvent(event: any) {
79
+ // ── exposed_call ──────────────────────────────────────────────────────────
78
80
  if (event.event === "exposed_call") {
79
81
  const { tabId, name, callId, data } = event;
80
82
  const effectiveTabId = tabId || "default";
81
83
  const handlers = this.eventHandlers.get(effectiveTabId);
82
84
  const handler = handlers?.get(name);
83
-
85
+
84
86
  if (handler) {
85
- Promise.resolve(handler(JSON.parse(data || "null")))
87
+ // ✅ FIX: data can be a plain string (e.g., "OPENING") or JSON
88
+ let parsedData;
89
+ try {
90
+ parsedData = JSON.parse(data || "null");
91
+ } catch {
92
+ parsedData = data; // fallback to raw string
93
+ }
94
+
95
+ Promise.resolve(handler(parsedData))
86
96
  .then(response => {
87
97
  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
- }
98
+ this.send("exposed.result", {
99
+ tabId: effectiveTabId,
100
+ callId,
101
+ result: response.success ? JSON.stringify(response.result) : (response.error || "Unknown error"),
102
+ isError: !response.success
103
+ }).catch(e => logger.error(`Failed to send exposed result: ${e}`));
103
104
  } else {
104
105
  this.send("exposed.result", {
105
106
  tabId: effectiveTabId,
@@ -120,15 +121,45 @@ export class PiggyClient {
120
121
  } else {
121
122
  logger.warn(`No handler for exposed function: ${name} in tab ${effectiveTabId}`);
122
123
  }
124
+ return;
125
+ }
126
+
127
+ // ── navigate ──────────────────────────────────────────────────────────────
128
+ if (event.event === "navigate") {
129
+ const handlers = this.globalEventHandlers.get(`navigate:${event.tabId}`);
130
+ if (handlers) {
131
+ for (const h of handlers) {
132
+ try { h(event.url); } catch (e) { logger.error(`navigate handler error: ${e}`); }
133
+ }
134
+ }
135
+ // Also fire wildcard listeners (no tabId filter)
136
+ const wildcard = this.globalEventHandlers.get("navigate:*");
137
+ if (wildcard) {
138
+ for (const h of wildcard) {
139
+ try { h({ url: event.url, tabId: event.tabId }); } catch {}
140
+ }
141
+ }
142
+ return;
123
143
  }
124
144
  }
125
145
 
146
+ // ── Global event subscription ─────────────────────────────────────────────
147
+ onEvent(eventName: string, tabId: string, handler: (data: any) => void): () => void {
148
+ const key = `${eventName}:${tabId}`;
149
+ if (!this.globalEventHandlers.has(key)) {
150
+ this.globalEventHandlers.set(key, new Set());
151
+ }
152
+ this.globalEventHandlers.get(key)!.add(handler);
153
+ // Return unsubscribe fn
154
+ return () => this.globalEventHandlers.get(key)?.delete(handler);
155
+ }
156
+
126
157
  disconnect() {
127
158
  this.socket?.destroy();
128
159
  this.socket = null;
129
160
  }
130
161
 
131
- private send<T = any>(cmd: string, payload: Record<string, any> = {}): Promise<T> {
162
+ send<T = any>(cmd: string, payload: Record<string, any> = {}): Promise<T> {
132
163
  return new Promise((resolve, reject) => {
133
164
  if (!this.socket) return reject(new Error("Not connected"));
134
165
  const id = String(++this.reqId);
@@ -137,118 +168,43 @@ export class PiggyClient {
137
168
  });
138
169
  }
139
170
 
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
-
171
+ // ── Tabs ──────────────────────────────────────────────────────────────────
172
+ async newTab(): Promise<string> { return this.send<string>("tab.new", {}); }
173
+ async closeTab(tabId: string): Promise<void> { await this.send("tab.close", { tabId }); }
174
+ async listTabs(): Promise<string[]> { return this.send<string[]>("tab.list", {}); }
175
+
176
+ // ── Navigation ────────────────────────────────────────────────────────────
177
+ async navigate(url: string, tabId = "default"): Promise<void> { await this.send("navigate", { url, tabId }); }
178
+ async reload(tabId = "default"): Promise<void> { await this.send("reload", { tabId }); }
179
+ async goBack(tabId = "default"): Promise<void> { await this.send("go.back", { tabId }); }
180
+ async goForward(tabId = "default"): Promise<void> { await this.send("go.forward", { tabId }); }
181
+
182
+ // ── Page info ─────────────────────────────────────────────────────────────
183
+ async getTitle(tabId = "default"): Promise<string> { return this.send<string>("page.title", { tabId }); }
184
+ async getUrl(tabId = "default"): Promise<string> { return this.send<string>("page.url", { tabId }); }
185
+ async content(tabId = "default"): Promise<string> { return this.send<string>("page.content", { tabId }); }
186
+
187
+ // ── Eval / JS ─────────────────────────────────────────────────────────────
188
+ async evaluate(js: string, tabId = "default"): Promise<any> { return this.send("evaluate", { js, tabId }); }
189
+ async addInitScript(js: string, tabId = "default"): Promise<void> { await this.send("addInitScript", { js, tabId }); }
190
+
191
+ // ── Interactions ──────────────────────────────────────────────────────────
192
+ async click(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("click", { selector, tabId }); }
193
+ async doubleClick(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("dblclick", { selector, tabId }); }
194
+ async hover(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("hover", { selector, tabId }); }
195
+ async type(selector: string, text: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("type", { selector, text, tabId }); }
196
+ async select(selector: string, value: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("select", { selector, value, tabId }); }
197
+ async keyPress(key: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("keyboard.press", { key, tabId }); }
198
+ async keyCombo(combo: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("keyboard.combo", { combo, tabId }); }
199
+ async mouseMove(x: number, y: number, tabId = "default"): Promise<boolean> { return this.send<boolean>("mouse.move", { x, y, tabId }); }
200
+ 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 }); }
201
+
202
+ // ── Scroll ────────────────────────────────────────────────────────────────
203
+ async scrollTo(selector: string, tabId = "default"): Promise<boolean> { return this.send<boolean>("scroll.to", { selector, tabId }); }
204
+ async scrollBy(px: number, tabId = "default"): Promise<boolean> { return this.send<boolean>("scroll.by", { px, tabId }); }
205
+
206
+ // ── Fetch ─────────────────────────────────────────────────────────────────
207
+ async fetchText(query: string, tabId = "default"): Promise<string | null> { return this.send<string | null>("fetch.text", { query, tabId }); }
252
208
  async fetchLinks(query: string, tabId = "default"): Promise<string[]> {
253
209
  if (query === "a" || query === "body") {
254
210
  const result = await this.send<string[]>("fetch.links.all", { tabId });
@@ -257,148 +213,68 @@ export class PiggyClient {
257
213
  const result = await this.send<string[]>("fetch.links", { query, tabId });
258
214
  return Array.isArray(result) ? result : [];
259
215
  }
260
-
261
216
  async fetchImages(query: string, tabId = "default"): Promise<string[]> {
262
217
  const result = await this.send<string[]>("fetch.image", { query, tabId });
263
218
  return Array.isArray(result) ? result : [];
264
219
  }
265
220
 
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
- }
275
-
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
- }
221
+ // ── Search ────────────────────────────────────────────────────────────────
222
+ async searchCss(query: string, tabId = "default"): Promise<any> { return this.send("search.css", { query, tabId }); }
223
+ async searchId(query: string, tabId = "default"): Promise<any> { return this.send("search.id", { query, tabId }); }
289
224
 
290
- // ── Screenshot ────────────────────────────────────────────────────────────────
225
+ // ── Wait ──────────────────────────────────────────────────────────────────
226
+ async waitForSelector(selector: string, timeout = 30000, tabId = "default"): Promise<void> { await this.send("wait.selector", { selector, timeout, tabId }); }
227
+ async waitForNavigation(tabId = "default"): Promise<void> { await this.send("wait.navigation", { tabId }); }
228
+ async waitForResponse(urlPattern: string, timeout = 30000, tabId = "default"): Promise<void> { await this.send("wait.response", { url: urlPattern, timeout, tabId }); }
291
229
 
230
+ // ── Screenshot / PDF ──────────────────────────────────────────────────────
292
231
  async screenshot(filePath?: string, tabId = "default"): Promise<string> {
293
232
  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
- }
233
+ if (filePath) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, Buffer.from(b64, "base64")); }
298
234
  return filePath ?? b64;
299
235
  }
300
-
301
- // ── PDF ───────────────────────────────────────────────────────────────────────
302
-
303
236
  async pdf(filePath?: string, tabId = "default"): Promise<string> {
304
237
  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
- }
238
+ if (filePath) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, Buffer.from(b64, "base64")); }
309
239
  return filePath ?? b64;
310
240
  }
311
241
 
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
- }
242
+ // ── Image blocking ────────────────────────────────────────────────────────
243
+ async blockImages(tabId = "default"): Promise<void> { await this.send("intercept.block.images", { tabId }); }
244
+ async unblockImages(tabId = "default"): Promise<void> { await this.send("intercept.unblock.images", { tabId }); }
321
245
 
322
- // ── Cookies ───────────────────────────────────────────────────────────────────
323
-
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 ──────────────────────────────────────────────────────────────
246
+ // ── Cookies ───────────────────────────────────────────────────────────────
247
+ async setCookie(name: string, value: string, domain: string, path = "/", tabId = "default"): Promise<void> { await this.send("cookie.set", { name, value, domain, path, tabId }); }
248
+ async getCookie(name: string, tabId = "default"): Promise<any> { return this.send("cookie.get", { name, tabId }); }
249
+ async deleteCookie(name: string, tabId = "default"): Promise<void> { await this.send("cookie.delete", { name, tabId }); }
250
+ async listCookies(tabId = "default"): Promise<any[]> { return this.send<any[]>("cookie.list", { tabId }); }
341
251
 
252
+ // ── Interception ──────────────────────────────────────────────────────────
342
253
  async addInterceptRule(action: "block" | "redirect" | "modifyHeaders", pattern: string, options: { redirectUrl?: string; headers?: Record<string, string> } = {}, tabId = "default"): Promise<void> {
343
254
  await this.send("intercept.rule.add", { action, pattern, ...options, tabId });
344
255
  }
256
+ async clearInterceptRules(tabId = "default"): Promise<void> { await this.send("intercept.rule.clear", { tabId }); }
345
257
 
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
- }
258
+ // ── Network capture ───────────────────────────────────────────────────────
259
+ async captureStart(tabId = "default"): Promise<void> { await this.send("capture.start", { tabId }); }
260
+ async captureStop(tabId = "default"): Promise<void> { await this.send("capture.stop", { tabId }); }
261
+ async captureRequests(tabId = "default"): Promise<any[]> { return this.send<any[]>("capture.requests", { tabId }); }
262
+ async captureWs(tabId = "default"): Promise<any[]> { return this.send<any[]>("capture.ws", { tabId }); }
263
+ async captureCookies(tabId = "default"): Promise<any[]> { return this.send<any[]>("capture.cookies", { tabId }); }
264
+ async captureStorage(tabId = "default"): Promise<any> { return this.send("capture.storage", { tabId }); }
265
+ async captureClear(tabId = "default"): Promise<void> { await this.send("capture.clear", { tabId }); }
355
266
 
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 ───────────────────────────────────────────────────────────
267
+ // ── Session ───────────────────────────────────────────────────────────────
268
+ async sessionExport(tabId = "default"): Promise<any> { return this.send("session.export", { tabId }); }
269
+ async sessionImport(data: any, tabId = "default"): Promise<void> { await this.send("session.import", { data, tabId }); }
391
270
 
271
+ // ── Expose Function ───────────────────────────────────────────────────────
392
272
  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
- }
273
+ if (!this.eventHandlers.has(tabId)) this.eventHandlers.set(tabId, new Map());
396
274
  this.eventHandlers.get(tabId)!.set(name, async (data: any) => {
397
275
  try {
398
276
  const result = await handler(data);
399
- if (result && typeof result === "object" && ("success" in result || "error" in result)) {
400
- return result;
401
- }
277
+ if (result && typeof result === "object" && ("success" in result || "error" in result)) return result;
402
278
  return { success: true, result };
403
279
  } catch (err: any) {
404
280
  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
+ }