@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/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:
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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))
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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 =
|
|
534
|
-
const { start, end, name, args } = matches[i];
|
|
535
|
-
|
|
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*\{([
|
|
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*\{([^}]
|
|
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(
|
|
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(
|
|
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
46
|
+
"@pyreon/compiler": "^0.24.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"vite": "^8.0.0"
|