The 8.x dependency convergence was already done and building. What was left is
the half it cannot catch: @hanzo/gui accepts any prop and drops the ones it does
not recognise, so a gui 7 spelling type-checks, builds, ships, and does nothing.
Asked the renderer instead of the type-checker (scripts/gui-prop-probe.mjs
renders a prop and reads the host element and emitted class back out), which
settled the open `tag` vs `render` question the interrupted session left and
found four live defects:
tag="a" -> <div tag="a"> an inert link
style lineHeight: 1.1 -> line-height: 1.1px a ratio is not a length
- CloudflareModule's pages.dev/workers.dev URL chip and every ContactModule
channel card (mailto included) were <div>s. Both now render="a".
- PublicLanding's hero title, PitchHero's headline and the CodeSamples block
shipped line-height 1.1px/1.12px/1.6px — a wrapped title, and every line of a
code sample, drawn on one baseline. gui appends px to a bare number in `style`
as much as in a prop, so the ratio is now spelled as a string.
Because the type system provably cannot gate this class, the gate is the source
text: src/lib/gui8-props.ts holds the four verified rules and its suite runs them
over every file that imports gui (335 of them). Scoping by import is what keeps
it precise — a `{ tag: 'v1' }` image tag in a pure-logic module is out of scope
by construction, as are the rule module and its own fixtures.
Also: typescript stays on 5.x, and that is correct rather than a shortfall.
typescript@7.0.2 is genuinely the native Go compiler (its tsc is a statically
linked ELF from typescript-go/cmd/tsgo), but it ships only
{version, versionMajorMinor} on the main entry, while `next build` calls
ts.parseJsonConfigFileContent / ts.JsxEmit / ts.ModuleKind /
ts.ModuleResolutionKind — all undefined there. TS7 breaks Next exactly the way it
breaks tsup, for the same reason. @typescript/native-preview (7.0.0-dev, behind
stable) stays removed.
Verified: next build ✓ compiled successfully in 25.8s, 20/20 pages;
tsc --noEmit exit 0; vitest 2903 passed / 8 skipped (the pre-existing enso-bench
parity self-skip); build:embed ✓ static export ready, 30 handlers restored.
Zero tailwind/radix/shadcn, not even transitively.
(cherry picked from commit c9a4d92554fa8b8274801218a2c14a0044cf17bf)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 19e6d2e250)
267 lines
16 KiB
JavaScript
267 lines
16 KiB
JavaScript
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { dirname, join } from 'node:path'
|
|
|
|
import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
|
|
|
|
/**
|
|
* Hanzo Cloud Console — Next.js config.
|
|
*
|
|
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
|
|
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
|
|
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
|
|
* optimization, not a requirement.
|
|
*
|
|
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
|
|
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
|
|
* and both now ship. Adopting the compiler is therefore a live option — as an
|
|
* optimization to measure, not a correctness fix, so it is deliberately not bundled
|
|
* into the 8.x convergence.)
|
|
*
|
|
* `react-native` is aliased to `react-native-web` for the browser.
|
|
*
|
|
* The v5 config sets `onlyShorthandStyleProps`, so components use Gui shorthand
|
|
* props (p/px/items/justify/...). `tsc --noEmit` passes clean, so the build
|
|
* type-checks too (no error suppression).
|
|
*/
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
|
// Single source of the app version: package.json. Exposed to the browser as
|
|
// NEXT_PUBLIC_APP_VERSION so the shell can render the shared "Hanzo Cloud <MAJOR.MINOR>"
|
|
// product-release label (console app-semver; cloud ships its own Go v1.x under the
|
|
// same umbrella). No second place holds the version.
|
|
const pkgVersion = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version
|
|
|
|
/** Every installed `@hanzogui/*` package, discovered (not hardcoded). */
|
|
function guiPackages() {
|
|
const dir = join(__dirname, 'node_modules', '@hanzogui')
|
|
let scoped = []
|
|
try {
|
|
scoped = readdirSync(dir).map((name) => `@hanzogui/${name}`)
|
|
} catch {
|
|
scoped = []
|
|
}
|
|
// @hanzo/dash and @hanzo/data ship their screens/components as ESM/TSX
|
|
// source (no compiled dist), so the shared Base UI is transpiled here the same
|
|
// way Gui is. @hanzo/usage ships its <UsagePanel> (`/react`) as TSX source for the
|
|
// same reason (its headless core `.`/`/node` are compiled dist and pass through).
|
|
// @hanzo/ui (v8, the shared shell: AppHeader/BrandMark/OrgSwitcher/orgScope)
|
|
// also ships raw TS source.
|
|
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
|
|
}
|
|
|
|
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
|
|
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
|
|
|
|
/**
|
|
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
|
|
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
|
|
* `/v1/<head>/...`, never `/cloud/...`, `/ai/...` or `/api/...`.
|
|
*
|
|
* The DEFAULT terminus for a `/v1/<head>` call is the console's `app/v1/[...path]`
|
|
* catch-all bearer proxy (→ cloud-api `/v1/*`): it mints a short-lived user bearer
|
|
* from the session cookie and forwards it, so the raw cookie NEVER reaches cloud-api
|
|
* (no cookie-CSRF surface) and the org is server-authoritative. That handler needs
|
|
* NO rewrite — a clean `/v1/agents` falls straight through to it.
|
|
*
|
|
* These `beforeFiles` rewrites exist ONLY to DISPATCH the heads whose backend is NOT
|
|
* cloud-api to their own hardened same-origin proxy, while keeping the client URL a
|
|
* clean `/v1/...`:
|
|
* - AI gateway heads (models/chat/embeddings/rerank/… + pricing/plans) → `/ai`.
|
|
* - Admin AGGREGATE reads/writes (`/v1/admin/{overview,usage,…}`, the cross-tenant
|
|
* god view) → the GLOBAL-ADMIN-GATED `/admin/aggregate` proxy, which runs
|
|
* `getAdminGate` (fail-closed 403) BEFORE forwarding (RED H1). `admin/iam` +
|
|
* `admin/kms` are deliberately NOT rewritten — they keep their own gated proxies,
|
|
* reached by the client's explicit `/admin/*` origin path.
|
|
* - Visor compute CATALOG (regions/sizes, and `gpu-sizes` → visor `gpus`) → `/v1/vm`.
|
|
*
|
|
* (Per-tenant billing + commerce store DATA are NOT dispatched here — they are
|
|
* FILESYSTEM routes `app/v1/{billing,commerce}/[...path]`, more specific than the
|
|
* `/v1/[...path]` cloud BFF, so a clean `/v1/billing/*` · `/v1/commerce/*` resolves
|
|
* straight to them with no rewrite.)
|
|
*
|
|
* `beforeFiles` so a dispatched head wins over the `/v1` catch-all; the scope is the
|
|
* CLOSED head list each non-cloud client uses (a blanket `/v1/:path*` would shadow the
|
|
* cloud surface). Each destination handler STILL enforces its own least-privilege
|
|
* allow-list (`proxy-allow.ts`), so a rewrite can never widen what a proxy admits.
|
|
*/
|
|
// `pricing` (the rich model+provider CATALOG at `/v1/pricing/models`) and `plans` (the
|
|
// subscription tiers/entitlements) are AI-gateway-served like models/chat and are in the
|
|
// `/ai` proxy ALLOWED set (app/ai/[...path]), so they route to `/ai` too.
|
|
// `ai` is the AI Login Manager connections head (`/v1/ai/connections[/*]`) — routed to the
|
|
// `/ai` bearer proxy like the rest; it is NOT a cloud-api head (never shadows a cloud surface).
|
|
// `training` is the interactive (Tinker-style) engine head (`/v1/training/clients[/*]`) — the
|
|
// live LoRA client plane, allow-listed in the `/ai` proxy, likewise never a cloud-api head.
|
|
// `router` is the router-config head — `/v1/router/policy` (GET read + PUT write),
|
|
// `/v1/router/stats`, and `/v1/router/{defaults,ledger,rewards,artifact-meta}` — all served
|
|
// by hanzoai/ai; `get-/update-training-contribution` are the org's opt-in flag. The super-admin
|
|
// OrgSettings noun `/v1/org/settings` (GET/PUT/DELETE + `/v1/org/settings/list`) is routed by the
|
|
// TARGETED rewrites below rather than an `org` head, because a broad `org` head would hijack the
|
|
// platform `/v1/org/{org}/cluster` surface. All are in the `/ai` proxy ALLOWED set (app/ai/[...path]).
|
|
const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images', 'videos', 'pricing', 'plans', 'ai', 'training', 'router', 'get-training-contribution', 'update-training-contribution']
|
|
// The admin aggregate heads rewritten to the GLOBAL-ADMIN-GATED proxy. `providers`
|
|
// is the AI-provider control board — its GET (the list) AND its POST mutations
|
|
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
|
|
// which is method-agnostic (Next matches on the URL), so POST is covered without a
|
|
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
|
|
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes']
|
|
/**
|
|
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
|
|
* reviews/users) to a real cloud backend so `npm run dev` renders the
|
|
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
|
|
* the built image), so production is unchanged — there the console host's edge routes
|
|
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
|
|
* is forwarded by the rewrite, so the local dev session resolves against the real cloud.
|
|
*/
|
|
const DEV_CLOUD_ORIGIN = process.env.DEV_CLOUD_ORIGIN?.replace(/\/+$/, '')
|
|
const devCloudRewrites = () =>
|
|
DEV_CLOUD_ORIGIN
|
|
? [
|
|
{ source: '/v1/iam/:path*', destination: `${DEV_CLOUD_ORIGIN}/v1/iam/:path*` },
|
|
{ source: '/v1/o11y/:path*', destination: `${DEV_CLOUD_ORIGIN}/v1/o11y/:path*` },
|
|
]
|
|
: []
|
|
|
|
// Public compute CATALOG (regions / CPU sizes) → the same-origin visor proxy
|
|
// (`app/v1/vm/[...path]`). The GPU-accelerator catalog is the DISTINCT head `/v1/gpu-sizes`
|
|
// so it never collides with the cloud-api GPU INVENTORY at `/v1/gpus` (served by `/v1`).
|
|
const VM_V1_HEADS = ['regions', 'sizes']
|
|
|
|
const aiSurfaceRewrites = () => ({
|
|
beforeFiles: [
|
|
// Cloud-api heads (prompts/agents/automations/functions/framework/s3/vector/…) are
|
|
// NOT rewritten: a clean `/v1/<head>` falls through to the `app/v1/[...path]` bearer
|
|
// proxy → cloud-api `/v1/*`. Only the NON-cloud backends are dispatched below.
|
|
// Client builds a clean `/v1/<aihead>`; dispatch to the `/ai` bearer proxy WITHOUT a
|
|
// nested version in the target — `app/ai/[...path]` re-roots the upstream at `v1/`
|
|
// (`isAllowedAiPath`/the gateway see `v1/<aihead>`), so no nested version leaks anywhere.
|
|
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/ai/${h}` })),
|
|
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/${h}/:path*` })),
|
|
// Super-admin OrgSettings noun → the `/ai` bearer proxy (hanzoai/ai). TARGETED (not an
|
|
// `org` head) so it never shadows the platform `/v1/org/{org}/cluster|domain|…` surface,
|
|
// which keeps falling through to the `/v1` cloud BFF. Covers `/v1/org/settings` and
|
|
// `/v1/org/settings/list` (allow-listed in app/ai/[...path]).
|
|
{ source: `/v1/org/settings`, destination: `/ai/org/settings` },
|
|
{ source: `/v1/org/settings/:path*`, destination: `/ai/org/settings/:path*` },
|
|
// The SaaS-operations god-view is served by COMMERCE (the money SOT), NOT the
|
|
// cloud aggregate: route /v1/admin/saas to its OWN global-admin-gated commerce
|
|
// proxy (`app/admin/saas`). Placed before the aggregate map so it wins; `saas` is
|
|
// deliberately NOT in ADMIN_V1_HEADS (that list forwards to cloud /v1/admin/*).
|
|
{ source: `/v1/admin/saas`, destination: `/admin/saas` },
|
|
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}`, destination: `/admin/aggregate/${h}` })),
|
|
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}/:path*`, destination: `/admin/aggregate/${h}/:path*` })),
|
|
// Public compute catalog → the visor `app/v1/vm/[...path]` proxy (a bare `/v1/regions`
|
|
// dispatches to the /v1-first vm handler; the visor client also builds `/v1/vm/*` directly).
|
|
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/v1/vm/${h}` })),
|
|
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/v1/vm/${h}/:path*` })),
|
|
{ source: `/v1/gpu-sizes`, destination: `/v1/vm/gpus` },
|
|
// Per-tenant billing DATA + commerce store DATA need NO rewrite: they are FILESYSTEM
|
|
// routes `app/v1/billing/[...path]` (service token) and `app/v1/commerce/[...path]`
|
|
// (user bearer), each MORE SPECIFIC than the `app/v1/[...path]` cloud BFF, so a clean
|
|
// `/v1/billing/*` · `/v1/commerce/*` resolves straight to them (the /v1-first law).
|
|
...devCloudRewrites(),
|
|
],
|
|
})
|
|
|
|
/**
|
|
* EMBED MODE (the "True 1-binary FE" target — task #41).
|
|
*
|
|
* `npm run build:embed` sets CONSOLE_EMBED=1 and produces a STATIC EXPORT (`out/`)
|
|
* that the hanzoai/cloud Go binary go:embeds and serves at its own web root. In this
|
|
* mode the console SPA is same-origin with the cloud `/v1` API (config.cloudUrl
|
|
* defaults to window.location.origin), so:
|
|
*
|
|
* - `output: 'export'` — emit a pure static bundle, no Node server.
|
|
* - NO `rewrites` — a static export cannot run rewrites, and it does not need
|
|
* them: the clean `/v1/<head>` calls the SPA already builds now terminate
|
|
* DIRECTLY at the embedded cloud's mounted subsystems (prompts/agents/evals/…,
|
|
* models/chat/embeddings/…, admin/*), which is exactly what the rewrites/`app/v1`
|
|
* proxy forward to via the Next BFF. The BFF proxy routes (app/v1, app/ai,
|
|
* app/commerce, …) are the server, and in one-binary the cloud binary IS the
|
|
* server — so they are simply absent from the export (see below).
|
|
* - `images.unoptimized` — the export has no Image Optimization server.
|
|
*
|
|
* PRECONDITION for a clean `output:'export'`: the app/ tree must contain NO dynamic
|
|
* server route handlers (a static export has no server runtime to run them).
|
|
* Those handlers are the BFF proxies + the two standalone routes; the latter
|
|
* (keys/onboard) are ported to cloud `/v1/iam/{keys,onboard}`, and the proxies collapse to
|
|
* the cloud `/v1/*` the SPA calls directly. The embed build therefore runs against
|
|
* a tree with every app route handler removed (the build:embed script prunes the
|
|
* "route" files into a scratch stash so the server build on `main` is untouched).
|
|
*
|
|
* The normal `npm run build` is UNCHANGED (server build with rewrites) so nothing
|
|
* regresses for the standalone console deployment during the transition.
|
|
*/
|
|
const EMBED = process.env.CONSOLE_EMBED === '1'
|
|
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
reactStrictMode: true,
|
|
// Deterministic per-commit build id (see src/config/build-id.mjs): pins ONE id
|
|
// across every replica of a release so a rolling deploy never serves one build's
|
|
// HTML against another build's /_next/static/<BUILD_ID>/ path — the chunk-404 the
|
|
// live audit hit. SOURCE_COMMIT (CI build-arg) -> git HEAD -> package version.
|
|
generateBuildId: () => resolveBuildId({ env: process.env, gitSha: readGitSha(__dirname), version: pkgVersion }),
|
|
env: { NEXT_PUBLIC_APP_VERSION: pkgVersion },
|
|
// @hanzo/usage is transpiled (see guiPackages) so its source <UsagePanel> (`/react`)
|
|
// compiles in the client bundle; its headless `.` entry (used by the /ai-accounts
|
|
// server routes) carries no node built-ins, so it needs no server-external treatment.
|
|
transpilePackages: guiPackages(),
|
|
...(EMBED
|
|
? { output: 'export', images: { unoptimized: true } }
|
|
: { rewrites: aiSurfaceRewrites }),
|
|
experimental: {
|
|
esmExternals: true,
|
|
},
|
|
webpack(config, { webpack }) {
|
|
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
|
|
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
|
|
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
|
|
// package. Whatever resolves the directory therefore parses that file as ESM: the
|
|
// `export *` chain goes opaque ("'defaultConfig' is not exported from
|
|
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
|
|
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
|
|
// relative to `.next/server/chunks/`, not to the package).
|
|
//
|
|
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
|
|
// the RESOLVED file, so it holds however the request got there — and costs nothing
|
|
// the day the shims stop shipping.
|
|
config.plugins.push(
|
|
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
|
|
const resource = data.createData?.resource
|
|
if (!resource) return
|
|
const shim = GUI_SUBPATH_SHIM.exec(resource)
|
|
if (!shim) return
|
|
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
|
|
if (!existsSync(esm)) return
|
|
// The module's CONTEXT must move with it, or its own relative imports
|
|
// (`./v5-base.mjs`) keep resolving against the shim directory.
|
|
data.createData.resource = esm
|
|
data.createData.userRequest = esm
|
|
data.createData.context = dirname(esm)
|
|
data.context = dirname(esm)
|
|
}),
|
|
)
|
|
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
|
|
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
|
|
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
|
|
// types. Resolving the realpath would load a second copy and break theme context.
|
|
config.resolve.symlinks = false
|
|
config.resolve.alias = {
|
|
...config.resolve.alias,
|
|
'react-native$': 'react-native-web',
|
|
}
|
|
// Gui flags the platform via this define; web build.
|
|
config.resolve.extensions = [
|
|
'.web.tsx',
|
|
'.web.ts',
|
|
'.web.jsx',
|
|
'.web.js',
|
|
...config.resolve.extensions,
|
|
]
|
|
return config
|
|
},
|
|
}
|
|
|
|
export default nextConfig
|