nothing-browser 0.0.9 → 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.
@@ -3,6 +3,7 @@ import { PiggyClient } from "../client";
3
3
  import logger from "../logger";
4
4
  import { routeRegistry, keepAliveSites, type RouteHandler, type BeforeMiddleware } from "../server";
5
5
  import { randomDelay, humanTypeSequence } from "../human";
6
+ import { buildRespondScript, buildModifyResponseScript } from "../intercept/scripts";
6
7
 
7
8
  let globalClient: PiggyClient | null = null;
8
9
  export let humanMode = false;
@@ -27,6 +28,20 @@ async function retry<T>(label: string, fn: () => Promise<T>, retries = 2, backof
27
28
  export function createSiteObject(name: string, registeredUrl: string, client: PiggyClient, tabId: string) {
28
29
  let _currentUrl: string = registeredUrl;
29
30
 
31
+ // ── Event listeners store ──────────────────────────────────────────────────
32
+ const _eventListeners = new Map<string, Set<(data: any) => void>>();
33
+
34
+ // Wire the client-level navigate event into site-level listeners
35
+ const _unsubNavigate = client.onEvent("navigate", tabId, (url: string) => {
36
+ _currentUrl = url;
37
+ const handlers = _eventListeners.get("navigate");
38
+ if (handlers) {
39
+ for (const h of handlers) {
40
+ try { h(url); } catch (e) { logger.error(`[${name}] navigate handler error: ${e}`); }
41
+ }
42
+ }
43
+ });
44
+
30
45
  const withErrScreen = async <T>(fn: () => Promise<T>, label: string): Promise<T> => {
31
46
  try { return await fn(); } catch (err: any) {
32
47
  const p = `./error-${name}-${Date.now()}.png`;
@@ -36,6 +51,9 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
36
51
  }
37
52
  };
38
53
 
54
+ // ── Intercept helper: unique fn name per pattern ───────────────────────────
55
+ let _modifyRuleCounter = 0;
56
+
39
57
  const site: any = {
40
58
  _name: name,
41
59
  _tabId: tabId,
@@ -75,8 +93,7 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
75
93
  waitForVisible: (selector: string, timeout = 30000) => client.waitForSelector(selector, timeout, tabId),
76
94
  waitForResponse: (pattern: string, timeout = 30000) => client.waitForResponse(pattern, timeout, tabId),
77
95
 
78
- // ── Init Script ─────────────────────────────────────────────────────────────
79
- // HERE IT IS - ADD THIS METHOD TO THE SITE OBJECT
96
+ // ── Init Script ────────────────────────────────────────────────────────────
80
97
  addInitScript: async (js: string | (() => void)) => {
81
98
  const code = typeof js === 'function' ? `(${js.toString()})();` : js;
82
99
  await client.addInitScript(code, tabId);
@@ -84,6 +101,23 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
84
101
  return site;
85
102
  },
86
103
 
104
+ // ── Event emitter ──────────────────────────────────────────────────────────
105
+ // Usage: site.on('navigate', url => console.log('went to', url))
106
+ // Returns unsubscribe function
107
+ on: (event: string, handler: (data: any) => void): (() => void) => {
108
+ if (!_eventListeners.has(event)) _eventListeners.set(event, new Set());
109
+ _eventListeners.get(event)!.add(handler);
110
+ logger.debug(`[${name}] on('${event}') registered`);
111
+ return () => {
112
+ _eventListeners.get(event)?.delete(handler);
113
+ logger.debug(`[${name}] on('${event}') unsubscribed`);
114
+ };
115
+ },
116
+
117
+ off: (event: string, handler: (data: any) => void) => {
118
+ _eventListeners.get(event)?.delete(handler);
119
+ },
120
+
87
121
  // ── Interactions ───────────────────────────────────────────────────────────
88
122
  click: (selector: string, opts?: { retries?: number; timeout?: number }) =>
89
123
  withErrScreen(() =>
@@ -222,14 +256,151 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
222
256
  await client.addInterceptRule("block", pattern, {}, tabId);
223
257
  logger.info(`[${name}] intercept block: ${pattern}`);
224
258
  },
259
+
225
260
  redirect: async (pattern: string, redirectUrl: string) => {
226
261
  await client.addInterceptRule("redirect", pattern, { redirectUrl }, tabId);
227
262
  logger.info(`[${name}] intercept redirect: ${pattern} → ${redirectUrl}`);
228
263
  },
264
+
229
265
  headers: async (pattern: string, headers: Record<string, string>) => {
230
266
  await client.addInterceptRule("modifyHeaders", pattern, { headers }, tabId);
231
267
  logger.info(`[${name}] intercept modifyHeaders: ${pattern}`);
232
268
  },
269
+
270
+ // ── NEW: intercept.respond ──────────────────────────────────────────────
271
+ // Intercepts matching requests and returns a fake response — request never
272
+ // leaves the browser. Works for both fetch and XHR via JS injection.
273
+ //
274
+ // Usage:
275
+ // await site.intercept.respond('/api/prices', (req) => ({
276
+ // status: 200,
277
+ // contentType: 'application/json',
278
+ // body: JSON.stringify({ price: 99 })
279
+ // }))
280
+ //
281
+ // // Static shorthand:
282
+ // await site.intercept.respond('/api/prices', {
283
+ // status: 200, contentType: 'application/json', body: '{"price":99}'
284
+ // })
285
+ respond: async (
286
+ pattern: string,
287
+ handlerOrResponse:
288
+ | { status?: number; contentType?: string; body: string }
289
+ | ((req: { url: string; method: string }) => { status?: number; contentType?: string; body: string })
290
+ ) => {
291
+ // Static response — just inject the JS rule directly
292
+ const isStatic = typeof handlerOrResponse === "object";
293
+ const response = isStatic
294
+ ? handlerOrResponse
295
+ : { status: 200, contentType: "application/json", body: "" };
296
+
297
+ if (!isStatic) {
298
+ // Dynamic: expose a function, call it from the injected script
299
+ const fnName = `__piggy_respond_${name}_${++_modifyRuleCounter}__`;
300
+
301
+ await client.exposeFunction(fnName, async (req: { url: string; method: string }) => {
302
+ try {
303
+ const result = (handlerOrResponse as Function)(req);
304
+ return {
305
+ success: true,
306
+ result: {
307
+ status: result.status ?? 200,
308
+ contentType: result.contentType ?? "application/json",
309
+ body: result.body ?? "",
310
+ }
311
+ };
312
+ } catch (e: any) {
313
+ return { success: false, error: e.message };
314
+ }
315
+ }, tabId);
316
+
317
+ // Inject a script that calls the exposed function instead of static body
318
+ const dynamicScript = `
319
+ (function() {
320
+ 'use strict';
321
+ if (!window.__PIGGY_DYNAMIC_RESPOND__) window.__PIGGY_DYNAMIC_RESPOND__ = [];
322
+ window.__PIGGY_DYNAMIC_RESPOND__.push({ pattern: ${JSON.stringify(pattern)}, fn: ${JSON.stringify(fnName)} });
323
+
324
+ function matchUrl(url, pattern) {
325
+ try { return url.includes(pattern) || new RegExp(pattern).test(url); }
326
+ catch { return url.includes(pattern); }
327
+ }
328
+
329
+ if (window.__PIGGY_DYN_INSTALLED__) return;
330
+ window.__PIGGY_DYN_INSTALLED__ = true;
331
+
332
+ const _origFetch = window.fetch;
333
+ window.fetch = async function(input, init) {
334
+ const url = typeof input === 'string' ? input : (input?.url ?? String(input));
335
+ const method = (init?.method ?? 'GET').toUpperCase();
336
+ const rules = window.__PIGGY_DYNAMIC_RESPOND__ || [];
337
+ for (const rule of rules) {
338
+ if (matchUrl(url, rule.pattern) && typeof window[rule.fn] === 'function') {
339
+ try {
340
+ const r = await window[rule.fn]({ url, method });
341
+ return new Response(r.body ?? '', {
342
+ status: r.status ?? 200,
343
+ headers: { 'Content-Type': r.contentType ?? 'application/json' }
344
+ });
345
+ } catch { break; }
346
+ }
347
+ }
348
+ return _origFetch.apply(this, arguments);
349
+ };
350
+ })();`;
351
+ await client.addInitScript(dynamicScript, tabId);
352
+ await client.evaluate(dynamicScript, tabId);
353
+ logger.success(`[${name}] intercept.respond (dynamic): ${pattern}`);
354
+ return site;
355
+ }
356
+
357
+ // Static path: inject the JS intercept rule
358
+ const script = buildRespondScript(
359
+ pattern,
360
+ response.status ?? 200,
361
+ response.contentType ?? "application/json",
362
+ response.body
363
+ );
364
+ await client.addInitScript(script, tabId);
365
+ await client.evaluate(script, tabId);
366
+ logger.success(`[${name}] intercept.respond (static): ${pattern} → ${response.status ?? 200}`);
367
+ return site;
368
+ },
369
+
370
+ // ── NEW: intercept.modifyResponse ───────────────────────────────────────
371
+ // Lets the request hit the network, then calls your handler with the
372
+ // response. Return { body?, status?, headers? } to modify, or {} to
373
+ // pass through unchanged.
374
+ //
375
+ // Usage:
376
+ // await site.intercept.modifyResponse('/api/feed', async ({ body, status }) => {
377
+ // const data = JSON.parse(body)
378
+ // data.items = data.items.slice(0, 5)
379
+ // return { body: JSON.stringify(data) }
380
+ // })
381
+ modifyResponse: async (
382
+ pattern: string,
383
+ handler: (response: { body: string; status: number; headers: Record<string, string> }) =>
384
+ Promise<{ body?: string; status?: number; headers?: Record<string, string> } | void> | void
385
+ ) => {
386
+ const fnName = `__piggy_modres_${name}_${++_modifyRuleCounter}__`;
387
+
388
+ await client.exposeFunction(fnName, async (response: { body: string; status: number; headers: Record<string, string> }) => {
389
+ try {
390
+ const mod = await handler(response);
391
+ return { success: true, result: mod ?? {} };
392
+ } catch (e: any) {
393
+ return { success: false, error: e.message };
394
+ }
395
+ }, tabId);
396
+
397
+ const script = buildModifyResponseScript(pattern, fnName);
398
+ await client.addInitScript(script, tabId);
399
+ await client.evaluate(script, tabId);
400
+ logger.success(`[${name}] intercept.modifyResponse: ${pattern}`);
401
+ return site;
402
+ },
403
+
233
404
  clear: async () => {
234
405
  await client.clearInterceptRules(tabId);
235
406
  logger.info(`[${name}] intercept rules cleared`);
@@ -238,35 +409,19 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
238
409
 
239
410
  // ── Network capture ────────────────────────────────────────────────────────
240
411
  capture: {
241
- start: async () => {
242
- await client.captureStart(tabId);
243
- logger.info(`[${name}] capture started`);
244
- },
245
- stop: async () => {
246
- await client.captureStop(tabId);
247
- logger.info(`[${name}] capture stopped`);
248
- },
412
+ start: async () => { await client.captureStart(tabId); logger.info(`[${name}] capture started`); },
413
+ stop: async () => { await client.captureStop(tabId); logger.info(`[${name}] capture stopped`); },
249
414
  requests: () => client.captureRequests(tabId),
250
415
  ws: () => client.captureWs(tabId),
251
416
  cookies: () => client.captureCookies(tabId),
252
417
  storage: () => client.captureStorage(tabId),
253
- clear: async () => {
254
- await client.captureClear(tabId);
255
- logger.info(`[${name}] capture cleared`);
256
- },
418
+ clear: async () => { await client.captureClear(tabId); logger.info(`[${name}] capture cleared`); },
257
419
  },
258
420
 
259
421
  // ── Session ────────────────────────────────────────────────────────────────
260
422
  session: {
261
- export: async () => {
262
- const data = await client.sessionExport(tabId);
263
- logger.success(`[${name}] session exported`);
264
- return data;
265
- },
266
- import: async (data: any) => {
267
- await client.sessionImport(data, tabId);
268
- logger.success(`[${name}] session imported`);
269
- },
423
+ export: async () => { const data = await client.sessionExport(tabId); logger.success(`[${name}] session exported`); return data; },
424
+ import: async (data: any) => { await client.sessionImport(data, tabId); logger.success(`[${name}] session imported`); },
270
425
  },
271
426
 
272
427
  // ── Expose Function ─────────────────────────────────────────────────────────
@@ -275,19 +430,16 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
275
430
  logger.success(`[${name}] exposed function: ${fnName}`);
276
431
  return site;
277
432
  },
278
-
279
433
  unexposeFunction: async (fnName: string) => {
280
434
  await client.unexposeFunction(fnName, tabId);
281
435
  logger.info(`[${name}] unexposed function: ${fnName}`);
282
436
  return site;
283
437
  },
284
-
285
438
  clearExposedFunctions: async () => {
286
439
  await client.clearExposedFunctions(tabId);
287
440
  logger.info(`[${name}] cleared all exposed functions`);
288
441
  return site;
289
442
  },
290
-
291
443
  exposeAndInject: async (fnName: string, handler: (data: any) => Promise<any> | any, injectionJs: string | ((fnName: string) => string)) => {
292
444
  await client.exposeFunction(fnName, handler, tabId);
293
445
  const js = typeof injectionJs === "function" ? injectionJs(fnName) : injectionJs;
@@ -299,10 +451,7 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
299
451
  // ── Elysia API ─────────────────────────────────────────────────────────────
300
452
  api: (path: string, handler: RouteHandler, opts?: { ttl?: number; before?: BeforeMiddleware[]; method?: "GET" | "POST" | "PUT" | "DELETE" }) => {
301
453
  const key = `${name}:${path}`;
302
- if (routeRegistry.has(key)) {
303
- logger.warn(`[${name}] route ${path} already registered`);
304
- return site;
305
- }
454
+ if (routeRegistry.has(key)) { logger.warn(`[${name}] route ${path} already registered`); return site; }
306
455
  routeRegistry.set(key, {
307
456
  path,
308
457
  method: opts?.method ?? "GET",
@@ -317,6 +466,7 @@ export function createSiteObject(name: string, registeredUrl: string, client: Pi
317
466
  noclose: () => { keepAliveSites.add(name); logger.info(`[${name}] keep-alive`); return site; },
318
467
 
319
468
  close: async () => {
469
+ _unsubNavigate(); // Clean up navigate listener
320
470
  keepAliveSites.delete(name);
321
471
  if (tabId !== "default") {
322
472
  await client.closeTab(tabId);
@@ -333,12 +483,8 @@ export function createExposedAPI<T extends Record<string, (data: any) => any>>(s
333
483
  const { method, args } = call;
334
484
  const handler = handlers[method as keyof T];
335
485
  if (!handler) throw new Error(`Unknown method: ${method}`);
336
- try {
337
- return await handler(args);
338
- } catch (err) {
339
- logger.error(`[${site._name}] API error in ${method}:`, err);
340
- throw err;
341
- }
486
+ try { return await handler(args); }
487
+ catch (err) { logger.error(`[${site._name}] API error in ${method}:`, err); throw err; }
342
488
  };
343
489
  return site.exposeFunction(apiName, wrappedHandler);
344
490
  }
package/piggy.ts CHANGED
@@ -14,6 +14,7 @@ let _tabMode: TabMode = "tab";
14
14
  const _extraProcs: { socket: string; client: PiggyClient }[] = [];
15
15
  const _sites: Record<string, SiteObject> = {};
16
16
 
17
+ // CREATE THE PIGGY OBJECT AS A PLAIN OBJECT - NOT A PROXY
17
18
  const piggy: any = {
18
19
  // ── Lifecycle ───────────────────────────────────────────────────────────────
19
20
 
@@ -35,9 +36,12 @@ const piggy: any = {
35
36
 
36
37
  let tabId = "default";
37
38
  if (_tabMode === "tab") {
38
- tabId = await _client!.newTab();
39
- _sites[name] = createSiteObject(name, url, _client!, tabId);
40
- piggy[name] = _sites[name];
39
+ if (!_client) throw new Error("No client. Call piggy.launch() first.");
40
+ tabId = await _client.newTab();
41
+ // HERE IT IS - CREATE SITE OBJECT AND ASSIGN DIRECTLY
42
+ const siteObj = createSiteObject(name, url, _client, tabId);
43
+ _sites[name] = siteObj;
44
+ piggy[name] = siteObj; // DIRECT ASSIGNMENT - NO PROXY
41
45
  logger.success(`[${name}] registered as tab ${tabId}`);
42
46
  } else {
43
47
  const socketName = `piggy_${name}`;
@@ -46,8 +50,9 @@ const piggy: any = {
46
50
  const c = new PiggyClient(socketName);
47
51
  await c.connect();
48
52
  _extraProcs.push({ socket: socketName, client: c });
49
- _sites[name] = createSiteObject(name, url, c, "default");
50
- piggy[name] = _sites[name];
53
+ const siteObj = createSiteObject(name, url, c, "default");
54
+ _sites[name] = siteObj;
55
+ piggy[name] = siteObj; // DIRECT ASSIGNMENT - NO PROXY
51
56
  logger.success(`[${name}] registered as process on "${socketName}"`);
52
57
  }
53
58
 
@@ -148,5 +153,6 @@ const piggy: any = {
148
153
  logger,
149
154
  };
150
155
 
156
+ // NO PROXY WRAPPER - EXPORT THE PLAIN OBJECT DIRECTLY
151
157
  export default piggy;
152
158
  export { piggy };