@pyreon/vite-plugin 0.23.0 → 0.24.1

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.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"120296e8-1"}]},{"name":"rocketstyle-collapse-C4eMAnwR.js","children":[{"name":"src/rocketstyle-collapse.ts","uid":"120296e8-3"}]}],"isRoot":true},"nodeParts":{"120296e8-1":{"renderedLength":34147,"gzipLength":11034,"brotliLength":0,"metaUid":"120296e8-0"},"120296e8-3":{"renderedLength":3424,"gzipLength":1530,"brotliLength":0,"metaUid":"120296e8-2"}},"nodeMetas":{"120296e8-0":{"id":"/src/index.ts","moduleParts":{"index.js":"120296e8-1"},"imported":[{"uid":"120296e8-4"},{"uid":"120296e8-5"},{"uid":"120296e8-6"},{"uid":"120296e8-2","dynamic":true}],"importedBy":[],"isEntry":true},"120296e8-2":{"id":"/src/rocketstyle-collapse.ts","moduleParts":{"rocketstyle-collapse-C4eMAnwR.js":"120296e8-3"},"imported":[{"uid":"120296e8-7","dynamic":true}],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-4":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-5":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-6":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-0"}]},"120296e8-7":{"id":"vite","moduleParts":{},"imported":[],"importedBy":[{"uid":"120296e8-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"e04d9c6c-1"}]},{"name":"rocketstyle-collapse-C4eMAnwR.js","children":[{"name":"src/rocketstyle-collapse.ts","uid":"e04d9c6c-3"}]}],"isRoot":true},"nodeParts":{"e04d9c6c-1":{"renderedLength":45559,"gzipLength":14773,"brotliLength":0,"metaUid":"e04d9c6c-0"},"e04d9c6c-3":{"renderedLength":3424,"gzipLength":1530,"brotliLength":0,"metaUid":"e04d9c6c-2"}},"nodeMetas":{"e04d9c6c-0":{"id":"/src/index.ts","moduleParts":{"index.js":"e04d9c6c-1"},"imported":[{"uid":"e04d9c6c-4"},{"uid":"e04d9c6c-5"},{"uid":"e04d9c6c-6"},{"uid":"e04d9c6c-2","dynamic":true},{"uid":"e04d9c6c-7","dynamic":true}],"importedBy":[],"isEntry":true},"e04d9c6c-2":{"id":"/src/rocketstyle-collapse.ts","moduleParts":{"rocketstyle-collapse-C4eMAnwR.js":"e04d9c6c-3"},"imported":[{"uid":"e04d9c6c-8","dynamic":true}],"importedBy":[{"uid":"e04d9c6c-0"}]},"e04d9c6c-4":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"e04d9c6c-0"}]},"e04d9c6c-5":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"e04d9c6c-0"}]},"e04d9c6c-6":{"id":"@pyreon/compiler","moduleParts":{},"imported":[],"importedBy":[{"uid":"e04d9c6c-0"}]},"e04d9c6c-7":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"e04d9c6c-0"}]},"e04d9c6c-8":{"id":"vite","moduleParts":{},"imported":[],"importedBy":[{"uid":"e04d9c6c-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -179,6 +179,10 @@ function pyreonPlugin(options) {
179
179
  const ssrConfig = options?.ssr;
180
180
  const compat = options?.compat;
181
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;
182
186
  const collapseOpt = options?.collapse;
183
187
  const collapseEnabled = collapseOpt === true || collapseOpt != null && collapseOpt !== false;
184
188
  const collapseUserCfg = collapseOpt && collapseOpt !== true ? collapseOpt : {};
@@ -355,7 +359,7 @@ function pyreonPlugin(options) {
355
359
  let output = result.code;
356
360
  if (!isBuild) {
357
361
  output = injectHmr(output, id);
358
- output = injectSignalNames(output);
362
+ output = injectSignalNames(output, id);
359
363
  }
360
364
  return {
361
365
  code: output,
@@ -371,6 +375,7 @@ function pyreonPlugin(options) {
371
375
  contextTimer = setTimeout(() => generateProjectContext(projectRoot), 500);
372
376
  }
373
377
  });
378
+ if (lpihEnabled) registerLpihMiddleware(server, projectRoot, lpihUserCfg);
374
379
  if (!ssrConfig) return;
375
380
  return () => {
376
381
  server.middlewares.use(async (req, res, next) => {
@@ -385,6 +390,11 @@ function pyreonPlugin(options) {
385
390
  }
386
391
  });
387
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>`);
388
398
  }
389
399
  };
390
400
  }
@@ -425,6 +435,130 @@ function generateProjectContext(root) {
425
435
  } catch {}
426
436
  }
427
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
+ /**
428
562
  * Regex that detects signal declarations (prefix + variable name).
429
563
  * The arguments are extracted via balanced-paren matching in `injectHmr`.
430
564
  * A brace-depth check filters out matches inside functions/blocks — only
@@ -523,37 +657,250 @@ function hasMultipleArgs(args) {
523
657
  return false;
524
658
  }
525
659
  /**
526
- * 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
527
662
  * an options argument. Only runs in dev mode for debugging/devtools.
528
663
  *
529
- * `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)
530
675
  *
531
676
  * Module-scope signals rewritten to __hmr_signal() are naturally skipped
532
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).
533
696
  */
534
- function injectSignalNames(code) {
535
- 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;
536
701
  const matches = [];
537
- let m = re.exec(code);
702
+ const covered = /* @__PURE__ */ new Set();
703
+ let m = reBound.exec(masked);
538
704
  while (m !== null) {
539
705
  const argsStart = m.index + m[0].length;
540
706
  const args = extractBalancedArgs(code, argsStart);
541
- if (args !== null && !hasMultipleArgs(args)) matches.push({
542
- start: argsStart,
543
- end: argsStart + args.length,
544
- name: m[1] ?? "",
545
- args
546
- });
547
- 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);
548
719
  }
549
- 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);
550
740
  let output = code;
551
- for (let i = matches.length - 1; i >= 0; i--) {
552
- const { start, end, name, args } = matches[i];
553
- 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)}`;
554
747
  }
555
748
  return output;
556
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
+ }
557
904
  function injectHmr(code, moduleId) {
558
905
  const hasSignals = SIGNAL_PREFIX_RE.test(code);
559
906
  SIGNAL_PREFIX_RE.lastIndex = 0;
@@ -895,5 +1242,5 @@ export function __hmr_dispose(moduleId) {
895
1242
  `;
896
1243
 
897
1244
  //#endregion
898
- export { pyreonPlugin as default };
1245
+ export { _computeLineStarts, _maskStringsAndComments, _offsetToLineCol, buildLpihClientScript, pyreonPlugin as default, registerLpihMiddleware, resolveLpihCachePath, writeLpihCacheFile };
899
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.23.0",
3
+ "version": "0.24.1",
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.23.0"
46
+ "@pyreon/compiler": "^0.24.1"
47
47
  },
48
48
  "devDependencies": {
49
49
  "vite": "^8.0.0"