@pyreon/vite-plugin 0.23.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/analysis/index.js.html +1 -1
- package/lib/index.js +365 -18
- package/lib/types/index.d.ts +128 -2
- package/package.json +2 -2
- package/src/index.ts +545 -14
- package/src/tests/dev-server.test.ts +5 -1
- package/src/tests/lpih-auto-bridge.test.ts +408 -0
- package/src/tests/lpih-injection.test.ts +559 -0
- package/src/tests/vite-plugin.test.ts +5 -2
package/src/index.ts
CHANGED
|
@@ -134,6 +134,31 @@ export interface PyreonPluginOptions {
|
|
|
134
134
|
*/
|
|
135
135
|
islands?: boolean
|
|
136
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
|
+
|
|
137
162
|
/**
|
|
138
163
|
* P0 — opt-in compile-time rocketstyle wrapper collapse. `true` uses
|
|
139
164
|
* the default provider/theme/mode wiring (PyreonUI + theme +
|
|
@@ -174,6 +199,26 @@ export interface PyreonCollapseOptions {
|
|
|
174
199
|
mode?: { name: string; source: string }
|
|
175
200
|
}
|
|
176
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
|
+
|
|
177
222
|
// ── Compat alias maps ─────────────────────────────────────────────────────────
|
|
178
223
|
|
|
179
224
|
const COMPAT_ALIASES: Record<CompatFramework, Record<string, string>> = {
|
|
@@ -336,6 +381,15 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
336
381
|
// you have a specific reason.
|
|
337
382
|
const islandsEnabled = options?.islands !== false
|
|
338
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
|
+
|
|
339
393
|
// ── P0 rocketstyle-collapse config (opt-in) ───────────────────────────────
|
|
340
394
|
const collapseOpt = options?.collapse
|
|
341
395
|
const collapseEnabled = collapseOpt === true || (collapseOpt != null && collapseOpt !== false)
|
|
@@ -748,8 +802,10 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
748
802
|
// ── Dev-only transforms ────────────────────────────────────────────
|
|
749
803
|
if (!isBuild) {
|
|
750
804
|
output = injectHmr(output, id)
|
|
751
|
-
// Inject debug names for signal() calls
|
|
752
|
-
|
|
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)
|
|
753
809
|
}
|
|
754
810
|
|
|
755
811
|
// R12: surface the compiler's V3 source map so stack traces /
|
|
@@ -777,6 +833,14 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
777
833
|
}
|
|
778
834
|
})
|
|
779
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
|
+
|
|
780
844
|
if (!ssrConfig) return
|
|
781
845
|
|
|
782
846
|
// Return a function so the middleware runs AFTER Vite's built-in middleware
|
|
@@ -796,6 +860,18 @@ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
|
|
|
796
860
|
})
|
|
797
861
|
}
|
|
798
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
|
+
},
|
|
799
875
|
}
|
|
800
876
|
}
|
|
801
877
|
|
|
@@ -854,6 +930,186 @@ function generateProjectContext(root: string): void {
|
|
|
854
930
|
}
|
|
855
931
|
}
|
|
856
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
|
+
|
|
857
1113
|
// ── HMR injection ─────────────────────────────────────────────────────────────
|
|
858
1114
|
|
|
859
1115
|
/**
|
|
@@ -981,37 +1237,312 @@ function hasMultipleArgs(args: string): boolean {
|
|
|
981
1237
|
}
|
|
982
1238
|
|
|
983
1239
|
/**
|
|
984
|
-
* Inject `{ name:
|
|
1240
|
+
* Inject `{ name?, __sourceLocation: { file, line, col } }` into
|
|
1241
|
+
* `signal()` / `computed()` / `effect()` calls that don't already have
|
|
985
1242
|
* an options argument. Only runs in dev mode for debugging/devtools.
|
|
986
1243
|
*
|
|
987
|
-
*
|
|
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)
|
|
988
1255
|
*
|
|
989
1256
|
* Module-scope signals rewritten to __hmr_signal() are naturally skipped
|
|
990
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).
|
|
991
1276
|
*/
|
|
992
|
-
function injectSignalNames(code: string): string {
|
|
993
|
-
|
|
994
|
-
|
|
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>()
|
|
995
1309
|
|
|
996
|
-
let m: RegExpExecArray | null =
|
|
1310
|
+
let m: RegExpExecArray | null = reBound.exec(masked)
|
|
997
1311
|
while (m !== null) {
|
|
998
1312
|
const argsStart = m.index + m[0].length
|
|
999
1313
|
const args = extractBalancedArgs(code, argsStart)
|
|
1000
1314
|
if (args !== null && !hasMultipleArgs(args)) {
|
|
1001
|
-
matches.push({
|
|
1315
|
+
matches.push({
|
|
1316
|
+
start: argsStart,
|
|
1317
|
+
end: argsStart + args.length,
|
|
1318
|
+
name: m[1] ?? '',
|
|
1319
|
+
args,
|
|
1320
|
+
matchIdx: m.index,
|
|
1321
|
+
})
|
|
1322
|
+
// Mark the `effect(`/`signal(`/`computed(` token start so the
|
|
1323
|
+
// unbound-effect pass doesn't double-process it.
|
|
1324
|
+
const tokStart = m.index + m[0].length - (m[2]?.length ?? 0) - 1
|
|
1325
|
+
covered.add(tokStart)
|
|
1326
|
+
}
|
|
1327
|
+
m = reBound.exec(masked)
|
|
1328
|
+
}
|
|
1329
|
+
reBound.lastIndex = 0
|
|
1330
|
+
|
|
1331
|
+
m = reUnboundEffect.exec(masked)
|
|
1332
|
+
while (m !== null) {
|
|
1333
|
+
if (!covered.has(m.index)) {
|
|
1334
|
+
const argsStart = m.index + m[0].length
|
|
1335
|
+
const args = extractBalancedArgs(code, argsStart)
|
|
1336
|
+
if (args !== null && !hasMultipleArgs(args)) {
|
|
1337
|
+
matches.push({
|
|
1338
|
+
start: argsStart,
|
|
1339
|
+
end: argsStart + args.length,
|
|
1340
|
+
name: null,
|
|
1341
|
+
args,
|
|
1342
|
+
matchIdx: m.index,
|
|
1343
|
+
})
|
|
1344
|
+
}
|
|
1002
1345
|
}
|
|
1003
|
-
m =
|
|
1346
|
+
m = reUnboundEffect.exec(masked)
|
|
1004
1347
|
}
|
|
1005
|
-
|
|
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)
|
|
1006
1359
|
|
|
1007
1360
|
let output = code
|
|
1008
|
-
for (let i =
|
|
1009
|
-
const { start, end, name, args } = matches[i] as
|
|
1010
|
-
|
|
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)}`
|
|
1011
1369
|
}
|
|
1012
1370
|
return output
|
|
1013
1371
|
}
|
|
1014
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
|
+
|
|
1015
1546
|
function injectHmr(code: string, moduleId: string): string {
|
|
1016
1547
|
const hasSignals = SIGNAL_PREFIX_RE.test(code)
|
|
1017
1548
|
SIGNAL_PREFIX_RE.lastIndex = 0
|
|
@@ -54,7 +54,11 @@ afterEach(() => {
|
|
|
54
54
|
})
|
|
55
55
|
|
|
56
56
|
function bootstrap(opts?: PyreonPluginOptions) {
|
|
57
|
-
|
|
57
|
+
// Default `lpih: false` — these tests cover the SSR / watcher / debounce
|
|
58
|
+
// surface; LPIH auto-bridge adds its own middleware whose presence would
|
|
59
|
+
// change the `middlewares.use` call count + first-element shape. Tests
|
|
60
|
+
// that specifically exercise LPIH live in `lpih-auto-bridge.test.ts`.
|
|
61
|
+
const plugin = pyreonPlugin({ lpih: false, ...opts })
|
|
58
62
|
;(plugin.config as unknown as ConfigHook)({ root }, { command: 'serve' })
|
|
59
63
|
return plugin
|
|
60
64
|
}
|