@pyreon/vite-plugin 0.24.4 → 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,2094 +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
- optimizeDeps: {
508
- exclude: optimizeDepsExclude,
509
- },
510
- // Vite 8 uses oxc for JSX transform (not esbuildOptions or rolldownOptions)
511
- oxc: {
512
- jsx: {
513
- runtime: 'automatic',
514
- importSource: jsxSource,
515
- },
516
- },
517
- // In SSR build mode, configure the entry
518
- ...(env.isSsrBuild && ssrConfig
519
- ? {
520
- build: {
521
- ssr: true,
522
- rollupOptions: {
523
- input: ssrConfig.entry,
524
- },
525
- },
526
- }
527
- : {}),
528
- }
529
- },
530
-
531
- // ── Pre-scan all source files for signal exports ──────────────────────
532
- async buildStart() {
533
- // Pre-scan all source files for signal exports so the registry
534
- // is complete before any transforms run. This solves the build
535
- // ordering problem where component.tsx is transformed before
536
- // store.ts — without pre-scanning, the registry would be empty.
537
- await prescanSignalExports(projectRoot, signalExportRegistry)
538
-
539
- // Mirror prescan for `island()` declarations. The result populates
540
- // `virtual:pyreon/islands-registry`, consumed by `hydrateIslandsAuto()`
541
- // in `@pyreon/server/client`. Eliminates the manual sync between
542
- // `island()` source-of-truth and the client `hydrateIslands({ ... })`
543
- // call — the #1 author foot-gun for islands.
544
- if (islandsEnabled) {
545
- await prescanIslandDeclarations(projectRoot, islandRegistry)
546
- }
547
- },
548
-
549
- // @internal — debug accessor for tests; returns live references to
550
- // the per-instance caches so `cache-eviction-on-delete.test.ts` can
551
- // assert on contents. Symbol.for-keyed so it's not part of the
552
- // plugin's documented surface but stays stable across reloads.
553
- [Symbol.for('pyreon/vite-plugin:caches')]: {
554
- signalExportRegistry,
555
- resolveCache,
556
- pyreonWorkspaceDirCache,
557
- islandRegistry,
558
- },
559
-
560
- // ── Cache invalidation on file delete (long-running `vite dev`) ─────
561
- // Vite's `watchChange` hook fires on filesystem events for files in
562
- // the watched module graph. Without this, the four per-instance
563
- // caches (`signalExportRegistry`, `resolveCache`, `islandRegistry`,
564
- // `pyreonWorkspaceDirCache`) accumulated stale entries for the
565
- // entire lifetime of the dev server — a long `vite dev` session
566
- // that edited / renamed / deleted source files would grow each
567
- // cache by one entry per dead file. Bounded by total source tree
568
- // size in practice, but a real leak over hours of editing.
569
- //
570
- // `'create' | 'update'` events are handled implicitly by the
571
- // existing transform-time `scanSignalExports` /
572
- // `scanIslandDeclarations` calls — they re-populate the registry
573
- // every time a file's `transform` hook fires, overwriting any
574
- // stale entry. So watchChange only needs to handle `'delete'`.
575
- watchChange(id: string, change: { event: 'create' | 'update' | 'delete' }) {
576
- if (change.event !== 'delete') return
577
-
578
- // Leak-class C diagnostic — emit per handled delete event. Bounded
579
- // by file-deletion count in a dev session; should grow strictly
580
- // monotonically with developer edit activity. Zero in a session
581
- // with known deletes = the watchChange hook regressed (and the
582
- // 4 per-instance caches will leak again).
583
- if (__DEV__) _countSink.__pyreon_count__?.('vite-plugin.watchChange.delete')
584
-
585
- const normalized = normalizeModuleId(id)
586
-
587
- // 1) signalExportRegistry — keyed by normalized module id.
588
- signalExportRegistry.delete(normalized)
589
-
590
- // 2) islandRegistry — keyed by absolute source path of the
591
- // declaration site (the original `id`, not normalized).
592
- islandRegistry.delete(id)
593
- // Also try the normalized form just in case the registry was
594
- // populated with a slightly different shape.
595
- if (normalized !== id) islandRegistry.delete(normalized)
596
-
597
- // 3) resolveCache — keyed by `${importer}::${source}` where
598
- // `importer` is normalized AND values can be the deleted
599
- // file's resolved path. Sweep both directions:
600
- // a) entries WHERE the deleted file is the importer (this
601
- // file's resolved imports are no longer relevant).
602
- // b) entries WHERE the deleted file is the resolved value
603
- // (other files importing the deleted file need to
604
- // re-resolve so they see `null` next time).
605
- const importerPrefix = `${normalized}::`
606
- for (const [key, value] of resolveCache) {
607
- if (key.startsWith(importerPrefix) || value === normalized) {
608
- resolveCache.delete(key)
609
- }
610
- }
611
-
612
- // 4) pyreonWorkspaceDirCache — keyed by DIRECTORY, not file. A
613
- // single file deletion doesn't invalidate the directory's
614
- // workspace status (other files may still live there), so
615
- // this cache stays. Bounded by source-tree directory count
616
- // in any case (small + finite).
617
- },
618
-
619
- // Tear down the one programmatic Vite SSR server the collapse
620
- // resolver holds (created lazily on first client-graph transform).
621
- async closeBundle() {
622
- if (collapseResolver) {
623
- await collapseResolver.dispose()
624
- collapseResolver = null
625
- collapseResolverInit = null
626
- }
627
- },
628
-
629
- // ── Virtual module + compat alias resolution ─────────────────────────────
630
- async resolveId(id, importer) {
631
- if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
632
- if (id === ISLANDS_REGISTRY_IMPORT) return ISLANDS_REGISTRY_ID
633
-
634
- // `@pyreon/core/jsx-runtime` resolves to the compat package only for
635
- // user code — never for `@pyreon/*` framework files (zero, router,
636
- // runtime-dom, etc.). Without this importer guard, every JSX file in
637
- // the build (including framework internals resolved via the `bun`
638
- // workspace condition) would get redirected to a compat runtime that
639
- // doesn't match the framework's JSX shape. Caught by `cpa-smoke-app-*-compat`.
640
- if (
641
- compat &&
642
- (id === '@pyreon/core/jsx-runtime' || id === '@pyreon/core/jsx-dev-runtime') &&
643
- importer &&
644
- isPyreonWorkspaceFile(importer, pyreonWorkspaceDirCache)
645
- ) {
646
- return // let Vite resolve to the real `@pyreon/core/jsx-runtime`
647
- }
648
-
649
- const target = getCompatTarget(compat, id)
650
- if (!target) return
651
-
652
- // Vite 8 resolves the "bun" condition natively via resolve.conditions.
653
- // Delegate to Vite's resolver instead of manual package.json parsing.
654
- const resolved = await this.resolve(target, importer, { skipSelf: true })
655
- return resolved?.id
656
- },
657
-
658
- load(id) {
659
- if (id === HMR_RUNTIME_ID) {
660
- return HMR_RUNTIME_SOURCE
661
- }
662
- if (id === ISLANDS_REGISTRY_ID) {
663
- return renderIslandsRegistry(islandRegistry, islandsEnabled)
664
- }
665
- },
666
-
667
- async transform(code, id, transformOptions) {
668
- const ext = getExt(id)
669
- if (ext !== '.tsx' && ext !== '.jsx' && ext !== '.pyreon') return
670
-
671
- // In compat mode, skip Pyreon's reactive JSX transform but apply
672
- // attribute renames (className → class, htmlFor → for) so source code
673
- // that uses React-style attribute names works correctly.
674
- if (
675
- compat === 'react' ||
676
- compat === 'preact' ||
677
- compat === 'vue' ||
678
- compat === 'solid' ||
679
- compat === 'svelte'
680
- ) {
681
- if (compat === 'react' || compat === 'preact') {
682
- const transformed = transformCompatAttributes(code)
683
- if (transformed !== code) return { code: transformed, map: null }
684
- }
685
- return
686
- }
687
-
688
- // ── Scan for exported signal declarations (populate registry) ──────
689
- // This runs on every .tsx/.jsx file so the registry is built
690
- // incrementally. buildStart pre-scans all files, but this handles
691
- // files created/modified after buildStart (dev mode HMR).
692
- scanSignalExports(code, normalizeModuleId(id), signalExportRegistry)
693
-
694
- // ── Same incremental update for island() declarations ──────────────
695
- // HMR: when a user adds/renames/removes an island() call, the
696
- // virtual:pyreon/islands-registry module needs to reflect it on the
697
- // next dev-server module reload.
698
- if (islandsEnabled) scanIslandDeclarations(code, id, islandRegistry)
699
-
700
- // ── Inline-Defer pre-pass ──────────────────────────────────────────
701
- // Rewrites `<Defer when={x}><Modal /></Defer>` into the explicit
702
- // chunk-prop form so Rolldown emits a proper per-Defer chunk and
703
- // the main bundle drops the static `import { Modal } from ...`
704
- // when it's exclusively used inside this Defer's subtree. Runs
705
- // BEFORE the JSX→runtime transform so the downstream pipeline
706
- // sees an already-explicit `<Defer chunk={...}>` shape with no
707
- // special-casing needed in `transformJSX`. See
708
- // `@pyreon/compiler/defer-inline` for the rewrite contract.
709
- const deferResult = transformDeferInline(code, id)
710
- const sourceForJsx = deferResult.changed ? deferResult.code : code
711
- for (const w of deferResult.warnings) {
712
- this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
713
- }
714
-
715
- // ── Resolve imported signals from the registry ─────────────────────
716
- // Check each import in this file: if the imported module has signal
717
- // exports in the registry, pass them as knownSignals to the compiler.
718
- const knownSignals = await resolveImportedSignals(sourceForJsx, id, signalExportRegistry, this, resolveCache)
719
-
720
- // Vite passes `ssr: true` when transforming for the SSR module graph
721
- // (both build --ssr and dev `ssrLoadModule`). The compiler emits plain
722
- // `h()` calls in that mode so `runtime-server` can render to a string.
723
- const isSsr = transformOptions?.ssr === true
724
-
725
- // ── P0 rocketstyle-collapse (opt-in, CLIENT graph only) ────────────
726
- // Never collapse the SSR graph: renderToString needs the real
727
- // VNode tree, AND the resolver itself SSR-renders the component —
728
- // collapsing the SSR graph would be circular. Resolve every
729
- // scanned literal-prop site once (real component, light + dark)
730
- // and hand the compiler a key→emission map; the compiler's AST
731
- // bail catalogue is the real gate, an unresolved key just falls
732
- // back to the normal mount.
733
- let collapseRocketstyle:
734
- | NonNullable<Parameters<typeof transformJSX>[2]>['collapseRocketstyle']
735
- | undefined
736
- if (collapseEnabled && !isBuild && !isSsr && !warnedDevCollapse) {
737
- warnedDevCollapse = true
738
- this.info(
739
- '[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.',
740
- )
741
- }
742
- if (collapseEnabled && isBuild && !isSsr) {
743
- const scanned: CollapsibleSite[] = scanCollapsibleSites(
744
- sourceForJsx,
745
- id,
746
- collapseSources,
747
- ).filter((s) => !collapseComponentFilter || collapseComponentFilter(s.componentName))
748
- if (scanned.length > 0) {
749
- const resolver = await ensureCollapseResolver()
750
- if (resolver) {
751
- const sites = new Map<
752
- string,
753
- {
754
- templateHtml: string
755
- lightClass: string
756
- darkClass: string
757
- rules: string[]
758
- ruleKey: string
759
- }
760
- >()
761
- const candidates = new Set<string>()
762
- for (const s of scanned) {
763
- const resolved = await resolver.resolve({
764
- component: { name: s.importedName, source: s.source },
765
- props: s.props,
766
- childrenText: s.childrenText,
767
- config: {
768
- provider: collapseProvider,
769
- theme: collapseTheme,
770
- mode: collapseMode,
771
- },
772
- })
773
- if (!resolved) continue
774
- candidates.add(s.componentName)
775
- sites.set(s.key, {
776
- templateHtml: resolved.templateHtml,
777
- lightClass: resolved.lightClass,
778
- darkClass: resolved.darkClass,
779
- rules: resolved.rules,
780
- ruleKey: resolved.key,
781
- })
782
- }
783
- if (sites.size > 0) {
784
- collapseRocketstyle = { candidates, sites, mode: collapseMode }
785
- }
786
- }
787
- }
788
- }
789
-
790
- const result = transformJSX(sourceForJsx, id, {
791
- ssr: isSsr,
792
- knownSignals,
793
- ...(collapseRocketstyle ? { collapseRocketstyle } : {}),
794
- })
795
- // Surface compiler warnings in the terminal
796
- for (const w of result.warnings) {
797
- this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
798
- }
799
-
800
- let output = result.code
801
-
802
- // ── Dev-only transforms ────────────────────────────────────────────
803
- if (!isBuild) {
804
- output = injectHmr(output, id)
805
- // Inject debug names + LPIH source locations for signal() calls
806
- // not rewritten by HMR. `id` is Vite's resolved module path —
807
- // the same path the runtime would have parsed from new Error().
808
- output = injectSignalNames(output, id)
809
- }
810
-
811
- // R12: surface the compiler's V3 source map so stack traces /
812
- // breakpoints in Pyreon components resolve to the right source line
813
- // (the JS backend now emits one; substitutions shift line counts, so
814
- // `map: null` previously mislocated every frame app-wide). Exact in
815
- // build; in dev the small extra HMR / signal-name injections aren't
816
- // re-mapped (still vastly better than no map). The native backend
817
- // emits no map yet (its own scoped follow-up) → `null`, unchanged
818
- // behaviour for that path.
819
- return { code: output, map: result.map ?? null }
820
- },
821
-
822
- // ── SSR dev middleware ───────────────────────────────────────────────────
823
- configureServer(server: ViteDevServer) {
824
- // Generate .pyreon/context.json for AI tools on dev server start
825
- generateProjectContext(projectRoot)
826
-
827
- // Debounced regeneration on file changes
828
- let contextTimer: ReturnType<typeof setTimeout> | null = null
829
- server.watcher.on('change', (file) => {
830
- if (/\.(tsx|jsx|ts|js)$/.test(file) && !file.includes('node_modules')) {
831
- if (contextTimer) clearTimeout(contextTimer)
832
- contextTimer = setTimeout(() => generateProjectContext(projectRoot), 500)
833
- }
834
- })
835
-
836
- // LPIH auto-bridge — accepts POST /__pyreon_lpih__ from the browser
837
- // client and atomically writes the cache file the LSP auto-discovers.
838
- // Registered BEFORE the SSR middleware so it short-circuits and never
839
- // falls through to handleSsrRequest.
840
- if (lpihEnabled) {
841
- registerLpihMiddleware(server, projectRoot, lpihUserCfg)
842
- }
843
-
844
- if (!ssrConfig) return
845
-
846
- // Return a function so the middleware runs AFTER Vite's built-in middleware
847
- // (static files, HMR, etc.) — only handle requests that Vite doesn't serve.
848
- return () => {
849
- server.middlewares.use(async (req, res, next) => {
850
- if (req.method !== 'GET') return next()
851
- const url = req.url ?? '/'
852
- if (isAssetRequest(url)) return next()
853
-
854
- try {
855
- await handleSsrRequest(server, ssrConfig.entry, url, req, res, next)
856
- } catch (err) {
857
- server.ssrFixStacktrace(err as Error)
858
- next(err)
859
- }
860
- })
861
- }
862
- },
863
-
864
- // ── LPIH auto-bridge client injection ────────────────────────────────────
865
- transformIndexHtml(html: string): string | undefined {
866
- if (isBuild || !lpihEnabled) return undefined
867
- // Inject a tiny <script type="module"> that activates devtools + polls
868
- // getFireSummaries() and POSTs to /__pyreon_lpih__. The dev server
869
- // middleware (above) writes the body to <projectRoot>/.pyreon-lpih.json
870
- // using @pyreon/reactivity's atomic-rename pattern. The LSP
871
- // auto-discovers that file (R2, #777) so the user wires NOTHING.
872
- const script = buildLpihClientScript(lpihIntervalMs)
873
- return html.replace('</head>', `${script}\n</head>`)
874
- },
875
- }
876
- }
877
-
878
- async function handleSsrRequest(
879
- server: ViteDevServer,
880
- entry: string,
881
- url: string,
882
- req: import('node:http').IncomingMessage,
883
- res: import('node:http').ServerResponse,
884
- next: (err?: unknown) => void,
885
- ): Promise<void> {
886
- const mod = await server.ssrLoadModule(entry)
887
- const handler = mod.handler ?? mod.default
888
-
889
- if (typeof handler !== 'function') {
890
- next()
891
- return
892
- }
893
-
894
- const origin = `http://${req.headers.host ?? 'localhost'}`
895
- const fullUrl = new URL(url, origin)
896
- const request = new Request(fullUrl.href, {
897
- method: req.method ?? 'GET',
898
- headers: Object.entries(req.headers).reduce((h, [k, v]) => {
899
- if (v) h.set(k, Array.isArray(v) ? v.join(', ') : v)
900
- return h
901
- }, new Headers()),
902
- })
903
-
904
- const response: Response = await handler(request)
905
- let html = await response.text()
906
-
907
- html = await server.transformIndexHtml(url, html)
908
-
909
- res.statusCode = response.status
910
- response.headers.forEach((v, k) => {
911
- res.setHeader(k, v)
912
- })
913
- res.end(html)
914
- }
915
-
916
- // ── AI context generation ─────────────────────────────────────────────────────
917
-
918
- /**
919
- * Generate .pyreon/context.json — project map for AI coding assistants.
920
- * Delegates to @pyreon/compiler's unified project scanner.
921
- */
922
- function generateProjectContext(root: string): void {
923
- try {
924
- const context = generateContext(root)
925
- const outDir = pathJoin(root, '.pyreon')
926
- if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true })
927
- writeFileSync(pathJoin(outDir, 'context.json'), JSON.stringify(context, null, 2), 'utf-8')
928
- } catch {
929
- // Silently fail — context generation is best-effort
930
- }
931
- }
932
-
933
- // ── LPIH auto-bridge helpers ───────────────────────────────────────────────
934
-
935
- /**
936
- * Resolve the LPIH cache-file path for a given project root. Matches the
937
- * convention `@pyreon/reactivity/lpih`'s `getDefaultLpihCachePath()` uses
938
- * AND the LSP auto-discovers (R2, #777): `<projectRoot>/.pyreon-lpih.json`.
939
- *
940
- * @internal — exported for tests.
941
- */
942
- export function resolveLpihCachePath(projectRoot: string): string {
943
- return pathJoin(projectRoot, '.pyreon-lpih.json')
944
- }
945
-
946
- /**
947
- * Register the LPIH dev-server middleware on a Vite server. Extracted from
948
- * `configureServer` so the `cachePath` option reference lives at module
949
- * scope (top-level helper) rather than inside the plugin's inline body —
950
- * keeps `scripts/audit-types.ts` happy regardless of how its comment-
951
- * stripping handles the long inline `configureServer` block.
952
- *
953
- * @internal — exported for tests.
954
- */
955
- export function registerLpihMiddleware(
956
- server: ViteDevServer,
957
- projectRoot: string,
958
- userCfg: PyreonLpihOptions,
959
- ): void {
960
- const cachePath = userCfg.cachePath ?? resolveLpihCachePath(projectRoot)
961
- server.middlewares.use('/__pyreon_lpih__', (req, res) => {
962
- if (req.method !== 'POST') {
963
- res.statusCode = 405
964
- res.end('Method Not Allowed')
965
- return
966
- }
967
- let body = ''
968
- req.on('data', (chunk: Buffer | string) => {
969
- body += chunk.toString()
970
- // Defensive cap — fire payloads are tiny (a few KB at most);
971
- // anything larger is malicious or buggy. Drop the request.
972
- if (body.length > 1024 * 1024) {
973
- res.statusCode = 413
974
- res.end('Payload Too Large')
975
- req.destroy()
976
- }
977
- })
978
- req.on('end', () => {
979
- void writeLpihCacheFile(cachePath, body)
980
- .then(() => {
981
- res.statusCode = 204
982
- res.end()
983
- })
984
- .catch((err: unknown) => {
985
- // Don't crash the dev server — log + return 500 so the
986
- // browser-side bridge can back off + retry next interval.
987
- // oxlint-disable-next-line no-console
988
- console.warn(
989
- '[pyreon] LPIH cache write failed:',
990
- err instanceof Error ? err.message : err,
991
- )
992
- res.statusCode = 500
993
- res.end('LPIH cache write failed')
994
- })
995
- })
996
- })
997
- }
998
-
999
- let _lpihSeq = 0
1000
-
1001
- /**
1002
- * Atomically write a LPIH cache file (tmp + rename), mirroring the
1003
- * `@pyreon/reactivity/lpih:writeLpihCache` implementation. The payload
1004
- * comes pre-serialized from the browser-side bridge — we validate the
1005
- * outer shape (`{ fires: [...] }`) and reject malformed bodies to stop a
1006
- * buggy client from corrupting the file the LSP reads.
1007
- *
1008
- * @internal — exported for tests.
1009
- */
1010
- export async function writeLpihCacheFile(path: string, body: string): Promise<void> {
1011
- // Validate shape — must be a JSON object with `fires: array`. We re-
1012
- // serialize so the on-disk format is stable regardless of how the
1013
- // browser-side bridge encodes it.
1014
- let parsed: unknown
1015
- try {
1016
- parsed = JSON.parse(body)
1017
- } catch {
1018
- throw new Error('LPIH bridge: payload is not valid JSON')
1019
- }
1020
- if (
1021
- parsed === null ||
1022
- typeof parsed !== 'object' ||
1023
- !Array.isArray((parsed as { fires?: unknown }).fires)
1024
- ) {
1025
- throw new Error('LPIH bridge: payload is missing `fires` array')
1026
- }
1027
- const fs = await import('node:fs/promises')
1028
- const pid = typeof process !== 'undefined' && 'pid' in process ? process.pid : 0
1029
- const tmp = `${path}.tmp.${pid}.${++_lpihSeq}`
1030
- // Single try/catch covering BOTH writeFile AND rename. The previous
1031
- // shape only guarded the rename — if `fs.writeFile` itself threw (disk
1032
- // full, EIO, EACCES, transient FS error), the partial tmp file leaked
1033
- // on disk with a unique PID+seq name (so no conflict, but it accumulated
1034
- // forever). Audit caught this in the LPIH followups round.
1035
- try {
1036
- await fs.writeFile(tmp, JSON.stringify(parsed), 'utf8')
1037
- await fs.rename(tmp, path)
1038
- } catch (err) {
1039
- // Best-effort cleanup; original error is more useful than unlink's.
1040
- // Covers BOTH the writeFile-failed (tmp may not exist) and the
1041
- // rename-failed (tmp exists, rename didn't move it) cases —
1042
- // `fs.unlink` of a non-existent file throws ENOENT, which we swallow.
1043
- try {
1044
- await fs.unlink(tmp)
1045
- } catch {
1046
- /* swallow — original error is the user-facing one */
1047
- }
1048
- throw err
1049
- }
1050
- }
1051
-
1052
- /**
1053
- * Build the `<script type="module">` body injected into the HTML head.
1054
- * The script imports devtools activation + `getFireSummaries` from
1055
- * `@pyreon/reactivity`, sets up a `setInterval` that POSTs every
1056
- * `intervalMs` ms, and registers a `beforeunload` cleanup so the timer
1057
- * doesn't outlive the page.
1058
- *
1059
- * Browser bundlers serve `@pyreon/reactivity` from the workspace via
1060
- * Vite's normal module resolution — no virtual module needed.
1061
- *
1062
- * @internal — exported for tests.
1063
- */
1064
- export function buildLpihClientScript(intervalMs: number): string {
1065
- // Note: the script body is intentionally compact — the goal is zero
1066
- // visible payload in DevTools "Sources" while still being readable
1067
- // when someone DOES go looking. `JSON.stringify` for `intervalMs` is
1068
- // defense against `__proto__` / NaN / non-finite values reaching the
1069
- // emitted JS as a literal.
1070
- // CRITICAL — top-level await on the dynamic import. `<script type="module">`
1071
- // tags execute in document order with `defer` semantics; the head-injected
1072
- // LPIH script's body MUST fully evaluate (including this await) BEFORE the
1073
- // body-injected app entry's module body runs. Otherwise activateReactiveDevtools()
1074
- // would land AFTER the app has already created its module-scope signals,
1075
- // and `_rdRegister` (gated on `if (!_active) return undefined`) would skip
1076
- // them entirely — making the most common signal shape (top-of-file `const x = signal(0)`)
1077
- // invisible to LPIH. With the `await`, the LPIH module doesn't complete
1078
- // until activation finishes; the app's entry waits its turn.
1079
- return `<script type="module">
1080
- // Pyreon LPIH auto-bridge — POSTs fire summaries to /__pyreon_lpih__
1081
- // so the LSP (pyreon-lint --lsp) sees live fire data. Dev-only.
1082
- const __px = await import('@pyreon/reactivity').catch(() => null)
1083
- if (__px) {
1084
- __px.activateReactiveDevtools()
1085
- const __pxGet = __px.getFireSummaries
1086
- const __pxInterval = ${JSON.stringify(intervalMs)}
1087
- const __pxPost = () => {
1088
- const summaries = __pxGet()
1089
- const payload = JSON.stringify({
1090
- fires: summaries.map((s) => ({
1091
- file: s.loc.file,
1092
- line: s.loc.line,
1093
- count: s.count,
1094
- kind: s.kind,
1095
- lastFire: s.lastFire,
1096
- rate1s: s.rate1s,
1097
- })),
1098
- })
1099
- fetch('/__pyreon_lpih__', { method: 'POST', body: payload, headers: { 'content-type': 'application/json' } }).catch(() => {
1100
- // Dev-server might be restarting; swallow + retry next interval.
1101
- })
1102
- }
1103
- const __pxId = setInterval(__pxPost, __pxInterval)
1104
- window.addEventListener('beforeunload', () => clearInterval(__pxId))
1105
- }
1106
- // If __px is null, @pyreon/reactivity isn't in the dep graph — stay silent,
1107
- // LPIH is opt-in via the runtime API too. The dynamic-import catch returns
1108
- // null instead of letting the rejection bubble so consumers without the
1109
- // package don't see a console error.
1110
- </script>`
1111
- }
1112
-
1113
- // ── HMR injection ─────────────────────────────────────────────────────────────
1114
-
1115
- /**
1116
- * Regex that detects signal declarations (prefix + variable name).
1117
- * The arguments are extracted via balanced-paren matching in `injectHmr`.
1118
- * A brace-depth check filters out matches inside functions/blocks — only
1119
- * module-scope (depth 0) signals are rewritten for HMR state preservation.
1120
- *
1121
- * The optional `<...>` group accepts a TypeScript type parameter so that
1122
- * `signal<T>(initial)` declarations are also rewritten — without it, any
1123
- * generic-typed module-scope signal silently skipped HMR preservation.
1124
- *
1125
- * The inner `(?:[^<>]|<[^<>]*>)*` permits one level of generic nesting
1126
- * (e.g. `signal<Array<Row>>([])`, `signal<Map<string, number>>(m)`).
1127
- * Deeper nesting (`signal<Array<{ id: T<U> }>>(...)`) falls back to
1128
- * not-rewritten — tracked as a follow-up if real consumers need it,
1129
- * but unlikely at module scope where generics are usually shallow.
1130
- */
1131
- const SIGNAL_PREFIX_RE =
1132
- /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal(?:<(?:[^<>]|<[^<>]*>)*>)?\(/gm
1133
-
1134
- /**
1135
- * Detect whether the module exports any component-like functions
1136
- * (uppercase first letter — standard convention for JSX components).
1137
- */
1138
- const EXPORT_COMPONENT_RE =
1139
- /export\s+(?:default\s+)?(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*[=:])/
1140
-
1141
- function skipStringLiteral(code: string, start: number, quote: string): number {
1142
- let j = start + 1
1143
- while (j < code.length) {
1144
- if (code[j] === '\\') {
1145
- j += 2
1146
- continue
1147
- }
1148
- if (code[j] === quote) break
1149
- j++
1150
- }
1151
- return j
1152
- }
1153
-
1154
- function extractBalancedArgs(code: string, start: number): string | null {
1155
- let depth = 1
1156
- for (let i = start; i < code.length; i++) {
1157
- const ch = code[i]
1158
- if (ch === '(') depth++
1159
- else if (ch === ')') {
1160
- depth--
1161
- if (depth === 0) return code.slice(start, i)
1162
- } else if (ch === '"' || ch === "'" || ch === '`') {
1163
- i = skipStringLiteral(code, i, ch)
1164
- }
1165
- }
1166
- return null
1167
- }
1168
-
1169
- /**
1170
- * Compute brace depth at position `pos` — returns 0 for module scope.
1171
- * Skips string literals to avoid counting braces inside strings.
1172
- */
1173
- function braceDepthAt(code: string, pos: number): number {
1174
- let depth = 0
1175
- for (let i = 0; i < pos; i++) {
1176
- const ch = code[i]
1177
- if (ch === '{') depth++
1178
- else if (ch === '}') depth--
1179
- else if (ch === '"' || ch === "'" || ch === '`') {
1180
- i = skipStringLiteral(code, i, ch)
1181
- }
1182
- }
1183
- return depth
1184
- }
1185
-
1186
- /** Rewrite module-scope `signal()` calls to `__hmr_signal()` for state preservation. */
1187
- function rewriteSignals(code: string, moduleId: string): string {
1188
- const escapedId = JSON.stringify(moduleId)
1189
- const matches: {
1190
- start: number
1191
- end: number
1192
- prefix: string
1193
- name: string
1194
- args: string
1195
- }[] = []
1196
- let m: RegExpExecArray | null = SIGNAL_PREFIX_RE.exec(code)
1197
- while (m !== null) {
1198
- const argsStart = m.index + m[0].length
1199
- const args = extractBalancedArgs(code, argsStart)
1200
- if (args === null) {
1201
- m = SIGNAL_PREFIX_RE.exec(code)
1202
- continue // unbalanced — skip
1203
- }
1204
- // Only rewrite module-scope signals (brace depth 0).
1205
- if (braceDepthAt(code, m.index) === 0) {
1206
- matches.push({
1207
- start: m.index,
1208
- end: argsStart + args.length + 1, // +1 for closing paren
1209
- prefix: m[1] ?? '',
1210
- name: m[2] ?? '',
1211
- args,
1212
- })
1213
- }
1214
- m = SIGNAL_PREFIX_RE.exec(code)
1215
- }
1216
- SIGNAL_PREFIX_RE.lastIndex = 0
1217
-
1218
- // Replace in reverse to preserve offsets
1219
- let output = code
1220
- for (let i = matches.length - 1; i >= 0; i--) {
1221
- const { start, end, prefix, name, args } = matches[i] as (typeof matches)[number]
1222
- const replacement = `${prefix}__hmr_signal(${escapedId}, ${JSON.stringify(name)}, signal, ${args})`
1223
- output = output.slice(0, start) + replacement + output.slice(end)
1224
- }
1225
- return output
1226
- }
1227
-
1228
- /** Check if an argument string contains a top-level comma (i.e. has multiple arguments). */
1229
- function hasMultipleArgs(args: string): boolean {
1230
- let depth = 0
1231
- for (const ch of args) {
1232
- if (ch === '(' || ch === '[' || ch === '{') depth++
1233
- else if (ch === ')' || ch === ']' || ch === '}') depth--
1234
- else if (ch === ',' && depth === 0) return true
1235
- }
1236
- return false
1237
- }
1238
-
1239
- /**
1240
- * Inject `{ name?, __sourceLocation: { file, line, col } }` into
1241
- * `signal()` / `computed()` / `effect()` calls that don't already have
1242
- * an options argument. Only runs in dev mode for debugging/devtools.
1243
- *
1244
- * Three forms covered:
1245
- *
1246
- * `const count = signal(0)` →
1247
- * `const count = signal(0, { name: "count", __sourceLocation: {...} })`
1248
- *
1249
- * `const doubled = computed(() => count() * 2)` →
1250
- * `const doubled = computed(() => count() * 2, { name: "doubled", __sourceLocation: {...} })`
1251
- *
1252
- * `effect(() => console.log(count()))` →
1253
- * `effect(() => console.log(count()), { __sourceLocation: {...} })`
1254
- * (no `name` — anonymous effects have no binding to derive from)
1255
- *
1256
- * Module-scope signals rewritten to __hmr_signal() are naturally skipped
1257
- * because the regex matches `signal(` not `__hmr_signal(`.
1258
- *
1259
- * **LPIH integration**: `__sourceLocation` is consumed by
1260
- * `@pyreon/reactivity`'s `signal()` / `computed()` / `effect()` to skip
1261
- * the `new Error().stack` capture in `_rdRegister` — saves ~2.2µs per
1262
- * creation when devtools is active. The injected literal is byte-for-byte
1263
- * the same info the runtime would have parsed from the stack, so behavior
1264
- * is identical except no stack-parse cost.
1265
- *
1266
- * **Anonymous-effect detection**: `effect(` can also appear as a property
1267
- * access (`obj.effect(...)`), a longer identifier (`sideEffect(...)`), or
1268
- * a previously-injected call (`effect(fn, { ... })`). The unbound-effect
1269
- * pass guards against all three:
1270
- * - preceded by NOT `[A-Za-z0-9_$.]` (so `.effect`/`sideEffect` skip)
1271
- * - args do NOT already contain a 2nd arg (`hasMultipleArgs` check)
1272
- *
1273
- * @param code - source text
1274
- * @param moduleId - the file path to embed in the injected `__sourceLocation`.
1275
- * Vite passes the resolved module ID (absolute path).
1276
- */
1277
- function injectSignalNames(code: string, moduleId: string): string {
1278
- // Pre-pass: mask string-literal, template-literal, and comment regions
1279
- // so the regexes below don't false-fire on `effect(` inside docstrings,
1280
- // help-text strings, JS-as-text test fixtures, or comments mentioning
1281
- // reactive primitives. The regex runs against the MASKED code (positions
1282
- // are preserved), so a match's index points at real code; args extraction
1283
- // pulls from the ORIGINAL code for accurate output.
1284
- //
1285
- // Without this, user code like `const docs = \`effect(() => x)\`` would
1286
- // get `, { __sourceLocation: ... }` injected INSIDE the template literal,
1287
- // corrupting the help-text content at runtime.
1288
- const masked = _maskStringsAndComments(code)
1289
-
1290
- // Pass 1: bound forms — `const X = (signal|computed|effect)(…)`.
1291
- // Extract `X` as the debug name + the reactive primitive kind.
1292
- const reBound = /(?:const|let)\s+(\w+)\s*=\s*(signal|computed|effect)\(/gm
1293
- // Pass 2: unbound effect — `effect(() => …)` at statement position,
1294
- // not following a member-access (.) or identifier char ($_a-zA-Z0-9).
1295
- // Reactive primitives other than `effect` are rare without binding,
1296
- // so we skip the bare `signal(` / `computed(` form to stay conservative.
1297
- const reUnboundEffect = /(?<![\w$.])effect\(/gm
1298
-
1299
- type Match = {
1300
- start: number
1301
- end: number
1302
- name: string | null
1303
- args: string
1304
- matchIdx: number
1305
- }
1306
- const matches: Match[] = []
1307
- // Track call positions covered by pass 1 so pass 2 can skip them.
1308
- const covered = new Set<number>()
1309
-
1310
- let m: RegExpExecArray | null = reBound.exec(masked)
1311
- while (m !== null) {
1312
- const argsStart = m.index + m[0].length
1313
- const args = extractBalancedArgs(code, argsStart)
1314
- if (args !== null && !hasMultipleArgs(args)) {
1315
- matches.push({
1316
- start: argsStart,
1317
- end: argsStart + args.length,
1318
- name: m[1] ?? '',
1319
- args,
1320
- matchIdx: m.index,
1321
- })
1322
- // Mark the `effect(`/`signal(`/`computed(` token start so the
1323
- // unbound-effect pass doesn't double-process it.
1324
- const tokStart = m.index + m[0].length - (m[2]?.length ?? 0) - 1
1325
- covered.add(tokStart)
1326
- }
1327
- m = reBound.exec(masked)
1328
- }
1329
- reBound.lastIndex = 0
1330
-
1331
- m = reUnboundEffect.exec(masked)
1332
- while (m !== null) {
1333
- if (!covered.has(m.index)) {
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: null,
1341
- args,
1342
- matchIdx: m.index,
1343
- })
1344
- }
1345
- }
1346
- m = reUnboundEffect.exec(masked)
1347
- }
1348
- reUnboundEffect.lastIndex = 0
1349
-
1350
- if (matches.length === 0) return code
1351
-
1352
- // Sort by descending start so back-to-front rewriting doesn't shift
1353
- // later indices (each splice leaves earlier offsets unchanged).
1354
- matches.sort((a, b) => b.start - a.start)
1355
-
1356
- // Pre-compute line offsets ONCE — avoids O(N²) when many calls share
1357
- // a file. Each lookup becomes O(log N) via binary search.
1358
- const lineStarts = _computeLineStarts(code)
1359
-
1360
- let output = code
1361
- for (let i = 0; i < matches.length; i++) {
1362
- const { start, end, name, args, matchIdx } = matches[i] as Match
1363
- const { line, col } = _offsetToLineCol(matchIdx, lineStarts)
1364
- const locLiteral = `__sourceLocation: { file: ${JSON.stringify(moduleId)}, line: ${line}, col: ${col} }`
1365
- const inner = name !== null
1366
- ? `name: ${JSON.stringify(name)}, ${locLiteral}`
1367
- : locLiteral
1368
- output = `${output.slice(0, start)}${args}, { ${inner} }${output.slice(end)}`
1369
- }
1370
- return output
1371
- }
1372
-
1373
- /**
1374
- * Mask string-literal / template-literal / comment regions in `code` by
1375
- * replacing their content with spaces. Returns a SAME-LENGTH string so
1376
- * regex match positions in the masked version line up with the original.
1377
- *
1378
- * Used by `injectSignalNames` to skip false-positive matches against
1379
- * reactive-primitive names that appear inside strings or comments. Without
1380
- * masking, a user's `const docs = \`effect(() => x)\`` template literal
1381
- * would get `, { __sourceLocation: ... }` injected INSIDE the string,
1382
- * corrupting runtime values.
1383
- *
1384
- * Handles:
1385
- * - `"..."` / `'...'` strings (escape-aware)
1386
- * - `` `...` `` template literals; interpolations `${...}` are KEPT as
1387
- * code (their content can contain real `signal()` calls worth catching)
1388
- * - `// ...` line comments
1389
- * - `/* ... *\/` block comments
1390
- *
1391
- * Regex literals (`/foo/g`) are NOT special-cased — they're rare and the
1392
- * downstream extractBalancedArgs handles unmatched parens by returning null.
1393
- *
1394
- * @internal — exported for tests.
1395
- */
1396
- export function _maskStringsAndComments(code: string): string {
1397
- const out: string[] = []
1398
- let i = 0
1399
- const n = code.length
1400
- while (i < n) {
1401
- const c = code[i]
1402
- const c1 = code[i + 1]
1403
-
1404
- // Line comment `// ...`
1405
- if (c === '/' && c1 === '/') {
1406
- while (i < n && code[i] !== '\n') {
1407
- out.push(' ')
1408
- i++
1409
- }
1410
- continue
1411
- }
1412
- // Block comment `/* ... */`
1413
- if (c === '/' && c1 === '*') {
1414
- out.push(' ', ' ')
1415
- i += 2
1416
- while (i < n) {
1417
- if (code[i] === '*' && code[i + 1] === '/') {
1418
- out.push(' ', ' ')
1419
- i += 2
1420
- break
1421
- }
1422
- // Preserve newlines so line numbers don't shift
1423
- out.push(code[i] === '\n' ? '\n' : ' ')
1424
- i++
1425
- }
1426
- continue
1427
- }
1428
- // String literal "..." or '...'
1429
- if (c === '"' || c === "'") {
1430
- const quote = c
1431
- out.push(' ')
1432
- i++
1433
- while (i < n && code[i] !== quote) {
1434
- // Escape sequence — skip the next char too (handles `\"`, `\\`, etc.)
1435
- if (code[i] === '\\' && i + 1 < n) {
1436
- // Preserve a newline (line-continuation `\<LF>`) as a newline.
1437
- out.push(' ', code[i + 1] === '\n' ? '\n' : ' ')
1438
- i += 2
1439
- continue
1440
- }
1441
- // Unterminated string (legacy parsers stop at newline) — break
1442
- if (code[i] === '\n') break
1443
- out.push(' ')
1444
- i++
1445
- }
1446
- if (i < n && code[i] === quote) {
1447
- out.push(' ')
1448
- i++
1449
- }
1450
- continue
1451
- }
1452
- // Template literal `...` — preserve `${...}` interpolations as code
1453
- if (c === '`') {
1454
- out.push(' ')
1455
- i++
1456
- while (i < n && code[i] !== '`') {
1457
- if (code[i] === '\\' && i + 1 < n) {
1458
- out.push(' ', code[i + 1] === '\n' ? '\n' : ' ')
1459
- i += 2
1460
- continue
1461
- }
1462
- // `${...}` — keep the interpolation body as code (with nested
1463
- // brace tracking so we find the matching `}`).
1464
- if (code[i] === '$' && code[i + 1] === '{') {
1465
- out.push(' ', ' ')
1466
- i += 2
1467
- let depth = 1
1468
- while (i < n && depth > 0) {
1469
- if (code[i] === '{') {
1470
- depth++
1471
- out.push(code[i] ?? ' ')
1472
- i++
1473
- continue
1474
- }
1475
- if (code[i] === '}') {
1476
- depth--
1477
- if (depth === 0) {
1478
- out.push(' ')
1479
- i++
1480
- break
1481
- }
1482
- out.push(code[i] ?? ' ')
1483
- i++
1484
- continue
1485
- }
1486
- // Inside `${}` — pass through as code (might contain `signal(` etc).
1487
- out.push(code[i] ?? ' ')
1488
- i++
1489
- }
1490
- continue
1491
- }
1492
- // Preserve newlines so line numbers don't shift.
1493
- out.push(code[i] === '\n' ? '\n' : ' ')
1494
- i++
1495
- }
1496
- if (i < n && code[i] === '`') {
1497
- out.push(' ')
1498
- i++
1499
- }
1500
- continue
1501
- }
1502
- out.push(c ?? '')
1503
- i++
1504
- }
1505
- return out.join('')
1506
- }
1507
-
1508
- /**
1509
- * Compute the 0-indexed character offset for the start of each line.
1510
- * `lineStarts[i]` is the offset of the FIRST character on line i+1
1511
- * (1-based, so `lineStarts[0]` = offset 0 = line 1).
1512
- *
1513
- * @internal — exported for tests.
1514
- */
1515
- export function _computeLineStarts(code: string): number[] {
1516
- const starts: number[] = [0]
1517
- for (let i = 0; i < code.length; i++) {
1518
- if (code.charCodeAt(i) === 10) starts.push(i + 1) // \n
1519
- }
1520
- return starts
1521
- }
1522
-
1523
- /**
1524
- * Convert a 0-indexed offset to `{ line: 1-based, col: 1-based }` using a
1525
- * pre-computed line-starts array. Binary search → O(log N) per lookup.
1526
- *
1527
- * @internal — exported for tests.
1528
- */
1529
- export function _offsetToLineCol(
1530
- offset: number,
1531
- lineStarts: number[],
1532
- ): { line: number; col: number } {
1533
- // Binary search for the largest lineStarts[i] <= offset.
1534
- let lo = 0
1535
- let hi = lineStarts.length - 1
1536
- while (lo < hi) {
1537
- const mid = (lo + hi + 1) >>> 1
1538
- const v = lineStarts[mid]
1539
- if (v !== undefined && v <= offset) lo = mid
1540
- else hi = mid - 1
1541
- }
1542
- const lineStart = lineStarts[lo] ?? 0
1543
- return { line: lo + 1, col: offset - lineStart + 1 }
1544
- }
1545
-
1546
- function injectHmr(code: string, moduleId: string): string {
1547
- const hasSignals = SIGNAL_PREFIX_RE.test(code)
1548
- SIGNAL_PREFIX_RE.lastIndex = 0
1549
-
1550
- const hasComponentExport = EXPORT_COMPONENT_RE.test(code)
1551
-
1552
- // Only inject HMR if the module exports components or has module-scope signals
1553
- if (!hasComponentExport && !hasSignals) return code
1554
-
1555
- let output = hasSignals ? rewriteSignals(code, moduleId) : code
1556
-
1557
- // Build the HMR footer
1558
- const escapedId = JSON.stringify(moduleId)
1559
- const lines: string[] = []
1560
-
1561
- if (hasSignals) {
1562
- lines.push(`import { __hmr_signal, __hmr_dispose } from "${HMR_RUNTIME_IMPORT}";`)
1563
- }
1564
-
1565
- lines.push(`if (import.meta.hot) {`)
1566
-
1567
- if (hasSignals) {
1568
- lines.push(` import.meta.hot.dispose(() => __hmr_dispose(${escapedId}));`)
1569
- }
1570
-
1571
- // Self-accept the module, then drive Pyreon's HMR coordinator.
1572
- //
1573
- // The OLD code emitted a bare `import.meta.hot.accept()` (no callback):
1574
- // Vite re-evaluated the module but NOTHING re-rendered the mounted tree,
1575
- // AND the self-accept suppressed Vite's full-reload fallback — so a
1576
- // component/JSX edit produced a silently-stale UI until a MANUAL refresh.
1577
- //
1578
- // Now: the accept callback hands the FRESH module namespace Vite already
1579
- // re-evaluated straight to `globalThis.__pyreon_hmr_swap__` (registered
1580
- // by `@pyreon/router` in a dev browser — zero import coupling, same
1581
- // pattern as the perf-harness counter sink), keyed by THIS module's id.
1582
- // The coordinator finds every active matched route record whose lazy
1583
- // `_hmrId` matches and swaps in the new component, re-rendering ONLY
1584
- // that subtree IN PLACE (no page reload → `__pyreon_hmr_registry__`
1585
- // survives → `__hmr_signal` restores module-scope signal values).
1586
- //
1587
- // Using the namespace Vite passes (not a re-run of the lazy thunk)
1588
- // sidesteps the stale-`?t=` trap: the dynamic-import thunk lives in the
1589
- // virtual routes module, which is NOT invalidated when this leaf route
1590
- // self-accepts — re-importing it would return the OLD module.
1591
- //
1592
- // `__pyreon_hmr_swap__` returns falsy when the edit was outside the
1593
- // active route tree (nested non-route component, unrelated route,
1594
- // signal-only module) OR no coordinator is registered (plain
1595
- // `@pyreon/runtime-dom` app, or module loaded before any router
1596
- // mounted). Then `import.meta.hot.invalidate()` → Vite propagates → an
1597
- // AUTOMATIC full reload. Either way the user never refreshes by hand.
1598
- lines.push(` import.meta.hot.accept((__m) => {`)
1599
- lines.push(` const __s = globalThis.__pyreon_hmr_swap__;`)
1600
- lines.push(
1601
- ` if (typeof __s === "function" && __m && __s(${escapedId}, __m)) return;`,
1602
- )
1603
- lines.push(` import.meta.hot.invalidate();`)
1604
- lines.push(` });`)
1605
- lines.push(`}`)
1606
-
1607
- output = `${output}\n\n${lines.join('\n')}\n`
1608
-
1609
- return output
1610
- }
1611
-
1612
- // ── Compat attribute transforms ──────────────────────────────────────────────
1613
-
1614
- /**
1615
- * Transform React-style JSX attribute names to standard HTML attribute names.
1616
- * This is a lightweight string transform that runs on JSX source before OXC's
1617
- * JSX transform converts it to jsx() calls.
1618
- *
1619
- * - `className` → `class`
1620
- * - `htmlFor` → `for`
1621
- *
1622
- * Only matches attribute position in JSX (after `<tag ` or whitespace).
1623
- * Does not transform property access (e.g. `props.className` stays as-is since
1624
- * the compat JSX runtime handles that at call time).
1625
- */
1626
- function transformCompatAttributes(code: string): string {
1627
- // Match className/htmlFor in JSX attribute position:
1628
- // After < and tag name, or after whitespace between attributes
1629
- // Pattern: word boundary + attribute name + = (with optional whitespace)
1630
- return code
1631
- .replace(/(\s)className(\s*=)/g, '$1class$2')
1632
- .replace(/(\s)htmlFor(\s*=)/g, '$1for$2')
1633
- }
1634
-
1635
- // ── Helpers ───────────────────────────────────────────────────────────────────
1636
-
1637
- function getExt(id: string): string {
1638
- const clean = id.split('?')[0] ?? id
1639
- const dot = clean.lastIndexOf('.')
1640
- return dot >= 0 ? clean.slice(dot) : ''
1641
- }
1642
-
1643
- /** Skip Vite-handled asset requests (CSS, images, HMR, etc.) */
1644
- function isAssetRequest(url: string): boolean {
1645
- return (
1646
- url.startsWith('/@') || // @vite/client, @id, @fs, etc.
1647
- url.startsWith('/__') || // __open-in-editor, etc.
1648
- url.includes('/node_modules/') ||
1649
- /\.(css|js|ts|tsx|jsx|json|ico|png|jpg|jpeg|gif|svg|woff2?|ttf|eot|map)(\?|$)/.test(url)
1650
- )
1651
- }
1652
-
1653
- // ── HMR runtime source (served as virtual module) ─────────────────────────────
1654
- //
1655
- // Inlined here so it's available without a filesystem read. This is the
1656
- // compiled-to-JS version of hmr-runtime.ts — kept in sync manually.
1657
-
1658
- // ─── Cross-module signal auto-call helpers ──────────────────────────────────
1659
-
1660
- /**
1661
- * Normalize a Vite module ID by stripping query strings (?v=..., ?t=...)
1662
- * and resolving to an absolute path for consistent registry lookups.
1663
- */
1664
- function normalizeModuleId(id: string): string {
1665
- const queryIndex = id.indexOf('?')
1666
- return queryIndex >= 0 ? id.slice(0, queryIndex) : id
1667
- }
1668
-
1669
- // ─── Island declaration scanner ────────────────────────────────────────────
1670
-
1671
- /**
1672
- * One island() call site discovered in source.
1673
- *
1674
- * `loaderAbsPath` is the dynamic-import target resolved relative to the
1675
- * source file where the call was written. Vite's resolver finds the actual
1676
- * file (.tsx / .jsx / .ts / .js extension auto-added) when the registry
1677
- * module emits `() => import('<loaderAbsPath>')`.
1678
- */
1679
- interface IslandDecl {
1680
- name: string
1681
- hydrate: string
1682
- loaderAbsPath: string
1683
- }
1684
-
1685
- /**
1686
- * Pre-scan all source files in the project for `island()` declarations.
1687
- *
1688
- * Called from `buildStart` (when `islands: true`) so the registry is fully
1689
- * populated before any transforms run. Mirrors `prescanSignalExports` shape;
1690
- * the per-file regex pattern matches:
1691
- *
1692
- * island(() => import('PATH'), { name: 'NAME', hydrate: 'STRATEGY' })
1693
- *
1694
- * Edge cases the regex deliberately doesn't cover (user falls back to manual
1695
- * `hydrateIslands({ ... })`):
1696
- * - Loader is a variable, not an inline arrow: `island(myLoader, { name })`
1697
- * - Name is a variable: `island(() => import('./X'), { name: NAME_CONST })`
1698
- * - Options come from a spread: `island(loader, { ...opts })`
1699
- */
1700
- async function prescanIslandDeclarations(
1701
- root: string,
1702
- registry: Map<string, IslandDecl[]>,
1703
- ): Promise<void> {
1704
- const files: string[] = []
1705
-
1706
- function walk(dir: string) {
1707
- try {
1708
- for (const entry of readdirSync(dir)) {
1709
- if (
1710
- entry.startsWith('.') ||
1711
- entry === 'node_modules' ||
1712
- entry === 'dist' ||
1713
- entry === 'lib' ||
1714
- entry === 'build'
1715
- )
1716
- continue
1717
- const full = pathJoin(dir, entry)
1718
- try {
1719
- const stat = statSync(full)
1720
- if (stat.isDirectory()) walk(full)
1721
- else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
1722
- } catch {
1723
- /* permission error, etc. */
1724
- }
1725
- }
1726
- } catch {
1727
- /* dir doesn't exist */
1728
- }
1729
- }
1730
-
1731
- walk(root)
1732
-
1733
- for (const file of files) {
1734
- try {
1735
- const code = readFileSync(file, 'utf-8')
1736
- scanIslandDeclarations(code, file, registry)
1737
- } catch {
1738
- /* read error */
1739
- }
1740
- }
1741
- }
1742
-
1743
- /**
1744
- * Scan a single source file for `island()` declarations and record them.
1745
- *
1746
- * The regex captures:
1747
- * - Group 1: dynamic-import path (`./components/Counter`)
1748
- * - Group 2: options block contents
1749
- *
1750
- * Then a follow-up regex pulls `name: 'X'` and `hydrate: 'Y'` from the
1751
- * options block. Single-line and multi-line forms both work.
1752
- *
1753
- * Resolves the loader path relative to the file where the call lives so
1754
- * the emitted virtual-module registry gets an absolute path Vite's resolver
1755
- * can find.
1756
- */
1757
- function scanIslandDeclarations(
1758
- code: string,
1759
- filePath: string,
1760
- registry: Map<string, IslandDecl[]>,
1761
- ): void {
1762
- // `[\s\S]` lets the options block span multiple lines. The lazy `?` after
1763
- // the options block prevents over-matching when several `island()` calls
1764
- // appear in the same file.
1765
- // `[^}]{0,500}` instead of `[\s\S]*?` — real island() option blocks
1766
- // are tiny (`{ name: 'X', hydrate: 'load' }`); excluding `}` from
1767
- // the inner class also tightens the match against the outer `\}`.
1768
- const ISLAND_CALL_RE =
1769
- /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([^}]{0,500})\}\s*\)/g
1770
- const decls: IslandDecl[] = []
1771
- let match: RegExpExecArray | null
1772
- while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
1773
- const importPath = match[1]!
1774
- const optsBlock = match[2]!
1775
- const nameMatch = /(?:^|[\s,{])name\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
1776
- if (!nameMatch) continue // can't auto-register without a name
1777
- const hydrateMatch = /(?:^|[\s,{])hydrate\s*:\s*['"]([^'"]+)['"]/.exec(optsBlock)
1778
- const hydrate = hydrateMatch ? hydrateMatch[1]! : 'load'
1779
- const loaderAbsPath = importPath.startsWith('.')
1780
- ? resolveRelative(filePath, importPath)
1781
- : importPath
1782
- decls.push({ name: nameMatch[1]!, hydrate, loaderAbsPath })
1783
- }
1784
- if (decls.length > 0) {
1785
- registry.set(normalizeModuleId(filePath), decls)
1786
- } else {
1787
- // Clean up if file no longer declares islands (e.g. after edit)
1788
- registry.delete(normalizeModuleId(filePath))
1789
- }
1790
- }
1791
-
1792
- /**
1793
- * Resolve a dynamic-import specifier to an absolute path, mirroring how Node
1794
- * / Vite resolve `import('./X')` from the source file's directory.
1795
- */
1796
- function resolveRelative(fromFile: string, relPath: string): string {
1797
- return pathJoin(dirname(fromFile), relPath)
1798
- }
1799
-
1800
- /**
1801
- * Render the auto-generated `virtual:pyreon/islands-registry` source. Emits:
1802
- *
1803
- * export const __pyreonIslandRegistry = {
1804
- * Counter: () => import('/abs/path/to/components/Counter'),
1805
- * IdleClock: () => import('/abs/path/to/components/IdleClock'),
1806
- * // never-strategy islands deliberately omitted
1807
- * }
1808
- *
1809
- * `hydrate: 'never'` islands are skipped — registering a loader for them
1810
- * would defeat the strategy by pulling the component module into the
1811
- * client bundle graph. `hydrateIslandsAuto()` short-circuits never-islands
1812
- * at runtime regardless; emitting here would still create the dynamic-
1813
- * import chunk.
1814
- *
1815
- * Duplicate `name` across declarations: the LAST one wins. Documented as
1816
- * an anti-pattern (caught by the planned `pyreon doctor --check-islands`).
1817
- */
1818
- function renderIslandsRegistry(
1819
- registry: Map<string, IslandDecl[]>,
1820
- enabled: boolean,
1821
- ): string {
1822
- if (!enabled) {
1823
- return [
1824
- `// pyreon plugin: islands feature is disabled (pyreon({ islands: false })).`,
1825
- `// hydrateIslandsAuto() will throw at runtime — re-enable via vite.config.ts`,
1826
- `// or use manual hydrateIslands({ ... }) instead.`,
1827
- `export const __pyreonIslandRegistry = {};`,
1828
- `export const __pyreonIslandsEnabled = false;`,
1829
- ].join('\n')
1830
- }
1831
- const entries: string[] = []
1832
- const seen = new Set<string>()
1833
- // Deterministic order: sort by name for stable output / predictable HMR.
1834
- const all = Array.from(registry.values()).flat()
1835
- all.sort((a, b) => a.name.localeCompare(b.name))
1836
- for (const { name, hydrate, loaderAbsPath } of all) {
1837
- if (hydrate === 'never') continue
1838
- if (seen.has(name)) continue
1839
- seen.add(name)
1840
- // JSON.stringify gives proper escaping for both name (object key) and path.
1841
- entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(loaderAbsPath)}),`)
1842
- }
1843
- return [
1844
- `// Auto-generated by @pyreon/vite-plugin (islands: true). Do not edit.`,
1845
- `// Sourced from island() declarations in your project. Never-strategy`,
1846
- `// islands are intentionally omitted — registering a loader for them`,
1847
- `// would defeat the zero-JS contract.`,
1848
- `export const __pyreonIslandRegistry = {`,
1849
- ...entries,
1850
- `};`,
1851
- `export const __pyreonIslandsEnabled = true;`,
1852
- ].join('\n')
1853
- }
1854
-
1855
- /**
1856
- * Pre-scan all source files in the project for signal exports.
1857
- *
1858
- * Called from `buildStart` so the registry is fully populated before any
1859
- * transforms run. This solves the build ordering problem where component.tsx
1860
- * is transformed before store.ts — without pre-scanning, the registry would
1861
- * be empty and imported signals would not be auto-called.
1862
- */
1863
- async function prescanSignalExports(root: string, registry: Map<string, Set<string>>): Promise<void> {
1864
- const files: string[] = []
1865
-
1866
- function walk(dir: string) {
1867
- try {
1868
- for (const entry of readdirSync(dir)) {
1869
- if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'lib' || entry === 'build') continue
1870
- const full = pathJoin(dir, entry)
1871
- try {
1872
- const stat = statSync(full)
1873
- if (stat.isDirectory()) walk(full)
1874
- else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full)
1875
- } catch {
1876
- /* permission error, etc. */
1877
- }
1878
- }
1879
- } catch {
1880
- /* dir doesn't exist */
1881
- }
1882
- }
1883
-
1884
- walk(root)
1885
-
1886
- for (const file of files) {
1887
- try {
1888
- const code = readFileSync(file, 'utf-8')
1889
- scanSignalExports(code, file, registry)
1890
- } catch {
1891
- /* read error */
1892
- }
1893
- }
1894
- }
1895
-
1896
- /**
1897
- * Scan a module's source for exported signal declarations and register them.
1898
- *
1899
- * Detects patterns:
1900
- * 1. `export const x = signal(...)` or `export const x = computed(...)` — inline export
1901
- * 2. `const x = signal(...); export { x }` — separate declaration + named export
1902
- * 3. `export default signal(...)` — default export (tracked as 'default')
1903
- *
1904
- * Re-exports (`export { x } from './signals'`) are NOT detected — the source
1905
- * module must be scanned directly. This is a known limitation.
1906
- *
1907
- * Uses simple regex — no AST parse needed.
1908
- */
1909
- // Bounded `\s{1,10}` instead of unbounded `\s+` to remove worst-case
1910
- // backtracking; real import specifiers have 1-2 spaces around `as`.
1911
- const AS_SPLIT_RE = /\s{1,10}as\s{1,10}/
1912
-
1913
- function scanSignalExports(code: string, moduleId: string, registry: Map<string, Set<string>>): void {
1914
- const normalizedId = normalizeModuleId(moduleId)
1915
- let match: RegExpExecArray | null
1916
- const signals = new Set<string>()
1917
-
1918
- // Pattern 1: export const x = signal(...) or export const x = computed(...)
1919
- const EXPORT_CONST_RE = /export\s+const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/g
1920
- while ((match = EXPORT_CONST_RE.exec(code)) !== null) {
1921
- signals.add(match[1]!)
1922
- }
1923
-
1924
- // Pattern 2: const x = signal(...) followed by export { x }
1925
- // First, find all local `const x = signal(` or `const x = computed(` declarations
1926
- const localSignals = new Set<string>()
1927
- const LOCAL_SIGNAL_RE = /(?:^|[\s;])const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/gm
1928
- while ((match = LOCAL_SIGNAL_RE.exec(code)) !== null) {
1929
- localSignals.add(match[1]!)
1930
- }
1931
-
1932
- // Then check named exports: export { x, y as z }
1933
- if (localSignals.size > 0) {
1934
- // Bounded `[^}]{1,500}` — real export blocks fit easily.
1935
- const NAMED_EXPORT_RE = /export\s*\{([^}]{1,500})\}/g
1936
- while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
1937
- // Skip re-exports (export { x } from '...')
1938
- const afterBrace = code.slice(match.index + match[0].length).trimStart()
1939
- if (afterBrace.startsWith('from')) continue
1940
-
1941
- for (const spec of match[1]!.split(',')) {
1942
- const trimmed = spec.trim()
1943
- if (!trimmed) continue
1944
- const parts = trimmed.split(AS_SPLIT_RE)
1945
- const localName = parts[0]!.trim()
1946
- const exportedName = (parts[1] ?? parts[0])!.trim()
1947
- if (localSignals.has(localName)) {
1948
- signals.add(exportedName)
1949
- }
1950
- }
1951
- }
1952
- }
1953
-
1954
- // Pattern 3: export default signal(...) or export default computed(...) — tracked as 'default'
1955
- if (/export\s+default\s+(?:signal|computed)\s*[<(]/.test(code)) {
1956
- signals.add('default')
1957
- }
1958
-
1959
- if (signals.size > 0) {
1960
- registry.set(normalizedId, signals)
1961
- } else {
1962
- // Clean up if module no longer exports signals (e.g. after edit)
1963
- registry.delete(normalizedId)
1964
- }
1965
- }
1966
-
1967
- /**
1968
- * Resolve imported signal names from the signal export registry.
1969
- *
1970
- * For each import in the source, resolves the module and checks if it has
1971
- * signal exports in the registry. Returns the local names of imported signals.
1972
- *
1973
- * Handles named imports (`import { x } from ...`) and default imports
1974
- * (`import x from ...` — matched against 'default' in the registry).
1975
- */
1976
- async function resolveImportedSignals(
1977
- code: string,
1978
- _moduleId: string,
1979
- registry: Map<string, Set<string>>,
1980
- pluginCtx: { resolve: (id: string, importer?: string, options?: { skipSelf: boolean }) => Promise<{ id: string } | null> },
1981
- resolveCache: Map<string, string | null>,
1982
- ): Promise<string[]> {
1983
- if (registry.size === 0) return []
1984
-
1985
- const knownSignals: string[] = []
1986
- let match: RegExpExecArray | null
1987
-
1988
- /** Resolve a source specifier to a normalized module ID, using the cache. */
1989
- async function resolveSource(source: string): Promise<string | null> {
1990
- const cacheKey = `${_moduleId}::${source}`
1991
- if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null
1992
- let resolvedId: string | null = null
1993
- try {
1994
- const resolved = await pluginCtx.resolve(source, _moduleId, { skipSelf: true })
1995
- resolvedId = resolved?.id ? normalizeModuleId(resolved.id) : null
1996
- } catch {
1997
- /* resolve error */
1998
- }
1999
- resolveCache.set(cacheKey, resolvedId)
2000
- return resolvedId
2001
- }
2002
-
2003
- // Named imports: import { name1, name2 as alias } from 'source'
2004
- // Excludes `import type { ... }` — type-only imports have no runtime value
2005
- const IMPORT_RE = /import\s+(?!type\s)\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g
2006
- while ((match = IMPORT_RE.exec(code)) !== null) {
2007
- const specifiers = match[1]!
2008
- const source = match[2]!
2009
-
2010
- const resolvedId = await resolveSource(source)
2011
- if (!resolvedId) continue
2012
- const exportedSignals = registry.get(resolvedId)
2013
- if (!exportedSignals) continue
2014
-
2015
- // Parse import specifiers: "count, theme as t, other"
2016
- for (const spec of specifiers.split(',')) {
2017
- const trimmed = spec.trim()
2018
- if (!trimmed) continue
2019
-
2020
- const parts = trimmed.split(AS_SPLIT_RE)
2021
- const importedName = parts[0]!.trim()
2022
- const localName = (parts[1] ?? parts[0])!.trim()
2023
-
2024
- if (exportedSignals.has(importedName)) {
2025
- knownSignals.push(localName)
2026
- }
2027
- }
2028
- }
2029
-
2030
- // Default imports: import count from './store'
2031
- // Excludes: `import { ... }`, `import type X`, `import * as X`
2032
- const DEFAULT_IMPORT_RE = /import\s+(?!type\s)(\w+)\s+from\s*['"]([^'"]+)['"]/g
2033
- while ((match = DEFAULT_IMPORT_RE.exec(code)) !== null) {
2034
- // Skip if this is actually a `import type X from` pattern
2035
- const fullMatch = match[0]
2036
- if (/import\s+type\s+/.test(fullMatch)) continue
2037
-
2038
- const localName = match[1]!
2039
- const source = match[2]!
2040
-
2041
- const resolvedId = await resolveSource(source)
2042
- if (!resolvedId) continue
2043
- const exportedSignals = registry.get(resolvedId)
2044
- if (!exportedSignals) continue
2045
-
2046
- if (exportedSignals.has('default')) {
2047
- knownSignals.push(localName)
2048
- }
2049
- }
2050
-
2051
- return knownSignals
2052
- }
2053
-
2054
- const HMR_RUNTIME_SOURCE = `
2055
- const REGISTRY_KEY = "__pyreon_hmr_registry__";
2056
-
2057
- function getRegistry() {
2058
- if (!globalThis[REGISTRY_KEY]) {
2059
- globalThis[REGISTRY_KEY] = new Map();
2060
- }
2061
- return globalThis[REGISTRY_KEY];
2062
- }
2063
-
2064
- const moduleSignals = new Map();
2065
-
2066
- export function __hmr_signal(moduleId, name, signalFn, initialValue) {
2067
- const registry = getRegistry();
2068
- const saved = registry.get(moduleId);
2069
- const value = saved?.has(name) ? saved.get(name) : initialValue;
2070
- const s = signalFn(value, { name: name });
2071
-
2072
- let mod = moduleSignals.get(moduleId);
2073
- if (!mod) {
2074
- mod = { entries: new Map() };
2075
- moduleSignals.set(moduleId, mod);
2076
- }
2077
- mod.entries.set(name, s);
2078
-
2079
- return s;
2080
- }
2081
-
2082
- export function __hmr_dispose(moduleId) {
2083
- const mod = moduleSignals.get(moduleId);
2084
- if (!mod) return;
2085
-
2086
- const registry = getRegistry();
2087
- const saved = new Map();
2088
- for (const [name, s] of mod.entries) {
2089
- saved.set(name, s.peek());
2090
- }
2091
- registry.set(moduleId, saved);
2092
- moduleSignals.delete(moduleId);
2093
- }
2094
- `