@linxin666/dsh-client-ui-task-board 0.2.9 → 0.3.1

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.
@@ -2,22 +2,17 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store/execution seams remain for v1 migration tests. It also
6
- * closes the board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. It also closes the
6
+ * board whenever the user navigates to a session.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
10
10
  * The per use-case domain transitions (create/update/delete/schedule) live in
11
11
  * dedicated modules under core/use-cases and are applied here; the controller
12
- * owns only the orchestration seam (state, persistence, notify, execution,
13
- * navigation, reconciliation).
12
+ * owns only the orchestration seam (state, persistence, notify, navigation).
14
13
  */
15
- import type { ExecutionEvent, ExecutionService } from './execution.ts'
16
14
  import type { TaskStore } from './store.ts'
17
- import {
18
- settleExecution, startExecution, withStatus,
19
- type NewTaskInput, type TaskRecord, type TaskStatus,
20
- } from './tasks.ts'
15
+ import { withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
21
16
  import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
22
17
  import { applyCreateTask } from './use-cases/task-create.ts'
23
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
@@ -45,15 +40,11 @@ export interface SessionsControllerFace {
45
40
  /** Controller dependencies (all swappable in tests). */
46
41
  export interface ControllerDeps {
47
42
  store: TaskStore
48
- /** Legacy browser execution seam used only by isolated v1 tests. */
49
- exec?: ExecutionService
50
43
  sessions: SessionsControllerFace
51
44
  /** Clock; defaults to Date.now. */
52
45
  now?: () => number
53
46
  /** Id minting; defaults to a random-uuid. */
54
47
  uuid?: () => string
55
- /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
56
- reconcileDebounceMs?: number
57
48
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
58
49
  transport?: TaskBoardTransport
59
50
  }
@@ -147,7 +138,7 @@ export class BoardController {
147
138
  private remoteSubscribed = false
148
139
  private remoteInitialization: Promise<boolean> | undefined
149
140
 
150
- /** @param deps - store, execution service, and the sessions navigation face. */
141
+ /** @param deps - store and the sessions navigation face. */
151
142
  constructor(private readonly deps: ControllerDeps) {
152
143
  this.now = deps.now ?? (() => Date.now())
153
144
  this.uuid = deps.uuid ?? randomUuid
@@ -158,11 +149,7 @@ export class BoardController {
158
149
  /** Load the persisted ledger and start the navigation/status subscriptions. */
159
150
  start(): void {
160
151
  this.tasks = this.deps.store.load()
161
- if (this.deps.transport !== undefined) {
162
- void this.initializeRemote()
163
- } else {
164
- void this.reconcileRunningTasks()
165
- }
152
+ if (this.deps.transport !== undefined) void this.initializeRemote()
166
153
  // A sibling tab may have edited or deleted the ledger (same origin,
167
154
  // storage events). Reload on external change so a task deleted in
168
155
  // another tab stops firing here — and is never written back by this
@@ -182,8 +169,6 @@ export class BoardController {
182
169
  dispose(): void {
183
170
  for (const dispose of this.disposers.splice(0)) dispose()
184
171
  this.listeners.clear()
185
- if (this.reconcileTimer !== undefined) clearTimeout(this.reconcileTimer)
186
- this.reconcileTimer = undefined
187
172
  }
188
173
 
189
174
  // --- snapshot / subscription ------------------------------------------------
@@ -413,68 +398,30 @@ export class BoardController {
413
398
  // --- execution ---------------------------------------------------------------
414
399
 
415
400
  /**
416
- * Execute a task for real: move it to 'running', open an execution record,
417
- * and hand off to the ExecutionService. A second call while the task is
418
- * already running is ignored.
401
+ * Request a Host execution for a task: the Host ledger owns the running
402
+ * transition, the execution record, and the settlement. A second call
403
+ * while the task is already running is ignored; without a Host transport
404
+ * the run is refused (returns false).
419
405
  */
420
406
  async runTask(id: string): Promise<boolean> {
421
407
  const task = this.tasks.find(candidate => candidate.id === id)
422
408
  if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
423
- if (this.deps.transport !== undefined) {
424
- return await this.commitRemote({ kind: 'run', taskId: id }, id)
425
- }
426
- const { task: next, execution } = startExecution(task, this.now(), this.uuid())
427
- this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
428
- this.persistAndNotify()
429
- // This page owns the settlement of its own launches: the live watch
430
- // (ExecutionService.run) settles on the turn boundary, and list
431
- // reconciliation must not pre-empt it with a session that has not
432
- // started a turn yet (its list row is idle, not completed).
433
- this.activeExecutionIds.add(execution.id)
434
- if (this.deps.exec === undefined) throw new Error('legacy execution service is unavailable')
435
- await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
436
- return true
409
+ if (this.deps.transport === undefined) return false
410
+ return await this.commitRemote({ kind: 'run', taskId: id }, id)
437
411
  }
438
412
 
439
- /** Re-run a settled task: move it back to 'todo' first, then execute. */
413
+ /** Re-run a settled task through the Host (the Host replans and executes). */
440
414
  async rerunTask(id: string): Promise<void> {
441
415
  const task = this.tasks.find(candidate => candidate.id === id)
442
416
  if (task === undefined || task.archivedAt !== undefined) return
443
- if (this.deps.transport !== undefined) {
444
- await this.commitRemote({ kind: 'rerun', taskId: id }, id)
445
- return
446
- }
447
- if (task.status !== 'running') {
448
- this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
449
- this.persistAndNotify()
450
- }
451
- await this.runTask(id)
452
- }
453
-
454
- private handleExecutionEvent(event: ExecutionEvent): void {
455
- if (event.kind === 'started') {
456
- this.tasks = this.tasks.map(task => task.id === event.taskId
457
- ? attachSessionId(task, event.executionId, event.sessionId, this.now())
458
- : task)
459
- this.persistAndNotify()
460
- return
461
- }
462
- this.activeExecutionIds.delete(event.executionId)
463
- this.tasks = this.tasks.map(task => task.id === event.taskId
464
- ? settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
465
- : task)
466
- this.persistAndNotify()
417
+ if (this.deps.transport === undefined) return
418
+ await this.commitRemote({ kind: 'rerun', taskId: id }, id)
467
419
  }
468
420
 
469
421
  // --- internals ---------------------------------------------------------------
470
422
 
471
- /** Reconcile running tasks and close the board when the user navigates. */
423
+ /** Close the board when the user navigates to another session. */
472
424
  private onSessionsChanged(): void {
473
- // Background/leftover executions settle through the session list (their
474
- // conversation snapshots stay cold until opened). Coalesce the burst of
475
- // list notifications into one reconcile pass instead of fanning out a
476
- // history read per notification; see scheduleReconcile.
477
- if (this.deps.transport === undefined) this.scheduleReconcile()
478
425
  if (!this.boardOpen) return
479
426
  const current = currentOf(this.deps.sessions)
480
427
  if (current !== this.lastCurrent) this.closeBoard()
@@ -483,74 +430,6 @@ export class BoardController {
483
430
 
484
431
  private lastCurrent: string | undefined = undefined
485
432
 
486
- /** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
487
- private readonly activeExecutionIds = new Set<string>()
488
-
489
- /** Debounce timer for {@link reconcileRunningTasks}. */
490
- private reconcileTimer: ReturnType<typeof setTimeout> | undefined = undefined
491
-
492
- /** Whether a reconcile pass is underway (single-flight guard). */
493
- private reconcileInFlight = false
494
-
495
- /**
496
- * Debounce + single-flight trigger for the running-task reconciliation.
497
- * Session-list notifications arrive in bursts (one per session status
498
- * change); both guards together keep a burst from reading the history API
499
- * once per running task.
500
- */
501
- private scheduleReconcile(): void {
502
- if (this.reconcileTimer !== undefined) return
503
- this.reconcileTimer = setTimeout(() => {
504
- this.reconcileTimer = undefined
505
- void this.reconcileRunningTasks()
506
- }, this.deps.reconcileDebounceMs ?? 350)
507
- }
508
-
509
- /** Settle tasks left 'running' whose sessions already finished. */
510
- private async reconcileRunningTasks(): Promise<void> {
511
- if (this.deps.exec === undefined) return
512
- if (this.reconcileInFlight) {
513
- // A session change arrived while a pass was in flight. The in-flight
514
- // pass already captured the task list it iterates and will not revisit
515
- // this task, so dropping the notification would leave it stuck
516
- // 'running'. Re-arm the debounce so the change is reconciled once the
517
- // current pass settles.
518
- this.scheduleReconcile()
519
- return
520
- }
521
- this.reconcileInFlight = true
522
- try {
523
- type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
524
- const events: Array<{ taskId: string; event: Settled }> = []
525
- for (const task of this.tasks) {
526
- if (task.status !== 'running') continue
527
- const execution = task.executions[task.executions.length - 1]
528
- // Runs launched on this page settle through their live watch (turn
529
- // boundary); reconciliation exists for background/leftover runs.
530
- if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
531
- const event = await this.deps.exec.reconcile(task)
532
- if (event !== undefined && event.kind === 'settled') events.push({ taskId: task.id, event })
533
- }
534
- if (events.length === 0) return
535
- let changed = false
536
- for (const { taskId, event } of events) {
537
- // The reconcile call above awaited: a sibling tab may have rewritten
538
- // the ledger (storage event reload) meanwhile. Re-read the freshest
539
- // record now so the stale task captured before the await can never
540
- // overwrite fields the sibling wrote.
541
- const task = this.tasks.find(candidate => candidate.id === taskId)
542
- if (task === undefined) continue
543
- const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
544
- if (next === task) continue
545
- this.tasks = this.tasks.map(candidate => candidate.id === taskId ? next : candidate)
546
- changed = true
547
- }
548
- if (changed) this.persistAndNotify()
549
- } finally {
550
- this.reconcileInFlight = false
551
- }
552
- }
553
-
554
433
  private persistAndNotify(): void {
555
434
  if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
556
435
  this.notify()
@@ -676,18 +555,3 @@ export class BoardController {
676
555
  for (const fn of [...this.listeners]) fn()
677
556
  }
678
557
  }
679
-
680
- /** Record which session ran an execution (once the execution service reports it). */
681
- function attachSessionId(
682
- task: TaskRecord,
683
- executionId: string,
684
- sessionId: string,
685
- now: number,
686
- ): TaskRecord {
687
- return {
688
- ...task,
689
- updatedAt: now,
690
- executions: task.executions.map(execution =>
691
- execution.id === executionId ? { ...execution, sessionId } : execution),
692
- }
693
- }
package/src/core/tasks.ts CHANGED
@@ -28,6 +28,27 @@ export interface ExecutionRecord {
28
28
  error: string | undefined
29
29
  }
30
30
 
31
+ /**
32
+ * Maximum number of execution records retained per task. Older settled runs
33
+ * are trimmed when a new execution starts so per-action ledger cost stays
34
+ * bounded regardless of how often a task ran before.
35
+ */
36
+ export const EXECUTION_HISTORY_LIMIT = 20
37
+
38
+ /**
39
+ * Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
40
+ * most recent last. A running (unsettled) execution is never trimmed: the
41
+ * Host monitor and restart recovery depend on the active record, and a task
42
+ * cannot start a new run while one is still open.
43
+ */
44
+ export function retainRecentExecutions(executions: readonly ExecutionRecord[]): ExecutionRecord[] {
45
+ if (executions.length <= EXECUTION_HISTORY_LIMIT) return [...executions]
46
+ const open = executions.filter(execution => execution.endedAt === undefined)
47
+ const settled = executions.filter(execution => execution.endedAt !== undefined)
48
+ const keepSettled = Math.max(EXECUTION_HISTORY_LIMIT - open.length, 0)
49
+ return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open]
50
+ }
51
+
31
52
  /**
32
53
  * A scheduled-run rule attached to a task. The Host scheduler triggers the
33
54
  * task when `nextRunAt` is due and persists the rule in the Host ledger.
@@ -59,7 +80,10 @@ export interface TaskRecord {
59
80
  createdAt: number
60
81
  /** Last mutation instant (ms epoch). */
61
82
  updatedAt: number
62
- /** Every execution attempt, most recent last. */
83
+ /**
84
+ * Execution history retained on the task, most recent last: the latest
85
+ * {@link EXECUTION_HISTORY_LIMIT} attempts, oldest trimmed on append.
86
+ */
63
87
  executions: ExecutionRecord[]
64
88
  /** Optional scheduled-run rule (absent on tasks without a schedule). */
65
89
  schedule?: ScheduleRule
@@ -221,7 +245,12 @@ export function startExecution(
221
245
  error: undefined,
222
246
  }
223
247
  return {
224
- task: { ...task, status: 'running', updatedAt: now, executions: [...task.executions, execution] },
248
+ task: {
249
+ ...task,
250
+ status: 'running',
251
+ updatedAt: now,
252
+ executions: retainRecentExecutions([...task.executions, execution]),
253
+ },
225
254
  execution,
226
255
  }
227
256
  }
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
6
  import { isValidCron, nextRunAtMs } from './core/schedule.ts'
7
7
  import { parseLedger } from './core/store.ts'
8
- import { canMoveManually, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
8
+ import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
9
9
  import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
10
10
  import { applyCreateTask } from './core/use-cases/task-create.ts'
11
11
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
@@ -232,7 +232,8 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
232
232
  const previous = byId.get(entry.id)
233
233
  byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
234
234
  }
235
- return { ...newer, executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt) }
235
+ const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
236
+ return { ...newer, executions: retainRecentExecutions(executions) }
236
237
  }
237
238
 
238
239
  function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
@@ -574,7 +575,7 @@ export class HostTaskLedger {
574
575
  try {
575
576
  const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
576
577
  if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
577
- const tasks = parseHostTasks(parsed.tasks)
578
+ const tasks = parseHostTasks(parsed.tasks).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
578
579
  const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
579
580
  if (typeof value !== 'object' || value === null) return []
580
581
  const row = value as { id?: unknown; schedule?: unknown }
@@ -2,6 +2,7 @@ import { timingSafeEqual } from 'node:crypto'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
4
4
  import type { TaskBoardHostService } from './host-service.ts'
5
+ import { writeJson } from './http.ts'
5
6
  import { isLoopbackAddress, isLoopbackRequest } from './loopback.ts'
6
7
  import { parseActionEnvelope, TASK_BOARD_API_PREFIX } from './protocol.ts'
7
8
 
@@ -23,11 +24,6 @@ interface ResolvedRouteAccess {
23
24
  proxyToken?: string
24
25
  }
25
26
 
26
- function json(res: ServerResponse, status: number, body: unknown): void {
27
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
28
- res.end(JSON.stringify(body))
29
- }
30
-
31
27
  function parseAuthority(authority: string): { canonical: string; url: URL } | undefined {
32
28
  if (authority.trim() !== authority) return undefined
33
29
  const match = authority.startsWith('[')
@@ -127,38 +123,38 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
127
123
  const resolvedAccess = resolveAccess(access)
128
124
  const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
129
125
  if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
130
- json(res, 403, { ok: false, error: 'forbidden' })
126
+ writeJson(res, 403, { ok: false, error: 'forbidden' }, { 'cache-control': 'no-store' })
131
127
  return false
132
128
  }
133
129
  const state: WebRoute = {
134
130
  kind: 'exact',
135
131
  path: `${TASK_BOARD_API_PREFIX}/state`,
136
132
  handler: (req, res): void => {
137
- if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
133
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
138
134
  if (!guard(req, res)) return
139
- json(res, 200, service.snapshot())
135
+ writeJson(res, 200, service.snapshot(), { 'cache-control': 'no-store' })
140
136
  },
141
137
  }
142
138
  const action: WebRoute = {
143
139
  kind: 'exact',
144
140
  path: `${TASK_BOARD_API_PREFIX}/action`,
145
141
  handler: async (req, res): Promise<void> => {
146
- if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
142
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
147
143
  if (!guard(req, res)) return
148
144
  if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
149
- return json(res, 415, { ok: false, error: 'json-required' })
145
+ return writeJson(res, 415, { ok: false, error: 'json-required' }, { 'cache-control': 'no-store' })
150
146
  }
151
147
  try {
152
148
  const body = await readBody(req)
153
149
  const parsed = parseActionEnvelope(body.value)
154
- if (parsed === undefined) return json(res, 400, { ok: false, error: 'invalid-action' })
150
+ if (parsed === undefined) return writeJson(res, 400, { ok: false, error: 'invalid-action' }, { 'cache-control': 'no-store' })
155
151
  if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
156
- return json(res, 413, { ok: false, error: 'body-too-large' })
152
+ return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
157
153
  }
158
- json(res, 200, service.apply(parsed.requestId, parsed.action))
154
+ writeJson(res, 200, service.apply(parsed.requestId, parsed.action), { 'cache-control': 'no-store' })
159
155
  } catch (error) {
160
156
  const message = error instanceof Error ? error.message : String(error)
161
- json(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message })
157
+ writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
162
158
  }
163
159
  },
164
160
  }
@@ -1,6 +1,5 @@
1
1
  import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
2
2
  import { nextRunAtMs } from './core/schedule.ts'
3
- import type { TaskRecord } from './core/tasks.ts'
4
3
  import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
5
4
  import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary } from './host-runner.ts'
6
5
  import { PowerInhibitor } from './power-inhibitor.ts'
package/src/http.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared JSON body/response helpers for the host route families: one strict
4
+ * bounded body reader, one lenient bounded body reader, one JSON object
5
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
6
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
9
+ *
10
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
11
+ * edit this shared source and re-run the sync instead of editing a copy.
12
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
13
+ * belong in the same change as its introduction.
14
+ * @module dsh-web-ui-shared/host/http
15
+ */
16
+
17
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
18
+
19
+ /** Default body cap for readJsonBody: 64 KiB. */
20
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
21
+
22
+ /** Family-default JSON response headers; callers may append or override. */
23
+ const JSON_HEADERS = {
24
+ 'content-type': 'application/json; charset=utf-8',
25
+ 'referrer-policy': 'no-referrer',
26
+ } satisfies OutgoingHttpHeaders
27
+
28
+ /**
29
+ * Strict bounded body reader: parse a request body of at most maxBytes as
30
+ * JSON.
31
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
32
+ * invalid or empty payload.
33
+ */
34
+ export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
35
+ const chunks: Buffer[] = []
36
+ let size = 0
37
+ for await (const chunk of req) {
38
+ const buffer = chunk as Buffer
39
+ size += buffer.length
40
+ if (size > maxBytes) throw new Error('body too large')
41
+ chunks.push(buffer)
42
+ }
43
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
44
+ }
45
+
46
+ /**
47
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
48
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
49
+ * Overflow destroys the request instead of draining the remainder (no drain
50
+ * call, matching the current repo-wide behavior); callers must not keep
51
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
52
+ * also yield null.
53
+ */
54
+ export async function readJsonBody(
55
+ req: IncomingMessage,
56
+ opts: { maxBytes?: number; objectOnly?: boolean } = {},
57
+ ): Promise<unknown | null> {
58
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
59
+ const chunks: Buffer[] = []
60
+ let size = 0
61
+ for await (const chunk of req) {
62
+ const buffer = chunk as Buffer
63
+ size += buffer.length
64
+ if (size > maxBytes) {
65
+ req.destroy()
66
+ return null
67
+ }
68
+ chunks.push(buffer)
69
+ }
70
+ const text = Buffer.concat(chunks).toString('utf8')
71
+ if (text === '') return null
72
+ try {
73
+ const parsed: unknown = JSON.parse(text)
74
+ if (opts.objectOnly && !isJsonObject(parsed)) return null
75
+ return parsed
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
82
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
84
+ }
85
+
86
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
87
+ export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
88
+ return isJsonObject(value) ? value : undefined
89
+ }
90
+
91
+ /**
92
+ * Write one JSON response. Default headers are the family defaults
93
+ * (content-type and referrer-policy); caller headers are appended or
94
+ * override them.
95
+ */
96
+ export function writeJson(
97
+ res: ServerResponse,
98
+ status: number,
99
+ body: unknown,
100
+ headers: OutgoingHttpHeaders = {},
101
+ ): void {
102
+ const payload = JSON.stringify(body)
103
+ res.writeHead(status, { ...JSON_HEADERS, ...headers })
104
+ res.end(payload)
105
+ }