nothing-browser 0.0.1

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.
@@ -0,0 +1,316 @@
1
+ // piggy/register/index.ts
2
+ import { PiggyClient } from "../client";
3
+ import logger from "../logger";
4
+ import { routeRegistry, keepAliveSites, type RouteHandler, type BeforeMiddleware } from "../server";
5
+ import { randomDelay, humanTypeSequence } from "../human";
6
+
7
+ let globalClient: PiggyClient | null = null;
8
+ export let humanMode = false;
9
+
10
+ export function setClient(c: PiggyClient | null) { globalClient = c; }
11
+ export function setHumanMode(v: boolean) { humanMode = v; }
12
+
13
+ async function retry<T>(
14
+ label: string,
15
+ fn: () => Promise<T>,
16
+ retries = 2,
17
+ backoff = 150
18
+ ): Promise<T> {
19
+ let last!: Error;
20
+ for (let i = 0; i <= retries; i++) {
21
+ try { return await fn(); } catch (e: any) {
22
+ last = e;
23
+ if (i < retries) {
24
+ logger.warn(`[${label}] retry ${i + 1}/${retries}: ${e.message}`);
25
+ await new Promise(r => setTimeout(r, backoff * (i + 1)));
26
+ }
27
+ }
28
+ }
29
+ throw last;
30
+ }
31
+
32
+ export function createSiteObject(
33
+ name: string,
34
+ registeredUrl: string,
35
+ client: PiggyClient,
36
+ tabId: string
37
+ ) {
38
+ let _currentUrl: string = registeredUrl;
39
+
40
+ const withErrScreen = async <T>(fn: () => Promise<T>, label: string): Promise<T> => {
41
+ try { return await fn(); } catch (err: any) {
42
+ const p = `./error-${name}-${Date.now()}.png`;
43
+ try { await client.screenshot(p, tabId); logger.error(`[${name}] ${label} failed → ${p}`); }
44
+ catch { logger.error(`[${name}] ${label} failed (no screenshot)`); }
45
+ throw err;
46
+ }
47
+ };
48
+
49
+ const site: any = {
50
+ _name: name,
51
+ _tabId: tabId,
52
+
53
+ // ── Navigation ─────────────────────────────────────────────────────────────
54
+
55
+ navigate: (url?: string, opts?: { retries?: number }) => {
56
+ const target = url ?? registeredUrl;
57
+ return retry(name, async () => {
58
+ logger.network(`[${name}] navigating → ${target}`);
59
+ await client.navigate(target, tabId);
60
+ _currentUrl = target;
61
+ }, opts?.retries ?? 2);
62
+ },
63
+
64
+ reload: () => client.reload(tabId),
65
+ goBack: () => client.goBack(tabId),
66
+ goForward: () => client.goForward(tabId),
67
+ waitForNavigation: () => client.waitForNavigation(tabId),
68
+
69
+ title: async () => {
70
+ const t = await client.getTitle(tabId);
71
+ logger.info(`[${name}] title: ${t}`);
72
+ return t;
73
+ },
74
+ url: () => _currentUrl,
75
+ content: () => client.content(tabId),
76
+
77
+ wait: (ms: number) => {
78
+ const actual = humanMode ? ms + Math.floor(Math.random() * 600) - 300 : ms;
79
+ return new Promise<void>(r => setTimeout(r, Math.max(0, actual)));
80
+ },
81
+
82
+ waitForSelector: (selector: string, timeout = 30000) => {
83
+ logger.debug(`[${name}] waitForSelector: ${selector}`);
84
+ return client.waitForSelector(selector, timeout, tabId);
85
+ },
86
+ waitForVisible: (selector: string, timeout = 30000) => client.waitForSelector(selector, timeout, tabId),
87
+ waitForResponse: (pattern: string, timeout = 30000) => client.waitForResponse(pattern, timeout, tabId),
88
+
89
+ // ── Interactions ───────────────────────────────────────────────────────────
90
+
91
+ click: (selector: string, opts?: { retries?: number; timeout?: number }) =>
92
+ withErrScreen(() =>
93
+ retry(name, async () => {
94
+ if (humanMode) await randomDelay(80, 220);
95
+ await client.waitForSelector(selector, opts?.timeout ?? 15000, tabId);
96
+ const ok = await client.click(selector, tabId);
97
+ if (!ok) throw new Error(`click failed: ${selector}`);
98
+ logger.success(`[${name}] clicked: ${selector}`);
99
+ return ok;
100
+ }, opts?.retries ?? 2),
101
+ `click(${selector})`
102
+ ),
103
+
104
+ doubleClick: (selector: string) =>
105
+ withErrScreen(async () => {
106
+ if (humanMode) await randomDelay(80, 200);
107
+ return client.doubleClick(selector, tabId);
108
+ }, `dblclick(${selector})`),
109
+
110
+ hover: (selector: string) =>
111
+ withErrScreen(async () => {
112
+ if (humanMode) await randomDelay(50, 150);
113
+ return client.hover(selector, tabId);
114
+ }, `hover(${selector})`),
115
+
116
+ type: async (selector: string, text: string, opts?: { delay?: number; retries?: number; fact?: boolean; wpm?: number }) =>
117
+ withErrScreen(async () => {
118
+ await client.waitForSelector(selector, 15000, tabId);
119
+ if (humanMode && !opts?.fact) {
120
+ const seq = humanTypeSequence(text);
121
+ let current = "";
122
+ for (const action of seq) {
123
+ if (action === "BACKSPACE") current = current.slice(0, -1);
124
+ else current += action;
125
+ await client.type(selector, current, tabId);
126
+ const wpm = opts?.wpm ?? 120;
127
+ const msPerChar = Math.round(60000 / (wpm * 5));
128
+ await randomDelay(msPerChar * 0.5, msPerChar * 1.8);
129
+ }
130
+ } else if (opts?.delay) {
131
+ for (const ch of text) {
132
+ await client.type(selector, ch, tabId);
133
+ await new Promise(r => setTimeout(r, opts.delay));
134
+ }
135
+ } else {
136
+ await client.type(selector, text, tabId);
137
+ }
138
+ logger.success(`[${name}] typed into: ${selector}`);
139
+ return true;
140
+ }, `type(${selector})`),
141
+
142
+ select: (selector: string, value: string) => client.select(selector, value, tabId),
143
+ evaluate: (js: string | (() => any), ...args: any[]) => {
144
+ const code = typeof js === "function" ? `(${js.toString()})(${args.map(a => JSON.stringify(a)).join(",")})` : js;
145
+ return client.evaluate(code, tabId);
146
+ },
147
+
148
+ keyboard: {
149
+ press: (key: string) => client.keyPress(key, tabId),
150
+ combo: (combo: string) => client.keyCombo(combo, tabId),
151
+ },
152
+
153
+ mouse: {
154
+ move: (x: number, y: number) => client.mouseMove(x, y, tabId),
155
+ drag: (from: { x: number; y: number }, to: { x: number; y: number }) => client.mouseDrag(from, to, tabId),
156
+ },
157
+
158
+ scroll: {
159
+ to: (selector: string) => client.scrollTo(selector, tabId),
160
+ by: (px: number) => {
161
+ if (humanMode) {
162
+ const steps = Math.ceil(Math.abs(px) / 120);
163
+ const chunk = px / steps;
164
+ return (async () => {
165
+ for (let i = 0; i < steps; i++) {
166
+ await client.scrollBy(chunk, tabId);
167
+ await randomDelay(30, 80);
168
+ }
169
+ })();
170
+ }
171
+ return client.scrollBy(px, tabId);
172
+ },
173
+ },
174
+
175
+ // ── Fetch ──────────────────────────────────────────────────────────────────
176
+
177
+ fetchText: (selector: string) => client.fetchText(selector, tabId),
178
+ fetchLinks: async (selector: string) => {
179
+ const links = await client.fetchLinks(selector, tabId);
180
+ logger.info(`[${name}] fetchLinks(${selector}): ${links.length}`);
181
+ return links;
182
+ },
183
+ fetchImages: async (selector: string) => {
184
+ const imgs = await client.fetchImages(selector, tabId);
185
+ logger.info(`[${name}] fetchImages(${selector}): ${imgs.length}`);
186
+ return imgs;
187
+ },
188
+
189
+ search: {
190
+ css: (query: string) => client.searchCss(query, tabId),
191
+ id: (query: string) => client.searchId(query, tabId),
192
+ },
193
+
194
+ // ── Screenshot / PDF ───────────────────────────────────────────────────────
195
+
196
+ screenshot: async (filePath?: string) => {
197
+ const r = await client.screenshot(filePath, tabId);
198
+ logger.success(`[${name}] screenshot → ${filePath ?? "base64"}`);
199
+ return r;
200
+ },
201
+ pdf: async (filePath?: string) => {
202
+ const r = await client.pdf(filePath, tabId);
203
+ logger.success(`[${name}] pdf → ${filePath ?? "base64"}`);
204
+ return r;
205
+ },
206
+
207
+ blockImages: async () => { await client.blockImages(tabId); logger.info(`[${name}] images blocked`); },
208
+ unblockImages: async () => { await client.unblockImages(tabId); logger.info(`[${name}] images unblocked`); },
209
+
210
+ // ── Cookies ────────────────────────────────────────────────────────────────
211
+
212
+ cookies: {
213
+ set: async (name: string, value: string, domain: string, path = "/") => {
214
+ await client.setCookie(name, value, domain, path, tabId);
215
+ logger.info(`[${name}] cookie set: ${name} @ ${domain}`);
216
+ },
217
+ get: (cookieName: string) => client.getCookie(cookieName, tabId),
218
+ delete: async (cookieName: string) => {
219
+ await client.deleteCookie(cookieName, tabId);
220
+ logger.info(`[${name}] cookie deleted: ${cookieName}`);
221
+ },
222
+ list: () => client.listCookies(tabId),
223
+ },
224
+
225
+ // ── Interception ───────────────────────────────────────────────────────────
226
+
227
+ intercept: {
228
+ block: async (pattern: string) => {
229
+ await client.addInterceptRule("block", pattern, {}, tabId);
230
+ logger.info(`[${name}] intercept block: ${pattern}`);
231
+ },
232
+ redirect: async (pattern: string, redirectUrl: string) => {
233
+ await client.addInterceptRule("redirect", pattern, { redirectUrl }, tabId);
234
+ logger.info(`[${name}] intercept redirect: ${pattern} → ${redirectUrl}`);
235
+ },
236
+ headers: async (pattern: string, headers: Record<string, string>) => {
237
+ await client.addInterceptRule("modifyHeaders", pattern, { headers }, tabId);
238
+ logger.info(`[${name}] intercept modifyHeaders: ${pattern}`);
239
+ },
240
+ clear: async () => {
241
+ await client.clearInterceptRules(tabId);
242
+ logger.info(`[${name}] intercept rules cleared`);
243
+ },
244
+ },
245
+
246
+ // ── Network capture ────────────────────────────────────────────────────────
247
+
248
+ capture: {
249
+ start: async () => {
250
+ await client.captureStart(tabId);
251
+ logger.info(`[${name}] capture started`);
252
+ },
253
+ stop: async () => {
254
+ await client.captureStop(tabId);
255
+ logger.info(`[${name}] capture stopped`);
256
+ },
257
+ requests: () => client.captureRequests(tabId),
258
+ ws: () => client.captureWs(tabId),
259
+ cookies: () => client.captureCookies(tabId),
260
+ storage: () => client.captureStorage(tabId),
261
+ clear: async () => {
262
+ await client.captureClear(tabId);
263
+ logger.info(`[${name}] capture cleared`);
264
+ },
265
+ },
266
+
267
+ // ── Session ────────────────────────────────────────────────────────────────
268
+
269
+ session: {
270
+ export: async () => {
271
+ const data = await client.sessionExport(tabId);
272
+ logger.success(`[${name}] session exported`);
273
+ return data;
274
+ },
275
+ import: async (data: any) => {
276
+ await client.sessionImport(data, tabId);
277
+ logger.success(`[${name}] session imported`);
278
+ },
279
+ },
280
+
281
+ // ── Elysia API ─────────────────────────────────────────────────────────────
282
+
283
+ api: (
284
+ path: string,
285
+ handler: RouteHandler,
286
+ opts?: { ttl?: number; before?: BeforeMiddleware[]; method?: "GET" | "POST" | "PUT" | "DELETE" }
287
+ ) => {
288
+ const key = `${name}:${path}`;
289
+ if (routeRegistry.has(key)) {
290
+ logger.warn(`[${name}] route ${path} already registered`);
291
+ return site;
292
+ }
293
+ routeRegistry.set(key, {
294
+ path,
295
+ method: opts?.method ?? "GET",
296
+ handler,
297
+ ttl: opts?.ttl ?? 360_000,
298
+ before: opts?.before ?? [],
299
+ });
300
+ logger.info(`[${name}] api route: ${opts?.method ?? "GET"} /${name}${path}`);
301
+ return site;
302
+ },
303
+
304
+ noclose: () => { keepAliveSites.add(name); logger.info(`[${name}] keep-alive`); return site; },
305
+
306
+ close: async () => {
307
+ keepAliveSites.delete(name);
308
+ if (tabId !== "default") {
309
+ await client.closeTab(tabId);
310
+ logger.info(`[${name}] tab closed`);
311
+ }
312
+ },
313
+ };
314
+
315
+ return site;
316
+ }
@@ -0,0 +1,137 @@
1
+ // piggy/server/index.ts
2
+ import { Elysia } from "elysia";
3
+ import * as cache from "../cache/memory";
4
+ import logger from "../logger";
5
+
6
+ export type BeforeMiddleware = (ctx: {
7
+ params: Record<string, string>;
8
+ query: Record<string, string>;
9
+ body: any;
10
+ headers: Record<string, string>;
11
+ set: any;
12
+ }) => void | Promise<void>;
13
+
14
+ export type RouteHandler = (
15
+ params: Record<string, string>,
16
+ query: Record<string, string>,
17
+ body: any
18
+ ) => Promise<any>;
19
+
20
+ export interface RouteConfig {
21
+ path: string;
22
+ method: "GET" | "POST" | "PUT" | "DELETE";
23
+ handler: RouteHandler;
24
+ ttl: number;
25
+ before: BeforeMiddleware[];
26
+ }
27
+
28
+ export const routeRegistry = new Map<string, RouteConfig>();
29
+ export const keepAliveSites = new Set<string>();
30
+
31
+ // ── Error mapper ──────────────────────────────────────────────────────────────
32
+
33
+ function mapError(err: Error, site: string) {
34
+ const msg = err.message.toLowerCase();
35
+ if (msg.includes("selector") || msg.includes("not found") || msg.includes("null"))
36
+ return { status: 404, error: "Not found", site, details: err.message };
37
+ if (msg.includes("timeout") || msg.includes("timed out"))
38
+ return { status: 504, error: "Timeout", site, details: err.message };
39
+ if (msg.includes("socket") || msg.includes("closed") || msg.includes("browser"))
40
+ return { status: 503, error: "Browser unavailable", site, details: err.message };
41
+ return { status: 500, error: "Internal server error", site, details: err.message };
42
+ }
43
+
44
+ // ── Server factory ────────────────────────────────────────────────────────────
45
+
46
+ let _app: Elysia | null = null;
47
+
48
+ export async function startServer(port: number, hostname = "0.0.0.0"): Promise<Elysia> {
49
+ _app = new Elysia();
50
+
51
+ // ── Health route ────────────────────────────────────────────────────────────
52
+ _app.get("/health", () => ({
53
+ status: "ok",
54
+ routes: routeRegistry.size,
55
+ cacheEntries: cache.size(),
56
+ uptime: process.uptime(),
57
+ }));
58
+
59
+ // ── Cache management routes ─────────────────────────────────────────────────
60
+ _app.delete("/cache", () => {
61
+ cache.clear();
62
+ return { cleared: true };
63
+ });
64
+
65
+ _app.get("/cache/keys", () => ({ keys: cache.keys() }));
66
+
67
+ // ── Registered site routes ──────────────────────────────────────────────────
68
+ for (const [registryKey, config] of routeRegistry.entries()) {
69
+ // registryKey format is "siteName:path" e.g. "movie:/title"
70
+ const colonIdx = registryKey.indexOf(":");
71
+ const siteName = registryKey.substring(0, colonIdx);
72
+ const fullPath = `/${siteName}${config.path}`;
73
+ const method = config.method.toLowerCase() as "get" | "post" | "put" | "delete";
74
+
75
+ logger.info(`[server] mounting ${config.method} ${fullPath} (ttl=${config.ttl}ms)`);
76
+
77
+ const routeHandler = async ({ params, query, body, headers, set }: any) => {
78
+ // 1. Run before middleware
79
+ for (const mw of config.before) {
80
+ try {
81
+ await mw({ params, query, body, headers, set });
82
+ } catch (e: any) {
83
+ set.status = set.status ?? 400;
84
+ return { error: e.message };
85
+ }
86
+ }
87
+
88
+ // 2. Cache check
89
+ const cacheKey = `${siteName}:${fullPath}:${JSON.stringify({ params, query })}`;
90
+ const hit = cache.get(cacheKey);
91
+ if (hit !== null) {
92
+ set.headers["x-cache"] = "HIT";
93
+ set.headers["x-cache-key"] = cacheKey;
94
+ return hit;
95
+ }
96
+
97
+ // 3. Execute handler
98
+ let result: any;
99
+ try {
100
+ result = await config.handler(params, query, body);
101
+ } catch (e: any) {
102
+ const mapped = mapError(e, siteName);
103
+ set.status = mapped.status;
104
+ return mapped;
105
+ }
106
+
107
+ // 4. Store in cache
108
+ if (config.ttl > 0) {
109
+ cache.set(cacheKey, result, config.ttl);
110
+ set.headers["x-cache"] = "MISS";
111
+ }
112
+
113
+ return result;
114
+ };
115
+
116
+ if (method === "get") _app.get(fullPath, routeHandler);
117
+ else if (method === "post") _app.post(fullPath, routeHandler);
118
+ else if (method === "put") _app.put(fullPath, routeHandler);
119
+ else if (method === "delete") _app.delete(fullPath, routeHandler);
120
+ }
121
+
122
+ _app.listen({ port, hostname });
123
+
124
+ logger.success(`🚀 Piggy API server → http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}`);
125
+ logger.info(` Routes mounted: ${routeRegistry.size}`);
126
+ routeRegistry.forEach((cfg, key) => {
127
+ const siteName = key.substring(0, key.indexOf(":"));
128
+ logger.info(` ${cfg.method} /${siteName}${cfg.path} (ttl=${cfg.ttl}ms)`);
129
+ });
130
+
131
+ return _app;
132
+ }
133
+
134
+ export function stopServer() {
135
+ _app?.stop();
136
+ _app = null;
137
+ }
package/piggy.ts ADDED
@@ -0,0 +1,135 @@
1
+ // piggy.ts
2
+ import { detectBinary } from "./piggy/launch/detect";
3
+ import { spawnBrowser, killBrowser, spawnBrowserOnSocket } from "./piggy/launch/spawn";
4
+ import { PiggyClient } from "./piggy/client";
5
+ import { setClient, setHumanMode, createSiteObject } from "./piggy/register";
6
+ import { routeRegistry, keepAliveSites, startServer, stopServer } from "./piggy/server";
7
+ import logger from "./piggy/logger";
8
+
9
+ type BrowserMode = "tab" | "process";
10
+ type SiteObject = ReturnType<typeof createSiteObject>;
11
+
12
+ let _client: PiggyClient | null = null;
13
+ let _mode: BrowserMode = "tab";
14
+ const _extraProcs: { socket: string; client: PiggyClient }[] = [];
15
+ const _sites: Record<string, SiteObject> = {};
16
+
17
+ const piggy: any = {
18
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
19
+
20
+ launch: async (opts?: { mode?: BrowserMode }) => {
21
+ _mode = opts?.mode ?? "tab";
22
+ await spawnBrowser();
23
+ await new Promise(r => setTimeout(r, 500));
24
+ _client = new PiggyClient();
25
+ await _client.connect();
26
+ setClient(_client);
27
+ logger.info(`[piggy] launched in "${_mode}" mode`);
28
+ return piggy;
29
+ },
30
+
31
+ register: async (name: string, url: string, opts?: any) => {
32
+ if (!url?.trim()) throw new Error(`No URL for site "${name}"`);
33
+
34
+ let tabId = "default";
35
+ if (_mode === "tab") {
36
+ tabId = await _client!.newTab();
37
+ _sites[name] = createSiteObject(name, url, _client!, tabId);
38
+ piggy[name] = _sites[name];
39
+ logger.success(`[${name}] registered as tab ${tabId}`);
40
+ } else {
41
+ const socketName = `piggy_${name}`;
42
+ await spawnBrowserOnSocket(socketName);
43
+ await new Promise(r => setTimeout(r, 500));
44
+ const c = new PiggyClient(socketName);
45
+ await c.connect();
46
+ _extraProcs.push({ socket: socketName, client: c });
47
+ _sites[name] = createSiteObject(name, url, c, "default");
48
+ piggy[name] = _sites[name];
49
+ logger.success(`[${name}] registered as process on "${socketName}"`);
50
+ }
51
+
52
+ if (opts?.mode) logger.info(`[${name}] mode: ${opts.mode}`);
53
+ return piggy;
54
+ },
55
+
56
+ // ── Global controls ─────────────────────────────────────────────────────────
57
+
58
+ actHuman: (enable: boolean) => {
59
+ setHumanMode(enable);
60
+ logger.info(`[piggy] actHuman: ${enable}`);
61
+ return piggy;
62
+ },
63
+
64
+ mode: (m: BrowserMode) => { _mode = m; return piggy; },
65
+
66
+ // ── Elysia server ────────────────────────────────────────────────────────────
67
+
68
+ serve: (port: number, opts?: { hostname?: string }) =>
69
+ startServer(port, opts?.hostname),
70
+
71
+ stopServer,
72
+
73
+ // ── Route listing ────────────────────────────────────────────────────────────
74
+
75
+ routes: () =>
76
+ Array.from(routeRegistry.entries()).map(([key, cfg]) => {
77
+ const [site] = key.split(":");
78
+ return {
79
+ site,
80
+ method: cfg.method,
81
+ path: `/${site}${cfg.path}`,
82
+ ttl: cfg.ttl,
83
+ middlewareCount: cfg.before.length,
84
+ };
85
+ }),
86
+
87
+ // ── Multi-site ───────────────────────────────────────────────────────────────
88
+
89
+ all: (sites: SiteObject[]) =>
90
+ new Proxy({} as any, {
91
+ get: (_, method: string) =>
92
+ (...args: any[]) => Promise.all(sites.map((s: any) => s[method]?.(...args))),
93
+ }),
94
+
95
+ diff: (sites: SiteObject[]) =>
96
+ new Proxy({} as any, {
97
+ get: (_, method: string) =>
98
+ async (...args: any[]) => {
99
+ const results = await Promise.all(sites.map((s: any) => s[method]?.(...args)));
100
+ return Object.fromEntries(sites.map((s: any, i) => [s._name ?? i, results[i]]));
101
+ },
102
+ }),
103
+
104
+ // ── Shutdown ─────────────────────────────────────────────────────────────────
105
+
106
+ close: async (opts?: { force?: boolean }) => {
107
+ stopServer();
108
+ if (opts?.force) {
109
+ for (const { client: c } of _extraProcs) c.disconnect();
110
+ _client?.disconnect();
111
+ killBrowser();
112
+ routeRegistry.clear();
113
+ keepAliveSites.clear();
114
+ } else {
115
+ for (const [name, site] of Object.entries(_sites)) {
116
+ if (!keepAliveSites.has(name)) await site.close?.();
117
+ }
118
+ if (keepAliveSites.size === 0) {
119
+ for (const { client: c } of _extraProcs) c.disconnect();
120
+ _extraProcs.length = 0;
121
+ _client?.disconnect();
122
+ _client = null;
123
+ setClient(null);
124
+ killBrowser();
125
+ }
126
+ }
127
+ logger.info("[piggy] closed");
128
+ },
129
+
130
+ detect: detectBinary,
131
+ logger,
132
+ };
133
+
134
+ export default piggy;
135
+ export { piggy };