synaptic-playwright-sidecar 0.1.0
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 +162 -0
- package/package.json +43 -0
- package/src/server.mjs +553 -0
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Synaptic Playwright Sidecar
|
|
2
|
+
|
|
3
|
+
In-repo browser sidecar used by Synaptic's Elixir planner (`browser_task`).
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
- Synaptic (Elixir) is the planner/brain.
|
|
8
|
+
- This sidecar executes primitive browser actions only.
|
|
9
|
+
- Contract:
|
|
10
|
+
- `POST /session/start`
|
|
11
|
+
- `POST /action`
|
|
12
|
+
- `POST /session/close`
|
|
13
|
+
|
|
14
|
+
## Install and run
|
|
15
|
+
|
|
16
|
+
From the repository root:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cd sidecar/playwright
|
|
20
|
+
npm install
|
|
21
|
+
npm run start
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
By default the server listens on `127.0.0.1:3456`.
|
|
25
|
+
|
|
26
|
+
## Environment variables
|
|
27
|
+
|
|
28
|
+
- `PORT` (default `3456`)
|
|
29
|
+
- `HOST` (default `127.0.0.1`)
|
|
30
|
+
- `BROWSER_TYPE` (`chromium`, `firefox`, `webkit`; default `chromium`)
|
|
31
|
+
- `BROWSER_HEADLESS` (`true`/`false`; default `true`)
|
|
32
|
+
- `BROWSER_HUMAN_EMULATION` (`true`/`false`; default `true`)
|
|
33
|
+
- `BROWSER_CHANNEL` (default none; in human emulation on Chromium defaults to `chrome`)
|
|
34
|
+
- `BROWSER_USER_AGENT` (optional default UA used in human emulation)
|
|
35
|
+
- `BROWSER_LOCALE` (optional default locale used in human emulation)
|
|
36
|
+
- `BROWSER_TIMEZONE_ID` (optional default timezone used in human emulation)
|
|
37
|
+
- `BROWSER_COLOR_SCHEME` (optional default color scheme, e.g. `light` or `dark`)
|
|
38
|
+
- `DEFAULT_ACTION_TIMEOUT_MS` (default `30000`)
|
|
39
|
+
|
|
40
|
+
## API examples
|
|
41
|
+
|
|
42
|
+
### `POST /session/start`
|
|
43
|
+
|
|
44
|
+
Request:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"session_id": "optional-session-id",
|
|
49
|
+
"session_options": {
|
|
50
|
+
"headless": false,
|
|
51
|
+
"viewport": { "width": 1400, "height": 900 },
|
|
52
|
+
"human_emulation": true,
|
|
53
|
+
"user_agent": "optional custom UA",
|
|
54
|
+
"locale": "pl-PL",
|
|
55
|
+
"timezone_id": "Europe/Warsaw",
|
|
56
|
+
"color_scheme": "light",
|
|
57
|
+
"channel": "chrome",
|
|
58
|
+
"storage_state_path": "/absolute/or/relative/path/to/storage-state.json"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Response:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"session_id": "optional-session-id",
|
|
68
|
+
"browser_type": "chromium",
|
|
69
|
+
"headless": false,
|
|
70
|
+
"session_options": {
|
|
71
|
+
"headless": false,
|
|
72
|
+
"viewport": { "width": 1400, "height": 900 },
|
|
73
|
+
"human_emulation": true,
|
|
74
|
+
"user_agent": null,
|
|
75
|
+
"locale": null,
|
|
76
|
+
"timezone_id": null,
|
|
77
|
+
"color_scheme": null,
|
|
78
|
+
"channel": null,
|
|
79
|
+
"storage_state_path": "/absolute/or/relative/path/to/storage-state.json"
|
|
80
|
+
},
|
|
81
|
+
"created_at": "2026-02-21T00:00:00.000Z"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
When `session_options.storage_state_path` is set:
|
|
86
|
+
- `/session/start` loads existing storage state from that path (if file exists).
|
|
87
|
+
- `/session/close` writes current context storage state to that path.
|
|
88
|
+
|
|
89
|
+
### `POST /action`
|
|
90
|
+
|
|
91
|
+
Request:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"session_id": "optional-session-id",
|
|
96
|
+
"action": "navigate",
|
|
97
|
+
"params": {
|
|
98
|
+
"url": "https://example.com"
|
|
99
|
+
},
|
|
100
|
+
"timeout_ms": 15000
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Success response:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"ok": true,
|
|
109
|
+
"action": "navigate",
|
|
110
|
+
"result": {
|
|
111
|
+
"url": "https://example.com/"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Failure response:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"ok": false,
|
|
121
|
+
"action": "navigate",
|
|
122
|
+
"error": {
|
|
123
|
+
"code": "action_timeout",
|
|
124
|
+
"message": "Timeout 15000ms exceeded.",
|
|
125
|
+
"retryable": true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `POST /session/close`
|
|
131
|
+
|
|
132
|
+
Request:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"session_id": "optional-session-id"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Response:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"ok": true,
|
|
145
|
+
"session_id": "optional-session-id"
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Supported action enum (v1)
|
|
150
|
+
|
|
151
|
+
- `navigate`
|
|
152
|
+
- `click`
|
|
153
|
+
- `type`
|
|
154
|
+
- `select`
|
|
155
|
+
- `wait_for`
|
|
156
|
+
- `extract_text`
|
|
157
|
+
- `extract_json`
|
|
158
|
+
- `screenshot`
|
|
159
|
+
- `get_url`
|
|
160
|
+
- `get_title`
|
|
161
|
+
- `press`
|
|
162
|
+
- `scroll`
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "synaptic-playwright-sidecar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "In-repo Playwright sidecar for Synaptic browser actions",
|
|
6
|
+
"author": "bionaut",
|
|
7
|
+
"main": "src/server.mjs",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node src/server.mjs",
|
|
14
|
+
"dev": "node --watch src/server.mjs",
|
|
15
|
+
"check": "node --check src/server.mjs",
|
|
16
|
+
"prepublishOnly": "npm run check"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"playwright": "^1.52.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"synaptic",
|
|
30
|
+
"playwright",
|
|
31
|
+
"browser",
|
|
32
|
+
"sidecar"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/bionaut/synaptic.git",
|
|
37
|
+
"directory": "sidecar/playwright"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/bionaut/synaptic/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/bionaut/synaptic#readme"
|
|
43
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
7
|
+
|
|
8
|
+
const PORT = parseInt(process.env.PORT || "3456", 10);
|
|
9
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
10
|
+
const REQUEST_BODY_LIMIT_BYTES = 2_000_000;
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = parseInt(process.env.DEFAULT_ACTION_TIMEOUT_MS || "30000", 10);
|
|
12
|
+
const BROWSER_TYPE = (process.env.BROWSER_TYPE || "chromium").toLowerCase();
|
|
13
|
+
const HEADLESS = parseBoolean(process.env.BROWSER_HEADLESS, true);
|
|
14
|
+
const HUMAN_EMULATION = parseBoolean(process.env.BROWSER_HUMAN_EMULATION, true);
|
|
15
|
+
const DEFAULT_CHANNEL = process.env.BROWSER_CHANNEL || null;
|
|
16
|
+
const DEFAULT_USER_AGENT = process.env.BROWSER_USER_AGENT || null;
|
|
17
|
+
const DEFAULT_LOCALE = process.env.BROWSER_LOCALE || null;
|
|
18
|
+
const DEFAULT_TIMEZONE_ID = process.env.BROWSER_TIMEZONE_ID || null;
|
|
19
|
+
const DEFAULT_COLOR_SCHEME = process.env.BROWSER_COLOR_SCHEME || null;
|
|
20
|
+
const DEFAULT_VIEWPORT = { width: 1400, height: 900 };
|
|
21
|
+
|
|
22
|
+
const sessions = new Map();
|
|
23
|
+
const browserFactory = { chromium, firefox, webkit }[BROWSER_TYPE] || chromium;
|
|
24
|
+
|
|
25
|
+
const ACTIONS = new Set([
|
|
26
|
+
"navigate",
|
|
27
|
+
"click",
|
|
28
|
+
"type",
|
|
29
|
+
"select",
|
|
30
|
+
"wait_for",
|
|
31
|
+
"extract_text",
|
|
32
|
+
"extract_json",
|
|
33
|
+
"screenshot",
|
|
34
|
+
"get_url",
|
|
35
|
+
"get_title",
|
|
36
|
+
"press",
|
|
37
|
+
"scroll",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function parseBoolean(input, fallback) {
|
|
41
|
+
if (input == null) return fallback;
|
|
42
|
+
const normalized = String(input).trim().toLowerCase();
|
|
43
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
44
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isObject(value) {
|
|
49
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asPositiveInt(input, fallback) {
|
|
53
|
+
if (Number.isInteger(input) && input > 0) return input;
|
|
54
|
+
if (typeof input === "string" && input.trim() !== "") {
|
|
55
|
+
const parsed = Number.parseInt(input, 10);
|
|
56
|
+
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
|
57
|
+
}
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function jsonResponse(res, statusCode, body) {
|
|
62
|
+
const payload = JSON.stringify(body);
|
|
63
|
+
res.writeHead(statusCode, {
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
"content-length": Buffer.byteLength(payload),
|
|
66
|
+
});
|
|
67
|
+
res.end(payload);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function methodNotAllowed(res) {
|
|
71
|
+
return jsonResponse(res, 405, { error: { code: "method_not_allowed", message: "Use POST" } });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function notFound(res) {
|
|
75
|
+
return jsonResponse(res, 404, { error: { code: "not_found", message: "Route not found" } });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function badRequest(res, code, message) {
|
|
79
|
+
return jsonResponse(res, 400, { error: { code, message } });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function internalError(res, code, message) {
|
|
83
|
+
return jsonResponse(res, 500, { error: { code, message } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readJson(req) {
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let total = 0;
|
|
89
|
+
|
|
90
|
+
for await (const chunk of req) {
|
|
91
|
+
total += chunk.length;
|
|
92
|
+
if (total > REQUEST_BODY_LIMIT_BYTES) {
|
|
93
|
+
throw new Error("request_too_large");
|
|
94
|
+
}
|
|
95
|
+
chunks.push(chunk);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const body = Buffer.concat(chunks).toString("utf8").trim();
|
|
99
|
+
if (!body) return {};
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(body);
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error("invalid_json");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sessionPayload(sessionId, session) {
|
|
109
|
+
return {
|
|
110
|
+
session_id: sessionId,
|
|
111
|
+
browser_type: session.browserType,
|
|
112
|
+
headless: session.headless,
|
|
113
|
+
session_options: session.sessionOptions,
|
|
114
|
+
created_at: session.createdAt,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function startSession(body) {
|
|
119
|
+
const requestedSessionId = typeof body.session_id === "string" ? body.session_id.trim() : "";
|
|
120
|
+
const sessionId = requestedSessionId || randomUUID();
|
|
121
|
+
|
|
122
|
+
if (sessions.has(sessionId)) {
|
|
123
|
+
return { status: 200, body: { ...sessionPayload(sessionId, sessions.get(sessionId)), reused: true } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const requestedSessionOptions = isObject(body.session_options) ? body.session_options : {};
|
|
127
|
+
const humanEmulation = resolveHumanEmulation(requestedSessionOptions);
|
|
128
|
+
const launchOptions = launchOptionsFromSessionOptions(requestedSessionOptions, humanEmulation);
|
|
129
|
+
const contextOptions = contextOptionsFromSessionOptions(requestedSessionOptions, humanEmulation);
|
|
130
|
+
const storageStatePath = resolveStorageStatePath(requestedSessionOptions);
|
|
131
|
+
|
|
132
|
+
if (storageStatePath && fs.existsSync(storageStatePath)) {
|
|
133
|
+
contextOptions.storageState = storageStatePath;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const browser = await browserFactory.launch(launchOptions);
|
|
137
|
+
const context = await browser.newContext(contextOptions);
|
|
138
|
+
if (humanEmulation) {
|
|
139
|
+
await applyStealthInitScripts(context);
|
|
140
|
+
}
|
|
141
|
+
const page = await context.newPage();
|
|
142
|
+
|
|
143
|
+
const session = {
|
|
144
|
+
browser,
|
|
145
|
+
context,
|
|
146
|
+
page,
|
|
147
|
+
browserType: BROWSER_TYPE,
|
|
148
|
+
headless: launchOptions.headless,
|
|
149
|
+
sessionOptions: {
|
|
150
|
+
headless: launchOptions.headless,
|
|
151
|
+
viewport: contextOptions.viewport || DEFAULT_VIEWPORT,
|
|
152
|
+
user_agent: contextOptions.userAgent || null,
|
|
153
|
+
locale: contextOptions.locale || null,
|
|
154
|
+
timezone_id: contextOptions.timezoneId || null,
|
|
155
|
+
color_scheme: contextOptions.colorScheme || null,
|
|
156
|
+
human_emulation: humanEmulation,
|
|
157
|
+
channel: launchOptions.channel || null,
|
|
158
|
+
storage_state_path: storageStatePath || null,
|
|
159
|
+
},
|
|
160
|
+
storageStatePath,
|
|
161
|
+
createdAt: new Date().toISOString(),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
sessions.set(sessionId, session);
|
|
165
|
+
|
|
166
|
+
return { status: 200, body: sessionPayload(sessionId, session) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function launchOptionsFromSessionOptions(sessionOptions, humanEmulation) {
|
|
170
|
+
const options = {
|
|
171
|
+
headless: parseBoolean(sessionOptions.headless, HEADLESS),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const channel =
|
|
175
|
+
requireString(sessionOptions, "channel") ||
|
|
176
|
+
(humanEmulation && BROWSER_TYPE === "chromium" ? DEFAULT_CHANNEL || "chrome" : null);
|
|
177
|
+
if (channel) options.channel = channel;
|
|
178
|
+
|
|
179
|
+
const launchArgs =
|
|
180
|
+
(Array.isArray(sessionOptions.launch_args) && sessionOptions.launch_args) ||
|
|
181
|
+
(Array.isArray(sessionOptions.args) && sessionOptions.args) ||
|
|
182
|
+
[];
|
|
183
|
+
|
|
184
|
+
const args = launchArgs.filter((arg) => typeof arg === "string" && arg.trim() !== "");
|
|
185
|
+
if (humanEmulation && BROWSER_TYPE === "chromium") {
|
|
186
|
+
args.push("--disable-blink-features=AutomationControlled");
|
|
187
|
+
options.ignoreDefaultArgs = ["--enable-automation"];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (args.length > 0) {
|
|
191
|
+
options.args = [...new Set(args)];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return options;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function contextOptionsFromSessionOptions(sessionOptions, humanEmulation) {
|
|
198
|
+
const contextOptions = {
|
|
199
|
+
viewport: resolveViewport(sessionOptions),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const userAgent =
|
|
203
|
+
requireString(sessionOptions, "user_agent") ||
|
|
204
|
+
requireString(sessionOptions, "userAgent") ||
|
|
205
|
+
(humanEmulation ? DEFAULT_USER_AGENT : null);
|
|
206
|
+
if (userAgent) contextOptions.userAgent = userAgent;
|
|
207
|
+
|
|
208
|
+
const locale = requireString(sessionOptions, "locale") || (humanEmulation ? DEFAULT_LOCALE : null);
|
|
209
|
+
if (locale) contextOptions.locale = locale;
|
|
210
|
+
|
|
211
|
+
const timezoneId =
|
|
212
|
+
requireString(sessionOptions, "timezone_id") ||
|
|
213
|
+
requireString(sessionOptions, "timezoneId") ||
|
|
214
|
+
(humanEmulation ? DEFAULT_TIMEZONE_ID : null);
|
|
215
|
+
if (timezoneId) contextOptions.timezoneId = timezoneId;
|
|
216
|
+
|
|
217
|
+
const colorScheme =
|
|
218
|
+
requireString(sessionOptions, "color_scheme") ||
|
|
219
|
+
requireString(sessionOptions, "colorScheme") ||
|
|
220
|
+
(humanEmulation ? DEFAULT_COLOR_SCHEME : null);
|
|
221
|
+
if (colorScheme) contextOptions.colorScheme = colorScheme;
|
|
222
|
+
|
|
223
|
+
return contextOptions;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function resolveHumanEmulation(sessionOptions) {
|
|
227
|
+
if (typeof sessionOptions.human_emulation === "boolean") return sessionOptions.human_emulation;
|
|
228
|
+
if (typeof sessionOptions.humanEmulation === "boolean") return sessionOptions.humanEmulation;
|
|
229
|
+
return HUMAN_EMULATION;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function applyStealthInitScripts(context) {
|
|
233
|
+
await context.addInitScript(() => {
|
|
234
|
+
try {
|
|
235
|
+
Object.defineProperty(Navigator.prototype, "webdriver", {
|
|
236
|
+
get: () => undefined,
|
|
237
|
+
configurable: true,
|
|
238
|
+
});
|
|
239
|
+
} catch {
|
|
240
|
+
// No-op if the browser blocks redefining this property.
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resolveViewport(sessionOptions) {
|
|
246
|
+
if (!isObject(sessionOptions.viewport)) return DEFAULT_VIEWPORT;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
width: asPositiveInt(sessionOptions.viewport.width, DEFAULT_VIEWPORT.width),
|
|
250
|
+
height: asPositiveInt(sessionOptions.viewport.height, DEFAULT_VIEWPORT.height),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveStorageStatePath(sessionOptions) {
|
|
255
|
+
const candidate =
|
|
256
|
+
requireString(sessionOptions, "storage_state_path") || requireString(sessionOptions, "storageStatePath");
|
|
257
|
+
|
|
258
|
+
if (!candidate) return null;
|
|
259
|
+
|
|
260
|
+
if (path.isAbsolute(candidate)) return candidate;
|
|
261
|
+
return path.resolve(process.cwd(), candidate);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function getSession(sessionId) {
|
|
265
|
+
if (typeof sessionId !== "string" || sessionId.trim() === "") return null;
|
|
266
|
+
return sessions.get(sessionId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function closeSession(body) {
|
|
270
|
+
const sessionId = body.session_id;
|
|
271
|
+
const session = getSession(sessionId);
|
|
272
|
+
|
|
273
|
+
if (!session) {
|
|
274
|
+
return {
|
|
275
|
+
status: 404,
|
|
276
|
+
body: { error: { code: "session_not_found", message: "session not found" }, session_id: sessionId || null },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (session.storageStatePath) {
|
|
281
|
+
try {
|
|
282
|
+
fs.mkdirSync(path.dirname(session.storageStatePath), { recursive: true });
|
|
283
|
+
await session.context.storageState({ path: session.storageStatePath });
|
|
284
|
+
} catch {
|
|
285
|
+
// Best-effort persistence; continue closing even if this fails.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await session.context.close();
|
|
290
|
+
await session.browser.close();
|
|
291
|
+
sessions.delete(sessionId);
|
|
292
|
+
|
|
293
|
+
return { status: 200, body: { ok: true, session_id: sessionId } };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function resolveTimeoutMs(body, params) {
|
|
297
|
+
if (Number.isInteger(body.timeout_ms) && body.timeout_ms > 0) return body.timeout_ms;
|
|
298
|
+
if (params && Number.isInteger(params.timeout_ms) && params.timeout_ms > 0) return params.timeout_ms;
|
|
299
|
+
return DEFAULT_TIMEOUT_MS;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function actionError(code, message, retryable = false) {
|
|
303
|
+
return { ok: false, error: { code, message, retryable } };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function requireString(params, key) {
|
|
307
|
+
const value = params?.[key];
|
|
308
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function runAction(session, action, params, timeoutMs) {
|
|
312
|
+
const page = session.page;
|
|
313
|
+
|
|
314
|
+
switch (action) {
|
|
315
|
+
case "navigate": {
|
|
316
|
+
const url = requireString(params, "url");
|
|
317
|
+
if (!url) return actionError("invalid_action_params", "navigate requires params.url");
|
|
318
|
+
|
|
319
|
+
const waitUntil = requireString(params, "wait_until") || "domcontentloaded";
|
|
320
|
+
await page.goto(url, { waitUntil, timeout: timeoutMs });
|
|
321
|
+
return { ok: true, action, result: { url: page.url() } };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case "click": {
|
|
325
|
+
const selector = requireString(params, "selector");
|
|
326
|
+
if (!selector) return actionError("invalid_action_params", "click requires params.selector");
|
|
327
|
+
|
|
328
|
+
await page.click(selector, { timeout: timeoutMs });
|
|
329
|
+
return { ok: true, action, result: { selector } };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case "type": {
|
|
333
|
+
const selector = requireString(params, "selector");
|
|
334
|
+
const text = typeof params?.text === "string" ? params.text : null;
|
|
335
|
+
if (!selector || text == null)
|
|
336
|
+
return actionError("invalid_action_params", "type requires params.selector and params.text");
|
|
337
|
+
|
|
338
|
+
const clearFirst = parseBoolean(params?.clear, true);
|
|
339
|
+
if (clearFirst) await page.fill(selector, "", { timeout: timeoutMs });
|
|
340
|
+
await page.fill(selector, text, { timeout: timeoutMs });
|
|
341
|
+
return { ok: true, action, result: { selector } };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
case "select": {
|
|
345
|
+
const selector = requireString(params, "selector");
|
|
346
|
+
const value = params?.value;
|
|
347
|
+
if (!selector || (typeof value !== "string" && !Array.isArray(value))) {
|
|
348
|
+
return actionError("invalid_action_params", "select requires params.selector and params.value");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const selected = await page.selectOption(selector, value, { timeout: timeoutMs });
|
|
352
|
+
return { ok: true, action, result: { selector, selected } };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case "wait_for": {
|
|
356
|
+
if (typeof params?.ms === "number" && params.ms > 0) {
|
|
357
|
+
await page.waitForTimeout(params.ms);
|
|
358
|
+
return { ok: true, action, result: { waited_ms: params.ms } };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const selector = requireString(params, "selector");
|
|
362
|
+
if (selector) {
|
|
363
|
+
const state = requireString(params, "state") || "visible";
|
|
364
|
+
await page.waitForSelector(selector, { state, timeout: timeoutMs });
|
|
365
|
+
return { ok: true, action, result: { selector, state } };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const loadState = requireString(params, "load_state");
|
|
369
|
+
if (loadState) {
|
|
370
|
+
await page.waitForLoadState(loadState, { timeout: timeoutMs });
|
|
371
|
+
return { ok: true, action, result: { load_state: loadState } };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return actionError(
|
|
375
|
+
"invalid_action_params",
|
|
376
|
+
"wait_for requires one of params.ms, params.selector, params.load_state"
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case "extract_text": {
|
|
381
|
+
const selector = requireString(params, "selector") || "body";
|
|
382
|
+
const text = await page.textContent(selector, { timeout: timeoutMs });
|
|
383
|
+
return { ok: true, action, result: { selector, text: text || "" } };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
case "extract_json": {
|
|
387
|
+
const script = requireString(params, "script");
|
|
388
|
+
if (!script) return actionError("invalid_action_params", "extract_json requires params.script");
|
|
389
|
+
|
|
390
|
+
// Intentionally executes caller-provided extraction code in page context.
|
|
391
|
+
const extracted = await page.evaluate(new Function(`return (${script});`));
|
|
392
|
+
return { ok: true, action, result: { data: extracted ?? {} } };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
case "screenshot": {
|
|
396
|
+
const fullPage = parseBoolean(params?.full_page, true);
|
|
397
|
+
const outputPath = requireString(params, "path") || path.join(os.tmpdir(), `synaptic-shot-${randomUUID()}.png`);
|
|
398
|
+
await page.screenshot({ path: outputPath, fullPage });
|
|
399
|
+
return { ok: true, action, result: { path: outputPath, full_page: fullPage } };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
case "get_url":
|
|
403
|
+
return { ok: true, action, result: { url: page.url() } };
|
|
404
|
+
|
|
405
|
+
case "get_title":
|
|
406
|
+
return { ok: true, action, result: { title: await page.title() } };
|
|
407
|
+
|
|
408
|
+
case "press": {
|
|
409
|
+
const key = requireString(params, "key");
|
|
410
|
+
const selector = requireString(params, "selector");
|
|
411
|
+
if (!key) return actionError("invalid_action_params", "press requires params.key");
|
|
412
|
+
if (selector) await page.focus(selector);
|
|
413
|
+
await page.keyboard.press(key);
|
|
414
|
+
return { ok: true, action, result: { key, selector: selector || null } };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
case "scroll": {
|
|
418
|
+
const x = Number.isFinite(params?.x) ? params.x : 0;
|
|
419
|
+
const y = Number.isFinite(params?.y) ? params.y : 600;
|
|
420
|
+
await page.evaluate(
|
|
421
|
+
({ scrollX, scrollY }) => {
|
|
422
|
+
window.scrollBy(scrollX, scrollY);
|
|
423
|
+
},
|
|
424
|
+
{ scrollX: x, scrollY: y }
|
|
425
|
+
);
|
|
426
|
+
return { ok: true, action, result: { x, y } };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
default:
|
|
430
|
+
return actionError("invalid_action", `unsupported action: ${String(action)}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function handleAction(body) {
|
|
435
|
+
const sessionId = body.session_id;
|
|
436
|
+
const action = typeof body.action === "string" ? body.action.trim() : "";
|
|
437
|
+
const params = body.params && typeof body.params === "object" ? body.params : {};
|
|
438
|
+
|
|
439
|
+
if (!action || !ACTIONS.has(action)) {
|
|
440
|
+
return {
|
|
441
|
+
status: 400,
|
|
442
|
+
body: { ok: false, action: action || null, error: { code: "invalid_action", message: "unsupported action" } },
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const session = getSession(sessionId);
|
|
447
|
+
if (!session) {
|
|
448
|
+
return {
|
|
449
|
+
status: 404,
|
|
450
|
+
body: {
|
|
451
|
+
ok: false,
|
|
452
|
+
action,
|
|
453
|
+
error: { code: "session_not_found", message: "session not found", retryable: false },
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const timeoutMs = resolveTimeoutMs(body, params);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const result = await runAction(session, action, params, timeoutMs);
|
|
462
|
+
return { status: result.ok ? 200 : 400, body: result };
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
465
|
+
const timeoutLike = message.includes("Timeout") || message.includes("timeout");
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
status: timeoutLike ? 408 : 500,
|
|
469
|
+
body: {
|
|
470
|
+
ok: false,
|
|
471
|
+
action,
|
|
472
|
+
error: {
|
|
473
|
+
code: timeoutLike ? "action_timeout" : "action_failed",
|
|
474
|
+
message,
|
|
475
|
+
retryable: timeoutLike,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const server = createServer(async (req, res) => {
|
|
483
|
+
try {
|
|
484
|
+
if (!req.url) return notFound(res);
|
|
485
|
+
if (req.method !== "POST") return methodNotAllowed(res);
|
|
486
|
+
|
|
487
|
+
const body = await readJson(req);
|
|
488
|
+
|
|
489
|
+
if (req.url === "/session/start") {
|
|
490
|
+
const { status, body: payload } = await startSession(body);
|
|
491
|
+
return jsonResponse(res, status, payload);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (req.url === "/action") {
|
|
495
|
+
const { status, body: payload } = await handleAction(body);
|
|
496
|
+
return jsonResponse(res, status, payload);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (req.url === "/session/close") {
|
|
500
|
+
const { status, body: payload } = await closeSession(body);
|
|
501
|
+
return jsonResponse(res, status, payload);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return notFound(res);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (error instanceof Error && error.message === "invalid_json") {
|
|
507
|
+
return badRequest(res, "invalid_json", "Request body must be valid JSON");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (error instanceof Error && error.message === "request_too_large") {
|
|
511
|
+
return badRequest(res, "request_too_large", "Request body exceeds allowed size");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return internalError(
|
|
515
|
+
res,
|
|
516
|
+
"internal_error",
|
|
517
|
+
error instanceof Error ? error.message : String(error)
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
async function shutdown() {
|
|
523
|
+
for (const [sessionId, session] of sessions.entries()) {
|
|
524
|
+
try {
|
|
525
|
+
await session.context.close();
|
|
526
|
+
await session.browser.close();
|
|
527
|
+
} catch {
|
|
528
|
+
// Best-effort cleanup.
|
|
529
|
+
} finally {
|
|
530
|
+
sessions.delete(sessionId);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
server.close(() => {
|
|
535
|
+
process.exit(0);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
process.on("SIGINT", shutdown);
|
|
540
|
+
process.on("SIGTERM", shutdown);
|
|
541
|
+
|
|
542
|
+
server.listen(PORT, HOST, () => {
|
|
543
|
+
console.log(
|
|
544
|
+
JSON.stringify({
|
|
545
|
+
event: "playwright_sidecar_started",
|
|
546
|
+
host: HOST,
|
|
547
|
+
port: PORT,
|
|
548
|
+
browser_type: BROWSER_TYPE,
|
|
549
|
+
headless: HEADLESS,
|
|
550
|
+
human_emulation: HUMAN_EMULATION,
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
});
|