aipeek 0.2.4 → 0.2.6
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/dist/{chunk-3NVB3GGE.cjs → chunk-5ZZYOETF.cjs} +99 -131
- package/dist/{chunk-72ZKZ42D.js → chunk-XA2LT6I4.js} +92 -124
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +8 -2
- package/dist/plugin.js +9 -3
- package/package.json +2 -1
- package/src/client/client-patch.ts +35 -3
- package/src/client/client.ts +7 -17
- package/src/core/action.ts +54 -1
- package/src/core/compact.ts +4 -30
- package/src/core/detail.ts +10 -26
- package/src/core/util.ts +53 -0
- package/src/server/plugin.ts +39 -93
package/src/core/compact.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CompactState, ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
|
|
2
|
-
import { compactUrl, truncate } from './util'
|
|
2
|
+
import { appStackFrames, compactUrl, formatValue, truncate } from './util'
|
|
3
3
|
|
|
4
4
|
const SLOW_THRESHOLD = 1000
|
|
5
5
|
|
|
@@ -262,26 +262,14 @@ export function compactErrors(errors: ErrorEntry[]): string {
|
|
|
262
262
|
for (const err of seen.values()) {
|
|
263
263
|
lines.push(err.message)
|
|
264
264
|
if (err.stack) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
lines.push(` at ${frame}`)
|
|
268
|
-
}
|
|
265
|
+
for (const frame of appStackFrames(err.stack, 5))
|
|
266
|
+
lines.push(` ${frame}`)
|
|
269
267
|
}
|
|
270
268
|
}
|
|
271
269
|
|
|
272
270
|
return lines.join('\n')
|
|
273
271
|
}
|
|
274
272
|
|
|
275
|
-
function filterStack(stack: string): string[] {
|
|
276
|
-
return stack
|
|
277
|
-
.split('\n')
|
|
278
|
-
.map(l => l.trim())
|
|
279
|
-
.filter(l => l.startsWith('at '))
|
|
280
|
-
.map(l => l.slice(3))
|
|
281
|
-
.filter(l => !l.includes('node_modules') && !l.includes('<anonymous>'))
|
|
282
|
-
.slice(0, 5)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
273
|
// --- State ---
|
|
286
274
|
|
|
287
275
|
export function compactState(state: Record<string, unknown>): string {
|
|
@@ -293,7 +281,7 @@ export function compactState(state: Record<string, unknown>): string {
|
|
|
293
281
|
lines.push(`${name}:`)
|
|
294
282
|
if (typeof value === 'object' && value !== null) {
|
|
295
283
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
296
|
-
lines.push(` ${k}: ${formatValue(v)}`)
|
|
284
|
+
lines.push(` ${k}: ${truncate(formatValue(v), 120)}`)
|
|
297
285
|
}
|
|
298
286
|
}
|
|
299
287
|
else {
|
|
@@ -303,20 +291,6 @@ export function compactState(state: Record<string, unknown>): string {
|
|
|
303
291
|
return lines.join('\n')
|
|
304
292
|
}
|
|
305
293
|
|
|
306
|
-
function formatValue(v: unknown): string {
|
|
307
|
-
if (v === null || v === undefined)
|
|
308
|
-
return String(v)
|
|
309
|
-
if (typeof v === 'string')
|
|
310
|
-
return v
|
|
311
|
-
if (typeof v === 'number' || typeof v === 'boolean')
|
|
312
|
-
return String(v)
|
|
313
|
-
if (typeof v === 'object') {
|
|
314
|
-
const s = JSON.stringify(v)
|
|
315
|
-
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
|
316
|
-
}
|
|
317
|
-
return String(v)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
294
|
// --- Main ---
|
|
321
295
|
|
|
322
296
|
export function compact(raw: RawState): CompactState {
|
package/src/core/detail.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
|
|
2
2
|
import { compactUI } from './compact'
|
|
3
|
-
import { truncate } from './util'
|
|
3
|
+
import { appStackFrames, formatValue, truncate } from './util'
|
|
4
4
|
|
|
5
5
|
export function detail(raw: RawState, section: string, index: string | undefined, full: boolean): string | null {
|
|
6
6
|
switch (section) {
|
|
@@ -130,15 +130,10 @@ function detailError(errors: ErrorEntry[], index: string | undefined, full: bool
|
|
|
130
130
|
|
|
131
131
|
const lines = [err.message]
|
|
132
132
|
if (err.stack) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
.
|
|
137
|
-
if (appFrames.length)
|
|
138
|
-
lines.push(...appFrames)
|
|
139
|
-
const totalApp = err.stack.split('\n').filter(l => l.trim().startsWith('at ') && !l.includes('node_modules')).length
|
|
140
|
-
if (totalApp > 3)
|
|
141
|
-
lines.push(` ... ${totalApp - 3} more app frames`)
|
|
133
|
+
const all = appStackFrames(err.stack, Infinity)
|
|
134
|
+
lines.push(...all.slice(0, 3))
|
|
135
|
+
if (all.length > 3)
|
|
136
|
+
lines.push(` ... ${all.length - 3} more app frames`)
|
|
142
137
|
}
|
|
143
138
|
if (err.line != null)
|
|
144
139
|
lines.push(`location: ${err.source || ''}:${err.line}:${err.column ?? 0}`)
|
|
@@ -159,10 +154,10 @@ function detailState(state: Record<string, unknown>, name: string | undefined, f
|
|
|
159
154
|
const value = state[name]
|
|
160
155
|
if (full) {
|
|
161
156
|
try {
|
|
162
|
-
return JSON.stringify(value, null, 2)
|
|
157
|
+
return JSON.stringify(value, null, 2) ?? formatValue(value)
|
|
163
158
|
}
|
|
164
159
|
catch {
|
|
165
|
-
return
|
|
160
|
+
return formatValue(value) // 循环引用 / Error / Map → 不再 {}
|
|
166
161
|
}
|
|
167
162
|
}
|
|
168
163
|
if (typeof value !== 'object' || value === null)
|
|
@@ -182,20 +177,9 @@ function isArraySentinel(v: string): boolean {
|
|
|
182
177
|
}
|
|
183
178
|
|
|
184
179
|
function formatSummaryValue(v: unknown): string {
|
|
185
|
-
if (v ===
|
|
186
|
-
return
|
|
187
|
-
|
|
188
|
-
if (isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
|
|
189
|
-
return v
|
|
190
|
-
return v.length > 80 ? `${v.slice(0, 80)}…` : v
|
|
191
|
-
}
|
|
192
|
-
if (typeof v === 'number' || typeof v === 'boolean')
|
|
193
|
-
return String(v)
|
|
194
|
-
if (typeof v === 'object') {
|
|
195
|
-
const s = JSON.stringify(v)
|
|
196
|
-
return s.length > 80 ? `${s.slice(0, 80)}…` : s
|
|
197
|
-
}
|
|
198
|
-
return String(v)
|
|
180
|
+
if (typeof v === 'string' && isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
|
|
181
|
+
return v
|
|
182
|
+
return truncate(formatValue(v), 80)
|
|
199
183
|
}
|
|
200
184
|
|
|
201
185
|
// --- JSON schema fallback ---
|
package/src/core/util.ts
CHANGED
|
@@ -2,6 +2,59 @@ export function truncate(s: string, max: number): string {
|
|
|
2
2
|
return s.length > max ? `${s.slice(0, max)}…` : s
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
// unknown → 人类可读字符串。穷举 JS 类型,让 JSON.stringify 的「漏网类型 → {}」失败模式不可能出现。
|
|
6
|
+
// 返回未截断字符串——截断由调用方按各自 max 处理。
|
|
7
|
+
export function formatValue(v: unknown, seen: Set<object> = new Set()): string {
|
|
8
|
+
if (v === null || v === undefined)
|
|
9
|
+
return String(v)
|
|
10
|
+
const t = typeof v
|
|
11
|
+
if (t === 'string')
|
|
12
|
+
return v as string
|
|
13
|
+
if (t === 'number' || t === 'boolean' || t === 'bigint')
|
|
14
|
+
return String(v)
|
|
15
|
+
if (t === 'symbol')
|
|
16
|
+
return (v as symbol).toString()
|
|
17
|
+
if (t === 'function')
|
|
18
|
+
return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
|
|
19
|
+
// 此后 v 是 object
|
|
20
|
+
const obj = v as object
|
|
21
|
+
if (seen.has(obj))
|
|
22
|
+
return '[Circular]'
|
|
23
|
+
if (v instanceof Error)
|
|
24
|
+
return v.stack || `${v.name}: ${v.message}`
|
|
25
|
+
seen.add(obj)
|
|
26
|
+
if (v instanceof Map) {
|
|
27
|
+
const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
|
|
28
|
+
return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
29
|
+
}
|
|
30
|
+
if (v instanceof Set) {
|
|
31
|
+
const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
|
|
32
|
+
return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(v)) {
|
|
35
|
+
const items = v.slice(0, 30).map(val => formatValue(val, seen))
|
|
36
|
+
return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(v)
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// 循环引用 / getter 抛错 → 手动遍历可枚举键,循环处标 [Circular]
|
|
43
|
+
const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
|
|
44
|
+
const parts = entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`)
|
|
45
|
+
return `{${parts.join(', ')}}`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// stack → 应用栈帧(去 node_modules / <anonymous>),保留 `at ` 前缀。max 与溢出提示由调用方决定。
|
|
50
|
+
export function appStackFrames(stack: string, max: number): string[] {
|
|
51
|
+
return stack
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map(l => l.trim())
|
|
54
|
+
.filter(l => l.startsWith('at ') && !l.includes('node_modules') && !l.includes('<anonymous>'))
|
|
55
|
+
.slice(0, max)
|
|
56
|
+
}
|
|
57
|
+
|
|
5
58
|
// pathname only; pass `search` to append a truncated query string
|
|
6
59
|
export function compactUrl(url: string, search?: number): string {
|
|
7
60
|
try {
|
package/src/server/plugin.ts
CHANGED
|
@@ -21,6 +21,12 @@ function readBody(req: { on: (e: string, cb: (c: unknown) => void) => void }): P
|
|
|
21
21
|
})
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// 所有端点都回文本响应——writeHead(Content-Type) + end 二连写了 15 遍(且有 2 处漏 charset)。收敛成一处。
|
|
25
|
+
function send(res: { writeHead: (s: number, h: Record<string, string>) => void, end: (b: string) => void }, status: number, body: string) {
|
|
26
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
27
|
+
res.end(body)
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
25
31
|
// client/core sources are read at runtime (client.ts served via Vite transform,
|
|
26
32
|
// client-patch.ts compiled by esbuild). Two layouts: source consumption
|
|
@@ -107,73 +113,30 @@ aipeek auto-detects errors after HMR and prints them to the terminal — watch f
|
|
|
107
113
|
`
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const t = line.trim()
|
|
113
|
-
const i = t.indexOf('localhost:')
|
|
114
|
-
if (i === -1)
|
|
115
|
-
return t
|
|
116
|
-
let j = i + 'localhost:'.length
|
|
117
|
-
while (j < t.length && t[j] >= '0' && t[j] <= '9') j++
|
|
118
|
-
return `${t.slice(0, i)}localhost:PORT${t.slice(j)}`
|
|
119
|
-
}
|
|
116
|
+
export const START_TAG = '<!-- AIPEEK:START -->'
|
|
117
|
+
export const END_TAG = '<!-- AIPEEK:END -->'
|
|
120
118
|
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
function
|
|
126
|
-
const known = new Set(snippet.split('\n').map(norm).filter(l => l.length > 3))
|
|
127
|
-
const lines = content.split('\n')
|
|
128
|
-
const keep: string[] = []
|
|
129
|
-
let inside = false
|
|
130
|
-
let buf: string[] = []
|
|
131
|
-
let hits = 0
|
|
132
|
-
|
|
133
|
-
const flush = () => {
|
|
134
|
-
// settle the buffered run: drop it if it reads as snippet, else keep it
|
|
135
|
-
if (buf.length && hits / buf.length <= 0.5)
|
|
136
|
-
keep.push(...buf)
|
|
137
|
-
buf = []
|
|
138
|
-
hits = 0
|
|
139
|
-
inside = false
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
for (const line of lines) {
|
|
143
|
-
const isKnown = known.has(norm(line))
|
|
144
|
-
if (!inside) {
|
|
145
|
-
if (isKnown) {
|
|
146
|
-
inside = true
|
|
147
|
-
buf = [line]
|
|
148
|
-
hits = 1
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
keep.push(line)
|
|
152
|
-
}
|
|
153
|
-
continue
|
|
154
|
-
}
|
|
155
|
-
// INSIDE: keep accumulating until we drift away from the snippet
|
|
156
|
-
buf.push(line)
|
|
157
|
-
if (isKnown)
|
|
158
|
-
hits++
|
|
159
|
-
// a run of unknown lines means the block ended — settle it
|
|
160
|
-
else if (buf.slice(-3).every(l => !known.has(norm(l))))
|
|
161
|
-
flush()
|
|
162
|
-
}
|
|
163
|
-
flush()
|
|
164
|
-
return keep.join('\n')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function injectClaudeMd(root: string, port: number) {
|
|
119
|
+
// Marker-based injection — the block lives between START_TAG and END_TAG, so
|
|
120
|
+
// re-injection is a deterministic splice (find markers, replace between) rather
|
|
121
|
+
// than fuzzy line matching. New file → write markers + snippet. Existing markers
|
|
122
|
+
// → replace their contents. No markers yet → append a fresh marked block.
|
|
123
|
+
export function injectClaudeMd(root: string, port: number) {
|
|
168
124
|
const path = resolve(root, 'CLAUDE.md')
|
|
169
|
-
const
|
|
125
|
+
const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
|
|
170
126
|
try {
|
|
171
127
|
if (!existsSync(path)) {
|
|
172
|
-
writeFileSync(path,
|
|
128
|
+
writeFileSync(path, block)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
const content = readFileSync(path, 'utf-8')
|
|
132
|
+
const si = content.indexOf(START_TAG)
|
|
133
|
+
const ei = content.indexOf(END_TAG)
|
|
134
|
+
if (si !== -1 && ei !== -1) {
|
|
135
|
+
writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length))
|
|
173
136
|
return
|
|
174
137
|
}
|
|
175
|
-
const
|
|
176
|
-
writeFileSync(path, `${
|
|
138
|
+
const sep = content.endsWith('\n') ? '' : '\n'
|
|
139
|
+
writeFileSync(path, `${content}${sep}\n${block}`)
|
|
177
140
|
}
|
|
178
141
|
catch {}
|
|
179
142
|
}
|
|
@@ -374,13 +337,11 @@ export function aipeekPlugin(): Plugin {
|
|
|
374
337
|
if (!code && req.method === 'POST')
|
|
375
338
|
code = await readBody(req)
|
|
376
339
|
if (!code) {
|
|
377
|
-
res
|
|
378
|
-
res.end('eval needs ?code= or a POST body')
|
|
340
|
+
send(res, 400, 'eval needs ?code= or a POST body')
|
|
379
341
|
return
|
|
380
342
|
}
|
|
381
343
|
const r = await evalInClient(code)
|
|
382
|
-
res
|
|
383
|
-
res.end(r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
|
|
344
|
+
send(res, r.ok ? 200 : 422, r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
|
|
384
345
|
return
|
|
385
346
|
}
|
|
386
347
|
|
|
@@ -390,16 +351,14 @@ export function aipeekPlugin(): Plugin {
|
|
|
390
351
|
url.searchParams.get('scope') || undefined,
|
|
391
352
|
url.searchParams.get('sel') || undefined,
|
|
392
353
|
)
|
|
393
|
-
res
|
|
394
|
-
res.end(dom || '(empty)')
|
|
354
|
+
send(res, 200, dom || '(empty)')
|
|
395
355
|
return
|
|
396
356
|
}
|
|
397
357
|
|
|
398
358
|
// /__aipeek/screen — state-machine projection {view, modal, focus, knobs}
|
|
399
359
|
if (parts[0] === 'screen') {
|
|
400
360
|
const screen = await collectScreenFromClient()
|
|
401
|
-
res
|
|
402
|
-
res.end(screen || '(empty)')
|
|
361
|
+
send(res, 200, screen || '(empty)')
|
|
403
362
|
return
|
|
404
363
|
}
|
|
405
364
|
|
|
@@ -415,8 +374,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
415
374
|
throw new Error('body must be a JSON array')
|
|
416
375
|
}
|
|
417
376
|
catch (e) {
|
|
418
|
-
res
|
|
419
|
-
res.end(`invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
|
|
377
|
+
send(res, 400, `invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
|
|
420
378
|
return
|
|
421
379
|
}
|
|
422
380
|
lastRaw = null
|
|
@@ -445,8 +403,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
445
403
|
break
|
|
446
404
|
}
|
|
447
405
|
}
|
|
448
|
-
res
|
|
449
|
-
res.end(lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
|
|
406
|
+
send(res, allOk ? 200 : 422, lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
|
|
450
407
|
return
|
|
451
408
|
}
|
|
452
409
|
|
|
@@ -463,8 +420,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
463
420
|
}
|
|
464
421
|
const check = resolveAction(parts[0], args)
|
|
465
422
|
if (!check.valid) {
|
|
466
|
-
res
|
|
467
|
-
res.end(check.error)
|
|
423
|
+
send(res, 400, check.error ?? 'invalid action')
|
|
468
424
|
return
|
|
469
425
|
}
|
|
470
426
|
const result = await sendAction(parts[0], args)
|
|
@@ -475,13 +431,11 @@ export function aipeekPlugin(): Plugin {
|
|
|
475
431
|
const name = q.get('out') || `shot-${result.dataUrl.length}.png`
|
|
476
432
|
const file = resolve(dir, name)
|
|
477
433
|
writeFileSync(file, Buffer.from(result.dataUrl.split(',')[1], 'base64'))
|
|
478
|
-
res
|
|
479
|
-
res.end(`saved: ${file}`)
|
|
434
|
+
send(res, 200, `saved: ${file}`)
|
|
480
435
|
return
|
|
481
436
|
}
|
|
482
|
-
res.writeHead(result.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
483
437
|
const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
|
|
484
|
-
res.
|
|
438
|
+
send(res, result.ok ? 200 : 422, result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
|
|
485
439
|
return
|
|
486
440
|
}
|
|
487
441
|
|
|
@@ -491,8 +445,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
491
445
|
lastRaw = raw
|
|
492
446
|
const result = check(raw)
|
|
493
447
|
const output = emitCheck(result)
|
|
494
|
-
res
|
|
495
|
-
res.end(output)
|
|
448
|
+
send(res, result.pass ? 200 : 417, output)
|
|
496
449
|
return
|
|
497
450
|
}
|
|
498
451
|
|
|
@@ -502,12 +455,10 @@ export function aipeekPlugin(): Plugin {
|
|
|
502
455
|
lastRaw = await collectFromClient()
|
|
503
456
|
const result = detail(lastRaw, parts[0], parts[1], full)
|
|
504
457
|
if (result !== null) {
|
|
505
|
-
res
|
|
506
|
-
res.end(result)
|
|
458
|
+
send(res, 200, result)
|
|
507
459
|
return
|
|
508
460
|
}
|
|
509
|
-
res
|
|
510
|
-
res.end(`not found: ${parts.join('/')}`)
|
|
461
|
+
send(res, 404, `not found: ${parts.join('/')}`)
|
|
511
462
|
return
|
|
512
463
|
}
|
|
513
464
|
|
|
@@ -516,19 +467,14 @@ export function aipeekPlugin(): Plugin {
|
|
|
516
467
|
lastRaw = raw
|
|
517
468
|
if (full) {
|
|
518
469
|
const compacted = compact(raw)
|
|
519
|
-
|
|
520
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
521
|
-
res.end(output)
|
|
470
|
+
send(res, 200, emit(compacted))
|
|
522
471
|
}
|
|
523
472
|
else {
|
|
524
|
-
|
|
525
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
526
|
-
res.end(output)
|
|
473
|
+
send(res, 200, emitSummary(raw))
|
|
527
474
|
}
|
|
528
475
|
}
|
|
529
476
|
catch (err) {
|
|
530
|
-
res
|
|
531
|
-
res.end(err instanceof Error ? err.message : 'unknown error')
|
|
477
|
+
send(res, 504, err instanceof Error ? err.message : 'unknown error')
|
|
532
478
|
}
|
|
533
479
|
})
|
|
534
480
|
},
|