dsh-code-server-app 0.3.46 → 0.3.49

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/lib/launcher.mjs CHANGED
@@ -1,549 +1,549 @@
1
- /**
2
- * lib/launcher.mjs — VS Code server 的最小启动器(取代 code-server 的 out/node/** 服务层)。
3
- *
4
- * 在**子进程**里加载 <tree>/lib/vscode/out/server-main.js,自建 node:http 把请求交给 VS Code 的
5
- * handleRequest/handleUpgrade,并补齐 code-server 原先负责的少量 HTTP 面:/healthz(就绪探针)、
6
- * /manifest.json(PWA)、/_static/*(favicon/PWA/serviceWorker)、/proxy/:port/…(端口转发)。
7
- *
8
- * 为什么独立进程:VS Code server 会改进程全局(win32 下 import 即 chdir、注册 SIGPIPE、patch
9
- * Module 解析、多处 process.exit),且 node-pty/sqlite 崩溃时必须只带走 IDE、不能带走 DSH host。
10
- *
11
- * 用法(由 lib/index.js 调用):node lib/launcher.mjs --tree <树根> --user-data-dir <dir>
12
- * --extensions-dir <dir> (--port <n> [--host 127.0.0.1] [--token-file <path>] [--endpoint-file <path>]
13
- * | --pipe <name>) [--parent-pid <pid>] [--locale zh-cn]
14
- * 就绪信号:stdout 打印 `dshcs-ready <productPath> <mode> <addr>`。
15
- *
16
- * 安全模型(0.2.14 起):
17
- * - **随机端口**:host 传 `--port 0`,由系统分配空闲端口,launcher 把实际地址写进 `--endpoint-file`
18
- * (host 读回)。固定端口在 `port` 配置里显式指定时仍然照用。
19
- * - **路径令牌**:`--token-file` 给出的随机令牌成为 URL 的**路径前缀**(`http://127.0.0.1:<port>/<token>/…`)。
20
- * 为什么不走 VS Code 自带的 `connection-token`:它靠 `?tkn=` → 302 + `Set-Cookie: vscode-tkn; SameSite=Lax`,
21
- * 而本插件的桌面端 iframe 是**跨源**的(dsh-app:// → 127.0.0.1),Lax cookie 在跨站子框架里不会被带上
22
- * ⇒ 那样会把 desktop 直接打挂。路径前缀不需要 cookie:workbench 的资源与 WS 全部由 `location.pathname`
23
- * 派生(`serve: dsh` 挂载在 /code-server/ 下已验证同一机制),前缀天然跟随每个子请求与 WS 握手。
24
- * - **Host 白名单**:回环模式下只接受 `127.0.0.1|localhost|[::1]:<实际端口>`,挡 DNS rebinding
25
- * (即便请求不带 Origin,originAllowed 也放行不了)。
26
- * - 令牌只存在于文件与 URL 路径里,**不进 argv、不进日志**。
27
- */
28
-
29
- import { createServer as createHttpServer, request as httpRequest } from 'node:http';
30
- import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
31
- import { connect } from 'node:net';
32
- import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
33
- import { fileURLToPath, pathToFileURL } from 'node:url';
34
- import { gunzipSync } from 'node:zlib';
35
-
36
- const HERE = dirname(fileURLToPath(import.meta.url));
37
- const PACKAGE_ROOT = resolve(HERE, '..');
38
-
39
- // ---------------------------------------------------------------- 参数
40
-
41
- function parseArgs(argv) {
42
- const out = {
43
- tree: process.env.DSHCS_VS_ROOT ?? null,
44
- userDataDir: null,
45
- extensionsDir: null,
46
- host: '127.0.0.1',
47
- port: null,
48
- pipe: null,
49
- parentPid: null,
50
- locale: null,
51
- disableProxy: false,
52
- /** 路径令牌文件(host 写;回环模式下必给)。 */
53
- tokenFile: null,
54
- /** 实际监听地址回报文件(launcher 写;host 读)。 */
55
- endpointFile: null,
56
- /** ?v= 缓存击穿标记(host 传 DSHCS_HTML_TAG;为空则每次启动生成一个)。 */
57
- htmlTag: process.env.DSHCS_HTML_TAG ?? null,
58
- };
59
- for (let i = 0; i < argv.length; i += 1) {
60
- const a = argv[i];
61
- if (a === '--tree') out.tree = argv[++i];
62
- else if (a === '--user-data-dir') out.userDataDir = argv[++i];
63
- else if (a === '--extensions-dir') out.extensionsDir = argv[++i];
64
- else if (a === '--host') out.host = argv[++i];
65
- else if (a === '--port') out.port = Number(argv[++i]);
66
- else if (a === '--pipe') out.pipe = argv[++i];
67
- else if (a === '--token-file') out.tokenFile = argv[++i];
68
- else if (a === '--endpoint-file') out.endpointFile = argv[++i];
69
- else if (a === '--parent-pid') out.parentPid = Number(argv[++i]);
70
- else if (a === '--locale') out.locale = argv[++i];
71
- else if (a === '--disable-proxy') out.disableProxy = true;
72
- }
73
- return out;
74
- }
75
-
76
- const args = parseArgs(process.argv.slice(2));
77
- const log = (...parts) => console.log('[dshcs-launcher]', ...parts);
78
- const fail = (message) => { console.error('[dshcs-launcher] FATAL', message); process.exit(2); };
79
-
80
- if (args.tree === null) fail('缺少 --tree(或环境变量 DSHCS_VS_ROOT)');
81
- const tree = resolve(args.tree);
82
- const serverMain = join(tree, 'lib', 'vscode', 'out', 'server-main.js');
83
- if (!existsSync(serverMain)) fail(`不是一棵 VS Code 树(缺少 ${serverMain})`);
84
- if (args.pipe === null && !Number.isFinite(args.port)) fail('必须给 --port 或 --pipe');
85
- // 端口 0 = 由系统分配空闲端口(host 从 --endpoint-file 读回实际值)
86
- if (args.pipe === null && (!Number.isInteger(args.port) || args.port < 0 || args.port > 65535)) fail(`--port 非法: ${args.port}`);
87
-
88
- /** 路径令牌:非空 → 要求 URL 以此为前缀(见文件头"安全模型")。 */
89
- const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
90
- const pathToken = (() => {
91
- if (args.tokenFile === null) return null;
92
- let raw;
93
- try { raw = readFileSync(args.tokenFile, 'utf8').trim(); } catch (error) { fail(`读不到令牌文件 ${args.tokenFile}: ${error.message}`); }
94
- if (!TOKEN_RE.test(raw)) fail(`令牌文件内容不合法(应为 16~128 位 [0-9A-Za-z_-]): ${args.tokenFile}`);
95
- return raw;
96
- })();
97
- if (args.pipe !== null && pathToken !== null) fail('管道模式下不需要 --token-file(DSH 侧已做鉴权)');
98
- const prefix = pathToken === null ? null : `/${pathToken}`;
99
-
100
- const userDataDir = resolve(args.userDataDir ?? join(PACKAGE_ROOT, '.dshcs-data', 'user-data'));
101
- const extensionsDir = resolve(args.extensionsDir ?? join(userDataDir, '..', 'extensions'));
102
- mkdirSync(userDataDir, { recursive: true });
103
- mkdirSync(extensionsDir, { recursive: true });
104
-
105
- const product = (() => {
106
- try { return JSON.parse(readFileSync(join(tree, 'lib', 'vscode', 'product.json'), 'utf8')); } catch { return {}; }
107
- })();
108
- const productPath = `${product.quality ?? 'oss'}-${product.commit ?? 'dev'}`;
109
-
110
- // ---------------------------------------------------------------- 进程护栏(必须 import 前)
111
-
112
- process.env.CODE_SERVER_PARENT_PID ??= String(process.pid);
113
- process.env.VSCODE_HANDLES_SIGPIPE ??= '1';
114
- process.env.VSCODE_CWD ??= process.cwd();
115
-
116
- let vsServer = null;
117
- const recentUpgrades = [];
118
-
119
- async function loadVscodeServer() {
120
- const cwdBefore = process.cwd();
121
- const mod = await import(pathToFileURL(serverMain).href);
122
- // win32 下 server-main 顶层 DB() 会 chdir 到 dirname(process.execPath),立即复位
123
- try { if (process.cwd() !== cwdBefore) process.chdir(cwdBefore); } catch { /* ignore */ }
124
- const serverModule = await mod.loadCodeWithNls();
125
- const codeArgs = {
126
- auth: 'none',
127
- 'user-data-dir': userDataDir,
128
- 'extensions-dir': extensionsDir,
129
- 'accept-server-license-terms': true,
130
- compatibility: '1.64',
131
- 'without-connection-token': true,
132
- 'disable-telemetry': true,
133
- 'disable-update-check': true,
134
- _: [],
135
- };
136
- if (args.locale !== null) codeArgs.locale = args.locale;
137
- return serverModule.createServer(null, codeArgs);
138
- }
139
-
140
- // ---------------------------------------------------------------- 静态资源(/_static/*)
141
-
142
- const MIME = {
143
- '.svg': 'image/svg+xml; charset=utf-8',
144
- '.ico': 'image/x-icon',
145
- '.png': 'image/png',
146
- '.jpg': 'image/jpeg',
147
- '.js': 'text/javascript; charset=utf-8',
148
- '.css': 'text/css; charset=utf-8',
149
- '.json': 'application/json; charset=utf-8',
150
- '.txt': 'text/plain; charset=utf-8',
151
- '.webmanifest': 'application/manifest+json',
152
- };
153
-
154
- function serveTreeStatic(urlPath, res) {
155
- const rel = decodeURIComponent(urlPath.replace(/^\/_static\/?/, ''));
156
- const full = normalize(join(tree, rel));
157
- if (full !== tree && !full.startsWith(tree + sep)) { res.writeHead(403); res.end('forbidden'); return; }
158
- let stat;
159
- try { stat = statSync(full); } catch { res.writeHead(404); res.end(); return; }
160
- if (!stat.isFile()) { res.writeHead(404); res.end(); return; }
161
- const headers = {
162
- 'content-type': MIME[extname(full).toLowerCase()] ?? 'application/octet-stream',
163
- 'cache-control': 'public, max-age=3600',
164
- };
165
- if (full.endsWith('serviceWorker.js')) headers['service-worker-allowed'] = '/';
166
- res.writeHead(200, headers);
167
- createReadStream(full).pipe(res);
168
- }
169
-
170
- function manifestBody() {
171
- return JSON.stringify({
172
- name: product.nameShort ?? 'code-server',
173
- short_name: product.nameShort ?? 'code-server',
174
- start_url: '.',
175
- display: 'fullscreen',
176
- display_override: ['window-controls-overlay'],
177
- description: 'Run Code on a remote server.',
178
- icons: [192, 512].flatMap((size) => ([
179
- { src: `./_static/src/browser/media/pwa-icon-${size}.png`, type: 'image/png', sizes: `${size}x${size}`, purpose: 'any' },
180
- { src: `./_static/src/browser/media/pwa-icon-maskable-${size}.png`, type: 'image/png', sizes: `${size}x${size}`, purpose: 'maskable' },
181
- ])),
182
- }, null, 2);
183
- }
184
-
185
- // ---------------------------------------------------------------- 转发端口代理(/proxy/:port、/absproxy/:port)
186
-
187
- const PROXY_RE = /^\/(abs)?proxy\/(\d{1,5})(\/.*)?$/;
188
-
189
- function proxyTarget(url) {
190
- const m = PROXY_RE.exec(new URL(url, 'http://x').pathname);
191
- if (m === null) return null;
192
- const port = Number(m[2]);
193
- if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
194
- const rest = m[3] ?? '/';
195
- const search = new URL(url, 'http://x').search;
196
- return { port, path: m[1] === 'abs' ? `${rest}${search}` : `${rest}${search}` };
197
- }
198
-
199
- function handleProxy(req, res, target) {
200
- const upstream = httpRequest({
201
- host: '127.0.0.1',
202
- port: target.port,
203
- method: req.method,
204
- path: target.path,
205
- headers: stripHopHeaders(req.headers),
206
- }, (upstreamRes) => {
207
- res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
208
- upstreamRes.pipe(res);
209
- });
210
- upstream.on('error', (error) => {
211
- log(`proxy ${target.port} failed: ${error.message}`);
212
- if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
213
- res.end(`proxy error: ${error.message}`);
214
- });
215
- req.pipe(upstream);
216
- }
217
-
218
- function stripHopHeaders(headers) {
219
- const out = { ...headers };
220
- delete out.connection;
221
- delete out['proxy-connection'];
222
- return out;
223
- }
224
-
225
- function handleProxyUpgrade(req, socket, head, target) {
226
- const upstream = connect({ host: '127.0.0.1', port: target.port }, () => {
227
- const lines = [`GET ${target.path} HTTP/1.1`];
228
- for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
229
- upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
230
- if (head.length > 0) upstream.write(head);
231
- upstream.pipe(socket);
232
- socket.pipe(upstream);
233
- });
234
- upstream.on('error', (error) => { log(`proxy ws ${target.port} failed: ${error.message}`); socket.destroy(); });
235
- socket.on('error', () => upstream.destroy());
236
- socket.on('close', () => upstream.destroy());
237
- }
238
-
239
- // ---------------------------------------------------------------- 跨源防护(等价 code-server 的 ensureOrigin)
240
-
241
- /** 取请求 Host:优先 `Forwarded: host=`、其次 `X-Forwarded-Host`(取第一个)、最后 `Host`。
242
- * trim + 小写,与 code-server 的 getHost() 同语义(反向代理未透传 Host 时返回 undefined)。 */
243
- function getHostHeader(req) {
244
- const first = (name) => {
245
- const value = req.headers[name];
246
- return Array.isArray(value) ? value[0] : value;
247
- };
248
- const forwarded = first('forwarded');
249
- if (forwarded !== undefined && forwarded !== '') {
250
- for (const part of forwarded.split(/[;,]/)) {
251
- const eq = part.indexOf('=');
252
- if (eq < 0) continue;
253
- const key = part.slice(0, eq).trim().toLowerCase();
254
- const value = part.slice(eq + 1).trim();
255
- if (key === 'host' && value !== '') return value.toLowerCase();
256
- }
257
- }
258
- const xHost = first('x-forwarded-host');
259
- if (xHost !== undefined && xHost !== '') {
260
- const head = xHost.split(',')[0];
261
- if (head !== undefined && head.trim() !== '') return head.trim().toLowerCase();
262
- }
263
- const host = first('host');
264
- return host !== undefined && host !== '' ? host.trim().toLowerCase() : undefined;
265
- }
266
-
267
- /** code-server `authenticateOrigin()` 的等价物:带 Origin 的请求(浏览器)其 host 必须等于 Host;
268
- * 缺 Origin(非浏览器,如本地工具/测试)放行。code-server 另外支持 `--trusted-origins` /
269
- * `--proxy-domain` 通配,本插件没有这两个概念,故不实现。
270
- * 为什么必须有:VS Code 的 handleUpgrade 不校验来源,而 IDE 是 auth=none —— 缺了这道检查,
271
- * 本机任意浏览器页面都能开 ws://127.0.0.1:<port>/stable-<commit> 直接驱动 IDE。
272
- * (dsh 模式下请求来自 DSH 自己的路由并已过 requestRejection,Origin/Host 天然一致。) */
273
- function originAllowed(req) {
274
- const raw = req.headers.origin;
275
- const originRaw = Array.isArray(raw) ? raw[0] : raw;
276
- if (originRaw === undefined || originRaw === '') return true;
277
- let origin;
278
- try {
279
- origin = new URL(originRaw).host.trim().toLowerCase();
280
- } catch {
281
- return false;
282
- }
283
- if (origin === '') return false;
284
- const host = getHostHeader(req);
285
- if (host === undefined) return false;
286
- return host === origin;
287
- }
288
-
289
- /** 回环模式下的 Host 白名单:只接受本机字面量 + **实际**监听端口。
290
- * 为什么必须:originAllowed() 对"不带 Origin"的请求放行(本地工具/测试需要),而 DNS rebinding
291
- * 恰恰能构造出不带 Origin 的请求 —— 没有这道检查,一个恶意页面就能用 127.0.0.1 以外的域名
292
- * 打到本机端口上。端口在 listen 之后才确定,故用变量在请求到达时求值。 */
293
- let boundPort = Number.isFinite(args.port) ? args.port : 0;
294
- function hostAllowed(req) {
295
- if (prefix === null) return true; // 管道模式:Host 由 DSH 的 webServer 决定,已过 requestRejection
296
- const host = getHostHeader(req);
297
- if (host === undefined || host === '') return false;
298
- const m = /^(\[[0-9a-fA-F:.]+\]|[^:]+)(?::(\d+))?$/.exec(host);
299
- if (m === null) return false;
300
- const name = m[1].replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
301
- const port = m[2] === undefined ? 80 : Number(m[2]);
302
- if (port !== boundPort) return false;
303
- return name === '127.0.0.1' || name === 'localhost' || name === '::1' || name === String(args.host).toLowerCase();
304
- }
305
-
306
- /** 校验并剥掉路径令牌前缀。返回 null = 前缀不对(调用方回 404,不泄露"这里是个 IDE")。 */
307
- function stripToken(url) {
308
- if (prefix === null) return url;
309
- if (url === prefix) return '/';
310
- if (url.startsWith(`${prefix}/`)) return url.slice(prefix.length);
311
- return null;
312
- }
313
-
314
- // ---------------------------------------------------------------- HTTP / WS 分发
315
-
316
- /** 工作台 HTML 的响应改写:给 workbench.js / workbench.css / nls 资源加 `?v=<tag>` 缓存击穿。
317
- *
318
- * 为什么必须有:这些资源带 `cache-control: public, max-age=31536000`,而**渲染器的缓存按 URL 走** ——
319
- * 插件或 VS Code 树升级后沿用同一个 URL,渲染器会继续跑**旧 bundle**(表现为界面/配色行为诡异,
320
- * 与服务器不一致)。README 里那条"清 Cache/Code Cache/GPUCache"就是它的手工绕法。 */
321
- function rewriteWorkbenchHtml(html) {
322
- const tag = args.htmlTag !== null && args.htmlTag !== '' ? args.htmlTag : `t${Date.now()}`;
323
- let out = html.replace(/([^"'=]*\/workbench\.(?:js|css))"/g, `$1?v=${tag}"`);
324
- out = out.replace(/([^"'=]*\/nls\.messages\.js)"/g, `$1?v=${tag}"`);
325
- return out;
326
- }
327
-
328
- /** 缓冲响应体后改写(GET / 用):去掉 content-length/content-encoding,并改成 no-store。 */
329
- function interceptHtmlResponse(res) {
330
- const chunks = [];
331
- const originalEnd = res.end.bind(res);
332
- const originalWriteHead = res.writeHead.bind(res);
333
- let status = 200;
334
- let headers = {};
335
- res.writeHead = (code, reason, hdrs) => {
336
- status = code;
337
- const extra = typeof reason === 'object' && reason !== null ? reason : hdrs;
338
- headers = typeof reason === 'string' ? (hdrs ?? {}) : (extra ?? {});
339
- return res;
340
- };
341
- res.write = (chunk, encoding, callback) => {
342
- if (chunk !== undefined && chunk !== null) {
343
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
344
- }
345
- if (typeof encoding === 'function') encoding();
346
- else if (typeof callback === 'function') callback();
347
- return true;
348
- };
349
- res.end = (chunk, encoding, callback) => {
350
- if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
351
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
352
- }
353
- const clean = { ...(typeof res.getHeaders === 'function' ? res.getHeaders() : {}), ...headers };
354
- let body = Buffer.concat(chunks);
355
- // 要就地改字节(请求侧已删掉 accept-encoding,正常不会压缩;压缩了也兜一层)
356
- const encodingHeader = String(clean['content-encoding'] ?? clean['Content-Encoding'] ?? '').toLowerCase();
357
- if (encodingHeader.includes('gzip')) {
358
- try { body = gunzipSync(body); } catch { /* 原样透传 */ }
359
- }
360
- if (status === 200) body = Buffer.from(rewriteWorkbenchHtml(body.toString('utf8')), 'utf8');
361
- for (const name of ['content-length', 'Content-Length', 'content-encoding', 'Content-Encoding']) delete clean[name];
362
- clean['content-length'] = String(body.length);
363
- clean['cache-control'] = 'no-store';
364
- originalWriteHead(status, clean);
365
- return originalEnd(body);
366
- };
367
- }
368
-
369
- const server = createHttpServer((req, res) => {
370
- const url = req.url ?? '/';
371
- // 令牌只出现在 URL 路径里 —— 别让它经 Referer 漏给站外资源
372
- res.setHeader('Referrer-Policy', 'no-referrer');
373
- if (!hostAllowed(req)) {
374
- log(`拒绝 Host 头: host=${req.headers.host ?? '-'} ${req.method} ${url}`);
375
- res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
376
- res.end('forbidden');
377
- return;
378
- }
379
- // 前缀少了结尾斜杠 → 302 补上(否则文档里的相对引用会从根开始解析)
380
- if (prefix !== null && url.split('?')[0] === prefix) {
381
- const query = url.slice(prefix.length);
382
- res.writeHead(302, { location: `${prefix}/${query.startsWith('?') ? query : ''}` });
383
- res.end();
384
- return;
385
- }
386
- const stripped = stripToken(url);
387
- if (stripped === null) {
388
- res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
389
- res.end('not found');
390
- return;
391
- }
392
- req.url = stripped;
393
- // **按路径匹配,别拿整串比**:客户端加载文档时永远带查询串(`?folder=<cwd>`),
394
- // 用 `url === '/'` 判断会让改写整条失效(0.3.x 就踩过这个坑);`/healthz`、`/manifest.json`
395
- // 同理要用路径匹配,否则带查询串的探针会掉到 VS Code 那边变成 404。
396
- const urlPath = stripped.split('?')[0];
397
- if (urlPath === '/healthz') {
398
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
399
- res.end(JSON.stringify({
400
- ok: true,
401
- productPath,
402
- pid: process.pid,
403
- port: boundPort,
404
- mode: args.pipe === null ? 'tcp' : 'pipe',
405
- htmlTag: args.htmlTag,
406
- recentUpgrades: recentUpgrades.slice(-3),
407
- }));
408
- return;
409
- }
410
- if (urlPath === '/manifest.json') {
411
- res.writeHead(200, { 'content-type': 'application/manifest+json; charset=utf-8' });
412
- res.end(manifestBody());
413
- return;
414
- }
415
- if (urlPath.startsWith('/_static/')) { serveTreeStatic(urlPath, res); return; }
416
- if (urlPath === '/' || urlPath === '/index.html') {
417
- // 工作台 HTML:缓冲改写(只加 ?v= 缓存击穿;不做任何注入)
418
- delete req.headers['accept-encoding']; // 要就地改字节,让上游发未压缩的
419
- interceptHtmlResponse(res);
420
- Promise.resolve(vsServer.handleRequest(req, res)).catch((error) => {
421
- log(`handleRequest(html) failed: ${error && error.stack ? error.stack : error}`);
422
- try { res.end(); } catch { /* ignore */ }
423
- });
424
- return;
425
- }
426
- if (!args.disableProxy) {
427
- const target = proxyTarget(url);
428
- if (target !== null) { handleProxy(req, res, target); return; }
429
- }
430
- Promise.resolve(vsServer.handleRequest(req, res)).catch((error) => {
431
- log(`handleRequest failed: ${error && error.stack ? error.stack : error}`);
432
- if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
433
- if (!res.writableEnded) res.end('internal error');
434
- });
435
- });
436
-
437
- server.on('upgrade', (req, socket, head) => {
438
- const url = req.url ?? '/';
439
- recentUpgrades.push(url);
440
- if (recentUpgrades.length > 10) recentUpgrades.shift();
441
- if (!hostAllowed(req)) {
442
- log(`拒绝 Host 头的 WebSocket: host=${req.headers.host ?? '-'} url=${url}`);
443
- socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
444
- return;
445
- }
446
- const stripped = stripToken(url);
447
- if (stripped === null) {
448
- socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
449
- return;
450
- }
451
- req.url = stripped;
452
- // 跨源防护(与 code-server 一致:只卡 upgrade,HTTP 侧 VS Code 仅接受 GET 且状态变更都走 WS)
453
- if (!originAllowed(req)) {
454
- log(`拒绝跨源 WebSocket:origin=${req.headers.origin} host=${req.headers.host ?? '-'} url=${url}`);
455
- socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
456
- return;
457
- }
458
- if (!args.disableProxy) {
459
- const target = proxyTarget(stripped);
460
- if (target !== null) { handleProxyUpgrade(req, socket, head, target); return; }
461
- }
462
- socket.pause();
463
- req.ws = socket;
464
- req.head = head;
465
- try {
466
- vsServer.handleUpgrade(req, socket);
467
- } catch (error) {
468
- log(`handleUpgrade threw: ${error && error.message ? error.message : error}`);
469
- socket.destroy();
470
- }
471
- socket.resume();
472
- });
473
-
474
- // ---------------------------------------------------------------- 生命周期
475
-
476
- let shuttingDown = false;
477
- async function shutdown(reason, code = 0) {
478
- if (shuttingDown) return;
479
- shuttingDown = true;
480
- log(`shutting down (${reason})`);
481
- try { await vsServer?.dispose?.(); } catch (error) { log(`dispose failed: ${error && error.message}`); }
482
- const done = () => process.exit(code);
483
- try { server.close(done); } catch { done(); }
484
- setTimeout(() => process.exit(code), 3000).unref();
485
- }
486
-
487
- for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { void shutdown(sig); });
488
-
489
- // 父进程(DSH host)消失 → 自行退出,避免留下孤儿 IDE 进程
490
- if (Number.isFinite(args.parentPid) && args.parentPid > 0) {
491
- const parentPid = args.parentPid;
492
- const timer = setInterval(() => {
493
- try {
494
- process.kill(parentPid, 0);
495
- } catch (error) {
496
- if (error && error.code === 'ESRCH') void shutdown(`parent ${parentPid} gone`);
497
- }
498
- }, 5000);
499
- timer.unref();
500
- }
501
-
502
- // ---------------------------------------------------------------- 启动
503
-
504
- try {
505
- vsServer = await loadVscodeServer();
506
- } catch (error) {
507
- fail(`加载 VS Code server 失败: ${error && error.stack ? error.stack : error}`);
508
- }
509
-
510
- const listenTarget = args.pipe === null ? { host: args.host, port: args.port } : args.pipe;
511
- server.on('error', (error) => { fail(`监听失败(${JSON.stringify(listenTarget)}): ${error.message}`); });
512
-
513
- /** 把**实际**监听地址写给 host(端口 0 时 host 只能从这里知道端口;原子写避免读到半截)。 */
514
- function writeEndpoint(addr) {
515
- if (args.endpointFile === null) return;
516
- const payload = {
517
- host: addr.address,
518
- port: addr.port,
519
- pid: process.pid,
520
- productPath,
521
- mode: args.pipe === null ? 'tcp' : 'pipe',
522
- tokenized: prefix !== null,
523
- startedAt: Date.now(),
524
- };
525
- try {
526
- const tmp = `${args.endpointFile}.${process.pid}.tmp`;
527
- writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
528
- renameSync(tmp, args.endpointFile);
529
- } catch (error) {
530
- log(`写 endpoint 文件失败(${args.endpointFile}): ${error.message}`);
531
- }
532
- }
533
-
534
- server.listen(listenTarget, () => {
535
- const addr = server.address();
536
- const shown = typeof addr === 'string' ? addr : `${addr.address}:${addr.port}`;
537
- if (typeof addr === 'object' && addr !== null) boundPort = addr.port;
538
- if (typeof addr === 'object' && addr !== null) writeEndpoint(addr);
539
- log(`listening on ${shown} (tree=${tree})`);
540
- log(`user-data-dir=${userDataDir} extensions-dir=${extensionsDir}`);
541
- log(`path token: ${prefix === null ? '(无)' : '已启用(URL 前缀,不写日志)'}`);
542
- console.log(`dshcs-ready ${productPath} ${args.pipe === null ? 'tcp' : 'pipe'} ${shown}`);
543
- });
544
-
545
- // 兜底:未捕获异常不应静默留下半死进程
546
- process.on('uncaughtException', (error) => {
547
- console.error('[dshcs-launcher] uncaughtException', error && error.stack ? error.stack : error);
548
- void shutdown('uncaughtException', 1);
549
- });
1
+ /**
2
+ * lib/launcher.mjs — VS Code server 的最小启动器(取代 code-server 的 out/node/** 服务层)。
3
+ *
4
+ * 在**子进程**里加载 <tree>/lib/vscode/out/server-main.js,自建 node:http 把请求交给 VS Code 的
5
+ * handleRequest/handleUpgrade,并补齐 code-server 原先负责的少量 HTTP 面:/healthz(就绪探针)、
6
+ * /manifest.json(PWA)、/_static/*(favicon/PWA/serviceWorker)、/proxy/:port/…(端口转发)。
7
+ *
8
+ * 为什么独立进程:VS Code server 会改进程全局(win32 下 import 即 chdir、注册 SIGPIPE、patch
9
+ * Module 解析、多处 process.exit),且 node-pty/sqlite 崩溃时必须只带走 IDE、不能带走 DSH host。
10
+ *
11
+ * 用法(由 lib/index.js 调用):node lib/launcher.mjs --tree <树根> --user-data-dir <dir>
12
+ * --extensions-dir <dir> (--port <n> [--host 127.0.0.1] [--token-file <path>] [--endpoint-file <path>]
13
+ * | --pipe <name>) [--parent-pid <pid>] [--locale zh-cn]
14
+ * 就绪信号:stdout 打印 `dshcs-ready <productPath> <mode> <addr>`。
15
+ *
16
+ * 安全模型(0.2.14 起):
17
+ * - **随机端口**:host 传 `--port 0`,由系统分配空闲端口,launcher 把实际地址写进 `--endpoint-file`
18
+ * (host 读回)。固定端口在 `port` 配置里显式指定时仍然照用。
19
+ * - **路径令牌**:`--token-file` 给出的随机令牌成为 URL 的**路径前缀**(`http://127.0.0.1:<port>/<token>/…`)。
20
+ * 为什么不走 VS Code 自带的 `connection-token`:它靠 `?tkn=` → 302 + `Set-Cookie: vscode-tkn; SameSite=Lax`,
21
+ * 而本插件的桌面端 iframe 是**跨源**的(dsh-app:// → 127.0.0.1),Lax cookie 在跨站子框架里不会被带上
22
+ * ⇒ 那样会把 desktop 直接打挂。路径前缀不需要 cookie:workbench 的资源与 WS 全部由 `location.pathname`
23
+ * 派生(`serve: dsh` 挂载在 /code-server/ 下已验证同一机制),前缀天然跟随每个子请求与 WS 握手。
24
+ * - **Host 白名单**:回环模式下只接受 `127.0.0.1|localhost|[::1]:<实际端口>`,挡 DNS rebinding
25
+ * (即便请求不带 Origin,originAllowed 也放行不了)。
26
+ * - 令牌只存在于文件与 URL 路径里,**不进 argv、不进日志**。
27
+ */
28
+
29
+ import { createServer as createHttpServer, request as httpRequest } from 'node:http';
30
+ import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
31
+ import { connect } from 'node:net';
32
+ import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
33
+ import { fileURLToPath, pathToFileURL } from 'node:url';
34
+ import { gunzipSync } from 'node:zlib';
35
+
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+ const PACKAGE_ROOT = resolve(HERE, '..');
38
+
39
+ // ---------------------------------------------------------------- 参数
40
+
41
+ function parseArgs(argv) {
42
+ const out = {
43
+ tree: process.env.DSHCS_VS_ROOT ?? null,
44
+ userDataDir: null,
45
+ extensionsDir: null,
46
+ host: '127.0.0.1',
47
+ port: null,
48
+ pipe: null,
49
+ parentPid: null,
50
+ locale: null,
51
+ disableProxy: false,
52
+ /** 路径令牌文件(host 写;回环模式下必给)。 */
53
+ tokenFile: null,
54
+ /** 实际监听地址回报文件(launcher 写;host 读)。 */
55
+ endpointFile: null,
56
+ /** ?v= 缓存击穿标记(host 传 DSHCS_HTML_TAG;为空则每次启动生成一个)。 */
57
+ htmlTag: process.env.DSHCS_HTML_TAG ?? null,
58
+ };
59
+ for (let i = 0; i < argv.length; i += 1) {
60
+ const a = argv[i];
61
+ if (a === '--tree') out.tree = argv[++i];
62
+ else if (a === '--user-data-dir') out.userDataDir = argv[++i];
63
+ else if (a === '--extensions-dir') out.extensionsDir = argv[++i];
64
+ else if (a === '--host') out.host = argv[++i];
65
+ else if (a === '--port') out.port = Number(argv[++i]);
66
+ else if (a === '--pipe') out.pipe = argv[++i];
67
+ else if (a === '--token-file') out.tokenFile = argv[++i];
68
+ else if (a === '--endpoint-file') out.endpointFile = argv[++i];
69
+ else if (a === '--parent-pid') out.parentPid = Number(argv[++i]);
70
+ else if (a === '--locale') out.locale = argv[++i];
71
+ else if (a === '--disable-proxy') out.disableProxy = true;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ const args = parseArgs(process.argv.slice(2));
77
+ const log = (...parts) => console.log('[dshcs-launcher]', ...parts);
78
+ const fail = (message) => { console.error('[dshcs-launcher] FATAL', message); process.exit(2); };
79
+
80
+ if (args.tree === null) fail('缺少 --tree(或环境变量 DSHCS_VS_ROOT)');
81
+ const tree = resolve(args.tree);
82
+ const serverMain = join(tree, 'lib', 'vscode', 'out', 'server-main.js');
83
+ if (!existsSync(serverMain)) fail(`不是一棵 VS Code 树(缺少 ${serverMain})`);
84
+ if (args.pipe === null && !Number.isFinite(args.port)) fail('必须给 --port 或 --pipe');
85
+ // 端口 0 = 由系统分配空闲端口(host 从 --endpoint-file 读回实际值)
86
+ if (args.pipe === null && (!Number.isInteger(args.port) || args.port < 0 || args.port > 65535)) fail(`--port 非法: ${args.port}`);
87
+
88
+ /** 路径令牌:非空 → 要求 URL 以此为前缀(见文件头"安全模型")。 */
89
+ const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
90
+ const pathToken = (() => {
91
+ if (args.tokenFile === null) return null;
92
+ let raw;
93
+ try { raw = readFileSync(args.tokenFile, 'utf8').trim(); } catch (error) { fail(`读不到令牌文件 ${args.tokenFile}: ${error.message}`); }
94
+ if (!TOKEN_RE.test(raw)) fail(`令牌文件内容不合法(应为 16~128 位 [0-9A-Za-z_-]): ${args.tokenFile}`);
95
+ return raw;
96
+ })();
97
+ if (args.pipe !== null && pathToken !== null) fail('管道模式下不需要 --token-file(DSH 侧已做鉴权)');
98
+ const prefix = pathToken === null ? null : `/${pathToken}`;
99
+
100
+ const userDataDir = resolve(args.userDataDir ?? join(PACKAGE_ROOT, '.dshcs-data', 'user-data'));
101
+ const extensionsDir = resolve(args.extensionsDir ?? join(userDataDir, '..', 'extensions'));
102
+ mkdirSync(userDataDir, { recursive: true });
103
+ mkdirSync(extensionsDir, { recursive: true });
104
+
105
+ const product = (() => {
106
+ try { return JSON.parse(readFileSync(join(tree, 'lib', 'vscode', 'product.json'), 'utf8')); } catch { return {}; }
107
+ })();
108
+ const productPath = `${product.quality ?? 'oss'}-${product.commit ?? 'dev'}`;
109
+
110
+ // ---------------------------------------------------------------- 进程护栏(必须 import 前)
111
+
112
+ process.env.CODE_SERVER_PARENT_PID ??= String(process.pid);
113
+ process.env.VSCODE_HANDLES_SIGPIPE ??= '1';
114
+ process.env.VSCODE_CWD ??= process.cwd();
115
+
116
+ let vsServer = null;
117
+ const recentUpgrades = [];
118
+
119
+ async function loadVscodeServer() {
120
+ const cwdBefore = process.cwd();
121
+ const mod = await import(pathToFileURL(serverMain).href);
122
+ // win32 下 server-main 顶层 DB() 会 chdir 到 dirname(process.execPath),立即复位
123
+ try { if (process.cwd() !== cwdBefore) process.chdir(cwdBefore); } catch { /* ignore */ }
124
+ const serverModule = await mod.loadCodeWithNls();
125
+ const codeArgs = {
126
+ auth: 'none',
127
+ 'user-data-dir': userDataDir,
128
+ 'extensions-dir': extensionsDir,
129
+ 'accept-server-license-terms': true,
130
+ compatibility: '1.64',
131
+ 'without-connection-token': true,
132
+ 'disable-telemetry': true,
133
+ 'disable-update-check': true,
134
+ _: [],
135
+ };
136
+ if (args.locale !== null) codeArgs.locale = args.locale;
137
+ return serverModule.createServer(null, codeArgs);
138
+ }
139
+
140
+ // ---------------------------------------------------------------- 静态资源(/_static/*)
141
+
142
+ const MIME = {
143
+ '.svg': 'image/svg+xml; charset=utf-8',
144
+ '.ico': 'image/x-icon',
145
+ '.png': 'image/png',
146
+ '.jpg': 'image/jpeg',
147
+ '.js': 'text/javascript; charset=utf-8',
148
+ '.css': 'text/css; charset=utf-8',
149
+ '.json': 'application/json; charset=utf-8',
150
+ '.txt': 'text/plain; charset=utf-8',
151
+ '.webmanifest': 'application/manifest+json',
152
+ };
153
+
154
+ function serveTreeStatic(urlPath, res) {
155
+ const rel = decodeURIComponent(urlPath.replace(/^\/_static\/?/, ''));
156
+ const full = normalize(join(tree, rel));
157
+ if (full !== tree && !full.startsWith(tree + sep)) { res.writeHead(403); res.end('forbidden'); return; }
158
+ let stat;
159
+ try { stat = statSync(full); } catch { res.writeHead(404); res.end(); return; }
160
+ if (!stat.isFile()) { res.writeHead(404); res.end(); return; }
161
+ const headers = {
162
+ 'content-type': MIME[extname(full).toLowerCase()] ?? 'application/octet-stream',
163
+ 'cache-control': 'public, max-age=3600',
164
+ };
165
+ if (full.endsWith('serviceWorker.js')) headers['service-worker-allowed'] = '/';
166
+ res.writeHead(200, headers);
167
+ createReadStream(full).pipe(res);
168
+ }
169
+
170
+ function manifestBody() {
171
+ return JSON.stringify({
172
+ name: product.nameShort ?? 'code-server',
173
+ short_name: product.nameShort ?? 'code-server',
174
+ start_url: '.',
175
+ display: 'fullscreen',
176
+ display_override: ['window-controls-overlay'],
177
+ description: 'Run Code on a remote server.',
178
+ icons: [192, 512].flatMap((size) => ([
179
+ { src: `./_static/src/browser/media/pwa-icon-${size}.png`, type: 'image/png', sizes: `${size}x${size}`, purpose: 'any' },
180
+ { src: `./_static/src/browser/media/pwa-icon-maskable-${size}.png`, type: 'image/png', sizes: `${size}x${size}`, purpose: 'maskable' },
181
+ ])),
182
+ }, null, 2);
183
+ }
184
+
185
+ // ---------------------------------------------------------------- 转发端口代理(/proxy/:port、/absproxy/:port)
186
+
187
+ const PROXY_RE = /^\/(abs)?proxy\/(\d{1,5})(\/.*)?$/;
188
+
189
+ function proxyTarget(url) {
190
+ const m = PROXY_RE.exec(new URL(url, 'http://x').pathname);
191
+ if (m === null) return null;
192
+ const port = Number(m[2]);
193
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
194
+ const rest = m[3] ?? '/';
195
+ const search = new URL(url, 'http://x').search;
196
+ return { port, path: m[1] === 'abs' ? `${rest}${search}` : `${rest}${search}` };
197
+ }
198
+
199
+ function handleProxy(req, res, target) {
200
+ const upstream = httpRequest({
201
+ host: '127.0.0.1',
202
+ port: target.port,
203
+ method: req.method,
204
+ path: target.path,
205
+ headers: stripHopHeaders(req.headers),
206
+ }, (upstreamRes) => {
207
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
208
+ upstreamRes.pipe(res);
209
+ });
210
+ upstream.on('error', (error) => {
211
+ log(`proxy ${target.port} failed: ${error.message}`);
212
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
213
+ res.end(`proxy error: ${error.message}`);
214
+ });
215
+ req.pipe(upstream);
216
+ }
217
+
218
+ function stripHopHeaders(headers) {
219
+ const out = { ...headers };
220
+ delete out.connection;
221
+ delete out['proxy-connection'];
222
+ return out;
223
+ }
224
+
225
+ function handleProxyUpgrade(req, socket, head, target) {
226
+ const upstream = connect({ host: '127.0.0.1', port: target.port }, () => {
227
+ const lines = [`GET ${target.path} HTTP/1.1`];
228
+ for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
229
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
230
+ if (head.length > 0) upstream.write(head);
231
+ upstream.pipe(socket);
232
+ socket.pipe(upstream);
233
+ });
234
+ upstream.on('error', (error) => { log(`proxy ws ${target.port} failed: ${error.message}`); socket.destroy(); });
235
+ socket.on('error', () => upstream.destroy());
236
+ socket.on('close', () => upstream.destroy());
237
+ }
238
+
239
+ // ---------------------------------------------------------------- 跨源防护(等价 code-server 的 ensureOrigin)
240
+
241
+ /** 取请求 Host:优先 `Forwarded: host=`、其次 `X-Forwarded-Host`(取第一个)、最后 `Host`。
242
+ * trim + 小写,与 code-server 的 getHost() 同语义(反向代理未透传 Host 时返回 undefined)。 */
243
+ function getHostHeader(req) {
244
+ const first = (name) => {
245
+ const value = req.headers[name];
246
+ return Array.isArray(value) ? value[0] : value;
247
+ };
248
+ const forwarded = first('forwarded');
249
+ if (forwarded !== undefined && forwarded !== '') {
250
+ for (const part of forwarded.split(/[;,]/)) {
251
+ const eq = part.indexOf('=');
252
+ if (eq < 0) continue;
253
+ const key = part.slice(0, eq).trim().toLowerCase();
254
+ const value = part.slice(eq + 1).trim();
255
+ if (key === 'host' && value !== '') return value.toLowerCase();
256
+ }
257
+ }
258
+ const xHost = first('x-forwarded-host');
259
+ if (xHost !== undefined && xHost !== '') {
260
+ const head = xHost.split(',')[0];
261
+ if (head !== undefined && head.trim() !== '') return head.trim().toLowerCase();
262
+ }
263
+ const host = first('host');
264
+ return host !== undefined && host !== '' ? host.trim().toLowerCase() : undefined;
265
+ }
266
+
267
+ /** code-server `authenticateOrigin()` 的等价物:带 Origin 的请求(浏览器)其 host 必须等于 Host;
268
+ * 缺 Origin(非浏览器,如本地工具/测试)放行。code-server 另外支持 `--trusted-origins` /
269
+ * `--proxy-domain` 通配,本插件没有这两个概念,故不实现。
270
+ * 为什么必须有:VS Code 的 handleUpgrade 不校验来源,而 IDE 是 auth=none —— 缺了这道检查,
271
+ * 本机任意浏览器页面都能开 ws://127.0.0.1:<port>/stable-<commit> 直接驱动 IDE。
272
+ * (dsh 模式下请求来自 DSH 自己的路由并已过 requestRejection,Origin/Host 天然一致。) */
273
+ function originAllowed(req) {
274
+ const raw = req.headers.origin;
275
+ const originRaw = Array.isArray(raw) ? raw[0] : raw;
276
+ if (originRaw === undefined || originRaw === '') return true;
277
+ let origin;
278
+ try {
279
+ origin = new URL(originRaw).host.trim().toLowerCase();
280
+ } catch {
281
+ return false;
282
+ }
283
+ if (origin === '') return false;
284
+ const host = getHostHeader(req);
285
+ if (host === undefined) return false;
286
+ return host === origin;
287
+ }
288
+
289
+ /** 回环模式下的 Host 白名单:只接受本机字面量 + **实际**监听端口。
290
+ * 为什么必须:originAllowed() 对"不带 Origin"的请求放行(本地工具/测试需要),而 DNS rebinding
291
+ * 恰恰能构造出不带 Origin 的请求 —— 没有这道检查,一个恶意页面就能用 127.0.0.1 以外的域名
292
+ * 打到本机端口上。端口在 listen 之后才确定,故用变量在请求到达时求值。 */
293
+ let boundPort = Number.isFinite(args.port) ? args.port : 0;
294
+ function hostAllowed(req) {
295
+ if (prefix === null) return true; // 管道模式:Host 由 DSH 的 webServer 决定,已过 requestRejection
296
+ const host = getHostHeader(req);
297
+ if (host === undefined || host === '') return false;
298
+ const m = /^(\[[0-9a-fA-F:.]+\]|[^:]+)(?::(\d+))?$/.exec(host);
299
+ if (m === null) return false;
300
+ const name = m[1].replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
301
+ const port = m[2] === undefined ? 80 : Number(m[2]);
302
+ if (port !== boundPort) return false;
303
+ return name === '127.0.0.1' || name === 'localhost' || name === '::1' || name === String(args.host).toLowerCase();
304
+ }
305
+
306
+ /** 校验并剥掉路径令牌前缀。返回 null = 前缀不对(调用方回 404,不泄露"这里是个 IDE")。 */
307
+ function stripToken(url) {
308
+ if (prefix === null) return url;
309
+ if (url === prefix) return '/';
310
+ if (url.startsWith(`${prefix}/`)) return url.slice(prefix.length);
311
+ return null;
312
+ }
313
+
314
+ // ---------------------------------------------------------------- HTTP / WS 分发
315
+
316
+ /** 工作台 HTML 的响应改写:给 workbench.js / workbench.css / nls 资源加 `?v=<tag>` 缓存击穿。
317
+ *
318
+ * 为什么必须有:这些资源带 `cache-control: public, max-age=31536000`,而**渲染器的缓存按 URL 走** ——
319
+ * 插件或 VS Code 树升级后沿用同一个 URL,渲染器会继续跑**旧 bundle**(表现为界面/配色行为诡异,
320
+ * 与服务器不一致)。README 里那条"清 Cache/Code Cache/GPUCache"就是它的手工绕法。 */
321
+ function rewriteWorkbenchHtml(html) {
322
+ const tag = args.htmlTag !== null && args.htmlTag !== '' ? args.htmlTag : `t${Date.now()}`;
323
+ let out = html.replace(/([^"'=]*\/workbench\.(?:js|css))"/g, `$1?v=${tag}"`);
324
+ out = out.replace(/([^"'=]*\/nls\.messages\.js)"/g, `$1?v=${tag}"`);
325
+ return out;
326
+ }
327
+
328
+ /** 缓冲响应体后改写(GET / 用):去掉 content-length/content-encoding,并改成 no-store。 */
329
+ function interceptHtmlResponse(res) {
330
+ const chunks = [];
331
+ const originalEnd = res.end.bind(res);
332
+ const originalWriteHead = res.writeHead.bind(res);
333
+ let status = 200;
334
+ let headers = {};
335
+ res.writeHead = (code, reason, hdrs) => {
336
+ status = code;
337
+ const extra = typeof reason === 'object' && reason !== null ? reason : hdrs;
338
+ headers = typeof reason === 'string' ? (hdrs ?? {}) : (extra ?? {});
339
+ return res;
340
+ };
341
+ res.write = (chunk, encoding, callback) => {
342
+ if (chunk !== undefined && chunk !== null) {
343
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
344
+ }
345
+ if (typeof encoding === 'function') encoding();
346
+ else if (typeof callback === 'function') callback();
347
+ return true;
348
+ };
349
+ res.end = (chunk, encoding, callback) => {
350
+ if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
351
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
352
+ }
353
+ const clean = { ...(typeof res.getHeaders === 'function' ? res.getHeaders() : {}), ...headers };
354
+ let body = Buffer.concat(chunks);
355
+ // 要就地改字节(请求侧已删掉 accept-encoding,正常不会压缩;压缩了也兜一层)
356
+ const encodingHeader = String(clean['content-encoding'] ?? clean['Content-Encoding'] ?? '').toLowerCase();
357
+ if (encodingHeader.includes('gzip')) {
358
+ try { body = gunzipSync(body); } catch { /* 原样透传 */ }
359
+ }
360
+ if (status === 200) body = Buffer.from(rewriteWorkbenchHtml(body.toString('utf8')), 'utf8');
361
+ for (const name of ['content-length', 'Content-Length', 'content-encoding', 'Content-Encoding']) delete clean[name];
362
+ clean['content-length'] = String(body.length);
363
+ clean['cache-control'] = 'no-store';
364
+ originalWriteHead(status, clean);
365
+ return originalEnd(body);
366
+ };
367
+ }
368
+
369
+ const server = createHttpServer((req, res) => {
370
+ const url = req.url ?? '/';
371
+ // 令牌只出现在 URL 路径里 —— 别让它经 Referer 漏给站外资源
372
+ res.setHeader('Referrer-Policy', 'no-referrer');
373
+ if (!hostAllowed(req)) {
374
+ log(`拒绝 Host 头: host=${req.headers.host ?? '-'} ${req.method} ${url}`);
375
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
376
+ res.end('forbidden');
377
+ return;
378
+ }
379
+ // 前缀少了结尾斜杠 → 302 补上(否则文档里的相对引用会从根开始解析)
380
+ if (prefix !== null && url.split('?')[0] === prefix) {
381
+ const query = url.slice(prefix.length);
382
+ res.writeHead(302, { location: `${prefix}/${query.startsWith('?') ? query : ''}` });
383
+ res.end();
384
+ return;
385
+ }
386
+ const stripped = stripToken(url);
387
+ if (stripped === null) {
388
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
389
+ res.end('not found');
390
+ return;
391
+ }
392
+ req.url = stripped;
393
+ // **按路径匹配,别拿整串比**:客户端加载文档时永远带查询串(`?folder=<cwd>`),
394
+ // 用 `url === '/'` 判断会让改写整条失效(0.3.x 就踩过这个坑);`/healthz`、`/manifest.json`
395
+ // 同理要用路径匹配,否则带查询串的探针会掉到 VS Code 那边变成 404。
396
+ const urlPath = stripped.split('?')[0];
397
+ if (urlPath === '/healthz') {
398
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
399
+ res.end(JSON.stringify({
400
+ ok: true,
401
+ productPath,
402
+ pid: process.pid,
403
+ port: boundPort,
404
+ mode: args.pipe === null ? 'tcp' : 'pipe',
405
+ htmlTag: args.htmlTag,
406
+ recentUpgrades: recentUpgrades.slice(-3),
407
+ }));
408
+ return;
409
+ }
410
+ if (urlPath === '/manifest.json') {
411
+ res.writeHead(200, { 'content-type': 'application/manifest+json; charset=utf-8' });
412
+ res.end(manifestBody());
413
+ return;
414
+ }
415
+ if (urlPath.startsWith('/_static/')) { serveTreeStatic(urlPath, res); return; }
416
+ if (urlPath === '/' || urlPath === '/index.html') {
417
+ // 工作台 HTML:缓冲改写(只加 ?v= 缓存击穿;不做任何注入)
418
+ delete req.headers['accept-encoding']; // 要就地改字节,让上游发未压缩的
419
+ interceptHtmlResponse(res);
420
+ Promise.resolve(vsServer.handleRequest(req, res)).catch((error) => {
421
+ log(`handleRequest(html) failed: ${error && error.stack ? error.stack : error}`);
422
+ try { res.end(); } catch { /* ignore */ }
423
+ });
424
+ return;
425
+ }
426
+ if (!args.disableProxy) {
427
+ const target = proxyTarget(url);
428
+ if (target !== null) { handleProxy(req, res, target); return; }
429
+ }
430
+ Promise.resolve(vsServer.handleRequest(req, res)).catch((error) => {
431
+ log(`handleRequest failed: ${error && error.stack ? error.stack : error}`);
432
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
433
+ if (!res.writableEnded) res.end('internal error');
434
+ });
435
+ });
436
+
437
+ server.on('upgrade', (req, socket, head) => {
438
+ const url = req.url ?? '/';
439
+ recentUpgrades.push(url);
440
+ if (recentUpgrades.length > 10) recentUpgrades.shift();
441
+ if (!hostAllowed(req)) {
442
+ log(`拒绝 Host 头的 WebSocket: host=${req.headers.host ?? '-'} url=${url}`);
443
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
444
+ return;
445
+ }
446
+ const stripped = stripToken(url);
447
+ if (stripped === null) {
448
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
449
+ return;
450
+ }
451
+ req.url = stripped;
452
+ // 跨源防护(与 code-server 一致:只卡 upgrade,HTTP 侧 VS Code 仅接受 GET 且状态变更都走 WS)
453
+ if (!originAllowed(req)) {
454
+ log(`拒绝跨源 WebSocket:origin=${req.headers.origin} host=${req.headers.host ?? '-'} url=${url}`);
455
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
456
+ return;
457
+ }
458
+ if (!args.disableProxy) {
459
+ const target = proxyTarget(stripped);
460
+ if (target !== null) { handleProxyUpgrade(req, socket, head, target); return; }
461
+ }
462
+ socket.pause();
463
+ req.ws = socket;
464
+ req.head = head;
465
+ try {
466
+ vsServer.handleUpgrade(req, socket);
467
+ } catch (error) {
468
+ log(`handleUpgrade threw: ${error && error.message ? error.message : error}`);
469
+ socket.destroy();
470
+ }
471
+ socket.resume();
472
+ });
473
+
474
+ // ---------------------------------------------------------------- 生命周期
475
+
476
+ let shuttingDown = false;
477
+ async function shutdown(reason, code = 0) {
478
+ if (shuttingDown) return;
479
+ shuttingDown = true;
480
+ log(`shutting down (${reason})`);
481
+ try { await vsServer?.dispose?.(); } catch (error) { log(`dispose failed: ${error && error.message}`); }
482
+ const done = () => process.exit(code);
483
+ try { server.close(done); } catch { done(); }
484
+ setTimeout(() => process.exit(code), 3000).unref();
485
+ }
486
+
487
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { void shutdown(sig); });
488
+
489
+ // 父进程(DSH host)消失 → 自行退出,避免留下孤儿 IDE 进程
490
+ if (Number.isFinite(args.parentPid) && args.parentPid > 0) {
491
+ const parentPid = args.parentPid;
492
+ const timer = setInterval(() => {
493
+ try {
494
+ process.kill(parentPid, 0);
495
+ } catch (error) {
496
+ if (error && error.code === 'ESRCH') void shutdown(`parent ${parentPid} gone`);
497
+ }
498
+ }, 5000);
499
+ timer.unref();
500
+ }
501
+
502
+ // ---------------------------------------------------------------- 启动
503
+
504
+ try {
505
+ vsServer = await loadVscodeServer();
506
+ } catch (error) {
507
+ fail(`加载 VS Code server 失败: ${error && error.stack ? error.stack : error}`);
508
+ }
509
+
510
+ const listenTarget = args.pipe === null ? { host: args.host, port: args.port } : args.pipe;
511
+ server.on('error', (error) => { fail(`监听失败(${JSON.stringify(listenTarget)}): ${error.message}`); });
512
+
513
+ /** 把**实际**监听地址写给 host(端口 0 时 host 只能从这里知道端口;原子写避免读到半截)。 */
514
+ function writeEndpoint(addr) {
515
+ if (args.endpointFile === null) return;
516
+ const payload = {
517
+ host: addr.address,
518
+ port: addr.port,
519
+ pid: process.pid,
520
+ productPath,
521
+ mode: args.pipe === null ? 'tcp' : 'pipe',
522
+ tokenized: prefix !== null,
523
+ startedAt: Date.now(),
524
+ };
525
+ try {
526
+ const tmp = `${args.endpointFile}.${process.pid}.tmp`;
527
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
528
+ renameSync(tmp, args.endpointFile);
529
+ } catch (error) {
530
+ log(`写 endpoint 文件失败(${args.endpointFile}): ${error.message}`);
531
+ }
532
+ }
533
+
534
+ server.listen(listenTarget, () => {
535
+ const addr = server.address();
536
+ const shown = typeof addr === 'string' ? addr : `${addr.address}:${addr.port}`;
537
+ if (typeof addr === 'object' && addr !== null) boundPort = addr.port;
538
+ if (typeof addr === 'object' && addr !== null) writeEndpoint(addr);
539
+ log(`listening on ${shown} (tree=${tree})`);
540
+ log(`user-data-dir=${userDataDir} extensions-dir=${extensionsDir}`);
541
+ log(`path token: ${prefix === null ? '(无)' : '已启用(URL 前缀,不写日志)'}`);
542
+ console.log(`dshcs-ready ${productPath} ${args.pipe === null ? 'tcp' : 'pipe'} ${shown}`);
543
+ });
544
+
545
+ // 兜底:未捕获异常不应静默留下半死进程
546
+ process.on('uncaughtException', (error) => {
547
+ console.error('[dshcs-launcher] uncaughtException', error && error.stack ? error.stack : error);
548
+ void shutdown('uncaughtException', 1);
549
+ });