@stubber/virtual-worker 1.0.1 → 1.0.3
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/.env_dev +5 -14
- package/app/commands/browser/browser_extract_html.js +38 -0
- package/app/commands/browser/browser_screenshot.js +59 -0
- package/app/commands/file-server/download_files.js +48 -32
- package/app/commands/file-server/upload_files.js +75 -24
- package/app/commands/index.js +4 -0
- package/app/controllers/virtual_worker_list.js +37 -0
- package/app/controllers/virtual_worker_task.js +2 -5
- package/app/functions/create_error_technical.js +1 -1
- package/app/helpers/get_chromium_page.js +0 -17
- package/config/main.js +6 -0
- package/lib/interfaces/http/index.js +92 -3
- package/lib/interfaces/http/routers/stubber_task_schema.js +4 -3
- package/lib/interfaces/http/routers/task_gateway.js +40 -19
- package/package.json +4 -1
- package/.env_devmaster +0 -7
package/.env_dev
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
WORKER_CONNECT_URL="https://virtual-workers.dev.stubber.com/orgs/7cf3b2dc-5a49-51dd-8b4d-812a8ba6f233"
|
|
7
|
-
|
|
8
|
-
WORKER_SIGNATURE=069c293dc0d71dd42b32b122da62b2deae08c12c67a85224d14a7dd8c4907f21
|
|
9
|
-
|
|
10
|
-
WORKER_WORKERUUID=b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
|
|
11
|
-
|
|
12
|
-
WORKER_GROUPS=insurance-worker,b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
|
|
13
|
-
|
|
14
|
-
NODE_ENV=development
|
|
1
|
+
FILESERVER_URL=https://uploads.secure-link.services.dev.stubber.com/api/v1
|
|
2
|
+
API_KEY=123-456-789
|
|
3
|
+
WORKER_NAME=worker-01
|
|
4
|
+
VNC_ORIGIN=ws://localhost:3000
|
|
5
|
+
NODE_ENV=development
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
|
|
2
|
+
import { create_error_technical } from "#app/functions/create_error_technical.js";
|
|
3
|
+
import { create_success } from "#root/app/functions/create_success.js";
|
|
4
|
+
import { get_chromium_page } from "../../helpers/get_chromium_page.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} params
|
|
9
|
+
* @param {string} [params.locator] - Optional locator to extract HTML from a specific element
|
|
10
|
+
*/
|
|
11
|
+
export const browser_extract_html = async (params, stubber_context) => {
|
|
12
|
+
// Get the Chromium page
|
|
13
|
+
const page_result = await get_chromium_page(stubber_context.stubref);
|
|
14
|
+
if (!page_result.success) {
|
|
15
|
+
return page_result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const page = page_result.payload;
|
|
19
|
+
let html_content = "";
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const { locator } = params || {};
|
|
23
|
+
|
|
24
|
+
if (locator) {
|
|
25
|
+
const target = page.locator(locator);
|
|
26
|
+
if (!(await target.count())) {
|
|
27
|
+
return create_error_conceptual({ message: "Locator did not match any elements", details: { locator } });
|
|
28
|
+
}
|
|
29
|
+
html_content = await target.evaluate((el) => el.outerHTML);
|
|
30
|
+
} else {
|
|
31
|
+
html_content = await page.content();
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return create_error_technical(error);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return create_success({ message: `HTML content retrieved successfully`, payload: { html_content } });
|
|
38
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { get_chromium_page } from "#app/helpers/get_chromium_page.js";
|
|
2
|
+
import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
|
|
3
|
+
import { upload_files } from "../file-server/upload_files.js";
|
|
4
|
+
import { create_success } from "#app/functions/create_success.js";
|
|
5
|
+
import { create_error_technical } from "#app/functions/create_error_technical.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} params
|
|
10
|
+
* @param {import('playwright').PageScreenshotOptions} [params.screenshot_options] - Optional screenshot options
|
|
11
|
+
* @param {string} [params.locator] - Optional locator to screenshot a specific element
|
|
12
|
+
* @returns
|
|
13
|
+
*/
|
|
14
|
+
export const browser_screenshot = async ({ screenshot_options, locator }, stubber_context) => {
|
|
15
|
+
const page_result = await get_chromium_page(stubber_context.stubref);
|
|
16
|
+
if (!page_result.success) {
|
|
17
|
+
return page_result;
|
|
18
|
+
}
|
|
19
|
+
const page = page_result.payload;
|
|
20
|
+
|
|
21
|
+
if (!screenshot_options || typeof screenshot_options !== "object") {
|
|
22
|
+
screenshot_options = {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
if (!screenshot_options.path) {
|
|
27
|
+
const tmp_path = `/tmp/screenshot_${Date.now()}.png`;
|
|
28
|
+
screenshot_options.path = tmp_path;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let target = page;
|
|
32
|
+
|
|
33
|
+
if (locator) {
|
|
34
|
+
target = page.locator(locator);
|
|
35
|
+
if (!(await target.count())) {
|
|
36
|
+
return create_error_conceptual({ message: "Locator did not match any elements", details: { locator } });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await target.screenshot(screenshot_options);
|
|
41
|
+
|
|
42
|
+
// upload the screenshot to the file server
|
|
43
|
+
const upload_result = await upload_files({ files: [screenshot_options.path] }, stubber_context);
|
|
44
|
+
|
|
45
|
+
if (!upload_result.success) {
|
|
46
|
+
return upload_result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const uploaded_files = upload_result.payload.uploaded_files;
|
|
50
|
+
const file_info = uploaded_files.length > 0 ? uploaded_files[0] : null;
|
|
51
|
+
|
|
52
|
+
return create_success({
|
|
53
|
+
message: "Screenshot captured successfully",
|
|
54
|
+
payload: { screenshot: { path: screenshot_options.path, file_info } },
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return create_error_technical(error);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -5,10 +5,20 @@ import { create_success } from "#app/functions/create_success.js";
|
|
|
5
5
|
import { writeFile } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { config } from "#root/config/main.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef FileInfoObject
|
|
12
|
+
* @property {string} fileuuid - The UUID of the file
|
|
13
|
+
* @property {string} filename - The name of the file
|
|
14
|
+
* @property {string} originalname - The original name of the file
|
|
15
|
+
* @property {string} contentType - The content type of the file
|
|
16
|
+
* @property {string} f_id - The file ID added by form fields
|
|
17
|
+
*/
|
|
8
18
|
|
|
9
19
|
/**
|
|
10
20
|
* @typedef DownloadFilesParams
|
|
11
|
-
* @property {Array<string|
|
|
21
|
+
* @property {Array<string|FileInfoObject>} files - The files to download
|
|
12
22
|
*/
|
|
13
23
|
|
|
14
24
|
/**
|
|
@@ -23,11 +33,11 @@ import { join } from "node:path";
|
|
|
23
33
|
* @param {DownloadFilesParams} params
|
|
24
34
|
*/
|
|
25
35
|
export const download_files = async (params, _stubber) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return create_error_conceptual({ message: "Socket not available", details: {} });
|
|
36
|
+
const fileserver_url = config?.fileserver_url;
|
|
37
|
+
if (!fileserver_url) {
|
|
38
|
+
return create_error_conceptual({ message: "Fileserver URL not configured", details: {} });
|
|
30
39
|
}
|
|
40
|
+
console.log("Fileserver URL:", fileserver_url);
|
|
31
41
|
|
|
32
42
|
const { files } = params || {};
|
|
33
43
|
if (!files) {
|
|
@@ -37,44 +47,50 @@ export const download_files = async (params, _stubber) => {
|
|
|
37
47
|
return create_error_conceptual({ message: "Files must be a non-empty array", details: { files } });
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
|
|
50
|
+
/**
|
|
51
|
+
* @type {Array<{fileuuid:string, filename:string}>}
|
|
52
|
+
*/
|
|
53
|
+
const files_to_download = [];
|
|
41
54
|
for (const file of files) {
|
|
42
55
|
if (typeof file === "string") {
|
|
43
|
-
|
|
56
|
+
files_to_download.push({ fileuuid: file, filename: file });
|
|
44
57
|
} else if (file && typeof file === "object" && typeof file.fileuuid === "string") {
|
|
45
|
-
|
|
58
|
+
files_to_download.push({
|
|
59
|
+
fileuuid: file.fileuuid,
|
|
60
|
+
filename: file.filename || file.originalname || file.fileuuid,
|
|
61
|
+
});
|
|
46
62
|
} else {
|
|
47
63
|
return create_error_conceptual({ message: "Invalid file entry in files array", details: { entry: file } });
|
|
48
64
|
}
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
const downloaded_files = [];
|
|
67
|
+
const downloaded_files = [];
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
/** @type {ResponseFile[]} */
|
|
57
|
-
const files = response.payload;
|
|
69
|
+
for (const { fileuuid, filename } of files_to_download) {
|
|
70
|
+
const file_url = `${fileserver_url}/file/uuid/${encodeURIComponent(fileuuid)}`;
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// todo: support passing download location in params
|
|
62
|
-
const tmp_dir = tmpdir();
|
|
63
|
-
const file_path = join(tmp_dir, file.fileuuid);
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(file_url);
|
|
64
74
|
|
|
65
|
-
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
console.log("Error downloading file:", { fileuuid, status: response.status, status_text: response.statusText });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
66
79
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
console.log("Error downloading file:", file);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
80
|
+
const array_buffer = await response.arrayBuffer();
|
|
81
|
+
const data = Buffer.from(array_buffer);
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
// todo: support passing download location in params
|
|
84
|
+
const tmp_dir = tmpdir();
|
|
85
|
+
const file_path = join(tmp_dir, filename);
|
|
86
|
+
|
|
87
|
+
await writeFile(file_path, data);
|
|
88
|
+
|
|
89
|
+
downloaded_files.push(file_path);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.log("Error downloading file:", { fileuuid, error });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return create_success({ message: "Files downloaded successfully", payload: { downloaded_files } });
|
|
80
96
|
};
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
|
|
2
2
|
import { create_error_technical } from "#app/functions/create_error_technical.js";
|
|
3
3
|
import { create_success } from "#app/functions/create_success.js";
|
|
4
|
+
import { config } from "#root/config/main.js";
|
|
4
5
|
// eslint-disable-next-line id-match
|
|
5
6
|
import { readFile } from "node:fs/promises";
|
|
7
|
+
// eslint-disable-next-line id-match
|
|
8
|
+
import { Blob } from "buffer";
|
|
9
|
+
import mime from "mime-types";
|
|
10
|
+
// eslint-disable-next-line id-match
|
|
11
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* Upload files to the server
|
|
@@ -10,10 +16,15 @@ import { readFile } from "node:fs/promises";
|
|
|
10
16
|
* @param {Array<string>} params.files - The path to files to upload
|
|
11
17
|
*/
|
|
12
18
|
export const upload_files = async (params, _stubber) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
const fileserver_url = config?.fileserver_url;
|
|
20
|
+
if (!fileserver_url) {
|
|
21
|
+
return create_error_conceptual({ message: "Fileserver URL not configured", details: {} });
|
|
22
|
+
}
|
|
23
|
+
console.log("Fileserver URL:", fileserver_url);
|
|
24
|
+
|
|
25
|
+
const orguuid = _stubber?.orguuid;
|
|
26
|
+
if (!orguuid) {
|
|
27
|
+
return create_error_conceptual({ message: "orguuid not available", details: {} });
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
const { files } = params || {};
|
|
@@ -24,34 +35,74 @@ export const upload_files = async (params, _stubber) => {
|
|
|
24
35
|
});
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
const
|
|
38
|
+
const form_data = new FormData();
|
|
39
|
+
const endpoint = `${fileserver_url}/upload?orguuid=${encodeURIComponent(orguuid)}`;
|
|
28
40
|
|
|
29
41
|
for (const filepath of files) {
|
|
30
42
|
const filename = filepath.split("/").pop();
|
|
31
|
-
const file = await readFile(filepath); // Buffer
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
if (!filename) {
|
|
45
|
+
return create_error_conceptual({
|
|
46
|
+
message: "Unable to determine filename from path",
|
|
47
|
+
details: { filepath },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
let mimetype = mime.lookup(filename) || "application/octet-stream";
|
|
35
53
|
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
const file_buffer = await readFile(filepath);
|
|
55
|
+
|
|
56
|
+
const type = await fileTypeFromBuffer(file_buffer);
|
|
57
|
+
if (type?.mime) {
|
|
58
|
+
mimetype = type.mime; // trust buffer over extension
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const blob = new Blob([file_buffer], { type: mimetype });
|
|
62
|
+
form_data.append("file", blob, filename);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return create_error_technical(error);
|
|
38
65
|
}
|
|
39
66
|
}
|
|
40
67
|
|
|
41
|
-
|
|
42
|
-
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(endpoint, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
body: form_data,
|
|
72
|
+
});
|
|
43
73
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!response.success) {
|
|
51
|
-
reject(new Error(response));
|
|
52
|
-
} else {
|
|
53
|
-
resolve(response.payload);
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
let error_payload = null;
|
|
76
|
+
try {
|
|
77
|
+
error_payload = await response.json();
|
|
78
|
+
} catch (_) {
|
|
79
|
+
error_payload = { raw_error: await response.text() };
|
|
54
80
|
}
|
|
81
|
+
|
|
82
|
+
return create_error_conceptual({
|
|
83
|
+
message: "Failed to upload files",
|
|
84
|
+
details: { status: response.status, error_payload },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let payload = null;
|
|
89
|
+
try {
|
|
90
|
+
payload = await response.json();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return create_error_technical(error);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (payload?.data?.uploaded_files) {
|
|
96
|
+
payload = {
|
|
97
|
+
uploaded_files: payload.data.uploaded_files,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return create_success({
|
|
102
|
+
message: "Files uploaded successfully",
|
|
103
|
+
payload,
|
|
55
104
|
});
|
|
56
|
-
})
|
|
57
|
-
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return create_error_technical(error);
|
|
107
|
+
}
|
|
108
|
+
};
|
package/app/commands/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { browser_get_localstorage } from "./browser/browser_get_localstorage.js"
|
|
|
4
4
|
import { browser_navigate } from "./browser/browser_navigate.js";
|
|
5
5
|
import { browser_write_text } from "./browser/browser_write_text.js";
|
|
6
6
|
import { browser_press_key } from "./browser/browser_press_key.js";
|
|
7
|
+
import { browser_extract_html } from "./browser/browser_extract_html.js";
|
|
8
|
+
import { browser_screenshot } from "./browser/browser_screenshot.js";
|
|
7
9
|
import { cli_run } from "./cli/cli_run.js";
|
|
8
10
|
|
|
9
11
|
import { upload_files } from "./file-server/upload_files.js";
|
|
@@ -16,6 +18,8 @@ const all_commands = {
|
|
|
16
18
|
browser_navigate,
|
|
17
19
|
browser_write_text,
|
|
18
20
|
browser_press_key,
|
|
21
|
+
browser_extract_html,
|
|
22
|
+
browser_screenshot,
|
|
19
23
|
|
|
20
24
|
cli_run,
|
|
21
25
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { task_schema } from "#lib/interfaces/http/routers/stubber_task_schema.js";
|
|
2
|
+
import { config } from "#root/config/main.js";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { create_success } from "../functions/create_success.js";
|
|
5
|
+
import { virtual_worker_status } from "./virtual_worker_status.js";
|
|
6
|
+
|
|
7
|
+
// const params_schema = z.looseObject({});
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lists all virtual workers available. Mostly these will just list themselves as the solo worker.
|
|
11
|
+
* However stubber-virtual-worker-k8s-router has the same implementation but lists all workers in the k8s cluster.
|
|
12
|
+
* @param {z.infer<typeof task_schema>} task
|
|
13
|
+
*/
|
|
14
|
+
export const virtual_worker_list = async (task) => {
|
|
15
|
+
let vnc_url = `${config.vnc_origin}/vnc?worker_name=${encodeURIComponent(config.worker_name)}&orguuid=${
|
|
16
|
+
task._stubber.orguuid
|
|
17
|
+
}`;
|
|
18
|
+
|
|
19
|
+
if (config.api_key) {
|
|
20
|
+
vnc_url += `&api_key=${config.api_key}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const task_result = create_success({
|
|
24
|
+
message: "Virtual worker list retrieved successfully",
|
|
25
|
+
payload: {
|
|
26
|
+
workers: [
|
|
27
|
+
{
|
|
28
|
+
worker_name: config.worker_name,
|
|
29
|
+
status: await virtual_worker_status({}),
|
|
30
|
+
vnc_url,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return task_result;
|
|
37
|
+
};
|
|
@@ -3,7 +3,7 @@ import { task_schema } from "#lib/interfaces/http/routers/stubber_task_schema.js
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { worker_status } from "./virtual_worker_status.js";
|
|
5
5
|
|
|
6
|
-
const command_schema = z.
|
|
6
|
+
const command_schema = z.looseObject({
|
|
7
7
|
commandtype: z.string().min(1),
|
|
8
8
|
params: z.record(z.string(), z.any()).optional(),
|
|
9
9
|
continue_on_error: z.boolean().optional(),
|
|
@@ -11,11 +11,8 @@ const command_schema = z.object({
|
|
|
11
11
|
conditions: z.array(z.string()).optional(),
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
const params_schema = z.
|
|
14
|
+
const params_schema = z.looseObject({
|
|
15
15
|
commands: z.record(z.string(), command_schema),
|
|
16
|
-
worker: z.object({
|
|
17
|
-
group: z.string().min(1),
|
|
18
|
-
}),
|
|
19
16
|
});
|
|
20
17
|
|
|
21
18
|
/**
|
|
@@ -11,7 +11,7 @@ export const create_error_technical = (error) => {
|
|
|
11
11
|
details: {
|
|
12
12
|
name: error.name,
|
|
13
13
|
// stack up to the second \n newline
|
|
14
|
-
stack: error.stack
|
|
14
|
+
stack: error.stack?.split("\n").slice(0, 2).join("\n"),
|
|
15
15
|
},
|
|
16
16
|
},
|
|
17
17
|
};
|
|
@@ -11,7 +11,6 @@ let browser;
|
|
|
11
11
|
* last_used: number
|
|
12
12
|
}>} */
|
|
13
13
|
const contexts = {};
|
|
14
|
-
const CONTEXT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* @param {string} stubref
|
|
@@ -22,19 +21,6 @@ export const get_chromium_page = async (stubref) => {
|
|
|
22
21
|
return create_error_conceptual({ message: "stubref is required to get a Chromium page." });
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
// Cleanup unused contexts
|
|
26
|
-
const now = Date.now();
|
|
27
|
-
for (const [key, value] of Object.entries(contexts)) {
|
|
28
|
-
if (value.last_used && now - value.last_used > CONTEXT_TIMEOUT_MS) {
|
|
29
|
-
try {
|
|
30
|
-
await value.context.close();
|
|
31
|
-
} catch (e) {
|
|
32
|
-
// ignore errors during cleanup
|
|
33
|
-
}
|
|
34
|
-
delete contexts[key];
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
24
|
// Launch the browser once and reuse it
|
|
39
25
|
if (!browser) {
|
|
40
26
|
browser = await chromium.launch({
|
|
@@ -52,10 +38,7 @@ export const get_chromium_page = async (stubref) => {
|
|
|
52
38
|
});
|
|
53
39
|
contexts[stubref] = {
|
|
54
40
|
context,
|
|
55
|
-
last_used: now,
|
|
56
41
|
};
|
|
57
|
-
} else {
|
|
58
|
-
contexts[stubref].last_used = now;
|
|
59
42
|
}
|
|
60
43
|
|
|
61
44
|
const context = contexts[stubref].context;
|
package/config/main.js
ADDED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import body_parser from "body-parser";
|
|
2
2
|
import express from "express";
|
|
3
|
+
// eslint-disable-next-line id-match
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import net from "net";
|
|
3
6
|
import task_gateway_router from "./routers/task_gateway.js";
|
|
4
|
-
import { virtual_worker_status } from "#
|
|
7
|
+
import { virtual_worker_status } from "#app/controllers/virtual_worker_status.js";
|
|
8
|
+
import { config } from "#root/config/main.js";
|
|
9
|
+
import { create_error_conceptual } from "#root/app/functions/create_error_conceptual.js";
|
|
5
10
|
|
|
6
11
|
export const start_http_server = () => {
|
|
7
12
|
const app = express();
|
|
8
|
-
app.use(
|
|
13
|
+
app.use(
|
|
14
|
+
body_parser.json({
|
|
15
|
+
limit: "10mb",
|
|
16
|
+
})
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// add APIKEY middleware
|
|
20
|
+
if (config.api_key) {
|
|
21
|
+
app.use((req, res, next) => {
|
|
22
|
+
const incoming_apikey = req.headers["stubber-virtual-worker-apikey"];
|
|
23
|
+
|
|
24
|
+
if (incoming_apikey !== config.api_key) {
|
|
25
|
+
return res.status(403).json(
|
|
26
|
+
create_error_conceptual({
|
|
27
|
+
message: "Invalid API Key",
|
|
28
|
+
details: {
|
|
29
|
+
provided_apikey: incoming_apikey,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
next();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
// ROUTERS
|
|
11
40
|
app.use("/api/v1/task-gateway", task_gateway_router);
|
|
@@ -17,7 +46,67 @@ export const start_http_server = () => {
|
|
|
17
46
|
});
|
|
18
47
|
|
|
19
48
|
const PORT = process.env.HTTP_PORT || 3000;
|
|
20
|
-
app.listen(PORT, () => {
|
|
49
|
+
const server = app.listen(PORT, () => {
|
|
21
50
|
console.log(`HTTP server is running on port ${PORT}`);
|
|
22
51
|
});
|
|
52
|
+
|
|
53
|
+
// WebSocket server for VNC proxy
|
|
54
|
+
const wss = new WebSocketServer({ server, path: "/vnc" });
|
|
55
|
+
const VNC_HOST = process.env.VNC_HOST || "localhost";
|
|
56
|
+
const VNC_PORT = process.env.VNC_PORT || 5900;
|
|
57
|
+
|
|
58
|
+
wss.on("connection", (ws, req) => {
|
|
59
|
+
console.log("WebSocket client connected to VNC proxy");
|
|
60
|
+
|
|
61
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
62
|
+
const incoming_apikey = url.searchParams.get("api_key");
|
|
63
|
+
|
|
64
|
+
if (incoming_apikey !== config.api_key) {
|
|
65
|
+
console.log("Invalid VNC API key, closing connection");
|
|
66
|
+
ws.close();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Create TCP connection to VNC server
|
|
71
|
+
const vnc_socket = net.createConnection(VNC_PORT, VNC_HOST, () => {
|
|
72
|
+
console.log(`Connected to VNC server at ${VNC_HOST}:${VNC_PORT}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Proxy data from VNC server to WebSocket client
|
|
76
|
+
vnc_socket.on("data", (data) => {
|
|
77
|
+
if (ws.readyState === ws.OPEN) {
|
|
78
|
+
ws.send(data);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Proxy data from WebSocket client to VNC server
|
|
83
|
+
ws.on("message", (data) => {
|
|
84
|
+
vnc_socket.write(data);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Handle WebSocket close
|
|
88
|
+
ws.on("close", () => {
|
|
89
|
+
console.log("WebSocket client disconnected");
|
|
90
|
+
vnc_socket.end();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Handle VNC socket close
|
|
94
|
+
vnc_socket.on("close", () => {
|
|
95
|
+
console.log("VNC connection closed");
|
|
96
|
+
ws.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Handle errors
|
|
100
|
+
ws.on("error", (err) => {
|
|
101
|
+
console.error("WebSocket error:", err);
|
|
102
|
+
vnc_socket.end();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
vnc_socket.on("error", (err) => {
|
|
106
|
+
console.error("VNC socket error:", err);
|
|
107
|
+
ws.close();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log(`WebSocket VNC proxy listening on ws://localhost:${PORT}/vnc`);
|
|
23
112
|
};
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
|
|
3
|
-
export const stubber_schema = z.
|
|
3
|
+
export const stubber_schema = z.looseObject({
|
|
4
4
|
orguuid: z.uuid(),
|
|
5
|
+
stubref: z.string().min(1),
|
|
5
6
|
});
|
|
6
7
|
|
|
7
|
-
export const task_schema = z.
|
|
8
|
+
export const task_schema = z.looseObject({
|
|
8
9
|
tasktype: z.string().min(1),
|
|
9
10
|
task_name: z.string().min(1),
|
|
10
11
|
params: z.any(),
|
|
11
12
|
_stubber: stubber_schema,
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
export const task_payload_schema = z.
|
|
15
|
+
export const task_payload_schema = z.looseObject({
|
|
15
16
|
task: task_schema,
|
|
16
17
|
});
|
|
@@ -5,11 +5,12 @@ import { create_error_technical } from "#app/functions/create_error_technical.js
|
|
|
5
5
|
import { Router } from "express";
|
|
6
6
|
import * as z from "zod";
|
|
7
7
|
import { task_payload_schema } from "./stubber_task_schema.js";
|
|
8
|
-
import { virtual_worker_status } from "#
|
|
8
|
+
import { virtual_worker_status } from "#app/controllers/virtual_worker_status.js";
|
|
9
|
+
import { virtual_worker_list } from "#app/controllers/virtual_worker_list.js";
|
|
9
10
|
|
|
10
11
|
const router = Router();
|
|
11
12
|
|
|
12
|
-
router.post("/virtual_worker_task", async (req, res) => {
|
|
13
|
+
router.post(["/virtual_worker_task", "/virtual_worker_send_commands", "/send_commands"], async (req, res) => {
|
|
13
14
|
try {
|
|
14
15
|
const payload = task_payload_schema.parse(req.body);
|
|
15
16
|
const task = payload.task;
|
|
@@ -24,26 +25,46 @@ router.post("/virtual_worker_task", async (req, res) => {
|
|
|
24
25
|
res.status(400).json(result);
|
|
25
26
|
}
|
|
26
27
|
} catch (error) {
|
|
27
|
-
|
|
28
|
-
if (error instanceof z.ZodError) {
|
|
29
|
-
// console.log("Zod validation error:", error);
|
|
30
|
-
res.status(400).json(
|
|
31
|
-
create_error_conceptual({
|
|
32
|
-
message: "Validation error",
|
|
33
|
-
details: {
|
|
34
|
-
errors: JSON.parse(error.message),
|
|
35
|
-
},
|
|
36
|
-
})
|
|
37
|
-
);
|
|
38
|
-
} else {
|
|
39
|
-
res.status(500).json(create_error_technical(error));
|
|
40
|
-
}
|
|
28
|
+
handle_zod_error(error, res);
|
|
41
29
|
}
|
|
42
30
|
});
|
|
43
31
|
|
|
44
|
-
router.post("/
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
router.post("/get_status", async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const payload = task_payload_schema.parse(req.body);
|
|
35
|
+
const task = payload.task;
|
|
36
|
+
const result = await virtual_worker_status(task);
|
|
37
|
+
res.status(200).json(result);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
handle_zod_error(error, res);
|
|
40
|
+
}
|
|
47
41
|
});
|
|
48
42
|
|
|
43
|
+
router.post("/list_workers", async (req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
const payload = task_payload_schema.parse(req.body);
|
|
46
|
+
const task = payload.task;
|
|
47
|
+
const result = await virtual_worker_list(task);
|
|
48
|
+
res.status(200).json(result);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
handle_zod_error(error, res);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const handle_zod_error = (error, res) => {
|
|
55
|
+
if (error instanceof z.ZodError) {
|
|
56
|
+
res.status(400).json(
|
|
57
|
+
create_error_conceptual({
|
|
58
|
+
message: "Validation error",
|
|
59
|
+
details: {
|
|
60
|
+
errors: JSON.parse(error.message),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
res.status(500).json(create_error_technical(error));
|
|
68
|
+
};
|
|
69
|
+
|
|
49
70
|
export default router;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stubber/virtual-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Template to easily create a node app and keep development standards",
|
|
5
5
|
"main": "app.js",
|
|
6
6
|
"directories": {
|
|
@@ -33,12 +33,15 @@
|
|
|
33
33
|
"body-parser": "^1.20.3",
|
|
34
34
|
"dotenv": "^16.0.3",
|
|
35
35
|
"express": "^4.18.2",
|
|
36
|
+
"file-type": "^21.0.0",
|
|
36
37
|
"jsonata": "^2.1.0",
|
|
37
38
|
"lodash-es": "^4.17.21",
|
|
39
|
+
"mime-types": "^3.0.1",
|
|
38
40
|
"net": "^1.0.2",
|
|
39
41
|
"playwright": "^1.53.0",
|
|
40
42
|
"socket.io-client": "^4.8.1",
|
|
41
43
|
"uuid": "^9.0.0",
|
|
44
|
+
"ws": "^8.18.3",
|
|
42
45
|
"zod": "^4.1.12"
|
|
43
46
|
},
|
|
44
47
|
"devDependencies": {
|
package/.env_devmaster
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
WORKER_CONNECT_URL="https://virtual-workers.dev.stubber.com/orgs/7cf3b2dc-5a49-51dd-8b4d-812a8ba6f233"
|
|
2
|
-
|
|
3
|
-
WORKER_SIGNATURE=069c293dc0d71dd42b32b122da62b2deae08c12c67a85224d14a7dd8c4907f21
|
|
4
|
-
|
|
5
|
-
WORKER_WORKERUUID=b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
|
|
6
|
-
|
|
7
|
-
WORKER_GROUPS=insurance-worker,b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
|