nothing-browser 0.0.3 → 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>
@@ -17,6 +20,27 @@ A scraper-first headless browser library powered by the Nothing Browser Qt6/Chro
17
20
 
18
21
  ---
19
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.
41
+
42
+ ---
43
+
20
44
  ## Requirements
21
45
 
22
46
  - [Bun](https://bun.sh) ≥ 1.0
@@ -26,7 +50,7 @@ A scraper-first headless browser library powered by the Nothing Browser Qt6/Chro
26
50
 
27
51
  ## Binaries
28
52
 
29
- There are three binaries. All are downloaded from the same place — [GitHub Releases](https://github.com/BunElysiaReact/nothing-browser/releases).
53
+ There are three binaries. All downloaded from the same place — [GitHub Releases](https://github.com/BunElysiaReact/nothing-browser/releases).
30
54
 
31
55
  | Binary | What it is | Where it goes |
32
56
  |--------|-----------|---------------|
@@ -34,7 +58,7 @@ There are three binaries. All are downloaded from the same place — [GitHub Rel
34
58
  | `nothing-browser-headless` | No window, no GPU. Runs as a background daemon for the scraping lib. | **Your project root** |
35
59
  | `nothing-browser-headful` | Visible browser window, script-controlled. Useful when a site needs a real display. | **Your project root** |
36
60
 
37
- The `nothingbrowser` npm/Bun lib talks to whichever binary is in your project root over a local socket. You pick headless or headful depending on your use case.
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.
38
62
 
39
63
  ---
40
64
 
@@ -63,19 +87,15 @@ chmod +x nothing-browser-headful
63
87
  **Full browser** (system-wide install, for using the UI)
64
88
  ```bash
65
89
  sudo dpkg -i nothing-browser_*_amd64.deb
66
- # or
67
- tar -xzf nothing-browser-*-linux-x86_64.tar.gz
68
- cd nothing-browser-*-linux-x86_64
69
- ./nothing-browser
70
90
  ```
71
91
 
72
92
  ### Windows
73
93
 
74
- Download the `.zip` for your chosen binary → extract → place `nothing-browser-headless.exe` or `nothing-browser-headful.exe` in your project root. The JRE is bundled in the full browser zip.
94
+ Download the `.zip` → extract → place the exe in your project root.
75
95
 
76
96
  ### macOS
77
97
 
78
- Download the `.tar.gz` for your chosen binary → extract → place the binary in your project root.
98
+ Download the `.tar.gz` → extract → place the binary in your project root.
79
99
 
80
100
  ---
81
101
 
@@ -101,45 +121,143 @@ console.log(books);
101
121
  await piggy.close();
102
122
  ```
103
123
 
124
+ That's it. One import, one register, scrape, done.
125
+
104
126
  ---
105
127
 
106
- ## Modes
128
+ ## Headless vs Headful
107
129
 
108
- ### Tab mode (default)
109
- All sites share one browser process, each in its own tab.
130
+ **Headless** no display needed, runs anywhere including CI.
110
131
 
111
132
  ```ts
112
- await piggy.launch({ mode: "tab" });
133
+ await piggy.launch({ mode: "tab", binary: "headless" }); // default
113
134
  ```
114
135
 
115
- ### Process mode
116
- 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.
117
137
 
118
138
  ```ts
119
- await piggy.launch({ mode: "process" });
139
+ await piggy.launch({ mode: "tab", binary: "headful" });
120
140
  ```
121
141
 
142
+ Same API either way. Switching is just changing one word.
143
+
122
144
  ---
123
145
 
124
- ## Headless vs Headful
146
+ ## Examples
125
147
 
126
- **Headless** no display needed, runs anywhere including CI. Use this by default.
148
+ ### 🔥 NEW: Browser Node.js RPC with `exposeFunction`
127
149
 
128
- ```
129
- nothing-browser-headless ← in your project root
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...");
130
199
  ```
131
200
 
132
- **Headful** opens a real visible Chromium window that your script drives. Use this when a site detects headless mode or requires a real display (canvas fingerprinting, certain login flows, etc).
201
+ ### Expose + Inject convenience method
133
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
+ );
134
220
  ```
135
- nothing-browser-headful ← in your project root
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
+ });
136
248
  ```
137
249
 
138
- Both binaries expose the exact same socket API. Switching is just swapping which binary is in your project root.
250
+ ### Global expose (available to all sites)
139
251
 
140
- ---
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
+ });
141
258
 
142
- ## Examples
259
+ // Any page can call: window.logToServer({ event: 'pageview' })
260
+ ```
143
261
 
144
262
  ### Scrape a site and expose it as an API
145
263
 
@@ -151,7 +269,6 @@ await piggy.register("books", "https://books.toscrape.com");
151
269
 
152
270
  await piggy.books.intercept.block("*google-analytics*");
153
271
  await piggy.books.intercept.block("*doubleclick*");
154
- await piggy.books.intercept.block("*facebook*");
155
272
 
156
273
  piggy.books.api("/list", async (_params, query) => {
157
274
  const page = query.page ? parseInt(query.page) : 1;
@@ -179,6 +296,8 @@ piggy.books.api("/list", async (_params, query) => {
179
296
 
180
297
  piggy.books.noclose();
181
298
  await piggy.serve(3000);
299
+ // GET http://localhost:3000/books/list
300
+ // GET http://localhost:3000/books/list?page=2
182
301
  ```
183
302
 
184
303
  ---
@@ -186,37 +305,16 @@ await piggy.serve(3000);
186
305
  ### Middleware — auth + logging
187
306
 
188
307
  ```ts
189
- const logMiddleware = async ({ query, params }: any) => {
190
- console.log("[middleware] incoming request", { params, query });
191
- };
192
-
193
308
  const authMiddleware = async ({ headers, set }: any) => {
194
- const key = headers["x-api-key"];
195
- if (!key || key !== "piggy-secret") {
309
+ if (headers["x-api-key"] !== "secret") {
196
310
  set.status = 401;
197
- throw new Error("Unauthorized: missing or invalid x-api-key");
311
+ throw new Error("Unauthorized");
198
312
  }
199
313
  };
200
314
 
201
315
  piggy.books.api("/search", async (_params, query) => {
202
- if (!query.q) return { error: "query param 'q' required" };
203
-
204
- await piggy.books.navigate("https://books.toscrape.com");
205
- await piggy.books.waitForSelector(".product_pod", 10000);
206
-
207
- const books = await piggy.books.evaluate((q: string) =>
208
- Array.from(document.querySelectorAll(".product_pod"))
209
- .filter(el =>
210
- el.querySelector("h3 a")?.getAttribute("title")?.toLowerCase().includes(q.toLowerCase())
211
- )
212
- .map(el => ({
213
- title: el.querySelector("h3 a")?.getAttribute("title") ?? "",
214
- price: el.querySelector(".price_color")?.textContent?.trim() ?? "",
215
- }))
216
- , query.q);
217
-
218
- return { query: query.q, count: books.length, books };
219
- }, { ttl: 120_000, before: [logMiddleware, authMiddleware] });
316
+ // handler
317
+ }, { ttl: 120_000, before: [authMiddleware] });
220
318
  ```
221
319
 
222
320
  ---
@@ -236,8 +334,8 @@ await piggy.books.capture.stop();
236
334
 
237
335
  const requests = await piggy.books.capture.requests();
238
336
  const ws = await piggy.books.capture.ws();
239
- const storage = await piggy.books.capture.storage();
240
337
  const cookies = await piggy.books.capture.cookies();
338
+ const storage = await piggy.books.capture.storage();
241
339
 
242
340
  console.log(`${requests.length} requests, ${ws.length} WS frames`);
243
341
  ```
@@ -276,6 +374,8 @@ await piggy.books.type("#search", "mystery novels");
276
374
  await piggy.books.scroll.by(400);
277
375
  ```
278
376
 
377
+ Affects `click`, `type`, `hover`, `scroll.by`, `wait` — random delays, simulated typos, self-correction.
378
+
279
379
  ---
280
380
 
281
381
  ### Screenshot / PDF
@@ -284,7 +384,7 @@ await piggy.books.scroll.by(400);
284
384
  await piggy.books.screenshot("./out/page.png");
285
385
  await piggy.books.pdf("./out/page.pdf");
286
386
 
287
- const b64 = await piggy.books.screenshot();
387
+ const b64 = await piggy.books.screenshot(); // base64
288
388
  ```
289
389
 
290
390
  ---
@@ -309,6 +409,7 @@ const h1s = await piggy.diff([piggy.site1, piggy.site2]).fetchText("h1");
309
409
  | Option | Type | Default |
310
410
  |--------|------|---------|
311
411
  | `mode` | `"tab" \| "process"` | `"tab"` |
412
+ | `binary` | `"headless" \| "headful"` | `"headless"` |
312
413
 
313
414
  ### `piggy.register(name, url)`
314
415
  Registers a site. Accessible as `piggy.<name>` after registration.
@@ -316,6 +417,12 @@ Registers a site. Accessible as `piggy.<name>` after registration.
316
417
  ### `piggy.actHuman(enable)`
317
418
  Toggles human-like interaction timing globally.
318
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
+
319
426
  ### `piggy.serve(port, opts?)`
320
427
  Starts the Elysia HTTP server. Built-in routes: `GET /health`, `GET /cache/keys`, `DELETE /cache`.
321
428
 
@@ -325,7 +432,7 @@ Returns all registered API routes with method, path, TTL, and middleware count.
325
432
  ### `piggy.close(opts?)`
326
433
 
327
434
  ```ts
328
- await piggy.close(); // graceful — respects noclose()
435
+ await piggy.close(); // graceful
329
436
  await piggy.close({ force: true }); // kills everything immediately
330
437
  ```
331
438
 
@@ -346,10 +453,10 @@ site.wait(ms)
346
453
  ```ts
347
454
  site.click(selector, opts?)
348
455
  site.doubleClick(selector) / site.hover(selector)
349
- site.type(selector, text, opts?) // opts: { delay?, wpm?, fact? }
456
+ site.type(selector, text, opts?)
350
457
  site.select(selector, value)
351
458
  site.keyboard.press(key)
352
- site.keyboard.combo(combo) // e.g. "Ctrl+A"
459
+ site.keyboard.combo(combo)
353
460
  site.mouse.move(x, y)
354
461
  site.mouse.drag(from, to)
355
462
  site.scroll.to(selector) / site.scroll.by(px)
@@ -357,13 +464,30 @@ site.scroll.to(selector) / site.scroll.by(px)
357
464
 
358
465
  #### Data
359
466
  ```ts
360
- site.fetchText(selector) // → string | null
361
- site.fetchLinks(selector) // → string[]
362
- site.fetchImages(selector) // → string[]
467
+ site.fetchText(selector)
468
+ site.fetchLinks(selector)
469
+ site.fetchImages(selector)
363
470
  site.search.css(query) / site.search.id(query)
364
471
  site.evaluate(js | fn, ...args)
365
472
  ```
366
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
+
367
491
  #### Network
368
492
  ```ts
369
493
  site.capture.start() / .stop() / .clear()
@@ -386,12 +510,38 @@ site.session.export() / site.session.import(data)
386
510
  ```ts
387
511
  site.api(path, handler, opts?)
388
512
  // opts: { ttl?, method?, before?: middleware[] }
389
- // handler: (params, query, body) => Promise<any>
390
513
 
391
514
  site.noclose()
392
515
  site.screenshot(filePath?) / site.pdf(filePath?)
393
516
  ```
394
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
+
395
545
  ---
396
546
 
397
547
  ## Binary download
@@ -409,4 +559,4 @@ site.screenshot(filePath?) / site.pdf(filePath?)
409
559
 
410
560
  ## License
411
561
 
412
- MIT © [Ernest Tech House](https://github.com/BunElysiaReact/nothing-browser)
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.3",
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
  }
@@ -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
@@ -64,6 +64,22 @@ const piggy: any = {
64
64
 
65
65
  mode: (m: TabMode) => { _tabMode = m; return piggy; },
66
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
+ },
82
+
67
83
  // ── Elysia server ────────────────────────────────────────────────────────────
68
84
 
69
85
  serve: (port: number, opts?: { hostname?: string }) =>