Skip to content

Commit c6d5611

Browse files
committed
use plugin for client env file and save more time plus update docs and tests
1 parent d796b14 commit c6d5611

5 files changed

Lines changed: 443 additions & 82 deletions

File tree

BUNDLER-COMPARISON.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ Vite was chosen specifically because it is the default integration point for:
372372
| Browserify + Gulp | 30–60 s | 90–120 s |
373373
| esbuild | ~3 s | ~33 s |
374374
| Rollup + esbuild | ~40 s | ~70 s |
375-
| **Vite** | **~30 s** | **~60 s** |
375+
| **Vite** | **~30 s** | **~30 s** (client-env now free via Rollup plugin) |
376376

377377
Vite is faster than bare Rollup because its `optimizeDeps` pass converts `node_modules`
378378
CJS to ESM before Rollup sees them, reducing the number of modules `@rollup/plugin-commonjs`

CLAY-VITE.md

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
8. [Code References](#8-code-references)
2121
- [Why `_globals-init.js` exists as a separate file](#why-_globals-initjs-exists-as-a-separate-file)
2222
- [Why the build runs in two passes](#why-the-build-runs-in-two-passes)
23+
- [How client-env.json is generated](#how-client-envjson-is-generated)
2324
9. [Performance](#9-performance)
2425
10. [Learning Curve](#10-learning-curve)
2526
11. [For Product Managers](#11-for-product-managers)
@@ -168,8 +169,8 @@ clay vite
168169
├── media.js ← fs-extra copy
169170
│ └── components/**/media/* → public/media/
170171
171-
└── client-env.json ← generated by generateClientEnv()
172-
└── scans source files for process.env.VAR references → client-env.json
172+
└── client-env.json ← generated by viteClientEnvPlugin (createClientEnvCollector)
173+
└── collected as a side-effect of the Rollup transform pass — no extra file I/O
173174
(required by amphora-html's addEnvVars() at render time)
174175
```
175176

@@ -621,7 +622,7 @@ RUN if [ "$CLAYCLI_VITE_ENABLED" = "true" ]; then \
621622
| `.clay/vite-bootstrap.js` | `generate-bootstrap.js` | Imports every `client.js`; mounts via dynamic `import()` on DOM presence |
622623
| `.clay/_kiln-edit-init.js` | `generate-kiln-edit.js` | Imports every `model.js` + `kiln.js`; registers on `window.kiln.componentModels` |
623624
| `.clay/_globals-init.js` | `generate-globals-init.js` | Imports all `global/js/*.js` into one non-splitting entry |
624-
| `client-env.json` | `generateClientEnv()` | JSON array of `process.env.VAR_NAME` identifiers for `amphora-html` |
625+
| `client-env.json` | `createClientEnvCollector` (Rollup plugin) | JSON array of `process.env.VAR_NAME` identifiers for `amphora-html` |
625626

626627
> **Why `.clay/` exists — and why the old pipeline didn't need it**
627628
>
@@ -754,6 +755,68 @@ final state after full ESM migration.
754755

755756
---
756757

758+
### How client-env.json is generated
759+
760+
`client-env.json` is a JSON array of `process.env.VAR_NAME` identifiers that `amphora-html`
761+
reads at server startup. For every name in the array, `amphora-html` injects
762+
`window.process.env.VAR = process.env.VAR` into the rendered page so that browser code
763+
can read it at runtime.
764+
765+
#### The old way — grep scan (~30 seconds)
766+
767+
The previous implementation (`generateClientEnv`) ran a full file-system scan on every
768+
build: glob-expand every `.js` and `.vue` file under `components/`, `layouts/`, `services/`,
769+
`global/`, and `amphora/`, then `fs.readFile` each one sequentially, regex-match for
770+
`process\.env\.([A-Z_][A-Z0-9_]*)`, and write the result.
771+
772+
Two problems:
773+
1. **Speed** — O(N files), sequential I/O, ~30s standalone step on top of an already
774+
30-second JS build.
775+
2. **Scope** — it scanned *all* source files, including server-only utilities and
776+
`model.js` hooks that call external APIs. Server secrets like `STRIPE_API_SECRET`
777+
ended up in `client-env.json` even though no browser code ever reads them, creating
778+
unnecessary exposure in edit mode via `window.process.env`.
779+
780+
#### The new way — Rollup transform hook (effectively free)
781+
782+
`createClientEnvCollector` in `lib/cmd/vite/plugins/client-env.js` returns a pair:
783+
784+
- **`plugin()`** — a Rollup plugin whose `transform` hook scans each module as Rollup
785+
already processes it. No extra file I/O — the source is already in memory.
786+
- **`write()`** — flushes the accumulated Set to `client-env.json` after all passes complete.
787+
788+
Both the view pass and the kiln pass receive their own plugin instance from the same
789+
collector, so a single `write()` call covers `process.env` references from both
790+
`client.js` files and `model.js`/`kiln.js` files.
791+
792+
The scope boundary is now the Rollup module graph: only files that are actually imported
793+
(directly or transitively) by `vite-bootstrap.js` or `_kiln-edit-init.js` contribute to
794+
`client-env.json`. Pure server-side utilities outside the graph — amphora route handlers,
795+
`services/server/*` — are never seen by Rollup and are therefore never included, which
796+
is exactly the right behavior.
797+
798+
```
799+
Browserify pipeline: Vite pipeline:
800+
grep all *.js + *.vue ───► 30s Rollup transform hook ──► ~0s
801+
(sequential, N files) (already traversing graph)
802+
includes server-only files only files in module graph
803+
may expose server secrets scope-bounded to browser surface
804+
```
805+
806+
#### Watch mode behavior
807+
808+
In watch mode, the collector is created once for the entire session. After every
809+
incremental rebuild (`BUNDLE_END`), `write()` is called again with whatever is in the Set.
810+
Because Rollup re-transforms any changed module, a newly added `process.env.MY_VAR`
811+
reference is picked up automatically on the next rebuild.
812+
813+
The Set is intentionally **append-only** within a session — removing a reference leaves a
814+
stale entry until the next full build. This is the safe default: a stale extra entry
815+
causes the server to inject `undefined` (harmless), while a missing entry silently breaks
816+
client-side code.
817+
818+
---
819+
757820
### Sticky events and the `stickyEvents` config key
758821

759822
#### The problem — ESM dynamic-import race condition
@@ -813,7 +876,9 @@ The watch implementation uses **Rollup watch mode** for JS incremental rebuilds
813876
- **JS rebuild:** Rollup reprocesses only changed modules and their dependents
814877
- **Bootstrap regeneration:** when a new `client.js` is added, the bootstrap is regenerated
815878
automatically so the new component appears in the next rebuild
816-
- **client-env.json regeneration:** any JS file change triggers a `generateClientEnv()` pass
879+
- **client-env.json regeneration:** the `clay-client-env` Rollup plugin automatically
880+
collects any new `process.env.VAR` reference as Rollup re-transforms the changed
881+
module — no separate file scan, no extra I/O step
817882
to keep `process.env` variable references in sync
818883
- **usePolling: true** — required for Docker + macOS volume mounts where inotify is unreliable
819884

lib/cmd/vite/plugins/client-env.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use strict';
2+
3+
const fs = require('fs-extra');
4+
5+
const ENV_VAR_RE = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
6+
7+
/**
8+
* Factory for a shared process.env reference collector used across Rollup
9+
* build passes.
10+
*
11+
* ── Why this replaces the grep-based scan ───────────────────────────────────
12+
*
13+
* The previous approach (generateClientEnv) scanned the entire source tree
14+
* with a sequential fs.readFile loop on every build — O(N files), ~30s.
15+
* That scan also had no awareness of the module graph: it picked up
16+
* process.env references from server-only files (services/server/*, model.js
17+
* save hooks that call external APIs) and added them to client-env.json,
18+
* potentially leaking server secrets into the browser's window.process.env.
19+
*
20+
* With Vite + Rollup we already traverse every module in the graph during the
21+
* transform phase. Collecting process.env references there adds only a regex
22+
* match to work Rollup is already doing — effectively free.
23+
*
24+
* ── Scope boundary (intentional) ────────────────────────────────────────────
25+
*
26+
* Only files that Rollup actually processes end up in the collected Set.
27+
* Files outside the module graph (pure server utilities, amphora route handlers,
28+
* anything not imported by vite-bootstrap.js or vite-kiln-edit-init.js) are
29+
* intentionally excluded. Their process.env references are native Node.js
30+
* reads and should never be forwarded to the browser.
31+
*
32+
* ── Why both passes share one collector ─────────────────────────────────────
33+
*
34+
* The Vite pipeline runs two Rollup passes:
35+
* Pass 1 (view) — client.js + global/js files for public pages.
36+
* Pass 2 (kiln) — model.js + kiln.js for edit-mode saves in the browser.
37+
*
38+
* Both passes can reference process.env variables needed in the browser.
39+
* Sharing a single Set across both plugin instances ensures client-env.json
40+
* covers the full browser surface area in a single write.
41+
*
42+
* ── Watch mode note ──────────────────────────────────────────────────────────
43+
*
44+
* The collector's Set is append-only within a watch session. If a developer
45+
* removes a process.env reference, the var stays in the Set until the next
46+
* full build. This is intentional: stale entries in client-env.json are
47+
* harmless (the server injects undefined for missing vars), while a missing
48+
* entry silently breaks client-side code.
49+
*
50+
* @param {string} outputPath Absolute path for the output client-env.json.
51+
* @returns {{ plugin: function(): object, write: function(): Promise<string[]> }}
52+
*/
53+
function createClientEnvCollector(outputPath) {
54+
const found = new Set();
55+
56+
/**
57+
* Returns a Vite-compatible Rollup plugin instance that populates the
58+
* shared Set.
59+
*
60+
* Call once per build pass. Each call returns a distinct plugin object but
61+
* all write to the same underlying Set. The transform hook is purely
62+
* observational — it returns null so Rollup leaves the source unchanged.
63+
*
64+
* @returns {object}
65+
*/
66+
function plugin() {
67+
return {
68+
name: 'clay-client-env',
69+
70+
/**
71+
* Scan each module for process.env.VAR_NAME references and add the
72+
* variable name to the shared collector Set.
73+
*
74+
* Returning null signals to Rollup that no source transformation was
75+
* performed, preserving the original code exactly.
76+
*
77+
* @param {string} code
78+
* @returns {null}
79+
*/
80+
transform(code) {
81+
for (const match of code.matchAll(ENV_VAR_RE)) {
82+
found.add(match[1]);
83+
}
84+
return null;
85+
}
86+
};
87+
}
88+
89+
/**
90+
* Write the accumulated env var names to client-env.json as a sorted array.
91+
*
92+
* Called once after all build passes complete so that a single atomic write
93+
* covers process.env references from both view-mode and kiln-mode modules.
94+
* Also called after each incremental rebuild in watch mode.
95+
*
96+
* amphora-html's addEnvVars() reads this file at server startup to determine
97+
* which process.env values to forward to the browser as window.process.env.
98+
*
99+
* @returns {Promise<string[]>} sorted list of var names written
100+
*/
101+
async function write() {
102+
const vars = [...found].sort();
103+
104+
await fs.outputJson(outputPath, vars, { spaces: 2 });
105+
return vars;
106+
}
107+
108+
return { plugin, write };
109+
}
110+
111+
module.exports = { createClientEnvCollector };

0 commit comments

Comments
 (0)