@pasko70/pibo 3.5.0 → 3.6.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.
Files changed (52) hide show
  1. package/dist/agent-runtime/routed-session.js +80 -19
  2. package/dist/agent-runtimes/codex-native/turn.js +27 -3
  3. package/dist/apps/chat/data/chat-data-mappers.js +8 -1
  4. package/dist/apps/chat/data/read-state-service.js +24 -3
  5. package/dist/apps/chat/message-command-dispatcher.js +16 -1
  6. package/dist/apps/chat/web-app.js +40 -19
  7. package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-BQ-TQZet.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-CYTRhePp.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-Crj0LNXs.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-t24MVWWG.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-tFj2S0WU.js} +1 -1
  12. package/dist/apps/chat-ui/assets/index-Bc9O0z52.css +1 -0
  13. package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-Cr2oFRhI.js} +77 -77
  14. package/dist/apps/chat-ui/index.html +2 -2
  15. package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DgKYVP-6.js} +11 -11
  16. package/dist/apps/chat-vscode-web/index.html +1 -1
  17. package/dist/cli-session/localSessionSource.js +3 -2
  18. package/dist/core/output-render-sequence.js +63 -6
  19. package/dist/core/session-router.js +99 -11
  20. package/dist/data/async-chat-storage.js +7 -2
  21. package/dist/data/bounded-worker-client.js +1 -1
  22. package/dist/data/chat-read-projections.js +4 -4
  23. package/dist/data/chat-storage-worker.js +24 -3
  24. package/dist/data/ingest-service.js +73 -4
  25. package/dist/data/message-command-store.js +153 -11
  26. package/dist/data/schema.js +24 -3
  27. package/dist/data/storage-maintenance.js +344 -0
  28. package/dist/data/storage-verification-worker.js +25 -0
  29. package/dist/debug/cache.js +136 -0
  30. package/dist/debug/index.js +201 -1
  31. package/dist/debug/message-queue.js +108 -0
  32. package/dist/debug/output-collision-repair.js +140 -0
  33. package/dist/debug/output-integrity.js +38 -2
  34. package/dist/debug/output-repair.js +1 -0
  35. package/dist/debug/session.js +2 -0
  36. package/dist/debug/storage-backup.js +12 -4
  37. package/dist/debug/storage-maintenance.js +78 -0
  38. package/dist/debug/summary.js +1 -0
  39. package/dist/debug/trace.js +28 -0
  40. package/dist/gateway/cli.js +71 -7
  41. package/dist/gateway/server.js +1 -0
  42. package/dist/reliability/store.js +119 -23
  43. package/dist/session-ui/terminalRows.js +7 -8
  44. package/dist/sessions/pibo-data-store.js +18 -14
  45. package/dist/shared/cache-observability.js +73 -0
  46. package/dist/shared/model-inference-metrics.js +20 -7
  47. package/dist/shared/trace-event-projection.js +44 -6
  48. package/dist/web/channel.js +114 -12
  49. package/dist/web/http.js +105 -44
  50. package/npm-shrinkwrap.json +2 -2
  51. package/package.json +1 -1
  52. package/dist/apps/chat-ui/assets/index-hEkrlRk-.css +0 -1
@@ -0,0 +1,73 @@
1
+ import { modelInferenceCachedInputTokens, modelInferenceInputTokens, modelInferenceUncachedInputTokens, } from "./model-inference-metrics.js";
2
+ export const CACHE_READ_DROP_RULE = "provider-cache-read-drop-v1";
3
+ export const CACHE_READ_DROP_MIN_INPUT_TOKENS = 16_384;
4
+ function cacheState(ratio) {
5
+ if (ratio === undefined)
6
+ return "unknown";
7
+ if (ratio <= 0.1)
8
+ return "cold";
9
+ if (ratio >= 0.8)
10
+ return "warm";
11
+ return "partial";
12
+ }
13
+ function elapsedMs(current, previous) {
14
+ if (!previous?.completedAt || !current.completedAt)
15
+ return undefined;
16
+ const before = Date.parse(previous.completedAt);
17
+ const after = Date.parse(current.completedAt);
18
+ return Number.isFinite(before) && Number.isFinite(after) && after >= before ? after - before : undefined;
19
+ }
20
+ /**
21
+ * Compares bounded provider usage counters. It does not inspect prompts, infer a
22
+ * provider cache key, or identify why a cache read changed.
23
+ */
24
+ export function observeCacheUsage(current, previous, options = {}) {
25
+ const inputTokens = modelInferenceInputTokens(current.metrics);
26
+ const cacheReadTokens = modelInferenceCachedInputTokens(current.metrics);
27
+ const uncachedInputTokens = modelInferenceUncachedInputTokens(current.metrics);
28
+ const cacheWriteTokens = Number.isFinite(current.metrics.cacheWriteTokens) && Number(current.metrics.cacheWriteTokens) >= 0
29
+ ? Math.floor(Number(current.metrics.cacheWriteTokens))
30
+ : undefined;
31
+ const ratio = inputTokens !== undefined && inputTokens > 0 && cacheReadTokens !== undefined && cacheReadTokens <= inputTokens
32
+ ? cacheReadTokens / inputTokens
33
+ : undefined;
34
+ const previousInput = modelInferenceInputTokens(previous?.metrics);
35
+ const previousRead = modelInferenceCachedInputTokens(previous?.metrics);
36
+ const previousRatio = previousInput !== undefined && previousInput > 0 && previousRead !== undefined && previousRead <= previousInput
37
+ ? previousRead / previousInput
38
+ : undefined;
39
+ const elapsed = elapsedMs(current, previous);
40
+ const observation = {
41
+ rule: CACHE_READ_DROP_RULE,
42
+ source: "provider-reported-usage",
43
+ cacheState: cacheState(ratio),
44
+ warning: "none",
45
+ explanation: ratio === undefined ? "insufficient-data" : "provider-usage",
46
+ ...(inputTokens === undefined ? {} : { inputTokens }),
47
+ ...(cacheReadTokens === undefined ? {} : { cacheReadTokens }),
48
+ ...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }),
49
+ ...(uncachedInputTokens === undefined ? {} : { uncachedInputTokens }),
50
+ ...(ratio === undefined ? {} : { cacheReadRatio: ratio }),
51
+ ...(previous ? { previousInferenceId: previous.id } : {}),
52
+ ...(previousRatio === undefined ? {} : { previousCacheReadRatio: previousRatio }),
53
+ ...(elapsed === undefined ? {} : { elapsedMs: elapsed }),
54
+ };
55
+ if (options.compactionBetween) {
56
+ observation.explanation = "compaction-between-inferences";
57
+ return observation;
58
+ }
59
+ if (inputTokens !== undefined && previousInput !== undefined && cacheReadTokens !== undefined && previousRead !== undefined
60
+ && inputTokens >= CACHE_READ_DROP_MIN_INPUT_TOKENS && previousInput >= CACHE_READ_DROP_MIN_INPUT_TOKENS
61
+ && ratio !== undefined && previousRatio !== undefined && previousRatio >= 0.8 && ratio <= 0.1 && cacheReadTokens < previousRead) {
62
+ observation.warning = "possible-cache-read-drop";
63
+ observation.explanation = "cache-read-ratio-dropped";
64
+ }
65
+ return observation;
66
+ }
67
+ export function cacheUsageWarningText(observation) {
68
+ if (observation.warning === "none")
69
+ return undefined;
70
+ const current = observation.cacheReadRatio === undefined ? "unknown" : `${(observation.cacheReadRatio * 100).toFixed(1)}%`;
71
+ const previous = observation.previousCacheReadRatio === undefined ? "unknown" : `${(observation.previousCacheReadRatio * 100).toFixed(1)}%`;
72
+ return `Possible cache-read drop: provider-reported cached input fell from ${previous} to ${current}. The provider metrics do not identify the cause.`;
73
+ }
@@ -6,18 +6,31 @@ export function modelInferenceInputTokens(metrics) {
6
6
  return tokenCount(metrics?.inputTokens);
7
7
  }
8
8
  export function modelInferenceCachedInputTokens(metrics) {
9
- const cacheRead = tokenCount(metrics?.cacheReadTokens);
10
- const cacheWrite = tokenCount(metrics?.cacheWriteTokens);
11
- if (cacheRead === undefined && cacheWrite === undefined)
12
- return undefined;
13
- return (cacheRead ?? 0) + (cacheWrite ?? 0);
9
+ return tokenCount(metrics?.cacheReadTokens);
14
10
  }
15
11
  export function modelInferenceUncachedInputTokens(metrics) {
16
12
  const input = modelInferenceInputTokens(metrics);
17
- if (input === undefined)
13
+ const cached = modelInferenceCachedInputTokens(metrics);
14
+ if (input === undefined || cached === undefined || cached > input)
18
15
  return undefined;
19
- return Math.max(0, input - (modelInferenceCachedInputTokens(metrics) ?? 0));
16
+ return input - cached;
17
+ }
18
+ export function modelInferenceCacheReadRatio(metrics) {
19
+ const input = modelInferenceInputTokens(metrics);
20
+ const cached = modelInferenceCachedInputTokens(metrics);
21
+ if (input === undefined || input === 0 || cached === undefined || cached > input)
22
+ return undefined;
23
+ return cached / input;
20
24
  }
21
25
  function tokenCount(value) {
22
26
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
23
27
  }
28
+ export function compareInferenceCompletion(left, right) {
29
+ const time = Date.parse(left.completedAt ?? "") - Date.parse(right.completedAt ?? "");
30
+ if (Number.isFinite(time) && time !== 0)
31
+ return time;
32
+ if (left.completedSequence !== undefined && right.completedSequence !== undefined) {
33
+ return left.completedSequence - right.completedSequence;
34
+ }
35
+ return 0;
36
+ }
@@ -4,6 +4,8 @@ import { attachAsyncAgentRunNode, isRunStartToolNode } from "./trace-async-agent
4
4
  import { createRunNotificationNode, parseRunNotificationText } from "./trace-run-notifications.js";
5
5
  import { findLikelyTraceChildSession, isSubagentToolName, } from "./trace-subagent-links.js";
6
6
  import { qualifiedToolNodeId } from "./trace-tool-identity.js";
7
+ import { observeCacheUsage } from "./cache-observability.js";
8
+ import { compareInferenceCompletion } from "./model-inference-metrics.js";
7
9
  export function applySingleEventToNodes(nodes, byId, piboSessionId, storedEvent, childByParent, linkedChildByToolCallId, historyCoverage, openTranscriptEventIds, sessionStatus) {
8
10
  const payload = storedEvent.payload;
9
11
  if (payload.type === "assistant_usage") {
@@ -538,19 +540,35 @@ export function latestTraceStreamId(events, initial) {
538
540
  }
539
541
  function attachModelInferenceToLatestOutput(nodes, byId, event, storedEvent) {
540
542
  const eventId = event.eventId;
541
- const candidates = flattenTraceNodes(nodes)
542
- .filter((node) => node.eventId === eventId && traceNodeStartedBeforeInference(node, storedEvent) && (node.type === "assistant.message"
543
+ const flattened = flattenTraceNodes(nodes);
544
+ const id = event.inferenceId
545
+ ? `${eventId ?? storedEvent.piboSessionId}:inference:${event.inferenceId}`
546
+ : eventId ? `${eventId}:usage:${event.usageIndex ?? 0}` : storedEvent.id;
547
+ const candidates = flattened
548
+ .filter((node) => node.eventId === eventId && (event.inferenceTarget || traceNodeStartedBeforeInference(node, storedEvent)) && (node.type === "assistant.message"
543
549
  || node.type === "model.reasoning"
544
550
  || node.type === "tool.call"
545
551
  || node.type === "agent.delegation"))
546
552
  .sort(compareTraceNodes);
547
- const target = candidates.at(-1) ?? (eventId ? byId.get(messageTurnNodeId(eventId)) : undefined);
553
+ const anchor = event.inferenceTarget;
554
+ const turnNode = eventId ? byId.get(messageTurnNodeId(eventId)) : undefined;
555
+ const anchoredTarget = anchor
556
+ ? (anchor.type === "tool"
557
+ ? candidates.find((node) => node.toolCallId === anchor.toolCallId)
558
+ : anchor.type === "assistant"
559
+ ? candidates.find((node) => node.stableKey === `assistant:${eventId}:assistant:${anchor.assistantIndex}`)
560
+ : turnNode) ?? turnNode
561
+ : candidates.at(-1) ?? turnNode;
562
+ // A repeated provider receipt updates its original inference instead of moving
563
+ // the usage record to whichever output happened to render last.
564
+ const target = flattened.find((node) => node.modelInferences?.some((item) => item.id === id)) ?? anchoredTarget;
548
565
  if (!target)
549
566
  return;
550
- const id = eventId ? `${eventId}:usage:${event.usageIndex ?? 0}` : storedEvent.id;
567
+ const existingRecord = target.modelInferences?.find((item) => item.id === id);
551
568
  const record = {
552
569
  id,
553
- completedAt: storedEvent.createdAt,
570
+ completedAt: existingRecord?.completedAt ?? storedEvent.createdAt,
571
+ completedSequence: existingRecord?.completedSequence ?? storedEvent.eventSequence ?? storedEvent.streamId,
554
572
  metrics: {
555
573
  ...(event.inputTokens === undefined ? {} : { inputTokens: event.inputTokens }),
556
574
  ...(event.outputTokens === undefined ? {} : { outputTokens: event.outputTokens }),
@@ -561,7 +579,27 @@ function attachModelInferenceToLatestOutput(nodes, byId, event, storedEvent) {
561
579
  ...(event.costUsd === undefined ? {} : { costUsd: event.costUsd }),
562
580
  },
563
581
  };
564
- target.modelInferences = [...(target.modelInferences ?? []).filter((item) => item.id !== id), record];
582
+ const currentAt = Date.parse(record.completedAt ?? "");
583
+ let previous;
584
+ for (const node of flattened) {
585
+ for (const item of node.modelInferences ?? []) {
586
+ if (item.id !== id && compareInferenceCompletion(item, record) < 0
587
+ && (!previous || compareInferenceCompletion(item, previous) > 0)) {
588
+ previous = item;
589
+ }
590
+ }
591
+ }
592
+ const compactionBetween = previous !== undefined && Number.isFinite(currentAt) && flattened.some((node) => {
593
+ if (node.type !== "execution.compaction")
594
+ return false;
595
+ const boundary = {
596
+ completedAt: node.startedAt ?? node.completedAt,
597
+ completedSequence: node.orderKey?.eventSequence ?? node.orderKey?.streamId,
598
+ };
599
+ return compareInferenceCompletion(boundary, previous) > 0 && compareInferenceCompletion(boundary, record) <= 0;
600
+ });
601
+ record.cacheObservation = observeCacheUsage(record, previous, { compactionBetween });
602
+ target.modelInferences = [...(target.modelInferences ?? []).filter((item) => item.id !== id), record].sort(compareInferenceCompletion);
565
603
  }
566
604
  function traceNodeStartedBeforeInference(node, storedEvent) {
567
605
  const inferenceRenderSequence = storedEvent.renderSequence;
@@ -191,16 +191,31 @@ function createGatewayRuntimeStatuses(channelContext) {
191
191
  }
192
192
  });
193
193
  }
194
- function createGatewayStatusResponse(channelContext, options, generation) {
194
+ async function createGatewayStatusResponse(channelContext, options, generation) {
195
195
  const mode = gatewayMode(options);
196
+ const appStatuses = {};
197
+ for (const app of channelContext.getWebApps()) {
198
+ if (!app.gatewayStatus)
199
+ continue;
200
+ try {
201
+ Object.assign(appStatuses, await app.gatewayStatus());
202
+ }
203
+ catch (error) {
204
+ appStatuses[`${app.name}Status`] = { status: "ambiguous", error: error instanceof Error ? error.message : "Status unavailable" };
205
+ }
206
+ }
207
+ const durable = appStatuses.durableMessageQueue;
196
208
  return responseJson({
197
- status: "ok",
209
+ status: durable?.status === "degraded" || durable?.status === "ambiguous" ? "degraded" : "ok",
198
210
  mode,
199
211
  generation,
200
- health: { status: "ok", mode },
212
+ health: { status: durable?.status === "degraded" || durable?.status === "ambiguous" ? "degraded" : "ok", mode },
213
+ runtimeQueue: { layer: "runtime-session", statuses: createGatewayRuntimeStatuses(channelContext) },
201
214
  runtimeStatuses: createGatewayRuntimeStatuses(channelContext),
202
215
  ...(channelContext.getRuntimeCapacityStatus ? { runtimeCapacity: channelContext.getRuntimeCapacityStatus() } : {}),
216
+ ...(channelContext.getRunJobReliabilityStatus ? { reliability: channelContext.getRunJobReliabilityStatus() } : {}),
203
217
  activeRuns: collectActiveRuns(channelContext),
218
+ ...appStatuses,
204
219
  });
205
220
  }
206
221
  function createCanonicalRedirect(request, canonicalBaseURL) {
@@ -218,6 +233,68 @@ function createCanonicalRedirect(request, canonicalBaseURL) {
218
233
  function isEventStreamResponse(response) {
219
234
  return response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream") === true;
220
235
  }
236
+ function responseCanStart(response) {
237
+ return !response.destroyed && !response.writableEnded && !response.writableFinished && !response.headersSent;
238
+ }
239
+ function responseState(response) {
240
+ return [
241
+ `destroyed=${response.destroyed}`,
242
+ `writableEnded=${response.writableEnded}`,
243
+ `writableFinished=${response.writableFinished}`,
244
+ `headersSent=${response.headersSent}`,
245
+ ].join(",");
246
+ }
247
+ function requestPath(request) {
248
+ return (request.url ?? "/").split(/[?#]/, 1)[0].slice(0, 256).replace(/[\r\n]/g, "_");
249
+ }
250
+ function errorIdentity(error) {
251
+ try {
252
+ if (!(error instanceof Error))
253
+ return typeof error;
254
+ const rawName = typeof error.name === "string" ? error.name : "Error";
255
+ const name = rawName.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64) || "Error";
256
+ const code = "code" in error && typeof error.code === "string" ? error.code.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64) : undefined;
257
+ return code ? `${name}:${code}` : name;
258
+ }
259
+ catch {
260
+ return "Error";
261
+ }
262
+ }
263
+ function logHttpBoundaryFailure(phase, request, error, response) {
264
+ try {
265
+ const method = (request.method ?? "UNKNOWN").replace(/[^A-Z]/gi, "").slice(0, 16) || "UNKNOWN";
266
+ const state = response ? ` response={${responseState(response)}}` : "";
267
+ console.error(`[web-host] contained ${phase} failure method=${method} path=${requestPath(request)} error=${errorIdentity(error)}${state}`);
268
+ }
269
+ catch {
270
+ // Diagnostics are best-effort and must never reopen the request rejection path.
271
+ }
272
+ }
273
+ function terminateResponse(response, error) {
274
+ if (response.destroyed || response.writableEnded || response.writableFinished)
275
+ return;
276
+ try {
277
+ response.destroy(error instanceof Error ? error : undefined);
278
+ }
279
+ catch {
280
+ // A broken response implementation must not escape the request boundary.
281
+ }
282
+ }
283
+ function endUpgradeSocket(socket, statusLine) {
284
+ if (socket.destroyed)
285
+ return;
286
+ try {
287
+ socket.end(statusLine);
288
+ }
289
+ catch {
290
+ try {
291
+ socket.destroy();
292
+ }
293
+ catch {
294
+ // A broken socket implementation must not escape the upgrade boundary.
295
+ }
296
+ }
297
+ }
221
298
  async function waitForServerClose(closePromise, timeoutMs) {
222
299
  return await new Promise((resolve, reject) => {
223
300
  let settled = false;
@@ -323,7 +400,7 @@ export function createWebHostChannel(options = {}) {
323
400
  return;
324
401
  }
325
402
  if (url.pathname === "/gateway/status") {
326
- await sendResponse(nodeResponse, createGatewayStatusResponse(requireContext(), options, generation));
403
+ await sendResponse(nodeResponse, await createGatewayStatusResponse(requireContext(), options, generation));
327
404
  return;
328
405
  }
329
406
  if (url.pathname.startsWith("/api/auth/")) {
@@ -360,8 +437,18 @@ export function createWebHostChannel(options = {}) {
360
437
  }
361
438
  catch (error) {
362
439
  const status = error instanceof PiboAuthError || error instanceof PiboWebHttpError ? error.statusCode : 500;
363
- if (!nodeResponse.destroyed) {
364
- await sendResponse(nodeResponse, responseJson({ error: error instanceof Error ? error.message : String(error) }, { status }));
440
+ logHttpBoundaryFailure("request", nodeRequest, error, nodeResponse);
441
+ if (responseCanStart(nodeResponse)) {
442
+ try {
443
+ await sendResponse(nodeResponse, responseJson({ error: error instanceof Error ? error.message : String(error) }, { status }));
444
+ }
445
+ catch (responseError) {
446
+ logHttpBoundaryFailure("error-response", nodeRequest, responseError, nodeResponse);
447
+ terminateResponse(nodeResponse, responseError);
448
+ }
449
+ }
450
+ else {
451
+ terminateResponse(nodeResponse, error);
365
452
  }
366
453
  }
367
454
  finally {
@@ -384,10 +471,9 @@ export function createWebHostChannel(options = {}) {
384
471
  await app.handleUpgrade(nodeRequest, socket, head, createAppContext(ctx), requestURL);
385
472
  }
386
473
  catch (error) {
387
- if (!socket.destroyed) {
388
- const unauthorized = error instanceof PiboAuthError || error instanceof PiboWebHttpError;
389
- socket.end(`HTTP/1.1 ${unauthorized ? 401 : 502} ${unauthorized ? "Unauthorized" : "Bad Gateway"}\r\nConnection: close\r\n\r\n`);
390
- }
474
+ logHttpBoundaryFailure("upgrade", nodeRequest, error);
475
+ const unauthorized = error instanceof PiboAuthError || error instanceof PiboWebHttpError;
476
+ endUpgradeSocket(socket, `HTTP/1.1 ${unauthorized ? 401 : 502} ${unauthorized ? "Unauthorized" : "Bad Gateway"}\r\nConnection: close\r\n\r\n`);
391
477
  }
392
478
  };
393
479
  return {
@@ -405,10 +491,26 @@ export function createWebHostChannel(options = {}) {
405
491
  for (const app of channelContext.getWebApps())
406
492
  await app.initialize?.(createAppContext(channelContext));
407
493
  server = createServer((request, response) => {
408
- void handleRequest(request, response);
494
+ void handleRequest(request, response).catch((error) => {
495
+ try {
496
+ logHttpBoundaryFailure("request-terminal", request, error, response);
497
+ terminateResponse(response, error);
498
+ }
499
+ catch {
500
+ // The terminal request boundary itself is intentionally nonthrowing.
501
+ }
502
+ });
409
503
  });
410
504
  server.on("upgrade", (request, socket, head) => {
411
- void handleUpgrade(request, socket, head);
505
+ void handleUpgrade(request, socket, head).catch((error) => {
506
+ try {
507
+ logHttpBoundaryFailure("upgrade-terminal", request, error);
508
+ endUpgradeSocket(socket);
509
+ }
510
+ catch {
511
+ // The terminal upgrade boundary itself is intentionally nonthrowing.
512
+ }
513
+ });
412
514
  });
413
515
  server.on("connection", (socket) => {
414
516
  sockets.add(socket);
package/dist/web/http.js CHANGED
@@ -78,74 +78,136 @@ export async function nodeRequestToWebRequest(request, baseURL, signal) {
78
78
  });
79
79
  }
80
80
  export async function sendWebResponse(response, webResponse, options = {}) {
81
+ assertResponseCanStart(response);
81
82
  const headers = responseHeaders(webResponse);
82
83
  const compressEncoding = preferredResponseEncoding(response.req?.headers["accept-encoding"], webResponse);
83
- if (compressEncoding && webResponse.body) {
84
- const body = await readResponseBody(webResponse);
85
- if (body.length >= MIN_COMPRESS_RESPONSE_BYTES && body.length <= MAX_SYNC_GZIP_RESPONSE_BYTES) {
86
- const compressionStartedAt = performance.now();
87
- const compressed = gzipSync(body, { level: 1 });
88
- appendServerTimingHeader(headers, `response_compress;dur=${(performance.now() - compressionStartedAt).toFixed(1)}`);
89
- headers["content-encoding"] = compressEncoding;
90
- headers["content-length"] = String(compressed.length);
91
- headers.vary = appendVary(headers.vary, "accept-encoding");
84
+ let reader = webResponse.body?.getReader();
85
+ let wroteHeaders = false;
86
+ let clientClosed = false;
87
+ const cancelReader = () => {
88
+ if (reader)
89
+ void reader.cancel().catch(() => undefined);
90
+ };
91
+ const close = () => {
92
+ clientClosed = true;
93
+ cancelReader();
94
+ };
95
+ const abort = () => {
96
+ cancelReader();
97
+ try {
98
+ if (!response.destroyed && !response.writableEnded)
99
+ response.end();
100
+ }
101
+ catch {
102
+ tryDestroyResponse(response);
103
+ }
104
+ try {
105
+ response.socket?.end();
106
+ }
107
+ catch {
108
+ tryDestroyResponse(response);
109
+ }
110
+ };
111
+ if (reader) {
112
+ response.once("close", close);
113
+ options.signal?.addEventListener("abort", abort, { once: true });
114
+ if (options.signal?.aborted)
115
+ abort();
116
+ }
117
+ try {
118
+ if (compressEncoding && reader) {
119
+ const body = await readResponseBody(reader);
120
+ if (clientClosed || options.signal?.aborted || response.destroyed || response.writableEnded || response.writableFinished)
121
+ return;
122
+ assertResponseCanStart(response);
123
+ if (body.length >= MIN_COMPRESS_RESPONSE_BYTES && body.length <= MAX_SYNC_GZIP_RESPONSE_BYTES) {
124
+ const compressionStartedAt = performance.now();
125
+ const compressed = gzipSync(body, { level: 1 });
126
+ appendServerTimingHeader(headers, `response_compress;dur=${(performance.now() - compressionStartedAt).toFixed(1)}`);
127
+ headers["content-encoding"] = compressEncoding;
128
+ headers["content-length"] = String(compressed.length);
129
+ headers.vary = appendVary(headers.vary, "accept-encoding");
130
+ response.writeHead(webResponse.status, headers);
131
+ wroteHeaders = true;
132
+ response.end(compressed);
133
+ return;
134
+ }
135
+ if (body.length > MAX_SYNC_GZIP_RESPONSE_BYTES) {
136
+ headers["x-pibo-compression-skipped"] = "sync-gzip-size-limit";
137
+ }
92
138
  response.writeHead(webResponse.status, headers);
93
- response.end(compressed);
139
+ wroteHeaders = true;
140
+ response.end(body);
94
141
  return;
95
142
  }
96
- if (body.length > MAX_SYNC_GZIP_RESPONSE_BYTES) {
97
- headers["x-pibo-compression-skipped"] = "sync-gzip-size-limit";
98
- }
99
143
  response.writeHead(webResponse.status, headers);
100
- response.end(body);
101
- return;
102
- }
103
- response.writeHead(webResponse.status, headers);
104
- if (!webResponse.body) {
105
- response.end();
106
- return;
107
- }
108
- const reader = webResponse.body.getReader();
109
- const cancel = () => {
110
- void reader.cancel().catch(() => undefined);
111
- };
112
- const abort = () => {
113
- const socket = response.socket;
114
- void reader.cancel().catch(() => undefined);
115
- if (!response.writableEnded)
144
+ wroteHeaders = true;
145
+ if (!reader) {
116
146
  response.end();
117
- socket?.end();
118
- };
119
- response.once("close", cancel);
120
- options.signal?.addEventListener("abort", abort, { once: true });
121
- if (options.signal?.aborted)
122
- abort();
123
- try {
147
+ return;
148
+ }
124
149
  while (true) {
125
150
  const { done, value } = await reader.read();
126
151
  if (done)
127
152
  break;
128
153
  const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
129
154
  for (let offset = 0; offset < bytes.length; offset += 64 * 1024) {
130
- if (response.destroyed || response.writableEnded)
155
+ if (response.destroyed || response.writableEnded || response.writableFinished) {
156
+ cancelReader();
131
157
  return;
158
+ }
132
159
  if (!response.write(bytes.subarray(offset, offset + 64 * 1024))) {
133
160
  const drained = await waitForResponseDrain(response, (webResponse.headers.get("content-type") ?? "").startsWith("text/event-stream") ? 5000 : undefined, options.signal);
134
161
  if (!drained) {
135
- void reader.cancel().catch(() => undefined);
136
- if (!response.destroyed)
137
- response.destroy();
162
+ cancelReader();
163
+ tryDestroyResponse(response);
138
164
  return;
139
165
  }
140
166
  }
141
167
  }
142
168
  }
143
- if (!response.writableEnded)
169
+ if (!response.destroyed && !response.writableEnded && !response.writableFinished)
144
170
  response.end();
145
171
  }
172
+ catch (error) {
173
+ cancelReader();
174
+ if (clientClosed || response.destroyed || response.writableEnded || response.writableFinished)
175
+ return;
176
+ if (wroteHeaders || response.headersSent)
177
+ tryDestroyResponse(response, error);
178
+ throw error;
179
+ }
146
180
  finally {
147
- response.off("close", cancel);
181
+ response.off("close", close);
148
182
  options.signal?.removeEventListener("abort", abort);
183
+ if (reader) {
184
+ try {
185
+ reader.releaseLock();
186
+ }
187
+ catch {
188
+ // Cancellation/read settlement owns the lock until the pending read resolves.
189
+ }
190
+ }
191
+ }
192
+ }
193
+ function assertResponseCanStart(response) {
194
+ if (response.destroyed)
195
+ throw new Error("HTTP response is destroyed");
196
+ if (response.writableEnded)
197
+ throw new Error("HTTP response has ended");
198
+ if (response.writableFinished)
199
+ throw new Error("HTTP response has finished");
200
+ if (response.headersSent)
201
+ throw new Error("HTTP response headers were already sent");
202
+ }
203
+ function tryDestroyResponse(response, error) {
204
+ if (response.destroyed)
205
+ return;
206
+ try {
207
+ response.destroy(error instanceof Error ? error : undefined);
208
+ }
209
+ catch {
210
+ // The request boundary is best-effort once the response implementation itself fails.
149
211
  }
150
212
  }
151
213
  function appendServerTimingHeader(headers, value) {
@@ -198,9 +260,8 @@ function responseCanBeCompressed(webResponse) {
198
260
  const contentType = webResponse.headers.get("content-type") ?? "";
199
261
  return /^application\/json\b/.test(contentType);
200
262
  }
201
- async function readResponseBody(webResponse) {
263
+ async function readResponseBody(reader) {
202
264
  const chunks = [];
203
- const reader = webResponse.body.getReader();
204
265
  while (true) {
205
266
  const { done, value } = await reader.read();
206
267
  if (done)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@pasko70/pibo",
9
- "version": "3.5.0",
9
+ "version": "3.6.0",
10
10
  "workspaces": [
11
11
  "packages/workflows"
12
12
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",