@layervai/qurl 0.3.1 → 0.5.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 (72) hide show
  1. package/README.md +261 -11
  2. package/dist/cjs/client.d.ts +22 -12
  3. package/dist/cjs/client.js +675 -100
  4. package/dist/cjs/errors.d.ts +11 -11
  5. package/dist/cjs/errors.js +25 -20
  6. package/dist/cjs/index.d.ts +1 -1
  7. package/dist/cjs/internal.d.ts +4 -0
  8. package/dist/cjs/internal.js +16 -0
  9. package/dist/cjs/node/deployment.d.ts +44 -0
  10. package/dist/cjs/node/deployment.js +167 -0
  11. package/dist/cjs/node/native-udp.d.ts +35 -0
  12. package/dist/cjs/node/native-udp.js +294 -0
  13. package/dist/cjs/node/nhp-wire.d.ts +35 -0
  14. package/dist/cjs/node/nhp-wire.js +261 -0
  15. package/dist/cjs/node/portal-opener.d.ts +114 -0
  16. package/dist/cjs/node/portal-opener.js +1070 -0
  17. package/dist/cjs/node/qv2.d.ts +38 -0
  18. package/dist/cjs/node/qv2.js +292 -0
  19. package/dist/cjs/node/strict-json.d.ts +12 -0
  20. package/dist/cjs/node/strict-json.js +174 -0
  21. package/dist/cjs/node.d.ts +3 -0
  22. package/dist/cjs/node.js +18 -0
  23. package/dist/cjs/types.d.ts +17 -20
  24. package/dist/cjs/version.d.ts +1 -1
  25. package/dist/cjs/version.js +1 -1
  26. package/dist/esm/client.d.ts +22 -12
  27. package/dist/esm/client.d.ts.map +1 -1
  28. package/dist/esm/client.js +673 -98
  29. package/dist/esm/client.js.map +1 -1
  30. package/dist/esm/errors.d.ts +11 -11
  31. package/dist/esm/errors.d.ts.map +1 -1
  32. package/dist/esm/errors.js +25 -20
  33. package/dist/esm/errors.js.map +1 -1
  34. package/dist/esm/index.d.ts +1 -1
  35. package/dist/esm/index.d.ts.map +1 -1
  36. package/dist/esm/internal.d.ts +5 -0
  37. package/dist/esm/internal.d.ts.map +1 -0
  38. package/dist/esm/internal.js +14 -0
  39. package/dist/esm/internal.js.map +1 -0
  40. package/dist/esm/node/deployment.d.ts +45 -0
  41. package/dist/esm/node/deployment.d.ts.map +1 -0
  42. package/dist/esm/node/deployment.js +163 -0
  43. package/dist/esm/node/deployment.js.map +1 -0
  44. package/dist/esm/node/native-udp.d.ts +36 -0
  45. package/dist/esm/node/native-udp.d.ts.map +1 -0
  46. package/dist/esm/node/native-udp.js +291 -0
  47. package/dist/esm/node/native-udp.js.map +1 -0
  48. package/dist/esm/node/nhp-wire.d.ts +36 -0
  49. package/dist/esm/node/nhp-wire.d.ts.map +1 -0
  50. package/dist/esm/node/nhp-wire.js +257 -0
  51. package/dist/esm/node/nhp-wire.js.map +1 -0
  52. package/dist/esm/node/portal-opener.d.ts +115 -0
  53. package/dist/esm/node/portal-opener.d.ts.map +1 -0
  54. package/dist/esm/node/portal-opener.js +1053 -0
  55. package/dist/esm/node/portal-opener.js.map +1 -0
  56. package/dist/esm/node/qv2.d.ts +39 -0
  57. package/dist/esm/node/qv2.d.ts.map +1 -0
  58. package/dist/esm/node/qv2.js +286 -0
  59. package/dist/esm/node/qv2.js.map +1 -0
  60. package/dist/esm/node/strict-json.d.ts +13 -0
  61. package/dist/esm/node/strict-json.d.ts.map +1 -0
  62. package/dist/esm/node/strict-json.js +171 -0
  63. package/dist/esm/node/strict-json.js.map +1 -0
  64. package/dist/esm/node.d.ts +4 -0
  65. package/dist/esm/node.d.ts.map +1 -0
  66. package/dist/esm/node.js +2 -0
  67. package/dist/esm/node.js.map +1 -0
  68. package/dist/esm/types.d.ts +17 -20
  69. package/dist/esm/types.d.ts.map +1 -1
  70. package/dist/esm/version.d.ts +1 -1
  71. package/dist/esm/version.js +1 -1
  72. package/package.json +26 -7
@@ -1,8 +1,35 @@
1
1
  import { createError, ERROR_CODE_AMBIGUOUS_RESOURCE, ERROR_CODE_CLIENT_VALIDATION, ERROR_CODE_RESOURCE_NOT_FOUND, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, NotFoundError, QURLError, RuntimeError, TimeoutError, ValidationError, } from "./errors.js";
2
+ import { stripTrailingSlashes } from "./internal.js";
2
3
  import { VERSION } from "./version.js";
3
4
  const DEFAULT_BASE_URL = "https://api.layerv.ai";
4
5
  const DEFAULT_MAX_RETRIES = 3;
5
6
  const DEFAULT_TIMEOUT = 30_000;
7
+ // Match qurl-go c4f2f98's exact API response cap. Current qurl-service list
8
+ // routes cap pages at 100 items. A caller can still exceed this cap with a
9
+ // large, heavily escaped webhook-delivery page, so list callers must request a
10
+ // smaller page if the service reports the body-limit error. This cap is fixed:
11
+ // do not add an override or widen one SDK independently from the other.
12
+ // The limit is enforced from Content-Length when usable and again while
13
+ // consuming the response stream.
14
+ const MAX_RESPONSE_BODY_BYTES = 1 << 20;
15
+ const MAX_ERROR_SNIPPET_BYTES = 512;
16
+ // UTF-8 uses at least one byte per UTF-16 code unit. Reading more source units
17
+ // cannot add a byte that fits in a 512-byte retained diagnostic.
18
+ const MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS = MAX_ERROR_SNIPPET_BYTES;
19
+ const MAX_INVALID_FIELD_ENTRIES = 100;
20
+ const MAX_DEBUG_BODY_KEYS = 100;
21
+ // Bound each retained diagnostic collection as well as each string. Without
22
+ // this aggregate cap, 100 valid 512-byte keys and values could turn a small
23
+ // server error into a large caller-visible object. This is a diagnostic budget,
24
+ // not part of the API wire contract.
25
+ const MAX_DIAGNOSTIC_COLLECTION_BYTES = 8 << 10;
26
+ const TEXT_ENCODER = new TextEncoder();
27
+ const ERROR_SNIPPET_ELLIPSIS = "...";
28
+ const ERROR_SNIPPET_ELLIPSIS_BYTES = 3;
29
+ // RFC 8259 JSON exchanged between systems must be valid UTF-8. A non-fatal
30
+ // decoder would replace malformed bytes with U+FFFD before JSON.parse and hide
31
+ // the producer's exact contract violation.
32
+ const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
6
33
  const RETRY_BASE_DELAY_MS = 500;
7
34
  // Bounds local exponential backoff (NOT server-asserted Retry-After —
8
35
  // see `RETRY_AFTER_HARD_CAP_MS` for that).
@@ -15,13 +42,33 @@ const RETRY_AFTER_PARSE_LIMIT_S = RETRY_AFTER_HARD_CAP_MS / 1000;
15
42
  const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
16
43
  // Keep mutating status retries 429-only until the service-side idempotency
17
44
  // cache contract is proven across 5xx, TTL, and replay-window edge cases.
18
- // Network-error retries still reuse Idempotency-Key below.
45
+ // Fetch-level POST/PATCH retries still reuse Idempotency-Key below.
19
46
  const RETRYABLE_STATUS_MUTATING = new Set([429]);
47
+ const REDIRECT_RESPONSE_STATUSES = new Set([300, 301, 302, 303, 305, 307, 308]);
48
+ const NO_RETRYABLE_STATUSES = new Set();
20
49
  const IDEMPOTENCY_KEY_METHODS = new Set(["POST", "PATCH"]);
21
50
  const MUTATING_RETRY_METHODS = new Set(["POST", "PATCH"]);
22
51
  const MAX_IDEMPOTENCY_KEY = 256;
23
52
  const IDEMPOTENCY_KEY_VALUE_RE = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/;
24
53
  const UUID_HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
54
+ class ResponseBodyTooLargeError extends Error {
55
+ constructor() {
56
+ super(`Response body exceeds ${MAX_RESPONSE_BODY_BYTES}-byte limit`);
57
+ this.name = "ResponseBodyTooLargeError";
58
+ }
59
+ }
60
+ class InvalidResponseEncodingError extends Error {
61
+ constructor() {
62
+ super("Response body is not valid UTF-8");
63
+ this.name = "InvalidResponseEncodingError";
64
+ }
65
+ }
66
+ class ResponseBodyMaterializationError extends Error {
67
+ constructor(message = "Response-like fetch returned a value that cannot be serialized as JSON") {
68
+ super(message);
69
+ this.name = "ResponseBodyMaterializationError";
70
+ }
71
+ }
25
72
  const NO_PASSTHROUGH_STATUSES = [];
26
73
  const BATCH_PASSTHROUGH_STATUSES = [400];
27
74
  /** Allowlist of known query-param keys for the `list()` endpoint. */
@@ -146,6 +193,7 @@ const CREATE_PORTAL_OPTION_KEYS = [
146
193
  "oneTimeUse",
147
194
  "maxSessions",
148
195
  "sessionDuration",
196
+ "targetPath",
149
197
  ];
150
198
  assertExhaustive(true);
151
199
  const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
@@ -157,13 +205,6 @@ const UPDATE_RESOURCE_QURL_FIELD_KEYS = [
157
205
  "session_duration",
158
206
  ];
159
207
  assertExhaustive(true);
160
- const AGENT_BOOTSTRAP_FIELD_KEYS = [
161
- "public_key",
162
- "agent_id",
163
- "hostname",
164
- "version",
165
- ];
166
- assertExhaustive(true);
167
208
  const UPDATE_CUSTOMER_FIELD_KEYS = [
168
209
  "spending_cap_cents",
169
210
  ];
@@ -339,23 +380,330 @@ function fillRandomBytes(bytes) {
339
380
  * Distinct from {@link clientValidationError} so callers can `.code`-branch
340
381
  * between "I passed bad input locally" (`"client_validation"`) and "the
341
382
  * server returned a body I can't interpret" (`"unexpected_response"`).
342
- * Uses `status: 0` because the offending HTTP status (400/207/etc.) isn't
343
- * the thing being reported — the shape mismatch is.
383
+ * Uses `status: 0` by default because a logical shape mismatch is not tied to
384
+ * the HTTP classification. Callers that enforce an observed HTTP contract
385
+ * pass that status explicitly.
344
386
  *
345
387
  * Threads `request_id` through to {@link QURLError.requestId} when the
346
388
  * server-side correlation ID is available — operators debugging
347
389
  * "unexpected response" tickets need it on the *error* path too, not
348
390
  * just on success/passthrough returns.
349
391
  */
350
- function unexpectedResponseError(detail, request_id) {
392
+ function unexpectedResponseError(detail, options = {}) {
393
+ // Every detail passed here is an SDK-authored contract message whose dynamic
394
+ // inputs were validated by the caller-side shape guards. Do not pass raw
395
+ // server strings to this helper. Server diagnostics use boundedErrorSnippet.
396
+ const safeRequestId = boundedErrorIdentifier(options.requestId);
351
397
  return new ValidationError({
352
- status: 0,
398
+ status: options.status ?? 0,
353
399
  code: ERROR_CODE_UNEXPECTED_RESPONSE,
354
400
  title: "Unexpected Response",
355
401
  detail,
356
- request_id,
402
+ request_id: safeRequestId,
357
403
  });
358
404
  }
405
+ function withErrorCause(error, cause) {
406
+ // Match the native Error `cause` descriptor. In particular, do not leak a
407
+ // transport object through Object.keys(), object spread, or JSON logging.
408
+ Object.defineProperty(error, "cause", {
409
+ value: cause,
410
+ writable: true,
411
+ configurable: true,
412
+ });
413
+ return error;
414
+ }
415
+ function isIndependentAbort(error, requestSignal) {
416
+ return (error instanceof Error &&
417
+ (error.name === "AbortError" || error.name === "TimeoutError") &&
418
+ !requestSignal.aborted);
419
+ }
420
+ function isSdkTimeout(error, requestSignal) {
421
+ return (error instanceof Error &&
422
+ requestSignal.aborted &&
423
+ (error.name === "TimeoutError" || error.name === "AbortError"));
424
+ }
425
+ /**
426
+ * Normalize a server-provided problem title/detail into a single-line,
427
+ * UTF-8-safe snippet. Error messages must stay bounded even when a proxy or
428
+ * compromised upstream returns very large structured problem fields.
429
+ */
430
+ function normalizedErrorSnippet(value) {
431
+ if (typeof value !== "string")
432
+ return undefined;
433
+ // Bound source work before the Unicode regular expressions allocate
434
+ // normalized copies. The response body cap is much larger than one retained
435
+ // diagnostic, so processing the full server string would waste memory.
436
+ const normalized = value
437
+ .slice(0, MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS)
438
+ // Strip control characters plus bidi override/isolate controls that can
439
+ // visually reorder diagnostics. Preserve other formatting characters
440
+ // such as ZWJ/ZWNJ, which are required by legitimate scripts and emoji.
441
+ .replace(/[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, " ")
442
+ .trim()
443
+ .replace(/\s+/g, " ");
444
+ return normalized === "" ? undefined : normalized;
445
+ }
446
+ function boundedErrorSnippet(value) {
447
+ const sourceTruncated = typeof value === "string" && value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS;
448
+ const normalized = normalizedErrorSnippet(value);
449
+ if (normalized === undefined)
450
+ return undefined;
451
+ const contentLimit = sourceTruncated
452
+ ? MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES
453
+ : MAX_ERROR_SNIPPET_BYTES;
454
+ // UTF-8 uses at most three bytes per UTF-16 code unit. Most diagnostics are
455
+ // short ASCII, so avoid an allocation only to prove they fit.
456
+ if (normalized.length * 3 <= contentLimit) {
457
+ return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
458
+ }
459
+ const encoded = TEXT_ENCODER.encode(normalized);
460
+ if (encoded.byteLength <= contentLimit) {
461
+ return sourceTruncated ? `${normalized}${ERROR_SNIPPET_ELLIPSIS}` : normalized;
462
+ }
463
+ let end = MAX_ERROR_SNIPPET_BYTES - ERROR_SNIPPET_ELLIPSIS_BYTES;
464
+ // Do not split a multi-byte UTF-8 sequence at the cap.
465
+ while (end > 0 && (encoded[end] & 0xc0) === 0x80)
466
+ end--;
467
+ return `${TEXT_DECODER.decode(encoded.subarray(0, end))}${ERROR_SNIPPET_ELLIPSIS}`;
468
+ }
469
+ /** Keep server identifiers useful as exact machine-readable discriminants. */
470
+ function boundedErrorIdentifier(value) {
471
+ if (typeof value !== "string" || value.length > MAX_ERROR_SNIPPET_SOURCE_CODE_UNITS) {
472
+ return undefined;
473
+ }
474
+ const normalized = normalizedErrorSnippet(value);
475
+ if (normalized === undefined ||
476
+ normalized !== value ||
477
+ TEXT_ENCODER.encode(value).byteLength > MAX_ERROR_SNIPPET_BYTES) {
478
+ return undefined;
479
+ }
480
+ return value;
481
+ }
482
+ /** Collect at most the first N enumerable own string keys without a full key-array copy. */
483
+ function firstEnumerableOwnKeys(value, limit) {
484
+ const keys = [];
485
+ for (const key in value) {
486
+ if (!Object.prototype.hasOwnProperty.call(value, key))
487
+ continue;
488
+ keys.push(key);
489
+ if (keys.length === limit)
490
+ break;
491
+ }
492
+ return keys;
493
+ }
494
+ /** Bound the structured per-field diagnostics exposed on ValidationError. */
495
+ function boundedInvalidFields(value) {
496
+ if (typeof value !== "object" || value === null || Array.isArray(value))
497
+ return undefined;
498
+ const entries = [];
499
+ const retainedKeys = new Set();
500
+ let retainedBytes = 0;
501
+ // Bound inspected entries as well as retained entries. A hostile envelope
502
+ // whose values all have the wrong type must not make diagnostics scan every
503
+ // key in the response body.
504
+ for (const rawKey of firstEnumerableOwnKeys(value, MAX_INVALID_FIELD_ENTRIES)) {
505
+ const rawValue = value[rawKey];
506
+ const key = boundedErrorSnippet(rawKey);
507
+ const fieldDetail = boundedErrorSnippet(rawValue);
508
+ if (key !== undefined && fieldDetail !== undefined && !retainedKeys.has(key)) {
509
+ const entryBytes = TEXT_ENCODER.encode(key).byteLength + TEXT_ENCODER.encode(fieldDetail).byteLength;
510
+ // A later, smaller entry can still fit even when this one cannot.
511
+ if (retainedBytes + entryBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
512
+ continue;
513
+ retainedKeys.add(key);
514
+ entries.push([key, fieldDetail]);
515
+ retainedBytes += entryBytes;
516
+ }
517
+ }
518
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
519
+ }
520
+ /** Bound server-controlled object keys before forwarding them to a debug sink. */
521
+ function boundedObjectKeys(value) {
522
+ // Stop before normalization so a hostile envelope cannot force work over
523
+ // every enumerable own key. Some early keys may normalize away; bounded
524
+ // diagnostics are more important here than filling every available slot.
525
+ const retained = [];
526
+ let retainedBytes = 0;
527
+ for (const rawKey of firstEnumerableOwnKeys(value, MAX_DEBUG_BODY_KEYS)) {
528
+ const key = boundedErrorSnippet(rawKey);
529
+ if (key === undefined)
530
+ continue;
531
+ const keyBytes = TEXT_ENCODER.encode(key).byteLength;
532
+ // A later, smaller key can still fit even when this one cannot.
533
+ if (retainedBytes + keyBytes > MAX_DIAGNOSTIC_COLLECTION_BYTES)
534
+ continue;
535
+ retained.push(key);
536
+ retainedBytes += keyBytes;
537
+ }
538
+ return retained;
539
+ }
540
+ async function cancelResponseBody(body) {
541
+ if (!body)
542
+ return;
543
+ try {
544
+ await body.cancel();
545
+ }
546
+ catch {
547
+ // Best-effort resource cleanup must not replace the deterministic SDK error.
548
+ }
549
+ }
550
+ function materializedResponseBodyExceedsLimit(value) {
551
+ if (value.length > MAX_RESPONSE_BODY_BYTES)
552
+ return true;
553
+ // Encoding creates another buffer. UTF-8 uses at most three bytes per
554
+ // UTF-16 code unit, so shorter strings are provably safe without that copy.
555
+ return (value.length * 3 > MAX_RESPONSE_BODY_BYTES &&
556
+ TEXT_ENCODER.encode(value).byteLength > MAX_RESPONSE_BODY_BYTES);
557
+ }
558
+ function contentLengthExceedsLimit(response) {
559
+ // 204/205 never carry content, and a 304 Content-Length describes the
560
+ // selected representation rather than a response body. Do not reject those
561
+ // statuses for metadata about bytes that fetch will not deliver.
562
+ if (response.status === 204 || response.status === 205 || response.status === 304)
563
+ return false;
564
+ const value = response.headers.get("content-length");
565
+ if (value === null || !/^\d+$/.test(value.trim()))
566
+ return false;
567
+ return Number(value) > MAX_RESPONSE_BODY_BYTES;
568
+ }
569
+ /** Detect a followed redirect from fetch implementations that expose only the final URL. */
570
+ function responseUrlState(responseUrl, requestUrl) {
571
+ if (typeof responseUrl !== "string" || responseUrl === "")
572
+ return "same";
573
+ if (responseUrl === requestUrl)
574
+ return "same";
575
+ try {
576
+ // URL serialization normalizes equivalent spellings such as host case and
577
+ // an explicit default port, so they do not create false redirect reports.
578
+ return new URL(responseUrl).href === new URL(requestUrl).href ? "same" : "changed";
579
+ }
580
+ catch {
581
+ // A non-empty invalid response URL cannot prove that credentials stayed on
582
+ // the requested endpoint. Fail closed without reflecting it in diagnostics.
583
+ return "invalid";
584
+ }
585
+ }
586
+ /**
587
+ * Read a response body while retaining accepted chunks up to the documented
588
+ * cap. A fetch implementation can still hand us one arbitrarily large,
589
+ * already-materialized chunk before we can reject it. For a compliant body,
590
+ * final assembly transiently holds the chunks plus the combined output buffer
591
+ * (roughly twice the cap) before decoding. Content-Length is an early-rejection
592
+ * optimization only; streaming byte accounting remains authoritative because
593
+ * that header may be absent or false.
594
+ */
595
+ async function readBoundedResponseBody(response) {
596
+ // `headers.get` is part of the documented injected-fetch contract. Detect a
597
+ // malformed local shim before it can be mistaken for a retryable body-stream
598
+ // failure and replay an otherwise deterministic GET programming error.
599
+ if (typeof response.headers?.get !== "function") {
600
+ throw new ResponseBodyMaterializationError("Response-like fetch must provide headers.get(name)");
601
+ }
602
+ if (contentLengthExceedsLimit(response)) {
603
+ await cancelResponseBody(response.body);
604
+ throw new ResponseBodyTooLargeError();
605
+ }
606
+ const body = response.body;
607
+ if (body === null)
608
+ return "";
609
+ // Standards-compliant fetch implementations always expose a ReadableStream
610
+ // for a non-empty body. Retain compatibility with Response-like test/custom
611
+ // fetch implementations while still checking their materialized text before
612
+ // this SDK parses it as JSON.
613
+ if (body === undefined || typeof body.getReader !== "function") {
614
+ let materializedText = "";
615
+ if (typeof response.text === "function") {
616
+ try {
617
+ materializedText = await response.text();
618
+ }
619
+ catch {
620
+ // Native fetch exposes a body stream above. A failing text() here is a
621
+ // deterministic custom Response contract error, not a retryable reset.
622
+ throw new ResponseBodyMaterializationError("Response-like fetch text() failed");
623
+ }
624
+ }
625
+ const text = typeof materializedText === "string" ? materializedText : "";
626
+ if (materializedResponseBodyExceedsLimit(text)) {
627
+ throw new ResponseBodyTooLargeError();
628
+ }
629
+ if (text !== "" ||
630
+ response.status === 204 ||
631
+ response.status === 205 ||
632
+ response.status === 304) {
633
+ return text;
634
+ }
635
+ // Older injected Response-like objects may implement json() but return an
636
+ // empty placeholder from text(). This path is never used by native fetch.
637
+ let parsed;
638
+ try {
639
+ parsed = await response.json();
640
+ }
641
+ catch {
642
+ // A Response-like shim may expose json() but reject on an empty 204
643
+ // body. Native fetch represents that body as null and returns above.
644
+ return "";
645
+ }
646
+ let serialized;
647
+ try {
648
+ serialized = JSON.stringify(parsed);
649
+ }
650
+ catch {
651
+ throw new ResponseBodyMaterializationError();
652
+ }
653
+ if (serialized === undefined)
654
+ return "";
655
+ if (materializedResponseBodyExceedsLimit(serialized)) {
656
+ throw new ResponseBodyTooLargeError();
657
+ }
658
+ return serialized;
659
+ }
660
+ const reader = body.getReader();
661
+ const chunks = [];
662
+ let total = 0;
663
+ try {
664
+ while (true) {
665
+ const { done, value } = await reader.read();
666
+ if (done)
667
+ break;
668
+ if (!(value instanceof Uint8Array)) {
669
+ throw new ResponseBodyMaterializationError("Response-like fetch body stream must yield Uint8Array chunks");
670
+ }
671
+ const nextTotal = total + value.byteLength;
672
+ if (nextTotal > MAX_RESPONSE_BODY_BYTES) {
673
+ try {
674
+ await reader.cancel();
675
+ }
676
+ catch {
677
+ // Preserve the body-limit failure even if stream cancellation fails.
678
+ }
679
+ throw new ResponseBodyTooLargeError();
680
+ }
681
+ chunks.push(value);
682
+ total = nextTotal;
683
+ }
684
+ }
685
+ finally {
686
+ reader.releaseLock();
687
+ }
688
+ let bytes;
689
+ if (chunks.length === 1) {
690
+ bytes = chunks[0];
691
+ }
692
+ else {
693
+ bytes = new Uint8Array(total);
694
+ let offset = 0;
695
+ for (const chunk of chunks) {
696
+ bytes.set(chunk, offset);
697
+ offset += chunk.byteLength;
698
+ }
699
+ }
700
+ try {
701
+ return TEXT_DECODER.decode(bytes);
702
+ }
703
+ catch {
704
+ throw new InvalidResponseEncodingError();
705
+ }
706
+ }
359
707
  // ---- Spec-derived validation helpers ------------------------------------
360
708
  // These mirror constraints documented on each request schema in
361
709
  // `openapi.yaml` so obvious mistakes fail fast instead of round-tripping
@@ -391,6 +739,17 @@ function requireMaxLength(value, field, max) {
391
739
  throw clientValidationError(`${field}: must be ${max} characters or fewer (got ${value.length})`);
392
740
  }
393
741
  }
742
+ function requireMaxUtf8Bytes(value, field, max) {
743
+ if (value === undefined)
744
+ return;
745
+ if (typeof value !== "string") {
746
+ throw clientValidationError(`${field}: must be a string (got ${value === null ? "null" : typeof value})`);
747
+ }
748
+ const bytes = TEXT_ENCODER.encode(value).byteLength;
749
+ if (bytes > max) {
750
+ throw clientValidationError(`${field}: must be ${max} UTF-8 bytes or fewer (got ${bytes})`);
751
+ }
752
+ }
394
753
  function requireNonEmptyIfPresent(value, field) {
395
754
  // Reject `""` so uninitialized form state doesn't reach the wire.
396
755
  if (value === "") {
@@ -688,7 +1047,7 @@ function validateQurlTokenOptions(input) {
688
1047
  // gate server-authoritative (`invalid_target_path`); a client-side regex
689
1048
  // would create a drift surface. Keep max-before-empty consistent with label;
690
1049
  // empty strings pass the length check and are rejected by the next guard.
691
- requireMaxLength(input.target_path, "target_path", MAX_TARGET_PATH);
1050
+ requireMaxUtf8Bytes(input.target_path, "target_path", MAX_TARGET_PATH);
692
1051
  requireNonEmptyIfPresent(input.target_path, "target_path");
693
1052
  requireBooleanIfPresent(input.one_time_use, "one_time_use");
694
1053
  requireMaxSessionsInRange(input.max_sessions);
@@ -925,10 +1284,14 @@ const MIN_PORTAL_VALID_FOR_SECONDS = 60;
925
1284
  * treated as the server default).
926
1285
  */
927
1286
  const MIN_SESSION_DURATION_SECONDS = 1;
928
- // Shape of qurl-go's offline signed-fragment links: three or more
929
- // dot-separated base64url parts (<version>.<claims>.<secret>.<sig>).
930
- // Detected only to give those links a precise error in extractAccessToken.
931
- const SIGNED_FRAGMENT_RE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$/;
1287
+ /**
1288
+ * Shape of qurl-go's offline signed-fragment links: three or more
1289
+ * dot-separated base64url parts (<version>.<claims>.<secret>.<sig>).
1290
+ * Detected only to give those links a precise error in extractAccessToken.
1291
+ *
1292
+ * @internal Exported for in-package tests; not re-exported from index.ts.
1293
+ */
1294
+ export const SIGNED_FRAGMENT_RE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$/;
932
1295
  // Access-token grammar accepted by enterPortal (base64url charset).
933
1296
  const ACCESS_TOKEN_RE = /^[A-Za-z0-9_-]+$/;
934
1297
  /**
@@ -1070,7 +1433,7 @@ function buildCreatePortalBody(opts, method) {
1070
1433
  requireObjectInput(opts, method);
1071
1434
  requireNoUnknownFields(opts, CREATE_PORTAL_OPTION_KEYS, method);
1072
1435
  // null → omitted, via the SDK-wide normalizePatchFields convention.
1073
- const { validFor, label, oneTimeUse, maxSessions, sessionDuration } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
1436
+ const { validFor, label, oneTimeUse, maxSessions, sessionDuration, targetPath } = normalizePatchFields(opts, CREATE_PORTAL_OPTION_KEYS);
1074
1437
  if (label !== undefined) {
1075
1438
  requireMaxLength(label, "label", MAX_LABEL);
1076
1439
  // qurl-go's WithLabel rejects blank labels; the REST-shaped mintLink
@@ -1081,6 +1444,11 @@ function buildCreatePortalBody(opts, method) {
1081
1444
  }
1082
1445
  requireBooleanIfPresent(oneTimeUse, "oneTimeUse");
1083
1446
  requireMaxSessionsInRange(maxSessions, "maxSessions");
1447
+ requireMaxUtf8Bytes(targetPath, "targetPath", MAX_TARGET_PATH);
1448
+ requireNonEmptyIfPresent(targetPath, "targetPath");
1449
+ if (targetPath !== undefined && method === "createPortalForUrl") {
1450
+ throw clientValidationError("targetPath: requires an existing resource");
1451
+ }
1084
1452
  const body = {};
1085
1453
  const expiresIn = serializeApiDuration(validFor, "validFor", MIN_PORTAL_VALID_FOR_SECONDS);
1086
1454
  if (expiresIn !== undefined)
@@ -1095,6 +1463,8 @@ function buildCreatePortalBody(opts, method) {
1095
1463
  const wireSessionDuration = serializeApiDuration(sessionDuration, "sessionDuration", MIN_SESSION_DURATION_SECONDS);
1096
1464
  if (wireSessionDuration !== undefined)
1097
1465
  body.session_duration = wireSessionDuration;
1466
+ if (targetPath !== undefined)
1467
+ body.target_path = targetPath;
1098
1468
  return Object.keys(body).length > 0 ? body : undefined;
1099
1469
  }
1100
1470
  /** qURL API client. */
@@ -1117,7 +1487,7 @@ export class QURLClient {
1117
1487
  throw clientValidationError("apiKey: must not contain CR/LF characters");
1118
1488
  }
1119
1489
  this.apiKey = options.apiKey;
1120
- const rawBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
1490
+ const rawBaseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL);
1121
1491
  // Parse the URL so the loopback exemption matches on the hostname,
1122
1492
  // not a string prefix. `startsWith("http://localhost")` would
1123
1493
  // accept `http://localhost.attacker.com` and silently send the
@@ -1272,7 +1642,7 @@ export class QURLClient {
1272
1642
  // .requestId property AND the message string so a stack trace
1273
1643
  // pasted into a support ticket carries the correlation handle
1274
1644
  // without a follow-up round-trip.
1275
- const requestId = envelope.meta?.request_id;
1645
+ const requestId = boundedErrorIdentifier(envelope.meta?.request_id);
1276
1646
  const requestIdSuffix = requestId !== undefined ? ` [request_id=${requestId}]` : "";
1277
1647
  // `Number.isInteger` rejects NaN, Infinity, and floats. Combined
1278
1648
  // with `>= 0`, this rules out pathological proxy bodies (e.g.
@@ -1285,12 +1655,12 @@ export class QURLClient {
1285
1655
  !Number.isInteger(result.failed) ||
1286
1656
  result.failed < 0 ||
1287
1657
  !Array.isArray(result.results)) {
1288
- throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`, requestId);
1658
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}${requestIdSuffix}`, { requestId });
1289
1659
  }
1290
1660
  if (result.succeeded + result.failed !== result.results.length) {
1291
1661
  throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
1292
1662
  `counts/results length mismatch (succeeded=${result.succeeded}, ` +
1293
- `failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`, requestId);
1663
+ `failed=${result.failed}, results.length=${result.results.length})${requestIdSuffix}`, { requestId });
1294
1664
  }
1295
1665
  // OpenAPI contract: one result per input item. Without this check, a
1296
1666
  // server that drops items (e.g. sends 2 results for 3 inputs with
@@ -1301,7 +1671,7 @@ export class QURLClient {
1301
1671
  // exactly [0, requestItemCount).
1302
1672
  if (result.results.length !== requestItemCount) {
1303
1673
  throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: ` +
1304
- `results.length (${result.results.length}) does not match request item count (${requestItemCount})${requestIdSuffix}`, requestId);
1674
+ `results.length (${result.results.length}) does not match request item count (${requestItemCount})${requestIdSuffix}`, { requestId });
1305
1675
  }
1306
1676
  // Per-entry discriminated-union contract. Reason strings report
1307
1677
  // field NAMES only, never values — safe for observability pipelines.
@@ -1313,14 +1683,14 @@ export class QURLClient {
1313
1683
  for (let i = 0; i < result.results.length; i++) {
1314
1684
  const reason = batchItemResultValidationReason(result.results[i]);
1315
1685
  if (reason !== null) {
1316
- throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`, requestId);
1686
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] ${reason}${requestIdSuffix}`, { requestId });
1317
1687
  }
1318
1688
  const entry = result.results[i];
1319
1689
  if (entry.index >= requestItemCount) {
1320
- throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] index out of range (sent ${requestItemCount} items)${requestIdSuffix}`, requestId);
1690
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] index out of range (sent ${requestItemCount} items)${requestIdSuffix}`, { requestId });
1321
1691
  }
1322
1692
  if (seenIndices.has(entry.index)) {
1323
- throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] duplicate index${requestIdSuffix}`, requestId);
1693
+ throw unexpectedResponseError(`Unexpected response shape from POST /v1/qurls/batch${statusSuffix}: results[${i}] duplicate index${requestIdSuffix}`, { requestId });
1324
1694
  }
1325
1695
  seenIndices.add(entry.index);
1326
1696
  }
@@ -1346,11 +1716,11 @@ export class QURLClient {
1346
1716
  break;
1347
1717
  }
1348
1718
  if (seenCursors.has(cursor)) {
1349
- throw unexpectedResponseError(`${methodName}: server returned repeated cursor after ${pageCount} auto-pagination pages`, page.request_id);
1719
+ throw unexpectedResponseError(`${methodName}: server returned repeated cursor after ${pageCount} auto-pagination pages`, { requestId: page.request_id });
1350
1720
  }
1351
1721
  seenCursors.add(cursor);
1352
1722
  if (pageCount >= MAX_AUTO_PAGINATION_PAGES) {
1353
- throw unexpectedResponseError(`${methodName}: exceeded ${MAX_AUTO_PAGINATION_PAGES} auto-pagination pages without termination`, page.request_id);
1723
+ throw unexpectedResponseError(`${methodName}: exceeded ${MAX_AUTO_PAGINATION_PAGES} auto-pagination pages without termination`, { requestId: page.request_id });
1354
1724
  }
1355
1725
  } while (cursor);
1356
1726
  }
@@ -1476,7 +1846,8 @@ export class QURLClient {
1476
1846
  * number of milliseconds with qurl-go's client-side guardrails: whole
1477
1847
  * seconds only, at least one minute for `validFor` and one second for
1478
1848
  * `sessionDuration`. `maxSessions: 0` is sent explicitly and means
1479
- * unlimited; blank labels are rejected.
1849
+ * unlimited; blank labels are rejected. `targetPath` is valid only for an
1850
+ * existing resource. The API owns its full grammar and tunnel-only gate.
1480
1851
  */
1481
1852
  async createPortal(resource, opts = {}, requestOptions) {
1482
1853
  let resourceId;
@@ -1506,7 +1877,9 @@ export class QURLClient {
1506
1877
  * {@link protectUrl} when you need the full server-populated resource
1507
1878
  * metadata. Portal options match {@link createPortal}; `targetUrl` gets
1508
1879
  * the same malformed-URL and embedded-credentials rejection as
1509
- * {@link protectUrl}. REST-shaped equivalent: {@link create}.
1880
+ * {@link protectUrl}. `targetPath` is rejected because this call creates a
1881
+ * URL resource instead of addressing an existing resource. REST-shaped
1882
+ * equivalent: {@link create}.
1510
1883
  */
1511
1884
  async createPortalForUrl(targetUrl, opts = {}, requestOptions) {
1512
1885
  requirePortalTargetUrl(targetUrl);
@@ -1797,7 +2170,7 @@ export class QURLClient {
1797
2170
  `got an ID starting with "${observedPrefix}". ` +
1798
2171
  "To revoke a single access token, use the resource-scoped token endpoint.");
1799
2172
  }
1800
- await this.rawRequest("DELETE", `/v1/qurls/${encodeURIComponent(id)}`);
2173
+ await this.requestNoContent(`/v1/qurls/${encodeURIComponent(id)}`);
1801
2174
  }
1802
2175
  /**
1803
2176
  * Extend a qURL's expiration.
@@ -1949,15 +2322,6 @@ export class QURLClient {
1949
2322
  async getQuota() {
1950
2323
  return this.request("GET", "/v1/quota");
1951
2324
  }
1952
- /** Bootstrap a LayerV qURL Connector agent. */
1953
- async bootstrapAgent(input, options) {
1954
- requireObjectInput(input, "bootstrapAgent");
1955
- requireNoUnknownFields(input, AGENT_BOOTSTRAP_FIELD_KEYS, "bootstrapAgent");
1956
- const normalizedRecord = normalizePatchFields(input, AGENT_BOOTSTRAP_FIELD_KEYS);
1957
- const normalized = normalizedRecord;
1958
- requireNonEmptyStringField(normalizedRecord, "public_key", "bootstrapAgent");
1959
- return this.request("POST", "/v1/agent/bootstrap", normalized, options);
1960
- }
1961
2325
  /** List resources from the `/v1/resources` API. */
1962
2326
  async listResources(input = {}) {
1963
2327
  const { data, meta } = await this.rawRequest("GET", appendQuery("/v1/resources", input, RESOURCE_LIST_PARAM_KEYS, "listResources"));
@@ -2011,7 +2375,7 @@ export class QURLClient {
2011
2375
  /** Revoke a resource and all of its qURLs. */
2012
2376
  async deleteResource(id) {
2013
2377
  requireNonEmptyId(id, "deleteResource");
2014
- await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}`);
2378
+ await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
2015
2379
  }
2016
2380
  /**
2017
2381
  * Mint a qURL against an existing resource.
@@ -2034,7 +2398,7 @@ export class QURLClient {
2034
2398
  async revokeResourceQurl(id, qurlId) {
2035
2399
  requireNonEmptyId(id, "revokeResourceQurl");
2036
2400
  requireNonEmptyId(qurlId, "revokeResourceQurl");
2037
- await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
2401
+ await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`);
2038
2402
  }
2039
2403
  /** Update a specific qURL token on a resource. */
2040
2404
  async updateResourceQurl(id, qurlId, input, options) {
@@ -2070,13 +2434,7 @@ export class QURLClient {
2070
2434
  page_size: meta?.page_size,
2071
2435
  };
2072
2436
  }
2073
- /**
2074
- * Terminate all active sessions for a resource.
2075
- *
2076
- * The returned count is best-effort under retries: if the first DELETE
2077
- * succeeds server-side but the response is lost, a retried request may
2078
- * return `0` because there are no sessions left to terminate.
2079
- */
2437
+ /** Terminate all active sessions for a resource and return the server count. */
2080
2438
  async terminateAllResourceSessions(id) {
2081
2439
  requireNonEmptyId(id, "terminateAllResourceSessions");
2082
2440
  const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
@@ -2095,7 +2453,7 @@ export class QURLClient {
2095
2453
  async terminateResourceSession(id, sessionId) {
2096
2454
  requireNonEmptyId(id, "terminateResourceSession");
2097
2455
  requireNonEmptyId(sessionId, "terminateResourceSession");
2098
- await this.rawRequest("DELETE", `/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
2456
+ await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}`);
2099
2457
  }
2100
2458
  /** List connector installations. */
2101
2459
  async listConnectorInstallations(input = {}) {
@@ -2203,7 +2561,7 @@ export class QURLClient {
2203
2561
  /** Remove a custom domain. */
2204
2562
  async deleteDomain(domain) {
2205
2563
  requireNonEmptyId(domain, "deleteDomain", "domain");
2206
- await this.rawRequest("DELETE", `/v1/domains/${encodeURIComponent(domain)}`);
2564
+ await this.requestNoContent(`/v1/domains/${encodeURIComponent(domain)}`);
2207
2565
  }
2208
2566
  /** Trigger DNS verification for a custom domain. */
2209
2567
  async verifyDomain(domain, options) {
@@ -2264,7 +2622,7 @@ export class QURLClient {
2264
2622
  /** Delete a webhook. */
2265
2623
  async deleteWebhook(id) {
2266
2624
  requireNonEmptyId(id, "deleteWebhook");
2267
- await this.rawRequest("DELETE", `/v1/webhooks/${encodeURIComponent(id)}`);
2625
+ await this.requestNoContent(`/v1/webhooks/${encodeURIComponent(id)}`);
2268
2626
  }
2269
2627
  /** Regenerate a webhook signing secret. */
2270
2628
  async regenerateWebhookSecret(id, options) {
@@ -2328,7 +2686,7 @@ export class QURLClient {
2328
2686
  /** Revoke an API key. */
2329
2687
  async revokeApiKey(keyId) {
2330
2688
  requireNonEmptyId(keyId, "revokeApiKey");
2331
- await this.rawRequest("DELETE", `/v1/api-keys/${encodeURIComponent(keyId)}`);
2689
+ await this.requestNoContent(`/v1/api-keys/${encodeURIComponent(keyId)}`);
2332
2690
  }
2333
2691
  /** Redeem an access code. */
2334
2692
  async redeemAccessCode(input, options) {
@@ -2373,7 +2731,7 @@ export class QURLClient {
2373
2731
  /** Revoke an access code. */
2374
2732
  async revokeAccessCode(id) {
2375
2733
  requireNonEmptyId(id, "revokeAccessCode");
2376
- await this.rawRequest("DELETE", `/v1/access-codes/${encodeURIComponent(id)}`);
2734
+ await this.requestNoContent(`/v1/access-codes/${encodeURIComponent(id)}`);
2377
2735
  }
2378
2736
  // --- Internal HTTP plumbing ---
2379
2737
  async request(method, path, body, requestOptions) {
@@ -2387,6 +2745,22 @@ export class QURLClient {
2387
2745
  }
2388
2746
  return data;
2389
2747
  }
2748
+ /**
2749
+ * Require the service's exact 204 response with no response bytes exposed by
2750
+ * Fetch. A standards-compliant Fetch implementation discards any forbidden
2751
+ * wire body on 204, so the SDK cannot detect bytes that Fetch did not expose.
2752
+ */
2753
+ async requestNoContent(path) {
2754
+ const response = await this.rawRequest("DELETE", path, undefined, {
2755
+ allowEmptySuccessBody: true,
2756
+ });
2757
+ if (response.__http_status !== 204 || response.__http_body_empty !== true) {
2758
+ const status = response.__http_status ?? 0;
2759
+ const bodyShape = response.__http_body_empty ? "with an empty body" : "with response bytes";
2760
+ throw unexpectedResponseError(`Unexpected response from DELETE; expected empty HTTP 204, received HTTP ${status} ${bodyShape}. ` +
2761
+ "The delete may already have been applied; reconcile resource state before retrying.", { status });
2762
+ }
2763
+ }
2390
2764
  /**
2391
2765
  * Issue an HTTP request and parse the JSON response.
2392
2766
  *
@@ -2398,7 +2772,7 @@ export class QURLClient {
2398
2772
  */
2399
2773
  async rawRequest(method, path, body, rawOptions = {}) {
2400
2774
  const url = `${this.baseUrl}${path}`;
2401
- const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions } = rawOptions;
2775
+ const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, } = rawOptions;
2402
2776
  const idempotencyKey = idempotencyKeyForRequest(method, requestOptions);
2403
2777
  const headers = {
2404
2778
  Authorization: `Bearer ${this.apiKey}`,
@@ -2411,12 +2785,20 @@ export class QURLClient {
2411
2785
  if (idempotencyKey !== undefined) {
2412
2786
  headers["Idempotency-Key"] = idempotencyKey;
2413
2787
  }
2414
- // POST/PATCH keep status-code retries limited to rate limits. They
2415
- // still retry fetch-level failures below with the same Idempotency-Key,
2416
- // which fixes the duplicate-creation path from lost responses
2417
- // without broadening mutating 5xx replay behavior.
2418
- const mutatingForRetry = MUTATING_RETRY_METHODS.has(method);
2419
- const retryable = mutatingForRetry ? RETRYABLE_STATUS_MUTATING : RETRYABLE_STATUS;
2788
+ // Reads may retry transient statuses and transport failures. POST/PATCH
2789
+ // keep status-code retries limited to rate limits and reuse the same
2790
+ // Idempotency-Key for fetch-level failures. DELETE is deliberately never
2791
+ // replayed, including after 429: this matches qurl-go's no-hidden-HTTP-
2792
+ // retry rule and avoids SDK pacing on an application request path. The
2793
+ // caller receives retryAfter when supplied and must reconcile resource
2794
+ // state before it chooses to issue another delete.
2795
+ const idempotencyKeyBackedRetry = MUTATING_RETRY_METHODS.has(method);
2796
+ const retryFetchFailure = method === "GET" || idempotencyKeyBackedRetry;
2797
+ const retryable = method === "GET"
2798
+ ? RETRYABLE_STATUS
2799
+ : idempotencyKeyBackedRetry
2800
+ ? RETRYABLE_STATUS_MUTATING
2801
+ : NO_RETRYABLE_STATUSES;
2420
2802
  const serializedBody = body !== undefined ? JSON.stringify(body) : undefined;
2421
2803
  let lastError;
2422
2804
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
@@ -2427,6 +2809,7 @@ export class QURLClient {
2427
2809
  }
2428
2810
  this.log(`${method} ${url}`);
2429
2811
  let response;
2812
+ const requestSignal = AbortSignal.timeout(this.timeout);
2430
2813
  try {
2431
2814
  // `timeout` is intentionally a per-attempt budget, not a total-
2432
2815
  // request budget. With maxRetries=3 and timeout=30s, a slow
@@ -2440,20 +2823,151 @@ export class QURLClient {
2440
2823
  method,
2441
2824
  headers,
2442
2825
  body: serializedBody,
2443
- signal: AbortSignal.timeout(this.timeout),
2826
+ redirect: "manual",
2827
+ signal: requestSignal,
2444
2828
  });
2445
2829
  }
2446
2830
  catch (err) {
2447
- lastError = this.classifyFetchError(err);
2831
+ lastError = this.classifyFetchError(err, requestSignal);
2448
2832
  this.log(`${method} ${url} ${lastError instanceof TimeoutError ? "timed out" : "network error"}`, {
2449
- error: lastError.message,
2833
+ error: boundedErrorSnippet(lastError.message),
2450
2834
  });
2451
- if (attempt < this.maxRetries) {
2835
+ // An injected fetch can abort or impose a deadline for its own reason.
2836
+ // That is not this SDK's timeout. Replaying it can defeat caller-side
2837
+ // cancellation or deadline policy.
2838
+ if (retryFetchFailure &&
2839
+ !isIndependentAbort(err, requestSignal) &&
2840
+ attempt < this.maxRetries) {
2452
2841
  continue;
2453
2842
  }
2454
2843
  throw lastError;
2455
2844
  }
2456
2845
  this.log(`${method} ${url} → ${response.status}`);
2846
+ // Redirects are deterministic protocol violations for SDK API calls.
2847
+ // Refuse them before reading Location or entering retry handling so
2848
+ // Authorization and Idempotency-Key can never reach a follow-up target.
2849
+ const responseUrlStateValue = responseUrlState(response.url, url);
2850
+ if (response.redirected === true ||
2851
+ responseUrlStateValue !== "same" ||
2852
+ REDIRECT_RESPONSE_STATUSES.has(response.status) ||
2853
+ response.type === "opaqueredirect") {
2854
+ await cancelResponseBody(response.body);
2855
+ const redirectKind = response.redirected === true
2856
+ ? "followed"
2857
+ : responseUrlStateValue === "invalid"
2858
+ ? "invalid custom Response.url"
2859
+ : responseUrlStateValue === "changed"
2860
+ ? "changed Response.url"
2861
+ : REDIRECT_RESPONSE_STATUSES.has(response.status)
2862
+ ? `HTTP ${response.status}`
2863
+ : "opaque";
2864
+ throw unexpectedResponseError(`Refused ${redirectKind} redirect response for ${method}`, {
2865
+ status: response.status,
2866
+ });
2867
+ }
2868
+ let responseBody;
2869
+ try {
2870
+ responseBody = await readBoundedResponseBody(response);
2871
+ }
2872
+ catch (err) {
2873
+ if (err instanceof ResponseBodyTooLargeError) {
2874
+ this.log(`rejected oversized response body from ${response.status}`, {
2875
+ status: response.status,
2876
+ });
2877
+ const detail = `Response body exceeds ${MAX_RESPONSE_BODY_BYTES}-byte limit on HTTP ${response.status}; ` +
2878
+ "request a smaller page when listing collections";
2879
+ if (response.ok) {
2880
+ throw unexpectedResponseError(detail, { status: response.status });
2881
+ }
2882
+ const bodyError = createError({
2883
+ status: response.status,
2884
+ // The unread body cannot supply a trustworthy server code. Keep
2885
+ // the real HTTP status class (429/5xx) just like other unreadable
2886
+ // error bodies instead of reclassifying it as client validation.
2887
+ code: ERROR_CODE_UNKNOWN,
2888
+ title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
2889
+ detail,
2890
+ retry_after: this.parseRetryAfter(response),
2891
+ });
2892
+ // The unread body does not change operation semantics: retry a GET,
2893
+ // or an idempotency-key-backed mutation on an explicitly retryable
2894
+ // status. Successful oversized responses remain deterministic
2895
+ // contract failures, and DELETE has no retryable statuses.
2896
+ if (retryable.has(response.status) && attempt < this.maxRetries) {
2897
+ lastError = bodyError;
2898
+ continue;
2899
+ }
2900
+ throw bodyError;
2901
+ }
2902
+ if (err instanceof InvalidResponseEncodingError) {
2903
+ this.log(`rejected malformed UTF-8 response body from ${response.status}`, {
2904
+ status: response.status,
2905
+ });
2906
+ const detail = `Response body is not valid UTF-8 on HTTP ${response.status}`;
2907
+ if (response.ok) {
2908
+ // A successful status with an invalid wire body is a deterministic
2909
+ // response-contract failure. Do not replay a GET or a mutation.
2910
+ throw unexpectedResponseError(detail, { status: response.status });
2911
+ }
2912
+ const encodingError = createError({
2913
+ status: response.status,
2914
+ code: ERROR_CODE_UNKNOWN,
2915
+ title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
2916
+ detail,
2917
+ retry_after: this.parseRetryAfter(response),
2918
+ });
2919
+ // For an explicit error status, the HTTP classification remains
2920
+ // authoritative even when its body encoding is invalid.
2921
+ if (retryable.has(response.status) && attempt < this.maxRetries) {
2922
+ lastError = encodingError;
2923
+ continue;
2924
+ }
2925
+ throw encodingError;
2926
+ }
2927
+ await cancelResponseBody(response.body);
2928
+ const transportReadError = this.classifyResponseReadError(err, response, requestSignal);
2929
+ if (transportReadError !== undefined) {
2930
+ this.log(`transport failure while reading response body from ${response.status}`, {
2931
+ status: response.status,
2932
+ error: transportReadError.code,
2933
+ });
2934
+ // A successful GET can be replayed when its success body is lost. A
2935
+ // non-success GET retries only the status set that the normal error
2936
+ // path retries. Mutations are never replayed after a body transport
2937
+ // failure because the operation may have applied.
2938
+ if (method === "GET" &&
2939
+ !isIndependentAbort(err, requestSignal) &&
2940
+ (response.ok || retryable.has(response.status)) &&
2941
+ attempt < this.maxRetries) {
2942
+ lastError = transportReadError;
2943
+ continue;
2944
+ }
2945
+ throw transportReadError;
2946
+ }
2947
+ this.log(`failed to read response body from ${response.status}`, {
2948
+ status: response.status,
2949
+ });
2950
+ // ResponseBodyMaterializationError messages are fixed SDK-authored
2951
+ // contract text. The underlying shim rejection is never interpolated.
2952
+ const detail = err instanceof ResponseBodyMaterializationError
2953
+ ? err.message
2954
+ : `Failed to read response body on HTTP ${response.status}`;
2955
+ const readError = response.ok
2956
+ ? unexpectedResponseError(detail, { status: response.status })
2957
+ : createError({
2958
+ status: response.status,
2959
+ code: ERROR_CODE_UNKNOWN,
2960
+ title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
2961
+ detail,
2962
+ retry_after: this.parseRetryAfter(response),
2963
+ });
2964
+ // This arm contains deterministic Response-like shim failures (for
2965
+ // example a BigInt returned by json()) and non-Error throws. Do not
2966
+ // replay them: another attempt will see the same local contract bug.
2967
+ throw err instanceof ResponseBodyMaterializationError
2968
+ ? withErrorCause(readError, err)
2969
+ : readError;
2970
+ }
2457
2971
  // `response.ok` is true for the entire 200-299 range, so partial-
2458
2972
  // success responses like 207 flow through this path naturally —
2459
2973
  // `passthroughStatuses` only needs to enumerate non-2xx statuses
@@ -2476,33 +2990,56 @@ export class QURLClient {
2476
2990
  url,
2477
2991
  });
2478
2992
  }
2479
- return { data: undefined, __http_status: response.status };
2993
+ return {
2994
+ data: undefined,
2995
+ __http_status: response.status,
2996
+ __http_body_empty: responseBody.length === 0,
2997
+ };
2998
+ }
2999
+ // Exact no-content helpers must inspect both axes of the success
3000
+ // contract. Let only those callers observe an empty non-204 response;
3001
+ // body-returning methods still reject it as non-JSON below.
3002
+ if (response.ok && allowEmptySuccessBody && responseBody.length === 0) {
3003
+ return {
3004
+ data: undefined,
3005
+ __http_status: response.status,
3006
+ __http_body_empty: true,
3007
+ };
2480
3008
  }
2481
3009
  try {
2482
- const json = (await response.json());
2483
- return { ...json, __http_status: response.status };
3010
+ const json = JSON.parse(responseBody);
3011
+ return {
3012
+ ...json,
3013
+ __http_status: response.status,
3014
+ // JSON.parse("") throws, so parsed JSON is necessarily non-empty.
3015
+ __http_body_empty: false,
3016
+ };
2484
3017
  }
2485
3018
  catch {
2486
3019
  // Non-JSON body on a 2xx response (server contract violation)
2487
3020
  // or on a passthrough status (e.g. proxy HTML on 400). The
2488
- // body stream is already consumed by the failed `.json()`,
2489
- // so we can't delegate to parseError — synthesize a typed
2490
- // QURLError directly so consumers catching by-class don't
2491
- // miss it.
3021
+ // body was read through the cap before JSON decoding.
3022
+ // Synthesize a typed QURLError directly so consumers catching
3023
+ // by-class don't miss the contract failure.
2492
3024
  this.log(`non-JSON body on ${isPassthrough ? "passthrough" : "success"} response ${response.status}`, {
2493
3025
  status: response.status,
2494
- content_type: response.headers.get("content-type") ?? undefined,
3026
+ content_type: boundedErrorSnippet(response.headers.get("content-type")),
2495
3027
  });
2496
3028
  throw createError({
2497
3029
  status: response.status,
2498
3030
  code: ERROR_CODE_UNEXPECTED_RESPONSE,
2499
- title: response.statusText || `HTTP ${response.status}`,
3031
+ title: boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`,
2500
3032
  detail: `Expected JSON response body on HTTP ${response.status} but received non-JSON content`,
2501
3033
  });
2502
3034
  }
2503
3035
  }
2504
- const errorData = await this.parseError(response);
3036
+ const errorData = this.parseError(response, responseBody);
2505
3037
  const err = createError(errorData);
3038
+ // Retryability is determined by status even when an intermediary
3039
+ // returned HTML, an empty body, or another non-envelope error. Those
3040
+ // shapes are common for transient 429/502/503/504 responses and do not
3041
+ // prove that a retry cannot succeed. Redirects and oversized bodies
3042
+ // already throw before reaching this check.
2506
3043
  if (retryable.has(response.status) && attempt < this.maxRetries) {
2507
3044
  lastError = err;
2508
3045
  continue;
@@ -2511,9 +3048,9 @@ export class QURLClient {
2511
3048
  }
2512
3049
  throw lastError ?? new Error("Request failed after retries");
2513
3050
  }
2514
- async parseError(response) {
3051
+ parseError(response, responseBody) {
2515
3052
  try {
2516
- const json = (await response.json());
3053
+ const json = JSON.parse(responseBody);
2517
3054
  if (json.error) {
2518
3055
  const err = json.error;
2519
3056
  // Detail fallback chain:
@@ -2522,18 +3059,27 @@ export class QURLClient {
2522
3059
  // 3. err.title (RFC 7807 required field)
2523
3060
  // 4. HTTP status (final safety net)
2524
3061
  // This prevents `"Title (403): undefined"` when the API omits detail.
2525
- const detail = err.detail ?? err.message ?? err.title ?? `HTTP ${response.status}`;
3062
+ const detail = boundedErrorSnippet(err.detail) ??
3063
+ boundedErrorSnippet(err.message) ??
3064
+ boundedErrorSnippet(err.title) ??
3065
+ `HTTP ${response.status}`;
2526
3066
  // HTTP/2 omits reason-phrases — `statusText` may be "".
2527
- const title = err.title ?? (response.statusText || `HTTP ${response.status}`);
3067
+ const title = boundedErrorSnippet(err.title) ??
3068
+ boundedErrorSnippet(response.statusText) ??
3069
+ `HTTP ${response.status}`;
3070
+ // The transport status is authoritative for error classification. A
3071
+ // conflicting or malformed RFC 7807 body must not turn a real 5xx into
3072
+ // a NotFoundError (or vice versa) in caller reconciliation logic.
3073
+ const status = response.status;
2528
3074
  return {
2529
- status: err.status ?? response.status,
2530
- code: err.code ?? ERROR_CODE_UNKNOWN,
3075
+ status,
3076
+ code: boundedErrorIdentifier(err.code) ?? ERROR_CODE_UNKNOWN,
2531
3077
  title,
2532
3078
  detail,
2533
- type: err.type,
2534
- instance: err.instance,
2535
- invalid_fields: err.invalid_fields,
2536
- request_id: json.meta?.request_id,
3079
+ type: boundedErrorIdentifier(err.type),
3080
+ instance: boundedErrorIdentifier(err.instance),
3081
+ invalid_fields: boundedInvalidFields(err.invalid_fields),
3082
+ request_id: boundedErrorIdentifier(json.meta?.request_id),
2537
3083
  retry_after: this.parseRetryAfter(response),
2538
3084
  };
2539
3085
  }
@@ -2543,31 +3089,37 @@ export class QURLClient {
2543
3089
  // status-only safety net below.
2544
3090
  this.log(`unexpected error response shape from ${response.status}`, {
2545
3091
  status: response.status,
2546
- body_keys: Object.keys(json),
3092
+ body_keys: typeof json === "object" && json !== null ? boundedObjectKeys(json) : undefined,
2547
3093
  });
2548
3094
  }
2549
3095
  catch {
2550
- // Body wasn't valid JSON (or the network stream errored during
2551
- // read). Log so operators can distinguish this from a malformed
2552
- // envelope, and fall through to the status-only safety net.
3096
+ // Body wasn't valid JSON. Log so operators can distinguish this from a
3097
+ // malformed envelope, and fall through to the status-only safety net.
2553
3098
  this.log(`non-JSON error response from ${response.status}`, {
2554
3099
  status: response.status,
2555
- content_type: response.headers.get("content-type") ?? undefined,
3100
+ content_type: boundedErrorSnippet(response.headers.get("content-type")),
2556
3101
  });
2557
3102
  }
3103
+ const statusText = boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`;
2558
3104
  return {
2559
3105
  status: response.status,
2560
3106
  code: ERROR_CODE_UNKNOWN,
2561
- title: response.statusText || `HTTP ${response.status}`,
2562
- detail: response.statusText || `HTTP ${response.status}`,
3107
+ title: statusText,
3108
+ detail: statusText,
3109
+ retry_after: this.parseRetryAfter(response),
2563
3110
  };
2564
3111
  }
2565
3112
  parseRetryAfter(response) {
2566
- // RFC 7231 §7.1.3: Retry-After honored on 429 + 503. (RFC 7231
2567
- // also allows Retry-After on 3xx redirects; the SDK doesn't follow
2568
- // redirects, so 429/503 cover the relevant cases.)
3113
+ // RFC 7231 §7.1.3: Retry-After honored on 429 + 503. Although the RFC
3114
+ // also permits it on 3xx responses, rawRequest now refuses redirects
3115
+ // as deterministic errors before retry parsing, so only 429/503 apply.
2569
3116
  if (response.status !== 429 && response.status !== 503)
2570
3117
  return undefined;
3118
+ // This fallback can run after the body reader rejects a malformed injected
3119
+ // Response-like shim. Preserve the typed, non-retryable SDK error instead
3120
+ // of dereferencing the same missing method and leaking a raw TypeError.
3121
+ if (typeof response.headers?.get !== "function")
3122
+ return undefined;
2571
3123
  const header = response.headers.get("Retry-After");
2572
3124
  if (!header)
2573
3125
  return undefined;
@@ -2629,13 +3181,36 @@ export class QURLClient {
2629
3181
  const jitter = Math.random() * base * 0.5;
2630
3182
  return Math.min(base + jitter, RETRY_MAX_DELAY_MS);
2631
3183
  }
2632
- classifyFetchError(err) {
2633
- if (err instanceof DOMException && err.name === "TimeoutError") {
3184
+ classifyFetchError(err, requestSignal) {
3185
+ // AbortSignal.timeout is the only timeout source owned by this SDK. Keep
3186
+ // this check tied to that signal instead of trusting an injected fetch's
3187
+ // AbortError or TimeoutError name.
3188
+ if (isSdkTimeout(err, requestSignal)) {
2634
3189
  return new TimeoutError("Request timed out", { cause: err });
2635
3190
  }
2636
3191
  const cause = err instanceof Error ? err : undefined;
2637
3192
  return new NetworkError(cause?.message ?? String(err), { cause });
2638
3193
  }
3194
+ classifyResponseReadError(err, response, requestSignal) {
3195
+ if (err instanceof ResponseBodyMaterializationError)
3196
+ return undefined;
3197
+ if (!(err instanceof Error))
3198
+ return undefined;
3199
+ if (!response.ok) {
3200
+ const statusText = boundedErrorSnippet(response.statusText) || `HTTP ${response.status}`;
3201
+ return withErrorCause(createError({
3202
+ status: response.status,
3203
+ code: ERROR_CODE_UNKNOWN,
3204
+ title: statusText,
3205
+ detail: `Failed to read response body after HTTP ${response.status}`,
3206
+ retry_after: this.parseRetryAfter(response),
3207
+ }), err);
3208
+ }
3209
+ if (isSdkTimeout(err, requestSignal)) {
3210
+ return new TimeoutError(`Request timed out while reading response body after HTTP ${response.status}`, { cause: err });
3211
+ }
3212
+ return new NetworkError(`Network failure while reading response body after HTTP ${response.status}`, { cause: err });
3213
+ }
2639
3214
  }
2640
3215
  /**
2641
3216
  * A LayerV-protected resource, bound to the client that produced it.