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