@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/ssh-ca.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// vibe-os is its own short-lived SSH certificate authority.
|
|
2
|
+
//
|
|
3
|
+
// The browser generates an ed25519 keypair inside the WASM sandbox and keeps
|
|
4
|
+
// the private half in IndexedDB — it never leaves the tab. It POSTs only the
|
|
5
|
+
// public half here, and gets back a certificate signed by this CA, valid for a
|
|
6
|
+
// few hours. The VPS trusts exactly one line in ~/.ssh/authorized_keys:
|
|
7
|
+
//
|
|
8
|
+
// cert-authority ssh-ed25519 AAAA... vibe-os-ca
|
|
9
|
+
//
|
|
10
|
+
// That is the whole enrollment story: no key to copy and paste, no private key
|
|
11
|
+
// on the wire, no sshd_config edit, and no root. Revoking access to every
|
|
12
|
+
// browser that ever connected is deleting that one line.
|
|
13
|
+
//
|
|
14
|
+
// Certificates also carry a per-pane `force-command` critical option, which is
|
|
15
|
+
// how each pane lands in its own persistent dtach session (see certificate.ts's
|
|
16
|
+
// caller). sshd allocates the PTY because the client asks for a shell, then
|
|
17
|
+
// runs the forced command inside it — so resize and job control behave.
|
|
18
|
+
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import {
|
|
22
|
+
appendFile,
|
|
23
|
+
chmod,
|
|
24
|
+
mkdir,
|
|
25
|
+
mkdtemp,
|
|
26
|
+
readFile,
|
|
27
|
+
rm,
|
|
28
|
+
writeFile,
|
|
29
|
+
} from "node:fs/promises";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
import { pathExists } from "./fsx.ts";
|
|
34
|
+
import { log } from "./log.ts";
|
|
35
|
+
|
|
36
|
+
const run = promisify(execFile);
|
|
37
|
+
|
|
38
|
+
/** Public key types we are willing to sign. Certificates are rejected. */
|
|
39
|
+
const SIGNABLE_KEY_TYPE =
|
|
40
|
+
/^(ssh-ed25519|ecdsa-sha2-nistp(256|384|521)|ssh-rsa|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ceiling on a posted public key. Exported because `server/api.ts` pre-checks
|
|
44
|
+
* `content-length` against it: two constants meant one limit rejected at the
|
|
45
|
+
* HTTP edge and a different, smaller one rejected here with the same message.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_PUBKEY_BYTES = 8 * 1024;
|
|
48
|
+
|
|
49
|
+
export interface SignOptions {
|
|
50
|
+
/** Public key in authorized_keys form, as posted by the browser. */
|
|
51
|
+
publicKey: string;
|
|
52
|
+
/** Unix user the certificate is valid for. */
|
|
53
|
+
principal: string;
|
|
54
|
+
/** Human-readable identity; sshd logs this on every login. */
|
|
55
|
+
identity: string;
|
|
56
|
+
/** Optional forced command, e.g. `dtach new-session -A -s vibe-1`. */
|
|
57
|
+
forceCommand?: string;
|
|
58
|
+
/** Certificate lifetime in seconds. */
|
|
59
|
+
ttlSeconds: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Confirms ssh-keygen is on PATH.
|
|
64
|
+
*
|
|
65
|
+
* The probe has to be side-effect free: several of ssh-keygen's flags generate
|
|
66
|
+
* key material as a side effect (`-A` writes host keys), so this asks it to
|
|
67
|
+
* fingerprint a path that cannot exist. That always exits non-zero — the only
|
|
68
|
+
* outcome we care about is whether the binary was found at all.
|
|
69
|
+
*/
|
|
70
|
+
export async function requireSshKeygen(): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
await run("ssh-keygen", ["-l", "-f", "/nonexistent/vibe-os-probe"], {
|
|
73
|
+
timeout: 5_000,
|
|
74
|
+
});
|
|
75
|
+
} catch (err: unknown) {
|
|
76
|
+
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"ssh-keygen was not found on PATH — install openssh-client",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class SshCa {
|
|
85
|
+
readonly keyPath: string;
|
|
86
|
+
readonly pubPath: string;
|
|
87
|
+
private publicKeyLine = "";
|
|
88
|
+
private serialCounter = 0n;
|
|
89
|
+
|
|
90
|
+
constructor(stateDir: string) {
|
|
91
|
+
this.keyPath = path.join(stateDir, "ca");
|
|
92
|
+
this.pubPath = path.join(stateDir, "ca.pub");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The CA's public key in authorized_keys form, without the trailing newline. */
|
|
96
|
+
get publicKey(): string {
|
|
97
|
+
if (!this.publicKeyLine)
|
|
98
|
+
throw new Error("CA not initialised — call ensure() first");
|
|
99
|
+
return this.publicKeyLine;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Creates the CA keypair if it does not exist yet. Idempotent. */
|
|
103
|
+
async ensure(): Promise<void> {
|
|
104
|
+
await mkdir(path.dirname(this.keyPath), { recursive: true, mode: 0o700 });
|
|
105
|
+
await chmod(path.dirname(this.keyPath), 0o700).catch(() => {});
|
|
106
|
+
|
|
107
|
+
if (!(await pathExists(this.keyPath))) {
|
|
108
|
+
// ssh-keygen refuses to overwrite without prompting, so a stale .pub with
|
|
109
|
+
// no private half has to go first.
|
|
110
|
+
await rm(this.pubPath, { force: true });
|
|
111
|
+
await run("ssh-keygen", [
|
|
112
|
+
"-q",
|
|
113
|
+
"-t",
|
|
114
|
+
"ed25519",
|
|
115
|
+
"-f",
|
|
116
|
+
this.keyPath,
|
|
117
|
+
"-N",
|
|
118
|
+
"",
|
|
119
|
+
"-C",
|
|
120
|
+
`vibe-os-ca@${os.hostname()}`,
|
|
121
|
+
]);
|
|
122
|
+
log.ok(`generated SSH certificate authority at ${this.keyPath}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Outside the branch above on purpose. A key restored from a backup, copied
|
|
126
|
+
// from another box, or written by an older version under a loose umask
|
|
127
|
+
// would otherwise keep whatever mode it arrived with — and ssh-keygen signs
|
|
128
|
+
// with a world-readable CA without complaining. This is the one key whose
|
|
129
|
+
// disclosure mints certificates for any principal.
|
|
130
|
+
await chmod(this.keyPath, 0o600).catch(() => {});
|
|
131
|
+
|
|
132
|
+
this.publicKeyLine = (await readFile(this.pubPath, "utf8")).trim();
|
|
133
|
+
if (!this.publicKeyLine) throw new Error(`${this.pubPath} is empty`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Adds the `cert-authority` line to authorized_keys so sshd will accept any
|
|
138
|
+
* certificate this CA signs. Idempotent — matches on the key blob, so a
|
|
139
|
+
* hand-edited comment or extra options on the line are left alone.
|
|
140
|
+
*/
|
|
141
|
+
async trustInAuthorizedKeys(homeDir: string): Promise<"added" | "present"> {
|
|
142
|
+
const sshDir = path.join(homeDir, ".ssh");
|
|
143
|
+
const file = path.join(sshDir, "authorized_keys");
|
|
144
|
+
|
|
145
|
+
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
|
146
|
+
await chmod(sshDir, 0o700).catch(() => {});
|
|
147
|
+
|
|
148
|
+
const blob = this.publicKey.split(/\s+/)[1];
|
|
149
|
+
if (!blob) throw new Error("malformed CA public key");
|
|
150
|
+
|
|
151
|
+
let current = "";
|
|
152
|
+
if (await pathExists(file)) current = await readFile(file, "utf8");
|
|
153
|
+
if (current.includes(blob)) return "present";
|
|
154
|
+
|
|
155
|
+
const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
156
|
+
await appendFile(
|
|
157
|
+
file,
|
|
158
|
+
`${prefix}# added by vibe-os — trusts browser-held keys certified by this host\ncert-authority ${this.publicKey}\n`,
|
|
159
|
+
{ mode: 0o600 },
|
|
160
|
+
);
|
|
161
|
+
await chmod(file, 0o600).catch(() => {});
|
|
162
|
+
return "added";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Signs a browser-supplied public key.
|
|
167
|
+
*
|
|
168
|
+
* Everything reaching ssh-keygen goes through an argv array, never a shell,
|
|
169
|
+
* so a hostile `forceCommand` or public key cannot break out into command
|
|
170
|
+
* execution. The key itself is still validated because ssh-keygen will
|
|
171
|
+
* happily sign anything shaped like a key, and we do not want to mint
|
|
172
|
+
* certificates from certificates.
|
|
173
|
+
*/
|
|
174
|
+
async signUserCert(opts: SignOptions): Promise<string> {
|
|
175
|
+
const publicKey = normalisePublicKey(opts.publicKey);
|
|
176
|
+
|
|
177
|
+
// mkdtemp, not a name we compose ourselves: it creates the directory
|
|
178
|
+
// atomically with an unpredictable suffix, so nothing in a world-writable
|
|
179
|
+
// /tmp can pre-create the path (or a symlink at it) and have us write key
|
|
180
|
+
// material somewhere else.
|
|
181
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), "vibe-os-sign-"));
|
|
182
|
+
await chmod(tmp, 0o700).catch(() => {});
|
|
183
|
+
const keyFile = path.join(tmp, "id.pub");
|
|
184
|
+
const certFile = path.join(tmp, "id-cert.pub");
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await writeFile(keyFile, `${publicKey}\n`, { mode: 0o600 });
|
|
188
|
+
|
|
189
|
+
const args = [
|
|
190
|
+
"-q",
|
|
191
|
+
"-s",
|
|
192
|
+
this.keyPath,
|
|
193
|
+
"-I",
|
|
194
|
+
opts.identity,
|
|
195
|
+
"-n",
|
|
196
|
+
opts.principal,
|
|
197
|
+
// A little slack on the lower bound: the browser's clock is not ours.
|
|
198
|
+
"-V",
|
|
199
|
+
`-5m:+${Math.max(1, Math.round(opts.ttlSeconds / 60))}m`,
|
|
200
|
+
"-z",
|
|
201
|
+
String(this.nextSerial()),
|
|
202
|
+
];
|
|
203
|
+
if (opts.forceCommand)
|
|
204
|
+
args.push("-O", `force-command=${opts.forceCommand}`);
|
|
205
|
+
args.push(keyFile);
|
|
206
|
+
|
|
207
|
+
await run("ssh-keygen", args, { timeout: 15_000 });
|
|
208
|
+
const cert = await readFile(certFile, "utf8");
|
|
209
|
+
if (!cert.includes("-cert-v01@openssh.com")) {
|
|
210
|
+
throw new Error("ssh-keygen did not produce a certificate");
|
|
211
|
+
}
|
|
212
|
+
return cert;
|
|
213
|
+
} finally {
|
|
214
|
+
await rm(tmp, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private nextSerial(): bigint {
|
|
219
|
+
this.serialCounter += 1n;
|
|
220
|
+
return BigInt(Date.now()) * 1000n + (this.serialCounter % 1000n);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Validates and trims a public key posted by the browser. Throws on anything suspect. */
|
|
225
|
+
export function normalisePublicKey(raw: string): string {
|
|
226
|
+
if (raw.length > MAX_PUBKEY_BYTES) throw new Error("public key too large");
|
|
227
|
+
|
|
228
|
+
const line = raw.trim();
|
|
229
|
+
if (line.includes("\n") || line.includes("\r"))
|
|
230
|
+
throw new Error("public key must be a single line");
|
|
231
|
+
|
|
232
|
+
const [type, blob] = line.split(/\s+/, 2);
|
|
233
|
+
if (!type || !blob) throw new Error("malformed public key");
|
|
234
|
+
if (type.includes("-cert-v01@openssh.com"))
|
|
235
|
+
throw new Error("refusing to certify a certificate");
|
|
236
|
+
if (!SIGNABLE_KEY_TYPE.test(type))
|
|
237
|
+
throw new Error(`unsupported key type: ${type}`);
|
|
238
|
+
if (!/^[A-Za-z0-9+/]+={0,3}$/.test(blob))
|
|
239
|
+
throw new Error("malformed public key body");
|
|
240
|
+
|
|
241
|
+
// The base64 blob must decode to a key whose embedded type matches the
|
|
242
|
+
// declared one — otherwise a caller could mislabel a key to slip past the
|
|
243
|
+
// type check above.
|
|
244
|
+
const decoded = Buffer.from(blob, "base64");
|
|
245
|
+
if (decoded.length < 4) throw new Error("malformed public key body");
|
|
246
|
+
const nameLength = decoded.readUInt32BE(0);
|
|
247
|
+
if (nameLength > 64 || decoded.length < 4 + nameLength)
|
|
248
|
+
throw new Error("malformed public key body");
|
|
249
|
+
const embedded = decoded.subarray(4, 4 + nameLength).toString("ascii");
|
|
250
|
+
if (embedded !== type)
|
|
251
|
+
throw new Error("public key type does not match its body");
|
|
252
|
+
|
|
253
|
+
// Re-emit without any client-supplied comment.
|
|
254
|
+
return `${type} ${blob}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Finds this host's SSH host key so the browser can pin it and skip the
|
|
259
|
+
* trust-on-first-use prompt.
|
|
260
|
+
*
|
|
261
|
+
* The order matters: it mirrors the order golang.org/x/crypto/ssh offers host
|
|
262
|
+
* key algorithms, so the key we pin is the one sshd will actually present.
|
|
263
|
+
* If none is readable we return null and the browser falls back to prompting.
|
|
264
|
+
*/
|
|
265
|
+
/**
|
|
266
|
+
* OpenSSH-style SHA256 fingerprint of a public key line.
|
|
267
|
+
*
|
|
268
|
+
* Computed here rather than in the browser because `crypto.subtle` does not
|
|
269
|
+
* exist on a non-secure origin, and plain HTTP on a bare IP — the default way
|
|
270
|
+
* this thing gets run — is exactly that.
|
|
271
|
+
*/
|
|
272
|
+
export function fingerprint(publicKeyLine: string): string | null {
|
|
273
|
+
const blob = publicKeyLine.trim().split(/\s+/)[1];
|
|
274
|
+
if (!blob) return null;
|
|
275
|
+
try {
|
|
276
|
+
const digest = createHash("sha256")
|
|
277
|
+
.update(Buffer.from(blob, "base64"))
|
|
278
|
+
.digest("base64");
|
|
279
|
+
return `SHA256:${digest.replace(/=+$/, "")}`;
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Host key types in the order the client will actually pick them.
|
|
287
|
+
*
|
|
288
|
+
* This is golang.org/x/crypto/ssh's `supportedHostKeyAlgos` order, which puts
|
|
289
|
+
* ECDSA ahead of Ed25519 — the opposite of what OpenSSH does, and the opposite
|
|
290
|
+
* of what looks reasonable. Getting it wrong is not harmless: pinning a key the
|
|
291
|
+
* server holds but does not present makes every pane report the host key as
|
|
292
|
+
* *changed*, which reads like an attack rather than a misconfiguration.
|
|
293
|
+
*/
|
|
294
|
+
const HOST_KEY_TYPES = ["ecdsa", "rsa", "ed25519"] as const;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Finds the host key the SSH target will actually present, so the browser can
|
|
298
|
+
* pin it and skip the trust-on-first-use prompt.
|
|
299
|
+
*
|
|
300
|
+
* Asking the target with ssh-keyscan beats reading /etc/ssh/*.pub: it works
|
|
301
|
+
* when the bridge points somewhere other than this machine, it needs no file
|
|
302
|
+
* permissions, and it cannot pin a key that sshd is not actually serving.
|
|
303
|
+
* Reading the local files stays as a fallback for when ssh-keyscan is missing.
|
|
304
|
+
*
|
|
305
|
+
* The type order matters — the pinned key has to be the one that wins
|
|
306
|
+
* negotiation, not merely one the server happens to hold. Returns null if
|
|
307
|
+
* nothing is discoverable, and the browser prompts once instead.
|
|
308
|
+
*/
|
|
309
|
+
export async function discoverHostKey(
|
|
310
|
+
host: string,
|
|
311
|
+
port: number,
|
|
312
|
+
): Promise<string | null> {
|
|
313
|
+
for (const type of HOST_KEY_TYPES) {
|
|
314
|
+
try {
|
|
315
|
+
const { stdout } = await run(
|
|
316
|
+
"ssh-keyscan",
|
|
317
|
+
["-t", type, "-p", String(port), "-T", "5", host],
|
|
318
|
+
{
|
|
319
|
+
timeout: 10_000,
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
// Output is "<host> <type> <base64>". The leading host field is not part
|
|
323
|
+
// of the key, and sshterm keys its known-hosts entry by endpoint name.
|
|
324
|
+
const line = stdout
|
|
325
|
+
.split("\n")
|
|
326
|
+
.find((l) => l.trim() && !l.startsWith("#"));
|
|
327
|
+
const parts = line?.trim().split(/\s+/);
|
|
328
|
+
if (parts && parts.length >= 3) return `${parts[1]} ${parts[2]}`;
|
|
329
|
+
} catch {
|
|
330
|
+
// server does not offer this type, or no ssh-keyscan — try the next
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Reading this box's own host keys only answers the question when the bridge
|
|
335
|
+
// points at this box. With --ssh-host aimed elsewhere it would pin the wrong
|
|
336
|
+
// machine's key, and every pane would then report the host key as changed —
|
|
337
|
+
// which reads as an attack rather than the misconfiguration it is.
|
|
338
|
+
if (!isLocalHost(host)) return null;
|
|
339
|
+
|
|
340
|
+
for (const type of HOST_KEY_TYPES) {
|
|
341
|
+
try {
|
|
342
|
+
const line = (await readFile(`/etc/ssh/ssh_host_${type}_key.pub`, "utf8"))
|
|
343
|
+
.trim()
|
|
344
|
+
.split("\n")[0]
|
|
345
|
+
?.trim();
|
|
346
|
+
if (line) return line;
|
|
347
|
+
} catch {
|
|
348
|
+
// unreadable or absent — try the next
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Does this name refer to the machine we are running on? */
|
|
355
|
+
function isLocalHost(host: string): boolean {
|
|
356
|
+
const h = host
|
|
357
|
+
.trim()
|
|
358
|
+
.toLowerCase()
|
|
359
|
+
.replace(/^\[|\]$/g, "");
|
|
360
|
+
return (
|
|
361
|
+
h === "localhost" ||
|
|
362
|
+
h === "127.0.0.1" ||
|
|
363
|
+
h === "::1" ||
|
|
364
|
+
h === "0.0.0.0" ||
|
|
365
|
+
h === "" ||
|
|
366
|
+
h === os.hostname().toLowerCase()
|
|
367
|
+
);
|
|
368
|
+
}
|
package/server/static.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { BUILD_ID, EMBEDDED } from "./assets.generated.ts";
|
|
3
|
+
import { IMMUTABLE_CACHE_CONTROL } from "./http.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Serves the built web app.
|
|
7
|
+
*
|
|
8
|
+
* Two things earn their keep here: precompressed variants (ssh.wasm is ~20MB
|
|
9
|
+
* raw and about a fifth of that as brotli, and it is on the critical path of
|
|
10
|
+
* the very first page load), and an SPA fallback so deep links work.
|
|
11
|
+
*
|
|
12
|
+
* Assets resolve from the embedded manifest first, then from disk. In a
|
|
13
|
+
* compiled binary the manifest holds everything and the disk is never touched;
|
|
14
|
+
* under `bun run` the manifest is empty and everything comes off disk. Both
|
|
15
|
+
* paths end at Bun.file, so there is only one code path to get right.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Vite writes content-hashed filenames into assets/, so those cache forever. */
|
|
19
|
+
const IMMUTABLE = /\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/;
|
|
20
|
+
|
|
21
|
+
const MIME: Record<string, string> = {
|
|
22
|
+
".html": "text/html; charset=utf-8",
|
|
23
|
+
".js": "text/javascript; charset=utf-8",
|
|
24
|
+
".css": "text/css; charset=utf-8",
|
|
25
|
+
".json": "application/json; charset=utf-8",
|
|
26
|
+
// Required for WebAssembly.instantiateStreaming to take the fast path.
|
|
27
|
+
".wasm": "application/wasm",
|
|
28
|
+
".svg": "image/svg+xml",
|
|
29
|
+
".png": "image/png",
|
|
30
|
+
".jpg": "image/jpeg",
|
|
31
|
+
".jpeg": "image/jpeg",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".avif": "image/avif",
|
|
34
|
+
".gif": "image/gif",
|
|
35
|
+
".ico": "image/x-icon",
|
|
36
|
+
".woff2": "font/woff2",
|
|
37
|
+
".txt": "text/plain; charset=utf-8",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function cacheControl(urlPath: string): string {
|
|
41
|
+
if (IMMUTABLE.test(urlPath)) return IMMUTABLE_CACHE_CONTROL;
|
|
42
|
+
if (urlPath.endsWith(".html") || urlPath === "/") return "no-cache";
|
|
43
|
+
// ssh.wasm and wasm_exec.js are big but do change on upgrade: let the client
|
|
44
|
+
// keep them and revalidate cheaply with an ETag.
|
|
45
|
+
return "public, max-age=0, must-revalidate";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function encodingsFor(req: Request): ("br" | "gzip")[] {
|
|
49
|
+
const accept = req.headers.get("accept-encoding") ?? "";
|
|
50
|
+
const out: ("br" | "gzip")[] = [];
|
|
51
|
+
if (/\bbr\b/.test(accept)) out.push("br");
|
|
52
|
+
if (/\bgzip\b/.test(accept)) out.push("gzip");
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SUFFIX = { br: ".br", gzip: ".gz" } as const;
|
|
57
|
+
|
|
58
|
+
export function createStaticServer(webRoot: string) {
|
|
59
|
+
const root = path.resolve(webRoot);
|
|
60
|
+
|
|
61
|
+
/** Resolves a repo-relative asset path to something Bun.file can open. */
|
|
62
|
+
const locate = async (relative: string): Promise<string | null> => {
|
|
63
|
+
const embedded = EMBEDDED[`/${relative}`];
|
|
64
|
+
if (embedded) return embedded;
|
|
65
|
+
|
|
66
|
+
const onDisk = path.resolve(root, relative);
|
|
67
|
+
// path.resolve collapses "..", so one containment check is enough.
|
|
68
|
+
if (onDisk !== root && !onDisk.startsWith(root + path.sep)) return null;
|
|
69
|
+
return (await Bun.file(onDisk).exists()) ? onDisk : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return async function serveStatic(
|
|
73
|
+
req: Request,
|
|
74
|
+
urlPath: string,
|
|
75
|
+
): Promise<Response | null> {
|
|
76
|
+
if (req.method !== "GET" && req.method !== "HEAD") return null;
|
|
77
|
+
|
|
78
|
+
let decoded: string;
|
|
79
|
+
try {
|
|
80
|
+
decoded = decodeURIComponent(urlPath);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (decoded.includes("\0")) return null;
|
|
85
|
+
|
|
86
|
+
let relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
87
|
+
let resolved = await locate(relative);
|
|
88
|
+
// What the cache policy is decided against. It follows the file actually
|
|
89
|
+
// served rather than the path asked for, so an SPA route gets index.html's
|
|
90
|
+
// `no-cache` instead of the extensionless default of `must-revalidate`.
|
|
91
|
+
let served = urlPath;
|
|
92
|
+
|
|
93
|
+
// SPA fallback: anything that is not a real file and does not look like an
|
|
94
|
+
// asset request gets index.html.
|
|
95
|
+
if (!resolved) {
|
|
96
|
+
if (path.extname(relative) !== "") return null;
|
|
97
|
+
relative = "index.html";
|
|
98
|
+
resolved = await locate(relative);
|
|
99
|
+
if (!resolved) return null;
|
|
100
|
+
served = "/index.html";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const type =
|
|
104
|
+
MIME[path.extname(relative).toLowerCase()] ?? "application/octet-stream";
|
|
105
|
+
|
|
106
|
+
let encoding: "br" | "gzip" | null = null;
|
|
107
|
+
let body = Bun.file(resolved);
|
|
108
|
+
for (const candidate of encodingsFor(req)) {
|
|
109
|
+
const alt = await locate(`${relative}${SUFFIX[candidate]}`);
|
|
110
|
+
if (alt) {
|
|
111
|
+
encoding = candidate;
|
|
112
|
+
body = Bun.file(alt);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// `.size` works for both real files and files embedded in a compiled
|
|
118
|
+
// binary; `.stat()` returns undefined for the latter. Embedded assets have
|
|
119
|
+
// no mtime either, so BUILD_ID stands in as the thing that changes when the
|
|
120
|
+
// bytes do. The encoding is folded in so switching between br/gzip/identity
|
|
121
|
+
// can never reuse a cached body of the wrong shape.
|
|
122
|
+
const size = body.size;
|
|
123
|
+
let version = BUILD_ID;
|
|
124
|
+
if (!EMBEDDED[`/${relative}`]) {
|
|
125
|
+
const st = await Promise.resolve(body.stat()).catch(() => null);
|
|
126
|
+
if (st) version = Math.floor(st.mtimeMs).toString(16);
|
|
127
|
+
}
|
|
128
|
+
const etag = `W/"${size.toString(16)}-${version}${encoding ? `-${encoding}` : ""}"`;
|
|
129
|
+
|
|
130
|
+
const headers = new Headers({
|
|
131
|
+
"content-type": type,
|
|
132
|
+
"cache-control": cacheControl(served),
|
|
133
|
+
etag,
|
|
134
|
+
vary: "Accept-Encoding",
|
|
135
|
+
});
|
|
136
|
+
if (encoding) headers.set("content-encoding", encoding);
|
|
137
|
+
|
|
138
|
+
if (req.headers.get("if-none-match") === etag) {
|
|
139
|
+
return new Response(null, { status: 304, headers });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
headers.set("content-length", String(size));
|
|
143
|
+
if (req.method === "HEAD")
|
|
144
|
+
return new Response(null, { status: 200, headers });
|
|
145
|
+
return new Response(body, { status: 200, headers });
|
|
146
|
+
};
|
|
147
|
+
}
|