@reticlehq/vite-plugin 2.12.0 → 2.13.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.
@@ -0,0 +1,16 @@
1
+ import { type DevServerEntry } from '@reticlehq/core';
2
+ /** The filesystem this needs, injected so the writer is unit-testable without a real home. */
3
+ export interface AnnounceIo {
4
+ mkdir: (dir: string) => void;
5
+ writeFile: (path: string, data: string) => void;
6
+ removeFile: (path: string) => void;
7
+ }
8
+ /**
9
+ * Publish the entry; returns the cleanup that withdraws it.
10
+ *
11
+ * The cleanup is idempotent because it is called from shutdown paths — `close`, `SIGINT`, process
12
+ * exit — which fire more than once and race each other. A stale entry is handled on the read side
13
+ * by a liveness check (`liveDevServers`), so a missed cleanup degrades rather than misleads; a
14
+ * throw inside a signal handler does not degrade, it takes the shutdown with it.
15
+ */
16
+ export declare function announceDevServer(entry: DevServerEntry, home?: string, io?: AnnounceIo): () => void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Announce this dev server to `~/.reticle`, so setup can be diagnosed from facts rather than guesses.
3
+ *
4
+ * The mirror of `discover-port.ts`: that reads the daemon's `daemon-<port>.json` to find a bridge,
5
+ * this writes `devserver-<port>.json` so the bridge — and `reticle init` — can see that an
6
+ * instrumented dev server exists at all.
7
+ *
8
+ * Why this is the right half to add, rather than teaching `init` to run the dev command: the script
9
+ * name, the package manager, the port and the framework are all things a user or their agent can
10
+ * change, and a setup step that hardcodes any of them breaks on exactly the project that needed it.
11
+ * This plugin is ALREADY inside the dev server when it boots. It knows the port because it is being
12
+ * served on it, and it knows the URL because Vite reports it. Nothing here is assumed.
13
+ *
14
+ * Writing this file proves one specific thing and no more: the plugin is loaded in the process that
15
+ * is actually running. That is the fact nobody could observe, and the commonest setup failure — a
16
+ * plugin added to a config the running dev server already read — is precisely its absence.
17
+ *
18
+ * Best-effort throughout. This is a diagnostic, and a diagnostic that can break the dev server it
19
+ * reports on is worse than no diagnostic at all.
20
+ */
21
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { devServerRegistryFileName } from '@reticlehq/core';
24
+ import { stateHome } from './state-home.js';
25
+ const JSON_INDENT = 2;
26
+ const nodeIo = {
27
+ mkdir: (dir) => void mkdirSync(dir, { recursive: true }),
28
+ writeFile: (path, data) => void writeFileSync(path, data),
29
+ removeFile: (path) => void rmSync(path, { force: true }),
30
+ };
31
+ /**
32
+ * Publish the entry; returns the cleanup that withdraws it.
33
+ *
34
+ * The cleanup is idempotent because it is called from shutdown paths — `close`, `SIGINT`, process
35
+ * exit — which fire more than once and race each other. A stale entry is handled on the read side
36
+ * by a liveness check (`liveDevServers`), so a missed cleanup degrades rather than misleads; a
37
+ * throw inside a signal handler does not degrade, it takes the shutdown with it.
38
+ */
39
+ export function announceDevServer(entry, home = stateHome(), io = nodeIo) {
40
+ const path = join(home, devServerRegistryFileName(entry.port));
41
+ try {
42
+ io.mkdir(home);
43
+ io.writeFile(path, `${JSON.stringify(entry, null, JSON_INDENT)}\n`);
44
+ }
45
+ catch {
46
+ // No home directory, a read-only filesystem, a sandbox. The app still serves; we just cannot say so.
47
+ }
48
+ let withdrawn = false;
49
+ return () => {
50
+ if (withdrawn)
51
+ return;
52
+ withdrawn = true;
53
+ try {
54
+ io.removeFile(path);
55
+ }
56
+ catch {
57
+ // Already gone, or never written. The reader's liveness check covers what we cannot remove.
58
+ }
59
+ };
60
+ }
@@ -5,9 +5,9 @@
5
5
  * projectId, drop dead daemons — is the pure `pickDaemonPort` in core; this file is just the fs plumbing.
6
6
  */
7
7
  import { readdirSync, readFileSync } from 'node:fs';
8
- import { homedir } from 'node:os';
9
8
  import { join } from 'node:path';
10
- import { daemonRegistryPort, DaemonRegistryEntrySchema, pickDaemonPort, ReticleDir, } from '@reticlehq/core';
9
+ import { daemonRegistryPort, DaemonRegistryEntrySchema, pickDaemonPort, } from '@reticlehq/core';
10
+ import { stateHome } from './state-home.js';
11
11
  /** process.kill(pid, 0) throws iff the process is gone — the same liveness probe the daemon uses. */
12
12
  function isAlive(pid) {
13
13
  try {
@@ -23,7 +23,7 @@ function isAlive(pid) {
23
23
  * to the default port — never auto-connects to a mismatched daemon). `home` and `alive` are injectable
24
24
  * so the selection is unit-tested without a real ~/.reticle or real processes.
25
25
  */
26
- export function discoverDaemonPort(projectId, home = join(homedir(), ReticleDir.ROOT), alive = isAlive) {
26
+ export function discoverDaemonPort(projectId, home = stateHome(), alive = isAlive) {
27
27
  const entries = [];
28
28
  let files;
29
29
  try {
package/dist/index.cjs CHANGED
@@ -44,7 +44,7 @@ __export(index_exports, {
44
44
  reticle: () => reticle
45
45
  });
46
46
  module.exports = __toCommonJS(index_exports);
47
- var import_node_fs5 = require("node:fs");
47
+ var import_node_fs6 = require("node:fs");
48
48
 
49
49
  // src/missing-token.ts
50
50
  function missingTokenWarning(token) {
@@ -78,8 +78,8 @@ function ensurePairingToken(dir) {
78
78
 
79
79
  // src/index.ts
80
80
  var import_node_os2 = require("node:os");
81
- var import_node_path6 = require("node:path");
82
- var import_core4 = require("@babel/core");
81
+ var import_node_path8 = require("node:path");
82
+ var import_core6 = require("@babel/core");
83
83
  var import_babel_plugin = __toESM(require("@reticlehq/babel-plugin"), 1);
84
84
 
85
85
  // ../core/dist/constants.js
@@ -243,6 +243,24 @@ var ReticleDir = {
243
243
  AMBIENT_FILE: "ambient.json",
244
244
  /** per-flow flake ledger — replay outcomes that decide intermittent-failure quarantine. */
245
245
  FLAKE_FILE: "flake.json",
246
+ /**
247
+ * the project's cloud binding — .reticle/cloud.json, written by `reticle link`. Git-checked and
248
+ * non-secret: the project id, the API origin, and where its dashboard lives. The KEY lives in
249
+ * ~/.reticle/credentials.json instead, because that one must never reach a repository.
250
+ */
251
+ CLOUD_LINK_FILE: "cloud.json",
252
+ /**
253
+ * local sync bookkeeping — .reticle/cloud-state.json. The pull cursor, when each half last ran,
254
+ * and the last error. NOT git-checked: it describes this machine's conversation with the server,
255
+ * and committing one machine's cursor would make every other machine skip what it had not seen.
256
+ */
257
+ CLOUD_STATE_FILE: "cloud-state.json",
258
+ /**
259
+ * triage decisions pulled BACK from the dashboard — .reticle/issues.json. What a human said about
260
+ * a defect ("resolved", "not a bug"), so the HUD stops showing it and the next run does not
261
+ * re-report it as though nobody had looked.
262
+ */
263
+ ISSUES_FILE: "issues.json",
246
264
  /** Per-flow assertion tiers recorded on each PASSING replay — the gate's anti-downgrade baseline. */
247
265
  TIERS_FILE: "assertion-tiers.json",
248
266
  /**
@@ -4453,6 +4471,35 @@ function pickDaemonPort(entries, projectId, isAlive2) {
4453
4471
  return matches[0] ?? null;
4454
4472
  }
4455
4473
 
4474
+ // ../core/dist/dev-server-registry.js
4475
+ var DEV_SERVER_PREFIX = "devserver-";
4476
+ var DEV_SERVER_SUFFIX = ".json";
4477
+ function devServerRegistryFileName(port) {
4478
+ return `${DEV_SERVER_PREFIX}${String(port)}${DEV_SERVER_SUFFIX}`;
4479
+ }
4480
+ var DevServerEntrySchema = external_exports.object({
4481
+ port: external_exports.number(),
4482
+ pid: external_exports.number(),
4483
+ /** The project directory the dev server is serving — how a monorepo tells its apps apart. */
4484
+ root: external_exports.string(),
4485
+ /** Where the app is actually served, as the dev server itself reports it. Never assembled here. */
4486
+ url: external_exports.string(),
4487
+ /**
4488
+ * The SDK version in the bundle, so a skew can be named rather than guessed at.
4489
+ *
4490
+ * Optional, and ABSENT rather than empty when it cannot be resolved. `""` reads as "the version is
4491
+ * empty"; a missing field reads as "not known", which is the true statement — and the difference
4492
+ * matters to the one reader who exists for this field, a skew diagnosis.
4493
+ */
4494
+ sdkVersion: external_exports.string().min(1).optional(),
4495
+ startedAt: external_exports.number(),
4496
+ /**
4497
+ * Optional because an app can be running before `init` has ever named it — which is exactly the
4498
+ * state this signal has to be able to describe.
4499
+ */
4500
+ projectId: external_exports.string().optional()
4501
+ });
4502
+
4456
4503
  // src/project-id.ts
4457
4504
  var import_node_crypto2 = require("node:crypto");
4458
4505
  var import_node_path2 = require("node:path");
@@ -4490,8 +4537,17 @@ function resolveProjectId(explicit, cwd, readPkgName = readNearestPackageName) {
4490
4537
 
4491
4538
  // src/discover-port.ts
4492
4539
  var import_node_fs3 = require("node:fs");
4540
+ var import_node_path4 = require("node:path");
4541
+
4542
+ // src/state-home.ts
4493
4543
  var import_node_os = require("node:os");
4494
4544
  var import_node_path3 = require("node:path");
4545
+ function stateHome(env = process.env) {
4546
+ const override = env[ReticleEnv.STATE_DIR];
4547
+ return override !== void 0 && override.length > 0 ? override : (0, import_node_path3.join)((0, import_node_os.homedir)(), ReticleDir.ROOT);
4548
+ }
4549
+
4550
+ // src/discover-port.ts
4495
4551
  function isAlive(pid) {
4496
4552
  try {
4497
4553
  process.kill(pid, 0);
@@ -4500,7 +4556,7 @@ function isAlive(pid) {
4500
4556
  return false;
4501
4557
  }
4502
4558
  }
4503
- function discoverDaemonPort(projectId, home = (0, import_node_path3.join)((0, import_node_os.homedir)(), ReticleDir.ROOT), alive = isAlive) {
4559
+ function discoverDaemonPort(projectId, home = stateHome(), alive = isAlive) {
4504
4560
  const entries = [];
4505
4561
  let files;
4506
4562
  try {
@@ -4512,7 +4568,7 @@ function discoverDaemonPort(projectId, home = (0, import_node_path3.join)((0, im
4512
4568
  if (null === daemonRegistryPort(file)) continue;
4513
4569
  try {
4514
4570
  const parsed = DaemonRegistryEntrySchema.safeParse(
4515
- JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path3.join)(home, file), "utf8"))
4571
+ JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path4.join)(home, file), "utf8"))
4516
4572
  );
4517
4573
  if (parsed.success) entries.push(parsed.data);
4518
4574
  } catch {
@@ -4521,9 +4577,37 @@ function discoverDaemonPort(projectId, home = (0, import_node_path3.join)((0, im
4521
4577
  return pickDaemonPort(entries, projectId, alive) ?? void 0;
4522
4578
  }
4523
4579
 
4580
+ // src/announce.ts
4581
+ var import_node_fs4 = require("node:fs");
4582
+ var import_node_path5 = require("node:path");
4583
+ var JSON_INDENT = 2;
4584
+ var nodeIo = {
4585
+ mkdir: (dir) => void (0, import_node_fs4.mkdirSync)(dir, { recursive: true }),
4586
+ writeFile: (path, data) => void (0, import_node_fs4.writeFileSync)(path, data),
4587
+ removeFile: (path) => void (0, import_node_fs4.rmSync)(path, { force: true })
4588
+ };
4589
+ function announceDevServer(entry, home = stateHome(), io = nodeIo) {
4590
+ const path = (0, import_node_path5.join)(home, devServerRegistryFileName(entry.port));
4591
+ try {
4592
+ io.mkdir(home);
4593
+ io.writeFile(path, `${JSON.stringify(entry, null, JSON_INDENT)}
4594
+ `);
4595
+ } catch {
4596
+ }
4597
+ let withdrawn = false;
4598
+ return () => {
4599
+ if (withdrawn) return;
4600
+ withdrawn = true;
4601
+ try {
4602
+ io.removeFile(path);
4603
+ } catch {
4604
+ }
4605
+ };
4606
+ }
4607
+
4524
4608
  // src/svelte-source.ts
4525
4609
  var import_node_module = require("node:module");
4526
- var import_node_path4 = require("node:path");
4610
+ var import_node_path6 = require("node:path");
4527
4611
  var SVELTE_FILE = /\.svelte$/;
4528
4612
  var HOST_ELEMENT_TYPES = /* @__PURE__ */ new Set(["RegularElement", "Element"]);
4529
4613
  var PARENT_KEY = "parent";
@@ -4552,7 +4636,7 @@ function offsetToLineColumn(source, offset) {
4552
4636
  return { line, column: offset - lineStart };
4553
4637
  }
4554
4638
  function sourcePathFor(id, cwd = process.cwd()) {
4555
- return (0, import_node_path4.relative)(cwd, id).replace(/\\/g, "/");
4639
+ return (0, import_node_path6.relative)(cwd, id).replace(/\\/g, "/");
4556
4640
  }
4557
4641
  function isElementNode(value) {
4558
4642
  if (null === value || typeof value !== "object") return false;
@@ -4606,25 +4690,25 @@ function stampSvelte(code, id, load = defaultLoadCompiler) {
4606
4690
  }
4607
4691
 
4608
4692
  // src/installed.ts
4609
- var import_node_fs4 = require("node:fs");
4610
- var import_node_path5 = require("node:path");
4693
+ var import_node_fs5 = require("node:fs");
4694
+ var import_node_path7 = require("node:path");
4611
4695
  var import_node_module2 = require("node:module");
4612
4696
  var RETICLE_PACKAGE = "@reticlehq/react";
4613
4697
  var MANIFEST_SEARCH_DEPTH = 5;
4614
4698
  function requireFromApp(from) {
4615
- const absoluteFrom = (0, import_node_path5.isAbsolute)(from) ? from : (0, import_node_path5.resolve)(process.cwd(), from);
4616
- return (0, import_node_module2.createRequire)((0, import_node_path5.join)(absoluteFrom, "package.json"));
4699
+ const absoluteFrom = (0, import_node_path7.isAbsolute)(from) ? from : (0, import_node_path7.resolve)(process.cwd(), from);
4700
+ return (0, import_node_module2.createRequire)((0, import_node_path7.join)(absoluteFrom, "package.json"));
4617
4701
  }
4618
4702
  function packageDirOf(specifier, from) {
4619
4703
  try {
4620
- let dir = (0, import_node_path5.dirname)(requireFromApp(from).resolve(specifier));
4704
+ let dir = (0, import_node_path7.dirname)(requireFromApp(from).resolve(specifier));
4621
4705
  for (let up = 0; up < MANIFEST_SEARCH_DEPTH; up++) {
4622
- const candidate = (0, import_node_path5.join)(dir, "package.json");
4623
- if ((0, import_node_fs4.existsSync)(candidate)) {
4624
- const parsed = JSON.parse((0, import_node_fs4.readFileSync)(candidate, "utf8"));
4706
+ const candidate = (0, import_node_path7.join)(dir, "package.json");
4707
+ if ((0, import_node_fs5.existsSync)(candidate)) {
4708
+ const parsed = JSON.parse((0, import_node_fs5.readFileSync)(candidate, "utf8"));
4625
4709
  if (parsed.name === specifier) return dir;
4626
4710
  }
4627
- const parent = (0, import_node_path5.dirname)(dir);
4711
+ const parent = (0, import_node_path7.dirname)(dir);
4628
4712
  if (parent === dir) break;
4629
4713
  dir = parent;
4630
4714
  }
@@ -4649,14 +4733,14 @@ function sdkPackageVersion(from = process.cwd()) {
4649
4733
  } catch {
4650
4734
  }
4651
4735
  try {
4652
- let dir = (0, import_node_path5.dirname)(require_.resolve(RETICLE_PACKAGE));
4736
+ let dir = (0, import_node_path7.dirname)(require_.resolve(RETICLE_PACKAGE));
4653
4737
  for (let up = 0; up < MANIFEST_SEARCH_DEPTH; up++) {
4654
- const candidate = (0, import_node_path5.join)(dir, "package.json");
4655
- if ((0, import_node_fs4.existsSync)(candidate)) {
4656
- const parsed = JSON.parse((0, import_node_fs4.readFileSync)(candidate, "utf8"));
4738
+ const candidate = (0, import_node_path7.join)(dir, "package.json");
4739
+ if ((0, import_node_fs5.existsSync)(candidate)) {
4740
+ const parsed = JSON.parse((0, import_node_fs5.readFileSync)(candidate, "utf8"));
4657
4741
  if ("string" === typeof parsed.version) return parsed.version;
4658
4742
  }
4659
- const parent = (0, import_node_path5.dirname)(dir);
4743
+ const parent = (0, import_node_path7.dirname)(dir);
4660
4744
  if (parent === dir) break;
4661
4745
  dir = parent;
4662
4746
  }
@@ -4667,7 +4751,7 @@ function sdkPackageVersion(from = process.cwd()) {
4667
4751
  function sdkBuildFingerprint(from = process.cwd()) {
4668
4752
  try {
4669
4753
  const entry = requireFromApp(from).resolve(RETICLE_PACKAGE);
4670
- const { size, mtimeMs } = (0, import_node_fs4.statSync)(entry);
4754
+ const { size, mtimeMs } = (0, import_node_fs5.statSync)(entry);
4671
4755
  return `${String(size)}-${String(Math.trunc(mtimeMs))}`;
4672
4756
  } catch {
4673
4757
  return "unknown";
@@ -4753,7 +4837,7 @@ function shouldStampSvelte(id) {
4753
4837
  return clean !== null && SVELTE_FILE.test(clean);
4754
4838
  }
4755
4839
  function stamp(code, id) {
4756
- const out = (0, import_core4.transformSync)(code, {
4840
+ const out = (0, import_core6.transformSync)(code, {
4757
4841
  filename: id,
4758
4842
  plugins: [import_babel_plugin.default],
4759
4843
  parserOpts: { plugins: ["jsx", "typescript"] },
@@ -4769,7 +4853,7 @@ function stamp(code, id) {
4769
4853
  }
4770
4854
  function readPairingToken() {
4771
4855
  const override = process.env[ReticleEnv.PAIRING_TOKEN_DIR];
4772
- const dir = override !== void 0 && override.length > 0 ? override : (0, import_node_path6.join)((0, import_node_os2.homedir)(), ReticleDir.ROOT);
4856
+ const dir = override !== void 0 && override.length > 0 ? override : (0, import_node_path8.join)((0, import_node_os2.homedir)(), ReticleDir.ROOT);
4773
4857
  return ensurePairingToken(dir);
4774
4858
  }
4775
4859
  var tokenWarned = false;
@@ -4796,6 +4880,9 @@ function connectArgs(options) {
4796
4880
  if (true === options.captureNetworkBodies || "1" === process.env["VITE_RETICLE_CAPTURE_BODIES"]) {
4797
4881
  args["captureNetworkBodies"] = true;
4798
4882
  }
4883
+ if (true === options.exposePresenter || "1" === process.env["VITE_RETICLE_EXPOSE_PRESENTER"]) {
4884
+ args["exposePresenter"] = true;
4885
+ }
4799
4886
  if (true === options.allowNonLocalhost || "1" === process.env["VITE_RETICLE_ALLOW_NON_LOCALHOST"]) {
4800
4887
  args["allowNonLocalhost"] = true;
4801
4888
  }
@@ -4815,9 +4902,9 @@ function findDevModule(root, exists) {
4815
4902
  }
4816
4903
  function installedSdk(appRoot, canResolve = (dep) => null !== resolvableChain([dep], appRoot)) {
4817
4904
  try {
4818
- const pkgPath = (0, import_node_path6.join)(appRoot, "package.json");
4819
- if ((0, import_node_fs5.existsSync)(pkgPath)) {
4820
- const pkg = JSON.parse((0, import_node_fs5.readFileSync)(pkgPath, "utf8"));
4905
+ const pkgPath = (0, import_node_path8.join)(appRoot, "package.json");
4906
+ if ((0, import_node_fs6.existsSync)(pkgPath)) {
4907
+ const pkg = JSON.parse((0, import_node_fs6.readFileSync)(pkgPath, "utf8"));
4821
4908
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
4822
4909
  if (deps[RETICLE_SENSOR] !== void 0 && deps[RETICLE_PACKAGE2] === void 0) {
4823
4910
  return { specifier: RETICLE_SENSOR, usesInstall: false };
@@ -4870,10 +4957,10 @@ function reticle(options = {}) {
4870
4957
  const sdkVersion = withToken.sdkVersion ?? sdkPackageVersion(appRoot);
4871
4958
  return { ...withToken, root: appRoot, sdkVersion };
4872
4959
  };
4873
- const currentConnectSource = () => connectModuleSource(resolveLazy(), root === void 0 ? null : findDevModule(root, import_node_fs5.existsSync));
4960
+ const currentConnectSource = () => connectModuleSource(resolveLazy(), root === void 0 ? null : findDevModule(root, import_node_fs6.existsSync));
4874
4961
  let lastServedConnectSource;
4875
4962
  let connectChanges = 0;
4876
- const notInjectedMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not inject reticle.connect(): the HTML entry module was never matched, so this app carries no instrumentation and will never connect. Check that index.html references your entry with a <script type="module" src="...">, or pass \`inject: false\` and call reticle.connect() yourself.`;
4963
+ const notInjectedMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not inject reticle.connect(): the HTML entry module was never matched, so this app carries no instrumentation and will never connect. Check that index.html references your entry with a <script type="module" src="...">, or pass \`inject: false\` and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself. The plugin still inlines that define; a connect without it is refused.`;
4877
4964
  const unconfirmedInjectionMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not confirm reticle.connect() was injected: the HTML entry module was not transformed this session. That is expected when Vite served it from its transform cache. If the app does not appear in \`reticle status\`, restart the dev server with \`--force\` to bypass the cache, then check that index.html references your entry with a <script type="module" src="...">.`;
4878
4965
  const checkInjected = () => {
4879
4966
  if (!desktop || !inject || injected) return;
@@ -5036,6 +5123,30 @@ ${code}`;
5036
5123
  */
5037
5124
  configureServer(server) {
5038
5125
  if (!inject) return;
5126
+ const announce = () => {
5127
+ const address = server.httpServer?.address();
5128
+ const port = "object" === typeof address && null !== address && void 0 !== address ? address.port : void 0;
5129
+ if (void 0 === port) return;
5130
+ const opts = resolveLazy();
5131
+ const withdraw = announceDevServer({
5132
+ port,
5133
+ pid: process.pid,
5134
+ root: opts.root ?? process.cwd(),
5135
+ url: server.resolvedUrls?.local[0] ?? `http://localhost:${String(port)}/`,
5136
+ ...opts.sdkVersion === void 0 || 0 === opts.sdkVersion.length ? {} : { sdkVersion: opts.sdkVersion },
5137
+ startedAt: Date.now(),
5138
+ ...opts.projectId === void 0 ? {} : { projectId: opts.projectId }
5139
+ });
5140
+ server.httpServer?.once("close", withdraw);
5141
+ process.once("exit", withdraw);
5142
+ process.once("SIGINT", withdraw);
5143
+ process.once("SIGTERM", withdraw);
5144
+ };
5145
+ if (null === server.httpServer?.address() || void 0 === server.httpServer?.address()) {
5146
+ server.httpServer?.once("listening", announce);
5147
+ } else {
5148
+ announce();
5149
+ }
5039
5150
  server.middlewares.use((req, _res, next) => {
5040
5151
  if ((req.url ?? "").split("?")[0] === RETICLE_CONNECT_MODULE) {
5041
5152
  if (currentConnectSource() !== lastServedConnectSource) {
package/dist/index.d.cts CHANGED
@@ -84,6 +84,20 @@ export interface ReticleVitePluginOptions {
84
84
  * session without editing vite.config.
85
85
  */
86
86
  captureNetworkBodies?: boolean;
87
+ /**
88
+ * Make Reticle's OWN presenter visible to snapshots and queries. CONTRIBUTORS ONLY.
89
+ *
90
+ * Reachable here for the same reason `captureNetworkBodies` is: the plugin is the only `connect()`
91
+ * most apps ever have, so an SDK option the plugin cannot pass is an option that does not exist.
92
+ *
93
+ * The presenter is hidden from every tool by design — an agent that can drive Reticle's own
94
+ * interface can fabricate its own impact report. The cost is that a HUD change is the only kind of
95
+ * change Reticle cannot be used to check. This is the hatch for that one case, and the app reports
96
+ * it in its capabilities so a verdict drawn with it open is never mistaken for an ordinary one.
97
+ *
98
+ * Also settable as `VITE_RETICLE_EXPOSE_PRESENTER=1`.
99
+ */
100
+ exposePresenter?: boolean;
87
101
  /**
88
102
  * Let Reticle run when the page or the bridge is not on localhost.
89
103
  *
@@ -171,6 +185,26 @@ export interface ReticleVitePlugin {
171
185
  * plugin never imports, the same way the Svelte compiler and Playwright are handled elsewhere.
172
186
  */
173
187
  export interface ViteDevServerLike {
188
+ /**
189
+ * Vite's own HTTP server, for the port it ACTUALLY bound and the moment it bound it. Optional and
190
+ * nullable because middleware mode has none — and because a structural stand-in that demands more
191
+ * than Vite guarantees stops being assignable, which red-builds every typechecked config.
192
+ */
193
+ httpServer?: {
194
+ once(event: string, listener: () => void): unknown;
195
+ address(): string | {
196
+ port: number;
197
+ } | null;
198
+ } | null;
199
+ /**
200
+ * The URLs Vite prints on boot. Read rather than assembled: host, protocol and base are all
201
+ * configurable, so composing a URL here would be a guess about the one thing the dev server can
202
+ * simply be asked.
203
+ */
204
+ resolvedUrls?: {
205
+ local: string[];
206
+ network: string[];
207
+ } | null;
174
208
  middlewares: {
175
209
  use(handler: (req: {
176
210
  url?: string | undefined;
package/dist/index.d.ts CHANGED
@@ -84,6 +84,20 @@ export interface ReticleVitePluginOptions {
84
84
  * session without editing vite.config.
85
85
  */
86
86
  captureNetworkBodies?: boolean;
87
+ /**
88
+ * Make Reticle's OWN presenter visible to snapshots and queries. CONTRIBUTORS ONLY.
89
+ *
90
+ * Reachable here for the same reason `captureNetworkBodies` is: the plugin is the only `connect()`
91
+ * most apps ever have, so an SDK option the plugin cannot pass is an option that does not exist.
92
+ *
93
+ * The presenter is hidden from every tool by design — an agent that can drive Reticle's own
94
+ * interface can fabricate its own impact report. The cost is that a HUD change is the only kind of
95
+ * change Reticle cannot be used to check. This is the hatch for that one case, and the app reports
96
+ * it in its capabilities so a verdict drawn with it open is never mistaken for an ordinary one.
97
+ *
98
+ * Also settable as `VITE_RETICLE_EXPOSE_PRESENTER=1`.
99
+ */
100
+ exposePresenter?: boolean;
87
101
  /**
88
102
  * Let Reticle run when the page or the bridge is not on localhost.
89
103
  *
@@ -171,6 +185,26 @@ export interface ReticleVitePlugin {
171
185
  * plugin never imports, the same way the Svelte compiler and Playwright are handled elsewhere.
172
186
  */
173
187
  export interface ViteDevServerLike {
188
+ /**
189
+ * Vite's own HTTP server, for the port it ACTUALLY bound and the moment it bound it. Optional and
190
+ * nullable because middleware mode has none — and because a structural stand-in that demands more
191
+ * than Vite guarantees stops being assignable, which red-builds every typechecked config.
192
+ */
193
+ httpServer?: {
194
+ once(event: string, listener: () => void): unknown;
195
+ address(): string | {
196
+ port: number;
197
+ } | null;
198
+ } | null;
199
+ /**
200
+ * The URLs Vite prints on boot. Read rather than assembled: host, protocol and base are all
201
+ * configurable, so composing a URL here would be a guess about the one thing the dev server can
202
+ * simply be asked.
203
+ */
204
+ resolvedUrls?: {
205
+ local: string[];
206
+ network: string[];
207
+ } | null;
174
208
  middlewares: {
175
209
  use(handler: (req: {
176
210
  url?: string | undefined;
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import reticleSource from '@reticlehq/babel-plugin';
8
8
  import { RETICLE_DEFAULT_PORT, RETICLE_RENDER_PREHOOK, bridgeWsUrl, ReticleDir, ReticleEnv, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, } from '@reticlehq/core';
9
9
  import { resolveProjectId } from './project-id.js';
10
10
  import { discoverDaemonPort } from './discover-port.js';
11
+ import { announceDevServer } from './announce.js';
11
12
  import { SVELTE_FILE, stampSvelte } from './svelte-source.js';
12
13
  import { resolvableChain, sdkPackageVersion, sdkBuildFingerprint, viteMajor, optimizerOptionsKey, optimizerOptions, } from './installed.js';
13
14
  export const RETICLE_VITE_PLUGIN_NAME = 'reticle';
@@ -206,6 +207,10 @@ function connectArgs(options) {
206
207
  if (true === options.captureNetworkBodies || '1' === process.env['VITE_RETICLE_CAPTURE_BODIES']) {
207
208
  args['captureNetworkBodies'] = true;
208
209
  }
210
+ // Same shape, same reason. Off unless asked for, in a config or for one session.
211
+ if (true === options.exposePresenter || '1' === process.env['VITE_RETICLE_EXPOSE_PRESENTER']) {
212
+ args['exposePresenter'] = true;
213
+ }
209
214
  // Same shape, same reason: without it an app that cannot be served on localhost has no way to
210
215
  // reach the SDK option at all. The pairing token still applies — see the option's docstring.
211
216
  if (true === options.allowNonLocalhost ||
@@ -375,7 +380,8 @@ export function reticle(options = {}) {
375
380
  const notInjectedMessage = () => `[${RETICLE_VITE_PLUGIN_NAME}] could not inject reticle.connect(): the HTML entry module was ` +
376
381
  'never matched, so this app carries no instrumentation and will never connect. Check that ' +
377
382
  'index.html references your entry with a <script type="module" src="...">, or pass ' +
378
- '`inject: false` and call reticle.connect() yourself.';
383
+ '`inject: false` and call reticle.connect({ token: __RETICLE_TOKEN__ }) yourself. The plugin ' +
384
+ 'still inlines that define; a connect without it is refused.';
379
385
  /**
380
386
  * The DEV message, which must be weaker — and this is the whole reason the two are separate.
381
387
  *
@@ -572,6 +578,50 @@ export function reticle(options = {}) {
572
578
  configureServer(server) {
573
579
  if (!inject)
574
580
  return;
581
+ // Tell `~/.reticle` this dev server exists, the moment it is actually listening.
582
+ //
583
+ // This is the one fact nobody outside this process could observe: the plugin is loaded in the
584
+ // dev server that is RUNNING, not merely present in a config file on disk. Its absence is the
585
+ // commonest setup failure there is — a plugin added to a config the running server already
586
+ // read — and until now that failure was indistinguishable from every other one.
587
+ //
588
+ // Deliberately reads the port and URL off the server rather than composing them. The port may
589
+ // be `strictPort: false` and have moved, the host and base are configurable, and every one of
590
+ // those is something the user can change under us.
591
+ const announce = () => {
592
+ const address = server.httpServer?.address();
593
+ const port = 'object' === typeof address && null !== address && undefined !== address
594
+ ? address.port
595
+ : undefined;
596
+ if (undefined === port)
597
+ return;
598
+ const opts = resolveLazy();
599
+ const withdraw = announceDevServer({
600
+ port,
601
+ pid: process.pid,
602
+ root: opts.root ?? process.cwd(),
603
+ url: server.resolvedUrls?.local[0] ?? `http://localhost:${String(port)}/`,
604
+ ...(opts.sdkVersion === undefined || 0 === opts.sdkVersion.length
605
+ ? {}
606
+ : { sdkVersion: opts.sdkVersion }),
607
+ startedAt: Date.now(),
608
+ ...(opts.projectId === undefined ? {} : { projectId: opts.projectId }),
609
+ });
610
+ server.httpServer?.once('close', withdraw);
611
+ // `close` does not fire on Ctrl-C, which is how a dev server usually dies. The read side
612
+ // checks liveness anyway, so a missed withdrawal degrades rather than lies — these just
613
+ // keep the directory tidy in the cases we can catch.
614
+ process.once('exit', withdraw);
615
+ process.once('SIGINT', withdraw);
616
+ process.once('SIGTERM', withdraw);
617
+ };
618
+ // Already bound in some setups (middleware mode, a restart), not yet in the common one.
619
+ if (null === server.httpServer?.address() || undefined === server.httpServer?.address()) {
620
+ server.httpServer?.once('listening', announce);
621
+ }
622
+ else {
623
+ announce();
624
+ }
575
625
  server.middlewares.use((req, _res, next) => {
576
626
  if ((req.url ?? '').split('?')[0] === RETICLE_CONNECT_MODULE) {
577
627
  if (currentConnectSource() !== lastServedConnectSource) {
@@ -0,0 +1 @@
1
+ export declare function stateHome(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where Reticle keeps machine state — the one answer, for both halves of discovery.
3
+ *
4
+ * `RETICLE_STATE_DIR` already exists and already relocates the daemon's pidfiles, logs and discovery
5
+ * registry (a read-only `$HOME` — sandboxed agent, locked-down Windows profile, container — makes
6
+ * the default unwritable). The plugin's reader did not honour it: with the variable set, the daemon
7
+ * wrote its registry to one directory while `discoverDaemonPort` looked in another, so discovery
8
+ * silently found nothing and every app fell back to the default port. Exactly the class of split
9
+ * this file exists to prevent — two halves of one mechanism with two ideas of where it lives.
10
+ */
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { ReticleDir, ReticleEnv } from '@reticlehq/core';
14
+ export function stateHome(env = process.env) {
15
+ const override = env[ReticleEnv.STATE_DIR];
16
+ return override !== undefined && override.length > 0
17
+ ? override
18
+ : join(homedir(), ReticleDir.ROOT);
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/vite-plugin",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Vite plugin for Reticle: dev-only source-map stamping plus auto-injected reticle.connect(). apply:'serve' guarantees it never ships to production.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,14 +41,14 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@babel/core": "^7.26.0",
44
- "@reticlehq/babel-plugin": "2.12.0",
45
- "@reticlehq/core": "2.12.0"
44
+ "@reticlehq/core": "2.13.0",
45
+ "@reticlehq/babel-plugin": "2.13.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/babel__core": "^7.20.5",
49
- "svelte": "^5.56.8",
49
+ "svelte": "^5.56.10",
50
50
  "vite": "^8",
51
- "esbuild": "^0.28.1"
51
+ "esbuild": "^0.28.2"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "vite": ">=4"