@reticlehq/vite-plugin 2.14.0 → 3.1.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/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- export declare const RETICLE_VITE_PLUGIN_NAME = "reticle";
1
+ import { type InjectionWatch } from './injection-postcondition.js';
2
+ export { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
2
3
  /**
3
4
  * Compile-time global carrying the daemon's pairing token, for connects the plugin does not write
4
5
  * itself. The bridge requires the token even on localhost, and nothing in a browser can read the
@@ -88,31 +89,22 @@ export interface ReticleVitePluginOptions {
88
89
  * Off by default because a body is the one part of a request that routinely carries a card
89
90
  * number, a token or a customer's address, and the daemon journals what it is told.
90
91
  *
91
- * It matters that this is reachable at all. The SDK has supported `captureNetworkBodies` on
92
- * `connect()` since bodies existed, but the plugin the documented one-line integration, and the
93
- * only `connect()` most apps ever have — had no way to pass it, and calling `connect()` a second
94
- * time is a no-op. So for every app wired the recommended way, a payload was unreachable: on a
95
- * real payments dashboard, a refund POSTing `amount: 1187.01` into a paise field (a 100x
96
- * under-refund) was visible to Playwright's request inspector and invisible here.
92
+ * Exposed HERE because the plugin is the only `connect()` most apps have and a second `connect()`
93
+ * is a no-op, so an SDK option the plugin cannot pass is an option that does not exist.
97
94
  *
98
- * Also settable as `VITE_RETICLE_CAPTURE_BODIES=1`, so it can be turned on for one debugging
99
- * session without editing vite.config.
95
+ * Also settable as `VITE_RETICLE_CAPTURE_BODIES=1`.
100
96
  */
101
97
  captureNetworkBodies?: boolean;
102
98
  /**
103
99
  * Retain a FAILED request's response body even with `captureNetworkBodies` off. Default true.
104
100
  *
105
- * Reachable here for the reason `captureNetworkBodies` is: the plugin is the only `connect()`
106
- * most apps ever have. Also settable as `VITE_RETICLE_NO_ERROR_BODIES=1`, which turns it OFF --
107
- * the inverse of the other env vars, because this is the one that defaults on (#800).
101
+ * Also settable as `VITE_RETICLE_NO_ERROR_BODIES=1`, which turns it OFF -- the inverse of the
102
+ * other env vars, because this is the one that defaults on (#800).
108
103
  */
109
104
  captureErrorBodies?: boolean;
110
105
  /**
111
106
  * Make Reticle's OWN presenter visible to snapshots and queries. CONTRIBUTORS ONLY.
112
107
  *
113
- * Reachable here for the same reason `captureNetworkBodies` is: the plugin is the only `connect()`
114
- * most apps ever have, so an SDK option the plugin cannot pass is an option that does not exist.
115
- *
116
108
  * The presenter is hidden from every tool by design — an agent that can drive Reticle's own
117
109
  * interface can fabricate its own impact report. The cost is that a HUD change is the only kind of
118
110
  * change Reticle cannot be used to check. This is the hatch for that one case, and the app reports
@@ -127,9 +119,7 @@ export interface ReticleVitePluginOptions {
127
119
  * Off by default: the SDK refuses outside localhost so a page on the open internet cannot be
128
120
  * instrumented by a bridge it happened to reach. Turn it on for a dev server that CANNOT be served
129
121
  * on localhost — a host-based multi-tenant frontend, a white-label app resolving the tenant from
130
- * the `Host` header, anything with cookie-scoped auth on a custom dev hostname. Without it those
131
- * apps cannot use Reticle at all, because the plugin is the only `connect()` they have and a
132
- * second, hand-written one is a no-op.
122
+ * the `Host` header, anything with cookie-scoped auth on a custom dev hostname.
133
123
  *
134
124
  * NOT SUFFICIENT ON ITS OWN — a pairing token is also required. `connectionPolicy` in
135
125
  * `@reticlehq/browser` refuses a non-localhost connect with "a pairing token is required outside
@@ -138,8 +128,7 @@ export interface ReticleVitePluginOptions {
138
128
  * started daemon is normally all it takes; pass `token` yourself only when the daemon's file is
139
129
  * unreachable. A non-loopback BRIDGE additionally has to be `wss://`.
140
130
  *
141
- * Also settable as `VITE_RETICLE_ALLOW_NON_LOCALHOST=1`, so it can be turned on for one session
142
- * without editing vite.config.
131
+ * Also settable as `VITE_RETICLE_ALLOW_NON_LOCALHOST=1`.
143
132
  */
144
133
  allowNonLocalhost?: boolean;
145
134
  /**
@@ -230,6 +219,9 @@ export interface ReticleVitePlugin {
230
219
  buildEnd?: () => void;
231
220
  /** Runs the dev-mode injection check immediately. Test seam for the deferred timer. */
232
221
  checkInjectedForTest?: () => void;
222
+ checkHtmlHookForTest?: () => void;
223
+ /** The post-condition watch itself, so a test drives the real predicate and not a copy of it. */
224
+ injectionWatchForTest?: InjectionWatch;
233
225
  }
234
226
  /**
235
227
  * The slice of Vite's dev server this plugin touches, structurally — so `vite` stays a peer the
@@ -259,6 +251,9 @@ export interface ViteDevServerLike {
259
251
  middlewares: {
260
252
  use(handler: (req: {
261
253
  url?: string | undefined;
254
+ headers?: {
255
+ accept?: string | undefined;
256
+ } | undefined;
262
257
  }, res: unknown, next: () => void) => void): void;
263
258
  };
264
259
  moduleGraph: {
@@ -286,10 +281,9 @@ export declare function readPairingToken(): string | undefined;
286
281
  /**
287
282
  * The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
288
283
  *
289
- * It is imported by CONVENTION rather than by patching the app's entry file. The connect is injected
290
- * into a virtual module, so there is nowhere for a user to add these calls without `init` editing
291
- * `src/main.tsx` — an edit to the file people actually own, for something that is opt-in enrichment.
292
- * Convention costs one `existsSync` and leaves their entry untouched.
284
+ * Imported by CONVENTION rather than by patching the app's entry file: the connect is injected into
285
+ * a virtual module, so the alternative is `init` editing `src/main.tsx` a file the user owns for
286
+ * something that is opt-in enrichment. Convention costs one `existsSync`.
293
287
  */
294
288
  export declare const RETICLE_DEV_MODULE_CANDIDATES: readonly ["src/reticle-dev.ts", "src/reticle-dev.js", "src/reticle-dev.tsx", "src/reticle-dev.jsx"];
295
289
  /** The app's dev module, as an importable path — or null when the app has none. */
@@ -297,14 +291,11 @@ export declare function findDevModule(root: string, exists: (p: string) => boole
297
291
  /**
298
292
  * Which SDK package this app actually has, and whether `install()` applies.
299
293
  *
300
- * The injected connect used to name `@reticlehq/react` unconditionally. That is right for a React
294
+ * The injected connect must NOT name `@reticlehq/react` unconditionally. That is right for a React
301
295
  * app and fatal for any other: `reticle init` gives a Vue or Svelte codebase the framework-neutral
302
296
  * `@reticlehq/browser` — deliberately, because a package named `@reticlehq/react` with `react` in
303
- * its peers has no business in a Vue app — and the injected import then names a package that is not
304
- * installed, so nothing connects and the page reports no session with no obvious cause.
305
- *
306
- * Measured end to end on a pristine `npm create vite --template vue` app: init wrote every file
307
- * correctly and the tab never dialled the daemon, because of this one specifier.
297
+ * its peers has no business in a Vue app — and the injected import would then name a package that is
298
+ * not installed, so nothing connects and the page reports no session with no obvious cause.
308
299
  *
309
300
  * The React kit WINS when both resolve: it is a superset (it re-exports the sensor and adds the
310
301
  * adapter), so an app that has it wants component identity. `install()` is the adapter's alone and
@@ -339,5 +330,4 @@ export declare function connectModuleSource(options: ReticleVitePluginOptions, d
339
330
  */
340
331
  export declare const JOURNAL_IGNORE: RegExp;
341
332
  export declare function reticle(options?: ReticleVitePluginOptions): ReticleVitePlugin;
342
- export {};
343
333
  //# sourceMappingURL=index.d.ts.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export declare const RETICLE_VITE_PLUGIN_NAME = "reticle";
1
+ import { type InjectionWatch } from './injection-postcondition.js';
2
+ export { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
2
3
  /**
3
4
  * Compile-time global carrying the daemon's pairing token, for connects the plugin does not write
4
5
  * itself. The bridge requires the token even on localhost, and nothing in a browser can read the
@@ -88,31 +89,22 @@ export interface ReticleVitePluginOptions {
88
89
  * Off by default because a body is the one part of a request that routinely carries a card
89
90
  * number, a token or a customer's address, and the daemon journals what it is told.
90
91
  *
91
- * It matters that this is reachable at all. The SDK has supported `captureNetworkBodies` on
92
- * `connect()` since bodies existed, but the plugin the documented one-line integration, and the
93
- * only `connect()` most apps ever have — had no way to pass it, and calling `connect()` a second
94
- * time is a no-op. So for every app wired the recommended way, a payload was unreachable: on a
95
- * real payments dashboard, a refund POSTing `amount: 1187.01` into a paise field (a 100x
96
- * under-refund) was visible to Playwright's request inspector and invisible here.
92
+ * Exposed HERE because the plugin is the only `connect()` most apps have and a second `connect()`
93
+ * is a no-op, so an SDK option the plugin cannot pass is an option that does not exist.
97
94
  *
98
- * Also settable as `VITE_RETICLE_CAPTURE_BODIES=1`, so it can be turned on for one debugging
99
- * session without editing vite.config.
95
+ * Also settable as `VITE_RETICLE_CAPTURE_BODIES=1`.
100
96
  */
101
97
  captureNetworkBodies?: boolean;
102
98
  /**
103
99
  * Retain a FAILED request's response body even with `captureNetworkBodies` off. Default true.
104
100
  *
105
- * Reachable here for the reason `captureNetworkBodies` is: the plugin is the only `connect()`
106
- * most apps ever have. Also settable as `VITE_RETICLE_NO_ERROR_BODIES=1`, which turns it OFF --
107
- * the inverse of the other env vars, because this is the one that defaults on (#800).
101
+ * Also settable as `VITE_RETICLE_NO_ERROR_BODIES=1`, which turns it OFF -- the inverse of the
102
+ * other env vars, because this is the one that defaults on (#800).
108
103
  */
109
104
  captureErrorBodies?: boolean;
110
105
  /**
111
106
  * Make Reticle's OWN presenter visible to snapshots and queries. CONTRIBUTORS ONLY.
112
107
  *
113
- * Reachable here for the same reason `captureNetworkBodies` is: the plugin is the only `connect()`
114
- * most apps ever have, so an SDK option the plugin cannot pass is an option that does not exist.
115
- *
116
108
  * The presenter is hidden from every tool by design — an agent that can drive Reticle's own
117
109
  * interface can fabricate its own impact report. The cost is that a HUD change is the only kind of
118
110
  * change Reticle cannot be used to check. This is the hatch for that one case, and the app reports
@@ -127,9 +119,7 @@ export interface ReticleVitePluginOptions {
127
119
  * Off by default: the SDK refuses outside localhost so a page on the open internet cannot be
128
120
  * instrumented by a bridge it happened to reach. Turn it on for a dev server that CANNOT be served
129
121
  * on localhost — a host-based multi-tenant frontend, a white-label app resolving the tenant from
130
- * the `Host` header, anything with cookie-scoped auth on a custom dev hostname. Without it those
131
- * apps cannot use Reticle at all, because the plugin is the only `connect()` they have and a
132
- * second, hand-written one is a no-op.
122
+ * the `Host` header, anything with cookie-scoped auth on a custom dev hostname.
133
123
  *
134
124
  * NOT SUFFICIENT ON ITS OWN — a pairing token is also required. `connectionPolicy` in
135
125
  * `@reticlehq/browser` refuses a non-localhost connect with "a pairing token is required outside
@@ -138,8 +128,7 @@ export interface ReticleVitePluginOptions {
138
128
  * started daemon is normally all it takes; pass `token` yourself only when the daemon's file is
139
129
  * unreachable. A non-loopback BRIDGE additionally has to be `wss://`.
140
130
  *
141
- * Also settable as `VITE_RETICLE_ALLOW_NON_LOCALHOST=1`, so it can be turned on for one session
142
- * without editing vite.config.
131
+ * Also settable as `VITE_RETICLE_ALLOW_NON_LOCALHOST=1`.
143
132
  */
144
133
  allowNonLocalhost?: boolean;
145
134
  /**
@@ -230,6 +219,9 @@ export interface ReticleVitePlugin {
230
219
  buildEnd?: () => void;
231
220
  /** Runs the dev-mode injection check immediately. Test seam for the deferred timer. */
232
221
  checkInjectedForTest?: () => void;
222
+ checkHtmlHookForTest?: () => void;
223
+ /** The post-condition watch itself, so a test drives the real predicate and not a copy of it. */
224
+ injectionWatchForTest?: InjectionWatch;
233
225
  }
234
226
  /**
235
227
  * The slice of Vite's dev server this plugin touches, structurally — so `vite` stays a peer the
@@ -259,6 +251,9 @@ export interface ViteDevServerLike {
259
251
  middlewares: {
260
252
  use(handler: (req: {
261
253
  url?: string | undefined;
254
+ headers?: {
255
+ accept?: string | undefined;
256
+ } | undefined;
262
257
  }, res: unknown, next: () => void) => void): void;
263
258
  };
264
259
  moduleGraph: {
@@ -286,10 +281,9 @@ export declare function readPairingToken(): string | undefined;
286
281
  /**
287
282
  * The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
288
283
  *
289
- * It is imported by CONVENTION rather than by patching the app's entry file. The connect is injected
290
- * into a virtual module, so there is nowhere for a user to add these calls without `init` editing
291
- * `src/main.tsx` — an edit to the file people actually own, for something that is opt-in enrichment.
292
- * Convention costs one `existsSync` and leaves their entry untouched.
284
+ * Imported by CONVENTION rather than by patching the app's entry file: the connect is injected into
285
+ * a virtual module, so the alternative is `init` editing `src/main.tsx` a file the user owns for
286
+ * something that is opt-in enrichment. Convention costs one `existsSync`.
293
287
  */
294
288
  export declare const RETICLE_DEV_MODULE_CANDIDATES: readonly ["src/reticle-dev.ts", "src/reticle-dev.js", "src/reticle-dev.tsx", "src/reticle-dev.jsx"];
295
289
  /** The app's dev module, as an importable path — or null when the app has none. */
@@ -297,14 +291,11 @@ export declare function findDevModule(root: string, exists: (p: string) => boole
297
291
  /**
298
292
  * Which SDK package this app actually has, and whether `install()` applies.
299
293
  *
300
- * The injected connect used to name `@reticlehq/react` unconditionally. That is right for a React
294
+ * The injected connect must NOT name `@reticlehq/react` unconditionally. That is right for a React
301
295
  * app and fatal for any other: `reticle init` gives a Vue or Svelte codebase the framework-neutral
302
296
  * `@reticlehq/browser` — deliberately, because a package named `@reticlehq/react` with `react` in
303
- * its peers has no business in a Vue app — and the injected import then names a package that is not
304
- * installed, so nothing connects and the page reports no session with no obvious cause.
305
- *
306
- * Measured end to end on a pristine `npm create vite --template vue` app: init wrote every file
307
- * correctly and the tab never dialled the daemon, because of this one specifier.
297
+ * its peers has no business in a Vue app — and the injected import would then name a package that is
298
+ * not installed, so nothing connects and the page reports no session with no obvious cause.
308
299
  *
309
300
  * The React kit WINS when both resolve: it is a superset (it re-exports the sensor and adds the
310
301
  * adapter), so an app that has it wants component identity. `install()` is the adapter's alone and
@@ -339,4 +330,3 @@ export declare function connectModuleSource(options: ReticleVitePluginOptions, d
339
330
  */
340
331
  export declare const JOURNAL_IGNORE: RegExp;
341
332
  export declare function reticle(options?: ReticleVitePluginOptions): ReticleVitePlugin;
342
- export {};
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { missingTokenWarning } from './missing-token.js';
3
- import { ensurePairingToken } from './ensure-token.js';
2
+ import { missingTokenWarning } from './token/missing-token.js';
3
+ import { ensurePairingToken } from './token/ensure-token.js';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { transformSync } from '@babel/core';
@@ -11,7 +11,9 @@ import { discoverDaemonPort } from './discover-port.js';
11
11
  import { announceDevServer } from './announce.js';
12
12
  import { SVELTE_FILE, stampSvelte } from './svelte-source.js';
13
13
  import { resolvableChain, sdkPackageVersion, sdkBuildFingerprint, viteMajor, optimizerOptionsKey, optimizerOptions, } from './installed.js';
14
- export const RETICLE_VITE_PLUGIN_NAME = 'reticle';
14
+ import { createInjectionWatch, notInjectedMessage, } from './injection-postcondition.js';
15
+ import { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
16
+ export { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
15
17
  // The React kit the host app imports the SDK from. It re-exports the browser sensor, so a single
16
18
  // specifier yields both `reticle` (connect) and `install` (the React adapter). NOT `@reticlehq/core`
17
19
  // — that is the isomorphic foundation and exports neither.
@@ -84,22 +86,14 @@ onScheduleFiberRoot:function(){},onCommitFiberRoot:fire,onPostCommitFiberRoot:fu
84
86
  }else{var prev=h.onCommitFiberRoot;h.onCommitFiberRoot=function(){try{fire.apply(null,arguments);}catch(e){}
85
87
  if(typeof prev==='function')return prev.apply(this,arguments);};}
86
88
  }catch(e){}})();`;
87
- /**
88
- * How long after serving the HTML to wait before concluding the entry was never injected.
89
- *
90
- * Generous on purpose: the browser has to request the entry, and a cold dev server transforming a
91
- * large app can take a moment. A false warning would train people to ignore a real one.
92
- */
93
- const DEV_INJECTION_GRACE_MS = 10_000;
94
89
  /**
95
90
  * How many times the connect module's source may legitimately change in one dev-server session
96
91
  * before the plugin says so.
97
92
  *
98
93
  * The source is a function of the port, the projectId, the pairing token and whether the app has a
99
- * `reticle-dev` module. In a healthy session that settles almost immediately: the daemon starting
100
- * after Vite is one change, a dev module being created is another. Anything past a handful means
101
- * an input is oscillating, and an oscillating connect module is what makes Vite re-resolve it on
102
- * every page load — the reload loop this counter exists to make audible instead of mysterious.
94
+ * `reticle-dev` module in a healthy session, the daemon starting after Vite is one change and a
95
+ * dev module appearing is another. Past a handful, an input is oscillating, which makes Vite
96
+ * re-resolve the module on every page load: the reload loop this counter makes audible.
103
97
  */
104
98
  const CONNECT_CHURN_LIMIT = 5;
105
99
  /**
@@ -260,10 +254,9 @@ function connectArgs(options) {
260
254
  /**
261
255
  * The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
262
256
  *
263
- * It is imported by CONVENTION rather than by patching the app's entry file. The connect is injected
264
- * into a virtual module, so there is nowhere for a user to add these calls without `init` editing
265
- * `src/main.tsx` — an edit to the file people actually own, for something that is opt-in enrichment.
266
- * Convention costs one `existsSync` and leaves their entry untouched.
257
+ * Imported by CONVENTION rather than by patching the app's entry file: the connect is injected into
258
+ * a virtual module, so the alternative is `init` editing `src/main.tsx` a file the user owns for
259
+ * something that is opt-in enrichment. Convention costs one `existsSync`.
267
260
  */
268
261
  export const RETICLE_DEV_MODULE_CANDIDATES = [
269
262
  'src/reticle-dev.ts',
@@ -282,14 +275,11 @@ export function findDevModule(root, exists) {
282
275
  /**
283
276
  * Which SDK package this app actually has, and whether `install()` applies.
284
277
  *
285
- * The injected connect used to name `@reticlehq/react` unconditionally. That is right for a React
278
+ * The injected connect must NOT name `@reticlehq/react` unconditionally. That is right for a React
286
279
  * app and fatal for any other: `reticle init` gives a Vue or Svelte codebase the framework-neutral
287
280
  * `@reticlehq/browser` — deliberately, because a package named `@reticlehq/react` with `react` in
288
- * its peers has no business in a Vue app — and the injected import then names a package that is not
289
- * installed, so nothing connects and the page reports no session with no obvious cause.
290
- *
291
- * Measured end to end on a pristine `npm create vite --template vue` app: init wrote every file
292
- * correctly and the tab never dialled the daemon, because of this one specifier.
281
+ * its peers has no business in a Vue app — and the injected import would then name a package that is
282
+ * not installed, so nothing connects and the page reports no session with no obvious cause.
293
283
  *
294
284
  * The React kit WINS when both resolve: it is a superset (it re-exports the sensor and adds the
295
285
  * adapter), so an app that has it wants component identity. `install()` is the adapter's alone and
@@ -388,6 +378,14 @@ export function reticle(options = {}) {
388
378
  const warn = options.onWarn ?? ((message) => globalThis.console.warn(message));
389
379
  /** Whether connect() actually reached a module — asserted at buildEnd, never assumed. */
390
380
  let injected = false;
381
+ /**
382
+ * Whether Vite ever asked us to transform the app's HTML.
383
+ *
384
+ * On the web this is how the connect script gets in, so "this never happened" and "this app will
385
+ * not connect" are the same statement — which is what makes a warning safe here, unlike the
386
+ * desktop entry-module flag above.
387
+ */
388
+ let htmlTransformed = false;
391
389
  /**
392
390
  * Resolve port + token at the moment of injection, not at plugin construction. By the time a
393
391
  * module is served or built the daemon is up and has written its pairing token; resolving early
@@ -415,37 +413,17 @@ export function reticle(options = {}) {
415
413
  /** How many times the served source has actually changed. See connectChurnWarning. */
416
414
  let connectChanges = 0;
417
415
  /**
418
- * The BUILD message. A build always runs every transform, so "my transform never ran" and "the
419
- * bundle has no connect()" are the same statement there, and stating it as a certainty is correct.
416
+ * When to conclude injection failed, and which of the three messages to say. Lives beside those
417
+ * messages rather than here: choosing between them is the subtlety, and it was a thousand lines
418
+ * from the wording it chose. The flags are read through getters because both flip mid-session.
420
419
  */
421
- const notInjectedMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not inject reticle.connect(): the HTML entry module was ` +
422
- 'never matched, so this app carries no instrumentation and will never connect. Check that ' +
423
- 'index.html references your entry with a <script type="module" src="...">, or pass ' +
424
- '`inject: false` and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself. The plugin ' +
425
- 'still inlines that define; a connect without it is refused.';
426
- /**
427
- * The DEV message, which must be weaker — and this is the whole reason the two are separate.
428
- *
429
- * In serve, `injected` records "my transform ran THIS session", which is not the same as "the app
430
- * has no connect()". Vite serves an unchanged module straight from its transform cache, so on a
431
- * warm cache the transform never runs, the flag stays false, and the old wording announced that
432
- * the app "will never connect" while the served entry demonstrably contained the injection —
433
- * verified by fetching it from the dev server. A false alarm, in the tool whose entire argument is
434
- * that it does not raise them.
435
- *
436
- * So dev reports what it actually knows: unconfirmed, with the benign explanation first.
437
- */
438
- const unconfirmedInjectionMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not confirm reticle.connect() was injected: the HTML entry ` +
439
- 'module was not transformed this session. That is expected when Vite served it from its ' +
440
- 'transform cache. If the app does not appear in `reticle status`, restart the dev server with ' +
441
- '`--force` to bypass the cache, then check that index.html references your entry with a ' +
442
- '<script type="module" src="...">.';
443
- /** Warn (never throw) in dev — a running dev server should report the doubt, not die of it. */
444
- const checkInjected = () => {
445
- if (!desktop || !inject || injected)
446
- return;
447
- warn(unconfirmedInjectionMessage());
448
- };
420
+ const watch = createInjectionWatch({
421
+ desktop,
422
+ inject,
423
+ injected: () => injected,
424
+ htmlTransformed: () => htmlTransformed,
425
+ warn,
426
+ });
449
427
  return {
450
428
  name: RETICLE_VITE_PLUGIN_NAME,
451
429
  // Web: serve-only, so a production bundle can never carry the SDK — gating is the tool's job.
@@ -456,42 +434,31 @@ export function reticle(options = {}) {
456
434
  /**
457
435
  * Declare the SDK itself and the optimizer cache fingerprint.
458
436
  *
459
- * The browser SDK used to need extra CJS query-engine deps here. It no longer imports that
460
- * second accessibility engine, so keeping those names would make Vite pre-bundle packages the
461
- * app may not have and blame Reticle for a false `Failed to resolve dependency` warning.
437
+ * The SDK itself only. It does not import a second accessibility engine, and naming CJS deps it
438
+ * does not use would make Vite pre-bundle packages the app may not have, then blame Reticle for a
439
+ * false `Failed to resolve dependency` warning.
462
440
  */
463
441
  config(config) {
464
442
  // Everything below asks what the APP has installed, so every lookup is rooted here and never
465
- // at the plugin's own location. Vite defaults an omitted root to the cwd; so do we.
443
+ // at the plugin's own location. Vite defaults an omitted root to the cwd, and so does this.
466
444
  const appRoot = config.root ?? process.cwd();
467
445
  const optimizerKey = optimizerOptionsKey(viteMajor(appRoot));
468
446
  return {
469
447
  // Keep the daemon's journal out of the dev server's watcher.
470
448
  //
471
449
  // The daemon writes `.reticle/` into the PROJECT root — session journals, and `ambient.json`
472
- // rewritten atomically as `ambient.json.tmp` + rename on a live session. Vite watches the
473
- // project root and does not ignore that directory, so every journal write read as a project
474
- // file changing and Vite answered with a full page reload.
450
+ // rewritten atomically as `ambient.json.tmp` + rename on a live session. Unignored, every
451
+ // journal write reads as a project file changing and Vite answers with a full page reload,
452
+ // which is a loop with no exit: page loads -> SDK connects and streams events -> daemon
453
+ // journals them -> Vite reloads the page -> SDK reconnects.
475
454
  //
476
- // That is a loop with no exit: page loads -> SDK connects and streams events -> daemon
477
- // journals them -> Vite reloads the page -> SDK reconnects -> more events. It ran several
478
- // times a second for as long as the dev server was up, and the damage was total but
479
- // misattributed: every ref went stale, every act_and_wait died mid-flight, and the log
480
- // filled with connect/disconnect pairs that looked like a flapping SDK rather than a
481
- // watcher chasing its own tail.
482
- //
483
- // A RegExp, not a glob, and that is the whole difference between this working and not.
484
- // chokidar dropped glob support in v4 — Vite 7+ ships v4/v5, where a pattern like
485
- // `**/.reticle/**` is silently accepted and matches nothing. MEASURED against the chokidar
486
- // this repo resolves: with the glob, a write to `.reticle/ambient.json` still fires; with
487
- // this RegExp it does not, while a normal file still does. Vite's own defaults are globs and
488
- // have the same problem, which is why it is not safe to copy their shape here.
455
+ // A RegExp, NOT a glob: chokidar dropped glob support in v4, and Vite 7+ ships v4/v5, where
456
+ // `**/.reticle/**` is silently accepted and matches nothing. Vite's own defaults are globs
457
+ // and have the same problem, so their shape is not safe to copy here.
489
458
  //
490
459
  // Anchored on `^` or a separator so it matches the directory and not a file that merely ends
491
460
  // in those characters, and both separators are accepted because chokidar reports the path in
492
- // the platform's own form.
493
- //
494
- // Appends to the app's list rather than replacing it, so nothing it already excluded is lost.
461
+ // the platform's own form. Appends to the app's list rather than replacing it.
495
462
  server: {
496
463
  watch: {
497
464
  ignored: mergeIgnored(config.server?.watch?.ignored, JOURNAL_IGNORE),
@@ -499,8 +466,8 @@ export function reticle(options = {}) {
499
466
  },
500
467
  // Expose the daemon's pairing token to hand-written connects in the same Vite app. The
501
468
  // plugin's own injected connect gets the token directly, but a connect the USER writes —
502
- // SvelteKit's client hook, a custom entry — had no way to reach a file only Node can read,
503
- // so it called connect() with no credential and the bridge answered "authentication
469
+ // SvelteKit's client hook, a custom entry — cannot reach a file only Node can read, so it
470
+ // would call connect() with no credential and the bridge would answer "authentication
504
471
  // failed". Empty until the daemon has provisioned one; the page reloads once it has.
505
472
  define: {
506
473
  ...(config.define ?? {}),
@@ -598,29 +565,32 @@ export function reticle(options = {}) {
598
565
  * Serve the connect module fresh, every time.
599
566
  *
600
567
  * `load` reads the daemon's pairing token at serve time precisely because the daemon may start
601
- * after the dev server — but Vite caches the module it produced, and answers every later request
602
- * from that cache, INCLUDING after a full page reload. So a dev server started first served a
603
- * tokenless connect module once and then kept serving it: the SDK got a 1008 `authentication
604
- * failed`, stopped retrying (correctly — a wrong token does not fix itself), and `reticle status`
605
- * showed no session while the page demonstrably contained `/@reticle-connect`. Only restarting
606
- * the dev server cleared it, which is not a step anybody guesses.
568
+ * after the dev server — but Vite caches the module it produced and answers every later request
569
+ * from that cache, INCLUDING after a full page reload. A dev server started first therefore keeps
570
+ * serving a tokenless connect module: the SDK gets a 1008 `authentication failed` and stops
571
+ * retrying (correctly — a wrong token does not fix itself), so `reticle status` shows no session
572
+ * while the page demonstrably contains `/@reticle-connect`, and only a dev-server restart clears
573
+ * it.
607
574
  *
608
575
  * Dropping the cached module before it is served makes `load` re-read the token, so starting the
609
576
  * daemon and reloading the page is enough.
610
577
  *
611
- * Only when the source would ACTUALLY differ, though. This used to invalidate on every request
612
- * for the module, forever and a module that is force-invalidated on every request is
613
- * re-resolved against Vite's dep optimizer on every page load, which is the shape of a
614
- * self-sustaining reload loop: reload request invalidate re-resolve reload. Reported
615
- * from the field on a Vite + React Router app pinned to a non-default port: every route
616
- * reloaded the whole page about once a second, `/@reticle-connect` was fetched in every cycle,
617
- * and removing the plugin stopped it instantly. Comparing the source first costs one string
618
- * compare, keeps the late-daemon fix intact (the token appearing IS a change), and makes the
619
- * module inert once it has settled.
578
+ * Only when the source would ACTUALLY differ, though. A module force-invalidated on every
579
+ * request is re-resolved against Vite's dep optimizer on every page load, which is a
580
+ * self-sustaining reload loop: reload request invalidate re-resolve reload. Comparing
581
+ * the source first costs one string compare, keeps the late-daemon fix intact (the token
582
+ * appearing IS a change), and makes the module inert once it has settled.
620
583
  */
621
584
  configureServer(server) {
622
585
  if (!inject)
623
586
  return;
587
+ // The web post-condition is armed by the first DOCUMENT REQUEST, in the middleware below.
588
+ //
589
+ // Not from `transformIndexHtml`, because a framework that renders its own HTML never calls it
590
+ // and the check would be unreachable in the one case it exists for. But not from boot either:
591
+ // that fires whether or not anybody has opened the app, and tells a healthy project it will
592
+ // never connect. The request is the earliest moment the plugin knows enough to have an
593
+ // opinion.
624
594
  // Tell `~/.reticle` this dev server exists, the moment it is actually listening.
625
595
  //
626
596
  // This is the one fact nobody outside this process could observe: the plugin is loaded in the
@@ -666,6 +636,11 @@ export function reticle(options = {}) {
666
636
  announce();
667
637
  }
668
638
  server.middlewares.use((req, _res, next) => {
639
+ // One middleware, two observations. A second `use()` would work equally well in Vite and
640
+ // is the obvious way to write this, but it makes the ORDER of registration load-bearing for
641
+ // anything that records a single handler — so both live here instead.
642
+ if (!desktop && watch.isDocumentRequest(req))
643
+ watch.noteHtmlRequest();
669
644
  // Matched against BOTH forms: plugin middlewares run ahead of Vite's own base
670
645
  // middleware, so the request still carries `base` here, while a middleware-mode host may
671
646
  // have stripped it already.
@@ -693,15 +668,13 @@ export function reticle(options = {}) {
693
668
  return;
694
669
  throw new Error(notInjectedMessage());
695
670
  },
696
- checkInjectedForTest: checkInjected,
671
+ checkInjectedForTest: watch.checkInjected,
672
+ checkHtmlHookForTest: watch.checkHtmlHookRan,
673
+ injectionWatchForTest: watch,
697
674
  transformIndexHtml() {
698
- // In serve, the HTML is sent BEFORE the browser requests the entry module, so the check has to
699
- // be deferred asserting here would fire on every healthy start. Unref'd so a dev server is
700
- // never held open by it.
701
- if (desktop && inject && 'serve' === command) {
702
- const timer = setTimeout(checkInjected, DEV_INJECTION_GRACE_MS);
703
- timer.unref?.();
704
- }
675
+ htmlTransformed = true;
676
+ if (desktop && inject && 'serve' === command)
677
+ watch.armDesktopCheck();
705
678
  // Desktop injects via the entry module instead (see transform) — a tag here would be a dead
706
679
  // URL in a packaged build. A Vitest run gets nothing unless `inject: true` says otherwise —
707
680
  // see isVitestBrowserServer.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The check that says whether `reticle.connect()` actually made it into the page, and what to say
3
+ * when it did not.
4
+ *
5
+ * These three messages live together because choosing between them is the whole subtlety. Each says
6
+ * exactly as much as the plugin actually knows and no more, and getting that wrong in either
7
+ * direction has cost real users:
8
+ *
9
+ * - Claim "this is broken" when it is not, and the tool whose entire pitch is that it does not
10
+ * raise false alarms has just raised one.
11
+ * - Say nothing when it IS broken, and someone waits twenty minutes for a session that can never
12
+ * arrive.
13
+ */
14
+ /**
15
+ * How long to wait before concluding that the connect script never made it in.
16
+ *
17
+ * Generous on purpose: the browser has to request the entry, and a cold dev server transforming a
18
+ * large app can take a moment. A false warning would train people to ignore a real one.
19
+ */
20
+ export declare const DEV_INJECTION_GRACE_MS = 10000;
21
+ /**
22
+ * The BUILD message. A build always runs every transform, so "my transform never ran" and "the
23
+ * bundle has no connect()" are the same statement there, and stating it as a certainty is correct.
24
+ */
25
+ export declare const notInjectedMessage: () => string;
26
+ /**
27
+ * The DEV message, which must be weaker — and this is the whole reason the two are separate.
28
+ *
29
+ * In serve, `injected` records "my transform ran THIS session", which is not the same as "the app
30
+ * has no connect()". Vite serves an unchanged module straight from its transform cache, so on a
31
+ * warm cache the transform never runs and the flag stays false, so wording that announces the app
32
+ * "will never connect" is a false alarm raised over a served entry that does contain the injection.
33
+ *
34
+ * So dev reports what it actually knows: unconfirmed, with the benign explanation first.
35
+ */
36
+ export declare const unconfirmedInjectionMessage: () => string;
37
+ /**
38
+ * The web message.
39
+ *
40
+ * On the web the connect script is added by `transformIndexHtml`. A framework that renders its own
41
+ * HTML never calls that hook, so the script is never added and the app never appears in
42
+ * `reticle status`. Known examples: SvelteKit, Nuxt, Astro, React Router in framework mode and
43
+ * TanStack Start. Each was found the same way — a user waiting on a session that could never
44
+ * arrive — so the plain-language cause comes first and the fix comes second.
45
+ */
46
+ export declare const htmlHookNeverRanMessage: () => string;
47
+ /**
48
+ * The state behind those three messages, and the rules for reaching each one.
49
+ *
50
+ * Beside the wording rather than in the plugin factory: choosing BETWEEN these messages is the whole
51
+ * subtlety, the flags are only ever read by these four functions, and every defect in this area has
52
+ * been a rule about when to speak rather than a bug in the speaking.
53
+ */
54
+ export interface InjectionWatchDeps {
55
+ /** Desktop builds keep the certainty; web dev does not. See the two messages above. */
56
+ readonly desktop: boolean;
57
+ /** `inject: false` means the user wires connect() themselves; the plugin has no opinion then. */
58
+ readonly inject: boolean;
59
+ /** Whether the desktop entry module was transformed. Read late: it flips during the session. */
60
+ readonly injected: () => boolean;
61
+ /** Whether Vite ever asked us to transform the app's HTML. Read late, for the same reason. */
62
+ readonly htmlTransformed: () => boolean;
63
+ readonly warn: (message: string) => void;
64
+ /** Injected so a test does not wait ten real seconds. Defaults to the module-level timer. */
65
+ readonly schedule?: (run: () => void, ms: number) => void;
66
+ }
67
+ export interface InjectionWatch {
68
+ /** Record a browser asking for a page, and start the grace period from there. */
69
+ noteHtmlRequest: () => void;
70
+ /** A navigation, as opposed to the module and asset fetches Vite serves constantly. */
71
+ isDocumentRequest: (req: {
72
+ headers?: {
73
+ accept?: string | undefined;
74
+ } | undefined;
75
+ }) => boolean;
76
+ checkHtmlHookRan: () => void;
77
+ checkInjected: () => void;
78
+ /** Defer the desktop check past the HTML response, which lands before the entry module. */
79
+ armDesktopCheck: () => void;
80
+ }
81
+ export declare function createInjectionWatch(deps: InjectionWatchDeps): InjectionWatch;