diff --git a/README.md b/README.md
index d0ba487402f..e339580ea57 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,27 @@ If you're interested in contributing to OpenCode, please read our [contributing
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
+### Running Behind a Reverse Proxy
+
+OpenCode supports running behind a reverse proxy with a base path prefix:
+
+```bash
+# CLI flag
+opencode web --base-path /my-prefix/
+
+# Environment variable
+OPENCODE_BASE_PATH=/my-prefix/ opencode web
+
+# Config file (opencode.json)
+{
+ "server": {
+ "basePath": "/my-prefix/"
+ }
+}
+```
+
+This is useful for deploying behind a reverse proxy with path-based routing (e.g., Kubernetes Ingress, nginx, traefik).
+
### FAQ
#### How is this different from Claude Code?
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index d0678dc5369..4cc7835507e 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -34,6 +34,7 @@ const Loading = () =>
{
if (props.defaultUrl) return props.defaultUrl
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
- return window.location.origin
+ // Support for reverse proxy with base path
+ // The server injects window.__OPENCODE_BASE_PATH__ when serving under a base path
+ return window.location.origin + basePath
}
return (
@@ -81,6 +86,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
(
diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts
index bee2c8f711f..4bc7abbe901 100644
--- a/packages/opencode/src/cli/cmd/serve.ts
+++ b/packages/opencode/src/cli/cmd/serve.ts
@@ -2,6 +2,7 @@ import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
+import { normalizeBasePath } from "../../util/base-path"
export const ServeCommand = cmd({
command: "serve",
@@ -13,7 +14,9 @@ export const ServeCommand = cmd({
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
- console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
+ const basePath = normalizeBasePath(opts.basePath)
+ const pathSuffix = basePath ? `${basePath}/` : ""
+ console.log(`opencode server listening on http://${server.hostname}:${server.port}${pathSuffix}`)
await new Promise(() => {})
await server.stop()
},
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index e63f10ba80c..b3a872e7cea 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -116,10 +116,17 @@ export const rpc = {
body,
}
},
- async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
+ async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[]; basePath?: string }) {
if (server) await server.stop(true)
- server = Server.listen(input)
- return { url: server.url.toString() }
+ try {
+ server = Server.listen(input)
+ return {
+ url: Server.url().toString(),
+ }
+ } catch (e) {
+ console.error(e)
+ throw e
+ }
},
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts
index 2c207ecc2f2..a230fdffe4b 100644
--- a/packages/opencode/src/cli/cmd/web.ts
+++ b/packages/opencode/src/cli/cmd/web.ts
@@ -5,6 +5,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import open from "open"
import { networkInterfaces } from "os"
+import { normalizeBasePath } from "../../util/base-path"
function getNetworkIPs() {
const nets = networkInterfaces()
@@ -38,13 +39,16 @@ export const WebCommand = cmd({
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
+ const basePath = normalizeBasePath(opts.basePath)
+ const pathSuffix = basePath ? `${basePath}/` : ""
+
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
if (opts.hostname === "0.0.0.0") {
// Show localhost for local access
- const localhostUrl = `http://localhost:${server.port}`
+ const localhostUrl = `http://localhost:${server.port}${pathSuffix}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
// Show network IPs for remote access
@@ -54,7 +58,7 @@ export const WebCommand = cmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " Network access: ",
UI.Style.TEXT_NORMAL,
- `http://${ip}:${server.port}`,
+ `http://${ip}:${server.port}${pathSuffix}`,
)
}
}
@@ -63,11 +67,18 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
}
+ if (basePath) {
+ UI.println(UI.Style.TEXT_INFO_BOLD + " Base path: ", UI.Style.TEXT_NORMAL, basePath)
+ }
+
// Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
} else {
- const displayUrl = server.url.toString()
+ const displayUrl = Server.url().toString()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
+ if (basePath) {
+ UI.println(UI.Style.TEXT_INFO_BOLD + " Base path: ", UI.Style.TEXT_NORMAL, basePath)
+ }
open(displayUrl).catch(() => {})
}
diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts
index fe5731d0713..14149ccf082 100644
--- a/packages/opencode/src/cli/network.ts
+++ b/packages/opencode/src/cli/network.ts
@@ -12,6 +12,11 @@ const options = {
describe: "hostname to listen on",
default: "127.0.0.1",
},
+ "base-path": {
+ type: "string" as const,
+ describe: "base path prefix for all routes (e.g., /my-prefix/)",
+ default: "/",
+ },
mdns: {
type: "boolean" as const,
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
@@ -35,6 +40,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await Config.global()
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
+ const basePathExplicitlySet = process.argv.includes("--base-path")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const corsExplicitlySet = process.argv.includes("--cors")
@@ -49,5 +55,13 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors]
- return { hostname, port, mdns, cors }
+ // Resolve base path: CLI arg > env var > config > default
+ const envBasePath = process.env.OPENCODE_BASE_PATH
+ const basePath = basePathExplicitlySet
+ ? args["base-path"]
+ : envBasePath
+ ? envBasePath
+ : (config?.server?.basePath ?? args["base-path"])
+
+ return { hostname, port, mdns, cors, basePath }
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index bf4a6035bd8..d4c3887dc29 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -771,6 +771,10 @@ export namespace Config {
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
+ basePath: z
+ .string()
+ .optional()
+ .describe("Base path prefix for all routes (e.g., /my-prefix/)"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 52457515b8e..d8d3d2700bc 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -2,6 +2,7 @@ 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"
@@ -63,10 +64,19 @@ export namespace Server {
const log = Log.create({ service: "server" })
let _url: URL | undefined
+ let _basePath: string = ""
let _corsWhitelist: string[] = []
export function url(): URL {
- return _url ?? new URL("http://localhost:4096")
+ const base = _url ?? new URL("http://localhost:4096")
+ if (_basePath) {
+ return new URL(_basePath + "/", base)
+ }
+ return base
+ }
+
+ export function basePath(): string {
+ return _basePath
}
export const Event = {
@@ -152,14 +162,24 @@ export namespace Server {
description: "Health information",
content: {
"application/json": {
- schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
+ schema: resolver(
+ z.object({
+ healthy: z.literal(true),
+ version: z.string(),
+ basePath: z.string().optional(),
+ }),
+ ),
},
},
},
},
}),
async (c) => {
- return c.json({ healthy: true, version: Installation.VERSION })
+ return c.json({
+ healthy: true,
+ version: Installation.VERSION,
+ basePath: _basePath || undefined,
+ })
},
)
.get(
@@ -2829,7 +2849,12 @@ export namespace Server {
},
)
.all("/*", async (c) => {
- const path = c.req.path
+ // Strip basePath from the request path before proxying
+ 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: {
@@ -2837,6 +2862,39 @@ export namespace Server {
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'",
@@ -2860,13 +2918,35 @@ export namespace Server {
return result
}
- export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
+ export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; basePath?: string }) {
_corsWhitelist = opts.cors ?? []
+ // Normalize basePath: ensure leading slash, remove trailing slash
+ const rawBasePath = opts.basePath ?? "/"
+ _basePath =
+ rawBasePath === "/"
+ ? ""
+ : (rawBasePath.startsWith("/") ? rawBasePath : `/${rawBasePath}`).replace(/\/+$/, "")
+
+ // Create wrapper app for base path routing
+ const baseApp = new Hono()
+ const mainApp = App()
+
+ if (_basePath) {
+ // Mount the main app under the base path
+ baseApp.route(_basePath, mainApp)
+
+ // Also mount at root level to support reverse proxies that strip the basePath
+ // before forwarding requests (e.g., some Kubernetes ingress configurations)
+ baseApp.route("/", mainApp)
+ }
+
+ const appToServe = _basePath ? baseApp : mainApp
+
const args = {
hostname: opts.hostname,
idleTimeout: 0,
- fetch: App().fetch,
+ fetch: appToServe.fetch,
websocket: websocket,
} as const
const tryServe = (port: number) => {
diff --git a/packages/opencode/src/util/base-path.ts b/packages/opencode/src/util/base-path.ts
new file mode 100644
index 00000000000..7f8c90c53cd
--- /dev/null
+++ b/packages/opencode/src/util/base-path.ts
@@ -0,0 +1,118 @@
+/**
+ * Normalizes a base path to ensure consistent format:
+ * - Returns empty string for root path or undefined
+ * - Ensures leading slash
+ * - Removes trailing slashes
+ */
+export function normalizeBasePath(path?: string): string {
+ if (!path || path === "/") return ""
+
+ // Ensure leading slash, remove trailing slashes
+ let normalized = path.startsWith("/") ? path : `/${path}`
+ normalized = normalized.replace(/\/+$/, "")
+
+ return normalized
+}
+
+/**
+ * Joins a base path with additional path segments.
+ * Handles normalization of the base path and proper joining of segments.
+ */
+export function joinPath(basePath: string, ...segments: string[]): string {
+ const base = normalizeBasePath(basePath)
+ const path = segments.join("/").replace(/\/+/g, "/")
+ return `${base}${path.startsWith("/") ? path : `/${path}`}`
+}
+
+/**
+ * Generates the JavaScript snippet that wraps history.pushState/replaceState
+ * to automatically prepend the basePath to URLs.
+ */
+export function generateBasePathScript(basePath: string): string {
+ return ``
+}
+
+/**
+ * Rewrites HTML content to include basePath in asset references.
+ * - Rewrites href="/...", src="/...", content="/..." to include basePath
+ * - Injects the basePath script before
+ * - Does NOT rewrite protocol-relative URLs (//...)
+ */
+export function rewriteHtmlForBasePath(html: string, basePath: string): string {
+ if (!basePath) return html
+
+ // Rewrite absolute paths in HTML to include basePath
+ // Matches href="/...", src="/...", content="/..." but not href="//..." (protocol-relative)
+ let result = html.replace(/(href|src|content)="\/(?!\/)/g, `$1="${basePath}/`)
+
+ // Inject basePath script before
+ result = result.replace("", `${generateBasePathScript(basePath)}`)
+
+ return result
+}
+
+/**
+ * Rewrites JavaScript content to work with basePath.
+ * - Patches window.location.origin references to include basePath
+ * - Patches Vite's base path function for dynamic asset loading
+ *
+ * Note: The Vite patch is fragile and depends on minified output format.
+ */
+export function rewriteJsForBasePath(js: string, basePath: string): string {
+ if (!basePath) return js
+
+ let result = js
+
+ // Replace the pattern where the app determines the server URL
+ // In minified code it can appear as either:
+ // :window.location.origin) (inside function call)
+ // :window.location.origin; (end of ternary expression)
+ result = result.replace(
+ /:window\.location\.origin([;)])/g,
+ `:window.location.origin+(window.__OPENCODE_BASE_PATH__||"")$1`,
+ )
+
+ // Patch Vite's base path function to use our basePath instead of "/"
+ // The function looks like: function(t){return"/"+t}
+ // This handles all dynamic asset loading
+ result = result.replace(/function\(t\)\{return"\/"\+t\}/g, `function(t){return"${basePath}/"+t}`)
+
+ return result
+}
+
+/**
+ * Rewrites CSS content to include basePath in url() references.
+ * - Rewrites url(/...) to url(/basePath/...)
+ * - Does NOT rewrite protocol-relative URLs (//...)
+ */
+export function rewriteCssForBasePath(css: string, basePath: string): string {
+ if (!basePath) return css
+
+ // Rewrite url(/assets/...) to url(/basePath/assets/...)
+ return css.replace(/url\(\/(?!\/)/g, `url(${basePath}/`)
+}
diff --git a/packages/opencode/test/util/base-path.test.ts b/packages/opencode/test/util/base-path.test.ts
new file mode 100644
index 00000000000..4f10218f451
--- /dev/null
+++ b/packages/opencode/test/util/base-path.test.ts
@@ -0,0 +1,217 @@
+import { describe, expect, test } from "bun:test"
+import {
+ normalizeBasePath,
+ joinPath,
+ generateBasePathScript,
+ rewriteHtmlForBasePath,
+ rewriteJsForBasePath,
+ rewriteCssForBasePath,
+} from "../../src/util/base-path"
+
+describe("util.base-path", () => {
+ describe("normalizeBasePath", () => {
+ test("returns empty string for root", () => {
+ expect(normalizeBasePath("/")).toBe("")
+ expect(normalizeBasePath(undefined)).toBe("")
+ })
+
+ test("returns empty string for empty string", () => {
+ expect(normalizeBasePath("")).toBe("")
+ })
+
+ test("normalizes paths with leading slash", () => {
+ expect(normalizeBasePath("/prefix")).toBe("/prefix")
+ expect(normalizeBasePath("/notebook/namespace/name")).toBe("/notebook/namespace/name")
+ })
+
+ test("removes trailing slash", () => {
+ expect(normalizeBasePath("/prefix/")).toBe("/prefix")
+ expect(normalizeBasePath("/notebook/namespace/name/")).toBe("/notebook/namespace/name")
+ })
+
+ test("adds leading slash if missing", () => {
+ expect(normalizeBasePath("prefix")).toBe("/prefix")
+ expect(normalizeBasePath("notebook/namespace/name")).toBe("/notebook/namespace/name")
+ })
+
+ test("handles path without leading slash and with trailing slash", () => {
+ expect(normalizeBasePath("prefix/")).toBe("/prefix")
+ expect(normalizeBasePath("notebook/namespace/name/")).toBe("/notebook/namespace/name")
+ })
+
+ test("handles multiple trailing slashes", () => {
+ expect(normalizeBasePath("/prefix///")).toBe("/prefix")
+ })
+ })
+
+ describe("joinPath", () => {
+ test("joins base path with segments", () => {
+ expect(joinPath("/prefix", "api", "v1")).toBe("/prefix/api/v1")
+ expect(joinPath("/prefix", "/api", "/v1")).toBe("/prefix/api/v1")
+ })
+
+ test("handles empty base path", () => {
+ expect(joinPath("/", "api", "v1")).toBe("/api/v1")
+ expect(joinPath("", "api", "v1")).toBe("/api/v1")
+ })
+
+ test("normalizes multiple slashes", () => {
+ expect(joinPath("/prefix", "//api//", "//v1")).toBe("/prefix/api/v1")
+ })
+
+ test("handles segments with leading slashes", () => {
+ expect(joinPath("/prefix", "/session")).toBe("/prefix/session")
+ })
+
+ test("handles trailing slash on base path", () => {
+ expect(joinPath("/prefix/", "api")).toBe("/prefix/api")
+ })
+ })
+
+ describe("generateBasePathScript", () => {
+ test("generates script with basePath variable", () => {
+ const script = generateBasePathScript("/myapp")
+ expect(script).toContain('window.__OPENCODE_BASE_PATH__="/myapp"')
+ expect(script).toContain("")
+ })
+
+ test("includes history.pushState wrapper", () => {
+ const script = generateBasePathScript("/myapp")
+ expect(script).toContain("history.pushState")
+ expect(script).toContain("origPushState")
+ })
+
+ test("includes history.replaceState wrapper", () => {
+ const script = generateBasePathScript("/myapp")
+ expect(script).toContain("history.replaceState")
+ expect(script).toContain("origReplaceState")
+ })
+ })
+
+ describe("rewriteHtmlForBasePath", () => {
+ test("returns unchanged HTML when basePath is empty", () => {
+ const html = 'Link'
+ expect(rewriteHtmlForBasePath(html, "")).toBe(html)
+ })
+
+ test("rewrites href attributes with absolute paths", () => {
+ const html = 'Link'
+ const result = rewriteHtmlForBasePath(html, "/myapp")
+ expect(result).toContain('href="/myapp/page"')
+ })
+
+ test("rewrites src attributes with absolute paths", () => {
+ const html = ''
+ const result = rewriteHtmlForBasePath(html, "/myapp")
+ expect(result).toContain('src="/myapp/assets/main.js"')
+ })
+
+ test("rewrites content attributes with absolute paths", () => {
+ const html = ''
+ const result = rewriteHtmlForBasePath(html, "/myapp")
+ expect(result).toContain('content="/myapp/image.png"')
+ })
+
+ test("does NOT rewrite protocol-relative URLs", () => {
+ const html = 'Link'
+ const result = rewriteHtmlForBasePath(html, "/myapp")
+ expect(result).toContain('href="//cdn.example.com/file.js"')
+ })
+
+ test("does NOT rewrite relative paths without leading slash", () => {
+ const html = 'Link'
+ const result = rewriteHtmlForBasePath(html, "/myapp")
+ expect(result).toContain('href="page.html"')
+ })
+
+ test("injects basePath script before ", () => {
+ const html = "Test"
+ const result = rewriteHtmlForBasePath(html, "/myapp")
+ expect(result).toContain('window.__OPENCODE_BASE_PATH__="/myapp"')
+ expect(result).toContain("")
+ })
+
+ test("handles multiple attributes in same HTML", () => {
+ const html = ''
+ const result = rewriteHtmlForBasePath(html, "/prefix")
+ expect(result).toContain('href="/prefix/style.css"')
+ expect(result).toContain('src="/prefix/app.js"')
+ })
+ })
+
+ describe("rewriteJsForBasePath", () => {
+ test("returns unchanged JS when basePath is empty", () => {
+ const js = "const url = window.location.origin)"
+ expect(rewriteJsForBasePath(js, "")).toBe(js)
+ })
+
+ test("patches window.location.origin with closing paren", () => {
+ const js = "const url = :window.location.origin)"
+ const result = rewriteJsForBasePath(js, "/myapp")
+ expect(result).toContain(':window.location.origin+(window.__OPENCODE_BASE_PATH__||""))')
+ })
+
+ test("patches window.location.origin with semicolon (ternary ending)", () => {
+ const js = 'location.hostname.includes("opencode.ai")?"http://localhost:4096":window.location.origin;return'
+ const result = rewriteJsForBasePath(js, "/myapp")
+ expect(result).toContain(':window.location.origin+(window.__OPENCODE_BASE_PATH__||"");return')
+ })
+
+ test("patches Vite base path function", () => {
+ const js = 'function(t){return"/"+t}'
+ const result = rewriteJsForBasePath(js, "/myapp")
+ expect(result).toBe('function(t){return"/myapp/"+t}')
+ })
+
+ test("handles multiple Vite function occurrences", () => {
+ const js = 'function(t){return"/"+t};function(t){return"/"+t}'
+ const result = rewriteJsForBasePath(js, "/app")
+ expect(result).toBe('function(t){return"/app/"+t};function(t){return"/app/"+t}')
+ })
+
+ test("does NOT modify unrelated code", () => {
+ const js = 'const x = 1; function foo() { return "hello"; }'
+ const result = rewriteJsForBasePath(js, "/myapp")
+ expect(result).toBe(js)
+ })
+ })
+
+ describe("rewriteCssForBasePath", () => {
+ test("returns unchanged CSS when basePath is empty", () => {
+ const css = "background: url(/assets/bg.png);"
+ expect(rewriteCssForBasePath(css, "")).toBe(css)
+ })
+
+ test("rewrites url() with absolute paths", () => {
+ const css = "background: url(/assets/image.png);"
+ const result = rewriteCssForBasePath(css, "/myapp")
+ expect(result).toBe("background: url(/myapp/assets/image.png);")
+ })
+
+ test("rewrites multiple url() occurrences", () => {
+ const css = "@font-face { src: url(/fonts/a.woff); } .bg { background: url(/img/bg.png); }"
+ const result = rewriteCssForBasePath(css, "/prefix")
+ expect(result).toContain("url(/prefix/fonts/a.woff)")
+ expect(result).toContain("url(/prefix/img/bg.png)")
+ })
+
+ test("does NOT rewrite protocol-relative URLs", () => {
+ const css = "background: url(//cdn.example.com/image.png);"
+ const result = rewriteCssForBasePath(css, "/myapp")
+ expect(result).toBe("background: url(//cdn.example.com/image.png);")
+ })
+
+ test("does NOT rewrite relative paths", () => {
+ const css = "background: url(assets/image.png);"
+ const result = rewriteCssForBasePath(css, "/myapp")
+ expect(result).toBe("background: url(assets/image.png);")
+ })
+
+ test("does NOT rewrite data URIs", () => {
+ const css = "background: url(data:image/png;base64,abc123);"
+ const result = rewriteCssForBasePath(css, "/myapp")
+ expect(result).toBe("background: url(data:image/png;base64,abc123);")
+ })
+ })
+})