dsh-design-studio 0.0.0-stage → 0.3.2

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/phone.js ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * 手机上看:在局域网开一个入口,手机扫二维码就能打开正在设计的页面(手机和电脑连同一个 Wi-Fi)。
3
+ *
4
+ * - 默认不开;右侧栏点了才开,30 分钟没人看自动关,也能随时关
5
+ * - 只监听本机在局域网里的那个地址(不是 0.0.0.0),Host 也得是它
6
+ * - 链接带口令 ?k=<随机 128 位>:手机第一次打开时换成 Cookie(只在这个端口用),之后的图片、样式靠 Cookie;
7
+ * 口令不对、没有 Cookie 的一律 403。关掉再开换新口令,旧链接作废
8
+ * - 原样转发到这个会话现在的预览服务器(127.0.0.1),路径不变(开发服务器的根路径照常);WebSocket 也转(热更新)
9
+ * - HTML 里补上 viewport(没写的话手机会按电脑宽度缩小显示),再插一段小脚本:电脑上换了一屏,手机跟着跳
10
+ */
11
+
12
+ import crypto from 'node:crypto'
13
+ import http from 'node:http'
14
+ import net from 'node:net'
15
+ import os from 'node:os'
16
+
17
+ export const PHONE = '/__dsh_phone__/'
18
+ const IDLE_MS = 30 * 60 * 1000
19
+ const MAX_HTML = 8 * 1024 * 1024
20
+
21
+ /** 本机在局域网里的 IPv4:优先私有网段(192.168 / 10 / 172.16–31),Wi-Fi / 有线网卡在前;没有给 '' */
22
+ export function lanAddress(ifaces = os.networkInterfaces()) {
23
+ const cands = []
24
+ for (const [name, list] of Object.entries(ifaces || {})) {
25
+ for (const a of list || []) {
26
+ const v4 = a.family === 'IPv4' || a.family === 4
27
+ if (!v4 || a.internal || /^169\.254\./.test(a.address)) continue
28
+ const priv = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address)
29
+ const virtual = /^(utun|bridge|vmnet|vbox|docker|veth|tailscale|zt|llw|awdl|anpi|ap\d)/i.test(name)
30
+ cands.push({ ip: a.address, score: (priv ? 0 : 10) + (virtual ? 5 : 0) + (/^(en|eth|wlan|wl|Wi-?Fi|以太网)/i.test(name) ? 0 : 1) })
31
+ }
32
+ }
33
+ cands.sort((a, b) => a.score - b.score)
34
+ return cands.length ? cands[0].ip : ''
35
+ }
36
+
37
+ /** 给 HTML 补 viewport、插跟随脚本 */
38
+ export function phoneHtml(html) {
39
+ let out = html
40
+ if (!/<meta[^>]+name=["']?viewport/i.test(out)) {
41
+ const meta = '<meta name="viewport" content="width=device-width, initial-scale=1">'
42
+ const i = out.search(/<head[^>]*>/i)
43
+ out = i >= 0 ? out.replace(/<head[^>]*>/i, (m) => m + meta) : meta + out
44
+ }
45
+ const tag = `<script src="${PHONE}follow.js"></script>`
46
+ const j = out.toLowerCase().lastIndexOf('</body>')
47
+ return j >= 0 ? out.slice(0, j) + tag + out.slice(j) : out + tag
48
+ }
49
+
50
+ // 手机页面里的小脚本:电脑上换了一屏就跳过去(同一个入口下的路径)
51
+ const FOLLOW_JS = `(() => {
52
+ if (!window.EventSource) return
53
+ const es = new EventSource('${PHONE}events')
54
+ es.addEventListener('goto', (e) => {
55
+ try {
56
+ const p = JSON.parse(e.data).path
57
+ if (typeof p === 'string' && p.startsWith('/') && !p.startsWith('//') && p !== location.pathname + location.search) location.replace(p)
58
+ } catch {}
59
+ })
60
+ })()`
61
+
62
+ const DENIED = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>链接失效了</title>
63
+ <style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font:16px/1.6 -apple-system,system-ui,sans-serif;color:#555;background:#f5f5f7;text-align:center;padding:24px;box-sizing:border-box}b{display:block;color:#111;font-size:18px;margin-bottom:6px}</style>
64
+ </head><body><div><b>链接失效了</b>回到电脑上,在「设计工作台」里点手机按钮,重新扫一次二维码。</div></body></html>`
65
+
66
+ function same(a, b) {
67
+ const x = Buffer.from(String(a || ''))
68
+ const y = Buffer.from(String(b || ''))
69
+ return x.length === y.length && crypto.timingSafeEqual(x, y)
70
+ }
71
+
72
+ function cookieOf(header, name) {
73
+ for (const part of String(header || '').split(';')) {
74
+ const i = part.indexOf('=')
75
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim()
76
+ }
77
+ return ''
78
+ }
79
+
80
+ /**
81
+ * @param opts.upstream (session) → 这个会话现在的预览地址(http://127.0.0.1:端口),没有给 ''
82
+ * @param opts.address () → 局域网地址(测试可换)
83
+ * @param opts.idleMs 多久没人看自动关
84
+ */
85
+ export function createPhone({ upstream, address = () => lanAddress(), log = () => {}, now = Date.now, idleMs = IDLE_MS } = {}) {
86
+ const shares = new Map() // session → share
87
+
88
+ /** 手机打开的链接:指到电脑上现在这一屏(换了屏就跟着换,二维码按它重新画) */
89
+ function linkOf(s) {
90
+ return `http://${s.ip}:${s.port}${encodePath(s.path)}${s.path.includes('?') ? '&' : '?'}k=${s.token}`
91
+ }
92
+
93
+ /** 给右侧栏的状态 */
94
+ function status(session) {
95
+ const s = shares.get(session)
96
+ if (!s) return { on: false }
97
+ s.url = linkOf(s)
98
+ return { on: true, url: s.url, ip: s.ip, port: s.port, seen: s.seen, lastSeen: s.lastSeen }
99
+ }
100
+
101
+ /**
102
+ * 打开(已经开着就沿用)。path 是手机先打开哪一页(如 /login.html)。
103
+ * @returns status;没有局域网地址时 { on: false, error: 'no-lan' }
104
+ */
105
+ async function start(session, path = '/') {
106
+ const have = shares.get(session)
107
+ if (have) {
108
+ if (path && path !== have.path) {
109
+ have.path = path
110
+ have.missed = !have.followers.size
111
+ }
112
+ return status(session)
113
+ }
114
+ const ip = address()
115
+ if (!ip) return { on: false, error: 'no-lan' }
116
+ const s = {
117
+ session,
118
+ ip,
119
+ token: crypto.randomBytes(16).toString('base64url'),
120
+ path: path || '/',
121
+ port: 0,
122
+ url: '',
123
+ seen: 0,
124
+ lastSeen: 0,
125
+ started: now(),
126
+ followers: new Set(),
127
+ sockets: new Set(),
128
+ }
129
+ s.cookie = 'ddp_phone'
130
+ // 出了意外也不让异常冒出去(DSH 遇到没接住的异常会整个退出)
131
+ const server = http.createServer((req, res) => {
132
+ try { handle(s, req, res) } catch (e) {
133
+ log(e)
134
+ try { if (!res.headersSent) deny(res, 500); else res.destroy() } catch { /* 连接已经没了 */ }
135
+ }
136
+ })
137
+ server.on('upgrade', (req, socket, head) => {
138
+ try { upgrade(s, req, socket, head) } catch (e) { log(e); socket.destroy() }
139
+ })
140
+ server.on('connection', (c) => { s.sockets.add(c); c.on('close', () => s.sockets.delete(c)) })
141
+ server.on('clientError', (_e, c) => c.destroy())
142
+ s.server = server
143
+ shares.set(session, s)
144
+ try {
145
+ await new Promise((resolve, reject) => {
146
+ server.once('error', reject)
147
+ server.listen(0, ip, () => resolve())
148
+ })
149
+ } catch (e) {
150
+ shares.delete(session)
151
+ log(e)
152
+ return { on: false, error: 'listen', message: e.message }
153
+ }
154
+ s.port = server.address().port
155
+ s.cookie = `ddp_phone_${s.port}` // Cookie 不分端口:带上端口免得几个会话互相顶掉
156
+ s.timer = setInterval(() => {
157
+ if (s.followers.size) s.lastSeen = now() // 手机页面开着(连着跟随通道)就算有人在看
158
+ else if (now() - Math.max(s.started, s.lastSeen) > idleMs) stop(session)
159
+ }, Math.min(60000, idleMs))
160
+ if (s.timer.unref) s.timer.unref()
161
+ return status(session)
162
+ }
163
+
164
+ function stop(session) {
165
+ const s = shares.get(session)
166
+ if (!s) return
167
+ shares.delete(session)
168
+ clearInterval(s.timer)
169
+ for (const f of s.followers) f.end()
170
+ for (const c of s.sockets) c.destroy()
171
+ s.server.close()
172
+ }
173
+
174
+ function stopAll() {
175
+ for (const k of [...shares.keys()]) stop(k)
176
+ }
177
+
178
+ /** 电脑上换了一屏:开着的手机跟着跳 */
179
+ function follow(session, path) {
180
+ const s = shares.get(session)
181
+ if (!s || typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return false
182
+ s.path = path
183
+ s.missed = !s.followers.size // 手机这会儿没连着(还没扫、锁屏、正在换页):等它连上再补发
184
+ for (const f of s.followers) f.write(gotoEvent(s.path))
185
+ return true
186
+ }
187
+
188
+ function gotoEvent(path) {
189
+ return `event: goto\ndata: ${JSON.stringify({ path: encodePath(path) })}\n\n`
190
+ }
191
+
192
+ // ── 入口 ──────────────────────────────────────────────────────────────────
193
+
194
+ function authed(s, req) {
195
+ return same(cookieOf(req.headers.cookie, s.cookie), s.token)
196
+ }
197
+
198
+ function handle(s, req, res) {
199
+ if (String(req.headers.host || '').toLowerCase() !== `${s.ip}:${s.port}`) return deny(res)
200
+ let url
201
+ try { url = new URL(req.url, 'http://x') } catch { return deny(res) }
202
+ const k = url.searchParams.get('k')
203
+ if (k !== null) {
204
+ if (!same(k, s.token)) return deny(res)
205
+ // 口令换成 Cookie,再跳到不带口令的地址(口令不留在手机的地址栏和历史里)
206
+ url.searchParams.delete('k')
207
+ res.writeHead(302, {
208
+ 'Set-Cookie': `${s.cookie}=${s.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`,
209
+ Location: url.pathname.replace(/^\/+/, '/') + url.search,
210
+ 'Cache-Control': 'no-store',
211
+ })
212
+ return res.end()
213
+ }
214
+ if (!authed(s, req)) return deny(res)
215
+ s.lastSeen = now()
216
+ if (url.pathname === PHONE + 'follow.js') {
217
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' })
218
+ return res.end(FOLLOW_JS)
219
+ }
220
+ if (url.pathname === PHONE + 'events') {
221
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
222
+ res.write(': hi\n\n')
223
+ // 没连着的时候电脑上换过屏:连上先补发一次现在这一屏(只补错过的,免得手机上自己点链接换页又被拉回去)
224
+ if (s.missed) {
225
+ s.missed = false
226
+ res.write(gotoEvent(s.path))
227
+ }
228
+ s.followers.add(res)
229
+ const beat = setInterval(() => res.write(': ping\n\n'), 20000)
230
+ req.on('close', () => { clearInterval(beat); s.followers.delete(res) })
231
+ return
232
+ }
233
+ const up = upstreamOf(s)
234
+ if (!up) return deny(res, 503)
235
+ if (/text\/html/.test(String(req.headers.accept || '')) && req.method === 'GET') s.seen++
236
+ proxy(s, up, req, res)
237
+ }
238
+
239
+ function deny(res, code = 403) {
240
+ res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
241
+ res.end(DENIED)
242
+ }
243
+
244
+ function upstreamOf(s) {
245
+ const origin = String(upstream(s.session) || '')
246
+ const m = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(origin)
247
+ return m ? { origin, port: Number(m[1]) } : null
248
+ }
249
+
250
+ /** 发往预览服务器的头:Host / Origin / Referer 换成它自己的,口令 Cookie 不往下传 */
251
+ function headersFor(s, up, headers) {
252
+ const out = { ...headers, host: `127.0.0.1:${up.port}` }
253
+ const mine = `http://${s.ip}:${s.port}`
254
+ if (out.origin === mine) out.origin = up.origin
255
+ if (typeof out.referer === 'string' && out.referer.startsWith(mine)) out.referer = up.origin + out.referer.slice(mine.length)
256
+ if (out.cookie) {
257
+ const rest = String(out.cookie).split(';').filter((p) => p.split('=')[0].trim() !== s.cookie).join(';').trim()
258
+ if (rest) out.cookie = rest
259
+ else delete out.cookie
260
+ }
261
+ return out
262
+ }
263
+
264
+ function proxy(s, up, req, res) {
265
+ const headers = headersFor(s, up, req.headers)
266
+ const wantsHtml = /text\/html/.test(String(req.headers.accept || ''))
267
+ if (wantsHtml) headers['accept-encoding'] = 'identity'
268
+ let gone = false
269
+ const r = http.request({ host: '127.0.0.1', port: up.port, method: req.method, path: req.url, headers }, (upRes) => {
270
+ const h = { ...upRes.headers }
271
+ if (typeof h.location === 'string' && h.location.startsWith(up.origin)) h.location = h.location.slice(up.origin.length) || '/'
272
+ const isHtml = /text\/html/i.test(String(h['content-type'] || '')) && !h['content-encoding']
273
+ if (!isHtml || req.method === 'HEAD') {
274
+ res.writeHead(upRes.statusCode || 502, h)
275
+ upRes.pipe(res)
276
+ return
277
+ }
278
+ const chunks = []
279
+ let size = 0
280
+ const onData = (c) => {
281
+ size += c.length
282
+ chunks.push(c)
283
+ if (size <= MAX_HTML) return
284
+ // 太大的 HTML:不改了,攒下的连同后面的原样流下去(不截断)
285
+ upRes.off('data', onData)
286
+ upRes.off('end', onEnd)
287
+ res.writeHead(upRes.statusCode || 200, h)
288
+ for (const x of chunks) res.write(x)
289
+ upRes.pipe(res)
290
+ }
291
+ const onEnd = () => {
292
+ if (res.headersSent || res.destroyed) return
293
+ delete h['content-length']
294
+ delete h['transfer-encoding']
295
+ res.writeHead(upRes.statusCode || 200, h)
296
+ res.end(phoneHtml(Buffer.concat(chunks).toString('utf8')))
297
+ }
298
+ upRes.on('data', onData)
299
+ upRes.on('end', onEnd)
300
+ upRes.on('error', () => res.destroy())
301
+ })
302
+ // 手机断了(关页面、锁屏、换页):往预览服务器的那条也断,不然 SSE 长连接和它的心跳一直挂着
303
+ res.on('close', () => {
304
+ if (res.writableFinished) return
305
+ gone = true
306
+ r.destroy()
307
+ })
308
+ r.on('error', () => {
309
+ if (gone) return
310
+ if (res.headersSent) res.destroy(); else deny(res, 502)
311
+ })
312
+ req.pipe(r)
313
+ }
314
+
315
+ function upgrade(s, req, socket, head) {
316
+ const up = upstreamOf(s)
317
+ if (String(req.headers.host || '').toLowerCase() !== `${s.ip}:${s.port}` || !authed(s, req) || !up) return socket.destroy()
318
+ s.lastSeen = now()
319
+ const conn = net.connect(up.port, '127.0.0.1', () => {
320
+ let raw = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`
321
+ for (const [k, v] of Object.entries(headersFor(s, up, req.headers))) for (const one of Array.isArray(v) ? v : [v]) raw += `${k}: ${one}\r\n`
322
+ conn.write(raw + '\r\n')
323
+ if (head && head.length) conn.write(head)
324
+ conn.pipe(socket)
325
+ socket.pipe(conn)
326
+ })
327
+ // 一头断了(含手机入口停掉、预览服务器被关)另一头也关,不留挂着的连接
328
+ const done = () => { conn.destroy(); socket.destroy() }
329
+ conn.on('error', done)
330
+ socket.on('error', done)
331
+ conn.on('close', done)
332
+ socket.on('close', done)
333
+ }
334
+
335
+ return { start, stop, stopAll, status, follow }
336
+ }
337
+
338
+ /**
339
+ * 路径里的中文、空格、& # ? + 这些编码一下(已经编码过的不重复编码):路径逐段先解开再编码;
340
+ * ? 后面的查询串原样留着,只把空格、中文这类不能直接出现在地址里的字编码
341
+ */
342
+ export function encodePath(p) {
343
+ const s = String(p)
344
+ const q = s.indexOf('?')
345
+ const pathname = q >= 0 ? s.slice(0, q) : s
346
+ const search = q >= 0 ? s.slice(q) : ''
347
+ const path = pathname.split('/').map((seg) => {
348
+ let raw = seg
349
+ try { raw = decodeURIComponent(seg) } catch { /* 不是合法的编码:当原文 */ }
350
+ try { return encodeURIComponent(raw) } catch { return encodeURIComponent(seg.replace(/[\uD800-\uDFFF]/g, '')) }
351
+ }).join('/')
352
+ let tail = search
353
+ try { tail = search.replace(/[^\x21-\x7e]+/g, (m) => encodeURIComponent(m)) } catch { tail = '' }
354
+ return path + tail
355
+ }