@barefootjs/vite 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/child-marker.d.ts +62 -0
  2. package/dist/child-marker.d.ts.map +1 -0
  3. package/dist/compile-cache.d.ts +19 -0
  4. package/dist/compile-cache.d.ts.map +1 -0
  5. package/dist/component-manifest.d.ts +69 -0
  6. package/dist/component-manifest.d.ts.map +1 -0
  7. package/dist/corpus-program.d.ts +41 -0
  8. package/dist/corpus-program.d.ts.map +1 -0
  9. package/dist/debounced-serial-runner.d.ts +27 -0
  10. package/dist/debounced-serial-runner.d.ts.map +1 -0
  11. package/dist/dev-server.d.ts +99 -0
  12. package/dist/dev-server.d.ts.map +1 -0
  13. package/dist/discover.d.ts +117 -0
  14. package/dist/discover.d.ts.map +1 -0
  15. package/dist/emit.d.ts +9 -0
  16. package/dist/emit.d.ts.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +24626 -0
  20. package/dist/manifest.d.ts +39 -0
  21. package/dist/manifest.d.ts.map +1 -0
  22. package/dist/paths.d.ts +57 -0
  23. package/dist/paths.d.ts.map +1 -0
  24. package/dist/plugin.d.ts +5 -0
  25. package/dist/plugin.d.ts.map +1 -0
  26. package/dist/resolve-client-js.d.ts +6 -0
  27. package/dist/resolve-client-js.d.ts.map +1 -0
  28. package/dist/types.d.ts +141 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/package.json +55 -0
  31. package/src/__tests__/child-marker.test.ts +24 -0
  32. package/src/__tests__/compile-cache.test.ts +73 -0
  33. package/src/__tests__/component-dir-entry.test.ts +239 -0
  34. package/src/__tests__/component-manifest.test.ts +124 -0
  35. package/src/__tests__/corpus-program.test.ts +244 -0
  36. package/src/__tests__/debounced-serial-runner.test.ts +131 -0
  37. package/src/__tests__/dev-server.test.ts +138 -0
  38. package/src/__tests__/discover.test.ts +148 -0
  39. package/src/__tests__/e2e-vite-build.test.ts +191 -0
  40. package/src/__tests__/e2e-vite-dev.test.ts +478 -0
  41. package/src/__tests__/emit.test.ts +73 -0
  42. package/src/__tests__/manifest.test.ts +146 -0
  43. package/src/__tests__/paths.test.ts +93 -0
  44. package/src/__tests__/plugin.test.ts +417 -0
  45. package/src/__tests__/relative-import-rewrite.test.ts +79 -0
  46. package/src/__tests__/resolve-client-js.test.ts +55 -0
  47. package/src/__tests__/templates-optional.test.ts +139 -0
  48. package/src/child-marker.ts +67 -0
  49. package/src/compile-cache.ts +63 -0
  50. package/src/component-manifest.ts +139 -0
  51. package/src/corpus-program.ts +125 -0
  52. package/src/debounced-serial-runner.ts +67 -0
  53. package/src/dev-server.ts +184 -0
  54. package/src/discover.ts +230 -0
  55. package/src/emit.ts +66 -0
  56. package/src/index.ts +25 -0
  57. package/src/manifest.ts +89 -0
  58. package/src/paths.ts +114 -0
  59. package/src/plugin.ts +792 -0
  60. package/src/resolve-client-js.ts +34 -0
  61. package/src/types.ts +144 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,792 @@
1
+ /**
2
+ * `@barefootjs/vite` — the Vite plugin that turns JSX components into
3
+ * templates and bundled client JS. Vite/Rollup owns bundling, hashing,
4
+ * chunking, tree-shaking and minification; BarefootJS keeps only the
5
+ * JSX → (template, client JS) compile. See `spike-findings.md` (R1-R3)
6
+ * for the mechanics this plugin is built on, and the design doc for the
7
+ * two-engine architecture:
8
+ *
9
+ * 1. graph pass (`transform`) — Rollup visits `.tsx` modules it can
10
+ * reach from `build.rollupOptions.input`; this plugin compiles each
11
+ * one and hands back plain client JS for Rollup to bundle, hash,
12
+ * tree-shake, chunk, and minify like any other module.
13
+ * 2. eager pass (`writeBundle` for `vite build`, `configureServer` for
14
+ * `vite dev`) — walks every `.tsx` under `components` directly, NOT
15
+ * via the module graph. Server-only components (no `'use client'`)
16
+ * never appear in the graph at all (nothing imports them as a
17
+ * script, so Rollup never visits them) but still need a template —
18
+ * this pass is the only place that happens. The build variant runs
19
+ * in `writeBundle` specifically because the Vite manifest is only
20
+ * final once Rollup has finished hashing output filenames; the dev
21
+ * variant runs once the dev server starts listening (the resolved
22
+ * port is needed to build dev-origin URLs) and again on every
23
+ * tracked `.tsx` change.
24
+ *
25
+ * Both passes share one `CompileCache` (§4) so a given file's content is
26
+ * compiled at most twice: once canonically (`scriptAssets: []`, cached,
27
+ * shared by both passes — this is the ONLY compile a server-only file, or
28
+ * any file whose real `scriptAssets` also turns out to be `[]`, ever
29
+ * needs) and, only for a `'use client'` file whose real script list
30
+ * (manifest-resolved for build, dev-origin-based for dev) resolves to a
31
+ * non-empty URL list, one further compile with the real `scriptAssets` to
32
+ * bake the correct URL into the template. That second compile is
33
+ * unavoidable within `compileJSX`'s current API shape: a component's
34
+ * template and its client JS are produced by ONE call, but only the
35
+ * template depends on `scriptAssets` (client JS comes from an entirely
36
+ * separate codegen path `adapter.generate()` never touches) — and for the
37
+ * build variant, `scriptAssets` can't be known until Rollup has already
38
+ * hashed the bundle, which requires `transform` to have already run. So
39
+ * `transform` unavoidably compiles once per file before the real URL
40
+ * exists, and the eager pass MUST recompile once more, only for the
41
+ * strict subset of files whose true `scriptAssets` differs from the
42
+ * cached `[]` canonical form, to get a template with the correct URL
43
+ * baked in.
44
+ *
45
+ * Dev's `configureServer` intentionally does NOT diff what changed and
46
+ * recompile only that file's dependents — it re-runs the ENTIRE eager
47
+ * pass on every tracked change. A change to a shared signal module or a
48
+ * child component changes the *parent's* template too, so anything less
49
+ * than a full re-run needs dependency tracking, which this design avoids
50
+ * entirely. The content-hash `CompileCache` makes the full pass cheap:
51
+ * every unchanged file's `compileCanonical` call is a cache hit.
52
+ *
53
+ * `options.afterEmit`, if supplied, fires once at the end of EITHER eager
54
+ * pass (`writeBundle`'s `mode: 'build'`, `runDevEagerPass`'s `mode: 'dev'`)
55
+ * with a narrow `AfterEmitContext` (`types`, `projectDir`, `templatesDir`,
56
+ * `outDir`, `mode` — see its docstring in `types.ts`). It exists so an
57
+ * adapter's own `/vite` subpath can combine per-file `types` output into
58
+ * one backend-native file (Go's `components.go`, stripping headers,
59
+ * deduping, injecting shared helpers — real per-language work core has no
60
+ * business doing generically) without a `postBuild`-style rewrite hook on
61
+ * emitted client JS ever being on the table. It fires from BOTH passes,
62
+ * not just the build one, because e.g. Go's `components.go` has to exist
63
+ * for `go run .` to compile even in dev.
64
+ */
65
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
66
+ import { relative, resolve, sep } from 'node:path'
67
+ import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
68
+ import {
69
+ compileJSX,
70
+ formatError,
71
+ type CompileResult,
72
+ } from '@barefootjs/jsx'
73
+ import type { BarefootPluginApi, BarefootViteOptions } from './types.ts'
74
+ import { CompileCache } from './compile-cache.ts'
75
+ import { CorpusProgramManager } from './corpus-program.ts'
76
+ import { BF_CHILD_NOOP_ID, bfChildMarkerName } from './child-marker.ts'
77
+ import {
78
+ buildChildNameIndex,
79
+ discoverComponents,
80
+ isComponentSourceFile,
81
+ type DiscoveredComponent,
82
+ type ResolvedComponentDirEntry,
83
+ } from './discover.ts'
84
+ import { resolveClientJsSpecifier } from './resolve-client-js.ts'
85
+ import { buildRelativeImportRewriter, relativeUnderComponentDir, safeRollupEntryName, toPosixRelative } from './paths.ts'
86
+ import { loadManifest, resolvePreloadAssets, resolveScriptAssets } from './manifest.ts'
87
+ import { planEmits, writeEmits, type EmitTarget } from './emit.ts'
88
+ import { buildManifestEntry, type ManifestEntry } from './component-manifest.ts'
89
+ import {
90
+ DEFAULT_DEV_CORS_ORIGIN,
91
+ DEV_ARTIFACT_MARKER_CONTENT,
92
+ DEV_ARTIFACT_MARKER_FILENAME,
93
+ DEV_WATCH_DEBOUNCE_MS,
94
+ devScriptAssets,
95
+ devSentinelPath,
96
+ resolveDevOrigin,
97
+ } from './dev-server.ts'
98
+ import { createDebouncedSerialRunner } from './debounced-serial-runner.ts'
99
+
100
+ // Exported so tooling can find this plugin by name in a resolved Vite
101
+ // config's `plugins` array (e.g. `bf`'s `context.ts`, reading `api` off it —
102
+ // see `BarefootPluginApi`'s docstring) without hardcoding the string
103
+ // independently of what this file actually names the plugin.
104
+ export const PLUGIN_NAME = 'barefoot'
105
+
106
+ function reportErrors(result: CompileResult, source: string, projectDir: string): void {
107
+ const errors = result.errors.filter(e => e.severity === 'error')
108
+ const warnings = result.errors.filter(e => e.severity === 'warning')
109
+ for (const warning of warnings) {
110
+ console.warn(formatError(warning, source, { projectDir }))
111
+ }
112
+ if (errors.length > 0) {
113
+ const messages = errors.map(e => formatError(e, source, { projectDir })).join('\n\n')
114
+ throw new Error(`[${PLUGIN_NAME}] compile failed:\n\n${messages}`)
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Resolves `options.components` (each entry a plain string OR a
120
+ * `ComponentDirEntry`) against `root` into `ResolvedComponentDirEntry[]` —
121
+ * `dir` always absolute, `cssLayerPrefix`/`skipDirs` carried through
122
+ * unchanged, array order preserved. A plain string entry becomes
123
+ * `{ dir: resolve(root, entry) }` with neither field set, exactly
124
+ * equivalent to `{ dir: entry }` per `ComponentDirEntry`'s own contract.
125
+ *
126
+ * Called twice per plugin instance with two different roots — `config`'s
127
+ * best-effort `guessedRoot` (Vite's real root isn't resolved yet at that
128
+ * point in its lifecycle) and `configResolved`'s authoritative
129
+ * `config.root` — which is why this takes `root` as a parameter instead of
130
+ * closing over one.
131
+ */
132
+ function normalizeComponents(
133
+ components: BarefootViteOptions['components'],
134
+ root: string,
135
+ ): ResolvedComponentDirEntry[] {
136
+ return components.map(entry =>
137
+ typeof entry === 'string'
138
+ ? { dir: resolve(root, entry) }
139
+ : { dir: resolve(root, entry.dir), cssLayerPrefix: entry.cssLayerPrefix, skipDirs: entry.skipDirs },
140
+ )
141
+ }
142
+
143
+ export function barefoot(options: BarefootViteOptions): Plugin {
144
+ const cache = new CompileCache()
145
+
146
+ // One shared ts.Program across every compile in this plugin instance —
147
+ // built once per pass from the discovered files that need type-based
148
+ // detection, incrementally rebuilt on change. Without it, each such file
149
+ // pays its own ~500-600 ms `ts.createProgram` type-graph construction
150
+ // inside `compileJSX`'s per-file fallback (tens of seconds across a real
151
+ // corpus), and a Reactive<T>-brand importer fails the build outright
152
+ // with BF050. See `corpus-program.ts`.
153
+ const corpusProgram = new CorpusProgramManager()
154
+
155
+ // What `emitTemplatesFor` last wrote for a given source file, keyed by
156
+ // absolute path. The ONLY consumer is the dev watcher's `'unlink'`
157
+ // handler: when a component file is deleted, this is how it knows which
158
+ // on-disk template/ssrDefaults/types files to remove without having to
159
+ // re-derive the (possibly `templatesPerComponent`, i.e. named after the
160
+ // exported component rather than the source file) output path from a
161
+ // file that no longer exists to read.
162
+ const lastEmitsByAbsPath = new Map<string, EmitTarget[]>()
163
+
164
+ // Populated (redundantly but cheaply — a directory walk, no compiling)
165
+ // once in `config` with a best-effort root, so `rollupOptions.input` can
166
+ // be set; then again in `configResolved` with Vite's authoritative
167
+ // `root`, which is what `resolveId` / `transform` / `writeBundle` use.
168
+ //
169
+ // `dirEntries` is the normalized `options.components` — `dir` resolved
170
+ // to absolute, `cssLayerPrefix`/`skipDirs` carried through — in the
171
+ // OPTION'S OWN ORDER, which doubles as precedence: `entryForPath` below
172
+ // returns the FIRST entry whose `dir` prefixes a given path, so an
173
+ // earlier `components` entry shadows a later one for a file reachable
174
+ // under both (the same first-writer-wins precedence
175
+ // `buildChildNameIndex` already applies to `@bf-child:` name
176
+ // collisions). `componentDirs` is `dirEntries.map(e => e.dir)`, kept
177
+ // around unchanged because every existing bit of path arithmetic
178
+ // (`rewriterFor`, `planEmits`, `buildManifestEntry`,
179
+ // `safeRollupEntryName`) only ever needs the bare directory list, never
180
+ // the per-entry options.
181
+ let dirEntries: ResolvedComponentDirEntry[] = []
182
+ let componentDirs: string[] = []
183
+ // `undefined` exactly when `options.templates` was never set — the CSR
184
+ // degenerate case. Every write path below (`writeEmits`, `manifest.json`,
185
+ // the dev-artifact marker, `afterEmit`) is gated on this being defined;
186
+ // `assertNoRealTemplateOutput` is what makes skipping those writes safe
187
+ // rather than a silent drop.
188
+ let templatesDir: string | undefined
189
+ let resolvedConfig: ResolvedConfig | undefined
190
+
191
+ // Name → absolute-path index for resolving `@bf-child:<Name>` markers
192
+ // (see `child-marker.ts` and `discover.ts`'s `buildChildNameIndex`).
193
+ // Built once in `configResolved` — before Rollup's graph pass starts
194
+ // calling `resolveId`, which is the only consumer — from a dedicated
195
+ // discovery pass (cheap: a directory walk + a `'use client'` directive
196
+ // peek per file, no compiling).
197
+ let childNameIndex = new Map<string, string>()
198
+
199
+ // Re-anchors a relative import written in `absPath` so it still resolves
200
+ // once the template is emitted under `templatesDir`. Pure function of
201
+ // (absPath, componentDirs, templatesDir) — safe to compute for every
202
+ // compile, including the canonical `transform`-time one where it's
203
+ // unused (client JS generation never reads `rewriteRelativeImport`).
204
+ //
205
+ // `outputPathGuess` MUST mirror `planEmits`'s actual on-disk output
206
+ // location — the position of `absPath` under WHICHEVER `componentDirs`
207
+ // entry contains it, joined onto `templatesDir` (`relativeUnderComponentDir`,
208
+ // the same helper `planEmits`/`safeRollupEntryName` use) — not a
209
+ // root-relative guess. The two coincide only when every configured
210
+ // `components` dir IS the Vite root; this repo's real layouts commonly
211
+ // configure MULTIPLE sibling `components` dirs (e.g. `../shared/blog`)
212
+ // that are flattened directly under `templatesDir` with no `shared/blog/`
213
+ // prefix preserved. A root-relative guess computes a phantom nested path
214
+ // (`dist/shared/blog/Foo.tsx`) that diverges from where the file is
215
+ // actually written (`dist/components/Foo.tsx`), corrupting every
216
+ // relative import a same-directory sibling file re-anchors from it — only
217
+ // surfaced by an adapter whose templates carry real `import` syntax
218
+ // (Hono-shaped JS-runtime adapters; Go/Mojo/etc. templates have no
219
+ // imports and never call this at all).
220
+ function rewriterFor(absPath: string): (importPath: string) => string {
221
+ // No `templates` dir configured (the CSR degenerate case) — there is
222
+ // nowhere for a rewritten import to point, but that's harmless: a
223
+ // relative import only reaches emitted template text (never client
224
+ // JS), and `assertNoRealTemplateOutput` refuses loudly the moment any
225
+ // component's template output turns out non-empty. Identity is a safe
226
+ // placeholder for output that can never survive to be read.
227
+ if (templatesDir === undefined) return importPath => importPath
228
+ const outputPathGuess = resolve(templatesDir, relativeUnderComponentDir(absPath, componentDirs))
229
+ return buildRelativeImportRewriter(absPath, outputPathGuess, componentDirs, templatesDir)
230
+ }
231
+
232
+ /**
233
+ * Refuses loudly when `options.templates` is omitted but `result` (a
234
+ * specific discovered component's compile) produced a REAL `markedTemplate`
235
+ * — non-empty content that would otherwise be silently dropped by the
236
+ * `templatesDir === undefined` skip below. `ssrDefaults`/`types` output is
237
+ * deliberately NOT checked here: a CSR `Counter` DOES produce non-empty
238
+ * `ssrDefaults` — proven by inspection — precisely because that
239
+ * computation reads IR metadata, not the adapter's `generate()` output,
240
+ * so treating them as loudness-worthy here would make `templates`
241
+ * impossible to omit for the one adapter (`CSRAdapter`) this option
242
+ * exists to accommodate.
243
+ */
244
+ function assertNoRealTemplateOutput(result: CompileResult, absPath: string): void {
245
+ const real = result.files.find(f => f.type === 'markedTemplate' && f.content.trim() !== '')
246
+ if (!real) return
247
+ throw new Error(
248
+ `[${PLUGIN_NAME}] adapter "${options.adapter.name}" produced a real template for ` +
249
+ `"${absPath}", but no \`templates\` option is configured on barefoot() — that output ` +
250
+ `would be silently dropped. Set \`templates: '<dir>'\`, or use an adapter whose ` +
251
+ `generate() output is always empty (e.g. CSRAdapter) if this project truly emits no ` +
252
+ `templates.`,
253
+ )
254
+ }
255
+
256
+ /**
257
+ * The FIRST `dirEntries` entry (option order) whose `dir` prefixes
258
+ * `absPath`, or `undefined` if no configured `components` entry contains
259
+ * it. This is the single source of precedence: whichever entry a path
260
+ * resolves to here is also the entry `isUnderComponentDir`'s `skipDirs`
261
+ * check consults, so a file's `cssLayerPrefix` and its skip/no-skip
262
+ * verdict always come from the SAME entry, never two different ones.
263
+ */
264
+ function entryForPath(absPath: string): ResolvedComponentDirEntry | undefined {
265
+ return dirEntries.find(entry => absPath === entry.dir || absPath.startsWith(`${entry.dir}/`))
266
+ }
267
+
268
+ /** Does `entry.skipDirs` name any directory component on the path from
269
+ * `entry.dir` down to `absPath`? Mirrors `discoverComponentFiles`'s own
270
+ * recursive skip (a directory whose NAME is listed is skipped, along
271
+ * with everything under it) so a file discovery never walks into is
272
+ * never treated as a component by the transform gate either — the
273
+ * "half-fix" this module's docstring on `isUnderComponentDir` warns
274
+ * about. The file's own basename (the last path segment) is excluded:
275
+ * `skipDirs` names directories, not files. */
276
+ function isSkippedByEntry(absPath: string, entry: ResolvedComponentDirEntry): boolean {
277
+ if (!entry.skipDirs || entry.skipDirs.length === 0) return false
278
+ const relParts = relative(entry.dir, absPath).split(sep)
279
+ return relParts.slice(0, -1).some(part => entry.skipDirs!.includes(part))
280
+ }
281
+
282
+ function compileCanonical(absPath: string, content: string): CompileResult {
283
+ return cache.getOrCompile(absPath, content, () =>
284
+ compileJSX(content, absPath, {
285
+ adapter: options.adapter,
286
+ sourceMaps: true,
287
+ // `undefined` for the majority of files (no type-based detection
288
+ // needed — the analyzer never builds a checker for them); the
289
+ // shared corpus Program for the rest. Resolved INSIDE the cache
290
+ // thunk so a cache hit never touches the Program at all.
291
+ program: corpusProgram.programFor(absPath, content),
292
+ // Per-directory, from whichever `dirEntries` entry `absPath` falls
293
+ // under — `undefined` for a plain-string entry (or a file matching
294
+ // no entry, which shouldn't happen for anything reaching this
295
+ // function). `CompileCache` is keyed `(absPath, contentHash)` with
296
+ // NO `cssLayerPrefix` component — deliberately: the prefix is a
297
+ // pure function of `absPath` via `dirEntries`, and `dirEntries`
298
+ // only ever changes on a config reload (a full plugin restart,
299
+ // hence a fresh `cache` too), so two calls with the same
300
+ // `(absPath, content)` always agree on `cssLayerPrefix` and the
301
+ // cache key needs no widening. Do not "fix" this later.
302
+ cssLayerPrefix: entryForPath(absPath)?.cssLayerPrefix,
303
+ // The canonical, cacheable compile always uses an empty
304
+ // scriptAssets ("no scripts") — the one input every discovered
305
+ // file (server-only or client) shares regardless of its eventual
306
+ // manifest entry. Everything except the template's script
307
+ // registration is identical no matter what `scriptAssets` is, so
308
+ // this single compile is reused as-is for every server-only
309
+ // component and any client component that turns out to need no
310
+ // scripts. See this module's docstring.
311
+ scriptAssets: [],
312
+ rewriteRelativeImport: rewriterFor(absPath),
313
+ // The eager pass ALWAYS writes every discovered component's
314
+ // template into the SAME `templates` dir for the app to register
315
+ // together at request/startup time (that's the whole point of
316
+ // walking `components` directly instead of following the module
317
+ // graph) — the exact guarantee `siblingTemplatesRegistered`
318
+ // exists to assert. Without it, a DSL-template adapter (Go,
319
+ // ERB, Blade, Jinja, ...) refuses to compile ANY component that
320
+ // uses a sibling-imported child inside a `.map()` loop (BF103),
321
+ // even though the shape works fine once the app's own template
322
+ // registration (e.g. Go's `filepath.WalkDir` + `ParseFiles` over
323
+ // every `.tmpl`) puts every template on one instance — which this
324
+ // plugin's design already requires. Harmless for the client-JS
325
+ // graph pass that also calls this function — the option only
326
+ // ever reaches `adapter.generate()`'s TEMPLATE codegen, never
327
+ // client JS.
328
+ siblingTemplatesRegistered: true,
329
+ }),
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Is `absPath` a component this plugin should treat as live — under a
335
+ * configured `components` entry AND not inside that entry's `skipDirs`?
336
+ * Gates `transform` (the graph pass) and the dev watcher's
337
+ * `change`/`add`/`unlink` handlers. `skipDirs` must gate THIS, not just
338
+ * `discoverComponents`'s directory walk: discovery only decides what the
339
+ * EAGER pass walks up front, but a skipped file can still be reached by
340
+ * an ordinary `import` from a non-skipped sibling — `site/ui`'s
341
+ * `PageNavigation.tsx` (imported by pages, but living in a skipped
342
+ * `shared/` dir) is exactly that shape. Without this check the graph
343
+ * pass would compile it anyway: discovery silently skips it, but
344
+ * `transform` doesn't, which is the half-fix that used to bite that
345
+ * layout.
346
+ */
347
+ function isUnderComponentDir(absPath: string): boolean {
348
+ const entry = entryForPath(absPath)
349
+ return entry !== undefined && !isSkippedByEntry(absPath, entry)
350
+ }
351
+
352
+ /**
353
+ * Shared eager-pass body: compile + emit a template for every discovered
354
+ * component, resolving each `'use client'` component's real
355
+ * `scriptAssets`/`preloadAssets` via the caller-supplied
356
+ * `resolveAssetsFor` (the manifest for `writeBundle`, dev-origin URLs
357
+ * for `configureServer`) — one callback returning both lists, since
358
+ * both come from the same single manifest lookup. See this module's
359
+ * docstring for why both callers need the FULL discovered set every
360
+ * time, not just what changed.
361
+ *
362
+ * Returns every `types`-typed output this pass produced, keyed by
363
+ * source absolute path — the raw material `afterEmit` receives (see
364
+ * `AfterEmitContext`). Collected here (not read back off disk) since
365
+ * `planEmits`/`writeEmits` already have the compiled `CompileResult` in
366
+ * hand; no adapter-specific knowledge is needed to harvest it.
367
+ *
368
+ * When `options.templates` is omitted (`templatesDir === undefined`),
369
+ * this still compiles every discovered component — the graph pass
370
+ * (`transform`) needs the same canonical compile anyway, and
371
+ * `assertNoRealTemplateOutput` needs a real `CompileResult` to check —
372
+ * but writes nothing to disk on any component's behalf and returns an
373
+ * empty `types` map. See `types.ts`'s docstring on `templates`.
374
+ */
375
+ async function emitTemplatesFor(
376
+ discovered: DiscoveredComponent[],
377
+ projectDir: string,
378
+ resolveAssetsFor: (component: DiscoveredComponent) => { scriptAssets: string[]; preloadAssets: string[] },
379
+ ): Promise<Map<string, string>> {
380
+ const types = new Map<string, string>()
381
+ // Combined `manifest.json` row per source file — see
382
+ // `component-manifest.ts`'s header for why this is written alongside
383
+ // (not instead of) the per-component `.ssr-defaults.json` files
384
+ // `writeEmits` already produces. Stays empty (and unwritten) when
385
+ // `templatesDir` is undefined.
386
+ const manifestEntries: Record<string, ManifestEntry> = {}
387
+ // Refresh the shared Program from this pass's full discovery snapshot
388
+ // BEFORE the compile loop, so the loop never triggers `programFor`'s
389
+ // one-root-at-a-time incremental rebuilds. A no-op when nothing
390
+ // type-needing changed — the common case, mirroring `CompileCache`.
391
+ corpusProgram.seed(discovered)
392
+ for (const component of discovered) {
393
+ const content = component.content
394
+ const canonical = compileCanonical(component.absPath, content)
395
+ reportErrors(canonical, content, projectDir)
396
+
397
+ let result = canonical
398
+ if (component.isClient) {
399
+ const { scriptAssets, preloadAssets } = resolveAssetsFor(component)
400
+ if (scriptAssets.length > 0) {
401
+ result = compileJSX(content, component.absPath, {
402
+ adapter: options.adapter,
403
+ sourceMaps: true,
404
+ // Same shared Program as the canonical compile — this second,
405
+ // uncached compile re-runs the same analysis with only
406
+ // `scriptAssets` differing, so skipping it here would re-open
407
+ // the per-file fallback (and BF050) for exactly the files
408
+ // that recompile every pass.
409
+ program: corpusProgram.programFor(component.absPath, content),
410
+ // Already stamped on `component` by `discoverComponents` from
411
+ // the same `dirEntries` entry `compileCanonical` would have
412
+ // derived via `entryForPath` — reused directly rather than
413
+ // re-derived, since the two always agree.
414
+ cssLayerPrefix: component.cssLayerPrefix,
415
+ scriptAssets,
416
+ preloadAssets,
417
+ rewriteRelativeImport: rewriterFor(component.absPath),
418
+ // See `compileCanonical`'s docstring on this same field.
419
+ siblingTemplatesRegistered: true,
420
+ })
421
+ reportErrors(result, content, projectDir)
422
+ }
423
+ }
424
+
425
+ if (templatesDir === undefined) {
426
+ assertNoRealTemplateOutput(result, component.absPath)
427
+ continue
428
+ }
429
+
430
+ const targets = planEmits(result, component.absPath, componentDirs, options.adapter)
431
+ lastEmitsByAbsPath.set(component.absPath, targets)
432
+ await writeEmits(templatesDir, targets)
433
+
434
+ for (const file of result.files) {
435
+ if (file.type === 'types') types.set(component.absPath, file.content)
436
+ }
437
+
438
+ const manifestRow = buildManifestEntry(result, component.absPath, componentDirs, options.adapter)
439
+ if (manifestRow) manifestEntries[manifestRow.manifestKey] = manifestRow.entry
440
+ }
441
+
442
+ if (templatesDir === undefined) return types
443
+
444
+ // Written unconditionally every pass (matching `writeEmits`'s own
445
+ // no-diffing convention for this eager pass) — `discovered` is always
446
+ // the FULL current set (see this module's docstring on why this
447
+ // plugin never does a partial/diffed re-run), so this naturally drops
448
+ // a deleted component's row without any separate cleanup step.
449
+ // `mkdir` first: an empty `discovered` set means `writeEmits` never
450
+ // ran (no targets to create `templatesDir` for), so it may not exist
451
+ // yet.
452
+ await mkdir(templatesDir, { recursive: true })
453
+ await writeFile(resolve(templatesDir, 'manifest.json'), JSON.stringify(manifestEntries, null, 2))
454
+
455
+ return types
456
+ }
457
+
458
+ /**
459
+ * Remove whatever `emitTemplatesFor` last wrote for each of `absPaths`
460
+ * (a deleted component's template, `ssrDefaults`, and `.types` fragment)
461
+ * and forget it — both from the emit-tracking map and the compile
462
+ * cache, so a file later recreated at the same path never reuses a
463
+ * stale cached result. Best-effort: `rm(..., { force: true })` so a
464
+ * file already gone (or never successfully written) isn't an error.
465
+ */
466
+ async function removeEmitsFor(absPaths: Iterable<string>): Promise<void> {
467
+ for (const absPath of absPaths) {
468
+ const targets = lastEmitsByAbsPath.get(absPath)
469
+ lastEmitsByAbsPath.delete(absPath)
470
+ cache.delete(absPath)
471
+ // `lastEmitsByAbsPath` is only ever populated inside `emitTemplatesFor`
472
+ // when `templatesDir` is defined (see its `continue` for the CSR
473
+ // degenerate case), so `targets` is never non-empty here without
474
+ // `templatesDir` also being defined — this check is for the type
475
+ // checker, not a real runtime possibility.
476
+ if (!targets || templatesDir === undefined) continue
477
+ for (const target of targets) {
478
+ await rm(resolve(templatesDir, target.relPath), { force: true })
479
+ }
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Dev variant of the eager pass: discovers every component fresh (a
485
+ * changed file's new content must be picked up — `CompileCache` keys on
486
+ * content hash, so a stale in-memory listing is harmless, but a stale
487
+ * listing that MISSES a newly added file would not be) and bakes
488
+ * `devOrigin`-based `scriptAssets` instead of manifest-resolved ones.
489
+ * Also (re)writes the dev-artifact marker — see `dev-server.ts` — and
490
+ * refreshes `childNameIndex` (a component added or removed mid-session
491
+ * must be reflected in `@bf-child:` marker resolution the same way it's
492
+ * reflected in everything else this pass recomputes from scratch).
493
+ */
494
+ async function runDevEagerPass(devOrigin: string): Promise<void> {
495
+ const config = resolvedConfig
496
+ if (!config) return
497
+
498
+ const discovered = await discoverComponents(dirEntries, absPath => readFile(absPath, 'utf8'))
499
+ childNameIndex = buildChildNameIndex(discovered)
500
+ const types = await emitTemplatesFor(discovered, config.root, component => ({
501
+ scriptAssets: devScriptAssets(config, devOrigin, component.absPath),
502
+ // Dev serves unbundled modules and runs its own on-demand dep
503
+ // pre-bundling — there is no stable, hashed chunk graph to walk (no
504
+ // manifest exists in dev mode at all), so there is nothing correct
505
+ // to preload. Emitting hints here would just be noise the browser
506
+ // has to fetch and discard.
507
+ preloadAssets: [],
508
+ }))
509
+
510
+ // Both the marker and `afterEmit` exist to annotate/post-process
511
+ // `templatesDir` — neither has anything to do when there isn't one
512
+ // (the CSR degenerate case).
513
+ if (templatesDir === undefined) return
514
+
515
+ await writeFile(resolve(templatesDir, DEV_ARTIFACT_MARKER_FILENAME), DEV_ARTIFACT_MARKER_CONTENT)
516
+
517
+ // Cross-language dev-reload sentinel (see `devSentinelPath`'s
518
+ // docstring) — written unconditionally on every pass, initial pass
519
+ // included. A fresh timestamp is enough: consumers only compare it
520
+ // against their own last-seen value.
521
+ const sentinelPath = devSentinelPath(templatesDir)
522
+ await mkdir(resolve(sentinelPath, '..'), { recursive: true })
523
+ await writeFile(sentinelPath, String(Date.now()))
524
+
525
+ if (options.afterEmit) {
526
+ await options.afterEmit({
527
+ types,
528
+ projectDir: config.root,
529
+ templatesDir,
530
+ outDir: resolve(config.root, config.build.outDir),
531
+ mode: 'dev',
532
+ })
533
+ }
534
+ }
535
+
536
+ return {
537
+ name: PLUGIN_NAME,
538
+ enforce: 'pre',
539
+ // Vite's own "plugin API" convention — see `BarefootPluginApi`'s
540
+ // docstring for who reads this and why it's populated here (at
541
+ // construction time) rather than from a lifecycle hook.
542
+ api: { options } satisfies BarefootPluginApi,
543
+
544
+ async config(userConfig) {
545
+ // Best-effort root for the eager discovery this hook needs to set
546
+ // `rollupOptions.input` — Vite hasn't resolved the real root yet at
547
+ // this point in its own lifecycle (that only exists once
548
+ // `configResolved` fires), so this assumes the common case of
549
+ // running Vite from the project root with no `root` override. If
550
+ // that assumption doesn't hold for a given project, `configResolved`
551
+ // still recomputes everything `resolveId`/`transform`/`writeBundle`
552
+ // actually use against Vite's real resolved root — only the
553
+ // convenience `rollupOptions.input` keys this hook picks could be
554
+ // off, not correctness.
555
+ const guessedRoot = userConfig.root ? resolve(process.cwd(), userConfig.root) : process.cwd()
556
+ const guessedEntries = normalizeComponents(options.components, guessedRoot)
557
+ const dirs = guessedEntries.map(e => e.dir)
558
+ const found = await discoverComponents(guessedEntries, absPath => readFile(absPath, 'utf8'))
559
+
560
+ const input: Record<string, string> = {}
561
+ for (const c of found) {
562
+ if (!c.isClient) continue
563
+ // The object KEY only names the output chunk (Rollup's `[name]`) —
564
+ // it must be safe even for a `components` dir outside `root` (see
565
+ // `safeRollupEntryName`). It is NOT the manifest lookup key
566
+ // (`writeBundle` computes that separately via `toPosixRelative`
567
+ // against Vite's real resolved root, matching Vite's own
568
+ // manifest keying, which this name has no effect on).
569
+ input[safeRollupEntryName(guessedRoot, c.absPath, dirs)] = c.absPath
570
+ }
571
+
572
+ // Cross-origin dev default: the page comes from the backend, its
573
+ // module scripts from Vite — two different origins. Vite 6+ defaults
574
+ // `cors` to same-origin-only, which would reject those cross-origin
575
+ // module requests outright. Fill in a localhost-only default ONLY
576
+ // when the user hasn't set `server.cors` themselves — this stays a
577
+ // 3-option plugin (`adapter` / `components` / `templates`); no 4th
578
+ // `devOrigin`-style option is added for this. Done here in `config`
579
+ // (not `configureServer`) so it lands before Vite installs its own
580
+ // CORS middleware, and so it's plain, synchronously-testable data
581
+ // instead of a hook-timing dependency on Vite's internal
582
+ // configureServer/middleware install order.
583
+ //
584
+ // Checked against `undefined` specifically, NOT falsiness: a user
585
+ // who writes `server.cors = false` to explicitly DISABLE CORS means
586
+ // exactly that, and `!false` is `true` — a truthiness check would
587
+ // silently override their choice with this default, the opposite of
588
+ // "only fill in when unset".
589
+ const serverDefaults: Record<string, unknown> = {}
590
+ if (userConfig.server?.cors === undefined) {
591
+ serverDefaults.cors = { origin: DEFAULT_DEV_CORS_ORIGIN }
592
+ }
593
+
594
+ return {
595
+ appType: 'custom',
596
+ build: {
597
+ manifest: true,
598
+ rollupOptions: { input },
599
+ },
600
+ server: serverDefaults,
601
+ }
602
+ },
603
+
604
+ async configResolved(config) {
605
+ resolvedConfig = config
606
+ dirEntries = normalizeComponents(options.components, config.root)
607
+ componentDirs = dirEntries.map(e => e.dir)
608
+ templatesDir = options.templates !== undefined ? resolve(config.root, options.templates) : undefined
609
+ const discovered = await discoverComponents(dirEntries, absPath => readFile(absPath, 'utf8'))
610
+ childNameIndex = buildChildNameIndex(discovered)
611
+ // Seed the shared Program now, before Rollup's graph pass starts
612
+ // calling `transform` — otherwise the first type-needing file the
613
+ // graph reaches would build the corpus one root at a time.
614
+ corpusProgram.seed(discovered)
615
+ },
616
+
617
+ resolveId(source, importer) {
618
+ // See `child-marker.ts`: a `@bf-child:` marker isn't a real module.
619
+ // Resolve it to the named child's REAL `.tsx` file when discovery
620
+ // found one — Rollup then treats it as an ordinary entry-to-entry
621
+ // import (the child is independently a Rollup entry too, being
622
+ // `'use client'`), which is what makes the browser fetch and
623
+ // execute the child's script as a side effect of loading the
624
+ // parent's. Falls back to a shared empty virtual module (elided by
625
+ // Rollup's tree-shaking, `moduleSideEffects: false`) for a name this
626
+ // simple map doesn't cover, rather than failing the build outright.
627
+ const childName = bfChildMarkerName(source)
628
+ if (childName !== null) {
629
+ const childAbsPath = childNameIndex.get(childName)
630
+ if (childAbsPath) return childAbsPath
631
+ return { id: BF_CHILD_NOOP_ID, moduleSideEffects: false }
632
+ }
633
+ return resolveClientJsSpecifier(source, importer)
634
+ },
635
+
636
+ load(id) {
637
+ if (id === BF_CHILD_NOOP_ID) return ''
638
+ return null
639
+ },
640
+
641
+ transform(code, id) {
642
+ if (!id.endsWith('.tsx')) return null
643
+ if (!isUnderComponentDir(id)) return null
644
+
645
+ const result = compileCanonical(id, code)
646
+ reportErrors(result, code, resolvedConfig?.root ?? process.cwd())
647
+
648
+ const clientJs = result.files.find(f => f.type === 'clientJs')
649
+ if (!clientJs) return null
650
+
651
+ const map = result.files.find(f => f.type === 'sourceMap' && f.path === `${clientJs.path}.map`)
652
+ return {
653
+ code: clientJs.content,
654
+ map: map ? JSON.parse(map.content) : null,
655
+ }
656
+ },
657
+
658
+ async writeBundle() {
659
+ const config = resolvedConfig
660
+ if (!config) return
661
+
662
+ // Authoritative discovery — Vite's real root, not `config`'s guess.
663
+ const discovered: DiscoveredComponent[] = await discoverComponents(
664
+ dirEntries,
665
+ absPath => readFile(absPath, 'utf8'),
666
+ )
667
+
668
+ const outDir = resolve(config.root, config.build.outDir)
669
+ const manifest = await loadManifest(outDir, config.build.manifest)
670
+
671
+ const types = await emitTemplatesFor(discovered, config.root, component => {
672
+ const manifestKey = toPosixRelative(config.root, component.absPath)
673
+ return {
674
+ scriptAssets: resolveScriptAssets(manifest, manifestKey, config.base),
675
+ preloadAssets: resolvePreloadAssets(manifest, manifestKey, config.base),
676
+ }
677
+ })
678
+
679
+ // No `templates` dir configured (the CSR degenerate case) — nothing
680
+ // was written for `emitTemplatesFor` to have staled, and `afterEmit`
681
+ // exists to post-process `templatesDir`, which doesn't exist here.
682
+ if (templatesDir === undefined) return
683
+
684
+ // A prior `vite dev` run may have left the dev-artifact marker (and
685
+ // dev-origin URLs) behind — this pass just overwrote every template
686
+ // with production URLs, so the marker is now stale. Best-effort:
687
+ // there may never have been one.
688
+ await rm(resolve(templatesDir, DEV_ARTIFACT_MARKER_FILENAME), { force: true })
689
+ // Same for the dev-reload sentinel: a production build is not a dev
690
+ // rebuild, so a stale `.dev/build-id` left over from an earlier
691
+ // `vite dev` session should not trick a still-running Go/Perl dev
692
+ // server into firing a reload for output that didn't come from it.
693
+ await rm(devSentinelPath(templatesDir), { force: true })
694
+
695
+ if (options.afterEmit) {
696
+ await options.afterEmit({ types, projectDir: config.root, templatesDir, outDir, mode: 'build' })
697
+ }
698
+ },
699
+
700
+ configureServer(server: ViteDevServer) {
701
+ // Mandatory: Vite's dev watcher only reliably covers the module
702
+ // graph plus whatever's under its own project `root`. Server-only
703
+ // components (no `'use client'`) never enter the module graph at
704
+ // all (nothing imports them as a script), and in this monorepo's
705
+ // real layouts `components` dirs are commonly siblings of — not
706
+ // descendants of — the Vite project root (an app's `vite.config.ts`
707
+ // root is the backend app dir; components live in a shared `ui/`
708
+ // directory next to it). Without this explicit `add`, editing such
709
+ // a file is silently invisible to the dev server: no watcher event,
710
+ // no re-emit, no reload. See `e2e-vite-dev.test.ts`'s server-only /
711
+ // out-of-root regression coverage.
712
+ for (const dir of componentDirs) {
713
+ server.watcher.add(dir)
714
+ }
715
+
716
+ let devOrigin: string | undefined
717
+ // Deleted files queued for cleanup by the `'unlink'` handler,
718
+ // drained by the runner's own task the next time it actually runs —
719
+ // NOT deleted synchronously in the handler, so an unlink that lands
720
+ // while a pass is already in flight for other reasons is still
721
+ // batched into the SAME follow-up run as everything else instead of
722
+ // racing it.
723
+ const pendingUnlinks = new Set<string>()
724
+
725
+ // One serialized, debounced entry point for every dev-watcher event
726
+ // this plugin reacts to (`change` / `add` / `unlink`). See
727
+ // `debounced-serial-runner.ts`: a burst of events collapses into one
728
+ // pass, an event arriving mid-pass is queued as exactly one
729
+ // follow-up (never dropped, never overlapped), and no distinction
730
+ // needs to be drawn between which files changed — the pass itself
731
+ // re-discovers everything from disk (see this module's docstring on
732
+ // why a diff-based re-run is the wrong shape here).
733
+ const devPassRunner = createDebouncedSerialRunner(
734
+ async () => {
735
+ if (!devOrigin) return // initial pass (below) will cover current disk state once it runs
736
+ await removeEmitsFor(pendingUnlinks)
737
+ pendingUnlinks.clear()
738
+ await runDevEagerPass(devOrigin)
739
+ server.ws.send({ type: 'full-reload' })
740
+ },
741
+ DEV_WATCH_DEBOUNCE_MS,
742
+ err => server.config.logger.error(String(err)),
743
+ )
744
+
745
+ async function runInitialPass(): Promise<void> {
746
+ devOrigin = resolveDevOrigin(server)
747
+ await runDevEagerPass(devOrigin)
748
+ }
749
+
750
+ if (server.httpServer) {
751
+ // The resolved port isn't known until the server actually starts
752
+ // listening — Vite auto-increments past an in-use configured port
753
+ // unless `strictPort` is set, so anything read earlier could be
754
+ // wrong. `configureServer` itself always runs before `listen()`.
755
+ server.httpServer.once('listening', () => {
756
+ runInitialPass().catch(err => server.config.logger.error(String(err)))
757
+ })
758
+ } else {
759
+ // Middleware mode: no `httpServer`, so no `'listening'` event ever
760
+ // fires. Run immediately with whatever origin is already
761
+ // configured (or the bare `localhost:<configured port>` fallback
762
+ // inside `resolveDevOrigin`).
763
+ runInitialPass().catch(err => server.config.logger.error(String(err)))
764
+ }
765
+
766
+ // `'change'`: an existing tracked file's content changed.
767
+ server.watcher.on('change', (file: string) => {
768
+ if (!isComponentSourceFile(file) || !isUnderComponentDir(file)) return
769
+ devPassRunner.trigger()
770
+ })
771
+
772
+ // `'add'`: a brand-new component file. Without this, a file created
773
+ // mid-session gets no template at all until some OTHER file happens
774
+ // to change and drags it along on the next full pass — creating a
775
+ // component is completely ordinary, not an edge case.
776
+ server.watcher.on('add', (file: string) => {
777
+ if (!isComponentSourceFile(file) || !isUnderComponentDir(file)) return
778
+ devPassRunner.trigger()
779
+ })
780
+
781
+ // `'unlink'`: a tracked file was deleted. Its template would
782
+ // otherwise linger on disk forever — queue it for `removeEmitsFor`
783
+ // inside the same debounced/serialized pass rather than deleting
784
+ // synchronously here (see `pendingUnlinks` above).
785
+ server.watcher.on('unlink', (file: string) => {
786
+ if (!isComponentSourceFile(file) || !isUnderComponentDir(file)) return
787
+ pendingUnlinks.add(file)
788
+ devPassRunner.trigger()
789
+ })
790
+ },
791
+ }
792
+ }