@ricsam/r5d-browser 0.0.125 → 0.0.127

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
@@ -26,6 +26,12 @@ Password input values are replaced with `[redacted]` in `browser_read_page` snap
26
26
 
27
27
  Agents read pages through `browser_read_page`, an accessibility outline with stable element refs (`[ref=e12]`) on interactable elements and `[box=x,y,w,h]` viewport coordinates on everything visible. Refs feed the one-call interaction tools: `browser_mouse_click` clicks a ref (scrolled into view, with actionability checks) or an x/y point, `browser_form_input` sets values on text inputs, selects, checkboxes, and contenteditable elements with trusted events that controlled (React-style) components honor, and `browser_scroll` scrolls an element into view or dispatches trusted wheel input. Same-origin iframe content appears inline with frame-prefixed refs such as `f1e3`; cross-origin frame content may be omitted. Refs come from the most recent snapshot of a tab, so agents re-read the page after navigations or large re-renders. `browser_wait_for` waits for a selector, visible text, a URL glob, or a load state instead of sleeping, and `browser_keyboard_type` has a `keys` mode that presses each character as a real key event (up to 500 characters per call) for type-ahead widgets — characters without key codes, such as emoji, still fall back to text insertion. Screenshots capture the viewport by default; `fullPage: true` captures the scrollable page up to 4000 CSS px of height.
28
28
 
29
+ Agents attach worker files with `browser_upload_files({ tabId, files, ref })`, or `selector` instead of `ref` for a hidden file input. `files` contains up to 100 regular-file paths on the session's bound worker: absolute paths, paths relative to the session working directory, `~/` paths, or paths using `$R5D_ROOT` and `$R5D_SESSION_ID`. The files may live outside the repository. Multiple files require an input with `multiple`; directory inputs and native file-picker dialogs are not supported.
30
+
31
+ The operation reads the source files in 1 MiB chunks, relays them through r5d.dev without storing their bytes in artifacts or recovery journals, verifies size and SHA-256, and attaches them using Playwright. Browser copies live in private, generated folders under `~/.r5d/browser/uploads`, preserving filenames even when several files share a name. Completed copies remain until their tab closes or r5d-browser exits so later form submission can still read them. Failed transfers are removed; abandoned partial transfers expire after five minutes without activity, and the next browser start clears staging left after a crash. There is no per-file byte limit; one operation has a 30-minute deadline. Changed source files, disconnections, stale target elements, and session cancellation fail the operation without automatically retrying attachment. Both the worker and browser must advertise upload support.
32
+
33
+ Setting a file input fires its normal input/change events, which may immediately start a site's upload. For forms that require a separate submit action, the agent submits afterwards. The result reports attached filenames and byte sizes; it does not claim the website finished processing them.
34
+
29
35
  Agents running JavaScript in a page can save captured text straight into the active session's artifacts by calling `r5dCreateArtifact(filename, content)`, which takes two strings and must be called before the agent's code returns. Each captured file is written to the session artifacts in r5d.dev, synchronized to every connected worker, and reported back under `$R5D_ROOT/artifacts/$R5D_SESSION_ID`; a single call may create at most 32 files totalling 4 MiB. The function is passed into the agent's code rather than assigned to `window`, so pages can neither call it nor use it to detect automation.
30
36
 
31
37
  For responsive testing that needs an existing login (for example, an app using Google login), agents can call `browser_set_viewport` with `{ tabId, width: 390, height: 844 }`. Both dimensions must be integers from 1 to 4096 CSS pixels. The override is scoped to that tab, even in a shared window: it does not resize the OS window or affect other tabs. It preserves the profile, user agent, touch support, and device pixel ratio; this is layout sizing, not mobile-device emulation. It adds no Chrome launch flags or webdriver spoofing; `navigator.webdriver` remains natively `false`.
@@ -41,6 +41,11 @@ const browserInputFields = {
41
41
  keyboard: { required: ["tabId", "keys"], optional: ["modifiers"] },
42
42
  keyboard_type: { required: ["tabId", "text"], optional: ["mode", "delayMs"] },
43
43
  run_js: { required: ["tabId", "code"], optional: [] },
44
+ upload_files: { required: ["tabId", "files"], optional: ["ref", "selector"] },
45
+ upload_begin: { required: ["tabId", "uploadId", "files"], optional: ["ref", "selector"] },
46
+ upload_chunk: { required: ["tabId", "uploadId", "index", "offset", "base64"], optional: [] },
47
+ upload_attach: { required: ["tabId", "uploadId", "hashes"], optional: [] },
48
+ upload_abort: { required: ["tabId", "uploadId"], optional: [] },
44
49
  port_forward: { required: ["workerPort"], optional: ["browserPort"] },
45
50
  list_port_forwards: { required: [], optional: [] },
46
51
  stop_port_forward: { required: ["forwardId"], optional: [] }
@@ -34,6 +34,8 @@ module.exports = __toCommonJS(browser_runtime_exports);
34
34
  var import_node_fs = __toESM(require("node:fs"), 1);
35
35
  var import_node_path = __toESM(require("node:path"), 1);
36
36
  var import_browser_input = require("./browser-input.cjs");
37
+ var import_upload_store = require("./upload-store.cjs");
38
+ var import_browser_upload = require("@ricsam/r5d-api/browser-upload");
37
39
  var import_password_safe_snapshot = require("./password-safe-snapshot.cjs");
38
40
  const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
39
41
  const MAX_RUN_JS_RESULT_BYTES = 1024 * 1024;
@@ -72,9 +74,12 @@ Ref ${ref} comes from the most recent browser_read_page snapshot of this tab. If
72
74
  );
73
75
  }
74
76
  class BrowserRuntime {
75
- constructor(context, downloadsPath) {
77
+ constructor(context, downloadsPath, uploadsPath = `${downloadsPath}.uploads`) {
76
78
  this.context = context;
77
79
  this.downloadsPath = downloadsPath;
80
+ this.uploads = new import_upload_store.BrowserUploadStore(uploadsPath, (target) => {
81
+ void target.dispose().catch(() => void 0);
82
+ });
78
83
  import_node_fs.default.mkdirSync(downloadsPath, { recursive: true });
79
84
  for (const page of context.pages()) this.track(page);
80
85
  context.on("page", (page) => this.track(page));
@@ -85,6 +90,7 @@ class BrowserRuntime {
85
90
  pages = /* @__PURE__ */ new Map();
86
91
  cursor = /* @__PURE__ */ new Map();
87
92
  viewportSessions = /* @__PURE__ */ new Map();
93
+ uploads;
88
94
  downloadSaveQueue = Promise.resolve();
89
95
  track(page) {
90
96
  const existing = this.ids.get(page);
@@ -99,6 +105,7 @@ class BrowserRuntime {
99
105
  });
100
106
  });
101
107
  page.once("close", () => {
108
+ this.uploads.closeTab(id);
102
109
  this.pages.delete(id);
103
110
  this.cursor.delete(id);
104
111
  this.viewportSessions.delete(id);
@@ -265,8 +272,15 @@ class BrowserRuntime {
265
272
  const actual = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }));
266
273
  return { tabId, ...actual, reset: viewport === null };
267
274
  }
275
+ closeUploads() {
276
+ this.uploads.close();
277
+ }
268
278
  async execute(action, input) {
269
279
  input = (0, import_browser_input.normalizeBrowserInput)(action, input);
280
+ if (action === "upload_abort") {
281
+ this.uploads.abort(input.uploadId, input.tabId);
282
+ return { aborted: true };
283
+ }
270
284
  if (action === "list_tabs") return { tabs: await this.listTabs() };
271
285
  if (action === "list_downloads") return { downloads: await this.listDownloads() };
272
286
  if (action === "get_download") return await this.getDownloadChunk(input);
@@ -490,6 +504,38 @@ class BrowserRuntime {
490
504
  }
491
505
  return { tabId, matched: true, elapsedMs: Date.now() - started, url: page.url(), title: await page.title().catch(() => "") };
492
506
  }
507
+ case "upload_begin": {
508
+ const target = (0, import_browser_upload.parseUploadTarget)(input);
509
+ const locator = target.ref ? this.refLocator(page, target.ref) : page.locator(target.selector);
510
+ const element = await locator.elementHandle({ timeout: REF_ACTION_TIMEOUT_MS });
511
+ if (!element) throw new Error("File input was not found.");
512
+ try {
513
+ const info = await element.evaluate((el) => ({
514
+ fileInput: el instanceof HTMLInputElement && el.type === "file",
515
+ directory: el.hasAttribute("webkitdirectory"),
516
+ multiple: el.hasAttribute("multiple")
517
+ }));
518
+ if (!info.fileInput || info.directory) throw new Error("Target must be a file input (directory inputs are not supported).");
519
+ if (Array.isArray(input.files) && input.files.length > 1 && !info.multiple) throw new Error("This file input does not accept multiple files.");
520
+ this.uploads.begin(input.uploadId, tabId, input.files, element);
521
+ return { tabId, uploadId: input.uploadId };
522
+ } catch (error) {
523
+ await element.dispose().catch(() => void 0);
524
+ throw error;
525
+ }
526
+ }
527
+ case "upload_chunk":
528
+ this.uploads.append(input.uploadId, tabId, input.index, input.offset, input.base64);
529
+ return { tabId, uploadId: input.uploadId };
530
+ case "upload_attach": {
531
+ const upload = this.uploads.ready(input.uploadId, tabId, input.hashes);
532
+ if (!await upload.target.evaluate((el) => el.isConnected && el instanceof HTMLInputElement && el.type === "file")) {
533
+ throw new Error("The file input was removed or changed during the upload. Read the page again and retry.");
534
+ }
535
+ await upload.target.setInputFiles(upload.paths, { timeout: REF_ACTION_TIMEOUT_MS });
536
+ this.uploads.attached(input.uploadId, tabId);
537
+ return { tabId, files: upload.files };
538
+ }
493
539
  case "form_input": {
494
540
  const ref = requireRef(input.ref);
495
541
  const provided = ["value", "values", "checked"].filter((key) => input[key] !== void 0);
package/dist/cjs/main.cjs CHANGED
@@ -160,7 +160,8 @@ async function connectLoop(params) {
160
160
  formInput: true,
161
161
  typedKeys: true,
162
162
  screenshotFullPage: true,
163
- viewportSizing: true
163
+ viewportSizing: true,
164
+ fileUploads: true
164
165
  }
165
166
  },
166
167
  tabs: await params.runtime.listTabs(),
@@ -213,6 +214,11 @@ async function connectLoop(params) {
213
214
  }
214
215
  if (message.type !== "operation") return;
215
216
  try {
217
+ if (["upload_begin", "upload_chunk", "upload_abort"].includes(message.action)) {
218
+ const result2 = await params.runtime.execute(message.action, message.input);
219
+ socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result: result2 }));
220
+ return;
221
+ }
216
222
  const beforeTabs = await params.runtime.listTabs();
217
223
  const operationResult = await params.runtime.execute(message.action, message.input);
218
224
  const tabs = await params.runtime.listTabs();
@@ -332,7 +338,9 @@ async function start(options) {
332
338
  }
333
339
  try {
334
340
  const profilePath = launch.profilePath;
335
- const runtime = new import_browser_runtime.BrowserRuntime(launch.context, downloadsPath);
341
+ const uploadsPath = import_node_path.default.join(browserRoot(), "uploads");
342
+ import_node_fs.default.rmSync(uploadsPath, { recursive: true, force: true });
343
+ const runtime = new import_browser_runtime.BrowserRuntime(launch.context, downloadsPath, uploadsPath);
336
344
  const instanceId = loadInstanceId();
337
345
  const portForwards = new import_port_forward_manager.BrowserPortForwardManager(baseUrl, token, instanceId);
338
346
  process.stdout.write(`[r5d-browser] profile: ${profilePath}
@@ -391,6 +399,7 @@ async function start(options) {
391
399
  } catch (error) {
392
400
  cleanupError ??= error;
393
401
  }
402
+ runtime.closeUploads();
394
403
  await controlStopped;
395
404
  for (const [signal, handler] of signalHandlers) processEvents.off(signal, handler);
396
405
  if (outcome.exitCode !== 0) throw new Error(outcome.message ?? "Google Chrome stopped unexpectedly.");
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
4
4
  "type": "commonjs"
5
5
  }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var upload_store_exports = {};
30
+ __export(upload_store_exports, {
31
+ BrowserUploadStore: () => BrowserUploadStore
32
+ });
33
+ module.exports = __toCommonJS(upload_store_exports);
34
+ var import_node_fs = __toESM(require("node:fs"), 1);
35
+ var import_node_path = __toESM(require("node:path"), 1);
36
+ var import_node_crypto = require("node:crypto");
37
+ var import_browser_upload = require("@ricsam/r5d-api/browser-upload");
38
+ class BrowserUploadStore {
39
+ constructor(root, releaseTarget = () => void 0) {
40
+ this.releaseTarget = releaseTarget;
41
+ import_node_fs.default.mkdirSync(root, { recursive: true, mode: 448 });
42
+ this.directory = import_node_fs.default.mkdtempSync(import_node_path.default.join(root, "run-"));
43
+ }
44
+ releaseTarget;
45
+ directory;
46
+ uploads = /* @__PURE__ */ new Map();
47
+ begin(id, tabId, files, target) {
48
+ if (typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id) || this.uploads.has(id)) throw new Error("Invalid or duplicate upload id.");
49
+ if (!Array.isArray(files) || files.length === 0 || files.length > 100) throw new Error("Upload requires between 1 and 100 files.");
50
+ const metadata = files.map(import_browser_upload.validateUploadFile);
51
+ const directory = import_node_fs.default.mkdtempSync(import_node_path.default.join(this.directory, "upload-"));
52
+ try {
53
+ const staged = metadata.map((file, index) => {
54
+ const parent = import_node_path.default.join(directory, String(index));
55
+ import_node_fs.default.mkdirSync(parent, { mode: 448 });
56
+ const filePath = import_node_path.default.join(parent, file.filename);
57
+ import_node_fs.default.closeSync(import_node_fs.default.openSync(filePath, "wx", 384));
58
+ return { ...file, path: filePath, offset: 0, hash: (0, import_node_crypto.createHash)("sha256") };
59
+ });
60
+ const timer = setTimeout(() => this.abort(id, tabId), 5 * 60 * 1e3);
61
+ timer.unref();
62
+ this.uploads.set(id, { tabId, directory, files: staged, target, attached: false, timer });
63
+ } catch (error) {
64
+ import_node_fs.default.rmSync(directory, { recursive: true, force: true });
65
+ throw error;
66
+ }
67
+ }
68
+ get(id, tabId) {
69
+ const upload = typeof id === "string" ? this.uploads.get(id) : void 0;
70
+ if (!upload || upload.tabId !== tabId) throw new Error("Upload is missing, expired, or belongs to another tab.");
71
+ if (upload.attached) throw new Error("Upload has already been attached.");
72
+ upload.timer.refresh();
73
+ return upload;
74
+ }
75
+ append(id, tabId, index, offset, base64) {
76
+ const upload = this.get(id, tabId);
77
+ const file = typeof index === "number" && Number.isInteger(index) ? upload.files[index] : void 0;
78
+ if (!file || offset !== file.offset) throw new Error("Invalid upload file index or chunk offset.");
79
+ const bytes = (0, import_browser_upload.decodeUploadChunk)(base64);
80
+ if (bytes.length === 0 || file.offset + bytes.length > file.size)
81
+ throw new Error("Upload chunk does not match the declared file size.");
82
+ const fd = import_node_fs.default.openSync(file.path, import_node_fs.default.constants.O_WRONLY | import_node_fs.default.constants.O_NOFOLLOW);
83
+ try {
84
+ let written = 0;
85
+ while (written < bytes.length) {
86
+ const count = import_node_fs.default.writeSync(fd, bytes, written, bytes.length - written, file.offset + written);
87
+ if (!count) throw new Error("Could not write upload chunk.");
88
+ written += count;
89
+ }
90
+ } finally {
91
+ import_node_fs.default.closeSync(fd);
92
+ }
93
+ file.hash.update(bytes);
94
+ file.offset += bytes.length;
95
+ }
96
+ ready(id, tabId, hashes) {
97
+ const upload = this.get(id, tabId);
98
+ if (!Array.isArray(hashes) || hashes.length !== upload.files.length) throw new Error("Upload requires a checksum for every file.");
99
+ for (const [index, file] of upload.files.entries()) {
100
+ if (file.offset !== file.size || file.hash.copy().digest("hex") !== hashes[index])
101
+ throw new Error("Upload is incomplete or its checksum does not match.");
102
+ }
103
+ return {
104
+ target: upload.target,
105
+ paths: upload.files.map((file) => file.path),
106
+ files: upload.files.map(({ filename, size }) => ({ filename, size }))
107
+ };
108
+ }
109
+ attached(id, tabId) {
110
+ const upload = this.get(id, tabId);
111
+ upload.attached = true;
112
+ clearTimeout(upload.timer);
113
+ }
114
+ abort(id, tabId) {
115
+ const upload = this.uploads.get(id);
116
+ if (!upload || upload.tabId !== tabId || upload.attached) return;
117
+ this.remove(id, upload);
118
+ }
119
+ remove(id, upload) {
120
+ clearTimeout(upload.timer);
121
+ this.uploads.delete(id);
122
+ this.releaseTarget(upload.target);
123
+ import_node_fs.default.rmSync(upload.directory, { recursive: true, force: true });
124
+ }
125
+ closeTab(tabId) {
126
+ for (const [id, upload] of this.uploads) if (upload.tabId === tabId) this.remove(id, upload);
127
+ }
128
+ close() {
129
+ for (const [id, upload] of this.uploads) this.remove(id, upload);
130
+ import_node_fs.default.rmSync(this.directory, { recursive: true, force: true });
131
+ }
132
+ }
133
+ // Annotate the CommonJS export names for ESM import in node:
134
+ 0 && (module.exports = {
135
+ BrowserUploadStore
136
+ });
@@ -16,6 +16,11 @@ const browserInputFields = {
16
16
  keyboard: { required: ["tabId", "keys"], optional: ["modifiers"] },
17
17
  keyboard_type: { required: ["tabId", "text"], optional: ["mode", "delayMs"] },
18
18
  run_js: { required: ["tabId", "code"], optional: [] },
19
+ upload_files: { required: ["tabId", "files"], optional: ["ref", "selector"] },
20
+ upload_begin: { required: ["tabId", "uploadId", "files"], optional: ["ref", "selector"] },
21
+ upload_chunk: { required: ["tabId", "uploadId", "index", "offset", "base64"], optional: [] },
22
+ upload_attach: { required: ["tabId", "uploadId", "hashes"], optional: [] },
23
+ upload_abort: { required: ["tabId", "uploadId"], optional: [] },
19
24
  port_forward: { required: ["workerPort"], optional: ["browserPort"] },
20
25
  list_port_forwards: { required: [], optional: [] },
21
26
  stop_port_forward: { required: ["forwardId"], optional: [] }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { normalizeBrowserInput, parseViewportInput } from "./browser-input.mjs";
4
+ import { BrowserUploadStore } from "./upload-store.mjs";
5
+ import { parseUploadTarget } from "@ricsam/r5d-api/browser-upload";
4
6
  import { passwordSafeAriaSnapshot } from "./password-safe-snapshot.mjs";
5
7
  const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
6
8
  const MAX_RUN_JS_RESULT_BYTES = 1024 * 1024;
@@ -39,9 +41,12 @@ Ref ${ref} comes from the most recent browser_read_page snapshot of this tab. If
39
41
  );
40
42
  }
41
43
  class BrowserRuntime {
42
- constructor(context, downloadsPath) {
44
+ constructor(context, downloadsPath, uploadsPath = `${downloadsPath}.uploads`) {
43
45
  this.context = context;
44
46
  this.downloadsPath = downloadsPath;
47
+ this.uploads = new BrowserUploadStore(uploadsPath, (target) => {
48
+ void target.dispose().catch(() => void 0);
49
+ });
45
50
  fs.mkdirSync(downloadsPath, { recursive: true });
46
51
  for (const page of context.pages()) this.track(page);
47
52
  context.on("page", (page) => this.track(page));
@@ -52,6 +57,7 @@ class BrowserRuntime {
52
57
  pages = /* @__PURE__ */ new Map();
53
58
  cursor = /* @__PURE__ */ new Map();
54
59
  viewportSessions = /* @__PURE__ */ new Map();
60
+ uploads;
55
61
  downloadSaveQueue = Promise.resolve();
56
62
  track(page) {
57
63
  const existing = this.ids.get(page);
@@ -66,6 +72,7 @@ class BrowserRuntime {
66
72
  });
67
73
  });
68
74
  page.once("close", () => {
75
+ this.uploads.closeTab(id);
69
76
  this.pages.delete(id);
70
77
  this.cursor.delete(id);
71
78
  this.viewportSessions.delete(id);
@@ -232,8 +239,15 @@ class BrowserRuntime {
232
239
  const actual = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }));
233
240
  return { tabId, ...actual, reset: viewport === null };
234
241
  }
242
+ closeUploads() {
243
+ this.uploads.close();
244
+ }
235
245
  async execute(action, input) {
236
246
  input = normalizeBrowserInput(action, input);
247
+ if (action === "upload_abort") {
248
+ this.uploads.abort(input.uploadId, input.tabId);
249
+ return { aborted: true };
250
+ }
237
251
  if (action === "list_tabs") return { tabs: await this.listTabs() };
238
252
  if (action === "list_downloads") return { downloads: await this.listDownloads() };
239
253
  if (action === "get_download") return await this.getDownloadChunk(input);
@@ -457,6 +471,38 @@ class BrowserRuntime {
457
471
  }
458
472
  return { tabId, matched: true, elapsedMs: Date.now() - started, url: page.url(), title: await page.title().catch(() => "") };
459
473
  }
474
+ case "upload_begin": {
475
+ const target = parseUploadTarget(input);
476
+ const locator = target.ref ? this.refLocator(page, target.ref) : page.locator(target.selector);
477
+ const element = await locator.elementHandle({ timeout: REF_ACTION_TIMEOUT_MS });
478
+ if (!element) throw new Error("File input was not found.");
479
+ try {
480
+ const info = await element.evaluate((el) => ({
481
+ fileInput: el instanceof HTMLInputElement && el.type === "file",
482
+ directory: el.hasAttribute("webkitdirectory"),
483
+ multiple: el.hasAttribute("multiple")
484
+ }));
485
+ if (!info.fileInput || info.directory) throw new Error("Target must be a file input (directory inputs are not supported).");
486
+ if (Array.isArray(input.files) && input.files.length > 1 && !info.multiple) throw new Error("This file input does not accept multiple files.");
487
+ this.uploads.begin(input.uploadId, tabId, input.files, element);
488
+ return { tabId, uploadId: input.uploadId };
489
+ } catch (error) {
490
+ await element.dispose().catch(() => void 0);
491
+ throw error;
492
+ }
493
+ }
494
+ case "upload_chunk":
495
+ this.uploads.append(input.uploadId, tabId, input.index, input.offset, input.base64);
496
+ return { tabId, uploadId: input.uploadId };
497
+ case "upload_attach": {
498
+ const upload = this.uploads.ready(input.uploadId, tabId, input.hashes);
499
+ if (!await upload.target.evaluate((el) => el.isConnected && el instanceof HTMLInputElement && el.type === "file")) {
500
+ throw new Error("The file input was removed or changed during the upload. Read the page again and retry.");
501
+ }
502
+ await upload.target.setInputFiles(upload.paths, { timeout: REF_ACTION_TIMEOUT_MS });
503
+ this.uploads.attached(input.uploadId, tabId);
504
+ return { tabId, files: upload.files };
505
+ }
460
506
  case "form_input": {
461
507
  const ref = requireRef(input.ref);
462
508
  const provided = ["value", "values", "checked"].filter((key) => input[key] !== void 0);
package/dist/mjs/main.mjs CHANGED
@@ -137,7 +137,8 @@ async function connectLoop(params) {
137
137
  formInput: true,
138
138
  typedKeys: true,
139
139
  screenshotFullPage: true,
140
- viewportSizing: true
140
+ viewportSizing: true,
141
+ fileUploads: true
141
142
  }
142
143
  },
143
144
  tabs: await params.runtime.listTabs(),
@@ -190,6 +191,11 @@ async function connectLoop(params) {
190
191
  }
191
192
  if (message.type !== "operation") return;
192
193
  try {
194
+ if (["upload_begin", "upload_chunk", "upload_abort"].includes(message.action)) {
195
+ const result2 = await params.runtime.execute(message.action, message.input);
196
+ socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result: result2 }));
197
+ return;
198
+ }
193
199
  const beforeTabs = await params.runtime.listTabs();
194
200
  const operationResult = await params.runtime.execute(message.action, message.input);
195
201
  const tabs = await params.runtime.listTabs();
@@ -309,7 +315,9 @@ async function start(options) {
309
315
  }
310
316
  try {
311
317
  const profilePath = launch.profilePath;
312
- const runtime = new BrowserRuntime(launch.context, downloadsPath);
318
+ const uploadsPath = path.join(browserRoot(), "uploads");
319
+ fs.rmSync(uploadsPath, { recursive: true, force: true });
320
+ const runtime = new BrowserRuntime(launch.context, downloadsPath, uploadsPath);
313
321
  const instanceId = loadInstanceId();
314
322
  const portForwards = new BrowserPortForwardManager(baseUrl, token, instanceId);
315
323
  process.stdout.write(`[r5d-browser] profile: ${profilePath}
@@ -368,6 +376,7 @@ async function start(options) {
368
376
  } catch (error) {
369
377
  cleanupError ??= error;
370
378
  }
379
+ runtime.closeUploads();
371
380
  await controlStopped;
372
381
  for (const [signal, handler] of signalHandlers) processEvents.off(signal, handler);
373
382
  if (outcome.exitCode !== 0) throw new Error(outcome.message ?? "Google Chrome stopped unexpectedly.");
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
4
4
  "type": "module"
5
5
  }
@@ -0,0 +1,102 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { decodeUploadChunk, validateUploadFile } from "@ricsam/r5d-api/browser-upload";
5
+ class BrowserUploadStore {
6
+ constructor(root, releaseTarget = () => void 0) {
7
+ this.releaseTarget = releaseTarget;
8
+ fs.mkdirSync(root, { recursive: true, mode: 448 });
9
+ this.directory = fs.mkdtempSync(path.join(root, "run-"));
10
+ }
11
+ releaseTarget;
12
+ directory;
13
+ uploads = /* @__PURE__ */ new Map();
14
+ begin(id, tabId, files, target) {
15
+ if (typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id) || this.uploads.has(id)) throw new Error("Invalid or duplicate upload id.");
16
+ if (!Array.isArray(files) || files.length === 0 || files.length > 100) throw new Error("Upload requires between 1 and 100 files.");
17
+ const metadata = files.map(validateUploadFile);
18
+ const directory = fs.mkdtempSync(path.join(this.directory, "upload-"));
19
+ try {
20
+ const staged = metadata.map((file, index) => {
21
+ const parent = path.join(directory, String(index));
22
+ fs.mkdirSync(parent, { mode: 448 });
23
+ const filePath = path.join(parent, file.filename);
24
+ fs.closeSync(fs.openSync(filePath, "wx", 384));
25
+ return { ...file, path: filePath, offset: 0, hash: createHash("sha256") };
26
+ });
27
+ const timer = setTimeout(() => this.abort(id, tabId), 5 * 60 * 1e3);
28
+ timer.unref();
29
+ this.uploads.set(id, { tabId, directory, files: staged, target, attached: false, timer });
30
+ } catch (error) {
31
+ fs.rmSync(directory, { recursive: true, force: true });
32
+ throw error;
33
+ }
34
+ }
35
+ get(id, tabId) {
36
+ const upload = typeof id === "string" ? this.uploads.get(id) : void 0;
37
+ if (!upload || upload.tabId !== tabId) throw new Error("Upload is missing, expired, or belongs to another tab.");
38
+ if (upload.attached) throw new Error("Upload has already been attached.");
39
+ upload.timer.refresh();
40
+ return upload;
41
+ }
42
+ append(id, tabId, index, offset, base64) {
43
+ const upload = this.get(id, tabId);
44
+ const file = typeof index === "number" && Number.isInteger(index) ? upload.files[index] : void 0;
45
+ if (!file || offset !== file.offset) throw new Error("Invalid upload file index or chunk offset.");
46
+ const bytes = decodeUploadChunk(base64);
47
+ if (bytes.length === 0 || file.offset + bytes.length > file.size)
48
+ throw new Error("Upload chunk does not match the declared file size.");
49
+ const fd = fs.openSync(file.path, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
50
+ try {
51
+ let written = 0;
52
+ while (written < bytes.length) {
53
+ const count = fs.writeSync(fd, bytes, written, bytes.length - written, file.offset + written);
54
+ if (!count) throw new Error("Could not write upload chunk.");
55
+ written += count;
56
+ }
57
+ } finally {
58
+ fs.closeSync(fd);
59
+ }
60
+ file.hash.update(bytes);
61
+ file.offset += bytes.length;
62
+ }
63
+ ready(id, tabId, hashes) {
64
+ const upload = this.get(id, tabId);
65
+ if (!Array.isArray(hashes) || hashes.length !== upload.files.length) throw new Error("Upload requires a checksum for every file.");
66
+ for (const [index, file] of upload.files.entries()) {
67
+ if (file.offset !== file.size || file.hash.copy().digest("hex") !== hashes[index])
68
+ throw new Error("Upload is incomplete or its checksum does not match.");
69
+ }
70
+ return {
71
+ target: upload.target,
72
+ paths: upload.files.map((file) => file.path),
73
+ files: upload.files.map(({ filename, size }) => ({ filename, size }))
74
+ };
75
+ }
76
+ attached(id, tabId) {
77
+ const upload = this.get(id, tabId);
78
+ upload.attached = true;
79
+ clearTimeout(upload.timer);
80
+ }
81
+ abort(id, tabId) {
82
+ const upload = this.uploads.get(id);
83
+ if (!upload || upload.tabId !== tabId || upload.attached) return;
84
+ this.remove(id, upload);
85
+ }
86
+ remove(id, upload) {
87
+ clearTimeout(upload.timer);
88
+ this.uploads.delete(id);
89
+ this.releaseTarget(upload.target);
90
+ fs.rmSync(upload.directory, { recursive: true, force: true });
91
+ }
92
+ closeTab(tabId) {
93
+ for (const [id, upload] of this.uploads) if (upload.tabId === tabId) this.remove(id, upload);
94
+ }
95
+ close() {
96
+ for (const [id, upload] of this.uploads) this.remove(id, upload);
97
+ fs.rmSync(this.directory, { recursive: true, force: true });
98
+ }
99
+ }
100
+ export {
101
+ BrowserUploadStore
102
+ };
@@ -67,6 +67,26 @@ declare const browserInputFields: {
67
67
  required: string[];
68
68
  optional: never[];
69
69
  };
70
+ upload_files: {
71
+ required: string[];
72
+ optional: string[];
73
+ };
74
+ upload_begin: {
75
+ required: string[];
76
+ optional: string[];
77
+ };
78
+ upload_chunk: {
79
+ required: string[];
80
+ optional: never[];
81
+ };
82
+ upload_attach: {
83
+ required: string[];
84
+ optional: never[];
85
+ };
86
+ upload_abort: {
87
+ required: string[];
88
+ optional: never[];
89
+ };
70
90
  port_forward: {
71
91
  required: string[];
72
92
  optional: string[];
@@ -1,5 +1,5 @@
1
1
  import type { BrowserContext } from "playwright-core";
2
- export type BrowserAction = "list_tabs" | "list_downloads" | "get_download" | "open_tab" | "close_tab" | "navigate" | "set_viewport" | "read_page" | "screenshot" | "move_mouse" | "mouse_click" | "scroll" | "wait_for" | "form_input" | "keyboard" | "keyboard_type" | "run_js";
2
+ export type BrowserAction = "list_tabs" | "list_downloads" | "get_download" | "open_tab" | "close_tab" | "navigate" | "set_viewport" | "read_page" | "screenshot" | "move_mouse" | "mouse_click" | "scroll" | "wait_for" | "form_input" | "keyboard" | "keyboard_type" | "run_js" | "upload_begin" | "upload_chunk" | "upload_attach" | "upload_abort";
3
3
  export type TabInfo = {
4
4
  tabId: string;
5
5
  windowId: string;
@@ -19,8 +19,9 @@ export declare class BrowserRuntime {
19
19
  private readonly pages;
20
20
  private readonly cursor;
21
21
  private readonly viewportSessions;
22
+ private readonly uploads;
22
23
  private downloadSaveQueue;
23
- constructor(context: BrowserContext, downloadsPath: string);
24
+ constructor(context: BrowserContext, downloadsPath: string, uploadsPath?: string);
24
25
  private track;
25
26
  private safeDownloadFilename;
26
27
  private availableDownloadPath;
@@ -36,5 +37,6 @@ export declare class BrowserRuntime {
36
37
  listTabs(): Promise<TabInfo[]>;
37
38
  drainDownloads(): Promise<void>;
38
39
  private setViewport;
40
+ closeUploads(): void;
39
41
  execute(action: BrowserAction, input: Record<string, unknown>): Promise<unknown>;
40
42
  }
@@ -0,0 +1,21 @@
1
+ import { type BrowserUploadFile } from "@ricsam/r5d-api/browser-upload";
2
+ /** Owns only private, generated directories. Source paths are never accepted on the browser host. */
3
+ export declare class BrowserUploadStore<T> {
4
+ private readonly releaseTarget;
5
+ private readonly directory;
6
+ private readonly uploads;
7
+ constructor(root: string, releaseTarget?: (target: T) => void);
8
+ begin(id: string, tabId: string, files: unknown, target: T): void;
9
+ private get;
10
+ append(id: unknown, tabId: string, index: unknown, offset: unknown, base64: unknown): void;
11
+ ready(id: unknown, tabId: string, hashes: unknown): {
12
+ target: T;
13
+ paths: string[];
14
+ files: BrowserUploadFile[];
15
+ };
16
+ attached(id: string, tabId: string): void;
17
+ abort(id: string, tabId: string): void;
18
+ private remove;
19
+ closeTab(tabId: string): void;
20
+ close(): void;
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",
@@ -21,6 +21,7 @@
21
21
  "r5d-browser": "dist/cjs/main.cjs"
22
22
  },
23
23
  "dependencies": {
24
+ "@ricsam/r5d-api": "^0.0.127",
24
25
  "playwright-core": "1.62.1",
25
26
  "ws": "^8.18.3"
26
27
  },