@reticlehq/vite-plugin 2.7.0 → 2.9.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,275 @@
1
+ export declare const RETICLE_VITE_PLUGIN_NAME = "reticle";
2
+ /**
3
+ * Compile-time global carrying the daemon's pairing token, for connects the plugin does not write
4
+ * itself. The bridge requires the token even on localhost, and nothing in a browser can read the
5
+ * file it lives in.
6
+ */
7
+ export declare const RETICLE_TOKEN_GLOBAL = "__RETICLE_TOKEN__";
8
+ /**
9
+ * The connect code is served as a real module (not an inline <script>) so that Vite's import
10
+ * pipeline resolves the bare `@reticlehq/react` specifier. An inline injected script is NOT run through
11
+ * import resolution, so its bare import would fail in the browser. This path-like id is requested
12
+ * by the injected <script src> and served by the load hook below.
13
+ */
14
+ export declare const RETICLE_CONNECT_MODULE = "/@reticle-connect";
15
+ /**
16
+ * The pre-hook, as source for an inline <head> script.
17
+ *
18
+ * Deliberately dependency-free ES5 in a try/catch: it runs before anything else on the page, so it
19
+ * must not assume a bundler, a module system, or that React is present at all. It installs a faithful
20
+ * devtools hook (React calls `inject` and expects a renderer id back, and stores the renderer) and
21
+ * counts commits into a buffer the module-side meter adopts later.
22
+ */
23
+ export declare const RENDER_PREHOOK_SOURCE = "(function(){try{\nvar K='__REACT_DEVTOOLS_GLOBAL_HOOK__',P='__reticleRenderPreHook';\nif(globalThis[P])return;\nvar B={commits:0,sinks:[]};\nglobalThis[P]=B;\nvar fire=function(){B.commits++;for(var i=0;i<B.sinks.length;i++){try{B.sinks[i].apply(null,arguments);}catch(e){}}};\nvar h=globalThis[K];\nif(h===undefined){\nglobalThis[K]={supportsFiber:true,renderers:new Map(),inject:function(r){var id=this.renderers.size+1;this.renderers.set(id,r);return id;},\nonScheduleFiberRoot:function(){},onCommitFiberRoot:fire,onPostCommitFiberRoot:function(){},onCommitFiberUnmount:function(){}};\n}else{var prev=h.onCommitFiberRoot;h.onCommitFiberRoot=function(){try{fire.apply(null,arguments);}catch(e){}\nif(typeof prev==='function')return prev.apply(this,arguments);};}\n}catch(e){}})();";
24
+ /**
25
+ * Said ONCE, and it names the symptom the user is looking at rather than the mechanism, because the
26
+ * mechanism is invisible from a browser: the page reloads and nothing explains why.
27
+ */
28
+ export declare const connectChurnWarning: () => string;
29
+ export interface ReticleVitePluginOptions {
30
+ /** Bridge WebSocket port. Defaults to the SDK default; only baked into connect when non-default. */
31
+ port?: number;
32
+ /**
33
+ * Project root, so React's absolute `_debugSource.fileName` reports repo-relative. Resolved from
34
+ * the Vite config at injection time; set it only to override.
35
+ */
36
+ root?: string;
37
+ /**
38
+ * The installed SDK's version, so a pair skewed against the daemon can name itself instead of
39
+ * surfacing as a bare -32000. Read from the installed package; set it only to override.
40
+ */
41
+ sdkVersion?: string;
42
+ /** Stable session label for the bridge. Defaults to the SDK's auto-generated id. */
43
+ session?: string;
44
+ /**
45
+ * Stable project identity. Defaults to one derived from the app's package.json name + root path,
46
+ * so multi-project session scoping works with zero config. Override only for special setups.
47
+ */
48
+ projectId?: string;
49
+ /** Auth token forwarded to connect when the bridge requires one. */
50
+ token?: string;
51
+ /** Stamp data-reticle-source for React 19 source mapping. Default true (harmless on React <=18). */
52
+ sourceMapping?: boolean;
53
+ /** Auto-inject the dev-gated reticle.connect call. Default true. */
54
+ inject?: boolean;
55
+ /**
56
+ * This build is an Electron/Tauri renderer. Changes two things a desktop shell needs and a web app
57
+ * must not get:
58
+ *
59
+ * - The plugin also applies to `vite build`. A packaged desktop renderer IS a production build
60
+ * loaded from `file://` or a custom protocol — there is no dev server — so the default
61
+ * `apply: 'serve'` drops the plugin entirely and the app ships with no `connect()` at all.
62
+ * - `connect()` is called with `allowInProduction`, because that same renderer reports
63
+ * NODE_ENV=production and the SDK's prod backstop would otherwise refuse to start.
64
+ *
65
+ * Off by default and never inferred: turning it on means an instrumented production BUNDLE, which
66
+ * is exactly what a web app must never ship. Keep it behind your own dev-only build (a dev target,
67
+ * or `process.env.NODE_ENV !== 'production'` in vite.config) so it cannot reach a release binary.
68
+ */
69
+ desktop?: boolean;
70
+ /**
71
+ * Record request/response BODIES on `reticle_network`, not just method/url/status.
72
+ *
73
+ * Off by default because a body is the one part of a request that routinely carries a card
74
+ * number, a token or a customer's address, and the daemon journals what it is told.
75
+ *
76
+ * It matters that this is reachable at all. The SDK has supported `captureNetworkBodies` on
77
+ * `connect()` since bodies existed, but the plugin — the documented one-line integration, and the
78
+ * only `connect()` most apps ever have — had no way to pass it, and calling `connect()` a second
79
+ * time is a no-op. So for every app wired the recommended way, a payload was unreachable: on a
80
+ * real payments dashboard, a refund POSTing `amount: 1187.01` into a paise field (a 100x
81
+ * under-refund) was visible to Playwright's request inspector and invisible here.
82
+ *
83
+ * Also settable as `VITE_RETICLE_CAPTURE_BODIES=1`, so it can be turned on for one debugging
84
+ * session without editing vite.config.
85
+ */
86
+ captureNetworkBodies?: boolean;
87
+ /**
88
+ * Let Reticle run when the page or the bridge is not on localhost.
89
+ *
90
+ * Off by default: the SDK refuses outside localhost so a page on the open internet cannot be
91
+ * instrumented by a bridge it happened to reach. Turn it on for a dev server that CANNOT be served
92
+ * on localhost — a host-based multi-tenant frontend, a white-label app resolving the tenant from
93
+ * the `Host` header, anything with cookie-scoped auth on a custom dev hostname. Without it those
94
+ * apps cannot use Reticle at all, because the plugin is the only `connect()` they have and a
95
+ * second, hand-written one is a no-op.
96
+ *
97
+ * NOT SUFFICIENT ON ITS OWN — a pairing token is also required. `connectionPolicy` in
98
+ * `@reticlehq/browser` refuses a non-localhost connect with "a pairing token is required outside
99
+ * localhost" whenever the token is missing or empty, whatever this flag says. The plugin supplies
100
+ * one automatically from the daemon's `~/.reticle/pairing-token` (see readPairingToken), so a
101
+ * started daemon is normally all it takes; pass `token` yourself only when the daemon's file is
102
+ * unreachable. A non-loopback BRIDGE additionally has to be `wss://`.
103
+ *
104
+ * Also settable as `VITE_RETICLE_ALLOW_NON_LOCALHOST=1`, so it can be turned on for one session
105
+ * without editing vite.config.
106
+ */
107
+ allowNonLocalhost?: boolean;
108
+ /**
109
+ * Where a diagnostic goes. Defaults to the console; injected so the dev-mode injection check is
110
+ * testable without capturing global console output.
111
+ */
112
+ onWarn?: (message: string) => void;
113
+ }
114
+ /** Structural Vite plugin shape — avoids a hard dependency on `vite` while staying assignable to its `Plugin`. */
115
+ export interface ReticleVitePlugin {
116
+ name: string;
117
+ /**
118
+ * Vite's `config` hook. Used to declare the SDK's CJS runtime deps for pre-bundling — see the
119
+ * implementation for why omitting them makes the whole SDK fail to load on linked setups.
120
+ */
121
+ config?: (config: {
122
+ optimizeDeps?: {
123
+ include?: string[];
124
+ /** Whichever key the app used — the plugin reads both and writes the one this Vite wants. */
125
+ esbuildOptions?: Record<string, unknown>;
126
+ rolldownOptions?: Record<string, unknown>;
127
+ };
128
+ define?: Record<string, string>;
129
+ root?: string;
130
+ server?: {
131
+ watch?: {
132
+ ignored?: (string | RegExp)[];
133
+ };
134
+ };
135
+ }) => {
136
+ optimizeDeps: {
137
+ include: string[];
138
+ [optionsKey: string]: unknown;
139
+ };
140
+ define: Record<string, string>;
141
+ server: {
142
+ watch: {
143
+ ignored: (string | RegExp)[];
144
+ };
145
+ };
146
+ };
147
+ /** Absent in desktop mode, where the plugin must also run for `vite build`. */
148
+ apply?: 'serve';
149
+ enforce: 'pre';
150
+ transform: (code: string, id: string) => {
151
+ code: string;
152
+ map: string | null;
153
+ } | null;
154
+ resolveId: (id: string, importer?: string) => string | null;
155
+ load: (id: string) => string | null;
156
+ transformIndexHtml: (html: string) => HtmlTag[];
157
+ /** Vite hands over the resolved config; used to resolve the HTML entry exactly. */
158
+ configResolved?: (config: {
159
+ root?: string;
160
+ command?: string;
161
+ }) => void;
162
+ /** Dev-server hook: keeps the served connect module from outliving the token it was built without. */
163
+ configureServer?: (server: ViteDevServerLike) => void;
164
+ /** Build-time post-condition: desktop injection must have happened. */
165
+ buildEnd?: () => void;
166
+ /** Runs the dev-mode injection check immediately. Test seam for the deferred timer. */
167
+ checkInjectedForTest?: () => void;
168
+ }
169
+ /**
170
+ * The slice of Vite's dev server this plugin touches, structurally — so `vite` stays a peer the
171
+ * plugin never imports, the same way the Svelte compiler and Playwright are handled elsewhere.
172
+ */
173
+ export interface ViteDevServerLike {
174
+ middlewares: {
175
+ use(handler: (req: {
176
+ url?: string | undefined;
177
+ }, res: unknown, next: () => void) => void): void;
178
+ };
179
+ moduleGraph: {
180
+ getModuleById(id: string): object | undefined;
181
+ invalidateModule(mod: object): void;
182
+ };
183
+ }
184
+ interface HtmlTag {
185
+ tag: string;
186
+ /** Absent on an inline script, which carries its source in `children` instead. */
187
+ attrs?: Record<string, string>;
188
+ /** Inline source, for a tag that has no `src`. */
189
+ children?: string;
190
+ /** `head-prepend` is required for the render pre-hook: it must run before any module script. */
191
+ injectTo: 'body' | 'head-prepend';
192
+ }
193
+ /**
194
+ * Name each CJS dep ONLY in the bare form, and only when the app root can resolve it.
195
+ *
196
+ * This used to try three layouts and emit Vite's nested `a > b > c` form when a hoisted lookup
197
+ * failed. The guard asked the wrong question: it tested NODE resolvability, walking the chain
198
+ * segment by segment, and under pnpm that succeeds exactly where Vite fails. Measured on the
199
+ * sveltekit fixture:
200
+ *
201
+ * ['@testing-library/dom'] -> null
202
+ * ['@reticlehq/browser', '@testing-library/dom'] -> null
203
+ * ['@reticlehq/react', '@reticlehq/browser', '@testing-library/dom'] -> emitted
204
+ *
205
+ * So we emitted the three-segment chain, Vite could not follow it, and the boot warning this
206
+ * function exists to prevent appeared anyway — `Failed to resolve dependency: …, present in
207
+ * optimizeDeps.include`, pointing at Reticle, naming a package the developer has never heard of,
208
+ * and forcing a full re-optimization on every cold start. The comment stated the rule correctly and
209
+ * the code broke it.
210
+ *
211
+ * Dropping the nested form loses nothing that matters: the SDK itself is still pre-bundled, and Vite
212
+ * follows its imports when it does that, so these deps are handled as part of it. Naming them
213
+ * separately was belt-and-braces for a locally-aliased SDK, where the bare form resolves anyway.
214
+ */
215
+ export declare function cjsDepIncludes(appRoot: string, canResolve?: (dep: string) => boolean): string[];
216
+ export declare function readPairingToken(): string | undefined;
217
+ /** The body of the connect module — real imports, resolved by Vite when the module is served. */
218
+ /**
219
+ * The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
220
+ *
221
+ * It is imported by CONVENTION rather than by patching the app's entry file. The connect is injected
222
+ * into a virtual module, so there is nowhere for a user to add these calls without `init` editing
223
+ * `src/main.tsx` — an edit to the file people actually own, for something that is opt-in enrichment.
224
+ * Convention costs one `existsSync` and leaves their entry untouched.
225
+ */
226
+ export declare const RETICLE_DEV_MODULE_CANDIDATES: readonly ["src/reticle-dev.ts", "src/reticle-dev.js", "src/reticle-dev.tsx", "src/reticle-dev.jsx"];
227
+ /** The app's dev module, as an importable path — or null when the app has none. */
228
+ export declare function findDevModule(root: string, exists: (p: string) => boolean): string | null;
229
+ /**
230
+ * Which SDK package this app actually has, and whether `install()` applies.
231
+ *
232
+ * The injected connect used to name `@reticlehq/react` unconditionally. That is right for a React
233
+ * app and fatal for any other: `reticle init` gives a Vue or Svelte codebase the framework-neutral
234
+ * `@reticlehq/browser` — deliberately, because a package named `@reticlehq/react` with `react` in
235
+ * its peers has no business in a Vue app — and the injected import then names a package that is not
236
+ * installed, so nothing connects and the page reports no session with no obvious cause.
237
+ *
238
+ * Measured end to end on a pristine `npm create vite --template vue` app: init wrote every file
239
+ * correctly and the tab never dialled the daemon, because of this one specifier.
240
+ *
241
+ * The React kit WINS when both resolve: it is a superset (it re-exports the sensor and adds the
242
+ * adapter), so an app that has it wants component identity. `install()` is the adapter's alone and
243
+ * the sensor does not export it — naming it against the sensor would trade a missing module for a
244
+ * missing export.
245
+ */
246
+ export declare function installedSdk(appRoot: string, canResolve?: (dep: string) => boolean): {
247
+ specifier: string;
248
+ usesInstall: boolean;
249
+ };
250
+ export declare function connectModuleSource(options: ReticleVitePluginOptions, devModule?: string | null): string;
251
+ /**
252
+ * Reticle Vite plugin. Add to your `plugins` array and the entire integration is done:
253
+ *
254
+ * import { reticle } from '@reticlehq/vite-plugin';
255
+ * export default defineConfig({ plugins: [react(), reticle()] });
256
+ *
257
+ * `apply: 'serve'` means Vite drops the plugin entirely from `vite build`, so a web production
258
+ * bundle is never instrumented — gating is the tool's job, not a user-managed env check.
259
+ *
260
+ * `desktop: true` is the ONE documented exception, and it inverts that guarantee deliberately: a
261
+ * packaged Electron/Tauri renderer IS a production build with no dev server, so serve-only gating
262
+ * would ship an app with no connect() at all. The cost is that the flag hands gating back to the
263
+ * caller — keep it behind your own dev-only build target so an instrumented bundle can never reach
264
+ * a release binary.
265
+ */
266
+ /**
267
+ * The daemon's journal directory, as a matcher every chokidar major honours.
268
+ *
269
+ * Exported so the one regression test can assert on the matcher itself rather than on a string that
270
+ * looked right and matched nothing.
271
+ */
272
+ export declare const JOURNAL_IGNORE: RegExp;
273
+ export declare function reticle(options?: ReticleVitePluginOptions): ReticleVitePlugin;
274
+ export {};
275
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.d.ts CHANGED
@@ -21,6 +21,11 @@ export declare const RETICLE_CONNECT_MODULE = "/@reticle-connect";
21
21
  * counts commits into a buffer the module-side meter adopts later.
22
22
  */
23
23
  export declare const RENDER_PREHOOK_SOURCE = "(function(){try{\nvar K='__REACT_DEVTOOLS_GLOBAL_HOOK__',P='__reticleRenderPreHook';\nif(globalThis[P])return;\nvar B={commits:0,sinks:[]};\nglobalThis[P]=B;\nvar fire=function(){B.commits++;for(var i=0;i<B.sinks.length;i++){try{B.sinks[i].apply(null,arguments);}catch(e){}}};\nvar h=globalThis[K];\nif(h===undefined){\nglobalThis[K]={supportsFiber:true,renderers:new Map(),inject:function(r){var id=this.renderers.size+1;this.renderers.set(id,r);return id;},\nonScheduleFiberRoot:function(){},onCommitFiberRoot:fire,onPostCommitFiberRoot:function(){},onCommitFiberUnmount:function(){}};\n}else{var prev=h.onCommitFiberRoot;h.onCommitFiberRoot=function(){try{fire.apply(null,arguments);}catch(e){}\nif(typeof prev==='function')return prev.apply(this,arguments);};}\n}catch(e){}})();";
24
+ /**
25
+ * Said ONCE, and it names the symptom the user is looking at rather than the mechanism, because the
26
+ * mechanism is invisible from a browser: the page reloads and nothing explains why.
27
+ */
28
+ export declare const connectChurnWarning: () => string;
24
29
  export interface ReticleVitePluginOptions {
25
30
  /** Bridge WebSocket port. Defaults to the SDK default; only baked into connect when non-default. */
26
31
  port?: number;
@@ -79,6 +84,27 @@ export interface ReticleVitePluginOptions {
79
84
  * session without editing vite.config.
80
85
  */
81
86
  captureNetworkBodies?: boolean;
87
+ /**
88
+ * Let Reticle run when the page or the bridge is not on localhost.
89
+ *
90
+ * Off by default: the SDK refuses outside localhost so a page on the open internet cannot be
91
+ * instrumented by a bridge it happened to reach. Turn it on for a dev server that CANNOT be served
92
+ * on localhost — a host-based multi-tenant frontend, a white-label app resolving the tenant from
93
+ * the `Host` header, anything with cookie-scoped auth on a custom dev hostname. Without it those
94
+ * apps cannot use Reticle at all, because the plugin is the only `connect()` they have and a
95
+ * second, hand-written one is a no-op.
96
+ *
97
+ * NOT SUFFICIENT ON ITS OWN — a pairing token is also required. `connectionPolicy` in
98
+ * `@reticlehq/browser` refuses a non-localhost connect with "a pairing token is required outside
99
+ * localhost" whenever the token is missing or empty, whatever this flag says. The plugin supplies
100
+ * one automatically from the daemon's `~/.reticle/pairing-token` (see readPairingToken), so a
101
+ * started daemon is normally all it takes; pass `token` yourself only when the daemon's file is
102
+ * unreachable. A non-loopback BRIDGE additionally has to be `wss://`.
103
+ *
104
+ * Also settable as `VITE_RETICLE_ALLOW_NON_LOCALHOST=1`, so it can be turned on for one session
105
+ * without editing vite.config.
106
+ */
107
+ allowNonLocalhost?: boolean;
82
108
  /**
83
109
  * Where a diagnostic goes. Defaults to the console; injected so the dev-mode injection check is
84
110
  * testable without capturing global console output.
@@ -101,12 +127,22 @@ export interface ReticleVitePlugin {
101
127
  };
102
128
  define?: Record<string, string>;
103
129
  root?: string;
130
+ server?: {
131
+ watch?: {
132
+ ignored?: (string | RegExp)[];
133
+ };
134
+ };
104
135
  }) => {
105
136
  optimizeDeps: {
106
137
  include: string[];
107
138
  [optionsKey: string]: unknown;
108
139
  };
109
140
  define: Record<string, string>;
141
+ server: {
142
+ watch: {
143
+ ignored: (string | RegExp)[];
144
+ };
145
+ };
110
146
  };
111
147
  /** Absent in desktop mode, where the plugin must also run for `vite build`. */
112
148
  apply?: 'serve';
@@ -190,6 +226,27 @@ export declare function readPairingToken(): string | undefined;
190
226
  export declare const RETICLE_DEV_MODULE_CANDIDATES: readonly ["src/reticle-dev.ts", "src/reticle-dev.js", "src/reticle-dev.tsx", "src/reticle-dev.jsx"];
191
227
  /** The app's dev module, as an importable path — or null when the app has none. */
192
228
  export declare function findDevModule(root: string, exists: (p: string) => boolean): string | null;
229
+ /**
230
+ * Which SDK package this app actually has, and whether `install()` applies.
231
+ *
232
+ * The injected connect used to name `@reticlehq/react` unconditionally. That is right for a React
233
+ * app and fatal for any other: `reticle init` gives a Vue or Svelte codebase the framework-neutral
234
+ * `@reticlehq/browser` — deliberately, because a package named `@reticlehq/react` with `react` in
235
+ * its peers has no business in a Vue app — and the injected import then names a package that is not
236
+ * installed, so nothing connects and the page reports no session with no obvious cause.
237
+ *
238
+ * Measured end to end on a pristine `npm create vite --template vue` app: init wrote every file
239
+ * correctly and the tab never dialled the daemon, because of this one specifier.
240
+ *
241
+ * The React kit WINS when both resolve: it is a superset (it re-exports the sensor and adds the
242
+ * adapter), so an app that has it wants component identity. `install()` is the adapter's alone and
243
+ * the sensor does not export it — naming it against the sensor would trade a missing module for a
244
+ * missing export.
245
+ */
246
+ export declare function installedSdk(appRoot: string, canResolve?: (dep: string) => boolean): {
247
+ specifier: string;
248
+ usesInstall: boolean;
249
+ };
193
250
  export declare function connectModuleSource(options: ReticleVitePluginOptions, devModule?: string | null): string;
194
251
  /**
195
252
  * Reticle Vite plugin. Add to your `plugins` array and the entire integration is done:
@@ -206,5 +263,12 @@ export declare function connectModuleSource(options: ReticleVitePluginOptions, d
206
263
  * caller — keep it behind your own dev-only build target so an instrumented bundle can never reach
207
264
  * a release binary.
208
265
  */
266
+ /**
267
+ * The daemon's journal directory, as a matcher every chokidar major honours.
268
+ *
269
+ * Exported so the one regression test can assert on the matcher itself rather than on a string that
270
+ * looked right and matched nothing.
271
+ */
272
+ export declare const JOURNAL_IGNORE: RegExp;
209
273
  export declare function reticle(options?: ReticleVitePluginOptions): ReticleVitePlugin;
210
274
  export {};
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ export const RETICLE_VITE_PLUGIN_NAME = 'reticle';
15
15
  // specifier yields both `reticle` (connect) and `install` (the React adapter). NOT `@reticlehq/core`
16
16
  // — that is the isomorphic foundation and exports neither.
17
17
  const RETICLE_PACKAGE = '@reticlehq/react';
18
+ /** The framework-neutral sensor, which a Vue or Svelte app gets instead. See installedSdk. */
19
+ const RETICLE_SENSOR = '@reticlehq/browser';
18
20
  /**
19
21
  * Compile-time global carrying the daemon's pairing token, for connects the plugin does not write
20
22
  * itself. The bridge requires the token even on localhost, and nothing in a browser can read the
@@ -61,6 +63,27 @@ if(typeof prev==='function')return prev.apply(this,arguments);};}
61
63
  * large app can take a moment. A false warning would train people to ignore a real one.
62
64
  */
63
65
  const DEV_INJECTION_GRACE_MS = 10_000;
66
+ /**
67
+ * How many times the connect module's source may legitimately change in one dev-server session
68
+ * before the plugin says so.
69
+ *
70
+ * The source is a function of the port, the projectId, the pairing token and whether the app has a
71
+ * `reticle-dev` module. In a healthy session that settles almost immediately: the daemon starting
72
+ * after Vite is one change, a dev module being created is another. Anything past a handful means
73
+ * an input is oscillating, and an oscillating connect module is what makes Vite re-resolve it on
74
+ * every page load — the reload loop this counter exists to make audible instead of mysterious.
75
+ */
76
+ const CONNECT_CHURN_LIMIT = 5;
77
+ /**
78
+ * Said ONCE, and it names the symptom the user is looking at rather than the mechanism, because the
79
+ * mechanism is invisible from a browser: the page reloads and nothing explains why.
80
+ */
81
+ export const connectChurnWarning = () => `[${RETICLE_VITE_PLUGIN_NAME}] the injected connect module has changed ${String(CONNECT_CHURN_LIMIT)} ` +
82
+ 'times in one dev-server session. Something it depends on (the bridge port, the pairing token, ' +
83
+ 'or a reticle-dev module appearing and disappearing) is not settling, and that can make the page ' +
84
+ 'reload repeatedly. Reticle will keep serving the newest version. Please report this at ' +
85
+ 'https://github.com/ReticleHQ/reticle/issues with your vite.config and whether more than one ' +
86
+ 'daemon is running (`npx @reticlehq/server status`).';
64
87
  /**
65
88
  * Is this resolved module id the one the HTML referenced?
66
89
  *
@@ -220,6 +243,12 @@ function connectArgs(options) {
220
243
  if (true === options.captureNetworkBodies || '1' === process.env['VITE_RETICLE_CAPTURE_BODIES']) {
221
244
  args['captureNetworkBodies'] = true;
222
245
  }
246
+ // Same shape, same reason: without it an app that cannot be served on localhost has no way to
247
+ // reach the SDK option at all. The pairing token still applies — see the option's docstring.
248
+ if (true === options.allowNonLocalhost ||
249
+ '1' === process.env['VITE_RETICLE_ALLOW_NON_LOCALHOST']) {
250
+ args['allowNonLocalhost'] = true;
251
+ }
223
252
  return Object.keys(args).length > 0 ? JSON.stringify(args) : '';
224
253
  }
225
254
  /** The body of the connect module — real imports, resolved by Vite when the module is served. */
@@ -245,9 +274,38 @@ export function findDevModule(root, exists) {
245
274
  }
246
275
  return null;
247
276
  }
277
+ /**
278
+ * Which SDK package this app actually has, and whether `install()` applies.
279
+ *
280
+ * The injected connect used to name `@reticlehq/react` unconditionally. That is right for a React
281
+ * app and fatal for any other: `reticle init` gives a Vue or Svelte codebase the framework-neutral
282
+ * `@reticlehq/browser` — deliberately, because a package named `@reticlehq/react` with `react` in
283
+ * its peers has no business in a Vue app — and the injected import then names a package that is not
284
+ * installed, so nothing connects and the page reports no session with no obvious cause.
285
+ *
286
+ * Measured end to end on a pristine `npm create vite --template vue` app: init wrote every file
287
+ * correctly and the tab never dialled the daemon, because of this one specifier.
288
+ *
289
+ * The React kit WINS when both resolve: it is a superset (it re-exports the sensor and adds the
290
+ * adapter), so an app that has it wants component identity. `install()` is the adapter's alone and
291
+ * the sensor does not export it — naming it against the sensor would trade a missing module for a
292
+ * missing export.
293
+ */
294
+ export function installedSdk(appRoot, canResolve = (dep) => null !== resolvableChain([dep], appRoot)) {
295
+ if (canResolve(RETICLE_PACKAGE))
296
+ return { specifier: RETICLE_PACKAGE, usesInstall: true };
297
+ if (canResolve(RETICLE_SENSOR))
298
+ return { specifier: RETICLE_SENSOR, usesInstall: false };
299
+ // Neither resolves: keep the historical name so the failure reads as "the SDK is not installed"
300
+ // rather than as a package nobody recognises.
301
+ return { specifier: RETICLE_PACKAGE, usesInstall: true };
302
+ }
248
303
  export function connectModuleSource(options, devModule = null) {
249
304
  const args = connectArgs(options);
250
- const base = `import { reticle, install } from '${RETICLE_PACKAGE}';\ninstall();\nreticle.connect(${args});\n`;
305
+ const sdk = installedSdk(options.root ?? process.cwd());
306
+ const named = sdk.usesInstall ? 'reticle, install' : 'reticle';
307
+ const call = sdk.usesInstall ? 'install();\n' : '';
308
+ const base = `import { ${named} } from '${sdk.specifier}';\n${call}reticle.connect(${args});\n`;
251
309
  // AFTER connect: registerStore subscribes through the live SDK, and registering before there is a
252
310
  // session to report into drops the first diffs.
253
311
  return null === devModule ? base : `${base}import('${devModule}');\n`;
@@ -267,6 +325,13 @@ export function connectModuleSource(options, devModule = null) {
267
325
  * caller — keep it behind your own dev-only build target so an instrumented bundle can never reach
268
326
  * a release binary.
269
327
  */
328
+ /**
329
+ * The daemon's journal directory, as a matcher every chokidar major honours.
330
+ *
331
+ * Exported so the one regression test can assert on the matcher itself rather than on a string that
332
+ * looked right and matched nothing.
333
+ */
334
+ export const JOURNAL_IGNORE = new RegExp(`(^|[\\\\/])${ReticleDir.ROOT.replace('.', '\\.')}([\\\\/]|$)`);
270
335
  export function reticle(options = {}) {
271
336
  const sourceMapping = options.sourceMapping !== false;
272
337
  const inject = options.inject !== false;
@@ -308,6 +373,16 @@ export function reticle(options = {}) {
308
373
  const sdkVersion = withToken.sdkVersion ?? sdkPackageVersion(appRoot);
309
374
  return { ...withToken, root: appRoot, sdkVersion };
310
375
  };
376
+ /**
377
+ * The connect module's source as it would be served RIGHT NOW. Recomputed rather than cached: the
378
+ * daemon's token and the app's dev module can both appear after the dev server started, which is
379
+ * the whole reason the module is re-read at all.
380
+ */
381
+ const currentConnectSource = () => connectModuleSource(resolveLazy(), root === undefined ? null : findDevModule(root, existsSync));
382
+ /** The source `load` last handed to Vite, or undefined before the first serve. */
383
+ let lastServedConnectSource;
384
+ /** How many times the served source has actually changed. See connectChurnWarning. */
385
+ let connectChanges = 0;
311
386
  /**
312
387
  * The BUILD message. A build always runs every transform, so "my transform never ran" and "the
313
388
  * bundle has no connect()" are the same statement there, and stating it as a certainty is correct.
@@ -369,6 +444,37 @@ export function reticle(options = {}) {
369
444
  const appRoot = config.root ?? process.cwd();
370
445
  const optimizerKey = optimizerOptionsKey(viteMajor(appRoot));
371
446
  return {
447
+ // Keep the daemon's journal out of the dev server's watcher.
448
+ //
449
+ // The daemon writes `.reticle/` into the PROJECT root — session journals, and `ambient.json`
450
+ // rewritten atomically as `ambient.json.tmp` + rename on a live session. Vite watches the
451
+ // project root and does not ignore that directory, so every journal write read as a project
452
+ // file changing and Vite answered with a full page reload.
453
+ //
454
+ // That is a loop with no exit: page loads -> SDK connects and streams events -> daemon
455
+ // journals them -> Vite reloads the page -> SDK reconnects -> more events. It ran several
456
+ // times a second for as long as the dev server was up, and the damage was total but
457
+ // misattributed: every ref went stale, every act_and_wait died mid-flight, and the log
458
+ // filled with connect/disconnect pairs that looked like a flapping SDK rather than a
459
+ // watcher chasing its own tail.
460
+ //
461
+ // A RegExp, not a glob, and that is the whole difference between this working and not.
462
+ // chokidar dropped glob support in v4 — Vite 7+ ships v4/v5, where a pattern like
463
+ // `**/.reticle/**` is silently accepted and matches nothing. MEASURED against the chokidar
464
+ // this repo resolves: with the glob, a write to `.reticle/ambient.json` still fires; with
465
+ // this RegExp it does not, while a normal file still does. Vite's own defaults are globs and
466
+ // have the same problem, which is why it is not safe to copy their shape here.
467
+ //
468
+ // Anchored on `^` or a separator so it matches the directory and not a file that merely ends
469
+ // in those characters, and both separators are accepted because chokidar reports the path in
470
+ // the platform's own form.
471
+ //
472
+ // Appends to the app's list rather than replacing it, so nothing it already excluded is lost.
473
+ server: {
474
+ watch: {
475
+ ignored: [...(config.server?.watch?.ignored ?? []), JOURNAL_IGNORE],
476
+ },
477
+ },
372
478
  // Expose the daemon's pairing token to hand-written connects in the same Vite app. The
373
479
  // plugin's own injected connect gets the token directly, but a connect the USER writes —
374
480
  // SvelteKit's client hook, a custom entry — had no way to reach a file only Node can read,
@@ -406,7 +512,11 @@ export function reticle(options = {}) {
406
512
  // no WebSocket, no session, no console message. The FIRST load after `reticle init` —
407
513
  // the one the whole product is judged on — silently did nothing, and it worked on the
408
514
  // next refresh, which is the worst possible shape for a bug like this.
409
- RETICLE_PACKAGE,
515
+ //
516
+ // Whichever SDK this app actually has: naming `@reticlehq/react` in a Vue app that was
517
+ // given the sensor produces the exact boot warning the note below is about, for a
518
+ // package that is correctly absent.
519
+ installedSdk(appRoot).specifier,
410
520
  // Only in a form that resolves — see above; a name Vite cannot resolve produces a boot
411
521
  // warning that blames Reticle, and a forced re-optimization on every cold start.
412
522
  ...cjsDepIncludes(appRoot),
@@ -455,9 +565,9 @@ export function reticle(options = {}) {
455
565
  load(id) {
456
566
  if (!inject || id !== RETICLE_CONNECT_MODULE)
457
567
  return null;
458
- // Resolved at load, not at config: the file may be created after the dev server starts.
459
- const devModule = root === undefined ? null : findDevModule(root, existsSync);
460
- return connectModuleSource(resolveLazy(), devModule);
568
+ const source = currentConnectSource();
569
+ lastServedConnectSource = source;
570
+ return source;
461
571
  },
462
572
  configResolved(config) {
463
573
  root = config.root;
@@ -475,17 +585,31 @@ export function reticle(options = {}) {
475
585
  * the dev server cleared it, which is not a step anybody guesses.
476
586
  *
477
587
  * Dropping the cached module before it is served makes `load` re-read the token, so starting the
478
- * daemon and reloading the page is enough. Costs one string compare per request and re-runs a
479
- * three-line module — no reason to be cleverer about when to invalidate.
588
+ * daemon and reloading the page is enough.
589
+ *
590
+ * Only when the source would ACTUALLY differ, though. This used to invalidate on every request
591
+ * for the module, forever — and a module that is force-invalidated on every request is
592
+ * re-resolved against Vite's dep optimizer on every page load, which is the shape of a
593
+ * self-sustaining reload loop: reload → request → invalidate → re-resolve → reload. Reported
594
+ * from the field on a Vite + React Router app pinned to a non-default port: every route
595
+ * reloaded the whole page about once a second, `/@reticle-connect` was fetched in every cycle,
596
+ * and removing the plugin stopped it instantly. Comparing the source first costs one string
597
+ * compare, keeps the late-daemon fix intact (the token appearing IS a change), and makes the
598
+ * module inert once it has settled.
480
599
  */
481
600
  configureServer(server) {
482
601
  if (!inject)
483
602
  return;
484
603
  server.middlewares.use((req, _res, next) => {
485
604
  if ((req.url ?? '').split('?')[0] === RETICLE_CONNECT_MODULE) {
486
- const mod = server.moduleGraph.getModuleById(RETICLE_CONNECT_MODULE);
487
- if (mod !== undefined)
488
- server.moduleGraph.invalidateModule(mod);
605
+ if (currentConnectSource() !== lastServedConnectSource) {
606
+ connectChanges++;
607
+ if (CONNECT_CHURN_LIMIT === connectChanges)
608
+ warn(connectChurnWarning());
609
+ const mod = server.moduleGraph.getModuleById(RETICLE_CONNECT_MODULE);
610
+ if (mod !== undefined)
611
+ server.moduleGraph.invalidateModule(mod);
612
+ }
489
613
  }
490
614
  next();
491
615
  });
@@ -49,6 +49,13 @@ function defaultLoadCompiler() {
49
49
  cachedCompiler = null;
50
50
  // From the APP's root first. The plugin may be linked, hoisted, or in a pnpm store far from the
51
51
  // project, and the compiler that matters is the one the app's own Svelte plugin will use.
52
+ //
53
+ // `import.meta.url` is this module's own location and is the ESM spelling of it. The package also
54
+ // ships a CJS build (an app without `"type": "module"` can only `require` its Vite config), where
55
+ // `import.meta` is empty — so that build SUBSTITUTES `__filename` here rather than leaving the
56
+ // expression to evaluate to nothing. Both artefacts therefore try the same two origins; see
57
+ // scripts/build-cjs.mjs. Reading it unguarded is deliberate: the build fails loudly if the
58
+ // substitution is ever dropped.
52
59
  for (const from of [`${process.cwd()}/package.json`, import.meta.url]) {
53
60
  try {
54
61
  cachedCompiler = createRequire(from)('svelte/compiler');