@dadigua/web-terminal 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,772 @@
1
+ import {
2
+ serviceName,
3
+ serviceProtocol,
4
+ serviceVersion
5
+ } from "./chunk-TD3NJESY.js";
6
+
7
+ // src/server/app.ts
8
+ import express from "express";
9
+ import { createServer } from "node:http";
10
+ import { mkdirSync, chmodSync } from "node:fs";
11
+ import { join as join3, resolve as resolve2 } from "node:path";
12
+ import { WebSocketServer } from "ws";
13
+ import multer from "multer";
14
+ import sharp2 from "sharp";
15
+
16
+ // src/server/auth.ts
17
+ import { createHash, timingSafeEqual } from "node:crypto";
18
+ function validToken(candidate, token) {
19
+ if (typeof candidate !== "string" || candidate.length > 1024) return false;
20
+ const hash = (v) => createHash("sha256").update(v).digest();
21
+ return timingSafeEqual(hash(candidate), hash(token));
22
+ }
23
+ function authenticate(token) {
24
+ const failures = /* @__PURE__ */ new Map();
25
+ return (req, res, next) => {
26
+ res.setHeader("Cache-Control", "no-store");
27
+ const key = req.socket.remoteAddress || "unknown";
28
+ const now = Date.now();
29
+ for (const [ip, failure2] of failures) if (failure2.until < now) failures.delete(ip);
30
+ const failure = failures.get(key);
31
+ if (failure && failure.count >= 30) {
32
+ res.status(429).json({ error: "\u5C1D\u8BD5\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5" });
33
+ return;
34
+ }
35
+ if (!validToken(req.headers.authorization?.match(/^Bearer (.+)$/)?.[1], token)) {
36
+ failures.set(key, { count: (failure?.count || 0) + 1, until: failure?.until || now + 6e4 });
37
+ res.status(401).json({ error: "token \u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u8F93\u5165" });
38
+ return;
39
+ }
40
+ failures.delete(key);
41
+ next();
42
+ };
43
+ }
44
+
45
+ // src/server/files.ts
46
+ import { constants } from "node:fs";
47
+ import { mkdir, readdir, open, realpath, unlink, writeFile, appendFile, readFile, rm } from "node:fs/promises";
48
+ import { resolve, basename, dirname, join } from "node:path";
49
+ import { randomBytes } from "node:crypto";
50
+ import { parse, stringify } from "yaml";
51
+ import sharp from "sharp";
52
+ var HttpError = class extends Error {
53
+ constructor(status, message) {
54
+ super(message);
55
+ this.status = status;
56
+ }
57
+ status;
58
+ };
59
+ var Files = class {
60
+ constructor(config) {
61
+ this.config = config;
62
+ }
63
+ config;
64
+ async directory(path) {
65
+ const canonical = await realpath(resolve(path));
66
+ const handle = await open(canonical, constants.O_RDONLY);
67
+ try {
68
+ if (!(await handle.stat()).isDirectory()) throw new HttpError(400, "\u5DE5\u4F5C\u76EE\u5F55\u65E0\u6548");
69
+ } finally {
70
+ await handle.close();
71
+ }
72
+ return canonical;
73
+ }
74
+ async directories(path) {
75
+ const cwd = await this.directory(path);
76
+ const entries = await readdir(cwd, { withFileTypes: true });
77
+ const folders = await Promise.all(entries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink()).map(async (entry) => {
78
+ try {
79
+ return { name: entry.name, path: await this.directory(join(cwd, entry.name)) };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }));
84
+ const parent = dirname(cwd);
85
+ return {
86
+ path: cwd,
87
+ parent: parent !== cwd ? parent : null,
88
+ entries: folders.filter((entry) => entry !== null).sort((a, b) => a.name.localeCompare(b.name))
89
+ };
90
+ }
91
+ async read(cwd, path, base) {
92
+ if (!path || path.includes("\0") || path.length > 8192) throw new HttpError(400, "\u6587\u4EF6\u8DEF\u5F84\u65E0\u6548");
93
+ const expanded = path.startsWith("~/") ? join(process.env.HOME || cwd, path.slice(2)) : path;
94
+ const canonical = await realpath(resolve(base || cwd, expanded));
95
+ const handle = await open(canonical, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
96
+ try {
97
+ const stat = await handle.stat();
98
+ if (!stat.isFile()) throw new HttpError(400, "\u53EA\u80FD\u9884\u89C8\u666E\u901A\u6587\u4EF6");
99
+ const head = Buffer.alloc(16);
100
+ await handle.read(head, 0, 16, 0);
101
+ const mime = imageMime(head);
102
+ const info = { path: canonical, name: basename(canonical), size: stat.size, mime: mime || "application/octet-stream", isImage: !!mime };
103
+ if (mime && stat.size <= 50 * 1024 * 1024) {
104
+ const bytes = await handle.readFile();
105
+ const metadata = await sharp(bytes, { limitInputPixels: 64e6 }).metadata();
106
+ info.width = metadata.width;
107
+ info.height = metadata.height;
108
+ return { handle, info, bytes };
109
+ }
110
+ return { handle, info, bytes: void 0 };
111
+ } catch (error) {
112
+ await handle.close();
113
+ throw error;
114
+ }
115
+ }
116
+ async upload(sessionId, temporary, originalName) {
117
+ try {
118
+ const handle = await open(temporary, "r");
119
+ let bytes;
120
+ try {
121
+ bytes = await handle.readFile();
122
+ } finally {
123
+ await handle.close();
124
+ }
125
+ const mime = imageMime(bytes);
126
+ if (!mime) throw new HttpError(415, "\u652F\u6301 PNG\u3001JPEG\u3001WebP \u548C GIF \u56FE\u7247");
127
+ const meta = await sharp(bytes, { limitInputPixels: 64e6 }).metadata().catch(() => {
128
+ throw new HttpError(415, "\u56FE\u7247\u65E0\u6CD5\u89E3\u7801\u6216\u50CF\u7D20\u6570\u8D85\u8FC7\u9650\u5236");
129
+ });
130
+ if (!meta.width || !meta.height) throw new HttpError(415, "\u56FE\u7247\u65E0\u6CD5\u89E3\u7801");
131
+ const folder = join(this.config.dataDir, "uploads", sessionId);
132
+ await mkdir(folder, { recursive: true, mode: 448 });
133
+ await mkdir(this.config.uploadDir, { recursive: true, mode: 448 });
134
+ const ext = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif" }[mime];
135
+ let path;
136
+ for (; ; ) {
137
+ path = join(this.config.uploadDir, `${randomBytes(6).toString("hex")}${ext}`);
138
+ try {
139
+ await writeFile(path, bytes, { flag: "wx", mode: 384 });
140
+ break;
141
+ } catch (error) {
142
+ if (error.code !== "EEXIST") throw error;
143
+ }
144
+ }
145
+ try {
146
+ await appendFile(join(folder, "files.yaml"), stringify([path]), { mode: 384 });
147
+ } catch (error) {
148
+ await unlink(path);
149
+ throw error;
150
+ }
151
+ return { path, name: basename(originalName).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 160) || basename(path), size: bytes.length, mime, isImage: true, width: meta.width, height: meta.height };
152
+ } finally {
153
+ await unlink(temporary).catch(() => {
154
+ });
155
+ }
156
+ }
157
+ async removeUploads(sessionId) {
158
+ const folder = join(this.config.dataDir, "uploads", sessionId);
159
+ let paths;
160
+ try {
161
+ paths = parse(await readFile(join(folder, "files.yaml"), "utf8"));
162
+ } catch (error) {
163
+ if (error.code !== "ENOENT") throw error;
164
+ }
165
+ if (Array.isArray(paths)) {
166
+ await Promise.all(paths.filter((path) => typeof path === "string" && /^[a-f0-9]{12}\.(?:png|jpg|webp|gif)$/.test(basename(path))).map((path) => rm(path, { force: true })));
167
+ }
168
+ await rm(folder, { recursive: true, force: true });
169
+ }
170
+ };
171
+ function imageMime(bytes) {
172
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
173
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
174
+ if (/^GIF8[79]a/.test(bytes.subarray(0, 6).toString())) return "image/gif";
175
+ if (bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP") return "image/webp";
176
+ }
177
+
178
+ // src/server/sessions.ts
179
+ import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
180
+ import { join as join2 } from "node:path";
181
+ import { randomUUID } from "node:crypto";
182
+ import { parse as parse2, stringify as stringify2 } from "yaml";
183
+ import * as pty from "node-pty";
184
+
185
+ // src/shared/terminalInput.ts
186
+ function pasteText(text, bracketedPaste) {
187
+ const normalized = text.replace(/\r?\n/g, "\r");
188
+ return bracketedPaste ? `\x1B[200~${normalized}\x1B[201~` : normalized;
189
+ }
190
+
191
+ // src/server/processInfo.ts
192
+ import { readFile as readFile2, readlink } from "node:fs/promises";
193
+ import { basename as basename2 } from "node:path";
194
+ async function foregroundInfo(pid, fallback) {
195
+ let foreground = pid;
196
+ try {
197
+ const stat = await readFile2(`/proc/${pid}/stat`, "utf8");
198
+ const group = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[5]);
199
+ if (Number.isInteger(group) && group > 0) foreground = group;
200
+ } catch {
201
+ }
202
+ let processName = basename2(fallback).replace(/^-/, "");
203
+ let cwd;
204
+ try {
205
+ cwd = await readlink(`/proc/${foreground}/cwd`);
206
+ } catch {
207
+ }
208
+ try {
209
+ const executable = await readlink(`/proc/${foreground}/exe`);
210
+ if (/\/claude\/versions\//.test(executable)) processName = "claude";
211
+ else if (basename2(executable) === "codex") processName = "codex";
212
+ else if (/^(node|nodejs|bun)$/.test(basename2(executable))) {
213
+ const argv = (await readFile2(`/proc/${foreground}/cmdline`, "utf8")).split("\0", 2);
214
+ if (/[/\\](?:@anthropic-ai[/\\]claude-code|claude-code)[/\\]/.test(argv[1] || "")) processName = "claude";
215
+ else if (/[/\\]@openai[/\\]codex[/\\]/.test(argv[1] || "")) processName = "codex";
216
+ }
217
+ } catch {
218
+ }
219
+ return { processName, cwd };
220
+ }
221
+
222
+ // src/server/escapeTail.ts
223
+ var EscapeTail = class {
224
+ state = "text";
225
+ osc = false;
226
+ escaped = false;
227
+ tail = "";
228
+ overflow = false;
229
+ push(data) {
230
+ for (const char of data) {
231
+ if (char === "" || char === "") {
232
+ this.reset();
233
+ continue;
234
+ }
235
+ if (this.state === "text") {
236
+ if (char === "\x1B") {
237
+ this.state = "escape";
238
+ this.tail = char;
239
+ } else if (char === "\x9B") {
240
+ this.state = "csi";
241
+ this.tail = char;
242
+ } else if (["\x90", "\x9D", "\x98", "\x9E", "\x9F"].includes(char)) {
243
+ this.state = "string";
244
+ this.osc = char === "\x9D";
245
+ this.tail = char;
246
+ }
247
+ continue;
248
+ }
249
+ if (this.tail.length < 65536) this.tail += char;
250
+ else this.overflow = true;
251
+ if (this.state === "string") {
252
+ if (char === "\x9C" || this.osc && char === "\x07" || this.escaped && char === "\\") this.reset();
253
+ else this.escaped = char === "\x1B";
254
+ } else if (char === "\x1B") {
255
+ this.state = "escape";
256
+ this.tail = char;
257
+ this.overflow = false;
258
+ } else if (this.state === "escape") {
259
+ if (char === "[") this.state = "csi";
260
+ else if ("]PX^_".includes(char)) {
261
+ this.state = "string";
262
+ this.osc = char === "]";
263
+ } else if (char >= "0" && char <= "~") this.reset();
264
+ } else if (char >= "@" && char <= "~") this.reset();
265
+ }
266
+ }
267
+ value() {
268
+ if (this.overflow) throw new Error("\u63A7\u5236\u5E8F\u5217\u5C1A\u672A\u7ED3\u675F\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8FDE");
269
+ return this.tail;
270
+ }
271
+ reset() {
272
+ this.state = "text";
273
+ this.tail = "";
274
+ this.osc = false;
275
+ this.escaped = false;
276
+ this.overflow = false;
277
+ }
278
+ };
279
+
280
+ // src/server/terminalScreen.ts
281
+ import headless from "@xterm/headless";
282
+ import serialize from "@xterm/addon-serialize";
283
+ var TerminalScreen = class {
284
+ terminal = new headless.Terminal({ cols: 100, rows: 30, scrollback: 2e3, allowProposedApi: true });
285
+ tail = new EscapeTail();
286
+ serializer = new serialize.SerializeAddon();
287
+ queue = Promise.resolve();
288
+ closed = false;
289
+ cursorVisible = true;
290
+ mouseEncoding = 0;
291
+ margins = [1, 30];
292
+ constructor(onTitle, onResponse) {
293
+ this.terminal.loadAddon(this.serializer);
294
+ this.terminal.onTitleChange((title) => onTitle(title.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 512)));
295
+ this.terminal.onData(onResponse);
296
+ for (const [code, color] of [[10, "d8d8/e3e3/e8e8"], [11, "1010/1818/2121"]]) {
297
+ this.terminal.parser.registerOscHandler(code, (data) => {
298
+ if (data !== "?") return false;
299
+ onResponse(`\x1B]${code};rgb:${color}\x1B\\`);
300
+ return true;
301
+ });
302
+ }
303
+ for (const final of ["h", "l"]) this.terminal.parser.registerCsiHandler({ prefix: "?", final }, (params) => {
304
+ if (params.includes(25)) this.cursorVisible = final === "h";
305
+ for (const mode of params) if (mode === 1006 || mode === 1016) this.mouseEncoding = final === "h" ? mode : 0;
306
+ return false;
307
+ });
308
+ this.terminal.parser.registerCsiHandler({ final: "r" }, (params) => {
309
+ const top = Number(params[0]) || 1, bottom = Number(params[1]) || this.terminal.rows;
310
+ if (top < bottom && bottom <= this.terminal.rows) this.margins = [top, bottom];
311
+ return false;
312
+ });
313
+ this.terminal.parser.registerEscHandler({ final: "c" }, () => {
314
+ this.cursorVisible = true;
315
+ this.mouseEncoding = 0;
316
+ this.margins = [1, this.terminal.rows];
317
+ return false;
318
+ });
319
+ }
320
+ run(operation) {
321
+ const task = this.queue.then(() => {
322
+ if (!this.closed) return operation();
323
+ });
324
+ this.queue = task.catch(() => {
325
+ });
326
+ return task;
327
+ }
328
+ write(data, after) {
329
+ return this.run(() => new Promise((resolve3, reject) => this.terminal.write(data, () => {
330
+ try {
331
+ this.tail.push(data);
332
+ after();
333
+ resolve3();
334
+ } catch (error) {
335
+ reject(error);
336
+ }
337
+ })));
338
+ }
339
+ resize(cols, rows) {
340
+ this.terminal.resize(cols, rows);
341
+ this.margins = [1, rows];
342
+ }
343
+ snapshot() {
344
+ const term = this.terminal;
345
+ const row = term.buffer.active.cursorY + 1 - (term.modes.originMode ? this.margins[0] - 1 : 0);
346
+ const col = term.buffer.active.cursorX + 1;
347
+ return {
348
+ cols: term.cols,
349
+ rows: term.rows,
350
+ data: this.serializer.serialize() + (this.mouseEncoding ? `\x1B[?${this.mouseEncoding}h` : "") + (this.margins[0] !== 1 || this.margins[1] !== term.rows ? `\x1B[${this.margins[0]};${this.margins[1]}r\x1B[${row};${col}H` : "") + `\x1B[?25${this.cursorVisible ? "h" : "l"}` + this.tail.value()
351
+ };
352
+ }
353
+ async dispose() {
354
+ await this.queue;
355
+ this.closed = true;
356
+ this.terminal.dispose();
357
+ }
358
+ };
359
+
360
+ // src/server/sessions.ts
361
+ function send(ws, message) {
362
+ if (ws.readyState !== 1) return;
363
+ if (ws.bufferedAmount > 4 * 1024 * 1024) {
364
+ ws.close(1013, "\u7EC8\u7AEF\u8F93\u51FA\u8FC7\u5FEB\uFF0C\u8BF7\u91CD\u65B0\u8FDE\u63A5");
365
+ return;
366
+ }
367
+ ws.send(JSON.stringify(message));
368
+ }
369
+ var Sessions = class {
370
+ constructor(config, files) {
371
+ this.config = config;
372
+ this.files = files;
373
+ const saved = join2(config.dataDir, "sessions.yaml");
374
+ if (existsSync(saved)) {
375
+ const records = parse2(readFileSync(saved, "utf8"));
376
+ if (!Array.isArray(records)) throw new Error("sessions.yaml \u683C\u5F0F\u65E0\u6548");
377
+ for (const record of records) {
378
+ if (!/^[a-f0-9-]{36}$/.test(record.id)) continue;
379
+ const { id, name, cwd, createdAt } = record;
380
+ const info = { id, name, cwd, createdAt, running: false };
381
+ this.entries.set(info.id, this.makeSession(info));
382
+ }
383
+ }
384
+ }
385
+ config;
386
+ files;
387
+ entries = /* @__PURE__ */ new Map();
388
+ shuttingDown = false;
389
+ makeSession(info) {
390
+ const session = { info, clients: /* @__PURE__ */ new Map(), pendingBytes: 0, screen: new TerminalScreen(
391
+ (title) => {
392
+ info.title = title;
393
+ },
394
+ (data) => session.process?.write(data)
395
+ ) };
396
+ return session;
397
+ }
398
+ save() {
399
+ const file = join2(this.config.dataDir, "sessions.yaml");
400
+ writeFileSync(`${file}.tmp`, stringify2([...this.entries.values()].map((s) => s.info)), { mode: 384 });
401
+ renameSync(`${file}.tmp`, file);
402
+ }
403
+ get(id) {
404
+ const session = this.entries.get(id);
405
+ if (!session) throw new HttpError(404, "\u4F1A\u8BDD\u4E0D\u5B58\u5728");
406
+ return session;
407
+ }
408
+ async current(id) {
409
+ const session = this.get(id);
410
+ if (session.info.running) {
411
+ try {
412
+ const foreground = await foregroundInfo(session.process.pid, session.process.process);
413
+ if (foreground.cwd) session.info.cwd = foreground.cwd;
414
+ session.info.processName = foreground.processName;
415
+ } catch {
416
+ }
417
+ }
418
+ return { ...session.info };
419
+ }
420
+ async list() {
421
+ return Promise.all([...this.entries.keys()].map((id) => this.current(id)));
422
+ }
423
+ async create(name, cwd) {
424
+ if (this.entries.size >= 32) throw new HttpError(409, "\u6700\u591A\u4FDD\u7559 32 \u4E2A\u4F1A\u8BDD\uFF0C\u8BF7\u7ED3\u675F\u4E0D\u9700\u8981\u7684\u4F1A\u8BDD");
425
+ if (name !== void 0 && (typeof name !== "string" || name.length > 80)) throw new HttpError(400, "\u4F1A\u8BDD\u540D\u79F0\u6700\u591A 80 \u5B57\u7B26");
426
+ if (cwd !== void 0 && typeof cwd !== "string") throw new HttpError(400, "\u5DE5\u4F5C\u76EE\u5F55\u65E0\u6548");
427
+ const dir = await this.files.directory(cwd || this.config.defaultCwd);
428
+ const id = randomUUID();
429
+ const info = { id, name: name?.trim() || `\u7EC8\u7AEF ${this.entries.size + 1}`, cwd: dir, createdAt: (/* @__PURE__ */ new Date()).toISOString(), running: true };
430
+ const session = this.makeSession(info);
431
+ try {
432
+ this.entries.set(id, session);
433
+ this.ensureProcess(session);
434
+ } catch (error) {
435
+ this.entries.delete(id);
436
+ void session.screen.dispose();
437
+ throw error;
438
+ }
439
+ this.save();
440
+ return info;
441
+ }
442
+ spawnTerminal(command, args, cwd, cols, rows) {
443
+ const env = {};
444
+ for (const [key, value] of Object.entries(process.env)) if (value !== void 0 && !["WEB_TERMINAL_TOKEN", "TMUX", "TMUX_PANE"].includes(key)) env[key] = value;
445
+ env.TERM = "xterm-256color";
446
+ env.COLORTERM = "truecolor";
447
+ return pty.spawn(command, args, { name: "xterm-256color", cols, rows, cwd, env });
448
+ }
449
+ dimensions(cols, rows) {
450
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 2 || rows < 2) throw new HttpError(400, "\u7EC8\u7AEF\u5C3A\u5BF8\u65E0\u6548");
451
+ return { cols: Math.min(500, cols), rows: Math.min(300, rows) };
452
+ }
453
+ ensureProcess(session) {
454
+ if (session.process || !session.info.running) return;
455
+ const shellArgs = /(?:^|[/\\])(?:powershell|pwsh)(?:\.exe)?$/i.test(this.config.shell) ? ["-NoLogo"] : /(?:^|[/\\])cmd(?:\.exe)?$/i.test(this.config.shell) ? ["/Q"] : ["-l"];
456
+ const terminalProcess = this.spawnTerminal(this.config.shell, shellArgs, session.info.cwd, 100, 30);
457
+ session.process = terminalProcess;
458
+ terminalProcess.onData((data) => {
459
+ session.pendingBytes += data.length;
460
+ if (session.pendingBytes > 256 * 1024) terminalProcess.pause();
461
+ void session.screen.write(data, () => {
462
+ for (const ws of session.clients.keys()) send(ws, { type: "output", data });
463
+ session.pendingBytes -= data.length;
464
+ if (session.pendingBytes < 64 * 1024) terminalProcess.resume();
465
+ }).catch((error) => console.error("\u7EC8\u7AEF\u753B\u9762\u66F4\u65B0\u5931\u8D25", error));
466
+ });
467
+ terminalProcess.onExit(({ exitCode }) => {
468
+ session.process = void 0;
469
+ if (this.shuttingDown) return;
470
+ void session.screen.run(() => {
471
+ session.info.running = false;
472
+ this.save();
473
+ for (const ws of session.clients.keys()) {
474
+ send(ws, { type: "exit", exitCode });
475
+ ws.close(4404, "\u4F1A\u8BDD\u5DF2\u7ED3\u675F");
476
+ }
477
+ session.clients.clear();
478
+ session.controller = void 0;
479
+ });
480
+ });
481
+ }
482
+ snapshot(session, ws) {
483
+ try {
484
+ send(ws, { type: "snapshot", ...session.screen.snapshot(), controller: ws === session.controller });
485
+ } catch {
486
+ send(ws, { type: "error", message: "\u753B\u9762\u5C1A\u672A\u5C31\u7EEA\uFF0C\u6B63\u5728\u91CD\u65B0\u8FDE\u63A5" });
487
+ ws.close(1013, "\u753B\u9762\u5C1A\u672A\u5C31\u7EEA");
488
+ }
489
+ }
490
+ applySize(session, cols, rows) {
491
+ const term = session.screen.terminal;
492
+ if (term.cols === cols && term.rows === rows) return;
493
+ session.screen.resize(cols, rows);
494
+ session.process?.resize(cols, rows);
495
+ for (const ws of session.clients.keys()) this.snapshot(session, ws);
496
+ }
497
+ async attach(id, ws, cols, rows) {
498
+ const session = this.get(id), size = this.dimensions(cols, rows);
499
+ if (!session.info.running) throw new HttpError(409, "\u4F1A\u8BDD\u5DF2\u7ED3\u675F\uFF0C\u8BF7\u65B0\u5EFA\u7EC8\u7AEF");
500
+ ws.once("close", () => {
501
+ void session.screen.run(() => {
502
+ session.clients.delete(ws);
503
+ if (session.controller !== ws) return;
504
+ session.controller = session.clients.keys().next().value;
505
+ if (session.controller) {
506
+ const next = session.clients.get(session.controller);
507
+ send(session.controller, { type: "control", controller: true });
508
+ this.applySize(session, next.cols, next.rows);
509
+ }
510
+ });
511
+ });
512
+ await this.current(id);
513
+ await session.screen.run(() => {
514
+ if (ws.readyState !== 1) return;
515
+ if (!session.info.running) throw new HttpError(409, "\u4F1A\u8BDD\u5DF2\u7ED3\u675F");
516
+ if (!session.controller) {
517
+ session.controller = ws;
518
+ this.applySize(session, size.cols, size.rows);
519
+ }
520
+ session.clients.set(ws, size);
521
+ this.snapshot(session, ws);
522
+ send(ws, { type: "ready", session: session.info });
523
+ });
524
+ }
525
+ input(id, ws, data) {
526
+ const session = this.get(id);
527
+ void session.screen.run(() => {
528
+ if (session.clients.has(ws)) session.process?.write(data);
529
+ });
530
+ }
531
+ paste(id, ws, text, submit) {
532
+ const session = this.get(id);
533
+ void session.screen.run(() => {
534
+ if (session.clients.has(ws)) session.process?.write(pasteText(text, session.screen.terminal.modes.bracketedPasteMode) + (submit ? "\r" : ""));
535
+ });
536
+ }
537
+ resize(id, ws, cols, rows, claim = false) {
538
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 2 || rows < 2) return;
539
+ const session = this.get(id), size = this.dimensions(cols, rows);
540
+ void session.screen.run(() => {
541
+ if (!session.clients.has(ws)) return;
542
+ session.clients.set(ws, size);
543
+ if (claim && session.controller !== ws) {
544
+ session.controller = ws;
545
+ for (const client of session.clients.keys()) send(client, { type: "control", controller: client === ws });
546
+ }
547
+ if (session.controller === ws) this.applySize(session, size.cols, size.rows);
548
+ });
549
+ }
550
+ async remove(id) {
551
+ const session = this.get(id);
552
+ session.process?.kill();
553
+ for (const ws of session.clients.keys()) ws.close(1e3, "\u4F1A\u8BDD\u5DF2\u7ED3\u675F");
554
+ this.entries.delete(id);
555
+ this.save();
556
+ await session.screen.dispose();
557
+ await this.files.removeUploads(id);
558
+ }
559
+ async shutdown() {
560
+ this.shuttingDown = true;
561
+ this.save();
562
+ for (const session of this.entries.values()) {
563
+ for (const ws of session.clients.keys()) ws.close(1012, "\u670D\u52A1\u91CD\u542F\u4E2D");
564
+ session.process?.kill();
565
+ }
566
+ await Promise.all([...this.entries.values()].map((session) => session.screen.dispose()));
567
+ }
568
+ };
569
+
570
+ // src/server/embedding.ts
571
+ var vscodeFrameAncestors = [
572
+ "'self'",
573
+ "vscode-webview:",
574
+ "vscode-file:",
575
+ "https://*.vscode-cdn.net",
576
+ "https://vscode.dev",
577
+ "https://*.vscode.dev",
578
+ "https://github.dev",
579
+ "https://*.github.dev"
580
+ ].join(" ");
581
+ function embeddingHeaders(path, embed, dev) {
582
+ const embedded = path === "/" && embed === "vscode";
583
+ const ancestors = embedded ? vscodeFrameAncestors : "'none'";
584
+ const headers = {};
585
+ if (!embedded) headers["X-Frame-Options"] = "DENY";
586
+ const rules = dev ? [] : [
587
+ "default-src 'self'",
588
+ "script-src 'self'",
589
+ "style-src 'self' 'unsafe-inline'",
590
+ "img-src 'self' blob: data:",
591
+ "connect-src 'self'",
592
+ "font-src 'self'",
593
+ "object-src 'none'",
594
+ "base-uri 'none'"
595
+ ];
596
+ headers["Content-Security-Policy"] = [...rules, `frame-ancestors ${ancestors}`].join("; ");
597
+ return headers;
598
+ }
599
+
600
+ // src/server/app.ts
601
+ import { randomUUID as randomUUID2 } from "node:crypto";
602
+ import { fileURLToPath } from "node:url";
603
+ async function createApp(config, options = {}) {
604
+ mkdirSync(config.dataDir, { recursive: true, mode: 448 });
605
+ const app = express();
606
+ const instanceId = randomUUID2();
607
+ app.disable("x-powered-by");
608
+ const server = createServer(app);
609
+ const files = new Files(config);
610
+ await files.directory(config.defaultCwd);
611
+ const sessions = new Sessions(config, files);
612
+ const uploadDir = join3(config.dataDir, "staging");
613
+ mkdirSync(uploadDir, { recursive: true, mode: 448 });
614
+ const upload = multer({ dest: uploadDir, limits: { fileSize: config.maxUploadBytes, files: 1, fields: 0 } });
615
+ app.use((req, res, next) => {
616
+ res.setHeader("X-Content-Type-Options", "nosniff");
617
+ res.setHeader("Referrer-Policy", "no-referrer");
618
+ res.set(embeddingHeaders(req.path, req.query.embed, !!options.dev));
619
+ next();
620
+ });
621
+ app.get("/health", (_req, res) => res.set("Cache-Control", "no-store").json({
622
+ service: serviceName,
623
+ version: serviceVersion,
624
+ protocol: serviceProtocol,
625
+ instanceId: options.instanceId ?? instanceId,
626
+ pid: process.pid,
627
+ managed: options.managed ?? false
628
+ }));
629
+ app.use("/api", authenticate(config.token), express.json({ limit: "32kb" }));
630
+ app.get("/api/auth", (_req, res) => res.json({ ok: true }));
631
+ app.get("/api/info", (_req, res) => res.json({ machineId: config.machineId, defaultCwd: config.defaultCwd, maxUploadBytes: config.maxUploadBytes }));
632
+ app.get("/api/directories", async (req, res) => {
633
+ if (req.query.path !== void 0 && (typeof req.query.path !== "string" || req.query.path.length > 8192 || req.query.path.includes("\0"))) throw new HttpError(400, "\u8DEF\u5F84\u53C2\u6570\u65E0\u6548");
634
+ res.json(await files.directories(typeof req.query.path === "string" && req.query.path ? req.query.path : config.defaultCwd));
635
+ });
636
+ app.get("/api/sessions", async (_req, res) => res.json(await sessions.list()));
637
+ app.post("/api/sessions", async (req, res) => res.status(201).json(await sessions.create(req.body?.name, req.body?.cwd)));
638
+ app.delete("/api/sessions/:id", async (req, res) => {
639
+ await sessions.remove(req.params.id);
640
+ res.json({ ok: true });
641
+ });
642
+ app.post("/api/sessions/:id/uploads", (req, _res, next) => {
643
+ sessions.get(String(req.params.id));
644
+ next();
645
+ }, upload.single("file"), async (req, res) => {
646
+ if (!req.file) throw new HttpError(400, "\u8BF7\u9009\u62E9\u56FE\u7247");
647
+ chmodSync(req.file.path, 384);
648
+ const info = await files.upload(String(req.params.id), req.file.path, req.file.originalname);
649
+ res.status(201).json(info);
650
+ });
651
+ app.get("/api/sessions/:id/files/:action", async (req, res) => {
652
+ if (req.params.action !== "meta" && req.params.action !== "content") throw new HttpError(404, "\u63A5\u53E3\u4E0D\u5B58\u5728");
653
+ const session = await sessions.current(req.params.id);
654
+ if (typeof req.query.path !== "string" || req.query.base !== void 0 && typeof req.query.base !== "string") throw new HttpError(400, "\u8DEF\u5F84\u53C2\u6570\u65E0\u6548");
655
+ const result = await files.read(session.cwd, req.query.path, req.query.base);
656
+ if (req.params.action === "meta") {
657
+ await result.handle.close();
658
+ res.json(result.info);
659
+ return;
660
+ }
661
+ if (req.query.thumbnail === "1") {
662
+ await result.handle.close();
663
+ if (!result.info.isImage) throw new HttpError(415, "\u8BE5\u6587\u4EF6\u4E0D\u662F\u652F\u6301\u9884\u89C8\u7684\u56FE\u7247");
664
+ if (!result.bytes) throw new HttpError(413, "\u56FE\u7247\u8FC7\u5927\uFF0C\u8BF7\u4E0B\u8F7D\u67E5\u770B");
665
+ const thumbnail = await sharp2(result.bytes, { limitInputPixels: 64e6 }).rotate().resize({ width: 960, height: 720, fit: "inside", withoutEnlargement: true }).webp({ quality: 82 }).toBuffer();
666
+ res.type("image/webp").send(thumbnail);
667
+ return;
668
+ }
669
+ res.setHeader("Content-Type", result.info.mime);
670
+ res.setHeader("Content-Length", result.info.size);
671
+ if (req.query.download === "1" || !result.info.isImage) res.setHeader("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(result.info.name).replace(/'/g, "%27")}`);
672
+ const stream = result.handle.createReadStream({ start: 0, autoClose: true });
673
+ res.on("close", () => stream.destroy());
674
+ stream.on("error", () => res.destroy());
675
+ stream.pipe(res);
676
+ });
677
+ app.use("/api", (_req, res) => res.status(404).json({ error: "\u63A5\u53E3\u4E0D\u5B58\u5728" }));
678
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 128 * 1024 });
679
+ server.on("upgrade", (req, socket, head) => {
680
+ if (new URL(req.url || "/", "http://localhost").pathname !== "/ws") {
681
+ if (!options.dev) socket.destroy();
682
+ return;
683
+ }
684
+ const origin = req.headers.origin;
685
+ try {
686
+ if (origin && new URL(origin).host !== req.headers.host) {
687
+ socket.destroy();
688
+ return;
689
+ }
690
+ } catch {
691
+ socket.destroy();
692
+ return;
693
+ }
694
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws));
695
+ });
696
+ wss.on("connection", (ws) => {
697
+ let sessionId;
698
+ const timer = setTimeout(() => ws.close(4401, "\u9700\u8981 token"), 5e3);
699
+ ws.once("close", () => clearTimeout(timer));
700
+ ws.on("error", () => {
701
+ });
702
+ ws.on("message", (raw, binary) => {
703
+ try {
704
+ if (binary) throw new Error("\u6D88\u606F\u683C\u5F0F\u65E0\u6548");
705
+ const message = JSON.parse(raw.toString());
706
+ if (!sessionId) {
707
+ if (message.type !== "auth" || !validToken(message.token, config.token)) {
708
+ ws.close(4401, "token \u65E0\u6548");
709
+ return;
710
+ }
711
+ if (message.protocol !== 2) {
712
+ send(ws, { type: "error", message: "\u7EC8\u7AEF\u534F\u8BAE\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u9875\u9762" });
713
+ ws.close(4406, "\u8BF7\u5237\u65B0\u9875\u9762");
714
+ return;
715
+ }
716
+ if (typeof message.sessionId !== "string") throw new Error("\u4F1A\u8BDD\u65E0\u6548");
717
+ const session = sessions.get(message.sessionId);
718
+ sessionId = session.info.id;
719
+ clearTimeout(timer);
720
+ void sessions.attach(sessionId, ws, message.cols, message.rows).catch((error) => {
721
+ send(ws, { type: "error", message: error.message });
722
+ ws.close(4404, "\u4F1A\u8BDD\u4E0D\u53EF\u7528");
723
+ });
724
+ } else if (message.type === "input" && typeof message.data === "string") {
725
+ sessions.input(sessionId, ws, message.data);
726
+ } else if (message.type === "paste" && typeof message.text === "string" && typeof message.submit === "boolean") {
727
+ sessions.paste(sessionId, ws, message.text, message.submit);
728
+ } else if (message.type === "resize" || message.type === "claim") {
729
+ sessions.resize(sessionId, ws, message.cols, message.rows, message.type === "claim");
730
+ } else throw new Error("\u6D88\u606F\u683C\u5F0F\u65E0\u6548");
731
+ } catch (error) {
732
+ send(ws, { type: "error", message: error instanceof Error ? error.message : "\u8BF7\u6C42\u5931\u8D25" });
733
+ ws.close(4404, "\u6D88\u606F\u6216\u4F1A\u8BDD\u4E0D\u53EF\u7528");
734
+ }
735
+ });
736
+ });
737
+ let closeVite;
738
+ if (options.dev) {
739
+ const { createServer: createVite } = await import("vite");
740
+ const vite = await createVite({ server: { middlewareMode: true, hmr: { server } }, appType: "spa" });
741
+ closeVite = () => vite.close();
742
+ app.use(vite.middlewares);
743
+ } else if (options.staticFiles !== false) {
744
+ const staticDir = options.staticDir || fileURLToPath(new URL("../../dist/", import.meta.url));
745
+ app.use(express.static(staticDir));
746
+ app.get("/{*path}", (_req, res) => res.sendFile(resolve2(staticDir, "index.html")));
747
+ }
748
+ app.use((error, _req, res, _next) => {
749
+ if (res.headersSent) {
750
+ res.destroy();
751
+ return;
752
+ }
753
+ const code = error?.code;
754
+ const status = error instanceof HttpError ? error.status : error instanceof multer.MulterError ? 413 : code === "ENOENT" ? 404 : code === "EACCES" ? 403 : error instanceof SyntaxError ? 400 : 500;
755
+ const message = status === 404 ? "\u6587\u4EF6\u6216\u76EE\u5F55\u4E0D\u5B58\u5728" : status === 413 ? "\u56FE\u7247\u8D85\u8FC7 20 MiB \u6216\u4E0A\u4F20\u683C\u5F0F\u65E0\u6548" : status === 500 ? "\u670D\u52A1\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u67E5\u770B\u670D\u52A1\u65E5\u5FD7" : error.message;
756
+ if (status === 500) console.error(error);
757
+ res.status(status).json({ error: message });
758
+ });
759
+ return { app, server, sessions, async close() {
760
+ await sessions.shutdown();
761
+ for (const ws of wss.clients) ws.terminate();
762
+ wss.close();
763
+ await closeVite?.();
764
+ await new Promise((done, reject) => {
765
+ server.close((error) => error ? reject(error) : done());
766
+ server.closeAllConnections();
767
+ });
768
+ } };
769
+ }
770
+ export {
771
+ createApp
772
+ };