@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +928 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +843 -0
- package/server/config.ts +362 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +966 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +654 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/server/http.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Small HTTP pieces shared by the routes and the static server.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately free of imports. The cache-control constant used to live in
|
|
4
|
+
// static.ts, which is the module that embeds the built web assets — so any
|
|
5
|
+
// module wanting one string pulled the whole asset manifest in with it, and
|
|
6
|
+
// anything importing api.ts could not be loaded without a completed build.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A year, the conventional "forever" for content-addressed assets: the hashed
|
|
10
|
+
* bundles, and the wallpapers, whose filenames are a digest of their bytes.
|
|
11
|
+
*/
|
|
12
|
+
export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A JSON response.
|
|
16
|
+
*
|
|
17
|
+
* `no-store` on every one of them. These describe live server state — which
|
|
18
|
+
* windows exist, which are running — and a cached answer is worse than a slow
|
|
19
|
+
* one: a stale window list draws terminals that are gone.
|
|
20
|
+
*/
|
|
21
|
+
export function json(body: unknown, status = 200): Response {
|
|
22
|
+
return Response.json(body, {
|
|
23
|
+
status,
|
|
24
|
+
headers: { "cache-control": "no-store" },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The 405 body, which fourteen routes were spelling out individually. */
|
|
29
|
+
export const methodNotAllowed = (): Response =>
|
|
30
|
+
json({ error: "method not allowed" }, 405);
|
|
31
|
+
|
|
32
|
+
/** The 400 for an id that does not even have the right shape. */
|
|
33
|
+
export const badId = (what: string): Response =>
|
|
34
|
+
json({ error: `invalid ${what} id` }, 400);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A JSON body, or `{}` when there isn't one.
|
|
38
|
+
*
|
|
39
|
+
* Every write route wants the same thing: parse if you can, and treat a missing
|
|
40
|
+
* or malformed body as empty so validation reports the real problem ("a profile
|
|
41
|
+
* needs a name") rather than a parse error. One route was the odd one out and
|
|
42
|
+
* threw instead.
|
|
43
|
+
*
|
|
44
|
+
* A body not labelled JSON counts as missing. `text/plain` is one of the types
|
|
45
|
+
* a page on another site can POST without a preflight, so honouring it would
|
|
46
|
+
* let such a page fill in a route's fields. The Origin check already stops
|
|
47
|
+
* that; this means a gap in it would only ever deliver an empty body.
|
|
48
|
+
*/
|
|
49
|
+
export const readJson = async <T>(req: Request): Promise<T> => {
|
|
50
|
+
const type = req.headers.get("content-type")?.split(";")[0].trim();
|
|
51
|
+
if (type !== "application/json") return {} as T;
|
|
52
|
+
return (await req.json().catch(() => ({}))) as T;
|
|
53
|
+
};
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import type { Server } from "bun";
|
|
6
|
+
import { Acme } from "./acme.ts";
|
|
7
|
+
import { createApi } from "./api.ts";
|
|
8
|
+
import { createGate, hostAllowed, hostName, originAllowed } from "./auth.ts";
|
|
9
|
+
import {
|
|
10
|
+
type BridgeData,
|
|
11
|
+
bridgeConnections,
|
|
12
|
+
createBridgeHandlers,
|
|
13
|
+
} from "./bridge.ts";
|
|
14
|
+
import type { Config } from "./config.ts";
|
|
15
|
+
import { openDb } from "./db.ts";
|
|
16
|
+
import { discoverClaude, discoverCursor, discoverHermes } from "./harness.ts";
|
|
17
|
+
import { json } from "./http.ts";
|
|
18
|
+
import { color, describeError, log } from "./log.ts";
|
|
19
|
+
import { ensureWasm, ensureWebRoot } from "./preflight.ts";
|
|
20
|
+
import { scanProjects } from "./projects.ts";
|
|
21
|
+
import { windowCommand } from "./session.ts";
|
|
22
|
+
import {
|
|
23
|
+
discoverHostKey,
|
|
24
|
+
fingerprint,
|
|
25
|
+
requireSshKeygen,
|
|
26
|
+
SshCa,
|
|
27
|
+
} from "./ssh-ca.ts";
|
|
28
|
+
import { createStaticServer } from "./static.ts";
|
|
29
|
+
import { WallpaperStore } from "./wallpapers.ts";
|
|
30
|
+
|
|
31
|
+
const run = promisify(execFile);
|
|
32
|
+
|
|
33
|
+
export interface RunningServer {
|
|
34
|
+
port: number;
|
|
35
|
+
close: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Ceiling on an HTTP request body, sized for a wallpaper upload. */
|
|
39
|
+
const MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
|
|
40
|
+
/**
|
|
41
|
+
* How long a request may go quiet before Bun hangs up, in seconds.
|
|
42
|
+
*
|
|
43
|
+
* Bun's default is 10, and it counts a request that is still being handled as
|
|
44
|
+
* idle, not just a socket between requests. Creating a workspace fetches origin
|
|
45
|
+
* before it branches, so on a large repo or a slow link the browser got
|
|
46
|
+
* `ERR_EMPTY_RESPONSE` while the server carried on and made the workspace
|
|
47
|
+
* anyway: the one thing here that is slow was the one thing that could not
|
|
48
|
+
* finish. Bun's maximum, because the budget it has to cover is git's own
|
|
49
|
+
* timeouts in projects.ts plus checking out a worktree at the end of them.
|
|
50
|
+
*/
|
|
51
|
+
const REQUEST_IDLE_TIMEOUT_S = 255;
|
|
52
|
+
/** Below this, binding needs root or CAP_NET_BIND_SERVICE. */
|
|
53
|
+
const FIRST_UNPRIVILEGED_PORT = 1024;
|
|
54
|
+
/** Where we land when port 80 is refused, so a first run still gets a URL. */
|
|
55
|
+
const UNPRIVILEGED_FALLBACK_PORT = 8080;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The installable-app description Chrome reads before it will offer "Install".
|
|
59
|
+
*
|
|
60
|
+
* `display: standalone` is what drops the browser chrome; without the 192 and
|
|
61
|
+
* 512 icons Chrome declines to offer installation at all. `id` is fixed so that
|
|
62
|
+
* changing `start_url` later updates the installed app rather than creating a
|
|
63
|
+
* second one beside it.
|
|
64
|
+
*/
|
|
65
|
+
function webManifest(themeColor: string) {
|
|
66
|
+
return {
|
|
67
|
+
id: "/",
|
|
68
|
+
name: "vibe-os",
|
|
69
|
+
short_name: "vibe-os",
|
|
70
|
+
description: "A coding desktop in the browser, on a box you own.",
|
|
71
|
+
start_url: "/",
|
|
72
|
+
scope: "/",
|
|
73
|
+
display: "standalone",
|
|
74
|
+
orientation: "any",
|
|
75
|
+
background_color: "#0d1117",
|
|
76
|
+
theme_color: themeColor,
|
|
77
|
+
// PNGs only, and every one of them opaque. `icon.svg` is deliberately
|
|
78
|
+
// absent: it has the rounded corners that suit a favicon and a README, and
|
|
79
|
+
// an installer that fills transparency with white turns those into a white
|
|
80
|
+
// border around a dark icon in the Dock.
|
|
81
|
+
icons: [
|
|
82
|
+
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
|
83
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
|
84
|
+
{
|
|
85
|
+
src: "/icon-maskable-512.png",
|
|
86
|
+
sizes: "512x512",
|
|
87
|
+
type: "image/png",
|
|
88
|
+
purpose: "maskable",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const UNAUTHORISED_PAGE = `<!doctype html><meta charset="utf-8"><title>vibe-os</title>
|
|
95
|
+
<body style="font:14px ui-monospace,monospace;background:#07090d;color:#c8cedb;padding:2rem">
|
|
96
|
+
<h1 style="font-weight:600">vibe-os</h1>
|
|
97
|
+
<p>This server requires a token. Open the URL printed in the server log.</p></body>`;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Addresses a browser can actually reach.
|
|
101
|
+
*
|
|
102
|
+
* `os.hostname()` is the tempting answer and the wrong one — on a VPS it is
|
|
103
|
+
* usually something like "ubuntu-2gb-fsn1" that resolves nowhere, and in a
|
|
104
|
+
* container it is a hex id. Interface addresses are what someone can type.
|
|
105
|
+
*/
|
|
106
|
+
function reachableHosts(config: Config): string[] {
|
|
107
|
+
if (config.domain) return [config.domain];
|
|
108
|
+
if (config.host !== "0.0.0.0" && config.host !== "::") return [config.host];
|
|
109
|
+
|
|
110
|
+
const addresses: string[] = [];
|
|
111
|
+
for (const iface of Object.values(os.networkInterfaces())) {
|
|
112
|
+
for (const info of iface ?? []) {
|
|
113
|
+
if (info.family === "IPv4" && !info.internal)
|
|
114
|
+
addresses.push(info.address);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return addresses.length > 0 ? addresses : [os.hostname()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* This machine's name on the tailnet, if Tailscale is up.
|
|
122
|
+
*
|
|
123
|
+
* `tailscale serve` passes the browser's Host through, so behind it every
|
|
124
|
+
* request names the box by its MagicDNS name, which nothing else here knows.
|
|
125
|
+
*/
|
|
126
|
+
async function tailscaleName(): Promise<string | null> {
|
|
127
|
+
try {
|
|
128
|
+
const { stdout } = await run("tailscale", ["status", "--json"], {
|
|
129
|
+
timeout: 3_000,
|
|
130
|
+
});
|
|
131
|
+
const name = (JSON.parse(stdout) as { Self?: { DNSName?: string } }).Self
|
|
132
|
+
?.DNSName;
|
|
133
|
+
return name ? hostName(name) : null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Every name a request may use to reach this server when there is no token.
|
|
141
|
+
*
|
|
142
|
+
* Addresses and `localhost` need no listing; `hostAllowed` takes those as
|
|
143
|
+
* given. A dotted name brings its first label too, since MagicDNS and most
|
|
144
|
+
* LANs answer to the short form.
|
|
145
|
+
*/
|
|
146
|
+
async function knownHosts(config: Config): Promise<Set<string>> {
|
|
147
|
+
const names = [
|
|
148
|
+
os.hostname(),
|
|
149
|
+
config.domain,
|
|
150
|
+
config.sshAdvertise && hostName(config.sshAdvertise),
|
|
151
|
+
await tailscaleName(),
|
|
152
|
+
...config.allowedHosts,
|
|
153
|
+
]
|
|
154
|
+
.filter((n): n is string => Boolean(n))
|
|
155
|
+
.map(hostName);
|
|
156
|
+
return new Set(names.flatMap((n) => [n, n.split(".")[0]]));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function banner(
|
|
160
|
+
config: Config,
|
|
161
|
+
port: number,
|
|
162
|
+
urls: string[],
|
|
163
|
+
extras: string[],
|
|
164
|
+
): void {
|
|
165
|
+
const line = color.dim("─".repeat(58));
|
|
166
|
+
console.log("");
|
|
167
|
+
console.log(
|
|
168
|
+
` ${color.bold(color.cyan("vibe-os"))} ${color.dim(`· ${os.hostname()}`)}`,
|
|
169
|
+
);
|
|
170
|
+
console.log(` ${line}`);
|
|
171
|
+
console.log(` ${color.bold("open")} ${color.cyan(urls[0])}`);
|
|
172
|
+
for (const extra of urls.slice(1, 4))
|
|
173
|
+
console.log(` ${color.dim(extra)}`);
|
|
174
|
+
console.log(
|
|
175
|
+
` ${color.dim("shell")} ${config.user}@${config.sshHost}:${config.sshPort}`,
|
|
176
|
+
);
|
|
177
|
+
console.log(
|
|
178
|
+
` ${color.dim("windows")} ${config.sessions ? "dtach-backed (survive reload)" : "plain login shell"}`,
|
|
179
|
+
);
|
|
180
|
+
for (const extra of extras) console.log(` ${color.dim(extra)}`);
|
|
181
|
+
console.log(` ${line}`);
|
|
182
|
+
|
|
183
|
+
if (!config.token) {
|
|
184
|
+
console.log("");
|
|
185
|
+
console.log(
|
|
186
|
+
` ${color.yellow("!")} ${color.bold("This server is unauthenticated.")}`,
|
|
187
|
+
);
|
|
188
|
+
console.log(
|
|
189
|
+
` Anyone who can reach ${color.bold(`${config.host}:${port}`)} gets a shell as ${color.bold(config.user)}.`,
|
|
190
|
+
);
|
|
191
|
+
console.log(
|
|
192
|
+
` Keep it on a private network, or restart with ${color.bold("--token")}.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
console.log("");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function startServer(config: Config): Promise<RunningServer> {
|
|
199
|
+
await mkdir(config.stateDir, { recursive: true, mode: 0o700 });
|
|
200
|
+
await ensureWebRoot(config.webRoot);
|
|
201
|
+
await ensureWasm(config.webRoot);
|
|
202
|
+
await requireSshKeygen();
|
|
203
|
+
|
|
204
|
+
// Certificate authority: created once, then trusted by sshd for this user.
|
|
205
|
+
const ca = new SshCa(config.stateDir);
|
|
206
|
+
await ca.ensure();
|
|
207
|
+
const trust = await ca.trustInAuthorizedKeys(os.homedir());
|
|
208
|
+
if (trust === "added") {
|
|
209
|
+
log.ok(`added vibe-os CA to ${os.homedir()}/.ssh/authorized_keys`);
|
|
210
|
+
} else {
|
|
211
|
+
log.debug("vibe-os CA already trusted in authorized_keys");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const hostKey = await discoverHostKey(config.sshHost, config.sshPort);
|
|
215
|
+
if (hostKey) {
|
|
216
|
+
log.ok(
|
|
217
|
+
`pinned host key ${fingerprint(hostKey)} for ${config.sshHost}:${config.sshPort}`,
|
|
218
|
+
);
|
|
219
|
+
} else {
|
|
220
|
+
log.warn(
|
|
221
|
+
"could not discover the SSH host key — the browser will ask you to confirm it on first connect",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const wallpapers = new WallpaperStore(config.stateDir);
|
|
226
|
+
await wallpapers.init();
|
|
227
|
+
|
|
228
|
+
const db = openDb(config.stateDir);
|
|
229
|
+
// Warm the project list before the first request so the picker is populated
|
|
230
|
+
// on the very first page load rather than one poll later.
|
|
231
|
+
await scanProjects(db, config);
|
|
232
|
+
|
|
233
|
+
// Warmed here, deliberately not awaited: asking the Claude binary what it
|
|
234
|
+
// supports costs a few seconds of its startup, and the answer is only needed
|
|
235
|
+
// the first time someone opens the profile editor. Doing it now means it is
|
|
236
|
+
// ready by then; doing it there would make the panel hang on first open.
|
|
237
|
+
void discoverClaude();
|
|
238
|
+
// Hermes is Python and answers three `config get` calls, so it is slower
|
|
239
|
+
// still. Same reasoning, more of it.
|
|
240
|
+
void discoverHermes();
|
|
241
|
+
// Cursor asks its own service for the model list and login state, so this
|
|
242
|
+
// one can wait on the network rather than a binary. Same reasoning again.
|
|
243
|
+
void discoverCursor();
|
|
244
|
+
|
|
245
|
+
const gate = createGate(config.token);
|
|
246
|
+
// Only consulted without a token, so only worth the Tailscale probe then.
|
|
247
|
+
const hosts = config.token ? new Set<string>() : await knownHosts(config);
|
|
248
|
+
const serveStatic = createStaticServer(config.webRoot);
|
|
249
|
+
const handleApi = createApi({ config, ca, hostKey, wallpapers, db });
|
|
250
|
+
const bridge = createBridgeHandlers({
|
|
251
|
+
host: config.sshHost,
|
|
252
|
+
port: config.sshPort,
|
|
253
|
+
});
|
|
254
|
+
const acme = config.domain
|
|
255
|
+
? new Acme({
|
|
256
|
+
domain: config.domain,
|
|
257
|
+
email: config.acmeEmail,
|
|
258
|
+
staging: config.acmeStaging,
|
|
259
|
+
stateDir: config.stateDir,
|
|
260
|
+
})
|
|
261
|
+
: null;
|
|
262
|
+
|
|
263
|
+
let tlsReady = false;
|
|
264
|
+
|
|
265
|
+
const handle = async (
|
|
266
|
+
req: Request,
|
|
267
|
+
server: Server<BridgeData>,
|
|
268
|
+
secure: boolean,
|
|
269
|
+
): Promise<Response | undefined> => {
|
|
270
|
+
const url = new URL(req.url);
|
|
271
|
+
|
|
272
|
+
// ACME challenges must answer on plain HTTP, unauthenticated, before any
|
|
273
|
+
// redirect — Let's Encrypt will not follow a 302 to a cert we do not have.
|
|
274
|
+
if (!secure && acme) {
|
|
275
|
+
const challenge = acme.handleChallenge(url);
|
|
276
|
+
if (challenge) return challenge;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!secure && tlsReady && config.domain) {
|
|
280
|
+
return Response.redirect(
|
|
281
|
+
`https://${config.domain}${url.pathname}${url.search}`,
|
|
282
|
+
308,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!originAllowed(req)) {
|
|
287
|
+
log.warn(
|
|
288
|
+
`rejected ${req.method} ${url.pathname} from origin ${req.headers.get("origin")}`,
|
|
289
|
+
);
|
|
290
|
+
return new Response("forbidden: cross-origin request\n", {
|
|
291
|
+
status: 403,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (!config.token && !hostAllowed(req, hosts)) {
|
|
295
|
+
const name = hostName(req.headers.get("host") ?? "");
|
|
296
|
+
log.warn(
|
|
297
|
+
`rejected a request for host ${JSON.stringify(name)}; if that is this server, add --allowed-host ${name}`,
|
|
298
|
+
);
|
|
299
|
+
return new Response(
|
|
300
|
+
`forbidden: this server does not answer to ${name}\n` +
|
|
301
|
+
`If it should, restart it with --allowed-host ${name}, or with a token.\n`,
|
|
302
|
+
{ status: 403 },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const redirect = gate.consumeTokenParam(req, url, secure);
|
|
307
|
+
if (redirect) return redirect;
|
|
308
|
+
|
|
309
|
+
const denied = gate.check(req);
|
|
310
|
+
|
|
311
|
+
if (url.pathname === "/websocket") {
|
|
312
|
+
if (denied) return new Response("unauthorized", { status: 401 });
|
|
313
|
+
if (bridge.atCapacity())
|
|
314
|
+
return new Response("too many connections", { status: 503 });
|
|
315
|
+
|
|
316
|
+
const data: BridgeData = {
|
|
317
|
+
socket: null,
|
|
318
|
+
peer: server.requestIP(req)?.address ?? "?",
|
|
319
|
+
closed: false,
|
|
320
|
+
};
|
|
321
|
+
if (server.upgrade(req, { data })) return undefined;
|
|
322
|
+
return new Response("websocket upgrade failed", { status: 400 });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (denied) {
|
|
326
|
+
if (url.pathname.startsWith("/api/")) return json({ error: denied }, 401);
|
|
327
|
+
return new Response(UNAUTHORISED_PAGE, {
|
|
328
|
+
status: 401,
|
|
329
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Generated rather than a file in public/, because the theme colour is
|
|
334
|
+
// configurable and the whole point of that is telling two installed
|
|
335
|
+
// instances apart.
|
|
336
|
+
if (url.pathname === "/manifest.webmanifest") {
|
|
337
|
+
return Response.json(webManifest(config.themeColor), {
|
|
338
|
+
headers: {
|
|
339
|
+
"content-type": "application/manifest+json",
|
|
340
|
+
// Cheap to fetch and it changes with a flag, so never cache it.
|
|
341
|
+
"cache-control": "no-store",
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const api = await handleApi(req, url);
|
|
347
|
+
if (api) return api;
|
|
348
|
+
|
|
349
|
+
const asset = await serveStatic(req, url.pathname);
|
|
350
|
+
if (asset) return asset;
|
|
351
|
+
|
|
352
|
+
return new Response("not found\n", {
|
|
353
|
+
status: 404,
|
|
354
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const makeServer = (
|
|
359
|
+
port: number,
|
|
360
|
+
secure: boolean,
|
|
361
|
+
tls?: { key: string; cert: string },
|
|
362
|
+
): Server<BridgeData> =>
|
|
363
|
+
Bun.serve<BridgeData>({
|
|
364
|
+
port,
|
|
365
|
+
hostname: config.host,
|
|
366
|
+
...(tls ? { tls } : {}),
|
|
367
|
+
// Wallpaper uploads are the largest body any route accepts, and this is
|
|
368
|
+
// the ceiling before per-route checks see it. WebSocket framing is a
|
|
369
|
+
// different knob entirely — that is `backpressureLimit` in bridge.ts.
|
|
370
|
+
maxRequestBodySize: MAX_REQUEST_BODY_BYTES,
|
|
371
|
+
idleTimeout: REQUEST_IDLE_TIMEOUT_S,
|
|
372
|
+
development: false,
|
|
373
|
+
async fetch(req, server) {
|
|
374
|
+
try {
|
|
375
|
+
return (await handle(req, server, secure)) as Response;
|
|
376
|
+
} catch (err) {
|
|
377
|
+
log.error(`request failed: ${describeError(err)}`);
|
|
378
|
+
return new Response("internal error\n", { status: 500 });
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
websocket: bridge.handlers,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Binds the requested port, falling back rather than dying.
|
|
386
|
+
*
|
|
387
|
+
* Port 80 needs root or CAP_NET_BIND_SERVICE, and on a fresh VPS the user is
|
|
388
|
+
* usually root so it just works. When it does not, exiting with EACCES is a
|
|
389
|
+
* bad experience — come up somewhere reachable and say how to fix it.
|
|
390
|
+
*/
|
|
391
|
+
let httpServer: Server<BridgeData>;
|
|
392
|
+
let port = config.port;
|
|
393
|
+
try {
|
|
394
|
+
httpServer = makeServer(config.port, false);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
397
|
+
if (
|
|
398
|
+
(code !== "EACCES" && code !== "EPERM") ||
|
|
399
|
+
config.port >= FIRST_UNPRIVILEGED_PORT
|
|
400
|
+
)
|
|
401
|
+
throw err;
|
|
402
|
+
|
|
403
|
+
log.warn(
|
|
404
|
+
`cannot bind port ${config.port} as ${os.userInfo().username} (needs root or CAP_NET_BIND_SERVICE)`,
|
|
405
|
+
);
|
|
406
|
+
log.warn(
|
|
407
|
+
` grant it once: sudo setcap 'cap_net_bind_service=+ep' $(readlink -f "$(which bun)")`,
|
|
408
|
+
);
|
|
409
|
+
log.warn(" or install the service: sudo vibe-os install-service");
|
|
410
|
+
port = UNPRIVILEGED_FALLBACK_PORT;
|
|
411
|
+
httpServer = makeServer(port, false);
|
|
412
|
+
log.warn(`listening on ${port} instead of ${config.port}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let httpsServer: Server<BridgeData> | undefined;
|
|
416
|
+
let stopRenewal: (() => void) | undefined;
|
|
417
|
+
const extras: string[] = [];
|
|
418
|
+
|
|
419
|
+
if (acme && config.domain) {
|
|
420
|
+
try {
|
|
421
|
+
const material = await acme.obtain();
|
|
422
|
+
httpsServer = makeServer(config.tlsPort, true, material);
|
|
423
|
+
tlsReady = true;
|
|
424
|
+
extras.push(`http://${config.domain} redirects to https`);
|
|
425
|
+
// Certificates last 90 days and this process is meant to run for longer,
|
|
426
|
+
// so expiry has to be re-checked while it runs, not only at boot.
|
|
427
|
+
stopRenewal = acme.watch((renewed) => {
|
|
428
|
+
httpsServer?.stop(true);
|
|
429
|
+
httpsServer = makeServer(config.tlsPort, true, renewed);
|
|
430
|
+
log.ok(`TLS certificate renewed for ${config.domain}`);
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
433
|
+
log.error(`TLS setup failed: ${describeError(err)}`);
|
|
434
|
+
log.warn(`continuing on plain HTTP at port ${port}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const query = config.token ? `?token=${config.token}` : "";
|
|
439
|
+
const urls = tlsReady
|
|
440
|
+
? [
|
|
441
|
+
`https://${config.domain}${config.tlsPort === 443 ? "" : `:${config.tlsPort}`}/${query}`,
|
|
442
|
+
]
|
|
443
|
+
: reachableHosts(config).map(
|
|
444
|
+
(h) => `http://${h}${port === 80 ? "" : `:${port}`}/${query}`,
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const sample = windowCommand(
|
|
448
|
+
"vibe-<workspace>-1",
|
|
449
|
+
"<worktree>",
|
|
450
|
+
config,
|
|
451
|
+
null,
|
|
452
|
+
{ forDisplay: true },
|
|
453
|
+
);
|
|
454
|
+
if (sample) extras.push(`each window runs: ${sample}`);
|
|
455
|
+
extras.push(`state: ${config.stateDir}`);
|
|
456
|
+
extras.push(`runtime: bun ${Bun.version}`);
|
|
457
|
+
|
|
458
|
+
banner(config, port, urls, extras);
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
port,
|
|
462
|
+
close() {
|
|
463
|
+
stopRenewal?.();
|
|
464
|
+
httpServer.stop(true);
|
|
465
|
+
httpsServer?.stop(true);
|
|
466
|
+
log.info(`stopped (${bridgeConnections()} bridge connections were open)`);
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
package/server/log.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
|
|
2
|
+
|
|
3
|
+
const paint = (code: string, s: string) =>
|
|
4
|
+
useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
5
|
+
|
|
6
|
+
export const color = {
|
|
7
|
+
dim: (s: string) => paint("2", s),
|
|
8
|
+
bold: (s: string) => paint("1", s),
|
|
9
|
+
cyan: (s: string) => paint("36", s),
|
|
10
|
+
green: (s: string) => paint("32", s),
|
|
11
|
+
yellow: (s: string) => paint("33", s),
|
|
12
|
+
red: (s: string) => paint("31", s),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function stamp(): string {
|
|
16
|
+
return color.dim(new Date().toISOString().slice(11, 19));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a caught `unknown` should say to a person.
|
|
21
|
+
*
|
|
22
|
+
* `catch` binds `unknown`, so every site that wants to report a failure has to
|
|
23
|
+
* narrow it first. That one-liner was written out verbatim in thirteen places
|
|
24
|
+
* across the server before this existed; the client has its own copy in
|
|
25
|
+
* `src/data.ts` for the same reason.
|
|
26
|
+
*/
|
|
27
|
+
export function describeError(err: unknown): string {
|
|
28
|
+
return err instanceof Error ? err.message : String(err);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const log = {
|
|
32
|
+
info: (msg: string) => console.log(`${stamp()} ${msg}`),
|
|
33
|
+
ok: (msg: string) => console.log(`${stamp()} ${color.green("✓")} ${msg}`),
|
|
34
|
+
warn: (msg: string) => console.warn(`${stamp()} ${color.yellow("!")} ${msg}`),
|
|
35
|
+
error: (msg: string) => console.error(`${stamp()} ${color.red("✗")} ${msg}`),
|
|
36
|
+
debug: (msg: string) => {
|
|
37
|
+
if (process.env.VIBE_OS_DEBUG)
|
|
38
|
+
console.log(`${stamp()} ${color.dim(`· ${msg}`)}`);
|
|
39
|
+
},
|
|
40
|
+
};
|
package/server/main.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Entry point for `bun build --compile`.
|
|
2
|
+
import { main } from "./cli.ts";
|
|
3
|
+
import { cliArgs } from "./runtime.ts";
|
|
4
|
+
|
|
5
|
+
const code = await main(cliArgs());
|
|
6
|
+
// -1 means "the server is running"; anything else is a one-shot command.
|
|
7
|
+
if (code >= 0) process.exit(code);
|