@reticlehq/vite-plugin 2.13.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,13 +13,31 @@ export declare function shortHash(input: string): string;
13
13
  * Derive the stable projectId from the package name (may be undefined) and the absolute root path.
14
14
  *
15
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.
16
+ * session to an app, so what the plugin stamps and what init records must be the same string — ONE
17
+ * implementation, not two copies kept in step by hand.
18
18
  */
19
19
  export declare function deriveProjectId(pkgName: string | undefined, rootPath: string): string;
20
+ /** Reads a file, or throws — the one filesystem touch these walkers make, injectable for tests. */
21
+ type ReadFile = (path: string) => string;
20
22
  /**
21
- * Resolve the projectId for a plugin instance: an explicit option wins; otherwise derive from the
22
- * app's package.json name + root. `cwd` and `readPkgName` are injectable so the resolution is
23
- * unit-tested without touching the real filesystem.
23
+ * Read the `projectId` `reticle init` recorded in the nearest `.reticle.json`, or undefined.
24
+ *
25
+ * This is the id of RECORD. The derivation below is a fallback for a project that has never been
26
+ * through `init` — it is not a second opinion, and where the two disagree the config wins.
27
+ */
28
+ export declare function readConfiguredProjectId(startDir: string, readFile?: ReadFile): string | undefined;
29
+ /**
30
+ * Resolve the projectId for a plugin instance, most-local statement of intent first: an explicit
31
+ * option, then the id `reticle init` recorded in `.reticle.json`, then derivation.
32
+ *
33
+ * The config step is what makes the id survive a dev server that does not share a filesystem with
34
+ * the CLI. Derivation hashes the ABSOLUTE ROOT, so a containerised Vite (`/app`) and the host `init`
35
+ * that wired it produce different ids for one project — the page announces one, the daemon expects
36
+ * the other, and the bridge refuses the connection with `authentication failed`, which names
37
+ * neither. Reading the file `init` already wrote costs one `readFileSync` at config time and makes
38
+ * the two halves agree by construction rather than by coincidence of working directory.
39
+ *
40
+ * `cwd`, `readPkgName` and `readConfiguredId` are injectable so resolution is unit-tested without
41
+ * touching the real filesystem.
24
42
  */
25
- export declare function resolveProjectId(explicit: string | undefined, cwd: string, readPkgName?: (dir: string) => string | undefined): string;
43
+ export declare function resolveProjectId(explicit: string | undefined, cwd: string, readPkgName?: (dir: string) => string | undefined, readConfiguredId?: (dir: string) => string | undefined): string;
@@ -8,8 +8,10 @@
8
8
  */
9
9
  import { createHash } from 'node:crypto';
10
10
  import { dirname, join } from 'node:path';
11
- import { existsSync, readFileSync } from 'node:fs';
11
+ import { readFileSync } from 'node:fs';
12
12
  import { PROJECT_ID_HASH_LENGTH, projectIdFrom } from '@reticlehq/core';
13
+ /** The project config `reticle init` writes, and the id of record it carries. */
14
+ const RETICLE_CONFIG_BASENAME = '.reticle.json';
13
15
  export { slugifyPackageName } from '@reticlehq/core';
14
16
  /** A short, stable hex fingerprint of the absolute project root (disambiguates same-named checkouts). */
15
17
  export function shortHash(input) {
@@ -19,30 +21,48 @@ export function shortHash(input) {
19
21
  * Derive the stable projectId from the package name (may be undefined) and the absolute root path.
20
22
  *
21
23
  * 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.
24
+ * session to an app, so what the plugin stamps and what init records must be the same string — ONE
25
+ * implementation, not two copies kept in step by hand.
24
26
  */
25
27
  export function deriveProjectId(pkgName, rootPath) {
26
28
  return projectIdFrom(pkgName, rootPath, shortHash);
27
29
  }
28
- /** Read the `name` from the nearest package.json at or above `startDir`, or undefined if none. */
29
- function readNearestPackageName(startDir) {
30
+ const readFileOrThrow = (path) => readFileSync(path, 'utf8');
31
+ /**
32
+ * How far up to look for `.reticle.json`, and WHY it is not the package.json depth below.
33
+ *
34
+ * The same number, for the same reason, as the server's `MAX_CONFIG_SEARCH_DEPTH`: deep enough for
35
+ * an app in `frontend/` or `apps/web/`, a worktree beside its main checkout, and a package inside a
36
+ * monorepo — and shallow enough that a dev server started somewhere unrelated cannot silently adopt
37
+ * a distant ancestor's config and announce another app's identity. The two walkers have to agree,
38
+ * because the whole point of reading this file is that the plugin and the CLI stop disagreeing.
39
+ */
40
+ const MAX_CONFIG_SEARCH_DEPTH = 6;
41
+ /**
42
+ * How far up to look for `package.json`, which is a different question with a different answer.
43
+ *
44
+ * A package name is only ever used to BUILD an id, never to adopt one, so an over-eager walk here
45
+ * costs a less specific name rather than the wrong app's identity. Left as it was.
46
+ */
47
+ const MAX_PACKAGE_SEARCH_DEPTH = 50;
48
+ /**
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.
51
+ */
52
+ function readNearestField(startDir, basename, field, readFile, maxDepth) {
30
53
  let dir = startDir;
31
- for (let depth = 0; depth < 50; depth++) {
32
- const pkgPath = join(dir, 'package.json');
33
- if (existsSync(pkgPath)) {
34
- try {
35
- const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'));
36
- if ('object' === typeof parsed && parsed !== null) {
37
- const name = parsed['name'];
38
- if ('string' === typeof name && name.length > 0)
39
- return name;
40
- }
41
- }
42
- catch {
43
- // unreadable package.json → keep walking up
54
+ for (let depth = 0; depth <= maxDepth; depth++) {
55
+ try {
56
+ const parsed = JSON.parse(readFile(join(dir, basename)));
57
+ if ('object' === typeof parsed && parsed !== null) {
58
+ const value = parsed[field];
59
+ if ('string' === typeof value && value.length > 0)
60
+ return value;
44
61
  }
45
62
  }
63
+ catch {
64
+ // missing, unreadable or unparseable → keep walking up
65
+ }
46
66
  const parent = dirname(dir);
47
67
  if (parent === dir)
48
68
  break; // reached filesystem root
@@ -50,13 +70,38 @@ function readNearestPackageName(startDir) {
50
70
  }
51
71
  return undefined;
52
72
  }
73
+ /** Read the `name` from the nearest package.json at or above `startDir`, or undefined if none. */
74
+ function readNearestPackageName(startDir, readFile = readFileOrThrow) {
75
+ return readNearestField(startDir, 'package.json', 'name', readFile, MAX_PACKAGE_SEARCH_DEPTH);
76
+ }
53
77
  /**
54
- * Resolve the projectId for a plugin instance: an explicit option wins; otherwise derive from the
55
- * app's package.json name + root. `cwd` and `readPkgName` are injectable so the resolution is
56
- * unit-tested without touching the real filesystem.
78
+ * Read the `projectId` `reticle init` recorded in the nearest `.reticle.json`, or undefined.
79
+ *
80
+ * This is the id of RECORD. The derivation below is a fallback for a project that has never been
81
+ * through `init` — it is not a second opinion, and where the two disagree the config wins.
82
+ */
83
+ export function readConfiguredProjectId(startDir, readFile = readFileOrThrow) {
84
+ return readNearestField(startDir, RETICLE_CONFIG_BASENAME, 'projectId', readFile, MAX_CONFIG_SEARCH_DEPTH);
85
+ }
86
+ /**
87
+ * Resolve the projectId for a plugin instance, most-local statement of intent first: an explicit
88
+ * option, then the id `reticle init` recorded in `.reticle.json`, then derivation.
89
+ *
90
+ * The config step is what makes the id survive a dev server that does not share a filesystem with
91
+ * the CLI. Derivation hashes the ABSOLUTE ROOT, so a containerised Vite (`/app`) and the host `init`
92
+ * that wired it produce different ids for one project — the page announces one, the daemon expects
93
+ * the other, and the bridge refuses the connection with `authentication failed`, which names
94
+ * neither. Reading the file `init` already wrote costs one `readFileSync` at config time and makes
95
+ * the two halves agree by construction rather than by coincidence of working directory.
96
+ *
97
+ * `cwd`, `readPkgName` and `readConfiguredId` are injectable so resolution is unit-tested without
98
+ * touching the real filesystem.
57
99
  */
58
- export function resolveProjectId(explicit, cwd, readPkgName = readNearestPackageName) {
100
+ export function resolveProjectId(explicit, cwd, readPkgName = readNearestPackageName, readConfiguredId = readConfiguredProjectId) {
59
101
  if (explicit !== undefined && explicit.length > 0)
60
102
  return explicit;
103
+ const configured = readConfiguredId(cwd);
104
+ if (configured !== undefined && configured.length > 0)
105
+ return configured;
61
106
  return deriveProjectId(readPkgName(cwd), cwd);
62
107
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Telling Vitest's browser-mode runner apart from every other Vite server.
3
+ *
4
+ * Small, but it is the discriminator a wrong cut gets wrong in production, so it earns its own file
5
+ * and its own name.
6
+ */
7
+ /**
8
+ * Is THIS server Vitest's browser-mode runner?
9
+ *
10
+ * Vitest browser mode renders each component test into its own iframe served by the same Vite dev
11
+ * server, so `transformIndexHtml` injects into every one of them. The HUD then sits in the test
12
+ * document's hit-test path and intercepts pointer events, and a user who adds Reticle watches their
13
+ * unrelated component tests start timing out on clicks.
14
+ *
15
+ * The signal is `test.browser.enabled` on the RESOLVED config. The first cut used the `VITEST`
16
+ * environment variable and was wrong in a way its own unit tests could not show: `VITEST` means
17
+ * "Vitest is running somewhere in this process", which is ALSO true when a Vitest suite boots an app
18
+ * in order to test it. `frameworks.integration.test.ts` starts a real Vite dev server per example
19
+ * app and asserts Reticle connects; under the env check it injected nothing and the suite failed
20
+ * with "@reticlehq/example-react never connected an Reticle session". CI caught that; three local
21
+ * battery runs did not, because that suite is not the battery.
22
+ *
23
+ * A bare `test` key is wrong the other way — most projects configure Vitest and are not under it.
24
+ * `test.browser.enabled` is true only for the server actually serving browser-mode test iframes,
25
+ * which is the one case with a HUD to suppress.
26
+ *
27
+ * An explicit `inject: true` still beats it: the check is a default chosen on the user's behalf.
28
+ */
29
+ export declare function isVitestBrowserServer(config: {
30
+ test?: unknown;
31
+ }): boolean;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Telling Vitest's browser-mode runner apart from every other Vite server.
3
+ *
4
+ * Small, but it is the discriminator a wrong cut gets wrong in production, so it earns its own file
5
+ * and its own name.
6
+ */
7
+ /**
8
+ * Is THIS server Vitest's browser-mode runner?
9
+ *
10
+ * Vitest browser mode renders each component test into its own iframe served by the same Vite dev
11
+ * server, so `transformIndexHtml` injects into every one of them. The HUD then sits in the test
12
+ * document's hit-test path and intercepts pointer events, and a user who adds Reticle watches their
13
+ * unrelated component tests start timing out on clicks.
14
+ *
15
+ * The signal is `test.browser.enabled` on the RESOLVED config. The first cut used the `VITEST`
16
+ * environment variable and was wrong in a way its own unit tests could not show: `VITEST` means
17
+ * "Vitest is running somewhere in this process", which is ALSO true when a Vitest suite boots an app
18
+ * in order to test it. `frameworks.integration.test.ts` starts a real Vite dev server per example
19
+ * app and asserts Reticle connects; under the env check it injected nothing and the suite failed
20
+ * with "@reticlehq/example-react never connected an Reticle session". CI caught that; three local
21
+ * battery runs did not, because that suite is not the battery.
22
+ *
23
+ * A bare `test` key is wrong the other way — most projects configure Vitest and are not under it.
24
+ * `test.browser.enabled` is true only for the server actually serving browser-mode test iframes,
25
+ * which is the one case with a HUD to suppress.
26
+ *
27
+ * An explicit `inject: true` still beats it: the check is a default chosen on the user's behalf.
28
+ */
29
+ export function isVitestBrowserServer(config) {
30
+ const test = config.test;
31
+ if (null === test || 'object' !== typeof test)
32
+ return false;
33
+ const browser = test.browser;
34
+ if (null === browser || 'object' !== typeof browser)
35
+ return false;
36
+ return true === browser.enabled;
37
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Normalising Vite's watcher `ignored` list, which is not a list.
3
+ *
4
+ * Small but cohesive: one third-party type shape, one runtime hazard it hides, and the guard that
5
+ * makes the plugin's return assignable without a cast.
6
+ */
7
+ /**
8
+ * Append Reticle's journal pattern to whatever the app already ignored, without assuming it is an array.
9
+ *
10
+ * Vite's `ignored` is `AnymatchMatcher`, so `watch: { ignored: '**\/fixtures/**' }` is a legal
11
+ * config. Spreading that string would explode it into one pattern PER CHARACTER — every one of
12
+ * which matches nothing, so the app's own exclusion is silently dropped and no error is raised. A
13
+ * function matcher is worse: it is not iterable at all, so the spread throws at config time and
14
+ * takes the dev server down, blaming the last plugin to touch the config.
15
+ */
16
+ export declare function mergeIgnored(existing: unknown, ours: RegExp): WatchPattern[];
17
+ /** Vite's `AnymatchPattern`, restated so the return type needs no cast. */
18
+ export type WatchPattern = string | RegExp | ((path: string) => boolean);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Normalising Vite's watcher `ignored` list, which is not a list.
3
+ *
4
+ * Small but cohesive: one third-party type shape, one runtime hazard it hides, and the guard that
5
+ * makes the plugin's return assignable without a cast.
6
+ */
7
+ /**
8
+ * Append Reticle's journal pattern to whatever the app already ignored, without assuming it is an array.
9
+ *
10
+ * Vite's `ignored` is `AnymatchMatcher`, so `watch: { ignored: '**\/fixtures/**' }` is a legal
11
+ * config. Spreading that string would explode it into one pattern PER CHARACTER — every one of
12
+ * which matches nothing, so the app's own exclusion is silently dropped and no error is raised. A
13
+ * function matcher is worse: it is not iterable at all, so the spread throws at config time and
14
+ * takes the dev server down, blaming the last plugin to touch the config.
15
+ */
16
+ export function mergeIgnored(existing, ours) {
17
+ if (undefined === existing || null === existing)
18
+ return [ours];
19
+ const listed = Array.isArray(existing) ? existing : [existing];
20
+ // Filtered rather than cast: an entry that is none of the three legal matcher shapes could never
21
+ // have excluded anything, so dropping it loses nothing and keeps the return honest without `any`.
22
+ return [...listed.filter(isWatchPattern), ours];
23
+ }
24
+ function isWatchPattern(value) {
25
+ return 'string' === typeof value || value instanceof RegExp || 'function' === typeof value;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/vite-plugin",
3
- "version": "2.13.1",
3
+ "version": "3.1.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",
@@ -8,7 +8,7 @@
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/reticlehq/reticle.git",
11
- "directory": "packages/vite-plugin"
11
+ "directory": "adapters/build/vite"
12
12
  },
13
13
  "homepage": "https://github.com/reticlehq/reticle#readme",
14
14
  "bugs": "https://github.com/reticlehq/reticle/issues",
@@ -41,14 +41,15 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@babel/core": "^7.26.0",
44
- "@reticlehq/babel-plugin": "2.13.1",
45
- "@reticlehq/core": "2.13.1"
44
+ "@reticlehq/babel-plugin": "3.1.0",
45
+ "@reticlehq/core": "3.1.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/babel__core": "^7.20.5",
49
+ "esbuild": "^0.28.2",
49
50
  "svelte": "^5.56.10",
50
51
  "vite": "^8",
51
- "esbuild": "^0.28.2"
52
+ "@reticlehq/react": "3.1.0"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "vite": ">=4"
@@ -65,7 +66,7 @@
65
66
  "node": ">=20.0.0"
66
67
  },
67
68
  "scripts": {
68
- "build": "tsc -b && node scripts/build-cjs.mjs",
69
+ "build": "tsc -b && node ../../../scripts/alias-dist.mjs && node scripts/build-cjs.mjs",
69
70
  "typecheck": "tsc -b",
70
71
  "lint": "eslint src",
71
72
  "test:unit": "vitest run src --passWithNoTests",
File without changes