From d1d673559688ad6464759a9d7046530e8f56233a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 01:19:51 +0200 Subject: [PATCH] fix(ui): survive an unset BUN_PUBLIC_BASE_PATH in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun inlines a literal `process.env.FOO` reference only when FOO is set. With it unset — a bare `bun --hot src/index.ts`, which is what the README tells you to run — the reference survived into the client bundle and the app died on load with `ReferenceError: process is not defined`, before rendering anything. Production builds were unaffected, because build.ts passes an explicit define. Reading the value now goes through readBasePathEnv(), which catches. Catching rather than a `typeof process` guard is deliberate: after inlining the literal becomes a string, but a typeof test would still run in the browser and discard that inlined value, silently breaking every subpath deploy. Verified all four paths: dev unset (reference survives, caught, base "/"), dev set (inlined), and root + subpath production builds (inlined by define). Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/basePath.test.ts | 17 ++++++++++++++++- src/ui/basePath.ts | 24 +++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/ui/basePath.test.ts b/src/ui/basePath.test.ts index 0f102ba..5d8c088 100644 --- a/src/ui/basePath.test.ts +++ b/src/ui/basePath.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { withBase, normalizeBase } from "./basePath.ts"; +import { withBase, normalizeBase, readBasePathEnv } from "./basePath.ts"; test("normalizeBase defaults to root", () => { expect(normalizeBase(undefined)).toBe("/"); @@ -21,3 +21,18 @@ test("withBase joins without doubling slashes", () => { ); expect(withBase("/sw.js", "/")).toBe("/sw.js"); }); + +// Regression: Bun only inlines `process.env.FOO` when the variable is SET. When +// it is unset (e.g. a bare `bun --hot src/index.ts`), the reference survives +// into the browser bundle and throws `ReferenceError: process is not defined` +// before the app can boot. Reading the value must therefore tolerate `process` +// not existing at all. +test("reading the base path env does not throw when process is undefined", () => { + const saved = (globalThis as { process?: unknown }).process; + try { + delete (globalThis as { process?: unknown }).process; + expect(readBasePathEnv()).toBeUndefined(); + } finally { + (globalThis as { process?: unknown }).process = saved; + } +}); diff --git a/src/ui/basePath.ts b/src/ui/basePath.ts index 3da17f7..58c6efe 100644 --- a/src/ui/basePath.ts +++ b/src/ui/basePath.ts @@ -6,10 +6,28 @@ export function normalizeBase(raw: string | undefined): string { } /** - * Bun substitutes process.env.BUN_PUBLIC_* into client code at build time, so - * this becomes a literal string in the bundle. + * Reads the build-time base path. + * + * Bun inlines a literal `process.env.FOO` reference ONLY when FOO is actually + * set. When it is unset — a bare `bun --hot src/index.ts`, say — the reference + * survives into the browser bundle, where `process` does not exist, and the app + * dies with `ReferenceError: process is not defined` before it can boot. + * + * try/catch rather than a `typeof process` guard, deliberately: after inlining + * the literal becomes a string, but a `typeof process === "undefined"` test + * would still run in the browser and discard that inlined value, silently + * breaking every subpath deploy. Catching leaves the inlined value untouched + * and only absorbs the unset case. */ -export const BASE_PATH = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH); +export function readBasePathEnv(): string | undefined { + try { + return process.env.BUN_PUBLIC_BASE_PATH; + } catch { + return undefined; + } +} + +export const BASE_PATH = normalizeBase(readBasePathEnv()); /** react-router wants the basename without a trailing slash ("" for root). */ export const ROUTER_BASENAME = BASE_PATH === "/" ? "" : BASE_PATH.slice(0, -1);