Skip to content

Commit df6e41e

Browse files
committed
Add network-exposed dev mode flags
1 parent a67dcc4 commit df6e41e

File tree

4 files changed

+263
-25
lines changed

4 files changed

+263
-25
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,25 @@ Or edit `~/.config/arcane-agents/config.yaml` directly.
151151
npm run dev
152152
```
153153

154+
To expose dev mode to other computers on your LAN:
155+
156+
```bash
157+
npm run dev -- --host
158+
```
159+
160+
If you want to open the app through a named host such as a Tailscale MagicDNS
161+
name, allow that host explicitly:
162+
163+
```bash
164+
npm run dev -- --host --allow-host waystone
165+
```
166+
167+
You can also bind to a specific interface:
168+
169+
```bash
170+
npm run dev -- --host 192.168.1.42
171+
```
172+
154173
Default URLs:
155174

156175
- App (Vite): `http://127.0.0.1:7600`

dev.mjs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { spawn, spawnSync } from "node:child_process";
2+
3+
const DEFAULT_HOST = "127.0.0.1";
4+
const CLIENT_PORT = "7600";
5+
const API_PORT = "7601";
6+
7+
function printHelp() {
8+
console.log(`Usage: npm run dev -- [--host [bind-host]] [--allow-host hostname]
9+
10+
Examples:
11+
npm run dev
12+
npm run dev -- --host
13+
npm run dev -- --host 192.168.1.42
14+
npm run dev -- --host --allow-host waystone
15+
16+
Flags:
17+
--host Bind both the Vite dev server and the API to a host.
18+
When passed without a value, uses 0.0.0.0 so other
19+
computers on the LAN can connect.
20+
--allow-host Allow Vite to answer requests for a hostname such as
21+
a Tailscale MagicDNS name.
22+
-h, --help Show this help output.`);
23+
}
24+
25+
function parseArgs(argv) {
26+
let host = DEFAULT_HOST;
27+
const allowedHosts = [];
28+
29+
for (let index = 0; index < argv.length; index += 1) {
30+
const arg = argv[index];
31+
32+
if (arg === "-h" || arg === "--help") {
33+
printHelp();
34+
process.exit(0);
35+
}
36+
37+
if (arg === "--host") {
38+
const next = argv[index + 1];
39+
if (next && !next.startsWith("-")) {
40+
host = next;
41+
index += 1;
42+
} else {
43+
host = "0.0.0.0";
44+
}
45+
continue;
46+
}
47+
48+
if (arg === "--allow-host") {
49+
const next = argv[index + 1];
50+
if (!next || next.startsWith("-")) {
51+
console.error("[arcane-agents] missing value for --allow-host.");
52+
process.exit(1);
53+
}
54+
allowedHosts.push(next);
55+
index += 1;
56+
continue;
57+
}
58+
59+
if (arg.startsWith("--host=")) {
60+
const value = arg.slice("--host=".length).trim();
61+
if (!value) {
62+
console.error("[arcane-agents] missing value for --host.");
63+
process.exit(1);
64+
}
65+
host = value;
66+
continue;
67+
}
68+
69+
if (arg.startsWith("--allow-host=")) {
70+
const value = arg.slice("--allow-host=".length).trim();
71+
if (!value) {
72+
console.error("[arcane-agents] missing value for --allow-host.");
73+
process.exit(1);
74+
}
75+
allowedHosts.push(value);
76+
continue;
77+
}
78+
79+
console.error(`[arcane-agents] unknown dev flag: ${arg}`);
80+
printHelp();
81+
process.exit(1);
82+
}
83+
84+
return { host, allowedHosts };
85+
}
86+
87+
function isIpLiteral(host) {
88+
return /^[\d.]+$/.test(host) || host.includes(":");
89+
}
90+
91+
function isDefaultAllowedHost(host) {
92+
return host === "localhost" || host.endsWith(".localhost") || isIpLiteral(host);
93+
}
94+
95+
function shouldAutoAllowHost(host) {
96+
return host.length > 0 && host !== "0.0.0.0" && host !== "::" && !isDefaultAllowedHost(host);
97+
}
98+
99+
function resolveProxyHost(host) {
100+
if (host === "0.0.0.0") {
101+
return "127.0.0.1";
102+
}
103+
104+
if (host === "::") {
105+
return "::1";
106+
}
107+
108+
return host;
109+
}
110+
111+
function getNpmCommand() {
112+
return process.platform === "win32" ? "npm.cmd" : "npm";
113+
}
114+
115+
function runChecked(command, args, env) {
116+
const result = spawnSync(command, args, {
117+
stdio: "inherit",
118+
env
119+
});
120+
121+
if (result.error) {
122+
throw result.error;
123+
}
124+
125+
if (result.status !== 0) {
126+
process.exit(result.status ?? 1);
127+
}
128+
}
129+
130+
const { host, allowedHosts: cliAllowedHosts } = parseArgs(process.argv.slice(2));
131+
const allowedHosts = new Set(cliAllowedHosts);
132+
133+
if (shouldAutoAllowHost(host)) {
134+
allowedHosts.add(host);
135+
}
136+
137+
const env = {
138+
...process.env,
139+
ARCANE_AGENTS_API_HOST: host,
140+
ARCANE_AGENTS_API_PORT: API_PORT,
141+
ARCANE_AGENTS_DEV_CLIENT_HOST: host,
142+
ARCANE_AGENTS_DEV_CLIENT_PORT: CLIENT_PORT,
143+
ARCANE_AGENTS_DEV_API_HOST: resolveProxyHost(host),
144+
ARCANE_AGENTS_DEV_API_PORT: API_PORT,
145+
ARCANE_AGENTS_DEV_ALLOWED_HOSTS: Array.from(allowedHosts).join(",")
146+
};
147+
148+
if (host === DEFAULT_HOST) {
149+
console.log("[arcane-agents] starting dev mode on localhost only.");
150+
} else if (host === "0.0.0.0") {
151+
console.log("[arcane-agents] starting dev mode on all network interfaces.");
152+
console.log("[arcane-agents] open http://<this-machine-ip>:7600 from another computer on your LAN.");
153+
} else {
154+
console.log(`[arcane-agents] starting dev mode on ${host}.`);
155+
}
156+
157+
if (allowedHosts.size > 0) {
158+
console.log(`[arcane-agents] allowing dev requests for: ${Array.from(allowedHosts).join(", ")}`);
159+
}
160+
161+
const npmCommand = getNpmCommand();
162+
163+
runChecked(npmCommand, ["run", "dev:clean"], env);
164+
165+
const child = spawn(npmCommand, ["run", "dev:stack"], {
166+
stdio: "inherit",
167+
env
168+
});
169+
170+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
171+
process.on(signal, () => {
172+
if (!child.killed) {
173+
child.kill(signal);
174+
}
175+
});
176+
}
177+
178+
child.on("exit", (code, signal) => {
179+
if (signal) {
180+
process.kill(process.pid, signal);
181+
return;
182+
}
183+
184+
process.exit(code ?? 0);
185+
});

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
"node": ">=20"
1717
},
1818
"scripts": {
19-
"dev": "npm run dev:clean && concurrently -k \"npm:dev:server\" \"npm:dev:client\"",
19+
"dev": "node ./dev.mjs",
2020
"dev:clean": "node ./kill-dev-ports.mjs 7600 7601",
21+
"dev:stack": "concurrently -k \"npm:dev:server\" \"npm:dev:client\"",
2122
"dev:server": "ARCANE_AGENTS_API_PORT=7601 tsx watch src/server/index.ts",
22-
"dev:client": "vite --host 127.0.0.1 --port 7600",
23+
"dev:client": "vite --port 7600",
2324
"build": "vite build && tsc -p tsconfig.server.json",
2425
"start": "node dist/server/server/cli.js start",
2526
"cli": "tsx src/server/cli.ts",

vite.config.ts

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,61 @@
11
import { defineConfig } from "vite";
22
import react from "@vitejs/plugin-react";
33

4-
export default defineConfig({
5-
plugins: [react()],
6-
server: {
7-
host: "127.0.0.1",
8-
port: 7600,
9-
proxy: {
10-
"/api/ws": {
11-
target: "ws://127.0.0.1:7601",
12-
ws: true,
13-
},
14-
"/api/terminal": {
15-
target: "ws://127.0.0.1:7601",
16-
ws: true,
17-
},
18-
"/api": {
19-
target: "http://127.0.0.1:7601",
20-
changeOrigin: false,
21-
},
22-
}
23-
},
24-
build: {
25-
outDir: "dist/client",
26-
target: "es2021"
4+
function resolveDevPort(value: string | undefined, fallback: number): number {
5+
const port = Number(value);
6+
return Number.isInteger(port) && port > 0 ? port : fallback;
7+
}
8+
9+
function formatHostForUrl(host: string): string {
10+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
11+
}
12+
13+
function resolveAllowedHosts(value: string | undefined): string[] | undefined {
14+
if (!value) {
15+
return undefined;
2716
}
17+
18+
const hosts = value
19+
.split(",")
20+
.map((host) => host.trim())
21+
.filter((host) => host.length > 0);
22+
23+
return hosts.length > 0 ? hosts : undefined;
24+
}
25+
26+
export default defineConfig(() => {
27+
const clientHost = process.env.ARCANE_AGENTS_DEV_CLIENT_HOST ?? "127.0.0.1";
28+
const clientPort = resolveDevPort(process.env.ARCANE_AGENTS_DEV_CLIENT_PORT, 7600);
29+
const apiHost = formatHostForUrl(process.env.ARCANE_AGENTS_DEV_API_HOST ?? "127.0.0.1");
30+
const apiPort = resolveDevPort(process.env.ARCANE_AGENTS_DEV_API_PORT, 7601);
31+
const allowedHosts = resolveAllowedHosts(process.env.ARCANE_AGENTS_DEV_ALLOWED_HOSTS);
32+
const apiHttpTarget = `http://${apiHost}:${apiPort}`;
33+
const apiWsTarget = `ws://${apiHost}:${apiPort}`;
34+
35+
return {
36+
plugins: [react()],
37+
server: {
38+
host: clientHost,
39+
port: clientPort,
40+
allowedHosts,
41+
proxy: {
42+
"/api/ws": {
43+
target: apiWsTarget,
44+
ws: true,
45+
},
46+
"/api/terminal": {
47+
target: apiWsTarget,
48+
ws: true,
49+
},
50+
"/api": {
51+
target: apiHttpTarget,
52+
changeOrigin: false,
53+
},
54+
}
55+
},
56+
build: {
57+
outDir: "dist/client",
58+
target: "es2021"
59+
}
60+
};
2861
});

0 commit comments

Comments
 (0)