@vincentt-xr/harness 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +90 -9
- package/dist/preview/index.d.ts +2 -14
- package/dist/preview/index.js +5 -29
- package/dist/preview/net.d.ts +6 -0
- package/dist/preview/net.js +56 -0
- package/dist/preview/proxy.d.ts +4 -0
- package/dist/preview/proxy.js +49 -0
- package/dist/preview/runner.d.ts +43 -0
- package/dist/preview/runner.js +110 -0
- package/dist/preview/tunnel.d.ts +14 -0
- package/dist/preview/tunnel.js +28 -0
- package/dist/scaffold/index.d.ts +7 -0
- package/dist/scaffold/index.js +13 -0
- package/package.json +1 -1
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { type RelayClient } from "./diagnostics.js";
|
|
3
|
+
/** Reap the running preview, if any. Called by preview_stop and on server exit. */
|
|
4
|
+
export declare function shutdownActivePreview(): Promise<void>;
|
|
3
5
|
export interface McpOptions {
|
|
4
6
|
/** Base URL of the relay's HTTP endpoint (default matches the relay CLI). */
|
|
5
7
|
relayUrl?: string;
|
package/dist/mcp/server.js
CHANGED
|
@@ -9,8 +9,20 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
|
|
11
11
|
import { createProject, gitHeadSha, publishUpload } from "./backend.js";
|
|
12
|
-
import { loadProjectBinding, resolveConfig, writeProjectBinding } from "../shared/config.js";
|
|
13
|
-
import { needsScaffold, scaffoldFromTemplate } from "../scaffold/index.js";
|
|
12
|
+
import { loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
|
|
13
|
+
import { installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
|
|
14
|
+
import { startPreview } from "../preview/index.js";
|
|
15
|
+
// One preview per server process. Held at module scope so runStdio can reap it on
|
|
16
|
+
// shutdown without threading the handle through createHarnessMcp's return type.
|
|
17
|
+
let activePreview = null;
|
|
18
|
+
/** Reap the running preview, if any. Called by preview_stop and on server exit. */
|
|
19
|
+
export async function shutdownActivePreview() {
|
|
20
|
+
if (!activePreview)
|
|
21
|
+
return;
|
|
22
|
+
const preview = activePreview;
|
|
23
|
+
activePreview = null;
|
|
24
|
+
await preview.stop();
|
|
25
|
+
}
|
|
14
26
|
/** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
|
|
15
27
|
async function guard(run) {
|
|
16
28
|
try {
|
|
@@ -36,7 +48,10 @@ const sharedInput = {
|
|
|
36
48
|
.describe("Cap the number of events returned (newest kept)."),
|
|
37
49
|
};
|
|
38
50
|
export function createHarnessMcp(opts = {}) {
|
|
39
|
-
const
|
|
51
|
+
const defaultRelay = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
|
|
52
|
+
// Diag tools read through this. preview_start re-points it at the live preview's
|
|
53
|
+
// relay port (OS-assigned), and preview_stop resets it to the default.
|
|
54
|
+
let relayClient = defaultRelay;
|
|
40
55
|
const now = opts.now ?? Date.now;
|
|
41
56
|
const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
|
|
42
57
|
server.registerTool("diag_logs", {
|
|
@@ -47,7 +62,7 @@ export function createHarnessMcp(opts = {}) {
|
|
|
47
62
|
},
|
|
48
63
|
}, async ({ sessionId, since, limit, errorsOnly }) => {
|
|
49
64
|
const q = { kind: "log", sessionId, since, limit };
|
|
50
|
-
let result = await
|
|
65
|
+
let result = await relayClient.query(q);
|
|
51
66
|
if (errorsOnly)
|
|
52
67
|
result = filterErrors(result);
|
|
53
68
|
return { content: [{ type: "text", text: renderResult(result, "log", now()) }] };
|
|
@@ -56,7 +71,12 @@ export function createHarnessMcp(opts = {}) {
|
|
|
56
71
|
description: "Read fetch/XHR requests the preview app made on the device (method, URL, status, duration). Use to find failed asset loads or slow calls without DevTools.",
|
|
57
72
|
inputSchema: sharedInput,
|
|
58
73
|
}, async ({ sessionId, since, limit }) => {
|
|
59
|
-
const result = await
|
|
74
|
+
const result = await relayClient.query({
|
|
75
|
+
kind: "network",
|
|
76
|
+
sessionId,
|
|
77
|
+
since,
|
|
78
|
+
limit,
|
|
79
|
+
});
|
|
60
80
|
return {
|
|
61
81
|
content: [{ type: "text", text: renderResult(result, "network", now()) }],
|
|
62
82
|
};
|
|
@@ -65,7 +85,7 @@ export function createHarnessMcp(opts = {}) {
|
|
|
65
85
|
description: "Read performance samples from the device (fps, longest main-thread task, long-task count, phase marks). Replaces hand-exporting a DevTools trace to spot jank.",
|
|
66
86
|
inputSchema: sharedInput,
|
|
67
87
|
}, async ({ sessionId, since, limit }) => {
|
|
68
|
-
const result = await
|
|
88
|
+
const result = await relayClient.query({ kind: "trace", sessionId, since, limit });
|
|
69
89
|
return { content: [{ type: "text", text: renderResult(result, "trace", now()) }] };
|
|
70
90
|
});
|
|
71
91
|
// The cwd the agent spawned this server in IS the creator's project directory —
|
|
@@ -104,7 +124,21 @@ export function createHarnessMcp(opts = {}) {
|
|
|
104
124
|
projectId: created.projectId,
|
|
105
125
|
slug: created.slug,
|
|
106
126
|
});
|
|
107
|
-
|
|
127
|
+
// Install deps for a freshly scaffolded app so the first preview/build works
|
|
128
|
+
// out of the box. Best-effort — a failure just becomes a manual-install hint.
|
|
129
|
+
let scaffoldNote = "";
|
|
130
|
+
if (scaffolded) {
|
|
131
|
+
scaffoldNote = "Scaffolded the v2-template starter into this directory.\n";
|
|
132
|
+
try {
|
|
133
|
+
await installDependencies(projectCwd);
|
|
134
|
+
scaffoldNote += "Installed dependencies (npm install).\n";
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
+
scaffoldNote += `Note: \`npm install\` failed (${msg}) — run it manually before preview.\n`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return (scaffoldNote +
|
|
108
142
|
`Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
|
|
109
143
|
`Binding written to ${bindingPath} (gitignored).\n` +
|
|
110
144
|
`Build locally, then run project_publish to go live at ${created.slug}.vincentt.app.`);
|
|
@@ -116,7 +150,10 @@ export function createHarnessMcp(opts = {}) {
|
|
|
116
150
|
.string()
|
|
117
151
|
.optional()
|
|
118
152
|
.describe("Path to the built dist directory, relative to the project (default: dist)."),
|
|
119
|
-
note: z
|
|
153
|
+
note: z
|
|
154
|
+
.string()
|
|
155
|
+
.optional()
|
|
156
|
+
.describe("Optional release note recorded with the version."),
|
|
120
157
|
},
|
|
121
158
|
}, async ({ distDir, note }) => guard(async () => {
|
|
122
159
|
const binding = await loadProjectBinding(projectCwd);
|
|
@@ -126,15 +163,59 @@ export function createHarnessMcp(opts = {}) {
|
|
|
126
163
|
const cfg = await resolveConfig(projectCwd);
|
|
127
164
|
const dist = path.resolve(projectCwd, distDir ?? "dist");
|
|
128
165
|
const commitSha = await gitHeadSha(projectCwd);
|
|
129
|
-
const result = await publishUpload(cfg, binding.projectId, dist, {
|
|
166
|
+
const result = await publishUpload(cfg, binding.projectId, dist, {
|
|
167
|
+
note,
|
|
168
|
+
commitSha,
|
|
169
|
+
});
|
|
130
170
|
return `Published v${result.version}. Live at ${result.liveUrl}\nThis version: ${result.url}`;
|
|
131
171
|
}));
|
|
172
|
+
server.registerTool("preview_start", {
|
|
173
|
+
description: "Start a live on-device preview: serves the app, opens a public https tunnel, and wires the diagnostics relay so diag_logs/diag_network/diag_trace read from the connected device. Returns the URL to open on a phone. Requires a project_create binding; cloudflared is auto-provisioned if missing. One preview at a time — call preview_stop before starting another. The dev server keeps running until preview_stop, so start it once and iterate.",
|
|
174
|
+
inputSchema: {},
|
|
175
|
+
}, async () => guard(async () => {
|
|
176
|
+
if (activePreview) {
|
|
177
|
+
return `A preview is already running:\n${activePreview.url}\nCall preview_stop first to restart.`;
|
|
178
|
+
}
|
|
179
|
+
// App stdout would corrupt the MCP channel; keep it off stdout entirely and
|
|
180
|
+
// route harness progress to stderr.
|
|
181
|
+
activePreview = await startPreview({
|
|
182
|
+
projectCwd,
|
|
183
|
+
onLog: (m) => console.error(`[preview] ${m}`),
|
|
184
|
+
});
|
|
185
|
+
// Point the diag tools at this preview's relay (its port is OS-assigned).
|
|
186
|
+
relayClient = httpRelayClient(`http://localhost:${activePreview.relayPort}`);
|
|
187
|
+
const readiness = activePreview.appReady
|
|
188
|
+
? ""
|
|
189
|
+
: `\nNote: the app on :${activePreview.appPort} isn't responding yet — the URL may be blank until its build finishes. Check diag_logs.`;
|
|
190
|
+
return (`Live preview running — open on your device:\n${activePreview.url}\n\n` +
|
|
191
|
+
`Diagnostics are live: diag_logs / diag_network / diag_trace now read from this device.\n` +
|
|
192
|
+
`Call preview_stop when finished.${readiness}`);
|
|
193
|
+
}));
|
|
194
|
+
server.registerTool("preview_stop", {
|
|
195
|
+
description: "Stop the running preview: tears down the tunnel (and its DNS route), the app dev server, and the diagnostics relay. Safe to call when nothing is running.",
|
|
196
|
+
inputSchema: {},
|
|
197
|
+
}, async () => guard(async () => {
|
|
198
|
+
if (!activePreview)
|
|
199
|
+
return "No preview is running.";
|
|
200
|
+
await shutdownActivePreview();
|
|
201
|
+
// The preview's relay is gone; send diag reads back to the default.
|
|
202
|
+
relayClient = defaultRelay;
|
|
203
|
+
return "Preview stopped. Tunnel and dev server torn down.";
|
|
204
|
+
}));
|
|
132
205
|
return server;
|
|
133
206
|
}
|
|
134
207
|
export async function runStdio(opts = {}) {
|
|
135
208
|
const server = createHarnessMcp(opts);
|
|
136
209
|
const transport = new StdioServerTransport();
|
|
137
210
|
await server.connect(transport);
|
|
211
|
+
// A running preview owns a child process + a live backend tunnel; reap both when
|
|
212
|
+
// the agent disconnects so we never leak a tunnel/DNS route past the session.
|
|
213
|
+
const shutdown = async () => {
|
|
214
|
+
await shutdownActivePreview();
|
|
215
|
+
process.exit(0);
|
|
216
|
+
};
|
|
217
|
+
process.on("SIGINT", shutdown);
|
|
218
|
+
process.on("SIGTERM", shutdown);
|
|
138
219
|
// stdout is the MCP channel — status goes to stderr only.
|
|
139
220
|
console.error("[harness-mcp] connected over stdio");
|
|
140
221
|
}
|
package/dist/preview/index.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+
export { startSessionTunnel, type SessionTunnel } from "./tunnel.js";
|
|
2
2
|
export { ensureCloudflared, type EnsureCloudflaredOptions } from "./cloudflared.js";
|
|
3
|
-
export
|
|
4
|
-
/** Public https URL to open on the device. */
|
|
5
|
-
url: string;
|
|
6
|
-
/** Tear the tunnel down (DNS route + tunnel). Idempotent; call on SIGINT. */
|
|
7
|
-
reap: () => Promise<void>;
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
11
|
-
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
12
|
-
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
13
|
-
* unbound (run project_create) or no backend/PAT is configured.
|
|
14
|
-
*/
|
|
15
|
-
export declare function startSessionTunnel(projectCwd: string, localPort: number): Promise<SessionTunnel>;
|
|
3
|
+
export { startPreview, type StartPreviewOptions, type RunningPreview } from "./runner.js";
|
package/dist/preview/index.js
CHANGED
|
@@ -1,30 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// this module stays free of any child-process / cloudflared coupling.
|
|
6
|
-
import { loadProjectBinding, resolveConfig } from "../shared/config.js";
|
|
7
|
-
import { mintTunnel, reapTunnel } from "../mcp/backend.js";
|
|
1
|
+
// Public surface of the preview limb (`@vincentt-xr/harness/preview`): the tunnel
|
|
2
|
+
// mint, the cloudflared resolver, and the full one-call preview runner. Barrel
|
|
3
|
+
// only — implementations live in the sibling modules.
|
|
4
|
+
export { startSessionTunnel } from "./tunnel.js";
|
|
8
5
|
export { ensureCloudflared } from "./cloudflared.js";
|
|
9
|
-
|
|
10
|
-
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
11
|
-
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
12
|
-
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
13
|
-
* unbound (run project_create) or no backend/PAT is configured.
|
|
14
|
-
*/
|
|
15
|
-
export async function startSessionTunnel(projectCwd, localPort) {
|
|
16
|
-
const binding = await loadProjectBinding(projectCwd);
|
|
17
|
-
if (!binding) {
|
|
18
|
-
throw new Error("No project bound to this directory — run project_create first.");
|
|
19
|
-
}
|
|
20
|
-
const cfg = await resolveConfig(projectCwd);
|
|
21
|
-
const minted = await mintTunnel(cfg, binding.projectId, localPort);
|
|
22
|
-
return {
|
|
23
|
-
...minted,
|
|
24
|
-
url: `https://${minted.hostname}`,
|
|
25
|
-
reap: () => reapTunnel(cfg, binding.projectId, {
|
|
26
|
-
tunnelId: minted.tunnelId,
|
|
27
|
-
dnsRecordId: minted.dnsRecordId,
|
|
28
|
-
}),
|
|
29
|
-
};
|
|
30
|
-
}
|
|
6
|
+
export { startPreview } from "./runner.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Ask the OS for an unused ephemeral port. */
|
|
2
|
+
export declare function getFreePort(): Promise<number>;
|
|
3
|
+
/** True if something already accepts TCP connections on the port (e.g. a dev server). */
|
|
4
|
+
export declare function isPortListening(port: number, host?: string): Promise<boolean>;
|
|
5
|
+
/** Poll the app for any non-5xx HTTP response until it's ready or the timeout hits. */
|
|
6
|
+
export declare function waitForApp(port: number, timeoutMs: number): Promise<boolean>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// TCP/HTTP port helpers for the preview runner: pick a free port for the internal
|
|
2
|
+
// servers, detect a dev server already listening (so we reuse it instead of
|
|
3
|
+
// spawning a second one), and wait for the app to actually serve before we hand
|
|
4
|
+
// back a public URL.
|
|
5
|
+
import { createServer, connect } from "node:net";
|
|
6
|
+
import { get as httpGet } from "node:http";
|
|
7
|
+
/** Ask the OS for an unused ephemeral port. */
|
|
8
|
+
export function getFreePort() {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const srv = createServer();
|
|
11
|
+
srv.on("error", reject);
|
|
12
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
13
|
+
const { port } = srv.address();
|
|
14
|
+
srv.close(() => resolve(port));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** True if something already accepts TCP connections on the port (e.g. a dev server). */
|
|
19
|
+
export function isPortListening(port, host = "127.0.0.1") {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const sock = connect(port, host);
|
|
22
|
+
const done = (v) => {
|
|
23
|
+
sock.destroy();
|
|
24
|
+
resolve(v);
|
|
25
|
+
};
|
|
26
|
+
sock.setTimeout(400);
|
|
27
|
+
sock.once("connect", () => done(true));
|
|
28
|
+
sock.once("timeout", () => done(false));
|
|
29
|
+
sock.once("error", () => resolve(false));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Poll the app for any non-5xx HTTP response until it's ready or the timeout hits. */
|
|
33
|
+
export function waitForApp(port, timeoutMs) {
|
|
34
|
+
const probe = () => new Promise((resolve) => {
|
|
35
|
+
const req = httpGet({ host: "127.0.0.1", port, path: "/", timeout: 1000 }, (res) => {
|
|
36
|
+
res.resume();
|
|
37
|
+
resolve((res.statusCode ?? 500) < 500);
|
|
38
|
+
});
|
|
39
|
+
req.on("error", () => resolve(false));
|
|
40
|
+
req.on("timeout", () => {
|
|
41
|
+
req.destroy();
|
|
42
|
+
resolve(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const deadline = Date.now() + timeoutMs;
|
|
47
|
+
const tick = async () => {
|
|
48
|
+
if (await probe())
|
|
49
|
+
return resolve(true);
|
|
50
|
+
if (Date.now() >= deadline)
|
|
51
|
+
return resolve(false);
|
|
52
|
+
setTimeout(() => void tick(), 300);
|
|
53
|
+
};
|
|
54
|
+
void tick();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import type { Duplex } from "node:stream";
|
|
3
|
+
export declare function proxyWeb(req: IncomingMessage, res: ServerResponse, targetPort: number): void;
|
|
4
|
+
export declare function proxyWs(req: IncomingMessage, socket: Duplex, head: Buffer, targetPort: number): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A minimal same-host reverse proxy: forward HTTP requests and WebSocket upgrades
|
|
2
|
+
// to a localhost target port. Enough for the preview front proxy (app + harness
|
|
3
|
+
// relay behind one origin); not a general proxy. No deps.
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { request, } from "node:http";
|
|
6
|
+
// Rewrite Host to the loopback target. cloudflared forwards the public Host, and
|
|
7
|
+
// esbuild's `serve` 403s any Host it doesn't recognize — so the phone would get a
|
|
8
|
+
// 403. The origin only needs a Host it accepts; the browser never sees this value.
|
|
9
|
+
function localHeaders(headers, targetPort) {
|
|
10
|
+
return { ...headers, host: `localhost:${targetPort}` };
|
|
11
|
+
}
|
|
12
|
+
export function proxyWeb(req, res, targetPort) {
|
|
13
|
+
const proxyReq = request({
|
|
14
|
+
host: "127.0.0.1",
|
|
15
|
+
port: targetPort,
|
|
16
|
+
path: req.url,
|
|
17
|
+
method: req.method,
|
|
18
|
+
headers: localHeaders(req.headers, targetPort),
|
|
19
|
+
}, (proxyRes) => {
|
|
20
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
21
|
+
proxyRes.pipe(res);
|
|
22
|
+
});
|
|
23
|
+
proxyReq.on("error", () => {
|
|
24
|
+
if (!res.headersSent)
|
|
25
|
+
res.writeHead(502);
|
|
26
|
+
res.end();
|
|
27
|
+
});
|
|
28
|
+
req.pipe(proxyReq);
|
|
29
|
+
}
|
|
30
|
+
export function proxyWs(req, socket, head, targetPort) {
|
|
31
|
+
// Re-issue the upgrade handshake against the target and splice the sockets.
|
|
32
|
+
const headers = localHeaders(req.headers, targetPort);
|
|
33
|
+
const upstream = connect(targetPort, "127.0.0.1", () => {
|
|
34
|
+
const headerLines = [
|
|
35
|
+
`${req.method} ${req.url} HTTP/1.1`,
|
|
36
|
+
...Object.entries(headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`),
|
|
37
|
+
"",
|
|
38
|
+
"",
|
|
39
|
+
].join("\r\n");
|
|
40
|
+
upstream.write(headerLines);
|
|
41
|
+
if (head && head.length)
|
|
42
|
+
upstream.write(head);
|
|
43
|
+
upstream.pipe(socket);
|
|
44
|
+
socket.pipe(upstream);
|
|
45
|
+
});
|
|
46
|
+
const bail = () => socket.destroy();
|
|
47
|
+
upstream.on("error", bail);
|
|
48
|
+
socket.on("error", bail);
|
|
49
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
export interface StartPreviewOptions {
|
|
3
|
+
/** Project directory — its .vincentt binding + dev script drive the preview. */
|
|
4
|
+
projectCwd: string;
|
|
5
|
+
/** App dev-serve port (default 5173). If a server is already listening here, it
|
|
6
|
+
* is reused instead of spawning a second one. */
|
|
7
|
+
appPort?: number;
|
|
8
|
+
/** Relay port. Default: an OS-assigned free port (returned as relayPort). */
|
|
9
|
+
relayPort?: number;
|
|
10
|
+
/** Front-proxy port the tunnel points at. Default: an OS-assigned free port. */
|
|
11
|
+
frontPort?: number;
|
|
12
|
+
/** Path routed to the relay instead of the app (default /__harness). */
|
|
13
|
+
harnessPath?: string;
|
|
14
|
+
/** Command that starts the app dev serve on $PORT (default `npm run dev`). */
|
|
15
|
+
devCommand?: string[];
|
|
16
|
+
/** stdio for the app dev serve. "ignore" (default) keeps an MCP server's stdout
|
|
17
|
+
* clean; a CLI can pass "inherit" to surface build output. */
|
|
18
|
+
appStdio?: "ignore" | "inherit";
|
|
19
|
+
/** How long to wait for cloudflared to register before giving up (default 45s). */
|
|
20
|
+
registerTimeoutMs?: number;
|
|
21
|
+
/** How long to wait for the app to serve before returning anyway (default 20s). */
|
|
22
|
+
appReadyTimeoutMs?: number;
|
|
23
|
+
/** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
|
|
24
|
+
onLog?: (message: string) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface RunningPreview {
|
|
27
|
+
/** Public https URL to open on the device. */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Relay port the diag_* tools query (may be OS-assigned). */
|
|
30
|
+
relayPort: number;
|
|
31
|
+
/** The app dev-serve port the tunnel ultimately serves. */
|
|
32
|
+
appPort: number;
|
|
33
|
+
/** Whether the app was responding when we returned (false = URL may be blank). */
|
|
34
|
+
appReady: boolean;
|
|
35
|
+
/** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
|
|
36
|
+
stop: () => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve once cloudflared reports a live edge connection, reject if it exits
|
|
41
|
+
* first or never registers. Exported for unit testing the log-scan/timeout logic.
|
|
42
|
+
*/
|
|
43
|
+
export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number): Promise<void>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The whole preview stack behind one call. Stands up, in order: the app dev serve
|
|
2
|
+
// (on an internal port), the harness relay (WS event sink + localhost /query), a
|
|
3
|
+
// front proxy unifying them on one origin (`/__harness` → relay, else → app), and
|
|
4
|
+
// a cloudflared tunnel to that origin so a phone gets ONE https URL that serves
|
|
5
|
+
// both the app (secure context → live camera) and the diagnostics socket.
|
|
6
|
+
//
|
|
7
|
+
// Returns the public URL plus a `stop()` that reaps everything. A failure at any
|
|
8
|
+
// step tears down what was already started, so the caller never leaks a child
|
|
9
|
+
// process or a live backend tunnel. Used by both the CLI (`npm run preview`) and
|
|
10
|
+
// the MCP verbs (preview_start / preview_stop).
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { startRelay } from "../relay/server.js";
|
|
14
|
+
import { ensureCloudflared } from "./cloudflared.js";
|
|
15
|
+
import { startSessionTunnel } from "./tunnel.js";
|
|
16
|
+
import { proxyWeb, proxyWs } from "./proxy.js";
|
|
17
|
+
import { getFreePort, isPortListening, waitForApp } from "./net.js";
|
|
18
|
+
export async function startPreview(opts) {
|
|
19
|
+
const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 20_000, onLog = () => undefined, } = opts;
|
|
20
|
+
// Resolve ports. The app port defaults to 5173; the relay + front ports are
|
|
21
|
+
// harness-internal, so auto-pick free ones (the relay port is returned for the
|
|
22
|
+
// MCP diag_* tools to point at) instead of colliding on fixed defaults.
|
|
23
|
+
const appPort = opts.appPort ?? 5173;
|
|
24
|
+
const relayPort = opts.relayPort ?? (await getFreePort());
|
|
25
|
+
const frontPort = opts.frontPort ?? (await getFreePort());
|
|
26
|
+
// If a dev server is already up on appPort, reuse it — don't spawn a second one
|
|
27
|
+
// that would fail to bind and leave the tunnel serving a broken origin.
|
|
28
|
+
const reuseApp = await isPortListening(appPort);
|
|
29
|
+
// Track every resource so any failure below can unwind exactly what started.
|
|
30
|
+
let app;
|
|
31
|
+
let relay;
|
|
32
|
+
let front;
|
|
33
|
+
let session;
|
|
34
|
+
let tunnel;
|
|
35
|
+
const stop = async () => {
|
|
36
|
+
tunnel?.kill();
|
|
37
|
+
app?.kill();
|
|
38
|
+
front?.close();
|
|
39
|
+
if (relay)
|
|
40
|
+
await relay.close();
|
|
41
|
+
if (session)
|
|
42
|
+
await session.reap();
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
// Resolve cloudflared up front so a fresh host provisions it before we mint a
|
|
46
|
+
// tunnel we couldn't otherwise run.
|
|
47
|
+
const cloudflaredBin = await ensureCloudflared({ onLog });
|
|
48
|
+
if (reuseApp) {
|
|
49
|
+
onLog(`reusing the dev server already on :${appPort}`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
app = spawn(devCommand[0], devCommand.slice(1), {
|
|
53
|
+
cwd: projectCwd,
|
|
54
|
+
env: { ...process.env, PORT: String(appPort) },
|
|
55
|
+
stdio: appStdio,
|
|
56
|
+
// Windows: npm/pnpm are .cmd shims — spawn needs a shell to resolve them.
|
|
57
|
+
shell: process.platform === "win32",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
relay = startRelay({
|
|
61
|
+
port: relayPort,
|
|
62
|
+
path: harnessPath,
|
|
63
|
+
onLog: (m) => onLog(`[relay] ${m}`),
|
|
64
|
+
});
|
|
65
|
+
front = createServer((req, res) => proxyWeb(req, res, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
66
|
+
front.on("upgrade", (req, socket, head) => proxyWs(req, socket, head, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
67
|
+
await new Promise((resolve) => front.listen(frontPort, resolve));
|
|
68
|
+
session = await startSessionTunnel(projectCwd, frontPort);
|
|
69
|
+
tunnel = spawn(cloudflaredBin, ["tunnel", "run", "--token", session.runToken], {
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
});
|
|
72
|
+
await waitForRegister(tunnel, registerTimeoutMs);
|
|
73
|
+
// Don't hand back a live URL that serves a blank page: wait for the app to
|
|
74
|
+
// actually respond. Non-fatal — a slow build still returns, flagged not-ready.
|
|
75
|
+
const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
|
|
76
|
+
if (!appReady) {
|
|
77
|
+
onLog(`app on :${appPort} isn't responding yet — the URL may be blank until it builds`);
|
|
78
|
+
}
|
|
79
|
+
return { url: session.url, relayPort, appPort, appReady, stop };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
await stop();
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve once cloudflared reports a live edge connection, reject if it exits
|
|
88
|
+
* first or never registers. Exported for unit testing the log-scan/timeout logic.
|
|
89
|
+
*/
|
|
90
|
+
export function waitForRegister(tunnel, timeoutMs) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
let settled = false;
|
|
93
|
+
const finish = (fn) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
fn();
|
|
99
|
+
};
|
|
100
|
+
const scan = (buf) => {
|
|
101
|
+
if (/Registered tunnel connection|Connection [^ ]+ registered/.test(String(buf))) {
|
|
102
|
+
finish(resolve);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
tunnel.stdout?.on("data", scan);
|
|
106
|
+
tunnel.stderr?.on("data", scan);
|
|
107
|
+
tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).`))));
|
|
108
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.`))), timeoutMs);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type MintedTunnel } from "../mcp/backend.js";
|
|
2
|
+
export interface SessionTunnel extends MintedTunnel {
|
|
3
|
+
/** Public https URL to open on the device. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Tear the tunnel down (DNS route + tunnel). Idempotent; call on SIGINT. */
|
|
6
|
+
reap: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
10
|
+
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
11
|
+
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
12
|
+
* unbound (run project_create) or no backend/PAT is configured.
|
|
13
|
+
*/
|
|
14
|
+
export declare function startSessionTunnel(projectCwd: string, localPort: number): Promise<SessionTunnel>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Per-session dev tunnel: resolve the project binding + machine config and mint a
|
|
2
|
+
// named tunnel via the backend (which holds the Cloudflare account creds). Stays
|
|
3
|
+
// free of any child-process / cloudflared coupling — the caller runs cloudflared
|
|
4
|
+
// with the returned run token (see runner.ts) and calls reap() on exit.
|
|
5
|
+
import { loadProjectBinding, resolveConfig } from "../shared/config.js";
|
|
6
|
+
import { mintTunnel, reapTunnel } from "../mcp/backend.js";
|
|
7
|
+
/**
|
|
8
|
+
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
9
|
+
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
10
|
+
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
11
|
+
* unbound (run project_create) or no backend/PAT is configured.
|
|
12
|
+
*/
|
|
13
|
+
export async function startSessionTunnel(projectCwd, localPort) {
|
|
14
|
+
const binding = await loadProjectBinding(projectCwd);
|
|
15
|
+
if (!binding) {
|
|
16
|
+
throw new Error("No project bound to this directory — run project_create first.");
|
|
17
|
+
}
|
|
18
|
+
const cfg = await resolveConfig(projectCwd);
|
|
19
|
+
const minted = await mintTunnel(cfg, binding.projectId, localPort);
|
|
20
|
+
return {
|
|
21
|
+
...minted,
|
|
22
|
+
url: `https://${minted.hostname}`,
|
|
23
|
+
reap: () => reapTunnel(cfg, binding.projectId, {
|
|
24
|
+
tunnelId: minted.tunnelId,
|
|
25
|
+
dnsRecordId: minted.dnsRecordId,
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
}
|
package/dist/scaffold/index.d.ts
CHANGED
|
@@ -17,3 +17,10 @@ export interface ScaffoldOptions {
|
|
|
17
17
|
* fresh repo. Throws a clear error if the clone fails (e.g. no git access).
|
|
18
18
|
*/
|
|
19
19
|
export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Install a freshly scaffolded project's dependencies so the first preview/build
|
|
22
|
+
* doesn't fail on a missing node_modules. Throws on failure (no network, no npm);
|
|
23
|
+
* the caller surfaces it as a "run npm install yourself" hint rather than aborting
|
|
24
|
+
* project creation.
|
|
25
|
+
*/
|
|
26
|
+
export declare function installDependencies(dir: string): Promise<void>;
|
package/dist/scaffold/index.js
CHANGED
|
@@ -70,3 +70,16 @@ async function isGitRepo(dir) {
|
|
|
70
70
|
return false;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Install a freshly scaffolded project's dependencies so the first preview/build
|
|
75
|
+
* doesn't fail on a missing node_modules. Throws on failure (no network, no npm);
|
|
76
|
+
* the caller surfaces it as a "run npm install yourself" hint rather than aborting
|
|
77
|
+
* project creation.
|
|
78
|
+
*/
|
|
79
|
+
export async function installDependencies(dir) {
|
|
80
|
+
// Windows: npm is npm.cmd — execFile needs a shell to resolve it.
|
|
81
|
+
await execFileAsync("npm", ["install"], {
|
|
82
|
+
cwd: dir,
|
|
83
|
+
shell: process.platform === "win32",
|
|
84
|
+
});
|
|
85
|
+
}
|
package/package.json
CHANGED