diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8b3cc..a8a6971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.16.0] - 2026-08-17 + +### Added + +- Added an app target selector for switching between all discovered engines, + Antigravity, and Antigravity IDE. The selected target is preserved across + HTTP requests, conversation mutations, and WebSocket updates. (#128) +- Porta can now start a standalone Antigravity CLI/core when no Language Server + is available, with configurable binary selection and clean shutdown handling. + (#127) +- Added a native production runner for launching the built proxy and web + preview together with project environment settings. (#127) + +### Changed + +- Conversation histories are restored from session storage and refreshed in + the background, reducing blank loading states on mobile reloads. (#128) + +### Fixed + +- Chat auto-scroll now follows asynchronous content growth when the user is + already near the bottom. (#128) +- Language Server discovery now invalidates stale routing state after + unavailable or not-found RPC responses and supports headless `agy` discovery + and CLI conversation storage. (#127) +- Command-action responses once again use the payload expected by Antigravity. + (#127) + +### Security + +- Production previews retain Vite host validation by default while still + supporting explicitly configured allowed hosts. (#127) +- Updated React Router and Hono to versions containing upstream fixes for + remote code execution, cross-site scripting, denial-of-service, and related + request-handling vulnerabilities. + ## [0.15.0] - 2026-08-03 ### Added diff --git a/README.md b/README.md index 314679d..2e0919b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/L1M80/porta/actions/workflows/ci.yml/badge.svg)](https://github.com/L1M80/porta/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![Version](https://img.shields.io/badge/version-0.15.0-green) +![Version](https://img.shields.io/badge/version-0.16.0-green) Remote web interface for [Antigravity](https://antigravity.google/) Agent Manager. Access your local Antigravity sessions from your phone, tablet, or any remote browser through a lightweight LSP bridge. diff --git a/package.json b/package.json index b9c3a6c..8722006 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "porta", - "version": "0.15.0", + "version": "0.16.0", "private": true, "scripts": { "dev": "node scripts/dev.mjs", diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 684b840..4f23432 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@hono/node-server": "^2.0.10", - "hono": "^4.12.27", + "hono": "^4.12.34", "ws": "^8.20.1" }, "devDependencies": { @@ -22,4 +22,4 @@ "typescript": "^5.7.0", "vitest": "^4.1.0" } -} \ No newline at end of file +} diff --git a/packages/proxy/src/__tests__/conversations-route.test.ts b/packages/proxy/src/__tests__/conversations-route.test.ts index c1db3d7..ae729b6 100644 --- a/packages/proxy/src/__tests__/conversations-route.test.ts +++ b/packages/proxy/src/__tests__/conversations-route.test.ts @@ -302,6 +302,25 @@ describe("POST /api/conversations", () => { mockScanDiskConversations.mockResolvedValue([]); }); + it("does not fall back to a different engine when the selected engine is unavailable", async () => { + mockGetInstances.mockResolvedValue([]); + + const res = await app().request("/api/conversations", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-porta-target-app": "antigravity", + }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(503); + await expect(res.json()).resolves.toEqual({ + error: "No running Language Server found for antigravity.", + }); + expect(mockRpcCall).not.toHaveBeenCalled(); + }); + it("sets the Antigravity 2.x required trajectory source and caches unscoped hub ownership", async () => { const hubLS = makeInstance({ pid: 4, workspaceId: undefined }); mockGetInstances.mockResolvedValue([hubLS]); diff --git a/packages/proxy/src/__tests__/workspaces-route.test.ts b/packages/proxy/src/__tests__/workspaces-route.test.ts index d72424b..a853ee0 100644 --- a/packages/proxy/src/__tests__/workspaces-route.test.ts +++ b/packages/proxy/src/__tests__/workspaces-route.test.ts @@ -10,6 +10,7 @@ const mockRpcCall = vi.fn< vi.mock("../routing.js", () => ({ discovery: { getInstances: mockGetInstances }, rpc: { call: mockRpcCall }, + extractTargetAppDataDir: () => undefined, })); const { registerWorkspaceRoutes } = await import("../routes/workspaces.js"); diff --git a/packages/proxy/src/__tests__/ws.test.ts b/packages/proxy/src/__tests__/ws.test.ts index 9781b64..e51a02e 100644 --- a/packages/proxy/src/__tests__/ws.test.ts +++ b/packages/proxy/src/__tests__/ws.test.ts @@ -79,6 +79,20 @@ describe("WS upgrade validation", () => { ).toEqual({ ok: true, cascadeId: "abc123" }); }); + it("keeps the selected target on the WebSocket upgrade", () => { + expect( + validateWebSocketUpgrade( + "/api/conversations/abc123/ws?targetApp=antigravity-ide", + "http://localhost:5173", + 3100, + ), + ).toEqual({ + ok: true, + cascadeId: "abc123", + targetApp: "antigravity-ide", + }); + }); + it("rejects cross-origin upgrades on the WS endpoint", () => { const allowedOrigins = getAllowedOrigins({}); diff --git a/packages/proxy/src/core-manager.ts b/packages/proxy/src/core-manager.ts new file mode 100644 index 0000000..6d8b9b7 --- /dev/null +++ b/packages/proxy/src/core-manager.ts @@ -0,0 +1,103 @@ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcess } from "node:child_process"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +let coreProcess: ChildProcess | null = null; +let isStarting = false; + +export async function ensureStandaloneCore(): Promise { + if (process.env.PORTA_STANDALONE_CORE !== "true") { + return; + } + + if (coreProcess) { + return; + } + + if (isStarting) { + // Wait a bit if it's already starting + await new Promise((resolve) => setTimeout(resolve, 2000)); + return; + } + + isStarting = true; + + try { + const binaryPath = + process.env.PORTA_CORE_BINARY_PATH || + "agy"; + + const isWin = process.platform === "win32"; + const basename = binaryPath.replace(/^.*[\\/]/, "").toLowerCase(); + const isAgyCli = basename === "agy" || basename === "agy.exe"; + + if (isAgyCli) { + if (isWin) { + console.log(`[Core Manager] Starting standalone Antigravity CLI on Windows...`); + coreProcess = spawn(binaryPath, [], { + stdio: "ignore", + detached: true, + }); + } else { + console.log(`[Core Manager] Starting standalone Antigravity CLI via script...`); + coreProcess = spawn("script", ["-q", "-c", binaryPath, "/dev/null"], { + stdio: "ignore", + detached: true, + }); + } + } else { + const csrfToken = randomUUID(); + console.log(`[Core Manager] Starting standalone Antigravity core from ${binaryPath} with csrf ${csrfToken}...`); + + coreProcess = spawn( + binaryPath, + [ + "--standalone", + "--override_ide_name", "antigravity", + "--subclient_type", "hub", + "--override_ide_version", "2.2.1", + "--override_user_agent_name", "antigravity", + "--https_server_port", "0", + "--csrf_token", csrfToken, + "--app_data_dir", "antigravity-cli", + "--api_server_url", "https://generativelanguage.googleapis.com", + "--cloud_code_endpoint", "https://daily-cloudcode-pa.googleapis.com", + "--enable_sidecars", + ], + { + stdio: ["pipe", "ignore", "ignore"], + detached: true, + } + ); + } + + coreProcess.on("error", (err) => { + console.error(`[Core Manager] Failed to start standalone core:`, err); + coreProcess = null; + isStarting = false; + }); + + coreProcess.on("exit", (code) => { + console.log(`[Core Manager] Standalone core exited with code ${code}`); + coreProcess = null; + isStarting = false; + }); + + // Unref so it doesn't keep the proxy alive unnecessarily, though we want it to run as long as proxy runs. + coreProcess.unref(); + + // Give it a moment to initialize and write the daemon file + await new Promise((resolve) => setTimeout(resolve, 3000)); + } finally { + isStarting = false; + } +} + +export function stopStandaloneCore() { + if (coreProcess) { + console.log("[Core Manager] Stopping standalone Antigravity core..."); + coreProcess.kill("SIGTERM"); + coreProcess = null; + } +} diff --git a/packages/proxy/src/discovery.ts b/packages/proxy/src/discovery.ts index 74f1794..ee082db 100644 --- a/packages/proxy/src/discovery.ts +++ b/packages/proxy/src/discovery.ts @@ -21,6 +21,7 @@ import { rememberSuccessfulTransport, type TransportProtocol, } from "./transport-hints.js"; +import { ensureStandaloneCore } from "./core-manager.js"; export interface LSInstance { pid: number; @@ -43,6 +44,10 @@ const DAEMON_DIRS = [ dir: join(homedir(), ".gemini", "antigravity-ide", "daemon"), appDataDir: "antigravity-ide", }, + { + dir: join(homedir(), ".gemini", "antigravity-cli", "daemon"), + appDataDir: "antigravity-cli", + }, ]; const SERVICE_PREFIX = "exa.language_server_pb.LanguageServerService"; @@ -405,48 +410,67 @@ export class LSDiscovery { } protected async discover(): Promise { - return discoverInstances(); + let instances = await discoverInstances(); + if (instances.length === 0) { + await ensureStandaloneCore(); + instances = await discoverInstances(); + } + return instances; + } + + invalidateCache(): void { + this.lastDiscovery = 0; } - async getInstances(forceRefresh = false): Promise { + async getInstances( + forceRefresh = false, + targetAppDataDir?: string, + ): Promise { const now = Date.now(); const cacheFresh = !forceRefresh && this.instances.length > 0 && now - this.lastDiscovery <= this.ttlMs; + let instances: LSInstance[]; if (cacheFresh) { - return this.instances; - } + instances = this.instances; + } else if (!forceRefresh && this.pendingDiscovery) { + instances = await this.pendingDiscovery; + } else { + const generation = ++this.discoveryGeneration; + const pending = this.discover() + .then((discovered) => { + if (generation === this.discoveryGeneration) { + this.instances = discovered; + this.lastDiscovery = Date.now(); + } + return discovered; + }) + .finally(() => { + if (this.pendingDiscovery === pending) { + this.pendingDiscovery = null; + } + }); - if (!forceRefresh && this.pendingDiscovery) { - return this.pendingDiscovery; + this.pendingDiscovery = pending; + instances = await pending; } - const generation = ++this.discoveryGeneration; - const pending = this.discover() - .then((instances) => { - if (generation === this.discoveryGeneration) { - this.instances = instances; - this.lastDiscovery = Date.now(); - } - return instances; - }) - .finally(() => { - if (this.pendingDiscovery === pending) { - this.pendingDiscovery = null; - } - }); - - this.pendingDiscovery = pending; - return pending; + if (targetAppDataDir && targetAppDataDir !== "all") { + return instances.filter((inst) => inst.appDataDir === targetAppDataDir); + } + return instances; } /** * Get the first available instance (or a specific workspace). */ - async getInstance(workspaceId?: string): Promise { - const instances = await this.getInstances(); + async getInstance( + workspaceId?: string, + targetAppDataDir?: string, + ): Promise { + const instances = await this.getInstances(false, targetAppDataDir); if (workspaceId) { return instances.find((inst) => inst.workspaceId === workspaceId) ?? null; diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index ad15f3c..f62d4fb 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { createAdaptorServer } from "@hono/node-server"; -import { discovery } from "./routing.js"; +import { discovery, extractTargetAppDataDir } from "./routing.js"; import { registerConversationRoutes } from "./routes/conversations.js"; import { registerModelRoutes } from "./routes/models.js"; import { registerWorkspaceRoutes } from "./routes/workspaces.js"; @@ -23,6 +23,7 @@ import { } from "./exposure.js"; import { getAllowedOrigins, resolveCorsOrigin } from "./origins.js"; import { setupWebSocket } from "./ws.js"; +import { stopStandaloneCore } from "./core-manager.js"; const PORT = parseInt(process.env.PORTA_PORT ?? "3170", 10); const HOST = resolveProxyHost(); @@ -45,7 +46,10 @@ app.use( // ── Health ── app.get("/api/health", async (c) => { - const instances = await discovery.getInstances(); + const instances = await discovery.getInstances( + false, + extractTargetAppDataDir(c), + ); return c.json({ status: "ok", proxy: { port: PORT, uptime: process.uptime() }, @@ -53,6 +57,7 @@ app.get("/api/health", async (c) => { pid: i.pid, httpsPort: i.httpsPort, workspaceId: i.workspaceId, + appDataDir: i.appDataDir, source: i.source, })), }); @@ -93,3 +98,13 @@ void discovery server.listen(PORT, HOST, () => { console.log(`✅ Porta proxy listening on ${listenAddress}`); }); + +process.on("SIGTERM", () => { + stopStandaloneCore(); + process.exit(0); +}); + +process.on("SIGINT", () => { + stopStandaloneCore(); + process.exit(0); +}); diff --git a/packages/proxy/src/metadata.ts b/packages/proxy/src/metadata.ts index 94aa4e4..48ddcc8 100644 --- a/packages/proxy/src/metadata.ts +++ b/packages/proxy/src/metadata.ts @@ -16,7 +16,7 @@ export interface ConversationWorkspaceMetadata { branchName?: string; } -const KNOWN_APP_DATA_DIRS = ["antigravity", "antigravity-ide"] as const; +const KNOWN_APP_DATA_DIRS = ["antigravity", "antigravity-ide", "antigravity-cli"] as const; function conversationDirForAppDataDir(appDataDir: string): string { return join(homedir(), ".gemini", appDataDir, "conversations"); diff --git a/packages/proxy/src/platform/linux.ts b/packages/proxy/src/platform/linux.ts index 4be3ac3..61cf4b4 100644 --- a/packages/proxy/src/platform/linux.ts +++ b/packages/proxy/src/platform/linux.ts @@ -52,7 +52,7 @@ export const linuxAdapter: PlatformAdapter = { try { const comm = await readFile(join("/proc", String(pid), "comm"), "utf-8"); - return comm.includes("language_server"); + return comm.includes("language_server") || comm.includes("agy"); } catch { return false; } diff --git a/packages/proxy/src/platform/shared.ts b/packages/proxy/src/platform/shared.ts index 734266b..d6e46ed 100644 --- a/packages/proxy/src/platform/shared.ts +++ b/packages/proxy/src/platform/shared.ts @@ -4,19 +4,21 @@ import type { ProcessDiscoveryCandidate } from "./types.js"; const execFileAsync = promisify(execFile); const LANGUAGE_SERVER_EXECUTABLE = - /(?:^|[\\/])language_server(?:_[^/\\\s"']+)?(?:\.exe)?$/i; + /(?:^|[\\/])(language_server(?:_[^/\\\s"']+)?|agy)(?:\.exe)?$/i; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function parseExecutable(args: string): string | undefined { - const match = args.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))(?:\s|$)/); - return match?.[1] ?? match?.[2] ?? match?.[3]; + const match = args.match(/^(?:["']?([^"']+)["']?)/); + const raw = match?.[1] ?? args; + const lsMatch = raw.match(/^(.*?language_server[^\s]*)/i); + return (lsMatch?.[1] ?? raw).trim(); } export function isLanguageServerExecutable(value: string): boolean { - return LANGUAGE_SERVER_EXECUTABLE.test(value.trim()); + return /(?:^|[\\/])language_server(?:_[^/\\\s"']+)?(?:\.exe)?$/i.test(value.trim()); } function parseArgValue(args: string, flag: string): string | undefined { @@ -68,8 +70,15 @@ export function parseCommandCandidate( return undefined; } - const csrfToken = parseArgValue(args, "--csrf_token"); - if (!csrfToken) return undefined; + let csrfToken = parseArgValue(args, "--csrf_token"); + if (!csrfToken) { + const basename = executable.replace(/^.*[\\/]/, '').toLowerCase(); + if (basename === "agy" || basename === "agy.exe") { + csrfToken = "agy_no_csrf"; + } else { + return undefined; + } + } const appDataDir = parseArgValue(args, "--app_data_dir"); return { @@ -150,7 +159,7 @@ export function parseSsPorts(output: string, pid: number): number[] { for (const rawLine of output.split("\n")) { const line = rawLine.trim(); if (!line) continue; - if (!line.includes(`pid=${pid},`) || !line.includes("language_server")) { + if (!line.includes(`pid=${pid},`) || (!line.includes("language_server") && !line.includes("agy"))) { continue; } @@ -173,7 +182,7 @@ export function parseLsofPorts(output: string): number[] { const line = rawLine.trim(); if (!line || !line.includes("(LISTEN)")) continue; - const match = line.match(/:(\d+)\s+\(LISTEN\)$/); + const match = line.match(/:(\d+)\s+\(LISTEN\)/); if (!match) continue; const port = parseInt(match[1], 10); diff --git a/packages/proxy/src/routes/conversations.ts b/packages/proxy/src/routes/conversations.ts index 41ea193..eb52263 100644 --- a/packages/proxy/src/routes/conversations.ts +++ b/packages/proxy/src/routes/conversations.ts @@ -13,6 +13,7 @@ import { normalizeWorkspaceId, rpcForConversation, getStepCount, + extractTargetAppDataDir, } from "../routing.js"; import { extractConversationWorkspaces, @@ -191,8 +192,9 @@ function warmUpDiskConversations( export function registerConversationRoutes(app: Hono): void { app.get("/api/conversations", async (c) => { try { + const targetApp = extractTargetAppDataDir(c); const projectNameMap = await getProjectNameMap(); - const instances = await discovery.getInstances(); + const instances = await discovery.getInstances(false, targetApp); const merged: Record> = {}; // Build normalized set of workspaceIds served by running LS instances. @@ -278,9 +280,10 @@ export function registerConversationRoutes(app: Hono): void { // Also scan disk for conversations in the app-data tree used by the // running LS instances. - const diskConversationDirs = conversationDirsForAppDataDirs( - instances.map((inst) => inst.appDataDir), - ); + const targetDirs = targetApp + ? [targetApp] + : instances.map((inst) => inst.appDataDir); + const diskConversationDirs = conversationDirsForAppDataDirs(targetDirs); const diskIds = await scanDiskConversations( diskConversationDirs.length > 0 ? diskConversationDirs : undefined, ); @@ -349,10 +352,20 @@ export function registerConversationRoutes(app: Hono): void { app.get("/api/conversations/:id", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { - const data = await rpcForConversation("GetCascadeTrajectory", id, { - cascadeId: id, - }, undefined, true); + const data = targetApp + ? await rpcForConversation( + "GetCascadeTrajectory", + id, + { cascadeId: id }, + undefined, + true, + targetApp, + ) + : await rpcForConversation("GetCascadeTrajectory", id, { + cascadeId: id, + }, undefined, true); return c.json(data); } catch (err) { return handleRPCError(c, err); @@ -361,6 +374,7 @@ export function registerConversationRoutes(app: Hono): void { app.get("/api/conversations/:id/steps", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); const offset = parseInt(c.req.query("offset") ?? "0", 10); const limitParam = c.req.query("limit"); let limit = limitParam ? parseInt(limitParam, 10) : undefined; @@ -381,7 +395,9 @@ export function registerConversationRoutes(app: Hono): void { // readOnly=true: this endpoint only reads steps; the pinned instance // is NOT reused for mutations, so try-all fallback is safe and // necessary for disk-only conversations that no LS has in memory yet. - const sc = await getStepCount(id, undefined, true); + const sc = targetApp + ? await getStepCount(id, undefined, true, targetApp) + : await getStepCount(id, undefined, true); pinnedInstance = sc.instance; if (sc.count > 0) { stepCount = sc.count; @@ -415,16 +431,28 @@ export function registerConversationRoutes(app: Hono): void { while (stepsArray.length < targetCount) { try { - const data = await rpcForConversation<{ steps?: unknown[] }>( - "GetCascadeTrajectorySteps", - id, - { - cascadeId: id, - stepOffset: currentOffset, - }, - pinnedInstance, - true, - ); + const data = targetApp + ? await rpcForConversation<{ steps?: unknown[] }>( + "GetCascadeTrajectorySteps", + id, + { + cascadeId: id, + stepOffset: currentOffset, + }, + pinnedInstance, + true, + targetApp, + ) + : await rpcForConversation<{ steps?: unknown[] }>( + "GetCascadeTrajectorySteps", + id, + { + cascadeId: id, + stepOffset: currentOffset, + }, + pinnedInstance, + true, + ); const chunk = data.steps ?? []; if (chunk.length === 0) break; @@ -447,7 +475,12 @@ export function registerConversationRoutes(app: Hono): void { } else if (isRecoverableStepError(fetchErr)) { // Corrupted batch (e.g. invalid UTF-8) — binary search forward if (stepCount === undefined) { - const sc = await getStepCount(id, undefined, true); + const sc = await getStepCount( + id, + undefined, + true, + targetApp, + ); stepCount = sc.count; pinnedInstance ??= sc.instance; } @@ -502,7 +535,19 @@ export function registerConversationRoutes(app: Hono): void { typeof body.workspaceFolderAbsoluteUri === "string" ? body.workspaceFolderAbsoluteUri : bodyWorkspaceUris[0]; - const instances = await discovery.getInstances(); + const targetApp = extractTargetAppDataDir(c); + const instances = await discovery.getInstances(false, targetApp); + + if (instances.length === 0) { + return c.json( + { + error: targetApp + ? `No running Language Server found for ${targetApp}.` + : "No running Language Server found.", + }, + 503, + ); + } // Resolve which LS instance to use based on workspace URI let targetInstance: LSInstance | undefined; @@ -571,7 +616,7 @@ export function registerConversationRoutes(app: Hono): void { conversationInstanceAffinity.set(newId, targetInstance); } - // Signal WS connections for this conversation to enter ACTIVE state + // Signal WS connections for this conversation to enter ACTIVE state if (newId) conversationSignals.emit("activate", newId); return c.json(data, 201); @@ -582,12 +627,13 @@ export function registerConversationRoutes(app: Hono): void { app.post("/api/conversations/:id/messages", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { return await runConversationMutation(id, async () => { const body = await c.req.json(); const { items, model, media, plannerType, clientMessageId } = body; const metadata = await getMetadata(!!body.fileAccessGranted); - const { count: preSendStepCount, instance } = await getStepCount(id); + const { count: preSendStepCount, instance } = await getStepCount(id, undefined, false, targetApp); const req: Record = { metadata, @@ -611,12 +657,24 @@ export function registerConversationRoutes(app: Hono): void { }; } - const data = await rpcForConversation( - "SendUserCascadeMessage", - id, - req, - instance, - ); + // Emit activate before the RPC to start 200ms polling immediately + conversationSignals.emit("activate", id); + + const data = targetApp + ? await rpcForConversation( + "SendUserCascadeMessage", + id, + req, + instance, + false, + targetApp, + ) + : await rpcForConversation( + "SendUserCascadeMessage", + id, + req, + instance, + ); if (typeof clientMessageId === "string" && clientMessageId.length > 0) { messageTracker.trackPendingMessage( id, @@ -624,7 +682,6 @@ export function registerConversationRoutes(app: Hono): void { preSendStepCount, ); } - conversationSignals.emit("activate", id); return c.json(data); }); } catch (err) { @@ -636,10 +693,22 @@ export function registerConversationRoutes(app: Hono): void { app.post("/api/conversations/:id/stop", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { - const data = await rpcForConversation("CancelCascadeInvocation", id, { - cascadeId: id, - }); + const data = targetApp + ? await rpcForConversation( + "CancelCascadeInvocation", + id, + { + cascadeId: id, + }, + undefined, + false, + targetApp, + ) + : await rpcForConversation("CancelCascadeInvocation", id, { + cascadeId: id, + }); return c.json(data); } catch (err) { return handleRPCError(c, err); @@ -650,13 +719,26 @@ export function registerConversationRoutes(app: Hono): void { app.delete("/api/conversations/:id", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { return await runConversationMutation(id, async () => { const metadata = await getMetadata(true); - const data = await rpcForConversation("DeleteCascadeTrajectory", id, { - metadata, - cascadeId: id, - }); + const data = targetApp + ? await rpcForConversation( + "DeleteCascadeTrajectory", + id, + { + metadata, + cascadeId: id, + }, + undefined, + false, + targetApp, + ) + : await rpcForConversation("DeleteCascadeTrajectory", id, { + metadata, + cascadeId: id, + }); messageTracker.clearConversation(id); return c.json(data); }); @@ -671,6 +753,7 @@ export function registerConversationRoutes(app: Hono): void { app.post("/api/conversations/:id/file-permission", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { const body = await c.req.json(); const { trajectoryId, stepIndex, allow, scope, absolutePathUri } = body; @@ -692,22 +775,32 @@ export function registerConversationRoutes(app: Hono): void { // Build HandleCascadeUserInteraction request with exact protobuf structure. // CRITICAL: top-level field is "interaction" (not "userInteraction"), // and it MUST include trajectoryId + stepIndex alongside filePermission. - const data = await rpcForConversation( - "HandleCascadeUserInteraction", - id, - { - cascadeId: id, - interaction: { - trajectoryId, - stepIndex: Number(stepIndex), - filePermission: { - allow: !!allow, - scope: Number(scope) || 0, - absolutePathUri, - }, + const payload = { + cascadeId: id, + interaction: { + trajectoryId, + stepIndex: Number(stepIndex), + filePermission: { + allow: !!allow, + scope: Number(scope) || 0, + absolutePathUri, }, }, - ); + }; + const data = targetApp + ? await rpcForConversation( + "HandleCascadeUserInteraction", + id, + payload, + undefined, + false, + targetApp, + ) + : await rpcForConversation( + "HandleCascadeUserInteraction", + id, + payload, + ); // Permission approval unblocks subsequent WAITING steps — wake WS polling conversationSignals.emit("activate", id); @@ -722,6 +815,7 @@ export function registerConversationRoutes(app: Hono): void { app.post("/api/conversations/:id/command-action", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { const body = await c.req.json(); const { trajectoryId, stepIndex, approved } = body; @@ -738,20 +832,30 @@ export function registerConversationRoutes(app: Hono): void { // Use HandleCascadeUserInteraction with commandAction field. // Same RPC as filePermission, different interaction type. - const data = await rpcForConversation( - "HandleCascadeUserInteraction", - id, - { - cascadeId: id, - interaction: { - trajectoryId, - stepIndex: Number(stepIndex), - permission: { - allow: !!approved, - }, + const payload = { + cascadeId: id, + interaction: { + trajectoryId, + stepIndex: Number(stepIndex), + permission: { + allow: !!approved, }, }, - ); + }; + const data = targetApp + ? await rpcForConversation( + "HandleCascadeUserInteraction", + id, + payload, + undefined, + false, + targetApp, + ) + : await rpcForConversation( + "HandleCascadeUserInteraction", + id, + payload, + ); // Command approval/rejection unblocks the agent — wake WS polling conversationSignals.emit("activate", id); @@ -766,6 +870,7 @@ export function registerConversationRoutes(app: Hono): void { app.post("/api/conversations/:id/ask-question", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { const body = await c.req.json(); const { trajectoryId, stepIndex, responses, cancelled } = body; @@ -779,21 +884,31 @@ export function registerConversationRoutes(app: Hono): void { ); } - const data = await rpcForConversation( - "HandleCascadeUserInteraction", - id, - { - cascadeId: id, - interaction: { - trajectoryId, - stepIndex: Number(stepIndex), - askQuestion: { - responses: Array.isArray(responses) ? responses : [], - cancelled: !!cancelled, - }, + const payload = { + cascadeId: id, + interaction: { + trajectoryId, + stepIndex: Number(stepIndex), + askQuestion: { + responses: Array.isArray(responses) ? responses : [], + cancelled: !!cancelled, }, }, - ); + }; + const data = targetApp + ? await rpcForConversation( + "HandleCascadeUserInteraction", + id, + payload, + undefined, + false, + targetApp, + ) + : await rpcForConversation( + "HandleCascadeUserInteraction", + id, + payload, + ); conversationSignals.emit("activate", id); @@ -805,6 +920,7 @@ export function registerConversationRoutes(app: Hono): void { app.post("/api/conversations/:id/revert", async (c) => { const id = c.req.param("id"); + const targetApp = extractTargetAppDataDir(c); try { return await runConversationMutation(id, async () => { const body = await c.req.json(); @@ -816,16 +932,16 @@ export function registerConversationRoutes(app: Hono): void { metadata, }; - if (body.model) { - req.overrideConfig = { - plannerConfig: { - plannerTypeConfig: { conversational: {} }, - requestedModel: { model: body.model }, - }, - }; - } - - const data = await rpcForConversation("RevertToCascadeStep", id, req); + const data = targetApp + ? await rpcForConversation( + "RevertToCascadeStep", + id, + req, + undefined, + false, + targetApp, + ) + : await rpcForConversation("RevertToCascadeStep", id, req); messageTracker.clearConversation(id); conversationSignals.emit("activate", id); return c.json(data); diff --git a/packages/proxy/src/routes/workspaces.ts b/packages/proxy/src/routes/workspaces.ts index 8061994..2515bcf 100644 --- a/packages/proxy/src/routes/workspaces.ts +++ b/packages/proxy/src/routes/workspaces.ts @@ -3,14 +3,15 @@ */ import type { Hono } from "hono"; -import { discovery, rpc } from "../routing.js"; +import { discovery, rpc, extractTargetAppDataDir } from "../routing.js"; import { handleRPCError } from "../errors.js"; import { extractConversationWorkspaces } from "../metadata.js"; export function registerWorkspaceRoutes(app: Hono): void { app.get("/api/workspaces", async (c) => { try { - const instances = await discovery.getInstances(); + const targetApp = extractTargetAppDataDir(c); + const instances = await discovery.getInstances(false, targetApp); const workspaceMap = new Map< string, { workspaceUri: string; gitRootUri?: string } diff --git a/packages/proxy/src/routing.ts b/packages/proxy/src/routing.ts index c166809..c75259a 100644 --- a/packages/proxy/src/routing.ts +++ b/packages/proxy/src/routing.ts @@ -22,6 +22,18 @@ import { dirname, join } from "node:path"; export const discovery = new LSDiscovery(); export const rpc = new RPCClient(discovery); +export function extractTargetAppDataDir(c: { + req: { header: (name: string) => string | undefined; query: (name: string) => string | undefined }; +}): string | undefined { + const val = + c.req.header("x-porta-target-app") || + c.req.query("targetApp") || + c.req.query("appDataDir"); + if (!val || val === "all") return undefined; + if (val === "antigravity" || val === "antigravity-ide") return val; + return undefined; +} + const AFFINITY_FILE = join( homedir(), ".gemini", @@ -160,6 +172,7 @@ export async function rpcForConversation( /** Allow try-all fallback for disk-only .pb reads. Must be false for * mutation RPCs to prevent writes to the wrong LS. */ readOnly = false, + targetAppDataDir?: string, ): Promise { const result = await resolveAndCall( method, @@ -167,6 +180,7 @@ export async function rpcForConversation( body, pinnedInstance, readOnly, + targetAppDataDir, ); return result.data; } @@ -238,6 +252,16 @@ export async function discoverOwnerInstance( // Determine the conversation's workspace URI (consistent across candidates) const wsUri = candidates.find((c) => c.wsUri)?.wsUri; + if (wsUri) { + const wsId = uriToWorkspaceId(wsUri); + conversationAffinity.set(cascadeId, wsId); + } + + // If exactly one LS returned this trajectory in GetAllCascadeTrajectories, it is the owner + if (candidates.length === 1) { + conversationInstanceAffinity.set(cascadeId, candidates[0].inst); + return candidates[0].inst; + } if (wsUri) { const wsId = uriToWorkspaceId(wsUri); @@ -249,6 +273,7 @@ export async function discoverOwnerInstance( ); if (wsOwners.length > 0) { wsOwners.sort((a, b) => b.stepCount - a.stepCount); + conversationInstanceAffinity.set(cascadeId, wsOwners[0].inst); return wsOwners[0].inst; } @@ -272,25 +297,15 @@ export async function discoverOwnerInstance( conversationInstanceAffinity.set(cascadeId, unscopedOwners[0].inst); return unscopedOwners[0].inst; } + if (candidates.length === 1) { + conversationInstanceAffinity.set(cascadeId, candidates[0].inst); + return candidates[0].inst; + } return null; } // No workspace metadata. - // - // For writes (readOnly=false): return null. Without a workspace URI we - // cannot determine definitive ownership. Returning a heuristic guess - // here would let mutations (SendUserCascadeMessage, RevertToCascadeStep, - // etc.) reach a non-owner LS — the exact bug this guard prevents. - // - // For reads (readOnly=true): use RUNNING status + stepCount heuristics. - // A RUNNING LS is definitively the active owner (only one LS can execute - // a conversation at a time). Affinity is NOT learned because we don't - // know the workspace URI. - if ( - !readOnly && - candidates.length === 1 && - !candidates[0].inst.workspaceId - ) { + if (candidates.length === 1) { conversationInstanceAffinity.set(cascadeId, candidates[0].inst); return candidates[0].inst; } @@ -303,6 +318,7 @@ export async function discoverOwnerInstance( if (aRunning !== bRunning) return bRunning - aRunning; return b.stepCount - a.stepCount; }); + conversationInstanceAffinity.set(cascadeId, candidates[0].inst); return candidates[0].inst; } @@ -325,6 +341,7 @@ export async function resolveAndCall( * Must be false for mutation RPCs and when the returned instance * will be pinned for subsequent writes. */ readOnly = false, + targetAppDataDir?: string, ): Promise<{ data: T; instance: LSInstance }> { // If caller pinned a specific instance, use it directly if (pinnedInstance) { @@ -332,7 +349,7 @@ export async function resolveAndCall( return { data, instance: pinnedInstance }; } - const instances = await discovery.getInstances(); + const instances = await discovery.getInstances(false, targetAppDataDir); if (instances.length === 0) { throw new RPCError("No LS instances available", "unavailable"); } @@ -354,6 +371,8 @@ export async function resolveAndCall( (err.code === "unavailable" || err.code === "not_found") ) { conversationInstanceAffinity.delete(cascadeId); + discovery.invalidateCache(); + return resolveAndCall(method, cascadeId, body, undefined, readOnly); } else { throw err; } @@ -381,6 +400,8 @@ export async function resolveAndCall( ) { // Affinity LS is dead or lost the conversation — clear stale affinity and re-discover conversationAffinity.delete(cascadeId); + discovery.invalidateCache(); + return resolveAndCall(method, cascadeId, body, undefined, readOnly); } else { // Application error (e.g. invalid model, internal LS error) -> throw immediately throw err; @@ -398,21 +419,10 @@ export async function resolveAndCall( return { data, instance: owner }; } - // Fallback for read-only operations: conversation not in any LS's memory - // (disk-only .pb file). Try all instances — the LS will auto-load from - // disk if the .pb exists in its conversation store. - // - // Restricted to reads because multiple LSes can load the same .pb from - // the shared conversations dir. The first success doesn't prove ownership, - // so routing a write here could mutate state on the wrong LS. - // - // Since discoverOwnerInstance now handles the "candidates in memory but - // no workspace metadata" case (using RUNNING status), this path is only - // reached for truly unknown conversations (no LS has them in - // GetAllCascadeTrajectories). All LSes will load the same .pb from disk, - // so they're functionally equivalent. We still sort by RUNNING > stepCount - // as defense-in-depth. - if (readOnly) { + // Fallback for read-only operations or targetAppDataDir with single instance: + // conversation not in any LS's memory (disk-only .pb file). + // Try available instances — the LS will auto-load from disk if the .pb exists. + if (readOnly || (targetAppDataDir && instances.length === 1)) { const results: { data: T; instance: LSInstance; isRunning: boolean; stepCount: number }[] = []; const errors: unknown[] = []; await Promise.allSettled( @@ -454,8 +464,9 @@ export async function resolveAndCall( export async function rpcAny( method: string, body: Record = {}, + targetAppDataDir?: string, ): Promise { - const instances = await discovery.getInstances(); + const instances = await discovery.getInstances(false, targetAppDataDir); let lastError: unknown; for (const inst of instances) { try { @@ -480,6 +491,7 @@ export async function getStepCount( * Use false (default) when the instance may be reused for mutations * (e.g. SendUserCascadeMessage) to prevent writes to the wrong LS. */ readOnly = false, + targetAppDataDir?: string, ): Promise<{ count: number; instance: LSInstance | undefined }> { try { const result = await resolveAndCall<{ numTotalSteps?: number }>( @@ -488,6 +500,7 @@ export async function getStepCount( { cascadeId }, pinnedInstance, readOnly, + targetAppDataDir, ); return { count: result.data.numTotalSteps ?? 0, instance: result.instance }; } catch { diff --git a/packages/proxy/src/ws.ts b/packages/proxy/src/ws.ts index 8604269..201b016 100644 --- a/packages/proxy/src/ws.ts +++ b/packages/proxy/src/ws.ts @@ -35,7 +35,7 @@ import { conversationSignals } from "./signals.js"; /** Active polling interval (ms). */ const ACTIVE_INTERVAL = 200; /** Idle polling interval (ms) for externally-originated updates. */ -const HEARTBEAT_INTERVAL = 5000; +const HEARTBEAT_INTERVAL = 1000; /** Transport keepalive interval (ms) for detecting dead idle sockets. */ const SOCKET_KEEPALIVE_INTERVAL = 25_000; @@ -75,10 +75,6 @@ const TERMINAL_STATUSES = new Set([ type PollState = "idle" | "active"; -type UpgradeValidationResult = - | { ok: true; cascadeId: string } - | { ok: false; code: "not_found" | "forbidden_origin" }; - function unrefTimer( timer: ReturnType | ReturnType, ): void { @@ -131,6 +127,10 @@ export function buildRecoverableStepDelta( }; } +export type UpgradeValidationResult = + | { ok: true; cascadeId: string; targetApp?: string } + | { ok: false; code: "not_found" | "forbidden_origin" }; + export function isWebSocketOriginAllowed( origin: string | undefined, allowedOrigins: AllowedOrigin[] = getAllowedOrigins(), @@ -145,6 +145,7 @@ export function validateWebSocketUpgrade( origin: string | undefined, port: number, allowedOrigins: AllowedOrigin[] = getAllowedOrigins(), + headers?: Record, ): UpgradeValidationResult { const url = new URL(reqUrl ?? "", `http://localhost:${port}`); const match = url.pathname.match(/^\/api\/conversations\/([^/]+)\/ws$/); @@ -154,7 +155,14 @@ export function validateWebSocketUpgrade( if (!isWebSocketOriginAllowed(origin, allowedOrigins)) { return { ok: false, code: "forbidden_origin" }; } - return { ok: true, cascadeId: match[1] }; + + const targetAppParam = url.searchParams.get("targetApp"); + const targetAppHeader = Array.isArray(headers?.["x-porta-target-app"]) + ? headers?.["x-porta-target-app"][0] + : headers?.["x-porta-target-app"]; + const targetApp = targetAppParam || targetAppHeader || undefined; + + return { ok: true, cascadeId: match[1], targetApp }; } export function setupWebSocket( @@ -170,6 +178,7 @@ export function setupWebSocket( req.headers.origin, port, allowedOrigins, + req.headers, ); if (!upgrade.ok) { @@ -182,13 +191,18 @@ export function setupWebSocket( } wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req, upgrade.cascadeId); + wss.emit("connection", ws, req, upgrade.cascadeId, upgrade.targetApp); }); }); wss.on( "connection", - (ws: WebSocket, _req: IncomingMessage, cascadeId: string) => { + ( + ws: WebSocket, + _req: IncomingMessage, + cascadeId: string, + targetApp?: string, + ) => { const shortId = cascadeId.slice(0, 8); console.log(`[ws:${shortId}] connected`); @@ -305,6 +319,7 @@ export function setupWebSocket( { cascadeId, stepOffset: fetchOffset }, undefined, true, + targetApp, )) as { steps?: unknown[] }; const newSteps = data.steps ?? []; @@ -353,7 +368,12 @@ export function setupWebSocket( return delta.grew; } else if (isRecoverableStepError(err)) { try { - const { count: total } = await getStepCount(cascadeId, undefined, true); + const { count: total } = await getStepCount( + cascadeId, + undefined, + true, + targetApp, + ); const nextValid = await findNextValidOffset( cascadeId, Math.max(lastStepCount, minFetchOffset), @@ -411,6 +431,7 @@ export function setupWebSocket( { cascadeId }, undefined, true, + targetApp, )) as { status?: string }; return TERMINAL_STATUSES.has(data.status ?? ""); } catch { @@ -467,6 +488,7 @@ export function setupWebSocket( { cascadeId }, undefined, true, + targetApp, )) as { status?: string; numTotalSteps?: number }; if ( @@ -512,6 +534,7 @@ export function setupWebSocket( { cascadeId }, undefined, true, + targetApp, )) as { numTotalSteps?: number; status?: string }; const total = data.numTotalSteps ?? 0; diff --git a/packages/web/package-lock.json b/packages/web/package-lock.json index c8da340..f267936 100644 --- a/packages/web/package-lock.json +++ b/packages/web/package-lock.json @@ -11,7 +11,7 @@ "marked": "^17.0.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.1" + "react-router-dom": "^7.18.2" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -372,27 +372,6 @@ "react": "^19.2.4" } }, - "../node_modules/.pnpm/react-router-dom@7.13.1_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/react-router-dom": { - "version": "7.13.1", - "license": "MIT", - "dependencies": { - "react-router": "7.13.1" - }, - "devDependencies": { - "react": "^19.2.3", - "react-dom": "^19.2.3", - "tsup": "^8.3.0", - "typescript": "^5.4.5", - "wireit": "0.14.9" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, "../node_modules/.pnpm/react@19.2.4/node_modules/react": { "version": "19.2.4", "license": "MIT", @@ -2495,9 +2474,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2515,9 +2491,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2535,9 +2508,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2555,9 +2525,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2575,9 +2542,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2595,9 +2559,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2896,9 +2857,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2913,9 +2871,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2930,9 +2885,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2947,9 +2899,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2964,9 +2913,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2981,9 +2927,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2998,9 +2941,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3015,9 +2955,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3032,9 +2969,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3049,9 +2983,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3066,9 +2997,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3083,9 +3011,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3100,9 +3025,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3917,6 +3839,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js-compat": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", @@ -5799,9 +5734,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5823,9 +5755,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5847,9 +5776,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5871,9 +5797,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6332,9 +6255,43 @@ "dev": true, "license": "MIT" }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-router-dom": { - "resolved": "../node_modules/.pnpm/react-router-dom@7.13.1_react-dom@19.2.4_react@19.2.4__react@19.2.4/node_modules/react-router-dom", - "link": true + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } }, "node_modules/redent": { "version": "3.0.0", @@ -6650,6 +6607,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/packages/web/package.json b/packages/web/package.json index fa149e6..d30e8a4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -16,7 +16,7 @@ "marked": "^17.0.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.1" + "react-router-dom": "^7.18.2" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 627d0f2..c3760bc 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -77,7 +77,10 @@ function ChatView() { const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 480); const isMobile = () => window.innerWidth <= 480; const { conversations, loading, refresh, optimisticRemove } = useConversations(15_000); - const { data: health } = usePolling(api.health, 30_000); + const { data: health, refresh: refreshHealth } = usePolling( + api.health, + 30_000, + ); // ── Hooks ── const { workspaces, currentWorkspaceUri } = useWorkspaces( @@ -87,6 +90,14 @@ function ChatView() { const { draftText, handleDraftChange } = useDraftText(activeId); const { settings, updateSettings } = useClientSettings(); + const previousTargetApp = useRef(settings.targetApp); + useEffect(() => { + if (previousTargetApp.current === settings.targetApp) return; + previousTargetApp.current = settings.targetApp; + refresh(); + refreshHealth(); + }, [refresh, refreshHealth, settings.targetApp]); + const activeConv = conversations.find((c) => c.id === activeId); const isRunning = activeConv?.summary.status === "CASCADE_RUN_STATUS_RUNNING"; const connected = !!health && health.languageServers.length > 0; @@ -299,6 +310,8 @@ function ChatView() { updateSettings({ targetApp: app })} onMenuToggle={() => setSidebarOpen(true)} /> {isSettingsPage ? ( @@ -309,8 +322,9 @@ function ChatView() { /> ) : activeId ? ( = {}) { describe("api client", () => { afterEach(() => { + localStorage.clear(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); @@ -91,4 +92,31 @@ describe("api client", () => { expect.anything(), ); }); + + it("sends the selected target engine on API requests", async () => { + localStorage.setItem( + "porta:settings", + JSON.stringify({ targetApp: "antigravity-ide" }), + ); + const fetchStub = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ trajectorySummaries: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchStub); + + const api = await loadApi(); + await api.conversations(); + + expect(fetchStub).toHaveBeenCalledWith( + "/api/conversations", + expect.objectContaining({ + cache: "no-store", + headers: expect.objectContaining({ + "x-porta-target-app": "antigravity-ide", + }), + }), + ); + }); }); diff --git a/packages/web/src/__tests__/useStepsStream.test.tsx b/packages/web/src/__tests__/useStepsStream.test.tsx index 62d8408..870dc52 100644 --- a/packages/web/src/__tests__/useStepsStream.test.tsx +++ b/packages/web/src/__tests__/useStepsStream.test.tsx @@ -42,6 +42,7 @@ class MockWebSocket { describe("useStepsStream", () => { beforeEach(() => { MockWebSocket.instances = []; + sessionStorage.clear(); vi.restoreAllMocks(); vi.stubGlobal("WebSocket", MockWebSocket as unknown as typeof WebSocket); Object.defineProperty(document, "hidden", { @@ -51,6 +52,7 @@ describe("useStepsStream", () => { }); afterEach(() => { + sessionStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); Object.defineProperty(document, "hidden", { @@ -74,6 +76,14 @@ describe("useStepsStream", () => { expect(MockWebSocket.instances).toHaveLength(1); }); + expect(getSteps).toHaveBeenNthCalledWith( + 1, + "cascade-1", + 0, + undefined, + 100, + ); + await waitFor(() => { expect(MockWebSocket.instances[0].sent).toContain( JSON.stringify({ type: "sync", fromOffset: 0 }), @@ -105,4 +115,55 @@ describe("useStepsStream", () => { expect(getConversation).not.toHaveBeenCalled(); }); + + it("shows cached steps while the background refresh is pending", () => { + sessionStorage.setItem( + "porta:steps:antigravity-ide:cascade-1", + JSON.stringify({ + offset: 42, + steps: [{ type: "CORTEX_STEP_TYPE_USER_INPUT" }], + }), + ); + vi.spyOn(api, "getSteps").mockReturnValue(new Promise(() => {})); + + const { result, unmount } = renderHook(() => + useStepsStream( + "cascade-1", + 0, + undefined, + false, + false, + "antigravity-ide", + ), + ); + + expect(result.current.steps).toEqual([ + { type: "CORTEX_STEP_TYPE_USER_INPUT" }, + ]); + expect(result.current.loading).toBe(false); + expect(result.current.hasMore).toBe(true); + unmount(); + }); + + it("routes the WebSocket to the selected engine", async () => { + vi.spyOn(api, "getSteps").mockResolvedValue({ steps: [], offset: 0 }); + + renderHook(() => + useStepsStream( + "cascade-1", + 0, + undefined, + false, + false, + "antigravity-ide", + ), + ); + + await waitFor(() => { + expect(MockWebSocket.instances).toHaveLength(1); + }); + expect(MockWebSocket.instances[0].url).toContain( + "/api/conversations/cascade-1/ws?targetApp=antigravity-ide", + ); + }); }); diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index b099660..7369094 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -6,13 +6,30 @@ function previewBody(text: string): string { return `${singleLine.slice(0, 117)}...`; } +function getTargetAppHeader(): string | undefined { + try { + const raw = localStorage.getItem("porta:settings"); + if (!raw) return undefined; + const settings = JSON.parse(raw); + if (settings.targetApp && settings.targetApp !== "all") { + return settings.targetApp; + } + } catch { + // Ignore error + } + return undefined; +} + async function request(path: string, options: RequestInit = {}): Promise { + const targetApp = getTargetAppHeader(); const headers: Record = { "Content-Type": "application/json", + ...(targetApp ? { "x-porta-target-app": targetApp } : {}), ...((options.headers as Record) ?? {}), }; const res = await fetch(`${API_BASE}${path}`, { + cache: "no-store", ...options, headers, }); @@ -50,9 +67,10 @@ export const api = { `/api/conversations/${cascadeId}`, ), - /** Fetch steps with optional limit. Returns { steps, offset, stepCount? }. */ - getSteps: (cascadeId: string, offset = 0, limit?: number, tail?: number) => { - const params = new URLSearchParams({ offset: String(offset) }); + /** Fetch steps with optional limit or tail. Returns { steps, offset, stepCount? }. */ + getSteps: (cascadeId: string, offset?: number, limit?: number, tail?: number) => { + const params = new URLSearchParams(); + if (offset !== undefined) params.set("offset", String(offset)); if (limit !== undefined) params.set("limit", String(limit)); if (tail !== undefined) params.set("tail", String(tail)); return request( diff --git a/packages/web/src/components/AppTargetSelector.tsx b/packages/web/src/components/AppTargetSelector.tsx new file mode 100644 index 0000000..880c3f5 --- /dev/null +++ b/packages/web/src/components/AppTargetSelector.tsx @@ -0,0 +1,28 @@ +import type { TargetApp } from "../types"; + +interface Props { + value: TargetApp; + onChange: (value: TargetApp) => void; + className?: string; +} + +export function AppTargetSelector({ value, onChange, className = "" }: Props) { + return ( +
+ + +
+ ); +} diff --git a/packages/web/src/components/ChatHeader.tsx b/packages/web/src/components/ChatHeader.tsx index 8403651..aee79b2 100644 --- a/packages/web/src/components/ChatHeader.tsx +++ b/packages/web/src/components/ChatHeader.tsx @@ -1,12 +1,22 @@ import { IconMenu, IconFolder } from "./Icons"; +import { AppTargetSelector } from "./AppTargetSelector"; +import type { TargetApp } from "../types"; interface Props { title: string; projectName?: string; + targetApp?: TargetApp; + onTargetAppChange?: (app: TargetApp) => void; onMenuToggle?: () => void; } -export function ChatHeader({ title, projectName, onMenuToggle }: Props) { +export function ChatHeader({ + title, + projectName, + targetApp, + onTargetAppChange, + onMenuToggle, +}: Props) { return (
{onMenuToggle && ( @@ -29,6 +39,12 @@ export function ChatHeader({ title, projectName, onMenuToggle }: Props) { {title}
+ {targetApp && onTargetAppChange && ( + + )} {projectName && ( {projectName} diff --git a/packages/web/src/components/ChatPanel.tsx b/packages/web/src/components/ChatPanel.tsx index 8751eae..1a0aa0b 100644 --- a/packages/web/src/components/ChatPanel.tsx +++ b/packages/web/src/components/ChatPanel.tsx @@ -41,10 +41,11 @@ import { IconAlertTriangle, IconChevron, } from "./Icons"; -import type { AskQuestionEntry, ChatMessage } from "../types"; +import type { AskQuestionEntry, ChatMessage, TargetApp } from "../types"; interface Props { cascadeId: string; + targetApp?: TargetApp; onRevert: (stepIndex: number, editText?: string) => void; onFilePermission: ( trajectoryId: string, @@ -543,6 +544,7 @@ function Lightbox({ src, onClose }: { src: string; onClose: () => void }) { export function ChatPanel({ cascadeId, + targetApp = "all", onRevert, onFilePermission, onCommandAction, @@ -572,6 +574,7 @@ export function ChatPanel({ onSidebarRefresh, isConversationRunning, browserNotificationsEnabled, + targetApp, ); useChatNotifications({ @@ -663,6 +666,7 @@ export function ChatPanel({ }, [liveImplementationPlanActive, liveImplementationPlan]); const scrollRef = useRef(null); + const innerRef = useRef(null); const didInitialScroll = useRef(false); const isNearBottom = useRef(true); const showScrollBtnRef = useRef(false); @@ -690,6 +694,22 @@ export function ChatPanel({ prevMsgCount.current = messages.length; }, [messages.length]); + // Keep scroll at bottom if content resizes (e.g., images loading, markdown expanding) + useLayoutEffect(() => { + const innerEl = innerRef.current; + const scrollEl = scrollRef.current; + if (!innerEl || !scrollEl || typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(() => { + if (didInitialScroll.current && isNearBottom.current && !suppressScroll.current) { + scrollEl.scrollTop = scrollEl.scrollHeight; + } + }); + + observer.observe(innerEl); + return () => observer.disconnect(); + }, []); + // Lazy load older steps when user scrolls to top const loadOlderLock = useRef(false); const handleScroll = useCallback(() => { @@ -745,8 +765,6 @@ export function ChatPanel({ }); }, []); - const innerRef = useRef(null); - // Prevent infinite retry of broken images rendered from markdown. // When an 404s, mark it so subsequent re-renders don't re-request it. const failedImages = useRef>(new Set()); diff --git a/packages/web/src/components/SettingsPanel.tsx b/packages/web/src/components/SettingsPanel.tsx index f921338..b8b8aa4 100644 --- a/packages/web/src/components/SettingsPanel.tsx +++ b/packages/web/src/components/SettingsPanel.tsx @@ -16,7 +16,7 @@ import { requestBrowserNotificationPermission, type BrowserNotificationPermission, } from "../utils/browserNotifications"; -import type { ClientSettings } from "../types"; +import type { ClientSettings, TargetApp } from "../types"; import type { PlannerType } from "./ChatInput"; interface ModelConfig { @@ -119,11 +119,20 @@ export function SettingsPanel({ settings, onUpdate, onBack }: Props) { [onUpdate, flashSaved], ); + const handleTargetAppChange = useCallback( + (value: string) => { + onUpdate({ targetApp: value as TargetApp }); + flashSaved(); + }, + [onUpdate, flashSaved], + ); + const handleReset = useCallback(() => { onUpdate({ defaultModel: null, defaultPlannerType: "conversational", browserNotificationsEnabled: false, + targetApp: "all", }); flashSaved(); }, [onUpdate, flashSaved]); @@ -158,6 +167,27 @@ export function SettingsPanel({ settings, onUpdate, onBack }: Props) {
+ {/* ── Target AI Engine ── */} +
+

Target AI Engine

+
+
+ Language Server Target + + Choose whether to connect to Antigravity 2, Antigravity IDE, or both. + +
+ +
+
{/* ── Model ── */}

Model

diff --git a/packages/web/src/hooks/useClientSettings.ts b/packages/web/src/hooks/useClientSettings.ts index 26a32d9..2d2b6fc 100644 --- a/packages/web/src/hooks/useClientSettings.ts +++ b/packages/web/src/hooks/useClientSettings.ts @@ -15,6 +15,7 @@ const DEFAULT_SETTINGS: ClientSettings = { defaultModel: DEFAULT_MODEL, defaultPlannerType: "conversational", browserNotificationsEnabled: false, + targetApp: "all", }; function readSettings(): ClientSettings { diff --git a/packages/web/src/hooks/useStepsStream.ts b/packages/web/src/hooks/useStepsStream.ts index cffbcee..cc0aaee 100644 --- a/packages/web/src/hooks/useStepsStream.ts +++ b/packages/web/src/hooks/useStepsStream.ts @@ -1,11 +1,44 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; -import type { TrajectoryStep } from "../types"; +import type { TargetApp, TrajectoryStep } from "../types"; import { useAppResume } from "./useAppResume"; /** How many steps to fetch on initial load and each lazy-load page. */ const PAGE_SIZE = 100; +interface CachedStepsPage { + offset: number; + steps: TrajectoryStep[]; +} + +function stepsCacheKey(cascadeId: string, targetApp: TargetApp): string { + return `porta:steps:${targetApp}:${cascadeId}`; +} + +function readCachedSteps( + cascadeId: string, + targetApp: TargetApp, +): CachedStepsPage { + try { + const cached = sessionStorage.getItem(stepsCacheKey(cascadeId, targetApp)); + if (!cached) return { offset: 0, steps: [] }; + + const parsed = JSON.parse(cached) as unknown; + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + typeof (parsed as CachedStepsPage).offset === "number" && + Array.isArray((parsed as CachedStepsPage).steps) + ) { + return parsed as CachedStepsPage; + } + } catch { + // Corrupt or unavailable session storage should fall back to the network. + } + return { offset: 0, steps: [] }; +} + interface UseStepsStreamResult { /** All loaded steps (ordered oldest → newest). */ steps: TrajectoryStep[]; @@ -45,24 +78,46 @@ export function useStepsStream( onIdleTransition?: () => void, isConversationRunning = false, keepAliveWhenHidden = false, + targetApp: TargetApp = "all", ): UseStepsStreamResult { - const [steps, setSteps] = useState([]); - const [loading, setLoading] = useState(true); + const [initialCachedPage] = useState(() => + readCachedSteps(cascadeId, targetApp), + ); + const [steps, setSteps] = useState( + initialCachedPage.steps, + ); + const [loading, setLoading] = useState(initialCachedPage.steps.length === 0); const [error, setError] = useState(null); - const [hasMore, setHasMore] = useState(false); + const [hasMore, setHasMore] = useState(initialCachedPage.offset > 0); const [loadingOlder, setLoadingOlder] = useState(false); const [wsRunning, setWsRunning] = useState(false); const mountedRef = useRef(true); const wsRef = useRef(null); const reconnectTimerRef = useRef | null>(null); - const stepsRef = useRef([]); + const stepsRef = useRef(initialCachedPage.steps); // The absolute offset of stepsRef[0] in the full trajectory. - const baseOffsetRef = useRef(0); + const baseOffsetRef = useRef(initialCachedPage.offset); // The exact offset of the NEXT step AFTER the end of stepsRef. - const endOffsetRef = useRef(0); + const endOffsetRef = useRef( + initialCachedPage.offset + initialCachedPage.steps.length, + ); // Monotonic generation counter — prevents stale responses from overwriting. const genRef = useRef(0); + + const saveStepsToCache = useCallback( + (newSteps: TrajectoryStep[], offset: number) => { + try { + sessionStorage.setItem( + stepsCacheKey(cascadeId, targetApp), + JSON.stringify({ offset, steps: newSteps }), + ); + } catch { + // Ignore quota errors. + } + }, + [cascadeId, targetApp], + ); const bumpGeneration = useCallback(() => { genRef.current += 1; }, []); @@ -86,8 +141,8 @@ export function useStepsStream( const initialFetch = useCallback(async () => { const gen = genRef.current; try { - // Calculate starting offset from the known total step count. - // If we don't know the count, we use the `tail` parameter to let the proxy compute it. + // Prefer the latest page. If the sidebar has not supplied a count yet, + // let the proxy calculate the tail offset. const isUnknown = totalRef.current === 0; const startOffset = isUnknown ? 0 @@ -113,6 +168,7 @@ export function useStepsStream( endOffsetRef.current = offset + fetchedSteps.length; stepsRef.current = fetchedSteps; setSteps([...fetchedSteps]); + saveStepsToCache(fetchedSteps, offset); setHasMore(offset > 0); setLoading(false); setError(null); @@ -125,7 +181,7 @@ export function useStepsStream( setLoading(false); return null; } - }, [cascadeId]); + }, [cascadeId, saveStepsToCache]); // ── WS: connect for deltas ── const connectWs = useCallback( @@ -139,13 +195,17 @@ export function useStepsStream( } const apiBase = import.meta.env.VITE_API_BASE ?? ""; + const targetQuery = + targetApp === "all" + ? "" + : `?targetApp=${encodeURIComponent(targetApp)}`; let url: string; if (apiBase) { const wsBase = apiBase.replace(/^http/, "ws"); - url = `${wsBase}/api/conversations/${cascadeId}/ws`; + url = `${wsBase}/api/conversations/${cascadeId}/ws${targetQuery}`; } else { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - url = `${protocol}//${window.location.host}/api/conversations/${cascadeId}/ws`; + url = `${protocol}//${window.location.host}/api/conversations/${cascadeId}/ws${targetQuery}`; } const gen = genRef.current; @@ -213,6 +273,7 @@ export function useStepsStream( stepsRef.current = updated; endOffsetRef.current = deltaOffset + newSteps.length; setSteps([...updated]); + saveStepsToCache(updated, baseOffsetRef.current); } // If relOffset < 0, the delta is for steps before our window // (shouldn't happen in practice — ignore) @@ -269,20 +330,22 @@ export function useStepsStream( // WS not available — no real-time updates } }, - [cascadeId, clearReconnectTimer, keepAliveWhenHidden], + [ + cascadeId, + clearReconnectTimer, + keepAliveWhenHidden, + saveStepsToCache, + targetApp, + ], ); // ── Lifecycle: fetch + connect ── useEffect(() => { mountedRef.current = true; bumpGeneration(); - stepsRef.current = []; - baseOffsetRef.current = 0; - endOffsetRef.current = 0; - setSteps([]); - setLoading(true); + setLoading(stepsRef.current.length === 0); setError(null); - setHasMore(false); + setHasMore(baseOffsetRef.current > 0); (async () => { const result = await initialFetch(); @@ -353,6 +416,7 @@ export function useStepsStream( baseOffsetRef.current = actualOffset; stepsRef.current = [...olderSteps, ...stepsRef.current]; setSteps([...stepsRef.current]); + saveStepsToCache(stepsRef.current, actualOffset); setHasMore(actualOffset > 0); return olderSteps.length; @@ -363,7 +427,7 @@ export function useStepsStream( } finally { if (mountedRef.current) setLoadingOlder(false); } - }, [cascadeId, loadingOlder]); + }, [cascadeId, loadingOlder, saveStepsToCache]); const syncLatestSteps = useCallback( async (reconnectMode: "always" | "if-running") => { @@ -391,6 +455,7 @@ export function useStepsStream( endOffsetRef.current = fetchedOffset + fetchedSteps.length; stepsRef.current = fetchedSteps; setSteps([...fetchedSteps]); + saveStepsToCache(fetchedSteps, fetchedOffset); setHasMore(fetchedOffset > 0); setLoading(false); } else { @@ -412,6 +477,7 @@ export function useStepsStream( endOffsetRef.current = Math.max(currentEnd, fetchedEnd); stepsRef.current = merged; setSteps([...merged]); + saveStepsToCache(merged, newBase); setHasMore(newBase > 0); } setError(null); @@ -442,7 +508,7 @@ export function useStepsStream( console.error("Soft refresh failed:", err); } }, - [cascadeId, connectWs], + [cascadeId, connectWs, saveStepsToCache], ); // ── Soft refresh: merge new steps without clearing existing messages ── @@ -464,6 +530,7 @@ export function useStepsStream( baseOffsetRef.current = 0; endOffsetRef.current = 0; setSteps([]); + saveStepsToCache([], 0); setLoading(true); setError(null); setHasMore(false); @@ -480,7 +547,7 @@ export function useStepsStream( const syncFrom = result.offset + result.count; connectWs(syncFrom); })(); - }, [initialFetch, connectWs, clearReconnectTimer]); + }, [initialFetch, connectWs, clearReconnectTimer, saveStepsToCache]); return { steps, diff --git a/packages/web/src/styles/chat.css b/packages/web/src/styles/chat.css index ffccf86..b1c166a 100644 --- a/packages/web/src/styles/chat.css +++ b/packages/web/src/styles/chat.css @@ -36,6 +36,25 @@ flex-shrink: 0; } +.app-target-dropdown { + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 3px 8px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + outline: none; + transition: all var(--transition-fast); +} + +.app-target-dropdown:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-hover); +} + .main-header-project { display: flex; align-items: center; diff --git a/packages/web/src/types/index.ts b/packages/web/src/types/index.ts index 815f5bb..ecf44ae 100644 --- a/packages/web/src/types/index.ts +++ b/packages/web/src/types/index.ts @@ -42,6 +42,7 @@ export interface HealthResponse { pid: number; httpsPort: number; workspaceId?: string; + appDataDir?: string; source: string; }[]; } @@ -336,6 +337,8 @@ export interface ChatMessage { optimisticState?: "unconfirmed" | "failed"; } +export type TargetApp = "all" | "antigravity" | "antigravity-ide"; + // ── Client Settings ── export interface ClientSettings { @@ -345,4 +348,6 @@ export interface ClientSettings { defaultPlannerType: "conversational" | "planning"; /** Enables browser notifications for run completion and approval requests. */ browserNotificationsEnabled: boolean; + /** Target application engine filter (antigravity vs antigravity-ide vs all). */ + targetApp: TargetApp; } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 8aaaf7a..16bfb0f 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -95,5 +95,11 @@ export default defineConfig(({ mode }) => { }, }, }, + preview: { + host: env.PORTA_HOST || process.env.PORTA_HOST || "127.0.0.1", + port: Number(env.PORTA_WEB_PORT || process.env.PORTA_WEB_PORT || 5173), + strictPort: true, + ...(allowedHosts !== undefined ? { allowedHosts } : {}), + }, }; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c0d766..ad1784b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,10 +12,10 @@ importers: dependencies: '@hono/node-server': specifier: ^2.0.10 - version: 2.0.10(hono@4.12.27) + version: 2.0.10(hono@4.13.2) hono: - specifier: ^4.12.27 - version: 4.12.27 + specifier: ^4.12.34 + version: 4.13.2 ws: specifier: ^8.20.1 version: 8.21.0 @@ -48,8 +48,8 @@ importers: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) react-router-dom: - specifier: ^7.13.1 - version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^7.18.2 + version: 7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@eslint/js': specifier: ^9.39.1 @@ -1203,42 +1203,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -1347,28 +1341,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.18': resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.18': resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.18': resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.18': resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} @@ -2148,8 +2138,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.27: - resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + hono@4.13.2: + resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -2415,28 +2405,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2654,15 +2640,15 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-router-dom@7.13.1: - resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} + react-router-dom@7.18.2: + resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.13.1: - resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + react-router@7.18.2: + resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -4304,9 +4290,9 @@ snapshots: '@exodus/bytes@1.15.0': {} - '@hono/node-server@2.0.10(hono@4.12.27)': + '@hono/node-server@2.0.10(hono@4.13.2)': dependencies: - hono: 4.12.27 + hono: 4.13.2 '@humanfs/core@0.19.1': {} @@ -5444,7 +5430,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.27: {} + hono@4.13.2: {} html-encoding-sniffer@6.0.0: dependencies: @@ -5901,13 +5887,13 @@ snapshots: react-is@17.0.2: {} - react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-router-dom@7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-router: 7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-router@7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: cookie: 1.1.1 react: 19.2.4 diff --git a/scripts/start-prod.mjs b/scripts/start-prod.mjs new file mode 100644 index 0000000..6b81080 --- /dev/null +++ b/scripts/start-prod.mjs @@ -0,0 +1,74 @@ +import path from "node:path"; +import { + commandName, + ensureLogsDir, + loadEnvFile, + spawnLoggedProcess, + terminateChild, + waitForExit, +} from "./common.mjs"; + +loadEnvFile(); + +const logsDir = ensureLogsDir(); +const runners = [ + spawnLoggedProcess( + "proxy", + commandName("pnpm"), + ["--filter", "@porta/proxy", "start"], + path.join(logsDir, "proxy.log"), + { + NODE_ENV: "production", + PORTA_HOST: process.env.PORTA_HOST || "127.0.0.1", + PORTA_PORT: process.env.PORTA_PORT || "3170", + } + ), + spawnLoggedProcess( + "web", + commandName("pnpm"), + ["--filter", "@porta/web", "preview"], + path.join(logsDir, "web.log"), + { + NODE_ENV: "production", + PORTA_HOST: process.env.PORTA_HOST || "127.0.0.1", + PORTA_PORT: process.env.PORTA_PORT || "3170", + PORTA_WEB_PORT: process.env.PORTA_WEB_PORT || "5173", + } + ), +]; + +console.log( + `✓ Porta production running - proxy on :${process.env.PORTA_PORT || "3170"}, web on :${process.env.PORTA_WEB_PORT || "5173"}`, +); + +let shuttingDown = false; + +async function shutdown(code = 0) { + if (shuttingDown) return; + shuttingDown = true; + + await Promise.all(runners.map(({ child }) => terminateChild(child))); + await Promise.all(runners.map(({ logStream }) => new Promise((resolve) => { + logStream.end(resolve); + }))); + process.exit(code); +} + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + void shutdown(0); + }); +} + +const exits = runners.map(async ({ child }, index) => ({ + index, + ...(await waitForExit(child)), +})); + +const firstExit = await Promise.race(exits); +if (!shuttingDown) { + const label = firstExit.index === 0 ? "proxy" : "web"; + const code = typeof firstExit.code === "number" ? firstExit.code : 1; + console.error(`${label} exited early`); + await shutdown(code); +}