dsh-reference-session-collector 0.0.0-stage → 0.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/README.de.md +75 -0
- package/README.en.md +110 -0
- package/README.es.md +74 -0
- package/README.fr.md +74 -0
- package/README.it.md +74 -0
- package/README.ja.md +68 -0
- package/README.ko.md +68 -0
- package/README.md +111 -3
- package/README.ru.md +73 -0
- package/assets/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +21 -0
- package/assets/deepseek-v4/manifest.json +23 -0
- package/assets/deepseek-v4/tokenizer.json +267359 -0
- package/assets/deepseek-v4/tokenizer_config.json +34 -0
- package/cordis.patch.yml +11 -0
- package/dsh.plugin.json +22 -0
- package/lib/client.js +15 -0
- package/lib/index.d.ts +668 -0
- package/lib/index.js +2150 -0
- package/package.json +90 -4
- package/src/ambient.d.ts +9 -0
- package/src/client/index.ts +10 -0
- package/src/deepseek-v4-tokenizer.ts +212 -0
- package/src/index.ts +431 -0
- package/src/projection.ts +478 -0
- package/src/schema.ts +288 -0
- package/src/segment.ts +67 -0
- package/src/session-file-decode.ts +529 -0
- package/src/strip-injected.ts +46 -0
- package/src/surface.ts +392 -0
- package/src/sync.ts +434 -0
- package/src/token-count.ts +77 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-reference-session-collector 宿主半(T5.1)。
|
|
3
|
+
*
|
|
4
|
+
* - `export const inject = ['webServer']`(唯一声明;webStartup 不列,老线兼容
|
|
5
|
+
* ——trustedHosts 经 ctx.get 双名回退链 best-effort 读取)。
|
|
6
|
+
* - 单条 fenced 路由 `GET/POST /api/dsh-reference-session-collector`,op 分发
|
|
7
|
+
* status / sessions / session / surface / recall / search / sync。
|
|
8
|
+
* - 双通道注册(direct `ctx.get` + `ctx.inject` 迟到臂),duplicate-route
|
|
9
|
+
* 容错(同 lib 双挂载 = 成功),`ctx.effect` 注销。
|
|
10
|
+
* - webServer 缺席时插件本体照常同步,仅 HTTP 面缺失(fail-soft)。
|
|
11
|
+
* - 启动延迟 2.5s 首轮 syncAll + interval 轮(缺省 30s,env 可关);
|
|
12
|
+
* dispose 关库清定时器。
|
|
13
|
+
*
|
|
14
|
+
* 只读纪律:本文件不直接触碰会话文件——全部经 ProjectionSync 端口。
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
|
+
import {
|
|
19
|
+
createQueryContext,
|
|
20
|
+
listSessions,
|
|
21
|
+
recallReport,
|
|
22
|
+
search,
|
|
23
|
+
searchGrouped,
|
|
24
|
+
sessionDetail,
|
|
25
|
+
status,
|
|
26
|
+
surfaceLedger,
|
|
27
|
+
type QueryContext,
|
|
28
|
+
} from './surface.ts'
|
|
29
|
+
import { openProjectionStore, type ProjectionStore } from './schema.ts'
|
|
30
|
+
import { projectSessionEvents } from './projection.ts'
|
|
31
|
+
import type { SessionProjection } from './projection.ts'
|
|
32
|
+
|
|
33
|
+
// steward 式导出面:库/投影/切词/查询/同步 API 对消费方(与单测)可导入。
|
|
34
|
+
export { openProjectionStore, reloadSummaries, PROJECTION_SCHEMA_VERSION,
|
|
35
|
+
type ProjectionStore, type SessionRow } from './schema.ts'
|
|
36
|
+
export { projectSessionEvents, extractSearchEventText, extractTitleFromEvents,
|
|
37
|
+
type SessionProjection, type ProjectionSegment, type ProjectionReplace,
|
|
38
|
+
type SessionSummary, type ProjectionOptions, type ExactCounter } from './projection.ts'
|
|
39
|
+
export { segmentForIndex, segmentQueryTerm, sanitizeFtsQuery } from './segment.ts'
|
|
40
|
+
export { stripInjectedBlocks, INJECTED_BLOCK_TAGS } from './strip-injected.ts'
|
|
41
|
+
export { createQueryContext, status, listSessions, sessionDetail, surfaceLedger,
|
|
42
|
+
recallReport, search, searchGrouped, buildSearchSnippet, LedgerCache, LEDGER_LRU_CAP,
|
|
43
|
+
type QueryContext, type GroupedSearchRequest, type GroupedSearchHit } from './surface.ts'
|
|
44
|
+
export { ProjectionSync, discoverSessionLogs, resolveDshHome, resolveIndexDir,
|
|
45
|
+
resolveExactCounter, readArchivedSessionIds, startIntervalSync, resolveSyncIntervalMs,
|
|
46
|
+
DEFAULT_SYNC_INTERVAL_MS, SYNC_INTERVAL_ENV, DISCOVERY_LIMIT,
|
|
47
|
+
type SyncResult, type SyncStorePort, type ProjectionPort } from './sync.ts'
|
|
48
|
+
export { decodeSessionLogBytes, decodeSessionLogFile, classifySessionHeader,
|
|
49
|
+
readSessionGenerations, generationLogFilename,
|
|
50
|
+
type DecodedEvent, type SessionLogRead } from './session-file-decode.ts'
|
|
51
|
+
import {
|
|
52
|
+
ProjectionSync,
|
|
53
|
+
resolveIndexDir,
|
|
54
|
+
startIntervalSync,
|
|
55
|
+
type SyncReplaceMeta,
|
|
56
|
+
type SyncStorePort,
|
|
57
|
+
type SyncWatermark,
|
|
58
|
+
} from './sync.ts'
|
|
59
|
+
|
|
60
|
+
/** 本插件声明的宿主服务(唯一声明;webStartup 走 ctx.get 回退臂)。 */
|
|
61
|
+
export const inject = ['webServer']
|
|
62
|
+
|
|
63
|
+
/** API 路由前缀(fenced 单条)。 */
|
|
64
|
+
export const DRS_API_PATH = '/api/dsh-reference-session-collector'
|
|
65
|
+
|
|
66
|
+
/** 启动首轮同步延迟(ms)——让路给宿主冷启动。 */
|
|
67
|
+
export const BOOT_SYNC_DELAY_MS = 2_500
|
|
68
|
+
|
|
69
|
+
/** webServer 服务面(结构化镜像,零 value import)。 */
|
|
70
|
+
interface WebServerLike {
|
|
71
|
+
register(route: {
|
|
72
|
+
kind: 'exact' | 'prefix'
|
|
73
|
+
path: string
|
|
74
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
75
|
+
}): (() => void) | void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** web runtime 服务面:绑定派生的可信 authority。 */
|
|
79
|
+
interface WebStartupLike {
|
|
80
|
+
trustedHosts: readonly string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `webServer.register` 对同串 exact 路由抛 duplicate——双挂载视为成功(CCI 同款)。 */
|
|
84
|
+
const isDuplicateRouteError = (error: unknown): boolean =>
|
|
85
|
+
String((error as Error)?.message ?? error).includes('duplicate exact route')
|
|
86
|
+
|
|
87
|
+
function asWebServer(value: unknown): WebServerLike | undefined {
|
|
88
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
89
|
+
return typeof (value as WebServerLike).register === 'function' ? (value as WebServerLike) : undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeJson(res: ServerResponse, code: number, body: unknown): void {
|
|
93
|
+
try {
|
|
94
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
|
|
95
|
+
} catch { /* 已发头的 mock res(单测面)忽略 */ }
|
|
96
|
+
res.end(JSON.stringify(body))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Host 头信任门(steward isTrustedApiRequest 同款简化):trustedHosts 缺席=放行。 */
|
|
100
|
+
function isTrustedRequest(req: IncomingMessage, trustedHosts: readonly string[] | undefined): boolean {
|
|
101
|
+
if (trustedHosts === undefined || trustedHosts.length === 0) return true
|
|
102
|
+
const host = req.headers['host']
|
|
103
|
+
if (typeof host !== 'string') return false
|
|
104
|
+
return trustedHosts.some(trusted => trusted.toLowerCase() === host.toLowerCase())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** sync 端口 → ProjectionStore 真身适配(dir 水位经常驻 Map 扫描)。 */
|
|
108
|
+
export function createStorePort(store: ProjectionStore): SyncStorePort {
|
|
109
|
+
return {
|
|
110
|
+
getSessionByDir(dirId: string): SyncWatermark | undefined {
|
|
111
|
+
for (const row of store.summaries.values()) {
|
|
112
|
+
if (row.dir_id !== dirId) continue
|
|
113
|
+
return {
|
|
114
|
+
sessionId: row.session_id,
|
|
115
|
+
artifactBytes: row.artifact_bytes,
|
|
116
|
+
artifactMtimeMs: row.artifact_mtime_ms,
|
|
117
|
+
archived: row.archived,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined
|
|
121
|
+
},
|
|
122
|
+
replaceSession(projection: unknown, meta: SyncReplaceMeta): void {
|
|
123
|
+
store.replaceSession(projection as SessionProjection, meta.artifactBytes, meta.artifactMtimeMs, {
|
|
124
|
+
archived: meta.archived === 1 ? 1 : 0,
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
setSessionArchived(sessionId: string, archived: number): void {
|
|
128
|
+
store.setArchived(sessionId, archived === 1 ? 1 : 0)
|
|
129
|
+
},
|
|
130
|
+
listProjectedDirs(): { dirId: string; sessionId: string }[] {
|
|
131
|
+
const out: { dirId: string; sessionId: string }[] = []
|
|
132
|
+
for (const row of store.summaries.values()) {
|
|
133
|
+
out.push({ dirId: row.dir_id, sessionId: row.session_id })
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
},
|
|
137
|
+
deleteSession(sessionId: string): void {
|
|
138
|
+
store.deleteSession(sessionId)
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 请求处理依赖(导出面便于路由单测直调)。 */
|
|
144
|
+
export interface DrsHandlerDeps {
|
|
145
|
+
query: QueryContext
|
|
146
|
+
sync: { syncAll: () => Promise<unknown>; lastSyncResult: unknown; isSyncing: boolean }
|
|
147
|
+
trustedHosts?: readonly string[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 省参自解析(CCI 无参臂契约):max(last_time_ms) 的最活跃会话;
|
|
152
|
+
* 同刻按 synced_at 新者;空库返回 undefined(调用侧 400)。
|
|
153
|
+
*/
|
|
154
|
+
function resolveLatestSessionId(query: QueryContext): string | undefined {
|
|
155
|
+
let bestId: string | undefined
|
|
156
|
+
let bestTime = -1
|
|
157
|
+
let bestSync = ''
|
|
158
|
+
for (const row of query.store.summaries.values()) {
|
|
159
|
+
if (row.last_time_ms > bestTime || (row.last_time_ms === bestTime && row.synced_at > bestSync)) {
|
|
160
|
+
bestId = row.session_id
|
|
161
|
+
bestTime = row.last_time_ms
|
|
162
|
+
bestSync = row.synced_at
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return bestId
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* op 分发:GET `?op=…`;POST `{op:'sync'}` 触发后台同步。
|
|
170
|
+
* 未知 op / 缺参 → 400(不留空壳)。
|
|
171
|
+
*/
|
|
172
|
+
export async function handleDrsRequest(deps: DrsHandlerDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
173
|
+
if (!isTrustedRequest(req, deps.trustedHosts)) {
|
|
174
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
let url: URL
|
|
178
|
+
try {
|
|
179
|
+
url = new URL(req.url ?? DRS_API_PATH, 'http://localhost')
|
|
180
|
+
} catch {
|
|
181
|
+
writeJson(res, 400, { ok: false, error: 'bad url' })
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const op = url.searchParams.get('op') ?? ''
|
|
185
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
186
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
187
|
+
|
|
188
|
+
if (method === 'POST') {
|
|
189
|
+
if (op !== 'sync') {
|
|
190
|
+
writeJson(res, 400, { ok: false, error: `unknown POST op: ${op || '(missing)'}` })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
// 触发后台同步即回(syncing 旗标防撞轮,busy 轮自然合并到下一 interval)。
|
|
194
|
+
void deps.sync.syncAll().catch(() => {})
|
|
195
|
+
writeJson(res, 200, { ok: true, accepted: true, lastResult: deps.sync.lastSyncResult ?? null })
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
switch (op) {
|
|
200
|
+
case 'status': {
|
|
201
|
+
writeJson(res, 200, { ok: true, ...status(deps.query, deps.sync.lastSyncResult ?? null) })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
case 'sessions': {
|
|
205
|
+
const offset = Number(url.searchParams.get('offset') ?? '0')
|
|
206
|
+
const limit = Number(url.searchParams.get('limit') ?? '50')
|
|
207
|
+
writeJson(res, 200, { ok: true, ...listSessions(deps.query,
|
|
208
|
+
Number.isFinite(offset) ? offset : 0,
|
|
209
|
+
Number.isFinite(limit) ? limit : 50) })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
case 'session': {
|
|
213
|
+
if (sessionId === '') {
|
|
214
|
+
writeJson(res, 400, { ok: false, error: 'session parameter required' })
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
const turn = url.searchParams.get('turn') ?? undefined
|
|
218
|
+
const fields = url.searchParams.get('fields') ?? undefined
|
|
219
|
+
const detail = sessionDetail(deps.query, sessionId, {
|
|
220
|
+
...(turn === undefined ? {} : { turn }),
|
|
221
|
+
...(fields === undefined ? {} : { fields }),
|
|
222
|
+
})
|
|
223
|
+
if (detail.session === undefined) {
|
|
224
|
+
writeJson(res, 200, { ok: true, session: null, rows: [] })
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
writeJson(res, 200, { ok: true, ...detail })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
case 'surface': {
|
|
231
|
+
const sid = sessionId !== '' ? sessionId : resolveLatestSessionId(deps.query)
|
|
232
|
+
if (sid === undefined) {
|
|
233
|
+
writeJson(res, 400, { ok: false, error: 'session parameter required (no sessions to resolve)' })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const ledger = surfaceLedger(deps.query, sid)
|
|
237
|
+
writeJson(res, 200, sessionId !== ''
|
|
238
|
+
? { ok: true, ...ledger }
|
|
239
|
+
: { ok: true, ...ledger, resolved_session_id: sid, resolved_by: 'latest' })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
case 'recall': {
|
|
243
|
+
const sid = sessionId !== '' ? sessionId : resolveLatestSessionId(deps.query)
|
|
244
|
+
if (sid === undefined) {
|
|
245
|
+
writeJson(res, 400, { ok: false, error: 'session parameter required (no sessions to resolve)' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const report = recallReport(deps.query, sid)
|
|
249
|
+
writeJson(res, 200, sessionId !== ''
|
|
250
|
+
? { ok: true, ...report }
|
|
251
|
+
: { ok: true, ...report, resolved_session_id: sid, resolved_by: 'latest' })
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
case 'search': {
|
|
255
|
+
const q = url.searchParams.get('q') ?? ''
|
|
256
|
+
if (q === '') {
|
|
257
|
+
writeJson(res, 400, { ok: false, error: 'q parameter required' })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
// 分组臂(steward 直连):group=session 时走 searchGrouped,参数见
|
|
261
|
+
// GroupedSearchRequest;缺省仍为逐文档平铺命中(CCI/既有调用不变)。
|
|
262
|
+
if (url.searchParams.get('group') === 'session') {
|
|
263
|
+
const kindsRaw = url.searchParams.get('kinds') ?? ''
|
|
264
|
+
const archivedRaw = url.searchParams.get('archived')
|
|
265
|
+
const fromRaw = url.searchParams.get('from')
|
|
266
|
+
const toRaw = url.searchParams.get('to')
|
|
267
|
+
const limitRaw = url.searchParams.get('limit')
|
|
268
|
+
const grouped = searchGrouped(deps.query, {
|
|
269
|
+
query: q,
|
|
270
|
+
...(kindsRaw === '' ? {} : { kinds: kindsRaw.split(',').map(k => k.trim()).filter(Boolean) }),
|
|
271
|
+
...(archivedRaw === 'active' || archivedRaw === 'archived' || archivedRaw === 'all'
|
|
272
|
+
? { archived: archivedRaw } : {}),
|
|
273
|
+
...(fromRaw !== null && Number.isFinite(Number(fromRaw)) ? { from: Number(fromRaw) } : {}),
|
|
274
|
+
...(toRaw !== null && Number.isFinite(Number(toRaw)) ? { to: Number(toRaw) } : {}),
|
|
275
|
+
...(url.searchParams.get('surface') === '0' ? { surfaceOnly: false } : {}),
|
|
276
|
+
...(url.searchParams.get('sort') === 'time' ? { sortBy: 'time' as const } : {}),
|
|
277
|
+
...(limitRaw !== null && Number.isFinite(Number(limitRaw)) ? { limit: Number(limitRaw) } : {}),
|
|
278
|
+
})
|
|
279
|
+
writeJson(res, 200, { ok: true, ...grouped })
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
const limitRaw = url.searchParams.get('limit')
|
|
283
|
+
writeJson(res, 200, { ok: true, ...search(deps.query, q, {
|
|
284
|
+
...(sessionId === '' ? {} : { session: sessionId }),
|
|
285
|
+
...(limitRaw === null ? {} : { limit: Number(limitRaw) }),
|
|
286
|
+
}) })
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
default: {
|
|
290
|
+
writeJson(res, 400, { ok: false, error: `unknown op: ${op || '(missing)'}` })
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** cordis 对未声明 inject 的服务面访问(属性读与 ctx.get)都会抛——两层安全
|
|
296
|
+
* 包裹(装机实锤×2:请求期未保护读 webStartup/webRuntime → webServer 全局
|
|
297
|
+
* 兑底 400 空体;Proxy 属性读臂同样需要 try)。 */
|
|
298
|
+
function safeReadService(ctx: Context, name: string): unknown {
|
|
299
|
+
try {
|
|
300
|
+
const direct = (ctx as unknown as Record<string, unknown>)[name]
|
|
301
|
+
if (direct !== undefined) return direct
|
|
302
|
+
} catch { /* Proxy 面:未声明服务属性读抛 */ }
|
|
303
|
+
try {
|
|
304
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined
|
|
305
|
+
} catch {
|
|
306
|
+
return undefined
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 插件主体:开库 → 装配 sync(启动延迟首轮 + interval 轮)→ 双通道注册路由。
|
|
312
|
+
* @param ctx - host 插件上下文(webServer 声明注入;webStartup/webRuntime 回退臂)。
|
|
313
|
+
*/
|
|
314
|
+
export function apply(ctx: Context): void {
|
|
315
|
+
const log = (level: 'info' | 'warn', message: string, ...args: unknown[]): void => {
|
|
316
|
+
try {
|
|
317
|
+
const logger = (ctx as unknown as { logger?: { warn?: (m: string, ...a: unknown[]) => void; info?: (m: string, ...a: unknown[]) => void } }).logger
|
|
318
|
+
if (level === 'warn' && typeof logger?.warn === 'function') logger.warn(message, ...args)
|
|
319
|
+
else if (level === 'info' && typeof logger?.info === 'function') logger.info(message, ...args)
|
|
320
|
+
else console[level]('[dsh-reference-session-collector]', message, ...args)
|
|
321
|
+
} catch { /* 日志面永不致命 */ }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const storeDir = resolveIndexDir()
|
|
325
|
+
let store: ProjectionStore
|
|
326
|
+
let query: QueryContext
|
|
327
|
+
try {
|
|
328
|
+
store = openProjectionStore(storeDir)
|
|
329
|
+
query = createQueryContext(store)
|
|
330
|
+
} catch (error) {
|
|
331
|
+
log('warn', 'projection store 不可用(node:sqlite 缺席/目录不可写):插件停用—— %s',
|
|
332
|
+
String((error as Error)?.message ?? error))
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const sync = new ProjectionSync({
|
|
337
|
+
store: createStorePort(store),
|
|
338
|
+
project: (events, options) => projectSessionEvents(events, {
|
|
339
|
+
dirId: options.dirId,
|
|
340
|
+
...(options.header === undefined ? {} : { header: options.header }),
|
|
341
|
+
// 端口计数器返 TokenCount;投影只需数值臂(CCI verify 同款降级)。
|
|
342
|
+
...(options.exactCounter === undefined ? {} : {
|
|
343
|
+
exactCounter: (text: string): number => {
|
|
344
|
+
const count = options.exactCounter?.(text)
|
|
345
|
+
return count !== undefined && count.kind === 'exact-tokenizer'
|
|
346
|
+
? count.tokens
|
|
347
|
+
: Math.ceil(text.length / 4)
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
}),
|
|
351
|
+
log: (level, message, ...args) => log(level, message, ...args),
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// 生命周期:dispose 清定时器 + 关库。
|
|
355
|
+
let bootTimer: ReturnType<typeof setTimeout> | undefined
|
|
356
|
+
let disposeInterval: () => void = () => {}
|
|
357
|
+
ctx.effect(() => {
|
|
358
|
+
bootTimer = setTimeout(() => {
|
|
359
|
+
void sync.syncAll().then(result => {
|
|
360
|
+
log('info', 'boot sync done: updated=%d skipped=%d basis=%s %dms',
|
|
361
|
+
result.updated, result.skipped, result.basis, result.durationMs)
|
|
362
|
+
}).catch((error: unknown) => log('warn', 'boot sync failed: %s', String((error as Error)?.message ?? error)))
|
|
363
|
+
}, BOOT_SYNC_DELAY_MS)
|
|
364
|
+
disposeInterval = startIntervalSync(sync)
|
|
365
|
+
return () => {
|
|
366
|
+
if (bootTimer !== undefined) clearTimeout(bootTimer)
|
|
367
|
+
disposeInterval()
|
|
368
|
+
store.close()
|
|
369
|
+
}
|
|
370
|
+
}, 'dsh-reference-session-collector: projection lifecycle')
|
|
371
|
+
|
|
372
|
+
// trustedHosts best-effort(双名回退链,属性/get 两层 safe;缺席=放行)。
|
|
373
|
+
const readStartupFace = (): WebStartupLike | undefined => {
|
|
374
|
+
const direct = safeReadService(ctx, 'webStartup') ?? safeReadService(ctx, 'webRuntime')
|
|
375
|
+
return direct !== null && typeof direct === 'object' ? direct as WebStartupLike : undefined
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const depsFor = (): DrsHandlerDeps => {
|
|
379
|
+
const startup = readStartupFace()
|
|
380
|
+
return {
|
|
381
|
+
query,
|
|
382
|
+
sync,
|
|
383
|
+
...(startup?.trustedHosts !== undefined ? { trustedHosts: startup.trustedHosts } : {}),
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let registered = false
|
|
388
|
+
const register = (webServer: WebServerLike, channel: 'direct' | 'inject'): void => {
|
|
389
|
+
if (registered) return
|
|
390
|
+
try {
|
|
391
|
+
const off = webServer.register({
|
|
392
|
+
kind: 'exact',
|
|
393
|
+
path: DRS_API_PATH,
|
|
394
|
+
handler: (req, res) => handleDrsRequest(depsFor(), req, res),
|
|
395
|
+
})
|
|
396
|
+
registered = true
|
|
397
|
+
if (typeof off === 'function') {
|
|
398
|
+
ctx.effect(() => off, 'dsh-reference-session-collector: /api route unregister')
|
|
399
|
+
}
|
|
400
|
+
log('info', 'drs api route registered (%s)', channel)
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (isDuplicateRouteError(error)) {
|
|
403
|
+
registered = true
|
|
404
|
+
log('info', 'drs api route already owned by second mount — ok')
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
log('warn', 'drs api route registration failed (%s): %s', channel, String((error as Error)?.message ?? error))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const readService = (name: string): unknown => safeReadService(ctx, name)
|
|
412
|
+
|
|
413
|
+
const active = asWebServer(readService('webServer'))
|
|
414
|
+
if (active !== undefined) {
|
|
415
|
+
register(active, 'direct')
|
|
416
|
+
if (registered) return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
ctx.inject(['webServer'], (injected) => {
|
|
421
|
+
const webServer = asWebServer((injected as { webServer?: unknown }).webServer)
|
|
422
|
+
if (webServer === undefined) {
|
|
423
|
+
log('warn', 'webServer exposes no register() — drs api route not registered (projection sync keeps running)')
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
register(webServer, 'inject')
|
|
427
|
+
})
|
|
428
|
+
} catch (error) {
|
|
429
|
+
log('warn', 'webServer inject arm unavailable: %s — HTTP 面缺席,同步本体照常', String((error as Error)?.message ?? error))
|
|
430
|
+
}
|
|
431
|
+
}
|