@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.
- package/dist/agent-runtime/routed-session.js +80 -19
- package/dist/agent-runtimes/codex-native/turn.js +27 -3
- package/dist/apps/chat/data/chat-data-mappers.js +8 -1
- package/dist/apps/chat/data/read-state-service.js +24 -3
- package/dist/apps/chat/message-command-dispatcher.js +16 -1
- package/dist/apps/chat/web-app.js +40 -19
- package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-BQ-TQZet.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-CYTRhePp.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-Crj0LNXs.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-t24MVWWG.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-tFj2S0WU.js} +1 -1
- package/dist/apps/chat-ui/assets/index-Bc9O0z52.css +1 -0
- package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-Cr2oFRhI.js} +77 -77
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DgKYVP-6.js} +11 -11
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/cli-session/localSessionSource.js +3 -2
- package/dist/core/output-render-sequence.js +63 -6
- package/dist/core/session-router.js +99 -11
- package/dist/data/async-chat-storage.js +7 -2
- package/dist/data/bounded-worker-client.js +1 -1
- package/dist/data/chat-read-projections.js +4 -4
- package/dist/data/chat-storage-worker.js +24 -3
- package/dist/data/ingest-service.js +73 -4
- package/dist/data/message-command-store.js +153 -11
- package/dist/data/schema.js +24 -3
- package/dist/data/storage-maintenance.js +344 -0
- package/dist/data/storage-verification-worker.js +25 -0
- package/dist/debug/cache.js +136 -0
- package/dist/debug/index.js +201 -1
- package/dist/debug/message-queue.js +108 -0
- package/dist/debug/output-collision-repair.js +140 -0
- package/dist/debug/output-integrity.js +38 -2
- package/dist/debug/output-repair.js +1 -0
- package/dist/debug/session.js +2 -0
- package/dist/debug/storage-backup.js +12 -4
- package/dist/debug/storage-maintenance.js +78 -0
- package/dist/debug/summary.js +1 -0
- package/dist/debug/trace.js +28 -0
- package/dist/gateway/cli.js +71 -7
- package/dist/gateway/server.js +1 -0
- package/dist/reliability/store.js +119 -23
- package/dist/session-ui/terminalRows.js +7 -8
- package/dist/sessions/pibo-data-store.js +18 -14
- package/dist/shared/cache-observability.js +73 -0
- package/dist/shared/model-inference-metrics.js +20 -7
- package/dist/shared/trace-event-projection.js +44 -6
- package/dist/web/channel.js +114 -12
- package/dist/web/http.js +105 -44
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
13
|
+
const cached = modelInferenceCachedInputTokens(metrics);
|
|
14
|
+
if (input === undefined || cached === undefined || cached > input)
|
|
18
15
|
return undefined;
|
|
19
|
-
return
|
|
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
|
|
542
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/web/channel.js
CHANGED
|
@@ -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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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",
|
|
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(
|
|
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)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pasko70/pibo",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
9
|
+
"version": "3.6.0",
|
|
10
10
|
"workspaces": [
|
|
11
11
|
"packages/workflows"
|
|
12
12
|
],
|