fix: build a mount-independent artifact with runtime config

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-09-04 13:15:31 +05:30
parent ccb6993d69
commit 0222f68691
8 changed files with 63 additions and 13 deletions

View File

@ -3,11 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/zino.svg" />
<!--BASE_HREF-->
<link rel="icon" type="image/svg+xml" href="zino.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>P2P</title>
<!-- Written by frontgen at placement time; absent under `npm run dev`,
where resolveConfigValue falls back to import.meta.env. -->
<script src="./config.js"></script>
</head>
<body>
<div id="root"></div>

View File

@ -1,9 +1,10 @@
import { APP_ID } from "../config";
import { resolveConfigValue, basePath } from "../runtimeConfig";
const TOKEN_KEY = "p2p_token";
function baseUrl(): string {
return (import.meta.env.VITE_ZINO_API_URL || "https://studio.getzino.in").replace(/\/+$/, "");
return resolveConfigValue("VITE_ZINO_API_URL", "https://studio.getzino.in").replace(/\/+$/, "");
}
function headers(): Record<string, string> {
@ -19,7 +20,7 @@ async function parseResponse<T>(res: Response): Promise<T> {
if (res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem("p2p_user");
window.location.href = `${import.meta.env.BASE_URL}login`;
window.location.href = `${basePath()}login`;
throw new Error("Unauthorized");
}
if (!res.ok) {
@ -622,7 +623,7 @@ export async function downloadFieldFile(blobPath: string, originalName?: string)
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
window.location.href = `${import.meta.env.BASE_URL}login`;
window.location.href = `${basePath()}login`;
}
throw new Error(`Could not load the document (${res.status})`);
}

View File

@ -1,4 +1,5 @@
import { useNavigate } from "react-router-dom";
import { basePath } from "@/runtimeConfig";
import {
FileText,
HelpCircle,
@ -151,7 +152,7 @@ export default function DynamicHeader({
here would double it up. */}
{sidebar.isMobile && (
<img
src={import.meta.env.BASE_URL + "zino.svg"}
src={basePath() + "zino.svg"}
alt="Zino"
className="h-5 w-auto dark:brightness-0 dark:invert"
/>
@ -270,7 +271,7 @@ function SidebarBody({
{/* 43×20 wordmark — fits the 56px rail as-is, so it needs no separate
collapsed mark. Inverted in dark mode like the top-bar copy. */}
<img
src={import.meta.env.BASE_URL + "zino.svg"}
src={basePath() + "zino.svg"}
alt="Zino"
className="h-5 w-auto shrink-0 dark:brightness-0 dark:invert"
/>

View File

@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import type { User } from "../zino-sdk/types";
import { resolveConfigValue } from "../runtimeConfig";
const TOKEN_KEY = "p2p_token";
const USER_KEY = "p2p_user";
@ -31,7 +32,7 @@ export function useAuth() {
});
const apiUrl = (
import.meta.env.VITE_ZINO_API_URL || "https://studio.getzino.in"
resolveConfigValue("VITE_ZINO_API_URL", "https://studio.getzino.in")
).replace(/\/+$/, "");
const login = useCallback(

View File

@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'
import { ZinoProvider } from './zino-sdk'
import { resolveConfigValue, basePath } from './runtimeConfig'
import { AuthProvider } from './hooks/AuthContext'
import { ThemeProvider } from './hooks/ThemeContext'
import { TooltipProvider } from './components/ui/tooltip'
@ -10,13 +11,15 @@ import { Toaster } from './components/ui/sonner'
import './index.css'
import App from './App'
const apiUrl = import.meta.env.VITE_ZINO_API_URL || 'https://studio.getzino.in'
const apiUrl = resolveConfigValue('VITE_ZINO_API_URL', 'https://studio.getzino.in')
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<ZinoProvider baseUrl={apiUrl}>
<BrowserRouter basename={import.meta.env.BASE_URL}>
{/* Not import.meta.env.BASE_URL — with a relative Vite base that is
'./'. The mount path comes from the document's <base href>. */}
<BrowserRouter basename={basePath()}>
{/* NuqsAdapter must sit inside the router — it reads and writes the
router's search params, so table state can live in the URL. */}
<NuqsAdapter>

View File

@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuthContext } from "@/hooks/AuthContext";
import { ORG_ID } from "@/config";
import { basePath } from "@/runtimeConfig";
const HIGHLIGHTS = [
{
@ -76,7 +77,7 @@ export default function Login() {
</div>
<img
src={import.meta.env.BASE_URL + "zino.svg"}
src={basePath() + "zino.svg"}
alt="Zino"
className="absolute left-16 top-10 z-10 h-5 w-auto brightness-0 invert"
/>
@ -118,7 +119,7 @@ export default function Login() {
<div className="flex flex-1 items-center justify-center px-6">
<div className="w-full max-w-[340px]">
<img
src={import.meta.env.BASE_URL + "zino.svg"}
src={basePath() + "zino.svg"}
alt="Zino"
className="mb-10 h-5 w-auto dark:invert lg:hidden"
/>
@ -201,7 +202,7 @@ export default function Login() {
<div className="mt-10 flex items-center justify-center gap-1.5">
<span className="text-xs text-muted-foreground">Powered by</span>
<img
src={import.meta.env.BASE_URL + "zino.svg"}
src={basePath() + "zino.svg"}
alt="Zino"
className="h-3.5 w-auto dark:invert"
/>

36
src/runtimeConfig.ts Normal file
View File

@ -0,0 +1,36 @@
// Runtime configuration, per frontgen's placement contract.
//
// A deployed artifact carries no environment values. When frontgen places a
// build it writes config.js next to index.html and rewrites the
// <!--BASE_HREF--> placeholder into a real <base href>. That is what lets one
// artifact serve any mount path — /p2p/ here, something else after promotion —
// without being rebuilt. See sm2/frontgen internal/deploy/place.go and the
// checks in internal/builder/assert.go.
declare global {
interface Window {
__RUNTIME_CONFIG__?: Record<string, string>;
}
}
// SAME_ORIGIN is what placement writes for a custom-domain mount, where the
// platform is proxied under the serving host. An empty string could not express
// it — it is falsy, so it would fall through the || chain below.
export function resolveConfigValue(key: string, fallback = ""): string {
const runtime = (window.__RUNTIME_CONFIG__ || {})[key];
const devFallback = import.meta.env.DEV ? (import.meta.env[key] as string) : "";
const value = runtime || devFallback || fallback;
return value === "SAME_ORIGIN" ? window.location.origin.replace(/\/$/, "") : value;
}
// The mount path this app is served under, read from the <base href> placement
// writes. Drives the router basename and every public-asset URL.
//
// The <base> guard is for `npm run dev`, where the placeholder is still an HTML
// comment: without it document.baseURI is just the current page URL, so a deep
// link like /invoices/12 would yield "/invoices/12" as the basename and the
// router would match nothing.
export function basePath(): string {
if (!document.querySelector("base")) return "/";
return new URL(document.baseURI).pathname;
}

View File

@ -11,7 +11,10 @@ export default defineConfig({
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
base: process.env.VITE_BASE_URL ?? '/p2p/',
// Relative, never a mount path: frontgen's placement step writes the real
// <base href> into index.html, so one artifact serves any mount without a
// rebuild. An absolute base fails internal/builder/assert.go.
base: './',
server: {
port: 3007,
proxy: {