@reticlehq/vite-plugin 2.4.0 → 2.6.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.
@@ -33,7 +33,7 @@ export function discoverDaemonPort(projectId, home = join(homedir(), ReticleDir.
33
33
  return undefined; // no ~/.reticle yet
34
34
  }
35
35
  for (const file of files) {
36
- if (daemonRegistryPort(file) === null)
36
+ if (null === daemonRegistryPort(file))
37
37
  continue;
38
38
  try {
39
39
  const parsed = DaemonRegistryEntrySchema.safeParse(JSON.parse(readFileSync(join(home, file), 'utf8')));
@@ -0,0 +1 @@
1
+ export declare function ensurePairingToken(dir: string): string | undefined;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Read the pairing token, or create it — whichever process gets there first.
3
+ *
4
+ * The token is read ONCE, when Vite resolves its config, and inlined as `__RETICLE_TOKEN__`. The
5
+ * daemon is what normally writes it. So a dev server started BEFORE the daemon froze an empty
6
+ * string, every page it served was refused by the bridge, and no session ever appeared — with the
7
+ * SDK loading and the socket opening, so nothing looked broken. Bisected against a real SvelteKit
8
+ * fixture, where it presented as a regression.
9
+ *
10
+ * Restarting the dev server was the only cure, because by the time the daemon minted a token the
11
+ * empty value had already been baked in.
12
+ *
13
+ * The daemon READS-OR-CREATES this file (see the server's `readOrCreatePairingToken`, kept "stable
14
+ * across restarts so a plugin-injected page keeps working after the daemon bounces"), so the plugin
15
+ * doing the same makes the two agree whichever order they start in. Same path, same 0600 mode, same
16
+ * random generation. An existing token is never replaced: overwriting would invalidate every page
17
+ * the daemon has already handed one to.
18
+ */
19
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { randomBytes } from 'node:crypto';
21
+ import { join } from 'node:path';
22
+ /** Matches the server's token size. */
23
+ const TOKEN_BYTES = 32;
24
+ const TOKEN_FILE = 'pairing-token';
25
+ export function ensurePairingToken(dir) {
26
+ try {
27
+ const existing = readFileSync(join(dir, TOKEN_FILE), 'utf8').trim();
28
+ if (existing.length > 0)
29
+ return existing;
30
+ }
31
+ catch {
32
+ /* missing or unreadable — fall through and create one */
33
+ }
34
+ try {
35
+ const token = randomBytes(TOKEN_BYTES).toString('hex');
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const path = join(dir, TOKEN_FILE);
38
+ writeFileSync(path, token, { encoding: 'utf8', mode: 0o600 });
39
+ // writeFile keeps a pre-existing looser mode, so set it explicitly — same as the daemon does.
40
+ chmodSync(path, 0o600);
41
+ return token;
42
+ }
43
+ catch {
44
+ // A dev server must still start. Degrading to the previous tokenless behaviour is correct.
45
+ return undefined;
46
+ }
47
+ }
package/dist/index.d.ts CHANGED
@@ -24,6 +24,16 @@ export declare const RENDER_PREHOOK_SOURCE = "(function(){try{\nvar K='__REACT_D
24
24
  export interface ReticleVitePluginOptions {
25
25
  /** Bridge WebSocket port. Defaults to the SDK default; only baked into connect when non-default. */
26
26
  port?: number;
27
+ /**
28
+ * Project root, so React's absolute `_debugSource.fileName` reports repo-relative. Resolved from
29
+ * the Vite config at injection time; set it only to override.
30
+ */
31
+ root?: string;
32
+ /**
33
+ * The installed SDK's version, so a pair skewed against the daemon can name itself instead of
34
+ * surfacing as a bare -32000. Read from the installed package; set it only to override.
35
+ */
36
+ sdkVersion?: string;
27
37
  /** Stable session label for the bridge. Defaults to the SDK's auto-generated id. */
28
38
  session?: string;
29
39
  /**
@@ -85,18 +95,16 @@ export interface ReticleVitePlugin {
85
95
  config?: (config: {
86
96
  optimizeDeps?: {
87
97
  include?: string[];
88
- esbuildOptions?: {
89
- define?: Record<string, string>;
90
- };
98
+ /** Whichever key the app used — the plugin reads both and writes the one this Vite wants. */
99
+ esbuildOptions?: Record<string, unknown>;
100
+ rolldownOptions?: Record<string, unknown>;
91
101
  };
92
102
  define?: Record<string, string>;
93
103
  root?: string;
94
104
  }) => {
95
105
  optimizeDeps: {
96
106
  include: string[];
97
- esbuildOptions: {
98
- define: Record<string, string>;
99
- };
107
+ [optionsKey: string]: unknown;
100
108
  };
101
109
  define: Record<string, string>;
102
110
  };
@@ -115,11 +123,28 @@ export interface ReticleVitePlugin {
115
123
  root?: string;
116
124
  command?: string;
117
125
  }) => void;
126
+ /** Dev-server hook: keeps the served connect module from outliving the token it was built without. */
127
+ configureServer?: (server: ViteDevServerLike) => void;
118
128
  /** Build-time post-condition: desktop injection must have happened. */
119
129
  buildEnd?: () => void;
120
130
  /** Runs the dev-mode injection check immediately. Test seam for the deferred timer. */
121
131
  checkInjectedForTest?: () => void;
122
132
  }
133
+ /**
134
+ * The slice of Vite's dev server this plugin touches, structurally — so `vite` stays a peer the
135
+ * plugin never imports, the same way the Svelte compiler and Playwright are handled elsewhere.
136
+ */
137
+ export interface ViteDevServerLike {
138
+ middlewares: {
139
+ use: (handler: (req: {
140
+ url?: string;
141
+ }, res: unknown, next: () => void) => void) => void;
142
+ };
143
+ moduleGraph: {
144
+ getModuleById: (id: string) => object | undefined;
145
+ invalidateModule: (mod: object) => void;
146
+ };
147
+ }
123
148
  interface HtmlTag {
124
149
  tag: string;
125
150
  /** Absent on an inline script, which carries its source in `children` instead. */
@@ -129,6 +154,29 @@ interface HtmlTag {
129
154
  /** `head-prepend` is required for the render pre-hook: it must run before any module script. */
130
155
  injectTo: 'body' | 'head-prepend';
131
156
  }
157
+ /**
158
+ * Name each CJS dep ONLY in the bare form, and only when the app root can resolve it.
159
+ *
160
+ * This used to try three layouts and emit Vite's nested `a > b > c` form when a hoisted lookup
161
+ * failed. The guard asked the wrong question: it tested NODE resolvability, walking the chain
162
+ * segment by segment, and under pnpm that succeeds exactly where Vite fails. Measured on the
163
+ * sveltekit fixture:
164
+ *
165
+ * ['@testing-library/dom'] -> null
166
+ * ['@reticlehq/browser', '@testing-library/dom'] -> null
167
+ * ['@reticlehq/react', '@reticlehq/browser', '@testing-library/dom'] -> emitted
168
+ *
169
+ * So we emitted the three-segment chain, Vite could not follow it, and the boot warning this
170
+ * function exists to prevent appeared anyway — `Failed to resolve dependency: …, present in
171
+ * optimizeDeps.include`, pointing at Reticle, naming a package the developer has never heard of,
172
+ * and forcing a full re-optimization on every cold start. The comment stated the rule correctly and
173
+ * the code broke it.
174
+ *
175
+ * Dropping the nested form loses nothing that matters: the SDK itself is still pre-bundled, and Vite
176
+ * follows its imports when it does that, so these deps are handled as part of it. Naming them
177
+ * separately was belt-and-braces for a locally-aliased SDK, where the bare form resolves anyway.
178
+ */
179
+ export declare function cjsDepIncludes(appRoot: string, canResolve?: (dep: string) => boolean): string[];
132
180
  export declare function readPairingToken(): string | undefined;
133
181
  /** The body of the connect module — real imports, resolved by Vite when the module is served. */
134
182
  /**
package/dist/index.js CHANGED
@@ -1,13 +1,15 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
+ import { missingTokenWarning } from './missing-token.js';
3
+ import { ensurePairingToken } from './ensure-token.js';
2
4
  import { homedir } from 'node:os';
3
5
  import { join } from 'node:path';
4
6
  import { transformSync } from '@babel/core';
5
7
  import reticleSource from '@reticlehq/babel-plugin';
6
- import { RETICLE_DEFAULT_PORT, RETICLE_RENDER_PREHOOK, bridgeWsUrl, ReticleDir, ReticleEnv, RETICLE_ROOT_GLOBAL, } from '@reticlehq/core';
8
+ import { RETICLE_DEFAULT_PORT, RETICLE_RENDER_PREHOOK, bridgeWsUrl, ReticleDir, ReticleEnv, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, } from '@reticlehq/core';
7
9
  import { resolveProjectId } from './project-id.js';
8
10
  import { discoverDaemonPort } from './discover-port.js';
9
11
  import { SVELTE_FILE, stampSvelte } from './svelte-source.js';
10
- import { createRequire } from 'node:module';
12
+ import { resolvableChain, sdkPackageVersion, sdkBuildFingerprint, viteMajor, optimizerOptionsKey, optimizerOptions, } from './installed.js';
11
13
  export const RETICLE_VITE_PLUGIN_NAME = 'reticle';
12
14
  // The React kit the host app imports the SDK from. It re-exports the browser sensor, so a single
13
15
  // specifier yields both `reticle` (connect) and `install` (the React adapter). NOT `@reticlehq/core`
@@ -19,43 +21,6 @@ const RETICLE_PACKAGE = '@reticlehq/react';
19
21
  * file it lives in.
20
22
  */
21
23
  export const RETICLE_TOKEN_GLOBAL = '__RETICLE_TOKEN__';
22
- /**
23
- * Whether a package can be resolved from this process. Used to avoid declaring an optimizeDeps entry
24
- * for something the app does not have, which Vite reports as a resolve failure on every boot.
25
- */
26
- function isResolvable(specifier) {
27
- try {
28
- createRequire(import.meta.url).resolve(specifier);
29
- return true;
30
- }
31
- catch {
32
- return false;
33
- }
34
- }
35
- /**
36
- * A fingerprint of the installed SDK build, mixed into `optimizeDeps` so Vite re-bundles when the
37
- * SDK changes.
38
- *
39
- * Vite's dep-optimizer cache is keyed on the `optimizeDeps` config and the lockfile — NOT on the
40
- * contents of the packages it bundled. Upgrade the SDK in place (a patched dist, a linked checkout,
41
- * an overlay) and the version in `package.json` can stay the same, so Vite keeps serving the OLD
42
- * pre-bundled copy out of `node_modules/.vite` across dev-server restarts. The fix you just shipped
43
- * is simply not in the browser, and it looks like the fix does not work. That cost a real
44
- * false-negative during this bug hunt, and every user upgrading in place hits the same thing.
45
- *
46
- * Size+mtime is enough: it changes whenever the bundle does and costs one `stat`.
47
- */
48
- function sdkBuildFingerprint() {
49
- try {
50
- const entry = createRequire(import.meta.url).resolve(RETICLE_PACKAGE);
51
- const { size, mtimeMs } = statSync(entry);
52
- return `${String(size)}-${String(Math.trunc(mtimeMs))}`;
53
- }
54
- catch {
55
- // Unresolvable (not installed yet, exotic layout) — a constant is still correct, just inert.
56
- return 'unknown';
57
- }
58
- }
59
24
  /** Files we stamp with source info — JSX/TSX only. */
60
25
  const JSX_FILE = /\.[jt]sx$/;
61
26
  /** Rollup virtual-module ids start with a NUL byte; never transform those. */
@@ -149,11 +114,11 @@ function stamp(code, id) {
149
114
  configFile: false,
150
115
  babelrc: false,
151
116
  });
152
- if (out?.code === undefined || out.code === null)
117
+ if (out?.code === undefined || null === out.code)
153
118
  return null;
154
119
  return {
155
120
  code: out.code,
156
- map: out.map === undefined || out.map === null ? null : JSON.stringify(out.map),
121
+ map: out.map === undefined || null === out.map ? null : JSON.stringify(out.map),
157
122
  };
158
123
  }
159
124
  /**
@@ -170,16 +135,58 @@ const SDK_CJS_DEPS = {
170
135
  TESTING_LIBRARY: '@testing-library/dom',
171
136
  ARIA_QUERY: 'aria-query',
172
137
  };
138
+ /**
139
+ * Name each CJS dep ONLY in the bare form, and only when the app root can resolve it.
140
+ *
141
+ * This used to try three layouts and emit Vite's nested `a > b > c` form when a hoisted lookup
142
+ * failed. The guard asked the wrong question: it tested NODE resolvability, walking the chain
143
+ * segment by segment, and under pnpm that succeeds exactly where Vite fails. Measured on the
144
+ * sveltekit fixture:
145
+ *
146
+ * ['@testing-library/dom'] -> null
147
+ * ['@reticlehq/browser', '@testing-library/dom'] -> null
148
+ * ['@reticlehq/react', '@reticlehq/browser', '@testing-library/dom'] -> emitted
149
+ *
150
+ * So we emitted the three-segment chain, Vite could not follow it, and the boot warning this
151
+ * function exists to prevent appeared anyway — `Failed to resolve dependency: …, present in
152
+ * optimizeDeps.include`, pointing at Reticle, naming a package the developer has never heard of,
153
+ * and forcing a full re-optimization on every cold start. The comment stated the rule correctly and
154
+ * the code broke it.
155
+ *
156
+ * Dropping the nested form loses nothing that matters: the SDK itself is still pre-bundled, and Vite
157
+ * follows its imports when it does that, so these deps are handled as part of it. Naming them
158
+ * separately was belt-and-braces for a locally-aliased SDK, where the bare form resolves anyway.
159
+ */
160
+ export function cjsDepIncludes(appRoot,
161
+ // Injected so the rule can be tested hermetically. Real module resolution is not: under vitest,
162
+ // `createRequire` from a directory that does not exist still resolves packages out of the runner's
163
+ // own graph, so a filesystem-based test of "nothing is reachable here" silently asserts nothing.
164
+ canResolve = (dep) => null !== resolvableChain([dep], appRoot)) {
165
+ return [SDK_CJS_DEPS.TESTING_LIBRARY, SDK_CJS_DEPS.ARIA_QUERY].filter(canResolve);
166
+ }
173
167
  export function readPairingToken() {
174
168
  const override = process.env[ReticleEnv.PAIRING_TOKEN_DIR];
175
169
  const dir = override !== undefined && override.length > 0 ? override : join(homedir(), ReticleDir.ROOT);
176
- try {
177
- const token = readFileSync(join(dir, ReticleDir.PAIRING_TOKEN_FILE), 'utf8').trim();
178
- return token.length > 0 ? token : undefined;
179
- }
180
- catch {
181
- return undefined;
170
+ // Read-or-CREATE, matching the daemon. Reading alone meant a dev server started before the daemon
171
+ // baked in an empty token and every page it served was refused — with the SDK loading and the
172
+ // socket opening, so nothing looked broken. See ensure-token for the bisect.
173
+ return ensurePairingToken(dir);
174
+ }
175
+ /**
176
+ * Pass the token through, saying so once when it is absent.
177
+ *
178
+ * Warned HERE rather than at connect time because this is the moment the value is frozen: by the
179
+ * time the app is refused, the empty string was inlined minutes ago and restarting the dev server is
180
+ * the only fix. Once per config resolve, so a watch-mode rebuild does not repeat it.
181
+ */
182
+ let tokenWarned = false;
183
+ function warnIfTokenMissing(token) {
184
+ const warning = missingTokenWarning(token);
185
+ if (warning !== undefined && !tokenWarned) {
186
+ tokenWarned = true;
187
+ console.warn(warning);
182
188
  }
189
+ return token;
183
190
  }
184
191
  /** Build the `reticle.connect` argument literal — only includes keys the user set. */
185
192
  function connectArgs(options) {
@@ -193,14 +200,24 @@ function connectArgs(options) {
193
200
  args['projectId'] = options.projectId;
194
201
  if (options.token !== undefined)
195
202
  args['token'] = options.token;
203
+ // Passed as connect ARGUMENTS, not as a `define`. A define substitutes a bare identifier in the
204
+ // source it transforms; the SDK reads these as `globalThis[NAME]`, a dynamic lookup no define can
205
+ // ever reach — so defining them looked right, shipped, and did nothing. Baking them into the
206
+ // generated connect call is a literal in generated source: no bundler subtleties, works the same
207
+ // in dev and in a desktop build.
208
+ if (options.root !== undefined && options.root.length > 0)
209
+ args['root'] = options.root;
210
+ if (options.sdkVersion !== undefined && options.sdkVersion.length > 0) {
211
+ args['sdkVersion'] = options.sdkVersion;
212
+ }
196
213
  // A desktop renderer is a production build by construction; without this the SDK's prod backstop
197
214
  // refuses to connect and the app is silently uninstrumented.
198
- if (options.desktop === true)
215
+ if (true === options.desktop)
199
216
  args['allowInProduction'] = true;
200
217
  // Env wins nothing — it only turns the flag ON, so a config that never set it can still be
201
218
  // switched on for one debugging session without editing vite.config and restarting the mental
202
219
  // model with it.
203
- if (options.captureNetworkBodies === true || process.env['VITE_RETICLE_CAPTURE_BODIES'] === '1') {
220
+ if (true === options.captureNetworkBodies || '1' === process.env['VITE_RETICLE_CAPTURE_BODIES']) {
204
221
  args['captureNetworkBodies'] = true;
205
222
  }
206
223
  return Object.keys(args).length > 0 ? JSON.stringify(args) : '';
@@ -233,7 +250,7 @@ export function connectModuleSource(options, devModule = null) {
233
250
  const base = `import { reticle, install } from '${RETICLE_PACKAGE}';\ninstall();\nreticle.connect(${args});\n`;
234
251
  // AFTER connect: registerStore subscribes through the live SDK, and registering before there is a
235
252
  // session to report into drops the first diffs.
236
- return devModule === null ? base : `${base}import('${devModule}');\n`;
253
+ return null === devModule ? base : `${base}import('${devModule}');\n`;
237
254
  }
238
255
  /**
239
256
  * Reticle Vite plugin. Add to your `plugins` array and the entire integration is done:
@@ -253,7 +270,7 @@ export function connectModuleSource(options, devModule = null) {
253
270
  export function reticle(options = {}) {
254
271
  const sourceMapping = options.sourceMapping !== false;
255
272
  const inject = options.inject !== false;
256
- const desktop = options.desktop === true;
273
+ const desktop = true === options.desktop;
257
274
  // Resolve the stable projectId once (explicit option, else derived from package.json + cwd) so the
258
275
  // app is identifiable across port changes with zero config.
259
276
  const resolved = {
@@ -284,7 +301,12 @@ export function reticle(options = {}) {
284
301
  const port = resolved.port ?? discoverDaemonPort(resolved.projectId);
285
302
  const withPort = port !== undefined ? { ...resolved, port } : resolved;
286
303
  const token = withPort.token ?? readPairingToken();
287
- return token !== undefined ? { ...withPort, token } : withPort;
304
+ const withToken = token !== undefined ? { ...withPort, token } : withPort;
305
+ // Resolved here for the same reason as the token: these are Node-side facts about the installed
306
+ // tree, and they travel in the generated connect call rather than through a `define`.
307
+ const appRoot = withToken.root ?? root ?? process.cwd();
308
+ const sdkVersion = withToken.sdkVersion ?? sdkPackageVersion(appRoot);
309
+ return { ...withToken, root: appRoot, sdkVersion };
288
310
  };
289
311
  /**
290
312
  * The BUILD message. A build always runs every transform, so "my transform never ran" and "the
@@ -322,7 +344,7 @@ export function reticle(options = {}) {
322
344
  // Web: serve-only, so a production bundle can never carry the SDK — gating is the tool's job.
323
345
  // Desktop: a packaged renderer IS a production build with no dev server, so the plugin must also
324
346
  // run for `vite build` or the shipped app has no connect() at all.
325
- ...(options.desktop === true ? {} : { apply: 'serve' }),
347
+ ...(true === options.desktop ? {} : { apply: 'serve' }),
326
348
  enforce: 'pre',
327
349
  /**
328
350
  * Declare the SDK's CJS runtime deps so Vite pre-bundles them.
@@ -342,6 +364,10 @@ export function reticle(options = {}) {
342
364
  * tree depends on @testing-library/dom.
343
365
  */
344
366
  config(config) {
367
+ // Everything below asks what the APP has installed, so every lookup is rooted here and never
368
+ // at the plugin's own location. Vite defaults an omitted root to the cwd; so do we.
369
+ const appRoot = config.root ?? process.cwd();
370
+ const optimizerKey = optimizerOptionsKey(viteMajor(appRoot));
345
371
  return {
346
372
  // Expose the daemon's pairing token to hand-written connects in the same Vite app. The
347
373
  // plugin's own injected connect gets the token directly, but a connect the USER writes —
@@ -350,21 +376,28 @@ export function reticle(options = {}) {
350
376
  // failed". Empty until the daemon has provisioned one; the page reloads once it has.
351
377
  define: {
352
378
  ...(config.define ?? {}),
353
- [RETICLE_TOKEN_GLOBAL]: JSON.stringify(readPairingToken() ?? ''),
379
+ [RETICLE_TOKEN_GLOBAL]: JSON.stringify(warnIfTokenMissing(readPairingToken()) ?? ''),
354
380
  // Lets the SDK report React's absolute `_debugSource.fileName` as a repo-relative path,
355
381
  // so source looks the same whichever React version an app is on.
356
- [RETICLE_ROOT_GLOBAL]: JSON.stringify(config.root ?? process.cwd()),
382
+ // Kept for HAND-WRITTEN connects (SvelteKit's hook, a custom entry): those live in app
383
+ // source, where a define does substitute. The plugin's own injected connect passes both as
384
+ // arguments instead — see connectArgs.
385
+ [RETICLE_ROOT_GLOBAL]: JSON.stringify(appRoot),
386
+ [RETICLE_SDK_VERSION_GLOBAL]: JSON.stringify(sdkPackageVersion(appRoot)),
357
387
  },
358
388
  optimizeDeps: {
359
389
  // Part of the cache key, not of the build: changing it is what makes Vite notice that the
360
390
  // SDK on disk is not the SDK it pre-bundled. See sdkBuildFingerprint.
361
- esbuildOptions: {
391
+ //
392
+ // Under the key THIS Vite wants. Vite 7 moved the optimizer to rolldown and deprecated
393
+ // `esbuildOptions`, warning on every boot — a warning attributed to the plugin that set
394
+ // it, which is us.
395
+ // Inherited from whichever key the app used, and `define` placed where this bundler will
396
+ // take it — rolldown refuses it at the top level. See optimizerOptions.
397
+ [optimizerKey]: optimizerOptions(optimizerKey, {
362
398
  ...(config.optimizeDeps?.esbuildOptions ?? {}),
363
- define: {
364
- ...(config.optimizeDeps?.esbuildOptions?.define ?? {}),
365
- __RETICLE_SDK_BUILD__: JSON.stringify(sdkBuildFingerprint()),
366
- },
367
- },
399
+ ...(config.optimizeDeps?.rolldownOptions ?? {}),
400
+ }, { __RETICLE_SDK_BUILD__: JSON.stringify(sdkBuildFingerprint(appRoot)) }),
368
401
  include: [
369
402
  ...(config.optimizeDeps?.include ?? []),
370
403
  // The SDK ITSELF. Without this, Vite does not learn about @reticlehq/react until the
@@ -374,9 +407,9 @@ export function reticle(options = {}) {
374
407
  // the one the whole product is judged on — silently did nothing, and it worked on the
375
408
  // next refresh, which is the worst possible shape for a bug like this.
376
409
  RETICLE_PACKAGE,
377
- // Only if present — see above; naming an absent package produces a boot warning that
378
- // blames Reticle for nothing.
379
- ...[SDK_CJS_DEPS.TESTING_LIBRARY, SDK_CJS_DEPS.ARIA_QUERY].filter(isResolvable),
410
+ // Only in a form that resolves — see above; a name Vite cannot resolve produces a boot
411
+ // warning that blames Reticle, and a forced re-optimization on every cold start.
412
+ ...cjsDepIncludes(appRoot),
380
413
  ],
381
414
  },
382
415
  };
@@ -398,7 +431,7 @@ export function reticle(options = {}) {
398
431
  // map: the insertions are within a line and never move one, and a wrong map is worse than none.
399
432
  if (shouldStampSvelte(id)) {
400
433
  const stamped = stampSvelte(code, id);
401
- return stamped === null ? null : { code: stamped, map: null };
434
+ return null === stamped ? null : { code: stamped, map: null };
402
435
  }
403
436
  if (!shouldStamp(id))
404
437
  return null;
@@ -430,6 +463,33 @@ export function reticle(options = {}) {
430
463
  root = config.root;
431
464
  command = config.command;
432
465
  },
466
+ /**
467
+ * Serve the connect module fresh, every time.
468
+ *
469
+ * `load` reads the daemon's pairing token at serve time precisely because the daemon may start
470
+ * after the dev server — but Vite caches the module it produced, and answers every later request
471
+ * from that cache, INCLUDING after a full page reload. So a dev server started first served a
472
+ * tokenless connect module once and then kept serving it: the SDK got a 1008 `authentication
473
+ * failed`, stopped retrying (correctly — a wrong token does not fix itself), and `reticle status`
474
+ * showed no session while the page demonstrably contained `/@reticle-connect`. Only restarting
475
+ * the dev server cleared it, which is not a step anybody guesses.
476
+ *
477
+ * Dropping the cached module before it is served makes `load` re-read the token, so starting the
478
+ * daemon and reloading the page is enough. Costs one string compare per request and re-runs a
479
+ * three-line module — no reason to be cleverer about when to invalidate.
480
+ */
481
+ configureServer(server) {
482
+ if (!inject)
483
+ return;
484
+ server.middlewares.use((req, _res, next) => {
485
+ if ((req.url ?? '').split('?')[0] === RETICLE_CONNECT_MODULE) {
486
+ const mod = server.moduleGraph.getModuleById(RETICLE_CONNECT_MODULE);
487
+ if (mod !== undefined)
488
+ server.moduleGraph.invalidateModule(mod);
489
+ }
490
+ next();
491
+ });
492
+ },
433
493
  /**
434
494
  * Desktop injection is silent when it misses — the bundle simply has no connect() in it and the
435
495
  * app looks wired while reporting nothing. That happened twice while this was being built. A
@@ -445,7 +505,7 @@ export function reticle(options = {}) {
445
505
  // In serve, the HTML is sent BEFORE the browser requests the entry module, so the check has to
446
506
  // be deferred — asserting here would fire on every healthy start. Unref'd so a dev server is
447
507
  // never held open by it.
448
- if (desktop && inject && command === 'serve') {
508
+ if (desktop && inject && 'serve' === command) {
449
509
  const timer = setTimeout(checkInjected, DEV_INJECTION_GRACE_MS);
450
510
  timer.unref?.();
451
511
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Node-side questions about what is actually installed in the app.
3
+ *
4
+ * All three answer "what is on disk right now" rather than "what does the plugin do", and each exists
5
+ * because guessing was wrong in a way that reached a user: a declared-but-absent dependency logs a
6
+ * resolve failure on every boot, an unnoticed SDK change leaves stale code in the browser, and an
7
+ * unreported version makes a skewed pair surface as a bare -32000.
8
+ *
9
+ * All three resolve from the APP ROOT, never from this file. The SDK is the user's dependency and
10
+ * deliberately not one of this plugin's, so a resolve based on `import.meta.url` walks the PLUGIN's
11
+ * node_modules — which under pnpm's strict layout does not contain the SDK at all. Every one of these
12
+ * then fell into its own catch and returned the "not installed" answer: no `sdkVersion` on the HELLO,
13
+ * a CONSTANT build fingerprint (so Vite never re-bundled a changed SDK — the exact stale-bundle
14
+ * false-negative the fingerprint exists to prevent), and no optimizeDeps entry for the SDK itself.
15
+ * Silently, for every pnpm user, with no error anywhere.
16
+ */
17
+ /**
18
+ * Whether a package can be resolved from the app. Used to avoid declaring an optimizeDeps entry for
19
+ * something the app does not have, which Vite reports as a resolve failure on every boot.
20
+ */
21
+ export declare function isResolvable(specifier: string, from?: string): boolean;
22
+ /**
23
+ * Vite's nested include form — `a > b > c` — for a chain that fully resolves, else null.
24
+ *
25
+ * A single-segment chain is just the bare specifier, so this also answers "can the app see it
26
+ * itself". Each further segment is resolved from the PREVIOUS package's directory, which is what
27
+ * Vite does with the `>` form and the only way to name a dependency the app root cannot see: under
28
+ * pnpm (and npm's nested layout) `@testing-library/dom` belongs to `@reticlehq/browser`, not to the
29
+ * user's app. Naming it bare made Vite fail to resolve it and force a re-optimization on every cold
30
+ * boot. SvelteKit ships `svelte > clsx` for the same reason.
31
+ */
32
+ export declare function resolvableChain(chain: readonly string[], from: string): string | null;
33
+ /** The installed SDK's package version, for the HELLO's `sdkVersion`. Node-side only. */
34
+ export declare function sdkPackageVersion(from?: string): string;
35
+ /**
36
+ * A fingerprint of the installed SDK build, mixed into `optimizeDeps` so Vite re-bundles when the
37
+ * SDK changes.
38
+ *
39
+ * Vite's dep-optimizer cache is keyed on the `optimizeDeps` config and the lockfile — NOT on the
40
+ * contents of the packages it bundled. Upgrade the SDK in place (a patched dist, a linked checkout,
41
+ * an overlay) and the version in `package.json` can stay the same, so Vite keeps serving the OLD
42
+ * pre-bundled copy out of `node_modules/.vite` across dev-server restarts. The fix you just shipped
43
+ * is simply not in the browser, and it looks like the fix does not work. That cost a real
44
+ * false-negative during this bug hunt, and every user upgrading in place hits the same thing.
45
+ *
46
+ * Size+mtime is enough: it changes whenever the bundle does and costs one `stat`.
47
+ */
48
+ export declare function sdkBuildFingerprint(from?: string): string;
49
+ /**
50
+ * The installed Vite's major version, or null when it cannot be read.
51
+ *
52
+ * Asked from the APP's root, never the plugin's: the plugin and the app can resolve different Vites
53
+ * in a monorepo, and the one whose deprecation warnings the user sees is the app's.
54
+ */
55
+ export declare function viteMajor(from?: string): number | null;
56
+ /** Vite renamed the dep-optimizer's passthrough options when it moved to rolldown. */
57
+ export declare const OPTIMIZER_OPTIONS_KEY: {
58
+ readonly ESBUILD: "esbuildOptions";
59
+ readonly ROLLDOWN: "rolldownOptions";
60
+ };
61
+ export type OptimizerOptionsKey = (typeof OPTIMIZER_OPTIONS_KEY)[keyof typeof OPTIMIZER_OPTIONS_KEY];
62
+ /**
63
+ * Which key carries optimizer options on this Vite.
64
+ *
65
+ * Vite 7 moved the dep optimizer to rolldown and deprecated `optimizeDeps.esbuildOptions`, warning
66
+ * on every boot to use `rolldownOptions` instead — a warning attributed to whichever plugin set it,
67
+ * which is us. Unknown versions get the older key: a deprecation notice is a much smaller failure
68
+ * than an option the installed Vite has never heard of.
69
+ */
70
+ export declare function optimizerOptionsKey(major: number | null): OptimizerOptionsKey;
71
+ /**
72
+ * The optimizer options object, with `define` where THIS bundler will accept it.
73
+ *
74
+ * esbuild reads `define` from the top level of its options. Rolldown does not — it rejects the key
75
+ * outright and reads defines from `transform.define` instead. Vite 8 surfaces that as a warning on
76
+ * every dev boot, attributed to whichever plugin set the option:
77
+ *
78
+ * Warning: Invalid input options (1 issue found)
79
+ * - For the "define". Invalid key: Expected never but received "define".
80
+ *
81
+ * The app still ran, which is why this survived: the option was dropped, `__RETICLE_SDK_BUILD__`
82
+ * stopped participating in the optimizer cache key, and the only visible symptom was a warning with
83
+ * Reticle's name on it. Reported by a user on vite@8.0.16.
84
+ *
85
+ * An inherited top-level `define` is MOVED rather than dropped on the rolldown path — it is the
86
+ * app's own, it meant something, and passing it through unchanged is what produced the warning.
87
+ */
88
+ export declare function optimizerOptions(key: OptimizerOptionsKey, inherited: Record<string, unknown>, add: Record<string, string>): Record<string, unknown>;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Node-side questions about what is actually installed in the app.
3
+ *
4
+ * All three answer "what is on disk right now" rather than "what does the plugin do", and each exists
5
+ * because guessing was wrong in a way that reached a user: a declared-but-absent dependency logs a
6
+ * resolve failure on every boot, an unnoticed SDK change leaves stale code in the browser, and an
7
+ * unreported version makes a skewed pair surface as a bare -32000.
8
+ *
9
+ * All three resolve from the APP ROOT, never from this file. The SDK is the user's dependency and
10
+ * deliberately not one of this plugin's, so a resolve based on `import.meta.url` walks the PLUGIN's
11
+ * node_modules — which under pnpm's strict layout does not contain the SDK at all. Every one of these
12
+ * then fell into its own catch and returned the "not installed" answer: no `sdkVersion` on the HELLO,
13
+ * a CONSTANT build fingerprint (so Vite never re-bundled a changed SDK — the exact stale-bundle
14
+ * false-negative the fingerprint exists to prevent), and no optimizeDeps entry for the SDK itself.
15
+ * Silently, for every pnpm user, with no error anywhere.
16
+ */
17
+ import { existsSync, readFileSync, statSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { createRequire } from 'node:module';
20
+ /** The React kit the host app imports the SDK from. Mirrors the constant in index.ts. */
21
+ const RETICLE_PACKAGE = '@reticlehq/react';
22
+ /** How far up from the resolved entry to look for the manifest beside it. */
23
+ const MANIFEST_SEARCH_DEPTH = 5;
24
+ /**
25
+ * A `require` rooted at the app rather than at this plugin. `from` is Vite's `config.root`; the
26
+ * `process.cwd()` default matches how Vite itself defaults the root when the config omits it.
27
+ */
28
+ function requireFromApp(from) {
29
+ return createRequire(join(from, 'package.json'));
30
+ }
31
+ /**
32
+ * Whether a package can be resolved from the app. Used to avoid declaring an optimizeDeps entry for
33
+ * something the app does not have, which Vite reports as a resolve failure on every boot.
34
+ */
35
+ export function isResolvable(specifier, from = process.cwd()) {
36
+ try {
37
+ requireFromApp(from).resolve(specifier);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * The directory holding `specifier`'s own manifest, resolved from `from`. Null when it is not there.
46
+ *
47
+ * Resolves the entry and walks up to the manifest beside it rather than requiring
48
+ * `<pkg>/package.json` directly: a package with an `exports` map may refuse that subpath, and our
49
+ * own packages do.
50
+ */
51
+ function packageDirOf(specifier, from) {
52
+ try {
53
+ let dir = dirname(requireFromApp(from).resolve(specifier));
54
+ for (let up = 0; up < MANIFEST_SEARCH_DEPTH; up++) {
55
+ const candidate = join(dir, 'package.json');
56
+ if (existsSync(candidate)) {
57
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
58
+ if (parsed.name === specifier)
59
+ return dir;
60
+ }
61
+ const parent = dirname(dir);
62
+ if (parent === dir)
63
+ break;
64
+ dir = parent;
65
+ }
66
+ }
67
+ catch {
68
+ // Unresolvable from here — the caller's next chain, or nothing.
69
+ }
70
+ return null;
71
+ }
72
+ /**
73
+ * Vite's nested include form — `a > b > c` — for a chain that fully resolves, else null.
74
+ *
75
+ * A single-segment chain is just the bare specifier, so this also answers "can the app see it
76
+ * itself". Each further segment is resolved from the PREVIOUS package's directory, which is what
77
+ * Vite does with the `>` form and the only way to name a dependency the app root cannot see: under
78
+ * pnpm (and npm's nested layout) `@testing-library/dom` belongs to `@reticlehq/browser`, not to the
79
+ * user's app. Naming it bare made Vite fail to resolve it and force a re-optimization on every cold
80
+ * boot. SvelteKit ships `svelte > clsx` for the same reason.
81
+ */
82
+ export function resolvableChain(chain, from) {
83
+ let base = from;
84
+ for (const segment of chain) {
85
+ const dir = packageDirOf(segment, base);
86
+ if (null === dir)
87
+ return null;
88
+ base = dir;
89
+ }
90
+ return 0 === chain.length ? null : chain.join(' > ');
91
+ }
92
+ /** The installed SDK's package version, for the HELLO's `sdkVersion`. Node-side only. */
93
+ export function sdkPackageVersion(from = process.cwd()) {
94
+ const require_ = requireFromApp(from);
95
+ // Preferred: the package exports its own manifest. Newer SDKs do.
96
+ try {
97
+ const pkg = require_(`${RETICLE_PACKAGE}/package.json`);
98
+ if ('string' === typeof pkg.version)
99
+ return pkg.version;
100
+ }
101
+ catch {
102
+ // Falls through — see below.
103
+ }
104
+ // Fallback, and it is load-bearing rather than defensive: an OLDER SDK has no `./package.json`
105
+ // in its exports map, and an older SDK is precisely the skew we are trying to name. Resolve the
106
+ // main entry instead and walk up to the manifest beside it.
107
+ try {
108
+ let dir = dirname(require_.resolve(RETICLE_PACKAGE));
109
+ for (let up = 0; up < MANIFEST_SEARCH_DEPTH; up++) {
110
+ const candidate = join(dir, 'package.json');
111
+ if (existsSync(candidate)) {
112
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
113
+ if ('string' === typeof parsed.version)
114
+ return parsed.version;
115
+ }
116
+ const parent = dirname(dir);
117
+ if (parent === dir)
118
+ break;
119
+ dir = parent;
120
+ }
121
+ }
122
+ catch {
123
+ // Unresolvable (not installed yet, exotic layout) — report nothing rather than guessing.
124
+ }
125
+ return '';
126
+ }
127
+ /**
128
+ * A fingerprint of the installed SDK build, mixed into `optimizeDeps` so Vite re-bundles when the
129
+ * SDK changes.
130
+ *
131
+ * Vite's dep-optimizer cache is keyed on the `optimizeDeps` config and the lockfile — NOT on the
132
+ * contents of the packages it bundled. Upgrade the SDK in place (a patched dist, a linked checkout,
133
+ * an overlay) and the version in `package.json` can stay the same, so Vite keeps serving the OLD
134
+ * pre-bundled copy out of `node_modules/.vite` across dev-server restarts. The fix you just shipped
135
+ * is simply not in the browser, and it looks like the fix does not work. That cost a real
136
+ * false-negative during this bug hunt, and every user upgrading in place hits the same thing.
137
+ *
138
+ * Size+mtime is enough: it changes whenever the bundle does and costs one `stat`.
139
+ */
140
+ export function sdkBuildFingerprint(from = process.cwd()) {
141
+ try {
142
+ const entry = requireFromApp(from).resolve(RETICLE_PACKAGE);
143
+ const { size, mtimeMs } = statSync(entry);
144
+ return `${String(size)}-${String(Math.trunc(mtimeMs))}`;
145
+ }
146
+ catch {
147
+ // Unresolvable (not installed yet, exotic layout) — a constant is still correct, just inert.
148
+ return 'unknown';
149
+ }
150
+ }
151
+ /**
152
+ * The installed Vite's major version, or null when it cannot be read.
153
+ *
154
+ * Asked from the APP's root, never the plugin's: the plugin and the app can resolve different Vites
155
+ * in a monorepo, and the one whose deprecation warnings the user sees is the app's.
156
+ */
157
+ export function viteMajor(from = process.cwd()) {
158
+ try {
159
+ const pkg = requireFromApp(from)('vite/package.json');
160
+ const major = parseInt((pkg.version ?? '').split('.')[0] ?? '', 10);
161
+ return isNaN(major) ? null : major;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /** Vite renamed the dep-optimizer's passthrough options when it moved to rolldown. */
168
+ export const OPTIMIZER_OPTIONS_KEY = {
169
+ ESBUILD: 'esbuildOptions',
170
+ ROLLDOWN: 'rolldownOptions',
171
+ };
172
+ /**
173
+ * Which key carries optimizer options on this Vite.
174
+ *
175
+ * Vite 7 moved the dep optimizer to rolldown and deprecated `optimizeDeps.esbuildOptions`, warning
176
+ * on every boot to use `rolldownOptions` instead — a warning attributed to whichever plugin set it,
177
+ * which is us. Unknown versions get the older key: a deprecation notice is a much smaller failure
178
+ * than an option the installed Vite has never heard of.
179
+ */
180
+ export function optimizerOptionsKey(major) {
181
+ return null !== major && 7 <= major
182
+ ? OPTIMIZER_OPTIONS_KEY.ROLLDOWN
183
+ : OPTIMIZER_OPTIONS_KEY.ESBUILD;
184
+ }
185
+ /**
186
+ * The optimizer options object, with `define` where THIS bundler will accept it.
187
+ *
188
+ * esbuild reads `define` from the top level of its options. Rolldown does not — it rejects the key
189
+ * outright and reads defines from `transform.define` instead. Vite 8 surfaces that as a warning on
190
+ * every dev boot, attributed to whichever plugin set the option:
191
+ *
192
+ * Warning: Invalid input options (1 issue found)
193
+ * - For the "define". Invalid key: Expected never but received "define".
194
+ *
195
+ * The app still ran, which is why this survived: the option was dropped, `__RETICLE_SDK_BUILD__`
196
+ * stopped participating in the optimizer cache key, and the only visible symptom was a warning with
197
+ * Reticle's name on it. Reported by a user on vite@8.0.16.
198
+ *
199
+ * An inherited top-level `define` is MOVED rather than dropped on the rolldown path — it is the
200
+ * app's own, it meant something, and passing it through unchanged is what produced the warning.
201
+ */
202
+ export function optimizerOptions(key, inherited, add) {
203
+ const inheritedDefine = (inherited['define'] ?? {});
204
+ if (OPTIMIZER_OPTIONS_KEY.ROLLDOWN !== key) {
205
+ return { ...inherited, define: { ...inheritedDefine, ...add } };
206
+ }
207
+ const { define: _moved, ...rest } = inherited;
208
+ const transform = (inherited['transform'] ?? {});
209
+ const transformDefine = (transform['define'] ?? {});
210
+ return {
211
+ ...rest,
212
+ transform: { ...transform, define: { ...inheritedDefine, ...transformDefine, ...add } },
213
+ };
214
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The one build-time condition that guarantees a runtime failure.
3
+ *
4
+ * The pairing token is read from disk ONCE, when Vite resolves its config, and inlined as
5
+ * `__RETICLE_TOKEN__`. The daemon is what writes that file — so a dev server started BEFORE the
6
+ * daemon bakes in an empty token, and every app it serves opens a WebSocket the bridge then refuses.
7
+ *
8
+ * Nothing about that looks broken from outside: the SDK module loads, the socket opens, and then a
9
+ * session simply never appears. Restarting the dev server is the whole fix, and no other layer is in
10
+ * a position to say so — by the time the failure is observable, the value was baked in minutes ago.
11
+ */
12
+ export declare function missingTokenWarning(token: string | undefined): string | undefined;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The one build-time condition that guarantees a runtime failure.
3
+ *
4
+ * The pairing token is read from disk ONCE, when Vite resolves its config, and inlined as
5
+ * `__RETICLE_TOKEN__`. The daemon is what writes that file — so a dev server started BEFORE the
6
+ * daemon bakes in an empty token, and every app it serves opens a WebSocket the bridge then refuses.
7
+ *
8
+ * Nothing about that looks broken from outside: the SDK module loads, the socket opens, and then a
9
+ * session simply never appears. Restarting the dev server is the whole fix, and no other layer is in
10
+ * a position to say so — by the time the failure is observable, the value was baked in minutes ago.
11
+ */
12
+ export function missingTokenWarning(token) {
13
+ if (token !== undefined && token.length > 0)
14
+ return undefined;
15
+ return ('[reticle] no pairing token was available when this dev server started, so the app will connect ' +
16
+ 'and be refused — you will see NO SESSION even though the SDK loads and the socket opens. ' +
17
+ 'The token is written by the Reticle daemon: start it (`reticle serve`, or let your agent start ' +
18
+ 'it) and then RESTART this dev server, because the value is inlined at config time.');
19
+ }
@@ -6,16 +6,15 @@
6
6
  * package.json name plus a short hash of its absolute root — human-readable AND unique per checkout,
7
7
  * and unchanged when the port shifts. An explicit `projectId` option always overrides.
8
8
  */
9
- /**
10
- * Turn a package name into an id-safe slug: scoped names lose the `@scope/` punctuation
11
- * (`@acme/web` → `acme-web`), everything non-alphanumeric collapses to single dashes, edges trimmed.
12
- */
13
- export declare function slugifyPackageName(name: string): string;
9
+ export { slugifyPackageName } from '@reticlehq/core';
14
10
  /** A short, stable hex fingerprint of the absolute project root (disambiguates same-named checkouts). */
15
11
  export declare function shortHash(input: string): string;
16
12
  /**
17
13
  * Derive the stable projectId from the package name (may be undefined) and the absolute root path.
18
- * Pure — both inputs are passed in. Falls back to the root's folder name, then to "app".
14
+ *
15
+ * The rule itself is core's `projectIdFrom`, shared with `reticle init`: this id is what scopes a
16
+ * session to an app, so what the plugin stamps and what init records must be the same string. They
17
+ * used to be two identical copies kept in step by a comment.
19
18
  */
20
19
  export declare function deriveProjectId(pkgName: string | undefined, rootPath: string): string;
21
20
  /**
@@ -7,31 +7,23 @@
7
7
  * and unchanged when the port shifts. An explicit `projectId` option always overrides.
8
8
  */
9
9
  import { createHash } from 'node:crypto';
10
- import { basename, dirname, join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
- /**
13
- * Turn a package name into an id-safe slug: scoped names lose the `@scope/` punctuation
14
- * (`@acme/web` → `acme-web`), everything non-alphanumeric collapses to single dashes, edges trimmed.
15
- */
16
- export function slugifyPackageName(name) {
17
- return name
18
- .toLowerCase()
19
- .replace(/^@/, '')
20
- .replace(/[^a-z0-9]+/g, '-')
21
- .replace(/^-+|-+$/g, '');
22
- }
12
+ import { PROJECT_ID_HASH_LENGTH, projectIdFrom } from '@reticlehq/core';
13
+ export { slugifyPackageName } from '@reticlehq/core';
23
14
  /** A short, stable hex fingerprint of the absolute project root (disambiguates same-named checkouts). */
24
15
  export function shortHash(input) {
25
- return createHash('sha1').update(input).digest('hex').slice(0, 8);
16
+ return createHash('sha1').update(input).digest('hex').slice(0, PROJECT_ID_HASH_LENGTH);
26
17
  }
27
18
  /**
28
19
  * Derive the stable projectId from the package name (may be undefined) and the absolute root path.
29
- * Pure — both inputs are passed in. Falls back to the root's folder name, then to "app".
20
+ *
21
+ * The rule itself is core's `projectIdFrom`, shared with `reticle init`: this id is what scopes a
22
+ * session to an app, so what the plugin stamps and what init records must be the same string. They
23
+ * used to be two identical copies kept in step by a comment.
30
24
  */
31
25
  export function deriveProjectId(pkgName, rootPath) {
32
- const fromName = pkgName !== undefined ? slugifyPackageName(pkgName) : '';
33
- const base = fromName.length > 0 ? fromName : slugifyPackageName(basename(rootPath)) || 'app';
34
- return `${base}-${shortHash(rootPath)}`;
26
+ return projectIdFrom(pkgName, rootPath, shortHash);
35
27
  }
36
28
  /** Read the `name` from the nearest package.json at or above `startDir`, or undefined if none. */
37
29
  function readNearestPackageName(startDir) {
@@ -41,9 +33,9 @@ function readNearestPackageName(startDir) {
41
33
  if (existsSync(pkgPath)) {
42
34
  try {
43
35
  const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'));
44
- if (typeof parsed === 'object' && parsed !== null) {
36
+ if ('object' === typeof parsed && parsed !== null) {
45
37
  const name = parsed['name'];
46
- if (typeof name === 'string' && name.length > 0)
38
+ if ('string' === typeof name && name.length > 0)
47
39
  return name;
48
40
  }
49
41
  }
@@ -35,13 +35,13 @@
35
35
  /** Files this module stamps. `.svelte.ts` (a runes module) is code, not markup — excluded. */
36
36
  export declare const SVELTE_FILE: RegExp;
37
37
  /** The part of `svelte/compiler` this module uses. Structural, so `svelte` stays un-imported. */
38
- export interface SvelteCompilerLike {
38
+ interface SvelteCompilerLike {
39
39
  parse: (source: string, options?: {
40
40
  modern?: boolean;
41
41
  }) => unknown;
42
42
  }
43
43
  /** How the compiler is obtained. Injected in tests; the default resolves it from the app. */
44
- export type LoadSvelteCompiler = () => SvelteCompilerLike | null;
44
+ type LoadSvelteCompiler = () => SvelteCompilerLike | null;
45
45
  /**
46
46
  * A character offset as Babel would report it: 1-based line, 0-based column.
47
47
  *
@@ -53,8 +53,6 @@ export declare function offsetToLineColumn(source: string, offset: number): {
53
53
  line: number;
54
54
  column: number;
55
55
  };
56
- /** Project-relative, forward-slashed. A pointer must be the same string on Windows as on Linux. */
57
- export declare function sourcePathFor(id: string, cwd?: string): string;
58
56
  /**
59
57
  * Stamp `data-reticle-source="file:line:column"` on every host element of a `.svelte` component.
60
58
  *
@@ -64,3 +62,4 @@ export declare function sourcePathFor(id: string, cwd?: string): string;
64
62
  * enrichment on top of an app that otherwise works.
65
63
  */
66
64
  export declare function stampSvelte(code: string, id: string, load?: LoadSvelteCompiler): string | null;
65
+ export {};
@@ -71,7 +71,7 @@ export function offsetToLineColumn(source, offset) {
71
71
  let line = 1;
72
72
  let lineStart = 0;
73
73
  for (let i = 0; i < offset && i < source.length; i++) {
74
- if (source[i] === '\n') {
74
+ if ('\n' === source[i]) {
75
75
  line += 1;
76
76
  lineStart = i + 1;
77
77
  }
@@ -79,21 +79,21 @@ export function offsetToLineColumn(source, offset) {
79
79
  return { line, column: offset - lineStart };
80
80
  }
81
81
  /** Project-relative, forward-slashed. A pointer must be the same string on Windows as on Linux. */
82
- export function sourcePathFor(id, cwd = process.cwd()) {
82
+ function sourcePathFor(id, cwd = process.cwd()) {
83
83
  return relative(cwd, id).replace(/\\/g, '/');
84
84
  }
85
85
  function isElementNode(value) {
86
- if (value === null || typeof value !== 'object')
86
+ if (null === value || typeof value !== 'object')
87
87
  return false;
88
88
  const node = value;
89
- return (typeof node.type === 'string' &&
89
+ return ('string' === typeof node.type &&
90
90
  HOST_ELEMENT_TYPES.has(node.type) &&
91
- typeof node.name === 'string' &&
92
- typeof node.start === 'number');
91
+ 'string' === typeof node.name &&
92
+ 'number' === typeof node.start);
93
93
  }
94
94
  function isAlreadyStamped(node) {
95
95
  return (node.attributes ?? []).some((attr) => attr !== null &&
96
- typeof attr === 'object' &&
96
+ 'object' === typeof attr &&
97
97
  attr.name === DATA_RETICLE_SOURCE_ATTR);
98
98
  }
99
99
  /**
@@ -109,7 +109,7 @@ function collectElements(root) {
109
109
  const found = [];
110
110
  const seen = new Set();
111
111
  const visit = (value) => {
112
- if (value === null || typeof value !== 'object' || seen.has(value))
112
+ if (null === value || typeof value !== 'object' || seen.has(value))
113
113
  return;
114
114
  seen.add(value);
115
115
  if (Array.isArray(value)) {
@@ -137,7 +137,7 @@ function collectElements(root) {
137
137
  */
138
138
  export function stampSvelte(code, id, load = defaultLoadCompiler) {
139
139
  const compiler = load();
140
- if (compiler === null)
140
+ if (null === compiler)
141
141
  return null;
142
142
  let ast;
143
143
  try {
@@ -149,7 +149,7 @@ export function stampSvelte(code, id, load = defaultLoadCompiler) {
149
149
  return null;
150
150
  }
151
151
  const elements = collectElements(ast);
152
- if (elements.length === 0)
152
+ if (0 === elements.length)
153
153
  return null;
154
154
  const file = sourcePathFor(id);
155
155
  // Insert from the LAST element backwards: every insertion shifts the offsets after it, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/vite-plugin",
3
- "version": "2.4.0",
3
+ "version": "2.6.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",
@@ -25,7 +25,8 @@
25
25
  ".": {
26
26
  "types": "./dist/index.d.ts",
27
27
  "default": "./dist/index.js"
28
- }
28
+ },
29
+ "./package.json": "./package.json"
29
30
  },
30
31
  "files": [
31
32
  "dist",
@@ -34,8 +35,8 @@
34
35
  ],
35
36
  "dependencies": {
36
37
  "@babel/core": "^7.26.0",
37
- "@reticlehq/babel-plugin": "2.4.0",
38
- "@reticlehq/core": "2.4.0"
38
+ "@reticlehq/babel-plugin": "2.6.0",
39
+ "@reticlehq/core": "2.6.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@types/babel__core": "^7.20.5",
@@ -60,6 +61,7 @@
60
61
  "build": "tsc -b",
61
62
  "typecheck": "tsc -b",
62
63
  "lint": "eslint src",
63
- "test:unit": "vitest run src --passWithNoTests"
64
+ "test:unit": "vitest run src --passWithNoTests",
65
+ "test:coverage": "vitest run src --passWithNoTests --coverage.enabled --coverage.provider=v8 --coverage.reporter=text-summary --coverage.include='src/**/*.ts' --coverage.exclude='src/**/*.test.ts'"
64
66
  }
65
67
  }