@ulpi/browse 0.7.4 → 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.
@@ -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
+ }