@handstage/core 0.0.1
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 +2 -0
- package/dist/index.js +1 -0
- package/dist/v3/cli.js +9 -0
- package/dist/v3/index.js +13 -0
- package/dist/v3/launch/local.js +97 -0
- package/dist/v3/logger.js +37 -0
- package/dist/v3/runtimePaths.js +106 -0
- package/dist/v3/shutdown/cleanupLocal.js +29 -0
- package/dist/v3/shutdown/supervisor.js +159 -0
- package/dist/v3/shutdown/supervisorClient.js +88 -0
- package/dist/v3/timeoutConfig.js +31 -0
- package/dist/v3/types/private/api.js +1 -0
- package/dist/v3/types/private/index.js +3 -0
- package/dist/v3/types/private/internal.js +1 -0
- package/dist/v3/types/private/locator.js +1 -0
- package/dist/v3/types/private/network.js +2 -0
- package/dist/v3/types/private/shutdown.js +4 -0
- package/dist/v3/types/private/shutdownErrors.js +21 -0
- package/dist/v3/types/private/snapshot.js +1 -0
- package/dist/v3/types/public/api.js +36 -0
- package/dist/v3/types/public/consoleLogger.js +49 -0
- package/dist/v3/types/public/context.js +1 -0
- package/dist/v3/types/public/index.js +8 -0
- package/dist/v3/types/public/locator.js +1 -0
- package/dist/v3/types/public/logs.js +29 -0
- package/dist/v3/types/public/options.js +2 -0
- package/dist/v3/types/public/page.js +2 -0
- package/dist/v3/types/public/screenshotTypes.js +1 -0
- package/dist/v3/types/public/sdkErrors.js +257 -0
- package/dist/v3/understudy/a11y/snapshot/a11yTree.js +195 -0
- package/dist/v3/understudy/a11y/snapshot/activeElement.js +120 -0
- package/dist/v3/understudy/a11y/snapshot/capture.js +333 -0
- package/dist/v3/understudy/a11y/snapshot/coordinateResolver.js +126 -0
- package/dist/v3/understudy/a11y/snapshot/domTree.js +273 -0
- package/dist/v3/understudy/a11y/snapshot/focusSelectors.js +211 -0
- package/dist/v3/understudy/a11y/snapshot/index.js +4 -0
- package/dist/v3/understudy/a11y/snapshot/sessions.js +21 -0
- package/dist/v3/understudy/a11y/snapshot/treeFormatUtils.js +134 -0
- package/dist/v3/understudy/a11y/snapshot/xpathUtils.js +101 -0
- package/dist/v3/understudy/a11yInvocation.js +11 -0
- package/dist/v3/understudy/cdp.js +300 -0
- package/dist/v3/understudy/consoleMessage.js +65 -0
- package/dist/v3/understudy/context.js +914 -0
- package/dist/v3/understudy/cookies.js +135 -0
- package/dist/v3/understudy/deepLocator.js +195 -0
- package/dist/v3/understudy/executionContextRegistry.js +82 -0
- package/dist/v3/understudy/fileUploadUtils.js +80 -0
- package/dist/v3/understudy/frame.js +225 -0
- package/dist/v3/understudy/frameLocator.js +254 -0
- package/dist/v3/understudy/frameRegistry.js +295 -0
- package/dist/v3/understudy/initScripts.js +32 -0
- package/dist/v3/understudy/lifecycleWatcher.js +244 -0
- package/dist/v3/understudy/locator.js +752 -0
- package/dist/v3/understudy/locatorInvocation.js +11 -0
- package/dist/v3/understudy/navigationResponseTracker.js +223 -0
- package/dist/v3/understudy/networkManager.js +303 -0
- package/dist/v3/understudy/page.js +1876 -0
- package/dist/v3/understudy/piercer.js +58 -0
- package/dist/v3/understudy/response.js +329 -0
- package/dist/v3/understudy/screenshotUtils.js +333 -0
- package/dist/v3/understudy/selectorResolver.js +293 -0
- package/dist/v3/v3.js +339 -0
- package/dist/version.js +2 -0
- package/package.json +55 -0
- package/src/index.ts +1 -0
- package/src/v3/cli.js +13 -0
- package/src/v3/index.ts +17 -0
- package/src/v3/launch/local.ts +130 -0
- package/src/v3/logger.ts +48 -0
- package/src/v3/runtimePaths.ts +121 -0
- package/src/v3/shutdown/cleanupLocal.ts +35 -0
- package/src/v3/shutdown/supervisor.ts +179 -0
- package/src/v3/shutdown/supervisorClient.ts +124 -0
- package/src/v3/timeoutConfig.ts +36 -0
- package/src/v3/types/private/api.ts +12 -0
- package/src/v3/types/private/index.ts +3 -0
- package/src/v3/types/private/internal.ts +37 -0
- package/src/v3/types/private/locator.ts +10 -0
- package/src/v3/types/private/network.ts +41 -0
- package/src/v3/types/private/shutdown.ts +16 -0
- package/src/v3/types/private/shutdownErrors.ts +24 -0
- package/src/v3/types/private/snapshot.ts +131 -0
- package/src/v3/types/public/api.ts +37 -0
- package/src/v3/types/public/consoleLogger.ts +44 -0
- package/src/v3/types/public/context.ts +34 -0
- package/src/v3/types/public/index.ts +8 -0
- package/src/v3/types/public/locator.ts +16 -0
- package/src/v3/types/public/logs.ts +51 -0
- package/src/v3/types/public/options.ts +42 -0
- package/src/v3/types/public/page.ts +23 -0
- package/src/v3/types/public/screenshotTypes.ts +28 -0
- package/src/v3/types/public/sdkErrors.ts +338 -0
- package/src/v3/understudy/a11y/snapshot/a11yTree.ts +243 -0
- package/src/v3/understudy/a11y/snapshot/activeElement.ts +134 -0
- package/src/v3/understudy/a11y/snapshot/capture.ts +467 -0
- package/src/v3/understudy/a11y/snapshot/coordinateResolver.ts +163 -0
- package/src/v3/understudy/a11y/snapshot/domTree.ts +344 -0
- package/src/v3/understudy/a11y/snapshot/focusSelectors.ts +288 -0
- package/src/v3/understudy/a11y/snapshot/index.ts +4 -0
- package/src/v3/understudy/a11y/snapshot/sessions.ts +31 -0
- package/src/v3/understudy/a11y/snapshot/treeFormatUtils.ts +150 -0
- package/src/v3/understudy/a11y/snapshot/xpathUtils.ts +117 -0
- package/src/v3/understudy/a11yInvocation.ts +19 -0
- package/src/v3/understudy/cdp.ts +399 -0
- package/src/v3/understudy/consoleMessage.ts +75 -0
- package/src/v3/understudy/context.ts +1120 -0
- package/src/v3/understudy/cookies.ts +170 -0
- package/src/v3/understudy/deepLocator.ts +270 -0
- package/src/v3/understudy/executionContextRegistry.ts +111 -0
- package/src/v3/understudy/fileUploadUtils.ts +102 -0
- package/src/v3/understudy/frame.ts +318 -0
- package/src/v3/understudy/frameLocator.ts +304 -0
- package/src/v3/understudy/frameRegistry.ts +394 -0
- package/src/v3/understudy/initScripts.ts +52 -0
- package/src/v3/understudy/lifecycleWatcher.ts +289 -0
- package/src/v3/understudy/locator.ts +941 -0
- package/src/v3/understudy/locatorInvocation.ts +19 -0
- package/src/v3/understudy/navigationResponseTracker.ts +265 -0
- package/src/v3/understudy/networkManager.ts +341 -0
- package/src/v3/understudy/page.ts +2310 -0
- package/src/v3/understudy/piercer.ts +72 -0
- package/src/v3/understudy/response.ts +399 -0
- package/src/v3/understudy/screenshotUtils.ts +440 -0
- package/src/v3/understudy/selectorResolver.ts +439 -0
- package/src/v3/v3.ts +380 -0
- package/src/version.ts +2 -0
package/README.md
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./v3/index";
|
package/dist/v3/cli.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { maybeRunShutdownSupervisorFromArgv } from "./shutdown/supervisor";
|
|
4
|
+
// currently the CLI is only used to spawn the shutdown supervisor
|
|
5
|
+
// in the future, we may want to add more CLI commands here
|
|
6
|
+
if (!maybeRunShutdownSupervisorFromArgv(process.argv.slice(2))) {
|
|
7
|
+
console.error("Unsupported stagehand CLI invocation. Expected --supervisor with valid args.");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
package/dist/v3/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { maybeRunShutdownSupervisorFromArgv } from "./shutdown/supervisor";
|
|
2
|
+
import * as PublicApi from "./types/public/index";
|
|
3
|
+
import { V3 } from "./v3";
|
|
4
|
+
export { maybeRunShutdownSupervisorFromArgv as __internalMaybeRunShutdownSupervisorFromArgv } from "./shutdown/supervisor";
|
|
5
|
+
export * from "./types/public/index";
|
|
6
|
+
export { V3, V3 as Stagehand } from "./v3";
|
|
7
|
+
const StagehandDefault = {
|
|
8
|
+
...PublicApi,
|
|
9
|
+
V3,
|
|
10
|
+
Stagehand: V3,
|
|
11
|
+
__internalMaybeRunShutdownSupervisorFromArgv: maybeRunShutdownSupervisorFromArgv,
|
|
12
|
+
};
|
|
13
|
+
export default StagehandDefault;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { launch } from "chrome-launcher";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
import { ConnectionTimeoutError } from "../types/public/sdkErrors";
|
|
4
|
+
export async function launchLocalChrome(opts) {
|
|
5
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? 15_000;
|
|
6
|
+
const deadlineMs = Date.now() + connectTimeoutMs;
|
|
7
|
+
const connectionPollInterval = 250;
|
|
8
|
+
const maxConnectionRetries = Math.max(1, Math.ceil(connectTimeoutMs / connectionPollInterval));
|
|
9
|
+
const headless = opts.headless ?? false;
|
|
10
|
+
const chromeFlags = [
|
|
11
|
+
headless ? "--headless=new" : undefined,
|
|
12
|
+
"--remote-allow-origins=*",
|
|
13
|
+
"--no-first-run",
|
|
14
|
+
"--no-default-browser-check",
|
|
15
|
+
"--disable-dev-shm-usage",
|
|
16
|
+
"--site-per-process",
|
|
17
|
+
...(opts.chromeFlags ?? []),
|
|
18
|
+
].filter((f) => typeof f === "string");
|
|
19
|
+
const chrome = await launch({
|
|
20
|
+
chromePath: opts.chromePath,
|
|
21
|
+
chromeFlags,
|
|
22
|
+
port: opts.port,
|
|
23
|
+
userDataDir: opts.userDataDir,
|
|
24
|
+
handleSIGINT: opts.handleSIGINT,
|
|
25
|
+
connectionPollInterval,
|
|
26
|
+
maxConnectionRetries,
|
|
27
|
+
});
|
|
28
|
+
const ws = await waitForWebSocketDebuggerUrl(chrome.port, deadlineMs);
|
|
29
|
+
await waitForWebSocketReady(ws, deadlineMs);
|
|
30
|
+
return { ws, chrome };
|
|
31
|
+
}
|
|
32
|
+
async function waitForWebSocketDebuggerUrl(port, deadlineMs) {
|
|
33
|
+
let lastErrMsg = "";
|
|
34
|
+
while (Date.now() < deadlineMs) {
|
|
35
|
+
try {
|
|
36
|
+
const resp = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
37
|
+
if (resp.ok) {
|
|
38
|
+
const json = (await resp.json());
|
|
39
|
+
const url = json
|
|
40
|
+
.webSocketDebuggerUrl;
|
|
41
|
+
if (typeof url === "string")
|
|
42
|
+
return url;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
lastErrMsg = `${resp.status} ${resp.statusText}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
lastErrMsg = err instanceof Error ? err.message : String(err);
|
|
50
|
+
}
|
|
51
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
52
|
+
}
|
|
53
|
+
throw new ConnectionTimeoutError(`Timed out waiting for /json/version on port ${port} ${lastErrMsg ? ` (last error: ${lastErrMsg})` : ""}`);
|
|
54
|
+
}
|
|
55
|
+
async function waitForWebSocketReady(wsUrl, deadlineMs) {
|
|
56
|
+
let lastErrMsg = "";
|
|
57
|
+
while (Date.now() < deadlineMs) {
|
|
58
|
+
const remainingMs = Math.max(200, deadlineMs - Date.now());
|
|
59
|
+
try {
|
|
60
|
+
await probeWebSocket(wsUrl, Math.min(2_000, remainingMs));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
lastErrMsg = error instanceof Error ? error.message : String(error);
|
|
65
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new ConnectionTimeoutError(`Timed out waiting for CDP websocket to accept connections at ${wsUrl}${lastErrMsg ? ` (last error: ${lastErrMsg})` : ""}`);
|
|
69
|
+
}
|
|
70
|
+
function probeWebSocket(wsUrl, timeoutMs) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const ws = new WebSocket(wsUrl);
|
|
73
|
+
let settled = false;
|
|
74
|
+
const finish = (error) => {
|
|
75
|
+
if (settled)
|
|
76
|
+
return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
try {
|
|
80
|
+
ws.terminate();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// best-effort cleanup
|
|
84
|
+
}
|
|
85
|
+
if (error) {
|
|
86
|
+
reject(error);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
finish(new Error(`websocket probe timeout after ${timeoutMs}ms`));
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
ws.once("open", () => finish());
|
|
95
|
+
ws.once("error", (error) => finish(error));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createConsoleLogger } from "./types/public/consoleLogger";
|
|
3
|
+
/**
|
|
4
|
+
* Stagehand V3 per-instance log routing (AsyncLocalStorage).
|
|
5
|
+
*
|
|
6
|
+
* - `bindInstanceLogger` / `unbindInstanceLogger`: register the effective logger for an instance id.
|
|
7
|
+
* - `withInstanceLogContext`: run a function with that instance id on the async context.
|
|
8
|
+
* - `v3Logger`: emit a line for the current instance, or fall back to `createConsoleLogger()` when no context.
|
|
9
|
+
*/
|
|
10
|
+
const logContext = new AsyncLocalStorage();
|
|
11
|
+
const instanceLoggers = new Map();
|
|
12
|
+
const fallbackLogger = createConsoleLogger();
|
|
13
|
+
export function bindInstanceLogger(instanceId, logger) {
|
|
14
|
+
instanceLoggers.set(instanceId, logger);
|
|
15
|
+
}
|
|
16
|
+
export function unbindInstanceLogger(instanceId) {
|
|
17
|
+
instanceLoggers.delete(instanceId);
|
|
18
|
+
}
|
|
19
|
+
export function withInstanceLogContext(instanceId, fn) {
|
|
20
|
+
return logContext.run(instanceId, fn);
|
|
21
|
+
}
|
|
22
|
+
export function v3Logger(line) {
|
|
23
|
+
const id = logContext.getStore();
|
|
24
|
+
if (id) {
|
|
25
|
+
const fn = instanceLoggers.get(id);
|
|
26
|
+
if (fn) {
|
|
27
|
+
try {
|
|
28
|
+
fn(line);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// fall through to fallback
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
fallbackLogger(line);
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep this file in sync with:
|
|
3
|
+
* - /packages/core/lib/v3/runtimePaths.ts
|
|
4
|
+
* - /packages/server-v3/scripts/runtimePaths.ts
|
|
5
|
+
* - /packages/server-v4/scripts/runtimePaths.ts
|
|
6
|
+
* - /packages/evals/runtimePaths.ts
|
|
7
|
+
* - /packages/docs/scripts/runtimePaths.js
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
const PACKAGE_SEGMENT = "/packages/core/";
|
|
13
|
+
const EVAL_FRAMES = new Set(["[eval]", "[eval]-wrapper"]);
|
|
14
|
+
const INTERNAL_FRAME_NAMES = new Set([
|
|
15
|
+
"readCallsites",
|
|
16
|
+
"readCallsitePath",
|
|
17
|
+
"resolveCallerFilePath",
|
|
18
|
+
"getCurrentFilePath",
|
|
19
|
+
"getCurrentDirPath",
|
|
20
|
+
"getRepoRootDir",
|
|
21
|
+
"getPackageRootDir",
|
|
22
|
+
"createRequireFromCaller",
|
|
23
|
+
"isMainModule",
|
|
24
|
+
]);
|
|
25
|
+
const normalizePath = (value) => {
|
|
26
|
+
const input = value.startsWith("file://") ? fileURLToPath(value) : value;
|
|
27
|
+
return path.resolve(input).replaceAll("\\", "/");
|
|
28
|
+
};
|
|
29
|
+
const readCallsites = () => {
|
|
30
|
+
const previousPrepare = Error.prepareStackTrace;
|
|
31
|
+
try {
|
|
32
|
+
Error.prepareStackTrace = (_, stack) => stack;
|
|
33
|
+
return new Error().stack ?? [];
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
Error.prepareStackTrace = previousPrepare;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const readCallsitePath = (callsite) => {
|
|
40
|
+
const callsiteWithScript = callsite;
|
|
41
|
+
const rawPath = callsite.getFileName() ?? callsiteWithScript.getScriptNameOrSourceURL?.();
|
|
42
|
+
if (!rawPath)
|
|
43
|
+
return null;
|
|
44
|
+
if (rawPath.startsWith("node:"))
|
|
45
|
+
return null;
|
|
46
|
+
if (EVAL_FRAMES.has(rawPath))
|
|
47
|
+
return null;
|
|
48
|
+
return normalizePath(rawPath);
|
|
49
|
+
};
|
|
50
|
+
const isInternalCallsite = (callsite) => {
|
|
51
|
+
const functionName = callsite.getFunctionName();
|
|
52
|
+
if (functionName && INTERNAL_FRAME_NAMES.has(functionName))
|
|
53
|
+
return true;
|
|
54
|
+
const methodName = callsite.getMethodName();
|
|
55
|
+
if (methodName && INTERNAL_FRAME_NAMES.has(methodName))
|
|
56
|
+
return true;
|
|
57
|
+
const callsiteString = callsite.toString();
|
|
58
|
+
for (const frameName of INTERNAL_FRAME_NAMES) {
|
|
59
|
+
if (callsiteString.includes(`${frameName} (`))
|
|
60
|
+
return true;
|
|
61
|
+
if (callsiteString.includes(`.${frameName} (`))
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
const resolveCallerFilePath = () => {
|
|
67
|
+
const packageCandidates = [];
|
|
68
|
+
const fallbackCandidates = [];
|
|
69
|
+
for (const callsite of readCallsites()) {
|
|
70
|
+
const filePath = readCallsitePath(callsite);
|
|
71
|
+
if (!filePath)
|
|
72
|
+
continue;
|
|
73
|
+
if (isInternalCallsite(callsite))
|
|
74
|
+
continue;
|
|
75
|
+
if (filePath.includes(PACKAGE_SEGMENT)) {
|
|
76
|
+
packageCandidates.push(filePath);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
fallbackCandidates.push(filePath);
|
|
80
|
+
}
|
|
81
|
+
const packageCandidate = packageCandidates[0];
|
|
82
|
+
if (packageCandidate)
|
|
83
|
+
return packageCandidate;
|
|
84
|
+
const fallbackCandidate = fallbackCandidates[0];
|
|
85
|
+
if (fallbackCandidate)
|
|
86
|
+
return fallbackCandidate;
|
|
87
|
+
throw new Error("Unable to resolve caller file path.");
|
|
88
|
+
};
|
|
89
|
+
export const getCurrentFilePath = () => resolveCallerFilePath();
|
|
90
|
+
export const getCurrentDirPath = () => path.dirname(getCurrentFilePath());
|
|
91
|
+
export const getRepoRootDir = () => {
|
|
92
|
+
const currentFilePath = getCurrentFilePath();
|
|
93
|
+
const index = currentFilePath.lastIndexOf(PACKAGE_SEGMENT);
|
|
94
|
+
if (index === -1) {
|
|
95
|
+
throw new Error(`Unable to determine repo root from ${currentFilePath} (missing ${PACKAGE_SEGMENT}).`);
|
|
96
|
+
}
|
|
97
|
+
return currentFilePath.slice(0, index);
|
|
98
|
+
};
|
|
99
|
+
export const getPackageRootDir = () => `${getRepoRootDir()}${PACKAGE_SEGMENT.slice(0, -1)}`;
|
|
100
|
+
export const createRequireFromCaller = () => createRequire(getCurrentFilePath());
|
|
101
|
+
export const isMainModule = () => {
|
|
102
|
+
const entryScript = process.argv.at(1);
|
|
103
|
+
if (!entryScript)
|
|
104
|
+
return false;
|
|
105
|
+
return normalizePath(entryScript) === getCurrentFilePath();
|
|
106
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Shared cleanup logic for locally launched Chrome.
|
|
4
|
+
*
|
|
5
|
+
* Used by both `V3.close()` (normal shutdown) and the supervisor process
|
|
6
|
+
* (crash cleanup). The caller provides a `killChrome` callback since the
|
|
7
|
+
* kill mechanism differs: chrome-launcher's `chrome.kill()` in-process
|
|
8
|
+
* vs raw `process.kill(pid)` from the supervisor.
|
|
9
|
+
*/
|
|
10
|
+
export async function cleanupLocalBrowser(opts) {
|
|
11
|
+
if (opts.killChrome) {
|
|
12
|
+
try {
|
|
13
|
+
await opts.killChrome();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// best-effort
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (opts.createdTempProfile &&
|
|
20
|
+
!opts.preserveUserDataDir &&
|
|
21
|
+
opts.userDataDir) {
|
|
22
|
+
try {
|
|
23
|
+
fs.rmSync(opts.userDataDir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// ignore cleanup errors
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shutdown supervisor process.
|
|
3
|
+
*
|
|
4
|
+
* This process watches a stdin lifeline. When the parent dies, stdin closes
|
|
5
|
+
* and the supervisor performs best-effort cleanup (Chrome kill + temp profile).
|
|
6
|
+
*/
|
|
7
|
+
import { cleanupLocalBrowser } from "./cleanupLocal";
|
|
8
|
+
const SIGKILL_POLL_MS = 250;
|
|
9
|
+
const SIGKILL_TIMEOUT_MS = 7_000;
|
|
10
|
+
const PID_POLL_INTERVAL_MS = 500;
|
|
11
|
+
// `cleanupPromise` guarantees we execute cleanup at most once.
|
|
12
|
+
let config = null;
|
|
13
|
+
let cleanupPromise = null;
|
|
14
|
+
let started = false;
|
|
15
|
+
let localPidKnownGone = false;
|
|
16
|
+
const exit = (code = 0) => {
|
|
17
|
+
try {
|
|
18
|
+
process.exit(code);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
// Best-effort two-phase kill: SIGTERM first, then SIGKILL after timeout.
|
|
25
|
+
// Treat only ESRCH as "already gone"; other errors should not imply dead.
|
|
26
|
+
const politeKill = async (pid) => {
|
|
27
|
+
const isAlive = () => {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const err = error;
|
|
34
|
+
// ESRCH = "No such process" (PID is already gone).
|
|
35
|
+
return err.code !== "ESRCH";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
if (!isAlive())
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, "SIGTERM");
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const err = error;
|
|
45
|
+
// ESRCH = process already exited; no further action needed.
|
|
46
|
+
if (err.code === "ESRCH")
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const deadline = Date.now() + SIGKILL_TIMEOUT_MS;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, SIGKILL_POLL_MS));
|
|
52
|
+
if (!isAlive())
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, "SIGKILL");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// best-effort
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
let pidPollTimer = null;
|
|
63
|
+
// Local-only fallback: if Chrome dies while parent still lives, run cleanup and exit.
|
|
64
|
+
const startPidPolling = (pid) => {
|
|
65
|
+
if (pidPollTimer)
|
|
66
|
+
return;
|
|
67
|
+
pidPollTimer = setInterval(() => {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const err = error;
|
|
74
|
+
// Only ESRCH means the process is definitely gone.
|
|
75
|
+
if (err.code !== "ESRCH")
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
localPidKnownGone = true;
|
|
79
|
+
if (pidPollTimer) {
|
|
80
|
+
clearInterval(pidPollTimer);
|
|
81
|
+
pidPollTimer = null;
|
|
82
|
+
}
|
|
83
|
+
void runCleanup("Browser process exited").finally(() => exit(0));
|
|
84
|
+
}, PID_POLL_INTERVAL_MS);
|
|
85
|
+
};
|
|
86
|
+
const cleanupLocal = async (cfg, reason) => {
|
|
87
|
+
const deletingUserDataDir = Boolean(cfg.createdTempProfile && !cfg.preserveUserDataDir && cfg.userDataDir);
|
|
88
|
+
await cleanupLocalBrowser({
|
|
89
|
+
// If polling already observed ESRCH, avoid a follow-up PID kill.
|
|
90
|
+
// The PID could be reused by a different process before cleanup runs.
|
|
91
|
+
killChrome: cfg.pid && !localPidKnownGone
|
|
92
|
+
? () => {
|
|
93
|
+
console.error(`[shutdown-supervisor] Shutting down Chrome pid=${cfg.pid} ` +
|
|
94
|
+
`(reason=${reason}, deletingUserDataDir=${deletingUserDataDir})`);
|
|
95
|
+
return politeKill(cfg.pid);
|
|
96
|
+
}
|
|
97
|
+
: undefined,
|
|
98
|
+
userDataDir: cfg.userDataDir,
|
|
99
|
+
createdTempProfile: cfg.createdTempProfile,
|
|
100
|
+
preserveUserDataDir: cfg.preserveUserDataDir,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
// Idempotent cleanup entrypoint used by all supervisor shutdown paths.
|
|
104
|
+
const runCleanup = (reason) => {
|
|
105
|
+
if (!cleanupPromise) {
|
|
106
|
+
cleanupPromise = (async () => {
|
|
107
|
+
const cfg = config;
|
|
108
|
+
if (!cfg)
|
|
109
|
+
return;
|
|
110
|
+
await cleanupLocal(cfg, reason);
|
|
111
|
+
})();
|
|
112
|
+
}
|
|
113
|
+
return cleanupPromise;
|
|
114
|
+
};
|
|
115
|
+
const applyConfig = (nextConfig) => {
|
|
116
|
+
config = nextConfig;
|
|
117
|
+
localPidKnownGone = false;
|
|
118
|
+
if (config.pid) {
|
|
119
|
+
startPidPolling(config.pid);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const onLifelineClosed = (reason) => {
|
|
123
|
+
void runCleanup(reason).finally(() => exit(0));
|
|
124
|
+
};
|
|
125
|
+
const parseConfigFromArgv = (argv = process.argv.slice(2)) => {
|
|
126
|
+
const prefix = "--supervisor-config=";
|
|
127
|
+
const raw = argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
|
|
128
|
+
if (!argv.includes("--supervisor") || !raw)
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(raw);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
export const runShutdownSupervisor = (initialConfig) => {
|
|
138
|
+
if (started)
|
|
139
|
+
return;
|
|
140
|
+
started = true;
|
|
141
|
+
applyConfig(initialConfig);
|
|
142
|
+
// Stdin is the lifeline; losing it means parent is gone.
|
|
143
|
+
try {
|
|
144
|
+
process.stdin.resume();
|
|
145
|
+
process.stdin.on("end", () => onLifelineClosed("Stagehand process completed"));
|
|
146
|
+
process.stdin.on("close", () => onLifelineClosed("Stagehand process completed"));
|
|
147
|
+
process.stdin.on("error", () => onLifelineClosed("Stagehand process crashed or was killed"));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
export const maybeRunShutdownSupervisorFromArgv = (argv = process.argv.slice(2)) => {
|
|
154
|
+
const parsed = parseConfigFromArgv(argv);
|
|
155
|
+
if (!parsed)
|
|
156
|
+
return false;
|
|
157
|
+
runShutdownSupervisor(parsed);
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side helper for spawning the shutdown supervisor process.
|
|
3
|
+
*
|
|
4
|
+
* The supervisor runs out-of-process and watches a lifeline pipe. If the parent
|
|
5
|
+
* dies, the supervisor performs best-effort cleanup (Chrome kill, temp profile)
|
|
6
|
+
* when keepAlive is false.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { getCurrentFilePath } from "../runtimePaths";
|
|
13
|
+
import { ShutdownSupervisorResolveError, ShutdownSupervisorSpawnError, } from "../types/private/shutdownErrors";
|
|
14
|
+
const moduleFilename = getCurrentFilePath();
|
|
15
|
+
const moduleDir = path.dirname(moduleFilename);
|
|
16
|
+
const nodeRequire = createRequire(moduleFilename);
|
|
17
|
+
const isSeaRuntime = () => {
|
|
18
|
+
try {
|
|
19
|
+
const sea = nodeRequire("node:sea");
|
|
20
|
+
return Boolean(sea.isSea?.());
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
// SEA: re-exec current binary with supervisor args.
|
|
27
|
+
// Non-SEA: execute Stagehand CLI entrypoint with supervisor args.
|
|
28
|
+
const resolveCliPath = () => `${moduleDir}/../cli.js`;
|
|
29
|
+
const resolveSupervisorCommand = (config) => {
|
|
30
|
+
const baseArgs = ["--supervisor", serializeConfigArg(config)];
|
|
31
|
+
if (isSeaRuntime()) {
|
|
32
|
+
return { command: process.execPath, args: baseArgs };
|
|
33
|
+
}
|
|
34
|
+
const cliPath = resolveCliPath();
|
|
35
|
+
if (!fs.existsSync(cliPath))
|
|
36
|
+
return null;
|
|
37
|
+
const needsTsxLoader = fs.existsSync(`${moduleDir}/supervisor.ts`) &&
|
|
38
|
+
!fs.existsSync(`${moduleDir}/supervisor.js`);
|
|
39
|
+
return {
|
|
40
|
+
command: process.execPath,
|
|
41
|
+
args: needsTsxLoader
|
|
42
|
+
? ["--import", "tsx", cliPath, ...baseArgs]
|
|
43
|
+
: [cliPath, ...baseArgs],
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
// Single JSON arg keeps supervisor bootstrap parsing tiny and versionable.
|
|
47
|
+
const serializeConfigArg = (config) => `--supervisor-config=${JSON.stringify({
|
|
48
|
+
...config,
|
|
49
|
+
parentPid: process.pid,
|
|
50
|
+
})}`;
|
|
51
|
+
/**
|
|
52
|
+
* Start a supervisor process for crash cleanup. Returns a handle that can
|
|
53
|
+
* stop the supervisor during a normal shutdown.
|
|
54
|
+
*/
|
|
55
|
+
export function startShutdownSupervisor(config, opts) {
|
|
56
|
+
const resolved = resolveSupervisorCommand(config);
|
|
57
|
+
if (!resolved) {
|
|
58
|
+
opts?.onError?.(new ShutdownSupervisorResolveError("Shutdown supervisor entry missing (expected Stagehand CLI entrypoint)."), "resolve");
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const child = spawn(resolved.command, resolved.args, {
|
|
62
|
+
// stdin is the parent lifeline.
|
|
63
|
+
// Preserve supervisor stderr so crash-cleanup debug lines are visible.
|
|
64
|
+
stdio: ["pipe", "ignore", "inherit"],
|
|
65
|
+
detached: true,
|
|
66
|
+
});
|
|
67
|
+
child.on("error", (error) => {
|
|
68
|
+
opts?.onError?.(new ShutdownSupervisorSpawnError(`Shutdown supervisor failed to start: ${error.message}`), "spawn");
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
child.unref();
|
|
72
|
+
const stdin = child.stdin;
|
|
73
|
+
stdin?.unref?.();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// best-effort: avoid keeping the event loop alive
|
|
77
|
+
}
|
|
78
|
+
const stop = () => {
|
|
79
|
+
// Normal close path: terminate supervisor directly.
|
|
80
|
+
try {
|
|
81
|
+
child.kill("SIGTERM");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// ignore
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return { stop };
|
|
88
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { TimeoutError } from "./types/public/sdkErrors";
|
|
2
|
+
export function getEnvTimeoutMs(name) {
|
|
3
|
+
const raw = process.env[name];
|
|
4
|
+
if (!raw)
|
|
5
|
+
return undefined;
|
|
6
|
+
const normalized = raw.trim().replace(/ms$/i, "");
|
|
7
|
+
const value = Number(normalized);
|
|
8
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export async function withTimeout(promise, timeoutMs, operation) {
|
|
13
|
+
if (typeof timeoutMs !== "number" ||
|
|
14
|
+
!Number.isFinite(timeoutMs) ||
|
|
15
|
+
timeoutMs <= 0) {
|
|
16
|
+
return await promise;
|
|
17
|
+
}
|
|
18
|
+
let timeoutId;
|
|
19
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
20
|
+
timeoutId = setTimeout(() => {
|
|
21
|
+
reject(new TimeoutError(operation, timeoutMs));
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (timeoutId)
|
|
29
|
+
clearTimeout(timeoutId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal-only errors for the shutdown supervisor.
|
|
3
|
+
*/
|
|
4
|
+
export class ShutdownSupervisorError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ShutdownSupervisorError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class ShutdownSupervisorResolveError extends ShutdownSupervisorError {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ShutdownSupervisorResolveError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class ShutdownSupervisorSpawnError extends ShutdownSupervisorError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ShutdownSupervisorSpawnError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local browser launch options schema (Zod).
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const LocalBrowserLaunchOptionsSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
args: z.array(z.string()).optional(),
|
|
8
|
+
executablePath: z.string().optional(),
|
|
9
|
+
port: z.number().optional(),
|
|
10
|
+
userDataDir: z.string().optional(),
|
|
11
|
+
preserveUserDataDir: z.boolean().optional(),
|
|
12
|
+
headless: z.boolean().optional(),
|
|
13
|
+
devtools: z.boolean().optional(),
|
|
14
|
+
chromiumSandbox: z.boolean().optional(),
|
|
15
|
+
ignoreDefaultArgs: z.union([z.boolean(), z.array(z.string())]).optional(),
|
|
16
|
+
proxy: z
|
|
17
|
+
.object({
|
|
18
|
+
server: z.string(),
|
|
19
|
+
bypass: z.string().optional(),
|
|
20
|
+
username: z.string().optional(),
|
|
21
|
+
password: z.string().optional(),
|
|
22
|
+
})
|
|
23
|
+
.optional(),
|
|
24
|
+
locale: z.string().optional(),
|
|
25
|
+
viewport: z.object({ width: z.number(), height: z.number() }).optional(),
|
|
26
|
+
deviceScaleFactor: z.number().optional(),
|
|
27
|
+
hasTouch: z.boolean().optional(),
|
|
28
|
+
ignoreHTTPSErrors: z.boolean().optional(),
|
|
29
|
+
cdpUrl: z.string().optional(),
|
|
30
|
+
cdpHeaders: z.record(z.string(), z.string()).optional(),
|
|
31
|
+
connectTimeoutMs: z.number().optional(),
|
|
32
|
+
downloadsPath: z.string().optional(),
|
|
33
|
+
acceptDownloads: z.boolean().optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict()
|
|
36
|
+
.meta({ id: "LocalBrowserLaunchOptions" });
|