@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.cjs +581 -463
- package/dist/index.d.cts +85 -44
- package/dist/index.d.ts +85 -44
- package/dist/index.js +129 -106
- package/dist/injection-postcondition.d.ts +81 -0
- package/dist/injection-postcondition.js +123 -0
- package/dist/installed.d.ts +2 -1
- package/dist/plugin-name.d.ts +7 -0
- package/dist/plugin-name.js +7 -0
- package/dist/project-id.d.ts +24 -6
- package/dist/project-id.js +67 -22
- package/dist/vitest-browser.d.ts +31 -0
- package/dist/vitest-browser.js +37 -0
- package/dist/watch-ignore.d.ts +18 -0
- package/dist/watch-ignore.js +26 -0
- package/package.json +7 -6
- /package/dist/{ensure-token.d.ts → token/ensure-token.d.ts} +0 -0
- /package/dist/{ensure-token.js → token/ensure-token.js} +0 -0
- /package/dist/{missing-token.d.ts → token/missing-token.d.ts} +0 -0
- /package/dist/{missing-token.js → token/missing-token.js} +0 -0
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
|
-
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
378
|
-
*
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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
|
|
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.
|
|
432
|
-
//
|
|
433
|
-
//
|
|
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,
|
|
443
|
-
//
|
|
444
|
-
//
|
|
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:
|
|
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 —
|
|
462
|
-
//
|
|
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
|
|
559
|
-
* from that cache, INCLUDING after a full page reload.
|
|
560
|
-
* tokenless connect module
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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.
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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:
|
|
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
|
+
}
|
package/dist/installed.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export declare const OPTIMIZER_OPTIONS_KEY: {
|
|
|
56
56
|
readonly ESBUILD: "esbuildOptions";
|
|
57
57
|
readonly ROLLDOWN: "rolldownOptions";
|
|
58
58
|
};
|
|
59
|
-
|
|
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';
|