rechrome 1.17.0 → 1.18.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 +32 -0
- package/package.json +1 -1
- package/rech.js +389 -37
- package/rech.ts +389 -37
- package/serve.js +73 -7
- package/serve.ts +73 -7
package/serve.js
CHANGED
|
@@ -58,9 +58,20 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// Use path.relative rather than string-prefix: resolve() yields backslash paths on
|
|
62
|
+
// Windows, so a "absBase + '/'" prefix check never matched there (every file was
|
|
63
|
+
// rejected). A candidate is under base iff the relative path neither escapes (..) nor
|
|
64
|
+
// is absolute (different drive).
|
|
65
|
+
const rel = relative(resolve(base), resolve(base, candidate));
|
|
66
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tokenize a command string into argv, honoring double-quoted segments so an interpreter
|
|
70
|
+
// path containing spaces (e.g. a quoted Windows "C:\Program Files\…\node.exe") survives.
|
|
71
|
+
// PLAYWRIGHT_CLI is space-joined by the installer; a plain split(" ") would shatter such paths.
|
|
72
|
+
export function splitCommand(cmd: string): string[] {
|
|
73
|
+
return (cmd.match(/"[^"]*"|\S+/g) ?? []).map(t =>
|
|
74
|
+
t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
@@ -84,6 +95,45 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
84
95
|
return nameOrEmail;
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
99
|
+
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
100
|
+
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
101
|
+
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
102
|
+
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
103
|
+
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
104
|
+
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
105
|
+
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
106
|
+
async function freeStalePort(port: number): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
// Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
|
|
110
|
+
// process; (2) only if the port is STILL held — the inherited-handle case, where the
|
|
111
|
+
// socket lives in a child while netstat attributes it to a now-dead owner — fall back
|
|
112
|
+
// to killing orphaned cliDaemon holders. The fallback is the only recovery for that
|
|
113
|
+
// case (the live holder can't be mapped from the port), but it runs only when the
|
|
114
|
+
// precise kill failed, so the broad sweep is a logged last resort, not the default.
|
|
115
|
+
const ps = [
|
|
116
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
117
|
+
`$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
|
|
118
|
+
"if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
|
|
119
|
+
`if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
|
|
120
|
+
" $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
|
|
121
|
+
" Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
|
|
122
|
+
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
123
|
+
"}",
|
|
124
|
+
].join(" ");
|
|
125
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
126
|
+
const out = r.stdout?.toString().trim();
|
|
127
|
+
if (out) log(out);
|
|
128
|
+
} else {
|
|
129
|
+
Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// best effort — the retry will surface a clear error if the port is still held
|
|
133
|
+
}
|
|
134
|
+
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
135
|
+
}
|
|
136
|
+
|
|
87
137
|
export async function serve() {
|
|
88
138
|
const url = await getOrCreateUrl();
|
|
89
139
|
const { key, port } = parseUrl(url);
|
|
@@ -104,7 +154,7 @@ export async function serve() {
|
|
|
104
154
|
}, 86_400_000);
|
|
105
155
|
}
|
|
106
156
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
107
|
-
const
|
|
157
|
+
const startServer = () => Bun.serve({
|
|
108
158
|
hostname: listenHost,
|
|
109
159
|
port,
|
|
110
160
|
tls,
|
|
@@ -183,7 +233,7 @@ export async function serve() {
|
|
|
183
233
|
});
|
|
184
234
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
185
235
|
|
|
186
|
-
const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab")
|
|
236
|
+
const [bin, ...binArgs] = splitCommand(process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab");
|
|
187
237
|
|
|
188
238
|
if (filteredArgs.length === 0) {
|
|
189
239
|
filteredArgs.push("--help");
|
|
@@ -196,8 +246,10 @@ export async function serve() {
|
|
|
196
246
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
197
247
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
198
248
|
|
|
199
|
-
// bare `
|
|
200
|
-
|
|
249
|
+
// open against an existing session: bare `open` returns a tab-list hint; `open <url>`
|
|
250
|
+
// converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
|
|
251
|
+
// was dead — about:blank/<url> is already appended above, so length is always >=2.)
|
|
252
|
+
if (isOpenCmd) {
|
|
201
253
|
try {
|
|
202
254
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
203
255
|
cwd: workDir,
|
|
@@ -254,6 +306,8 @@ export async function serve() {
|
|
|
254
306
|
TMPDIR: process.env.TMPDIR,
|
|
255
307
|
DISPLAY: process.env.DISPLAY,
|
|
256
308
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
309
|
+
DEBUG: process.env.DEBUG, // forward debug namespaces (e.g. pw:mcp:relay) for diagnostics
|
|
310
|
+
PWDEBUG: process.env.PWDEBUG,
|
|
257
311
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
258
312
|
...passthroughEnv,
|
|
259
313
|
// Enable extension bridge when credentials are present
|
|
@@ -341,6 +395,18 @@ export async function serve() {
|
|
|
341
395
|
},
|
|
342
396
|
});
|
|
343
397
|
|
|
398
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port held after a
|
|
399
|
+
// prior serve exits; on EADDRINUSE, clear stale holders once and retry rather than crash-loop.
|
|
400
|
+
let server: ReturnType<typeof startServer>;
|
|
401
|
+
try {
|
|
402
|
+
server = startServer();
|
|
403
|
+
} catch (e: any) {
|
|
404
|
+
if (!String(e?.code ?? e?.message ?? "").includes("EADDRINUSE")) throw e;
|
|
405
|
+
log(`port ${port} in use — clearing stale daemon holders and retrying`);
|
|
406
|
+
await freeStalePort(port);
|
|
407
|
+
server = startServer();
|
|
408
|
+
}
|
|
409
|
+
|
|
344
410
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
345
411
|
log(`Connection URL set (use .env.local to view)`);
|
|
346
412
|
}
|
package/serve.ts
CHANGED
|
@@ -58,9 +58,20 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function isUnderDir(base: string, candidate: string): boolean {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// Use path.relative rather than string-prefix: resolve() yields backslash paths on
|
|
62
|
+
// Windows, so a "absBase + '/'" prefix check never matched there (every file was
|
|
63
|
+
// rejected). A candidate is under base iff the relative path neither escapes (..) nor
|
|
64
|
+
// is absolute (different drive).
|
|
65
|
+
const rel = relative(resolve(base), resolve(base, candidate));
|
|
66
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tokenize a command string into argv, honoring double-quoted segments so an interpreter
|
|
70
|
+
// path containing spaces (e.g. a quoted Windows "C:\Program Files\…\node.exe") survives.
|
|
71
|
+
// PLAYWRIGHT_CLI is space-joined by the installer; a plain split(" ") would shatter such paths.
|
|
72
|
+
export function splitCommand(cmd: string): string[] {
|
|
73
|
+
return (cmd.match(/"[^"]*"|\S+/g) ?? []).map(t =>
|
|
74
|
+
t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
@@ -84,6 +95,45 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
|
|
|
84
95
|
return nameOrEmail;
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
// Free the listening port from stale daemon holders before retrying a failed bind.
|
|
99
|
+
// On Windows the listening socket (created inheritable by Bun.serve) is swept into the
|
|
100
|
+
// detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
|
|
101
|
+
// previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
|
|
102
|
+
// then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
|
|
103
|
+
// only happens when such a stale holder exists; killing orphaned daemon holders here is
|
|
104
|
+
// safe because a freshly-starting serve owns no live sessions of its own yet (the user's
|
|
105
|
+
// Chrome tabs persist regardless — the cliDaemon only drives them).
|
|
106
|
+
async function freeStalePort(port: number): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
// Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
|
|
110
|
+
// process; (2) only if the port is STILL held — the inherited-handle case, where the
|
|
111
|
+
// socket lives in a child while netstat attributes it to a now-dead owner — fall back
|
|
112
|
+
// to killing orphaned cliDaemon holders. The fallback is the only recovery for that
|
|
113
|
+
// case (the live holder can't be mapped from the port), but it runs only when the
|
|
114
|
+
// precise kill failed, so the broad sweep is a logged last resort, not the default.
|
|
115
|
+
const ps = [
|
|
116
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
117
|
+
`$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
|
|
118
|
+
"if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
|
|
119
|
+
`if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
|
|
120
|
+
" $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
|
|
121
|
+
" Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
|
|
122
|
+
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
123
|
+
"}",
|
|
124
|
+
].join(" ");
|
|
125
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
126
|
+
const out = r.stdout?.toString().trim();
|
|
127
|
+
if (out) log(out);
|
|
128
|
+
} else {
|
|
129
|
+
Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// best effort — the retry will surface a clear error if the port is still held
|
|
133
|
+
}
|
|
134
|
+
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
135
|
+
}
|
|
136
|
+
|
|
87
137
|
export async function serve() {
|
|
88
138
|
const url = await getOrCreateUrl();
|
|
89
139
|
const { key, port } = parseUrl(url);
|
|
@@ -104,7 +154,7 @@ export async function serve() {
|
|
|
104
154
|
}, 86_400_000);
|
|
105
155
|
}
|
|
106
156
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
107
|
-
const
|
|
157
|
+
const startServer = () => Bun.serve({
|
|
108
158
|
hostname: listenHost,
|
|
109
159
|
port,
|
|
110
160
|
tls,
|
|
@@ -183,7 +233,7 @@ export async function serve() {
|
|
|
183
233
|
});
|
|
184
234
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
185
235
|
|
|
186
|
-
const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab")
|
|
236
|
+
const [bin, ...binArgs] = splitCommand(process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab");
|
|
187
237
|
|
|
188
238
|
if (filteredArgs.length === 0) {
|
|
189
239
|
filteredArgs.push("--help");
|
|
@@ -196,8 +246,10 @@ export async function serve() {
|
|
|
196
246
|
const isOpenNoUrl = isOpenCmd && filteredArgs.length === 1;
|
|
197
247
|
if (isOpenNoUrl) filteredArgs.push("about:blank");
|
|
198
248
|
|
|
199
|
-
// bare `
|
|
200
|
-
|
|
249
|
+
// open against an existing session: bare `open` returns a tab-list hint; `open <url>`
|
|
250
|
+
// converts to `goto` to reuse the live browser. (Guarding on filteredArgs.length===1
|
|
251
|
+
// was dead — about:blank/<url> is already appended above, so length is always >=2.)
|
|
252
|
+
if (isOpenCmd) {
|
|
201
253
|
try {
|
|
202
254
|
const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
|
|
203
255
|
cwd: workDir,
|
|
@@ -254,6 +306,8 @@ export async function serve() {
|
|
|
254
306
|
TMPDIR: process.env.TMPDIR,
|
|
255
307
|
DISPLAY: process.env.DISPLAY,
|
|
256
308
|
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
309
|
+
DEBUG: process.env.DEBUG, // forward debug namespaces (e.g. pw:mcp:relay) for diagnostics
|
|
310
|
+
PWDEBUG: process.env.PWDEBUG,
|
|
257
311
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
258
312
|
...passthroughEnv,
|
|
259
313
|
// Enable extension bridge when credentials are present
|
|
@@ -341,6 +395,18 @@ export async function serve() {
|
|
|
341
395
|
},
|
|
342
396
|
});
|
|
343
397
|
|
|
398
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port held after a
|
|
399
|
+
// prior serve exits; on EADDRINUSE, clear stale holders once and retry rather than crash-loop.
|
|
400
|
+
let server: ReturnType<typeof startServer>;
|
|
401
|
+
try {
|
|
402
|
+
server = startServer();
|
|
403
|
+
} catch (e: any) {
|
|
404
|
+
if (!String(e?.code ?? e?.message ?? "").includes("EADDRINUSE")) throw e;
|
|
405
|
+
log(`port ${port} in use — clearing stale daemon holders and retrying`);
|
|
406
|
+
await freeStalePort(port);
|
|
407
|
+
server = startServer();
|
|
408
|
+
}
|
|
409
|
+
|
|
344
410
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
345
411
|
log(`Connection URL set (use .env.local to view)`);
|
|
346
412
|
}
|