fix(id): emit per-brand brand.json in build (ESM require) + harden loadBrand

Root cause of the post-cutover white-screen: vite.config.ts is loaded as a
native ESM module where `require` is undefined, so the brandJsonPlugin's
`require.resolve('<pkg>/brand.json')` threw and the silent catch skipped
emitting EVERY brand.json. The SPA then served index.html (HTTP 200,
text/html) for /brand/<pkg>/brand.json, loadBrand did res.json() on HTML,
threw "Unexpected token '<'", and App's catch rendered that error as the
whole page — no login form ever mounted.

- vite.config.ts: build a createRequire(import.meta.url) resolver so the
  brand.json subpath resolves under ESM. Build now emits all four
  dist/brand/<pkg>/brand.json (verified: @hanzo brand.name = "Hanzo").
- brand.ts: read the response as text and JSON.parse explicitly; on
  non-JSON throw a precise diagnostic instead of a cryptic SyntaxError,
  and require the "brand" key.
- apps/web 0.1.8 -> 0.1.9.
This commit is contained in:
zooqueen
2026-06-19 10:39:53 -07:00
parent 8f3f605bea
commit f23ab3d670
3 changed files with 21 additions and 4 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.8",
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"version": "0.1.9",
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"type": "module",
"scripts": {
"dev": "vite",
+6
View File
@@ -2,6 +2,12 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
import { readFileSync, existsSync } from 'fs'
import { createRequire } from 'module'
// Vite loads this config as a native ESM module, where `require` is undefined.
// Build a CJS-style resolver bound to this file so `<pkg>/brand.json` subpath
// resolution works in both `configureServer` and `generateBundle`.
const require = createRequire(import.meta.url)
/**
* Per-brand brand.json copy plugin.
+13 -2
View File
@@ -19,8 +19,19 @@ export async function loadBrand(brandPackage: string): Promise<BrandContract> {
const url = `/brand/${encodeURIComponent(brandPackage)}/brand.json`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`brand.json fetch failed: ${res.status} for ${brandPackage}`)
const raw = await res.json()
return raw.brand as BrandContract
// The SPA server returns index.html (HTTP 200, text/html) for unknown
// paths, so a missing brand.json yields HTML, not JSON. Read as text and
// parse explicitly so we throw a precise error instead of a cryptic
// "Unexpected token '<'" that whites-out the whole portal.
const text = await res.text()
let raw: { brand?: BrandContract }
try {
raw = JSON.parse(text) as { brand?: BrandContract }
} catch {
throw new Error(`brand.json for ${brandPackage} is not JSON (got ${res.headers.get('content-type') ?? 'unknown'}); is /brand/<pkg>/brand.json served?`)
}
if (!raw.brand) throw new Error(`brand.json for ${brandPackage} has no "brand" key`)
return raw.brand
}
// Node: dynamic import (build step + SSR fallback)
const mod = (await import(/* @vite-ignore */ `${brandPackage}/brand.json`, {