@ulpi/browse 0.1.0
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/BENCHMARKS.md +222 -0
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/browse.ts +2 -0
- package/package.json +54 -0
- package/skill/SKILL.md +301 -0
- package/src/browser-manager.ts +687 -0
- package/src/buffers.ts +81 -0
- package/src/bun.d.ts +47 -0
- package/src/cli.ts +442 -0
- package/src/commands/meta.ts +358 -0
- package/src/commands/read.ts +304 -0
- package/src/commands/write.ts +259 -0
- package/src/constants.ts +12 -0
- package/src/diff.d.ts +12 -0
- package/src/install-skill.ts +98 -0
- package/src/server.ts +325 -0
- package/src/session-manager.ts +121 -0
- package/src/snapshot.ts +497 -0
- package/src/types.ts +12 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browse server — persistent Chromium daemon
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* Bun.serve HTTP on localhost → routes commands to Playwright
|
|
6
|
+
* Session multiplexing: multiple agents share one Chromium via X-Browse-Session header
|
|
7
|
+
* Console/network buffers: per-session in-memory + disk flush every 1s
|
|
8
|
+
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
|
|
9
|
+
* Auto-shutdown when all sessions idle past BROWSE_IDLE_TIMEOUT (default 30 min)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { chromium, type Browser } from 'playwright';
|
|
13
|
+
import { SessionManager, type Session } from './session-manager';
|
|
14
|
+
import { handleReadCommand } from './commands/read';
|
|
15
|
+
import { handleWriteCommand } from './commands/write';
|
|
16
|
+
import { handleMetaCommand } from './commands/meta';
|
|
17
|
+
import { DEFAULTS } from './constants';
|
|
18
|
+
import { type LogEntry, type NetworkEntry } from './buffers';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import * as crypto from 'crypto';
|
|
22
|
+
|
|
23
|
+
// Re-export types for backward compatibility
|
|
24
|
+
export { type LogEntry, type NetworkEntry };
|
|
25
|
+
|
|
26
|
+
// ─── Auth (inline) ─────────────────────────────────────────────
|
|
27
|
+
const AUTH_TOKEN = crypto.randomUUID();
|
|
28
|
+
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
|
|
29
|
+
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
|
30
|
+
const LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || '/tmp';
|
|
31
|
+
const STATE_FILE = process.env.BROWSE_STATE_FILE || `${LOCAL_DIR}/browse-server${INSTANCE_SUFFIX}.json`;
|
|
32
|
+
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || String(DEFAULTS.IDLE_TIMEOUT_MS), 10);
|
|
33
|
+
|
|
34
|
+
function validateAuth(req: Request): boolean {
|
|
35
|
+
const header = req.headers.get('authorization');
|
|
36
|
+
return header === `Bearer ${AUTH_TOKEN}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Per-Session Buffer Flush ──────────────────────────────────
|
|
40
|
+
// Flushes each session's buffers to separate log files on disk.
|
|
41
|
+
|
|
42
|
+
function flushAllBuffers(sessionManager: SessionManager, final = false) {
|
|
43
|
+
for (const session of sessionManager.getAllSessions()) {
|
|
44
|
+
flushSessionBuffers(session, final);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function flushSessionBuffers(session: Session, final: boolean) {
|
|
49
|
+
const suffix = session.id === 'default' ? INSTANCE_SUFFIX : `-${session.id}`;
|
|
50
|
+
const consolePath = `${LOCAL_DIR}/browse-console${suffix}.log`;
|
|
51
|
+
const networkPath = `${LOCAL_DIR}/browse-network${suffix}.log`;
|
|
52
|
+
const buffers = session.buffers;
|
|
53
|
+
|
|
54
|
+
// Console flush
|
|
55
|
+
const newConsoleCount = buffers.consoleTotalAdded - buffers.lastConsoleFlushed;
|
|
56
|
+
if (newConsoleCount > 0) {
|
|
57
|
+
const count = Math.min(newConsoleCount, buffers.consoleBuffer.length);
|
|
58
|
+
const newEntries = buffers.consoleBuffer.slice(-count);
|
|
59
|
+
const lines = newEntries.map(e =>
|
|
60
|
+
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
|
61
|
+
).join('\n') + '\n';
|
|
62
|
+
fs.appendFileSync(consolePath, lines);
|
|
63
|
+
buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Network flush
|
|
67
|
+
let newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
|
|
68
|
+
if (newNetworkCount > 0) {
|
|
69
|
+
if (newNetworkCount > buffers.networkBuffer.length) {
|
|
70
|
+
buffers.lastNetworkFlushed = buffers.networkTotalAdded - buffers.networkBuffer.length;
|
|
71
|
+
newNetworkCount = buffers.networkBuffer.length;
|
|
72
|
+
}
|
|
73
|
+
const newEntries = buffers.networkBuffer.slice(-newNetworkCount);
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
|
|
76
|
+
let prefixLen = 0;
|
|
77
|
+
for (let i = 0; i < newEntries.length; i++) {
|
|
78
|
+
const e = newEntries[i];
|
|
79
|
+
if (final || e.status !== undefined || (now - e.timestamp > DEFAULTS.NETWORK_SETTLE_MS)) {
|
|
80
|
+
prefixLen = i + 1;
|
|
81
|
+
} else {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (prefixLen > 0) {
|
|
87
|
+
const prefix = newEntries.slice(0, prefixLen);
|
|
88
|
+
const lines = prefix.map(e =>
|
|
89
|
+
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
|
90
|
+
).join('\n') + '\n';
|
|
91
|
+
fs.appendFileSync(networkPath, lines);
|
|
92
|
+
buffers.lastNetworkFlushed += prefixLen;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Server ────────────────────────────────────────────────────
|
|
98
|
+
let sessionManager: SessionManager;
|
|
99
|
+
let browser: Browser;
|
|
100
|
+
let isShuttingDown = false;
|
|
101
|
+
|
|
102
|
+
// Read/write/meta command sets for routing
|
|
103
|
+
const READ_COMMANDS = new Set([
|
|
104
|
+
'text', 'html', 'links', 'forms', 'accessibility',
|
|
105
|
+
'js', 'eval', 'css', 'attrs', 'state', 'dialog',
|
|
106
|
+
'console', 'network', 'cookies', 'storage', 'perf', 'devices',
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const WRITE_COMMANDS = new Set([
|
|
110
|
+
'goto', 'back', 'forward', 'reload',
|
|
111
|
+
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
|
112
|
+
'viewport', 'cookie', 'header', 'useragent',
|
|
113
|
+
'upload', 'dialog-accept', 'dialog-dismiss', 'emulate',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const META_COMMANDS = new Set([
|
|
117
|
+
'tabs', 'tab', 'newtab', 'closetab',
|
|
118
|
+
'status', 'stop', 'restart',
|
|
119
|
+
'screenshot', 'pdf', 'responsive',
|
|
120
|
+
'chain', 'diff',
|
|
121
|
+
'url', 'snapshot', 'snapshot-diff',
|
|
122
|
+
'sessions', 'session-close',
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
// Find port: use BROWSE_PORT or scan range
|
|
126
|
+
async function findPort(): Promise<number> {
|
|
127
|
+
if (BROWSE_PORT) {
|
|
128
|
+
try {
|
|
129
|
+
const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') });
|
|
130
|
+
testServer.stop();
|
|
131
|
+
return BROWSE_PORT;
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(`[browse] Port ${BROWSE_PORT} is in use`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Scan range
|
|
138
|
+
const start = parseInt(process.env.BROWSE_PORT_START || String(DEFAULTS.PORT_RANGE_START), 10);
|
|
139
|
+
const end = start + (DEFAULTS.PORT_RANGE_END - DEFAULTS.PORT_RANGE_START);
|
|
140
|
+
for (let port = start; port <= end; port++) {
|
|
141
|
+
try {
|
|
142
|
+
const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
|
|
143
|
+
testServer.stop();
|
|
144
|
+
return port;
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`[browse] No available port in range ${start}-${end}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function handleCommand(body: any, session: Session): Promise<Response> {
|
|
153
|
+
const { command, args = [] } = body;
|
|
154
|
+
|
|
155
|
+
if (!command) {
|
|
156
|
+
return new Response(JSON.stringify({ error: 'Missing "command" field' }), {
|
|
157
|
+
status: 400,
|
|
158
|
+
headers: { 'Content-Type': 'application/json' },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
let result: string;
|
|
164
|
+
|
|
165
|
+
if (READ_COMMANDS.has(command)) {
|
|
166
|
+
result = await handleReadCommand(command, args, session.manager, session.buffers);
|
|
167
|
+
} else if (WRITE_COMMANDS.has(command)) {
|
|
168
|
+
result = await handleWriteCommand(command, args, session.manager);
|
|
169
|
+
} else if (META_COMMANDS.has(command)) {
|
|
170
|
+
result = await handleMetaCommand(command, args, session.manager, shutdown, sessionManager, session);
|
|
171
|
+
} else {
|
|
172
|
+
return new Response(JSON.stringify({
|
|
173
|
+
error: `Unknown command: ${command}`,
|
|
174
|
+
hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
|
|
175
|
+
}), {
|
|
176
|
+
status: 400,
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return new Response(result, {
|
|
182
|
+
status: 200,
|
|
183
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
184
|
+
});
|
|
185
|
+
} catch (err: any) {
|
|
186
|
+
return new Response(JSON.stringify({ error: err.message }), {
|
|
187
|
+
status: 500,
|
|
188
|
+
headers: { 'Content-Type': 'application/json' },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function shutdown() {
|
|
194
|
+
if (isShuttingDown) return;
|
|
195
|
+
isShuttingDown = true;
|
|
196
|
+
|
|
197
|
+
console.log('[browse] Shutting down...');
|
|
198
|
+
clearInterval(flushInterval);
|
|
199
|
+
clearInterval(sessionCleanupInterval);
|
|
200
|
+
flushAllBuffers(sessionManager, true);
|
|
201
|
+
|
|
202
|
+
await sessionManager.closeAll();
|
|
203
|
+
|
|
204
|
+
// Close the shared browser
|
|
205
|
+
if (browser) {
|
|
206
|
+
browser.removeAllListeners('disconnected');
|
|
207
|
+
await browser.close().catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Only remove state file if it still belongs to this server instance.
|
|
211
|
+
try {
|
|
212
|
+
const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
213
|
+
if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
|
|
214
|
+
fs.unlinkSync(STATE_FILE);
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Handle signals
|
|
222
|
+
process.on('SIGTERM', shutdown);
|
|
223
|
+
process.on('SIGINT', shutdown);
|
|
224
|
+
|
|
225
|
+
// ─── Flush Timer ────────────────────────────────────────────────
|
|
226
|
+
const flushInterval = setInterval(() => {
|
|
227
|
+
if (sessionManager) flushAllBuffers(sessionManager);
|
|
228
|
+
}, DEFAULTS.BUFFER_FLUSH_INTERVAL_MS);
|
|
229
|
+
|
|
230
|
+
// ─── Session Idle Cleanup ───────────────────────────────────────
|
|
231
|
+
const sessionCleanupInterval = setInterval(async () => {
|
|
232
|
+
if (!sessionManager || isShuttingDown) return;
|
|
233
|
+
|
|
234
|
+
const closed = await sessionManager.closeIdleSessions(IDLE_TIMEOUT_MS);
|
|
235
|
+
for (const id of closed) {
|
|
236
|
+
console.log(`[browse] Session "${id}" idle for ${IDLE_TIMEOUT_MS / 1000}s — closed`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (sessionManager.getSessionCount() === 0) {
|
|
240
|
+
console.log('[browse] All sessions idle — shutting down');
|
|
241
|
+
shutdown();
|
|
242
|
+
}
|
|
243
|
+
}, 60_000);
|
|
244
|
+
|
|
245
|
+
// ─── Start ─────────────────────────────────────────────────────
|
|
246
|
+
async function start() {
|
|
247
|
+
const port = await findPort();
|
|
248
|
+
|
|
249
|
+
// Launch shared Chromium
|
|
250
|
+
browser = await chromium.launch({ headless: true });
|
|
251
|
+
|
|
252
|
+
// Chromium crash → flush, cleanup, exit
|
|
253
|
+
browser.on('disconnected', () => {
|
|
254
|
+
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
|
255
|
+
if (sessionManager) flushAllBuffers(sessionManager, true);
|
|
256
|
+
try {
|
|
257
|
+
const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
258
|
+
if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
|
|
259
|
+
fs.unlinkSync(STATE_FILE);
|
|
260
|
+
}
|
|
261
|
+
} catch {}
|
|
262
|
+
process.exit(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Create session manager
|
|
266
|
+
sessionManager = new SessionManager(browser);
|
|
267
|
+
|
|
268
|
+
const startTime = Date.now();
|
|
269
|
+
const server = Bun.serve({
|
|
270
|
+
port,
|
|
271
|
+
hostname: '127.0.0.1',
|
|
272
|
+
fetch: async (req) => {
|
|
273
|
+
const url = new URL(req.url);
|
|
274
|
+
|
|
275
|
+
// Health check — no auth required
|
|
276
|
+
if (url.pathname === '/health') {
|
|
277
|
+
const healthy = browser.isConnected();
|
|
278
|
+
return new Response(JSON.stringify({
|
|
279
|
+
status: healthy ? 'healthy' : 'unhealthy',
|
|
280
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
281
|
+
sessions: sessionManager.getSessionCount(),
|
|
282
|
+
}), {
|
|
283
|
+
status: 200,
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// All other endpoints require auth
|
|
289
|
+
if (!validateAuth(req)) {
|
|
290
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
291
|
+
status: 401,
|
|
292
|
+
headers: { 'Content-Type': 'application/json' },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (url.pathname === '/command' && req.method === 'POST') {
|
|
297
|
+
const body = await req.json();
|
|
298
|
+
const sessionId = req.headers.get('x-browse-session') || 'default';
|
|
299
|
+
const session = await sessionManager.getOrCreate(sessionId);
|
|
300
|
+
return handleCommand(body, session);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return new Response('Not found', { status: 404 });
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Write state file
|
|
308
|
+
const state = {
|
|
309
|
+
pid: process.pid,
|
|
310
|
+
port,
|
|
311
|
+
token: AUTH_TOKEN,
|
|
312
|
+
startedAt: new Date().toISOString(),
|
|
313
|
+
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
|
314
|
+
};
|
|
315
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
316
|
+
|
|
317
|
+
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
|
318
|
+
console.log(`[browse] State file: ${STATE_FILE}`);
|
|
319
|
+
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
start().catch((err) => {
|
|
323
|
+
console.error(`[browse] Failed to start: ${err.message}`);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager — multiplexes multiple agents on a single Chromium instance
|
|
3
|
+
*
|
|
4
|
+
* Each session gets its own BrowserManager (tabs, refs, cookies, storage)
|
|
5
|
+
* backed by an isolated BrowserContext on the shared Browser.
|
|
6
|
+
* Sessions are identified by string IDs (from X-Browse-Session header).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Browser } from 'playwright';
|
|
10
|
+
import { BrowserManager } from './browser-manager';
|
|
11
|
+
import { SessionBuffers } from './buffers';
|
|
12
|
+
|
|
13
|
+
export interface Session {
|
|
14
|
+
id: string;
|
|
15
|
+
manager: BrowserManager;
|
|
16
|
+
buffers: SessionBuffers;
|
|
17
|
+
lastActivity: number;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SessionManager {
|
|
22
|
+
private sessions = new Map<string, Session>();
|
|
23
|
+
private browser: Browser;
|
|
24
|
+
|
|
25
|
+
constructor(browser: Browser) {
|
|
26
|
+
this.browser = browser;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get an existing session or create a new one.
|
|
31
|
+
* Creating a session launches a new BrowserContext on the shared Chromium.
|
|
32
|
+
*/
|
|
33
|
+
async getOrCreate(sessionId: string): Promise<Session> {
|
|
34
|
+
let session = this.sessions.get(sessionId);
|
|
35
|
+
if (session) {
|
|
36
|
+
session.lastActivity = Date.now();
|
|
37
|
+
return session;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const buffers = new SessionBuffers();
|
|
41
|
+
const manager = new BrowserManager(buffers);
|
|
42
|
+
await manager.launchWithBrowser(this.browser);
|
|
43
|
+
|
|
44
|
+
session = {
|
|
45
|
+
id: sessionId,
|
|
46
|
+
manager,
|
|
47
|
+
buffers,
|
|
48
|
+
lastActivity: Date.now(),
|
|
49
|
+
createdAt: Date.now(),
|
|
50
|
+
};
|
|
51
|
+
this.sessions.set(sessionId, session);
|
|
52
|
+
console.log(`[browse] Session "${sessionId}" created`);
|
|
53
|
+
return session;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Close and remove a specific session.
|
|
58
|
+
*/
|
|
59
|
+
async closeSession(sessionId: string): Promise<void> {
|
|
60
|
+
const session = this.sessions.get(sessionId);
|
|
61
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
62
|
+
|
|
63
|
+
await session.manager.close();
|
|
64
|
+
this.sessions.delete(sessionId);
|
|
65
|
+
console.log(`[browse] Session "${sessionId}" closed`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Close sessions idle longer than maxIdleMs.
|
|
70
|
+
* Returns list of closed session IDs.
|
|
71
|
+
*/
|
|
72
|
+
async closeIdleSessions(maxIdleMs: number): Promise<string[]> {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const closed: string[] = [];
|
|
75
|
+
|
|
76
|
+
for (const [id, session] of this.sessions) {
|
|
77
|
+
if (now - session.lastActivity > maxIdleMs) {
|
|
78
|
+
await session.manager.close().catch(() => {});
|
|
79
|
+
this.sessions.delete(id);
|
|
80
|
+
closed.push(id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return closed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List all active sessions (for status/sessions commands).
|
|
89
|
+
*/
|
|
90
|
+
listSessions(): Array<{ id: string; tabs: number; url: string; idleSeconds: number; active: boolean }> {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
return [...this.sessions.entries()].map(([id, session]) => ({
|
|
93
|
+
id,
|
|
94
|
+
tabs: session.manager.getTabCount(),
|
|
95
|
+
url: session.manager.getCurrentUrl(),
|
|
96
|
+
idleSeconds: Math.floor((now - session.lastActivity) / 1000),
|
|
97
|
+
active: true,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get all sessions (for buffer flush iteration).
|
|
103
|
+
*/
|
|
104
|
+
getAllSessions(): Session[] {
|
|
105
|
+
return [...this.sessions.values()];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getSessionCount(): number {
|
|
109
|
+
return this.sessions.size;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Close all sessions (for server shutdown).
|
|
114
|
+
*/
|
|
115
|
+
async closeAll(): Promise<void> {
|
|
116
|
+
for (const [id, session] of this.sessions) {
|
|
117
|
+
await session.manager.close().catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
this.sessions.clear();
|
|
120
|
+
}
|
|
121
|
+
}
|