@ricsam/r5d-browser 0.0.44 → 0.0.46

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 CHANGED
@@ -7,4 +7,4 @@ npm install -g @ricsam/r5d-browser
7
7
  r5d-browser start
8
8
  ```
9
9
 
10
- The profile is stored under `~/.r5d/browser/profile`. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
10
+ The profile is stored under `~/.r5d/browser/profile`. Completed downloads are retained under `~/.r5d/browser/downloads`; agents can list them and copy a selected file into the active session's artifacts. That artifact is then synchronized to every connected worker. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
package/dist/cjs/main.cjs CHANGED
@@ -33,6 +33,7 @@ const DEFAULT_BASE_URL = "https://r5d.dev";
33
33
  const PACKAGE_NAME = "@ricsam/r5d-browser";
34
34
  const RECONNECT_DELAY_MS = 2e3;
35
35
  const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
36
+ const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
36
37
  function browserRoot() {
37
38
  return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "browser");
38
39
  }
@@ -169,27 +170,133 @@ function loadInstanceId() {
169
170
  return id;
170
171
  }
171
172
  class BrowserRuntime {
172
- constructor(context) {
173
+ constructor(context, downloadsPath) {
173
174
  this.context = context;
175
+ this.downloadsPath = downloadsPath;
176
+ import_node_fs.default.mkdirSync(downloadsPath, { recursive: true });
174
177
  for (const page of context.pages()) this.track(page);
175
178
  context.on("page", (page) => this.track(page));
176
179
  }
177
180
  context;
181
+ downloadsPath;
178
182
  ids = /* @__PURE__ */ new WeakMap();
179
183
  pages = /* @__PURE__ */ new Map();
180
184
  cursor = /* @__PURE__ */ new Map();
185
+ downloadSaveQueue = Promise.resolve();
181
186
  track(page) {
182
187
  const existing = this.ids.get(page);
183
188
  if (existing) return existing;
184
189
  const id = crypto.randomUUID();
185
190
  this.ids.set(page, id);
186
191
  this.pages.set(id, page);
192
+ page.on("download", (download) => {
193
+ this.downloadSaveQueue = this.downloadSaveQueue.then(() => this.persistDownload(download)).catch((error) => {
194
+ process.stderr.write(`[r5d-browser] failed to save download: ${error instanceof Error ? error.message : String(error)}
195
+ `);
196
+ });
197
+ });
187
198
  page.once("close", () => {
188
199
  this.pages.delete(id);
189
200
  this.cursor.delete(id);
190
201
  });
191
202
  return id;
192
203
  }
204
+ safeDownloadFilename(suggestedFilename) {
205
+ const basename = import_node_path.default.basename(suggestedFilename).replace(/[\\/\0]/g, "-").trim();
206
+ if (!basename || basename === "." || basename === "..") return "download";
207
+ const extension = import_node_path.default.extname(basename).slice(0, 30);
208
+ let stem = basename.slice(0, basename.length - import_node_path.default.extname(basename).length) || "download";
209
+ while (Buffer.byteLength(`${stem}${extension}`) > 200) stem = stem.slice(0, -1);
210
+ return `${stem || "download"}${extension}`;
211
+ }
212
+ availableDownloadPath(suggestedFilename) {
213
+ const filename = this.safeDownloadFilename(suggestedFilename);
214
+ const extension = import_node_path.default.extname(filename);
215
+ const stem = filename.slice(0, filename.length - extension.length) || "download";
216
+ for (let suffix = 1; ; suffix += 1) {
217
+ const candidate = suffix === 1 ? filename : `${stem}-${suffix}${extension}`;
218
+ const candidatePath = import_node_path.default.join(this.downloadsPath, candidate);
219
+ if (!import_node_fs.default.existsSync(candidatePath)) return candidatePath;
220
+ }
221
+ }
222
+ async persistDownload(download) {
223
+ const failure = await download.failure();
224
+ if (failure) throw new Error(failure);
225
+ const targetPath = this.availableDownloadPath(download.suggestedFilename());
226
+ await download.saveAs(targetPath);
227
+ process.stdout.write(`[r5d-browser] downloaded ${import_node_path.default.basename(targetPath)}
228
+ `);
229
+ }
230
+ downloadId(filename) {
231
+ return Buffer.from(filename, "utf8").toString("base64url");
232
+ }
233
+ resolveDownload(downloadId) {
234
+ if (typeof downloadId !== "string" || !downloadId) throw new Error("downloadId is required.");
235
+ const filename = Buffer.from(downloadId, "base64url").toString("utf8");
236
+ if (this.downloadId(filename) !== downloadId || filename !== import_node_path.default.basename(filename) || filename.includes("\0")) {
237
+ throw new Error("Invalid downloadId.");
238
+ }
239
+ const filePath = import_node_path.default.join(this.downloadsPath, filename);
240
+ let stats;
241
+ try {
242
+ stats = import_node_fs.default.lstatSync(filePath);
243
+ } catch {
244
+ throw new Error(`Browser download ${downloadId} no longer exists.`);
245
+ }
246
+ if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Browser download is not a regular file.");
247
+ return { filename, filePath, stats };
248
+ }
249
+ async listDownloads() {
250
+ await this.downloadSaveQueue;
251
+ return import_node_fs.default.readdirSync(this.downloadsPath, { withFileTypes: true }).flatMap((entry) => {
252
+ if (!entry.isFile() || entry.name.startsWith(".")) return [];
253
+ const { filename, stats } = this.resolveDownload(this.downloadId(entry.name));
254
+ return [
255
+ {
256
+ downloadId: this.downloadId(filename),
257
+ filename,
258
+ size: stats.size,
259
+ modifiedAt: stats.mtime.toISOString()
260
+ }
261
+ ];
262
+ }).sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.filename.localeCompare(right.filename));
263
+ }
264
+ async getDownloadChunk(input) {
265
+ await this.downloadSaveQueue;
266
+ const { filename, filePath, stats } = this.resolveDownload(input.downloadId);
267
+ const offset = input.offset === void 0 ? 0 : Number(input.offset);
268
+ const requestedBytes = input.maxBytes === void 0 ? MAX_DOWNLOAD_CHUNK_BYTES : Number(input.maxBytes);
269
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > stats.size) throw new Error("Invalid download offset.");
270
+ if (!Number.isSafeInteger(requestedBytes) || requestedBytes < 1) throw new Error("Invalid download chunk size.");
271
+ const chunkSize = Math.min(requestedBytes, MAX_DOWNLOAD_CHUNK_BYTES, stats.size - offset);
272
+ const bytes = Buffer.alloc(chunkSize);
273
+ const handle = import_node_fs.default.openSync(filePath, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
274
+ let bytesRead = 0;
275
+ try {
276
+ const openedStats = import_node_fs.default.fstatSync(handle);
277
+ if (!openedStats.isFile() || openedStats.dev !== stats.dev || openedStats.ino !== stats.ino) {
278
+ throw new Error("Browser download changed before it could be read.");
279
+ }
280
+ bytesRead = import_node_fs.default.readSync(handle, bytes, 0, chunkSize, offset);
281
+ const completedStats = import_node_fs.default.fstatSync(handle);
282
+ if (completedStats.size !== stats.size || completedStats.mtimeMs !== stats.mtimeMs) {
283
+ throw new Error("Browser download changed while it was being read.");
284
+ }
285
+ } finally {
286
+ import_node_fs.default.closeSync(handle);
287
+ }
288
+ const nextOffset = offset + bytesRead;
289
+ return {
290
+ downloadId: this.downloadId(filename),
291
+ filename,
292
+ size: stats.size,
293
+ modifiedAt: stats.mtime.toISOString(),
294
+ offset,
295
+ nextOffset,
296
+ eof: nextOffset === stats.size,
297
+ base64: bytes.subarray(0, bytesRead).toString("base64")
298
+ };
299
+ }
193
300
  page(tabId) {
194
301
  if (typeof tabId !== "string") throw new Error("tabId is required.");
195
302
  const page = this.pages.get(tabId);
@@ -213,6 +320,8 @@ class BrowserRuntime {
213
320
  }
214
321
  async execute(action, input) {
215
322
  if (action === "list_tabs") return { tabs: await this.listTabs() };
323
+ if (action === "list_downloads") return { downloads: await this.listDownloads() };
324
+ if (action === "get_download") return await this.getDownloadChunk(input);
216
325
  if (action === "open_tab") {
217
326
  const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
218
327
  let page2;
@@ -394,6 +503,8 @@ async function start(options) {
394
503
  const { baseUrl, token } = resolveConnection(options);
395
504
  const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
396
505
  const profilePath = import_node_path.default.resolve(options.profilePath ?? import_node_path.default.join(browserRoot(), "profile"));
506
+ const downloadsPath = import_node_path.default.join(browserRoot(), "downloads");
507
+ const playwrightDownloadsPath = import_node_path.default.join(downloadsPath, ".playwright");
397
508
  process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
398
509
  let playwright = await import("playwright");
399
510
  if (!import_node_fs.default.existsSync(playwright.chromium.executablePath())) {
@@ -401,15 +512,18 @@ async function start(options) {
401
512
  playwright = await import("playwright");
402
513
  }
403
514
  import_node_fs.default.mkdirSync(profilePath, { recursive: true });
515
+ import_node_fs.default.mkdirSync(playwrightDownloadsPath, { recursive: true });
404
516
  const context = await playwright.chromium.launchPersistentContext(profilePath, {
405
517
  headless: false,
406
518
  viewport: { width: 1440, height: 900 },
407
519
  acceptDownloads: true,
408
- downloadsPath: import_node_path.default.join(browserRoot(), "downloads")
520
+ downloadsPath: playwrightDownloadsPath
409
521
  });
410
522
  if (context.pages().length === 0) await context.newPage();
411
- const runtime = new BrowserRuntime(context);
523
+ const runtime = new BrowserRuntime(context, downloadsPath);
412
524
  process.stdout.write(`[r5d-browser] profile: ${profilePath}
525
+ `);
526
+ process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
413
527
  `);
414
528
  process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
415
529
  `);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/main.mjs CHANGED
@@ -10,6 +10,7 @@ const DEFAULT_BASE_URL = "https://r5d.dev";
10
10
  const PACKAGE_NAME = "@ricsam/r5d-browser";
11
11
  const RECONNECT_DELAY_MS = 2e3;
12
12
  const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
13
+ const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
13
14
  function browserRoot() {
14
15
  return path.join(os.homedir(), ".r5d", "browser");
15
16
  }
@@ -146,27 +147,133 @@ function loadInstanceId() {
146
147
  return id;
147
148
  }
148
149
  class BrowserRuntime {
149
- constructor(context) {
150
+ constructor(context, downloadsPath) {
150
151
  this.context = context;
152
+ this.downloadsPath = downloadsPath;
153
+ fs.mkdirSync(downloadsPath, { recursive: true });
151
154
  for (const page of context.pages()) this.track(page);
152
155
  context.on("page", (page) => this.track(page));
153
156
  }
154
157
  context;
158
+ downloadsPath;
155
159
  ids = /* @__PURE__ */ new WeakMap();
156
160
  pages = /* @__PURE__ */ new Map();
157
161
  cursor = /* @__PURE__ */ new Map();
162
+ downloadSaveQueue = Promise.resolve();
158
163
  track(page) {
159
164
  const existing = this.ids.get(page);
160
165
  if (existing) return existing;
161
166
  const id = crypto.randomUUID();
162
167
  this.ids.set(page, id);
163
168
  this.pages.set(id, page);
169
+ page.on("download", (download) => {
170
+ this.downloadSaveQueue = this.downloadSaveQueue.then(() => this.persistDownload(download)).catch((error) => {
171
+ process.stderr.write(`[r5d-browser] failed to save download: ${error instanceof Error ? error.message : String(error)}
172
+ `);
173
+ });
174
+ });
164
175
  page.once("close", () => {
165
176
  this.pages.delete(id);
166
177
  this.cursor.delete(id);
167
178
  });
168
179
  return id;
169
180
  }
181
+ safeDownloadFilename(suggestedFilename) {
182
+ const basename = path.basename(suggestedFilename).replace(/[\\/\0]/g, "-").trim();
183
+ if (!basename || basename === "." || basename === "..") return "download";
184
+ const extension = path.extname(basename).slice(0, 30);
185
+ let stem = basename.slice(0, basename.length - path.extname(basename).length) || "download";
186
+ while (Buffer.byteLength(`${stem}${extension}`) > 200) stem = stem.slice(0, -1);
187
+ return `${stem || "download"}${extension}`;
188
+ }
189
+ availableDownloadPath(suggestedFilename) {
190
+ const filename = this.safeDownloadFilename(suggestedFilename);
191
+ const extension = path.extname(filename);
192
+ const stem = filename.slice(0, filename.length - extension.length) || "download";
193
+ for (let suffix = 1; ; suffix += 1) {
194
+ const candidate = suffix === 1 ? filename : `${stem}-${suffix}${extension}`;
195
+ const candidatePath = path.join(this.downloadsPath, candidate);
196
+ if (!fs.existsSync(candidatePath)) return candidatePath;
197
+ }
198
+ }
199
+ async persistDownload(download) {
200
+ const failure = await download.failure();
201
+ if (failure) throw new Error(failure);
202
+ const targetPath = this.availableDownloadPath(download.suggestedFilename());
203
+ await download.saveAs(targetPath);
204
+ process.stdout.write(`[r5d-browser] downloaded ${path.basename(targetPath)}
205
+ `);
206
+ }
207
+ downloadId(filename) {
208
+ return Buffer.from(filename, "utf8").toString("base64url");
209
+ }
210
+ resolveDownload(downloadId) {
211
+ if (typeof downloadId !== "string" || !downloadId) throw new Error("downloadId is required.");
212
+ const filename = Buffer.from(downloadId, "base64url").toString("utf8");
213
+ if (this.downloadId(filename) !== downloadId || filename !== path.basename(filename) || filename.includes("\0")) {
214
+ throw new Error("Invalid downloadId.");
215
+ }
216
+ const filePath = path.join(this.downloadsPath, filename);
217
+ let stats;
218
+ try {
219
+ stats = fs.lstatSync(filePath);
220
+ } catch {
221
+ throw new Error(`Browser download ${downloadId} no longer exists.`);
222
+ }
223
+ if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Browser download is not a regular file.");
224
+ return { filename, filePath, stats };
225
+ }
226
+ async listDownloads() {
227
+ await this.downloadSaveQueue;
228
+ return fs.readdirSync(this.downloadsPath, { withFileTypes: true }).flatMap((entry) => {
229
+ if (!entry.isFile() || entry.name.startsWith(".")) return [];
230
+ const { filename, stats } = this.resolveDownload(this.downloadId(entry.name));
231
+ return [
232
+ {
233
+ downloadId: this.downloadId(filename),
234
+ filename,
235
+ size: stats.size,
236
+ modifiedAt: stats.mtime.toISOString()
237
+ }
238
+ ];
239
+ }).sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.filename.localeCompare(right.filename));
240
+ }
241
+ async getDownloadChunk(input) {
242
+ await this.downloadSaveQueue;
243
+ const { filename, filePath, stats } = this.resolveDownload(input.downloadId);
244
+ const offset = input.offset === void 0 ? 0 : Number(input.offset);
245
+ const requestedBytes = input.maxBytes === void 0 ? MAX_DOWNLOAD_CHUNK_BYTES : Number(input.maxBytes);
246
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > stats.size) throw new Error("Invalid download offset.");
247
+ if (!Number.isSafeInteger(requestedBytes) || requestedBytes < 1) throw new Error("Invalid download chunk size.");
248
+ const chunkSize = Math.min(requestedBytes, MAX_DOWNLOAD_CHUNK_BYTES, stats.size - offset);
249
+ const bytes = Buffer.alloc(chunkSize);
250
+ const handle = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
251
+ let bytesRead = 0;
252
+ try {
253
+ const openedStats = fs.fstatSync(handle);
254
+ if (!openedStats.isFile() || openedStats.dev !== stats.dev || openedStats.ino !== stats.ino) {
255
+ throw new Error("Browser download changed before it could be read.");
256
+ }
257
+ bytesRead = fs.readSync(handle, bytes, 0, chunkSize, offset);
258
+ const completedStats = fs.fstatSync(handle);
259
+ if (completedStats.size !== stats.size || completedStats.mtimeMs !== stats.mtimeMs) {
260
+ throw new Error("Browser download changed while it was being read.");
261
+ }
262
+ } finally {
263
+ fs.closeSync(handle);
264
+ }
265
+ const nextOffset = offset + bytesRead;
266
+ return {
267
+ downloadId: this.downloadId(filename),
268
+ filename,
269
+ size: stats.size,
270
+ modifiedAt: stats.mtime.toISOString(),
271
+ offset,
272
+ nextOffset,
273
+ eof: nextOffset === stats.size,
274
+ base64: bytes.subarray(0, bytesRead).toString("base64")
275
+ };
276
+ }
170
277
  page(tabId) {
171
278
  if (typeof tabId !== "string") throw new Error("tabId is required.");
172
279
  const page = this.pages.get(tabId);
@@ -190,6 +297,8 @@ class BrowserRuntime {
190
297
  }
191
298
  async execute(action, input) {
192
299
  if (action === "list_tabs") return { tabs: await this.listTabs() };
300
+ if (action === "list_downloads") return { downloads: await this.listDownloads() };
301
+ if (action === "get_download") return await this.getDownloadChunk(input);
193
302
  if (action === "open_tab") {
194
303
  const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
195
304
  let page2;
@@ -371,6 +480,8 @@ async function start(options) {
371
480
  const { baseUrl, token } = resolveConnection(options);
372
481
  const browsersPath = path.resolve(options.browsersPath ?? path.join(browserRoot(), "browsers"));
373
482
  const profilePath = path.resolve(options.profilePath ?? path.join(browserRoot(), "profile"));
483
+ const downloadsPath = path.join(browserRoot(), "downloads");
484
+ const playwrightDownloadsPath = path.join(downloadsPath, ".playwright");
374
485
  process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
375
486
  let playwright = await import("playwright");
376
487
  if (!fs.existsSync(playwright.chromium.executablePath())) {
@@ -378,15 +489,18 @@ async function start(options) {
378
489
  playwright = await import("playwright");
379
490
  }
380
491
  fs.mkdirSync(profilePath, { recursive: true });
492
+ fs.mkdirSync(playwrightDownloadsPath, { recursive: true });
381
493
  const context = await playwright.chromium.launchPersistentContext(profilePath, {
382
494
  headless: false,
383
495
  viewport: { width: 1440, height: 900 },
384
496
  acceptDownloads: true,
385
- downloadsPath: path.join(browserRoot(), "downloads")
497
+ downloadsPath: playwrightDownloadsPath
386
498
  });
387
499
  if (context.pages().length === 0) await context.newPage();
388
- const runtime = new BrowserRuntime(context);
500
+ const runtime = new BrowserRuntime(context, downloadsPath);
389
501
  process.stdout.write(`[r5d-browser] profile: ${profilePath}
502
+ `);
503
+ process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
390
504
  `);
391
505
  process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
392
506
  `);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "type": "module"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",