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 +210 -60
- package/package.json +1 -1
- package/piggy/client/index.ts +109 -3
- package/piggy/register/index.ts +62 -0
- package/piggy.ts +16 -0
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
|
|
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
|
|
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`
|
|
94
|
+
Download the `.zip` → extract → place the exe in your project root.
|
|
75
95
|
|
|
76
96
|
### macOS
|
|
77
97
|
|
|
78
|
-
Download the `.tar.gz`
|
|
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
|
-
##
|
|
128
|
+
## Headless vs Headful
|
|
107
129
|
|
|
108
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
##
|
|
146
|
+
## Examples
|
|
125
147
|
|
|
126
|
-
|
|
148
|
+
### 🔥 NEW: Browser → Node.js RPC with `exposeFunction`
|
|
127
149
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
if (!key || key !== "piggy-secret") {
|
|
309
|
+
if (headers["x-api-key"] !== "secret") {
|
|
196
310
|
set.status = 401;
|
|
197
|
-
throw new Error("Unauthorized
|
|
311
|
+
throw new Error("Unauthorized");
|
|
198
312
|
}
|
|
199
313
|
};
|
|
200
314
|
|
|
201
315
|
piggy.books.api("/search", async (_params, query) => {
|
|
202
|
-
|
|
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
|
|
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?)
|
|
456
|
+
site.type(selector, text, opts?)
|
|
350
457
|
site.select(selector, value)
|
|
351
458
|
site.keyboard.press(key)
|
|
352
|
-
site.keyboard.combo(combo)
|
|
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)
|
|
361
|
-
site.fetchLinks(selector)
|
|
362
|
-
site.fetchImages(selector)
|
|
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
|
+
"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",
|
package/piggy/client/index.ts
CHANGED
|
@@ -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.
|
|
32
|
-
const lines = this.
|
|
33
|
-
this.
|
|
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
|
}
|
package/piggy/register/index.ts
CHANGED
|
@@ -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 }) =>
|