nothing-browser 0.0.15 → 0.0.17

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.
package/piggy.ts CHANGED
@@ -2,324 +2,26 @@
2
2
  import { detectBinary, type BinaryMode } from "./piggy/launch/detect";
3
3
  import { spawnBrowser, killBrowser, spawnBrowserOnSocket } from "./piggy/launch/spawn";
4
4
  import { PiggyClient } from "./piggy/client";
5
- import { setClient, setHumanMode, createSiteObject } from "./piggy/register";
5
+ import { setClient, setHumanMode, createSiteObject, type SiteObject } from "./piggy/register";
6
6
  import { routeRegistry, keepAliveSites, startServer, stopServer } from "./piggy/server";
7
+ import { TabPool } from "./piggy/pool";
7
8
  import logger from "./piggy/logger";
8
- import type { Logger } from "ernest-logger";
9
- import type { Elysia } from "elysia";
10
- import type { RouteHandler, BeforeMiddleware, RouteConfig } from "./piggy/server";
11
9
 
12
- // ── Core types ───────────────────────────────────────────────────────────────
13
-
14
- export type TabMode = "tab" | "process";
15
-
16
- /** Returned by createSiteObject — full API surface of a registered site. */
17
- export interface SiteObject {
18
- /** Internal name used when registering. */
19
- _name: string;
20
- /** Internal CDP / socket tab ID. */
21
- _tabId: string;
22
-
23
- // ── Navigation ─────────────────────────────────────────────────────────────
24
- navigate(url?: string, opts?: { retries?: number }): Promise<void>;
25
- reload(): Promise<void>;
26
- goBack(): Promise<void>;
27
- goForward(): Promise<void>;
28
- waitForNavigation(): Promise<void>;
29
-
30
- title(): Promise<string>;
31
- /** Returns the last known URL (synchronous — does NOT hit the browser). */
32
- url(): string;
33
- content(): Promise<string>;
34
-
35
- // ── Timing ────────────────────────────────────────────────────────────────
36
- wait(ms: number): Promise<void>;
37
- waitForSelector(selector: string, timeout?: number): Promise<void>;
38
- waitForVisible(selector: string, timeout?: number): Promise<void>;
39
- waitForResponse(pattern: string, timeout?: number): Promise<void>;
40
-
41
- // ── Init script ───────────────────────────────────────────────────────────
42
- addInitScript(js: string | (() => void)): Promise<SiteObject>;
43
-
44
- // ── Event emitter ─────────────────────────────────────────────────────────
45
- on(event: "navigate", handler: (url: string) => void): () => void;
46
- on(event: string, handler: (data: unknown) => void): () => void;
47
- off(event: string, handler: (data: unknown) => void): void;
48
-
49
- // ── Interactions ──────────────────────────────────────────────────────────
50
- click(selector: string, opts?: { retries?: number; timeout?: number }): Promise<boolean>;
51
- doubleClick(selector: string): Promise<boolean>;
52
- hover(selector: string): Promise<boolean>;
53
- type(
54
- selector: string,
55
- text: string,
56
- opts?: { delay?: number; retries?: number; fact?: boolean; wpm?: number }
57
- ): Promise<boolean>;
58
- select(selector: string, value: string): Promise<boolean>;
59
- evaluate<T = unknown>(js: string | ((...args: unknown[]) => T), ...args: unknown[]): Promise<T>;
60
-
61
- keyboard: {
62
- press(key: string): Promise<boolean>;
63
- combo(combo: string): Promise<boolean>;
64
- };
65
-
66
- mouse: {
67
- move(x: number, y: number): Promise<boolean>;
68
- drag(from: { x: number; y: number }, to: { x: number; y: number }): Promise<boolean>;
69
- };
70
-
71
- scroll: {
72
- to(selector: string): Promise<boolean>;
73
- by(px: number): Promise<boolean>;
74
- };
75
-
76
- // ── Fetch ─────────────────────────────────────────────────────────────────
77
- fetchText(selector: string): Promise<string | null>;
78
- fetchLinks(selector: string): Promise<string[]>;
79
- fetchImages(selector: string): Promise<string[]>;
80
-
81
- search: {
82
- css(query: string): Promise<unknown>;
83
- id(query: string): Promise<unknown>;
84
- };
85
-
86
- // ── Screenshot / PDF ──────────────────────────────────────────────────────
87
- screenshot(filePath?: string): Promise<string>;
88
- pdf(filePath?: string): Promise<string>;
89
- blockImages(): Promise<void>;
90
- unblockImages(): Promise<void>;
91
-
92
- // ── Cookies ───────────────────────────────────────────────────────────────
93
- cookies: {
94
- set(name: string, value: string, domain: string, path?: string): Promise<void>;
95
- get(name: string): Promise<unknown>;
96
- delete(name: string): Promise<void>;
97
- list(): Promise<unknown[]>;
98
- };
99
-
100
- // ── Interception ──────────────────────────────────────────────────────────
101
- intercept: {
102
- block(pattern: string): Promise<void>;
103
- redirect(pattern: string, redirectUrl: string): Promise<void>;
104
- headers(pattern: string, headers: Record<string, string>): Promise<void>;
105
- respond(
106
- pattern: string,
107
- handlerOrResponse:
108
- | { status?: number; contentType?: string; body: string }
109
- | ((req: { url: string; method: string }) => {
110
- status?: number;
111
- contentType?: string;
112
- body: string;
113
- })
114
- ): Promise<SiteObject>;
115
- modifyResponse(
116
- pattern: string,
117
- handler: (response: {
118
- body: string;
119
- status: number;
120
- headers: Record<string, string>;
121
- }) => Promise<{ body?: string; status?: number; headers?: Record<string, string> } | void> | void
122
- ): Promise<SiteObject>;
123
- clear(): Promise<void>;
124
- };
125
-
126
- // ── Network capture ───────────────────────────────────────────────────────
127
- capture: {
128
- start(): Promise<void>;
129
- stop(): Promise<void>;
130
- requests(): Promise<unknown[]>;
131
- ws(): Promise<unknown[]>;
132
- cookies(): Promise<unknown[]>;
133
- storage(): Promise<unknown>;
134
- clear(): Promise<void>;
135
- };
136
-
137
- // ── Session ───────────────────────────────────────────────────────────────
138
- session: {
139
- export(): Promise<unknown>;
140
- import(data: unknown): Promise<void>;
141
- };
142
-
143
- // ── Expose / unexpose functions ───────────────────────────────────────────
144
- exposeFunction(
145
- fnName: string,
146
- handler: (data: unknown) => Promise<unknown> | unknown
147
- ): Promise<SiteObject>;
148
- unexposeFunction(fnName: string): Promise<SiteObject>;
149
- clearExposedFunctions(): Promise<SiteObject>;
150
- exposeAndInject(
151
- fnName: string,
152
- handler: (data: unknown) => Promise<unknown> | unknown,
153
- injectionJs: string | ((fnName: string) => string)
154
- ): Promise<SiteObject>;
155
-
156
- // ── Elysia route registration ─────────────────────────────────────────────
157
- api(
158
- path: string,
159
- handler: RouteHandler,
160
- opts?: {
161
- ttl?: number;
162
- before?: BeforeMiddleware[];
163
- method?: "GET" | "POST" | "PUT" | "DELETE";
164
- }
165
- ): SiteObject;
166
-
167
- noclose(): SiteObject;
168
- close(): Promise<void>;
169
- }
170
-
171
- // ── Route summary returned by piggy.routes() ─────────────────────────────────
172
-
173
- export interface RouteSummary {
174
- site: string;
175
- method: RouteConfig["method"];
176
- path: string;
177
- ttl: number;
178
- middlewareCount: number;
179
- }
180
-
181
- // ── Multi-site proxy helpers ──────────────────────────────────────────────────
182
-
183
- /**
184
- * Proxy that runs the same method on every site in parallel (Promise.all).
185
- * The return type mirrors the SiteObject API but every method returns
186
- * `Promise<ReturnType[]>` instead of a single value.
187
- */
188
- export type AllProxy = {
189
- [K in keyof SiteObject]: SiteObject[K] extends (...args: infer A) => Promise<infer R>
190
- ? (...args: A) => Promise<R[]>
191
- : SiteObject[K] extends (...args: infer A) => infer R
192
- ? (...args: A) => Promise<R[]>
193
- : never;
194
- };
195
-
196
- /**
197
- * Proxy that runs the same method on every site in parallel and returns a
198
- * Record keyed by site `_name`.
199
- */
200
- export type DiffProxy = {
201
- [K in keyof SiteObject]: SiteObject[K] extends (...args: infer A) => Promise<infer R>
202
- ? (...args: A) => Promise<Record<string, R>>
203
- : SiteObject[K] extends (...args: infer A) => infer R
204
- ? (...args: A) => Promise<Record<string, R>>
205
- : never;
206
- };
207
-
208
- // ── Launch / register option bags ────────────────────────────────────────────
209
-
210
- export interface LaunchOptions {
211
- /** Whether to open a separate browser process per registered site or share a single one via tabs. Default: `"tab"`. */
212
- mode?: TabMode;
213
- /** Whether to run the browser binary in headed or headless mode. Default: `"headless"`. */
214
- binary?: BinaryMode;
215
- }
216
-
217
- export interface RegisterOptions {
218
- /** Override the binary mode for this site's dedicated process (only used when `mode === "process"`). */
219
- binary?: BinaryMode;
220
- }
221
-
222
- export interface ServeOptions {
223
- hostname?: string;
224
- }
225
-
226
- // ── The Piggy object type ─────────────────────────────────────────────────────
227
-
228
- export interface Piggy {
229
- // ── Lifecycle ──────────────────────────────────────────────────────────────
230
- /**
231
- * Spawns the Nothing Browser binary and connects the internal socket client.
232
- * Must be called before any other method.
233
- */
234
- launch(opts?: LaunchOptions): Promise<Piggy>;
235
-
236
- /**
237
- * Registers a named site at the given URL.
238
- * After registration `piggy[name]` is available as a `SiteObject`.
239
- */
240
- register(name: string, url: string, opts?: RegisterOptions): Promise<Piggy>;
241
-
242
- // ── Global controls ────────────────────────────────────────────────────────
243
- /** Enables or disables human-simulation mode (random delays, typos, smooth scrolling). */
244
- actHuman(enable: boolean): Piggy;
245
-
246
- /** Changes the tab/process mode *before* the next `register()` call. */
247
- mode(m: TabMode): Piggy;
248
-
249
- // ── Global function exposure ───────────────────────────────────────────────
250
- /** Exposes a Node.js function to the browser's global scope on the default (or specified) tab. */
251
- expose(
252
- name: string,
253
- handler: (data: unknown) => Promise<unknown> | unknown,
254
- tabId?: string
255
- ): Promise<Piggy>;
256
-
257
- /** Removes a previously exposed function from the browser. */
258
- unexpose(name: string, tabId?: string): Promise<Piggy>;
259
-
260
- // ── Elysia server ─────────────────────────────────────────────────────────
261
- /** Starts an Elysia HTTP server that exposes all registered `.api()` routes. */
262
- serve(port: number, opts?: ServeOptions): Promise<Elysia>;
263
-
264
- /** Stops the running Elysia server. */
265
- stopServer(): void;
266
-
267
- // ── Introspection ─────────────────────────────────────────────────────────
268
- /** Returns a summary of every mounted HTTP route. */
269
- routes(): RouteSummary[];
270
-
271
- // ── Multi-site helpers ────────────────────────────────────────────────────
272
- /**
273
- * Returns a proxy that calls the same method on **all** given sites in
274
- * parallel (via `Promise.all`) and returns an array of results.
275
- */
276
- all(sites: SiteObject[]): AllProxy;
277
-
278
- /**
279
- * Returns a proxy that calls the same method on **all** given sites in
280
- * parallel and returns a `Record<siteName, result>` object.
281
- */
282
- diff(sites: SiteObject[]): DiffProxy;
283
-
284
- // ── Shutdown ──────────────────────────────────────────────────────────────
285
- /**
286
- * Gracefully shuts down all tabs and the browser process.
287
- * Pass `{ force: true }` to skip graceful close and kill immediately.
288
- */
289
- close(opts?: { force?: boolean }): Promise<void>;
290
-
291
- // ── Utilities ─────────────────────────────────────────────────────────────
292
- /** Detects the Nothing Browser binary path for the given mode. */
293
- detect(mode?: BinaryMode): string | null;
294
-
295
- /** The ernest-logger instance used internally. */
296
- logger: Logger;
297
-
298
- // ── Dynamic site keys ─────────────────────────────────────────────────────
299
- /**
300
- * After `piggy.register("mysite", url)` you can access the site as
301
- * `piggy.mysite`. The index signature covers those dynamic properties.
302
- */
303
- [site: string]: SiteObject | unknown;
304
- }
305
-
306
- // ── Module-private state ──────────────────────────────────────────────────────
10
+ type TabMode = "tab" | "process";
307
11
 
308
12
  let _client: PiggyClient | null = null;
309
13
  let _tabMode: TabMode = "tab";
310
14
  const _extraProcs: { socket: string; client: PiggyClient }[] = [];
311
- const _sites: Record<string, SiteObject> = {};
15
+ const _sites: Record<string, SiteObject> = [];
312
16
 
313
- // ── The piggy singleton ───────────────────────────────────────────────────────
17
+ const piggy: any = {
18
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
314
19
 
315
- const piggy: Piggy = {
316
- // ── Lifecycle ───────────────────────────────────────────────────────────────
317
-
318
- launch: async (opts?: LaunchOptions): Promise<Piggy> => {
20
+ launch: async (opts?: { mode?: TabMode; binary?: BinaryMode }) => {
319
21
  _tabMode = opts?.mode ?? "tab";
320
22
  const binaryMode: BinaryMode = opts?.binary ?? "headless";
321
23
  await spawnBrowser(binaryMode);
322
- await new Promise<void>((r) => setTimeout(r, 500));
24
+ await new Promise(r => setTimeout(r, 500));
323
25
  _client = new PiggyClient();
324
26
  await _client.connect();
325
27
  setClient(_client);
@@ -327,81 +29,104 @@ const piggy: Piggy = {
327
29
  return piggy;
328
30
  },
329
31
 
330
- register: async (name: string, url: string, opts?: RegisterOptions): Promise<Piggy> => {
32
+ register: async (
33
+ name: string,
34
+ url: string,
35
+ opts?: {
36
+ binary?: BinaryMode;
37
+ pool?: number; // number of tabs to pool — enables concurrent requests
38
+ }
39
+ ) => {
331
40
  if (!url?.trim()) throw new Error(`No URL for site "${name}"`);
332
41
  const binaryMode: BinaryMode = opts?.binary ?? "headless";
42
+ const poolSize = opts?.pool ?? 0;
333
43
 
334
- let tabId = "default";
335
44
  if (_tabMode === "tab") {
336
45
  if (!_client) throw new Error("No client. Call piggy.launch() first.");
337
- tabId = await _client.newTab();
338
- const siteObj: SiteObject = createSiteObject(name, url, _client, tabId);
339
- _sites[name] = siteObj;
340
- (piggy as Record<string, unknown>)[name] = siteObj;
341
- logger.success(`[${name}] registered as tab ${tabId}`);
46
+
47
+ if (poolSize > 1) {
48
+ // Pool mode — create N tabs, wrap in TabPool
49
+ const pool = new TabPool(_client, poolSize, url, name);
50
+ await pool.init();
51
+
52
+ // Use first tab as the "default" tabId for event subscriptions
53
+ const firstTabId = "default";
54
+ const siteObj = createSiteObject(name, url, _client, firstTabId, pool);
55
+ _sites[name] = siteObj;
56
+ piggy[name] = siteObj;
57
+ logger.success(`[${name}] registered with pool of ${poolSize} tabs`);
58
+ } else {
59
+ // Single tab mode
60
+ const tabId = await _client.newTab();
61
+ const siteObj = createSiteObject(name, url, _client, tabId);
62
+ _sites[name] = siteObj;
63
+ piggy[name] = siteObj;
64
+ logger.success(`[${name}] registered as tab ${tabId}`);
65
+ }
342
66
  } else {
343
67
  const socketName = `piggy_${name}`;
344
68
  await spawnBrowserOnSocket(socketName, binaryMode);
345
- await new Promise<void>((r) => setTimeout(r, 500));
69
+ await new Promise(r => setTimeout(r, 500));
346
70
  const c = new PiggyClient(socketName);
347
71
  await c.connect();
348
72
  _extraProcs.push({ socket: socketName, client: c });
349
- const siteObj: SiteObject = createSiteObject(name, url, c, "default");
73
+ const siteObj = createSiteObject(name, url, c, "default");
350
74
  _sites[name] = siteObj;
351
- (piggy as Record<string, unknown>)[name] = siteObj;
75
+ piggy[name] = siteObj;
352
76
  logger.success(`[${name}] registered as process on "${socketName}"`);
353
77
  }
354
78
 
355
79
  return piggy;
356
80
  },
357
81
 
358
- // ── Global controls ─────────────────────────────────────────────────────────
82
+ // ── Global controls ───────────────────────────────────────────────────────
359
83
 
360
- actHuman: (enable: boolean): Piggy => {
84
+ actHuman: (enable: boolean) => {
361
85
  setHumanMode(enable);
362
86
  logger.info(`[piggy] actHuman: ${enable}`);
363
87
  return piggy;
364
88
  },
365
89
 
366
- mode: (m: TabMode): Piggy => {
367
- _tabMode = m;
368
- return piggy;
369
- },
90
+ mode: (m: TabMode) => { _tabMode = m; return piggy; },
370
91
 
371
- // ── Expose Function (global) ─────────────────────────────────────────────────
92
+ // ── Expose Function ───────────────────────────────────────────────────────
372
93
 
373
- expose: async (
374
- name: string,
375
- handler: (data: unknown) => Promise<unknown> | unknown,
376
- tabId = "default"
377
- ): Promise<Piggy> => {
94
+ expose: async (name: string, handler: (data: any) => Promise<any> | any, tabId = "default") => {
378
95
  if (!_client) throw new Error("No client. Call piggy.launch() first.");
379
96
  await _client.exposeFunction(name, handler, tabId);
380
97
  logger.success(`[piggy] exposed global function: ${name}`);
381
98
  return piggy;
382
99
  },
383
100
 
384
- unexpose: async (name: string, tabId = "default"): Promise<Piggy> => {
101
+ unexpose: async (name: string, tabId = "default") => {
385
102
  if (!_client) throw new Error("No client. Call piggy.launch() first.");
386
103
  await _client.unexposeFunction(name, tabId);
387
104
  logger.info(`[piggy] unexposed function: ${name}`);
388
105
  return piggy;
389
106
  },
390
107
 
391
- // ── Elysia server ────────────────────────────────────────────────────────────
108
+ // ── Elysia server ─────────────────────────────────────────────────────────
392
109
 
393
- serve: (port: number, opts?: ServeOptions): Promise<Elysia> =>
394
- startServer(port, opts?.hostname),
110
+ serve: (
111
+ port: number,
112
+ opts?: {
113
+ hostname?: string;
114
+ title?: string;
115
+ version?: string;
116
+ description?: string;
117
+ path?: string;
118
+ }
119
+ ) => startServer(port, opts?.hostname, opts),
395
120
 
396
121
  stopServer,
397
122
 
398
- // ── Route listing ────────────────────────────────────────────────────────────
123
+ // ── Route listing ─────────────────────────────────────────────────────────
399
124
 
400
- routes: (): RouteSummary[] =>
401
- Array.from(routeRegistry.entries()).map(([key, cfg]): RouteSummary => {
125
+ routes: () =>
126
+ Array.from(routeRegistry.entries()).map(([key, cfg]) => {
402
127
  const [site] = key.split(":");
403
128
  return {
404
- site: site ?? key,
129
+ site,
405
130
  method: cfg.method,
406
131
  path: `/${site}${cfg.path}`,
407
132
  ttl: cfg.ttl,
@@ -409,35 +134,26 @@ const piggy: Piggy = {
409
134
  };
410
135
  }),
411
136
 
412
- // ── Multi-site ───────────────────────────────────────────────────────────────
137
+ // ── Multi-site ────────────────────────────────────────────────────────────
413
138
 
414
- all: (sites: SiteObject[]): AllProxy =>
415
- new Proxy({} as AllProxy, {
416
- get: (_target, method: string) =>
417
- (...args: unknown[]) =>
418
- Promise.all(
419
- sites.map((s) =>
420
- (s as unknown as Record<string, (...a: unknown[]) => unknown>)[method]?.(...args)
421
- )
422
- ),
139
+ all: (sites: SiteObject[]) =>
140
+ new Proxy({} as any, {
141
+ get: (_, method: string) =>
142
+ (...args: any[]) => Promise.all(sites.map((s: any) => s[method]?.(...args))),
423
143
  }),
424
144
 
425
- diff: (sites: SiteObject[]): DiffProxy =>
426
- new Proxy({} as DiffProxy, {
427
- get: (_target, method: string) =>
428
- async (...args: unknown[]) => {
429
- const results = await Promise.all(
430
- sites.map((s) =>
431
- (s as unknown as Record<string, (...a: unknown[]) => unknown>)[method]?.(...args)
432
- )
433
- );
434
- return Object.fromEntries(sites.map((s, i) => [s._name ?? i, results[i]]));
145
+ diff: (sites: SiteObject[]) =>
146
+ new Proxy({} as any, {
147
+ get: (_, method: string) =>
148
+ async (...args: any[]) => {
149
+ const results = await Promise.all(sites.map((s: any) => s[method]?.(...args)));
150
+ return Object.fromEntries(sites.map((s: any, i) => [s._name ?? i, results[i]]));
435
151
  },
436
152
  }),
437
153
 
438
- // ── Shutdown ─────────────────────────────────────────────────────────────────
154
+ // ── Shutdown ──────────────────────────────────────────────────────────────
439
155
 
440
- close: async (opts?: { force?: boolean }): Promise<void> => {
156
+ close: async (opts?: { force?: boolean }) => {
441
157
  stopServer();
442
158
  if (opts?.force) {
443
159
  for (const { client: c } of _extraProcs) c.disconnect();
@@ -447,7 +163,7 @@ const piggy: Piggy = {
447
163
  keepAliveSites.clear();
448
164
  } else {
449
165
  for (const [name, site] of Object.entries(_sites)) {
450
- if (!keepAliveSites.has(name)) await site.close?.();
166
+ if (!keepAliveSites.has(name)) await (site as any).close?.();
451
167
  }
452
168
  if (keepAliveSites.size === 0) {
453
169
  for (const { client: c } of _extraProcs) c.disconnect();
@@ -465,5 +181,18 @@ const piggy: Piggy = {
465
181
  logger,
466
182
  };
467
183
 
184
+ // ── usePiggy ──────────────────────────────────────────────────────────────────
185
+ // Typed accessor — call AFTER register() so sites exist on piggy.
186
+ // const { amazon, ebay } = usePiggy<"amazon" | "ebay">()
187
+
188
+ type TypedPiggy<Sites extends string> = typeof piggy & {
189
+ [K in Sites]: SiteObject;
190
+ };
191
+
192
+ export function usePiggy<Sites extends string>(): TypedPiggy<Sites> {
193
+ return piggy as TypedPiggy<Sites>;
194
+ }
195
+
196
+ export type { SiteObject };
468
197
  export default piggy;
469
198
  export { piggy };