@pyreon/vite-plugin 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -25
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +388 -22
- package/lib/types/index.d.ts +128 -2
- package/package.json +2 -2
- package/src/index.ts +631 -18
- package/src/tests/cache-eviction-on-delete.test.ts +187 -0
- package/src/tests/dev-server.test.ts +5 -1
- package/src/tests/lpih-auto-bridge.test.ts +408 -0
- package/src/tests/lpih-injection.test.ts +559 -0
- package/src/tests/vite-plugin.test.ts +5 -2
package/src/index.ts
CHANGED
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
import type { CollapseResolver } from './rocketstyle-collapse'
|
|
45
45
|
import type { Plugin, ViteDevServer } from 'vite'
|
|
46
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
|
+
|
|
47
51
|
// Lazy — the resolver module (and its `vite` SSR machinery) must NOT be
|
|
48
52
|
// on the static import path of this cheap entry. It loads ONLY when
|
|
49
53
|
// `pyreon({ collapse })` is enabled AND a collapsible site is scanned;
|
|
@@ -130,6 +134,31 @@ export interface PyreonPluginOptions {
|
|
|
130
134
|
*/
|
|
131
135
|
islands?: boolean
|
|
132
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
|
+
|
|
133
162
|
/**
|
|
134
163
|
* P0 — opt-in compile-time rocketstyle wrapper collapse. `true` uses
|
|
135
164
|
* the default provider/theme/mode wiring (PyreonUI + theme +
|
|
@@ -170,6 +199,26 @@ export interface PyreonCollapseOptions {
|
|
|
170
199
|
mode?: { name: string; source: string }
|
|
171
200
|
}
|
|
172
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
|
+
|
|
173
222
|
// ── Compat alias maps ─────────────────────────────────────────────────────────
|
|
174
223
|
|
|
175
224
|
const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
|
|
@@ -332,6 +381,15 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
332
381
|
// you have a specific reason.
|
|
333
382
|
const islandsEnabled = options?.islands !== false
|
|
334
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
|
+
|
|
335
393
|
// ── P0 rocketstyle-collapse config (opt-in) ───────────────────────────────
|
|
336
394
|
const collapseOpt = options?.collapse
|
|
337
395
|
const collapseEnabled = collapseOpt === true || (collapseOpt != null && collapseOpt !== false)
|
|
@@ -488,6 +546,76 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
488
546
|
}
|
|
489
547
|
},
|
|
490
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
|
+
|
|
491
619
|
// Tear down the one programmatic Vite SSR server the collapse
|
|
492
620
|
// resolver holds (created lazily on first client-graph transform).
|
|
493
621
|
async closeBundle() {
|
|
@@ -674,8 +802,10 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
674
802
|
// ── Dev-only transforms ────────────────────────────────────────────
|
|
675
803
|
if (!isBuild) {
|
|
676
804
|
output = injectHmr(output, id)
|
|
677
|
-
// Inject debug names for signal() calls
|
|
678
|
-
|
|
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)
|
|
679
809
|
}
|
|
680
810
|
|
|
681
811
|
// R12: surface the compiler's V3 source map so stack traces /
|
|
@@ -703,6 +833,14 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
703
833
|
}
|
|
704
834
|
})
|
|
705
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
|
+
|
|
706
844
|
if (!ssrConfig) return
|
|
707
845
|
|
|
708
846
|
// Return a function so the middleware runs AFTER Vite's built-in middleware
|
|
@@ -722,6 +860,18 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
722
860
|
})
|
|
723
861
|
}
|
|
724
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
|
+
},
|
|
725
875
|
}
|
|
726
876
|
}
|
|
727
877
|
|
|
@@ -780,6 +930,186 @@ function generateProjectContext(root: string): void {
|
|
|
780
930
|
}
|
|
781
931
|
}
|
|
782
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
|
+
|
|
783
1113
|
// ── HMR injection ─────────────────────────────────────────────────────────────
|
|
784
1114
|
|
|
785
1115
|
/**
|
|
@@ -907,37 +1237,312 @@ function hasMultipleArgs(args: string): boolean {
|
|
|
907
1237
|
}
|
|
908
1238
|
|
|
909
1239
|
/**
|
|
910
|
-
* Inject `{ name:
|
|
1240
|
+
* Inject `{ name?, __sourceLocation: { file, line, col } }` into
|
|
1241
|
+
* `signal()` / `computed()` / `effect()` calls that don't already have
|
|
911
1242
|
* an options argument. Only runs in dev mode for debugging/devtools.
|
|
912
1243
|
*
|
|
913
|
-
*
|
|
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)
|
|
914
1255
|
*
|
|
915
1256
|
* Module-scope signals rewritten to __hmr_signal() are naturally skipped
|
|
916
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).
|
|
917
1276
|
*/
|
|
918
|
-
function injectSignalNames(code: string): string {
|
|
919
|
-
|
|
920
|
-
|
|
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>()
|
|
921
1309
|
|
|
922
|
-
let m: RegExpExecArray | null =
|
|
1310
|
+
let m: RegExpExecArray | null = reBound.exec(masked)
|
|
923
1311
|
while (m !== null) {
|
|
924
1312
|
const argsStart = m.index + m[0].length
|
|
925
1313
|
const args = extractBalancedArgs(code, argsStart)
|
|
926
1314
|
if (args !== null && !hasMultipleArgs(args)) {
|
|
927
|
-
matches.push({
|
|
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
|
+
}
|
|
928
1345
|
}
|
|
929
|
-
m =
|
|
1346
|
+
m = reUnboundEffect.exec(masked)
|
|
930
1347
|
}
|
|
931
|
-
|
|
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)
|
|
932
1359
|
|
|
933
1360
|
let output = code
|
|
934
|
-
for (let i =
|
|
935
|
-
const { start, end, name, args } = matches[i] as
|
|
936
|
-
|
|
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)}`
|
|
937
1369
|
}
|
|
938
1370
|
return output
|
|
939
1371
|
}
|
|
940
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
|
+
|
|
941
1546
|
function injectHmr(code: string, moduleId: string): string {
|
|
942
1547
|
const hasSignals = SIGNAL_PREFIX_RE.test(code)
|
|
943
1548
|
SIGNAL_PREFIX_RE.lastIndex = 0
|
|
@@ -1157,8 +1762,11 @@ function scanIslandDeclarations(
|
|
|
1157
1762
|
// `[\s\S]` lets the options block span multiple lines. The lazy `?` after
|
|
1158
1763
|
// the options block prevents over-matching when several `island()` calls
|
|
1159
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 `\}`.
|
|
1160
1768
|
const ISLAND_CALL_RE =
|
|
1161
|
-
/island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([
|
|
1769
|
+
/island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([^}]{0,500})\}\s*\)/g
|
|
1162
1770
|
const decls: IslandDecl[] = []
|
|
1163
1771
|
let match: RegExpExecArray | null
|
|
1164
1772
|
while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
|
|
@@ -1298,6 +1906,10 @@ async function prescanSignalExports(root: string, registry: Map<string, Set<stri
|
|
|
1298
1906
|
*
|
|
1299
1907
|
* Uses simple regex — no AST parse needed.
|
|
1300
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
|
+
|
|
1301
1913
|
function scanSignalExports(code: string, moduleId: string, registry: Map<string, Set<string>>): void {
|
|
1302
1914
|
const normalizedId = normalizeModuleId(moduleId)
|
|
1303
1915
|
let match: RegExpExecArray | null
|
|
@@ -1319,7 +1931,8 @@ function scanSignalExports(code: string, moduleId: string, registry: Map<string,
|
|
|
1319
1931
|
|
|
1320
1932
|
// Then check named exports: export { x, y as z }
|
|
1321
1933
|
if (localSignals.size > 0) {
|
|
1322
|
-
|
|
1934
|
+
// Bounded `[^}]{1,500}` — real export blocks fit easily.
|
|
1935
|
+
const NAMED_EXPORT_RE = /export\s*\{([^}]{1,500})\}/g
|
|
1323
1936
|
while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
|
|
1324
1937
|
// Skip re-exports (export { x } from '...')
|
|
1325
1938
|
const afterBrace = code.slice(match.index + match[0].length).trimStart()
|
|
@@ -1328,7 +1941,7 @@ function scanSignalExports(code: string, moduleId: string, registry: Map<string,
|
|
|
1328
1941
|
for (const spec of match[1]!.split(',')) {
|
|
1329
1942
|
const trimmed = spec.trim()
|
|
1330
1943
|
if (!trimmed) continue
|
|
1331
|
-
const parts = trimmed.split(
|
|
1944
|
+
const parts = trimmed.split(AS_SPLIT_RE)
|
|
1332
1945
|
const localName = parts[0]!.trim()
|
|
1333
1946
|
const exportedName = (parts[1] ?? parts[0])!.trim()
|
|
1334
1947
|
if (localSignals.has(localName)) {
|
|
@@ -1404,7 +2017,7 @@ async function resolveImportedSignals(
|
|
|
1404
2017
|
const trimmed = spec.trim()
|
|
1405
2018
|
if (!trimmed) continue
|
|
1406
2019
|
|
|
1407
|
-
const parts = trimmed.split(
|
|
2020
|
+
const parts = trimmed.split(AS_SPLIT_RE)
|
|
1408
2021
|
const importedName = parts[0]!.trim()
|
|
1409
2022
|
const localName = (parts[1] ?? parts[0])!.trim()
|
|
1410
2023
|
|