dsh-desktop-embed 0.0.0-stage → 1.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/index.js ADDED
@@ -0,0 +1,1049 @@
1
+ /**
2
+ * dsh-desktop-embed —— 宿主半区(装在**手机**侧那一份 DSH 上)。
3
+ *
4
+ * 要解决的问题
5
+ * ------------
6
+ * DSHA 的 WebView 只认 `http://127.0.0.1:<端口>` 这一个服务,任何**顶层**跳转到别的
7
+ * 地址都会被 `openExternal()` 交给系统浏览器。源码里那行判断写得很清楚:
8
+ *
9
+ * // 插件的 iframe / 内嵌预览保持 WebView 原有行为,只接管顶层导航。
10
+ * if (!request.isForMainFrame()) return false;
11
+ *
12
+ * 也就是说:**iframe 是被允许的,被拦掉的只有顶层跳转。** 于是只要让桌面版界面以
13
+ * `127.0.0.1` 的地址出现在一个 iframe 里,它就留在 DSHA 里,不会再被甩给浏览器。
14
+ *
15
+ * 但直接把中继地址塞进 iframe 不行:`/pair-accept` 下发的 `dsh_pair` 是
16
+ * `SameSite=Lax; Secure`,而 DSHA 又显式关掉了第三方 cookie
17
+ * (`cookies.setAcceptThirdPartyCookies(view, false)`),跨站的 iframe 拿不到它。
18
+ *
19
+ * 所以这里起一个**回环反向代理**(默认 127.0.0.1:8799):代理自己持有配对 cookie,
20
+ * 服务端注入到每一个上游请求里;浏览器侧完全不需要 cookie。iframe 指向代理,
21
+ * 代理把 `https://<中继>` 以 `http://127.0.0.1:8799` 同源端出来 —— 页面里的
22
+ * 绝对路径(`/assets/…`、`/plugins/…`、`/api/…`)和 WebSocket 全部照常工作。
23
+ *
24
+ * 路由(都挂在这台手机的 DSH 上)
25
+ * ------------------------------
26
+ * GET /embed iframe 外壳页(全屏,零外链)
27
+ * GET /embed/setup 无脚本配置表单
28
+ * POST /embed/setup 保存并立即配对一次,然后 303 到 /embed
29
+ * GET /api/dsh-embed/status 同源 JSON 诊断
30
+ *
31
+ * 配置来源(命中即停):本行 config → 环境变量 → `$DSH_HOME/dsh-embed.json`
32
+ *
33
+ * {
34
+ * "bridgeUrl": "http://192.168.100.2:8791", // dsha-pair/bridge.py
35
+ * "bridgeKey": "<bridge-secret.txt 的内容>",
36
+ * "proxyPort": 8799
37
+ * }
38
+ *
39
+ * 配对流程:`GET {bridgeUrl}/link` 铸一枚码 → 取回 `pair-accept?pair=…` 的
40
+ * `Set-Cookie: dsh_pair=…`(不跟随重定向,只留 cookie)→ 中继 origin 由该链接推出。
41
+ * cookie 只留在内存里,进程重启就重铸一枚;设备会话本身仍然沿用
42
+ * `~/.dsh/remote-web-ui-devices.json`,重启 `dsh web` 不会掉。
43
+ */
44
+ import { createHash, randomBytes } from 'node:crypto'
45
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
46
+ import { Agent as HttpAgent, createServer, request as httpRequest } from 'node:http'
47
+ import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'
48
+ import { createRequire } from 'node:module'
49
+ import { homedir } from 'node:os'
50
+ import { dirname, join, resolve } from 'node:path'
51
+ import { fileURLToPath } from 'node:url'
52
+
53
+ const NS = 'dsh-embed'
54
+ const EMBED_PATH = '/embed'
55
+ const SETUP_PATH = '/embed/setup'
56
+ const STATUS_PATH = '/api/dsh-embed/status'
57
+ const DEFAULT_PROXY_PORT = 8799
58
+ const CONFIG_FILE_NAME = 'dsh-embed.json'
59
+ /** cookie 由 `/embed` 下发、由代理校验;都绑在 127.0.0.1 上(cookie 不区分端口)。 */
60
+ const GATE_COOKIE = 'dsh_embed'
61
+ const TARGET_COOKIE = 'dsh_pair'
62
+
63
+ /**
64
+ * 这两条是「第一次打开要等一两分钟」的主要来源:每个资源都新建一条 TCP+TLS
65
+ * 连接,手机上每次握手几百毫秒,几十个资源排下来就是一分钟。改成 keep-alive
66
+ * 池以后同一台中继上的请求复用同一条连接。
67
+ */
68
+ const httpAgent = new HttpAgent({ keepAlive: true, keepAliveMsecs: 20000, maxSockets: 16 })
69
+ const httpsAgent = new HttpsAgent({ keepAlive: true, keepAliveMsecs: 20000, maxSockets: 16 })
70
+
71
+ /** 静态资源(带内容哈希的文件名)可以长期缓存;入口页 5 分钟内有效。 */
72
+ const MAX_CACHE_ENTRY = 4 * 1024 * 1024
73
+ const MAX_CACHE_BYTES = 96 * 1024 * 1024
74
+ const ENTRY_TTL_MS = 5 * 60 * 1000
75
+
76
+ function agentFor(target) {
77
+ return target.protocol === 'https:' ? httpsAgent : httpAgent
78
+ }
79
+
80
+ /**
81
+ * 定位随 harness 一起安装的 schemastery;找不到就不导出 Config,
82
+ * 插件仍然可以用文件 / 环境变量配置。裸 `import` 在插件目录里解析不到,
83
+ * 会把整个插件一起拖死。
84
+ * @returns {any} schemastery 的 z 函数,或 undefined
85
+ */
86
+ function loadSchemastery() {
87
+ const requireFrom = createRequire(import.meta.url)
88
+ const candidates = ['@deepseek-ai/schemastery']
89
+ const argv1 = process.argv[1]
90
+ if (typeof argv1 === 'string' && argv1) {
91
+ const dir = dirname(resolve(argv1))
92
+ candidates.push(join(dir, '..', 'node_modules', '@deepseek-ai', 'schemastery'))
93
+ candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'schemastery'))
94
+ }
95
+ try {
96
+ for (const base of requireFrom.resolve.paths?.('@deepseek-ai/schemastery') ?? []) {
97
+ candidates.push(join(base, '@deepseek-ai', 'schemastery'))
98
+ }
99
+ } catch {}
100
+ for (const spec of candidates) {
101
+ try {
102
+ const mod = requireFrom(spec)
103
+ const z = mod?.default ?? mod
104
+ if (typeof z === 'function') return z
105
+ } catch {}
106
+ }
107
+ return undefined
108
+ }
109
+
110
+ const z = loadSchemastery()
111
+ const configSchema = z?.object({
112
+ bridgeUrl: z.string().description('中转地址,例如 http://192.168.100.2:8791(dsha-pair/bridge.py)'),
113
+ bridgeKey: z.string().description('中转密钥,就是 bridge-secret.txt 的内容'),
114
+ proxyPort: z.natural().description('回环代理端口,默认 8799'),
115
+ })
116
+
117
+ function dshHome() {
118
+ const fromEnv = process.env.DSH_HOME
119
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return resolve(fromEnv.trim())
120
+ return join(homedir(), '.dsh')
121
+ }
122
+
123
+ function configPath() {
124
+ return join(dshHome(), CONFIG_FILE_NAME)
125
+ }
126
+
127
+ function readConfigFile() {
128
+ const file = configPath()
129
+ if (!existsSync(file)) return {}
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
132
+ return parsed && typeof parsed === 'object' ? parsed : {}
133
+ } catch {
134
+ return {}
135
+ }
136
+ }
137
+
138
+ function writeConfigFile(next) {
139
+ const file = configPath()
140
+ const tmp = `${file}.tmp`
141
+ const payload = `${JSON.stringify(next, null, 2)}\n`
142
+ writeFileSync(tmp, payload, { mode: 0o600 })
143
+ try {
144
+ renameSync(tmp, file)
145
+ } catch {
146
+ writeFileSync(file, payload, { mode: 0o600 })
147
+ }
148
+ }
149
+
150
+ /** 逐字段读配置:Cordis 的 config 是代理对象,整块展开可能抛错。 */
151
+ function resolveConfig(live) {
152
+ const fromFile = readConfigFile()
153
+ const pick = (name, envName, fallback) => {
154
+ let value
155
+ try {
156
+ value = live?.[name]
157
+ } catch {
158
+ value = undefined
159
+ }
160
+ if (typeof value === 'string' && value.trim()) return value.trim()
161
+ if (typeof value === 'number' && Number.isFinite(value)) return value
162
+ const env = process.env[envName]
163
+ if (typeof env === 'string' && env.trim()) return env.trim()
164
+ const file = fromFile[name]
165
+ if (typeof file === 'string' && file.trim()) return file.trim()
166
+ if (typeof file === 'number' && Number.isFinite(file)) return file
167
+ return fallback
168
+ }
169
+ const port = Number(pick('proxyPort', 'DSH_EMBED_PORT', DEFAULT_PROXY_PORT))
170
+ return {
171
+ bridgeUrl: String(pick('bridgeUrl', 'DSH_EMBED_BRIDGE_URL', '')).replace(/\/+$/, ''),
172
+ bridgeKey: String(pick('bridgeKey', 'DSH_EMBED_BRIDGE_KEY', '')),
173
+ proxyPort: Number.isInteger(port) && port >= 1024 && port <= 65535 ? port : DEFAULT_PROXY_PORT,
174
+ }
175
+ }
176
+
177
+ /** 会话状态:中继 origin + 配对 cookie,全在内存里。 */
178
+ function createState() {
179
+ return {
180
+ relay: '',
181
+ cookie: '',
182
+ mintedAt: 0,
183
+ lastError: '',
184
+ server: null,
185
+ actualPort: 0,
186
+ gate: randomBytes(8).toString('hex'),
187
+ minting: null,
188
+ cache: new Map(),
189
+ cacheBytes: 0,
190
+ inflight: new Map(),
191
+ warming: null,
192
+ warm: null,
193
+ }
194
+ }
195
+
196
+ function escapeHtml(value) {
197
+ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
198
+ '&': '&amp;',
199
+ '<': '&lt;',
200
+ '>': '&gt;',
201
+ '"': '&quot;',
202
+ "'": '&#39;',
203
+ })[ch])
204
+ }
205
+
206
+ function sendHtml(res, status, body) {
207
+ const raw = Buffer.from(body, 'utf8')
208
+ res.writeHead(status, {
209
+ 'content-type': 'text/html; charset=utf-8',
210
+ 'content-length': String(raw.length),
211
+ 'cache-control': 'no-store',
212
+ })
213
+ res.end(raw)
214
+ }
215
+
216
+ function sendJson(res, status, obj) {
217
+ const raw = Buffer.from(`${JSON.stringify(obj)}\n`, 'utf8')
218
+ res.writeHead(status, {
219
+ 'content-type': 'application/json; charset=utf-8',
220
+ 'content-length': String(raw.length),
221
+ 'cache-control': 'no-store',
222
+ })
223
+ res.end(raw)
224
+ }
225
+
226
+ function parseCookies(header) {
227
+ const out = {}
228
+ if (typeof header !== 'string' || !header) return out
229
+ for (const part of header.split(';')) {
230
+ const idx = part.indexOf('=')
231
+ if (idx < 0) continue
232
+ const name = part.slice(0, idx).trim()
233
+ if (!name) continue
234
+ out[name] = part.slice(idx + 1).trim()
235
+ }
236
+ return out
237
+ }
238
+
239
+ /**
240
+ * 铸一次配对:bridge 铸码 → 取回 pair-accept 的 Set-Cookie。
241
+ * 只留 `dsh_pair`,不跟随 303(跟过去只会把设备会话又打一次)。
242
+ */
243
+ async function mint(cfg) {
244
+ if (!cfg.bridgeUrl) throw new Error('还没配置中转地址(bridgeUrl)')
245
+ const linkRes = await fetch(`${cfg.bridgeUrl}/link`, {
246
+ headers: cfg.bridgeKey ? { 'x-pair-key': cfg.bridgeKey } : {},
247
+ redirect: 'manual',
248
+ })
249
+ if (!linkRes.ok) throw new Error(`中转返回 HTTP ${linkRes.status}`)
250
+ const link = await linkRes.json()
251
+ const pairUrl = typeof link?.url === 'string' ? link.url : ''
252
+ if (!pairUrl) throw new Error('中转没有返回配对链接')
253
+ const accept = await fetch(pairUrl, { redirect: 'manual' })
254
+ const cookies = typeof accept.headers.getSetCookie === 'function' ? accept.headers.getSetCookie() : []
255
+ const pair = cookies
256
+ .map((line) => line.split(';')[0].trim())
257
+ .find((line) => line.startsWith(`${TARGET_COOKIE}=`))
258
+ if (!pair) throw new Error(`配对链接没有下发 cookie(HTTP ${accept.status})`)
259
+ return { relay: new URL(pairUrl).origin, cookie: pair }
260
+ }
261
+
262
+ /** 同一时刻只铸一次。 */
263
+ function ensureSession(state, cfg) {
264
+ if (state.minting) return state.minting
265
+ state.minting = (async () => {
266
+ try {
267
+ const { relay, cookie } = await mint(cfg)
268
+ state.relay = relay
269
+ state.cookie = cookie
270
+ state.mintedAt = Date.now()
271
+ state.lastError = ''
272
+ } catch (error) {
273
+ state.lastError = error?.message ?? String(error)
274
+ throw error
275
+ } finally {
276
+ state.minting = null
277
+ }
278
+ })()
279
+ return state.minting
280
+ }
281
+
282
+ /** 直接向中继取一份(预热专用,不走浏览器那一层)。 */
283
+ function fetchThrough(state, path, method = 'GET') {
284
+ return new Promise((resolvePromise, rejectPromise) => {
285
+ const target = new URL(path, state.relay)
286
+ const mod = target.protocol === 'https:' ? httpsRequest : httpRequest
287
+ const req = mod(
288
+ {
289
+ protocol: target.protocol,
290
+ hostname: target.hostname,
291
+ port: target.port || undefined,
292
+ method,
293
+ path: upstreamPath(path),
294
+ headers: {
295
+ host: target.host,
296
+ cookie: state.cookie,
297
+ accept: '*/*',
298
+ 'accept-encoding': 'identity',
299
+ 'user-agent': 'dsh-embed-prewarm/1.1',
300
+ },
301
+ agent: agentFor(target),
302
+ },
303
+ (up) => {
304
+ const chunks = []
305
+ let size = 0
306
+ up.on('data', (chunk) => {
307
+ size += chunk.length
308
+ if (size > MAX_CACHE_ENTRY * 2) {
309
+ up.destroy()
310
+ rejectPromise(new Error('响应超过预热上限'))
311
+ return
312
+ }
313
+ chunks.push(chunk)
314
+ })
315
+ up.on('end', () =>
316
+ resolvePromise({ status: up.statusCode ?? 0, type: String(up.headers['content-type'] || ''), body: Buffer.concat(chunks) }),
317
+ )
318
+ up.on('error', rejectPromise)
319
+ },
320
+ )
321
+ req.on('error', rejectPromise)
322
+ req.end()
323
+ })
324
+ }
325
+
326
+ /** 从入口页里挑出要预热的资源(只认本地的 /assets、/plugins)。 */
327
+ function extractAssets(html) {
328
+ const out = []
329
+ const seen = new Set()
330
+ const re = /(?:src|href)\s*=\s*["']([^"']+)["']/g
331
+ let match
332
+ while ((match = re.exec(html))) {
333
+ let value = match[1].trim()
334
+ if (!value || value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://')) continue
335
+ if (value.startsWith('./')) value = value.slice(1)
336
+ // 入口页里有两种写法:`./assets/x.js` 和裸相对的 `plugins/??a,b,c`
337
+ if (!value.startsWith('/') && (value.startsWith('assets/') || value.startsWith('plugins/'))) value = `/${value}`
338
+ if (!value.startsWith('/assets/') && !value.startsWith('/plugins/')) continue
339
+ if (seen.has(value)) continue
340
+ seen.add(value)
341
+ out.push(value)
342
+ }
343
+ return out
344
+ }
345
+
346
+ /**
347
+ * 预热:用户点按钮之前,先把入口页和它引用的资源拉进内存缓存。
348
+ * 这样 iframe 真正启动时,界面框架的那几十个请求全部命中本地缓存。
349
+ */
350
+ function prewarm(state, cfg) {
351
+ if (state.warming) return state.warming
352
+ state.warming = (async () => {
353
+ const started = Date.now()
354
+ const report = { ok: false, assets: 0, fetched: 0, bytes: 0, ms: 0, at: Date.now(), error: '' }
355
+ try {
356
+ await ensureSession(state, cfg)
357
+ const entry = await fetchThrough(state, ENTRY_PATH)
358
+ if (entry.status !== 200) throw new Error(`入口页 HTTP ${entry.status}`)
359
+ cacheSet(state, ENTRY_PATH, 'text/html; charset=utf-8', entry.body)
360
+ report.bytes += entry.body.length
361
+ const list = extractAssets(entry.body.toString('utf8')).slice(0, 32)
362
+ report.assets = list.length
363
+ let cursor = 0
364
+ const worker = async () => {
365
+ while (cursor < list.length) {
366
+ const item = list[cursor++]
367
+ if (cacheGet(state, item)) {
368
+ report.fetched += 1
369
+ continue
370
+ }
371
+ try {
372
+ const got = await fetchThrough(state, item)
373
+ if (got.status === 200) {
374
+ cacheSet(state, item, got.type, got.body)
375
+ report.bytes += got.body.length
376
+ report.fetched += 1
377
+ }
378
+ } catch {}
379
+ }
380
+ }
381
+ await Promise.all([worker(), worker(), worker(), worker()])
382
+ report.ok = true
383
+ } catch (error) {
384
+ report.error = error?.message ?? String(error)
385
+ state.lastError = report.error
386
+ } finally {
387
+ report.ms = Date.now() - started
388
+ state.warm = report
389
+ state.warming = null
390
+ }
391
+ })()
392
+ return state.warming
393
+ }
394
+
395
+ const HOP_BY_HOP = new Set([
396
+ 'connection',
397
+ 'keep-alive',
398
+ 'proxy-authenticate',
399
+ 'proxy-authorization',
400
+ 'te',
401
+ 'trailer',
402
+ 'transfer-encoding',
403
+ 'upgrade',
404
+ 'host',
405
+ 'cookie',
406
+ 'origin',
407
+ 'referer',
408
+ 'accept-encoding',
409
+ ])
410
+
411
+ function upstreamHeaders(incoming, cookie, host) {
412
+ const headers = {}
413
+ for (const [name, value] of Object.entries(incoming)) {
414
+ const key = name.toLowerCase()
415
+ if (HOP_BY_HOP.has(key)) continue
416
+ if (key.startsWith('sec-fetch-')) continue
417
+ if (value === undefined) continue
418
+ headers[key] = value
419
+ }
420
+ headers.host = host
421
+ headers.cookie = cookie
422
+ headers['accept-encoding'] = 'identity'
423
+ return headers
424
+ }
425
+
426
+ /** 下游不能看到上游的 cookie,也不能让它声明自己不许被嵌。 */
427
+ const STRIPPED_RESPONSE = new Set([
428
+ 'set-cookie',
429
+ 'content-encoding',
430
+ 'content-length',
431
+ 'x-frame-options',
432
+ 'content-security-policy',
433
+ 'content-security-policy-report-only',
434
+ 'strict-transport-security',
435
+ 'alt-svc',
436
+ 'location',
437
+ ])
438
+
439
+ function downstreamHeaders(incoming, upstreamOrigin) {
440
+ const headers = {}
441
+ for (const [name, value] of Object.entries(incoming)) {
442
+ const key = name.toLowerCase()
443
+ if (STRIPPED_RESPONSE.has(key)) continue
444
+ if (value === undefined) continue
445
+ headers[key] = value
446
+ }
447
+ return headers
448
+ }
449
+
450
+ function rewriteLocation(value, upstreamOrigin) {
451
+ if (typeof value !== 'string' || !value) return value
452
+ try {
453
+ const url = new URL(value, upstreamOrigin)
454
+ if (url.origin === upstreamOrigin) return `${url.pathname}${url.search}${url.hash}`
455
+ return value
456
+ } catch {
457
+ return value
458
+ }
459
+ }
460
+
461
+ function gateOk(state, req) {
462
+ const cookies = parseCookies(req.headers.cookie)
463
+ return cookies[GATE_COOKIE] === state.gate
464
+ }
465
+
466
+ /**
467
+ * 代理入口映射:中继的 `/` 是「认证门」,带 cookie 也只回 401;
468
+ * 真正的应用页在 `/pair-app`。所以把根路径改写成它,其余路径原样透传
469
+ * (`<base href="./">` 让资源仍落在 `/assets/…`、`/plugins/…`)。
470
+ */
471
+ const ENTRY_PATH = '/pair-app'
472
+
473
+ function upstreamPath(rawUrl) {
474
+ const url = new URL(rawUrl || '/', 'http://placeholder.invalid')
475
+ const pathname = url.pathname || '/'
476
+ if (pathname === '/') return `${ENTRY_PATH}${url.search}`
477
+ // 中继把「已认证 API」挂在 /remote 下:GET 两个前缀都通,但 POST/PUT 只认
478
+ // /remote/api/*(裸 /api/* 会被桌面 api-gateway 以 403 forbidden 拒掉)。
479
+ // 浏览器侧只有在 relay 域名上运行时才会自己加这个前缀,127.0.0.1 不会,
480
+ // 所以这里统一补上。
481
+ if (pathname.startsWith('/api/')) return `/remote${pathname}${url.search}`
482
+ return `${pathname}${url.search}`
483
+ }
484
+
485
+ /** 只有静态资源进缓存:带内容哈希,路径一变就是新文件,不存在脏读。 */
486
+ function cacheableKey(req) {
487
+ if (req.method !== 'GET' && req.method !== 'HEAD') return ''
488
+ let pathname = '/'
489
+ try {
490
+ pathname = new URL(req.url || '/', 'http://placeholder.invalid').pathname || '/'
491
+ } catch {
492
+ return ''
493
+ }
494
+ if (pathname === '/') return ENTRY_PATH
495
+ if (pathname.startsWith('/assets/')) return pathname
496
+ if (pathname.startsWith('/plugins/')) return pathname
497
+ return ''
498
+ }
499
+
500
+ function cacheGet(state, key) {
501
+ const hit = state.cache.get(key)
502
+ if (!hit) return null
503
+ if (key === ENTRY_PATH && Date.now() - hit.at > ENTRY_TTL_MS) {
504
+ state.cache.delete(key)
505
+ state.cacheBytes -= hit.body.length
506
+ return null
507
+ }
508
+ // LRU:命中就挪到末尾
509
+ state.cache.delete(key)
510
+ state.cache.set(key, hit)
511
+ return hit
512
+ }
513
+
514
+ function cacheSet(state, key, type, body) {
515
+ if (!body || body.length === 0 || body.length > MAX_CACHE_ENTRY) return
516
+ const old = state.cache.get(key)
517
+ if (old) {
518
+ state.cache.delete(key)
519
+ state.cacheBytes -= old.body.length
520
+ }
521
+ state.cache.set(key, { type: type || 'application/octet-stream', body, at: Date.now() })
522
+ state.cacheBytes += body.length
523
+ while (state.cacheBytes > MAX_CACHE_BYTES && state.cache.size > 1) {
524
+ const [oldestKey, oldest] = state.cache.entries().next().value
525
+ state.cache.delete(oldestKey)
526
+ state.cacheBytes -= oldest.body.length
527
+ }
528
+ }
529
+
530
+ /**
531
+ * 手机上的排版修补:直接注入 iframe 里那份文档。
532
+ * iframe 与外层不同源(端口不同),外层改不了它的 DOM,只能在代理这一层注入;
533
+ * 全部规则都在 `(max-width: 1023px) and (pointer: coarse)` 里,桌面上完全无副作用。
534
+ */
535
+ const MOBILE_CSS = `@media (max-width: 1023px) and (pointer: coarse) {
536
+ html { -webkit-text-size-adjust: 100%; }
537
+ body { overflow-x: hidden; }
538
+ button, [role="button"], [role="tab"], [role="menuitem"], [role="option"] { min-height: 40px; min-width: 40px; }
539
+ input, select, textarea { min-height: 40px; font-size: 16px; }
540
+ [class*="drawer"], aside { max-width: 88vw; }
541
+ table { display: block; overflow-x: auto; }
542
+ textarea { padding-bottom: max(10px, env(safe-area-inset-bottom)); }
543
+ }`
544
+
545
+ /** 给 iframe 里的文档报进度用;只 postMessage,不碰 DOM。 */
546
+ const PROGRESS_SCRIPT = `<script id="dsh-embed-progress">(function(){var send=function(t){try{parent.postMessage({source:'dsh-embed',type:t},'*')}catch(e){}};send('dom');addEventListener('load',function(){send('load')});var n=0,t=setInterval(function(){if(window.__DSH_BOOT_READY__){send('boot');clearInterval(t)}if(++n>480)clearInterval(t)},250)})()<\/script>`
547
+
548
+ function injectIntoHtml(html) {
549
+ const style = `<style id="dsh-embed-mobile">${MOBILE_CSS}</style>`
550
+ let out = html.includes('</head>') ? html.replace('</head>', `${style}</head>`) : style + html
551
+ out = out.includes('</body>') ? out.replace('</body>', `${PROGRESS_SCRIPT}</body>`) : out + PROGRESS_SCRIPT
552
+ return out
553
+ }
554
+
555
+ /**
556
+ * 通用反向代理:路径、方法、请求体、响应体一律原样转发,cookie 服务端注入。
557
+ * 上游 401 时重铸一次再重试(设备会话过期 / cookie 被顶掉的常见情形)。
558
+ */
559
+ function proxyOnce(state, clientReq, clientRes, retried = false) {
560
+ if (!state.relay || !state.cookie) {
561
+ return sendHtml(
562
+ clientRes,
563
+ 503,
564
+ page('还没配对', '<p>这个代理还没有拿到配对 cookie。</p><p><a href="/embed/setup">先配置中转</a></p>'),
565
+ )
566
+ }
567
+ const target = new URL(clientReq.url || '/', state.relay)
568
+ if (target.origin !== state.relay) {
569
+ return sendHtml(clientRes, 400, page('地址不对', '<p>只代理同一个中继。</p>'))
570
+ }
571
+ const cacheKey = cacheableKey(clientReq)
572
+ if (cacheKey) {
573
+ const hit = cacheGet(state, cacheKey)
574
+ if (hit) {
575
+ // 入口页命中也要补注入(缓存里存的是原始 HTML),
576
+ // 注入后长度会变,content-length 必须按最终 body 算,否则响应会被截断。
577
+ const body = cacheKey === ENTRY_PATH ? Buffer.from(injectIntoHtml(hit.body.toString('utf8'))) : hit.body
578
+ clientRes.writeHead(200, {
579
+ 'content-type': hit.type,
580
+ 'content-length': String(body.length),
581
+ 'cache-control': cacheKey === ENTRY_PATH ? 'no-cache' : 'public, max-age=31536000, immutable',
582
+ 'x-dsh-embed-cache': 'hit',
583
+ })
584
+ clientRes.end(body)
585
+ return
586
+ }
587
+ // 预热和真实请求撞在一起时只发一次上游请求
588
+ const pending = state.inflight.get(cacheKey)
589
+ if (pending) {
590
+ pending
591
+ .catch(() => {})
592
+ .then(() => proxyOnce(state, clientReq, clientRes, retried))
593
+ return
594
+ }
595
+ }
596
+
597
+ const mod = target.protocol === 'https:' ? httpsRequest : httpRequest
598
+ const wanted = upstreamPath(clientReq.url)
599
+ const upstream = mod({
600
+ protocol: target.protocol,
601
+ hostname: target.hostname,
602
+ port: target.port || undefined,
603
+ method: clientReq.method,
604
+ path: wanted,
605
+ headers: upstreamHeaders(clientReq.headers, state.cookie, target.host),
606
+ agent: agentFor(target),
607
+ })
608
+ if (cacheKey) {
609
+ let settle
610
+ const done = new Promise((r) => {
611
+ settle = r
612
+ })
613
+ state.inflight.set(cacheKey, done)
614
+ const clear = () => {
615
+ if (state.inflight.get(cacheKey) === done) state.inflight.delete(cacheKey)
616
+ settle()
617
+ }
618
+ upstream.on('close', clear)
619
+ upstream.on('error', clear)
620
+ upstream.on('response', (up) => {
621
+ up.on('end', clear)
622
+ up.on('error', clear)
623
+ })
624
+ }
625
+ upstream.on('response', (up) => {
626
+ if (up.statusCode === 401 && !retried && state.canRemint) {
627
+ up.resume()
628
+ return state
629
+ .remint()
630
+ .then(() => proxyOnce(state, clientReq, clientRes, true))
631
+ .catch((error) => proxyOnce(state, clientReq, clientRes, true) ?? sendHtml(clientRes, 502, page('上游失败', `<p>${escapeHtml(error?.message)}</p>`)))
632
+ }
633
+ const type = String(up.headers['content-type'] || '')
634
+ const status = up.statusCode ?? 502
635
+
636
+ // 入口页:缓冲下来做两件事——写进缓存、注入手机排版与进度脚本
637
+ if (status === 200 && type.includes('text/html') && (cacheKey === ENTRY_PATH || wanted === ENTRY_PATH)) {
638
+ const chunks = []
639
+ up.on('data', (chunk) => chunks.push(chunk))
640
+ up.on('end', () => {
641
+ const raw = Buffer.concat(chunks)
642
+ cacheSet(state, ENTRY_PATH, 'text/html; charset=utf-8', raw)
643
+ const body = Buffer.from(injectIntoHtml(raw.toString('utf8')))
644
+ const headers = downstreamHeaders(up.headers, state.relay)
645
+ headers['content-type'] = 'text/html; charset=utf-8'
646
+ headers['content-length'] = String(body.length)
647
+ headers['cache-control'] = 'no-cache'
648
+ headers['x-dsh-embed-injected'] = 'mobile'
649
+ clientRes.writeHead(200, headers)
650
+ clientRes.end(body)
651
+ })
652
+ up.on('error', () => {
653
+ if (!clientRes.headersSent) sendHtml(clientRes, 502, page('上游失败', '<p>入口页读取中断。</p>'))
654
+ else clientRes.destroy()
655
+ })
656
+ return
657
+ }
658
+
659
+ // 静态资源:边转发边存,超过上限就放弃缓存但照常转发
660
+ if (status === 200 && cacheKey && !type.includes('text/html')) {
661
+ const chunks = []
662
+ let size = 0
663
+ let streaming = false
664
+ up.on('data', (chunk) => {
665
+ if (streaming) return
666
+ chunks.push(chunk)
667
+ size += chunk.length
668
+ if (size > MAX_CACHE_ENTRY) {
669
+ streaming = true
670
+ const headers = downstreamHeaders(up.headers, state.relay)
671
+ clientRes.writeHead(status, headers)
672
+ clientRes.write(Buffer.concat(chunks))
673
+ chunks.length = 0
674
+ }
675
+ })
676
+ up.on('end', () => {
677
+ if (streaming) {
678
+ clientRes.end()
679
+ return
680
+ }
681
+ const body = Buffer.concat(chunks)
682
+ cacheSet(state, cacheKey, type || 'application/octet-stream', body)
683
+ const headers = downstreamHeaders(up.headers, state.relay)
684
+ headers['content-length'] = String(body.length)
685
+ headers['cache-control'] = 'public, max-age=31536000, immutable'
686
+ headers['x-dsh-embed-cache'] = 'miss'
687
+ clientRes.writeHead(status, headers)
688
+ clientRes.end(body)
689
+ })
690
+ up.on('error', () => clientRes.destroy())
691
+ return
692
+ }
693
+
694
+ const headers = downstreamHeaders(up.headers, state.relay)
695
+ const location = rewriteLocation(up.headers.location, state.relay)
696
+ if (location) headers.location = location
697
+ clientRes.writeHead(status, headers)
698
+ up.pipe(clientRes)
699
+ })
700
+ upstream.on('error', (error) => {
701
+ if (!clientRes.headersSent) {
702
+ sendHtml(clientRes, 502, page('上游失败', `<p>${escapeHtml(error?.message ?? error)}</p>`))
703
+ } else {
704
+ clientRes.destroy()
705
+ }
706
+ })
707
+ clientReq.pipe(upstream)
708
+ }
709
+
710
+ /** WebSocket / 其它 upgrade:握手与后续字节全部双向打通。 */
711
+ function proxyUpgrade(state, req, socket, head) {
712
+ if (!gateOk(state, req) || !state.relay || !state.cookie) {
713
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n')
714
+ return
715
+ }
716
+ const target = new URL(req.url || '/', state.relay)
717
+ const mod = target.protocol === 'https:' ? httpsRequest : httpRequest
718
+ const headers = upstreamHeaders(req.headers, state.cookie, target.host)
719
+ headers.connection = 'Upgrade'
720
+ headers.upgrade = req.headers.upgrade || 'websocket'
721
+ if (req.headers['sec-websocket-key']) headers['sec-websocket-key'] = req.headers['sec-websocket-key']
722
+ if (req.headers['sec-websocket-version']) headers['sec-websocket-version'] = req.headers['sec-websocket-version']
723
+ const upstream = mod({
724
+ protocol: target.protocol,
725
+ hostname: target.hostname,
726
+ port: target.port || undefined,
727
+ method: 'GET',
728
+ path: upstreamPath(req.url),
729
+ headers,
730
+ agent: agentFor(target),
731
+ })
732
+ upstream.on('upgrade', (up, upSocket, upHead) => {
733
+ const lines = [`HTTP/1.1 101 ${up.statusMessage || 'Switching Protocols'}`]
734
+ const raw = up.rawHeaders || []
735
+ for (let i = 0; i + 1 < raw.length; i += 2) {
736
+ const name = String(raw[i]).toLowerCase()
737
+ if (name === 'upgrade' || name === 'connection' || name === 'sec-websocket-accept' || name === 'sec-websocket-protocol' || name === 'sec-websocket-extensions') {
738
+ lines.push(`${raw[i]}: ${raw[i + 1]}`)
739
+ }
740
+ }
741
+ if (!raw.some((name) => String(name).toLowerCase() === 'sec-websocket-accept')) {
742
+ socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
743
+ return
744
+ }
745
+ socket.write(`${lines.join('\r\n')}\r\n\r\n`)
746
+ if (upHead?.length) upSocket.unshift(upHead)
747
+ if (head?.length) upSocket.write(head)
748
+ upSocket.pipe(socket)
749
+ socket.pipe(upSocket)
750
+ const kill = () => {
751
+ upSocket.destroy()
752
+ socket.destroy()
753
+ }
754
+ upSocket.on('error', kill)
755
+ socket.on('error', kill)
756
+ upSocket.on('close', () => socket.destroy())
757
+ socket.on('close', () => upSocket.destroy())
758
+ })
759
+ upstream.on('response', (up) => {
760
+ const body = `上游拒绝了 upgrade(HTTP ${up.statusCode ?? 0})\n`
761
+ up.resume()
762
+ socket.end(`HTTP/1.1 ${up.statusCode ?? 502} Bad Gateway\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
763
+ })
764
+ upstream.on('error', () => socket.destroy())
765
+ upstream.end()
766
+ }
767
+
768
+ function page(title, body, extraHead = '') {
769
+ return `<!doctype html>
770
+ <html lang="zh"><head><meta charset="utf-8">
771
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
772
+ <title>${escapeHtml(title)}</title>
773
+ <style>
774
+ :root { color-scheme: light dark; }
775
+ body { font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; padding: 24px; }
776
+ main { max-width: 680px; margin: 0 auto; }
777
+ h1 { font-size: 20px; margin: 0 0 12px; }
778
+ p, li { color: #444; }
779
+ label { display: block; margin: 14px 0 6px; font-size: 14px; color: #555; }
780
+ input { width: 100%; box-sizing: border-box; font-size: 16px; padding: 10px 12px;
781
+ border: 1px solid #bbb; border-radius: 10px; }
782
+ button { margin-top: 18px; width: 100%; min-height: 44px; font-size: 16px;
783
+ border: 0; border-radius: 12px; background: #2b6cff; color: #fff; }
784
+ .note { font-size: 13px; color: #666; margin-top: 6px; }
785
+ a { color: #2b6cff; }
786
+ @media (prefers-color-scheme: dark) { p, li { color: #bbb; } input { background: #1b1b1b; color: #eee; border-color: #444; } .note { color: #999; } }
787
+ </style>${extraHead}</head><body><main>${body}</main></body></html>`
788
+ }
789
+
790
+ /** iframe 外壳:iframe + 一层加载进度(纯 CSS 动画,界面一就绪就淡出)。 */
791
+ function embedPage(state, port) {
792
+ const src = `http://127.0.0.1:${port}/`
793
+ const origin = `http://127.0.0.1:${port}`
794
+ const warm = state.warm?.ok ? `<span id="dsh-embed-warm">已预热 ${state.warm.fetched}/${state.warm.assets}</span>` : ''
795
+ const overlay = `<div id="dsh-embed-cover">
796
+ <div class="ring"></div>
797
+ <b>正在打开桌面版</b>
798
+ <p id="dsh-embed-step">正在建立配对…</p>
799
+ <span id="dsh-embed-time">0s</span>
800
+ ${warm}
801
+ <a href="/embed/setup">设置</a>
802
+ </div>`
803
+ const script = `<script>(function(){
804
+ var cover=document.getElementById('dsh-embed-cover')
805
+ var step=document.getElementById('dsh-embed-step')
806
+ var clock=document.getElementById('dsh-embed-time')
807
+ var frame=document.getElementById('dsh-embed-frame')
808
+ var t0=Date.now(), hints=[[0,'正在建立配对…'],[6,'正在下载界面资源(第一次最慢)…'],[20,'正在启动界面插件…'],[45,'中继这条路有点慢,还在下载…']]
809
+ var timer=setInterval(function(){
810
+ var s=Math.round((Date.now()-t0)/1000)
811
+ clock.textContent=s+'s'
812
+ var pick=0
813
+ for(var i=0;i<hints.length;i++){ if(s>=hints[i][0]) pick=i }
814
+ if(step.textContent!==hints[pick][1]) step.textContent=hints[pick][1]
815
+ },500)
816
+ function done(){
817
+ if(!cover||cover.dataset.done) return
818
+ cover.dataset.done='1'
819
+ window.__DSH_EMBED_READY_MS__=Math.round(performance.now())
820
+ clearInterval(timer)
821
+ cover.style.opacity='0'; cover.style.pointerEvents='none'
822
+ setTimeout(function(){ if(cover&&cover.parentNode) cover.parentNode.removeChild(cover) },500)
823
+ }
824
+ window.addEventListener('message',function(e){
825
+ if(e.origin!=='${origin}') return
826
+ var d=e.data
827
+ if(d&&d.source==='dsh-embed'&&(d.type==='load'||d.type==='boot')) done()
828
+ })
829
+ frame.addEventListener('load',function(){ setTimeout(done,600) })
830
+ setTimeout(function(){ if(cover&&!cover.dataset.done) step.textContent='还没回来:确认电脑上 bridge.py 在跑,或回设置页重填地址。' },180000)
831
+ })()<\/script>`
832
+ return page(
833
+ '桌面版 DSH',
834
+ `<iframe id="dsh-embed-frame" src="${escapeHtml(src)}" title="桌面版 DSH"
835
+ allow="clipboard-read; clipboard-write; microphone; camera"
836
+ style="position:fixed;inset:0;width:100%;height:100%;border:0;background:#fff"></iframe>
837
+ ${overlay}
838
+ <a href="/embed/setup" title="中转与端口设置"
839
+ style="position:fixed;right:10px;bottom:calc(10px + env(safe-area-inset-bottom));z-index:2147483000;
840
+ width:40px;height:40px;border-radius:20px;background:rgba(0,0,0,.45);color:#fff;
841
+ display:flex;align-items:center;justify-content:center;text-decoration:none;
842
+ font:18px/1 system-ui">⚙</a>
843
+ ${script}`,
844
+ `<style>
845
+ body{margin:0;padding:0;overflow:hidden}
846
+ #dsh-embed-cover{position:fixed;inset:0;z-index:2147483600;background:#0f1115;color:#e8e8ea;
847
+ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
848
+ font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;transition:opacity .4s}
849
+ #dsh-embed-cover b{font-size:17px;font-weight:600}
850
+ #dsh-embed-cover p{margin:0;color:#9aa0a6;font-size:14px;max-width:80vw;text-align:center}
851
+ #dsh-embed-cover span{color:#6b7280;font-size:13px}
852
+ #dsh-embed-cover a{color:#7aa2ff;font-size:14px;text-decoration:none;margin-top:4px}
853
+ .ring{width:38px;height:38px;border-radius:50%;border:3px solid rgba(255,255,255,.15);
854
+ border-top-color:#7aa2ff;animation:dsh-embed-spin 1s linear infinite}
855
+ @keyframes dsh-embed-spin{to{transform:rotate(360deg)}}
856
+ </style>`,
857
+ )
858
+ }
859
+
860
+ function setupPage(state, cfg, notice = '') {
861
+ const rows = [
862
+ ['bridgeUrl', '中转地址', cfg.bridgeUrl || 'http://192.168.100.2:8791', '运行 dsha-pair/bridge.py 的那台电脑,手机要能访问到。'],
863
+ ['bridgeKey', '中转密钥', cfg.bridgeKey || '', '就是 bridge-secret.txt 的内容。'],
864
+ ['proxyPort', '回环代理端口', String(cfg.proxyPort || DEFAULT_PROXY_PORT), '默认 8799,只有手机本机能访问。'],
865
+ ]
866
+ const body = `<h1>把桌面版嵌进 DSHA</h1>
867
+ <p>这台手机的 DSHA 会把任何顶层跳转交给系统浏览器,但**不会**拦截 iframe。
868
+ 所以这个插件在手机里起一个回环代理,把电脑上的界面以 <code>127.0.0.1</code> 端出来,
869
+ 再用 iframe 嵌回 DSHA 自己的页面。</p>
870
+ ${notice ? `<p style="color:#b00">${escapeHtml(notice)}</p>` : ''}
871
+ <p class="note">当前状态:${state.relay ? `已配对 ${escapeHtml(state.relay)}` : '还未配对'}${state.actualPort ? ` · 代理端口 ${state.actualPort}` : ''}</p>
872
+ <form method="post" action="/embed/setup">
873
+ ${rows
874
+ .map(
875
+ ([name, label, value, hint]) =>
876
+ `<label for="${name}">${label}</label>
877
+ <input id="${name}" name="${name}" value="${escapeHtml(value)}" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false">
878
+ <div class="note">${escapeHtml(hint)}</div>`,
879
+ )
880
+ .join('\n ')}
881
+ <button type="submit">保存并配对</button>
882
+ </form>
883
+ <p class="note">电脑侧要先跑起来:<code>python bridge.py</code>(默认 8791)。
884
+ 配对只是把一枚 <code>dsh_pair</code> cookie 拿在代理手里,cookie 不会进浏览器。</p>`
885
+ return page('中转设置', body)
886
+ }
887
+
888
+ function readForm(req, limit = 8192) {
889
+ return new Promise((resolvePromise) => {
890
+ let size = 0
891
+ const chunks = []
892
+ req.on('data', (chunk) => {
893
+ size += chunk.length
894
+ if (size > limit) {
895
+ req.destroy()
896
+ resolvePromise(null)
897
+ return
898
+ }
899
+ chunks.push(chunk)
900
+ })
901
+ req.on('end', () => {
902
+ try {
903
+ resolvePromise(new URLSearchParams(Buffer.concat(chunks).toString('utf8')))
904
+ } catch {
905
+ resolvePromise(null)
906
+ }
907
+ })
908
+ req.on('error', () => resolvePromise(null))
909
+ })
910
+ }
911
+
912
+ async function apply(ctx, rawConfig) {
913
+ const state = createState()
914
+ const cfgOf = () => resolveConfig(typeof rawConfig === 'function' ? rawConfig() : rawConfig)
915
+
916
+ state.remint = () => ensureSession(state, cfgOf())
917
+ state.canRemint = true
918
+
919
+ // 配置里已经有中转地址就先把配对拿好、把资源预热好。
920
+ // 既修掉「重启 DSHA 后不重填设置就打不开」,也让用户点按钮时资源已经在内存里。
921
+ if (cfgOf().bridgeUrl) void prewarm(state, cfgOf())
922
+
923
+ // ---- 回环代理 -----------------------------------------------------------
924
+ const proxy = createServer((req, res) => {
925
+ if (!gateOk(state, req)) {
926
+ return sendHtml(
927
+ res,
928
+ 403,
929
+ page(
930
+ '需要从 /embed 打开',
931
+ '<p>这个代理只服务于 <a href="/embed">/embed</a> 那个页面。直接从地址栏打开是拿不到通行 cookie 的。</p>',
932
+ ),
933
+ )
934
+ }
935
+ proxyOnce(state, req, res)
936
+ })
937
+ proxy.on('upgrade', (req, socket, head) => proxyUpgrade(state, req, socket, head))
938
+
939
+ const cfg = cfgOf()
940
+ const port = cfg.proxyPort
941
+ await new Promise((resolvePromise) => {
942
+ proxy.once('error', (error) => {
943
+ state.lastError = `端口 ${port} 起不来:${error?.message ?? error}`
944
+ resolvePromise(null)
945
+ })
946
+ proxy.listen(port, '127.0.0.1', () => {
947
+ state.server = proxy
948
+ state.actualPort = proxy.address()?.port ?? port
949
+ resolvePromise(null)
950
+ })
951
+ })
952
+
953
+ // ---- 页面与接口 ---------------------------------------------------------
954
+ const embedRoute = ctx.webServer.register({
955
+ kind: 'exact',
956
+ path: EMBED_PATH,
957
+ handler: (req, res) => {
958
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
959
+ res.writeHead(405, { allow: 'GET' })
960
+ return res.end()
961
+ }
962
+ const portNow = state.actualPort || port
963
+ res.setHeader(
964
+ 'set-cookie',
965
+ `${GATE_COOKIE}=${state.gate}; Path=/; HttpOnly; SameSite=Lax`,
966
+ )
967
+ sendHtml(res, 200, embedPage(state, portNow))
968
+ },
969
+ })
970
+
971
+ const setupRoute = ctx.webServer.register({
972
+ kind: 'exact',
973
+ path: SETUP_PATH,
974
+ handler: async (req, res) => {
975
+ if (req.method === 'GET' || req.method === 'HEAD') {
976
+ return sendHtml(res, 200, setupPage(state, cfgOf()))
977
+ }
978
+ if (req.method !== 'POST') {
979
+ res.writeHead(405, { allow: 'GET, POST' })
980
+ return res.end()
981
+ }
982
+ const form = await readForm(req)
983
+ if (!form) return sendHtml(res, 400, setupPage(state, cfgOf(), '提交内容太大或格式不对。'))
984
+ const next = {
985
+ bridgeUrl: String(form.get('bridgeUrl') ?? '').trim().replace(/\/+$/, ''),
986
+ bridgeKey: String(form.get('bridgeKey') ?? '').trim(),
987
+ proxyPort: Number(form.get('proxyPort') ?? DEFAULT_PROXY_PORT),
988
+ }
989
+ if (!/^https?:\/\/[^\s]+$/i.test(next.bridgeUrl)) {
990
+ return sendHtml(res, 400, setupPage(state, next, '中转地址要写成 http:// 或 https:// 开头的完整地址。'))
991
+ }
992
+ if (!Number.isInteger(next.proxyPort) || next.proxyPort < 1024 || next.proxyPort > 65535) {
993
+ return sendHtml(res, 400, setupPage(state, next, '端口要在 1024–65535 之间。'))
994
+ }
995
+ writeConfigFile(next)
996
+ try {
997
+ await ensureSession(state, next)
998
+ } catch (error) {
999
+ return sendHtml(res, 502, setupPage(state, next, `配对失败:${error?.message ?? error}`))
1000
+ }
1001
+ void prewarm(state, next)
1002
+ res.writeHead(303, { location: EMBED_PATH, 'cache-control': 'no-store' })
1003
+ res.end()
1004
+ },
1005
+ })
1006
+
1007
+ const statusRoute = ctx.webServer.register({
1008
+ kind: 'exact',
1009
+ path: STATUS_PATH,
1010
+ handler: (req, res) => {
1011
+ if (req.method !== 'GET') {
1012
+ res.writeHead(405, { allow: 'GET' })
1013
+ return res.end()
1014
+ }
1015
+ const live = cfgOf()
1016
+ sendJson(res, 200, {
1017
+ ok: true,
1018
+ relay: state.relay,
1019
+ paired: Boolean(state.cookie),
1020
+ mintedAt: state.mintedAt,
1021
+ lastError: state.lastError,
1022
+ proxyPort: state.actualPort || live.proxyPort,
1023
+ bridgeUrl: live.bridgeUrl,
1024
+ configPath: configPath(),
1025
+ embedPath: EMBED_PATH,
1026
+ cache: {
1027
+ entries: state.cache.size,
1028
+ bytes: state.cacheBytes,
1029
+ budget: MAX_CACHE_BYTES,
1030
+ },
1031
+ warm: state.warm,
1032
+ })
1033
+ },
1034
+ })
1035
+
1036
+ ctx.effect?.(() => () => {
1037
+ embedRoute?.()
1038
+ setupRoute?.()
1039
+ statusRoute?.()
1040
+ try {
1041
+ state.server?.close()
1042
+ } catch {}
1043
+ })
1044
+ }
1045
+
1046
+ export const name = NS
1047
+ export const inject = ['webServer']
1048
+ export { apply }
1049
+ export { configSchema as Config }