@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.
@@ -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.22.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
- return options?.equals ? computedWithEquals(fn, options.equals) : computedLazy(fn)
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>(fn: () => T): Computed<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
- _rdRegister(read, 'derived', host, recompute, undefined)
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>(fn: () => T, equals: (prev: T, next: T) => boolean): Computed<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
- _rdRegister(read, 'derived', host, recompute, undefined)
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(fn: () => (() => void) | void): 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
- _rdRegister(run, 'effect', null, run, undefined)
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
- _rdRegister(run, 'effect', null, run, undefined)
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
+ }