Skip to content

Commit 61e215c

Browse files
engineerCopilot
andcommitted
fix: handle stale chunks after redeployment in serve mode
Three fixes for the "+" button (and all dynamic imports) silently failing when the server is redeployed while browser tabs are open: 1. instance.ts: Fix SPA fallback to only serve index.html for extensionless routes. Previously, missing .js chunks got HTML content instead of 404, masking the stale-bundle error. 2. instance.ts + server.ts: Add Cache-Control: no-cache on HTML responses so browsers revalidate on each navigation and pick up new chunk hashes. 3. entry.tsx: Add global unhandledrejection listener that detects stale dynamic-import failures and shows a persistent "new version available" overlay with a Reload button. Covers all 15+ dynamic import sites. Closes #22087 Co-authored-by: Copilot <[email protected]>
1 parent 4c1dcf7 commit 61e215c

3 files changed

Lines changed: 73 additions & 9 deletions

File tree

‎packages/app/src/entry.tsx‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,52 @@ const platform: Platform = {
127127
setDefaultServer: writeDefaultServerUrl,
128128
}
129129

130+
// Detect stale dynamic-import failures after redeployment and prompt the user to reload.
131+
window.addEventListener("unhandledrejection", (event) => {
132+
const msg = String(event.reason?.message || event.reason || "")
133+
if (
134+
!msg.includes("Failed to fetch dynamically imported module") &&
135+
!msg.includes("error loading dynamically imported module")
136+
)
137+
return
138+
if (document.getElementById("opencode-stale-reload")) return
139+
const overlay = document.createElement("div")
140+
overlay.id = "opencode-stale-reload"
141+
Object.assign(overlay.style, {
142+
position: "fixed",
143+
bottom: "16px",
144+
right: "16px",
145+
zIndex: "99999",
146+
background: "var(--color-background, #1a1a2e)",
147+
color: "var(--color-foreground, #e0e0e0)",
148+
padding: "12px 20px",
149+
borderRadius: "8px",
150+
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
151+
fontFamily: "system-ui, sans-serif",
152+
fontSize: "14px",
153+
display: "flex",
154+
alignItems: "center",
155+
gap: "12px",
156+
border: "1px solid var(--color-border, #333)",
157+
})
158+
const label = document.createElement("span")
159+
label.textContent = "A new version is available."
160+
const btn = document.createElement("button")
161+
btn.textContent = "Reload"
162+
btn.onclick = () => location.reload()
163+
Object.assign(btn.style, {
164+
background: "var(--color-primary, #4f46e5)",
165+
color: "white",
166+
border: "none",
167+
padding: "6px 16px",
168+
borderRadius: "4px",
169+
cursor: "pointer",
170+
fontSize: "14px",
171+
})
172+
overlay.append(label, btn)
173+
document.body.appendChild(overlay)
174+
})
175+
130176
if (root instanceof HTMLElement) {
131177
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
132178
render(

‎packages/opencode/src/server/instance.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Hono } from "hono"
33
import { proxy } from "hono/proxy"
44
import z from "zod"
55
import { createHash } from "node:crypto"
6+
import { extname } from "node:path"
67
import { Log } from "../util/log"
78
import { Format } from "../format"
89
import { TuiRoutes } from "./routes/tui"
@@ -282,13 +283,29 @@ export const InstanceRoutes = (app?: Hono) =>
282283
const path = c.req.path
283284

284285
if (embeddedWebUI) {
285-
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
286-
if (!match) return c.json({ error: "Not Found" }, 404)
287-
const file = Bun.file(match)
286+
const stripped = path.replace(/^\//, "")
287+
const matched = embeddedWebUI[stripped] ?? null
288+
289+
if (!matched) {
290+
// Only use SPA fallback for extensionless client-side routes.
291+
// Asset paths (e.g. .js, .css) must 404 so stale clients detect redeployments.
292+
if (extname(path)) return c.json({ error: "Not Found" }, 404)
293+
const index = embeddedWebUI["index.html"]
294+
if (!index) return c.json({ error: "Not Found" }, 404)
295+
const html = Bun.file(index)
296+
if (!(await html.exists())) return c.json({ error: "Not Found" }, 404)
297+
c.header("Content-Type", "text/html")
298+
c.header("Content-Security-Policy", DEFAULT_CSP)
299+
c.header("Cache-Control", "no-cache")
300+
return c.body(await html.arrayBuffer())
301+
}
302+
303+
const file = Bun.file(matched)
288304
if (await file.exists()) {
289305
c.header("Content-Type", file.type)
290306
if (file.type.startsWith("text/html")) {
291307
c.header("Content-Security-Policy", DEFAULT_CSP)
308+
c.header("Cache-Control", "no-cache")
292309
}
293310
return c.body(await file.arrayBuffer())
294311
} else {

‎packages/opencode/src/server/server.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -525,12 +525,12 @@ export namespace Server {
525525
const target = reqpath === "/" ? "index.html" : reqpath.slice(1)
526526
const file = Bun.file(join(dir, target))
527527
if (await file.exists()) {
528-
return new Response(file, {
529-
headers: {
530-
"Content-Type": file.type,
531-
"Content-Security-Policy": csp,
532-
},
533-
})
528+
const headers: Record<string, string> = {
529+
"Content-Type": file.type,
530+
"Content-Security-Policy": csp,
531+
}
532+
if (file.type.startsWith("text/html")) headers["Cache-Control"] = "no-cache"
533+
return new Response(file, { headers })
534534
}
535535
// Only use SPA fallback for extensionless client-side routes.
536536
if (!extname(reqpath)) {
@@ -540,6 +540,7 @@ export namespace Server {
540540
headers: {
541541
"Content-Type": "text/html",
542542
"Content-Security-Policy": csp,
543+
"Cache-Control": "no-cache",
543544
},
544545
})
545546
}

0 commit comments

Comments
 (0)