@ricsam/r5d-browser 0.0.125 → 0.0.126
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 -0
- package/dist/cjs/browser-input.cjs +5 -0
- package/dist/cjs/browser-runtime.cjs +47 -1
- package/dist/cjs/main.cjs +11 -2
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/upload-store.cjs +136 -0
- package/dist/mjs/browser-input.mjs +5 -0
- package/dist/mjs/browser-runtime.mjs +47 -1
- package/dist/mjs/main.mjs +11 -2
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/upload-store.mjs +102 -0
- package/dist/types/browser-input.d.ts +20 -0
- package/dist/types/browser-runtime.d.ts +4 -2
- package/dist/types/upload-store.d.ts +21 -0
- package/package.json +2 -1
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
|
|
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.");
|
package/dist/cjs/package.json
CHANGED
|
@@ -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
|
|
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.");
|
package/dist/mjs/package.json
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.0.126",
|
|
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.126",
|
|
24
25
|
"playwright-core": "1.62.1",
|
|
25
26
|
"ws": "^8.18.3"
|
|
26
27
|
},
|