@ulpi/browse 0.7.5 → 0.10.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/LICENSE +1 -1
- package/README.md +444 -300
- package/package.json +1 -1
- package/skill/SKILL.md +113 -5
- package/src/auth-vault.ts +4 -52
- package/src/browser-manager.ts +20 -5
- package/src/bun.d.ts +15 -20
- package/src/chrome-discover.ts +73 -0
- package/src/cli.ts +110 -10
- package/src/commands/meta.ts +247 -9
- package/src/commands/read.ts +28 -0
- package/src/commands/write.ts +236 -16
- package/src/config.ts +0 -1
- package/src/cookie-import.ts +410 -0
- package/src/encryption.ts +48 -0
- package/src/record-export.ts +98 -0
- package/src/server.ts +43 -2
- package/src/session-manager.ts +48 -0
- package/src/session-persist.ts +192 -0
package/src/session-manager.ts
CHANGED
|
@@ -11,14 +11,23 @@ import { BrowserManager } from './browser-manager';
|
|
|
11
11
|
import { SessionBuffers } from './buffers';
|
|
12
12
|
import { DomainFilter } from './domain-filter';
|
|
13
13
|
import { sanitizeName } from './sanitize';
|
|
14
|
+
import { saveSessionState, loadSessionState, hasPersistedState } from './session-persist';
|
|
15
|
+
import { resolveEncryptionKey } from './encryption';
|
|
14
16
|
import * as fs from 'fs';
|
|
15
17
|
import * as path from 'path';
|
|
16
18
|
|
|
19
|
+
export interface RecordedStep {
|
|
20
|
+
command: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
export interface Session {
|
|
18
26
|
id: string;
|
|
19
27
|
manager: BrowserManager;
|
|
20
28
|
buffers: SessionBuffers;
|
|
21
29
|
domainFilter: DomainFilter | null;
|
|
30
|
+
recording: RecordedStep[] | null;
|
|
22
31
|
outputDir: string;
|
|
23
32
|
lastActivity: number;
|
|
24
33
|
createdAt: number;
|
|
@@ -28,10 +37,16 @@ export class SessionManager {
|
|
|
28
37
|
private sessions = new Map<string, Session>();
|
|
29
38
|
private browser: Browser;
|
|
30
39
|
private localDir: string;
|
|
40
|
+
private encryptionKey: Buffer | undefined;
|
|
31
41
|
|
|
32
42
|
constructor(browser: Browser, localDir: string = '/tmp') {
|
|
33
43
|
this.browser = browser;
|
|
34
44
|
this.localDir = localDir;
|
|
45
|
+
try {
|
|
46
|
+
this.encryptionKey = resolveEncryptionKey(localDir);
|
|
47
|
+
} catch {
|
|
48
|
+
// Encryption not available — persist unencrypted
|
|
49
|
+
}
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
/**
|
|
@@ -114,12 +129,23 @@ export class SessionManager {
|
|
|
114
129
|
manager,
|
|
115
130
|
buffers,
|
|
116
131
|
domainFilter,
|
|
132
|
+
recording: null,
|
|
117
133
|
outputDir,
|
|
118
134
|
lastActivity: Date.now(),
|
|
119
135
|
createdAt: Date.now(),
|
|
120
136
|
};
|
|
121
137
|
this.sessions.set(sessionId, session);
|
|
122
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
|
+
|
|
123
149
|
return session;
|
|
124
150
|
}
|
|
125
151
|
|
|
@@ -130,6 +156,14 @@ export class SessionManager {
|
|
|
130
156
|
const session = this.sessions.get(sessionId);
|
|
131
157
|
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
132
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
|
+
|
|
133
167
|
await session.manager.close();
|
|
134
168
|
this.sessions.delete(sessionId);
|
|
135
169
|
console.log(`[browse] Session "${sessionId}" closed`);
|
|
@@ -146,6 +180,13 @@ export class SessionManager {
|
|
|
146
180
|
for (const [id, session] of this.sessions) {
|
|
147
181
|
if (now - session.lastActivity > maxIdleMs) {
|
|
148
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
|
+
}
|
|
149
190
|
await session.manager.close().catch(() => {});
|
|
150
191
|
this.sessions.delete(id);
|
|
151
192
|
closed.push(id);
|
|
@@ -185,6 +226,13 @@ export class SessionManager {
|
|
|
185
226
|
*/
|
|
186
227
|
async closeAll(): Promise<void> {
|
|
187
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
|
+
}
|
|
188
236
|
await session.manager.close().catch(() => {});
|
|
189
237
|
}
|
|
190
238
|
this.sessions.clear();
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
}
|