@reticlehq/server 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-ENTERPRISE +1 -1
- package/README.md +3 -3
- package/SKILL.md +12 -14
- package/dist/chromium-launch-options.d.ts +4 -3
- package/dist/chromium-launch-options.js +6 -2
- package/dist/command/cli/adhoc-suite.d.ts +22 -0
- package/dist/command/cli/adhoc-suite.js +90 -19
- package/dist/command/cli/adhoc-verdict.d.ts +35 -0
- package/dist/command/cli/adhoc-verdict.js +90 -3
- package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
- package/dist/command/cli/answers/gate-hook-message.js +6 -0
- package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
- package/dist/command/cli/auth/cloud-keystore.js +0 -3
- package/dist/command/cli/auth/cloud-session.d.ts +4 -0
- package/dist/command/cli/auth/cloud-session.js +2 -0
- package/dist/command/cli/cli-doctor.js +7 -5
- package/dist/command/cli/cli-flow-commands.d.ts +3 -6
- package/dist/command/cli/cli-flow-commands.js +67 -4
- package/dist/command/cli/cli-parse.d.ts +8 -1
- package/dist/command/cli/cli-parse.js +21 -3
- package/dist/command/cli/cli-update-commands.js +8 -1
- package/dist/command/cli/cli-verify.d.ts +24 -3
- package/dist/command/cli/cli-verify.js +54 -11
- package/dist/command/cli/cloud-cli.js +5 -2
- package/dist/command/cli/cloud-kit.js +3 -5
- package/dist/command/cli/cloud-login.js +21 -3
- package/dist/command/cli/demo/demo-page.js +6 -3
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
- package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
- package/dist/command/cli/launch/cli-launch.d.ts +8 -0
- package/dist/command/cli/launch/cli-launch.js +21 -9
- package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
- package/dist/command/cli/launch/sdk-version-match.js +206 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
- package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
- package/dist/command/cli/ports/resolve/cli-port.js +125 -15
- package/dist/command/cli/setup-mcp-cli.js +13 -13
- package/dist/command/cli/tutorial.d.ts +2 -2
- package/dist/command/cli/tutorial.js +21 -7
- package/dist/command/cli.js +62 -10
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
- package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
- package/dist/command/daemon/daemon-resolve.d.ts +9 -0
- package/dist/command/daemon/daemon-resolve.js +1 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
- package/dist/command/dev/stale-issue-guard.d.ts +3 -4
- package/dist/command/dev/stale-issue-guard.js +8 -8
- package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
- package/dist/command/setup/bringup/connect-lease.js +38 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
- package/dist/command/setup/bringup/dev-server-files.js +131 -0
- package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
- package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
- package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
- package/dist/command/setup/bringup/ensure-daemon.js +135 -16
- package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
- package/dist/command/setup/bringup/static-page-server.js +151 -0
- package/dist/command/setup/desktop-shape.d.ts +4 -0
- package/dist/command/setup/desktop-shape.js +18 -2
- package/dist/command/setup/init/init-port.d.ts +15 -0
- package/dist/command/setup/init/init-port.js +44 -0
- package/dist/command/setup/init/init-runtime.d.ts +1 -1
- package/dist/command/setup/init/init-runtime.js +39 -2
- package/dist/command/setup/node-effects.d.ts +27 -14
- package/dist/command/setup/node-effects.js +80 -44
- package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
- package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
- package/dist/command/setup/probe/page-probe.d.ts +13 -2
- package/dist/command/setup/probe/page-probe.js +18 -3
- package/dist/command/setup/run-setup.d.ts +26 -1
- package/dist/command/setup/run-setup.js +96 -13
- package/dist/command/setup/session-pick.d.ts +13 -0
- package/dist/command/setup/session-pick.js +20 -0
- package/dist/command/setup/setup-command.d.ts +2 -14
- package/dist/command/setup/setup-command.js +105 -31
- package/dist/command/setup/setup-mcp.js +3 -3
- package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
- package/dist/command/setup/terminal/wait-progress.js +17 -0
- package/dist/features/crawl/crawl-empty.js +1 -1
- package/dist/features/crawl/crawl-tools.d.ts +6 -0
- package/dist/features/crawl/crawl-tools.js +57 -0
- package/dist/features/crawl/crawl.js +171 -157
- package/dist/features/exhaust/app-coverage.d.ts +33 -0
- package/dist/features/exhaust/app-coverage.js +31 -0
- package/dist/features/exhaust/code-coverage.d.ts +47 -0
- package/dist/features/exhaust/code-coverage.js +77 -0
- package/dist/features/exhaust/exhaust-run.d.ts +33 -0
- package/dist/features/exhaust/exhaust-run.js +82 -0
- package/dist/features/exhaust/explorer.d.ts +103 -0
- package/dist/features/exhaust/explorer.js +195 -0
- package/dist/features/exhaust/failure-branches.d.ts +33 -0
- package/dist/features/exhaust/failure-branches.js +92 -0
- package/dist/features/exhaust/ledger.d.ts +202 -0
- package/dist/features/exhaust/ledger.js +252 -0
- package/dist/features/exhaust/session-fold.d.ts +31 -0
- package/dist/features/exhaust/session-fold.js +79 -0
- package/dist/features/exhaust/session-port.d.ts +36 -0
- package/dist/features/exhaust/session-port.js +102 -0
- package/dist/features/harness/drive-report.d.ts +0 -9
- package/dist/features/harness/drive-report.js +18 -0
- package/dist/features/harness/driver.js +4 -7
- package/dist/features/harness/fill-values.d.ts +6 -0
- package/dist/features/harness/fill-values.js +3 -0
- package/dist/features/harness/jev-driver.js +30 -13
- package/dist/features/harness/openai-driver.js +4 -7
- package/dist/features/harness/platform-config.js +5 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +40 -11
- package/dist/judgement/domain/domain-model.js +3 -1
- package/dist/judgement/oracles/self-instrument.js +4 -2
- package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
- package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
- package/dist/judgement/runs/verification-sync.d.ts +3 -1
- package/dist/judgement/runs/verification-sync.js +1 -1
- package/dist/language/flows/change/affected.d.ts +9 -0
- package/dist/language/flows/change/affected.js +35 -0
- package/dist/language/flows/change/gate.d.ts +20 -0
- package/dist/language/flows/change/gate.js +12 -1
- package/dist/language/flows/flow-author.d.ts +7 -0
- package/dist/language/flows/flow-author.js +19 -0
- package/dist/language/flows/flow-expect-grammar.js +1 -1
- package/dist/language/flows/flow-journey.d.ts +54 -0
- package/dist/language/flows/flow-journey.js +119 -0
- package/dist/language/flows/flow-replay-run.d.ts +2 -3
- package/dist/language/flows/flow-replay-run.js +13 -47
- package/dist/language/flows/flow-replay.d.ts +22 -8
- package/dist/language/flows/flow-replay.js +55 -9
- package/dist/language/flows/flow-result.d.ts +2 -0
- package/dist/language/flows/flow-result.js +25 -0
- package/dist/language/flows/flow-tools.js +17 -5
- package/dist/language/flows/flows.d.ts +10 -1
- package/dist/language/flows/flows.js +90 -14
- package/dist/language/flows/recording/replay-halt.js +3 -1
- package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
- package/dist/language/flows/recording/tape/recordings.js +55 -1
- package/dist/language/flows/replay.d.ts +19 -0
- package/dist/language/flows/replay.js +42 -6
- package/dist/language/flows/verify-change-tools.js +5 -2
- package/dist/launch-chromium.d.ts +80 -0
- package/dist/launch-chromium.js +218 -0
- package/dist/memory/cloud/cloud-config.d.ts +11 -2
- package/dist/memory/cloud/cloud-config.js +47 -7
- package/dist/memory/cloud/cloud-sync.d.ts +4 -1
- package/dist/memory/cloud/cloud-sync.js +6 -9
- package/dist/memory/cloud/harness-offer.js +5 -6
- package/dist/memory/cloud/harness-switch.js +5 -6
- package/dist/memory/cloud/sync-cycle.d.ts +68 -3
- package/dist/memory/cloud/sync-cycle.js +298 -55
- package/dist/memory/cloud/sync-daemon.js +24 -20
- package/dist/memory/cloud/sync-disk.js +16 -2
- package/dist/memory/intent/intent-shard.d.ts +10 -32
- package/dist/memory/intent/intent-shard.js +0 -10
- package/dist/memory/journal/drive-flow.d.ts +18 -13
- package/dist/memory/journal/drive-flow.js +45 -37
- package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
- package/dist/memory/journal/session-end.d.ts +9 -1
- package/dist/memory/journal/session-end.js +24 -12
- package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
- package/dist/memory/project/dir/reticle-dir.js +1 -0
- package/dist/memory/project/project-tools.js +3 -1
- package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
- package/dist/portal/bridge/auth-failure-reason.js +4 -2
- package/dist/portal/bridge/bridge.d.ts +8 -0
- package/dist/portal/bridge/bridge.js +43 -2
- package/dist/portal/input/js-coverage.d.ts +25 -0
- package/dist/portal/input/js-coverage.js +30 -0
- package/dist/portal/input/real-input.d.ts +11 -0
- package/dist/portal/input/real-input.js +29 -4
- package/dist/portal/pool/playwright-launcher.d.ts +6 -0
- package/dist/portal/pool/playwright-launcher.js +20 -13
- package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
- package/dist/portal/session/dev-server/dev-command.js +30 -12
- package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
- package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
- package/dist/portal/session/dev-server/route-status-probe.js +57 -0
- package/dist/portal/session/facts/observed-state.d.ts +11 -0
- package/dist/portal/session/facts/observed-state.js +34 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
- package/dist/portal/session/no-session-diagnosis.js +62 -5
- package/dist/portal/session/no-session-next-action.d.ts +13 -0
- package/dist/portal/session/no-session-next-action.js +48 -9
- package/dist/portal/session/no-session-watch.d.ts +18 -0
- package/dist/portal/session/no-session-watch.js +88 -2
- package/dist/portal/session/session-health.js +3 -2
- package/dist/portal/session/session-manager.d.ts +20 -6
- package/dist/portal/session/session-manager.js +19 -3
- package/dist/portal/session/session-reaper.d.ts +28 -0
- package/dist/portal/session/session-reaper.js +49 -1
- package/dist/portal/session/session.d.ts +5 -0
- package/dist/portal/session/session.js +15 -19
- package/dist/surface/http-server.d.ts +2 -1
- package/dist/surface/http-server.js +4 -2
- package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
- package/dist/surface/mcp/mcp-proxy.js +2 -2
- package/dist/surface/mcp/mcp.js +4 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
- package/dist/surface/mcp/server-instructions.js +9 -7
- package/dist/surface/tools/act/after-match.d.ts +17 -0
- package/dist/surface/tools/act/after-match.js +30 -0
- package/dist/surface/tools/act/durable.d.ts +69 -0
- package/dist/surface/tools/act/durable.js +139 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
- package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
- package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
- package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
- package/dist/surface/tools/act/proved-controls.d.ts +9 -0
- package/dist/surface/tools/act/proved-controls.js +11 -0
- package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
- package/dist/surface/tools/act/sequence-grade.js +32 -1
- package/dist/surface/tools/act-capsule.d.ts +7 -0
- package/dist/surface/tools/act-capsule.js +46 -1
- package/dist/surface/tools/act-sequence-tool.js +25 -8
- package/dist/surface/tools/act-tools.js +26 -50
- package/dist/surface/tools/assert/assert-grade.js +12 -1
- package/dist/surface/tools/assert/assert-verdict.js +2 -2
- package/dist/surface/tools/browser-tools.js +14 -2
- package/dist/surface/tools/coverage-tools.js +27 -1
- package/dist/surface/tools/error-recovery.js +4 -4
- package/dist/surface/tools/feedback-invite.js +6 -4
- package/dist/surface/tools/harness-explore.d.ts +3 -2
- package/dist/surface/tools/harness-explore.js +7 -6
- package/dist/surface/tools/lease-tools.js +15 -6
- package/dist/surface/tools/live-call-text.js +4 -1
- package/dist/surface/tools/observe-tools.js +9 -1
- package/dist/surface/tools/tools.js +4 -0
- package/dist/telemetry/argument-shape.d.ts +0 -2
- package/dist/telemetry/argument-shape.js +0 -4
- package/dist/telemetry/error-class.d.ts +2 -3
- package/dist/telemetry/error-class.js +2 -3
- package/dist/telemetry/harness-drive.d.ts +0 -2
- package/dist/telemetry/harness-drive.js +0 -4
- package/dist/wire-journal.d.ts +3 -0
- package/dist/wire-journal.js +4 -0
- package/dist/workspace-packages.d.ts +0 -2
- package/dist/workspace-packages.js +1 -9
- package/docs/README.md +4 -2
- package/docs/actions.mdx +3 -3
- package/docs/agent-cheatsheet.md +4 -4
- package/docs/architecture.md +6 -6
- package/docs/best-practices.mdx +1 -1
- package/docs/capabilities.mdx +32 -1
- package/docs/cli/cloud.mdx +14 -4
- package/docs/cli/doctor.mdx +4 -0
- package/docs/cli/feedback.mdx +2 -2
- package/docs/cli/gate.mdx +9 -4
- package/docs/cli/update.mdx +10 -0
- package/docs/cli.mdx +9 -0
- package/docs/desktop.mdx +14 -10
- package/docs/environment.mdx +87 -0
- package/docs/faq.mdx +18 -16
- package/docs/first-drive.md +5 -13
- package/docs/flows.md +21 -2
- package/docs/frameworks.mdx +29 -10
- package/docs/getting-started.md +3 -3
- package/docs/http-transport.md +6 -3
- package/docs/human-control.md +2 -2
- package/docs/index.mdx +16 -6
- package/docs/install-agentic.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/integration-patterns.md +3 -3
- package/docs/multi-agent-testing.md +1 -1
- package/docs/packages/babel-plugin.mdx +1 -1
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/react.mdx +2 -2
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +2 -2
- package/docs/packages.mdx +9 -9
- package/docs/predicates.mdx +50 -5
- package/docs/quickstart.mdx +2 -2
- package/docs/recipes.mdx +2 -2
- package/docs/state-management.mdx +2 -2
- package/docs/system-map.md +1 -1
- package/docs/telemetry.md +1 -1
- package/docs/token-efficiency.md +3 -3
- package/docs/tools/act-and-wait.mdx +1 -0
- package/docs/tools/act-sequence.mdx +1 -1
- package/docs/tools/console.mdx +1 -1
- package/docs/tools/inspect.mdx +1 -1
- package/docs/tools/network.mdx +1 -1
- package/docs/tools/query.mdx +1 -1
- package/docs/tools/sessions.mdx +1 -1
- package/docs/tools/snapshot.mdx +1 -1
- package/docs/tools/state.mdx +1 -1
- package/docs/tools/wait-for.mdx +1 -1
- package/docs/usage.md +7 -6
- package/docs/use-cases.mdx +157 -0
- package/docs/vs-screenshots.mdx +3 -3
- package/docs/what-is-recorded.md +2 -2
- package/docs/what-is-reticle.mdx +17 -20
- package/docs/why-reticle.mdx +14 -21
- package/package.json +10 -9
- package/dist/judgement/capsule/minimize.d.ts +0 -12
- package/dist/judgement/capsule/minimize.js +0 -22
- package/dist/judgement/phenomena/phenomena.d.ts +0 -27
- package/dist/judgement/phenomena/phenomena.js +0 -101
- package/dist/language/flows/flow-report.d.ts +0 -22
- package/dist/language/flows/flow-report.js +0 -106
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server `init` starts for a plain HTML page that has none of its own.
|
|
3
|
+
*
|
|
4
|
+
* A directory holding only `index.html` (and a script or two) used to be wired correctly — init
|
|
5
|
+
* writes the connect snippet into the page — and then stop at "No dev command … start the app
|
|
6
|
+
* yourself and pass --url". The one thing left between that user and a connected session was a web
|
|
7
|
+
* server, which is the one thing a page like that never had. So init starts this one, the same way it
|
|
8
|
+
* starts `npm run dev`, and the rest of setup (wait, open, prove the connect) runs unchanged.
|
|
9
|
+
*
|
|
10
|
+
* It is run as its own process (`node static-page-server.js --dir <app>`), not inside init, because it
|
|
11
|
+
* has to outlive init exactly like any handed-over dev server. It imports nothing but Node, so the
|
|
12
|
+
* built file runs on its own.
|
|
13
|
+
*
|
|
14
|
+
* Loopback only, on both families: a page served to the LAN would publish the pairing token the
|
|
15
|
+
* snippet carries.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFile, statSync } from 'node:fs';
|
|
18
|
+
import { createServer } from 'node:http';
|
|
19
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
export const STATIC_PAGE_INDEX = 'index.html';
|
|
22
|
+
/** Where the search for a free port starts when none is given — clear of Vite's 5173 run. */
|
|
23
|
+
export const STATIC_PAGE_FIRST_PORT = 5500;
|
|
24
|
+
const PORT_SEARCH_SPAN = 50;
|
|
25
|
+
const DIR_FLAG = '--dir';
|
|
26
|
+
const PORT_FLAG = '--port';
|
|
27
|
+
const LOOPBACK_V4 = '127.0.0.1';
|
|
28
|
+
const LOOPBACK_V6 = '::1';
|
|
29
|
+
const ANNOUNCED_HOST = 'localhost';
|
|
30
|
+
const CONTENT_TYPES = {
|
|
31
|
+
'.html': 'text/html; charset=utf-8',
|
|
32
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
33
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
34
|
+
'.css': 'text/css; charset=utf-8',
|
|
35
|
+
'.json': 'application/json; charset=utf-8',
|
|
36
|
+
'.svg': 'image/svg+xml',
|
|
37
|
+
'.png': 'image/png',
|
|
38
|
+
'.jpg': 'image/jpeg',
|
|
39
|
+
'.jpeg': 'image/jpeg',
|
|
40
|
+
'.gif': 'image/gif',
|
|
41
|
+
'.webp': 'image/webp',
|
|
42
|
+
'.ico': 'image/x-icon',
|
|
43
|
+
'.woff2': 'font/woff2',
|
|
44
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
45
|
+
};
|
|
46
|
+
const FALLBACK_TYPE = 'application/octet-stream';
|
|
47
|
+
/** The file a request names, or null when it is missing or outside `root`. */
|
|
48
|
+
function fileFor(root, rawUrl) {
|
|
49
|
+
let path;
|
|
50
|
+
try {
|
|
51
|
+
path = decodeURIComponent(new URL(rawUrl, 'http://x').pathname);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const target = resolve(root, `.${path}`);
|
|
57
|
+
if (target !== root && !target.startsWith(`${root}${sep}`))
|
|
58
|
+
return null;
|
|
59
|
+
const file = path.endsWith('/') ? join(target, STATIC_PAGE_INDEX) : target;
|
|
60
|
+
try {
|
|
61
|
+
return statSync(file).isFile() ? file : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function listen(server, port, host) {
|
|
68
|
+
return new Promise((ok, fail) => {
|
|
69
|
+
server.once('error', fail);
|
|
70
|
+
server.listen(port, host, () => {
|
|
71
|
+
server.off('error', fail);
|
|
72
|
+
ok();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function closeServer(server) {
|
|
77
|
+
return new Promise((done) => server.close(() => done()));
|
|
78
|
+
}
|
|
79
|
+
export async function startStaticPageServer(opts) {
|
|
80
|
+
const root = resolve(opts.root);
|
|
81
|
+
const handler = (req, res) => {
|
|
82
|
+
const file = fileFor(root, req.url ?? '/');
|
|
83
|
+
if (null === file) {
|
|
84
|
+
res.writeHead(404).end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
readFile(file, (err, body) => {
|
|
88
|
+
if (null !== err) {
|
|
89
|
+
res.writeHead(404).end();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
res.writeHead(200, {
|
|
93
|
+
'content-type': CONTENT_TYPES[extname(file).toLowerCase()] ?? FALLBACK_TYPE,
|
|
94
|
+
'cache-control': 'no-store',
|
|
95
|
+
});
|
|
96
|
+
res.end(body);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
const v4 = createServer(handler);
|
|
100
|
+
// Port 0 means "any": the v4 bind picks it, and the v6 listener joins the same number.
|
|
101
|
+
const candidates = 0 === opts.port ? [0] : Array.from({ length: PORT_SEARCH_SPAN }, (_, i) => opts.port + i);
|
|
102
|
+
let bound;
|
|
103
|
+
for (const port of candidates) {
|
|
104
|
+
try {
|
|
105
|
+
await listen(v4, port, LOOPBACK_V4);
|
|
106
|
+
bound = v4.address().port;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* taken: try the next one */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (undefined === bound) {
|
|
114
|
+
throw new Error(`no free port in ${String(opts.port)}–${String(opts.port + PORT_SEARCH_SPAN)}`);
|
|
115
|
+
}
|
|
116
|
+
// `localhost` resolves to ::1 first on macOS, so the page must answer there too. A machine with
|
|
117
|
+
// no IPv6 loopback just keeps the v4 listener.
|
|
118
|
+
const v6 = createServer(handler);
|
|
119
|
+
const hasV6 = await listen(v6, bound, LOOPBACK_V6).then(() => true, () => false);
|
|
120
|
+
return {
|
|
121
|
+
url: `http://${ANNOUNCED_HOST}:${String(bound)}/`,
|
|
122
|
+
close: async () => {
|
|
123
|
+
await closeServer(v4);
|
|
124
|
+
if (hasV6)
|
|
125
|
+
await closeServer(v6);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The command setup runs for a page with no dev server of its own, or undefined when this is not
|
|
131
|
+
* that page. Quoted so an app path with a space survives the shell.
|
|
132
|
+
*/
|
|
133
|
+
export function staticPageDevCommand(opts) {
|
|
134
|
+
if (!opts.isStaticPage || !existsSync(join(opts.appDir, STATIC_PAGE_INDEX)))
|
|
135
|
+
return undefined;
|
|
136
|
+
const script = fileURLToPath(import.meta.url).replace(/\.ts$/, '.js');
|
|
137
|
+
return `"${process.execPath}" "${script}" ${DIR_FLAG} "${opts.appDir}"`;
|
|
138
|
+
}
|
|
139
|
+
function flag(argv, name) {
|
|
140
|
+
const at = argv.indexOf(name);
|
|
141
|
+
return -1 === at ? undefined : argv[at + 1];
|
|
142
|
+
}
|
|
143
|
+
// Run as a script: serve until killed, printing the url the setup wait reads.
|
|
144
|
+
if (undefined !== process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
145
|
+
const dir = flag(process.argv, DIR_FLAG) ?? process.cwd();
|
|
146
|
+
const port = Number(flag(process.argv, PORT_FLAG) ?? STATIC_PAGE_FIRST_PORT);
|
|
147
|
+
startStaticPageServer({ root: dir, port }).then((server) => process.stdout.write(`serving ${dir}\n Local: ${server.url}\n`), (err) => {
|
|
148
|
+
process.stderr.write(`static page server: ${String(err)}\n`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -53,6 +53,10 @@ interface ShapePolicy {
|
|
|
53
53
|
readonly connectBudgetMs: number;
|
|
54
54
|
/** Said out loud, because a desktop run looks stuck if nobody explains the wait. */
|
|
55
55
|
readonly note: string | undefined;
|
|
56
|
+
/** What the connect wait is waiting on, for the progress lines it prints while it waits. */
|
|
57
|
+
readonly awaiting: string;
|
|
56
58
|
}
|
|
59
|
+
/** "a Tauri app", "an Electron app": printed when the shape is detected. */
|
|
60
|
+
export declare function describeShape(shape: AppShape): string;
|
|
57
61
|
export declare function policyFor(shape: AppShape): ShapePolicy;
|
|
58
62
|
export {};
|
|
@@ -42,7 +42,21 @@ export function isDesktopOrigin(url) {
|
|
|
42
42
|
}
|
|
43
43
|
const WEB_CONNECT_MS = 120_000;
|
|
44
44
|
/** A cold `tauri dev` compiles the Rust side before a window exists. */
|
|
45
|
-
const
|
|
45
|
+
const TAURI_CONNECT_MS = 10 * 60_000;
|
|
46
|
+
/**
|
|
47
|
+
* Electron has no compile step of that size: its window is a Node process loading a dev server that
|
|
48
|
+
* is already up. It was given Tauri's ten minutes, so a run whose window was never going to dial in
|
|
49
|
+
* sat silent for all of them — longer than the daemon it was waiting on stayed alive.
|
|
50
|
+
*/
|
|
51
|
+
const ELECTRON_CONNECT_MS = 3 * 60_000;
|
|
52
|
+
/** "a Tauri app", "an Electron app": printed when the shape is detected. */
|
|
53
|
+
export function describeShape(shape) {
|
|
54
|
+
if (AppShape.ELECTRON === shape)
|
|
55
|
+
return 'an Electron app';
|
|
56
|
+
if (AppShape.TAURI === shape)
|
|
57
|
+
return 'a Tauri app';
|
|
58
|
+
return 'a web app';
|
|
59
|
+
}
|
|
46
60
|
export function policyFor(shape) {
|
|
47
61
|
if (AppShape.WEB === shape) {
|
|
48
62
|
return {
|
|
@@ -50,12 +64,14 @@ export function policyFor(shape) {
|
|
|
50
64
|
requireHttpReady: true,
|
|
51
65
|
connectBudgetMs: WEB_CONNECT_MS,
|
|
52
66
|
note: undefined,
|
|
67
|
+
awaiting: 'the page',
|
|
53
68
|
};
|
|
54
69
|
}
|
|
55
70
|
return {
|
|
56
71
|
openBrowser: false,
|
|
57
72
|
requireHttpReady: false,
|
|
58
|
-
connectBudgetMs:
|
|
73
|
+
connectBudgetMs: AppShape.TAURI === shape ? TAURI_CONNECT_MS : ELECTRON_CONNECT_MS,
|
|
74
|
+
awaiting: AppShape.TAURI === shape ? 'the Tauri window' : 'the Electron window',
|
|
59
75
|
note: AppShape.TAURI === shape
|
|
60
76
|
? 'Tauri app: not opening a browser, because its own window is the client. A cold `tauri dev` builds the Rust side first, so the window can take minutes to appear.'
|
|
61
77
|
: 'Electron app: not opening a browser, because its own window is the client. Waiting for the app to launch and dial in.',
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RETICLE_PORT`, when it names a port. The one reader of it for the CLI, `init` included.
|
|
3
|
+
*
|
|
4
|
+
* `init` took only `--port`, so a shell that exported RETICLE_PORT got a project wired, and a daemon
|
|
5
|
+
* started, on the default port while every other command it ran dialled the exported one. Fed to
|
|
6
|
+
* `portForInit` as the explicit port, so the files phase writes it into `.reticle.json` and the
|
|
7
|
+
* runtime phase binds it: the two halves of `init` cannot disagree.
|
|
8
|
+
*/
|
|
9
|
+
export declare function portFromEnv(env: Readonly<Record<string, string | undefined>>): number | undefined;
|
|
10
|
+
export declare function portForInit(explicit: number | undefined, configured: number | undefined, projectId: string | undefined, deps: {
|
|
11
|
+
/** The projects the daemon on `port` belongs to or is serving right now. */
|
|
12
|
+
daemonProjects: (port: number) => Promise<readonly string[]>;
|
|
13
|
+
daemonPresent: (port: number) => Promise<boolean>;
|
|
14
|
+
pickPort: (preferred: number) => Promise<number>;
|
|
15
|
+
}): Promise<number | undefined>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon port `reticle init` wires a project to.
|
|
3
|
+
*
|
|
4
|
+
* Init used to leave this unset, so every project on a machine was wired to the default port. The
|
|
5
|
+
* second one to run was connected to the first one's daemon, and the bridge refused its app. When a
|
|
6
|
+
* daemon for another project already holds the default, the new project gets its own port, and init
|
|
7
|
+
* records it in `.reticle.json`, where the CLI and the build plugins both read it.
|
|
8
|
+
*
|
|
9
|
+
* Undefined means the default.
|
|
10
|
+
*/
|
|
11
|
+
import { RETICLE_DEFAULT_PORT, ReticleEnv } from '@reticlehq/core';
|
|
12
|
+
const MAX_PORT = 65_535;
|
|
13
|
+
/**
|
|
14
|
+
* `RETICLE_PORT`, when it names a port. The one reader of it for the CLI, `init` included.
|
|
15
|
+
*
|
|
16
|
+
* `init` took only `--port`, so a shell that exported RETICLE_PORT got a project wired, and a daemon
|
|
17
|
+
* started, on the default port while every other command it ran dialled the exported one. Fed to
|
|
18
|
+
* `portForInit` as the explicit port, so the files phase writes it into `.reticle.json` and the
|
|
19
|
+
* runtime phase binds it: the two halves of `init` cannot disagree.
|
|
20
|
+
*/
|
|
21
|
+
export function portFromEnv(env) {
|
|
22
|
+
const raw = env[ReticleEnv.PORT];
|
|
23
|
+
if (undefined === raw || !/^\d+$/.test(raw))
|
|
24
|
+
return undefined;
|
|
25
|
+
const port = Number(raw);
|
|
26
|
+
return 0 < port && port <= MAX_PORT ? port : undefined;
|
|
27
|
+
}
|
|
28
|
+
export async function portForInit(explicit, configured, projectId, deps) {
|
|
29
|
+
if (explicit !== undefined)
|
|
30
|
+
return explicit;
|
|
31
|
+
if (configured !== undefined)
|
|
32
|
+
return configured;
|
|
33
|
+
// Undefined, not the default's number: an explicit default would be written into every generated
|
|
34
|
+
// config as a `port` nobody chose.
|
|
35
|
+
if (!(await deps.daemonPresent(RETICLE_DEFAULT_PORT)))
|
|
36
|
+
return undefined;
|
|
37
|
+
// The bridge's own rule: it refuses a page only when it has served exactly ONE other project. A
|
|
38
|
+
// daemon serving nobody, several projects, or ours accepts this one. A project that has never
|
|
39
|
+
// been through init has no id yet, so a single named holder is somebody else.
|
|
40
|
+
const holders = await deps.daemonProjects(RETICLE_DEFAULT_PORT);
|
|
41
|
+
if (1 !== holders.length || holders[0] === projectId)
|
|
42
|
+
return undefined;
|
|
43
|
+
return deps.pickPort(RETICLE_DEFAULT_PORT + 1);
|
|
44
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* cli.ts is a dispatcher: a command that grew a second half should not make the file that routes
|
|
6
6
|
* every command harder to read.
|
|
7
7
|
*/
|
|
8
|
-
import type
|
|
8
|
+
import { type InitResult } from '@reticlehq/init';
|
|
9
9
|
/** Just enough of the parsed command to decide and run. */
|
|
10
10
|
interface InitRuntimeArgs {
|
|
11
11
|
readonly port: number | undefined;
|
|
@@ -7,18 +7,27 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { RETICLE_DEFAULT_PORT } from '@reticlehq/core';
|
|
10
|
+
import { FEEDBACK_HINT, Framework } from '@reticlehq/init';
|
|
10
11
|
import { confirmInstall, nodeConfirmDeps } from '../terminal/confirm.js';
|
|
11
12
|
import { writeLicenseKey } from '../license-key.js';
|
|
12
13
|
import { registerOtherAgents, runSetupCommand } from '../setup-command.js';
|
|
13
14
|
import { bridgeOccupied } from '../bringup/bridge-port.js';
|
|
15
|
+
import { defaultPairingTokenDir, readOrCreatePairingTokenSync, } from '../../../portal/bridge/pairing-token.js';
|
|
14
16
|
import { relaunchDecision } from '../bringup/relaunch.js';
|
|
15
17
|
import { claudeTranscriptExists, codexSessionFor } from '../terminal/transcripts.js';
|
|
16
18
|
import { probePresence } from '../../daemon/binding/port-presence.js';
|
|
17
19
|
import { probeDaemon } from '../../../surface/mcp/proxy/proxy-daemon-probe.js';
|
|
18
20
|
import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
|
|
19
21
|
import { collectEnv, DEFAULT_PHASE_TIMEOUT_MS } from '../setup-options.js';
|
|
22
|
+
import { staticPageDevCommand } from '../bringup/static-page-server.js';
|
|
20
23
|
/** How often the runtime phases look again: fast enough not to be the wait, slow enough to be free. */
|
|
21
24
|
const POLL_MS = 250;
|
|
25
|
+
/**
|
|
26
|
+
* The shapes whose served HTML carries the SDK marker: Vite injects it into index.html, and a plain
|
|
27
|
+
* HTML page has the snippet pasted in. Everything else connects from the JS bundle, where a fetch
|
|
28
|
+
* of the document can never see it. See `htmlCarriesSdk` in run-setup.ts.
|
|
29
|
+
*/
|
|
30
|
+
const HTML_CARRIES_SDK = new Set([Framework.VITE, Framework.HTML]);
|
|
22
31
|
/** Registration is global MCP wiring, so both flags that disown it are read here. */
|
|
23
32
|
function wantsAgents(parsed) {
|
|
24
33
|
return false !== parsed.agents && false !== parsed.mcp;
|
|
@@ -77,9 +86,16 @@ export async function continueAfterInit(parsed, result, io, cwd) {
|
|
|
77
86
|
process.exit(1);
|
|
78
87
|
});
|
|
79
88
|
}
|
|
89
|
+
// From here on init left the ask to us, so it is the last thing on every exit below.
|
|
90
|
+
const ask = (spaced = true) => {
|
|
91
|
+
if (spaced)
|
|
92
|
+
io.print('');
|
|
93
|
+
io.print(FEEDBACK_HINT);
|
|
94
|
+
};
|
|
80
95
|
const context = result.context;
|
|
81
96
|
if (context === undefined) {
|
|
82
97
|
// Nothing was established, so there is nothing to run against. init has already said why.
|
|
98
|
+
ask();
|
|
83
99
|
process.exit(1);
|
|
84
100
|
}
|
|
85
101
|
// A PENDING connect step is not a reason to skip the runtime phase, and treating it as one made
|
|
@@ -100,12 +116,16 @@ export async function continueAfterInit(parsed, result, io, cwd) {
|
|
|
100
116
|
const refusal = bridgeOccupied(await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }), port);
|
|
101
117
|
if (refusal !== undefined) {
|
|
102
118
|
io.print(refusal);
|
|
119
|
+
ask();
|
|
103
120
|
process.exit(1);
|
|
104
121
|
}
|
|
105
122
|
return runSetupCommand({
|
|
106
123
|
appDir: context.appDir,
|
|
107
124
|
invokedAt: cwd,
|
|
108
125
|
bridgePort: port,
|
|
126
|
+
// Read here because this is the layer that already owns the bridge: setup opens a lease over
|
|
127
|
+
// the daemon's MCP transport for `--no-open`, and that transport is gated on this token.
|
|
128
|
+
pairingToken: readOrCreatePairingTokenSync(defaultPairingTokenDir()),
|
|
109
129
|
env: collectEnv(parsed.env ?? []),
|
|
110
130
|
openBrowser: false !== parsed.open,
|
|
111
131
|
registerAgents: wantsAgents(parsed),
|
|
@@ -118,7 +138,19 @@ export async function continueAfterInit(parsed, result, io, cwd) {
|
|
|
118
138
|
? {}
|
|
119
139
|
: { connectBudgetMs: parsed.timeoutSeconds * 1000 }),
|
|
120
140
|
pollMs: POLL_MS,
|
|
121
|
-
|
|
141
|
+
htmlCarriesSdk: HTML_CARRIES_SDK.has(context.framework),
|
|
142
|
+
// A plain page with no dev script gets Reticle's own static server, so the page is actually
|
|
143
|
+
// served and the connect can be proved, instead of stopping at "start the app yourself".
|
|
144
|
+
...(() => {
|
|
145
|
+
const devCommand = context.devCommand ??
|
|
146
|
+
(undefined === parsed.url
|
|
147
|
+
? staticPageDevCommand({
|
|
148
|
+
appDir: context.appDir,
|
|
149
|
+
isStaticPage: Framework.HTML === context.framework,
|
|
150
|
+
})
|
|
151
|
+
: undefined);
|
|
152
|
+
return undefined === devCommand ? {} : { devCommand };
|
|
153
|
+
})(),
|
|
122
154
|
...(undefined === parsed.url ? {} : { suppliedUrl: parsed.url }),
|
|
123
155
|
}, (line) => io.print(line)).then((outcome) => {
|
|
124
156
|
// One object, so an agent reads a result instead of interpreting a report.
|
|
@@ -145,9 +177,12 @@ export async function continueAfterInit(parsed, result, io, cwd) {
|
|
|
145
177
|
// Success, and no flow. Saying "a flow was driven" here would replace a wrong exit code with
|
|
146
178
|
// a wrong sentence, which is the worse of the two: the exit code is read by CI and the
|
|
147
179
|
// sentence is read by a person deciding whether their app is verified. It is not.
|
|
148
|
-
|
|
180
|
+
// The connect already said "connected, nothing verified yet" and listed what to do next;
|
|
181
|
+
// repeating it here was the same sentence twice, three lines apart.
|
|
149
182
|
for (const [i, step] of outcome.fallback.entries())
|
|
150
183
|
io.print(` ${String(i + 1)}. ${step}`);
|
|
184
|
+
// Already spaced by the blank line above when nothing was listed.
|
|
185
|
+
ask(0 < outcome.fallback.length);
|
|
151
186
|
return;
|
|
152
187
|
}
|
|
153
188
|
if (outcome.ok) {
|
|
@@ -156,6 +191,7 @@ export async function continueAfterInit(parsed, result, io, cwd) {
|
|
|
156
191
|
// themselves, and it deserves a line of its own rather than a paragraph that gets skimmed.
|
|
157
192
|
io.print(' Read the FINDINGS above before moving on: a flow can pass with a failed request or a ' +
|
|
158
193
|
'console error behind it, and that is the app, not the check.');
|
|
194
|
+
ask();
|
|
159
195
|
return;
|
|
160
196
|
}
|
|
161
197
|
// A run that produced no verdict did not succeed, and the exit code is the one place a caller
|
|
@@ -163,6 +199,7 @@ export async function continueAfterInit(parsed, result, io, cwd) {
|
|
|
163
199
|
io.print('⚠ setup did not finish. To carry on from here:');
|
|
164
200
|
for (const [i, step] of outcome.fallback.entries())
|
|
165
201
|
io.print(` ${i + 1}. ${step}`);
|
|
202
|
+
ask();
|
|
166
203
|
process.exit(1);
|
|
167
204
|
});
|
|
168
205
|
}
|
|
@@ -15,31 +15,42 @@ import type { CandidateSession } from './session-pick.js';
|
|
|
15
15
|
import type { PageProbe } from './probe/page-probe.js';
|
|
16
16
|
/** The dev server this process started, and the only one it will ever stop. */
|
|
17
17
|
export declare class OwnedDevServer {
|
|
18
|
+
private readonly logDir;
|
|
18
19
|
private child;
|
|
19
|
-
private
|
|
20
|
-
private
|
|
20
|
+
private log;
|
|
21
|
+
private cwd;
|
|
22
|
+
private startedAt;
|
|
21
23
|
private handedOver;
|
|
24
|
+
constructor(logDir?: string);
|
|
25
|
+
/**
|
|
26
|
+
* Start it with its output going to a bounded log, through a supervisor that outlives `init`.
|
|
27
|
+
*
|
|
28
|
+
* Not a pipe into THIS process. A pipe's read end belonged to `init`; once it exited nobody held
|
|
29
|
+
* it, and the server's next write (a compile error, an HMR line, SvelteKit's first warning) was an
|
|
30
|
+
* EPIPE that killed it: "it connected, then the app went away". And not a bare file either, which
|
|
31
|
+
* has no ceiling: an upstream echo loop grew one to 2.9 GB in five minutes. The supervisor reads
|
|
32
|
+
* the server's pipes for as long as they are open and rotates the file past a cap — see
|
|
33
|
+
* dev-server-log-cap.ts. It is the process group leader, so `pid()` and `stop()` cover both.
|
|
34
|
+
*/
|
|
22
35
|
start(command: string, cwd: string, env: Readonly<Record<string, string>>): void;
|
|
36
|
+
/** Where the server's output goes, for the line that hands it over. */
|
|
37
|
+
logPath(): string | undefined;
|
|
23
38
|
/** The process group leader, for a caller that has to clean up what it handed over. */
|
|
24
39
|
pid(): number | undefined;
|
|
25
40
|
output(): string;
|
|
26
41
|
exited(): boolean;
|
|
42
|
+
/** Since the log last grew, which is since the server last printed anything. */
|
|
27
43
|
quietForMs(): number;
|
|
28
44
|
/** Ports anything in this server's process tree is listening on. */
|
|
29
45
|
listeningPorts(): number[];
|
|
30
|
-
/** Hand the running server to the user. After this, `stop()` does nothing. */
|
|
31
46
|
/**
|
|
32
|
-
* Leave the server running and stop holding it.
|
|
33
|
-
*
|
|
34
|
-
* `unref` on the child is NOT enough: its stdout and stderr are pipes owned by this process, and
|
|
35
|
-
* an open pipe keeps the event loop alive on its own. So `init` printed "setup complete" and then
|
|
36
|
-
* hung until something killed it, turning a successful run into a non-zero exit — which is
|
|
37
|
-
* exactly what the install gate reported, on a run whose every other assertion passed.
|
|
47
|
+
* Leave the server running and stop holding it. After this, `stop()` does nothing.
|
|
38
48
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
49
|
+
* Only `unref` is needed: the output is the supervisor's, so no pipe of ours holds the event loop
|
|
50
|
+
* (`init` once printed "setup complete" and hung on one). `url` is recorded beside the log, so the
|
|
51
|
+
* next `init` attaches to this server rather than starting a second one beside it.
|
|
41
52
|
*/
|
|
42
|
-
handOver(): void;
|
|
53
|
+
handOver(url?: string): void;
|
|
43
54
|
stop(): void;
|
|
44
55
|
}
|
|
45
56
|
/**
|
|
@@ -50,9 +61,11 @@ export declare class OwnedDevServer {
|
|
|
50
61
|
* server that is already running.
|
|
51
62
|
*
|
|
52
63
|
* On loopback, the announced host is tried first and the other loopback families follow when it
|
|
53
|
-
*
|
|
64
|
+
* is REFUSED — so an IPv6-only Vite that printed `127.0.0.1` still counts as served (#884). Only
|
|
65
|
+
* refused: a timeout means the right server is slow, and the caller polls again on the same host.
|
|
66
|
+
* See `isAddressMiss` for the Next 16 first run that hopping on a timeout broke.
|
|
54
67
|
*/
|
|
55
|
-
export declare function probePage(url: string): Promise<PageProbe>;
|
|
68
|
+
export declare function probePage(url: string, timeoutMs?: number): Promise<PageProbe>;
|
|
56
69
|
/** Sessions the daemon is holding, in the shape the picker reads. */
|
|
57
70
|
export declare function listSessions(port: number): Promise<CandidateSession[]>;
|
|
58
71
|
/** Whether this project has a saved flow, wherever the project keeps one. */
|
|
@@ -12,60 +12,96 @@
|
|
|
12
12
|
* listening indefinitely.
|
|
13
13
|
*/
|
|
14
14
|
import { spawn, spawnSync } from 'node:child_process';
|
|
15
|
-
import {
|
|
16
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
17
16
|
import { join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { isAlive } from '../daemon/daemon.js';
|
|
19
|
+
import { DEV_SERVER_LOG_CAP_BYTES, ROTATED_SUFFIX } from './bringup/dev-server-log-cap.js';
|
|
20
|
+
import { devServerLogName, recordHandOver, sweepDeadDevServers, } from './bringup/dev-server-files.js';
|
|
18
21
|
import { fetchStatus } from '../daemon/binding/daemon-status-probe.js';
|
|
22
|
+
import { reticleStateHome } from '../daemon/daemon.js';
|
|
19
23
|
import { descendants, parseLsofPorts, parseNetstatListeners, parseWmicProcesses, } from './terminal/listeners.js';
|
|
20
|
-
import { loopbackProbeUrls } from './probe/loopback-probe-urls.js';
|
|
24
|
+
import { isAddressMiss, loopbackProbeUrls } from './probe/loopback-probe-urls.js';
|
|
21
25
|
const WINDOWS = 'win32' === process.platform;
|
|
22
26
|
/** A page fetch that is slow is a page fetch that failed, for our purposes. */
|
|
23
27
|
const PROBE_TIMEOUT_MS = 5_000;
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
* A child's piped stdout is a Socket at runtime and a Readable in the types, which has no `unref`.
|
|
28
|
-
* Narrowing through Socket keeps that honest rather than asserting it.
|
|
29
|
-
*/
|
|
30
|
-
function releasePipe(stream) {
|
|
31
|
-
if (stream instanceof Socket)
|
|
32
|
-
stream.unref();
|
|
33
|
-
}
|
|
28
|
+
/** The supervisor that owns the server's output and keeps its log bounded. */
|
|
29
|
+
const LOG_CAP_SCRIPT = fileURLToPath(new URL('./bringup/dev-server-log-cap.js', import.meta.url));
|
|
34
30
|
/** The dev server this process started, and the only one it will ever stop. */
|
|
35
31
|
export class OwnedDevServer {
|
|
32
|
+
logDir;
|
|
36
33
|
child;
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
log;
|
|
35
|
+
cwd;
|
|
36
|
+
startedAt = Date.now();
|
|
39
37
|
handedOver = false;
|
|
38
|
+
constructor(logDir = reticleStateHome()) {
|
|
39
|
+
this.logDir = logDir;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Start it with its output going to a bounded log, through a supervisor that outlives `init`.
|
|
43
|
+
*
|
|
44
|
+
* Not a pipe into THIS process. A pipe's read end belonged to `init`; once it exited nobody held
|
|
45
|
+
* it, and the server's next write (a compile error, an HMR line, SvelteKit's first warning) was an
|
|
46
|
+
* EPIPE that killed it: "it connected, then the app went away". And not a bare file either, which
|
|
47
|
+
* has no ceiling: an upstream echo loop grew one to 2.9 GB in five minutes. The supervisor reads
|
|
48
|
+
* the server's pipes for as long as they are open and rotates the file past a cap — see
|
|
49
|
+
* dev-server-log-cap.ts. It is the process group leader, so `pid()` and `stop()` cover both.
|
|
50
|
+
*/
|
|
40
51
|
start(command, cwd, env) {
|
|
41
|
-
|
|
52
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
53
|
+
// Logs of servers that are gone, from every project, so the state home stops growing a file
|
|
54
|
+
// per app ever started. This one's are replaced below anyway.
|
|
55
|
+
sweepDeadDevServers(this.logDir, { alive: isAlive, now: Date.now(), keep: cwd });
|
|
56
|
+
const log = join(this.logDir, devServerLogName(cwd));
|
|
57
|
+
// Unlinked rather than truncated: a previous server for this project may still hold the old
|
|
58
|
+
// file open, and truncating under it would interleave its lines into this run's output.
|
|
59
|
+
rmSync(log, { force: true });
|
|
60
|
+
rmSync(`${log}${ROTATED_SUFFIX}`, { force: true });
|
|
61
|
+
this.child = spawn(process.execPath, [LOG_CAP_SCRIPT, log, String(DEV_SERVER_LOG_CAP_BYTES), command], {
|
|
42
62
|
cwd,
|
|
43
|
-
shell: true,
|
|
44
63
|
// Its own process group, so stopping it stops what it started rather than only the wrapper.
|
|
45
64
|
detached: !WINDOWS,
|
|
46
|
-
|
|
65
|
+
// Nothing of ours: the supervisor holds the server's pipes, and init holds none of its.
|
|
66
|
+
stdio: 'ignore',
|
|
47
67
|
env: { ...process.env, ...env },
|
|
48
68
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
69
|
+
this.log = log;
|
|
70
|
+
this.cwd = cwd;
|
|
71
|
+
this.startedAt = Date.now();
|
|
72
|
+
}
|
|
73
|
+
/** Where the server's output goes, for the line that hands it over. */
|
|
74
|
+
logPath() {
|
|
75
|
+
return this.log;
|
|
56
76
|
}
|
|
57
77
|
/** The process group leader, for a caller that has to clean up what it handed over. */
|
|
58
78
|
pid() {
|
|
59
79
|
return this.child?.pid;
|
|
60
80
|
}
|
|
61
81
|
output() {
|
|
62
|
-
|
|
82
|
+
if (undefined === this.log)
|
|
83
|
+
return '';
|
|
84
|
+
try {
|
|
85
|
+
return readFileSync(this.log, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
63
90
|
}
|
|
64
91
|
exited() {
|
|
65
92
|
return undefined !== this.child && null !== this.child.exitCode;
|
|
66
93
|
}
|
|
94
|
+
/** Since the log last grew, which is since the server last printed anything. */
|
|
67
95
|
quietForMs() {
|
|
68
|
-
|
|
96
|
+
let lastOutputAt = this.startedAt;
|
|
97
|
+
try {
|
|
98
|
+
if (undefined !== this.log)
|
|
99
|
+
lastOutputAt = Math.max(lastOutputAt, statSync(this.log).mtimeMs);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* not written yet */
|
|
103
|
+
}
|
|
104
|
+
return Date.now() - lastOutputAt;
|
|
69
105
|
}
|
|
70
106
|
/** Ports anything in this server's process tree is listening on. */
|
|
71
107
|
listeningPorts() {
|
|
@@ -90,24 +126,20 @@ export class OwnedDevServer {
|
|
|
90
126
|
`lsof -a -p ${pids.join(',')} -iTCP -sTCP:LISTEN -P -n 2>/dev/null | awk 'NR>1{print $9}'`,
|
|
91
127
|
]));
|
|
92
128
|
}
|
|
93
|
-
/** Hand the running server to the user. After this, `stop()` does nothing. */
|
|
94
129
|
/**
|
|
95
|
-
* Leave the server running and stop holding it.
|
|
96
|
-
*
|
|
97
|
-
* `unref` on the child is NOT enough: its stdout and stderr are pipes owned by this process, and
|
|
98
|
-
* an open pipe keeps the event loop alive on its own. So `init` printed "setup complete" and then
|
|
99
|
-
* hung until something killed it, turning a successful run into a non-zero exit — which is
|
|
100
|
-
* exactly what the install gate reported, on a run whose every other assertion passed.
|
|
130
|
+
* Leave the server running and stop holding it. After this, `stop()` does nothing.
|
|
101
131
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
132
|
+
* Only `unref` is needed: the output is the supervisor's, so no pipe of ours holds the event loop
|
|
133
|
+
* (`init` once printed "setup complete" and hung on one). `url` is recorded beside the log, so the
|
|
134
|
+
* next `init` attaches to this server rather than starting a second one beside it.
|
|
104
135
|
*/
|
|
105
|
-
handOver() {
|
|
136
|
+
handOver(url) {
|
|
106
137
|
this.handedOver = true;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
138
|
+
this.child?.unref();
|
|
139
|
+
const pid = this.child?.pid;
|
|
140
|
+
if (undefined !== url && undefined !== pid && undefined !== this.cwd) {
|
|
141
|
+
recordHandOver(this.logDir, { pid, url, appDir: this.cwd });
|
|
142
|
+
}
|
|
111
143
|
}
|
|
112
144
|
stop() {
|
|
113
145
|
const child = this.child;
|
|
@@ -164,14 +196,16 @@ function windowsProcessPairs() {
|
|
|
164
196
|
* server that is already running.
|
|
165
197
|
*
|
|
166
198
|
* On loopback, the announced host is tried first and the other loopback families follow when it
|
|
167
|
-
*
|
|
199
|
+
* is REFUSED — so an IPv6-only Vite that printed `127.0.0.1` still counts as served (#884). Only
|
|
200
|
+
* refused: a timeout means the right server is slow, and the caller polls again on the same host.
|
|
201
|
+
* See `isAddressMiss` for the Next 16 first run that hopping on a timeout broke.
|
|
168
202
|
*/
|
|
169
|
-
export async function probePage(url) {
|
|
203
|
+
export async function probePage(url, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
170
204
|
const candidates = loopbackProbeUrls(url);
|
|
171
205
|
let sawTlsRefused = false;
|
|
172
206
|
for (const candidate of candidates) {
|
|
173
207
|
try {
|
|
174
|
-
const res = await fetch(candidate, { signal: AbortSignal.timeout(
|
|
208
|
+
const res = await fetch(candidate, { signal: AbortSignal.timeout(timeoutMs) });
|
|
175
209
|
const body = await res.text();
|
|
176
210
|
const sdkInPage = /@reticlehq|@reticle-connect|reticle-dev/.test(body);
|
|
177
211
|
// Only stamp reachedUrl when we had to leave the announcement — callers that already have
|
|
@@ -187,6 +221,8 @@ export async function probePage(url) {
|
|
|
187
221
|
if (/certificate|SELF_SIGNED|DEPTH_ZERO|ERR_TLS|unable to verify/i.test(message)) {
|
|
188
222
|
sawTlsRefused = true;
|
|
189
223
|
}
|
|
224
|
+
if (!isAddressMiss(err))
|
|
225
|
+
break;
|
|
190
226
|
}
|
|
191
227
|
}
|
|
192
228
|
return { served: false, sdkInPage: false, ...(sawTlsRefused ? { tlsRefused: true } : {}) };
|
|
@@ -16,3 +16,10 @@
|
|
|
16
16
|
* family-split problem, and inventing alternates would probe the wrong machine.
|
|
17
17
|
*/
|
|
18
18
|
export declare function loopbackProbeUrls(url: string): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Whether a failed fetch was refused at the connection level on this address.
|
|
21
|
+
*
|
|
22
|
+
* `fetch` wraps the socket error as `cause`; with happy-eyeballs that cause is an AggregateError of
|
|
23
|
+
* one error per address tried, and it is a miss only when every one of them was.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isAddressMiss(err: unknown): boolean;
|