diff --git a/packages/gym/.gitignore b/packages/gym/.gitignore index a680367ef..1288e0d39 100644 --- a/packages/gym/.gitignore +++ b/packages/gym/.gitignore @@ -1 +1,3 @@ .next + +certificates \ No newline at end of file diff --git a/packages/provider-amp/src/cli.ts b/packages/provider-amp/src/cli.ts index f9fbaef09..646746fc9 100644 --- a/packages/provider-amp/src/cli.ts +++ b/packages/provider-amp/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -const child = spawn(process.execPath, [serverPath], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(); diff --git a/packages/provider-amp/src/server.ts b/packages/provider-amp/src/server.ts index 72c2e0fb1..b4717d8d8 100644 --- a/packages/provider-amp/src/server.ts +++ b/packages/provider-amp/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { ampAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=amp&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: ampAgentHandler }); +connectRelay({ handler: ampAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-claude-code/src/cli.ts b/packages/provider-claude-code/src/cli.ts index f9fbaef09..646746fc9 100644 --- a/packages/provider-claude-code/src/cli.ts +++ b/packages/provider-claude-code/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -const child = spawn(process.execPath, [serverPath], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(); diff --git a/packages/provider-claude-code/src/server.ts b/packages/provider-claude-code/src/server.ts index 3032987ac..a12c7a42c 100644 --- a/packages/provider-claude-code/src/server.ts +++ b/packages/provider-claude-code/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { claudeAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=claude-code&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: claudeAgentHandler }); +connectRelay({ handler: claudeAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-codex/src/cli.ts b/packages/provider-codex/src/cli.ts index f9fbaef09..646746fc9 100644 --- a/packages/provider-codex/src/cli.ts +++ b/packages/provider-codex/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -const child = spawn(process.execPath, [serverPath], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(); diff --git a/packages/provider-codex/src/server.ts b/packages/provider-codex/src/server.ts index 4c50f6690..bca83b16a 100644 --- a/packages/provider-codex/src/server.ts +++ b/packages/provider-codex/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { codexAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=codex&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: codexAgentHandler }); +connectRelay({ handler: codexAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-cursor/src/cli.ts b/packages/provider-cursor/src/cli.ts index f9fbaef09..646746fc9 100644 --- a/packages/provider-cursor/src/cli.ts +++ b/packages/provider-cursor/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -const child = spawn(process.execPath, [serverPath], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(); diff --git a/packages/provider-cursor/src/server.ts b/packages/provider-cursor/src/server.ts index 6bed4cd94..3b45b8cc4 100644 --- a/packages/provider-cursor/src/server.ts +++ b/packages/provider-cursor/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { cursorAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=cursor&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: cursorAgentHandler }); +connectRelay({ handler: cursorAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-droid/src/cli.ts b/packages/provider-droid/src/cli.ts index f9fbaef09..646746fc9 100644 --- a/packages/provider-droid/src/cli.ts +++ b/packages/provider-droid/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -const child = spawn(process.execPath, [serverPath], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(); diff --git a/packages/provider-droid/src/server.ts b/packages/provider-droid/src/server.ts index ce6e557b0..8fae72887 100644 --- a/packages/provider-droid/src/server.ts +++ b/packages/provider-droid/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { droidAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=droid&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: droidAgentHandler }); +connectRelay({ handler: droidAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-gemini/src/cli.ts b/packages/provider-gemini/src/cli.ts index f9fbaef09..646746fc9 100644 --- a/packages/provider-gemini/src/cli.ts +++ b/packages/provider-gemini/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -const child = spawn(process.execPath, [serverPath], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(); diff --git a/packages/provider-gemini/src/server.ts b/packages/provider-gemini/src/server.ts index cdaac3730..b17b2feff 100644 --- a/packages/provider-gemini/src/server.ts +++ b/packages/provider-gemini/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { geminiAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=gemini&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: geminiAgentHandler }); +connectRelay({ handler: geminiAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-opencode/src/cli.ts b/packages/provider-opencode/src/cli.ts index f9fbaef09..646746fc9 100644 --- a/packages/provider-opencode/src/cli.ts +++ b/packages/provider-opencode/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -const child = spawn(process.execPath, [serverPath], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(); diff --git a/packages/provider-opencode/src/server.ts b/packages/provider-opencode/src/server.ts index d2fc38b06..93d2b9405 100644 --- a/packages/provider-opencode/src/server.ts +++ b/packages/provider-opencode/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { openCodeAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=opencode&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: openCodeAgentHandler }); +connectRelay({ handler: openCodeAgentHandler, secure: isSecureConnection }); diff --git a/packages/react-grab/e2e/freeze-animations.spec.ts b/packages/react-grab/e2e/freeze-animations.spec.ts index 4e879d79b..2ee8a43ec 100644 --- a/packages/react-grab/e2e/freeze-animations.spec.ts +++ b/packages/react-grab/e2e/freeze-animations.spec.ts @@ -9,8 +9,9 @@ test.describe("Freeze Animations", () => { }) => { const getPageAnimationStates = async () => { return reactGrab.page.evaluate((attrName) => { - return document.getAnimations().reduce( - (states, animation) => { + return document + .getAnimations() + .reduce((states, animation) => { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; if (target instanceof Element) { @@ -25,9 +26,7 @@ test.describe("Freeze Animations", () => { } states.push(animation.playState); return states; - }, - [], - ); + }, []); }, ATTRIBUTE_NAME); }; diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index ad2650ca5..a6af80018 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -456,7 +456,9 @@ export const Toolbar: Component = (props) => { props.onToggleEnabled?.(); if (expandableWidth > 0) { - const widthChange = isCurrentlyEnabled ? -expandableWidth : expandableWidth; + const widthChange = isCurrentlyEnabled + ? -expandableWidth + : expandableWidth; expandedDimensions = { width: expandedDimensions.width + widthChange, height: expandedDimensions.height, @@ -465,9 +467,15 @@ export const Toolbar: Component = (props) => { if (shouldCompensatePosition) { const viewport = getVisualViewport(); - const positionOffset = isCurrentlyEnabled ? expandableWidth : -expandableWidth; + const positionOffset = isCurrentlyEnabled + ? expandableWidth + : -expandableWidth; const clampMin = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX; - const clampMax = viewport.offsetLeft + viewport.width - expandedDimensions.width - TOOLBAR_SNAP_MARGIN_PX; + const clampMax = + viewport.offsetLeft + + viewport.width - + expandedDimensions.width - + TOOLBAR_SNAP_MARGIN_PX; const compensatedX = clampToViewport( preTogglePosition.x + positionOffset, clampMin, diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index 1872ef110..394dac277 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -7,8 +7,15 @@ import { DEFAULT_RELAY_PORT, DEFAULT_RECONNECT_INTERVAL_MS, RELAY_TOKEN_PARAM, + UPGRADE_RETRY_DELAY_MS, + MAX_UPGRADE_RETRIES, } from "./protocol.js"; +interface HealthCheckResponse { + status: string; + secure?: boolean; +} + export interface RelayClient { connect: () => Promise; disconnect: () => void; @@ -30,10 +37,37 @@ interface RelayClientOptions { token?: string; } +const isSecureContext = (): boolean => + typeof window !== "undefined" && window.location.protocol === "https:"; + +const getDefaultWebSocketUrl = (): string => { + if (typeof window === "undefined") { + return `ws://localhost:${DEFAULT_RELAY_PORT}`; + } + const protocol = isSecureContext() ? "wss:" : "ws:"; + return `${protocol}//${window.location.hostname}:${DEFAULT_RELAY_PORT}`; +}; + +const buildHealthUrl = ( + baseWsUrl: string, + protocol: "http:" | "https:", + token?: string, +): string => { + const url = new URL(baseWsUrl.replace(/^wss?:/, protocol)); + url.pathname = "/health"; + if (isSecureContext()) { + url.searchParams.set("secure", "true"); + } + if (token) { + url.searchParams.set(RELAY_TOKEN_PARAM, token); + } + return url.toString(); +}; + export const createRelayClient = ( options: RelayClientOptions = {}, ): RelayClient => { - const serverUrl = options.serverUrl ?? `ws://localhost:${DEFAULT_RELAY_PORT}`; + const serverUrl = options.serverUrl ?? getDefaultWebSocketUrl(); const autoReconnect = options.autoReconnect ?? true; const reconnectIntervalMs = options.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS; @@ -77,6 +111,76 @@ export const createRelayClient = ( } catch {} }; + const ensureServerReady = async (): Promise => { + const httpHealthUrl = buildHealthUrl(serverUrl, "http:", token); + const httpsHealthUrl = buildHealthUrl(serverUrl, "https:", token); + const needsUpgrade = isSecureContext(); + + let didTriggerUpgrade = false; + let consecutiveHttpFailures = 0; + const HTTP_FAILURE_THRESHOLD = 3; + + const delay = (): Promise => + new Promise((resolve) => setTimeout(resolve, UPGRADE_RETRY_DELAY_MS)); + + const tryFetch = async ( + url: string, + ): Promise => { + try { + const response = await fetch(url); + if (!response.ok) return null; + return (await response.json()) as HealthCheckResponse; + } catch { + return null; + } + }; + + for (let attempt = 0; attempt < MAX_UPGRADE_RETRIES; attempt++) { + if (!needsUpgrade) { + const healthResponse = await tryFetch(httpHealthUrl); + if (healthResponse && healthResponse.status === "ok") return; + await delay(); + continue; + } + + // HACK: When in a secure context, use HTTP to trigger the upgrade, + // then switch to HTTPS once the server has restarted + if (!didTriggerUpgrade) { + const healthResponse = await tryFetch(httpHealthUrl); + if (healthResponse) { + consecutiveHttpFailures = 0; + if ( + healthResponse.status === "upgrading" || + (!healthResponse.secure && needsUpgrade) + ) { + didTriggerUpgrade = true; + await delay(); + continue; + } + if (healthResponse.status === "ok" && healthResponse.secure) return; + } else { + consecutiveHttpFailures++; + if (consecutiveHttpFailures >= HTTP_FAILURE_THRESHOLD) { + didTriggerUpgrade = true; + } + } + await delay(); + continue; + } + + const healthResponse = await tryFetch(httpsHealthUrl); + if ( + healthResponse && + healthResponse.status === "ok" && + healthResponse.secure + ) + return; + await delay(); + } + + throw new Error("Server upgrade timed out after maximum retries"); + }; + const connect = (): Promise => { if (webSocketConnection?.readyState === WebSocket.OPEN) { return Promise.resolve(); @@ -88,49 +192,63 @@ export const createRelayClient = ( isIntentionalDisconnect = false; - pendingConnectionPromise = new Promise((resolve, reject) => { - pendingConnectionReject = reject; - const connectionUrl = token - ? `${serverUrl}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : serverUrl; - webSocketConnection = new WebSocket(connectionUrl); - - webSocketConnection.onopen = () => { - pendingConnectionPromise = null; - pendingConnectionReject = null; - isConnectedState = true; - for (const callback of connectionChangeCallbacks) { - callback(true); - } - resolve(); - }; - - webSocketConnection.onmessage = handleMessage; + pendingConnectionPromise = (async () => { + try { + await ensureServerReady(); - webSocketConnection.onclose = () => { - if (pendingConnectionReject) { - pendingConnectionReject(new Error("WebSocket connection closed")); - pendingConnectionReject = null; - } - pendingConnectionPromise = null; - isConnectedState = false; - availableHandlers = []; - for (const callback of handlersChangeCallbacks) { - callback(availableHandlers); + if (isIntentionalDisconnect) { + throw new Error("Connection aborted"); } - for (const callback of connectionChangeCallbacks) { - callback(false); - } - scheduleReconnect(); - }; - webSocketConnection.onerror = () => { + return new Promise((resolve, reject) => { + pendingConnectionReject = reject; + const connectionUrl = token + ? `${serverUrl}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : serverUrl; + webSocketConnection = new WebSocket(connectionUrl); + + webSocketConnection.onopen = () => { + pendingConnectionPromise = null; + pendingConnectionReject = null; + isConnectedState = true; + for (const callback of connectionChangeCallbacks) { + callback(true); + } + resolve(); + }; + + webSocketConnection.onmessage = handleMessage; + + webSocketConnection.onclose = () => { + if (pendingConnectionReject) { + pendingConnectionReject(new Error("WebSocket connection closed")); + pendingConnectionReject = null; + } + pendingConnectionPromise = null; + isConnectedState = false; + availableHandlers = []; + for (const callback of handlersChangeCallbacks) { + callback(availableHandlers); + } + for (const callback of connectionChangeCallbacks) { + callback(false); + } + scheduleReconnect(); + }; + + webSocketConnection.onerror = () => { + pendingConnectionPromise = null; + pendingConnectionReject = null; + isConnectedState = false; + reject(new Error("WebSocket connection failed")); + }; + }); + } catch (error) { pendingConnectionPromise = null; pendingConnectionReject = null; - isConnectedState = false; - reject(new Error("WebSocket connection failed")); - }; - }); + throw error; + } + })(); return pendingConnectionPromise; }; diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index fce281cd5..5a9249b9e 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -3,6 +3,7 @@ import fkill from "fkill"; import type { AgentHandler } from "./protocol.js"; import { DEFAULT_RELAY_PORT, + HANDLER_RECONNECT_DELAY_MS, HEALTH_CHECK_TIMEOUT_MS, POST_KILL_DELAY_MS, RELAY_TOKEN_PARAM, @@ -12,55 +13,149 @@ import { sleep } from "@react-grab/utils/server"; const VERSION = process.env.VERSION ?? "0.0.0"; +const getRelayMessageType = ( + agentMessageType: string, +): "agent-status" | "agent-error" | "agent-done" => { + if (agentMessageType === "status") return "agent-status"; + if (agentMessageType === "error") return "agent-error"; + return "agent-done"; +}; + interface ConnectRelayOptions { port?: number; handler: AgentHandler; token?: string; + secure?: boolean; + certHostnames?: string[]; } interface RelayConnection { disconnect: () => Promise; } +interface HealthCheckResult { + isRunning: true; + isSecure: boolean; +} + const checkIfRelayServerIsRunning = async ( port: number, token?: string, -): Promise => { - try { - const healthUrl = token - ? `http://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : `http://localhost:${port}/health`; - const response = await fetch(healthUrl, { - method: "GET", - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), - }); - return response.ok; - } catch { + secure?: boolean, +): Promise => { + const tryProtocol = async (useHttps: boolean): Promise => { + try { + const httpProtocol = useHttps ? "https" : "http"; + const healthUrl = token + ? `${httpProtocol}://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : `${httpProtocol}://localhost:${port}/health`; + + if (useHttps) { + // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs + const https = await import("node:https"); + return new Promise((resolve) => { + const request = https.get( + healthUrl, + { rejectUnauthorized: false, timeout: HEALTH_CHECK_TIMEOUT_MS }, + (response) => resolve(response.statusCode === 200), + ); + request.on("error", () => resolve(false)); + request.on("timeout", () => { + request.destroy(); + resolve(false); + }); + }); + } + + const response = await fetch(healthUrl, { + method: "GET", + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + }); + return response.ok; + } catch { + return false; + } + }; + + if (secure !== undefined) { + const result = await tryProtocol(secure); + if (result) return { isRunning: true, isSecure: secure }; + const fallbackResult = await tryProtocol(!secure); + if (fallbackResult) return { isRunning: true, isSecure: !secure }; return false; } + + const httpResult = await tryProtocol(false); + if (httpResult) return { isRunning: true, isSecure: false }; + const httpsResult = await tryProtocol(true); + if (httpsResult) return { isRunning: true, isSecure: true }; + return false; }; export const connectRelay = async ( options: ConnectRelayOptions, ): Promise => { const relayPort = options.port ?? DEFAULT_RELAY_PORT; - const { handler, token } = options; + const { handler, token, secure, certHostnames } = options; let relayServer: RelayServer | null = null; let isRelayHost = false; - const isRelayServerRunning = await checkIfRelayServerIsRunning( + const healthCheckResult = await checkIfRelayServerIsRunning( relayPort, token, + secure, ); - if (isRelayServerRunning) { - relayServer = await connectToExistingRelay(relayPort, handler, token); - } else { - await fkill(`:${relayPort}`, { force: true, silent: true }).catch(() => {}); - await sleep(POST_KILL_DELAY_MS); + const isRelayServerRunning = Boolean(healthCheckResult); + const detectedSecure = healthCheckResult + ? healthCheckResult.isSecure + : undefined; + + let isSecureMode = secure ?? detectedSecure ?? false; + + const startServer = async (useSecure: boolean): Promise => { + const onSecureUpgradeRequested = async () => { + if (isSecureMode || !isRelayHost) return; + isSecureMode = true; + + console.log( + pc.yellow( + "Secure connection requested by browser, upgrading to HTTPS...", + ), + ); + + await relayServer?.stop(); + + try { + relayServer = createRelayServer({ + port: relayPort, + token, + secure: true, + onSecureUpgradeRequested, + certHostnames, + }); + relayServer.registerHandler(handler); + await relayServer.start(); + + printStartupMessage(handler.agentId, relayPort, true); + } catch (error) { + // HACK: process.exit(1) because the old HTTP server is already stopped, so fallback isn't possible + console.error( + pc.red("Failed to upgrade to HTTPS:"), + error instanceof Error ? error.message : error, + ); + process.exit(1); + } + }; - relayServer = createRelayServer({ port: relayPort, token }); + relayServer = createRelayServer({ + port: relayPort, + token, + secure: useSecure, + onSecureUpgradeRequested, + certHostnames, + }); relayServer.registerHandler(handler); try { @@ -75,15 +170,39 @@ export const connectRelay = async ( if (!isAddressInUse) throw error; await sleep(POST_KILL_DELAY_MS); - const isNowRunning = await checkIfRelayServerIsRunning(relayPort, token); + const isNowRunning = await checkIfRelayServerIsRunning( + relayPort, + token, + useSecure, + ); if (!isNowRunning) throw error; - relayServer = await connectToExistingRelay(relayPort, handler, token); + relayServer = await connectToExistingRelay( + relayPort, + handler, + token, + isNowRunning.isSecure, + ); } + }; + + if (isRelayServerRunning) { + const actualSecure = detectedSecure ?? secure; + relayServer = await connectToExistingRelay( + relayPort, + handler, + token, + actualSecure, + ); + isSecureMode = actualSecure ?? false; + } else { + await fkill(`:${relayPort}`, { force: true, silent: true }).catch(() => {}); + await sleep(POST_KILL_DELAY_MS); + await startServer(isSecureMode); } - printStartupMessage(handler.agentId, relayPort); + printStartupMessage(handler.agentId, relayPort, isSecureMode); const handleShutdown = async () => { if (isRelayHost) { @@ -114,32 +233,82 @@ const connectToExistingRelay = async ( port: number, handler: AgentHandler, token?: string, + secure?: boolean, ): Promise => { const { WebSocket } = await import("ws"); - return new Promise((resolve, reject) => { - const connectionUrl = token - ? `ws://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : `ws://localhost:${port}`; - const socket = new WebSocket(connectionUrl, { - headers: { "x-relay-handler": "true" }, - }); + let currentSocket: typeof WebSocket.prototype | null = null; + let isSocketClosed = false; + let isExplicitDisconnect = false; + let hasConnectedOnce = false; + const activeSessionIds = new Set(); + let currentSecure = secure; + let reconnectTimeoutId: NodeJS.Timeout | null = null; - socket.on("open", () => { - let isSocketClosed = false; - const activeSessionIds = new Set(); + const sendData = (data: string): boolean => { + if ( + isSocketClosed || + !currentSocket || + currentSocket.readyState !== WebSocket.OPEN + ) { + return false; + } + try { + currentSocket.send(data); + return true; + } catch { + return false; + } + }; - const sendData = (data: string): boolean => { - if (isSocketClosed || socket.readyState !== WebSocket.OPEN) { - return false; - } - try { - socket.send(data); - return true; - } catch { - return false; - } - }; + const sendOperationResult = async ( + operation: () => Promise | undefined, + sessionId: string, + ): Promise => { + try { + await operation(); + sendData( + JSON.stringify({ + type: "agent-done", + sessionId, + agentId: handler.agentId, + content: "", + }), + ); + } catch (error) { + sendData( + JSON.stringify({ + type: "agent-error", + sessionId, + agentId: handler.agentId, + content: error instanceof Error ? error.message : "Unknown error", + }), + ); + } + }; + + const attemptConnection = async (): Promise => { + if (hasConnectedOnce) { + const healthCheckResult = await checkIfRelayServerIsRunning( + port, + token, + currentSecure, + ); + if (healthCheckResult) { + currentSecure = healthCheckResult.isSecure; + } + } + + return new Promise((resolve, reject) => { + const webSocketProtocol = currentSecure ? "wss" : "ws"; + const connectionUrl = token + ? `${webSocketProtocol}://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : `${webSocketProtocol}://localhost:${port}`; + // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs + const socket = new WebSocket(connectionUrl, { + headers: { "x-relay-handler": "true" }, + rejectUnauthorized: false, + }); socket.on("close", () => { isSocketClosed = true; @@ -149,156 +318,152 @@ const connectToExistingRelay = async ( } catch {} } activeSessionIds.clear(); + + if (!isExplicitDisconnect && hasConnectedOnce) { + reconnectTimeoutId = setTimeout(() => { + attemptConnection().catch(() => {}); + }, HANDLER_RECONNECT_DELAY_MS); + } }); - socket.send( - JSON.stringify({ - type: "register-handler", - agentId: handler.agentId, - }), - ); + socket.on("open", () => { + currentSocket = socket; + isSocketClosed = false; + hasConnectedOnce = true; - socket.on("message", async (data) => { - try { - const message = JSON.parse(data.toString()); + socket.send( + JSON.stringify({ + type: "register-handler", + agentId: handler.agentId, + }), + ); - if (message.type === "invoke-handler") { - const { method, sessionId, payload } = message; + socket.on("message", async (data) => { + try { + const message = JSON.parse(data.toString()); - if (method === "run" && payload?.prompt) { - activeSessionIds.add(sessionId); - try { - let didComplete = false; - for await (const agentMessage of handler.run(payload.prompt, { - sessionId, - })) { - if (isSocketClosed) { - break; + if (message.type === "invoke-handler") { + const { method, sessionId, payload } = message; + + if (method === "run" && payload?.prompt) { + activeSessionIds.add(sessionId); + try { + let didComplete = false; + for await (const agentMessage of handler.run(payload.prompt, { + sessionId, + })) { + if (isSocketClosed) { + break; + } + sendData( + JSON.stringify({ + type: getRelayMessageType(agentMessage.type), + sessionId, + agentId: handler.agentId, + content: agentMessage.content, + }), + ); + if ( + agentMessage.type === "done" || + agentMessage.type === "error" + ) { + didComplete = true; + } } - sendData( - JSON.stringify({ - type: - agentMessage.type === "status" - ? "agent-status" - : agentMessage.type === "error" - ? "agent-error" - : "agent-done", - sessionId, - agentId: handler.agentId, - content: agentMessage.content, - }), - ); - if ( - agentMessage.type === "done" || - agentMessage.type === "error" - ) { - didComplete = true; + if (!didComplete && !isSocketClosed) { + sendData( + JSON.stringify({ + type: "agent-done", + sessionId, + agentId: handler.agentId, + content: "", + }), + ); } - } - if (!didComplete && !isSocketClosed) { + } catch (error) { sendData( JSON.stringify({ - type: "agent-done", + type: "agent-error", sessionId, agentId: handler.agentId, - content: "", + content: + error instanceof Error + ? error.message + : "Unknown error", }), ); + } finally { + activeSessionIds.delete(sessionId); } - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error ? error.message : "Unknown error", - }), - ); - } finally { - activeSessionIds.delete(sessionId); - } - } else if (method === "abort") { - handler.abort?.(sessionId); - } else if (method === "undo") { - try { - await handler.undo?.(); - sendData( - JSON.stringify({ - type: "agent-done", - sessionId, - agentId: handler.agentId, - content: "", - }), - ); - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error ? error.message : "Unknown error", - }), - ); - } - } else if (method === "redo") { - try { - await handler.redo?.(); - sendData( - JSON.stringify({ - type: "agent-done", - sessionId, - agentId: handler.agentId, - content: "", - }), + } else if (method === "abort") { + handler.abort?.(sessionId); + } else if (method === "undo") { + await sendOperationResult( + async () => handler.undo?.(), + sessionId, ); - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error ? error.message : "Unknown error", - }), + } else if (method === "redo") { + await sendOperationResult( + async () => handler.redo?.(), + sessionId, ); } } - } - } catch {} - }); + } catch {} + }); - const proxyServer: RelayServer = { - start: async () => {}, - stop: async () => { - socket.close(); - }, - registerHandler: () => {}, - unregisterHandler: (agentId) => { - sendData( - JSON.stringify({ - type: "unregister-handler", - agentId, - }), - ); - socket.close(); - }, - getRegisteredHandlerIds: () => [handler.agentId], - }; + resolve(); + }); - resolve(proxyServer); + socket.on("error", (error) => { + reject(error); + }); }); + }; - socket.on("error", (error) => { - reject(error); - }); - }); + await attemptConnection(); + + const proxyServer: RelayServer = { + start: async () => {}, + stop: async () => { + isExplicitDisconnect = true; + if (reconnectTimeoutId) { + clearTimeout(reconnectTimeoutId); + reconnectTimeoutId = null; + } + currentSocket?.close(); + }, + registerHandler: () => {}, + unregisterHandler: (agentId) => { + isExplicitDisconnect = true; + if (reconnectTimeoutId) { + clearTimeout(reconnectTimeoutId); + reconnectTimeoutId = null; + } + sendData( + JSON.stringify({ + type: "unregister-handler", + agentId, + }), + ); + currentSocket?.close(); + }, + getRegisteredHandlerIds: () => [handler.agentId], + }; + + return proxyServer; }; -const printStartupMessage = (agentId: string, port: number) => { +const printStartupMessage = ( + agentId: string, + port: number, + secure: boolean, +) => { + const webSocketProtocol = secure ? "wss" : "ws"; console.log( `${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION)} ${pc.dim(`(${agentId})`)}`, ); - console.log(`- Local: ${pc.cyan(`ws://localhost:${port}`)}`); + console.log( + `- Local: ${pc.cyan(`${webSocketProtocol}://localhost:${port}`)}`, + ); }; diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts new file mode 100644 index 000000000..cbb0395dd --- /dev/null +++ b/packages/relay/src/mkcert.ts @@ -0,0 +1,160 @@ +import { exec as execCallback } from "node:child_process"; +import { createWriteStream, existsSync } from "node:fs"; +import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { homedir, platform, arch } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { pipeline } from "node:stream/promises"; + +const exec = promisify(execCallback); + +const DATA_DIR = join(homedir(), ".react-grab"); +const MKCERT_PATH = join( + DATA_DIR, + platform() === "win32" ? "mkcert.exe" : "mkcert", +); +const KEY_PATH = join(DATA_DIR, "localhost-key.pem"); +const CERT_PATH = join(DATA_DIR, "localhost.pem"); +const HOSTS_METADATA_PATH = join(DATA_DIR, "cert-hosts.json"); + +const getDefaultCaRoot = (): string => { + if (platform() === "win32") { + const localAppData = + process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"); + return join(localAppData, "mkcert"); + } + if (platform() === "darwin") { + return join(homedir(), "Library", "Application Support", "mkcert"); + } + const dataHome = + process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"); + return join(dataHome, "mkcert"); +}; + +const MKCERT_ENV = { env: { ...process.env, CAROOT: getDefaultCaRoot() } }; + +interface GithubReleaseAsset { + name: string; + browser_download_url: string; +} + +interface GithubReleaseResponse { + assets: GithubReleaseAsset[]; +} + +const getPlatformIdentifier = (): string => { + const architecture = arch() === "x64" ? "amd64" : arch(); + return platform() === "win32" + ? `windows-${architecture}.exe` + : `${platform()}-${architecture}`; +}; + +const fetchMkcertDownloadUrl = async (): Promise => { + const response = await fetch( + "https://api.github.com/repos/FiloSottile/mkcert/releases/latest", + ); + if (!response.ok) return undefined; + + const releaseData: GithubReleaseResponse = await response.json(); + const platformIdentifier = getPlatformIdentifier(); + const matchingAsset = releaseData.assets.find((asset) => + asset.name.includes(platformIdentifier), + ); + + return matchingAsset?.browser_download_url; +}; + +const downloadMkcert = async (downloadUrl: string): Promise => { + await mkdir(DATA_DIR, { recursive: true }); + + const response = await fetch(downloadUrl); + if (!response.ok || !response.body) { + throw new Error(`Failed to download mkcert: ${response.statusText}`); + } + + const fileStream = createWriteStream(MKCERT_PATH); + try { + // HACK: Web ReadableStream and Node.js ReadableStream types are incompatible but work at runtime + await pipeline( + response.body as unknown as NodeJS.ReadableStream, + fileStream, + ); + await chmod(MKCERT_PATH, 0o755); + } catch (error) { + await unlink(MKCERT_PATH).catch(() => {}); + throw error; + } +}; + +const runMkcert = async (args: string): Promise => { + await exec(`"${MKCERT_PATH}" ${args}`, MKCERT_ENV); +}; + +export interface Certificate { + key: Buffer; + cert: Buffer; +} + +const CA_INSTALLED_FLAG = join(DATA_DIR, ".ca-installed"); + +const validateHostname = (hostname: string): void => { + if (hostname.startsWith("-")) { + throw new Error(`Invalid hostname (leading hyphen): ${hostname}`); + } + if (!/^[a-zA-Z0-9.-]+$/.test(hostname)) { + throw new Error(`Invalid hostname: ${hostname}`); + } +}; + +export const ensureCertificates = async ( + hosts: string[] = ["localhost", "127.0.0.1"], +): Promise => { + hosts.forEach(validateHostname); + + if (!existsSync(MKCERT_PATH)) { + const downloadUrl = await fetchMkcertDownloadUrl(); + if (!downloadUrl) { + throw new Error( + `Unsupported platform: ${platform()} ${arch()}. Cannot download mkcert.`, + ); + } + await downloadMkcert(downloadUrl); + } + + if (!existsSync(CA_INSTALLED_FLAG)) { + await runMkcert("-install"); + await mkdir(DATA_DIR, { recursive: true }); + await writeFile(CA_INSTALLED_FLAG, ""); + } + + let shouldRegenerateCerts = !existsSync(KEY_PATH) || !existsSync(CERT_PATH); + + if (!shouldRegenerateCerts && existsSync(HOSTS_METADATA_PATH)) { + try { + const storedHostsData = await readFile(HOSTS_METADATA_PATH, "utf-8"); + const storedHosts = JSON.parse(storedHostsData) as string[]; + const sortedStoredHosts = [...storedHosts].sort(); + const sortedRequestedHosts = [...hosts].sort(); + shouldRegenerateCerts = + JSON.stringify(sortedStoredHosts) !== + JSON.stringify(sortedRequestedHosts); + } catch { + shouldRegenerateCerts = true; + } + } else if (!shouldRegenerateCerts) { + shouldRegenerateCerts = true; + } + + if (shouldRegenerateCerts) { + await runMkcert( + `-key-file "${KEY_PATH}" -cert-file "${CERT_PATH}" -- ${hosts.join(" ")}`, + ); + await writeFile(HOSTS_METADATA_PATH, JSON.stringify(hosts)); + } + + const [key, cert] = await Promise.all([ + readFile(KEY_PATH), + readFile(CERT_PATH), + ]); + return { key, cert }; +}; diff --git a/packages/relay/src/protocol.ts b/packages/relay/src/protocol.ts index ceaaef4ad..b75e3f8dc 100644 --- a/packages/relay/src/protocol.ts +++ b/packages/relay/src/protocol.ts @@ -4,6 +4,9 @@ export const HEALTH_CHECK_TIMEOUT_MS = 1000; export const POST_KILL_DELAY_MS = 100; export const RELAY_TOKEN_PARAM = "token"; export const COMPLETED_STATUS = "Completed"; +export const UPGRADE_RETRY_DELAY_MS = 1000; +export const MAX_UPGRADE_RETRIES = 15; +export const HANDLER_RECONNECT_DELAY_MS = 1000; export interface AgentMessage { type: "status" | "error" | "done"; diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 7439ca187..26cbdea7e 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -1,5 +1,10 @@ import { WebSocketServer, WebSocket } from "ws"; -import { createServer as createHttpServer } from "node:http"; +import { + createServer as createHttpServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { createServer as createHttpsServer } from "node:https"; import type { AgentHandler, AgentMessage, @@ -10,6 +15,7 @@ import type { AgentContext, } from "./protocol.js"; import { DEFAULT_RELAY_PORT, RELAY_TOKEN_PARAM } from "./protocol.js"; +import { ensureCertificates } from "./mkcert.js"; interface RegisteredHandler { agentId: string; @@ -83,6 +89,9 @@ interface ActiveSession { interface RelayServerOptions { port?: number; token?: string; + secure?: boolean; + onSecureUpgradeRequested?: () => Promise; + certHostnames?: string[]; } export interface RelayServer { @@ -98,6 +107,9 @@ export const createRelayServer = ( ): RelayServer => { const port = options.port ?? DEFAULT_RELAY_PORT; const token = options.token; + const secure = options.secure ?? false; + const onSecureUpgradeRequested = options.onSecureUpgradeRequested; + const certHostnames = options.certHostnames; const registeredHandlers = new Map(); const activeSessions = new Map(); @@ -106,7 +118,10 @@ export const createRelayServer = ( const sessionMessageQueues = new Map(); const undoRedoSessionOwners = new Map(); - let httpServer: ReturnType | null = null; + let httpServer: + | ReturnType + | ReturnType + | null = null; let webSocketServer: WebSocketServer | null = null; const broadcastHandlerList = () => { @@ -516,23 +531,63 @@ export const createRelayServer = ( }; const start = async (): Promise => { + const certificate = secure ? await ensureCertificates(certHostnames) : null; + return new Promise((resolve, reject) => { - httpServer = createHttpServer((req, res) => { + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }; + + const requestHandler = (req: IncomingMessage, res: ServerResponse) => { const requestUrl = new URL(req.url ?? "", `http://localhost:${port}`); + if (req.method === "OPTIONS") { + res.writeHead(204, corsHeaders); + res.end(); + return; + } + if (requestUrl.pathname === "/health") { + const healthHeaders = { + "Content-Type": "application/json", + ...corsHeaders, + }; + if (token) { const clientToken = requestUrl.searchParams.get(RELAY_TOKEN_PARAM); if (clientToken !== token) { - res.writeHead(401, { "Content-Type": "application/json" }); + res.writeHead(401, healthHeaders); res.end(JSON.stringify({ error: "Unauthorized" })); return; } } - res.writeHead(200, { "Content-Type": "application/json" }); + + const clientNeedsSecure = + requestUrl.searchParams.get("secure") === "true"; + if (clientNeedsSecure && !secure && onSecureUpgradeRequested) { + res.writeHead(200, healthHeaders); + res.end( + JSON.stringify({ + status: "upgrading", + handlers: getRegisteredHandlerIds(), + }), + ); + onSecureUpgradeRequested().catch((error) => { + console.error( + "Failed to upgrade to secure connection:", + error instanceof Error ? error.message : error, + ); + }); + return; + } + + res.writeHead(200, healthHeaders); res.end( JSON.stringify({ status: "ok", + secure, handlers: getRegisteredHandlerIds(), }), ); @@ -540,7 +595,19 @@ export const createRelayServer = ( } res.writeHead(404); res.end(); - }); + }; + + if (secure && certificate) { + httpServer = createHttpsServer( + { + key: certificate.key, + cert: certificate.cert, + }, + requestHandler, + ); + } else { + httpServer = createHttpServer(requestHandler); + } httpServer.on("error", (error) => { reject(error); @@ -657,8 +724,30 @@ export const createRelayServer = ( } handlerSockets.clear(); - webSocketServer?.close(); - httpServer?.close(); + const webSocketClosePromise = new Promise((resolve) => { + if (webSocketServer) { + webSocketServer.close(() => resolve()); + } else { + resolve(); + } + }); + + const httpServerClosePromise = new Promise((resolve, reject) => { + if (httpServer) { + httpServer.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + } else { + resolve(); + } + }); + + await webSocketClosePromise; + await httpServerClosePromise; }; const registerHandler = (handler: AgentHandler) => { diff --git a/packages/utils/package.json b/packages/utils/package.json index 1470d6a1d..c45ca7a61 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -22,6 +22,7 @@ "build": "rm -rf dist && NODE_ENV=production tsup" }, "devDependencies": { + "@types/node": "^25.2.0", "tsup": "^8.4.0" } } diff --git a/packages/utils/src/server.ts b/packages/utils/src/server.ts index 6a0795e19..c3cf0d2fd 100644 --- a/packages/utils/src/server.ts +++ b/packages/utils/src/server.ts @@ -1,3 +1,7 @@ +import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; + export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -55,3 +59,17 @@ export const formatSpawnError = (error: Error, commandName: string): string => { return error.message; }; + +export const spawnDetachedServer = (serverFilename = "server.cjs"): void => { + const realScriptPath = realpathSync(process.argv[1]); + const scriptDir = dirname(realScriptPath); + const serverPath = join(scriptDir, serverFilename); + + const userArgs = process.argv.slice(2); + const child = spawn(process.execPath, [serverPath, ...userArgs], { + detached: true, + stdio: "inherit", + }); + child.unref(); + process.exit(0); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805ef9c48..fd37d9e59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2.27.10 - version: 2.29.7(@types/node@24.10.2) + version: 2.29.7(@types/node@25.2.0) oxfmt: specifier: ^0.27.0 version: 0.27.0 @@ -38,7 +38,7 @@ importers: devDependencies: vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/cli: dependencies: @@ -66,7 +66,7 @@ importers: version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/design-system: dependencies: @@ -113,7 +113,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: 4.0.0-beta.8 - version: 4.0.0-beta.8(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.0.0-beta.8(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) '@types/react': specifier: ^19.0.4 version: 19.2.7 @@ -122,7 +122,7 @@ importers: version: 19.2.2(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) tailwindcss: specifier: 4.0.0-beta.8 version: 4.0.0-beta.8 @@ -131,7 +131,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.2 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/grab: dependencies: @@ -607,6 +607,9 @@ importers: packages/utils: devDependencies: + '@types/node': + specifier: ^25.2.0 + version: 25.2.0 tsup: specifier: ^8.4.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -637,7 +640,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: 4.0.0-beta.8 - version: 4.0.0-beta.8(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.0.0-beta.8(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) '@types/react': specifier: ^19.0.4 version: 19.2.2 @@ -646,7 +649,7 @@ importers: version: 19.2.2(@types/react@19.2.2) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) tailwindcss: specifier: 4.0.0-beta.8 version: 4.0.0-beta.8 @@ -655,7 +658,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.2 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/web-extension: dependencies: @@ -692,16 +695,16 @@ importers: version: 0.12.4 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) typescript: specifier: ^5.7.3 version: 5.9.3 vite: specifier: ^6.0.2 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) vite-plugin-web-extension: specifier: ^4.1.6 - version: 4.5.0(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6) + version: 4.5.0(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6) packages/website: dependencies: @@ -3467,8 +3470,8 @@ packages: '@types/node@22.19.2': resolution: {integrity: sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==} - '@types/node@24.10.2': - resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + '@types/node@25.2.0': + resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} @@ -7664,7 +7667,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.7(@types/node@24.10.2)': + '@changesets/cli@2.29.7(@types/node@25.2.0)': dependencies: '@changesets/apply-release-plan': 7.0.13 '@changesets/assemble-release-plan': 6.0.9 @@ -7680,7 +7683,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.2(@types/node@24.10.2) + '@inquirer/external-editor': 1.0.2(@types/node@25.2.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -8364,12 +8367,12 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true - '@inquirer/external-editor@1.0.2(@types/node@24.10.2)': + '@inquirer/external-editor@1.0.2(@types/node@25.2.0)': dependencies: chardet: 2.1.0 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 24.10.2 + '@types/node': 25.2.0 '@isaacs/cliui@8.0.2': dependencies: @@ -9518,13 +9521,13 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.15 - '@tailwindcss/vite@4.0.0-beta.8(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + '@tailwindcss/vite@4.0.0-beta.8(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.0.0-beta.8 '@tailwindcss/oxide': 4.0.0-beta.8 lightningcss: 1.30.2 tailwindcss: 4.0.0-beta.8 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) '@tanstack/react-table@8.21.3(react-dom@19.0.1(react@19.0.1))(react@19.0.1)': dependencies: @@ -9630,10 +9633,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.10.2': + '@types/node@25.2.0': dependencies: undici-types: 7.16.0 - optional: true '@types/prompts@2.4.9': dependencies: @@ -9845,7 +9847,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -9853,7 +9855,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -9865,13 +9867,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -13511,8 +13513,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: - optional: true + undici-types@7.16.0: {} unicorn-magic@0.3.0: {} @@ -13680,13 +13681,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): + vite-node@3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -13701,7 +13702,7 @@ snapshots: - tsx - yaml - vite-plugin-web-extension@4.5.0(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6): + vite-plugin-web-extension@4.5.0(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6): dependencies: ajv: 8.17.1 async-lock: 1.4.1 @@ -13711,7 +13712,7 @@ snapshots: lodash.uniq: 4.5.0 lodash.uniqby: 4.7.0 md5: 2.3.0 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) web-ext-option-types: 8.3.1 web-ext-run: 0.2.4 webextension-polyfill: 0.10.0 @@ -13729,7 +13730,7 @@ snapshots: - terser - tsx - vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): + vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) @@ -13738,7 +13739,7 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.2 + '@types/node': 25.2.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -13746,11 +13747,11 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitest@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -13768,11 +13769,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.2 + '@types/node': 25.2.0 transitivePeerDependencies: - jiti - less