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.
Files changed (6) hide show
  1. package/README.md +32 -0
  2. package/package.json +1 -1
  3. package/rech.js +389 -37
  4. package/rech.ts +389 -37
  5. package/serve.js +73 -7
  6. 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
- const absBase = resolve(base) + "/";
62
- const absCandidate = resolve(base, candidate);
63
- return absCandidate.startsWith(absBase);
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 server = Bun.serve({
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").split(" ");
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 `rech open` with no URL: warn if session already has tabs
200
- if (isOpenCmd && filteredArgs.length === 1) {
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
- const absBase = resolve(base) + "/";
62
- const absCandidate = resolve(base, candidate);
63
- return absCandidate.startsWith(absBase);
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 server = Bun.serve({
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").split(" ");
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 `rech open` with no URL: warn if session already has tabs
200
- if (isOpenCmd && filteredArgs.length === 1) {
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
  }