@mingozhou/dsh-replay 0.3.3 → 0.4.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/README.md +225 -164
- package/README.zh.md +225 -164
- package/assets/icon.png +0 -0
- package/lib/client.js +955 -2419
- package/lib/core.js +557 -86
- package/lib/index.js +1043 -51
- package/lib/types/src/client/api.d.ts +44 -0
- package/lib/types/src/client/components/AuditView.d.ts +2 -1
- package/lib/types/src/client/components/CompareView.d.ts +2 -1
- package/lib/types/src/client/components/DashboardView.d.ts +8 -0
- package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
- package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
- package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
- package/lib/types/src/client/components/TimelineView.d.ts +2 -1
- package/lib/types/src/client/export.d.ts +6 -1
- package/lib/types/src/client/i18n.d.ts +8 -1
- package/lib/types/src/client/pricing.d.ts +6 -0
- package/lib/types/src/core/audit.d.ts +18 -0
- package/lib/types/src/core/compare.d.ts +8 -3
- package/lib/types/src/core/cost.d.ts +21 -3
- package/lib/types/src/core/index.d.ts +1 -0
- package/lib/types/src/core/redact.d.ts +40 -0
- package/lib/types/src/core/timeline.d.ts +9 -0
- package/lib/types/src/index.d.ts +52 -1
- package/lib/viewer.js +282 -29
- package/package.json +118 -107
- package/scripts/build.mjs +8 -2
- package/src/client/api.ts +114 -10
- package/src/client/components/AuditView.tsx +89 -10
- package/src/client/components/CompareView.tsx +89 -13
- package/src/client/components/DashboardView.tsx +232 -0
- package/src/client/components/ExportDialog.tsx +136 -0
- package/src/client/components/ItemDetail.tsx +36 -4
- package/src/client/components/OverviewView.tsx +150 -34
- package/src/client/components/ReplayApp.tsx +207 -60
- package/src/client/components/ReplayModal.tsx +41 -6
- package/src/client/components/SessionPicker.tsx +55 -27
- package/src/client/components/TimelineView.tsx +106 -41
- package/src/client/export.ts +20 -4
- package/src/client/i18n.ts +266 -45
- package/src/client/index.ts +7 -5
- package/src/client/mascot-image.ts +1 -1
- package/src/client/pricing.ts +90 -0
- package/src/client/styles.css +250 -1
- package/src/client/viewer-entry.tsx +25 -4
- package/src/core/audit.ts +274 -67
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +94 -16
- package/src/core/index.ts +1 -0
- package/src/core/redact.ts +182 -0
- package/src/core/timeline.ts +107 -12
- package/src/index.ts +290 -54
package/src/index.ts
CHANGED
|
@@ -4,15 +4,23 @@
|
|
|
4
4
|
* Registers a small read-only HTTP API on the harness web server that the
|
|
5
5
|
* browser half (and nothing else) consumes:
|
|
6
6
|
*
|
|
7
|
-
* GET /replay/api/sessions → { sessions: [{ header, title }] }
|
|
7
|
+
* GET /replay/api/sessions → { sessions: [{ header, title, live }] }
|
|
8
8
|
* GET /replay/api/session/<id> → { header, events }
|
|
9
|
+
* GET /replay/api/summary?limit=30 → aggregate stats for the dashboard
|
|
10
|
+
* GET /replay/api/viewer.js → standalone viewer bundle
|
|
9
11
|
*
|
|
10
12
|
* Reads go through `ctx.sessionQuery` (live-preferred corpus), so an open
|
|
11
13
|
* session replays with its in-memory state, not a stale disk artifact.
|
|
14
|
+
*
|
|
15
|
+
* The API is intentionally loopback-only by default: session logs can contain
|
|
16
|
+
* secrets, and this route is registered directly on the web server rather than
|
|
17
|
+
* through the authenticated Connection fetch bridge.
|
|
12
18
|
*/
|
|
13
19
|
import type { Context } from '@deepseek-ai/cordis'
|
|
14
20
|
import { readFile } from 'node:fs/promises'
|
|
15
21
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
22
|
+
import { auditSession, buildTimeline, fromInspection } from './core/index.js'
|
|
23
|
+
import type { ParsedSession, SessionEvent, SessionHeader, StorageRecord } from './core/types.js'
|
|
16
24
|
|
|
17
25
|
export const name = 'dsh-replay'
|
|
18
26
|
|
|
@@ -21,12 +29,17 @@ export const inject = ['webServer', 'sessionQuery']
|
|
|
21
29
|
export interface Config {
|
|
22
30
|
/** URL prefix for the replay API (exact-prefix route). */
|
|
23
31
|
routePrefix?: string
|
|
32
|
+
/** Allow non-loopback clients. Off by default because the route is unauthenticated. */
|
|
33
|
+
allowRemote?: boolean
|
|
34
|
+
/** Maximum sessions included in one dashboard summary request. */
|
|
35
|
+
summaryLimit?: number
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
interface SessionQueryLike {
|
|
27
39
|
listSessions: (signal?: AbortSignal) => Promise<unknown[]>
|
|
28
40
|
readSession: (sessionId: string) => Promise<unknown>
|
|
29
41
|
readTitleSnapshots?: (ids: string[], signal?: AbortSignal) => Promise<unknown[]>
|
|
42
|
+
readTitle?: (id: string, signal?: AbortSignal) => Promise<unknown>
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
interface WebServerLike {
|
|
@@ -37,12 +50,57 @@ interface WebServerLike {
|
|
|
37
50
|
}) => () => void
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
export interface SessionEntry {
|
|
54
|
+
header: Record<string, unknown>
|
|
55
|
+
title?: string
|
|
56
|
+
live?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SessionSummary {
|
|
60
|
+
header: Record<string, unknown>
|
|
61
|
+
title?: string
|
|
62
|
+
live?: boolean
|
|
63
|
+
summary?: {
|
|
64
|
+
events: number
|
|
65
|
+
turns: number
|
|
66
|
+
steps: number
|
|
67
|
+
toolCalls: number
|
|
68
|
+
toolErrors: number
|
|
69
|
+
durationMs: number
|
|
70
|
+
models: string[]
|
|
71
|
+
tokens: {
|
|
72
|
+
input: number
|
|
73
|
+
output: number
|
|
74
|
+
cacheRead: number
|
|
75
|
+
cacheWrite: number
|
|
76
|
+
reasoning: number
|
|
77
|
+
billedInput: number
|
|
78
|
+
}
|
|
79
|
+
modelTotals: Record<string, {
|
|
80
|
+
input: number
|
|
81
|
+
output: number
|
|
82
|
+
cacheRead: number
|
|
83
|
+
cacheWrite: number
|
|
84
|
+
reasoning: number
|
|
85
|
+
billedInput: number
|
|
86
|
+
}>
|
|
87
|
+
findings: number
|
|
88
|
+
critical: number
|
|
89
|
+
serious: number
|
|
90
|
+
warning: number
|
|
91
|
+
info: number
|
|
92
|
+
}
|
|
93
|
+
error?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function json(res: ServerResponse, status: number, body: unknown, head = false): void {
|
|
41
97
|
res.writeHead(status, {
|
|
42
98
|
'content-type': 'application/json; charset=utf-8',
|
|
43
99
|
'cache-control': 'no-store',
|
|
100
|
+
'x-content-type-options': 'nosniff',
|
|
101
|
+
'referrer-policy': 'no-referrer',
|
|
44
102
|
})
|
|
45
|
-
res.end(JSON.stringify(body))
|
|
103
|
+
res.end(head ? undefined : JSON.stringify(body))
|
|
46
104
|
}
|
|
47
105
|
|
|
48
106
|
function get(obj: unknown, key: string): unknown {
|
|
@@ -59,94 +117,272 @@ function headerOf(record: unknown): Record<string, unknown> | undefined {
|
|
|
59
117
|
return undefined
|
|
60
118
|
}
|
|
61
119
|
|
|
62
|
-
/**
|
|
63
|
-
function titleOf(record: unknown, depth =
|
|
120
|
+
/** Titles may arrive as a string, `{ title }`, or a settled snapshot wrapper. */
|
|
121
|
+
function titleOf(record: unknown, depth = 4): string | undefined {
|
|
122
|
+
if (typeof record === 'string') return record === '' ? undefined : record
|
|
64
123
|
if (typeof record !== 'object' || record === null || depth < 0) return undefined
|
|
65
124
|
const direct = (record as Record<string, unknown>)['title']
|
|
66
125
|
if (typeof direct === 'string' && direct.length > 0) return direct
|
|
67
|
-
for (const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (found !== undefined) return found
|
|
71
|
-
}
|
|
126
|
+
for (const key of ['value', 'session', 'meta']) {
|
|
127
|
+
const found = titleOf((record as Record<string, unknown>)[key], depth - 1)
|
|
128
|
+
if (found !== undefined) return found
|
|
72
129
|
}
|
|
73
130
|
return undefined
|
|
74
131
|
}
|
|
75
132
|
|
|
133
|
+
function eventsOf(record: unknown): StorageRecord[] {
|
|
134
|
+
const events = get(record, 'events') ?? get(get(record, 'session'), 'events')
|
|
135
|
+
return Array.isArray(events) ? (events as StorageRecord[]) : []
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isLoopback(address: string | undefined): boolean {
|
|
139
|
+
if (address === undefined) return false
|
|
140
|
+
return (
|
|
141
|
+
address === '127.0.0.1' ||
|
|
142
|
+
address === '::1' ||
|
|
143
|
+
address === '::ffff:127.0.0.1' ||
|
|
144
|
+
address.startsWith('127.')
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hostIsLoopback(host: string | undefined): boolean {
|
|
149
|
+
if (host === undefined || host === '') return true
|
|
150
|
+
const name = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
|
|
151
|
+
return name === 'localhost' || name === '127.0.0.1' || name === '::1' || name.startsWith('127.')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reject cross-site and (by default) non-loopback requests before reading data. */
|
|
155
|
+
function allowed(req: IncomingMessage, allowRemote: boolean): boolean {
|
|
156
|
+
if (!allowRemote && !isLoopback(req.socket.remoteAddress)) return false
|
|
157
|
+
if (!allowRemote && !hostIsLoopback(req.headers.host)) return false
|
|
158
|
+
const origin = req.headers.origin
|
|
159
|
+
if (origin !== undefined) {
|
|
160
|
+
// A static Desktop shell may issue same-process requests with a file://
|
|
161
|
+
// or null origin. The loopback checks above already constrain those.
|
|
162
|
+
if (origin === 'null' || origin.startsWith('file://')) return true
|
|
163
|
+
try {
|
|
164
|
+
const originUrl = new URL(origin)
|
|
165
|
+
if (req.headers.host !== undefined && originUrl.host !== req.headers.host) return false
|
|
166
|
+
} catch {
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function mapLimit<T, R>(
|
|
175
|
+
items: T[],
|
|
176
|
+
limit: number,
|
|
177
|
+
task: (item: T) => Promise<R>,
|
|
178
|
+
): Promise<R[]> {
|
|
179
|
+
const results = new Array<R>(items.length)
|
|
180
|
+
let index = 0
|
|
181
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
182
|
+
while (index < items.length) {
|
|
183
|
+
const current = index++
|
|
184
|
+
results[current] = await task(items[current])
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
await Promise.all(workers)
|
|
188
|
+
return results
|
|
189
|
+
}
|
|
190
|
+
|
|
76
191
|
export function apply(ctx: Context, config: Config = {}): void {
|
|
77
192
|
const prefix = config.routePrefix ?? '/replay/api'
|
|
193
|
+
const allowRemote = config.allowRemote === true
|
|
194
|
+
const summaryLimit = Math.min(Math.max(config.summaryLimit ?? 30, 1), 100)
|
|
78
195
|
const webServer = (ctx as unknown as { webServer: WebServerLike }).webServer
|
|
79
196
|
const sessionQuery = (ctx as unknown as { sessionQuery: SessionQueryLike }).sessionQuery
|
|
80
197
|
|
|
198
|
+
const viewerCache = new Map<string, string>()
|
|
199
|
+
const summaryCache = new Map<string, { key: string; at: number; value: SessionSummary }>()
|
|
200
|
+
const SUMMARY_TTL_MS = 30_000
|
|
201
|
+
|
|
202
|
+
const loadEntries = async (
|
|
203
|
+
options: { withTitles?: boolean } = {},
|
|
204
|
+
): Promise<{ entries: SessionEntry[]; titles: Map<string, string> }> => {
|
|
205
|
+
const withTitles = options.withTitles !== false
|
|
206
|
+
const records = await sessionQuery.listSessions()
|
|
207
|
+
const entries: SessionEntry[] = []
|
|
208
|
+
for (const record of records) {
|
|
209
|
+
const header = headerOf(record)
|
|
210
|
+
if (header === undefined) continue
|
|
211
|
+
const id = String(header['id'])
|
|
212
|
+
const title = titleOf(record)
|
|
213
|
+
const live = get(record, 'live')
|
|
214
|
+
entries.push({ header, ...(title !== undefined ? { title } : {}), ...(typeof live === 'boolean' ? { live } : {}) })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const titles = new Map<string, string>()
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
if (entry.title !== undefined) titles.set(String(entry.header['id']), entry.title)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (withTitles && sessionQuery.readTitleSnapshots !== undefined) {
|
|
223
|
+
try {
|
|
224
|
+
const snapshots = await sessionQuery.readTitleSnapshots(
|
|
225
|
+
entries.map(entry => String(entry.header['id'])),
|
|
226
|
+
)
|
|
227
|
+
for (const snapshot of snapshots) {
|
|
228
|
+
// Current engines return { status, value: { session, title } }.
|
|
229
|
+
const value = get(snapshot, 'value') ?? snapshot
|
|
230
|
+
const header = get(value, 'session') ?? get(value, 'header')
|
|
231
|
+
const id = get(header, 'id')
|
|
232
|
+
const title = titleOf(value)
|
|
233
|
+
if (typeof id === 'string' && title !== undefined) titles.set(id, title)
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
/* titles are decoration — never fail the listing over them */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Per-id fallback for engines where the batch call misses. `readTitle`
|
|
241
|
+
// returns a string on current engines; older ones returned an object.
|
|
242
|
+
const readTitle = sessionQuery.readTitle
|
|
243
|
+
if (withTitles && readTitle !== undefined) {
|
|
244
|
+
await Promise.all(
|
|
245
|
+
entries
|
|
246
|
+
.filter(entry => !titles.has(String(entry.header['id'])))
|
|
247
|
+
.map(async entry => {
|
|
248
|
+
try {
|
|
249
|
+
const id = String(entry.header['id'])
|
|
250
|
+
const result = await readTitle.call(sessionQuery, id)
|
|
251
|
+
const title = titleOf(result)
|
|
252
|
+
if (title !== undefined) titles.set(id, title)
|
|
253
|
+
} catch {
|
|
254
|
+
/* decoration only */
|
|
255
|
+
}
|
|
256
|
+
}),
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
const id = String(entry.header['id'])
|
|
262
|
+
const title = titles.get(id)
|
|
263
|
+
if (title !== undefined) entry.title = title
|
|
264
|
+
}
|
|
265
|
+
return { entries, titles }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const summarize = async (entry: SessionEntry): Promise<SessionSummary> => {
|
|
269
|
+
const id = String(entry.header['id'])
|
|
270
|
+
try {
|
|
271
|
+
const snapshot = await sessionQuery.readSession(id)
|
|
272
|
+
const header = headerOf(snapshot) ?? headerOf(get(snapshot, 'session')) ?? entry.header
|
|
273
|
+
const records = eventsOf(snapshot)
|
|
274
|
+
const lastSeq = records.length > 0 ? Number(get(records.at(-1), 'seq') ?? -1) : -1
|
|
275
|
+
const key = `${id}:${lastSeq}`
|
|
276
|
+
const cached = summaryCache.get(id)
|
|
277
|
+
if (cached !== undefined && cached.key === key && Date.now() - cached.at < SUMMARY_TTL_MS) {
|
|
278
|
+
return cached.value
|
|
279
|
+
}
|
|
280
|
+
const parsed: ParsedSession = fromInspection(
|
|
281
|
+
header as unknown as SessionHeader,
|
|
282
|
+
records,
|
|
283
|
+
)
|
|
284
|
+
const model = buildTimeline(parsed)
|
|
285
|
+
const report = auditSession(parsed)
|
|
286
|
+
const value: SessionSummary = {
|
|
287
|
+
header,
|
|
288
|
+
...(entry.title !== undefined ? { title: entry.title } : model.title !== undefined ? { title: model.title } : {}),
|
|
289
|
+
...(entry.live !== undefined ? { live: entry.live } : {}),
|
|
290
|
+
summary: {
|
|
291
|
+
events: model.eventCount,
|
|
292
|
+
turns: model.turns.length,
|
|
293
|
+
steps: model.turns.reduce((sum, turn) => sum + turn.steps.length, 0),
|
|
294
|
+
toolCalls: model.toolCalls.length,
|
|
295
|
+
toolErrors: model.toolCalls.filter(call => call.error !== undefined).length,
|
|
296
|
+
durationMs: model.durationMs,
|
|
297
|
+
models: model.models,
|
|
298
|
+
tokens: model.totals,
|
|
299
|
+
modelTotals: model.modelTotals,
|
|
300
|
+
findings: report.findings.length,
|
|
301
|
+
critical: report.counts.critical,
|
|
302
|
+
serious: report.counts.serious,
|
|
303
|
+
warning: report.counts.warning,
|
|
304
|
+
info: report.counts.info,
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
summaryCache.set(id, { key, at: Date.now(), value })
|
|
308
|
+
// Bound the cache; dashboard reads only a page of recent sessions.
|
|
309
|
+
while (summaryCache.size > 100) {
|
|
310
|
+
const oldest = summaryCache.keys().next().value
|
|
311
|
+
if (oldest === undefined) break
|
|
312
|
+
summaryCache.delete(oldest)
|
|
313
|
+
}
|
|
314
|
+
return value
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return {
|
|
317
|
+
header: entry.header,
|
|
318
|
+
...(entry.title !== undefined ? { title: entry.title } : {}),
|
|
319
|
+
...(entry.live !== undefined ? { live: entry.live } : {}),
|
|
320
|
+
error: String(error),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
81
325
|
const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
326
|
+
if (!allowed(req, allowRemote)) {
|
|
327
|
+
json(res, 403, { error: 'forbidden' })
|
|
328
|
+
return
|
|
329
|
+
}
|
|
82
330
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
83
331
|
const rest = url.pathname.slice(prefix.length).replace(/^\//u, '')
|
|
332
|
+
const head = req.method === 'HEAD'
|
|
84
333
|
try {
|
|
85
334
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
86
|
-
json(res, 405, { error: 'method not allowed' })
|
|
335
|
+
json(res, 405, { error: 'method not allowed' }, head)
|
|
87
336
|
return
|
|
88
337
|
}
|
|
89
338
|
if (rest === 'sessions') {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
// Per-id fallback for engines where the batch call misses.
|
|
106
|
-
const readTitle = (sessionQuery as { readTitle?: (id: string) => Promise<unknown> }).readTitle
|
|
107
|
-
if (readTitle !== undefined) {
|
|
108
|
-
await Promise.all(
|
|
109
|
-
headers
|
|
110
|
-
.filter(h => !titles.has(String(h['id'])))
|
|
111
|
-
.map(async h => {
|
|
112
|
-
try {
|
|
113
|
-
const title = titleOf(await readTitle.call(sessionQuery, String(h['id'])))
|
|
114
|
-
if (title !== undefined) titles.set(String(h['id']), title)
|
|
115
|
-
} catch {
|
|
116
|
-
/* decoration only */
|
|
117
|
-
}
|
|
118
|
-
}),
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
json(res, 200, {
|
|
122
|
-
sessions: headers.map(h => ({ header: h, title: titles.get(String(h['id'])) })),
|
|
123
|
-
})
|
|
339
|
+
const { entries } = await loadEntries()
|
|
340
|
+
json(res, 200, { sessions: entries }, head)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (rest === 'summary') {
|
|
344
|
+
const limitParam = Number(url.searchParams.get('limit'))
|
|
345
|
+
const limit = Number.isFinite(limitParam) && limitParam > 0
|
|
346
|
+
? Math.min(Math.floor(limitParam), summaryLimit)
|
|
347
|
+
: summaryLimit
|
|
348
|
+
const { entries } = await loadEntries({ withTitles: false })
|
|
349
|
+
const selected = entries.slice(0, limit)
|
|
350
|
+
const summaries = await mapLimit(selected, 4, summarize)
|
|
351
|
+
json(res, 200, { sessions: summaries, total: entries.length }, head)
|
|
124
352
|
return
|
|
125
353
|
}
|
|
126
354
|
if (rest === 'viewer.js') {
|
|
127
355
|
// The standalone viewer bundle (sibling artifact of this module),
|
|
128
356
|
// inlined into exported HTML files by the browser half.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
357
|
+
let source = viewerCache.get('viewer')
|
|
358
|
+
if (source === undefined) {
|
|
359
|
+
source = await readFile(new URL('./viewer.js', import.meta.url), 'utf8')
|
|
360
|
+
viewerCache.set('viewer', source)
|
|
361
|
+
}
|
|
362
|
+
res.writeHead(200, {
|
|
363
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
364
|
+
'cache-control': 'no-cache',
|
|
365
|
+
'x-content-type-options': 'nosniff',
|
|
366
|
+
})
|
|
367
|
+
res.end(head ? undefined : source)
|
|
132
368
|
return
|
|
133
369
|
}
|
|
134
370
|
if (rest.startsWith('session/')) {
|
|
135
371
|
const id = decodeURIComponent(rest.slice('session/'.length))
|
|
136
372
|
const snapshot = await sessionQuery.readSession(id)
|
|
137
373
|
const header = headerOf(snapshot) ?? headerOf(get(snapshot, 'session'))
|
|
138
|
-
const events =
|
|
139
|
-
get(snapshot, 'events') ?? get(get(snapshot, 'session'), 'events') ?? []
|
|
140
374
|
if (header === undefined) {
|
|
141
|
-
json(res, 404, { error: 'session not found' })
|
|
375
|
+
json(res, 404, { error: 'session not found' }, head)
|
|
142
376
|
return
|
|
143
377
|
}
|
|
144
|
-
json(res, 200, { header, events })
|
|
378
|
+
json(res, 200, { header, events: eventsOf(snapshot) }, head)
|
|
145
379
|
return
|
|
146
380
|
}
|
|
147
|
-
json(res, 404, { error: 'unknown replay endpoint' })
|
|
381
|
+
json(res, 404, { error: 'unknown replay endpoint' }, head)
|
|
148
382
|
} catch (error) {
|
|
149
|
-
|
|
383
|
+
// Do not leak filesystem paths or provider details to the browser.
|
|
384
|
+
console.error('[dsh-replay] request failed', error)
|
|
385
|
+
json(res, 500, { error: 'internal error' }, head)
|
|
150
386
|
}
|
|
151
387
|
}
|
|
152
388
|
|