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 +260 -71
- package/package.json +1 -1
- package/piggy/client/index.ts +109 -3
- package/piggy/launch/detect.ts +37 -27
- package/piggy/launch/spawn.ts +73 -75
- package/piggy/register/index.ts +62 -0
- package/piggy.ts +29 -12
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/
|
|
11
|
-
<a href="https://github.com/BunElysiaReact/nothing-browser/releases"><img src="https://img.shields.io/github/v/release/BunElysiaReact/nothing-browser" alt="
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
##
|
|
128
|
+
## Headless vs Headful
|
|
76
129
|
|
|
77
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
148
|
-
if (!key || key !== "piggy-secret") {
|
|
309
|
+
if (headers["x-api-key"] !== "secret") {
|
|
149
310
|
set.status = 401;
|
|
150
|
-
throw new Error("Unauthorized
|
|
311
|
+
throw new Error("Unauthorized");
|
|
151
312
|
}
|
|
152
313
|
};
|
|
153
314
|
|
|
154
315
|
piggy.books.api("/search", async (_params, query) => {
|
|
155
|
-
|
|
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);
|
|
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);
|
|
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
|
|
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
|
-
//
|
|
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.<
|
|
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
|
|
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?)
|
|
456
|
+
site.type(selector, text, opts?)
|
|
311
457
|
site.select(selector, value)
|
|
312
458
|
site.keyboard.press(key)
|
|
313
|
-
site.keyboard.combo(combo)
|
|
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)
|
|
322
|
-
site.fetchLinks(selector)
|
|
323
|
-
site.fetchImages(selector)
|
|
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 |
|
|
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
|
|
365
|
-
| macOS | `nothing-browser-headless
|
|
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
|
-
→ [
|
|
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/
|
|
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/launch/detect.ts
CHANGED
|
@@ -2,32 +2,42 @@ import { existsSync } from 'fs';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import logger from '../logger';
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
}
|
package/piggy/launch/spawn.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
activeProcess.exited.then((code: number | null) => {
|
|
50
|
+
logger.warn(`Browser process exited with code: ${code}`);
|
|
51
|
+
activeProcess = null;
|
|
52
|
+
});
|
|
55
53
|
|
|
56
|
-
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
62
|
+
return binaryPath;
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
export async function spawnBrowserOnSocket(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
+
logger.info(`Spawning browser (${mode}) on socket: ${socketName}`);
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
const proc = spawn([binaryPath], {
|
|
77
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
78
|
+
env: { ...process.env, PIGGY_SOCKET: socketName },
|
|
79
|
+
});
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
extraProcesses.push(proc);
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
proc.exited.then((code: number | null) => {
|
|
84
|
+
logger.warn(`Browser on socket ${socketName} exited with code: ${code}`);
|
|
85
|
+
});
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|
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
|
@@ -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
|
|
9
|
+
type TabMode = "tab" | "process";
|
|
10
10
|
type SiteObject = ReturnType<typeof createSiteObject>;
|
|
11
11
|
|
|
12
12
|
let _client: PiggyClient | null = null;
|
|
13
|
-
let
|
|
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?:
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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?:
|
|
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 (
|
|
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:
|
|
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
|
|