dsh-intertidal 0.0.0-stage → 0.2.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/src/host.js ADDED
@@ -0,0 +1,458 @@
1
+ // dsh-intertidal —— 宿主半片(cordis 插件)。
2
+ //
3
+ // 职责只有三件:把 state.json 读写包装成 HTTP、把天文计算暴露成 `/intertidal/now`、
4
+ // 并且给每个请求过一道**准入围栏**。渲染全在浏览器半片,这里一行画图的代码都没有。
5
+ //
6
+ // 三条装载期约束(与 dsh-workbench 同款,都是踩过的坑):
7
+ // 1. 模块加载时不读文件、不监听端口——`import('./src/host.js')` 必须能在单测里直接跑;
8
+ // `$DSH_HOME` 只在 apply / handler 时才解析。
9
+ // 2. 只 `inject: ['webServer']`。把 `connection` 写进 inject 会让 fiber 等一个可能更晚
10
+ // 才出现的服务,进而把启动卡住(workbench 的 PO 记录过);connection 只在 handler 里
11
+ // 现取,取不到就走自写围栏。
12
+ // 3. 注册动作包在 `scope.effect()` 里,插件卸载即注销路由。
13
+ //
14
+ // 铁律(spec §8):潮间是"可选的呼吸"。宿主的任何异常都不得让 DSH 主流程受损——本文件
15
+ // 所有 handler 都自己兜错,`apply` 里的失败只记日志。
16
+
17
+ import { createReadStream } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { skyAt } from './sky.js'
20
+ import { guessCity } from './cities.js'
21
+ import {
22
+ cacheDir as mediaCacheDir,
23
+ openMediaFile,
24
+ pickFromCache,
25
+ pickFromDir,
26
+ resolveUrlToCache,
27
+ scanDir,
28
+ sniffSize,
29
+ } from './media.js'
30
+ import { patchSettings, readState, resolveDshHome, rhythmEvent, statePath, writeState } from './store.js'
31
+
32
+ export const name = 'dsh-intertidal'
33
+ /** 静态声明依赖:webServer 缺席时本插件不激活(fiber 保持 pending,不会拖垮启动)。 */
34
+ export const inject = ['webServer']
35
+
36
+ export const API_PREFIX = '/intertidal'
37
+
38
+ const TZ_OK = (() => {
39
+ try {
40
+ new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' })
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ })()
46
+
47
+ /** 任意输入 → 合法地点。非法就按系统时区猜一个城市(spec:「默认按系统时区猜」)。 */
48
+ export function sanitizeLocation(value) {
49
+ const raw = value && typeof value === 'object' ? value : {}
50
+ const lat = Number(raw.lat)
51
+ const lon = Number(raw.lon)
52
+ if (Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
53
+ let tz = typeof raw.tz === 'string' ? raw.tz.trim() : ''
54
+ if (tz && TZ_OK) {
55
+ try {
56
+ new Intl.DateTimeFormat('en-US', { timeZone: tz })
57
+ } catch {
58
+ tz = ''
59
+ }
60
+ }
61
+ const fallback = guessCity({ tz: tz || undefined })
62
+ return {
63
+ city: typeof raw.city === 'string' && raw.city ? raw.city : fallback.id ?? fallback.city,
64
+ lat,
65
+ lon,
66
+ tz: tz || fallback.tz,
67
+ }
68
+ }
69
+ const guess = guessCity({ tz: Intl.DateTimeFormat().resolvedOptions().timeZone })
70
+ return { city: guess.id ?? guess.city, lat: guess.lat, lon: guess.lon, tz: guess.tz }
71
+ }
72
+ /** 请求体上限:设置对象只有几百字节,1 MB 已经很宽容。 */
73
+ export const MAX_BODY_BYTES = 1 << 20
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // 信封与工具
77
+
78
+ function json(res, status, body) {
79
+ if (res.writableEnded || res.destroyed) return
80
+ const text = JSON.stringify(body)
81
+ res.writeHead(status, {
82
+ 'content-type': 'application/json; charset=utf-8',
83
+ 'cache-control': 'no-store',
84
+ 'content-length': Buffer.byteLength(text),
85
+ })
86
+ res.end(text)
87
+ }
88
+
89
+ const fail = (res, status, message) => json(res, status, { error: { message } })
90
+
91
+ /** 只有 JSON 才读 body:`text/plain` 表单 POST 是"无预检跨站"的经典口子,必须在门口挡掉。 */
92
+ function isJsonRequest(req) {
93
+ const raw = String(req?.headers?.['content-type'] ?? '')
94
+ const media = raw.split(';')[0].trim().toLowerCase()
95
+ return media === 'application/json' || /^application\/[a-z0-9.+-]+\+json$/.test(media)
96
+ }
97
+
98
+ async function readJsonBody(req, limit = MAX_BODY_BYTES) {
99
+ if (!isJsonRequest(req)) {
100
+ throw Object.assign(new Error('请求必须是 application/json'), { status: 415 })
101
+ }
102
+ const declared = Number(req?.headers?.['content-length'] ?? Number.NaN)
103
+ if (Number.isFinite(declared) && declared > limit) {
104
+ req.destroy?.()
105
+ throw Object.assign(new Error('请求体过大'), { status: 413 })
106
+ }
107
+ const chunks = []
108
+ let size = 0
109
+ for await (const chunk of req) {
110
+ size += chunk.length
111
+ if (size > limit) {
112
+ req.destroy?.()
113
+ throw Object.assign(new Error('请求体过大'), { status: 413 })
114
+ }
115
+ chunks.push(chunk)
116
+ }
117
+ if (!size) return {}
118
+ const text = Buffer.concat(chunks).toString('utf8')
119
+ try {
120
+ const parsed = JSON.parse(text)
121
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object')
122
+ return parsed
123
+ } catch {
124
+ throw Object.assign(new Error('请求不是有效的 JSON 对象'), { status: 400 })
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // 准入围栏
130
+ //
131
+ // `webServer.register({kind:'prefix'})` 注册的路由**绕过**框架给 `/api` 挂的浏览器信任围栏,
132
+ // 所以必须自己判"这个请求是不是本机页面自己发的"。这不是鉴权(本机端点没有凭据可校验),
133
+ // 而是防 DNS-rebinding / 跨站 POST 的副作用。
134
+ //
135
+ // 判定顺序(任何一项不满足即拒,先拒后信):
136
+ // ① Host 落在 loopback 或显式 bindHost;
137
+ // ② 已知端口时 Host 的端口必须与服务端口一致;
138
+ // ③ Origin 若存在必须与 Host 同源;
139
+ // ④ sec-fetch-site 只允许 same-origin / none。
140
+ function headerOf(req, key) {
141
+ const value = req?.headers?.[key]
142
+ if (typeof value === 'string') return value
143
+ if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
144
+ return undefined
145
+ }
146
+
147
+ export function localRejection(req, { port, bindHost } = {}) {
148
+ const host = headerOf(req, 'host') ?? headerOf(req, ':authority')
149
+ if (!host) return 'no-host'
150
+ // host 可能是 `127.0.0.1:51234` 或 `[::1]:51234`
151
+ const hostname = host.startsWith('[')
152
+ ? host.slice(0, host.indexOf(']') + 1)
153
+ : host.split(':')[0]
154
+ const hostPort = host.includes(']') ? host.slice(host.indexOf(']') + 2) : host.split(':')[1]
155
+ const loopback =
156
+ hostname === 'localhost' ||
157
+ hostname === '[::1]' ||
158
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
159
+ const allowedName = loopback || (typeof bindHost === 'string' && bindHost !== '' && hostname === bindHost)
160
+ if (!allowedName) return 'host-not-loopback'
161
+ if (port !== undefined && port !== null && hostPort !== undefined && String(hostPort) !== String(port)) {
162
+ return 'port-mismatch'
163
+ }
164
+ const origin = headerOf(req, 'origin')
165
+ if (origin !== undefined) {
166
+ try {
167
+ const parsed = new URL(origin)
168
+ if (parsed.host !== host) return 'origin-mismatch'
169
+ } catch {
170
+ return 'origin-unparsable'
171
+ }
172
+ }
173
+ const site = headerOf(req, 'sec-fetch-site')
174
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') return 'cross-site'
175
+ return null
176
+ }
177
+
178
+ /**
179
+ * 先问框架(connection.requestRejection),它不在或抛了就退回自写围栏。
180
+ * 返回拒绝原因字符串表示拒绝,返回 null 表示放行。
181
+ */
182
+ function rejectionFor(ctx, req) {
183
+ const connection = ctx.get?.('connection')
184
+ const guard = connection?.requestRejection
185
+ if (typeof guard === 'function') {
186
+ try {
187
+ const rejection = guard.call(connection, req)
188
+ if (rejection !== undefined && rejection !== null) return String(rejection)
189
+ return null
190
+ } catch {
191
+ /* 框架围栏异常 → 继续走自写围栏,绝不放行 */
192
+ }
193
+ }
194
+ const server = ctx.get?.('webServer')
195
+ return localRejection(req, { port: server?.port, bindHost: server?.host })
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // 天文计算的按分钟缓存:`dayLength` 要扫 1440 个采样点,没必要每次请求都算。
200
+
201
+ const SKY_CACHE_MS = 60_000
202
+ const skyCache = new Map()
203
+
204
+ function nowPayload(location, at = Date.now(), noCache = false) {
205
+ const key = `${location.lat},${location.lon},${location.tz}`
206
+ const hit = skyCache.get(key)
207
+ if (!noCache && hit && at - hit.at < SKY_CACHE_MS) return hit.value
208
+ let value
209
+ try {
210
+ value = skyAt(new Date(at), location)
211
+ } catch {
212
+ // 兜底:任何天文计算异常都退回"午后的海",画面不会因此崩掉(spec §8)。
213
+ value = {
214
+ t: Math.floor(at / 1000),
215
+ sun: { altitude: 20, azimuth: 200 },
216
+ moon: { altitude: -10, azimuth: 90, phase: 0.5 },
217
+ tide: { height: 0, trend: 'rising' },
218
+ phase: 'afternoon',
219
+ dayLength: null,
220
+ }
221
+ }
222
+ skyCache.set(key, { at, value })
223
+ return value
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // 方法表
228
+
229
+ /**
230
+ * @param {{ ctx: any, logger: any, home: string, clock: () => number }} deps
231
+ */
232
+ export function createApiHandler({ ctx, logger, clock = () => Date.now() }) {
233
+ const home = () => resolveDshHome()
234
+
235
+ async function currentLocation() {
236
+ const state = await readState(home())
237
+ const raw = state?.settings?.location
238
+ if (raw && Number.isFinite(raw.lat) && Number.isFinite(raw.lon) && typeof raw.tz === 'string') {
239
+ return sanitizeLocation(raw)
240
+ }
241
+ return sanitizeLocation(guessCity({ tz: Intl.DateTimeFormat().resolvedOptions().timeZone }))
242
+ }
243
+
244
+ const methods = {
245
+ // GET /intertidal/state —— 一次拿全,减少首屏往返(settings + rhythm + now)。
246
+ async state(req, res) {
247
+ const state = await readState(home())
248
+ const location = sanitizeLocation(state?.settings?.location)
249
+ return json(res, 200, {
250
+ settings: state.settings,
251
+ rhythm: state.rhythm,
252
+ now: nowPayload(location, clock()),
253
+ home: home(),
254
+ })
255
+ },
256
+
257
+ // GET /intertidal/now?lat=&lon=&tz=
258
+ async now(req, res, url) {
259
+ const q = url.searchParams
260
+ const location = sanitizeLocation({
261
+ lat: q.get('lat') !== null ? Number(q.get('lat')) : undefined,
262
+ lon: q.get('lon') !== null ? Number(q.get('lon')) : undefined,
263
+ tz: q.get('tz') ?? undefined,
264
+ })
265
+ // 没带参数就用设置里的地点
266
+ const explicit = q.get('lat') !== null || q.get('lon') !== null || q.get('tz') !== null
267
+ return json(res, 200, nowPayload(explicit ? location : await currentLocation(), clock(), true))
268
+ },
269
+
270
+ // POST /intertidal/settings
271
+ async settings(req, res) {
272
+ const body = await readJsonBody(req)
273
+ const settings = await patchSettings(home(), body)
274
+ return json(res, 200, settings)
275
+ },
276
+
277
+ // POST /intertidal/rhythm { event }
278
+ async rhythm(req, res) {
279
+ const body = await readJsonBody(req)
280
+ const event = typeof body.event === 'string' ? body.event : ''
281
+ if (!['shown', 'hidden', 'skip-today', 'manual-open'].includes(event)) {
282
+ return fail(res, 400, '未知的 rhythm 事件')
283
+ }
284
+ const rhythm = await rhythmEvent(home(), event, clock())
285
+ return json(res, 200, rhythm)
286
+ },
287
+
288
+ // GET /intertidal/media?fresh=1 —— 这一次浮现该用哪张素材
289
+ async media(req, res, url) {
290
+ const state = await readState(home())
291
+ const media = state?.settings?.media ?? { mode: 'off' }
292
+ if (media.mode === 'off') return json(res, 200, { item: null })
293
+
294
+ if (media.mode === 'dir') {
295
+ const item = await pickFromDir(media.dir, { shuffle: media.shuffle !== false })
296
+ if (!item) return json(res, 200, { item: null, reason: 'empty-dir' })
297
+ const file = await openMediaFile(media.dir, item.name)
298
+ if (file && item.kind === 'image') {
299
+ const size = await sniffSize(file.path)
300
+ if (size) Object.assign(item, size)
301
+ }
302
+ return json(res, 200, { item })
303
+ }
304
+
305
+ // url 模式:缓存优先(离线也能看),没有缓存时才去下载
306
+ const cache = mediaCacheDir(home())
307
+ const fresh = url.searchParams.get('fresh') === '1'
308
+ let item = fresh ? null : await pickFromCache(cache)
309
+ if (!item) {
310
+ const urls = Array.isArray(media.urls) ? media.urls : []
311
+ const order = media.shuffle === false ? urls : [...urls].sort(() => Math.random() - 0.5)
312
+ for (const target of order.slice(0, 3)) {
313
+ item = await resolveUrlToCache(target, cache)
314
+ if (item) break
315
+ }
316
+ }
317
+ if (!item) return json(res, 200, { item: null, reason: 'unresolved' })
318
+ const file = await openMediaFile(cache, item.name)
319
+ if (file && item.kind === 'image') {
320
+ const size = await sniffSize(file.path)
321
+ if (size) Object.assign(item, size)
322
+ }
323
+ return json(res, 200, { item })
324
+ },
325
+
326
+ // GET /intertidal/media/file?name=xxx.jpg —— 字节流(支持 Range,视频要循环播放)
327
+ async mediaFile(req, res, url) {
328
+ const state = await readState(home())
329
+ const media = state?.settings?.media ?? { mode: 'off' }
330
+ const name = url.searchParams.get('name') ?? ''
331
+ if (!name) return fail(res, 400, '缺少 name')
332
+ if (media.mode === 'off') return fail(res, 404, '未启用素材')
333
+
334
+ const candidates = [mediaCacheDir(home())]
335
+ if (media.dir) candidates.push(media.dir)
336
+ let file = null
337
+ for (const directory of candidates) {
338
+ file = await openMediaFile(directory, name)
339
+ if (file) break
340
+ }
341
+ if (!file) return fail(res, 404, '素材不存在')
342
+
343
+ res.setHeader('content-type', file.mime)
344
+ res.setHeader('accept-ranges', 'bytes')
345
+ res.setHeader('cache-control', 'private, max-age=86400')
346
+ res.setHeader('x-content-type-options', 'nosniff')
347
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers?.range ?? '')
348
+ if (range && (range[1] || range[2])) {
349
+ const start = range[1] ? Number(range[1]) : Math.max(0, file.size - Number(range[2]))
350
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), file.size - 1) : file.size - 1
351
+ if (start >= file.size || start > end) {
352
+ res.statusCode = 416
353
+ res.setHeader('content-range', `bytes */${file.size}`)
354
+ return res.end()
355
+ }
356
+ res.statusCode = 206
357
+ res.setHeader('content-range', `bytes ${start}-${end}/${file.size}`)
358
+ res.setHeader('content-length', end - start + 1)
359
+ return file.stream({ start, end }).pipe(res)
360
+ }
361
+ res.statusCode = 200
362
+ res.setHeader('content-length', file.size)
363
+ if (req.method === 'HEAD') return res.end()
364
+ return file.stream().pipe(res)
365
+ },
366
+
367
+ // POST /intertidal/media/resolve { url }
368
+ async mediaResolve(req, res) {
369
+ const body = await readJsonBody(req)
370
+ const target = typeof body.url === 'string' ? body.url.trim() : ''
371
+ if (!target) return fail(res, 400, '缺少 url')
372
+ const item = await resolveUrlToCache(target, mediaCacheDir(home()))
373
+ if (!item) return json(res, 200, { item: null, reason: 'unresolved' })
374
+ return json(res, 200, { item })
375
+ },
376
+
377
+ // POST /intertidal/media/scan { dir } —— 设置页验证目录
378
+ async mediaScan(req, res) {
379
+ const body = await readJsonBody(req)
380
+ const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
381
+ if (!dir) return fail(res, 400, '缺少 dir')
382
+ const scan = await scanDir(dir)
383
+ return json(res, 200, scan)
384
+ },
385
+ }
386
+
387
+ return {
388
+ methods,
389
+ /** Node http 风格的入口。 */
390
+ async handler(req, res) {
391
+ try {
392
+ const rejection = rejectionFor(ctx, req)
393
+ if (rejection) {
394
+ logger?.warn?.(`dsh-intertidal: 请求被准入围栏拒绝(${rejection})`)
395
+ return fail(res, 403, 'forbidden')
396
+ }
397
+ const url = new URL(req.url ?? '/', 'http://localhost')
398
+ // `/intertidal/state` → `state`
399
+ const path = url.pathname.replace(/^\/intertidal\/?/, '').replace(/\/$/, '')
400
+ if (req.method === 'GET' && (path === '' || path === 'state')) return await methods.state(req, res, url)
401
+ if (req.method === 'GET' && path === 'now') return await methods.now(req, res, url)
402
+ if (req.method === 'POST' && path === 'settings') return await methods.settings(req, res, url)
403
+ if (req.method === 'POST' && path === 'rhythm') return await methods.rhythm(req, res, url)
404
+ if (req.method === 'GET' && path === 'media') return await methods.media(req, res, url)
405
+ if (req.method === 'GET' && path === 'media/file') return await methods.mediaFile(req, res, url)
406
+ if (req.method === 'POST' && path === 'media/resolve') return await methods.mediaResolve(req, res, url)
407
+ if (req.method === 'POST' && path === 'media/scan') return await methods.mediaScan(req, res, url)
408
+ if (path in methods) return fail(res, 405, '方法不允许')
409
+ return fail(res, 404, '没有这个接口')
410
+ } catch (error) {
411
+ const status = Number(error?.status) || 500
412
+ if (status >= 500) logger?.warn?.(`dsh-intertidal: ${API_PREFIX} 处理失败:${String(error?.message ?? error)}`)
413
+ return fail(res, status, status >= 500 ? '潮间宿主侧出错' : String(error?.message ?? error))
414
+ }
415
+ },
416
+ }
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // cordis 插件
421
+
422
+ export function apply(ctx) {
423
+ const logger = ctx.logger ?? ctx.get?.('logger')
424
+ const api = createApiHandler({ ctx, logger })
425
+
426
+ const mount = (scope) => {
427
+ try {
428
+ scope.effect(
429
+ () =>
430
+ scope.webServer.register({
431
+ kind: 'prefix',
432
+ path: API_PREFIX,
433
+ handler: api.handler,
434
+ }),
435
+ 'dsh-intertidal: api',
436
+ )
437
+ } catch (error) {
438
+ // 同一 path 注册两次会抛(插件被装两遍)。宁可少一条路由并留日志,也不让整层装载失败。
439
+ logger?.warn?.(`dsh-intertidal: 注册 ${API_PREFIX} 失败:${String(error?.message ?? error)}`)
440
+ }
441
+ }
442
+
443
+ if (typeof ctx.inject === 'function') ctx.inject(['webServer'], mount)
444
+ else mount(ctx)
445
+
446
+ // 首次启动记下 installedAt(用于"装完先安静 20 分钟")。失败只记日志。
447
+ void (async () => {
448
+ try {
449
+ const state = await readState(resolveDshHome())
450
+ if (!state.rhythm.installedAt) {
451
+ state.rhythm.installedAt = Math.floor(Date.now() / 1000)
452
+ await writeState(resolveDshHome(), state)
453
+ }
454
+ } catch (error) {
455
+ logger?.warn?.(`dsh-intertidal: 初始化状态文件失败(${statePath(resolveDshHome())}):${String(error?.message ?? error)}`)
456
+ }
457
+ })()
458
+ }