nothing-browser 0.0.17 → 0.0.18

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.
@@ -5,78 +5,221 @@ import { dirname } from "path";
5
5
  import { platform } from "os";
6
6
  import logger from "../logger";
7
7
 
8
- const SOCKET_PATH = platform() === 'win32'
9
- ? '\\\\.\\pipe\\piggy'
10
- : '/tmp/piggy';
8
+ const DEFAULT_SOCKET_PATH = platform() === "win32"
9
+ ? "\\\\.\\pipe\\piggy"
10
+ : "/tmp/piggy";
11
+
12
+ // ── Transport interface ────────────────────────────────────────────────────────
13
+
14
+ interface Transport {
15
+ send(data: string): void;
16
+ on(event: "data", handler: (chunk: string) => void): void;
17
+ on(event: "error", handler: (e: Error) => void): void;
18
+ on(event: "close", handler: () => void): void;
19
+ destroy(): void;
20
+ }
21
+
22
+ // ── Socket transport ───────────────────────────────────────────────────────────
23
+
24
+ class SocketTransport implements Transport {
25
+ private sock: Socket;
26
+
27
+ constructor(sock: Socket) {
28
+ this.sock = sock;
29
+ }
30
+
31
+ send(data: string) {
32
+ this.sock.write(data);
33
+ }
34
+
35
+ on(event: string, handler: any) {
36
+ this.sock.on(event, handler);
37
+ }
38
+
39
+ destroy() {
40
+ this.sock.destroy();
41
+ }
42
+ }
43
+
44
+ // ── HTTP transport ─────────────────────────────────────────────────────────────
45
+
46
+ class HttpTransport implements Transport {
47
+ private host: string;
48
+ private key: string;
49
+ private dataHandlers: ((chunk: string) => void)[] = [];
50
+ private errorHandlers: ((e: Error) => void)[] = [];
51
+ private closeHandlers: (() => void)[] = [];
52
+ private _destroyed = false;
53
+
54
+ constructor(host: string, key: string) {
55
+ this.host = host.replace(/\/$/, "");
56
+ this.key = key;
57
+ }
58
+
59
+ on(event: string, handler: any) {
60
+ if (event === "data") this.dataHandlers.push(handler);
61
+ if (event === "error") this.errorHandlers.push(handler);
62
+ if (event === "close") this.closeHandlers.push(handler);
63
+ }
64
+
65
+ send(data: string) {
66
+ if (this._destroyed) return;
67
+
68
+ fetch(this.host, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ "X-Piggy-Key": this.key,
73
+ },
74
+ body: data,
75
+ })
76
+ .then(async (res) => {
77
+ if (!res.ok) {
78
+ const text = await res.text().catch(() => `HTTP ${res.status}`);
79
+ this.errorHandlers.forEach(h =>
80
+ h(new Error(`HTTP ${res.status}: ${text}`))
81
+ );
82
+ return;
83
+ }
84
+ const text = await res.text();
85
+ const lines = text.split("\n").filter(l => l.trim());
86
+ for (const line of lines) {
87
+ this.dataHandlers.forEach(h => h(line + "\n"));
88
+ }
89
+ })
90
+ .catch((e: Error) => {
91
+ if (!this._destroyed) {
92
+ this.errorHandlers.forEach(h => h(e));
93
+ }
94
+ });
95
+ }
96
+
97
+ destroy() {
98
+ this._destroyed = true;
99
+ this.closeHandlers.forEach(h => h());
100
+ }
101
+ }
102
+
103
+ // ── PiggyClient ────────────────────────────────────────────────────────────────
11
104
 
12
105
  export class PiggyClient {
13
106
  private socketPath: string;
14
- private socket: Socket | null = null;
107
+ private httpHost: string | null = null;
108
+ private httpKey: string | null = null;
109
+ private transport: Transport | null = null;
15
110
  private reqId = 0;
16
111
  private pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
17
112
  private buf = "";
18
- private eventBuffer = "";
19
113
  private eventHandlers = new Map<string, Map<string, (data: any) => Promise<any>>>();
20
114
  private globalEventHandlers = new Map<string, Set<(data: any) => void>>();
21
115
 
22
- constructor(socketPath = SOCKET_PATH) {
23
- this.socketPath = socketPath;
116
+ constructor(socketPath?: string);
117
+ constructor(opts: { host: string; key: string });
118
+
119
+ constructor(arg?: string | { host: string; key: string }) {
120
+ if (arg && typeof arg === "object") {
121
+ this.socketPath = "";
122
+ this.httpHost = arg.host.replace(/\/$/, "");
123
+ this.httpKey = arg.key;
124
+ } else {
125
+ this.socketPath = (arg as string | undefined) ?? DEFAULT_SOCKET_PATH;
126
+ }
24
127
  this.eventHandlers.set("default", new Map());
25
128
  }
26
129
 
130
+ // ── Connect ───────────────────────────────────────────────────────────────
131
+
27
132
  connect(): Promise<void> {
133
+ if (this.httpHost) return this._connectHttp();
134
+ return this._connectSocket();
135
+ }
136
+
137
+ private _connectSocket(): Promise<void> {
28
138
  return new Promise((resolve, reject) => {
29
139
  logger.info(`Connecting to socket: ${this.socketPath}`);
30
140
  const sock = connect(this.socketPath);
31
141
  sock.setEncoding("utf8");
32
142
 
33
143
  sock.on("connect", () => {
34
- this.socket = sock;
35
- logger.success("Connected to Piggy server");
144
+ this.transport = new SocketTransport(sock);
145
+ this._wireTransport();
146
+ logger.success("Connected to Piggy server (socket)");
36
147
  resolve();
37
148
  });
38
149
 
39
- sock.on("data", (chunk: string) => {
40
- this.eventBuffer += chunk;
41
- const lines = this.eventBuffer.split("\n");
42
- this.eventBuffer = lines.pop()!;
43
-
44
- for (const line of lines) {
45
- if (!line.trim()) continue;
46
- try {
47
- const msg = JSON.parse(line);
48
-
49
- if (msg.type === "event") {
50
- this.handleEvent(msg);
51
- continue;
52
- }
53
-
54
- const p = this.pending.get(msg.id);
55
- if (p) {
56
- this.pending.delete(msg.id);
57
- msg.ok ? p.resolve(msg.data) : p.reject(new Error(msg.data ?? "command failed"));
58
- }
59
- } catch {
60
- logger.error(`Bad JSON from server: ${line}`);
61
- }
62
- }
63
- });
64
-
65
150
  sock.on("error", (e) => {
66
151
  for (const p of this.pending.values()) p.reject(e);
67
152
  this.pending.clear();
68
153
  reject(e);
69
154
  });
155
+ });
156
+ }
70
157
 
71
- sock.on("close", () => {
72
- for (const p of this.pending.values()) p.reject(new Error("Socket closed"));
73
- this.pending.clear();
158
+ private async _connectHttp(): Promise<void> {
159
+ logger.info(`Connecting to Piggy server (HTTP): ${this.httpHost}`);
160
+ try {
161
+ const res = await fetch(this.httpHost!, {
162
+ method: "POST",
163
+ headers: {
164
+ "Content-Type": "application/json",
165
+ "X-Piggy-Key": this.httpKey!,
166
+ },
167
+ body: "hello",
74
168
  });
169
+ if (res.status === 401) {
170
+ throw new Error("Unauthorized — invalid X-Piggy-Key");
171
+ }
172
+ this.transport = new HttpTransport(this.httpHost!, this.httpKey!);
173
+ this._wireTransport();
174
+ logger.success(`Connected to Piggy server (HTTP): ${this.httpHost}`);
175
+ } catch (e: any) {
176
+ throw new Error(`Failed to connect to Piggy HTTP server: ${e.message}`);
177
+ }
178
+ }
179
+
180
+ private _wireTransport() {
181
+ if (!this.transport) return;
182
+
183
+ this.transport.on("data", (chunk: string) => {
184
+ this.buf += chunk;
185
+ const lines = this.buf.split("\n");
186
+ this.buf = lines.pop()!;
187
+
188
+ for (const line of lines) {
189
+ if (!line.trim()) continue;
190
+ try {
191
+ const msg = JSON.parse(line);
192
+
193
+ if (msg.type === "event") {
194
+ this.handleEvent(msg);
195
+ continue;
196
+ }
197
+
198
+ const p = this.pending.get(msg.id);
199
+ if (p) {
200
+ this.pending.delete(msg.id);
201
+ msg.ok ? p.resolve(msg.data) : p.reject(new Error(msg.data ?? "command failed"));
202
+ }
203
+ } catch {
204
+ logger.error(`Bad JSON from server: ${line}`);
205
+ }
206
+ }
207
+ });
208
+
209
+ this.transport.on("error", (e: Error) => {
210
+ for (const p of this.pending.values()) p.reject(e);
211
+ this.pending.clear();
212
+ });
213
+
214
+ this.transport.on("close", () => {
215
+ for (const p of this.pending.values()) p.reject(new Error("Connection closed"));
216
+ this.pending.clear();
75
217
  });
76
218
  }
77
219
 
220
+ // ── Event handling ────────────────────────────────────────────────────────
221
+
78
222
  private handleEvent(event: any) {
79
- // ── exposed_call ──────────────────────────────────────────────────────────
80
223
  if (event.event === "exposed_call") {
81
224
  const { tabId, name, callId, data } = event;
82
225
  const effectiveTabId = tabId || "default";
@@ -84,12 +227,11 @@ export class PiggyClient {
84
227
  const handler = handlers?.get(name);
85
228
 
86
229
  if (handler) {
87
- // ✅ FIX: data can be a plain string (e.g., "OPENING") or JSON
88
- let parsedData;
230
+ let parsedData: any;
89
231
  try {
90
232
  parsedData = JSON.parse(data || "null");
91
233
  } catch {
92
- parsedData = data; // fallback to raw string
234
+ parsedData = data;
93
235
  }
94
236
 
95
237
  Promise.resolve(handler(parsedData))
@@ -99,14 +241,14 @@ export class PiggyClient {
99
241
  tabId: effectiveTabId,
100
242
  callId,
101
243
  result: response.success ? JSON.stringify(response.result) : (response.error || "Unknown error"),
102
- isError: !response.success
244
+ isError: !response.success,
103
245
  }).catch(e => logger.error(`Failed to send exposed result: ${e}`));
104
246
  } else {
105
247
  this.send("exposed.result", {
106
248
  tabId: effectiveTabId,
107
249
  callId,
108
250
  result: JSON.stringify(response),
109
- isError: false
251
+ isError: false,
110
252
  }).catch(e => logger.error(`Failed to send exposed result: ${e}`));
111
253
  }
112
254
  })
@@ -115,7 +257,7 @@ export class PiggyClient {
115
257
  tabId: effectiveTabId,
116
258
  callId,
117
259
  result: err.message || "Handler error",
118
- isError: true
260
+ isError: true,
119
261
  }).catch(e => logger.error(`Failed to send exposed error: ${e}`));
120
262
  });
121
263
  } else {
@@ -124,7 +266,6 @@ export class PiggyClient {
124
266
  return;
125
267
  }
126
268
 
127
- // ── navigate ──────────────────────────────────────────────────────────────
128
269
  if (event.event === "navigate") {
129
270
  const handlers = this.globalEventHandlers.get(`navigate:${event.tabId}`);
130
271
  if (handlers) {
@@ -132,39 +273,37 @@ export class PiggyClient {
132
273
  try { h(event.url); } catch (e) { logger.error(`navigate handler error: ${e}`); }
133
274
  }
134
275
  }
135
- // Also fire wildcard listeners (no tabId filter)
136
276
  const wildcard = this.globalEventHandlers.get("navigate:*");
137
277
  if (wildcard) {
138
278
  for (const h of wildcard) {
139
279
  try { h({ url: event.url, tabId: event.tabId }); } catch {}
140
280
  }
141
281
  }
142
- return;
143
282
  }
144
283
  }
145
284
 
146
- // ── Global event subscription ─────────────────────────────────────────────
147
285
  onEvent(eventName: string, tabId: string, handler: (data: any) => void): () => void {
148
286
  const key = `${eventName}:${tabId}`;
149
287
  if (!this.globalEventHandlers.has(key)) {
150
288
  this.globalEventHandlers.set(key, new Set());
151
289
  }
152
290
  this.globalEventHandlers.get(key)!.add(handler);
153
- // Return unsubscribe fn
154
291
  return () => this.globalEventHandlers.get(key)?.delete(handler);
155
292
  }
156
293
 
157
294
  disconnect() {
158
- this.socket?.destroy();
159
- this.socket = null;
295
+ this.transport?.destroy();
296
+ this.transport = null;
160
297
  }
161
298
 
299
+ // ── Core send ─────────────────────────────────────────────────────────────
300
+
162
301
  send<T = any>(cmd: string, payload: Record<string, any> = {}): Promise<T> {
163
302
  return new Promise((resolve, reject) => {
164
- if (!this.socket) return reject(new Error("Not connected"));
303
+ if (!this.transport) return reject(new Error("Not connected"));
165
304
  const id = String(++this.reqId);
166
305
  this.pending.set(id, { resolve, reject });
167
- this.socket.write(JSON.stringify({ id, cmd, payload }) + "\n");
306
+ this.transport.send(JSON.stringify({ id, cmd, payload }) + "\n");
168
307
  });
169
308
  }
170
309
 
@@ -268,6 +407,56 @@ export class PiggyClient {
268
407
  async sessionExport(tabId = "default"): Promise<any> { return this.send("session.export", { tabId }); }
269
408
  async sessionImport(data: any, tabId = "default"): Promise<void> { await this.send("session.import", { data, tabId }); }
270
409
 
410
+ // ── Session persistence (opt-in) ──────────────────────────────────────────
411
+ // WS frames and pings are NOT saved by default — you must opt in.
412
+ // Files are written to cwd (same folder as cookies.json / profile.json).
413
+
414
+ /** Enable or disable saving WebSocket frames to ws.json in cwd */
415
+ async sessionWsSave(enabled = true): Promise<void> {
416
+ await this.send("session.ws.save", { enabled });
417
+ }
418
+
419
+ /** Enable or disable saving ping log to pings.json in cwd */
420
+ async sessionPingsSave(enabled = true): Promise<void> {
421
+ await this.send("session.pings.save", { enabled });
422
+ }
423
+
424
+ /** Get all data file paths for the current session */
425
+ async sessionPaths(): Promise<{
426
+ workDir: string;
427
+ cookies: string;
428
+ profile: string;
429
+ ws: string;
430
+ pings: string;
431
+ }> {
432
+ return this.send("session.paths", {});
433
+ }
434
+
435
+ /** Get path to cookies.json */
436
+ async sessionCookiesPath(): Promise<string> {
437
+ return this.send("session.cookies.path", {});
438
+ }
439
+
440
+ /** Get path to profile.json */
441
+ async sessionProfilePath(): Promise<string> {
442
+ return this.send("session.profile.path", {});
443
+ }
444
+
445
+ /** Get path to ws.json */
446
+ async sessionWsPath(): Promise<string> {
447
+ return this.send("session.ws.path", {});
448
+ }
449
+
450
+ /** Get path to pings.json */
451
+ async sessionPingsPath(): Promise<string> {
452
+ return this.send("session.pings.path", {});
453
+ }
454
+
455
+ /** Reload cookies.json and profile.json from disk without restarting */
456
+ async sessionReload(): Promise<void> {
457
+ await this.send("session.reload", {});
458
+ }
459
+
271
460
  // ── Expose Function ───────────────────────────────────────────────────────
272
461
  async exposeFunction(name: string, handler: (data: any) => Promise<any> | any, tabId = "default"): Promise<void> {
273
462
  if (!this.eventHandlers.has(tabId)) this.eventHandlers.set(tabId, new Map());
@@ -294,4 +483,86 @@ export class PiggyClient {
294
483
  this.eventHandlers.set(tabId, new Map());
295
484
  logger.info(`[${tabId}] cleared all exposed functions`);
296
485
  }
486
+
487
+ // ── Proxy ─────────────────────────────────────────────────────────────────
488
+
489
+ async proxyLoad(path: string): Promise<void> {
490
+ await this.send("proxy.load", { path });
491
+ }
492
+
493
+ async proxyFetch(url: string): Promise<void> {
494
+ await this.send("proxy.fetch", { url });
495
+ }
496
+
497
+ async proxyOvpn(path: string): Promise<void> {
498
+ await this.send("proxy.ovpn", { path });
499
+ }
500
+
501
+ async proxySet(opts: {
502
+ host?: string;
503
+ port?: number;
504
+ type?: "http" | "https" | "socks5" | "socks4";
505
+ user?: string;
506
+ pass?: string;
507
+ proxy?: string;
508
+ }): Promise<void> {
509
+ await this.send("proxy.set", opts as Record<string, any>);
510
+ }
511
+
512
+ async proxyTest(): Promise<void> {
513
+ await this.send("proxy.test", {});
514
+ }
515
+
516
+ async proxyTestStop(): Promise<void> {
517
+ await this.send("proxy.test.stop", {});
518
+ }
519
+
520
+ async proxyNext(): Promise<void> {
521
+ await this.send("proxy.next", {});
522
+ }
523
+
524
+ async proxyDisable(): Promise<void> {
525
+ await this.send("proxy.disable", {});
526
+ }
527
+
528
+ async proxyEnable(): Promise<void> {
529
+ await this.send("proxy.enable", {});
530
+ }
531
+
532
+ async proxyCurrent(): Promise<{
533
+ host: string; port: number; type: string;
534
+ user?: string; alive: boolean; latencyMs?: number;
535
+ }> {
536
+ return this.send("proxy.current", {});
537
+ }
538
+
539
+ async proxyStats(): Promise<{
540
+ total: number; alive: number; dead: number;
541
+ index: number; checking: boolean;
542
+ }> {
543
+ return this.send("proxy.stats", {});
544
+ }
545
+
546
+ async proxyList(limit?: number): Promise<{
547
+ host: string; port: number; type: string;
548
+ alive: boolean; latencyMs?: number;
549
+ }[]> {
550
+ return this.send("proxy.list", limit !== undefined ? { limit } : {});
551
+ }
552
+
553
+ async proxyRotation(mode: "none" | "timed" | "perrequest", interval?: number): Promise<void> {
554
+ await this.send("proxy.rotation", { mode, ...(interval !== undefined ? { interval } : {}) });
555
+ }
556
+
557
+ async proxyConfig(opts: { skipDead?: boolean; autoCheck?: boolean }): Promise<void> {
558
+ await this.send("proxy.config", opts as Record<string, any>);
559
+ }
560
+
561
+ async proxySave(path: string, filter: "alive" | "dead" | "all" = "all"): Promise<void> {
562
+ await this.send("proxy.save", { path, filter });
563
+ }
564
+
565
+ onProxyEvent(event: string, handler: (data: any) => void): () => void {
566
+ return this.onEvent(event, "*", handler);
567
+ }
297
568
  }
package/piggy.ts CHANGED
@@ -15,8 +15,7 @@ const _extraProcs: { socket: string; client: PiggyClient }[] = [];
15
15
  const _sites: Record<string, SiteObject> = [];
16
16
 
17
17
  const piggy: any = {
18
- // ── Lifecycle ─────────────────────────────────────────────────────────────
19
-
18
+ // ── Local launch (socket) ─────────────────────────────────────────────────
20
19
  launch: async (opts?: { mode?: TabMode; binary?: BinaryMode }) => {
21
20
  _tabMode = opts?.mode ?? "tab";
22
21
  const binaryMode: BinaryMode = opts?.binary ?? "headless";
@@ -29,12 +28,23 @@ const piggy: any = {
29
28
  return piggy;
30
29
  },
31
30
 
31
+ // ── Remote connect (HTTP) ─────────────────────────────────────────────────
32
+ connect: async (opts: { host: string; key: string }) => {
33
+ _tabMode = "tab";
34
+ _client = new PiggyClient({ host: opts.host, key: opts.key });
35
+ await _client.connect();
36
+ setClient(_client);
37
+ logger.info(`[piggy] connected (HTTP) → ${opts.host}`);
38
+ return piggy;
39
+ },
40
+
41
+ // ── Register ──────────────────────────────────────────────────────────────
32
42
  register: async (
33
43
  name: string,
34
44
  url: string,
35
45
  opts?: {
36
46
  binary?: BinaryMode;
37
- pool?: number; // number of tabs to pool — enables concurrent requests
47
+ pool?: number;
38
48
  }
39
49
  ) => {
40
50
  if (!url?.trim()) throw new Error(`No URL for site "${name}"`);
@@ -42,21 +52,16 @@ const piggy: any = {
42
52
  const poolSize = opts?.pool ?? 0;
43
53
 
44
54
  if (_tabMode === "tab") {
45
- if (!_client) throw new Error("No client. Call piggy.launch() first.");
55
+ if (!_client) throw new Error("No client. Call piggy.launch() or piggy.connect() first.");
46
56
 
47
57
  if (poolSize > 1) {
48
- // Pool mode — create N tabs, wrap in TabPool
49
58
  const pool = new TabPool(_client, poolSize, url, name);
50
59
  await pool.init();
51
-
52
- // Use first tab as the "default" tabId for event subscriptions
53
- const firstTabId = "default";
54
- const siteObj = createSiteObject(name, url, _client, firstTabId, pool);
60
+ const siteObj = createSiteObject(name, url, _client, "default", pool);
55
61
  _sites[name] = siteObj;
56
62
  piggy[name] = siteObj;
57
63
  logger.success(`[${name}] registered with pool of ${poolSize} tabs`);
58
64
  } else {
59
- // Single tab mode
60
65
  const tabId = await _client.newTab();
61
66
  const siteObj = createSiteObject(name, url, _client, tabId);
62
67
  _sites[name] = siteObj;
@@ -80,7 +85,6 @@ const piggy: any = {
80
85
  },
81
86
 
82
87
  // ── Global controls ───────────────────────────────────────────────────────
83
-
84
88
  actHuman: (enable: boolean) => {
85
89
  setHumanMode(enable);
86
90
  logger.info(`[piggy] actHuman: ${enable}`);
@@ -90,23 +94,41 @@ const piggy: any = {
90
94
  mode: (m: TabMode) => { _tabMode = m; return piggy; },
91
95
 
92
96
  // ── Expose Function ───────────────────────────────────────────────────────
93
-
94
97
  expose: async (name: string, handler: (data: any) => Promise<any> | any, tabId = "default") => {
95
- if (!_client) throw new Error("No client. Call piggy.launch() first.");
98
+ if (!_client) throw new Error("No client. Call piggy.launch() or piggy.connect() first.");
96
99
  await _client.exposeFunction(name, handler, tabId);
97
100
  logger.success(`[piggy] exposed global function: ${name}`);
98
101
  return piggy;
99
102
  },
100
103
 
101
104
  unexpose: async (name: string, tabId = "default") => {
102
- if (!_client) throw new Error("No client. Call piggy.launch() first.");
105
+ if (!_client) throw new Error("No client. Call piggy.launch() or piggy.connect() first.");
103
106
  await _client.unexposeFunction(name, tabId);
104
107
  logger.info(`[piggy] unexposed function: ${name}`);
105
108
  return piggy;
106
109
  },
107
110
 
108
- // ── Elysia server ─────────────────────────────────────────────────────────
111
+ // ── Proxy ─────────────────────────────────────────────────────────────────
112
+ proxy: {
113
+ load: (path: string) => { if (!_client) throw new Error("No client"); return _client.proxyLoad(path); },
114
+ fetch: (url: string) => { if (!_client) throw new Error("No client"); return _client.proxyFetch(url); },
115
+ ovpn: (path: string) => { if (!_client) throw new Error("No client"); return _client.proxyOvpn(path); },
116
+ set: (opts: Parameters<PiggyClient["proxySet"]>[0]) => { if (!_client) throw new Error("No client"); return _client.proxySet(opts); },
117
+ test: () => { if (!_client) throw new Error("No client"); return _client.proxyTest(); },
118
+ testStop: () => { if (!_client) throw new Error("No client"); return _client.proxyTestStop(); },
119
+ next: () => { if (!_client) throw new Error("No client"); return _client.proxyNext(); },
120
+ disable: () => { if (!_client) throw new Error("No client"); return _client.proxyDisable(); },
121
+ enable: () => { if (!_client) throw new Error("No client"); return _client.proxyEnable(); },
122
+ current: () => { if (!_client) throw new Error("No client"); return _client.proxyCurrent(); },
123
+ stats: () => { if (!_client) throw new Error("No client"); return _client.proxyStats(); },
124
+ list: (limit?: number) => { if (!_client) throw new Error("No client"); return _client.proxyList(limit); },
125
+ rotation: (mode: "none" | "timed" | "perrequest", interval?: number) => { if (!_client) throw new Error("No client"); return _client.proxyRotation(mode, interval); },
126
+ config: (opts: { skipDead?: boolean; autoCheck?: boolean }) => { if (!_client) throw new Error("No client"); return _client.proxyConfig(opts); },
127
+ save: (path: string, filter?: "alive" | "dead" | "all") => { if (!_client) throw new Error("No client"); return _client.proxySave(path, filter); },
128
+ on: (event: string, handler: (data: any) => void) => { if (!_client) throw new Error("No client"); return _client.onProxyEvent(event, handler); },
129
+ },
109
130
 
131
+ // ── Elysia server ─────────────────────────────────────────────────────────
110
132
  serve: (
111
133
  port: number,
112
134
  opts?: {
@@ -121,7 +143,6 @@ const piggy: any = {
121
143
  stopServer,
122
144
 
123
145
  // ── Route listing ─────────────────────────────────────────────────────────
124
-
125
146
  routes: () =>
126
147
  Array.from(routeRegistry.entries()).map(([key, cfg]) => {
127
148
  const [site] = key.split(":");
@@ -135,7 +156,6 @@ const piggy: any = {
135
156
  }),
136
157
 
137
158
  // ── Multi-site ────────────────────────────────────────────────────────────
138
-
139
159
  all: (sites: SiteObject[]) =>
140
160
  new Proxy({} as any, {
141
161
  get: (_, method: string) =>
@@ -152,7 +172,6 @@ const piggy: any = {
152
172
  }),
153
173
 
154
174
  // ── Shutdown ──────────────────────────────────────────────────────────────
155
-
156
175
  close: async (opts?: { force?: boolean }) => {
157
176
  stopServer();
158
177
  if (opts?.force) {