@pyreon/reactivity 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 +141 -36
- package/lib/_chunks/reactive-devtools-BCpGoGZ5.js +280 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +16 -173
- package/lib/lpih.js +177 -0
- package/lib/types/index.d.ts +116 -2
- package/lib/types/lpih.d.ts +111 -0
- package/package.json +6 -1
- package/src/computed.ts +47 -6
- package/src/effect.ts +33 -4
- package/src/index.ts +8 -0
- package/src/lpih.ts +227 -0
- package/src/reactive-devtools.ts +213 -0
- package/src/signal.ts +23 -3
- package/src/tests/lpih-source-location.test.ts +277 -0
- package/src/tests/lpih.test.ts +351 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//#region src/lpih.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Live Program Inlay Hints — runtime bridge.
|
|
4
|
+
*
|
|
5
|
+
* Writes the current `getFireSummaries()` snapshot to a JSON file that
|
|
6
|
+
* the LSP server reads via the `PYREON_LPIH_CACHE` env var. This is the
|
|
7
|
+
* file-cache bridge mechanism — chosen over IPC/WebSocket because:
|
|
8
|
+
*
|
|
9
|
+
* 1. LSP servers are stdio-only — they can't easily talk to a browser.
|
|
10
|
+
* 2. Filesystem is a universal lowest-common-denominator transport.
|
|
11
|
+
* 3. The runtime side writes (atomic rename); the LSP side reads.
|
|
12
|
+
* 4. The LSP re-reads the file on every inlay-hint request, so live
|
|
13
|
+
* edits land immediately without coordination.
|
|
14
|
+
*
|
|
15
|
+
* Two consumer modes:
|
|
16
|
+
*
|
|
17
|
+
* **Dev-server polled mode**: a dev-server hook calls
|
|
18
|
+
* `writeLpihCache(path)` on every signal write or at a regular interval
|
|
19
|
+
* (e.g. 250ms throttle). The LSP picks it up on next inlay-hint request.
|
|
20
|
+
*
|
|
21
|
+
* **On-demand mode**: a test harness or devtools UI calls
|
|
22
|
+
* `writeLpihCache(path)` explicitly when it wants the LSP to see the
|
|
23
|
+
* current state.
|
|
24
|
+
*
|
|
25
|
+
* Atomic write semantics: writes to `<path>.tmp.<pid>.<seq>` then renames
|
|
26
|
+
* to `<path>`. Readers (the LSP server) never see a half-written file.
|
|
27
|
+
*
|
|
28
|
+
* Zero-cost when devtools is inactive: `getFireSummaries()` returns []
|
|
29
|
+
* unless `activateReactiveDevtools()` has been called. So calling
|
|
30
|
+
* `writeLpihCache()` against an inactive registry writes an empty
|
|
31
|
+
* `{ fires: [] }` — cheap, correct.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Canonical filename for the LPIH cache file. Co-located with the
|
|
35
|
+
* project — convention: `<cwd>/.pyreon-lpih.json`. The dot-prefix marks
|
|
36
|
+
* it as a hidden / generated file by filesystem convention; the
|
|
37
|
+
* extension makes its contents grep-able as JSON.
|
|
38
|
+
*
|
|
39
|
+
* @internal — exported for tests + symmetry with the LSP-side default.
|
|
40
|
+
*/
|
|
41
|
+
declare const LPIH_DEFAULT_FILENAME = ".pyreon-lpih.json";
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the default LPIH cache path for the current process. The path
|
|
44
|
+
* is **`<cwd>/.pyreon-lpih.json`** — co-located with the project so the
|
|
45
|
+
* LSP can auto-discover it by walking up from any source file.
|
|
46
|
+
*
|
|
47
|
+
* Returns null in environments without `process.cwd()` (e.g. a fresh
|
|
48
|
+
* web worker without polyfills) — callers should fall back to an
|
|
49
|
+
* explicit path argument.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* import { startLpihPolling, getDefaultLpihCachePath } from '@pyreon/reactivity/lpih'
|
|
53
|
+
* console.log(getDefaultLpihCachePath()) // → '/Users/me/proj/.pyreon-lpih.json'
|
|
54
|
+
* startLpihPolling() // writes to that path
|
|
55
|
+
*/
|
|
56
|
+
declare function getDefaultLpihCachePath(): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Snapshot `getFireSummaries()` and write it to `path` atomically.
|
|
59
|
+
* Returns the number of fires written.
|
|
60
|
+
*
|
|
61
|
+
* **Path resolution**: when `path` is omitted, defaults to
|
|
62
|
+
* `<cwd>/.pyreon-lpih.json` (`getDefaultLpihCachePath()`). The LSP
|
|
63
|
+
* auto-discovers this convention by walking up from any source file to
|
|
64
|
+
* the nearest `package.json` — so projects that use the default need
|
|
65
|
+
* zero env-var configuration.
|
|
66
|
+
*
|
|
67
|
+
* Errors (filesystem permission, EACCES, etc.) are caught and re-thrown
|
|
68
|
+
* — the caller decides whether to swallow them. The runtime side wraps
|
|
69
|
+
* this in a try/catch when called from hot paths.
|
|
70
|
+
*
|
|
71
|
+
* Throws if `path` is omitted AND no default can be resolved (e.g.
|
|
72
|
+
* a web worker without `process.cwd()`).
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* import { activateReactiveDevtools } from '@pyreon/reactivity'
|
|
76
|
+
* import { writeLpihCache } from '@pyreon/reactivity/lpih'
|
|
77
|
+
*
|
|
78
|
+
* activateReactiveDevtools()
|
|
79
|
+
* await writeLpihCache() // → writes to <cwd>/.pyreon-lpih.json
|
|
80
|
+
* // The LSP server auto-discovers this path; no env var needed.
|
|
81
|
+
*/
|
|
82
|
+
declare function writeLpihCache(path?: string): Promise<number>;
|
|
83
|
+
/**
|
|
84
|
+
* Polling helper: call `writeLpihCache(path)` every `intervalMs`. Returns
|
|
85
|
+
* a disposer that stops the timer.
|
|
86
|
+
*
|
|
87
|
+
* **Path resolution**: same as `writeLpihCache` — `path` defaults to
|
|
88
|
+
* `<cwd>/.pyreon-lpih.json` when omitted. The LSP auto-discovers this
|
|
89
|
+
* convention so projects need zero configuration.
|
|
90
|
+
*
|
|
91
|
+
* Useful for dev servers that want the LSP to see live updates. The
|
|
92
|
+
* interval is throttled (not debounced); a fast-firing signal won't
|
|
93
|
+
* generate one write per fire. 250-500ms is the recommended range.
|
|
94
|
+
*
|
|
95
|
+
* Throws synchronously if `path` is omitted AND no default can be
|
|
96
|
+
* resolved — the caller catches this once at startup rather than
|
|
97
|
+
* silently never writing.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* import { activateReactiveDevtools } from '@pyreon/reactivity'
|
|
101
|
+
* import { startLpihPolling } from '@pyreon/reactivity/lpih'
|
|
102
|
+
*
|
|
103
|
+
* if (import.meta.env.DEV) {
|
|
104
|
+
* activateReactiveDevtools()
|
|
105
|
+
* startLpihPolling() // writes to <cwd>/.pyreon-lpih.json every 250ms
|
|
106
|
+
* }
|
|
107
|
+
*/
|
|
108
|
+
declare function startLpihPolling(path?: string, intervalMs?: number): () => void;
|
|
109
|
+
//#endregion
|
|
110
|
+
export { LPIH_DEFAULT_FILENAME, getDefaultLpihCachePath, startLpihPolling, writeLpihCache };
|
|
111
|
+
//# sourceMappingURL=lpih2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/reactivity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Signals-based reactivity system for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/reactivity#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
"bun": "./src/index.ts",
|
|
30
30
|
"import": "./lib/index.js",
|
|
31
31
|
"types": "./lib/types/index.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./lpih": {
|
|
34
|
+
"bun": "./src/lpih.ts",
|
|
35
|
+
"import": "./lib/lpih.js",
|
|
36
|
+
"types": "./lib/types/lpih.d.ts"
|
|
32
37
|
}
|
|
33
38
|
},
|
|
34
39
|
"publishConfig": {
|
package/src/computed.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { _markRecompute } from './batch'
|
|
2
2
|
import { _errorHandler } from './effect'
|
|
3
|
-
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
3
|
+
import { _captureCallerLocation, _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
4
4
|
import { getCurrentScope } from './scope'
|
|
5
5
|
import {
|
|
6
6
|
cleanupEffect,
|
|
@@ -36,6 +36,17 @@ export interface ComputedOptions<T> {
|
|
|
36
36
|
* })
|
|
37
37
|
*/
|
|
38
38
|
equals?: (prev: T, next: T) => boolean
|
|
39
|
+
/**
|
|
40
|
+
* @internal — source location injected by `@pyreon/vite-plugin` at build
|
|
41
|
+
* time. When present, the runtime skips the `new Error().stack` capture
|
|
42
|
+
* in `_rdRegister` — saves ~2.2µs per computed creation when devtools is
|
|
43
|
+
* active. Plain user code should NOT set this; the field is opaque
|
|
44
|
+
* (no public type) so it's not part of the public API surface.
|
|
45
|
+
*
|
|
46
|
+
* Shape: `{ file: string; line: number; col: number }` matching
|
|
47
|
+
* `@pyreon/reactivity`'s `SourceLocation`.
|
|
48
|
+
*/
|
|
49
|
+
__sourceLocation?: { file: string; line: number; col: number }
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/** Remove a computed from all dependency subscriber sets (local deps array). */
|
|
@@ -68,7 +79,14 @@ export function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed
|
|
|
68
79
|
)
|
|
69
80
|
}
|
|
70
81
|
}
|
|
71
|
-
|
|
82
|
+
// Prefer build-time-injected location (zero runtime cost) over the
|
|
83
|
+
// ~2.2µs stack-capture fallback. @pyreon/vite-plugin's `injectSignalNames`
|
|
84
|
+
// rewrites `computed(() => …)` to `computed(() => …, { __sourceLocation: {…} })`
|
|
85
|
+
// at transform time so most dev-mode computeds never pay the stack-capture cost.
|
|
86
|
+
const loc = options?.__sourceLocation
|
|
87
|
+
return options?.equals
|
|
88
|
+
? computedWithEquals(fn, options.equals, loc)
|
|
89
|
+
: computedLazy(fn, loc)
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
/**
|
|
@@ -81,7 +99,10 @@ export function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed
|
|
|
81
99
|
* in diamond patterns (a→b,c→d: b notifies d, c tries to notify d again —
|
|
82
100
|
* skipped because d is already dirty).
|
|
83
101
|
*/
|
|
84
|
-
function computedLazy<T>(
|
|
102
|
+
function computedLazy<T>(
|
|
103
|
+
fn: () => T,
|
|
104
|
+
injectedLoc?: { file: string; line: number; col: number },
|
|
105
|
+
): Computed<T> {
|
|
85
106
|
let value: T
|
|
86
107
|
let dirty = true
|
|
87
108
|
let disposed = false
|
|
@@ -165,7 +186,15 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
if (process.env.NODE_ENV !== 'production')
|
|
168
|
-
|
|
189
|
+
// skipFrames=2: skip computedLazy/computedWithEquals + computed, capture user's call site.
|
|
190
|
+
_rdRegister(
|
|
191
|
+
read,
|
|
192
|
+
'derived',
|
|
193
|
+
host,
|
|
194
|
+
recompute,
|
|
195
|
+
undefined,
|
|
196
|
+
injectedLoc ?? _captureCallerLocation(2),
|
|
197
|
+
)
|
|
169
198
|
|
|
170
199
|
getCurrentScope()?.add({ dispose: read.dispose })
|
|
171
200
|
return read as Computed<T>
|
|
@@ -177,7 +206,11 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
177
206
|
* Re-evaluates immediately when deps change and only notifies downstream
|
|
178
207
|
* if `equals(prev, next)` returns false.
|
|
179
208
|
*/
|
|
180
|
-
function computedWithEquals<T>(
|
|
209
|
+
function computedWithEquals<T>(
|
|
210
|
+
fn: () => T,
|
|
211
|
+
equals: (prev: T, next: T) => boolean,
|
|
212
|
+
injectedLoc?: { file: string; line: number; col: number },
|
|
213
|
+
): Computed<T> {
|
|
181
214
|
let value: T
|
|
182
215
|
let dirty = true
|
|
183
216
|
let initialized = false
|
|
@@ -265,7 +298,15 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
265
298
|
}
|
|
266
299
|
|
|
267
300
|
if (process.env.NODE_ENV !== 'production')
|
|
268
|
-
|
|
301
|
+
// skipFrames=2: skip computedLazy/computedWithEquals + computed, capture user's call site.
|
|
302
|
+
_rdRegister(
|
|
303
|
+
read,
|
|
304
|
+
'derived',
|
|
305
|
+
host,
|
|
306
|
+
recompute,
|
|
307
|
+
undefined,
|
|
308
|
+
injectedLoc ?? _captureCallerLocation(2),
|
|
309
|
+
)
|
|
269
310
|
|
|
270
311
|
getCurrentScope()?.add({ dispose: read.dispose })
|
|
271
312
|
return read as Computed<T>
|
package/src/effect.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
1
|
+
import { _captureCallerLocation, _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
2
2
|
import { getCurrentScope } from './scope'
|
|
3
3
|
import { _restoreActiveEffect, _setActiveEffect, setDepsCollector, withTracking } from './tracking'
|
|
4
4
|
|
|
@@ -9,6 +9,20 @@ export interface Effect {
|
|
|
9
9
|
dispose(): void
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export interface EffectOptions {
|
|
13
|
+
/**
|
|
14
|
+
* @internal — source location injected by `@pyreon/vite-plugin` at build
|
|
15
|
+
* time. When present, the runtime skips the `new Error().stack` capture
|
|
16
|
+
* in `_rdRegister` — saves ~2.2µs per effect creation when devtools is
|
|
17
|
+
* active. Plain user code should NOT set this; the field is opaque
|
|
18
|
+
* (no public type) so it's not part of the public API surface.
|
|
19
|
+
*
|
|
20
|
+
* Shape: `{ file: string; line: number; col: number }` matching
|
|
21
|
+
* `@pyreon/reactivity`'s `SourceLocation`.
|
|
22
|
+
*/
|
|
23
|
+
__sourceLocation?: { file: string; line: number; col: number }
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
// ─── Effect-scoped snapshot capture (DI from `@pyreon/core`) ─────────────────
|
|
13
27
|
//
|
|
14
28
|
// Effects re-run reactively in response to signal changes. When that re-run
|
|
@@ -137,7 +151,10 @@ function cleanupLocalDeps(deps: Set<() => void>[], fn: () => void): void {
|
|
|
137
151
|
}
|
|
138
152
|
}
|
|
139
153
|
|
|
140
|
-
export function effect(
|
|
154
|
+
export function effect(
|
|
155
|
+
fn: () => (() => void) | void,
|
|
156
|
+
options?: EffectOptions,
|
|
157
|
+
): Effect {
|
|
141
158
|
// Dev-mode warning for async effect callbacks (audit bug #1). The
|
|
142
159
|
// tracking context is the synchronous frame around `fn()`'s top half;
|
|
143
160
|
// anything after the first `await` runs detached, so signal reads on
|
|
@@ -258,7 +275,18 @@ export function effect(fn: () => (() => void) | void): Effect {
|
|
|
258
275
|
}
|
|
259
276
|
|
|
260
277
|
if (process.env.NODE_ENV !== 'production')
|
|
261
|
-
|
|
278
|
+
// skipFrames=1: skip the `effect()` / `renderEffect()` frame, capture the user's call site.
|
|
279
|
+
// Prefer build-time-injected location over the ~2.2µs stack-capture
|
|
280
|
+
// fallback. @pyreon/vite-plugin's `injectSignalNames` rewrites
|
|
281
|
+
// `effect(() => …)` to `effect(() => …, { __sourceLocation: {…} })`.
|
|
282
|
+
_rdRegister(
|
|
283
|
+
run,
|
|
284
|
+
'effect',
|
|
285
|
+
null,
|
|
286
|
+
run,
|
|
287
|
+
undefined,
|
|
288
|
+
options?.__sourceLocation ?? _captureCallerLocation(1),
|
|
289
|
+
)
|
|
262
290
|
|
|
263
291
|
run()
|
|
264
292
|
|
|
@@ -416,7 +444,8 @@ export function renderEffect(fn: () => void): () => void {
|
|
|
416
444
|
}
|
|
417
445
|
|
|
418
446
|
if (process.env.NODE_ENV !== 'production')
|
|
419
|
-
|
|
447
|
+
// skipFrames=1: skip the `effect()` / `renderEffect()` frame, capture the user's call site.
|
|
448
|
+
_rdRegister(run, 'effect', null, run, undefined, _captureCallerLocation(1))
|
|
420
449
|
|
|
421
450
|
run()
|
|
422
451
|
|
package/src/index.ts
CHANGED
|
@@ -6,19 +6,27 @@ export { type Computed, type ComputedOptions, computed } from './computed'
|
|
|
6
6
|
export { createSelector } from './createSelector'
|
|
7
7
|
export { inspectSignal, onSignalUpdate, why } from './debug'
|
|
8
8
|
export type {
|
|
9
|
+
FireSummary,
|
|
9
10
|
ReactiveEdge,
|
|
10
11
|
ReactiveFire,
|
|
11
12
|
ReactiveGraph,
|
|
12
13
|
ReactiveNode,
|
|
13
14
|
ReactiveNodeKind,
|
|
15
|
+
SourceLocation,
|
|
14
16
|
} from './reactive-devtools'
|
|
15
17
|
export {
|
|
16
18
|
activateReactiveDevtools,
|
|
17
19
|
deactivateReactiveDevtools,
|
|
20
|
+
getFireSummaries,
|
|
18
21
|
getReactiveFires,
|
|
19
22
|
getReactiveGraph,
|
|
20
23
|
isReactiveDevtoolsActive,
|
|
21
24
|
} from './reactive-devtools'
|
|
25
|
+
// `writeLpihCache` + `startLpihPolling` ship at the `@pyreon/reactivity/lpih`
|
|
26
|
+
// subpath. They depend on `node:fs/promises` (Node-only) and are dev-mode
|
|
27
|
+
// integration utilities — separating them keeps the core main-entry bundle
|
|
28
|
+
// smaller AND clarifies that LPIH writes are an opt-in side-channel, not a
|
|
29
|
+
// core reactivity primitive. See `./lpih.ts` and `docs/docs/lpih.md`.
|
|
22
30
|
export type { ReactiveTraceEntry } from './reactive-trace'
|
|
23
31
|
export { clearReactiveTrace, getReactiveTrace } from './reactive-trace'
|
|
24
32
|
export {
|
package/src/lpih.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Program Inlay Hints — runtime bridge.
|
|
3
|
+
*
|
|
4
|
+
* Writes the current `getFireSummaries()` snapshot to a JSON file that
|
|
5
|
+
* the LSP server reads via the `PYREON_LPIH_CACHE` env var. This is the
|
|
6
|
+
* file-cache bridge mechanism — chosen over IPC/WebSocket because:
|
|
7
|
+
*
|
|
8
|
+
* 1. LSP servers are stdio-only — they can't easily talk to a browser.
|
|
9
|
+
* 2. Filesystem is a universal lowest-common-denominator transport.
|
|
10
|
+
* 3. The runtime side writes (atomic rename); the LSP side reads.
|
|
11
|
+
* 4. The LSP re-reads the file on every inlay-hint request, so live
|
|
12
|
+
* edits land immediately without coordination.
|
|
13
|
+
*
|
|
14
|
+
* Two consumer modes:
|
|
15
|
+
*
|
|
16
|
+
* **Dev-server polled mode**: a dev-server hook calls
|
|
17
|
+
* `writeLpihCache(path)` on every signal write or at a regular interval
|
|
18
|
+
* (e.g. 250ms throttle). The LSP picks it up on next inlay-hint request.
|
|
19
|
+
*
|
|
20
|
+
* **On-demand mode**: a test harness or devtools UI calls
|
|
21
|
+
* `writeLpihCache(path)` explicitly when it wants the LSP to see the
|
|
22
|
+
* current state.
|
|
23
|
+
*
|
|
24
|
+
* Atomic write semantics: writes to `<path>.tmp.<pid>.<seq>` then renames
|
|
25
|
+
* to `<path>`. Readers (the LSP server) never see a half-written file.
|
|
26
|
+
*
|
|
27
|
+
* Zero-cost when devtools is inactive: `getFireSummaries()` returns []
|
|
28
|
+
* unless `activateReactiveDevtools()` has been called. So calling
|
|
29
|
+
* `writeLpihCache()` against an inactive registry writes an empty
|
|
30
|
+
* `{ fires: [] }` — cheap, correct.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { getFireSummaries } from './reactive-devtools'
|
|
34
|
+
|
|
35
|
+
let _seq = 0
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Canonical filename for the LPIH cache file. Co-located with the
|
|
39
|
+
* project — convention: `<cwd>/.pyreon-lpih.json`. The dot-prefix marks
|
|
40
|
+
* it as a hidden / generated file by filesystem convention; the
|
|
41
|
+
* extension makes its contents grep-able as JSON.
|
|
42
|
+
*
|
|
43
|
+
* @internal — exported for tests + symmetry with the LSP-side default.
|
|
44
|
+
*/
|
|
45
|
+
export const LPIH_DEFAULT_FILENAME = '.pyreon-lpih.json'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the default LPIH cache path for the current process. The path
|
|
49
|
+
* is **`<cwd>/.pyreon-lpih.json`** — co-located with the project so the
|
|
50
|
+
* LSP can auto-discover it by walking up from any source file.
|
|
51
|
+
*
|
|
52
|
+
* Returns null in environments without `process.cwd()` (e.g. a fresh
|
|
53
|
+
* web worker without polyfills) — callers should fall back to an
|
|
54
|
+
* explicit path argument.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* import { startLpihPolling, getDefaultLpihCachePath } from '@pyreon/reactivity/lpih'
|
|
58
|
+
* console.log(getDefaultLpihCachePath()) // → '/Users/me/proj/.pyreon-lpih.json'
|
|
59
|
+
* startLpihPolling() // writes to that path
|
|
60
|
+
*/
|
|
61
|
+
export function getDefaultLpihCachePath(): string | null {
|
|
62
|
+
if (typeof process === 'undefined') return null
|
|
63
|
+
// Pyreon's reactivity package narrows `process` to `{ env: ... }`.
|
|
64
|
+
// Cast through the runtime check so the call site typechecks under
|
|
65
|
+
// browser-target tsconfig while still working in Node where cwd exists.
|
|
66
|
+
const proc = process as unknown as { cwd?: () => string }
|
|
67
|
+
if (typeof proc.cwd !== 'function') return null
|
|
68
|
+
try {
|
|
69
|
+
const cwd = proc.cwd()
|
|
70
|
+
// Use forward-slash join; works on POSIX + Windows (Node accepts both).
|
|
71
|
+
return `${cwd.replace(/[/\\]+$/, '')}/${LPIH_DEFAULT_FILENAME}`
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Snapshot `getFireSummaries()` and write it to `path` atomically.
|
|
79
|
+
* Returns the number of fires written.
|
|
80
|
+
*
|
|
81
|
+
* **Path resolution**: when `path` is omitted, defaults to
|
|
82
|
+
* `<cwd>/.pyreon-lpih.json` (`getDefaultLpihCachePath()`). The LSP
|
|
83
|
+
* auto-discovers this convention by walking up from any source file to
|
|
84
|
+
* the nearest `package.json` — so projects that use the default need
|
|
85
|
+
* zero env-var configuration.
|
|
86
|
+
*
|
|
87
|
+
* Errors (filesystem permission, EACCES, etc.) are caught and re-thrown
|
|
88
|
+
* — the caller decides whether to swallow them. The runtime side wraps
|
|
89
|
+
* this in a try/catch when called from hot paths.
|
|
90
|
+
*
|
|
91
|
+
* Throws if `path` is omitted AND no default can be resolved (e.g.
|
|
92
|
+
* a web worker without `process.cwd()`).
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* import { activateReactiveDevtools } from '@pyreon/reactivity'
|
|
96
|
+
* import { writeLpihCache } from '@pyreon/reactivity/lpih'
|
|
97
|
+
*
|
|
98
|
+
* activateReactiveDevtools()
|
|
99
|
+
* await writeLpihCache() // → writes to <cwd>/.pyreon-lpih.json
|
|
100
|
+
* // The LSP server auto-discovers this path; no env var needed.
|
|
101
|
+
*/
|
|
102
|
+
export async function writeLpihCache(path?: string): Promise<number> {
|
|
103
|
+
const resolvedPath = path ?? getDefaultLpihCachePath()
|
|
104
|
+
if (resolvedPath === null) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'[lpih] writeLpihCache: no path provided and no default could be resolved ' +
|
|
107
|
+
'(process.cwd() unavailable). Pass an explicit path.',
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return await _writeToPath(resolvedPath)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function _writeToPath(path: string): Promise<number> {
|
|
114
|
+
const summaries = getFireSummaries()
|
|
115
|
+
const payload = {
|
|
116
|
+
fires: summaries.map((s) => ({
|
|
117
|
+
file: s.loc.file,
|
|
118
|
+
line: s.loc.line,
|
|
119
|
+
count: s.count,
|
|
120
|
+
kind: s.kind,
|
|
121
|
+
lastFire: s.lastFire,
|
|
122
|
+
rate1s: s.rate1s,
|
|
123
|
+
})),
|
|
124
|
+
}
|
|
125
|
+
const pid =
|
|
126
|
+
typeof process !== 'undefined' && 'pid' in process
|
|
127
|
+
? (process as { pid?: number }).pid ?? 0
|
|
128
|
+
: 0
|
|
129
|
+
const tmp = `${path}.tmp.${pid}.${++_seq}`
|
|
130
|
+
const fs = await import('node:fs/promises')
|
|
131
|
+
// Single try/catch covering BOTH writeFile AND rename. The previous
|
|
132
|
+
// shape only guarded the rename — if `fs.writeFile` itself threw (disk
|
|
133
|
+
// full, EIO, EACCES, transient FS error), the partial tmp file leaked
|
|
134
|
+
// on disk with a unique PID+seq name. The same bug class lived in the
|
|
135
|
+
// vite-plugin's `writeLpihCacheFile` (R1); both fixed in lockstep.
|
|
136
|
+
try {
|
|
137
|
+
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
138
|
+
await fs.rename(tmp, path)
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// Rename / writeFile failed — clean up the tmp file so we don't leak
|
|
141
|
+
// it on disk. Covers BOTH paths: writeFile-failed (tmp may not exist
|
|
142
|
+
// → unlink ENOENT, swallowed) AND rename-failed (tmp exists). Common
|
|
143
|
+
// rename causes: cross-device link (rare; same dir → same FS), target
|
|
144
|
+
// is a directory, EACCES. The caller sees the original error; the
|
|
145
|
+
// cleanup is best-effort and silent (unlink may also fail if the FS
|
|
146
|
+
// is broken — re-throwing that would mask the real problem).
|
|
147
|
+
try {
|
|
148
|
+
await fs.unlink(tmp)
|
|
149
|
+
} catch {
|
|
150
|
+
/* swallow — original error is more useful */
|
|
151
|
+
}
|
|
152
|
+
throw err
|
|
153
|
+
}
|
|
154
|
+
return summaries.length
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Polling helper: call `writeLpihCache(path)` every `intervalMs`. Returns
|
|
159
|
+
* a disposer that stops the timer.
|
|
160
|
+
*
|
|
161
|
+
* **Path resolution**: same as `writeLpihCache` — `path` defaults to
|
|
162
|
+
* `<cwd>/.pyreon-lpih.json` when omitted. The LSP auto-discovers this
|
|
163
|
+
* convention so projects need zero configuration.
|
|
164
|
+
*
|
|
165
|
+
* Useful for dev servers that want the LSP to see live updates. The
|
|
166
|
+
* interval is throttled (not debounced); a fast-firing signal won't
|
|
167
|
+
* generate one write per fire. 250-500ms is the recommended range.
|
|
168
|
+
*
|
|
169
|
+
* Throws synchronously if `path` is omitted AND no default can be
|
|
170
|
+
* resolved — the caller catches this once at startup rather than
|
|
171
|
+
* silently never writing.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* import { activateReactiveDevtools } from '@pyreon/reactivity'
|
|
175
|
+
* import { startLpihPolling } from '@pyreon/reactivity/lpih'
|
|
176
|
+
*
|
|
177
|
+
* if (import.meta.env.DEV) {
|
|
178
|
+
* activateReactiveDevtools()
|
|
179
|
+
* startLpihPolling() // writes to <cwd>/.pyreon-lpih.json every 250ms
|
|
180
|
+
* }
|
|
181
|
+
*/
|
|
182
|
+
export function startLpihPolling(
|
|
183
|
+
path?: string,
|
|
184
|
+
intervalMs = 250,
|
|
185
|
+
): () => void {
|
|
186
|
+
const resolvedPath = path ?? getDefaultLpihCachePath()
|
|
187
|
+
if (resolvedPath === null) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
'[lpih] startLpihPolling: no path provided and no default could be resolved ' +
|
|
190
|
+
'(process.cwd() unavailable). Pass an explicit path.',
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
return _startPollingAt(resolvedPath, intervalMs)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _startPollingAt(path: string, intervalMs: number): () => void {
|
|
197
|
+
let active = true
|
|
198
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
199
|
+
const tick = async (): Promise<void> => {
|
|
200
|
+
if (!active) return
|
|
201
|
+
try {
|
|
202
|
+
// Skip the default-resolution check on every tick — path is already
|
|
203
|
+
// resolved at startup.
|
|
204
|
+
await _writeToPath(path)
|
|
205
|
+
} catch {
|
|
206
|
+
// Swallow — polling continues. The LSP degrades gracefully if the
|
|
207
|
+
// file is missing or stale.
|
|
208
|
+
}
|
|
209
|
+
if (active) {
|
|
210
|
+
timer = setTimeout(tick, intervalMs)
|
|
211
|
+
// .unref() so a forgotten startLpihPolling() doesn't block process
|
|
212
|
+
// exit. Node-only; the setTimeout return type in browsers is a
|
|
213
|
+
// number with no .unref. Type-narrow defensively.
|
|
214
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
|
215
|
+
;(timer as { unref(): void }).unref()
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
void tick()
|
|
220
|
+
return () => {
|
|
221
|
+
active = false
|
|
222
|
+
if (timer !== null) {
|
|
223
|
+
clearTimeout(timer)
|
|
224
|
+
timer = null
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|