nothing-browser 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ Here's the updated README with the `exposeFunction` feature documented:
2
+
3
+ ```markdown
1
4
  <p align="center">
2
5
  <img src="nothing_browser_pig_pink.svg" width="160" alt="Nothing Browser logo"/>
3
6
  </p>
@@ -7,24 +10,55 @@
7
10
 
8
11
  <p align="center">
9
12
  <a href="https://www.npmjs.com/package/nothing-browser"><img src="https://img.shields.io/npm/v/nothing-browser" alt="npm version"/></a>
10
- <a href="LICENSE"><img src="https://img.shields.io/github/license/ernest-tech-house-co-operation/nothing-browser" alt="license"/></a>
11
- <a href="https://github.com/BunElysiaReact/nothing-browser/releases"><img src="https://img.shields.io/github/v/release/BunElysiaReact/nothing-browser" alt="binary releases"/></a>
13
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/BunElysiaReact/nothing-browser" alt="license"/></a>
14
+ <a href="https://github.com/BunElysiaReact/nothing-browser/releases"><img src="https://img.shields.io/github/v/release/BunElysiaReact/nothing-browser" alt="releases"/></a>
12
15
  </p>
13
16
 
14
17
  ---
15
18
 
16
19
  A scraper-first headless browser library powered by the Nothing Browser Qt6/Chromium engine. Control real browser tabs, intercept network traffic, spoof fingerprints, capture WebSockets — all from Bun + TypeScript.
17
20
 
18
- > **Two repos:**
19
- > - This package (npm lib) → [ernest-tech-house-co-operation/nothing-browser](https://github.com/ernest-tech-house-co-operation/nothing-browser)
20
- > - Headless binary → [BunElysiaReact/nothing-browser](https://github.com/BunElysiaReact/nothing-browser/releases)
21
+ ---
22
+
23
+ ## Why nothing-browser
24
+
25
+ Yes, we are bragging. Here's why.
26
+
27
+ | | nothing-browser | Puppeteer | Playwright |
28
+ |---|---|---|---|
29
+ | Imports | **1** | 5–10 | 5–10 |
30
+ | Lines to scrape a site | **~20** | 80–200 | 80–200 |
31
+ | Fingerprint spoofing | ✅ built in | ❌ plugin needed | ❌ plugin needed |
32
+ | Network capture | ✅ built in | ❌ manual | ❌ manual |
33
+ | Built-in API server | ✅ | ❌ | ❌ |
34
+ | Cloudflare bypass | ✅ passes | ⚠️ often blocked | ⚠️ often blocked |
35
+ | Headless detection bypass | ✅ built in | ❌ manual | ❌ manual |
36
+ | Session persistence | ✅ built in | ❌ manual | ❌ manual |
37
+ | Human mode | ✅ built in | ❌ manual | ❌ manual |
38
+ | **Browser → Node.js RPC** | ✅ **exposeFunction** | ✅ page.exposeFunction | ✅ page.exposeFunction |
39
+
40
+ One import. No middleware soup. No 47 plugins to avoid detection. No CAPTCHAs on sites that Puppeteer chokes on. Just write your scraper and go.
21
41
 
22
42
  ---
23
43
 
24
44
  ## Requirements
25
45
 
26
46
  - [Bun](https://bun.sh) ≥ 1.0
27
- - Nothing Browser headless binary (see below)
47
+ - A Nothing Browser binary placed in your **project root** (see below)
48
+
49
+ ---
50
+
51
+ ## Binaries
52
+
53
+ There are three binaries. All downloaded from the same place — [GitHub Releases](https://github.com/BunElysiaReact/nothing-browser/releases).
54
+
55
+ | Binary | What it is | Where it goes |
56
+ |--------|-----------|---------------|
57
+ | `nothing-browser` | Full UI browser app — DevTools, YouTube tab, Plugins, etc. | Install system-wide |
58
+ | `nothing-browser-headless` | No window, no GPU. Runs as a background daemon for the scraping lib. | **Your project root** |
59
+ | `nothing-browser-headful` | Visible browser window, script-controlled. Useful when a site needs a real display. | **Your project root** |
60
+
61
+ The lib talks to whichever binary is in your project root over a local socket. You pick headless or headful depending on your use case.
28
62
 
29
63
  ---
30
64
 
@@ -34,17 +68,34 @@ A scraper-first headless browser library powered by the Nothing Browser Qt6/Chro
34
68
  bun add nothing-browser
35
69
  ```
36
70
 
37
- Then download the **headless binary** for your platform from [BunElysiaReact/nothing-browser releases](https://github.com/BunElysiaReact/nothing-browser/releases) and place it in your project root.
71
+ Then download the binary for your platform from [GitHub Releases](https://github.com/BunElysiaReact/nothing-browser/releases) and place it in your project root.
72
+
73
+ ### Linux
38
74
 
39
- **Linux**
75
+ **Headless** (no visible window — most common for scraping)
40
76
  ```bash
41
77
  tar -xzf nothing-browser-headless-*-linux-x86_64.tar.gz
42
78
  chmod +x nothing-browser-headless
43
79
  ```
44
80
 
45
- **Windows** extract the `.zip`, place `nothing-browser-headless.exe` in project root.
81
+ **Headful** (visible window, script-controlled)
82
+ ```bash
83
+ tar -xzf nothing-browser-headful-*-linux-x86_64.tar.gz
84
+ chmod +x nothing-browser-headful
85
+ ```
46
86
 
47
- **macOS** extract the `.tar.gz`, place `nothing-browser-headless` in project root.
87
+ **Full browser** (system-wide install, for using the UI)
88
+ ```bash
89
+ sudo dpkg -i nothing-browser_*_amd64.deb
90
+ ```
91
+
92
+ ### Windows
93
+
94
+ Download the `.zip` → extract → place the exe in your project root.
95
+
96
+ ### macOS
97
+
98
+ Download the `.tar.gz` → extract → place the binary in your project root.
48
99
 
49
100
  ---
50
101
 
@@ -70,28 +121,144 @@ console.log(books);
70
121
  await piggy.close();
71
122
  ```
72
123
 
124
+ That's it. One import, one register, scrape, done.
125
+
73
126
  ---
74
127
 
75
- ## Modes
128
+ ## Headless vs Headful
76
129
 
77
- ### Tab mode (default)
78
- All sites share one browser process, each in its own tab.
130
+ **Headless** no display needed, runs anywhere including CI.
79
131
 
80
132
  ```ts
81
- await piggy.launch({ mode: "tab" });
133
+ await piggy.launch({ mode: "tab", binary: "headless" }); // default
82
134
  ```
83
135
 
84
- ### Process mode
85
- Each site gets its own browser process on a dedicated socket. More isolation, more RAM.
136
+ **Headful** opens a real visible Chromium window your script drives. Use this when a site detects headless or requires a real display.
86
137
 
87
138
  ```ts
88
- await piggy.launch({ mode: "process" });
139
+ await piggy.launch({ mode: "tab", binary: "headful" });
89
140
  ```
90
141
 
142
+ Same API either way. Switching is just changing one word.
143
+
91
144
  ---
92
145
 
93
146
  ## Examples
94
147
 
148
+ ### 🔥 NEW: Browser → Node.js RPC with `exposeFunction`
149
+
150
+ Call Node.js functions directly from browser JavaScript. Perfect for:
151
+ - Processing data in real-time as the user navigates
152
+ - Handling authentication callbacks
153
+ - Streaming WebSocket messages to your backend
154
+ - Building browser extensions with Node.js power
155
+
156
+ ```ts
157
+ import piggy from "nothing-browser";
158
+
159
+ await piggy.launch({ mode: "tab" });
160
+ await piggy.register("whatsapp", "https://web.whatsapp.com");
161
+
162
+ // Expose a function that WhatsApp Web can call
163
+ await piggy.whatsapp.exposeFunction("onNewMessage", async (message) => {
164
+ console.log("📱 New message:", message);
165
+
166
+ // Save to database
167
+ await db.messages.insert({
168
+ text: message.text,
169
+ sender: message.sender,
170
+ timestamp: message.timestamp,
171
+ });
172
+
173
+ // Return value goes back to the browser
174
+ return { saved: true, id: crypto.randomUUID() };
175
+ });
176
+
177
+ // Inject the listener that calls our exposed function
178
+ await piggy.whatsapp.evaluate(() => {
179
+ const observer = new MutationObserver(() => {
180
+ document.querySelectorAll('.message-in:not([data-seen])').forEach(el => {
181
+ el.dataset.seen = '1';
182
+
183
+ // Call the exposed function - returns a Promise!
184
+ window.onNewMessage({
185
+ text: el.innerText,
186
+ timestamp: Date.now(),
187
+ sender: el.querySelector('.sender')?.innerText,
188
+ }).then(result => {
189
+ console.log('Message saved with ID:', result.id);
190
+ el.style.borderLeft = '3px solid green';
191
+ });
192
+ });
193
+ });
194
+
195
+ observer.observe(document.body, { childList: true, subtree: true });
196
+ });
197
+
198
+ console.log("Listening for WhatsApp messages...");
199
+ ```
200
+
201
+ ### Expose + Inject convenience method
202
+
203
+ ```ts
204
+ await piggy.whatsapp.exposeAndInject(
205
+ "onNewMessage",
206
+ async (message) => {
207
+ await saveToDatabase(message);
208
+ return { ok: true };
209
+ },
210
+ (fnName) => `
211
+ // This runs in the browser
212
+ setInterval(() => {
213
+ const msgs = document.querySelectorAll('.new-message');
214
+ msgs.forEach(msg => {
215
+ window.${fnName}({ text: msg.innerText });
216
+ });
217
+ }, 2000);
218
+ `
219
+ );
220
+ ```
221
+
222
+ ### Structured API with multiple methods
223
+
224
+ ```ts
225
+ import { createExposedAPI } from "nothing-browser/register";
226
+
227
+ await createExposedAPI(piggy.whatsapp, "whatsappAPI", {
228
+ onMessage: async (msg) => {
229
+ await db.messages.insert(msg);
230
+ return { saved: true };
231
+ },
232
+
233
+ getContacts: async () => {
234
+ return await db.contacts.findAll();
235
+ },
236
+
237
+ sendReply: async ({ to, text }) => {
238
+ // Your sending logic here
239
+ return { sent: true };
240
+ }
241
+ });
242
+
243
+ // In browser JS:
244
+ const result = await window.whatsappAPI({
245
+ method: 'sendReply',
246
+ args: { to: '+1234567890', text: 'Hello!' }
247
+ });
248
+ ```
249
+
250
+ ### Global expose (available to all sites)
251
+
252
+ ```ts
253
+ await piggy.expose("logToServer", async (data) => {
254
+ console.log("[Browser]", data);
255
+ await analytics.track(data.event, data.properties);
256
+ return { logged: true };
257
+ });
258
+
259
+ // Any page can call: window.logToServer({ event: 'pageview' })
260
+ ```
261
+
95
262
  ### Scrape a site and expose it as an API
96
263
 
97
264
  ```ts
@@ -102,7 +269,6 @@ await piggy.register("books", "https://books.toscrape.com");
102
269
 
103
270
  await piggy.books.intercept.block("*google-analytics*");
104
271
  await piggy.books.intercept.block("*doubleclick*");
105
- await piggy.books.intercept.block("*facebook*");
106
272
 
107
273
  piggy.books.api("/list", async (_params, query) => {
108
274
  const page = query.page ? parseInt(query.page) : 1;
@@ -139,39 +305,16 @@ await piggy.serve(3000);
139
305
  ### Middleware — auth + logging
140
306
 
141
307
  ```ts
142
- const logMiddleware = async ({ query, params }: any) => {
143
- console.log("[middleware] incoming request", { params, query });
144
- };
145
-
146
308
  const authMiddleware = async ({ headers, set }: any) => {
147
- const key = headers["x-api-key"];
148
- if (!key || key !== "piggy-secret") {
309
+ if (headers["x-api-key"] !== "secret") {
149
310
  set.status = 401;
150
- throw new Error("Unauthorized: missing or invalid x-api-key");
311
+ throw new Error("Unauthorized");
151
312
  }
152
313
  };
153
314
 
154
315
  piggy.books.api("/search", async (_params, query) => {
155
- if (!query.q) return { error: "query param 'q' required" };
156
-
157
- await piggy.books.navigate("https://books.toscrape.com");
158
- await piggy.books.waitForSelector(".product_pod", 10000);
159
-
160
- const books = await piggy.books.evaluate((q: string) =>
161
- Array.from(document.querySelectorAll(".product_pod"))
162
- .filter(el =>
163
- el.querySelector("h3 a")?.getAttribute("title")?.toLowerCase().includes(q.toLowerCase())
164
- )
165
- .map(el => ({
166
- title: el.querySelector("h3 a")?.getAttribute("title") ?? "",
167
- price: el.querySelector(".price_color")?.textContent?.trim() ?? "",
168
- }))
169
- , query.q);
170
-
171
- return { query: query.q, count: books.length, books };
172
- }, { ttl: 120_000, before: [logMiddleware, authMiddleware] });
173
-
174
- // curl -H 'x-api-key: piggy-secret' 'http://localhost:3000/books/search?q=light'
316
+ // handler
317
+ }, { ttl: 120_000, before: [authMiddleware] });
175
318
  ```
176
319
 
177
320
  ---
@@ -181,18 +324,18 @@ piggy.books.api("/search", async (_params, query) => {
181
324
  ```ts
182
325
  await piggy.books.capture.clear();
183
326
  await piggy.books.capture.start();
184
- await piggy.books.wait(300); // ensure capture is active before nav
327
+ await piggy.books.wait(300);
185
328
 
186
329
  await piggy.books.navigate("https://books.toscrape.com");
187
330
  await piggy.books.waitForSelector("body", 10000);
188
- await piggy.books.wait(2000); // let async XHR/fetch calls settle
331
+ await piggy.books.wait(2000);
189
332
 
190
333
  await piggy.books.capture.stop();
191
334
 
192
335
  const requests = await piggy.books.capture.requests();
193
336
  const ws = await piggy.books.capture.ws();
194
- const storage = await piggy.books.capture.storage();
195
337
  const cookies = await piggy.books.capture.cookies();
338
+ const storage = await piggy.books.capture.storage();
196
339
 
197
340
  console.log(`${requests.length} requests, ${ws.length} WS frames`);
198
341
  ```
@@ -206,13 +349,11 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
206
349
 
207
350
  const SESSION_FILE = "./session.json";
208
351
 
209
- // Restore on startup
210
352
  if (existsSync(SESSION_FILE)) {
211
353
  const saved = JSON.parse(readFileSync(SESSION_FILE, "utf8"));
212
354
  await piggy.books.session.import(saved);
213
355
  }
214
356
 
215
- // Save on shutdown — always BEFORE piggy.close()
216
357
  process.on("SIGINT", async () => {
217
358
  const session = await piggy.books.session.export();
218
359
  writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
@@ -233,7 +374,7 @@ await piggy.books.type("#search", "mystery novels");
233
374
  await piggy.books.scroll.by(400);
234
375
  ```
235
376
 
236
- Affects: `click`, `type`, `hover`, `scroll.by`, `wait`.
377
+ Affects `click`, `type`, `hover`, `scroll.by`, `wait` — random delays, simulated typos, self-correction.
237
378
 
238
379
  ---
239
380
 
@@ -243,8 +384,7 @@ Affects: `click`, `type`, `hover`, `scroll.by`, `wait`.
243
384
  await piggy.books.screenshot("./out/page.png");
244
385
  await piggy.books.pdf("./out/page.pdf");
245
386
 
246
- // or base64
247
- const b64 = await piggy.books.screenshot();
387
+ const b64 = await piggy.books.screenshot(); // base64
248
388
  ```
249
389
 
250
390
  ---
@@ -256,8 +396,7 @@ await piggy.register("site1", "https://example.com");
256
396
  await piggy.register("site2", "https://example.org");
257
397
 
258
398
  const titles = await piggy.all([piggy.site1, piggy.site2]).title();
259
-
260
- const h1s = await piggy.diff([piggy.site1, piggy.site2]).fetchText("h1");
399
+ const h1s = await piggy.diff([piggy.site1, piggy.site2]).fetchText("h1");
261
400
  // → { site1: "...", site2: "..." }
262
401
  ```
263
402
 
@@ -270,13 +409,20 @@ const h1s = await piggy.diff([piggy.site1, piggy.site2]).fetchText("h1");
270
409
  | Option | Type | Default |
271
410
  |--------|------|---------|
272
411
  | `mode` | `"tab" \| "process"` | `"tab"` |
412
+ | `binary` | `"headless" \| "headful"` | `"headless"` |
273
413
 
274
414
  ### `piggy.register(name, url)`
275
- Registers a site. Accessible as `piggy.<n>` after registration.
415
+ Registers a site. Accessible as `piggy.<name>` after registration.
276
416
 
277
417
  ### `piggy.actHuman(enable)`
278
418
  Toggles human-like interaction timing globally.
279
419
 
420
+ ### `piggy.expose(name, handler, tabId?)`
421
+ Exposes a function globally (available to all tabs). The handler receives data from the browser and can return a value that resolves the Promise in the browser.
422
+
423
+ ### `piggy.unexpose(name, tabId?)`
424
+ Removes a globally exposed function.
425
+
280
426
  ### `piggy.serve(port, opts?)`
281
427
  Starts the Elysia HTTP server. Built-in routes: `GET /health`, `GET /cache/keys`, `DELETE /cache`.
282
428
 
@@ -286,7 +432,7 @@ Returns all registered API routes with method, path, TTL, and middleware count.
286
432
  ### `piggy.close(opts?)`
287
433
 
288
434
  ```ts
289
- await piggy.close(); // graceful — respects noclose()
435
+ await piggy.close(); // graceful
290
436
  await piggy.close({ force: true }); // kills everything immediately
291
437
  ```
292
438
 
@@ -307,10 +453,10 @@ site.wait(ms)
307
453
  ```ts
308
454
  site.click(selector, opts?)
309
455
  site.doubleClick(selector) / site.hover(selector)
310
- site.type(selector, text, opts?) // opts: { delay?, wpm?, fact? }
456
+ site.type(selector, text, opts?)
311
457
  site.select(selector, value)
312
458
  site.keyboard.press(key)
313
- site.keyboard.combo(combo) // e.g. "Ctrl+A"
459
+ site.keyboard.combo(combo)
314
460
  site.mouse.move(x, y)
315
461
  site.mouse.drag(from, to)
316
462
  site.scroll.to(selector) / site.scroll.by(px)
@@ -318,13 +464,30 @@ site.scroll.to(selector) / site.scroll.by(px)
318
464
 
319
465
  #### Data
320
466
  ```ts
321
- site.fetchText(selector) // → string | null
322
- site.fetchLinks(selector) // → string[]
323
- site.fetchImages(selector) // → string[]
467
+ site.fetchText(selector)
468
+ site.fetchLinks(selector)
469
+ site.fetchImages(selector)
324
470
  site.search.css(query) / site.search.id(query)
325
471
  site.evaluate(js | fn, ...args)
326
472
  ```
327
473
 
474
+ #### 🔥 Expose Function (RPC)
475
+ ```ts
476
+ // Expose a function that browser JS can call
477
+ site.exposeFunction(name, handler)
478
+ // handler: (data: any) => Promise<any> | any
479
+
480
+ // Remove an exposed function
481
+ site.unexposeFunction(name)
482
+
483
+ // Remove all exposed functions for this site
484
+ site.clearExposedFunctions()
485
+
486
+ // Expose and inject in one call
487
+ site.exposeAndInject(name, handler, injectionJs)
488
+ // injectionJs: string | ((fnName: string) => string)
489
+ ```
490
+
328
491
  #### Network
329
492
  ```ts
330
493
  site.capture.start() / .stop() / .clear()
@@ -347,27 +510,53 @@ site.session.export() / site.session.import(data)
347
510
  ```ts
348
511
  site.api(path, handler, opts?)
349
512
  // opts: { ttl?, method?, before?: middleware[] }
350
- // handler: (params, query, body) => Promise<any>
351
513
 
352
514
  site.noclose()
353
515
  site.screenshot(filePath?) / site.pdf(filePath?)
354
516
  ```
355
517
 
518
+ ### Helper Functions
519
+
520
+ ```ts
521
+ import { createExposedAPI } from "nothing-browser/register";
522
+
523
+ // Create a structured API with multiple methods
524
+ await createExposedAPI(site, apiName, {
525
+ method1: async (args) => { /* ... */ },
526
+ method2: async (args) => { /* ... */ },
527
+ });
528
+
529
+ // Browser calls: window[apiName]({ method: 'method1', args: {...} })
530
+ ```
531
+
532
+ ---
533
+
534
+ ## How `exposeFunction` Works
535
+
536
+ 1. **Browser injects stub**: `window.fnName` becomes a Promise-returning function
537
+ 2. **Browser queues calls**: Arguments are pushed to `__NOTHING_QUEUE__`
538
+ 3. **C++ picks up queue**: 250ms poll timer reads the queue
539
+ 4. **Signal to Node.js**: Server broadcasts event to all connected clients
540
+ 5. **Your handler runs**: TypeScript handler processes the data
541
+ 6. **Result returns**: Promise in browser resolves with your return value
542
+
543
+ The function survives page navigations (injected at `DocumentCreation`) and works with both tab and process modes.
544
+
356
545
  ---
357
546
 
358
547
  ## Binary download
359
548
 
360
- | Platform | File |
361
- |----------|------|
362
- | Linux x86_64 (deb) | `nothing-browser-headless_*_amd64.deb` |
363
- | Linux x86_64 (tar.gz) | `nothing-browser-headless-*-linux-x86_64.tar.gz` |
364
- | Windows x64 | `nothing-browser-headless-*.zip` |
365
- | macOS | `nothing-browser-headless-*.tar.gz` |
549
+ | Platform | Headless | Headful | Full Browser |
550
+ |----------|----------|---------|--------------|
551
+ | Linux x86_64 (deb) | `nothing-browser-headless_*_amd64.deb` | `nothing-browser-headful_*_amd64.deb` | `nothing-browser_*_amd64.deb` |
552
+ | Linux x86_64 (tar.gz) | `nothing-browser-headless-*-linux-x86_64.tar.gz` | `nothing-browser-headful-*-linux-x86_64.tar.gz` | `nothing-browser-*-linux-x86_64.tar.gz` |
553
+ | Windows x64 | `nothing-browser-headless-*-windows-x64.zip` | `nothing-browser-headful-*-windows-x64.zip` | `nothing-browser-*-windows-x64.zip` |
554
+ | macOS | `nothing-browser-headless-*-macos.tar.gz` | `nothing-browser-headful-*-macos.tar.gz` | `nothing-browser-*-macos.dmg` |
366
555
 
367
- → [BunElysiaReact/nothing-browser releases](https://github.com/BunElysiaReact/nothing-browser/releases)
556
+ → [All releases](https://github.com/BunElysiaReact/nothing-browser/releases)
368
557
 
369
558
  ---
370
559
 
371
560
  ## License
372
561
 
373
- MIT © [Ernest Tech House](https://github.com/ernest-tech-house-co-operation)
562
+ MIT © [Ernest Tech House](https://github.com/BunElysiaReact/nothing-browser)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothing-browser",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Scraper-first headless browser library — control real tabs, intercept network traffic, capture WebSockets, spoof fingerprints. Powered by Qt6/Chromium.",
5
5
  "module": "piggy.ts",
6
6
  "main": "piggy.ts",
@@ -10,9 +10,12 @@ export class PiggyClient {
10
10
  private reqId = 0;
11
11
  private pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
12
12
  private buf = "";
13
+ private eventBuffer = "";
14
+ private eventHandlers = new Map<string, Map<string, (data: any) => Promise<any>>>();
13
15
 
14
16
  constructor(socketPath = "/tmp/piggy") {
15
17
  this.socketPath = socketPath;
18
+ this.eventHandlers.set("default", new Map());
16
19
  }
17
20
 
18
21
  connect(): Promise<void> {
@@ -28,13 +31,22 @@ export class PiggyClient {
28
31
  });
29
32
 
30
33
  sock.on("data", (chunk: string) => {
31
- this.buf += chunk;
32
- const lines = this.buf.split("\n");
33
- this.buf = lines.pop()!;
34
+ this.eventBuffer += chunk;
35
+ const lines = this.eventBuffer.split("\n");
36
+ this.eventBuffer = lines.pop()!;
37
+
34
38
  for (const line of lines) {
35
39
  if (!line.trim()) continue;
36
40
  try {
37
41
  const msg = JSON.parse(line);
42
+
43
+ // Handle events
44
+ if (msg.type === "event") {
45
+ this.handleEvent(msg);
46
+ continue;
47
+ }
48
+
49
+ // Handle command responses
38
50
  const p = this.pending.get(msg.id);
39
51
  if (p) {
40
52
  this.pending.delete(msg.id);
@@ -59,6 +71,56 @@ export class PiggyClient {
59
71
  });
60
72
  }
61
73
 
74
+ private handleEvent(event: any) {
75
+ if (event.event === "exposed_call") {
76
+ const { tabId, name, callId, data } = event;
77
+ const effectiveTabId = tabId || "default";
78
+ const handlers = this.eventHandlers.get(effectiveTabId);
79
+ const handler = handlers?.get(name);
80
+
81
+ if (handler) {
82
+ Promise.resolve(handler(JSON.parse(data || "null")))
83
+ .then(response => {
84
+ if (response && typeof response === "object" && "success" in response) {
85
+ if (response.success) {
86
+ this.send("exposed.result", {
87
+ tabId: effectiveTabId,
88
+ callId,
89
+ result: JSON.stringify(response.result),
90
+ isError: false
91
+ }).catch(e => logger.error(`Failed to send exposed result: ${e}`));
92
+ } else {
93
+ this.send("exposed.result", {
94
+ tabId: effectiveTabId,
95
+ callId,
96
+ result: response.error || "Unknown error",
97
+ isError: true
98
+ }).catch(e => logger.error(`Failed to send exposed error: ${e}`));
99
+ }
100
+ } else {
101
+ // Handler returned raw value
102
+ this.send("exposed.result", {
103
+ tabId: effectiveTabId,
104
+ callId,
105
+ result: JSON.stringify(response),
106
+ isError: false
107
+ }).catch(e => logger.error(`Failed to send exposed result: ${e}`));
108
+ }
109
+ })
110
+ .catch(err => {
111
+ this.send("exposed.result", {
112
+ tabId: effectiveTabId,
113
+ callId,
114
+ result: err.message || "Handler error",
115
+ isError: true
116
+ }).catch(e => logger.error(`Failed to send exposed error: ${e}`));
117
+ });
118
+ } else {
119
+ logger.warn(`No handler for exposed function: ${name} in tab ${effectiveTabId}`);
120
+ }
121
+ }
122
+ }
123
+
62
124
  disconnect() {
63
125
  this.socket?.destroy();
64
126
  this.socket = null;
@@ -327,4 +389,48 @@ export class PiggyClient {
327
389
  async sessionImport(data: any, tabId = "default"): Promise<void> {
328
390
  await this.send("session.import", { data, tabId });
329
391
  }
392
+
393
+ // ── Expose Function ───────────────────────────────────────────────────────────
394
+
395
+ async exposeFunction(
396
+ name: string,
397
+ handler: (data: any) => Promise<any> | any,
398
+ tabId = "default"
399
+ ): Promise<void> {
400
+ // Initialize handlers map for this tab if needed
401
+ if (!this.eventHandlers.has(tabId)) {
402
+ this.eventHandlers.set(tabId, new Map());
403
+ }
404
+
405
+ // Store the handler
406
+ this.eventHandlers.get(tabId)!.set(name, async (data: any) => {
407
+ try {
408
+ const result = await handler(data);
409
+ // Return in expected format
410
+ if (result && typeof result === "object" && ("success" in result || "error" in result)) {
411
+ return result;
412
+ }
413
+ return { success: true, result };
414
+ } catch (err: any) {
415
+ return { success: false, error: err.message || String(err) };
416
+ }
417
+ });
418
+
419
+ // Register with server
420
+ await this.send("expose.function", { name, tabId });
421
+ logger.success(`[${tabId}] exposed function: ${name}`);
422
+ }
423
+
424
+ async unexposeFunction(name: string, tabId = "default"): Promise<void> {
425
+ const handlers = this.eventHandlers.get(tabId);
426
+ if (handlers) {
427
+ handlers.delete(name);
428
+ }
429
+ logger.info(`[${tabId}] unexposed function: ${name}`);
430
+ }
431
+
432
+ async clearExposedFunctions(tabId = "default"): Promise<void> {
433
+ this.eventHandlers.set(tabId, new Map());
434
+ logger.info(`[${tabId}] cleared all exposed functions`);
435
+ }
330
436
  }
@@ -2,32 +2,42 @@ import { existsSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import logger from '../logger';
4
4
 
5
- export function detectBinary(): string | null {
6
- const cwd = process.cwd();
7
-
8
- // Windows
9
- const windowsPath = join(cwd, 'nothing-browser-headless.exe');
10
- if (process.platform === 'win32' && existsSync(windowsPath)) {
11
- logger.success(`Binary found! Platform: Windows`);
12
- return windowsPath;
13
- }
14
-
15
- // Linux / macOS
16
- const unixPath = join(cwd, 'nothing-browser-headless');
17
- if (existsSync(unixPath)) {
18
- logger.success(`Binary found at: ${unixPath}`);
19
- return unixPath;
20
- }
21
-
22
- logger.error("❌ Binary not found in project root");
23
- logger.error("");
24
- logger.error("Download from:");
25
- logger.error(" https://github.com/BunElysiaReact/nothing-browser/releases/");
26
- logger.error("");
27
- logger.error(`Place in: ${cwd}/nothing-browser-headless${process.platform === 'win32' ? '.exe' : ''}`);
28
- if (process.platform !== 'win32') {
29
- logger.error("Then run: chmod +x nothing-browser-headless");
5
+ export type BinaryMode = 'headless' | 'headful';
6
+
7
+ const BINARY_NAMES: Record<BinaryMode, string> = {
8
+ headless: 'nothing-browser-headless',
9
+ headful: 'nothing-browser-headful',
10
+ };
11
+
12
+ export function detectBinary(mode: BinaryMode = 'headless'): string | null {
13
+ const cwd = process.cwd();
14
+ const name = BINARY_NAMES[mode];
15
+
16
+ // Windows
17
+ if (process.platform === 'win32') {
18
+ const p = join(cwd, `${name}.exe`);
19
+ if (existsSync(p)) {
20
+ logger.success(`Binary found (${mode}): ${p}`);
21
+ return p;
30
22
  }
31
-
32
- return null;
23
+ }
24
+
25
+ // Linux / macOS
26
+ const p = join(cwd, name);
27
+ if (existsSync(p)) {
28
+ logger.success(`Binary found (${mode}): ${p}`);
29
+ return p;
30
+ }
31
+
32
+ logger.error(`❌ Binary not found in project root: ${name}`);
33
+ logger.error('');
34
+ logger.error('Download from:');
35
+ logger.error(' https://github.com/BunElysiaReact/nothing-browser/releases/');
36
+ logger.error('');
37
+ logger.error(`Place in: ${cwd}/${name}${process.platform === 'win32' ? '.exe' : ''}`);
38
+ if (process.platform !== 'win32') {
39
+ logger.error(`Then run: chmod +x ${name}`);
40
+ }
41
+
42
+ return null;
33
43
  }
@@ -1,101 +1,99 @@
1
1
  import { spawn } from 'bun';
2
2
  import { execSync } from 'child_process';
3
- import { detectBinary } from './detect';
3
+ import { detectBinary, type BinaryMode } from './detect';
4
4
  import logger from '../logger';
5
5
 
6
6
  let activeProcess: any = null;
7
7
  const extraProcesses: any[] = [];
8
8
 
9
9
  export function killAllBrowsers(): void {
10
- try {
11
- logger.info("Cleaning up existing browser processes...");
12
- execSync('pkill -f nothing-browser-headless 2>/dev/null || true', { stdio: 'ignore' });
13
- execSync('pkill -f QtWebEngineProcess 2>/dev/null || true', { stdio: 'ignore' });
14
- execSync('rm -f /tmp/piggy', { stdio: 'ignore' });
15
- } catch {
16
- // Ignore errors - no processes to kill
17
- }
10
+ try {
11
+ logger.info('Cleaning up existing browser processes...');
12
+ execSync('pkill -f nothing-browser-headless 2>/dev/null || true', { stdio: 'ignore' });
13
+ execSync('pkill -f nothing-browser-headful 2>/dev/null || true', { stdio: 'ignore' });
14
+ execSync('pkill -f QtWebEngineProcess 2>/dev/null || true', { stdio: 'ignore' });
15
+ execSync('rm -f /tmp/piggy', { stdio: 'ignore' });
16
+ } catch {
17
+ // no processes to kill
18
+ }
18
19
  }
19
20
 
20
- export async function spawnBrowser(): Promise<string> {
21
- killAllBrowsers();
22
-
23
- // Give OS time to release the socket
24
- await new Promise(resolve => setTimeout(resolve, 500));
25
-
26
- const binaryPath = detectBinary();
27
- if (!binaryPath) {
28
- throw new Error("Binary not found. Cannot launch.");
29
- }
30
-
31
- logger.info(`Spawning Nothing Browser from: ${binaryPath}`);
32
-
33
- activeProcess = spawn([binaryPath], {
34
- stdio: ['ignore', 'pipe', 'pipe'],
35
- env: process.env
36
- });
37
-
38
- if (activeProcess.stdout) {
39
- const reader = activeProcess.stdout.getReader();
40
- const read = async () => {
41
- const { done, value } = await reader.read();
42
- if (!done) {
43
- const output = new TextDecoder().decode(value);
44
- logger.debug(`[Browser] ${output}`);
45
- read();
46
- }
47
- };
21
+ export async function spawnBrowser(mode: BinaryMode = 'headless'): Promise<string> {
22
+ killAllBrowsers();
23
+ await new Promise(resolve => setTimeout(resolve, 500));
24
+
25
+ const binaryPath = detectBinary(mode);
26
+ if (!binaryPath) {
27
+ throw new Error(`Binary not found (${mode}). Cannot launch.`);
28
+ }
29
+
30
+ logger.info(`Spawning Nothing Browser (${mode}) from: ${binaryPath}`);
31
+
32
+ activeProcess = spawn([binaryPath], {
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ env: process.env,
35
+ });
36
+
37
+ if (activeProcess.stdout) {
38
+ const reader = activeProcess.stdout.getReader();
39
+ const read = async () => {
40
+ const { done, value } = await reader.read();
41
+ if (!done) {
42
+ logger.debug(`[Browser] ${new TextDecoder().decode(value)}`);
48
43
  read();
49
- }
44
+ }
45
+ };
46
+ read();
47
+ }
50
48
 
51
- activeProcess.exited.then((code: number | null) => {
52
- logger.warn(`Browser process exited with code: ${code}`);
53
- activeProcess = null;
54
- });
49
+ activeProcess.exited.then((code: number | null) => {
50
+ logger.warn(`Browser process exited with code: ${code}`);
51
+ activeProcess = null;
52
+ });
55
53
 
56
- await new Promise(resolve => setTimeout(resolve, 2000));
54
+ await new Promise(resolve => setTimeout(resolve, 2000));
57
55
 
58
- if (activeProcess) {
59
- logger.success("Browser spawned and running");
60
- } else {
61
- logger.error("Browser started but exited immediately");
62
- }
56
+ if (activeProcess) {
57
+ logger.success(`Browser spawned and running (${mode})`);
58
+ } else {
59
+ logger.error('Browser started but exited immediately');
60
+ }
63
61
 
64
- return binaryPath;
62
+ return binaryPath;
65
63
  }
66
64
 
67
- export async function spawnBrowserOnSocket(socketName: string): Promise<void> {
68
- const binaryPath = detectBinary();
69
- if (!binaryPath) {
70
- throw new Error("Binary not found. Cannot launch.");
71
- }
65
+ export async function spawnBrowserOnSocket(
66
+ socketName: string,
67
+ mode: BinaryMode = 'headless'
68
+ ): Promise<void> {
69
+ const binaryPath = detectBinary(mode);
70
+ if (!binaryPath) {
71
+ throw new Error(`Binary not found (${mode}). Cannot launch.`);
72
+ }
72
73
 
73
- logger.info(`Spawning browser on socket: ${socketName}`);
74
+ logger.info(`Spawning browser (${mode}) on socket: ${socketName}`);
74
75
 
75
- const proc = spawn([binaryPath], {
76
- stdio: ['ignore', 'pipe', 'pipe'],
77
- env: { ...process.env, PIGGY_SOCKET: socketName }
78
- });
76
+ const proc = spawn([binaryPath], {
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ env: { ...process.env, PIGGY_SOCKET: socketName },
79
+ });
79
80
 
80
- extraProcesses.push(proc);
81
+ extraProcesses.push(proc);
81
82
 
82
- proc.exited.then((code: number | null) => {
83
- logger.warn(`Browser on socket ${socketName} exited with code: ${code}`);
84
- });
83
+ proc.exited.then((code: number | null) => {
84
+ logger.warn(`Browser on socket ${socketName} exited with code: ${code}`);
85
+ });
85
86
 
86
- await new Promise(resolve => setTimeout(resolve, 1000));
87
- logger.success(`Browser spawned on socket: ${socketName}`);
87
+ await new Promise(resolve => setTimeout(resolve, 1000));
88
+ logger.success(`Browser spawned (${mode}) on socket: ${socketName}`);
88
89
  }
89
90
 
90
91
  export function killBrowser(): void {
91
- if (activeProcess) {
92
- logger.info("Killing browser process...");
93
- activeProcess.kill();
94
- activeProcess = null;
95
- }
96
-
97
- for (const proc of extraProcesses) {
98
- proc.kill();
99
- }
100
- extraProcesses.length = 0;
92
+ if (activeProcess) {
93
+ logger.info('Killing browser process...');
94
+ activeProcess.kill();
95
+ activeProcess = null;
96
+ }
97
+ for (const proc of extraProcesses) proc.kill();
98
+ extraProcesses.length = 0;
101
99
  }
@@ -278,6 +278,42 @@ export function createSiteObject(
278
278
  },
279
279
  },
280
280
 
281
+ // ── Expose Function ─────────────────────────────────────────────────────────
282
+
283
+ exposeFunction: async (fnName: string, handler: (data: any) => Promise<any> | any) => {
284
+ await client.exposeFunction(fnName, handler, tabId);
285
+ logger.success(`[${name}] exposed function: ${fnName}`);
286
+ return site;
287
+ },
288
+
289
+ unexposeFunction: async (fnName: string) => {
290
+ await client.unexposeFunction(fnName, tabId);
291
+ logger.info(`[${name}] unexposed function: ${fnName}`);
292
+ return site;
293
+ },
294
+
295
+ clearExposedFunctions: async () => {
296
+ await client.clearExposedFunctions(tabId);
297
+ logger.info(`[${name}] cleared all exposed functions`);
298
+ return site;
299
+ },
300
+
301
+ exposeAndInject: async (
302
+ fnName: string,
303
+ handler: (data: any) => Promise<any> | any,
304
+ injectionJs: string | ((fnName: string) => string)
305
+ ) => {
306
+ await client.exposeFunction(fnName, handler, tabId);
307
+
308
+ const js = typeof injectionJs === "function"
309
+ ? injectionJs(fnName)
310
+ : injectionJs;
311
+
312
+ await client.evaluate(js, tabId);
313
+ logger.success(`[${name}] exposed and injected: ${fnName}`);
314
+ return site;
315
+ },
316
+
281
317
  // ── Elysia API ─────────────────────────────────────────────────────────────
282
318
 
283
319
  api: (
@@ -313,4 +349,30 @@ export function createSiteObject(
313
349
  };
314
350
 
315
351
  return site;
352
+ }
353
+
354
+ // ── Helper for creating structured APIs ───────────────────────────────────────
355
+
356
+ export function createExposedAPI<T extends Record<string, (data: any) => any>>(
357
+ site: any,
358
+ apiName: string,
359
+ handlers: T
360
+ ): Promise<void> {
361
+ const wrappedHandler = async (call: any) => {
362
+ const { method, args } = call;
363
+ const handler = handlers[method as keyof T];
364
+
365
+ if (!handler) {
366
+ throw new Error(`Unknown method: ${method}`);
367
+ }
368
+
369
+ try {
370
+ return await handler(args);
371
+ } catch (err) {
372
+ logger.error(`[${site._name}] API error in ${method}:`, err);
373
+ throw err;
374
+ }
375
+ };
376
+
377
+ return site.exposeFunction(apiName, wrappedHandler);
316
378
  }
package/piggy.ts CHANGED
@@ -1,45 +1,47 @@
1
1
  // piggy.ts
2
- import { detectBinary } from "./piggy/launch/detect";
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
5
  import { setClient, setHumanMode, createSiteObject } from "./piggy/register";
6
6
  import { routeRegistry, keepAliveSites, startServer, stopServer } from "./piggy/server";
7
7
  import logger from "./piggy/logger";
8
8
 
9
- type BrowserMode = "tab" | "process";
9
+ type TabMode = "tab" | "process";
10
10
  type SiteObject = ReturnType<typeof createSiteObject>;
11
11
 
12
12
  let _client: PiggyClient | null = null;
13
- let _mode: BrowserMode = "tab";
13
+ let _tabMode: TabMode = "tab";
14
14
  const _extraProcs: { socket: string; client: PiggyClient }[] = [];
15
15
  const _sites: Record<string, SiteObject> = {};
16
16
 
17
17
  const piggy: any = {
18
18
  // ── Lifecycle ───────────────────────────────────────────────────────────────
19
19
 
20
- launch: async (opts?: { mode?: BrowserMode }) => {
21
- _mode = opts?.mode ?? "tab";
22
- await spawnBrowser();
20
+ launch: async (opts?: { mode?: TabMode; binary?: BinaryMode }) => {
21
+ _tabMode = opts?.mode ?? "tab";
22
+ const binaryMode: BinaryMode = opts?.binary ?? "headless";
23
+ await spawnBrowser(binaryMode);
23
24
  await new Promise(r => setTimeout(r, 500));
24
25
  _client = new PiggyClient();
25
26
  await _client.connect();
26
27
  setClient(_client);
27
- logger.info(`[piggy] launched in "${_mode}" mode`);
28
+ logger.info(`[piggy] launched tab mode: "${_tabMode}", binary: "${binaryMode}"`);
28
29
  return piggy;
29
30
  },
30
31
 
31
- register: async (name: string, url: string, opts?: any) => {
32
+ register: async (name: string, url: string, opts?: { binary?: BinaryMode }) => {
32
33
  if (!url?.trim()) throw new Error(`No URL for site "${name}"`);
34
+ const binaryMode: BinaryMode = opts?.binary ?? "headless";
33
35
 
34
36
  let tabId = "default";
35
- if (_mode === "tab") {
37
+ if (_tabMode === "tab") {
36
38
  tabId = await _client!.newTab();
37
39
  _sites[name] = createSiteObject(name, url, _client!, tabId);
38
40
  piggy[name] = _sites[name];
39
41
  logger.success(`[${name}] registered as tab ${tabId}`);
40
42
  } else {
41
43
  const socketName = `piggy_${name}`;
42
- await spawnBrowserOnSocket(socketName);
44
+ await spawnBrowserOnSocket(socketName, binaryMode);
43
45
  await new Promise(r => setTimeout(r, 500));
44
46
  const c = new PiggyClient(socketName);
45
47
  await c.connect();
@@ -49,7 +51,6 @@ const piggy: any = {
49
51
  logger.success(`[${name}] registered as process on "${socketName}"`);
50
52
  }
51
53
 
52
- if (opts?.mode) logger.info(`[${name}] mode: ${opts.mode}`);
53
54
  return piggy;
54
55
  },
55
56
 
@@ -61,7 +62,23 @@ const piggy: any = {
61
62
  return piggy;
62
63
  },
63
64
 
64
- mode: (m: BrowserMode) => { _mode = m; return piggy; },
65
+ mode: (m: TabMode) => { _tabMode = m; return piggy; },
66
+
67
+ // ── Expose Function (global) ─────────────────────────────────────────────────
68
+
69
+ expose: async (name: string, handler: (data: any) => Promise<any> | any, tabId = "default") => {
70
+ if (!_client) throw new Error("No client. Call piggy.launch() first.");
71
+ await _client.exposeFunction(name, handler, tabId);
72
+ logger.success(`[piggy] exposed global function: ${name}`);
73
+ return piggy;
74
+ },
75
+
76
+ unexpose: async (name: string, tabId = "default") => {
77
+ if (!_client) throw new Error("No client. Call piggy.launch() first.");
78
+ await _client.unexposeFunction(name, tabId);
79
+ logger.info(`[piggy] unexposed function: ${name}`);
80
+ return piggy;
81
+ },
65
82
 
66
83
  // ── Elysia server ────────────────────────────────────────────────────────────
67
84