@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/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 not rewritten by HMR
678
- output = injectSignalNames(output)
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: "varName" }` into signal() calls that don't already have
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
- * `const count = signal(0)` → `const count = signal(0, { name: "count" })`
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
- const re = /(?:const|let)\s+(\w+)\s*=\s*signal\(/gm
920
- const matches: { start: number; end: number; name: string; args: string }[] = []
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 = re.exec(code)
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({ start: argsStart, end: argsStart + args.length, name: m[1] ?? '', 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
+ }
928
1345
  }
929
- m = re.exec(code)
1346
+ m = reUnboundEffect.exec(masked)
930
1347
  }
931
- re.lastIndex = 0
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 = matches.length - 1; i >= 0; i--) {
935
- const { start, end, name, args } = matches[i] as (typeof matches)[number]
936
- output = `${output.slice(0, start)}${args}, { name: ${JSON.stringify(name)} }${output.slice(end)}`
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*\{([\s\S]*?)\}\s*\)/g
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
- const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g
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(/\s+as\s+/)
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(/\s+as\s+/)
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