@reticlehq/vite-plugin 2.13.1 → 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.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.
@@ -36,6 +38,33 @@ const NODE_MODULES = 'node_modules';
36
38
  * by the injected <script src> and served by the load hook below.
37
39
  */
38
40
  export const RETICLE_CONNECT_MODULE = '/@reticle-connect';
41
+ /**
42
+ * The URL the injected `<script src>` must actually point at: `base` + the module id.
43
+ *
44
+ * {@link RETICLE_CONNECT_MODULE} is a SERVER-ROOT path, and emitting it verbatim is only correct
45
+ * when Vite is serving from the root. Under `base: '/playground/'` the browser asked for
46
+ * `/@reticle-connect`, Vite answered 404 with its own "did you mean /playground/@reticle-connect"
47
+ * hint, and the page rendered perfectly while never connecting (#676) — the exact failure shape
48
+ * Reticle exists to catch, in Reticle's own setup path.
49
+ *
50
+ * Vite does not prefix tags returned from `transformIndexHtml`, so the prefix has to be applied
51
+ * here. Only a path base is joined: Vite serves the dev app from the root when `base` is an
52
+ * external URL, so prefixing a CDN origin onto a dev-server module would point the tag off-host.
53
+ */
54
+ import { mergeIgnored } from './watch-ignore.js';
55
+ import { isVitestBrowserServer } from './vitest-browser.js';
56
+ export function connectModuleUrl(base) {
57
+ if (undefined === base || !base.startsWith('/'))
58
+ return RETICLE_CONNECT_MODULE;
59
+ // Trimmed by slicing rather than with `/\/+$/`: a trailing-slash-run regex is a polynomial
60
+ // backtracking shape over a value that comes out of the user's config, and CodeQL is right to
61
+ // flag it. This is linear and says the same thing.
62
+ let end = base.length;
63
+ while (0 < end && '/' === base[end - 1])
64
+ end -= 1;
65
+ const trimmed = base.slice(0, end);
66
+ return 0 === trimmed.length ? RETICLE_CONNECT_MODULE : `${trimmed}${RETICLE_CONNECT_MODULE}`;
67
+ }
39
68
  /**
40
69
  * The pre-hook, as source for an inline <head> script.
41
70
  *
@@ -57,22 +86,14 @@ onScheduleFiberRoot:function(){},onCommitFiberRoot:fire,onPostCommitFiberRoot:fu
57
86
  }else{var prev=h.onCommitFiberRoot;h.onCommitFiberRoot=function(){try{fire.apply(null,arguments);}catch(e){}
58
87
  if(typeof prev==='function')return prev.apply(this,arguments);};}
59
88
  }catch(e){}})();`;
60
- /**
61
- * How long after serving the HTML to wait before concluding the entry was never injected.
62
- *
63
- * Generous on purpose: the browser has to request the entry, and a cold dev server transforming a
64
- * large app can take a moment. A false warning would train people to ignore a real one.
65
- */
66
- const DEV_INJECTION_GRACE_MS = 10_000;
67
89
  /**
68
90
  * How many times the connect module's source may legitimately change in one dev-server session
69
91
  * before the plugin says so.
70
92
  *
71
93
  * The source is a function of the port, the projectId, the pairing token and whether the app has a
72
- * `reticle-dev` module. In a healthy session that settles almost immediately: the daemon starting
73
- * after Vite is one change, a dev module being created is another. Anything past a handful means
74
- * an input is oscillating, and an oscillating connect module is what makes Vite re-resolve it on
75
- * 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.
76
97
  */
77
98
  const CONNECT_CHURN_LIMIT = 5;
78
99
  /**
@@ -207,6 +228,16 @@ function connectArgs(options) {
207
228
  if (true === options.captureNetworkBodies || '1' === process.env['VITE_RETICLE_CAPTURE_BODIES']) {
208
229
  args['captureNetworkBodies'] = true;
209
230
  }
231
+ // Only the OPT-OUT is announced, never the default. The daemon reads absence as "unknown", so
232
+ // sending `true` here would say nothing it does not already assume — while sending `false` is the
233
+ // one fact a red verdict needs to stop prescribing a plugin this project has deliberately muted.
234
+ if (false === options.sourceMapping)
235
+ args['sourceMapping'] = false;
236
+ // The one option that defaults ON, so the env var and the config flag both DISABLE rather than
237
+ // enable. Emitted only when switched off; the default stays implicit in the SDK.
238
+ if (false === options.captureErrorBodies || '1' === process.env['VITE_RETICLE_NO_ERROR_BODIES']) {
239
+ args['captureErrorBodies'] = false;
240
+ }
210
241
  // Same shape, same reason. Off unless asked for, in a config or for one session.
211
242
  if (true === options.exposePresenter || '1' === process.env['VITE_RETICLE_EXPOSE_PRESENTER']) {
212
243
  args['exposePresenter'] = true;
@@ -223,10 +254,9 @@ function connectArgs(options) {
223
254
  /**
224
255
  * The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
225
256
  *
226
- * It is imported by CONVENTION rather than by patching the app's entry file. The connect is injected
227
- * into a virtual module, so there is nowhere for a user to add these calls without `init` editing
228
- * `src/main.tsx` — an edit to the file people actually own, for something that is opt-in enrichment.
229
- * 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`.
230
260
  */
231
261
  export const RETICLE_DEV_MODULE_CANDIDATES = [
232
262
  'src/reticle-dev.ts',
@@ -245,14 +275,11 @@ export function findDevModule(root, exists) {
245
275
  /**
246
276
  * Which SDK package this app actually has, and whether `install()` applies.
247
277
  *
248
- * 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
249
279
  * app and fatal for any other: `reticle init` gives a Vue or Svelte codebase the framework-neutral
250
280
  * `@reticlehq/browser` — deliberately, because a package named `@reticlehq/react` with `react` in
251
- * its peers has no business in a Vue app — and the injected import then names a package that is not
252
- * installed, so nothing connects and the page reports no session with no obvious cause.
253
- *
254
- * Measured end to end on a pristine `npm create vite --template vue` app: init wrote every file
255
- * 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.
256
283
  *
257
284
  * The React kit WINS when both resolve: it is a superset (it re-exports the sensor and adds the
258
285
  * adapter), so an app that has it wants component identity. `install()` is the adapter's alone and
@@ -344,9 +371,21 @@ export function reticle(options = {}) {
344
371
  let root;
345
372
  /** 'serve' | 'build'. The dev check only applies to serve; buildEnd covers the other. */
346
373
  let command;
374
+ /** Vite's resolved `base`. Undefined until configResolved, which is before any HTML is served. */
375
+ let base;
376
+ /** True only when THIS server is Vitest's browser-mode runner — see isVitestBrowserServer. */
377
+ let vitestBrowser = false;
347
378
  const warn = options.onWarn ?? ((message) => globalThis.console.warn(message));
348
379
  /** Whether connect() actually reached a module — asserted at buildEnd, never assumed. */
349
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;
350
389
  /**
351
390
  * Resolve port + token at the moment of injection, not at plugin construction. By the time a
352
391
  * module is served or built the daemon is up and has written its pairing token; resolving early
@@ -374,37 +413,17 @@ export function reticle(options = {}) {
374
413
  /** How many times the served source has actually changed. See connectChurnWarning. */
375
414
  let connectChanges = 0;
376
415
  /**
377
- * The BUILD message. A build always runs every transform, so "my transform never ran" and "the
378
- * bundle has no connect()" are the same statement there, and stating it as a certainty is correct.
379
- */
380
- const notInjectedMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not inject reticle.connect(): the HTML entry module was ` +
381
- 'never matched, so this app carries no instrumentation and will never connect. Check that ' +
382
- 'index.html references your entry with a <script type="module" src="...">, or pass ' +
383
- '`inject: false` and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself. The plugin ' +
384
- 'still inlines that define; a connect without it is refused.';
385
- /**
386
- * The DEV message, which must be weaker — and this is the whole reason the two are separate.
387
- *
388
- * In serve, `injected` records "my transform ran THIS session", which is not the same as "the app
389
- * has no connect()". Vite serves an unchanged module straight from its transform cache, so on a
390
- * warm cache the transform never runs, the flag stays false, and the old wording announced that
391
- * the app "will never connect" while the served entry demonstrably contained the injection —
392
- * verified by fetching it from the dev server. A false alarm, in the tool whose entire argument is
393
- * that it does not raise them.
394
- *
395
- * So dev reports what it actually knows: unconfirmed, with the benign explanation first.
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.
396
419
  */
397
- const unconfirmedInjectionMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not confirm reticle.connect() was injected: the HTML entry ` +
398
- 'module was not transformed this session. That is expected when Vite served it from its ' +
399
- 'transform cache. If the app does not appear in `reticle status`, restart the dev server with ' +
400
- '`--force` to bypass the cache, then check that index.html references your entry with a ' +
401
- '<script type="module" src="...">.';
402
- /** Warn (never throw) in dev — a running dev server should report the doubt, not die of it. */
403
- const checkInjected = () => {
404
- if (!desktop || !inject || injected)
405
- return;
406
- warn(unconfirmedInjectionMessage());
407
- };
420
+ const watch = createInjectionWatch({
421
+ desktop,
422
+ inject,
423
+ injected: () => injected,
424
+ htmlTransformed: () => htmlTransformed,
425
+ warn,
426
+ });
408
427
  return {
409
428
  name: RETICLE_VITE_PLUGIN_NAME,
410
429
  // Web: serve-only, so a production bundle can never carry the SDK — gating is the tool's job.
@@ -415,51 +434,40 @@ export function reticle(options = {}) {
415
434
  /**
416
435
  * Declare the SDK itself and the optimizer cache fingerprint.
417
436
  *
418
- * The browser SDK used to need extra CJS query-engine deps here. It no longer imports that
419
- * second accessibility engine, so keeping those names would make Vite pre-bundle packages the
420
- * 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.
421
440
  */
422
441
  config(config) {
423
442
  // Everything below asks what the APP has installed, so every lookup is rooted here and never
424
- // 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.
425
444
  const appRoot = config.root ?? process.cwd();
426
445
  const optimizerKey = optimizerOptionsKey(viteMajor(appRoot));
427
446
  return {
428
447
  // Keep the daemon's journal out of the dev server's watcher.
429
448
  //
430
449
  // The daemon writes `.reticle/` into the PROJECT root — session journals, and `ambient.json`
431
- // rewritten atomically as `ambient.json.tmp` + rename on a live session. Vite watches the
432
- // project root and does not ignore that directory, so every journal write read as a project
433
- // file changing and Vite answered with a full page reload.
434
- //
435
- // That is a loop with no exit: page loads -> SDK connects and streams events -> daemon
436
- // journals them -> Vite reloads the page -> SDK reconnects -> more events. It ran several
437
- // times a second for as long as the dev server was up, and the damage was total but
438
- // misattributed: every ref went stale, every act_and_wait died mid-flight, and the log
439
- // filled with connect/disconnect pairs that looked like a flapping SDK rather than a
440
- // watcher chasing its own tail.
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.
441
454
  //
442
- // A RegExp, not a glob, and that is the whole difference between this working and not.
443
- // chokidar dropped glob support in v4 Vite 7+ ships v4/v5, where a pattern like
444
- // `**/.reticle/**` is silently accepted and matches nothing. MEASURED against the chokidar
445
- // this repo resolves: with the glob, a write to `.reticle/ambient.json` still fires; with
446
- // this RegExp it does not, while a normal file still does. Vite's own defaults are globs and
447
- // 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.
448
458
  //
449
459
  // Anchored on `^` or a separator so it matches the directory and not a file that merely ends
450
460
  // in those characters, and both separators are accepted because chokidar reports the path in
451
- // the platform's own form.
452
- //
453
- // 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.
454
462
  server: {
455
463
  watch: {
456
- ignored: [...(config.server?.watch?.ignored ?? []), JOURNAL_IGNORE],
464
+ ignored: mergeIgnored(config.server?.watch?.ignored, JOURNAL_IGNORE),
457
465
  },
458
466
  },
459
467
  // Expose the daemon's pairing token to hand-written connects in the same Vite app. The
460
468
  // plugin's own injected connect gets the token directly, but a connect the USER writes —
461
- // SvelteKit's client hook, a custom entry — had no way to reach a file only Node can read,
462
- // 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
463
471
  // failed". Empty until the daemon has provisioned one; the page reloads once it has.
464
472
  define: {
465
473
  ...(config.define ?? {}),
@@ -550,34 +558,39 @@ export function reticle(options = {}) {
550
558
  configResolved(config) {
551
559
  root = config.root;
552
560
  command = config.command;
561
+ base = config.base;
562
+ vitestBrowser = isVitestBrowserServer(config);
553
563
  },
554
564
  /**
555
565
  * Serve the connect module fresh, every time.
556
566
  *
557
567
  * `load` reads the daemon's pairing token at serve time precisely because the daemon may start
558
- * after the dev server — but Vite caches the module it produced, and answers every later request
559
- * from that cache, INCLUDING after a full page reload. So a dev server started first served a
560
- * tokenless connect module once and then kept serving it: the SDK got a 1008 `authentication
561
- * failed`, stopped retrying (correctly — a wrong token does not fix itself), and `reticle status`
562
- * showed no session while the page demonstrably contained `/@reticle-connect`. Only restarting
563
- * 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.
564
574
  *
565
575
  * Dropping the cached module before it is served makes `load` re-read the token, so starting the
566
576
  * daemon and reloading the page is enough.
567
577
  *
568
- * Only when the source would ACTUALLY differ, though. This used to invalidate on every request
569
- * for the module, forever and a module that is force-invalidated on every request is
570
- * re-resolved against Vite's dep optimizer on every page load, which is the shape of a
571
- * self-sustaining reload loop: reload request invalidate re-resolve reload. Reported
572
- * from the field on a Vite + React Router app pinned to a non-default port: every route
573
- * reloaded the whole page about once a second, `/@reticle-connect` was fetched in every cycle,
574
- * and removing the plugin stopped it instantly. Comparing the source first costs one string
575
- * compare, keeps the late-daemon fix intact (the token appearing IS a change), and makes the
576
- * 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.
577
583
  */
578
584
  configureServer(server) {
579
585
  if (!inject)
580
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.
581
594
  // Tell `~/.reticle` this dev server exists, the moment it is actually listening.
582
595
  //
583
596
  // This is the one fact nobody outside this process could observe: the plugin is loaded in the
@@ -623,7 +636,16 @@ export function reticle(options = {}) {
623
636
  announce();
624
637
  }
625
638
  server.middlewares.use((req, _res, next) => {
626
- if ((req.url ?? '').split('?')[0] === RETICLE_CONNECT_MODULE) {
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();
644
+ // Matched against BOTH forms: plugin middlewares run ahead of Vite's own base
645
+ // middleware, so the request still carries `base` here, while a middleware-mode host may
646
+ // have stripped it already.
647
+ const requestPath = (req.url ?? '').split('?')[0];
648
+ if (requestPath === RETICLE_CONNECT_MODULE || requestPath === connectModuleUrl(base)) {
627
649
  if (currentConnectSource() !== lastServedConnectSource) {
628
650
  connectChanges++;
629
651
  if (CONNECT_CHURN_LIMIT === connectChanges)
@@ -646,19 +668,20 @@ export function reticle(options = {}) {
646
668
  return;
647
669
  throw new Error(notInjectedMessage());
648
670
  },
649
- checkInjectedForTest: checkInjected,
671
+ checkInjectedForTest: watch.checkInjected,
672
+ checkHtmlHookForTest: watch.checkHtmlHookRan,
673
+ injectionWatchForTest: watch,
650
674
  transformIndexHtml() {
651
- // In serve, the HTML is sent BEFORE the browser requests the entry module, so the check has to
652
- // be deferred asserting here would fire on every healthy start. Unref'd so a dev server is
653
- // never held open by it.
654
- if (desktop && inject && 'serve' === command) {
655
- const timer = setTimeout(checkInjected, DEV_INJECTION_GRACE_MS);
656
- timer.unref?.();
657
- }
675
+ htmlTransformed = true;
676
+ if (desktop && inject && 'serve' === command)
677
+ watch.armDesktopCheck();
658
678
  // Desktop injects via the entry module instead (see transform) — a tag here would be a dead
659
- // URL in a packaged build.
679
+ // URL in a packaged build. A Vitest run gets nothing unless `inject: true` says otherwise —
680
+ // see isVitestBrowserServer.
660
681
  if (!inject || desktop)
661
682
  return [];
683
+ if (true !== options.inject && vitestBrowser)
684
+ return [];
662
685
  return [
663
686
  // A CLASSIC inline script in <head>, and it has to be both.
664
687
  //
@@ -669,7 +692,7 @@ export function reticle(options = {}) {
669
692
  // `renderers.size === 0`, so the render meter counted zero forever while the docs advertised
670
693
  // commit counts. This runs during parse, before any module, and the meter adopts its buffer.
671
694
  { tag: 'script', children: RENDER_PREHOOK_SOURCE, injectTo: 'head-prepend' },
672
- { tag: 'script', attrs: { type: 'module', src: RETICLE_CONNECT_MODULE }, injectTo: 'body' },
695
+ { tag: 'script', attrs: { type: 'module', src: connectModuleUrl(base) }, injectTo: 'body' },
673
696
  ];
674
697
  },
675
698
  };
@@ -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;
@@ -0,0 +1,123 @@
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
+ import { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
15
+ /**
16
+ * How long to wait before concluding that the connect script never made it in.
17
+ *
18
+ * Generous on purpose: the browser has to request the entry, and a cold dev server transforming a
19
+ * large app can take a moment. A false warning would train people to ignore a real one.
20
+ */
21
+ export const DEV_INJECTION_GRACE_MS = 10_000;
22
+ /**
23
+ * The BUILD message. A build always runs every transform, so "my transform never ran" and "the
24
+ * bundle has no connect()" are the same statement there, and stating it as a certainty is correct.
25
+ */
26
+ export const notInjectedMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not inject reticle.connect(): the HTML entry module was ` +
27
+ 'never matched, so this app carries no instrumentation and will never connect. Check that ' +
28
+ 'index.html references your entry with a <script type="module" src="...">, or pass ' +
29
+ '`inject: false` and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself. The plugin ' +
30
+ 'still inlines that define; a connect without it is refused.';
31
+ /**
32
+ * The DEV message, which must be weaker — and this is the whole reason the two are separate.
33
+ *
34
+ * In serve, `injected` records "my transform ran THIS session", which is not the same as "the app
35
+ * has no connect()". Vite serves an unchanged module straight from its transform cache, so on a
36
+ * warm cache the transform never runs and the flag stays false, so wording that announces the app
37
+ * "will never connect" is a false alarm raised over a served entry that does contain the injection.
38
+ *
39
+ * So dev reports what it actually knows: unconfirmed, with the benign explanation first.
40
+ */
41
+ export const unconfirmedInjectionMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not confirm reticle.connect() was injected: the HTML entry ` +
42
+ 'module was not transformed this session. That is expected when Vite served it from its ' +
43
+ 'transform cache. If the app does not appear in `reticle status`, restart the dev server with ' +
44
+ '`--force` to bypass the cache, then check that index.html references your entry with a ' +
45
+ '<script type="module" src="...">.';
46
+ /**
47
+ * The web message.
48
+ *
49
+ * On the web the connect script is added by `transformIndexHtml`. A framework that renders its own
50
+ * HTML never calls that hook, so the script is never added and the app never appears in
51
+ * `reticle status`. Known examples: SvelteKit, Nuxt, Astro, React Router in framework mode and
52
+ * TanStack Start. Each was found the same way — a user waiting on a session that could never
53
+ * arrive — so the plain-language cause comes first and the fix comes second.
54
+ */
55
+ export const htmlHookNeverRanMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] this app will never connect: the dev server never asked this ` +
56
+ 'plugin to transform any HTML, so reticle.connect() was never added to the page. That usually ' +
57
+ 'means your framework renders its own HTML instead of serving index.html — SvelteKit, Nuxt, ' +
58
+ 'Astro, React Router (framework mode) and TanStack Start all do. Fix: import ' +
59
+ "'@reticlehq/browser' and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself from your " +
60
+ 'app entry file, and pass `inject: false` to this plugin so the two do not both try.';
61
+ const defaultSchedule = (run, ms) => {
62
+ const timer = setTimeout(run, ms);
63
+ // Never hold a dev server open on account of a warning it may not even need to print.
64
+ timer.unref?.();
65
+ };
66
+ export function createInjectionWatch(deps) {
67
+ /**
68
+ * Has a browser actually asked this dev server for a PAGE?
69
+ *
70
+ * The fact that makes "the HTML hook never ran" mean anything. `transformIndexHtml` only runs when
71
+ * a document is requested, so on a dev server nobody has opened it has correctly never run — and
72
+ * a check that cannot tell that apart from a framework owning its own HTML will call a perfectly
73
+ * healthy app permanently broken. That is what it did: the timer was armed at server boot, so ten
74
+ * seconds after startup an unopened dev server was told its app would never connect.
75
+ */
76
+ let htmlRequested = false;
77
+ const isDocumentRequest = (req) => true === req.headers?.accept?.includes('text/html');
78
+ /**
79
+ * Warn when the HTML hook never ran. NOT scheduled from `transformIndexHtml` — a hook that never
80
+ * runs would never arm its own check, and it would be unreachable in exactly the case it exists
81
+ * for. It hangs off the first request instead: independent of the hook, but still evidence-based.
82
+ *
83
+ * `htmlRequested` is re-checked here rather than only at the arming site. The timer is one caller;
84
+ * this states the precondition where the claim is actually made, which is where somebody reading
85
+ * `warn(htmlHookNeverRanMessage())` needs to see it.
86
+ */
87
+ const checkHtmlHookRan = () => {
88
+ if (deps.desktop || !deps.inject || deps.htmlTransformed())
89
+ return;
90
+ // Nobody has opened the app. That says nothing about whether it can connect.
91
+ if (!htmlRequested)
92
+ return;
93
+ deps.warn(htmlHookNeverRanMessage());
94
+ };
95
+ /**
96
+ * Note a document request, and start the clock from THERE.
97
+ *
98
+ * The grace period is meant to cover "the browser asked, so the transform should have happened by
99
+ * now". Measured from server start it was covering "the server booted", which is a question about
100
+ * the developer's attention rather than about the app.
101
+ */
102
+ const noteHtmlRequest = () => {
103
+ if (htmlRequested)
104
+ return;
105
+ htmlRequested = true;
106
+ (deps.schedule ?? defaultSchedule)(checkHtmlHookRan, DEV_INJECTION_GRACE_MS);
107
+ };
108
+ /** Warn (never throw) in dev — a running dev server should report the doubt, not die of it. */
109
+ const checkInjected = () => {
110
+ if (!deps.desktop || !deps.inject || deps.injected())
111
+ return;
112
+ deps.warn(unconfirmedInjectionMessage());
113
+ };
114
+ /**
115
+ * In serve the HTML is sent BEFORE the browser requests the entry module, so asserting at html
116
+ * time would fire on every healthy start. Deferred here rather than at the call site, so that
117
+ * every "when may this speak" rule in the plugin sits in the one file that argues about them.
118
+ */
119
+ const armDesktopCheck = () => {
120
+ (deps.schedule ?? defaultSchedule)(checkInjected, DEV_INJECTION_GRACE_MS);
121
+ };
122
+ return { noteHtmlRequest, isDocumentRequest, checkHtmlHookRan, checkInjected, armDesktopCheck };
123
+ }
@@ -56,7 +56,7 @@ export declare const OPTIMIZER_OPTIONS_KEY: {
56
56
  readonly ESBUILD: "esbuildOptions";
57
57
  readonly ROLLDOWN: "rolldownOptions";
58
58
  };
59
- export type OptimizerOptionsKey = (typeof OPTIMIZER_OPTIONS_KEY)[keyof typeof OPTIMIZER_OPTIONS_KEY];
59
+ type OptimizerOptionsKey = (typeof OPTIMIZER_OPTIONS_KEY)[keyof typeof OPTIMIZER_OPTIONS_KEY];
60
60
  /**
61
61
  * Which key carries optimizer options on this Vite.
62
62
  *
@@ -84,3 +84,4 @@ export declare function optimizerOptionsKey(major: number | null): OptimizerOpti
84
84
  * app's own, it meant something, and passing it through unchanged is what produced the warning.
85
85
  */
86
86
  export declare function optimizerOptions(key: OptimizerOptionsKey, inherited: Record<string, unknown>, add: Record<string, string>): Record<string, unknown>;
87
+ export {};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The name Vite shows for this plugin, and the prefix on every message it prints.
3
+ *
4
+ * It lives in its own file so the message builders can use it without importing the plugin itself,
5
+ * which would be a circular import.
6
+ */
7
+ export declare const RETICLE_VITE_PLUGIN_NAME = "reticle";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The name Vite shows for this plugin, and the prefix on every message it prints.
3
+ *
4
+ * It lives in its own file so the message builders can use it without importing the plugin itself,
5
+ * which would be a circular import.
6
+ */
7
+ export const RETICLE_VITE_PLUGIN_NAME = 'reticle';