From 0ca4db0adb56a2834fe18c2929e771663fbbeac7 Mon Sep 17 00:00:00 2001 From: Nicholas Jackson Date: Thu, 5 Feb 2026 13:06:05 -0700 Subject: [PATCH 1/2] fix(base-path): rewrite hardcoded /assets/ paths in JS Fonts (inter, BlexMono) and audio files reference /assets/ directly in string literals. These need to be rewritten to include the basePath. Co-Authored-By: Claude Opus 4.5 --- packages/opencode/src/util/base-path.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/util/base-path.ts b/packages/opencode/src/util/base-path.ts index 7f8c90c53cd..9dda3128b85 100644 --- a/packages/opencode/src/util/base-path.ts +++ b/packages/opencode/src/util/base-path.ts @@ -102,6 +102,10 @@ export function rewriteJsForBasePath(js: string, basePath: string): string { // This handles all dynamic asset loading result = result.replace(/function\(t\)\{return"\/"\+t\}/g, `function(t){return"${basePath}/"+t}`) + // Rewrite hardcoded "/assets/..." paths in string literals + // These are used for fonts (inter, BlexMono, etc.) and audio files (staplebops, nope, etc.) + result = result.replace(/"\/assets\//g, `"${basePath}/assets/`) + return result } From 3f306eb92a4225a6a455d0efeb8a2cd9208abe76 Mon Sep 17 00:00:00 2001 From: Nicholas Jackson Date: Thu, 5 Feb 2026 13:09:57 -0700 Subject: [PATCH 2/2] fix(server): serve embedded assets instead of proxying to app.opencode.ai The proxy approach fails because app.opencode.ai serves the anomalyco frontend which lacks basePath support in the Router. Even with __OPENCODE_BASE_PATH__ injected, the Router doesn't use it. This change: - Add generate-app-manifest.ts to embed frontend assets at build time - Add app.ts with serveApp() to serve embedded assets with basePath rewriting - Update server.ts to use serveApp() instead of proxy() The embedded frontend includes the basePath-aware Router, so the session directory is correctly parsed from the URL. Co-Authored-By: Claude Opus 4.5 --- .../opencode/script/generate-app-manifest.ts | 131 ++++++++++++++++++ packages/opencode/src/server/app.ts | 114 +++++++++++++++ packages/opencode/src/server/server.ts | 52 +------ 3 files changed, 249 insertions(+), 48 deletions(-) create mode 100644 packages/opencode/script/generate-app-manifest.ts create mode 100644 packages/opencode/src/server/app.ts diff --git a/packages/opencode/script/generate-app-manifest.ts b/packages/opencode/script/generate-app-manifest.ts new file mode 100644 index 00000000000..ca1ca07bc01 --- /dev/null +++ b/packages/opencode/script/generate-app-manifest.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env bun + +/** + * Generates the app-manifest.ts file by scanning packages/app/dist/ + * This creates import statements for all frontend assets using Bun's + * `with { type: "file" }` syntax for embedding into the binary. + */ + +import path from "path" +import fs from "fs" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const appDistDir = path.resolve(__dirname, "../../app/dist") +const outputFile = path.resolve(__dirname, "../src/server/app-manifest.ts") + +// MIME type mappings +const mimeTypes: Record = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".aac": "audio/aac", + ".webmanifest": "application/manifest+json", + ".map": "application/json", +} + +function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() + return mimeTypes[ext] || "application/octet-stream" +} + +function sanitizeVarName(filePath: string): string { + // Convert file path to a valid JS variable name + // e.g., "assets/index-BCXcO0Zi.js" -> "assets_index_BCXcO0Zi_js" + return filePath + .replace(/[^a-zA-Z0-9]/g, "_") + .replace(/^_+/, "") + .replace(/_+$/, "") + .replace(/_+/g, "_") +} + +function getAllFiles(dir: string, baseDir: string = dir): string[] { + const files: string[] = [] + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...getAllFiles(fullPath, baseDir)) + } else { + // Get relative path from base dir + const relativePath = path.relative(baseDir, fullPath) + files.push(relativePath) + } + } + + return files +} + +async function generate() { + if (!fs.existsSync(appDistDir)) { + console.error(`Error: ${appDistDir} does not exist. Run 'bun run build' in packages/app first.`) + process.exit(1) + } + + const files = getAllFiles(appDistDir) + console.log(`Found ${files.length} files in ${appDistDir}`) + + const imports: string[] = [] + const assetEntries: string[] = [] + + for (const file of files) { + // Skip source maps in production + if (file.endsWith(".map")) continue + // Skip _headers file (Cloudflare-specific) + if (file === "_headers") continue + + const varName = `asset_${sanitizeVarName(file)}` + const urlPath = "/" + file.replace(/\\/g, "/") + const mimeType = getMimeType(file) + + // Use relative path from the output file location to app/dist + const relativePath = "../../../app/dist/" + file.replace(/\\/g, "/") + + imports.push(`import ${varName} from "${relativePath}" with { type: "file" }`) + assetEntries.push(` ["${urlPath}", { path: ${varName}, mime: "${mimeType}" }]`) + } + + // Add root path mapping to index.html + const indexEntry = assetEntries.find((e) => e.includes('"/index.html"')) + if (indexEntry) { + // Extract the asset reference from the index.html entry + assetEntries.unshift(` ["/", { path: asset_index_html, mime: "text/html; charset=utf-8" }]`) + } + + const content = `// This file is auto-generated by generate-app-manifest.ts +// Do not edit manually +// @ts-nocheck - Bun's file embedding imports are not understood by TypeScript + +${imports.join("\n")} + +export interface Asset { + path: string + mime: string +} + +export const assets = new Map([ +${assetEntries.join(",\n")} +]) + +// Index HTML path for SPA fallback +export const indexHtmlPath = asset_index_html +` + + fs.writeFileSync(outputFile, content) + console.log(`Generated ${outputFile} with ${files.length} assets`) +} + +generate().catch((err) => { + console.error("Failed to generate app manifest:", err) + process.exit(1) +}) diff --git a/packages/opencode/src/server/app.ts b/packages/opencode/src/server/app.ts new file mode 100644 index 00000000000..39e39d61b47 --- /dev/null +++ b/packages/opencode/src/server/app.ts @@ -0,0 +1,114 @@ +/** + * Serves the embedded frontend application assets. + * Assets are embedded into the binary at build time using Bun's file embedding. + */ + +import { assets, indexHtmlPath, type Asset } from "./app-manifest" +import { rewriteHtmlForBasePath, rewriteJsForBasePath, rewriteCssForBasePath } from "../util/base-path" + +// Static file extensions that should be served directly (not as SPA fallback) +const staticExtensions = new Set([ + ".js", + ".css", + ".json", + ".woff2", + ".woff", + ".ttf", + ".svg", + ".png", + ".ico", + ".aac", + ".webmanifest", + ".map", +]) + +function isStaticAsset(path: string): boolean { + // Check if path starts with /assets/ (Vite's default output) + if (path.startsWith("/assets/")) return true + + // Check known static files at root + const staticRootFiles = [ + "/favicon.ico", + "/favicon.svg", + "/favicon-96x96.png", + "/apple-touch-icon.png", + "/site.webmanifest", + "/social-share.png", + "/social-share-zen.png", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", + "/oc-theme-preload.js", + ] + if (staticRootFiles.includes(path)) return true + + // Check by extension + const ext = path.substring(path.lastIndexOf(".")) + return staticExtensions.has(ext) +} + +/** + * Serve an embedded asset with proper headers and optional basePath rewriting + */ +export async function serveApp(requestPath: string, basePath?: string): Promise { + // Normalize path + let path = requestPath + if (path === "" || path === "/") { + path = "/index.html" + } + + // Try to find the asset + let asset = assets.get(path) + + // If not found and not a static asset, serve index.html for SPA routing + if (!asset && !isStaticAsset(path)) { + asset = assets.get("/index.html") + path = "/index.html" + } + + // 404 if still not found + if (!asset) { + return new Response("Not Found", { status: 404 }) + } + + // Read the file content + const file = Bun.file(asset.path) + let content: string | ArrayBuffer = await file.arrayBuffer() + + // Apply basePath rewriting if needed + if (basePath) { + const mime = asset.mime + if (mime.includes("text/html")) { + content = rewriteHtmlForBasePath(await file.text(), basePath) + } else if (mime.includes("javascript") || path.endsWith(".js")) { + content = rewriteJsForBasePath(await file.text(), basePath) + } else if (mime.includes("text/css") || path.endsWith(".css")) { + content = rewriteCssForBasePath(await file.text(), basePath) + } + } + + // Determine cache headers + // Hashed assets (in /assets/) can be cached forever + // Other files should revalidate + const isHashedAsset = path.startsWith("/assets/") + const cacheControl = isHashedAsset ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate" + + const headers: Record = { + "Content-Type": asset.mime, + "Cache-Control": cacheControl, + } + + // Add CSP header for HTML (only when not using basePath with inline scripts) + if (asset.mime.includes("text/html") && !basePath) { + headers["Content-Security-Policy"] = + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'" + } + + return new Response(content, { headers }) +} + +/** + * Check if embedded app assets are available + */ +export function hasEmbeddedApp(): boolean { + return assets.size > 0 +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d8d3d2700bc..b63c5c1bf45 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -2,12 +2,11 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Log } from "../util/log" -import { rewriteHtmlForBasePath, rewriteJsForBasePath, rewriteCssForBasePath } from "../util/base-path" import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { Hono } from "hono" import { cors } from "hono/cors" import { stream, streamSSE } from "hono/streaming" -import { proxy } from "hono/proxy" +import { serveApp } from "./app" import { basicAuth } from "hono/basic-auth" import { Session } from "../session" import z from "zod" @@ -2849,57 +2848,14 @@ export namespace Server { }, ) .all("/*", async (c) => { - // Strip basePath from the request path before proxying + // Strip basePath from the request path before serving let path = c.req.path if (_basePath && path.startsWith(_basePath)) { path = path.slice(_basePath.length) || "/" } - const response = await proxy(`https://app.opencode.ai${path}`, { - ...c.req, - headers: { - ...c.req.raw.headers, - host: "app.opencode.ai", - }, - }) - - // Rewrite content for basePath support - const contentType = response.headers.get("content-type") || "" - - if (_basePath && contentType.includes("text/html")) { - const html = rewriteHtmlForBasePath(await response.text(), _basePath) - return new Response(html, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) - } - - if (_basePath && (contentType.includes("javascript") || path.endsWith(".js"))) { - const js = rewriteJsForBasePath(await response.text(), _basePath) - return new Response(js, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) - } - - if (_basePath && (contentType.includes("text/css") || path.endsWith(".css"))) { - const css = rewriteCssForBasePath(await response.text(), _basePath) - return new Response(css, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) - } - - // Set CSP header only when not rewriting content (no basePath) - // When basePath is set, we inject inline scripts which would violate CSP - response.headers.set( - "Content-Security-Policy", - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'", - ) - return response + // Serve from embedded app assets + return serveApp(path, _basePath) }) as unknown as Hono, )