@pyreon/vite-plugin 0.24.5 → 0.24.6

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.
package/src/index.ts DELETED
@@ -1,2116 +0,0 @@
1
- /**
2
- * @pyreon/vite-plugin — Vite integration for Pyreon framework.
3
- *
4
- * Applies Pyreon's JSX reactive transform to .tsx, .jsx, and .pyreon files,
5
- * and configures Vite to use Pyreon's JSX runtime.
6
- *
7
- * ## Basic usage (SPA)
8
- *
9
- * import pyreon from "@pyreon/vite-plugin"
10
- * export default { plugins: [pyreon()] }
11
- *
12
- * ## Drop-in compat mode (zero code changes)
13
- *
14
- * import pyreon from "@pyreon/vite-plugin"
15
- * export default { plugins: [pyreon({ compat: "react" })] }
16
- *
17
- * Aliases `react`, `react-dom`, `vue`, `solid-js`, or `preact` imports to
18
- * Pyreon's compat packages — existing code works without changing imports.
19
- *
20
- * ## SSR mode
21
- *
22
- * import pyreon from "@pyreon/vite-plugin"
23
- * export default { plugins: [pyreon({ ssr: { entry: "./src/entry-server.ts" } })] }
24
- *
25
- * In SSR mode, the plugin adds dev server middleware that:
26
- * 1. Loads your server entry via Vite's `ssrLoadModule`
27
- * 2. Calls the exported `handler` or default export (Request → Response)
28
- * 3. Returns the SSR'd HTML for every non-asset request
29
- *
30
- * For production, build separately:
31
- * vite build # client bundle
32
- * vite build --ssr src/entry-server.ts --outDir dist/server # server bundle
33
- */
34
-
35
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
36
- import { dirname, join as pathJoin } from 'node:path'
37
- import {
38
- type CollapsibleSite,
39
- generateContext,
40
- scanCollapsibleSites,
41
- transformDeferInline,
42
- transformJSX,
43
- } from '@pyreon/compiler'
44
- import type { CollapseResolver } from './rocketstyle-collapse'
45
- import type { Plugin, ViteDevServer } from 'vite'
46
-
47
- // Dev-mode counter sink — see packages/internals/perf-harness for contract.
48
- const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
49
- const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
50
-
51
- // Lazy — the resolver module (and its `vite` SSR machinery) must NOT be
52
- // on the static import path of this cheap entry. It loads ONLY when
53
- // `pyreon({ collapse })` is enabled AND a collapsible site is scanned;
54
- // collapse-off consumers never pull it (bundle-budget + cold-load).
55
- let _createCollapseResolver:
56
- | ((root: string) => Promise<CollapseResolver>)
57
- | null = null
58
- async function loadCreateCollapseResolver(): Promise<
59
- (root: string) => Promise<CollapseResolver>
60
- > {
61
- if (!_createCollapseResolver) {
62
- _createCollapseResolver = (await import('./rocketstyle-collapse')).createCollapseResolver
63
- }
64
- return _createCollapseResolver
65
- }
66
-
67
- // Virtual module ID for the HMR runtime
68
- const HMR_RUNTIME_ID = '\0pyreon/hmr-runtime'
69
- const HMR_RUNTIME_IMPORT = 'virtual:pyreon/hmr-runtime'
70
-
71
- // Virtual module ID for the auto-generated islands registry. See
72
- // `prescanIslandDeclarations` + the `load` hook for emit shape. Consumed by
73
- // `hydrateIslandsAuto()` in `@pyreon/server/client`.
74
- const ISLANDS_REGISTRY_ID = '\0pyreon/islands-registry'
75
- const ISLANDS_REGISTRY_IMPORT = 'virtual:pyreon/islands-registry'
76
-
77
- export type CompatFramework = 'react' | 'preact' | 'vue' | 'solid' | 'svelte'
78
-
79
- export interface PyreonPluginOptions {
80
- /**
81
- * Alias imports from an existing framework to Pyreon's compat layer.
82
- *
83
- * This lets you drop Pyreon into an existing project with zero code changes —
84
- * `import { useState } from "react"` will resolve to `@pyreon/react-compat`.
85
- *
86
- * @example
87
- * pyreon({ compat: "react" }) // react + react-dom → @pyreon/react-compat
88
- * pyreon({ compat: "vue" }) // vue → @pyreon/vue-compat
89
- * pyreon({ compat: "solid" }) // solid-js → @pyreon/solid-compat
90
- * pyreon({ compat: "svelte" }) // svelte + svelte/store → @pyreon/svelte-compat
91
- * pyreon({ compat: "preact" }) // preact + hooks + signals → @pyreon/preact-compat
92
- */
93
- compat?: CompatFramework
94
-
95
- /**
96
- * Enable SSR dev middleware.
97
- *
98
- * Pass an object with `entry` pointing to your server entry file.
99
- * The entry must export a `handler` function: `(req: Request) => Promise<Response>`
100
- * or a default export of the same type.
101
- *
102
- * @example
103
- * pyreonPlugin({ ssr: { entry: "./src/entry-server.ts" } })
104
- */
105
- ssr?: {
106
- /** Server entry file path (e.g. "./src/entry-server.ts") */
107
- entry: string
108
- }
109
-
110
- /**
111
- * Auto-discover `island()` declarations and expose them as
112
- * `virtual:pyreon/islands-registry` for `hydrateIslandsAuto()` in
113
- * `@pyreon/server/client`.
114
- *
115
- * Eliminates the manual sync between `island()` declarations and the
116
- * client-side `hydrateIslands({ ... })` registry — typo / forgotten entry /
117
- * registry drift is the #1 author foot-gun for islands.
118
- *
119
- * Defaults to `true`. The prescan is cheap (regex over the same files
120
- * already walked by `prescanSignalExports`); set to `false` only if you
121
- * have a reason not to support `hydrateIslandsAuto()`.
122
- *
123
- * `hydrate: 'never'` islands are deliberately OMITTED from the auto-
124
- * registry — the whole point of the strategy is shipping zero client JS,
125
- * so registering a loader (which would pull the component module into the
126
- * client bundle graph) defeats it.
127
- *
128
- * @example
129
- * pyreon({ islands: true })
130
- *
131
- * // src/entry-client.ts
132
- * import { hydrateIslandsAuto } from '@pyreon/server/client'
133
- * hydrateIslandsAuto()
134
- */
135
- islands?: boolean
136
-
137
- /**
138
- * **LPIH auto-bridge** — zero-config Live Program Inlay Hints in dev.
139
- *
140
- * When `true` (the default in dev), the plugin auto-wires the LPIH
141
- * cache file: the browser-side activates devtools + polls fire data
142
- * every `intervalMs` (250ms default), and the dev-server middleware
143
- * receives the POST + writes `<project-root>/.pyreon-lpih.json` using
144
- * the atomic-rename pattern from `@pyreon/reactivity/lpih`. The LSP
145
- * (`pyreon-lint --lsp`) auto-discovers that file, so the end-to-end
146
- * "save file → see fire counts" loop needs ZERO user wiring.
147
- *
148
- * Set to `false` to opt out (e.g. if you're wiring `startLpihPolling()`
149
- * yourself from a non-browser runtime, or you want LPIH off entirely).
150
- * Pass an object to override the interval or the cache-file path.
151
- *
152
- * Build-only consumer: production builds skip injection entirely.
153
- *
154
- * @example
155
- * pyreon({ lpih: true }) // default in dev
156
- * pyreon({ lpih: false }) // opt out
157
- * pyreon({ lpih: { intervalMs: 500 } }) // slower poll
158
- * pyreon({ lpih: { cachePath: '/tmp/x.json' } }) // custom path
159
- */
160
- lpih?: boolean | PyreonLpihOptions
161
-
162
- /**
163
- * P0 — opt-in compile-time rocketstyle wrapper collapse. `true` uses
164
- * the default provider/theme/mode wiring (PyreonUI + theme +
165
- * useMode from @pyreon/ui-core / @pyreon/ui-theme). Pass an object to
166
- * override. OFF by default (zero behaviour change). When on, the
167
- * plugin SSR-resolves every literal-prop call site of a candidate
168
- * component (real component, light + dark) and the compiler collapses
169
- * the 5-layer wrapper mount into a single `_rsCollapse` cloneNode.
170
- * Only the CLIENT graph is collapsed — the SSR graph keeps the normal
171
- * mount (and the resolver itself uses SSR render).
172
- *
173
- * @example pyreon({ collapse: true })
174
- * @example pyreon({ collapse: { components: ['Button', 'Badge'] } })
175
- */
176
- collapse?: boolean | PyreonCollapseOptions
177
- }
178
-
179
- export interface PyreonCollapseOptions {
180
- /**
181
- * Import sources whose components may collapse. Default:
182
- * `['@pyreon/ui-components']`. The compiler's AST scan only considers
183
- * a call site whose component was imported from one of these sources;
184
- * the conservative bail catalogue + the SSR resolver are the real
185
- * gate beyond that.
186
- */
187
- sources?: string[]
188
- /**
189
- * Optional local-name allowlist applied AFTER the source scan
190
- * (e.g. `['Button']`). Omit to collapse every collapsible component
191
- * from the configured sources.
192
- */
193
- components?: string[]
194
- /** Override the theme/mode provider. Default PyreonUI@@pyreon/ui-core. */
195
- provider?: { name: string; source: string }
196
- /** Override the theme object. Default theme@@pyreon/ui-theme. */
197
- theme?: { name: string; source: string }
198
- /** Override the live mode accessor. Default useMode@@pyreon/ui-core. */
199
- mode?: { name: string; source: string }
200
- }
201
-
202
- export interface PyreonLpihOptions {
203
- /**
204
- * Poll interval in milliseconds. The browser-side bridge reads
205
- * `getFireSummaries()` and POSTs every `intervalMs` to the dev-server
206
- * middleware. Default 250ms — matches the LSP-debounce window so
207
- * editor hints settle within one frame of the typical save→hint cycle.
208
- *
209
- * Lower values (e.g. 100ms) trade dev-server CPU for snappier hints;
210
- * higher values (1000ms) reduce overhead for slow machines.
211
- */
212
- intervalMs?: number
213
- /**
214
- * Cache-file path override. Defaults to
215
- * `<projectRoot>/.pyreon-lpih.json` — the convention the LSP auto-
216
- * discovers (R2, #777). Override only if you need a non-default
217
- * location (shared mount, custom workspace layout).
218
- */
219
- cachePath?: string
220
- }
221
-
222
- // ── Compat alias maps ─────────────────────────────────────────────────────────
223
-
224
- const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
225
- react: {
226
- react: '@pyreon/react-compat',
227
- 'react/jsx-runtime': '@pyreon/react-compat/jsx-runtime',
228
- 'react/jsx-dev-runtime': '@pyreon/react-compat/jsx-runtime',
229
- 'react-dom': '@pyreon/react-compat/dom',
230
- 'react-dom/client': '@pyreon/react-compat/dom',
231
- },
232
- preact: {
233
- preact: '@pyreon/preact-compat',
234
- 'preact/hooks': '@pyreon/preact-compat/hooks',
235
- 'preact/jsx-runtime': '@pyreon/preact-compat/jsx-runtime',
236
- 'preact/jsx-dev-runtime': '@pyreon/preact-compat/jsx-runtime',
237
- '@preact/signals': '@pyreon/preact-compat/signals',
238
- },
239
- vue: {
240
- vue: '@pyreon/vue-compat',
241
- 'vue/jsx-runtime': '@pyreon/vue-compat/jsx-runtime',
242
- 'vue/jsx-dev-runtime': '@pyreon/vue-compat/jsx-runtime',
243
- },
244
- solid: {
245
- 'solid-js': '@pyreon/solid-compat',
246
- 'solid-js/jsx-runtime': '@pyreon/solid-compat/jsx-runtime',
247
- 'solid-js/jsx-dev-runtime': '@pyreon/solid-compat/jsx-runtime',
248
- },
249
- svelte: {
250
- svelte: '@pyreon/svelte-compat',
251
- 'svelte/store': '@pyreon/svelte-compat/store',
252
- 'svelte/internal': '@pyreon/svelte-compat',
253
- 'svelte/jsx-runtime': '@pyreon/svelte-compat/jsx-runtime',
254
- 'svelte/jsx-dev-runtime': '@pyreon/svelte-compat/jsx-runtime',
255
- },
256
- }
257
-
258
- /**
259
- * Detect whether a file id resolves to a `@pyreon/*` framework-package source
260
- * (i.e. a published Pyreon package whose .tsx is being pulled in via the
261
- * `bun` condition workspace-link, NOT user code, NOT an example app).
262
- *
263
- * Why this exists: in compat mode, OXC's per-project `importSource` is set
264
- * to `@pyreon/core` and the resolveId hook redirects `@pyreon/core/jsx-runtime`
265
- * to the compat package. That's correct for user code (the whole point of
266
- * compat mode) but WRONG for framework-internal sources like
267
- * `@pyreon/zero/src/link.tsx`, which need the real `@pyreon/core` runtime.
268
- * The fix skips the redirect when the importer is a `@pyreon/*` framework
269
- * file. Result: published-package consumers (where `@pyreon/zero` resolves
270
- * to its pre-built `lib/`) and workspace-dev consumers (where it resolves
271
- * to source) both get correct JSX runtime resolution.
272
- *
273
- * Detection heuristic: walk to nearest `package.json`, require BOTH:
274
- * 1. `name` starts with `@pyreon/` (workspace member of the @pyreon scope)
275
- * 2. file path contains `/packages/` AND NOT `/examples/`
276
- *
277
- * Step 2 excludes the existing `@pyreon/example-{react,vue,solid,preact}-compat`
278
- * apps under `examples/`. Without it, user code in those apps would skip the
279
- * compat-mode JSX-runtime redirect and import `@pyreon/core/jsx-runtime`
280
- * directly — breaking the React/Vue/Solid/Preact compat layer's contract.
281
- *
282
- * Result cached per directory. The `/packages/` + `/examples/` check is a
283
- * structural property of the monorepo (workspace layout), not the package
284
- * name — so it's robust against renames.
285
- */
286
- function isPyreonWorkspaceFile(id: string, cache: Map<string, boolean>): boolean {
287
- // Strip query strings (e.g. `?vue&type=script`) to get the bare path.
288
- const queryIdx = id.indexOf('?')
289
- const filePath = queryIdx === -1 ? id : id.slice(0, queryIdx)
290
- if (!filePath || filePath[0] === '\0') return false
291
-
292
- // Path-based filter first (cheap): file must live under `<root>/packages/`
293
- // and not under `<root>/examples/`. This excludes example apps even when
294
- // they have `@pyreon/example-*` names.
295
- if (!filePath.includes('/packages/') || filePath.includes('/examples/')) {
296
- return false
297
- }
298
-
299
- let dir = dirname(filePath)
300
- // Walk up at most ~12 levels — enough for any realistic monorepo depth.
301
- for (let i = 0; i < 12; i++) {
302
- const cached = cache.get(dir)
303
- if (cached !== undefined) return cached
304
-
305
- const pkgPath = pathJoin(dir, 'package.json')
306
- if (existsSync(pkgPath)) {
307
- let isPyreon = false
308
- try {
309
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { name?: string }
310
- isPyreon = typeof pkg.name === 'string' && pkg.name.startsWith('@pyreon/')
311
- } catch {
312
- // Malformed package.json — treat as not-pyreon.
313
- }
314
- cache.set(dir, isPyreon)
315
- return isPyreon
316
- }
317
-
318
- const parent = dirname(dir)
319
- if (parent === dir) break // reached filesystem root
320
- dir = parent
321
- }
322
- return false
323
- }
324
-
325
- /**
326
- * Return the Pyreon compat target for an import specifier, or undefined if
327
- * the import should not be redirected.
328
- */
329
- function getCompatTarget(compat: CompatFramework | undefined, id: string): string | undefined {
330
- if (!compat) return undefined
331
- const aliased = COMPAT_ALIASES[compat][id]
332
- if (aliased) return aliased
333
- // OXC's JSX transform reads jsxImportSource from tsconfig (@pyreon/core),
334
- // not from our plugin config. Redirect JSX runtime imports in compat mode.
335
- if (id === '@pyreon/core/jsx-runtime' || id === '@pyreon/core/jsx-dev-runtime') {
336
- if (compat === 'react') return '@pyreon/react-compat/jsx-runtime'
337
- if (compat === 'preact') return '@pyreon/preact-compat/jsx-runtime'
338
- if (compat === 'vue') return '@pyreon/vue-compat/jsx-runtime'
339
- if (compat === 'solid') return '@pyreon/solid-compat/jsx-runtime'
340
- if (compat === 'svelte') return '@pyreon/svelte-compat/jsx-runtime'
341
- }
342
- return undefined
343
- }
344
-
345
- /**
346
- * Scan the consumer's package.json for `@pyreon/*` deps. Result is the
347
- * list of names to exclude from Vite's deps optimizer (avoids
348
- * `.vite/deps/@pyreon_*.js: File does not exist` runtime errors caused
349
- * by esbuild trying to pre-bundle TypeScript source files exposed via
350
- * the `bun` resolve condition).
351
- *
352
- * Reads dependencies + devDependencies + peerDependencies. Best-effort:
353
- * missing/malformed package.json returns an empty list so a typo in
354
- * the consumer's manifest doesn't break the build.
355
- */
356
- function scanPyreonDeps(root: string): string[] {
357
- const pkgPath = pathJoin(root, 'package.json')
358
- if (!existsSync(pkgPath)) return []
359
- try {
360
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as {
361
- dependencies?: Record<string, string>
362
- devDependencies?: Record<string, string>
363
- peerDependencies?: Record<string, string>
364
- }
365
- const all = {
366
- ...pkg.dependencies,
367
- ...pkg.devDependencies,
368
- ...pkg.peerDependencies,
369
- }
370
- return Object.keys(all).filter((name) => name.startsWith('@pyreon/'))
371
- } catch {
372
- return []
373
- }
374
- }
375
-
376
- export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
377
- const ssrConfig = options?.ssr
378
- const compat = options?.compat
379
- // Default islands support to enabled — the prescan is cheap and the virtual
380
- // module is harmless if the user has no `island()` calls. Opt out only if
381
- // you have a specific reason.
382
- const islandsEnabled = options?.islands !== false
383
-
384
- // ── LPIH auto-bridge config ──────────────────────────────────────────────
385
- // Default `true` (zero-config Live Program Inlay Hints in dev). Set to
386
- // `false` to opt out. Object form overrides interval / cache path.
387
- const lpihOpt = options?.lpih
388
- const lpihEnabled = lpihOpt !== false
389
- const lpihUserCfg: PyreonLpihOptions =
390
- lpihOpt && lpihOpt !== true ? lpihOpt : {}
391
- const lpihIntervalMs = lpihUserCfg.intervalMs ?? 250
392
-
393
- // ── P0 rocketstyle-collapse config (opt-in) ───────────────────────────────
394
- const collapseOpt = options?.collapse
395
- const collapseEnabled = collapseOpt === true || (collapseOpt != null && collapseOpt !== false)
396
- const collapseUserCfg: PyreonCollapseOptions =
397
- collapseOpt && collapseOpt !== true ? collapseOpt : {}
398
- const collapseProvider = collapseUserCfg.provider ?? {
399
- name: 'PyreonUI',
400
- source: '@pyreon/ui-core',
401
- }
402
- const collapseTheme = collapseUserCfg.theme ?? { name: 'theme', source: '@pyreon/ui-theme' }
403
- const collapseMode = collapseUserCfg.mode ?? { name: 'useMode', source: '@pyreon/ui-core' }
404
- const collapseSources = new Set(collapseUserCfg.sources ?? ['@pyreon/ui-components'])
405
- const collapseComponentFilter = collapseUserCfg.components
406
- ? (n: string) => collapseUserCfg.components!.includes(n)
407
- : null
408
- // Lazily created on first client-graph transform; one Vite SSR server
409
- // reused for every resolve in the build. Disposed in closeBundle.
410
- let collapseResolver: import('./rocketstyle-collapse').CollapseResolver | null = null
411
- let collapseResolverInit: Promise<
412
- import('./rocketstyle-collapse').CollapseResolver | null
413
- > | null = null
414
-
415
- /**
416
- * Lazily spin ONE programmatic Vite SSR server (bound to the project's
417
- * own vite config) the first time a client-graph module actually has a
418
- * collapsible call site. Memoized via `collapseResolverInit` so
419
- * concurrent transforms share the single server. Returns null if the
420
- * server fails to start (graceful — every call site then keeps its
421
- * normal rocketstyle mount).
422
- */
423
- function ensureCollapseResolver(): Promise<
424
- import('./rocketstyle-collapse').CollapseResolver | null
425
- > {
426
- if (collapseResolver) return Promise.resolve(collapseResolver)
427
- if (collapseResolverInit) return collapseResolverInit
428
- collapseResolverInit = loadCreateCollapseResolver()
429
- .then((create) => create(projectRoot))
430
- .then((r) => {
431
- collapseResolver = r
432
- return r
433
- })
434
- .catch(() => null)
435
- return collapseResolverInit
436
- }
437
-
438
- let isBuild = false
439
- // Collapse is build-only by design: the resolver computes each site's
440
- // class from a SEPARATE nested Vite SSR server's module graph and caches
441
- // it. In dev that frozen class would NOT react to the user's theme-source
442
- // HMR edits — strictly worse than the normal mount, which IS reactive.
443
- // So dev keeps the normal mount; we surface that ONCE so an opted-in
444
- // consumer running `vite dev` isn't left wondering why nothing collapsed.
445
- let warnedDevCollapse = false
446
- let projectRoot = ''
447
-
448
- // ── Cross-module signal export registry ─────────────────────────────────
449
- // Tracks which modules export signal() declarations so imported signals
450
- // can be auto-called in JSX across file boundaries.
451
- // Key: normalized module ID, Value: set of exported signal names
452
- const signalExportRegistry = new Map<string, Set<string>>()
453
- // Cache resolved import specifiers to avoid redundant resolution calls
454
- const resolveCache = new Map<string, string | null>()
455
- // Cache `isPyreonWorkspaceFile` lookups by directory — package.json reads
456
- // happen at most once per containing directory across the build.
457
- const pyreonWorkspaceDirCache = new Map<string, boolean>()
458
-
459
- // ── Island declaration registry ─────────────────────────────────────────
460
- // Tracks every `island(() => import('PATH'), { name: 'X', hydrate: 'Y' })`
461
- // call across the source tree. Keyed by absolute source-file path of the
462
- // declaration site so HMR can invalidate per-file. Each entry's loader path
463
- // is resolved relative to the file where the call was written.
464
- const islandRegistry = new Map<string, IslandDecl[]>()
465
-
466
- return {
467
- name: 'pyreon',
468
- enforce: 'pre',
469
-
470
- config(userConfig, env) {
471
- isBuild = env.command === 'build'
472
- // Capture the project root for package resolution in resolveId
473
- projectRoot = userConfig.root ?? process.cwd()
474
-
475
- // Tell Vite's dep scanner not to pre-bundle the aliased framework imports —
476
- // they resolve to workspace packages via our resolveId hook, not node_modules.
477
- const compatExclude = compat ? Object.keys(COMPAT_ALIASES[compat]) : []
478
- // Auto-detect `@pyreon/*` deps in the consumer's package.json and add
479
- // them to optimizeDeps.exclude. Vite's deps optimizer pre-bundles
480
- // node_modules deps via esbuild, but the plugin's `bun` resolve
481
- // condition redirects every `@pyreon/*` import to source `.ts(x)`
482
- // files. Esbuild's pre-bundler can't process raw TS source from a
483
- // published package and silently produces broken bundles in
484
- // `.vite/deps/`, surfacing as `File does not exist at
485
- // .../node_modules/.vite/deps/@pyreon_styler.js` errors at runtime.
486
- // Excluding them sidesteps the optimizer entirely — they're resolved
487
- // on demand via the plugin's resolveId hook + Vite's normal source
488
- // pipeline. Workspace-linked apps in this monorepo aren't affected
489
- // because Vite never tries to pre-bundle workspace deps.
490
- const pyreonExclude = scanPyreonDeps(projectRoot)
491
- const optimizeDepsExclude = Array.from(
492
- new Set([...compatExclude, ...pyreonExclude]),
493
- )
494
-
495
- // Always set OXC's JSX importSource to `@pyreon/core`. In compat mode,
496
- // we redirect `@pyreon/core/jsx-runtime` imports to the compat package
497
- // VIA `resolveId` — but ONLY for user code, never for `@pyreon/*`
498
- // workspace-package files (zero, router, runtime-dom, etc.). Setting
499
- // OXC's importSource directly to the compat package would force the
500
- // compat runtime on framework internals too, which they cannot handle.
501
- const jsxSource = '@pyreon/core'
502
-
503
- return {
504
- // Use "bun" condition for workspace resolution — source .ts/.tsx files
505
- // for HMR, fast refresh, and type-safe imports.
506
- resolve: { conditions: ['bun'] },
507
- // Force every `@pyreon/*` package through Vite's transform pipeline
508
- // for SSR. Without this, Vite externalizes some `@pyreon/*` packages
509
- // (loads via Node's `import()`) while transforming others — producing
510
- // TWO module instances of `@pyreon/core` (one at `lib/index.js`, one
511
- // at `src/index.ts` via the `bun` condition). The two instances have
512
- // SEPARATE `_current` lifecycle state, so `runWithHooks` sets
513
- // `_current` on instance A while `provide()` reads `_current` from
514
- // instance B → null → `provide() outside setup` warning storm.
515
- //
516
- // Real-app symptom (bokisch.com dev-404 SSR, 0.24.4): 17 spurious
517
- // `[Pyreon] onUnmount() called outside component setup` warnings
518
- // per unmatched URL hit, even though every `provide()` IS structurally
519
- // inside a `runWithHooks` setup window. Fix is purely a Vite
520
- // module-graph reconciliation; no runtime behavior change.
521
- //
522
- // The regex `/@pyreon\//` matches every framework package + every
523
- // user-side `@pyreon/*` import. Internal `@pyreon/*` resolution
524
- // chains (zero → runtime-server → core; user `_layout.tsx` →
525
- // ui-core → core) all converge on the same module instance.
526
- ssr: {
527
- noExternal: [/@pyreon\//],
528
- },
529
- optimizeDeps: {
530
- exclude: optimizeDepsExclude,
531
- },
532
- // Vite 8 uses oxc for JSX transform (not esbuildOptions or rolldownOptions)
533
- oxc: {
534
- jsx: {
535
- runtime: 'automatic',
536
- importSource: jsxSource,
537
- },
538
- },
539
- // In SSR build mode, configure the entry
540
- ...(env.isSsrBuild && ssrConfig
541
- ? {
542
- build: {
543
- ssr: true,
544
- rollupOptions: {
545
- input: ssrConfig.entry,
546
- },
547
- },
548
- }
549
- : {}),
550
- }
551
- },
552
-
553
- // ── Pre-scan all source files for signal exports ──────────────────────
554
- async buildStart() {
555
- // Pre-scan all source files for signal exports so the registry
556
- // is complete before any transforms run. This solves the build
557
- // ordering problem where component.tsx is transformed before
558
- // store.ts — without pre-scanning, the registry would be empty.
559
- await prescanSignalExports(projectRoot, signalExportRegistry)
560
-
561
- // Mirror prescan for `island()` declarations. The result populates
562
- // `virtual:pyreon/islands-registry`, consumed by `hydrateIslandsAuto()`
563
- // in `@pyreon/server/client`. Eliminates the manual sync between
564
- // `island()` source-of-truth and the client `hydrateIslands({ ... })`
565
- // call — the #1 author foot-gun for islands.
566
- if (islandsEnabled) {
567
- await prescanIslandDeclarations(projectRoot, islandRegistry)
568
- }
569
- },
570
-
571
- // @internal — debug accessor for tests; returns live references to
572
- // the per-instance caches so `cache-eviction-on-delete.test.ts` can
573
- // assert on contents. Symbol.for-keyed so it's not part of the
574
- // plugin's documented surface but stays stable across reloads.
575
- [Symbol.for('pyreon/vite-plugin:caches')]: {
576
- signalExportRegistry,
577
- resolveCache,
578
- pyreonWorkspaceDirCache,
579
- islandRegistry,
580
- },
581
-
582
- // ── Cache invalidation on file delete (long-running `vite dev`) ─────
583
- // Vite's `watchChange` hook fires on filesystem events for files in
584
- // the watched module graph. Without this, the four per-instance
585
- // caches (`signalExportRegistry`, `resolveCache`, `islandRegistry`,
586
- // `pyreonWorkspaceDirCache`) accumulated stale entries for the
587
- // entire lifetime of the dev server — a long `vite dev` session
588
- // that edited / renamed / deleted source files would grow each
589
- // cache by one entry per dead file. Bounded by total source tree
590
- // size in practice, but a real leak over hours of editing.
591
- //
592
- // `'create' | 'update'` events are handled implicitly by the
593
- // existing transform-time `scanSignalExports` /
594
- // `scanIslandDeclarations` calls — they re-populate the registry
595
- // every time a file's `transform` hook fires, overwriting any
596
- // stale entry. So watchChange only needs to handle `'delete'`.
597
- watchChange(id: string, change: { event: 'create' | 'update' | 'delete' }) {
598
- if (change.event !== 'delete') return
599
-
600
- // Leak-class C diagnostic — emit per handled delete event. Bounded
601
- // by file-deletion count in a dev session; should grow strictly
602
- // monotonically with developer edit activity. Zero in a session
603
- // with known deletes = the watchChange hook regressed (and the
604
- // 4 per-instance caches will leak again).
605
- if (__DEV__) _countSink.__pyreon_count__?.('vite-plugin.watchChange.delete')
606
-
607
- const normalized = normalizeModuleId(id)
608
-
609
- // 1) signalExportRegistry — keyed by normalized module id.
610
- signalExportRegistry.delete(normalized)
611
-
612
- // 2) islandRegistry — keyed by absolute source path of the
613
- // declaration site (the original `id`, not normalized).
614
- islandRegistry.delete(id)
615
- // Also try the normalized form just in case the registry was
616
- // populated with a slightly different shape.
617
- if (normalized !== id) islandRegistry.delete(normalized)
618
-
619
- // 3) resolveCache — keyed by `${importer}::${source}` where
620
- // `importer` is normalized AND values can be the deleted
621
- // file's resolved path. Sweep both directions:
622
- // a) entries WHERE the deleted file is the importer (this
623
- // file's resolved imports are no longer relevant).
624
- // b) entries WHERE the deleted file is the resolved value
625
- // (other files importing the deleted file need to
626
- // re-resolve so they see `null` next time).
627
- const importerPrefix = `${normalized}::`
628
- for (const [key, value] of resolveCache) {
629
- if (key.startsWith(importerPrefix) || value === normalized) {
630
- resolveCache.delete(key)
631
- }
632
- }
633
-
634
- // 4) pyreonWorkspaceDirCache — keyed by DIRECTORY, not file. A
635
- // single file deletion doesn't invalidate the directory's
636
- // workspace status (other files may still live there), so
637
- // this cache stays. Bounded by source-tree directory count
638
- // in any case (small + finite).
639
- },
640
-
641
- // Tear down the one programmatic Vite SSR server the collapse
642
- // resolver holds (created lazily on first client-graph transform).
643
- async closeBundle() {
644
- if (collapseResolver) {
645
- await collapseResolver.dispose()
646
- collapseResolver = null
647
- collapseResolverInit = null
648
- }
649
- },
650
-
651
- // ── Virtual module + compat alias resolution ─────────────────────────────
652
- async resolveId(id, importer) {
653
- if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
654
- if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID
655
-
656
- // `@pyreon/core/jsx-runtime` resolves to the compat package only for
657
- // user code — never for `@pyreon/*` framework files (zero, router,
658
- // runtime-dom, etc.). Without this importer guard, every JSX file in
659
- // the build (including framework internals resolved via the `bun`
660
- // workspace condition) would get redirected to a compat runtime that
661
- // doesn't match the framework's JSX shape. Caught by `cpa-smoke-app-*-compat`.
662
- if (
663
- compat &&
664
- (id === '@pyreon/core/jsx-runtime' || id === '@pyreon/core/jsx-dev-runtime') &&
665
- importer &&
666
- isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)
667
- ) {
668
- return // let Vite resolve to the real `@pyreon/core/jsx-runtime`
669
- }
670
-
671
- const target = getCompatTarget(compat, id)
672
- if (!target) return
673
-
674
- // Vite 8 resolves the "bun" condition natively via resolve.conditions.
675
- // Delegate to Vite's resolver instead of manual package.json parsing.
676
- const resolved = await this.resolve(target, importer, { skipSelf: true })
677
- return resolved?.id
678
- },
679
-
680
- load(id) {
681
- if (id === HMR_RUNTIME_ID) {
682
- return HMR_RUNTIME_SOURCE
683
- }
684
- if (id === ISLANDS_REGISTRY_ID) {
685
- return renderIslandsRegistry(islandRegistry, islandsEnabled)
686
- }
687
- },
688
-
689
- async transform(code, id, transformOptions) {
690
- const ext = getExt(id)
691
- if (ext !== '.tsx' && ext !== '.jsx' && ext !== '.pyreon') return
692
-
693
- // In compat mode, skip Pyreon's reactive JSX transform but apply
694
- // attribute renames (className → class, htmlFor → for) so source code
695
- // that uses React-style attribute names works correctly.
696
- if (
697
- compat === 'react' ||
698
- compat === 'preact' ||
699
- compat === 'vue' ||
700
- compat === 'solid' ||
701
- compat === 'svelte'
702
- ) {
703
- if (compat === 'react' || compat === 'preact') {
704
- const transformed = transformCompatAttributes(code)
705
- if (transformed !== code) return { code: transformed, map: null }
706
- }
707
- return
708
- }
709
-
710
- // ── Scan for exported signal declarations (populate registry) ──────
711
- // This runs on every .tsx/.jsx file so the registry is built
712
- // incrementally. buildStart pre-scans all files, but this handles
713
- // files created/modified after buildStart (dev mode HMR).
714
- scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
715
-
716
- // ── Same incremental update for island() declarations ──────────────
717
- // HMR: when a user adds/renames/removes an island() call, the
718
- // virtual:pyreon/islands-registry module needs to reflect it on the
719
- // next dev-server module reload.
720
- if (islandsEnabled) scanIslandDeclarations(code, id, islandRegistry)
721
-
722
- // ── Inline-Defer pre-pass ──────────────────────────────────────────
723
- // Rewrites `<Defer when={x}><Modal /></Defer>` into the explicit
724
- // chunk-prop form so Rolldown emits a proper per-Defer chunk and
725
- // the main bundle drops the static `import { Modal } from ...`
726
- // when it's exclusively used inside this Defer's subtree. Runs
727
- // BEFORE the JSX→runtime transform so the downstream pipeline
728
- // sees an already-explicit `<Defer chunk={...}>` shape with no
729
- // special-casing needed in `transformJSX`. See
730
- // `@pyreon/compiler/defer-inline` for the rewrite contract.
731
- const deferResult = transformDeferInline(code, id)
732
- const sourceForJsx = deferResult.changed ? deferResult.code : code
733
- for (const w of deferResult.warnings) {
734
- this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
735
- }
736
-
737
- // ── Resolve imported signals from the registry ─────────────────────
738
- // Check each import in this file: if the imported module has signal
739
- // exports in the registry, pass them as knownSignals to the compiler.
740
- const knownSignals = await resolveImportedSignals(sourceForJsx, id, signalExportRegistry, this, resolveCache)
741
-
742
- // Vite passes `ssr: true` when transforming for the SSR module graph
743
- // (both build --ssr and dev `ssrLoadModule`). The compiler emits plain
744
- // `h()` calls in that mode so `runtime-server` can render to a string.
745
- const isSsr = transformOptions?.ssr === true
746
-
747
- // ── P0 rocketstyle-collapse (opt-in, CLIENT graph only) ────────────
748
- // Never collapse the SSR graph: renderToString needs the real
749
- // VNode tree, AND the resolver itself SSR-renders the component —
750
- // collapsing the SSR graph would be circular. Resolve every
751
- // scanned literal-prop site once (real component, light + dark)
752
- // and hand the compiler a key→emission map; the compiler's AST
753
- // bail catalogue is the real gate, an unresolved key just falls
754
- // back to the normal mount.
755
- let collapseRocketstyle:
756
- | NonNullable<Parameters<typeof transformJSX>[2]>['collapseRocketstyle']
757
- | undefined
758
- if (collapseEnabled && !isBuild && !isSsr && !warnedDevCollapse) {
759
- warnedDevCollapse = true
760
- this.info(
761
- '[Pyreon] collapse is build-only — `vite dev` keeps the normal rocketstyle mount so theme-source edits stay HMR-reactive. Production `vite build` collapses the literal-prop sites.',
762
- )
763
- }
764
- if (collapseEnabled && isBuild && !isSsr) {
765
- const scanned: CollapsibleSite[] = scanCollapsibleSites(
766
- sourceForJsx,
767
- id,
768
- collapseSources,
769
- ).filter((s) => !collapseComponentFilter || collapseComponentFilter(s.componentName))
770
- if (scanned.length > 0) {
771
- const resolver = await ensureCollapseResolver()
772
- if (resolver) {
773
- const sites = new Map<
774
- string,
775
- {
776
- templateHtml: string
777
- lightClass: string
778
- darkClass: string
779
- rules: string[]
780
- ruleKey: string
781
- }
782
- >()
783
- const candidates = new Set<string>()
784
- for (const s of scanned) {
785
- const resolved = await resolver.resolve({
786
- component: { name: s.importedName, source: s.source },
787
- props: s.props,
788
- childrenText: s.childrenText,
789
- config: {
790
- provider: collapseProvider,
791
- theme: collapseTheme,
792
- mode: collapseMode,
793
- },
794
- })
795
- if (!resolved) continue
796
- candidates.add(s.componentName)
797
- sites.set(s.key, {
798
- templateHtml: resolved.templateHtml,
799
- lightClass: resolved.lightClass,
800
- darkClass: resolved.darkClass,
801
- rules: resolved.rules,
802
- ruleKey: resolved.key,
803
- })
804
- }
805
- if (sites.size > 0) {
806
- collapseRocketstyle = { candidates, sites, mode: collapseMode }
807
- }
808
- }
809
- }
810
- }
811
-
812
- const result = transformJSX(sourceForJsx, id, {
813
- ssr: isSsr,
814
- knownSignals,
815
- ...(collapseRocketstyle ? { collapseRocketstyle } : {}),
816
- })
817
- // Surface compiler warnings in the terminal
818
- for (const w of result.warnings) {
819
- this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
820
- }
821
-
822
- let output = result.code
823
-
824
- // ── Dev-only transforms ────────────────────────────────────────────
825
- if (!isBuild) {
826
- output = injectHmr(output, id)
827
- // Inject debug names + LPIH source locations for signal() calls
828
- // not rewritten by HMR. `id` is Vite's resolved module path —
829
- // the same path the runtime would have parsed from new Error().
830
- output = injectSignalNames(output, id)
831
- }
832
-
833
- // R12: surface the compiler's V3 source map so stack traces /
834
- // breakpoints in Pyreon components resolve to the right source line
835
- // (the JS backend now emits one; substitutions shift line counts, so
836
- // `map: null` previously mislocated every frame app-wide). Exact in
837
- // build; in dev the small extra HMR / signal-name injections aren't
838
- // re-mapped (still vastly better than no map). The native backend
839
- // emits no map yet (its own scoped follow-up) → `null`, unchanged
840
- // behaviour for that path.
841
- return { code: output, map: result.map ?? null }
842
- },
843
-
844
- // ── SSR dev middleware ───────────────────────────────────────────────────
845
- configureServer(server: ViteDevServer) {
846
- // Generate .pyreon/context.json for AI tools on dev server start
847
- generateProjectContext(projectRoot)
848
-
849
- // Debounced regeneration on file changes
850
- let contextTimer: ReturnType<typeof setTimeout> | null = null
851
- server.watcher.on('change', (file) => {
852
- if (/\.(tsx|jsx|ts|js)$/.test(file) && !file.includes('node_modules')) {
853
- if (contextTimer) clearTimeout(contextTimer)
854
- contextTimer = setTimeout(() => generateProjectContext(projectRoot), 500)
855
- }
856
- })
857
-
858
- // LPIH auto-bridge — accepts POST /__pyreon_lpih__ from the browser
859
- // client and atomically writes the cache file the LSP auto-discovers.
860
- // Registered BEFORE the SSR middleware so it short-circuits and never
861
- // falls through to handleSsrRequest.
862
- if (lpihEnabled) {
863
- registerLpihMiddleware(server, projectRoot, lpihUserCfg)
864
- }
865
-
866
- if (!ssrConfig) return
867
-
868
- // Return a function so the middleware runs AFTER Vite's built-in middleware
869
- // (static files, HMR, etc.) — only handle requests that Vite doesn't serve.
870
- return () => {
871
- server.middlewares.use(async (req, res, next) => {
872
- if (req.method !== 'GET') return next()
873
- const url = req.url ?? '/'
874
- if (isAssetRequest(url)) return next()
875
-
876
- try {
877
- await handleSsrRequest(server, ssrConfig.entry, url, req, res, next)
878
- } catch (err) {
879
- server.ssrFixStacktrace(err as Error)
880
- next(err)
881
- }
882
- })
883
- }
884
- },
885
-
886
- // ── LPIH auto-bridge client injection ────────────────────────────────────
887
- transformIndexHtml(html: string): string | undefined {
888
- if (isBuild || !lpihEnabled) return undefined
889
- // Inject a tiny <script type="module"> that activates devtools + polls
890
- // getFireSummaries() and POSTs to /__pyreon_lpih__. The dev server
891
- // middleware (above) writes the body to <projectRoot>/.pyreon-lpih.json
892
- // using @pyreon/reactivity's atomic-rename pattern. The LSP
893
- // auto-discovers that file (R2, #777) so the user wires NOTHING.
894
- const script = buildLpihClientScript(lpihIntervalMs)
895
- return html.replace('</head>', `${script}\n</head>`)
896
- },
897
- }
898
- }
899
-
900
- async function handleSsrRequest(
901
- server: ViteDevServer,
902
- entry: string,
903
- url: string,
904
- req: import('node:http').IncomingMessage,
905
- res: import('node:http').ServerResponse,
906
- next: (err?: unknown) => void,
907
- ): Promise<void> {
908
- const mod = await server.ssrLoadModule(entry)
909
- const handler = mod.handler ?? mod.default
910
-
911
- if (typeof handler !== 'function') {
912
- next()
913
- return
914
- }
915
-
916
- const origin = `http://${req.headers.host ?? 'localhost'}`
917
- const fullUrl = new URL(url, origin)
918
- const request = new Request(fullUrl.href, {
919
- method: req.method ?? 'GET',
920
- headers: Object.entries(req.headers).reduce((h, [k, v]) => {
921
- if (v) h.set(k, Array.isArray(v) ? v.join(', ') : v)
922
- return h
923
- }, new Headers()),
924
- })
925
-
926
- const response: Response = await handler(request)
927
- let html = await response.text()
928
-
929
- html = await server.transformIndexHtml(url, html)
930
-
931
- res.statusCode = response.status
932
- response.headers.forEach((v, k) => {
933
- res.setHeader(k, v)
934
- })
935
- res.end(html)
936
- }
937
-
938
- // ── AI context generation ─────────────────────────────────────────────────────
939
-
940
- /**
941
- * Generate .pyreon/context.json — project map for AI coding assistants.
942
- * Delegates to @pyreon/compiler's unified project scanner.
943
- */
944
- function generateProjectContext(root: string): void {
945
- try {
946
- const context = generateContext(root)
947
- const outDir = pathJoin(root, '.pyreon')
948
- if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true })
949
- writeFileSync(pathJoin(outDir, 'context.json'), JSON.stringify(context, null, 2), 'utf-8')
950
- } catch {
951
- // Silently fail — context generation is best-effort
952
- }
953
- }
954
-
955
- // ── LPIH auto-bridge helpers ───────────────────────────────────────────────
956
-
957
- /**
958
- * Resolve the LPIH cache-file path for a given project root. Matches the
959
- * convention `@pyreon/reactivity/lpih`'s `getDefaultLpihCachePath()` uses
960
- * AND the LSP auto-discovers (R2, #777): `<projectRoot>/.pyreon-lpih.json`.
961
- *
962
- * @internal — exported for tests.
963
- */
964
- export function resolveLpihCachePath(projectRoot: string): string {
965
- return pathJoin(projectRoot, '.pyreon-lpih.json')
966
- }
967
-
968
- /**
969
- * Register the LPIH dev-server middleware on a Vite server. Extracted from
970
- * `configureServer` so the `cachePath` option reference lives at module
971
- * scope (top-level helper) rather than inside the plugin's inline body —
972
- * keeps `scripts/audit-types.ts` happy regardless of how its comment-
973
- * stripping handles the long inline `configureServer` block.
974
- *
975
- * @internal — exported for tests.
976
- */
977
- export function registerLpihMiddleware(
978
- server: ViteDevServer,
979
- projectRoot: string,
980
- userCfg: PyreonLpihOptions,
981
- ): void {
982
- const cachePath = userCfg.cachePath ?? resolveLpihCachePath(projectRoot)
983
- server.middlewares.use('/__pyreon_lpih__', (req, res) => {
984
- if (req.method !== 'POST') {
985
- res.statusCode = 405
986
- res.end('Method Not Allowed')
987
- return
988
- }
989
- let body = ''
990
- req.on('data', (chunk: Buffer | string) => {
991
- body += chunk.toString()
992
- // Defensive cap — fire payloads are tiny (a few KB at most);
993
- // anything larger is malicious or buggy. Drop the request.
994
- if (body.length > 1024 * 1024) {
995
- res.statusCode = 413
996
- res.end('Payload Too Large')
997
- req.destroy()
998
- }
999
- })
1000
- req.on('end', () => {
1001
- void writeLpihCacheFile(cachePath, body)
1002
- .then(() => {
1003
- res.statusCode = 204
1004
- res.end()
1005
- })
1006
- .catch((err: unknown) => {
1007
- // Don't crash the dev server — log + return 500 so the
1008
- // browser-side bridge can back off + retry next interval.
1009
- // oxlint-disable-next-line no-console
1010
- console.warn(
1011
- '[pyreon] LPIH cache write failed:',
1012
- err instanceof Error ? err.message : err,
1013
- )
1014
- res.statusCode = 500
1015
- res.end('LPIH cache write failed')
1016
- })
1017
- })
1018
- })
1019
- }
1020
-
1021
- let _lpihSeq = 0
1022
-
1023
- /**
1024
- * Atomically write a LPIH cache file (tmp + rename), mirroring the
1025
- * `@pyreon/reactivity/lpih:writeLpihCache` implementation. The payload
1026
- * comes pre-serialized from the browser-side bridge — we validate the
1027
- * outer shape (`{ fires: [...] }`) and reject malformed bodies to stop a
1028
- * buggy client from corrupting the file the LSP reads.
1029
- *
1030
- * @internal — exported for tests.
1031
- */
1032
- export async function writeLpihCacheFile(path: string, body: string): Promise<void> {
1033
- // Validate shape — must be a JSON object with `fires: array`. We re-
1034
- // serialize so the on-disk format is stable regardless of how the
1035
- // browser-side bridge encodes it.
1036
- let parsed: unknown
1037
- try {
1038
- parsed = JSON.parse(body)
1039
- } catch {
1040
- throw new Error('LPIH bridge: payload is not valid JSON')
1041
- }
1042
- if (
1043
- parsed === null ||
1044
- typeof parsed !== 'object' ||
1045
- !Array.isArray((parsed as { fires?: unknown }).fires)
1046
- ) {
1047
- throw new Error('LPIH bridge: payload is missing `fires` array')
1048
- }
1049
- const fs = await import('node:fs/promises')
1050
- const pid = typeof process !== 'undefined' && 'pid' in process ? process.pid : 0
1051
- const tmp = `${path}.tmp.${pid}.${++_lpihSeq}`
1052
- // Single try/catch covering BOTH writeFile AND rename. The previous
1053
- // shape only guarded the rename — if `fs.writeFile` itself threw (disk
1054
- // full, EIO, EACCES, transient FS error), the partial tmp file leaked
1055
- // on disk with a unique PID+seq name (so no conflict, but it accumulated
1056
- // forever). Audit caught this in the LPIH followups round.
1057
- try {
1058
- await fs.writeFile(tmp, JSON.stringify(parsed), 'utf8')
1059
- await fs.rename(tmp, path)
1060
- } catch (err) {
1061
- // Best-effort cleanup; original error is more useful than unlink's.
1062
- // Covers BOTH the writeFile-failed (tmp may not exist) and the
1063
- // rename-failed (tmp exists, rename didn't move it) cases —
1064
- // `fs.unlink` of a non-existent file throws ENOENT, which we swallow.
1065
- try {
1066
- await fs.unlink(tmp)
1067
- } catch {
1068
- /* swallow — original error is the user-facing one */
1069
- }
1070
- throw err
1071
- }
1072
- }
1073
-
1074
- /**
1075
- * Build the `<script type="module">` body injected into the HTML head.
1076
- * The script imports devtools activation + `getFireSummaries` from
1077
- * `@pyreon/reactivity`, sets up a `setInterval` that POSTs every
1078
- * `intervalMs` ms, and registers a `beforeunload` cleanup so the timer
1079
- * doesn't outlive the page.
1080
- *
1081
- * Browser bundlers serve `@pyreon/reactivity` from the workspace via
1082
- * Vite's normal module resolution — no virtual module needed.
1083
- *
1084
- * @internal — exported for tests.
1085
- */
1086
- export function buildLpihClientScript(intervalMs: number): string {
1087
- // Note: the script body is intentionally compact — the goal is zero
1088
- // visible payload in DevTools "Sources" while still being readable
1089
- // when someone DOES go looking. `JSON.stringify` for `intervalMs` is
1090
- // defense against `__proto__` / NaN / non-finite values reaching the
1091
- // emitted JS as a literal.
1092
- // CRITICAL — top-level await on the dynamic import. `<script type="module">`
1093
- // tags execute in document order with `defer` semantics; the head-injected
1094
- // LPIH script's body MUST fully evaluate (including this await) BEFORE the
1095
- // body-injected app entry's module body runs. Otherwise activateReactiveDevtools()
1096
- // would land AFTER the app has already created its module-scope signals,
1097
- // and `_rdRegister` (gated on `if (!_active) return undefined`) would skip
1098
- // them entirely — making the most common signal shape (top-of-file `const x = signal(0)`)
1099
- // invisible to LPIH. With the `await`, the LPIH module doesn't complete
1100
- // until activation finishes; the app's entry waits its turn.
1101
- return `<script type="module">
1102
- // Pyreon LPIH auto-bridge — POSTs fire summaries to /__pyreon_lpih__
1103
- // so the LSP (pyreon-lint --lsp) sees live fire data. Dev-only.
1104
- const __px = await import('@pyreon/reactivity').catch(() => null)
1105
- if (__px) {
1106
- __px.activateReactiveDevtools()
1107
- const __pxGet = __px.getFireSummaries
1108
- const __pxInterval = ${JSON.stringify(intervalMs)}
1109
- const __pxPost = () => {
1110
- const summaries = __pxGet()
1111
- const payload = JSON.stringify({
1112
- fires: summaries.map((s) => ({
1113
- file: s.loc.file,
1114
- line: s.loc.line,
1115
- count: s.count,
1116
- kind: s.kind,
1117
- lastFire: s.lastFire,
1118
- rate1s: s.rate1s,
1119
- })),
1120
- })
1121
- fetch('/__pyreon_lpih__', { method: 'POST', body: payload, headers: { 'content-type': 'application/json' } }).catch(() => {
1122
- // Dev-server might be restarting; swallow + retry next interval.
1123
- })
1124
- }
1125
- const __pxId = setInterval(__pxPost, __pxInterval)
1126
- window.addEventListener('beforeunload', () => clearInterval(__pxId))
1127
- }
1128
- // If __px is null, @pyreon/reactivity isn't in the dep graph — stay silent,
1129
- // LPIH is opt-in via the runtime API too. The dynamic-import catch returns
1130
- // null instead of letting the rejection bubble so consumers without the
1131
- // package don't see a console error.
1132
- </script>`
1133
- }
1134
-
1135
- // ── HMR injection ─────────────────────────────────────────────────────────────
1136
-
1137
- /**
1138
- * Regex that detects signal declarations (prefix + variable name).
1139
- * The arguments are extracted via balanced-paren matching in `injectHmr`.
1140
- * A brace-depth check filters out matches inside functions/blocks — only
1141
- * module-scope (depth 0) signals are rewritten for HMR state preservation.
1142
- *
1143
- * The optional `<...>` group accepts a TypeScript type parameter so that
1144
- * `signal<T>(initial)` declarations are also rewritten — without it, any
1145
- * generic-typed module-scope signal silently skipped HMR preservation.
1146
- *
1147
- * The inner `(?:[^<>]|<[^<>]*>)*` permits one level of generic nesting
1148
- * (e.g. `signal<Array<Row>>([])`, `signal<Map<string, number>>(m)`).
1149
- * Deeper nesting (`signal<Array<{ id: T<U> }>>(...)`) falls back to
1150
- * not-rewritten — tracked as a follow-up if real consumers need it,
1151
- * but unlikely at module scope where generics are usually shallow.
1152
- */
1153
- const SIGNAL_PREFIX_RE =
1154
- /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal(?:<(?:[^<>]|<[^<>]*>)*>)?\(/gm
1155
-
1156
- /**
1157
- * Detect whether the module exports any component-like functions
1158
- * (uppercase first letter — standard convention for JSX components).
1159
- */
1160
- const EXPORT_COMPONENT_RE =
1161
- /export\s+(?:default\s+)?(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*[=:])/
1162
-
1163
- function skipStringLiteral(code: string, start: number, quote: string): number {
1164
- let j = start + 1
1165
- while (j < code.length) {
1166
- if (code[j] === '\\') {
1167
- j += 2
1168
- continue
1169
- }
1170
- if (code[j] === quote) break
1171
- j++
1172
- }
1173
- return j
1174
- }
1175
-
1176
- function extractBalancedArgs(code: string, start: number): string | null {
1177
- let depth = 1
1178
- for (let i = start; i < code.length; i++) {
1179
- const ch = code[i]
1180
- if (ch === '(') depth++
1181
- else if (ch === ')') {
1182
- depth--
1183
- if (depth === 0) return code.slice(start, i)
1184
- } else if (ch === '"' || ch === "'" || ch === '`') {
1185
- i = skipStringLiteral(code, i, ch)
1186
- }
1187
- }
1188
- return null
1189
- }
1190
-
1191
- /**
1192
- * Compute brace depth at position `pos` — returns 0 for module scope.
1193
- * Skips string literals to avoid counting braces inside strings.
1194
- */
1195
- function braceDepthAt(code: string, pos: number): number {
1196
- let depth = 0
1197
- for (let i = 0; i < pos; i++) {
1198
- const ch = code[i]
1199
- if (ch === '{') depth++
1200
- else if (ch === '}') depth--
1201
- else if (ch === '"' || ch === "'" || ch === '`') {
1202
- i = skipStringLiteral(code, i, ch)
1203
- }
1204
- }
1205
- return depth
1206
- }
1207
-
1208
- /** Rewrite module-scope `signal()` calls to `__hmr_signal()` for state preservation. */
1209
- function rewriteSignals(code: string, moduleId: string): string {
1210
- const escapedId = JSON.stringify(moduleId)
1211
- const matches: {
1212
- start: number
1213
- end: number
1214
- prefix: string
1215
- name: string
1216
- args: string
1217
- }[] = []
1218
- let m: RegExpExecArray | null = SIGNAL_PREFIX_RE.exec(code)
1219
- while (m !== null) {
1220
- const argsStart = m.index + m[0].length
1221
- const args = extractBalancedArgs(code, argsStart)
1222
- if (args === null) {
1223
- m = SIGNAL_PREFIX_RE.exec(code)
1224
- continue // unbalanced — skip
1225
- }
1226
- // Only rewrite module-scope signals (brace depth 0).
1227
- if (braceDepthAt(code, m.index) === 0) {
1228
- matches.push({
1229
- start: m.index,
1230
- end: argsStart + args.length + 1, // +1 for closing paren
1231
- prefix: m[1] ?? '',
1232
- name: m[2] ?? '',
1233
- args,
1234
- })
1235
- }
1236
- m = SIGNAL_PREFIX_RE.exec(code)
1237
- }
1238
- SIGNAL_PREFIX_RE.lastIndex = 0
1239
-
1240
- // Replace in reverse to preserve offsets
1241
- let output = code
1242
- for (let i = matches.length - 1; i >= 0; i--) {
1243
- const { start, end, prefix, name, args } = matches[i] as (typeof matches)[number]
1244
- const replacement = `${prefix}__hmr_signal(${escapedId}, ${JSON.stringify(name)}, signal, ${args})`
1245
- output = output.slice(0, start) + replacement + output.slice(end)
1246
- }
1247
- return output
1248
- }
1249
-
1250
- /** Check if an argument string contains a top-level comma (i.e. has multiple arguments). */
1251
- function hasMultipleArgs(args: string): boolean {
1252
- let depth = 0
1253
- for (const ch of args) {
1254
- if (ch === '(' || ch === '[' || ch === '{') depth++
1255
- else if (ch === ')' || ch === ']' || ch === '}') depth--
1256
- else if (ch === ',' && depth === 0) return true
1257
- }
1258
- return false
1259
- }
1260
-
1261
- /**
1262
- * Inject `{ name?, __sourceLocation: { file, line, col } }` into
1263
- * `signal()` / `computed()` / `effect()` calls that don't already have
1264
- * an options argument. Only runs in dev mode for debugging/devtools.
1265
- *
1266
- * Three forms covered:
1267
- *
1268
- * `const count = signal(0)` →
1269
- * `const count = signal(0, { name: "count", __sourceLocation: {...} })`
1270
- *
1271
- * `const doubled = computed(() => count() * 2)` →
1272
- * `const doubled = computed(() => count() * 2, { name: "doubled", __sourceLocation: {...} })`
1273
- *
1274
- * `effect(() => console.log(count()))` →
1275
- * `effect(() => console.log(count()), { __sourceLocation: {...} })`
1276
- * (no `name` — anonymous effects have no binding to derive from)
1277
- *
1278
- * Module-scope signals rewritten to __hmr_signal() are naturally skipped
1279
- * because the regex matches `signal(` not `__hmr_signal(`.
1280
- *
1281
- * **LPIH integration**: `__sourceLocation` is consumed by
1282
- * `@pyreon/reactivity`'s `signal()` / `computed()` / `effect()` to skip
1283
- * the `new Error().stack` capture in `_rdRegister` — saves ~2.2µs per
1284
- * creation when devtools is active. The injected literal is byte-for-byte
1285
- * the same info the runtime would have parsed from the stack, so behavior
1286
- * is identical except no stack-parse cost.
1287
- *
1288
- * **Anonymous-effect detection**: `effect(` can also appear as a property
1289
- * access (`obj.effect(...)`), a longer identifier (`sideEffect(...)`), or
1290
- * a previously-injected call (`effect(fn, { ... })`). The unbound-effect
1291
- * pass guards against all three:
1292
- * - preceded by NOT `[A-Za-z0-9_$.]` (so `.effect`/`sideEffect` skip)
1293
- * - args do NOT already contain a 2nd arg (`hasMultipleArgs` check)
1294
- *
1295
- * @param code - source text
1296
- * @param moduleId - the file path to embed in the injected `__sourceLocation`.
1297
- * Vite passes the resolved module ID (absolute path).
1298
- */
1299
- function injectSignalNames(code: string, moduleId: string): string {
1300
- // Pre-pass: mask string-literal, template-literal, and comment regions
1301
- // so the regexes below don't false-fire on `effect(` inside docstrings,
1302
- // help-text strings, JS-as-text test fixtures, or comments mentioning
1303
- // reactive primitives. The regex runs against the MASKED code (positions
1304
- // are preserved), so a match's index points at real code; args extraction
1305
- // pulls from the ORIGINAL code for accurate output.
1306
- //
1307
- // Without this, user code like `const docs = \`effect(() => x)\`` would
1308
- // get `, { __sourceLocation: ... }` injected INSIDE the template literal,
1309
- // corrupting the help-text content at runtime.
1310
- const masked = _maskStringsAndComments(code)
1311
-
1312
- // Pass 1: bound forms — `const X = (signal|computed|effect)(…)`.
1313
- // Extract `X` as the debug name + the reactive primitive kind.
1314
- const reBound = /(?:const|let)\s+(\w+)\s*=\s*(signal|computed|effect)\(/gm
1315
- // Pass 2: unbound effect — `effect(() => …)` at statement position,
1316
- // not following a member-access (.) or identifier char ($_a-zA-Z0-9).
1317
- // Reactive primitives other than `effect` are rare without binding,
1318
- // so we skip the bare `signal(` / `computed(` form to stay conservative.
1319
- const reUnboundEffect = /(?<![\w$.])effect\(/gm
1320
-
1321
- type Match = {
1322
- start: number
1323
- end: number
1324
- name: string | null
1325
- args: string
1326
- matchIdx: number
1327
- }
1328
- const matches: Match[] = []
1329
- // Track call positions covered by pass 1 so pass 2 can skip them.
1330
- const covered = new Set<number>()
1331
-
1332
- let m: RegExpExecArray | null = reBound.exec(masked)
1333
- while (m !== null) {
1334
- const argsStart = m.index + m[0].length
1335
- const args = extractBalancedArgs(code, argsStart)
1336
- if (args !== null && !hasMultipleArgs(args)) {
1337
- matches.push({
1338
- start: argsStart,
1339
- end: argsStart + args.length,
1340
- name: m[1] ?? '',
1341
- args,
1342
- matchIdx: m.index,
1343
- })
1344
- // Mark the `effect(`/`signal(`/`computed(` token start so the
1345
- // unbound-effect pass doesn't double-process it.
1346
- const tokStart = m.index + m[0].length - (m[2]?.length ?? 0) - 1
1347
- covered.add(tokStart)
1348
- }
1349
- m = reBound.exec(masked)
1350
- }
1351
- reBound.lastIndex = 0
1352
-
1353
- m = reUnboundEffect.exec(masked)
1354
- while (m !== null) {
1355
- if (!covered.has(m.index)) {
1356
- const argsStart = m.index + m[0].length
1357
- const args = extractBalancedArgs(code, argsStart)
1358
- if (args !== null && !hasMultipleArgs(args)) {
1359
- matches.push({
1360
- start: argsStart,
1361
- end: argsStart + args.length,
1362
- name: null,
1363
- args,
1364
- matchIdx: m.index,
1365
- })
1366
- }
1367
- }
1368
- m = reUnboundEffect.exec(masked)
1369
- }
1370
- reUnboundEffect.lastIndex = 0
1371
-
1372
- if (matches.length === 0) return code
1373
-
1374
- // Sort by descending start so back-to-front rewriting doesn't shift
1375
- // later indices (each splice leaves earlier offsets unchanged).
1376
- matches.sort((a, b) => b.start - a.start)
1377
-
1378
- // Pre-compute line offsets ONCE — avoids O(N²) when many calls share
1379
- // a file. Each lookup becomes O(log N) via binary search.
1380
- const lineStarts = _computeLineStarts(code)
1381
-
1382
- let output = code
1383
- for (let i = 0; i < matches.length; i++) {
1384
- const { start, end, name, args, matchIdx } = matches[i] as Match
1385
- const { line, col } = _offsetToLineCol(matchIdx, lineStarts)
1386
- const locLiteral = `__sourceLocation: { file: ${JSON.stringify(moduleId)}, line: ${line}, col: ${col} }`
1387
- const inner = name !== null
1388
- ? `name: ${JSON.stringify(name)}, ${locLiteral}`
1389
- : locLiteral
1390
- output = `${output.slice(0, start)}${args}, { ${inner} }${output.slice(end)}`
1391
- }
1392
- return output
1393
- }
1394
-
1395
- /**
1396
- * Mask string-literal / template-literal / comment regions in `code` by
1397
- * replacing their content with spaces. Returns a SAME-LENGTH string so
1398
- * regex match positions in the masked version line up with the original.
1399
- *
1400
- * Used by `injectSignalNames` to skip false-positive matches against
1401
- * reactive-primitive names that appear inside strings or comments. Without
1402
- * masking, a user's `const docs = \`effect(() => x)\`` template literal
1403
- * would get `, { __sourceLocation: ... }` injected INSIDE the string,
1404
- * corrupting runtime values.
1405
- *
1406
- * Handles:
1407
- * - `"..."` / `'...'` strings (escape-aware)
1408
- * - `` `...` `` template literals; interpolations `${...}` are KEPT as
1409
- * code (their content can contain real `signal()` calls worth catching)
1410
- * - `// ...` line comments
1411
- * - `/* ... *\/` block comments
1412
- *
1413
- * Regex literals (`/foo/g`) are NOT special-cased — they're rare and the
1414
- * downstream extractBalancedArgs handles unmatched parens by returning null.
1415
- *
1416
- * @internal — exported for tests.
1417
- */
1418
- export function _maskStringsAndComments(code: string): string {
1419
- const out: string[] = []
1420
- let i = 0
1421
- const n = code.length
1422
- while (i < n) {
1423
- const c = code[i]
1424
- const c1 = code[i + 1]
1425
-
1426
- // Line comment `// ...`
1427
- if (c === '/' && c1 === '/') {
1428
- while (i < n && code[i] !== '\n') {
1429
- out.push(' ')
1430
- i++
1431
- }
1432
- continue
1433
- }
1434
- // Block comment `/* ... */`
1435
- if (c === '/' && c1 === '*') {
1436
- out.push(' ', ' ')
1437
- i += 2
1438
- while (i < n) {
1439
- if (code[i] === '*' && code[i + 1] === '/') {
1440
- out.push(' ', ' ')
1441
- i += 2
1442
- break
1443
- }
1444
- // Preserve newlines so line numbers don't shift
1445
- out.push(code[i] === '\n' ? '\n' : ' ')
1446
- i++
1447
- }
1448
- continue
1449
- }
1450
- // String literal "..." or '...'
1451
- if (c === '"' || c === "'") {
1452
- const quote = c
1453
- out.push(' ')
1454
- i++
1455
- while (i < n && code[i] !== quote) {
1456
- // Escape sequence — skip the next char too (handles `\"`, `\\`, etc.)
1457
- if (code[i] === '\\' && i + 1 < n) {
1458
- // Preserve a newline (line-continuation `\<LF>`) as a newline.
1459
- out.push(' ', code[i + 1] === '\n' ? '\n' : ' ')
1460
- i += 2
1461
- continue
1462
- }
1463
- // Unterminated string (legacy parsers stop at newline) — break
1464
- if (code[i] === '\n') break
1465
- out.push(' ')
1466
- i++
1467
- }
1468
- if (i < n && code[i] === quote) {
1469
- out.push(' ')
1470
- i++
1471
- }
1472
- continue
1473
- }
1474
- // Template literal `...` — preserve `${...}` interpolations as code
1475
- if (c === '`') {
1476
- out.push(' ')
1477
- i++
1478
- while (i < n && code[i] !== '`') {
1479
- if (code[i] === '\\' && i + 1 < n) {
1480
- out.push(' ', code[i + 1] === '\n' ? '\n' : ' ')
1481
- i += 2
1482
- continue
1483
- }
1484
- // `${...}` — keep the interpolation body as code (with nested
1485
- // brace tracking so we find the matching `}`).
1486
- if (code[i] === '$' && code[i + 1] === '{') {
1487
- out.push(' ', ' ')
1488
- i += 2
1489
- let depth = 1
1490
- while (i < n && depth > 0) {
1491
- if (code[i] === '{') {
1492
- depth++
1493
- out.push(code[i] ?? ' ')
1494
- i++
1495
- continue
1496
- }
1497
- if (code[i] === '}') {
1498
- depth--
1499
- if (depth === 0) {
1500
- out.push(' ')
1501
- i++
1502
- break
1503
- }
1504
- out.push(code[i] ?? ' ')
1505
- i++
1506
- continue
1507
- }
1508
- // Inside `${}` — pass through as code (might contain `signal(` etc).
1509
- out.push(code[i] ?? ' ')
1510
- i++
1511
- }
1512
- continue
1513
- }
1514
- // Preserve newlines so line numbers don't shift.
1515
- out.push(code[i] === '\n' ? '\n' : ' ')
1516
- i++
1517
- }
1518
- if (i < n && code[i] === '`') {
1519
- out.push(' ')
1520
- i++
1521
- }
1522
- continue
1523
- }
1524
- out.push(c ?? '')
1525
- i++
1526
- }
1527
- return out.join('')
1528
- }
1529
-
1530
- /**
1531
- * Compute the 0-indexed character offset for the start of each line.
1532
- * `lineStarts[i]` is the offset of the FIRST character on line i+1
1533
- * (1-based, so `lineStarts[0]` = offset 0 = line 1).
1534
- *
1535
- * @internal — exported for tests.
1536
- */
1537
- export function _computeLineStarts(code: string): number[] {
1538
- const starts: number[] = [0]
1539
- for (let i = 0; i < code.length; i++) {
1540
- if (code.charCodeAt(i) === 10) starts.push(i + 1) // \n
1541
- }
1542
- return starts
1543
- }
1544
-
1545
- /**
1546
- * Convert a 0-indexed offset to `{ line: 1-based, col: 1-based }` using a
1547
- * pre-computed line-starts array. Binary search → O(log N) per lookup.
1548
- *
1549
- * @internal — exported for tests.
1550
- */
1551
- export function _offsetToLineCol(
1552
- offset: number,
1553
- lineStarts: number[],
1554
- ): { line: number; col: number } {
1555
- // Binary search for the largest lineStarts[i] <= offset.
1556
- let lo = 0
1557
- let hi = lineStarts.length - 1
1558
- while (lo < hi) {
1559
- const mid = (lo + hi + 1) >>> 1
1560
- const v = lineStarts[mid]
1561
- if (v !== undefined && v <= offset) lo = mid
1562
- else hi = mid - 1
1563
- }
1564
- const lineStart = lineStarts[lo] ?? 0
1565
- return { line: lo + 1, col: offset - lineStart + 1 }
1566
- }
1567
-
1568
- function injectHmr(code: string, moduleId: string): string {
1569
- const hasSignals = SIGNAL_PREFIX_RE.test(code)
1570
- SIGNAL_PREFIX_RE.lastIndex = 0
1571
-
1572
- const hasComponentExport = EXPORT_COMPONENT_RE.test(code)
1573
-
1574
- // Only inject HMR if the module exports components or has module-scope signals
1575
- if (!hasComponentExport && !hasSignals) return code
1576
-
1577
- let output = hasSignals ? rewriteSignals(code, moduleId) : code
1578
-
1579
- // Build the HMR footer
1580
- const escapedId = JSON.stringify(moduleId)
1581
- const lines: string[] = []
1582
-
1583
- if (hasSignals) {
1584
- lines.push(`import { __hmr_signal, __hmr_dispose } from "${HMR_RUNTIME_IMPORT}";`)
1585
- }
1586
-
1587
- lines.push(`if (import.meta.hot) {`)
1588
-
1589
- if (hasSignals) {
1590
- lines.push(` import.meta.hot.dispose(() => __hmr_dispose(${escapedId}));`)
1591
- }
1592
-
1593
- // Self-accept the module, then drive Pyreon's HMR coordinator.
1594
- //
1595
- // The OLD code emitted a bare `import.meta.hot.accept()` (no callback):
1596
- // Vite re-evaluated the module but NOTHING re-rendered the mounted tree,
1597
- // AND the self-accept suppressed Vite's full-reload fallback — so a
1598
- // component/JSX edit produced a silently-stale UI until a MANUAL refresh.
1599
- //
1600
- // Now: the accept callback hands the FRESH module namespace Vite already
1601
- // re-evaluated straight to `globalThis.__pyreon_hmr_swap__` (registered
1602
- // by `@pyreon/router` in a dev browser — zero import coupling, same
1603
- // pattern as the perf-harness counter sink), keyed by THIS module's id.
1604
- // The coordinator finds every active matched route record whose lazy
1605
- // `_hmrId` matches and swaps in the new component, re-rendering ONLY
1606
- // that subtree IN PLACE (no page reload → `__pyreon_hmr_registry__`
1607
- // survives → `__hmr_signal` restores module-scope signal values).
1608
- //
1609
- // Using the namespace Vite passes (not a re-run of the lazy thunk)
1610
- // sidesteps the stale-`?t=` trap: the dynamic-import thunk lives in the
1611
- // virtual routes module, which is NOT invalidated when this leaf route
1612
- // self-accepts — re-importing it would return the OLD module.
1613
- //
1614
- // `__pyreon_hmr_swap__` returns falsy when the edit was outside the
1615
- // active route tree (nested non-route component, unrelated route,
1616
- // signal-only module) OR no coordinator is registered (plain
1617
- // `@pyreon/runtime-dom` app, or module loaded before any router
1618
- // mounted). Then `import.meta.hot.invalidate()` → Vite propagates → an
1619
- // AUTOMATIC full reload. Either way the user never refreshes by hand.
1620
- lines.push(` import.meta.hot.accept((__m) => {`)
1621
- lines.push(` const __s = globalThis.__pyreon_hmr_swap__;`)
1622
- lines.push(
1623
- ` if (typeof __s === "function" && __m && __s(${escapedId}, __m)) return;`,
1624
- )
1625
- lines.push(` import.meta.hot.invalidate();`)
1626
- lines.push(` });`)
1627
- lines.push(`}`)
1628
-
1629
- output = `${output}\n\n${lines.join('\n')}\n`
1630
-
1631
- return output
1632
- }
1633
-
1634
- // ── Compat attribute transforms ──────────────────────────────────────────────
1635
-
1636
- /**
1637
- * Transform React-style JSX attribute names to standard HTML attribute names.
1638
- * This is a lightweight string transform that runs on JSX source before OXC's
1639
- * JSX transform converts it to jsx() calls.
1640
- *
1641
- * - `className` → `class`
1642
- * - `htmlFor` → `for`
1643
- *
1644
- * Only matches attribute position in JSX (after `<tag ` or whitespace).
1645
- * Does not transform property access (e.g. `props.className` stays as-is since
1646
- * the compat JSX runtime handles that at call time).
1647
- */
1648
- function transformCompatAttributes(code: string): string {
1649
- // Match className/htmlFor in JSX attribute position:
1650
- // After < and tag name, or after whitespace between attributes
1651
- // Pattern: word boundary + attribute name + = (with optional whitespace)
1652
- return code
1653
- .replace(/(\s)className(\s*=)/g, '$1class$2')
1654
- .replace(/(\s)htmlFor(\s*=)/g, '$1for$2')
1655
- }
1656
-
1657
- // ── Helpers ───────────────────────────────────────────────────────────────────
1658
-
1659
- function getExt(id: string): string {
1660
- const clean = id.split('?')[0] ?? id
1661
- const dot = clean.lastIndexOf('.')
1662
- return dot >= 0 ? clean.slice(dot) : ''
1663
- }
1664
-
1665
- /** Skip Vite-handled asset requests (CSS, images, HMR, etc.) */
1666
- function isAssetRequest(url: string): boolean {
1667
- return (
1668
- url.startsWith('/@') || // @vite/client, @id, @fs, etc.
1669
- url.startsWith('/__') || // __open-in-editor, etc.
1670
- url.includes('/node_modules/') ||
1671
- /\.(css|js|ts|tsx|jsx|json|ico|png|jpg|jpeg|gif|svg|woff2?|ttf|eot|map)(\?|$)/.test(url)
1672
- )
1673
- }
1674
-
1675
- // ── HMR runtime source (served as virtual module) ─────────────────────────────
1676
- //
1677
- // Inlined here so it's available without a filesystem read. This is the
1678
- // compiled-to-JS version of hmr-runtime.ts — kept in sync manually.
1679
-
1680
- // ─── Cross-module signal auto-call helpers ──────────────────────────────────
1681
-
1682
- /**
1683
- * Normalize a Vite module ID by stripping query strings (?v=..., ?t=...)
1684
- * and resolving to an absolute path for consistent registry lookups.
1685
- */
1686
- function normalizeModuleId(id: string): string {
1687
- const queryIndex = id.indexOf('?')
1688
- return queryIndex >= 0 ? id.slice(0, queryIndex) : id
1689
- }
1690
-
1691
- // ─── Island declaration scanner ────────────────────────────────────────────
1692
-
1693
- /**
1694
- * One island() call site discovered in source.
1695
- *
1696
- * `loaderAbsPath` is the dynamic-import target resolved relative to the
1697
- * source file where the call was written. Vite's resolver finds the actual
1698
- * file (.tsx / .jsx / .ts / .js extension auto-added) when the registry
1699
- * module emits `() => import('<loaderAbsPath>')`.
1700
- */
1701
- interface IslandDecl {
1702
- name: string
1703
- hydrate: string
1704
- loaderAbsPath: string
1705
- }
1706
-
1707
- /**
1708
- * Pre-scan all source files in the project for `island()` declarations.
1709
- *
1710
- * Called from `buildStart` (when `islands: true`) so the registry is fully
1711
- * populated before any transforms run. Mirrors `prescanSignalExports` shape;
1712
- * the per-file regex pattern matches:
1713
- *
1714
- * island(() => import('PATH'), { name: 'NAME', hydrate: 'STRATEGY' })
1715
- *
1716
- * Edge cases the regex deliberately doesn't cover (user falls back to manual
1717
- * `hydrateIslands({ ... })`):
1718
- * - Loader is a variable, not an inline arrow: `island(myLoader, { name })`
1719
- * - Name is a variable: `island(() => import('./X'), { name: NAME_CONST })`
1720
- * - Options come from a spread: `island(loader, { ...opts })`
1721
- */
1722
- async function prescanIslandDeclarations(
1723
- root: string,
1724
- registry: Map<string, IslandDecl[]>,
1725
- ): Promise<void> {
1726
- const files: string[] = []
1727
-
1728
- function walk(dir: string) {
1729
- try {
1730
- for (const entry of readdirSync(dir)) {
1731
- if (
1732
- entry.startsWith('.') ||
1733
- entry === 'node_modules' ||
1734
- entry === 'dist' ||
1735
- entry === 'lib' ||
1736
- entry === 'build'
1737
- )
1738
- continue
1739
- const full = pathJoin(dir, entry)
1740
- try {
1741
- const stat = statSync(full)
1742
- if (stat.isDirectory()) walk(full)
1743
- else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
1744
- } catch {
1745
- /* permission error, etc. */
1746
- }
1747
- }
1748
- } catch {
1749
- /* dir doesn't exist */
1750
- }
1751
- }
1752
-
1753
- walk(root)
1754
-
1755
- for (const file of files) {
1756
- try {
1757
- const code = readFileSync(file, 'utf-8')
1758
- scanIslandDeclarations(code, file, registry)
1759
- } catch {
1760
- /* read error */
1761
- }
1762
- }
1763
- }
1764
-
1765
- /**
1766
- * Scan a single source file for `island()` declarations and record them.
1767
- *
1768
- * The regex captures:
1769
- * - Group 1: dynamic-import path (`./components/Counter`)
1770
- * - Group 2: options block contents
1771
- *
1772
- * Then a follow-up regex pulls `name: 'X'` and `hydrate: 'Y'` from the
1773
- * options block. Single-line and multi-line forms both work.
1774
- *
1775
- * Resolves the loader path relative to the file where the call lives so
1776
- * the emitted virtual-module registry gets an absolute path Vite's resolver
1777
- * can find.
1778
- */
1779
- function scanIslandDeclarations(
1780
- code: string,
1781
- filePath: string,
1782
- registry: Map<string, IslandDecl[]>,
1783
- ): void {
1784
- // `[\s\S]` lets the options block span multiple lines. The lazy `?` after
1785
- // the options block prevents over-matching when several `island()` calls
1786
- // appear in the same file.
1787
- // `[^}]{0,500}` instead of `[\s\S]*?` — real island() option blocks
1788
- // are tiny (`{ name: 'X', hydrate: 'load' }`); excluding `}` from
1789
- // the inner class also tightens the match against the outer `\}`.
1790
- const ISLAND_CALL_RE =
1791
- /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([^}]{0,500})\}\s*\)/g
1792
- const decls: IslandDecl[] = []
1793
- let match: RegExpExecArray | null
1794
- while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
1795
- const importPath = match[1]!
1796
- const optsBlock = match[2]!
1797
- const nameMatch = /(?:^|[\s,{])name\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
1798
- if (!nameMatch) continue // can't auto-register without a name
1799
- const hydrateMatch = /(?:^|[\s,{])hydrate\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
1800
- const hydrate = hydrateMatch ? hydrateMatch[1]! : 'load'
1801
- const loaderAbsPath = importPath.startsWith('.')
1802
- ? resolveRelative(filePath, importPath)
1803
- : importPath
1804
- decls.push({ name: nameMatch[1]!, hydrate, loaderAbsPath })
1805
- }
1806
- if (decls.length > 0) {
1807
- registry.set(normalizeModuleId(filePath), decls)
1808
- } else {
1809
- // Clean up if file no longer declares islands (e.g. after edit)
1810
- registry.delete(normalizeModuleId(filePath))
1811
- }
1812
- }
1813
-
1814
- /**
1815
- * Resolve a dynamic-import specifier to an absolute path, mirroring how Node
1816
- * / Vite resolve `import('./X')` from the source file's directory.
1817
- */
1818
- function resolveRelative(fromFile: string, relPath: string): string {
1819
- return pathJoin(dirname(fromFile), relPath)
1820
- }
1821
-
1822
- /**
1823
- * Render the auto-generated `virtual:pyreon/islands-registry` source. Emits:
1824
- *
1825
- * export const __pyreonIslandRegistry = {
1826
- * Counter: () => import('/abs/path/to/components/Counter'),
1827
- * IdleClock: () => import('/abs/path/to/components/IdleClock'),
1828
- * // never-strategy islands deliberately omitted
1829
- * }
1830
- *
1831
- * `hydrate: 'never'` islands are skipped — registering a loader for them
1832
- * would defeat the strategy by pulling the component module into the
1833
- * client bundle graph. `hydrateIslandsAuto()` short-circuits never-islands
1834
- * at runtime regardless; emitting here would still create the dynamic-
1835
- * import chunk.
1836
- *
1837
- * Duplicate `name` across declarations: the LAST one wins. Documented as
1838
- * an anti-pattern (caught by the planned `pyreon doctor --check-islands`).
1839
- */
1840
- function renderIslandsRegistry(
1841
- registry: Map<string, IslandDecl[]>,
1842
- enabled: boolean,
1843
- ): string {
1844
- if (!enabled) {
1845
- return [
1846
- `// pyreon plugin: islands feature is disabled (pyreon({ islands: false })).`,
1847
- `// hydrateIslandsAuto() will throw at runtime — re-enable via vite.config.ts`,
1848
- `// or use manual hydrateIslands({ ... }) instead.`,
1849
- `export const __pyreonIslandRegistry = {};`,
1850
- `export const __pyreonIslandsEnabled = false;`,
1851
- ].join('\n')
1852
- }
1853
- const entries: string[] = []
1854
- const seen = new Set<string>()
1855
- // Deterministic order: sort by name for stable output / predictable HMR.
1856
- const all = Array.from(registry.values()).flat()
1857
- all.sort((a, b) => a.name.localeCompare(b.name))
1858
- for (const { name, hydrate, loaderAbsPath } of all) {
1859
- if (hydrate === 'never') continue
1860
- if (seen.has(name)) continue
1861
- seen.add(name)
1862
- // JSON.stringify gives proper escaping for both name (object key) and path.
1863
- entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderAbsPath)}),`)
1864
- }
1865
- return [
1866
- `// Auto-generated by @pyreon/vite-plugin (islands: true). Do not edit.`,
1867
- `// Sourced from island() declarations in your project. Never-strategy`,
1868
- `// islands are intentionally omitted — registering a loader for them`,
1869
- `// would defeat the zero-JS contract.`,
1870
- `export const __pyreonIslandRegistry = {`,
1871
- ...entries,
1872
- `};`,
1873
- `export const __pyreonIslandsEnabled = true;`,
1874
- ].join('\n')
1875
- }
1876
-
1877
- /**
1878
- * Pre-scan all source files in the project for signal exports.
1879
- *
1880
- * Called from `buildStart` so the registry is fully populated before any
1881
- * transforms run. This solves the build ordering problem where component.tsx
1882
- * is transformed before store.ts — without pre-scanning, the registry would
1883
- * be empty and imported signals would not be auto-called.
1884
- */
1885
- async function prescanSignalExports(root: string, registry: Map<string, Set<string>>): Promise<void> {
1886
- const files: string[] = []
1887
-
1888
- function walk(dir: string) {
1889
- try {
1890
- for (const entry of readdirSync(dir)) {
1891
- if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'lib' || entry === 'build') continue
1892
- const full = pathJoin(dir, entry)
1893
- try {
1894
- const stat = statSync(full)
1895
- if (stat.isDirectory()) walk(full)
1896
- else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
1897
- } catch {
1898
- /* permission error, etc. */
1899
- }
1900
- }
1901
- } catch {
1902
- /* dir doesn't exist */
1903
- }
1904
- }
1905
-
1906
- walk(root)
1907
-
1908
- for (const file of files) {
1909
- try {
1910
- const code = readFileSync(file, 'utf-8')
1911
- scanSignalExports(code, file, registry)
1912
- } catch {
1913
- /* read error */
1914
- }
1915
- }
1916
- }
1917
-
1918
- /**
1919
- * Scan a module's source for exported signal declarations and register them.
1920
- *
1921
- * Detects patterns:
1922
- * 1. `export const x = signal(...)` or `export const x = computed(...)` — inline export
1923
- * 2. `const x = signal(...); export { x }` — separate declaration + named export
1924
- * 3. `export default signal(...)` — default export (tracked as 'default')
1925
- *
1926
- * Re-exports (`export { x } from './signals'`) are NOT detected — the source
1927
- * module must be scanned directly. This is a known limitation.
1928
- *
1929
- * Uses simple regex — no AST parse needed.
1930
- */
1931
- // Bounded `\s{1,10}` instead of unbounded `\s+` to remove worst-case
1932
- // backtracking; real import specifiers have 1-2 spaces around `as`.
1933
- const AS_SPLIT_RE = /\s{1,10}as\s{1,10}/
1934
-
1935
- function scanSignalExports(code: string, moduleId: string, registry: Map<string, Set<string>>): void {
1936
- const normalizedId = normalizeModuleId(moduleId)
1937
- let match: RegExpExecArray | null
1938
- const signals = new Set<string>()
1939
-
1940
- // Pattern 1: export const x = signal(...) or export const x = computed(...)
1941
- const EXPORT_CONST_RE = /export\s+const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/g
1942
- while ((match = EXPORT_CONST_RE.exec(code)) !== null) {
1943
- signals.add(match[1]!)
1944
- }
1945
-
1946
- // Pattern 2: const x = signal(...) followed by export { x }
1947
- // First, find all local `const x = signal(` or `const x = computed(` declarations
1948
- const localSignals = new Set<string>()
1949
- const LOCAL_SIGNAL_RE = /(?:^|[\s;])const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/gm
1950
- while ((match = LOCAL_SIGNAL_RE.exec(code)) !== null) {
1951
- localSignals.add(match[1]!)
1952
- }
1953
-
1954
- // Then check named exports: export { x, y as z }
1955
- if (localSignals.size > 0) {
1956
- // Bounded `[^}]{1,500}` — real export blocks fit easily.
1957
- const NAMED_EXPORT_RE = /export\s*\{([^}]{1,500})\}/g
1958
- while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
1959
- // Skip re-exports (export { x } from '...')
1960
- const afterBrace = code.slice(match.index + match[0].length).trimStart()
1961
- if (afterBrace.startsWith('from')) continue
1962
-
1963
- for (const spec of match[1]!.split(',')) {
1964
- const trimmed = spec.trim()
1965
- if (!trimmed) continue
1966
- const parts = trimmed.split(AS_SPLIT_RE)
1967
- const localName = parts[0]!.trim()
1968
- const exportedName = (parts[1] ?? parts[0])!.trim()
1969
- if (localSignals.has(localName)) {
1970
- signals.add(exportedName)
1971
- }
1972
- }
1973
- }
1974
- }
1975
-
1976
- // Pattern 3: export default signal(...) or export default computed(...) — tracked as 'default'
1977
- if (/export\s+default\s+(?:signal|computed)\s*[<(]/.test(code)) {
1978
- signals.add('default')
1979
- }
1980
-
1981
- if (signals.size > 0) {
1982
- registry.set(normalizedId, signals)
1983
- } else {
1984
- // Clean up if module no longer exports signals (e.g. after edit)
1985
- registry.delete(normalizedId)
1986
- }
1987
- }
1988
-
1989
- /**
1990
- * Resolve imported signal names from the signal export registry.
1991
- *
1992
- * For each import in the source, resolves the module and checks if it has
1993
- * signal exports in the registry. Returns the local names of imported signals.
1994
- *
1995
- * Handles named imports (`import { x } from ...`) and default imports
1996
- * (`import x from ...` — matched against 'default' in the registry).
1997
- */
1998
- async function resolveImportedSignals(
1999
- code: string,
2000
- _moduleId: string,
2001
- registry: Map<string, Set<string>>,
2002
- pluginCtx: { resolve: (id: string, importer?: string, options?: { skipSelf: boolean }) => Promise<{ id: string } | null> },
2003
- resolveCache: Map<string, string | null>,
2004
- ): Promise<string[]> {
2005
- if (registry.size === 0) return []
2006
-
2007
- const knownSignals: string[] = []
2008
- let match: RegExpExecArray | null
2009
-
2010
- /** Resolve a source specifier to a normalized module ID, using the cache. */
2011
- async function resolveSource(source: string): Promise<string | null> {
2012
- const cacheKey = `${_moduleId}::${source}`
2013
- if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null
2014
- let resolvedId: string | null = null
2015
- try {
2016
- const resolved = await pluginCtx.resolve(source, _moduleId, { skipSelf: true })
2017
- resolvedId = resolved?.id ? normalizeModuleId(resolved.id) : null
2018
- } catch {
2019
- /* resolve error */
2020
- }
2021
- resolveCache.set(cacheKey, resolvedId)
2022
- return resolvedId
2023
- }
2024
-
2025
- // Named imports: import { name1, name2 as alias } from 'source'
2026
- // Excludes `import type { ... }` — type-only imports have no runtime value
2027
- const IMPORT_RE = /import\s+(?!type\s)\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g
2028
- while ((match = IMPORT_RE.exec(code)) !== null) {
2029
- const specifiers = match[1]!
2030
- const source = match[2]!
2031
-
2032
- const resolvedId = await resolveSource(source)
2033
- if (!resolvedId) continue
2034
- const exportedSignals = registry.get(resolvedId)
2035
- if (!exportedSignals) continue
2036
-
2037
- // Parse import specifiers: "count, theme as t, other"
2038
- for (const spec of specifiers.split(',')) {
2039
- const trimmed = spec.trim()
2040
- if (!trimmed) continue
2041
-
2042
- const parts = trimmed.split(AS_SPLIT_RE)
2043
- const importedName = parts[0]!.trim()
2044
- const localName = (parts[1] ?? parts[0])!.trim()
2045
-
2046
- if (exportedSignals.has(importedName)) {
2047
- knownSignals.push(localName)
2048
- }
2049
- }
2050
- }
2051
-
2052
- // Default imports: import count from './store'
2053
- // Excludes: `import { ... }`, `import type X`, `import * as X`
2054
- const DEFAULT_IMPORT_RE = /import\s+(?!type\s)(\w+)\s+from\s*['"]([^'"]+)['"]/g
2055
- while ((match = DEFAULT_IMPORT_RE.exec(code)) !== null) {
2056
- // Skip if this is actually a `import type X from` pattern
2057
- const fullMatch = match[0]
2058
- if (/import\s+type\s+/.test(fullMatch)) continue
2059
-
2060
- const localName = match[1]!
2061
- const source = match[2]!
2062
-
2063
- const resolvedId = await resolveSource(source)
2064
- if (!resolvedId) continue
2065
- const exportedSignals = registry.get(resolvedId)
2066
- if (!exportedSignals) continue
2067
-
2068
- if (exportedSignals.has('default')) {
2069
- knownSignals.push(localName)
2070
- }
2071
- }
2072
-
2073
- return knownSignals
2074
- }
2075
-
2076
- const HMR_RUNTIME_SOURCE = `
2077
- const REGISTRY_KEY = "__pyreon_hmr_registry__";
2078
-
2079
- function getRegistry() {
2080
- if (!globalThis[REGISTRY_KEY]) {
2081
- globalThis[REGISTRY_KEY] = new Map();
2082
- }
2083
- return globalThis[REGISTRY_KEY];
2084
- }
2085
-
2086
- const moduleSignals = new Map();
2087
-
2088
- export function __hmr_signal(moduleId, name, signalFn, initialValue) {
2089
- const registry = getRegistry();
2090
- const saved = registry.get(moduleId);
2091
- const value = saved?.has(name) ? saved.get(name) : initialValue;
2092
- const s = signalFn(value, { name: name });
2093
-
2094
- let mod = moduleSignals.get(moduleId);
2095
- if (!mod) {
2096
- mod = { entries: new Map() };
2097
- moduleSignals.set(moduleId, mod);
2098
- }
2099
- mod.entries.set(name, s);
2100
-
2101
- return s;
2102
- }
2103
-
2104
- export function __hmr_dispose(moduleId) {
2105
- const mod = moduleSignals.get(moduleId);
2106
- if (!mod) return;
2107
-
2108
- const registry = getRegistry();
2109
- const saved = new Map();
2110
- for (const [name, s] of mod.entries) {
2111
- saved.set(name, s.peek());
2112
- }
2113
- registry.set(moduleId, saved);
2114
- moduleSignals.delete(moduleId);
2115
- }
2116
- `