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/LICENSE +21 -0
- package/README.en.md +89 -0
- package/README.md +142 -2
- package/cordis.patch.yml +4 -0
- package/icon.svg +7 -0
- package/lib/client.js +2843 -0
- package/lib/core.js +684 -0
- package/lib/history.js +244 -0
- package/lib/index.js +470 -0
- package/lib/phone.js +355 -0
- package/lib/picker.js +1403 -0
- package/lib/qr.js +263 -0
- package/lib/serve.js +513 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/skills/ui-design/SKILL.md +90 -0
- package/skills/ui-design/references/app.md +30 -0
- package/skills/ui-design/references/email.md +17 -0
- package/skills/ui-design/references/poster.md +21 -0
- package/skills/ui-design/references/slides.md +19 -0
- package/skills/ui-design/references/web.md +21 -0
- package/skills/ui-design/tokens.css +70 -0
package/lib/serve.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 预览服务器:每个预览目标单独开一个 127.0.0.1 随机端口,页面在自己的源里跑。
|
|
3
|
+
*
|
|
4
|
+
* file 本地 HTML:以它所在目录为根,静态给文件;目录里有改动就通过 SSE 让页面整页刷新
|
|
5
|
+
* proxy 本地开发服务器(vite / next / …):原样转发,WebSocket 也转(热更新照常用)
|
|
6
|
+
*
|
|
7
|
+
* 为什么不挂在 DSH 自己的地址下:
|
|
8
|
+
* - 挂在子路径下,页面里 /assets/x.js 这种根路径会打到 DSH 自己身上;独立端口就没这个问题
|
|
9
|
+
* - DSH 桌面版不转发 WebSocket,开发服务器的热更新会断;自己的端口可以转
|
|
10
|
+
* - 页面和 DSH 不同源,预览的页面碰不到 DSH 的接口
|
|
11
|
+
*
|
|
12
|
+
* 每个 HTML 页面都注入 /__dsh_design__/picker.js:负责点选 / 框选,用 postMessage 和右侧栏通信。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import http from 'node:http'
|
|
17
|
+
import net from 'node:net'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
export const INTERNAL = '/__dsh_design__/'
|
|
22
|
+
const PICKER_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker.js')
|
|
23
|
+
const MAX_SERVERS = 8
|
|
24
|
+
const MAX_HTML = 8 * 1024 * 1024 // 要注入脚本的 HTML 最多读这么大
|
|
25
|
+
const UP_HOSTS = ['127.0.0.1', '::1'] // 开发服务器可能只听其中一个
|
|
26
|
+
const DEBOUNCE_MS = 120
|
|
27
|
+
|
|
28
|
+
const MIME = {
|
|
29
|
+
'.html': 'text/html; charset=utf-8',
|
|
30
|
+
'.htm': 'text/html; charset=utf-8',
|
|
31
|
+
'.css': 'text/css; charset=utf-8',
|
|
32
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
33
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
34
|
+
'.json': 'application/json; charset=utf-8',
|
|
35
|
+
'.map': 'application/json; charset=utf-8',
|
|
36
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
37
|
+
'.md': 'text/plain; charset=utf-8',
|
|
38
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
39
|
+
'.svg': 'image/svg+xml',
|
|
40
|
+
'.png': 'image/png',
|
|
41
|
+
'.jpg': 'image/jpeg',
|
|
42
|
+
'.jpeg': 'image/jpeg',
|
|
43
|
+
'.gif': 'image/gif',
|
|
44
|
+
'.webp': 'image/webp',
|
|
45
|
+
'.avif': 'image/avif',
|
|
46
|
+
'.ico': 'image/x-icon',
|
|
47
|
+
'.woff': 'font/woff',
|
|
48
|
+
'.woff2': 'font/woff2',
|
|
49
|
+
'.ttf': 'font/ttf',
|
|
50
|
+
'.otf': 'font/otf',
|
|
51
|
+
'.mp4': 'video/mp4',
|
|
52
|
+
'.webm': 'video/webm',
|
|
53
|
+
'.mp3': 'audio/mpeg',
|
|
54
|
+
'.wav': 'audio/wav',
|
|
55
|
+
'.wasm': 'application/wasm',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 改动通知里不算的:版本库、依赖、系统垃圾
|
|
59
|
+
const EXPORTS = /^(exports([\\/][^\\/]+\.png)?|refs([\\/][^\\/]+\.(png|jpe?g|gif|webp))?)$/i // 导出的图片、参考图(和第一次建的 exports / refs 目录):不算设计改了
|
|
60
|
+
const IGNORED = /(^|[\\/])(\.git|node_modules|\.DS_Store|\.next|\.cache|\.turbo)([\\/]|$)/
|
|
61
|
+
|
|
62
|
+
/** 只认本机名 + 自己的端口(防 DNS 重绑定:外站把域名解析到 127.0.0.1 也进不来) */
|
|
63
|
+
export function hostOk(host, port) {
|
|
64
|
+
const h = String(host || '').toLowerCase()
|
|
65
|
+
return h === `127.0.0.1:${port}` || h === `localhost:${port}` || h === `[::1]:${port}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 在 </body> 前(没有就在末尾)插入选取脚本 */
|
|
69
|
+
export function injectPicker(html) {
|
|
70
|
+
const tag = `<script src="${INTERNAL}picker.js"></script>`
|
|
71
|
+
const i = html.toLowerCase().lastIndexOf('</body>')
|
|
72
|
+
return i >= 0 ? html.slice(0, i) + tag + html.slice(i) : html + tag
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 真实路径在不在根目录(也取真实路径)里面 */
|
|
76
|
+
function inRoot(root, real) {
|
|
77
|
+
const realRoot = fs.realpathSync(root)
|
|
78
|
+
return real === realRoot || real.startsWith(realRoot + path.sep)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** URL 路径 → 根目录下的真实文件;跳出根目录(含软链接)返回 null */
|
|
82
|
+
export function resolveInRoot(root, pathname) {
|
|
83
|
+
let rel
|
|
84
|
+
try { rel = decodeURIComponent(pathname) } catch { return null }
|
|
85
|
+
if (rel.includes('\0')) return null
|
|
86
|
+
const abs = path.resolve(root, '.' + path.posix.normalize('/' + rel))
|
|
87
|
+
let real
|
|
88
|
+
try { real = fs.realpathSync(abs) } catch { return { abs, real: null } }
|
|
89
|
+
if (!inRoot(root, real)) return null
|
|
90
|
+
return { abs, real }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function send(res, code, body, headers = {}) {
|
|
94
|
+
res.writeHead(code, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
|
|
95
|
+
res.end(body)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function errorPage(title, detail, retry) {
|
|
99
|
+
return injectPicker(`<!doctype html><html><head><meta charset="utf-8">${retry ? '<meta http-equiv="refresh" content="2">' : ''}
|
|
100
|
+
<title>${title}</title><style>
|
|
101
|
+
html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;font:14px/1.6 -apple-system,system-ui,sans-serif;color:#8a8f98;background:transparent}
|
|
102
|
+
div{text-align:center;max-width:360px;padding:24px}b{display:block;color:#c9ccd2;font-weight:500;margin-bottom:4px}
|
|
103
|
+
</style></head><body><div><b>${title}</b>${detail}</div></body></html>`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 桌面版 DSH 的界面在 dsh-app://app/(自己注册的标准协议),右侧栏就在这个源里;
|
|
108
|
+
* 预览页面里的 picker 只和它(以及 opts.parents 给的 DSH 网页端地址)说话,别的网站嵌进来收不到、也指挥不动
|
|
109
|
+
*/
|
|
110
|
+
export const DESKTOP_ORIGIN = 'dsh-app://app'
|
|
111
|
+
|
|
112
|
+
/** DSH 网页端的源(浏览器里开 DSH 时右侧栏在这里;本机地址的 localhost / 127.0.0.1 / [::1] 写法都认) */
|
|
113
|
+
export function webOrigins(origin) {
|
|
114
|
+
let u
|
|
115
|
+
try { u = new URL(origin) } catch { return [] }
|
|
116
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return []
|
|
117
|
+
const out = [u.origin]
|
|
118
|
+
if (/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(u.host)) {
|
|
119
|
+
for (const h of ['127.0.0.1', 'localhost', '[::1]']) out.push(`${u.protocol}//${h}${u.port ? ':' + u.port : ''}`)
|
|
120
|
+
}
|
|
121
|
+
return [...new Set(out)]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* DSH 声明过的对外地址(dsh-client-connection 的 trustedHosts:反向代理 / 域名,写成 host 或 host:port)→ 网页端可能的源。
|
|
126
|
+
* 前面是 http 还是 https 看不出来,两种都认;没写端口的匹配任意端口,记成 「源:*」(picker 按外层的真实源比对)
|
|
127
|
+
*/
|
|
128
|
+
export function trustedOrigins(hosts) {
|
|
129
|
+
const out = []
|
|
130
|
+
for (const h of Array.isArray(hosts) ? hosts : []) {
|
|
131
|
+
if (typeof h !== 'string' || !h || /[/?#@\s]/.test(h)) continue
|
|
132
|
+
const anyPort = !/(^|[^:]):\d+$|\]:\d+$/.test(h)
|
|
133
|
+
for (const proto of ['http:', 'https:']) {
|
|
134
|
+
let u
|
|
135
|
+
try { u = new URL(`${proto}//${h}`) } catch { continue }
|
|
136
|
+
out.push(u.origin)
|
|
137
|
+
if (anyPort) out.push(`${proto}//${u.host}:*`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return [...new Set(out)]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param opts.log 出错时写日志
|
|
145
|
+
* @param opts.onChange (key, file) 目录里有文件改了(file 目标)
|
|
146
|
+
* @param opts.history 设计版本(lib/history.js):/__dsh_design__/v/<版本>/<文件> 给出那一版的文件
|
|
147
|
+
* @param opts.parents () → 还有哪些 DSH 页面的源能嵌预览(DSH 网页端的地址)
|
|
148
|
+
*/
|
|
149
|
+
export function createHub({ log = () => {}, onChange = () => {}, history = null, parents = () => [] } = {}) {
|
|
150
|
+
const servers = new Map() // key → entry
|
|
151
|
+
|
|
152
|
+
function touch(entry) {
|
|
153
|
+
entry.used = Date.now()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 满了先关一个:没有页面连着的里面最久没用的;全都连着才关最久没用的 */
|
|
157
|
+
function evict() {
|
|
158
|
+
if (servers.size < MAX_SERVERS) return
|
|
159
|
+
const all = [...servers.values()].sort((a, b) => a.used - b.used)
|
|
160
|
+
const victim = all.find((e) => !e.sockets.size && !e.clients.size) || all[0]
|
|
161
|
+
if (victim) close(victim.key)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parentOrigins() {
|
|
165
|
+
let more = []
|
|
166
|
+
try { more = parents() || [] } catch (e) { log(e) }
|
|
167
|
+
return [...new Set([DESKTOP_ORIGIN, ...more.filter((o) => typeof o === 'string' && o)])]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function picker(res, entry) {
|
|
171
|
+
fs.readFile(PICKER_FILE, 'utf8', (err, src) => {
|
|
172
|
+
if (err) return send(res, 500, 'picker missing')
|
|
173
|
+
const cfg = JSON.stringify({ live: entry.kind === 'file', parents: parentOrigins() })
|
|
174
|
+
send(res, 200, `window.__DSH_DESIGN__=${cfg};\n${src}`, { 'Content-Type': 'text/javascript; charset=utf-8' })
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── 本地文件 ──────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function serveFile(req, res, entry, pathname) {
|
|
181
|
+
const hit = resolveInRoot(entry.root, pathname)
|
|
182
|
+
if (!hit) return send(res, 403, 'forbidden')
|
|
183
|
+
let file = hit.real
|
|
184
|
+
if (file) {
|
|
185
|
+
let st
|
|
186
|
+
try { st = fs.statSync(file) } catch { st = null }
|
|
187
|
+
if (st && st.isDirectory()) {
|
|
188
|
+
// 目录补 index.html:它自己也可能是指到根外的软链接,取真实路径再查一遍
|
|
189
|
+
try { file = fs.realpathSync(path.join(file, 'index.html')) } catch { file = null }
|
|
190
|
+
if (file && !inRoot(entry.root, file)) return send(res, 403, 'forbidden')
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!file) {
|
|
194
|
+
const html = errorPage('找不到这个文件', String(pathname).replace(/[<>&]/g, ''), false)
|
|
195
|
+
return send(res, 404, html, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
196
|
+
}
|
|
197
|
+
const ext = path.extname(file).toLowerCase()
|
|
198
|
+
const type = MIME[ext] || 'application/octet-stream'
|
|
199
|
+
if (ext === '.html' || ext === '.htm') {
|
|
200
|
+
fs.readFile(file, 'utf8', (err, html) => {
|
|
201
|
+
if (err) return send(res, 500, '读不了这个文件')
|
|
202
|
+
send(res, 200, req.method === 'HEAD' ? '' : injectPicker(html), { 'Content-Type': type })
|
|
203
|
+
})
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
// 别的源不能把这里的文件当子资源拿走
|
|
207
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin' })
|
|
208
|
+
if (req.method === 'HEAD') return res.end()
|
|
209
|
+
const stream = fs.createReadStream(file)
|
|
210
|
+
stream.on('error', () => res.destroy())
|
|
211
|
+
stream.pipe(res)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 旧版本里的文件(只读;HTML 照样注入 picker,好在画布里显示和对比) */
|
|
215
|
+
function serveVersion(req, res, entry, pathname) {
|
|
216
|
+
const m = /^\/__dsh_design__\/v\/(\d+)\/(.*)$/.exec(pathname)
|
|
217
|
+
let rel = ''
|
|
218
|
+
try { rel = m ? decodeURIComponent(m[2]) : '' } catch { rel = '' }
|
|
219
|
+
const file = m && history && !rel.split('/').includes('..') ? history.fileOf(entry.root, Number(m[1]), rel) : null
|
|
220
|
+
if (!file) return send(res, 404, 'no such version file')
|
|
221
|
+
const ext = path.extname(rel || 'index.html').toLowerCase()
|
|
222
|
+
const type = MIME[ext] || 'application/octet-stream'
|
|
223
|
+
fs.readFile(file, (err, buf) => {
|
|
224
|
+
if (err) return send(res, 404, 'no such version file')
|
|
225
|
+
if (ext === '.html' || ext === '.htm') return send(res, 200, req.method === 'HEAD' ? '' : injectPicker(buf.toString('utf8')), { 'Content-Type': type })
|
|
226
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin' })
|
|
227
|
+
res.end(req.method === 'HEAD' ? undefined : buf)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** SSE:目录里有改动就推一条 change,页面收到后整页刷新 */
|
|
232
|
+
function events(req, res, entry) {
|
|
233
|
+
res.writeHead(200, {
|
|
234
|
+
'Content-Type': 'text/event-stream',
|
|
235
|
+
'Cache-Control': 'no-store',
|
|
236
|
+
Connection: 'keep-alive',
|
|
237
|
+
'X-Accel-Buffering': 'no',
|
|
238
|
+
})
|
|
239
|
+
res.write(': hi\n\n')
|
|
240
|
+
entry.clients.add(res)
|
|
241
|
+
const beat = setInterval(() => {
|
|
242
|
+
touch(entry) // 页面还开着:算在用(满了不先关它)
|
|
243
|
+
res.write(': ping\n\n')
|
|
244
|
+
}, 20000)
|
|
245
|
+
req.on('close', () => {
|
|
246
|
+
clearInterval(beat)
|
|
247
|
+
entry.clients.delete(res)
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 目录里有改动:攒一小会儿一起报(file 是最后一个,files 是这一批全部;页面靠 files 判断跟自己有没有关系) */
|
|
252
|
+
function watch(entry) {
|
|
253
|
+
let timer = null
|
|
254
|
+
let last = ''
|
|
255
|
+
const batch = new Set()
|
|
256
|
+
try {
|
|
257
|
+
entry.watcher = fs.watch(entry.root, { recursive: true }, (_type, name) => {
|
|
258
|
+
const rel = String(name || '')
|
|
259
|
+
if (IGNORED.test(rel) || /(^|[\\/])\./.test(rel) || EXPORTS.test(rel)) return
|
|
260
|
+
last = rel
|
|
261
|
+
batch.add(rel.split(path.sep).join('/'))
|
|
262
|
+
clearTimeout(timer)
|
|
263
|
+
timer = setTimeout(() => {
|
|
264
|
+
const files = [...batch].slice(0, 50)
|
|
265
|
+
batch.clear()
|
|
266
|
+
for (const c of entry.clients) c.write(`event: change\ndata: ${JSON.stringify({ file: last, files })}\n\n`)
|
|
267
|
+
try { onChange(entry.key, path.join(entry.root, last)) } catch (e) { log(e) }
|
|
268
|
+
}, DEBOUNCE_MS)
|
|
269
|
+
})
|
|
270
|
+
entry.watcher.on('error', (e) => log(e))
|
|
271
|
+
} catch (e) {
|
|
272
|
+
log(e)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── 开发服务器 ────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 开发服务器听在哪个本机地址上:新版 Vite 等在 Mac 上 localhost 解析成 ::1,只听 IPv6;
|
|
280
|
+
* 先试 127.0.0.1 再试 ::1,连得上的记下来(连不上了再重新找)
|
|
281
|
+
*/
|
|
282
|
+
function upHost(entry) {
|
|
283
|
+
if (entry.upHost) return Promise.resolve(entry.upHost)
|
|
284
|
+
return new Promise((resolve) => {
|
|
285
|
+
let i = 0
|
|
286
|
+
const next = () => {
|
|
287
|
+
if (i >= UP_HOSTS.length) return resolve(UP_HOSTS[0])
|
|
288
|
+
const host = UP_HOSTS[i++]
|
|
289
|
+
const s = net.connect({ port: entry.port, host })
|
|
290
|
+
s.setTimeout(1500)
|
|
291
|
+
s.once('connect', () => { s.destroy(); entry.upHost = host; resolve(host) })
|
|
292
|
+
s.once('error', () => { s.destroy(); next() })
|
|
293
|
+
s.once('timeout', () => { s.destroy(); next() })
|
|
294
|
+
}
|
|
295
|
+
next()
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 发往开发服务器的头:Host / Origin / Referer 换回它自己的,免得被它的「只认本机名」挡掉 */
|
|
300
|
+
function upstreamHeaders(headers, entry) {
|
|
301
|
+
const out = { ...headers, host: `localhost:${entry.port}` }
|
|
302
|
+
const mine = entry.origin
|
|
303
|
+
const theirs = `http://localhost:${entry.port}`
|
|
304
|
+
if (out.origin === mine) out.origin = theirs
|
|
305
|
+
if (typeof out.referer === 'string' && out.referer.startsWith(mine)) out.referer = theirs + out.referer.slice(mine.length)
|
|
306
|
+
return out
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function proxy(req, res, entry) {
|
|
310
|
+
const headers = upstreamHeaders(req.headers, entry)
|
|
311
|
+
const wantsHtml = /text\/html/.test(String(req.headers.accept || ''))
|
|
312
|
+
if (wantsHtml) headers['accept-encoding'] = 'identity' // 要往 HTML 里插脚本,别压缩
|
|
313
|
+
upHost(entry).then((host) => forward(req, res, entry, headers, host)).catch((e) => fail(res, e))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function forward(req, res, entry, headers, host) {
|
|
317
|
+
if (res.destroyed) return // 浏览器已经走了
|
|
318
|
+
let gone = false
|
|
319
|
+
const up = http.request({ host, port: entry.port, method: req.method, path: req.url, headers }, (upRes) => {
|
|
320
|
+
const h = { ...upRes.headers }
|
|
321
|
+
delete h['x-frame-options']
|
|
322
|
+
if (typeof h.location === 'string') h.location = h.location.replace(/^https?:\/\/(localhost|127\.0\.0\.1):\d+/i, entry.origin)
|
|
323
|
+
const isHtml = /text\/html/i.test(String(h['content-type'] || '')) && !h['content-encoding']
|
|
324
|
+
if (!isHtml || req.method === 'HEAD') {
|
|
325
|
+
res.writeHead(upRes.statusCode || 502, h)
|
|
326
|
+
upRes.pipe(res)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const chunks = []
|
|
330
|
+
let size = 0
|
|
331
|
+
const onData = (c) => {
|
|
332
|
+
size += c.length
|
|
333
|
+
chunks.push(c)
|
|
334
|
+
if (size <= MAX_HTML) return
|
|
335
|
+
// 太大的 HTML:不注入了,攒下的连同后面的原样流下去(不截断)
|
|
336
|
+
upRes.off('data', onData)
|
|
337
|
+
upRes.off('end', onEnd)
|
|
338
|
+
delete h['content-security-policy']
|
|
339
|
+
res.writeHead(upRes.statusCode || 200, h)
|
|
340
|
+
for (const x of chunks) res.write(x)
|
|
341
|
+
upRes.pipe(res)
|
|
342
|
+
}
|
|
343
|
+
const onEnd = () => {
|
|
344
|
+
delete h['content-length']
|
|
345
|
+
delete h['content-security-policy'] // 会挡住注入的脚本和被嵌入
|
|
346
|
+
delete h['transfer-encoding']
|
|
347
|
+
res.writeHead(upRes.statusCode || 200, h)
|
|
348
|
+
res.end(injectPicker(Buffer.concat(chunks).toString('utf8')))
|
|
349
|
+
}
|
|
350
|
+
upRes.on('data', onData)
|
|
351
|
+
upRes.on('end', onEnd)
|
|
352
|
+
upRes.on('error', () => res.destroy())
|
|
353
|
+
})
|
|
354
|
+
// 下游(浏览器 / 手机入口)断了:上游也断,不然开发服务器的长连接(SSE 热更新)一直挂着
|
|
355
|
+
res.on('close', () => {
|
|
356
|
+
if (res.writableFinished) return
|
|
357
|
+
gone = true
|
|
358
|
+
up.destroy()
|
|
359
|
+
})
|
|
360
|
+
up.on('error', () => {
|
|
361
|
+
if (gone) return
|
|
362
|
+
entry.upHost = ''
|
|
363
|
+
if (res.headersSent) return res.destroy()
|
|
364
|
+
const html = errorPage(`连不上 localhost:${entry.port}`, '开发服务器可能还没启动,起来后这里会自动刷新。', true)
|
|
365
|
+
send(res, 502, html, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
366
|
+
})
|
|
367
|
+
req.pipe(up)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** WebSocket(热更新):原样接到开发服务器上,两头对接 */
|
|
371
|
+
function upgrade(req, socket, head, entry) {
|
|
372
|
+
if (entry.kind !== 'proxy' || !hostOk(req.headers.host, entry.listenPort)) return socket.destroy()
|
|
373
|
+
upHost(entry).then((host) => pipeSocket(req, socket, head, entry, host)).catch((e) => { log(e); socket.destroy() })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function pipeSocket(req, socket, head, entry, host) {
|
|
377
|
+
const up = net.connect(entry.port, host, () => {
|
|
378
|
+
const headers = upstreamHeaders(req.headers, entry)
|
|
379
|
+
let raw = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`
|
|
380
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
381
|
+
for (const one of Array.isArray(v) ? v : [v]) raw += `${k}: ${one}\r\n`
|
|
382
|
+
}
|
|
383
|
+
up.write(raw + '\r\n')
|
|
384
|
+
if (head && head.length) up.write(head)
|
|
385
|
+
up.pipe(socket)
|
|
386
|
+
socket.pipe(up)
|
|
387
|
+
})
|
|
388
|
+
// 一头断了(含预览服务器被关、开发服务器停了)另一头也关,不留挂着的连接
|
|
389
|
+
const done = () => { up.destroy(); socket.destroy() }
|
|
390
|
+
up.on('error', done)
|
|
391
|
+
socket.on('error', done)
|
|
392
|
+
up.on('close', done)
|
|
393
|
+
socket.on('close', done)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── 开关 ──────────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
/** 处理请求时出了意外:回 500,异常绝不往外冒(DSH 遇到没接住的异常会整个退出) */
|
|
399
|
+
function fail(res, e) {
|
|
400
|
+
log(e)
|
|
401
|
+
try {
|
|
402
|
+
if (!res.headersSent) send(res, 500, 'internal error')
|
|
403
|
+
else res.destroy()
|
|
404
|
+
} catch { /* 连接已经没了 */ }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function route(req, res, entry) {
|
|
408
|
+
if (!hostOk(req.headers.host, entry.listenPort)) return send(res, 403, 'forbidden')
|
|
409
|
+
touch(entry)
|
|
410
|
+
let pathname
|
|
411
|
+
try { pathname = new URL(req.url, 'http://x').pathname } catch { return send(res, 400, 'bad url') }
|
|
412
|
+
if (pathname === INTERNAL + 'picker.js') return picker(res, entry)
|
|
413
|
+
if (pathname === INTERNAL + 'events') {
|
|
414
|
+
if (entry.kind === 'file') return events(req, res, entry)
|
|
415
|
+
return send(res, 204, '')
|
|
416
|
+
}
|
|
417
|
+
if (entry.kind === 'file') {
|
|
418
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'method not allowed')
|
|
419
|
+
if (pathname.startsWith(INTERNAL + 'v/')) return serveVersion(req, res, entry, pathname)
|
|
420
|
+
return serveFile(req, res, entry, pathname)
|
|
421
|
+
}
|
|
422
|
+
return proxy(req, res, entry)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function handler(entry) {
|
|
426
|
+
return (req, res) => {
|
|
427
|
+
try { route(req, res, entry) } catch (e) { fail(res, e) }
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 拿到(没有就开)一个目标的预览服务器。
|
|
433
|
+
* @param target { kind: 'file', root } | { kind: 'proxy', port }
|
|
434
|
+
* @param opts.port 想用的端口(被关掉后重开时先试原来的,地址不变;被占了就随便挑一个)
|
|
435
|
+
* @returns { key, origin }
|
|
436
|
+
*/
|
|
437
|
+
async function open(target, { port: want = 0 } = {}) {
|
|
438
|
+
const key = target.kind === 'file' ? `file:${path.resolve(target.root)}` : `proxy:${target.port}`
|
|
439
|
+
const have = servers.get(key)
|
|
440
|
+
if (have) {
|
|
441
|
+
touch(have)
|
|
442
|
+
await have.ready
|
|
443
|
+
return { key, origin: have.origin }
|
|
444
|
+
}
|
|
445
|
+
evict()
|
|
446
|
+
const entry = {
|
|
447
|
+
key,
|
|
448
|
+
kind: target.kind,
|
|
449
|
+
root: target.kind === 'file' ? path.resolve(target.root) : null,
|
|
450
|
+
port: target.kind === 'proxy' ? Number(target.port) : null,
|
|
451
|
+
clients: new Set(),
|
|
452
|
+
sockets: new Set(),
|
|
453
|
+
watcher: null,
|
|
454
|
+
listenPort: 0,
|
|
455
|
+
origin: '',
|
|
456
|
+
used: Date.now(),
|
|
457
|
+
}
|
|
458
|
+
const server = http.createServer(handler(entry))
|
|
459
|
+
server.on('upgrade', (req, socket, head) => {
|
|
460
|
+
try { upgrade(req, socket, head, entry) } catch (e) { log(e); socket.destroy() }
|
|
461
|
+
})
|
|
462
|
+
server.on('connection', (s) => {
|
|
463
|
+
entry.sockets.add(s)
|
|
464
|
+
s.on('close', () => entry.sockets.delete(s))
|
|
465
|
+
})
|
|
466
|
+
server.on('clientError', (_e, s) => s.destroy())
|
|
467
|
+
server.on('error', (e) => log(e)) // 一直挂着:没人接的 error 会让整个进程退出
|
|
468
|
+
entry.server = server
|
|
469
|
+
servers.set(key, entry)
|
|
470
|
+
const listen = (p) => new Promise((resolve, reject) => {
|
|
471
|
+
server.once('error', reject)
|
|
472
|
+
server.listen(p, '127.0.0.1', () => {
|
|
473
|
+
server.off('error', reject)
|
|
474
|
+
resolve()
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
const wanted = Number.isInteger(Number(want)) && want > 0 && want < 65536 ? Number(want) : 0
|
|
478
|
+
entry.ready = (wanted ? listen(wanted).catch(() => listen(0)) : listen(0)).then(() => {
|
|
479
|
+
entry.listenPort = server.address().port
|
|
480
|
+
entry.origin = `http://127.0.0.1:${entry.listenPort}`
|
|
481
|
+
})
|
|
482
|
+
try {
|
|
483
|
+
await entry.ready
|
|
484
|
+
} catch (e) {
|
|
485
|
+
servers.delete(key)
|
|
486
|
+
throw e
|
|
487
|
+
}
|
|
488
|
+
if (entry.kind === 'file') watch(entry)
|
|
489
|
+
return { key, origin: entry.origin }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function close(key) {
|
|
493
|
+
const entry = servers.get(key)
|
|
494
|
+
if (!entry) return
|
|
495
|
+
servers.delete(key)
|
|
496
|
+
try { entry.watcher && entry.watcher.close() } catch { /* 已经关了 */ }
|
|
497
|
+
for (const c of entry.clients) c.end()
|
|
498
|
+
for (const s of entry.sockets) s.destroy()
|
|
499
|
+
entry.server.close()
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function closeAll() {
|
|
503
|
+
for (const key of [...servers.keys()]) close(key)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** 这个地址的预览服务器还开着吗(满了会关掉最久没用的,会话那边发现没了就重开) */
|
|
507
|
+
function alive(origin) {
|
|
508
|
+
for (const e of servers.values()) if (e.origin && e.origin === origin) return true
|
|
509
|
+
return false
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { open, close, closeAll, alive, size: () => servers.size }
|
|
513
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Design Studio",
|
|
4
|
+
"description": "A design workbench in the right sidebar: web, app and poster screens generate on a canvas inside device frames; click an element to have the model change it, tweak design tokens across every screen, compare and roll back versions, export share-ready images, and scan a QR code to view it on your phone."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-design-studio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Design workbench in the DeepSeek Harness right sidebar: watch web / app / poster screens generate on a canvas with device frames, click an element to send it to the chat, tweak design tokens, compare versions, export share-ready images and scan to view on your phone.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/OrderG-X/dsh-design-studio.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"dsh",
|
|
13
|
+
"deepseek-harness",
|
|
14
|
+
"dsh-plugin",
|
|
15
|
+
"preview",
|
|
16
|
+
"frontend",
|
|
17
|
+
"design"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./lib/index.js",
|
|
21
|
+
"./client": "./lib/client.js",
|
|
22
|
+
"./package.json": "./package.json",
|
|
23
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
24
|
+
"./icon.svg": "./icon.svg",
|
|
25
|
+
"./locale/*.json": "./locale/*.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib",
|
|
29
|
+
"skills",
|
|
30
|
+
"locale",
|
|
31
|
+
"icon.svg",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"README.md",
|
|
34
|
+
"README.en.md"
|
|
35
|
+
],
|
|
36
|
+
"icon": "./icon.svg",
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-locale",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@deepseek-ai/dsh-tools": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|