@stubber/virtual-worker 1.0.2 → 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 CHANGED
@@ -1 +1,5 @@
1
- FILESERVER_URL="https://uploads.secure-link.services.dev.stubber.com/api/v1"
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
+ };
@@ -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
+ };
@@ -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 CHANGED
@@ -1,3 +1,6 @@
1
1
  export const config = {
2
2
  fileserver_url: process.env.FILESERVER_URL || "https://uploads.secure-link.services.stubber.com/api/v1",
3
+ api_key: process.env.API_KEY,
4
+ worker_name: process.env.WORKER_NAME || "unnamed_worker",
5
+ vnc_origin: process.env.VNC_ORIGIN,
3
6
  };
@@ -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 "#root/app/controllers/virtual_worker_status.js";
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(body_parser.json());
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
  };
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
 
3
3
  export const stubber_schema = z.looseObject({
4
4
  orguuid: z.uuid(),
5
+ stubref: z.string().min(1),
5
6
  });
6
7
 
7
8
  export const task_schema = z.looseObject({
@@ -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 "#root/app/controllers/virtual_worker_status.js";
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
- // handle zod validation errors
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("/virtual_worker_status", async (req, res) => {
45
- const result = await virtual_worker_status({});
46
- res.status(200).json(result);
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.2",
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": {
@@ -41,6 +41,7 @@
41
41
  "playwright": "^1.53.0",
42
42
  "socket.io-client": "^4.8.1",
43
43
  "uuid": "^9.0.0",
44
+ "ws": "^8.18.3",
44
45
  "zod": "^4.1.12"
45
46
  },
46
47
  "devDependencies": {
package/.env_devmaster DELETED
@@ -1 +0,0 @@
1
- FILESERVER_URL="https://uploads.secure-link.services.dev.stubber.com/api/v1"