@reticlehq/vite-plugin 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -72,15 +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
- * - The plugin also applies to `vite build`. A packaged desktop renderer IS a production build
76
- * loaded from `file://` or a custom protocol — there is no dev server — so the default
77
- * `apply: 'serve'` drops the plugin entirely and the app ships with no `connect()` at all.
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.
78
80
  * - `connect()` is called with `allowInProduction`, because that same renderer reports
79
81
  * NODE_ENV=production and the SDK's prod backstop would otherwise refuse to start.
80
82
  *
81
- * Off by default and never inferred: turning it on means an instrumented production BUNDLE, which
82
- * is exactly what a web app must never ship. Keep it behind your own dev-only build (a dev target,
83
- * or `process.env.NODE_ENV !== 'production'` in vite.config) so it cannot reach a release binary.
83
+ * Off by default and never inferred.
84
84
  */
85
85
  desktop?: boolean;
86
86
  /**
@@ -95,6 +95,20 @@ export interface ReticleVitePluginOptions {
95
95
  * Also settable as `VITE_RETICLE_CAPTURE_BODIES=1`.
96
96
  */
97
97
  captureNetworkBodies?: boolean;
98
+ /**
99
+ * Per-body character cap for captured bodies. Default 8192; clamped to [256, 262144].
100
+ *
101
+ * Reachable here for the reason `captureNetworkBodies` is: the plugin is the only `connect()`
102
+ * most apps ever have, so an SDK option the plugin cannot pass is an option that does not exist.
103
+ *
104
+ * Raise it to make a NEGATIVE `bodyContains` decidable -- a negation is checked over the whole
105
+ * payload, so on a list endpoint bigger than the cap it is permanently undecidable, and that is
106
+ * the class that proves "this dangerous field is absent from every row" (#799).
107
+ *
108
+ * Also settable as `VITE_RETICLE_BODY_MAX_CHARS=65536`, so one run can raise it without editing
109
+ * vite.config.
110
+ */
111
+ networkBodyMaxChars?: number;
98
112
  /**
99
113
  * Retain a FAILED request's response body even with `captureNetworkBodies` off. Default true.
100
114
  *
@@ -195,8 +209,6 @@ export interface ReticleVitePlugin {
195
209
  };
196
210
  };
197
211
  };
198
- /** Absent in desktop mode, where the plugin must also run for `vite build`. */
199
- apply?: 'serve';
200
212
  enforce: 'pre';
201
213
  transform: (code: string, id: string) => {
202
214
  code: string;
@@ -209,6 +221,8 @@ export interface ReticleVitePlugin {
209
221
  configResolved?: (config: {
210
222
  root?: string;
211
223
  command?: string;
224
+ /** 'production' unless `--mode` says otherwise. See isReticleDisabledBuild. */
225
+ mode?: string;
212
226
  base?: string;
213
227
  /** Vitest's block, read only to spot browser mode. See isVitestBrowserServer. */
214
228
  test?: unknown;
@@ -313,14 +327,12 @@ export declare function connectModuleSource(options: ReticleVitePluginOptions, d
313
327
  * import { reticle } from '@reticlehq/vite-plugin';
314
328
  * export default defineConfig({ plugins: [react(), reticle()] });
315
329
  *
316
- * `apply: 'serve'` means Vite drops the plugin entirely from `vite build`, so a web production
317
- * bundle is never instrumented — gating is the tool's job, not a user-managed env check.
330
+ * The web plugin also builds, replacing the browser SDK with an inert stub so a default
331
+ * production bundle ships no Reticle runtime code at all.
318
332
  *
319
- * `desktop: true` is the ONE documented exception, and it inverts that guarantee deliberately: a
320
- * packaged Electron/Tauri renderer IS a production build with no dev server, so serve-only gating
321
- * would ship an app with no connect() at all. The cost is that the flag hands gating back to the
322
- * caller — keep it behind your own dev-only build target so an instrumented bundle can never reach
323
- * 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.
324
336
  */
325
337
  /**
326
338
  * The daemon's journal directory, as a matcher every chokidar major honours.
package/dist/index.d.ts CHANGED
@@ -72,15 +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
- * - The plugin also applies to `vite build`. A packaged desktop renderer IS a production build
76
- * loaded from `file://` or a custom protocol — there is no dev server — so the default
77
- * `apply: 'serve'` drops the plugin entirely and the app ships with no `connect()` at all.
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.
78
80
  * - `connect()` is called with `allowInProduction`, because that same renderer reports
79
81
  * NODE_ENV=production and the SDK's prod backstop would otherwise refuse to start.
80
82
  *
81
- * Off by default and never inferred: turning it on means an instrumented production BUNDLE, which
82
- * is exactly what a web app must never ship. Keep it behind your own dev-only build (a dev target,
83
- * or `process.env.NODE_ENV !== 'production'` in vite.config) so it cannot reach a release binary.
83
+ * Off by default and never inferred.
84
84
  */
85
85
  desktop?: boolean;
86
86
  /**
@@ -95,6 +95,20 @@ export interface ReticleVitePluginOptions {
95
95
  * Also settable as `VITE_RETICLE_CAPTURE_BODIES=1`.
96
96
  */
97
97
  captureNetworkBodies?: boolean;
98
+ /**
99
+ * Per-body character cap for captured bodies. Default 8192; clamped to [256, 262144].
100
+ *
101
+ * Reachable here for the reason `captureNetworkBodies` is: the plugin is the only `connect()`
102
+ * most apps ever have, so an SDK option the plugin cannot pass is an option that does not exist.
103
+ *
104
+ * Raise it to make a NEGATIVE `bodyContains` decidable -- a negation is checked over the whole
105
+ * payload, so on a list endpoint bigger than the cap it is permanently undecidable, and that is
106
+ * the class that proves "this dangerous field is absent from every row" (#799).
107
+ *
108
+ * Also settable as `VITE_RETICLE_BODY_MAX_CHARS=65536`, so one run can raise it without editing
109
+ * vite.config.
110
+ */
111
+ networkBodyMaxChars?: number;
98
112
  /**
99
113
  * Retain a FAILED request's response body even with `captureNetworkBodies` off. Default true.
100
114
  *
@@ -195,8 +209,6 @@ export interface ReticleVitePlugin {
195
209
  };
196
210
  };
197
211
  };
198
- /** Absent in desktop mode, where the plugin must also run for `vite build`. */
199
- apply?: 'serve';
200
212
  enforce: 'pre';
201
213
  transform: (code: string, id: string) => {
202
214
  code: string;
@@ -209,6 +221,8 @@ export interface ReticleVitePlugin {
209
221
  configResolved?: (config: {
210
222
  root?: string;
211
223
  command?: string;
224
+ /** 'production' unless `--mode` says otherwise. See isReticleDisabledBuild. */
225
+ mode?: string;
212
226
  base?: string;
213
227
  /** Vitest's block, read only to spot browser mode. See isVitestBrowserServer. */
214
228
  test?: unknown;
@@ -313,14 +327,12 @@ export declare function connectModuleSource(options: ReticleVitePluginOptions, d
313
327
  * import { reticle } from '@reticlehq/vite-plugin';
314
328
  * export default defineConfig({ plugins: [react(), reticle()] });
315
329
  *
316
- * `apply: 'serve'` means Vite drops the plugin entirely from `vite build`, so a web production
317
- * bundle is never instrumented — gating is the tool's job, not a user-managed env check.
330
+ * The web plugin also builds, replacing the browser SDK with an inert stub so a default
331
+ * production bundle ships no Reticle runtime code at all.
318
332
  *
319
- * `desktop: true` is the ONE documented exception, and it inverts that guarantee deliberately: a
320
- * packaged Electron/Tauri renderer IS a production build with no dev server, so serve-only gating
321
- * would ship an app with no connect() at all. The cost is that the flag hands gating back to the
322
- * caller — keep it behind your own dev-only build target so an instrumented bundle can never reach
323
- * 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.
324
336
  */
325
337
  /**
326
338
  * The daemon's journal directory, as a matcher every chokidar major honours.
package/dist/index.js CHANGED
@@ -3,14 +3,15 @@ import { missingTokenWarning } from './token/missing-token.js';
3
3
  import { ensurePairingToken } from './token/ensure-token.js';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { transformSync } from '@babel/core';
7
- import reticleSource from '@reticlehq/babel-plugin';
8
- import { RETICLE_DEFAULT_PORT, RETICLE_RENDER_PREHOOK, bridgeWsUrl, ReticleDir, ReticleEnv, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, } from '@reticlehq/core';
6
+ import { RETICLE_RENDER_PREHOOK, ReticleDir, ReticleEnv, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, } from '@reticlehq/core';
9
7
  import { resolveProjectId } from './project-id.js';
10
- import { discoverDaemonPort } from './discover-port.js';
8
+ import { resolveDaemonPort } from './discover-port.js';
11
9
  import { announceDevServer } from './announce.js';
12
- import { SVELTE_FILE, stampSvelte } from './svelte-source.js';
10
+ import { stampSvelte } from './svelte-source.js';
11
+ import { NODE_MODULES, ignoredFileNotice, optsOutOfStamping, shouldStamp, shouldStampSvelte, stamp, } from './stamping.js';
13
12
  import { resolvableChain, sdkPackageVersion, sdkBuildFingerprint, viteMajor, optimizerOptionsKey, optimizerOptions, } from './installed.js';
13
+ import { RETICLE_DISABLED_STUB_CODE, RETICLE_DISABLED_STUB, isReticleDisabledBuild, } from './disabled-browser-stub.js';
14
+ import { connectArgs } from './connect-args.js';
14
15
  import { createInjectionWatch, notInjectedMessage, } from './injection-postcondition.js';
15
16
  import { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
16
17
  export { RETICLE_VITE_PLUGIN_NAME } from './plugin-name.js';
@@ -26,11 +27,6 @@ const RETICLE_SENSOR = '@reticlehq/browser';
26
27
  * file it lives in.
27
28
  */
28
29
  export const RETICLE_TOKEN_GLOBAL = '__RETICLE_TOKEN__';
29
- /** Files we stamp with source info — JSX/TSX only. */
30
- const JSX_FILE = /\.[jt]sx$/;
31
- /** Rollup virtual-module ids start with a NUL byte; never transform those. */
32
- const VIRTUAL_PREFIX = '\0';
33
- const NODE_MODULES = 'node_modules';
34
30
  /**
35
31
  * The connect code is served as a real module (not an inline <script>) so that Vite's import
36
32
  * pipeline resolves the bare `@reticlehq/react` specifier. An inline injected script is NOT run through
@@ -132,40 +128,6 @@ function isHtmlEntry(id, specifier, root) {
132
128
  // buildEnd post-condition means a wrong match cannot pass unnoticed as "nothing happened".
133
129
  return candidate.endsWith(target.startsWith('/') ? target : `/${target}`);
134
130
  }
135
- /** A module id we may stamp at all: not virtual, not a dependency. Extension decides which stamper. */
136
- function stampableId(id) {
137
- if (id.startsWith(VIRTUAL_PREFIX))
138
- return null;
139
- if (id.includes(NODE_MODULES))
140
- return null;
141
- // Strip any query suffix (?worker, ?raw,...) before matching the extension.
142
- return id.split('?')[0] ?? id;
143
- }
144
- function shouldStamp(id) {
145
- const clean = stampableId(id);
146
- return clean !== null && JSX_FILE.test(clean);
147
- }
148
- /** A `.svelte` single-file component, which needs the Svelte stamper rather than Babel. */
149
- function shouldStampSvelte(id) {
150
- const clean = stampableId(id);
151
- return clean !== null && SVELTE_FILE.test(clean);
152
- }
153
- function stamp(code, id) {
154
- const out = transformSync(code, {
155
- filename: id,
156
- plugins: [reticleSource],
157
- parserOpts: { plugins: ['jsx', 'typescript'] },
158
- sourceMaps: true,
159
- configFile: false,
160
- babelrc: false,
161
- });
162
- if (out?.code === undefined || null === out.code)
163
- return null;
164
- return {
165
- code: out.code,
166
- map: out.map === undefined || null === out.map ? null : JSON.stringify(out.map),
167
- };
168
- }
169
131
  /**
170
132
  * Read the daemon's auto-provisioned pairing token (~/.reticle/pairing-token, or the
171
133
  * RETICLE_PAIRING_TOKEN_DIR override) so the served app can present it. Node-side only — a browser
@@ -196,60 +158,6 @@ function warnIfTokenMissing(token) {
196
158
  }
197
159
  return token;
198
160
  }
199
- /** Build the `reticle.connect` argument literal — only includes keys the user set. */
200
- function connectArgs(options) {
201
- const args = {};
202
- const port = options.port ?? RETICLE_DEFAULT_PORT;
203
- if (port !== RETICLE_DEFAULT_PORT)
204
- args['url'] = bridgeWsUrl(port);
205
- if (options.session !== undefined)
206
- args['session'] = options.session;
207
- if (options.projectId !== undefined)
208
- args['projectId'] = options.projectId;
209
- if (options.token !== undefined)
210
- args['token'] = options.token;
211
- // Passed as connect ARGUMENTS, not as a `define`. A define substitutes a bare identifier in the
212
- // source it transforms; the SDK reads these as `globalThis[NAME]`, a dynamic lookup no define can
213
- // ever reach — so defining them looked right, shipped, and did nothing. Baking them into the
214
- // generated connect call is a literal in generated source: no bundler subtleties, works the same
215
- // in dev and in a desktop build.
216
- if (options.root !== undefined && options.root.length > 0)
217
- args['root'] = options.root;
218
- if (options.sdkVersion !== undefined && options.sdkVersion.length > 0) {
219
- args['sdkVersion'] = options.sdkVersion;
220
- }
221
- // A desktop renderer is a production build by construction; without this the SDK's prod backstop
222
- // refuses to connect and the app is silently uninstrumented.
223
- if (true === options.desktop)
224
- args['allowInProduction'] = true;
225
- // Env wins nothing — it only turns the flag ON, so a config that never set it can still be
226
- // switched on for one debugging session without editing vite.config and restarting the mental
227
- // model with it.
228
- if (true === options.captureNetworkBodies || '1' === process.env['VITE_RETICLE_CAPTURE_BODIES']) {
229
- args['captureNetworkBodies'] = true;
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
- }
241
- // Same shape, same reason. Off unless asked for, in a config or for one session.
242
- if (true === options.exposePresenter || '1' === process.env['VITE_RETICLE_EXPOSE_PRESENTER']) {
243
- args['exposePresenter'] = true;
244
- }
245
- // Same shape, same reason: without it an app that cannot be served on localhost has no way to
246
- // reach the SDK option at all. The pairing token still applies — see the option's docstring.
247
- if (true === options.allowNonLocalhost ||
248
- '1' === process.env['VITE_RETICLE_ALLOW_NON_LOCALHOST']) {
249
- args['allowNonLocalhost'] = true;
250
- }
251
- return Object.keys(args).length > 0 ? JSON.stringify(args) : '';
252
- }
253
161
  /** The body of the connect module — real imports, resolved by Vite when the module is served. */
254
162
  /**
255
163
  * The conventional app-side dev module: `registerStore` / `registerCapabilities` live here.
@@ -333,14 +241,12 @@ export function connectModuleSource(options, devModule = null) {
333
241
  * import { reticle } from '@reticlehq/vite-plugin';
334
242
  * export default defineConfig({ plugins: [react(), reticle()] });
335
243
  *
336
- * `apply: 'serve'` means Vite drops the plugin entirely from `vite build`, so a web production
337
- * bundle is never instrumented — gating is the tool's job, not a user-managed env check.
244
+ * The web plugin also builds, replacing the browser SDK with an inert stub so a default
245
+ * production bundle ships no Reticle runtime code at all.
338
246
  *
339
- * `desktop: true` is the ONE documented exception, and it inverts that guarantee deliberately: a
340
- * packaged Electron/Tauri renderer IS a production build with no dev server, so serve-only gating
341
- * would ship an app with no connect() at all. The cost is that the flag hands gating back to the
342
- * caller — keep it behind your own dev-only build target so an instrumented bundle can never reach
343
- * 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.
344
250
  */
345
251
  /**
346
252
  * The daemon's journal directory, as a matcher every chokidar major honours.
@@ -371,6 +277,10 @@ export function reticle(options = {}) {
371
277
  let root;
372
278
  /** 'serve' | 'build'. The dev check only applies to serve; buildEnd covers the other. */
373
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);
374
284
  /** Vite's resolved `base`. Undefined until configResolved, which is before any HTML is served. */
375
285
  let base;
376
286
  /** True only when THIS server is Vitest's browser-mode runner — see isVitestBrowserServer. */
@@ -378,21 +288,38 @@ export function reticle(options = {}) {
378
288
  const warn = options.onWarn ?? ((message) => globalThis.console.warn(message));
379
289
  /** Whether connect() actually reached a module — asserted at buildEnd, never assumed. */
380
290
  let injected = false;
291
+ /** Files already announced as opted out of stamping: one line each, for the life of the server. */
292
+ const announcedIgnored = new Set();
381
293
  /**
382
294
  * Whether Vite ever asked us to transform the app's HTML.
383
295
  *
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.
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.
387
298
  */
388
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;
389
311
  /**
390
312
  * Resolve port + token at the moment of injection, not at plugin construction. By the time a
391
313
  * module is served or built the daemon is up and has written its pairing token; resolving early
392
314
  * would bake in `undefined` and the app would fail auth on every connect.
393
315
  */
394
316
  const resolveLazy = () => {
395
- 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
+ }
396
323
  const withPort = port !== undefined ? { ...resolved, port } : resolved;
397
324
  const token = withPort.token ?? readPairingToken();
398
325
  const withToken = token !== undefined ? { ...withPort, token } : withPort;
@@ -421,15 +348,12 @@ export function reticle(options = {}) {
421
348
  desktop,
422
349
  inject,
423
350
  injected: () => injected,
424
- htmlTransformed: () => htmlTransformed,
351
+ connectDelivered: () => htmlTransformed || connectDelivered,
352
+ connectModule: () => connectModuleUrl(base),
425
353
  warn,
426
354
  });
427
355
  return {
428
356
  name: RETICLE_VITE_PLUGIN_NAME,
429
- // Web: serve-only, so a production bundle can never carry the SDK — gating is the tool's job.
430
- // Desktop: a packaged renderer IS a production build with no dev server, so the plugin must also
431
- // run for `vite build` or the shipped app has no connect() at all.
432
- ...(true === options.desktop ? {} : { apply: 'serve' }),
433
357
  enforce: 'pre',
434
358
  /**
435
359
  * Declare the SDK itself and the optimizer cache fingerprint.
@@ -511,6 +435,13 @@ export function reticle(options = {}) {
511
435
  };
512
436
  },
513
437
  transform(code, id) {
438
+ if (disabledBuild())
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
+ }
514
445
  // Desktop injection: prepend connect() to the HTML's own entry module. It is a REAL module, so
515
446
  // its bare `@reticlehq/react` import resolves through the normal pipeline in both dev and
516
447
  // build — which a virtual <script src> only ever did in dev.
@@ -522,6 +453,16 @@ export function reticle(options = {}) {
522
453
  }
523
454
  if (!sourceMapping)
524
455
  return null;
456
+ // Decided HERE, ahead of both stampers, so the Svelte path honours the marker too and so the
457
+ // opt-out is announced once regardless of which stamper would have run. The Babel plugin reads
458
+ // the same marker on its own for the callers that reach it without Vite (#853).
459
+ if (optsOutOfStamping(code, id)) {
460
+ if (!announcedIgnored.has(id)) {
461
+ announcedIgnored.add(id);
462
+ warn(ignoredFileNotice(id));
463
+ }
464
+ return null;
465
+ }
525
466
  // `.svelte` runs on the RAW component source, which is only still markup because this plugin
526
467
  // declares `enforce: 'pre'` and therefore transforms before @sveltejs/vite-plugin-svelte. No
527
468
  // map: the insertions are within a line and never move one, and a wrong map is worse than none.
@@ -534,6 +475,9 @@ export function reticle(options = {}) {
534
475
  return stamp(code, id);
535
476
  },
536
477
  resolveId(id, importer) {
478
+ if (disabledBuild() && id === RETICLE_SENSOR) {
479
+ return RETICLE_DISABLED_STUB;
480
+ }
537
481
  // Desktop: remember the module the HTML points at, so `transform` can prepend connect() into
538
482
  // it. A packaged build has no dev server, so the serve-time trick below — a <script src> at a
539
483
  // virtual URL — would emit a tag pointing at a file that does not exist. That shipped an app
@@ -549,15 +493,20 @@ export function reticle(options = {}) {
549
493
  return inject && id === RETICLE_CONNECT_MODULE ? RETICLE_CONNECT_MODULE : null;
550
494
  },
551
495
  load(id) {
496
+ if (disabledBuild() && id === RETICLE_DISABLED_STUB) {
497
+ return RETICLE_DISABLED_STUB_CODE;
498
+ }
552
499
  if (!inject || id !== RETICLE_CONNECT_MODULE)
553
500
  return null;
554
501
  const source = currentConnectSource();
555
502
  lastServedConnectSource = source;
503
+ connectDelivered = true;
556
504
  return source;
557
505
  },
558
506
  configResolved(config) {
559
507
  root = config.root;
560
508
  command = config.command;
509
+ mode = config.mode;
561
510
  base = config.base;
562
511
  vitestBrowser = isVitestBrowserServer(config);
563
512
  },
@@ -582,8 +531,6 @@ export function reticle(options = {}) {
582
531
  * appearing IS a change), and makes the module inert once it has settled.
583
532
  */
584
533
  configureServer(server) {
585
- if (!inject)
586
- return;
587
534
  // The web post-condition is armed by the first DOCUMENT REQUEST, in the middleware below.
588
535
  //
589
536
  // Not from `transformIndexHtml`, because a framework that renders its own HTML never calls it
@@ -635,6 +582,12 @@ export function reticle(options = {}) {
635
582
  else {
636
583
  announce();
637
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;
638
591
  server.middlewares.use((req, _res, next) => {
639
592
  // One middleware, two observations. A second `use()` would work equally well in Vite and
640
593
  // is the obvious way to write this, but it makes the ORDER of registration load-bearing for
@@ -664,7 +617,8 @@ export function reticle(options = {}) {
664
617
  * build that could not instrument must fail loudly instead of shipping a binary that lies.
665
618
  */
666
619
  buildEnd() {
667
- if (!desktop || !inject || injected)
620
+ // A production build injects nothing on purpose; see isReticleDisabledBuild.
621
+ if (!desktop || !inject || injected || disabledBuild())
668
622
  return;
669
623
  throw new Error(notInjectedMessage());
670
624
  },
@@ -672,6 +626,8 @@ export function reticle(options = {}) {
672
626
  checkHtmlHookForTest: watch.checkHtmlHookRan,
673
627
  injectionWatchForTest: watch,
674
628
  transformIndexHtml() {
629
+ if (disabledBuild())
630
+ return [];
675
631
  htmlTransformed = true;
676
632
  if (desktop && inject && 'serve' === command)
677
633
  watch.armDesktopCheck();
@@ -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