@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/lib/index.js CHANGED
@@ -36,6 +36,8 @@ import { generateContext, scanCollapsibleSites, transformDeferInline, transformJ
36
36
  * vite build # client bundle
37
37
  * vite build --ssr src/entry-server.ts --outDir dist/server # server bundle
38
38
  */
39
+ const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
40
+ const _countSink = globalThis;
39
41
  let _createCollapseResolver = null;
40
42
  async function loadCreateCollapseResolver() {
41
43
  if (!_createCollapseResolver) _createCollapseResolver = (await import("./rocketstyle-collapse-C4eMAnwR.js")).createCollapseResolver;
@@ -177,6 +179,10 @@ function pyreonPlugin(options) {
177
179
  const ssrConfig = options?.ssr;
178
180
  const compat = options?.compat;
179
181
  const islandsEnabled = options?.islands !== false;
182
+ const lpihOpt = options?.lpih;
183
+ const lpihEnabled = lpihOpt !== false;
184
+ const lpihUserCfg = lpihOpt && lpihOpt !== true ? lpihOpt : {};
185
+ const lpihIntervalMs = lpihUserCfg.intervalMs ?? 250;
180
186
  const collapseOpt = options?.collapse;
181
187
  const collapseEnabled = collapseOpt === true || collapseOpt != null && collapseOpt !== false;
182
188
  const collapseUserCfg = collapseOpt && collapseOpt !== true ? collapseOpt : {};
@@ -245,6 +251,22 @@ function pyreonPlugin(options) {
245
251
  await prescanSignalExports(projectRoot, signalExportRegistry);
246
252
  if (islandsEnabled) await prescanIslandDeclarations(projectRoot, islandRegistry);
247
253
  },
254
+ [Symbol.for("pyreon/vite-plugin:caches")]: {
255
+ signalExportRegistry,
256
+ resolveCache,
257
+ pyreonWorkspaceDirCache,
258
+ islandRegistry
259
+ },
260
+ watchChange(id, change) {
261
+ if (change.event !== "delete") return;
262
+ if (__DEV__) _countSink.__pyreon_count__?.("vite-plugin.watchChange.delete");
263
+ const normalized = normalizeModuleId(id);
264
+ signalExportRegistry.delete(normalized);
265
+ islandRegistry.delete(id);
266
+ if (normalized !== id) islandRegistry.delete(normalized);
267
+ const importerPrefix = `${normalized}::`;
268
+ for (const [key, value] of resolveCache) if (key.startsWith(importerPrefix) || value === normalized) resolveCache.delete(key);
269
+ },
248
270
  async closeBundle() {
249
271
  if (collapseResolver) {
250
272
  await collapseResolver.dispose();
@@ -337,7 +359,7 @@ function pyreonPlugin(options) {
337
359
  let output = result.code;
338
360
  if (!isBuild) {
339
361
  output = injectHmr(output, id);
340
- output = injectSignalNames(output);
362
+ output = injectSignalNames(output, id);
341
363
  }
342
364
  return {
343
365
  code: output,
@@ -353,6 +375,7 @@ function pyreonPlugin(options) {
353
375
  contextTimer = setTimeout(() => generateProjectContext(projectRoot), 500);
354
376
  }
355
377
  });
378
+ if (lpihEnabled) registerLpihMiddleware(server, projectRoot, lpihUserCfg);
356
379
  if (!ssrConfig) return;
357
380
  return () => {
358
381
  server.middlewares.use(async (req, res, next) => {
@@ -367,6 +390,11 @@ function pyreonPlugin(options) {
367
390
  }
368
391
  });
369
392
  };
393
+ },
394
+ transformIndexHtml(html) {
395
+ if (isBuild || !lpihEnabled) return void 0;
396
+ const script = buildLpihClientScript(lpihIntervalMs);
397
+ return html.replace("</head>", `${script}\n</head>`);
370
398
  }
371
399
  };
372
400
  }
@@ -407,6 +435,130 @@ function generateProjectContext(root) {
407
435
  } catch {}
408
436
  }
409
437
  /**
438
+ * Resolve the LPIH cache-file path for a given project root. Matches the
439
+ * convention `@pyreon/reactivity/lpih`'s `getDefaultLpihCachePath()` uses
440
+ * AND the LSP auto-discovers (R2, #777): `<projectRoot>/.pyreon-lpih.json`.
441
+ *
442
+ * @internal — exported for tests.
443
+ */
444
+ function resolveLpihCachePath(projectRoot) {
445
+ return join(projectRoot, ".pyreon-lpih.json");
446
+ }
447
+ /**
448
+ * Register the LPIH dev-server middleware on a Vite server. Extracted from
449
+ * `configureServer` so the `cachePath` option reference lives at module
450
+ * scope (top-level helper) rather than inside the plugin's inline body —
451
+ * keeps `scripts/audit-types.ts` happy regardless of how its comment-
452
+ * stripping handles the long inline `configureServer` block.
453
+ *
454
+ * @internal — exported for tests.
455
+ */
456
+ function registerLpihMiddleware(server, projectRoot, userCfg) {
457
+ const cachePath = userCfg.cachePath ?? resolveLpihCachePath(projectRoot);
458
+ server.middlewares.use("/__pyreon_lpih__", (req, res) => {
459
+ if (req.method !== "POST") {
460
+ res.statusCode = 405;
461
+ res.end("Method Not Allowed");
462
+ return;
463
+ }
464
+ let body = "";
465
+ req.on("data", (chunk) => {
466
+ body += chunk.toString();
467
+ if (body.length > 1024 * 1024) {
468
+ res.statusCode = 413;
469
+ res.end("Payload Too Large");
470
+ req.destroy();
471
+ }
472
+ });
473
+ req.on("end", () => {
474
+ writeLpihCacheFile(cachePath, body).then(() => {
475
+ res.statusCode = 204;
476
+ res.end();
477
+ }).catch((err) => {
478
+ console.warn("[pyreon] LPIH cache write failed:", err instanceof Error ? err.message : err);
479
+ res.statusCode = 500;
480
+ res.end("LPIH cache write failed");
481
+ });
482
+ });
483
+ });
484
+ }
485
+ let _lpihSeq = 0;
486
+ /**
487
+ * Atomically write a LPIH cache file (tmp + rename), mirroring the
488
+ * `@pyreon/reactivity/lpih:writeLpihCache` implementation. The payload
489
+ * comes pre-serialized from the browser-side bridge — we validate the
490
+ * outer shape (`{ fires: [...] }`) and reject malformed bodies to stop a
491
+ * buggy client from corrupting the file the LSP reads.
492
+ *
493
+ * @internal — exported for tests.
494
+ */
495
+ async function writeLpihCacheFile(path, body) {
496
+ let parsed;
497
+ try {
498
+ parsed = JSON.parse(body);
499
+ } catch {
500
+ throw new Error("LPIH bridge: payload is not valid JSON");
501
+ }
502
+ if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.fires)) throw new Error("LPIH bridge: payload is missing `fires` array");
503
+ const fs = await import("node:fs/promises");
504
+ const tmp = `${path}.tmp.${typeof process !== "undefined" && "pid" in process ? process.pid : 0}.${++_lpihSeq}`;
505
+ try {
506
+ await fs.writeFile(tmp, JSON.stringify(parsed), "utf8");
507
+ await fs.rename(tmp, path);
508
+ } catch (err) {
509
+ try {
510
+ await fs.unlink(tmp);
511
+ } catch {}
512
+ throw err;
513
+ }
514
+ }
515
+ /**
516
+ * Build the `<script type="module">` body injected into the HTML head.
517
+ * The script imports devtools activation + `getFireSummaries` from
518
+ * `@pyreon/reactivity`, sets up a `setInterval` that POSTs every
519
+ * `intervalMs` ms, and registers a `beforeunload` cleanup so the timer
520
+ * doesn't outlive the page.
521
+ *
522
+ * Browser bundlers serve `@pyreon/reactivity` from the workspace via
523
+ * Vite's normal module resolution — no virtual module needed.
524
+ *
525
+ * @internal — exported for tests.
526
+ */
527
+ function buildLpihClientScript(intervalMs) {
528
+ return `<script type="module">
529
+ // Pyreon LPIH auto-bridge — POSTs fire summaries to /__pyreon_lpih__
530
+ // so the LSP (pyreon-lint --lsp) sees live fire data. Dev-only.
531
+ const __px = await import('@pyreon/reactivity').catch(() => null)
532
+ if (__px) {
533
+ __px.activateReactiveDevtools()
534
+ const __pxGet = __px.getFireSummaries
535
+ const __pxInterval = ${JSON.stringify(intervalMs)}
536
+ const __pxPost = () => {
537
+ const summaries = __pxGet()
538
+ const payload = JSON.stringify({
539
+ fires: summaries.map((s) => ({
540
+ file: s.loc.file,
541
+ line: s.loc.line,
542
+ count: s.count,
543
+ kind: s.kind,
544
+ lastFire: s.lastFire,
545
+ rate1s: s.rate1s,
546
+ })),
547
+ })
548
+ fetch('/__pyreon_lpih__', { method: 'POST', body: payload, headers: { 'content-type': 'application/json' } }).catch(() => {
549
+ // Dev-server might be restarting; swallow + retry next interval.
550
+ })
551
+ }
552
+ const __pxId = setInterval(__pxPost, __pxInterval)
553
+ window.addEventListener('beforeunload', () => clearInterval(__pxId))
554
+ }
555
+ // If __px is null, @pyreon/reactivity isn't in the dep graph — stay silent,
556
+ // LPIH is opt-in via the runtime API too. The dynamic-import catch returns
557
+ // null instead of letting the rejection bubble so consumers without the
558
+ // package don't see a console error.
559
+ <\/script>`;
560
+ }
561
+ /**
410
562
  * Regex that detects signal declarations (prefix + variable name).
411
563
  * The arguments are extracted via balanced-paren matching in `injectHmr`.
412
564
  * A brace-depth check filters out matches inside functions/blocks — only
@@ -505,37 +657,250 @@ function hasMultipleArgs(args) {
505
657
  return false;
506
658
  }
507
659
  /**
508
- * Inject `{ name: "varName" }` into signal() calls that don't already have
660
+ * Inject `{ name?, __sourceLocation: { file, line, col } }` into
661
+ * `signal()` / `computed()` / `effect()` calls that don't already have
509
662
  * an options argument. Only runs in dev mode for debugging/devtools.
510
663
  *
511
- * `const count = signal(0)` → `const count = signal(0, { name: "count" })`
664
+ * Three forms covered:
665
+ *
666
+ * `const count = signal(0)` →
667
+ * `const count = signal(0, { name: "count", __sourceLocation: {...} })`
668
+ *
669
+ * `const doubled = computed(() => count() * 2)` →
670
+ * `const doubled = computed(() => count() * 2, { name: "doubled", __sourceLocation: {...} })`
671
+ *
672
+ * `effect(() => console.log(count()))` →
673
+ * `effect(() => console.log(count()), { __sourceLocation: {...} })`
674
+ * (no `name` — anonymous effects have no binding to derive from)
512
675
  *
513
676
  * Module-scope signals rewritten to __hmr_signal() are naturally skipped
514
677
  * because the regex matches `signal(` not `__hmr_signal(`.
678
+ *
679
+ * **LPIH integration**: `__sourceLocation` is consumed by
680
+ * `@pyreon/reactivity`'s `signal()` / `computed()` / `effect()` to skip
681
+ * the `new Error().stack` capture in `_rdRegister` — saves ~2.2µs per
682
+ * creation when devtools is active. The injected literal is byte-for-byte
683
+ * the same info the runtime would have parsed from the stack, so behavior
684
+ * is identical except no stack-parse cost.
685
+ *
686
+ * **Anonymous-effect detection**: `effect(` can also appear as a property
687
+ * access (`obj.effect(...)`), a longer identifier (`sideEffect(...)`), or
688
+ * a previously-injected call (`effect(fn, { ... })`). The unbound-effect
689
+ * pass guards against all three:
690
+ * - preceded by NOT `[A-Za-z0-9_$.]` (so `.effect`/`sideEffect` skip)
691
+ * - args do NOT already contain a 2nd arg (`hasMultipleArgs` check)
692
+ *
693
+ * @param code - source text
694
+ * @param moduleId - the file path to embed in the injected `__sourceLocation`.
695
+ * Vite passes the resolved module ID (absolute path).
515
696
  */
516
- function injectSignalNames(code) {
517
- const re = /(?:const|let)\s+(\w+)\s*=\s*signal\(/gm;
697
+ function injectSignalNames(code, moduleId) {
698
+ const masked = _maskStringsAndComments(code);
699
+ const reBound = /(?:const|let)\s+(\w+)\s*=\s*(signal|computed|effect)\(/gm;
700
+ const reUnboundEffect = /(?<![\w$.])effect\(/gm;
518
701
  const matches = [];
519
- let m = re.exec(code);
702
+ const covered = /* @__PURE__ */ new Set();
703
+ let m = reBound.exec(masked);
520
704
  while (m !== null) {
521
705
  const argsStart = m.index + m[0].length;
522
706
  const args = extractBalancedArgs(code, argsStart);
523
- if (args !== null && !hasMultipleArgs(args)) matches.push({
524
- start: argsStart,
525
- end: argsStart + args.length,
526
- name: m[1] ?? "",
527
- args
528
- });
529
- m = re.exec(code);
707
+ if (args !== null && !hasMultipleArgs(args)) {
708
+ matches.push({
709
+ start: argsStart,
710
+ end: argsStart + args.length,
711
+ name: m[1] ?? "",
712
+ args,
713
+ matchIdx: m.index
714
+ });
715
+ const tokStart = m.index + m[0].length - (m[2]?.length ?? 0) - 1;
716
+ covered.add(tokStart);
717
+ }
718
+ m = reBound.exec(masked);
530
719
  }
531
- re.lastIndex = 0;
720
+ reBound.lastIndex = 0;
721
+ m = reUnboundEffect.exec(masked);
722
+ while (m !== null) {
723
+ if (!covered.has(m.index)) {
724
+ const argsStart = m.index + m[0].length;
725
+ const args = extractBalancedArgs(code, argsStart);
726
+ if (args !== null && !hasMultipleArgs(args)) matches.push({
727
+ start: argsStart,
728
+ end: argsStart + args.length,
729
+ name: null,
730
+ args,
731
+ matchIdx: m.index
732
+ });
733
+ }
734
+ m = reUnboundEffect.exec(masked);
735
+ }
736
+ reUnboundEffect.lastIndex = 0;
737
+ if (matches.length === 0) return code;
738
+ matches.sort((a, b) => b.start - a.start);
739
+ const lineStarts = _computeLineStarts(code);
532
740
  let output = code;
533
- for (let i = matches.length - 1; i >= 0; i--) {
534
- const { start, end, name, args } = matches[i];
535
- output = `${output.slice(0, start)}${args}, { name: ${JSON.stringify(name)} }${output.slice(end)}`;
741
+ for (let i = 0; i < matches.length; i++) {
742
+ const { start, end, name, args, matchIdx } = matches[i];
743
+ const { line, col } = _offsetToLineCol(matchIdx, lineStarts);
744
+ const locLiteral = `__sourceLocation: { file: ${JSON.stringify(moduleId)}, line: ${line}, col: ${col} }`;
745
+ const inner = name !== null ? `name: ${JSON.stringify(name)}, ${locLiteral}` : locLiteral;
746
+ output = `${output.slice(0, start)}${args}, { ${inner} }${output.slice(end)}`;
536
747
  }
537
748
  return output;
538
749
  }
750
+ /**
751
+ * Mask string-literal / template-literal / comment regions in `code` by
752
+ * replacing their content with spaces. Returns a SAME-LENGTH string so
753
+ * regex match positions in the masked version line up with the original.
754
+ *
755
+ * Used by `injectSignalNames` to skip false-positive matches against
756
+ * reactive-primitive names that appear inside strings or comments. Without
757
+ * masking, a user's `const docs = \`effect(() => x)\`` template literal
758
+ * would get `, { __sourceLocation: ... }` injected INSIDE the string,
759
+ * corrupting runtime values.
760
+ *
761
+ * Handles:
762
+ * - `"..."` / `'...'` strings (escape-aware)
763
+ * - `` `...` `` template literals; interpolations `${...}` are KEPT as
764
+ * code (their content can contain real `signal()` calls worth catching)
765
+ * - `// ...` line comments
766
+ * - `/* ... *\/` block comments
767
+ *
768
+ * Regex literals (`/foo/g`) are NOT special-cased — they're rare and the
769
+ * downstream extractBalancedArgs handles unmatched parens by returning null.
770
+ *
771
+ * @internal — exported for tests.
772
+ */
773
+ function _maskStringsAndComments(code) {
774
+ const out = [];
775
+ let i = 0;
776
+ const n = code.length;
777
+ while (i < n) {
778
+ const c = code[i];
779
+ const c1 = code[i + 1];
780
+ if (c === "/" && c1 === "/") {
781
+ while (i < n && code[i] !== "\n") {
782
+ out.push(" ");
783
+ i++;
784
+ }
785
+ continue;
786
+ }
787
+ if (c === "/" && c1 === "*") {
788
+ out.push(" ", " ");
789
+ i += 2;
790
+ while (i < n) {
791
+ if (code[i] === "*" && code[i + 1] === "/") {
792
+ out.push(" ", " ");
793
+ i += 2;
794
+ break;
795
+ }
796
+ out.push(code[i] === "\n" ? "\n" : " ");
797
+ i++;
798
+ }
799
+ continue;
800
+ }
801
+ if (c === "\"" || c === "'") {
802
+ const quote = c;
803
+ out.push(" ");
804
+ i++;
805
+ while (i < n && code[i] !== quote) {
806
+ if (code[i] === "\\" && i + 1 < n) {
807
+ out.push(" ", code[i + 1] === "\n" ? "\n" : " ");
808
+ i += 2;
809
+ continue;
810
+ }
811
+ if (code[i] === "\n") break;
812
+ out.push(" ");
813
+ i++;
814
+ }
815
+ if (i < n && code[i] === quote) {
816
+ out.push(" ");
817
+ i++;
818
+ }
819
+ continue;
820
+ }
821
+ if (c === "`") {
822
+ out.push(" ");
823
+ i++;
824
+ while (i < n && code[i] !== "`") {
825
+ if (code[i] === "\\" && i + 1 < n) {
826
+ out.push(" ", code[i + 1] === "\n" ? "\n" : " ");
827
+ i += 2;
828
+ continue;
829
+ }
830
+ if (code[i] === "$" && code[i + 1] === "{") {
831
+ out.push(" ", " ");
832
+ i += 2;
833
+ let depth = 1;
834
+ while (i < n && depth > 0) {
835
+ if (code[i] === "{") {
836
+ depth++;
837
+ out.push(code[i] ?? " ");
838
+ i++;
839
+ continue;
840
+ }
841
+ if (code[i] === "}") {
842
+ depth--;
843
+ if (depth === 0) {
844
+ out.push(" ");
845
+ i++;
846
+ break;
847
+ }
848
+ out.push(code[i] ?? " ");
849
+ i++;
850
+ continue;
851
+ }
852
+ out.push(code[i] ?? " ");
853
+ i++;
854
+ }
855
+ continue;
856
+ }
857
+ out.push(code[i] === "\n" ? "\n" : " ");
858
+ i++;
859
+ }
860
+ if (i < n && code[i] === "`") {
861
+ out.push(" ");
862
+ i++;
863
+ }
864
+ continue;
865
+ }
866
+ out.push(c ?? "");
867
+ i++;
868
+ }
869
+ return out.join("");
870
+ }
871
+ /**
872
+ * Compute the 0-indexed character offset for the start of each line.
873
+ * `lineStarts[i]` is the offset of the FIRST character on line i+1
874
+ * (1-based, so `lineStarts[0]` = offset 0 = line 1).
875
+ *
876
+ * @internal — exported for tests.
877
+ */
878
+ function _computeLineStarts(code) {
879
+ const starts = [0];
880
+ for (let i = 0; i < code.length; i++) if (code.charCodeAt(i) === 10) starts.push(i + 1);
881
+ return starts;
882
+ }
883
+ /**
884
+ * Convert a 0-indexed offset to `{ line: 1-based, col: 1-based }` using a
885
+ * pre-computed line-starts array. Binary search → O(log N) per lookup.
886
+ *
887
+ * @internal — exported for tests.
888
+ */
889
+ function _offsetToLineCol(offset, lineStarts) {
890
+ let lo = 0;
891
+ let hi = lineStarts.length - 1;
892
+ while (lo < hi) {
893
+ const mid = lo + hi + 1 >>> 1;
894
+ const v = lineStarts[mid];
895
+ if (v !== void 0 && v <= offset) lo = mid;
896
+ else hi = mid - 1;
897
+ }
898
+ const lineStart = lineStarts[lo] ?? 0;
899
+ return {
900
+ line: lo + 1,
901
+ col: offset - lineStart + 1
902
+ };
903
+ }
539
904
  function injectHmr(code, moduleId) {
540
905
  const hasSignals = SIGNAL_PREFIX_RE.test(code);
541
906
  SIGNAL_PREFIX_RE.lastIndex = 0;
@@ -636,7 +1001,7 @@ async function prescanIslandDeclarations(root, registry) {
636
1001
  * can find.
637
1002
  */
638
1003
  function scanIslandDeclarations(code, filePath, registry) {
639
- const ISLAND_CALL_RE = /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
1004
+ const ISLAND_CALL_RE = /island\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*\{([^}]{0,500})\}\s*\)/g;
640
1005
  const decls = [];
641
1006
  let match;
642
1007
  while ((match = ISLAND_CALL_RE.exec(code)) !== null) {
@@ -750,6 +1115,7 @@ async function prescanSignalExports(root, registry) {
750
1115
  *
751
1116
  * Uses simple regex — no AST parse needed.
752
1117
  */
1118
+ const AS_SPLIT_RE = /\s{1,10}as\s{1,10}/;
753
1119
  function scanSignalExports(code, moduleId, registry) {
754
1120
  const normalizedId = normalizeModuleId(moduleId);
755
1121
  let match;
@@ -760,13 +1126,13 @@ function scanSignalExports(code, moduleId, registry) {
760
1126
  const LOCAL_SIGNAL_RE = /(?:^|[\s;])const\s+(\w+)\s*=\s*(?:signal|computed)\s*[<(]/gm;
761
1127
  while ((match = LOCAL_SIGNAL_RE.exec(code)) !== null) localSignals.add(match[1]);
762
1128
  if (localSignals.size > 0) {
763
- const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g;
1129
+ const NAMED_EXPORT_RE = /export\s*\{([^}]{1,500})\}/g;
764
1130
  while ((match = NAMED_EXPORT_RE.exec(code)) !== null) {
765
1131
  if (code.slice(match.index + match[0].length).trimStart().startsWith("from")) continue;
766
1132
  for (const spec of match[1].split(",")) {
767
1133
  const trimmed = spec.trim();
768
1134
  if (!trimmed) continue;
769
- const parts = trimmed.split(/\s+as\s+/);
1135
+ const parts = trimmed.split(AS_SPLIT_RE);
770
1136
  const localName = parts[0].trim();
771
1137
  const exportedName = (parts[1] ?? parts[0]).trim();
772
1138
  if (localSignals.has(localName)) signals.add(exportedName);
@@ -813,7 +1179,7 @@ async function resolveImportedSignals(code, _moduleId, registry, pluginCtx, reso
813
1179
  for (const spec of specifiers.split(",")) {
814
1180
  const trimmed = spec.trim();
815
1181
  if (!trimmed) continue;
816
- const parts = trimmed.split(/\s+as\s+/);
1182
+ const parts = trimmed.split(AS_SPLIT_RE);
817
1183
  const importedName = parts[0].trim();
818
1184
  const localName = (parts[1] ?? parts[0]).trim();
819
1185
  if (exportedSignals.has(importedName)) knownSignals.push(localName);
@@ -876,5 +1242,5 @@ export function __hmr_dispose(moduleId) {
876
1242
  `;
877
1243
 
878
1244
  //#endregion
879
- export { pyreonPlugin as default };
1245
+ export { _computeLineStarts, _maskStringsAndComments, _offsetToLineCol, buildLpihClientScript, pyreonPlugin as default, registerLpihMiddleware, resolveLpihCachePath, writeLpihCacheFile };
880
1246
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,4 @@
1
- import { Plugin } from "vite";
1
+ import { Plugin, ViteDevServer } from "vite";
2
2
 
3
3
  //#region src/index.d.ts
4
4
  type CompatFramework = 'react' | 'preact' | 'vue' | 'solid' | 'svelte';
@@ -56,6 +56,30 @@ interface PyreonPluginOptions {
56
56
  * hydrateIslandsAuto()
57
57
  */
58
58
  islands?: boolean;
59
+ /**
60
+ * **LPIH auto-bridge** — zero-config Live Program Inlay Hints in dev.
61
+ *
62
+ * When `true` (the default in dev), the plugin auto-wires the LPIH
63
+ * cache file: the browser-side activates devtools + polls fire data
64
+ * every `intervalMs` (250ms default), and the dev-server middleware
65
+ * receives the POST + writes `<project-root>/.pyreon-lpih.json` using
66
+ * the atomic-rename pattern from `@pyreon/reactivity/lpih`. The LSP
67
+ * (`pyreon-lint --lsp`) auto-discovers that file, so the end-to-end
68
+ * "save file → see fire counts" loop needs ZERO user wiring.
69
+ *
70
+ * Set to `false` to opt out (e.g. if you're wiring `startLpihPolling()`
71
+ * yourself from a non-browser runtime, or you want LPIH off entirely).
72
+ * Pass an object to override the interval or the cache-file path.
73
+ *
74
+ * Build-only consumer: production builds skip injection entirely.
75
+ *
76
+ * @example
77
+ * pyreon({ lpih: true }) // default in dev
78
+ * pyreon({ lpih: false }) // opt out
79
+ * pyreon({ lpih: { intervalMs: 500 } }) // slower poll
80
+ * pyreon({ lpih: { cachePath: '/tmp/x.json' } }) // custom path
81
+ */
82
+ lpih?: boolean | PyreonLpihOptions;
59
83
  /**
60
84
  * P0 — opt-in compile-time rocketstyle wrapper collapse. `true` uses
61
85
  * the default provider/theme/mode wiring (PyreonUI + theme +
@@ -103,7 +127,109 @@ interface PyreonCollapseOptions {
103
127
  source: string;
104
128
  };
105
129
  }
130
+ interface PyreonLpihOptions {
131
+ /**
132
+ * Poll interval in milliseconds. The browser-side bridge reads
133
+ * `getFireSummaries()` and POSTs every `intervalMs` to the dev-server
134
+ * middleware. Default 250ms — matches the LSP-debounce window so
135
+ * editor hints settle within one frame of the typical save→hint cycle.
136
+ *
137
+ * Lower values (e.g. 100ms) trade dev-server CPU for snappier hints;
138
+ * higher values (1000ms) reduce overhead for slow machines.
139
+ */
140
+ intervalMs?: number;
141
+ /**
142
+ * Cache-file path override. Defaults to
143
+ * `<projectRoot>/.pyreon-lpih.json` — the convention the LSP auto-
144
+ * discovers (R2, #777). Override only if you need a non-default
145
+ * location (shared mount, custom workspace layout).
146
+ */
147
+ cachePath?: string;
148
+ }
106
149
  declare function pyreonPlugin(options?: PyreonPluginOptions): Plugin;
150
+ /**
151
+ * Resolve the LPIH cache-file path for a given project root. Matches the
152
+ * convention `@pyreon/reactivity/lpih`'s `getDefaultLpihCachePath()` uses
153
+ * AND the LSP auto-discovers (R2, #777): `<projectRoot>/.pyreon-lpih.json`.
154
+ *
155
+ * @internal — exported for tests.
156
+ */
157
+ declare function resolveLpihCachePath(projectRoot: string): string;
158
+ /**
159
+ * Register the LPIH dev-server middleware on a Vite server. Extracted from
160
+ * `configureServer` so the `cachePath` option reference lives at module
161
+ * scope (top-level helper) rather than inside the plugin's inline body —
162
+ * keeps `scripts/audit-types.ts` happy regardless of how its comment-
163
+ * stripping handles the long inline `configureServer` block.
164
+ *
165
+ * @internal — exported for tests.
166
+ */
167
+ declare function registerLpihMiddleware(server: ViteDevServer, projectRoot: string, userCfg: PyreonLpihOptions): void;
168
+ /**
169
+ * Atomically write a LPIH cache file (tmp + rename), mirroring the
170
+ * `@pyreon/reactivity/lpih:writeLpihCache` implementation. The payload
171
+ * comes pre-serialized from the browser-side bridge — we validate the
172
+ * outer shape (`{ fires: [...] }`) and reject malformed bodies to stop a
173
+ * buggy client from corrupting the file the LSP reads.
174
+ *
175
+ * @internal — exported for tests.
176
+ */
177
+ declare function writeLpihCacheFile(path: string, body: string): Promise<void>;
178
+ /**
179
+ * Build the `<script type="module">` body injected into the HTML head.
180
+ * The script imports devtools activation + `getFireSummaries` from
181
+ * `@pyreon/reactivity`, sets up a `setInterval` that POSTs every
182
+ * `intervalMs` ms, and registers a `beforeunload` cleanup so the timer
183
+ * doesn't outlive the page.
184
+ *
185
+ * Browser bundlers serve `@pyreon/reactivity` from the workspace via
186
+ * Vite's normal module resolution — no virtual module needed.
187
+ *
188
+ * @internal — exported for tests.
189
+ */
190
+ declare function buildLpihClientScript(intervalMs: number): string;
191
+ /**
192
+ * Mask string-literal / template-literal / comment regions in `code` by
193
+ * replacing their content with spaces. Returns a SAME-LENGTH string so
194
+ * regex match positions in the masked version line up with the original.
195
+ *
196
+ * Used by `injectSignalNames` to skip false-positive matches against
197
+ * reactive-primitive names that appear inside strings or comments. Without
198
+ * masking, a user's `const docs = \`effect(() => x)\`` template literal
199
+ * would get `, { __sourceLocation: ... }` injected INSIDE the string,
200
+ * corrupting runtime values.
201
+ *
202
+ * Handles:
203
+ * - `"..."` / `'...'` strings (escape-aware)
204
+ * - `` `...` `` template literals; interpolations `${...}` are KEPT as
205
+ * code (their content can contain real `signal()` calls worth catching)
206
+ * - `// ...` line comments
207
+ * - `/* ... *\/` block comments
208
+ *
209
+ * Regex literals (`/foo/g`) are NOT special-cased — they're rare and the
210
+ * downstream extractBalancedArgs handles unmatched parens by returning null.
211
+ *
212
+ * @internal — exported for tests.
213
+ */
214
+ declare function _maskStringsAndComments(code: string): string;
215
+ /**
216
+ * Compute the 0-indexed character offset for the start of each line.
217
+ * `lineStarts[i]` is the offset of the FIRST character on line i+1
218
+ * (1-based, so `lineStarts[0]` = offset 0 = line 1).
219
+ *
220
+ * @internal — exported for tests.
221
+ */
222
+ declare function _computeLineStarts(code: string): number[];
223
+ /**
224
+ * Convert a 0-indexed offset to `{ line: 1-based, col: 1-based }` using a
225
+ * pre-computed line-starts array. Binary search → O(log N) per lookup.
226
+ *
227
+ * @internal — exported for tests.
228
+ */
229
+ declare function _offsetToLineCol(offset: number, lineStarts: number[]): {
230
+ line: number;
231
+ col: number;
232
+ };
107
233
  //#endregion
108
- export { CompatFramework, PyreonCollapseOptions, PyreonPluginOptions, pyreonPlugin as default };
234
+ export { CompatFramework, PyreonCollapseOptions, PyreonLpihOptions, PyreonPluginOptions, _computeLineStarts, _maskStringsAndComments, _offsetToLineCol, buildLpihClientScript, pyreonPlugin as default, registerLpihMiddleware, resolveLpihCachePath, writeLpihCacheFile };
109
235
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/vite-plugin",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Vite plugin for Pyreon — .pyreon SFC support, HMR, compiler integration",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/vite-plugin#readme",
6
6
  "bugs": {
@@ -43,7 +43,7 @@
43
43
  "prepublishOnly": "bun run build"
44
44
  },
45
45
  "dependencies": {
46
- "@pyreon/compiler": "^0.22.0"
46
+ "@pyreon/compiler": "^0.24.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "vite": "^8.0.0"