-
Notifications
You must be signed in to change notification settings - Fork 0
Implementation Plan: Critical-Path SSE Improvements #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: local/amicode
Are you sure you want to change the base?
Changes from all commits
b2b2a8a
60eaddc
94de4d1
368d2dc
8dea418
e752d47
d1ad322
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,10 @@ | |
| "vscode": { | ||
| "extensions": [ | ||
| "harmoniqs.amicode" | ||
| ] | ||
| ], | ||
| "settings": { | ||
| "amicode.opencodePort": 43117 | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| # ADR 0005: Server Boot-ID and SSE Connection Resilience | ||
|
|
||
| ## Status | ||
|
|
||
| Accepted | ||
|
|
||
| ## Context | ||
|
|
||
| The opencode web app (`packages/app`) connects to the local server via | ||
| Server-Sent Events (SSE). When the server restarts — due to a container rebuild, | ||
| explicit restart command, or process crash — the SSE stream disconnects and the | ||
| client retries every 250 ms. | ||
|
|
||
| The fundamental issue is an asymmetry in persistence lifetimes: the webview's | ||
| localStorage lives on the **host machine** (or in VS Code Server's profile storage) | ||
| and survives container rebuilds, process restarts, and window reloads. The server | ||
| port, however, is **ephemeral** — determined at startup by whichever port happens | ||
| to be free. There is no stable identity linking "this localStorage scope" to "this | ||
| server instance." The extension host is the only component that knows the correct | ||
| server URL at all times (it spawned the process), making it the authoritative | ||
| source for runtime connection parameters. The webview should receive these from the | ||
| host, not persist and re-read them independently. | ||
|
|
||
| Three problems existed: | ||
|
|
||
| 1. **No restart detection**: the `server.connected` SSE event carried | ||
| `properties: {}`. The client could not distinguish "same server, reconnected" | ||
| from "different server boot on the same port" from "stale URL, server gone." | ||
|
|
||
| 2. **Stale URL persistence**: the web app persisted a `defaultServerUrl` in | ||
| localStorage that could override `location.origin`. In the Amicode webview | ||
| (iframe), `location.origin` is always correct — the override caused the SSE | ||
| loop to connect to a dead port indefinitely. | ||
|
|
||
| 3. **Infinite retry burn**: the SSE reconnect loop retried forever with no | ||
| escalation. A genuinely unreachable server consumed CPU and produced no user | ||
| feedback. | ||
|
|
||
| ## Decision | ||
|
|
||
| ### Server-side: boot-ID generation | ||
|
|
||
| - A new module `src/server/boot-id.ts` generates a `crypto.randomUUID()` on each | ||
| `Server.listen()` call and exports it via `BootId.get()`. | ||
| - Both SSE handlers (instance at `/api/event` and global at `/global/event`) emit | ||
| `{ bootId: BootId.get() }` in the `server.connected` event's `properties`. | ||
| - Each `listen()` call (including restarts) produces a fresh ID. | ||
|
|
||
| ### Client-side: stale URL prevention | ||
|
|
||
| - In `entry.tsx`, the `getDefaultUrl()` function is gated: when running inside | ||
| the Amicode webview (`inAmicode()`), the `defaultServerUrl` localStorage key is | ||
| never consulted. `location.origin` is used unconditionally. | ||
|
|
||
| ### Client-side: reconnect escalation | ||
|
|
||
| - A `consecutiveFailures` counter in `server-sdk.tsx` tracks connection failures. | ||
| - After 10 consecutive failures (2.5 seconds), the SSE loop breaks with a warning | ||
| log. The `streamStatus` signal remains `"disconnected"` — the ConnectionBanner | ||
| surfaces this to the user. | ||
| - A page visibility cycle (`pagehide` → `pageshow`) restarts the loop, providing | ||
| a user-initiated recovery path. | ||
|
|
||
| ### Client-side: boot-ID persistence and mismatch detection | ||
|
|
||
| - The server store (`packages/app/src/context/server.tsx`) gains a | ||
| `lastBootId: Record<string, string>` field, persisted in localStorage alongside | ||
| the existing server list and project state. | ||
| - On each `server.connected` event, the client extracts `properties.bootId`, | ||
| compares it to the persisted value, logs a warning on mismatch, and persists the | ||
| new value. | ||
| - The existing `server-sync.tsx` logic already triggers a full refresh (session | ||
| list refetch, directory re-bootstrap) on `server.connected` — the boot-ID | ||
| mismatch log provides additional observability for debugging. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Servers that restart on the same port (the common case with | ||
| `amicode.opencodePort = 43117`) reconnect seamlessly: the SSE loop retries, | ||
| connects, receives the new boot-ID, and triggers a refresh. | ||
| - Servers on a different port after restart are handled by the extension host's | ||
| panel-recreation mechanism (see amicode ADR 0008). | ||
| - The 10-failure abort prevents infinite CPU burn on genuinely unreachable servers. | ||
| - Future: the abort will be upgraded to self-healing (post `server-url-changed` to | ||
| self and redirect) once Phase 4's `AmicodeServerBridge` listener is stable. | ||
| - The `lastBootId` persistence enables detecting restarts that happened while the | ||
| webview was closed — on next open, the first `server.connected` event's boot-ID | ||
| won't match, and the full refresh path fires. | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### Boot-ID as HTTP response header on all responses | ||
|
|
||
| Adding `X-OpenCode-Boot-ID` to every HTTP response via a global middleware. This | ||
| would benefit non-SSE clients (REST API callers, CLI) but adds middleware | ||
| complexity with no immediate gain for the webview client, which connects | ||
| exclusively via SSE. The `BootId` module is importable by any future middleware | ||
| should this be needed. | ||
|
|
||
| ### Self-healing reconnect via postMessage | ||
|
|
||
| After the failure threshold, the SSE loop posts a `server-url-changed` message to | ||
| itself, which the `AmicodeServerBridge` handles by updating the server store and | ||
| reconnecting. This provides seamless recovery without user intervention. Deferred | ||
| until Phase 4's bridge is validated — documented in | ||
| `plans/followup-self-healing-reconnect.md`. | ||
|
|
||
| ### Forced page reload on failure threshold | ||
|
|
||
| Calling `window.location.reload()` after 10 consecutive failures. Guarantees | ||
| recovery (Phase 2 ensures the fresh load uses `location.origin`) but loses all | ||
| in-memory state (open editors, scroll positions, draft prompts). Rejected as too | ||
| disruptive for a recoverable failure. | ||
|
|
||
| ### Port 0 (OS-assigned) with readback | ||
|
|
||
| Changing the extension to pass port `0` and read back the actual bound port from | ||
| the server's response. Eliminates port collisions but requires restructuring the | ||
| terminal launch protocol and does not address the stale-URL problem (the webview | ||
| still needs to discover the new port). Deferred — the fixed-port default (43117) | ||
| is the primary mitigation. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -188,6 +188,7 @@ type ServerSDKBase = { | |||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { | ||||||||||||||||||||||||||||||||||||
| const platform = usePlatform() | ||||||||||||||||||||||||||||||||||||
| const serverCtx = useServer() | ||||||||||||||||||||||||||||||||||||
| const abort = new AbortController() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const eventFetch = (() => { | ||||||||||||||||||||||||||||||||||||
|
|
@@ -253,6 +254,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS | |||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| let streamErrorLogged = false | ||||||||||||||||||||||||||||||||||||
| let consecutiveFailures = 0 | ||||||||||||||||||||||||||||||||||||
| const MAX_CONSECUTIVE_FAILURES = 10 | ||||||||||||||||||||||||||||||||||||
| const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) | ||||||||||||||||||||||||||||||||||||
| let attempt: AbortController | undefined | ||||||||||||||||||||||||||||||||||||
| let run: Promise<void> | undefined | ||||||||||||||||||||||||||||||||||||
|
|
@@ -297,11 +300,32 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS | |||||||||||||||||||||||||||||||||||
| ? (await eventSdk.global.event({ signal: attempt.signal, onSseError })).stream | ||||||||||||||||||||||||||||||||||||
| : eventApi.event.subscribe({ signal: attempt.signal }) | ||||||||||||||||||||||||||||||||||||
| setStreamStatus("connected") | ||||||||||||||||||||||||||||||||||||
| consecutiveFailures = 0 | ||||||||||||||||||||||||||||||||||||
| let yielded = Date.now() | ||||||||||||||||||||||||||||||||||||
| for await (const event of events) { | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
302
to
305
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Do not treat stream creation as a successful connection. The code marks the stream as connected and resets Proposed adjustment- setStreamStatus("connected")
- consecutiveFailures = 0
+ let receivedEvent = false
let yielded = Date.now()
for await (const event of events) {
+ if (!receivedEvent) {
+ receivedEvent = true
+ setStreamStatus("connected")
+ consecutiveFailures = 0
+ }
streamErrorLogged = false
// existing event handling
}
+ setStreamStatus("disconnected")
+ if (!receivedEvent) consecutiveFailures++📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| streamErrorLogged = false | ||||||||||||||||||||||||||||||||||||
| const legacy = "payload" in event | ||||||||||||||||||||||||||||||||||||
| if (legacy && event.payload.type === "sync") continue | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Boot-ID detection: compare the server's boot identifier on each | ||||||||||||||||||||||||||||||||||||
| // server.connected event to detect restarts across reconnections. | ||||||||||||||||||||||||||||||||||||
| const eventType = legacy ? event.payload.type : event.type | ||||||||||||||||||||||||||||||||||||
| if (eventType === "server.connected") { | ||||||||||||||||||||||||||||||||||||
| const props = legacy ? event.payload.properties : (event as { properties?: Record<string, unknown> }).properties | ||||||||||||||||||||||||||||||||||||
| const newBootId = typeof props?.bootId === "string" ? props.bootId : undefined | ||||||||||||||||||||||||||||||||||||
| if (newBootId) { | ||||||||||||||||||||||||||||||||||||
| const previousBootId = serverCtx.getBootId(scope) | ||||||||||||||||||||||||||||||||||||
| if (previousBootId && previousBootId !== newBootId) { | ||||||||||||||||||||||||||||||||||||
| console.warn("[server-sdk] server restarted (boot-ID changed)", { | ||||||||||||||||||||||||||||||||||||
| previous: previousBootId, | ||||||||||||||||||||||||||||||||||||
| current: newBootId, | ||||||||||||||||||||||||||||||||||||
| scope, | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| serverCtx.setBootId(newBootId, scope) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") | ||||||||||||||||||||||||||||||||||||
| const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) | ||||||||||||||||||||||||||||||||||||
| if (enqueueServerEvent(queue, { directory, payload })) schedule() | ||||||||||||||||||||||||||||||||||||
|
|
@@ -311,7 +335,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS | |||||||||||||||||||||||||||||||||||
| await wait(0) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||
| if (!isStreamClosed(error, attempt?.signal)) setStreamStatus("disconnected") | ||||||||||||||||||||||||||||||||||||
| if (!isStreamClosed(error, attempt?.signal)) { | ||||||||||||||||||||||||||||||||||||
| setStreamStatus("disconnected") | ||||||||||||||||||||||||||||||||||||
| consecutiveFailures++ | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { | ||||||||||||||||||||||||||||||||||||
| streamErrorLogged = true | ||||||||||||||||||||||||||||||||||||
| console.error("[global-sdk] event stream failed", { | ||||||||||||||||||||||||||||||||||||
|
|
@@ -327,6 +354,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS | |||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (abort.signal.aborted || !started || generation !== active) return | ||||||||||||||||||||||||||||||||||||
| await wait(RECONNECT_DELAY_MS) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { | ||||||||||||||||||||||||||||||||||||
| console.warn("[server-sdk] server unreachable after", MAX_CONSECUTIVE_FAILURES, "retries — SSE loop stopped", { | ||||||||||||||||||||||||||||||||||||
| url: server.http.url, | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
355
to
+363
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Reset retry state when the loop starts a new generation. After the threshold breaks the loop, 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| })().finally(() => { | ||||||||||||||||||||||||||||||||||||
| if (run !== current) return | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,7 @@ import { installGlobalClipboardFallback } from "@/utils/global-clipboard" | |||||||||||||||||||||||||||||||||||||||||||||||
| import { installWebviewContextMenu } from "@/utils/webview-context-menu" | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { webZoom } from "@/utils/web-zoom" | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { authFromToken } from "@/utils/server" | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { inAmicode } from "@/utils/amicode-bridge" | ||||||||||||||||||||||||||||||||||||||||||||||||
| import pkg from "../package.json" | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { ServerConnection } from "./context/server" | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -155,8 +156,14 @@ const getCurrentUrl = () => { | |||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const getDefaultUrl = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const lsDefault = readDefaultServerUrl() | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (lsDefault) return lsDefault | ||||||||||||||||||||||||||||||||||||||||||||||||
| // In the Amicode webview (iframe), location.origin is always the correct | ||||||||||||||||||||||||||||||||||||||||||||||||
| // server URL because the iframe IS served by the running server. Never let a | ||||||||||||||||||||||||||||||||||||||||||||||||
| // stale localStorage override win over it — that causes the "no GUI response" | ||||||||||||||||||||||||||||||||||||||||||||||||
| // bug when the server restarts on a different port. | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!inAmicode()) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const lsDefault = readDefaultServerUrl() | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (lsDefault) return lsDefault | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| return getCurrentUrl() | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
158
to
167
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Return The current branch only skips localStorage. It still calls Move the Amicode check before the fallback logic. Proposed fix const getDefaultUrl = () => {
+ if (inAmicode()) return location.origin
+
// In the Amicode webview (iframe), location.origin is always the correct
// server URL because the iframe IS served by the running server. Never let a
// stale localStorage override win over it — that causes the "no GUI response"
// bug when the server restarts on a different port.
- if (!inAmicode()) {
- const lsDefault = readDefaultServerUrl()
- if (lsDefault) return lsDefault
- }
+ const lsDefault = readDefaultServerUrl()
+ if (lsDefault) return lsDefault
+
return getCurrentUrl()
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { randomUUID } from "node:crypto" | ||
|
|
||
| /** A random identifier generated fresh on each `refresh()` call. Clients persist | ||
| * this alongside the server URL and detect restarts by comparing it to the | ||
| * `bootId` emitted in the `server.connected` SSE event. */ | ||
| let _bootId: string | undefined | ||
|
|
||
| export function refresh() { | ||
| _bootId = randomUUID() | ||
| } | ||
|
|
||
| export function get(): string | undefined { | ||
| return _bootId | ||
| } | ||
|
Comment on lines
+6
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep run --pattern 'Server.listen($$$)' --lang typescript packages/opencode
rg -n 'export async function listen|BootId.refresh|last listener wins' packages/opencode/src/server --glob '*.ts'Repository: harmoniqs/opencode Length of output: 1897 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- server.ts ---'
sed -n '1,180p' packages/opencode/src/server/server.ts
printf '%s\n' '--- boot-id references ---'
rg -n -C 4 '\bBootId\b|bootId|boot_id' packages/opencode/src packages/opencode/test --glob '*.ts'
printf '%s\n' '--- boot-id.ts ---'
cat -n packages/opencode/src/server/boot-id.ts
printf '%s\n' '--- listener type and close paths ---'
rg -n -C 5 'type Listener|interface Listener|close\(|url|server\.listen|Bun\.serve|serve\(' packages/opencode/src/server/server.ts packages/opencode/src/server --glob '*.ts'Repository: harmoniqs/opencode Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- listener shutdown and URL handling ---'
sed -n '175,210p' packages/opencode/src/server/server.ts
printf '%s\n' '--- all bootId consumers ---'
rg -n -C 3 '\bbootId\b|\bBootId\b' --glob '*.{ts,tsx,js,jsx}' --glob '!packages/opencode/src/server/server.ts' --glob '!packages/opencode/src/server/boot-id.ts' .
printf '%s\n' '--- persistence and restart-detection consumers ---'
rg -n -C 3 'restart|persist|server\.connected|connected.*boot|boot.*connected' packages/opencode --glob '*.{ts,tsx,js,jsx}' | head -n 300
printf '%s\n' '--- tests that create multiple listeners or inspect global URL ---'
rg -n -C 5 'Server\.listen|Server\.url|bootId|server\.connected' packages/opencode/test --glob '*.{ts,tsx}'Repository: harmoniqs/opencode Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
server = Path("packages/opencode/src/server/server.ts").read_text()
boot = Path("packages/opencode/src/server/boot-id.ts").read_text()
event = Path("packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts").read_text()
global_event = Path("packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts").read_text()
listen_body = re.search(
r"export async function listen\(opts: ListenOptions\): Promise<Listener> \{(?P<body>.*?)\n\}",
server,
re.S,
).group("body")
assert listen_body.index("BootId.refresh()") < listen_body.index("await Effect.runPromise(listenEffect(opts))")
assert "let _bootId: string | undefined" in boot
assert re.search(r"_bootId\s*=\s*randomUUID\(\)", boot)
assert "BootId.get()" in event
assert "BootId.get()" in global_event
assert "export let url: URL | undefined" in server
assert re.search(r"url = listenerUrl", server)
assert "if (url === listenerUrl) url = undefined" in server
print("listen refreshes the process-global boot ID before bind completion")
print("both connected-event handlers read the process-global boot ID at request/stream creation time")
print("the listener URL is also process-global and is assigned after each successful bind")
print("no listener-local boot ID is present in the inspected server state or Listener return type")
PYRepository: harmoniqs/opencode Length of output: 491 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '292,335p' packages/app/src/context/server-sdk.tsx
sed -n '350,375p' packages/app/src/context/server.tsxRepository: harmoniqs/opencode Length of output: 2779 Keep boot IDs listener-scoped. 🤖 Prompt for AI Agents |
||
|
|
||
| export * as BootId from "./boot-id" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Handle invalid server URLs before redirecting.
d.urlcomes fromMessageEvent.data. A malformed value makesnew URL(d.url)throw inside the message listener. The redirect also assumes thatd.urlcontains only an origin. Parse the value once, catch invalid URLs, and use the parsedurl.originfor the redirect target.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents