@ulpi/browse 0.10.0 → 1.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.
- package/README.md +6 -6
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +2 -3
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -196
- package/src/browser-manager.ts +0 -976
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -65
- package/src/chrome-discover.ts +0 -73
- package/src/cli.ts +0 -783
- package/src/commands/meta.ts +0 -986
- package/src/commands/read.ts +0 -375
- package/src/commands/write.ts +0 -704
- package/src/config.ts +0 -44
- package/src/constants.ts +0 -14
- package/src/cookie-import.ts +0 -410
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- package/src/encryption.ts +0 -48
- package/src/har.ts +0 -66
- package/src/install-skill.ts +0 -98
- package/src/png-compare.ts +0 -247
- package/src/policy.ts +0 -94
- package/src/rebrowser.d.ts +0 -7
- package/src/record-export.ts +0 -98
- package/src/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -526
- package/src/session-manager.ts +0 -240
- package/src/session-persist.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
package/src/session-manager.ts
DELETED
|
@@ -1,240 +0,0 @@
|
|
|
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
|
-
import { DomainFilter } from './domain-filter';
|
|
13
|
-
import { sanitizeName } from './sanitize';
|
|
14
|
-
import { saveSessionState, loadSessionState, hasPersistedState } from './session-persist';
|
|
15
|
-
import { resolveEncryptionKey } from './encryption';
|
|
16
|
-
import * as fs from 'fs';
|
|
17
|
-
import * as path from 'path';
|
|
18
|
-
|
|
19
|
-
export interface RecordedStep {
|
|
20
|
-
command: string;
|
|
21
|
-
args: string[];
|
|
22
|
-
timestamp: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface Session {
|
|
26
|
-
id: string;
|
|
27
|
-
manager: BrowserManager;
|
|
28
|
-
buffers: SessionBuffers;
|
|
29
|
-
domainFilter: DomainFilter | null;
|
|
30
|
-
recording: RecordedStep[] | null;
|
|
31
|
-
outputDir: string;
|
|
32
|
-
lastActivity: number;
|
|
33
|
-
createdAt: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class SessionManager {
|
|
37
|
-
private sessions = new Map<string, Session>();
|
|
38
|
-
private browser: Browser;
|
|
39
|
-
private localDir: string;
|
|
40
|
-
private encryptionKey: Buffer | undefined;
|
|
41
|
-
|
|
42
|
-
constructor(browser: Browser, localDir: string = '/tmp') {
|
|
43
|
-
this.browser = browser;
|
|
44
|
-
this.localDir = localDir;
|
|
45
|
-
try {
|
|
46
|
-
this.encryptionKey = resolveEncryptionKey(localDir);
|
|
47
|
-
} catch {
|
|
48
|
-
// Encryption not available — persist unencrypted
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Get an existing session or create a new one.
|
|
54
|
-
* Creating a session launches a new BrowserContext on the shared Chromium.
|
|
55
|
-
*/
|
|
56
|
-
async getOrCreate(sessionId: string, allowedDomains?: string): Promise<Session> {
|
|
57
|
-
let session = this.sessions.get(sessionId);
|
|
58
|
-
if (session) {
|
|
59
|
-
session.lastActivity = Date.now();
|
|
60
|
-
// Update domain filter if provided and session doesn't already have one
|
|
61
|
-
if (allowedDomains && !session.domainFilter) {
|
|
62
|
-
const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
|
|
63
|
-
if (domains.length > 0) {
|
|
64
|
-
const domainFilter = new DomainFilter(domains);
|
|
65
|
-
session.manager.setDomainFilter(domainFilter);
|
|
66
|
-
const context = session.manager.getContext();
|
|
67
|
-
if (context) {
|
|
68
|
-
await context.route('**/*', (route) => {
|
|
69
|
-
const url = route.request().url();
|
|
70
|
-
if (domainFilter.isAllowed(url)) {
|
|
71
|
-
route.fallback();
|
|
72
|
-
} else {
|
|
73
|
-
route.abort('blockedbyclient');
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
const initScript = domainFilter.generateInitScript();
|
|
77
|
-
await context.addInitScript(initScript);
|
|
78
|
-
session.manager.setInitScript(initScript);
|
|
79
|
-
// Inject filter script into ALL open tabs immediately
|
|
80
|
-
for (const tab of session.manager.getTabList()) {
|
|
81
|
-
try {
|
|
82
|
-
const page = session.manager.getPageById(tab.id);
|
|
83
|
-
if (page) await page.evaluate(initScript);
|
|
84
|
-
} catch {}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
session.domainFilter = domainFilter;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return session;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Create per-session output directory
|
|
94
|
-
const outputDir = path.join(this.localDir, 'sessions', sanitizeName(sessionId));
|
|
95
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
96
|
-
|
|
97
|
-
const buffers = new SessionBuffers();
|
|
98
|
-
const manager = new BrowserManager(buffers);
|
|
99
|
-
await manager.launchWithBrowser(this.browser);
|
|
100
|
-
|
|
101
|
-
// Apply domain filter if allowed domains are specified
|
|
102
|
-
let domainFilter: DomainFilter | null = null;
|
|
103
|
-
if (allowedDomains) {
|
|
104
|
-
const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
|
|
105
|
-
if (domains.length > 0) {
|
|
106
|
-
domainFilter = new DomainFilter(domains);
|
|
107
|
-
manager.setDomainFilter(domainFilter);
|
|
108
|
-
const context = manager.getContext();
|
|
109
|
-
if (context) {
|
|
110
|
-
// Block disallowed domains at the network level via Playwright route()
|
|
111
|
-
await context.route('**/*', (route) => {
|
|
112
|
-
const url = route.request().url();
|
|
113
|
-
if (domainFilter!.isAllowed(url)) {
|
|
114
|
-
route.fallback();
|
|
115
|
-
} else {
|
|
116
|
-
route.abort('blockedbyclient');
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
// Block WebSocket, EventSource, sendBeacon via JS injection
|
|
120
|
-
const initScript = domainFilter.generateInitScript();
|
|
121
|
-
await context.addInitScript(initScript);
|
|
122
|
-
manager.setInitScript(initScript);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
session = {
|
|
128
|
-
id: sessionId,
|
|
129
|
-
manager,
|
|
130
|
-
buffers,
|
|
131
|
-
domainFilter,
|
|
132
|
-
recording: null,
|
|
133
|
-
outputDir,
|
|
134
|
-
lastActivity: Date.now(),
|
|
135
|
-
createdAt: Date.now(),
|
|
136
|
-
};
|
|
137
|
-
this.sessions.set(sessionId, session);
|
|
138
|
-
console.log(`[browse] Session "${sessionId}" created`);
|
|
139
|
-
|
|
140
|
-
// Auto-restore persisted state for named sessions (not "default")
|
|
141
|
-
if (sessionId !== 'default' && hasPersistedState(outputDir)) {
|
|
142
|
-
const context = manager.getContext();
|
|
143
|
-
if (context) {
|
|
144
|
-
await loadSessionState(outputDir, context, this.encryptionKey);
|
|
145
|
-
console.log(`[browse] Session "${sessionId}" state restored`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return session;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Close and remove a specific session.
|
|
154
|
-
*/
|
|
155
|
-
async closeSession(sessionId: string): Promise<void> {
|
|
156
|
-
const session = this.sessions.get(sessionId);
|
|
157
|
-
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
158
|
-
|
|
159
|
-
// Auto-save state for named sessions (not "default")
|
|
160
|
-
if (sessionId !== 'default') {
|
|
161
|
-
const context = session.manager.getContext();
|
|
162
|
-
if (context) {
|
|
163
|
-
await saveSessionState(session.outputDir, context, this.encryptionKey);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
await session.manager.close();
|
|
168
|
-
this.sessions.delete(sessionId);
|
|
169
|
-
console.log(`[browse] Session "${sessionId}" closed`);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Close sessions idle longer than maxIdleMs.
|
|
174
|
-
* Returns list of closed session IDs.
|
|
175
|
-
*/
|
|
176
|
-
async closeIdleSessions(maxIdleMs: number, flushFn?: (session: Session) => void): Promise<string[]> {
|
|
177
|
-
const now = Date.now();
|
|
178
|
-
const closed: string[] = [];
|
|
179
|
-
|
|
180
|
-
for (const [id, session] of this.sessions) {
|
|
181
|
-
if (now - session.lastActivity > maxIdleMs) {
|
|
182
|
-
if (flushFn) flushFn(session);
|
|
183
|
-
// Auto-save state for named sessions (not "default")
|
|
184
|
-
if (id !== 'default') {
|
|
185
|
-
const context = session.manager.getContext();
|
|
186
|
-
if (context) {
|
|
187
|
-
await saveSessionState(session.outputDir, context, this.encryptionKey).catch(() => {});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
await session.manager.close().catch(() => {});
|
|
191
|
-
this.sessions.delete(id);
|
|
192
|
-
closed.push(id);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return closed;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* List all active sessions (for status/sessions commands).
|
|
201
|
-
*/
|
|
202
|
-
listSessions(): Array<{ id: string; tabs: number; url: string; idleSeconds: number; active: boolean }> {
|
|
203
|
-
const now = Date.now();
|
|
204
|
-
return [...this.sessions.entries()].map(([id, session]) => ({
|
|
205
|
-
id,
|
|
206
|
-
tabs: session.manager.getTabCount(),
|
|
207
|
-
url: session.manager.getCurrentUrl(),
|
|
208
|
-
idleSeconds: Math.floor((now - session.lastActivity) / 1000),
|
|
209
|
-
active: true,
|
|
210
|
-
}));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Get all sessions (for buffer flush iteration).
|
|
215
|
-
*/
|
|
216
|
-
getAllSessions(): Session[] {
|
|
217
|
-
return [...this.sessions.values()];
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
getSessionCount(): number {
|
|
221
|
-
return this.sessions.size;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Close all sessions (for server shutdown).
|
|
226
|
-
*/
|
|
227
|
-
async closeAll(): Promise<void> {
|
|
228
|
-
for (const [id, session] of this.sessions) {
|
|
229
|
-
// Auto-save state for named sessions (not "default")
|
|
230
|
-
if (id !== 'default') {
|
|
231
|
-
const context = session.manager.getContext();
|
|
232
|
-
if (context) {
|
|
233
|
-
await saveSessionState(session.outputDir, context, this.encryptionKey).catch(() => {});
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
await session.manager.close().catch(() => {});
|
|
237
|
-
}
|
|
238
|
-
this.sessions.clear();
|
|
239
|
-
}
|
|
240
|
-
}
|
package/src/session-persist.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import type { BrowserContext } from 'playwright';
|
|
4
|
-
import { encrypt, decrypt } from './encryption';
|
|
5
|
-
|
|
6
|
-
const STATE_FILENAME = 'state.json';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Save session state (cookies + localStorage) to disk.
|
|
10
|
-
* Calls context.storageState(), optionally encrypts, writes state.json.
|
|
11
|
-
* Graceful: catches errors (context already closed, etc.) and logs warning.
|
|
12
|
-
*/
|
|
13
|
-
export async function saveSessionState(
|
|
14
|
-
sessionDir: string,
|
|
15
|
-
context: BrowserContext,
|
|
16
|
-
encryptionKey?: Buffer,
|
|
17
|
-
): Promise<void> {
|
|
18
|
-
try {
|
|
19
|
-
const state = await context.storageState();
|
|
20
|
-
const json = JSON.stringify(state, null, 2);
|
|
21
|
-
|
|
22
|
-
let content: string;
|
|
23
|
-
if (encryptionKey) {
|
|
24
|
-
const { ciphertext, iv, authTag } = encrypt(json, encryptionKey);
|
|
25
|
-
content = JSON.stringify({ encrypted: true, iv, authTag, data: ciphertext }, null, 2);
|
|
26
|
-
} else {
|
|
27
|
-
content = json;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
fs.mkdirSync(sessionDir, { recursive: true });
|
|
31
|
-
fs.writeFileSync(path.join(sessionDir, STATE_FILENAME), content, { mode: 0o600 });
|
|
32
|
-
} catch (err: any) {
|
|
33
|
-
console.log(`[session-persist] Warning: failed to save state: ${err.message}`);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Load session state from disk into a browser context.
|
|
39
|
-
* Reads state.json, optionally decrypts, applies cookies via context.addCookies().
|
|
40
|
-
* Restores localStorage by navigating to each origin with 3s timeout per origin.
|
|
41
|
-
* Returns true if state was loaded, false if no file/corrupted (logs warning).
|
|
42
|
-
* Does NOT throw on failure.
|
|
43
|
-
*/
|
|
44
|
-
export async function loadSessionState(
|
|
45
|
-
sessionDir: string,
|
|
46
|
-
context: BrowserContext,
|
|
47
|
-
encryptionKey?: Buffer,
|
|
48
|
-
): Promise<boolean> {
|
|
49
|
-
const statePath = path.join(sessionDir, STATE_FILENAME);
|
|
50
|
-
|
|
51
|
-
if (!fs.existsSync(statePath)) {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let stateData: any;
|
|
56
|
-
try {
|
|
57
|
-
const raw = fs.readFileSync(statePath, 'utf-8');
|
|
58
|
-
const parsed = JSON.parse(raw);
|
|
59
|
-
|
|
60
|
-
if (parsed.encrypted) {
|
|
61
|
-
if (!encryptionKey) {
|
|
62
|
-
console.log('[session-persist] Warning: state file is encrypted but no encryption key provided');
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
const decrypted = decrypt(parsed.data, parsed.iv, parsed.authTag, encryptionKey);
|
|
66
|
-
stateData = JSON.parse(decrypted);
|
|
67
|
-
} else {
|
|
68
|
-
stateData = parsed;
|
|
69
|
-
}
|
|
70
|
-
} catch (err: any) {
|
|
71
|
-
console.log(`[session-persist] Warning: failed to read/decrypt state file: ${err.message}`);
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
if (stateData.cookies?.length) {
|
|
77
|
-
try {
|
|
78
|
-
await context.addCookies(stateData.cookies);
|
|
79
|
-
} catch (err: any) {
|
|
80
|
-
console.log(`[session-persist] Warning: failed to restore cookies: ${err.message}`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (stateData.origins?.length) {
|
|
85
|
-
for (const origin of stateData.origins) {
|
|
86
|
-
if (!origin.localStorage?.length) continue;
|
|
87
|
-
let page: any = null;
|
|
88
|
-
try {
|
|
89
|
-
page = await context.newPage();
|
|
90
|
-
await page.goto(origin.origin, { waitUntil: 'domcontentloaded', timeout: 3000 });
|
|
91
|
-
for (const item of origin.localStorage) {
|
|
92
|
-
await page.evaluate(
|
|
93
|
-
([k, v]: [string, string]) => localStorage.setItem(k, v),
|
|
94
|
-
[item.name, item.value],
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
} catch (err: any) {
|
|
98
|
-
console.log(`[session-persist] Warning: failed to restore localStorage for ${origin.origin}: ${err.message}`);
|
|
99
|
-
} finally {
|
|
100
|
-
if (page) {
|
|
101
|
-
try {
|
|
102
|
-
await page.close();
|
|
103
|
-
} catch (_) {
|
|
104
|
-
// page may already be closed
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return true;
|
|
112
|
-
} catch (err: any) {
|
|
113
|
-
console.log(`[session-persist] Warning: failed to load state: ${err.message}`);
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Check if a persisted state file exists.
|
|
120
|
-
*/
|
|
121
|
-
export function hasPersistedState(sessionDir: string): boolean {
|
|
122
|
-
return fs.existsSync(path.join(sessionDir, STATE_FILENAME));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Delete state files older than maxAgeDays from both directories:
|
|
127
|
-
* - localDir/states/ (all .json files)
|
|
128
|
-
* - localDir/sessions/<id>/state.json (auto-persisted)
|
|
129
|
-
*/
|
|
130
|
-
export function cleanOldStates(
|
|
131
|
-
localDir: string,
|
|
132
|
-
maxAgeDays: number,
|
|
133
|
-
): { deleted: number } {
|
|
134
|
-
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
|
135
|
-
const now = Date.now();
|
|
136
|
-
let deleted = 0;
|
|
137
|
-
|
|
138
|
-
// Clean localDir/states/*.json
|
|
139
|
-
const statesDir = path.join(localDir, 'states');
|
|
140
|
-
if (fs.existsSync(statesDir)) {
|
|
141
|
-
try {
|
|
142
|
-
const entries = fs.readdirSync(statesDir);
|
|
143
|
-
for (const entry of entries) {
|
|
144
|
-
if (!entry.endsWith('.json')) continue;
|
|
145
|
-
const filePath = path.join(statesDir, entry);
|
|
146
|
-
try {
|
|
147
|
-
const stat = fs.statSync(filePath);
|
|
148
|
-
if (!stat.isFile()) continue;
|
|
149
|
-
if (now - stat.mtimeMs > maxAgeMs) {
|
|
150
|
-
fs.unlinkSync(filePath);
|
|
151
|
-
deleted++;
|
|
152
|
-
}
|
|
153
|
-
} catch (_) {
|
|
154
|
-
// stat or unlink failed, skip
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
} catch (_) {
|
|
158
|
-
// readdirSync failed, skip
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Clean localDir/sessions/*/state.json
|
|
163
|
-
const sessionsDir = path.join(localDir, 'sessions');
|
|
164
|
-
if (fs.existsSync(sessionsDir)) {
|
|
165
|
-
try {
|
|
166
|
-
const sessionDirs = fs.readdirSync(sessionsDir);
|
|
167
|
-
for (const dir of sessionDirs) {
|
|
168
|
-
const dirPath = path.join(sessionsDir, dir);
|
|
169
|
-
try {
|
|
170
|
-
const dirStat = fs.statSync(dirPath);
|
|
171
|
-
if (!dirStat.isDirectory()) continue;
|
|
172
|
-
} catch (_) {
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
const statePath = path.join(dirPath, STATE_FILENAME);
|
|
176
|
-
try {
|
|
177
|
-
const stat = fs.statSync(statePath);
|
|
178
|
-
if (now - stat.mtimeMs > maxAgeMs) {
|
|
179
|
-
fs.unlinkSync(statePath);
|
|
180
|
-
deleted++;
|
|
181
|
-
}
|
|
182
|
-
} catch (_) {
|
|
183
|
-
// file doesn't exist or stat/unlink failed, skip
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
} catch (_) {
|
|
187
|
-
// readdirSync failed, skip
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return { deleted };
|
|
192
|
-
}
|