@reticlehq/vite-plugin 3.3.0 → 3.5.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  One-line Vite integration for [Reticle](https://github.com/reticlehq/reticle). The plugin does the whole dev-time wiring for you:
4
4
 
5
- - **Source mapping**: stamps `data-reticle-source="file:line:col"` on JSX host elements (via [`@reticlehq/babel-plugin`](https://www.npmjs.com/package/@reticlehq/babel-plugin)) so `reticle_inspect` can report the component's source file. Needed on React 19.
5
+ - **Source mapping**: stamps `data-reticle-source="file:line:col"` on JSX host elements (via [`@reticlehq/babel-plugin`](https://www.npmjs.com/package/@reticlehq/babel-plugin)) so `reticle_look { action: "element" }` can report the component's source file. Needed on React 19.
6
6
  - **Auto-connect**: injects a dev-only `install(); reticle.connect()` into your entry module, so you never touch the entry file yourself.
7
7
  - **Svelte source mapping**: the same stamp on Svelte markup, applied before `@sveltejs/vite-plugin-svelte` compiles it.
8
8
  - **Dependency pre-bundling**: declares the SDK's CJS runtime deps in `optimizeDeps` so the SDK loads on linked and monorepo setups.
@@ -45,9 +45,9 @@ reticle({
45
45
  sdkVersion, // installed SDK version, so a skewed pair can name itself
46
46
  sourceMapping, // default true; stamp data-reticle-source (harmless on React <=18)
47
47
  inject, // default true; auto-inject reticle.connect()
48
- captureNetworkBodies, // default false; record request/response bodies on reticle_network
48
+ captureNetworkBodies, // default false; record request/response bodies on reticle_observe { action: "network" }
49
49
  allowNonLocalhost, // default false; allow a page/bridge that is not on localhost (needs a token)
50
- desktop, // default false; also apply to `vite build`, for an Electron/Tauri renderer
50
+ desktop, // default false; for an Electron/Tauri renderer (see below)
51
51
  onWarn, // where a diagnostic goes; defaults to the console
52
52
  });
53
53
  ```
@@ -56,7 +56,7 @@ reticle({
56
56
 
57
57
  `allowNonLocalhost` is for a dev server that cannot be served on localhost — a host-based multi-tenant frontend, or an app with cookie-scoped auth on a custom dev hostname. It is **not sufficient on its own**: the SDK also requires a pairing token outside localhost, and refuses with "a pairing token is required outside localhost" when it is missing. The plugin supplies one automatically from the daemon's `~/.reticle/pairing-token`, so a running daemon is normally all it takes — pass `token` yourself only when that file is unreachable. A bridge that is itself non-local must also use `wss://`. Settable as `VITE_RETICLE_ALLOW_NON_LOCALHOST=1` for a single session.
58
58
 
59
- `desktop: true` makes the plugin apply to `vite build` as well and calls `connect()` with `allowInProduction`, because a packaged desktop renderer is a production build with no dev server. That means an instrumented production bundle, which a web app must never ship. Keep it behind your own dev-only build target.
59
+ `desktop: true` injects `connect()` into the renderer's entry module and calls it with `allowInProduction`, because a desktop renderer reports `NODE_ENV=production`. It instruments the dev server, and a `vite build` only when that build runs in a non-production mode (`vite build --mode development`), for a packaged renderer you drive with no dev server. A production-mode build, which is what `vite build` and `electron-vite build` do by default, ships no Reticle code at all, exactly like a web build.
60
60
 
61
61
  ### Opting one file out of source stamping
62
62
 
@@ -1,3 +1,15 @@
1
1
  export declare const RETICLE_DISABLED_STUB = "\0reticle-disabled-stub";
2
- export declare const isReticleDisabledWebBuild: (desktop: boolean, command: string | undefined) => boolean;
2
+ /**
3
+ * Whether this is a build that must ship no Reticle runtime at all: the SDK swapped for the stub
4
+ * below, nothing injected.
5
+ *
6
+ * Every production build, desktop included. `desktop: true` used to exempt the whole build, and it
7
+ * is what `reticle init` writes for electron-vite, so a RELEASE renderer carried the full SDK and
8
+ * dialled `ws://localhost:<port>/reticle`: `electron-vite preview` of a production build connected
9
+ * to the daemon. A release binary gets the same guarantee a web bundle does. A packaged desktop
10
+ * build that SHOULD be instrumented (a smoke app driven headless) says so the way Vite already
11
+ * provides, by building in another mode: `vite build --mode development`. An unknown mode counts as
12
+ * production, because the cost of guessing wrong the other way is an instrumented release.
13
+ */
14
+ export declare const isReticleDisabledBuild: (desktop: boolean, command: string | undefined, mode: string | undefined) => boolean;
3
15
  export declare const RETICLE_DISABLED_STUB_CODE = "\nexport class Reticle {\n connect() {}\n signal() {}\n state() {}\n renderCommit() {}\n describe() {}\n endSession() {}\n observeHotUpdates() {}\n disconnect() {}\n\n get connected() {\n return false;\n }\n}\n\nexport const reticle = new Reticle();\n\nexport const SESSION_AUTO = 'auto';\n\nexport function setIgnoreSelectors() {}\n\nexport function registerAdapter() {}\nexport function identifyComponent() {\n return null;\n}\nexport function readComponentState() {\n return undefined;\n}\nexport function elementHasHoverHandlers() {\n return false;\n}\nexport function adapterNames() {\n return [];\n}\n\nexport function registerStore() {}\nexport function unregisterStore() {}\nexport function storeNames() {\n return [];\n}\nexport function readStores() {\n return {};\n}\nexport function readStoresWithTruncation() {\n return { stores: {} };\n}\nexport function sourceOwner() {\n return undefined;\n}\nexport function markAdapterSource() {}\n\nexport function tanstackQueryStore() {\n return undefined;\n}\nexport function jotaiStore() {\n return undefined;\n}\nexport function xstateStore() {\n return undefined;\n}\nexport function valtioStore() {\n return undefined;\n}\nexport function mobxStore() {\n return undefined;\n}\nexport function recoilStore() {\n return undefined;\n}\nexport function svelteStore() {\n return undefined;\n}\nexport function piniaStore() {\n return undefined;\n}\nexport function pushStore() {\n return undefined;\n}\n\nexport function registerCapabilities() {}\nexport function getCapabilities() {\n return {};\n}\nexport function hasCapabilities() {\n return false;\n}\nexport function setCapabilitiesListener() {}\n\nexport function createReticleEmitter() {\n return {\n signal() {},\n state() {},\n };\n}\n\nexport function commitAndSignal() {}\nexport function registerReticleDomain() {}\n\nexport function buildSnapshot() {\n return {};\n}\n\nexport function inspectChart() {\n return {};\n}\nexport function canvasChartData() {\n return {};\n}\n\nexport function matchQuery() {\n return false;\n}\nexport function runQuery() {\n return undefined;\n}\n\nexport function executeAction() {\n return undefined;\n}\nexport function executeSequence() {\n return undefined;\n}\n\nexport function describe() {\n return undefined;\n}\nexport function getRole() {\n return undefined;\n}\nexport function getAccessibleName() {\n return undefined;\n}\nexport function getStates() {\n return {};\n}\nexport function isVisible() {\n return false;\n}\n\nexport class RefRegistry {\n get size() {\n return 0;\n }\n\n markEdited() {}\n\n mintedBeforeLastEdit() {\n return false;\n }\n\n refFor() {\n return undefined;\n }\n\n resolve() {\n return null;\n }\n}\n\nexport const refs = new RefRegistry();\n\nexport class Annotator {\n constructor() {}\n\n get active() {\n return false;\n }\n\n get markCount() {\n return 0;\n }\n\n mount() {}\n unmount() {}\n}\n\nexport function installAnnotator() {}\n\nexport function resolveMarkAnchor() {\n return undefined;\n}\n";
@@ -1,5 +1,20 @@
1
1
  export const RETICLE_DISABLED_STUB = '\0reticle-disabled-stub';
2
- export const isReticleDisabledWebBuild = (desktop, command) => !desktop && 'build' === command;
2
+ const BUILD_COMMAND = 'build';
3
+ /** The mode `vite build` and `electron-vite build` use unless the command line says otherwise. */
4
+ const PRODUCTION_MODE = 'production';
5
+ /**
6
+ * Whether this is a build that must ship no Reticle runtime at all: the SDK swapped for the stub
7
+ * below, nothing injected.
8
+ *
9
+ * Every production build, desktop included. `desktop: true` used to exempt the whole build, and it
10
+ * is what `reticle init` writes for electron-vite, so a RELEASE renderer carried the full SDK and
11
+ * dialled `ws://localhost:<port>/reticle`: `electron-vite preview` of a production build connected
12
+ * to the daemon. A release binary gets the same guarantee a web bundle does. A packaged desktop
13
+ * build that SHOULD be instrumented (a smoke app driven headless) says so the way Vite already
14
+ * provides, by building in another mode: `vite build --mode development`. An unknown mode counts as
15
+ * production, because the cost of guessing wrong the other way is an instrumented release.
16
+ */
17
+ export const isReticleDisabledBuild = (desktop, command, mode) => BUILD_COMMAND === command && (!desktop || mode === undefined || PRODUCTION_MODE === mode);
3
18
  export const RETICLE_DISABLED_STUB_CODE = `
4
19
  export class Reticle {
5
20
  connect() {}
@@ -3,4 +3,35 @@
3
3
  * to the default port — never auto-connects to a mismatched daemon). `home` and `alive` are injectable
4
4
  * so the selection is unit-tested without a real ~/.reticle or real processes.
5
5
  */
6
- export declare function discoverDaemonPort(projectId: string | undefined, home?: string, alive?: (pid: number) => boolean): number | undefined;
6
+ export declare function discoverDaemonPort(projectId: string | undefined, home?: string, alive?: (pid: number) => boolean,
7
+ /** `port` in `.reticle.json`: wins whenever a live daemon is registered on it — see pickDaemonPort. */
8
+ configured?: number): number | undefined;
9
+ /** The three places a daemon port can come from, as the plugin sees them at connect time. */
10
+ export interface DaemonPortSources {
11
+ /** A live daemon registered in ~/.reticle for THIS project. */
12
+ readonly discovered: number | undefined;
13
+ /** `port` in the nearest `.reticle.json`: the value the daemon and the CLI follow. */
14
+ readonly configured: number | undefined;
15
+ /** The literal `reticle({ port })`, usually written once by `reticle init`. */
16
+ readonly explicit: number | undefined;
17
+ }
18
+ export interface DaemonPortChoice {
19
+ /** Where to dial, or undefined for the default port. */
20
+ readonly port: number | undefined;
21
+ /** Set only when the option and `.reticle.json` disagree: one line naming both and the winner. */
22
+ readonly warning: string | undefined;
23
+ }
24
+ /**
25
+ * The one rule for which port the page dials: wherever the daemon for this project actually is.
26
+ *
27
+ * A live registered daemon first, because it is a fact rather than a statement of intent — and when
28
+ * one is live on the configured port, that one (see pickDaemonPort: a moved port left the old daemon
29
+ * alive, and discovery used to pick it). Then
30
+ * `.reticle.json`, because that is what the daemon and the CLI read. The option used to win, and a
31
+ * user who edited the file moved the daemon while the page kept dialling the literal `init` had
32
+ * written into vite.config: "never dialled the bridge", with nothing saying why. The option counts
33
+ * only when nothing else says anything, and undefined leaves the default to the connect.
34
+ */
35
+ export declare function chooseDaemonPort(sources: DaemonPortSources): DaemonPortChoice;
36
+ /** {@link chooseDaemonPort} over the real registry and the real `.reticle.json` above `cwd`. */
37
+ export declare function resolveDaemonPort(explicit: number | undefined, projectId: string | undefined, cwd: string, env?: NodeJS.ProcessEnv): DaemonPortChoice;
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Build-time daemon discovery: when the app doesn't pin a port, find the daemon serving THIS project
3
- * by reading the registry entries the daemon drops in ~/.reticle (daemon-<port>.json). Node-only and
2
+ * Build-time daemon discovery, and the ONE place the plugin decides which port the page dials. Finds
3
+ * the daemon serving THIS project by reading the registry entries the daemon drops in ~/.reticle
4
+ * (daemon-<port>.json). Node-only and
4
5
  * runs at dev-server request time (the daemon is up by then). The tricky selection rule — match by
5
6
  * projectId, drop dead daemons — is the pure `pickDaemonPort` in core; this file is just the fs plumbing.
6
7
  */
7
8
  import { readdirSync, readFileSync } from 'node:fs';
8
9
  import { join } from 'node:path';
9
- import { daemonRegistryPort, DaemonRegistryEntrySchema, pickDaemonPort, } from '@reticlehq/core';
10
+ import { daemonRegistryPort, DaemonRegistryEntrySchema, pickDaemonPort, ReticleEnv, } from '@reticlehq/core';
10
11
  import { stateHome } from './state-home.js';
12
+ import { readConfiguredPort } from './project-id.js';
13
+ import { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
14
+ const MAX_TCP_PORT = 65_535;
11
15
  /** process.kill(pid, 0) throws iff the process is gone — the same liveness probe the daemon uses. */
12
16
  function isAlive(pid) {
13
17
  try {
@@ -23,7 +27,9 @@ function isAlive(pid) {
23
27
  * to the default port — never auto-connects to a mismatched daemon). `home` and `alive` are injectable
24
28
  * so the selection is unit-tested without a real ~/.reticle or real processes.
25
29
  */
26
- export function discoverDaemonPort(projectId, home = stateHome(), alive = isAlive) {
30
+ export function discoverDaemonPort(projectId, home = stateHome(), alive = isAlive,
31
+ /** `port` in `.reticle.json`: wins whenever a live daemon is registered on it — see pickDaemonPort. */
32
+ configured) {
27
33
  const entries = [];
28
34
  let files;
29
35
  try {
@@ -44,5 +50,47 @@ export function discoverDaemonPort(projectId, home = stateHome(), alive = isAliv
44
50
  // corrupt/unreadable entry — skip
45
51
  }
46
52
  }
47
- return pickDaemonPort(entries, projectId, alive) ?? undefined;
53
+ return pickDaemonPort(entries, projectId, alive, configured) ?? undefined;
54
+ }
55
+ const portConflictWarning = (explicit, configured, used) => `[${RETICLE_VITE_PLUGIN_NAME}] reticle({ port: ${String(explicit)} }) and "port": ` +
56
+ `${String(configured)} in .reticle.json disagree; connecting to ${String(used)}. The daemon and ` +
57
+ 'the CLI follow .reticle.json, so drop the port option from reticle() or make the two match.';
58
+ /**
59
+ * The one rule for which port the page dials: wherever the daemon for this project actually is.
60
+ *
61
+ * A live registered daemon first, because it is a fact rather than a statement of intent — and when
62
+ * one is live on the configured port, that one (see pickDaemonPort: a moved port left the old daemon
63
+ * alive, and discovery used to pick it). Then
64
+ * `.reticle.json`, because that is what the daemon and the CLI read. The option used to win, and a
65
+ * user who edited the file moved the daemon while the page kept dialling the literal `init` had
66
+ * written into vite.config: "never dialled the bridge", with nothing saying why. The option counts
67
+ * only when nothing else says anything, and undefined leaves the default to the connect.
68
+ */
69
+ export function chooseDaemonPort(sources) {
70
+ const { discovered, configured, explicit } = sources;
71
+ const port = discovered ?? configured ?? explicit;
72
+ if (explicit === undefined || configured === undefined || port === undefined) {
73
+ return { port, warning: undefined };
74
+ }
75
+ return {
76
+ port,
77
+ warning: explicit === configured ? undefined : portConflictWarning(explicit, configured, port),
78
+ };
79
+ }
80
+ /** {@link chooseDaemonPort} over the real registry and the real `.reticle.json` above `cwd`. */
81
+ export function resolveDaemonPort(explicit, projectId, cwd, env = process.env) {
82
+ // The CLI treats RETICLE_PORT as an override across projects. A page must follow the same
83
+ // daemon even when an older instance is still registered or init wrote a different port.
84
+ const raw = env[ReticleEnv.PORT];
85
+ if (raw !== undefined && /^\d+$/.test(raw)) {
86
+ const port = Number(raw);
87
+ if (0 < port && port <= MAX_TCP_PORT)
88
+ return { port, warning: undefined };
89
+ }
90
+ const configured = readConfiguredPort(cwd);
91
+ return chooseDaemonPort({
92
+ discovered: discoverDaemonPort(projectId, stateHome(env), isAlive, configured),
93
+ configured,
94
+ explicit,
95
+ });
48
96
  }
package/dist/index.cjs CHANGED
@@ -4359,7 +4359,14 @@ var ReticleEnv = {
4359
4359
  API_KEY: "RETICLE_API_KEY",
4360
4360
  /** The name this key had until 2026-09. Still honoured; see `apiKeyFrom()`. */
4361
4361
  CLOUD_KEY: "RETICLE_CLOUD_KEY",
4362
- CLOUD_URL: "RETICLE_CLOUD_URL"
4362
+ /** The platform host. Read through `cloudUrlFrom()`, which also accepts `URL`. */
4363
+ CLOUD_URL: "RETICLE_CLOUD_URL",
4364
+ /** The short name for `CLOUD_URL`, which people type first. `CLOUD_URL` wins when both are set. */
4365
+ URL: "RETICLE_URL"
4366
+ };
4367
+ var SYNC_BATCH_LIMITS = {
4368
+ MAX_RUNS: 50,
4369
+ MAX_BYTES: 4 * 1024 * 1024
4363
4370
  };
4364
4371
  var TRANSPORT_LIMITS = {
4365
4372
  MAX_MESSAGE_BYTES: 1024 * 1024,
@@ -4489,6 +4496,8 @@ var ReticleDir = {
4489
4496
  AMBIENT_FILE: "ambient.json",
4490
4497
  /** per-flow flake ledger — replay outcomes that decide intermittent-failure quarantine. */
4491
4498
  FLAKE_FILE: "flake.json",
4499
+ /** The app-wide coverage ledger — see server features/exhaust/ledger.ts. */
4500
+ COVERAGE_FILE: "coverage.json",
4492
4501
  /**
4493
4502
  * the project's cloud binding — .reticle/cloud.json, written by `reticle link`. Git-checked and
4494
4503
  * non-secret: the project id, the API origin, and where its dashboard lives. The KEY lives in
@@ -4595,7 +4604,10 @@ var DaemonRegistryEntrySchema = external_exports.object({
4595
4604
  projectId: external_exports.string().optional(),
4596
4605
  startedAt: external_exports.number()
4597
4606
  });
4598
- function pickDaemonPort(entries, projectId, isAlive2) {
4607
+ function pickDaemonPort(entries, projectId, isAlive2, configuredPort) {
4608
+ if (void 0 !== configuredPort && entries.some((e) => e.port === configuredPort && isAlive2(e.pid) && (e.projectId === void 0 || projectId === void 0 || e.projectId === projectId))) {
4609
+ return configuredPort;
4610
+ }
4599
4611
  if (projectId === void 0 || 0 === projectId.length)
4600
4612
  return null;
4601
4613
  const matches = entries.filter((e) => e.projectId === projectId && isAlive2(e.pid)).map((e) => e.port).sort((a, b) => a - b);
@@ -4645,14 +4657,17 @@ function deriveProjectId(pkgName, rootPath) {
4645
4657
  var readFileOrThrow = (path) => (0, import_node_fs2.readFileSync)(path, "utf8");
4646
4658
  var MAX_CONFIG_SEARCH_DEPTH = 6;
4647
4659
  var MAX_PACKAGE_SEARCH_DEPTH = 50;
4648
- function readNearestField(startDir, basename, field, readFile, maxDepth) {
4660
+ var nonEmptyString = (value) => "string" === typeof value && value.length > 0 ? value : void 0;
4661
+ var MAX_TCP_PORT = 65535;
4662
+ var tcpPort = (value) => Number.isInteger(value) && value > 0 && value <= MAX_TCP_PORT ? value : void 0;
4663
+ function readNearestField(startDir, basename, field, readFile, maxDepth, accept) {
4649
4664
  let dir = startDir;
4650
4665
  for (let depth = 0; depth <= maxDepth; depth++) {
4651
4666
  try {
4652
4667
  const parsed = JSON.parse(readFile((0, import_node_path2.join)(dir, basename)));
4653
4668
  if ("object" === typeof parsed && parsed !== null) {
4654
- const value = parsed[field];
4655
- if ("string" === typeof value && value.length > 0) return value;
4669
+ const value = accept(parsed[field]);
4670
+ if (value !== void 0) return value;
4656
4671
  }
4657
4672
  } catch {
4658
4673
  }
@@ -4663,7 +4678,14 @@ function readNearestField(startDir, basename, field, readFile, maxDepth) {
4663
4678
  return void 0;
4664
4679
  }
4665
4680
  function readNearestPackageName(startDir, readFile = readFileOrThrow) {
4666
- return readNearestField(startDir, "package.json", "name", readFile, MAX_PACKAGE_SEARCH_DEPTH);
4681
+ return readNearestField(
4682
+ startDir,
4683
+ "package.json",
4684
+ "name",
4685
+ readFile,
4686
+ MAX_PACKAGE_SEARCH_DEPTH,
4687
+ nonEmptyString
4688
+ );
4667
4689
  }
4668
4690
  function readConfiguredProjectId(startDir, readFile = readFileOrThrow) {
4669
4691
  return readNearestField(
@@ -4671,7 +4693,18 @@ function readConfiguredProjectId(startDir, readFile = readFileOrThrow) {
4671
4693
  RETICLE_CONFIG_BASENAME,
4672
4694
  "projectId",
4673
4695
  readFile,
4674
- MAX_CONFIG_SEARCH_DEPTH
4696
+ MAX_CONFIG_SEARCH_DEPTH,
4697
+ nonEmptyString
4698
+ );
4699
+ }
4700
+ function readConfiguredPort(startDir, readFile = readFileOrThrow) {
4701
+ return readNearestField(
4702
+ startDir,
4703
+ RETICLE_CONFIG_BASENAME,
4704
+ "port",
4705
+ readFile,
4706
+ MAX_CONFIG_SEARCH_DEPTH,
4707
+ tcpPort
4675
4708
  );
4676
4709
  }
4677
4710
  function resolveProjectId(explicit, cwd, readPkgName = readNearestPackageName, readConfiguredId = readConfiguredProjectId) {
@@ -4693,7 +4726,11 @@ function stateHome(env = process.env) {
4693
4726
  return override !== void 0 && override.length > 0 ? override : (0, import_node_path3.join)((0, import_node_os.homedir)(), ReticleDir.ROOT);
4694
4727
  }
4695
4728
 
4729
+ // src/plugin-name.ts
4730
+ var RETICLE_VITE_PLUGIN_NAME = "reticle";
4731
+
4696
4732
  // src/discover-port.ts
4733
+ var MAX_TCP_PORT2 = 65535;
4697
4734
  function isAlive(pid) {
4698
4735
  try {
4699
4736
  process.kill(pid, 0);
@@ -4702,7 +4739,7 @@ function isAlive(pid) {
4702
4739
  return false;
4703
4740
  }
4704
4741
  }
4705
- function discoverDaemonPort(projectId, home = stateHome(), alive = isAlive) {
4742
+ function discoverDaemonPort(projectId, home = stateHome(), alive = isAlive, configured) {
4706
4743
  const entries = [];
4707
4744
  let files;
4708
4745
  try {
@@ -4720,7 +4757,32 @@ function discoverDaemonPort(projectId, home = stateHome(), alive = isAlive) {
4720
4757
  } catch {
4721
4758
  }
4722
4759
  }
4723
- return pickDaemonPort(entries, projectId, alive) ?? void 0;
4760
+ return pickDaemonPort(entries, projectId, alive, configured) ?? void 0;
4761
+ }
4762
+ var portConflictWarning = (explicit, configured, used) => `[${RETICLE_VITE_PLUGIN_NAME}] reticle({ port: ${String(explicit)} }) and "port": ${String(configured)} in .reticle.json disagree; connecting to ${String(used)}. The daemon and the CLI follow .reticle.json, so drop the port option from reticle() or make the two match.`;
4763
+ function chooseDaemonPort(sources) {
4764
+ const { discovered, configured, explicit } = sources;
4765
+ const port = discovered ?? configured ?? explicit;
4766
+ if (explicit === void 0 || configured === void 0 || port === void 0) {
4767
+ return { port, warning: void 0 };
4768
+ }
4769
+ return {
4770
+ port,
4771
+ warning: explicit === configured ? void 0 : portConflictWarning(explicit, configured, port)
4772
+ };
4773
+ }
4774
+ function resolveDaemonPort(explicit, projectId, cwd, env = process.env) {
4775
+ const raw = env[ReticleEnv.PORT];
4776
+ if (raw !== void 0 && /^\d+$/.test(raw)) {
4777
+ const port = Number(raw);
4778
+ if (0 < port && port <= MAX_TCP_PORT2) return { port, warning: void 0 };
4779
+ }
4780
+ const configured = readConfiguredPort(cwd);
4781
+ return chooseDaemonPort({
4782
+ discovered: discoverDaemonPort(projectId, stateHome(env), isAlive, configured),
4783
+ configured,
4784
+ explicit
4785
+ });
4724
4786
  }
4725
4787
 
4726
4788
  // src/announce.ts
@@ -4980,7 +5042,9 @@ function optimizerOptions(key, inherited, add) {
4980
5042
 
4981
5043
  // src/disabled-browser-stub.ts
4982
5044
  var RETICLE_DISABLED_STUB = "\0reticle-disabled-stub";
4983
- var isReticleDisabledWebBuild = (desktop, command) => !desktop && "build" === command;
5045
+ var BUILD_COMMAND = "build";
5046
+ var PRODUCTION_MODE = "production";
5047
+ var isReticleDisabledBuild = (desktop, command, mode) => BUILD_COMMAND === command && (!desktop || mode === void 0 || PRODUCTION_MODE === mode);
4984
5048
  var RETICLE_DISABLED_STUB_CODE = `
4985
5049
  export class Reticle {
4986
5050
  connect() {}
@@ -5199,14 +5263,11 @@ function connectArgs(options) {
5199
5263
  return Object.keys(args).length > 0 ? JSON.stringify(args) : "";
5200
5264
  }
5201
5265
 
5202
- // src/plugin-name.ts
5203
- var RETICLE_VITE_PLUGIN_NAME = "reticle";
5204
-
5205
5266
  // src/injection-postcondition.ts
5206
5267
  var DEV_INJECTION_GRACE_MS = 1e4;
5207
5268
  var notInjectedMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not inject reticle.connect(): the HTML entry module was never matched, so this app carries no instrumentation and will never connect. Check that index.html references your entry with a <script type="module" src="...">, or pass \`inject: false\` and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself. The plugin still inlines that define; a connect without it is refused.`;
5208
5269
  var unconfirmedInjectionMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not confirm reticle.connect() was injected: the HTML entry module was not transformed this session. That is expected when Vite served it from its transform cache. If the app does not appear in \`reticle status\`, restart the dev server with \`--force\` to bypass the cache, then check that index.html references your entry with a <script type="module" src="...">.`;
5209
- var htmlHookNeverRanMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] this app will never connect: the dev server never asked this plugin to transform any HTML, so reticle.connect() was never added to the page. That usually means your framework renders its own HTML instead of serving index.html \u2014 SvelteKit, Nuxt, Astro, React Router (framework mode) and TanStack Start all do. Fix: import '@reticlehq/browser' and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself from your app entry file, and pass \`inject: false\` to this plugin so the two do not both try.`;
5270
+ var htmlHookNeverRanMessage = (connectModule) => `[${RETICLE_VITE_PLUGIN_NAME}] this app will never connect: the dev server never asked this plugin to transform any HTML and nothing loaded its connect module, so reticle.connect() never reached the page. That usually means your framework renders its own HTML instead of serving index.html \u2014 SvelteKit, Nuxt, Astro, React Router (framework mode) and TanStack Start all do. Fix: in dev, dynamically import '${connectModule}' from your client entry file \u2014 this plugin serves that module, so leave \`inject\` on \u2014 or import '@reticlehq/browser' there and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself.`;
5210
5271
  var defaultSchedule = (run, ms) => {
5211
5272
  const timer = setTimeout(run, ms);
5212
5273
  timer.unref?.();
@@ -5215,9 +5276,9 @@ function createInjectionWatch(deps) {
5215
5276
  let htmlRequested = false;
5216
5277
  const isDocumentRequest = (req) => true === req.headers?.accept?.includes("text/html");
5217
5278
  const checkHtmlHookRan = () => {
5218
- if (deps.desktop || !deps.inject || deps.htmlTransformed()) return;
5279
+ if (deps.desktop || !deps.inject || deps.connectDelivered()) return;
5219
5280
  if (!htmlRequested) return;
5220
- deps.warn(htmlHookNeverRanMessage());
5281
+ deps.warn(htmlHookNeverRanMessage(deps.connectModule()));
5221
5282
  };
5222
5283
  const noteHtmlRequest = () => {
5223
5284
  if (htmlRequested) return;
@@ -5363,14 +5424,22 @@ function reticle(options = {}) {
5363
5424
  let htmlEntrySpecifier;
5364
5425
  let root;
5365
5426
  let command;
5427
+ let mode;
5428
+ const disabledBuild = () => isReticleDisabledBuild(desktop, command, mode);
5366
5429
  let base;
5367
5430
  let vitestBrowser = false;
5368
5431
  const warn = options.onWarn ?? ((message) => globalThis.console.warn(message));
5369
5432
  let injected = false;
5370
5433
  const announcedIgnored = /* @__PURE__ */ new Set();
5371
5434
  let htmlTransformed = false;
5435
+ let connectDelivered = false;
5436
+ let lastPortWarning;
5372
5437
  const resolveLazy = () => {
5373
- const port = resolved.port ?? discoverDaemonPort(resolved.projectId);
5438
+ const { port, warning } = resolveDaemonPort(resolved.port, resolved.projectId, process.cwd());
5439
+ if (warning !== void 0 && warning !== lastPortWarning) {
5440
+ lastPortWarning = warning;
5441
+ warn(warning);
5442
+ }
5374
5443
  const withPort = port !== void 0 ? { ...resolved, port } : resolved;
5375
5444
  const token = withPort.token ?? readPairingToken();
5376
5445
  const withToken = token !== void 0 ? { ...withPort, token } : withPort;
@@ -5385,7 +5454,8 @@ function reticle(options = {}) {
5385
5454
  desktop,
5386
5455
  inject,
5387
5456
  injected: () => injected,
5388
- htmlTransformed: () => htmlTransformed,
5457
+ connectDelivered: () => htmlTransformed || connectDelivered,
5458
+ connectModule: () => connectModuleUrl(base),
5389
5459
  warn
5390
5460
  });
5391
5461
  return {
@@ -5473,7 +5543,10 @@ function reticle(options = {}) {
5473
5543
  };
5474
5544
  },
5475
5545
  transform(code, id) {
5476
- if (isReticleDisabledWebBuild(desktop, command)) return null;
5546
+ if (disabledBuild()) return null;
5547
+ if (!connectDelivered && code.includes(RETICLE_TOKEN_GLOBAL) && !id.includes(NODE_MODULES)) {
5548
+ connectDelivered = true;
5549
+ }
5477
5550
  if (desktop && inject && isHtmlEntry(id, htmlEntrySpecifier, root)) {
5478
5551
  injected = true;
5479
5552
  const withConnect = `${connectModuleSource(resolveLazy())}
@@ -5497,7 +5570,7 @@ ${code}`;
5497
5570
  return stamp(code, id);
5498
5571
  },
5499
5572
  resolveId(id, importer) {
5500
- if (isReticleDisabledWebBuild(desktop, command) && id === RETICLE_SENSOR) {
5573
+ if (disabledBuild() && id === RETICLE_SENSOR) {
5501
5574
  return RETICLE_DISABLED_STUB;
5502
5575
  }
5503
5576
  if (desktop && inject && importer !== void 0 && importer.includes(".html")) {
@@ -5506,17 +5579,19 @@ ${code}`;
5506
5579
  return inject && id === RETICLE_CONNECT_MODULE ? RETICLE_CONNECT_MODULE : null;
5507
5580
  },
5508
5581
  load(id) {
5509
- if (isReticleDisabledWebBuild(desktop, command) && id === RETICLE_DISABLED_STUB) {
5582
+ if (disabledBuild() && id === RETICLE_DISABLED_STUB) {
5510
5583
  return RETICLE_DISABLED_STUB_CODE;
5511
5584
  }
5512
5585
  if (!inject || id !== RETICLE_CONNECT_MODULE) return null;
5513
5586
  const source = currentConnectSource();
5514
5587
  lastServedConnectSource = source;
5588
+ connectDelivered = true;
5515
5589
  return source;
5516
5590
  },
5517
5591
  configResolved(config) {
5518
5592
  root = config.root;
5519
5593
  command = config.command;
5594
+ mode = config.mode;
5520
5595
  base = config.base;
5521
5596
  vitestBrowser = isVitestBrowserServer(config);
5522
5597
  },
@@ -5541,7 +5616,6 @@ ${code}`;
5541
5616
  * appearing IS a change), and makes the module inert once it has settled.
5542
5617
  */
5543
5618
  configureServer(server) {
5544
- if (!inject) return;
5545
5619
  const announce = () => {
5546
5620
  const address = server.httpServer?.address();
5547
5621
  const port = "object" === typeof address && null !== address && void 0 !== address ? address.port : void 0;
@@ -5566,6 +5640,7 @@ ${code}`;
5566
5640
  } else {
5567
5641
  announce();
5568
5642
  }
5643
+ if (!inject) return;
5569
5644
  server.middlewares.use((req, _res, next) => {
5570
5645
  if (!desktop && watch.isDocumentRequest(req)) watch.noteHtmlRequest();
5571
5646
  const requestPath = (req.url ?? "").split("?")[0];
@@ -5586,14 +5661,14 @@ ${code}`;
5586
5661
  * build that could not instrument must fail loudly instead of shipping a binary that lies.
5587
5662
  */
5588
5663
  buildEnd() {
5589
- if (!desktop || !inject || injected) return;
5664
+ if (!desktop || !inject || injected || disabledBuild()) return;
5590
5665
  throw new Error(notInjectedMessage());
5591
5666
  },
5592
5667
  checkInjectedForTest: watch.checkInjected,
5593
5668
  checkHtmlHookForTest: watch.checkHtmlHookRan,
5594
5669
  injectionWatchForTest: watch,
5595
5670
  transformIndexHtml() {
5596
- if (isReticleDisabledWebBuild(desktop, command)) return [];
5671
+ if (disabledBuild()) return [];
5597
5672
  htmlTransformed = true;
5598
5673
  if (desktop && inject && "serve" === command) watch.armDesktopCheck();
5599
5674
  if (!inject || desktop) return [];
package/dist/index.d.cts CHANGED
@@ -72,14 +72,15 @@ export interface ReticleVitePluginOptions {
72
72
  * This build is an Electron/Tauri renderer. Changes two things a desktop shell needs and a web app
73
73
  * must not get:
74
74
  *
75
- * - A packaged desktop renderer IS a production build with no dev server, so it needs the real
76
- * `connect()` wiring during `vite build` — the web default's stub-swap must not apply to it.
75
+ * - connect() is injected into the HTML's entry module rather than as a virtual <script src>,
76
+ * so a packaged renderer with no dev server still resolves it — but only for a build run in a
77
+ * NON-production mode (`vite build --mode development`). A production-mode build is stubbed
78
+ * exactly like a web one, so a release binary never carries the SDK. See
79
+ * isReticleDisabledBuild.
77
80
  * - `connect()` is called with `allowInProduction`, because that same renderer reports
78
81
  * NODE_ENV=production and the SDK's prod backstop would otherwise refuse to start.
79
82
  *
80
- * Off by default and never inferred: turning it on means an instrumented production BUNDLE, which
81
- * is exactly what a web app must never ship. Keep it behind your own dev-only build (a dev target,
82
- * or `process.env.NODE_ENV !== 'production'` in vite.config) so it cannot reach a release binary.
83
+ * Off by default and never inferred.
83
84
  */
84
85
  desktop?: boolean;
85
86
  /**
@@ -220,6 +221,8 @@ export interface ReticleVitePlugin {
220
221
  configResolved?: (config: {
221
222
  root?: string;
222
223
  command?: string;
224
+ /** 'production' unless `--mode` says otherwise. See isReticleDisabledBuild. */
225
+ mode?: string;
223
226
  base?: string;
224
227
  /** Vitest's block, read only to spot browser mode. See isVitestBrowserServer. */
225
228
  test?: unknown;
@@ -327,11 +330,9 @@ export declare function connectModuleSource(options: ReticleVitePluginOptions, d
327
330
  * The web plugin also builds, replacing the browser SDK with an inert stub so a default
328
331
  * production bundle ships no Reticle runtime code at all.
329
332
  *
330
- * `desktop: true` is the ONE documented exception, and it inverts that guarantee deliberately: a
331
- * packaged Electron/Tauri renderer IS a production build with no dev server, so the web default's
332
- * stub-replacement would ship an app with no connect() at all. The cost is that the flag hands
333
- * gating back to the caller — keep it behind your own dev-only build target so an instrumented
334
- * bundle can never reach a release binary.
333
+ * `desktop: true` keeps that guarantee for a production-mode build. It instruments `serve`, and a
334
+ * build only when it runs in another mode (`vite build --mode development`), which is how a packaged
335
+ * smoke renderer with no dev server still gets its connect(). See isReticleDisabledBuild.
335
336
  */
336
337
  /**
337
338
  * The daemon's journal directory, as a matcher every chokidar major honours.
package/dist/index.d.ts CHANGED
@@ -72,14 +72,15 @@ export interface ReticleVitePluginOptions {
72
72
  * This build is an Electron/Tauri renderer. Changes two things a desktop shell needs and a web app
73
73
  * must not get:
74
74
  *
75
- * - A packaged desktop renderer IS a production build with no dev server, so it needs the real
76
- * `connect()` wiring during `vite build` — the web default's stub-swap must not apply to it.
75
+ * - connect() is injected into the HTML's entry module rather than as a virtual <script src>,
76
+ * so a packaged renderer with no dev server still resolves it — but only for a build run in a
77
+ * NON-production mode (`vite build --mode development`). A production-mode build is stubbed
78
+ * exactly like a web one, so a release binary never carries the SDK. See
79
+ * isReticleDisabledBuild.
77
80
  * - `connect()` is called with `allowInProduction`, because that same renderer reports
78
81
  * NODE_ENV=production and the SDK's prod backstop would otherwise refuse to start.
79
82
  *
80
- * Off by default and never inferred: turning it on means an instrumented production BUNDLE, which
81
- * is exactly what a web app must never ship. Keep it behind your own dev-only build (a dev target,
82
- * or `process.env.NODE_ENV !== 'production'` in vite.config) so it cannot reach a release binary.
83
+ * Off by default and never inferred.
83
84
  */
84
85
  desktop?: boolean;
85
86
  /**
@@ -220,6 +221,8 @@ export interface ReticleVitePlugin {
220
221
  configResolved?: (config: {
221
222
  root?: string;
222
223
  command?: string;
224
+ /** 'production' unless `--mode` says otherwise. See isReticleDisabledBuild. */
225
+ mode?: string;
223
226
  base?: string;
224
227
  /** Vitest's block, read only to spot browser mode. See isVitestBrowserServer. */
225
228
  test?: unknown;
@@ -327,11 +330,9 @@ export declare function connectModuleSource(options: ReticleVitePluginOptions, d
327
330
  * The web plugin also builds, replacing the browser SDK with an inert stub so a default
328
331
  * production bundle ships no Reticle runtime code at all.
329
332
  *
330
- * `desktop: true` is the ONE documented exception, and it inverts that guarantee deliberately: a
331
- * packaged Electron/Tauri renderer IS a production build with no dev server, so the web default's
332
- * stub-replacement would ship an app with no connect() at all. The cost is that the flag hands
333
- * gating back to the caller — keep it behind your own dev-only build target so an instrumented
334
- * bundle can never reach a release binary.
333
+ * `desktop: true` keeps that guarantee for a production-mode build. It instruments `serve`, and a
334
+ * build only when it runs in another mode (`vite build --mode development`), which is how a packaged
335
+ * smoke renderer with no dev server still gets its connect(). See isReticleDisabledBuild.
335
336
  */
336
337
  /**
337
338
  * The daemon's journal directory, as a matcher every chokidar major honours.
package/dist/index.js CHANGED
@@ -5,12 +5,12 @@ import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { RETICLE_RENDER_PREHOOK, ReticleDir, ReticleEnv, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, } from '@reticlehq/core';
7
7
  import { resolveProjectId } from './project-id.js';
8
- import { discoverDaemonPort } from './discover-port.js';
8
+ import { resolveDaemonPort } from './discover-port.js';
9
9
  import { announceDevServer } from './announce.js';
10
10
  import { stampSvelte } from './svelte-source.js';
11
- import { ignoredFileNotice, optsOutOfStamping, shouldStamp, shouldStampSvelte, stamp, } from './stamping.js';
11
+ import { NODE_MODULES, ignoredFileNotice, optsOutOfStamping, shouldStamp, shouldStampSvelte, stamp, } from './stamping.js';
12
12
  import { resolvableChain, sdkPackageVersion, sdkBuildFingerprint, viteMajor, optimizerOptionsKey, optimizerOptions, } from './installed.js';
13
- import { RETICLE_DISABLED_STUB_CODE, RETICLE_DISABLED_STUB, isReticleDisabledWebBuild, } from './disabled-browser-stub.js';
13
+ import { RETICLE_DISABLED_STUB_CODE, RETICLE_DISABLED_STUB, isReticleDisabledBuild, } from './disabled-browser-stub.js';
14
14
  import { connectArgs } from './connect-args.js';
15
15
  import { createInjectionWatch, notInjectedMessage, } from './injection-postcondition.js';
16
16
  import { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
@@ -244,11 +244,9 @@ export function connectModuleSource(options, devModule = null) {
244
244
  * The web plugin also builds, replacing the browser SDK with an inert stub so a default
245
245
  * production bundle ships no Reticle runtime code at all.
246
246
  *
247
- * `desktop: true` is the ONE documented exception, and it inverts that guarantee deliberately: a
248
- * packaged Electron/Tauri renderer IS a production build with no dev server, so the web default's
249
- * stub-replacement would ship an app with no connect() at all. The cost is that the flag hands
250
- * gating back to the caller — keep it behind your own dev-only build target so an instrumented
251
- * bundle can never reach a release binary.
247
+ * `desktop: true` keeps that guarantee for a production-mode build. It instruments `serve`, and a
248
+ * build only when it runs in another mode (`vite build --mode development`), which is how a packaged
249
+ * smoke renderer with no dev server still gets its connect(). See isReticleDisabledBuild.
252
250
  */
253
251
  /**
254
252
  * The daemon's journal directory, as a matcher every chokidar major honours.
@@ -279,6 +277,10 @@ export function reticle(options = {}) {
279
277
  let root;
280
278
  /** 'serve' | 'build'. The dev check only applies to serve; buildEnd covers the other. */
281
279
  let command;
280
+ /** Vite's resolved mode. Decides whether a desktop build may be instrumented at all. */
281
+ let mode;
282
+ /** A build that must carry no Reticle runtime. See isReticleDisabledBuild. */
283
+ const disabledBuild = () => isReticleDisabledBuild(desktop, command, mode);
282
284
  /** Vite's resolved `base`. Undefined until configResolved, which is before any HTML is served. */
283
285
  let base;
284
286
  /** True only when THIS server is Vitest's browser-mode runner — see isVitestBrowserServer. */
@@ -291,18 +293,33 @@ export function reticle(options = {}) {
291
293
  /**
292
294
  * Whether Vite ever asked us to transform the app's HTML.
293
295
  *
294
- * On the web this is how the connect script gets in, so "this never happened" and "this app will
295
- * not connect" are the same statement — which is what makes a warning safe here, unlike the
296
- * desktop entry-module flag above.
296
+ * On the web this is the usual way the connect script gets in, but not the only one: see
297
+ * connectDelivered below, which the web warning reads together with this.
297
298
  */
298
299
  let htmlTransformed = false;
300
+ /**
301
+ * Whether the connect reached the page by a route OTHER than the HTML hook.
302
+ *
303
+ * A framework that renders its own HTML never calls `transformIndexHtml`, and still connects when
304
+ * its client entry imports the module `load` serves (React Router) or calls connect() itself with
305
+ * the token this plugin inlines (SvelteKit's client hook). Counting only the hook told those apps,
306
+ * ten seconds after every page load and while they WERE connected, that they would never connect.
307
+ */
308
+ let connectDelivered = false;
309
+ /** The last port warning printed, so a disagreement is said once and not on every request. */
310
+ let lastPortWarning;
299
311
  /**
300
312
  * Resolve port + token at the moment of injection, not at plugin construction. By the time a
301
313
  * module is served or built the daemon is up and has written its pairing token; resolving early
302
314
  * would bake in `undefined` and the app would fail auth on every connect.
303
315
  */
304
316
  const resolveLazy = () => {
305
- const port = resolved.port ?? discoverDaemonPort(resolved.projectId);
317
+ // Wherever the daemon for this project actually is; the order is argued in discover-port.ts.
318
+ const { port, warning } = resolveDaemonPort(resolved.port, resolved.projectId, process.cwd());
319
+ if (warning !== undefined && warning !== lastPortWarning) {
320
+ lastPortWarning = warning;
321
+ warn(warning);
322
+ }
306
323
  const withPort = port !== undefined ? { ...resolved, port } : resolved;
307
324
  const token = withPort.token ?? readPairingToken();
308
325
  const withToken = token !== undefined ? { ...withPort, token } : withPort;
@@ -331,7 +348,8 @@ export function reticle(options = {}) {
331
348
  desktop,
332
349
  inject,
333
350
  injected: () => injected,
334
- htmlTransformed: () => htmlTransformed,
351
+ connectDelivered: () => htmlTransformed || connectDelivered,
352
+ connectModule: () => connectModuleUrl(base),
335
353
  warn,
336
354
  });
337
355
  return {
@@ -417,8 +435,13 @@ export function reticle(options = {}) {
417
435
  };
418
436
  },
419
437
  transform(code, id) {
420
- if (isReticleDisabledWebBuild(desktop, command))
438
+ if (disabledBuild())
421
439
  return null;
440
+ // A hand-written connect in app code reads the token this plugin inlines; that is the
441
+ // connect reaching the page without the HTML hook. See connectDelivered.
442
+ if (!connectDelivered && code.includes(RETICLE_TOKEN_GLOBAL) && !id.includes(NODE_MODULES)) {
443
+ connectDelivered = true;
444
+ }
422
445
  // Desktop injection: prepend connect() to the HTML's own entry module. It is a REAL module, so
423
446
  // its bare `@reticlehq/react` import resolves through the normal pipeline in both dev and
424
447
  // build — which a virtual <script src> only ever did in dev.
@@ -452,7 +475,7 @@ export function reticle(options = {}) {
452
475
  return stamp(code, id);
453
476
  },
454
477
  resolveId(id, importer) {
455
- if (isReticleDisabledWebBuild(desktop, command) && id === RETICLE_SENSOR) {
478
+ if (disabledBuild() && id === RETICLE_SENSOR) {
456
479
  return RETICLE_DISABLED_STUB;
457
480
  }
458
481
  // Desktop: remember the module the HTML points at, so `transform` can prepend connect() into
@@ -470,18 +493,20 @@ export function reticle(options = {}) {
470
493
  return inject && id === RETICLE_CONNECT_MODULE ? RETICLE_CONNECT_MODULE : null;
471
494
  },
472
495
  load(id) {
473
- if (isReticleDisabledWebBuild(desktop, command) && id === RETICLE_DISABLED_STUB) {
496
+ if (disabledBuild() && id === RETICLE_DISABLED_STUB) {
474
497
  return RETICLE_DISABLED_STUB_CODE;
475
498
  }
476
499
  if (!inject || id !== RETICLE_CONNECT_MODULE)
477
500
  return null;
478
501
  const source = currentConnectSource();
479
502
  lastServedConnectSource = source;
503
+ connectDelivered = true;
480
504
  return source;
481
505
  },
482
506
  configResolved(config) {
483
507
  root = config.root;
484
508
  command = config.command;
509
+ mode = config.mode;
485
510
  base = config.base;
486
511
  vitestBrowser = isVitestBrowserServer(config);
487
512
  },
@@ -506,8 +531,6 @@ export function reticle(options = {}) {
506
531
  * appearing IS a change), and makes the module inert once it has settled.
507
532
  */
508
533
  configureServer(server) {
509
- if (!inject)
510
- return;
511
534
  // The web post-condition is armed by the first DOCUMENT REQUEST, in the middleware below.
512
535
  //
513
536
  // Not from `transformIndexHtml`, because a framework that renders its own HTML never calls it
@@ -559,6 +582,12 @@ export function reticle(options = {}) {
559
582
  else {
560
583
  announce();
561
584
  }
585
+ // Everything below serves or watches the injected connect, so `inject: false` stops HERE and
586
+ // not at the top. SvelteKit and TanStack Start are written `inject: false` because they connect
587
+ // themselves, and returning before the announcement made the dev server `init` started for
588
+ // them invisible to everything that asks which dev servers are running.
589
+ if (!inject)
590
+ return;
562
591
  server.middlewares.use((req, _res, next) => {
563
592
  // One middleware, two observations. A second `use()` would work equally well in Vite and
564
593
  // is the obvious way to write this, but it makes the ORDER of registration load-bearing for
@@ -588,7 +617,8 @@ export function reticle(options = {}) {
588
617
  * build that could not instrument must fail loudly instead of shipping a binary that lies.
589
618
  */
590
619
  buildEnd() {
591
- if (!desktop || !inject || injected)
620
+ // A production build injects nothing on purpose; see isReticleDisabledBuild.
621
+ if (!desktop || !inject || injected || disabledBuild())
592
622
  return;
593
623
  throw new Error(notInjectedMessage());
594
624
  },
@@ -596,7 +626,7 @@ export function reticle(options = {}) {
596
626
  checkHtmlHookForTest: watch.checkHtmlHookRan,
597
627
  injectionWatchForTest: watch,
598
628
  transformIndexHtml() {
599
- if (isReticleDisabledWebBuild(desktop, command))
629
+ if (disabledBuild())
600
630
  return [];
601
631
  htmlTransformed = true;
602
632
  if (desktop && inject && 'serve' === command)
@@ -42,8 +42,12 @@ export declare const unconfirmedInjectionMessage: () => string;
42
42
  * `reticle status`. Known examples: SvelteKit, Nuxt, Astro, React Router in framework mode and
43
43
  * TanStack Start. Each was found the same way — a user waiting on a session that could never
44
44
  * arrive — so the plain-language cause comes first and the fix comes second.
45
+ *
46
+ * The fix it names keeps `inject` ON. It used to say "pass `inject: false`", and on a React Router
47
+ * app, whose client entry imports the connect module this plugin serves, following that advice
48
+ * switched the module off and broke a working connect.
45
49
  */
46
- export declare const htmlHookNeverRanMessage: () => string;
50
+ export declare const htmlHookNeverRanMessage: (connectModule: string) => string;
47
51
  /**
48
52
  * The state behind those three messages, and the rules for reaching each one.
49
53
  *
@@ -58,8 +62,13 @@ export interface InjectionWatchDeps {
58
62
  readonly inject: boolean;
59
63
  /** Whether the desktop entry module was transformed. Read late: it flips during the session. */
60
64
  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;
65
+ /**
66
+ * Whether connect() reached the page by ANY route: the HTML hook, the served connect module, or a
67
+ * hand-written connect. Read late, for the same reason.
68
+ */
69
+ readonly connectDelivered: () => boolean;
70
+ /** The URL the connect module is served at (base-aware, so read late), for the web message. */
71
+ readonly connectModule: () => string;
63
72
  readonly warn: (message: string) => void;
64
73
  /** Injected so a test does not wait ten real seconds. Defaults to the module-level timer. */
65
74
  readonly schedule?: (run: () => void, ms: number) => void;
@@ -51,13 +51,18 @@ export const unconfirmedInjectionMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}]
51
51
  * `reticle status`. Known examples: SvelteKit, Nuxt, Astro, React Router in framework mode and
52
52
  * TanStack Start. Each was found the same way — a user waiting on a session that could never
53
53
  * arrive — so the plain-language cause comes first and the fix comes second.
54
+ *
55
+ * The fix it names keeps `inject` ON. It used to say "pass `inject: false`", and on a React Router
56
+ * app, whose client entry imports the connect module this plugin serves, following that advice
57
+ * switched the module off and broke a working connect.
54
58
  */
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.';
59
+ export const htmlHookNeverRanMessage = (connectModule) => `[${RETICLE_VITE_PLUGIN_NAME}] this app will never connect: the dev server never asked this ` +
60
+ 'plugin to transform any HTML and nothing loaded its connect module, so reticle.connect() never ' +
61
+ 'reached the page. That usually means your framework renders its own HTML instead of serving ' +
62
+ 'index.html — SvelteKit, Nuxt, Astro, React Router (framework mode) and TanStack Start all do. ' +
63
+ `Fix: in dev, dynamically import '${connectModule}' from your client entry file — this plugin ` +
64
+ "serves that module, so leave `inject` on — or import '@reticlehq/browser' there " +
65
+ 'and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself.';
61
66
  const defaultSchedule = (run, ms) => {
62
67
  const timer = setTimeout(run, ms);
63
68
  // Never hold a dev server open on account of a warning it may not even need to print.
@@ -85,12 +90,12 @@ export function createInjectionWatch(deps) {
85
90
  * `warn(htmlHookNeverRanMessage())` needs to see it.
86
91
  */
87
92
  const checkHtmlHookRan = () => {
88
- if (deps.desktop || !deps.inject || deps.htmlTransformed())
93
+ if (deps.desktop || !deps.inject || deps.connectDelivered())
89
94
  return;
90
95
  // Nobody has opened the app. That says nothing about whether it can connect.
91
96
  if (!htmlRequested)
92
97
  return;
93
- deps.warn(htmlHookNeverRanMessage());
98
+ deps.warn(htmlHookNeverRanMessage(deps.connectModule()));
94
99
  };
95
100
  /**
96
101
  * Note a document request, and start the clock from THERE.
@@ -26,6 +26,14 @@ type ReadFile = (path: string) => string;
26
26
  * through `init` — it is not a second opinion, and where the two disagree the config wins.
27
27
  */
28
28
  export declare function readConfiguredProjectId(startDir: string, readFile?: ReadFile): string | undefined;
29
+ /**
30
+ * Read the daemon port `reticle init --port` recorded in the nearest `.reticle.json`, or undefined.
31
+ *
32
+ * The CLI already obeys this field, so a plugin that ignored it put the two halves on different
33
+ * daemons: the page connected to the default port — on a machine running two projects, the other
34
+ * project's daemon — and the bridge refused it.
35
+ */
36
+ export declare function readConfiguredPort(startDir: string, readFile?: ReadFile): number | undefined;
29
37
  /**
30
38
  * Resolve the projectId for a plugin instance, most-local statement of intent first: an explicit
31
39
  * option, then the id `reticle init` recorded in `.reticle.json`, then derivation.
@@ -45,18 +45,24 @@ const MAX_CONFIG_SEARCH_DEPTH = 6;
45
45
  * costs a less specific name rather than the wrong app's identity. Left as it was.
46
46
  */
47
47
  const MAX_PACKAGE_SEARCH_DEPTH = 50;
48
+ const nonEmptyString = (value) => 'string' === typeof value && value.length > 0 ? value : undefined;
49
+ const MAX_TCP_PORT = 65_535;
50
+ const tcpPort = (value) => Number.isInteger(value) && value > 0 && value <= MAX_TCP_PORT
51
+ ? value
52
+ : undefined;
48
53
  /**
49
- * Walk up from `startDir` looking for `basename`, and return the first non-empty string `field`
50
- * yields. Unreadable and unparseable files are skipped rather than fatal: a dev server must start.
54
+ * Walk up from `startDir` looking for `basename`, and return the first value of `field` that
55
+ * `accept` takes. Unreadable and unparseable files are skipped rather than fatal: a dev server must
56
+ * start.
51
57
  */
52
- function readNearestField(startDir, basename, field, readFile, maxDepth) {
58
+ function readNearestField(startDir, basename, field, readFile, maxDepth, accept) {
53
59
  let dir = startDir;
54
60
  for (let depth = 0; depth <= maxDepth; depth++) {
55
61
  try {
56
62
  const parsed = JSON.parse(readFile(join(dir, basename)));
57
63
  if ('object' === typeof parsed && parsed !== null) {
58
- const value = parsed[field];
59
- if ('string' === typeof value && value.length > 0)
64
+ const value = accept(parsed[field]);
65
+ if (value !== undefined)
60
66
  return value;
61
67
  }
62
68
  }
@@ -72,7 +78,7 @@ function readNearestField(startDir, basename, field, readFile, maxDepth) {
72
78
  }
73
79
  /** Read the `name` from the nearest package.json at or above `startDir`, or undefined if none. */
74
80
  function readNearestPackageName(startDir, readFile = readFileOrThrow) {
75
- return readNearestField(startDir, 'package.json', 'name', readFile, MAX_PACKAGE_SEARCH_DEPTH);
81
+ return readNearestField(startDir, 'package.json', 'name', readFile, MAX_PACKAGE_SEARCH_DEPTH, nonEmptyString);
76
82
  }
77
83
  /**
78
84
  * Read the `projectId` `reticle init` recorded in the nearest `.reticle.json`, or undefined.
@@ -81,7 +87,17 @@ function readNearestPackageName(startDir, readFile = readFileOrThrow) {
81
87
  * through `init` — it is not a second opinion, and where the two disagree the config wins.
82
88
  */
83
89
  export function readConfiguredProjectId(startDir, readFile = readFileOrThrow) {
84
- return readNearestField(startDir, RETICLE_CONFIG_BASENAME, 'projectId', readFile, MAX_CONFIG_SEARCH_DEPTH);
90
+ return readNearestField(startDir, RETICLE_CONFIG_BASENAME, 'projectId', readFile, MAX_CONFIG_SEARCH_DEPTH, nonEmptyString);
91
+ }
92
+ /**
93
+ * Read the daemon port `reticle init --port` recorded in the nearest `.reticle.json`, or undefined.
94
+ *
95
+ * The CLI already obeys this field, so a plugin that ignored it put the two halves on different
96
+ * daemons: the page connected to the default port — on a machine running two projects, the other
97
+ * project's daemon — and the bridge refused it.
98
+ */
99
+ export function readConfiguredPort(startDir, readFile = readFileOrThrow) {
100
+ return readNearestField(startDir, RETICLE_CONFIG_BASENAME, 'port', readFile, MAX_CONFIG_SEARCH_DEPTH, tcpPort);
85
101
  }
86
102
  /**
87
103
  * Resolve the projectId for a plugin instance, most-local statement of intent first: an explicit
@@ -8,6 +8,7 @@
8
8
  * the JSX one is `@reticlehq/babel-plugin`, which also reads the opt-out on its own for the callers
9
9
  * that reach it without Vite.
10
10
  */
11
+ export declare const NODE_MODULES = "node_modules";
11
12
  /** A module id we may stamp at all: not virtual, not a dependency. Extension decides which stamper. */
12
13
  export declare function stampableId(id: string): string | null;
13
14
  export declare function shouldStamp(id: string): boolean;
package/dist/stamping.js CHANGED
@@ -17,7 +17,7 @@ import { SVELTE_FILE } from './svelte-source.js';
17
17
  const JSX_FILE = /\.[jt]sx$/;
18
18
  /** Rollup virtual-module ids start with a NUL byte; never transform those. */
19
19
  const VIRTUAL_PREFIX = '\0';
20
- const NODE_MODULES = 'node_modules';
20
+ export const NODE_MODULES = 'node_modules';
21
21
  /** A module id we may stamp at all: not virtual, not a dependency. Extension decides which stamper. */
22
22
  export function stampableId(id) {
23
23
  if (id.startsWith(VIRTUAL_PREFIX))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/vite-plugin",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "Vite plugin for Reticle: dev-only source-map stamping plus auto-injected reticle.connect(). apply:'serve' guarantees it never ships to production.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,15 +41,15 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@babel/core": "^7.26.0",
44
- "@reticlehq/babel-plugin": "3.3.0",
45
- "@reticlehq/core": "3.3.0"
44
+ "@reticlehq/babel-plugin": "3.5.0",
45
+ "@reticlehq/core": "3.5.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/babel__core": "^7.20.5",
49
49
  "esbuild": "^0.28.2",
50
- "svelte": "^5.56.10",
50
+ "svelte": "^5.57.1",
51
51
  "vite": "^8",
52
- "@reticlehq/react": "3.3.0"
52
+ "@reticlehq/react": "3.5.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "vite": ">=4"