@averyyy/pi-server 0.82.1-piclient.2 → 0.82.1-piclient.4

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.
@@ -1,6 +1,10 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { createHash } from "node:crypto";
3
+ import { TextDecoder } from "node:util";
3
4
  export const CHUNK_ENDPOINT = "/api/request/chunk";
5
+ export const REQUEST_CHUNK_SEGMENT_ENCODING = "base64-segment-v1";
6
+ const LEGACY_REQUEST_CHUNK_ENCODING = "legacy-base64-stream";
7
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
4
8
  const ALLOWED_TARGETS = new Set([
5
9
  "/api/session/init",
6
10
  "/api/session/update",
@@ -10,38 +14,183 @@ const ALLOWED_TARGETS = new Set([
10
14
  "/api/session/tree/append",
11
15
  "/api/session/tree/switch",
12
16
  "/api/session/drop-last-assistant-error",
17
+ "/api/session/run/cancel",
13
18
  "/api/session/compact",
19
+ "/api/session/compact/cancel",
14
20
  "/api/stream",
15
21
  "/api/receive",
16
22
  ]);
17
23
  const pendingRequests = new Map();
18
24
  const completedRequests = new Map();
25
+ const completedRequestTombstones = new Map();
19
26
  let pendingRequestBytes = 0;
27
+ let pendingRequestMetadataBytes = 0;
28
+ let pendingRequestTombstoneReservationBytes = 0;
29
+ let completedRequestBytes = 0;
30
+ let completedRequestTombstoneBytes = 0;
31
+ let cleanupTimer;
20
32
  export const REQUEST_CHUNK_PENDING_TTL_MS = 5 * 60 * 1000;
21
- export const REQUEST_CHUNK_MAX_PENDING_BYTES = 64 * 1024 * 1024;
33
+ // Chunk transport base64-encodes the JSON body, whose file payloads may already
34
+ // be base64. Keep enough bounded headroom for the 64 MiB decoded receive limit.
35
+ export const REQUEST_CHUNK_MAX_PENDING_BYTES = 256 * 1024 * 1024;
36
+ export const REQUEST_CHUNK_MAX_TOTAL_CHUNKS = 4096;
37
+ export const REQUEST_CHUNK_MAX_REQUEST_ID_CHARS = 128;
38
+ export const REQUEST_CHUNK_MAX_PENDING_COUNT = 128;
39
+ export const REQUEST_CHUNK_MAX_PENDING_METADATA_BYTES = 32 * 1024 * 1024;
22
40
  export const REQUEST_CHUNK_COMPLETED_TTL_MS = 60 * 1000;
41
+ export const REQUEST_CHUNK_MAX_COMPLETED_COUNT = 128;
42
+ export const REQUEST_CHUNK_MAX_COMPLETED_BYTES = 256 * 1024 * 1024;
43
+ export const REQUEST_CHUNK_TOMBSTONE_TTL_MS = 6 * 60 * 60 * 1000;
44
+ export const REQUEST_CHUNK_MAX_TOMBSTONE_COUNT = 8192;
45
+ export const REQUEST_CHUNK_MAX_TOMBSTONE_BYTES = 4 * 1024 * 1024;
46
+ const REQUEST_CHUNK_PENDING_BASE_METADATA_BYTES = 256;
47
+ const REQUEST_CHUNK_PENDING_PER_CHUNK_METADATA_BYTES = 192;
23
48
  function sha256(value) {
24
49
  return createHash("sha256").update(value).digest("hex");
25
50
  }
51
+ function base64Value(characterCode) {
52
+ if (characterCode >= 65 && characterCode <= 90)
53
+ return characterCode - 65;
54
+ if (characterCode >= 97 && characterCode <= 122)
55
+ return characterCode - 71;
56
+ if (characterCode >= 48 && characterCode <= 57)
57
+ return characterCode + 4;
58
+ if (characterCode === 43)
59
+ return 62;
60
+ if (characterCode === 47)
61
+ return 63;
62
+ return -1;
63
+ }
64
+ function getCanonicalBase64DecodedBytes(value, label) {
65
+ if (value.length % 4 !== 0) {
66
+ throw new Error(`${label} must be canonical padded base64`);
67
+ }
68
+ let padding = 0;
69
+ if (value.endsWith("==")) {
70
+ padding = 2;
71
+ }
72
+ else if (value.endsWith("=")) {
73
+ padding = 1;
74
+ }
75
+ const dataLength = value.length - padding;
76
+ for (let index = 0; index < dataLength; index++) {
77
+ if (base64Value(value.charCodeAt(index)) < 0) {
78
+ throw new Error(`${label} must be canonical padded base64`);
79
+ }
80
+ }
81
+ for (let index = dataLength; index < value.length; index++) {
82
+ if (value.charCodeAt(index) !== 61) {
83
+ throw new Error(`${label} must be canonical padded base64`);
84
+ }
85
+ }
86
+ if ((padding === 0 && dataLength % 4 !== 0) ||
87
+ (padding === 1 && (dataLength % 4 !== 3 || (base64Value(value.charCodeAt(dataLength - 1)) & 0b11) !== 0)) ||
88
+ (padding === 2 && (dataLength % 4 !== 2 || (base64Value(value.charCodeAt(dataLength - 1)) & 0b1111) !== 0))) {
89
+ throw new Error(`${label} must be canonical padded base64`);
90
+ }
91
+ return (value.length / 4) * 3 - padding;
92
+ }
93
+ function decodeCanonicalBase64(value, label) {
94
+ getCanonicalBase64DecodedBytes(value, label);
95
+ return Buffer.from(value, "base64");
96
+ }
97
+ function decodeRequestBody(rawBody) {
98
+ try {
99
+ return UTF8_DECODER.decode(rawBody);
100
+ }
101
+ catch {
102
+ throw new Error("Decoded request body must be valid UTF-8");
103
+ }
104
+ }
26
105
  function assertValidChunk(body) {
27
106
  if (!body.requestId)
28
107
  throw new Error("requestId is required");
108
+ if (typeof body.requestId !== "string" || body.requestId.length > REQUEST_CHUNK_MAX_REQUEST_ID_CHARS) {
109
+ throw new Error(`requestId must not exceed ${REQUEST_CHUNK_MAX_REQUEST_ID_CHARS} characters`);
110
+ }
29
111
  if (!ALLOWED_TARGETS.has(body.target))
30
112
  throw new Error(`Unsupported chunk target: ${body.target}`);
31
- if (!Number.isInteger(body.totalChunks) || body.totalChunks <= 0) {
32
- throw new Error("totalChunks must be a positive integer");
113
+ if (!Number.isInteger(body.totalChunks) ||
114
+ body.totalChunks <= 0 ||
115
+ body.totalChunks > REQUEST_CHUNK_MAX_TOTAL_CHUNKS) {
116
+ throw new Error(`totalChunks must be an integer from 1 to ${REQUEST_CHUNK_MAX_TOTAL_CHUNKS}`);
33
117
  }
34
118
  if (!Number.isInteger(body.chunkIndex) || body.chunkIndex < 0 || body.chunkIndex >= body.totalChunks) {
35
119
  throw new Error("chunkIndex must be an integer within the chunk range");
36
120
  }
37
121
  if (typeof body.chunk !== "string")
38
122
  throw new Error("chunk must be a string");
39
- if (typeof body.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(body.sha256)) {
40
- throw new Error("sha256 must be a 64-character hex string");
123
+ if (body.chunk.length === 0)
124
+ throw new Error("chunk must not be empty");
125
+ if (typeof body.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(body.sha256)) {
126
+ throw new Error("sha256 must be a canonical lowercase 64-character hex string");
41
127
  }
42
128
  if (sha256(body.chunk) !== body.sha256) {
43
129
  throw new Error(`Chunk checksum mismatch: ${body.chunkIndex}`);
44
130
  }
131
+ if (body.encoding === undefined) {
132
+ if (body.rawTotalBytes !== undefined || body.rawChunkBytes !== undefined) {
133
+ throw new Error("raw chunk layout metadata requires base64-segment-v1 encoding");
134
+ }
135
+ return {
136
+ encoding: LEGACY_REQUEST_CHUNK_ENCODING,
137
+ layout: "stored",
138
+ chunk: {
139
+ encoding: LEGACY_REQUEST_CHUNK_ENCODING,
140
+ encoded: body.chunk,
141
+ sha256: body.sha256,
142
+ retainedBytes: Buffer.byteLength(body.chunk, "utf-8"),
143
+ },
144
+ };
145
+ }
146
+ if (body.encoding !== REQUEST_CHUNK_SEGMENT_ENCODING) {
147
+ throw new Error(`Unsupported chunk encoding: ${String(body.encoding)}`);
148
+ }
149
+ const decodedBytes = getCanonicalBase64DecodedBytes(body.chunk, `Chunk ${body.chunkIndex}`);
150
+ if (body.rawTotalBytes === undefined && body.rawChunkBytes === undefined) {
151
+ const decoded = Buffer.from(body.chunk, "base64");
152
+ return {
153
+ encoding: REQUEST_CHUNK_SEGMENT_ENCODING,
154
+ layout: "stored",
155
+ chunk: {
156
+ encoding: REQUEST_CHUNK_SEGMENT_ENCODING,
157
+ decoded,
158
+ sha256: body.sha256,
159
+ retainedBytes: decoded.byteLength,
160
+ },
161
+ };
162
+ }
163
+ if (body.rawTotalBytes === undefined || body.rawChunkBytes === undefined) {
164
+ throw new Error("rawTotalBytes and rawChunkBytes must be supplied together");
165
+ }
166
+ if (!Number.isSafeInteger(body.rawTotalBytes) ||
167
+ body.rawTotalBytes <= 0 ||
168
+ body.rawTotalBytes > REQUEST_CHUNK_MAX_PENDING_BYTES) {
169
+ throw new Error(`rawTotalBytes must be an integer from 1 to ${REQUEST_CHUNK_MAX_PENDING_BYTES}`);
170
+ }
171
+ if (!Number.isSafeInteger(body.rawChunkBytes) ||
172
+ body.rawChunkBytes <= 0 ||
173
+ body.rawChunkBytes > body.rawTotalBytes) {
174
+ throw new Error("rawChunkBytes must be an integer within the raw request body");
175
+ }
176
+ if (Math.ceil(body.rawTotalBytes / body.rawChunkBytes) !== body.totalChunks) {
177
+ throw new Error("totalChunks does not match the raw chunk layout");
178
+ }
179
+ const offset = body.chunkIndex * body.rawChunkBytes;
180
+ const expectedBytes = Math.min(body.rawChunkBytes, body.rawTotalBytes - offset);
181
+ if (expectedBytes <= 0 || decodedBytes !== expectedBytes) {
182
+ throw new Error(`Chunk ${body.chunkIndex} byte length does not match the raw chunk layout`);
183
+ }
184
+ return {
185
+ encoding: REQUEST_CHUNK_SEGMENT_ENCODING,
186
+ layout: "offset",
187
+ decodedBytes,
188
+ rawTotalBytes: body.rawTotalBytes,
189
+ rawChunkBytes: body.rawChunkBytes,
190
+ offset,
191
+ sha256: body.sha256,
192
+ encoded: body.chunk,
193
+ };
45
194
  }
46
195
  function makeAck(body) {
47
196
  return {
@@ -56,24 +205,96 @@ function makeAck(body) {
56
205
  }
57
206
  function deletePendingRequest(requestId, pending) {
58
207
  pendingRequests.delete(requestId);
59
- pendingRequestBytes -= pending.receivedBytes;
208
+ pendingRequestBytes -= pending.allocatedBytes;
209
+ pendingRequestMetadataBytes -= pending.metadataBytes;
210
+ pendingRequestTombstoneReservationBytes -= pending.tombstoneBytes;
211
+ }
212
+ function deleteCompletedRequest(requestId, completed) {
213
+ completedRequests.delete(requestId);
214
+ completedRequestBytes -= completed.retainedBytes;
60
215
  }
61
- function cleanupExpiredRequests(nowMs, pendingTtlMs, completedTtlMs) {
216
+ function deleteCompletedRequestTombstone(requestId, tombstone) {
217
+ completedRequestTombstones.delete(requestId);
218
+ completedRequestTombstoneBytes -= tombstone.retainedBytes;
219
+ }
220
+ function cleanupExpiredRequests(nowMs) {
62
221
  for (const [requestId, pending] of pendingRequests) {
63
- if (nowMs - pending.updatedAtMs > pendingTtlMs) {
222
+ if (nowMs >= pending.expiresAtMs) {
64
223
  deletePendingRequest(requestId, pending);
65
224
  }
66
225
  }
67
226
  for (const [requestId, completed] of completedRequests) {
68
- if (nowMs - completed.completedAtMs > completedTtlMs) {
69
- completedRequests.delete(requestId);
227
+ if (nowMs >= completed.expiresAtMs) {
228
+ deleteCompletedRequest(requestId, completed);
70
229
  }
71
230
  }
231
+ for (const [requestId, tombstone] of completedRequestTombstones) {
232
+ if (nowMs >= tombstone.expiresAtMs) {
233
+ deleteCompletedRequestTombstone(requestId, tombstone);
234
+ }
235
+ }
236
+ }
237
+ function scheduleCleanupTimer(nowMs = Date.now()) {
238
+ if (cleanupTimer !== undefined) {
239
+ clearTimeout(cleanupTimer);
240
+ cleanupTimer = undefined;
241
+ }
242
+ let nextExpiryMs = Number.POSITIVE_INFINITY;
243
+ for (const pending of pendingRequests.values()) {
244
+ nextExpiryMs = Math.min(nextExpiryMs, pending.expiresAtMs);
245
+ }
246
+ for (const completed of completedRequests.values()) {
247
+ nextExpiryMs = Math.min(nextExpiryMs, completed.expiresAtMs);
248
+ }
249
+ for (const tombstone of completedRequestTombstones.values()) {
250
+ nextExpiryMs = Math.min(nextExpiryMs, tombstone.expiresAtMs);
251
+ }
252
+ if (!Number.isFinite(nextExpiryMs))
253
+ return;
254
+ cleanupTimer = setTimeout(() => {
255
+ cleanupTimer = undefined;
256
+ cleanupExpiredRequests(Date.now());
257
+ scheduleCleanupTimer();
258
+ }, Math.max(0, nextExpiryMs - nowMs));
259
+ cleanupTimer.unref();
260
+ }
261
+ function assertPositiveLimit(value, label) {
262
+ if (!Number.isSafeInteger(value) || value <= 0) {
263
+ throw new Error(`${label} must be a positive safe integer`);
264
+ }
265
+ }
266
+ function calculatePendingMetadataBytes(body) {
267
+ return (REQUEST_CHUNK_PENDING_BASE_METADATA_BYTES +
268
+ body.totalChunks * REQUEST_CHUNK_PENDING_PER_CHUNK_METADATA_BYTES +
269
+ Buffer.byteLength(body.requestId, "utf-8") +
270
+ Buffer.byteLength(body.target, "utf-8") +
271
+ Buffer.byteLength(body.encoding ?? LEGACY_REQUEST_CHUNK_ENCODING, "utf-8"));
272
+ }
273
+ function calculateTombstoneBytes(body) {
274
+ return (192 +
275
+ Buffer.byteLength(body.requestId, "utf-8") +
276
+ Buffer.byteLength(body.target, "utf-8") +
277
+ Buffer.byteLength(body.encoding ?? LEGACY_REQUEST_CHUNK_ENCODING, "utf-8"));
72
278
  }
73
- function cleanupForPendingBytes(extraBytes, maxPendingBytes, protectedRequestId) {
279
+ function cleanupForPendingCapacity(extraBytes, extraMetadataBytes, extraCount, maxPendingBytes, maxPendingMetadataBytes, maxPendingCount, protectedRequestId, protectedBytesAfterAdmission, protectedMetadataBytesAfterAdmission) {
280
+ assertPositiveLimit(maxPendingBytes, "maxPendingBytes");
281
+ assertPositiveLimit(maxPendingMetadataBytes, "maxPendingMetadataBytes");
282
+ assertPositiveLimit(maxPendingCount, "maxPendingCount");
283
+ if (protectedBytesAfterAdmission > maxPendingBytes) {
284
+ throw new Error("Request chunk pending bytes limit exceeded");
285
+ }
286
+ if (protectedMetadataBytesAfterAdmission > maxPendingMetadataBytes) {
287
+ throw new Error("Request chunk pending metadata limit exceeded");
288
+ }
289
+ if (extraCount > maxPendingCount) {
290
+ throw new Error("Request chunk pending count limit exceeded");
291
+ }
74
292
  for (const [requestId, pending] of pendingRequests) {
75
- if (pendingRequestBytes + extraBytes <= maxPendingBytes)
293
+ if (pendingRequestBytes + extraBytes <= maxPendingBytes &&
294
+ pendingRequestMetadataBytes + extraMetadataBytes <= maxPendingMetadataBytes &&
295
+ pendingRequests.size + extraCount <= maxPendingCount) {
76
296
  return;
297
+ }
77
298
  if (requestId !== protectedRequestId) {
78
299
  deletePendingRequest(requestId, pending);
79
300
  }
@@ -81,88 +302,359 @@ function cleanupForPendingBytes(extraBytes, maxPendingBytes, protectedRequestId)
81
302
  if (pendingRequestBytes + extraBytes > maxPendingBytes) {
82
303
  throw new Error("Request chunk pending bytes limit exceeded");
83
304
  }
305
+ if (pendingRequestMetadataBytes + extraMetadataBytes > maxPendingMetadataBytes) {
306
+ throw new Error("Request chunk pending metadata limit exceeded");
307
+ }
308
+ if (pendingRequests.size + extraCount > maxPendingCount) {
309
+ throw new Error("Request chunk pending count limit exceeded");
310
+ }
311
+ }
312
+ function cleanupForTombstoneReservation(extraCount, extraBytes, maxTombstoneCount, maxTombstoneBytes, protectedRequestId) {
313
+ assertPositiveLimit(maxTombstoneCount, "maxTombstoneCount");
314
+ assertPositiveLimit(maxTombstoneBytes, "maxTombstoneBytes");
315
+ if (extraCount > maxTombstoneCount || extraBytes > maxTombstoneBytes) {
316
+ throw new Error("Request chunk completed tombstone limit exceeded");
317
+ }
318
+ for (const [requestId, pending] of pendingRequests) {
319
+ if (completedRequestTombstones.size + pendingRequests.size + extraCount <= maxTombstoneCount &&
320
+ completedRequestTombstoneBytes + pendingRequestTombstoneReservationBytes + extraBytes <= maxTombstoneBytes) {
321
+ return;
322
+ }
323
+ if (requestId !== protectedRequestId)
324
+ deletePendingRequest(requestId, pending);
325
+ }
326
+ if (completedRequestTombstones.size + pendingRequests.size + extraCount > maxTombstoneCount ||
327
+ completedRequestTombstoneBytes + pendingRequestTombstoneReservationBytes + extraBytes > maxTombstoneBytes) {
328
+ throw new Error("Request chunk completed tombstone limit exceeded");
329
+ }
330
+ }
331
+ function cacheCompletedRequest(requestId, completed, maxCompletedCount, maxCompletedBytes) {
332
+ if (maxCompletedCount <= 0 || completed.retainedBytes > maxCompletedBytes)
333
+ return;
334
+ for (const [completedRequestId, existing] of completedRequests) {
335
+ if (completedRequests.size < maxCompletedCount &&
336
+ completedRequestBytes + completed.retainedBytes <= maxCompletedBytes) {
337
+ break;
338
+ }
339
+ deleteCompletedRequest(completedRequestId, existing);
340
+ }
341
+ if (completedRequests.size >= maxCompletedCount ||
342
+ completedRequestBytes + completed.retainedBytes > maxCompletedBytes) {
343
+ return;
344
+ }
345
+ completedRequests.set(requestId, completed);
346
+ completedRequestBytes += completed.retainedBytes;
347
+ }
348
+ function cacheCompletedRequestTombstone(requestId, tombstone, maxTombstoneCount, maxTombstoneBytes) {
349
+ assertPositiveLimit(maxTombstoneCount, "maxTombstoneCount");
350
+ assertPositiveLimit(maxTombstoneBytes, "maxTombstoneBytes");
351
+ if (completedRequestTombstones.size >= maxTombstoneCount ||
352
+ completedRequestTombstoneBytes + tombstone.retainedBytes > maxTombstoneBytes) {
353
+ throw new Error("Request chunk completed tombstone limit exceeded");
354
+ }
355
+ completedRequestTombstones.set(requestId, tombstone);
356
+ completedRequestTombstoneBytes += tombstone.retainedBytes;
357
+ }
358
+ function requestChunksEqual(left, right) {
359
+ if (left.encoding !== right.encoding || left.sha256 !== right.sha256)
360
+ return false;
361
+ if (left.encoding === LEGACY_REQUEST_CHUNK_ENCODING && right.encoding === LEGACY_REQUEST_CHUNK_ENCODING) {
362
+ return left.encoded === right.encoded;
363
+ }
364
+ if (left.encoding === REQUEST_CHUNK_SEGMENT_ENCODING && right.encoding === REQUEST_CHUNK_SEGMENT_ENCODING) {
365
+ return left.decoded.equals(right.decoded);
366
+ }
367
+ return false;
368
+ }
369
+ function requestLayoutMatches(request, validated) {
370
+ const requestUsesOffsetLayout = "rawTotalBytes" in request && request.rawTotalBytes !== undefined && request.rawChunkBytes !== undefined;
371
+ if (validated.layout === "offset") {
372
+ return (request.encoding === validated.encoding &&
373
+ requestUsesOffsetLayout &&
374
+ request.rawTotalBytes === validated.rawTotalBytes &&
375
+ request.rawChunkBytes === validated.rawChunkBytes);
376
+ }
377
+ return request.encoding === validated.encoding && !requestUsesOffsetLayout;
378
+ }
379
+ function assembleBodyJson(pending) {
380
+ if (pending.layout === "offset") {
381
+ if (pending.receivedBytes !== pending.rawTotalBytes) {
382
+ throw new Error("Decoded request chunk byte count did not match the raw chunk layout");
383
+ }
384
+ const rawBody = pending.rawBody;
385
+ pending.rawBody = Buffer.alloc(0);
386
+ return decodeRequestBody(rawBody);
387
+ }
388
+ if (pending.encoding === LEGACY_REQUEST_CHUNK_ENCODING) {
389
+ const encodedChunks = [];
390
+ for (let index = 0; index < pending.totalChunks; index++) {
391
+ const chunk = pending.chunks.get(index);
392
+ if (!chunk || chunk.encoding !== LEGACY_REQUEST_CHUNK_ENCODING) {
393
+ throw new Error(`Missing legacy chunk index: ${index}`);
394
+ }
395
+ encodedChunks.push(chunk.encoded);
396
+ pending.chunks.delete(index);
397
+ }
398
+ return decodeRequestBody(decodeCanonicalBase64(encodedChunks.join(""), "Legacy request body"));
399
+ }
400
+ const rawBody = Buffer.allocUnsafe(pending.receivedBytes);
401
+ let offset = 0;
402
+ for (let index = 0; index < pending.totalChunks; index++) {
403
+ const chunk = pending.chunks.get(index);
404
+ if (!chunk || chunk.encoding !== REQUEST_CHUNK_SEGMENT_ENCODING) {
405
+ throw new Error(`Missing ${REQUEST_CHUNK_SEGMENT_ENCODING} chunk index: ${index}`);
406
+ }
407
+ chunk.decoded.copy(rawBody, offset);
408
+ offset += chunk.decoded.byteLength;
409
+ pending.chunks.delete(index);
410
+ }
411
+ if (offset !== rawBody.byteLength) {
412
+ throw new Error("Decoded request chunk byte count did not match pending accounting");
413
+ }
414
+ return decodeRequestBody(rawBody);
84
415
  }
85
416
  export function receiveRequestChunk(body, options = {}) {
86
- assertValidChunk(body);
417
+ const validated = assertValidChunk(body);
87
418
  const nowMs = options.nowMs ?? Date.now();
88
- cleanupExpiredRequests(nowMs, options.pendingTtlMs ?? REQUEST_CHUNK_PENDING_TTL_MS, options.completedTtlMs ?? REQUEST_CHUNK_COMPLETED_TTL_MS);
419
+ const pendingTtlMs = options.pendingTtlMs ?? REQUEST_CHUNK_PENDING_TTL_MS;
420
+ const completedTtlMs = options.completedTtlMs ?? REQUEST_CHUNK_COMPLETED_TTL_MS;
421
+ const tombstoneTtlMs = options.tombstoneTtlMs ?? REQUEST_CHUNK_TOMBSTONE_TTL_MS;
422
+ assertPositiveLimit(pendingTtlMs, "pendingTtlMs");
423
+ assertPositiveLimit(completedTtlMs, "completedTtlMs");
424
+ assertPositiveLimit(tombstoneTtlMs, "tombstoneTtlMs");
425
+ cleanupExpiredRequests(nowMs);
89
426
  const completed = completedRequests.get(body.requestId);
90
427
  if (completed) {
91
- if (completed.target !== body.target || completed.totalChunks !== body.totalChunks) {
428
+ if (completed.target !== body.target ||
429
+ completed.totalChunks !== body.totalChunks ||
430
+ !requestLayoutMatches(completed, validated)) {
92
431
  throw new Error("Chunk metadata does not match the completed request");
93
432
  }
94
- const existing = completed.chunks.get(body.chunkIndex);
95
- if (!existing || existing.chunk !== body.chunk || existing.sha256 !== body.sha256) {
433
+ const existingHash = completed.chunkHashes[body.chunkIndex];
434
+ if (existingHash !== body.sha256) {
96
435
  throw new Error(`Duplicate chunk index does not match: ${body.chunkIndex}`);
97
436
  }
98
437
  if (body.chunkIndex === completed.completedChunkIndex) {
99
438
  return {
100
439
  complete: true,
440
+ requestId: body.requestId,
441
+ replayed: true,
101
442
  target: completed.target,
102
443
  bodyJson: completed.bodyJson,
103
444
  };
104
445
  }
105
446
  return makeAck(body);
106
447
  }
448
+ const tombstone = completedRequestTombstones.get(body.requestId);
449
+ if (tombstone) {
450
+ if (tombstone.target !== body.target ||
451
+ tombstone.totalChunks !== body.totalChunks ||
452
+ !requestLayoutMatches(tombstone, validated)) {
453
+ throw new Error("Chunk metadata does not match the completed request tombstone");
454
+ }
455
+ throw new Error("Chunk request already completed; target response is no longer replayable");
456
+ }
107
457
  let pending = pendingRequests.get(body.requestId);
108
458
  if (pending) {
109
- if (pending.target !== body.target || pending.totalChunks !== body.totalChunks) {
459
+ if (pending.target !== body.target ||
460
+ pending.totalChunks !== body.totalChunks ||
461
+ !requestLayoutMatches(pending, validated)) {
110
462
  throw new Error("Chunk metadata does not match the pending request");
111
463
  }
112
- const existing = pending.chunks.get(body.chunkIndex);
113
- if (existing) {
114
- if (existing.chunk !== body.chunk || existing.sha256 !== body.sha256) {
464
+ if (pending.layout === "offset" && validated.layout === "offset") {
465
+ const existingHash = pending.chunkHashes[body.chunkIndex];
466
+ if (existingHash !== undefined) {
467
+ if (existingHash !== body.sha256) {
468
+ throw new Error(`Duplicate chunk index does not match: ${body.chunkIndex}`);
469
+ }
470
+ pending.expiresAtMs = nowMs + pendingTtlMs;
471
+ scheduleCleanupTimer(nowMs);
472
+ return makeAck(body);
473
+ }
474
+ }
475
+ else if (pending.layout === "stored" && validated.layout === "stored") {
476
+ const existing = pending.chunks.get(body.chunkIndex);
477
+ if (existing && !requestChunksEqual(existing, validated.chunk)) {
115
478
  throw new Error(`Duplicate chunk index does not match: ${body.chunkIndex}`);
116
479
  }
117
- pending.updatedAtMs = nowMs;
118
- return makeAck(body);
480
+ if (existing) {
481
+ pending.expiresAtMs = nowMs + pendingTtlMs;
482
+ scheduleCleanupTimer(nowMs);
483
+ return makeAck(body);
484
+ }
485
+ }
486
+ else {
487
+ throw new Error("Chunk layout does not match the pending request");
119
488
  }
120
489
  }
121
- const chunkBytes = Buffer.byteLength(body.chunk, "utf-8");
122
- cleanupForPendingBytes(chunkBytes, options.maxPendingBytes ?? REQUEST_CHUNK_MAX_PENDING_BYTES, body.requestId);
490
+ const maxPendingBytes = options.maxPendingBytes ?? REQUEST_CHUNK_MAX_PENDING_BYTES;
491
+ const maxPendingMetadataBytes = options.maxPendingMetadataBytes ?? REQUEST_CHUNK_MAX_PENDING_METADATA_BYTES;
492
+ const maxPendingCount = options.maxPendingCount ?? REQUEST_CHUNK_MAX_PENDING_COUNT;
493
+ const maxTombstoneCount = options.maxTombstoneCount ?? REQUEST_CHUNK_MAX_TOMBSTONE_COUNT;
494
+ const maxTombstoneBytes = options.maxTombstoneBytes ?? REQUEST_CHUNK_MAX_TOMBSTONE_BYTES;
495
+ const metadataBytes = pending?.metadataBytes ?? calculatePendingMetadataBytes(body);
496
+ const tombstoneBytes = pending?.tombstoneBytes ?? calculateTombstoneBytes(body);
497
+ const chunkBytes = pending?.layout === "offset" || validated.layout === "offset" ? 0 : validated.chunk.retainedBytes;
498
+ const allocatedBytes = pending?.allocatedBytes ?? (validated.layout === "offset" ? validated.rawTotalBytes : 0);
499
+ const protectedBytesAfterAdmission = allocatedBytes + chunkBytes;
500
+ cleanupForPendingCapacity(pending ? chunkBytes : allocatedBytes + chunkBytes, pending ? 0 : metadataBytes, pending ? 0 : 1, maxPendingBytes, maxPendingMetadataBytes, maxPendingCount, body.requestId, protectedBytesAfterAdmission, metadataBytes);
501
+ cleanupForTombstoneReservation(pending ? 0 : 1, pending ? 0 : tombstoneBytes, maxTombstoneCount, maxTombstoneBytes, body.requestId);
123
502
  if (!pending) {
124
- pending = {
125
- target: body.target,
126
- totalChunks: body.totalChunks,
127
- chunks: new Map(),
128
- receivedBytes: 0,
129
- updatedAtMs: nowMs,
130
- };
503
+ if (validated.layout === "offset") {
504
+ pending = {
505
+ target: body.target,
506
+ totalChunks: body.totalChunks,
507
+ encoding: validated.encoding,
508
+ layout: "offset",
509
+ rawTotalBytes: validated.rawTotalBytes,
510
+ rawChunkBytes: validated.rawChunkBytes,
511
+ rawBody: Buffer.allocUnsafe(validated.rawTotalBytes),
512
+ receivedChunks: new Uint8Array(body.totalChunks),
513
+ chunkHashes: new Array(body.totalChunks),
514
+ receivedChunkCount: 0,
515
+ receivedBytes: 0,
516
+ allocatedBytes: validated.rawTotalBytes,
517
+ metadataBytes,
518
+ tombstoneBytes,
519
+ expiresAtMs: nowMs + pendingTtlMs,
520
+ };
521
+ }
522
+ else {
523
+ pending = {
524
+ target: body.target,
525
+ totalChunks: body.totalChunks,
526
+ encoding: validated.encoding,
527
+ layout: "stored",
528
+ chunks: new Map(),
529
+ receivedBytes: 0,
530
+ allocatedBytes: 0,
531
+ metadataBytes,
532
+ tombstoneBytes,
533
+ expiresAtMs: nowMs + pendingTtlMs,
534
+ };
535
+ }
131
536
  pendingRequests.set(body.requestId, pending);
537
+ pendingRequestBytes += pending.allocatedBytes;
538
+ pendingRequestMetadataBytes += pending.metadataBytes;
539
+ pendingRequestTombstoneReservationBytes += pending.tombstoneBytes;
540
+ }
541
+ if (pending.layout === "offset" && validated.layout === "offset") {
542
+ const written = pending.rawBody.write(validated.encoded, validated.offset, validated.decodedBytes, "base64");
543
+ if (written !== validated.decodedBytes) {
544
+ deletePendingRequest(body.requestId, pending);
545
+ throw new Error(`Chunk ${body.chunkIndex} base64 decode did not fill its raw chunk range`);
546
+ }
547
+ pending.receivedChunks[body.chunkIndex] = 1;
548
+ pending.chunkHashes[body.chunkIndex] = validated.sha256;
549
+ pending.receivedChunkCount++;
550
+ pending.receivedBytes += validated.decodedBytes;
132
551
  }
133
- pending.chunks.set(body.chunkIndex, { chunk: body.chunk, sha256: body.sha256 });
134
- pending.receivedBytes += chunkBytes;
135
- pending.updatedAtMs = nowMs;
136
- pendingRequestBytes += chunkBytes;
137
- if (pending.chunks.size !== pending.totalChunks) {
552
+ else if (pending.layout === "stored" && validated.layout === "stored") {
553
+ pending.chunks.set(body.chunkIndex, validated.chunk);
554
+ pending.receivedBytes += validated.chunk.retainedBytes;
555
+ pending.allocatedBytes += validated.chunk.retainedBytes;
556
+ pendingRequestBytes += validated.chunk.retainedBytes;
557
+ }
558
+ else {
559
+ deletePendingRequest(body.requestId, pending);
560
+ throw new Error("Chunk layout changed while admitting the request");
561
+ }
562
+ pending.expiresAtMs = nowMs + pendingTtlMs;
563
+ scheduleCleanupTimer(nowMs);
564
+ const receivedChunkCount = pending.layout === "offset" ? pending.receivedChunkCount : pending.chunks.size;
565
+ if (receivedChunkCount !== pending.totalChunks) {
138
566
  return makeAck(body);
139
567
  }
140
- const encodedChunks = [];
141
- for (let index = 0; index < pending.totalChunks; index++) {
142
- const chunk = pending.chunks.get(index);
143
- if (chunk === undefined)
144
- throw new Error(`Missing chunk index: ${index}`);
145
- encodedChunks.push(chunk.chunk);
568
+ const chunkHashes = [];
569
+ if (pending.layout === "offset") {
570
+ for (let index = 0; index < pending.totalChunks; index++) {
571
+ const chunkHash = pending.chunkHashes[index];
572
+ if (pending.receivedChunks[index] !== 1 || chunkHash === undefined) {
573
+ throw new Error(`Missing ${REQUEST_CHUNK_SEGMENT_ENCODING} chunk index: ${index}`);
574
+ }
575
+ chunkHashes.push(chunkHash);
576
+ }
577
+ }
578
+ else {
579
+ for (let index = 0; index < pending.totalChunks; index++) {
580
+ const chunk = pending.chunks.get(index);
581
+ if (!chunk)
582
+ throw new Error(`Missing chunk index: ${index}`);
583
+ chunkHashes.push(chunk.sha256);
584
+ }
146
585
  }
147
586
  deletePendingRequest(body.requestId, pending);
148
- const bodyJson = Buffer.from(encodedChunks.join(""), "base64").toString("utf-8");
149
- completedRequests.set(body.requestId, {
587
+ const bodyJson = assembleBodyJson(pending);
588
+ const retainedBytes = Buffer.byteLength(bodyJson, "utf-8") +
589
+ Buffer.byteLength(pending.target, "utf-8") +
590
+ Buffer.byteLength(pending.encoding, "utf-8") +
591
+ Buffer.byteLength(body.requestId, "utf-8") +
592
+ chunkHashes.length * 64;
593
+ const completedMetadata = {
150
594
  target: pending.target,
151
595
  totalChunks: pending.totalChunks,
596
+ encoding: pending.encoding,
597
+ ...(pending.layout === "offset"
598
+ ? { rawTotalBytes: pending.rawTotalBytes, rawChunkBytes: pending.rawChunkBytes }
599
+ : {}),
600
+ };
601
+ cacheCompletedRequestTombstone(body.requestId, {
602
+ ...completedMetadata,
603
+ retainedBytes: tombstoneBytes,
604
+ expiresAtMs: nowMs + tombstoneTtlMs,
605
+ }, maxTombstoneCount, maxTombstoneBytes);
606
+ cacheCompletedRequest(body.requestId, {
607
+ ...completedMetadata,
152
608
  bodyJson,
153
609
  completedChunkIndex: body.chunkIndex,
154
- completedAtMs: nowMs,
155
- chunks: pending.chunks,
156
- });
610
+ chunkHashes,
611
+ retainedBytes,
612
+ expiresAtMs: nowMs + completedTtlMs,
613
+ }, options.maxCompletedCount ?? REQUEST_CHUNK_MAX_COMPLETED_COUNT, options.maxCompletedBytes ?? REQUEST_CHUNK_MAX_COMPLETED_BYTES);
614
+ scheduleCleanupTimer(nowMs);
157
615
  return {
158
616
  complete: true,
617
+ requestId: body.requestId,
618
+ replayed: false,
159
619
  target: pending.target,
160
620
  bodyJson,
161
621
  };
162
622
  }
623
+ export function getRequestChunkCacheStats() {
624
+ let pendingPreallocatedBuffers = 0;
625
+ let pendingStoredChunks = 0;
626
+ for (const pending of pendingRequests.values()) {
627
+ if (pending.layout === "offset") {
628
+ pendingPreallocatedBuffers++;
629
+ }
630
+ else {
631
+ pendingStoredChunks += pending.chunks.size;
632
+ }
633
+ }
634
+ return {
635
+ pendingCount: pendingRequests.size,
636
+ pendingBytes: pendingRequestBytes,
637
+ pendingMetadataBytes: pendingRequestMetadataBytes,
638
+ pendingPreallocatedBuffers,
639
+ pendingStoredChunks,
640
+ completedCount: completedRequests.size,
641
+ completedBytes: completedRequestBytes,
642
+ tombstoneCount: completedRequestTombstones.size,
643
+ tombstoneBytes: completedRequestTombstoneBytes,
644
+ };
645
+ }
163
646
  export function clearAllRequestChunks() {
647
+ if (cleanupTimer !== undefined) {
648
+ clearTimeout(cleanupTimer);
649
+ cleanupTimer = undefined;
650
+ }
164
651
  pendingRequests.clear();
165
652
  completedRequests.clear();
653
+ completedRequestTombstones.clear();
166
654
  pendingRequestBytes = 0;
655
+ pendingRequestMetadataBytes = 0;
656
+ pendingRequestTombstoneReservationBytes = 0;
657
+ completedRequestBytes = 0;
658
+ completedRequestTombstoneBytes = 0;
167
659
  }
168
660
  //# sourceMappingURL=request-chunks.js.map