diff --git a/lib/cmd/vite/generate-kiln-edit.js b/lib/cmd/vite/generate-kiln-edit.js index 7b2f65f..6188a27 100644 --- a/lib/cmd/vite/generate-kiln-edit.js +++ b/lib/cmd/vite/generate-kiln-edit.js @@ -70,6 +70,36 @@ async function generateViteKilnEditEntry() { // If neither is present, fall back to the namespace itself. lines.push(''); lines.push('function _resolveDefault(ns) { return (ns && ns.default !== undefined) ? ns.default : ns; }'); + lines.push('function _serializeKilnInitError(error) {'); + lines.push(' if (error == null) return { message: "Unknown error" };'); + lines.push(' if (typeof error === "string") return { message: error };'); + lines.push(' if (typeof error !== "object") return { message: String(error) };'); + lines.push(''); + lines.push(' var payload = {'); + lines.push(' name: error.name || null,'); + lines.push(' message: error.message || String(error),'); + lines.push(' stack: error.stack || null,'); + lines.push(' };'); + lines.push(''); + lines.push(' if (error.code != null) payload.code = error.code;'); + lines.push(' if (error.cause != null) payload.cause = _serializeKilnInitError(error.cause);'); + lines.push(''); + lines.push(' return payload;'); + lines.push('}'); + lines.push(''); + lines.push('function _reportKilnInitError(error) {'); + lines.push(' var payload = _serializeKilnInitError(error);'); + lines.push(' var message = payload.message || "Unknown error";'); + lines.push(' var header = "[clay vite] kiln plugin init error: " + message;'); + lines.push(''); + lines.push(' if (console.groupCollapsed) {'); + lines.push(' console.groupCollapsed(header);'); + lines.push(' console.error(payload);'); + lines.push(' console.groupEnd();'); + lines.push(' } else {'); + lines.push(' console.error(header, payload);'); + lines.push(' }'); + lines.push('}'); lines.push(''); lines.push('window.kiln = window.kiln || {};'); @@ -94,7 +124,16 @@ async function generateViteKilnEditEntry() { // Run site kiln plugin initializer if (hasKilnPlugin) { lines.push('var _initKilnPlugins = _resolveDefault(_kilnPluginNs);'); - lines.push('if (typeof _initKilnPlugins === "function") _initKilnPlugins();'); + lines.push('if (typeof _initKilnPlugins === "function") {'); + lines.push(' try {'); + lines.push(' var _kilnInitResult = _initKilnPlugins();'); + lines.push(' if (_kilnInitResult && typeof _kilnInitResult.then === "function") {'); + lines.push(' _kilnInitResult.catch(_reportKilnInitError);'); + lines.push(' }'); + lines.push(' } catch (error) {'); + lines.push(' _reportKilnInitError(error);'); + lines.push(' }'); + lines.push('}'); } await fs.ensureDir(CLAY_DIR); diff --git a/lib/cmd/vite/generators.test.js b/lib/cmd/vite/generators.test.js index caa3831..10ba2d5 100644 --- a/lib/cmd/vite/generators.test.js +++ b/lib/cmd/vite/generators.test.js @@ -169,7 +169,9 @@ describe('generate-vite env/bootstrap/kiln generators', () => { expect(content).toContain('import * as _kilnPluginNs from "../services/kiln/index.js";'); expect(content).toContain('window.kiln.componentModels["article"] = _resolveDefault(_m0);'); expect(content).toContain('window.kiln.componentKilnjs["article"] = _resolveDefault(_k0);'); - expect(content).toContain('if (typeof _initKilnPlugins === "function") _initKilnPlugins();'); + expect(content).toContain('function _reportKilnInitError(error) {'); + expect(content).toContain('var _kilnInitResult = _initKilnPlugins();'); + expect(content).toContain('_kilnInitResult.catch(_reportKilnInitError);'); }); it('does not import kiln plugin when services/kiln/index.js is missing', async () => { diff --git a/lib/cmd/vite/index.js b/lib/cmd/vite/index.js index 13408db..a60a56c 100644 --- a/lib/cmd/vite/index.js +++ b/lib/cmd/vite/index.js @@ -95,7 +95,7 @@ function getEditScripts(assetPath) { * Populate media.moduleScripts and media.modulePreloads for amphora-html. * * In view mode: one bootstrap URL. - * In edit mode: kiln edit bundle + bootstrap. + * In edit mode: bootstrap + kiln edit bundle. * * @param {object} media * @param {string} assetPath @@ -113,7 +113,9 @@ function resolveModuleScripts(media, assetPath, options) { if (edit) { const editScripts = getEditScripts(assetPath); - media.moduleScripts = [...editScripts, ...viewScripts]; + // Keep bootstrap first so shared env/bootstrap globals are available + // before the kiln edit entry executes. + media.moduleScripts = [...viewScripts, ...editScripts]; media.modulePreloads = preloadEditBundle ? [...preloads, ...editScripts] : preloads; diff --git a/lib/cmd/vite/index.test.js b/lib/cmd/vite/index.test.js index 69806cc..eba7709 100644 --- a/lib/cmd/vite/index.test.js +++ b/lib/cmd/vite/index.test.js @@ -164,7 +164,7 @@ describe('index — view mode scripts', () => { // ── edit mode scripts ───────────────────────────────────────────────────────── describe('index — edit mode scripts', () => { - it('includes both kiln edit bundle and bootstrap in edit mode', () => { + it('includes bootstrap first, then kiln edit bundle in edit mode', () => { const { mod, cleanup } = withManifest({ [BOOTSTRAP_KEY]: { file: '/js/bootstrap-abc.js', imports: [] }, [KILN_EDIT_KEY]: { file: '/js/kiln-edit-xyz.js', imports: ['/js/vue-chunk.js'] }, @@ -174,8 +174,10 @@ describe('index — edit mode scripts', () => { mod.resolveModuleScripts(media, '', { edit: true }); cleanup(); - expect(media.moduleScripts).toContain('/js/kiln-edit-xyz.js'); expect(media.moduleScripts).toContain('/js/bootstrap-abc.js'); + expect(media.moduleScripts).toContain('/js/kiln-edit-xyz.js'); + expect(media.moduleScripts.indexOf('/js/bootstrap-abc.js')) + .toBeLessThan(media.moduleScripts.indexOf('/js/kiln-edit-xyz.js')); }); it('still serves view scripts when kiln entry is absent from manifest', () => { diff --git a/lib/cmd/vite/scripts.js b/lib/cmd/vite/scripts.js index 223a56c..646ceeb 100644 --- a/lib/cmd/vite/scripts.js +++ b/lib/cmd/vite/scripts.js @@ -178,6 +178,54 @@ function getViteConfig(cliOptions = {}) { exports.getViteConfig = getViteConfig; +/** + * Build the `process.env` define that redirects client env reads to the + * RUNTIME-hydrated `window.process.env`. + * + * This is the Vite equivalent of the legacy Browserify pipeline's transformEnv + * plugin (lib/cmd/compile/scripts.js), which rewrites every `process.env` in + * client code to `window.process.env` and inlines NO values ("reference window, + * so browserify doesn't bundle in `process`"). + * + * ── Why a redirect, not an inline ─────────────────────────────────────────── + * + * Vite does not inline arbitrary env, and @rollup/plugin-commonjs rewrites the + * bare `process.env` of a CJS module into a build-time-empty local stub + * (literally `var pVt = {}`). So `const HOST = process.env.PYXIS_HOST || '…'` + * compiles to `pVt.PYXIS_HOST || '…'` → always the fallback, and the read is dead + * at build time. That silently breaks every Kiln plugin / universal service that + * reads env (pyxis.js, glaze/agora helpers, …): they work in Browserify but not + * under Vite. + * + * Defining `process.env` → `window.process.env` makes `process.env.PYXIS_HOST` + * compile to `window.process.env.PYXIS_HOST` — a live read of the object + * .clay/_env-init.js hydrates from `window.kiln.preloadData._envVars` (amphora-html + * forwards the server's live env in edit mode, gated by client-env.json). It also + * turns master's `window.process.env.X = process.env.X` plugin line into a harmless + * self-assign instead of clobbering the hydrated value with "". + * + * We deliberately do NOT inline values from the build env. The Docker image build + * is bare (only CLAYCLI_* args), so there is nothing to inline in prod anyway; and + * inlining from the full build `process.env` would bake any referenced var — + * including secrets that are NOT on the client-env.json allowlist (e.g. + * CORAL_GRAPHQL_TOKEN) — into the public bundle on any env-present build (local + * `make assets`, future build-args, …). The runtime object is allowlist-gated, so + * a pure redirect keeps Vite's client-env exposure identical to Browserify's. + * + * `process.env.NODE_ENV` is kept as a build-time literal by buildDefines (a + * more-specific key, so it wins over this catch-all) → `process.env.NODE_ENV === + * 'production'` still tree-shakes. + * + * @returns {object} `{ 'process.env': 'window.process.env' }` + */ +function buildClientEnvDefines() { + // A member-expression value (not a JSON literal) tells esbuild/Vite to REWRITE + // the `process.env` token to `window.process.env` rather than substitute a + // constant. `window.process.env` on the left-hand side of an assignment is left + // alone (its `process` is a member of `window`, not the free `process` token). + return { 'process.env': 'window.process.env' }; +} + /** * Build the define map injected into every module at compile time. * @@ -222,10 +270,19 @@ function buildDefines(userDefines = {}) { // of window. globalThis is universally available in ES2017+ environments. global: 'globalThis', }, + // Client env (see buildClientEnvDefines): redirect `process.env` → + // `window.process.env` so reads resolve to the runtime-hydrated object + // (.clay/_env-init.js), matching the legacy Browserify transformEnv plugin — + // no build-time values are inlined. `process.env.NODE_ENV` above is more + // specific and still wins as a literal. Comes before userDefines so a site + // can still override a specific var via bundlerConfig().define if needed. + buildClientEnvDefines(), userDefines ); } +exports.buildDefines = buildDefines; + /** * Assemble the Vite plugin array for a build pass. * diff --git a/lib/cmd/vite/scripts.test.js b/lib/cmd/vite/scripts.test.js index 7d4e07e..6bec020 100644 --- a/lib/cmd/vite/scripts.test.js +++ b/lib/cmd/vite/scripts.test.js @@ -80,6 +80,52 @@ describe('vite scripts', () => { expect(cfg.sourcemap).toBe(true); }); + it('buildDefines redirects process.env to window.process.env and never inlines build env (Browserify parity)', async () => { + await setupTmp('claycli-vite-scripts-clientenv-'); + + const prev = process.env.PYXIS_HOST; + + // Even when the build env holds a value, it must NOT be baked in: the Docker + // image build is bare, and inlining from the full build env would leak vars + // outside the client-env.json allowlist into the public bundle. Reads resolve + // at runtime via window.process.env (hydrated by .clay/_env-init.js), exactly + // like the legacy Browserify transformEnv plugin. This also turns master's + // `window.process.env.X = process.env.X` into a harmless self-assign. + process.env.PYXIS_HOST = 'https://build-env-should-not-be-inlined.test'; + + try { + const { buildDefines } = require('./scripts'); + const define = buildDefines(); + + expect(define['process.env']).toBe('window.process.env'); + expect(define['process.env.PYXIS_HOST']).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.PYXIS_HOST; + else process.env.PYXIS_HOST = prev; + } + }); + + it('buildDefines keeps NODE_ENV a build-time literal so guards still tree-shake', async () => { + await setupTmp('claycli-vite-scripts-nodeenv-'); + + const prev = process.env.NODE_ENV; + + process.env.NODE_ENV = 'production'; + + try { + const { buildDefines } = require('./scripts'); + const define = buildDefines(); + + // The more-specific `process.env.NODE_ENV` key wins over `process.env` in + // esbuild, so `process.env.NODE_ENV === 'production'` folds to a literal. + expect(define['process.env.NODE_ENV']).toBe(JSON.stringify('production')); + expect(define['process.env']).toBe('window.process.env'); + } finally { + if (prev === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prev; + } + }); + it('buildJS runs split builds, writes manifest and client-env', async () => { await setupTmp('claycli-vite-scripts-build-');