kinetex 1.3.0 → 1.4.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 (120) hide show
  1. package/README.md +246 -9
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +2545 -550
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +133 -19
  6. package/dist/cjs/cache.js +49 -7
  7. package/dist/cjs/circuit-breaker.js +45 -3
  8. package/dist/cjs/client.js +387 -104
  9. package/dist/cjs/cookie-parser.js +103 -5
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +465 -66
  12. package/dist/cjs/dedup.js +49 -11
  13. package/dist/cjs/digest.js +160 -24
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +303 -45
  16. package/dist/cjs/interceptors.js +221 -7
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +168 -15
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +177 -27
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +131 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/ws.js +19 -10
  29. package/dist/esm/aws-sigv4.js +133 -19
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +49 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +45 -3
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +387 -104
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +103 -5
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +465 -66
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +49 -11
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +160 -24
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +164 -24
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +303 -45
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +221 -7
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +89 -40
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +168 -15
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +177 -27
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +413 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +316 -47
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +131 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +222 -48
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +19 -10
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +14 -1
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +103 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts.map +1 -1
  91. package/dist/types/digest.d.ts +17 -37
  92. package/dist/types/digest.d.ts.map +1 -1
  93. package/dist/types/graphql.d.ts.map +1 -1
  94. package/dist/types/headers.d.ts +45 -27
  95. package/dist/types/headers.d.ts.map +1 -1
  96. package/dist/types/interceptors.d.ts +102 -0
  97. package/dist/types/interceptors.d.ts.map +1 -1
  98. package/dist/types/lifecycle.d.ts +19 -2
  99. package/dist/types/lifecycle.d.ts.map +1 -1
  100. package/dist/types/logging.d.ts +22 -3
  101. package/dist/types/logging.d.ts.map +1 -1
  102. package/dist/types/mod.d.ts +5 -3
  103. package/dist/types/mod.d.ts.map +1 -1
  104. package/dist/types/pagination.d.ts +0 -25
  105. package/dist/types/pagination.d.ts.map +1 -1
  106. package/dist/types/progress.d.ts +1 -1
  107. package/dist/types/progress.d.ts.map +1 -1
  108. package/dist/types/proxy.d.ts +50 -0
  109. package/dist/types/proxy.d.ts.map +1 -0
  110. package/dist/types/response.d.ts +7 -1
  111. package/dist/types/response.d.ts.map +1 -1
  112. package/dist/types/socks5.d.ts.map +1 -1
  113. package/dist/types/sse.d.ts.map +1 -1
  114. package/dist/types/types.d.ts +114 -3
  115. package/dist/types/types.d.ts.map +1 -1
  116. package/dist/types/url.d.ts +0 -14
  117. package/dist/types/url.d.ts.map +1 -1
  118. package/dist/types/utils.d.ts.map +1 -1
  119. package/dist/types/ws.d.ts.map +1 -1
  120. package/package.json +1 -1
@@ -179,6 +179,72 @@ const MALICIOUS_QUERY_PATTERNS = [
179
179
  /onerror\s*=/i, // Event handler injection (onerror=, onclick=, etc.)
180
180
  /eval\s*\(/i, // Code execution via eval()
181
181
  ];
182
+ /**
183
+ * Blank out the contents of every GraphQL string literal, preserving the
184
+ * document's length so the surrounding structure is unchanged.
185
+ *
186
+ * Both the injection scan and the brace counter used to read string literals
187
+ * as if they were code, so a query the caller was entitled to send was
188
+ * rejected:
189
+ *
190
+ * - `{ search(term: "javascript:void(0)") }` -> "malicious pattern: /javascript:/"
191
+ * - `{ run(code: "eval(x)") }` -> "malicious pattern: /eval\s*\(/"
192
+ * - `{ page(html: "<div onerror=alert(1)>") }` -> "malicious pattern: /onerror\s*=/"
193
+ * - `{ css(v: "<!-- hi -->") }` -> "malicious pattern: /<!--/"
194
+ * - `{ a(s: "}") b }` -> "GraphQL query has unbalanced braces"
195
+ * - `{ f(x: "{{") }` -> "malicious pattern: /\{\s*\{/"
196
+ *
197
+ * A client refusing to transmit a well-formed query is a hard failure for the
198
+ * caller, and none of these is a client-side hazard: what the *server* renders
199
+ * from a string it was sent is the server's responsibility to escape, which is
200
+ * precisely what a scan of the request text cannot check. The patterns stay
201
+ * exactly as strict for text *outside* string literals, which is where a
202
+ * malformed or hostile document actually shows up.
203
+ *
204
+ * Handles `"..."`, the block forms `"""..."""` and `'''...'''`, and `#`
205
+ * comments, and leaves an unterminated quote alone so the structural checks
206
+ * still see it.
207
+ */
208
+ function maskGraphQLLiterals(query) {
209
+ const out = query.split("");
210
+ let i = 0;
211
+ const n = query.length;
212
+ while (i < n) {
213
+ const ch = query[i];
214
+ // Comment to end of line.
215
+ if (ch === "#") {
216
+ while (i < n && query[i] !== "\n")
217
+ out[i++] = " ";
218
+ continue;
219
+ }
220
+ // Block string: """ or '''.
221
+ if ((ch === '"' || ch === "'") && query[i + 1] === ch && query[i + 2] === ch) {
222
+ const delim = ch.repeat(3);
223
+ const end = query.indexOf(delim, i + 3);
224
+ const stop = end === -1 ? n : end + 3;
225
+ while (i < stop)
226
+ out[i++] = " ";
227
+ continue;
228
+ }
229
+ // Ordinary string.
230
+ if (ch === '"' || ch === "'") {
231
+ const quote = ch;
232
+ out[i] = " ";
233
+ i++;
234
+ while (i < n && query[i] !== quote && query[i] !== "\n") {
235
+ out[i] = " ";
236
+ i++;
237
+ }
238
+ if (i < n && query[i] === quote) {
239
+ out[i] = " ";
240
+ i++;
241
+ }
242
+ continue;
243
+ }
244
+ i++;
245
+ }
246
+ return out.join("");
247
+ }
182
248
  /**
183
249
  * Validate a GraphQL query for safety.
184
250
  * Prevents excessively large queries and detects malicious patterns.
@@ -192,17 +258,20 @@ function validateGraphQLQuery(query) {
192
258
  if (query.length > MAX_QUERY_LENGTH) {
193
259
  throw new ValidationError(`GraphQL query exceeds maximum length of ${MAX_QUERY_LENGTH} bytes`);
194
260
  }
261
+ // Structural checks run on the document with its string literals blanked, so
262
+ // a brace or a keyword inside a value is treated as data, not as syntax.
263
+ const structural = maskGraphQLLiterals(query);
195
264
  // Check for malicious patterns
196
265
  for (const pattern of MALICIOUS_QUERY_PATTERNS) {
197
- if (pattern.test(query)) {
266
+ if (pattern.test(structural)) {
198
267
  throw new ValidationError(`GraphQL query contains potentially malicious pattern: ${pattern}`);
199
268
  }
200
269
  }
201
270
  // Basic depth check by counting open braces
202
271
  let depth = 0;
203
272
  let maxDepth = 0;
204
- for (let i = 0; i < query.length; i++) {
205
- const char = query[i];
273
+ for (let i = 0; i < structural.length; i++) {
274
+ const char = structural[i];
206
275
  if (char === "{") {
207
276
  depth++;
208
277
  maxDepth = Math.max(maxDepth, depth);
@@ -289,12 +358,25 @@ function buildMultipartBody(req, uploads) {
289
358
  const form = new FormData();
290
359
  // Null out file variables in the operations object
291
360
  const operations = JSON.parse(buildJSONBody(req));
292
- // Build map: { "0": ["variables.input.file"], "1": [...] }
361
+ // The multipart spec's `map` paths are rooted at the `operations` object,
362
+ // and its example is `{"0": ["variables.file"]}` — so the path a caller
363
+ // passes has to name `variables` for the nulling below to reach the variable
364
+ // the mutation actually declares. The documented examples here did the
365
+ // opposite (`path: "file"`, `path: "input.file"`), and they produced
366
+ // `{"variables": {"file": {}}, "file": null}`: the file was nulled at the
367
+ // top level of the request object where no variable lives, and the real
368
+ // variable kept its original value. The upload never bound.
369
+ //
370
+ // Both spellings are now accepted, and the map always carries the rooted
371
+ // form so the request stays spec-conformant.
293
372
  const map = {};
294
373
  for (let i = 0; i < uploads.length; i++) {
295
374
  const upload = uploads[i];
296
- map[String(i)] = [upload.path];
297
- setNestedValue(operations, upload.path, null);
375
+ const rooted = upload.path === "variables" || upload.path.startsWith("variables.")
376
+ ? upload.path
377
+ : `variables.${upload.path}`;
378
+ map[String(i)] = [rooted];
379
+ setNestedValue(operations, rooted, null);
298
380
  }
299
381
  form.append("operations", JSON.stringify(operations));
300
382
  form.append("map", JSON.stringify(map));
@@ -315,8 +397,15 @@ function setNestedValue(obj, path, value) {
315
397
  if (key === "__proto__" || key === "constructor" || key === "prototype") {
316
398
  throw new ValidationError(`Invalid upload path: "${path}" — reserved key "${key}"`);
317
399
  }
400
+ // Whether this segment is an array or an object is decided by the segment
401
+ // *after* it: in `files.0` the container `files` holds an index, so it must
402
+ // be created as an array. Deciding by `key` instead produced `{}`, and the
403
+ // server then saw `variables.files` as `{"0": null}` — an object, not the
404
+ // list the mutation declared.
405
+ const nextIsIndex = /^(0|[1-9][0-9]*)$/.test(parts[i + 1]);
406
+ const next = nextIsIndex ? [] : {};
318
407
  if (!current[key] || typeof current[key] !== "object")
319
- current[key] = {};
408
+ current[key] = next;
320
409
  current = current[key];
321
410
  }
322
411
  const leaf = parts[parts.length - 1];
@@ -348,7 +437,9 @@ async function parseGraphQLResponse(response, req) {
348
437
  // §9 CORE EXECUTE FUNCTION
349
438
  // ============================================================================
350
439
  async function executeHTTP(req, config, signal, apqMode = "none", getAPQHashFn) {
351
- const headers = await buildHeaders(config);
440
+ // `req.headers` is last so a link (or a caller using `raw`) can override a
441
+ // default. It used to be ignored entirely.
442
+ const headers = await buildHeaders(config, req.headers ?? {});
352
443
  const opType = detectOperationType(req.query);
353
444
  const useGET = config.useGETForQueries && opType === "query";
354
445
  let fetchUrl = config.url;
@@ -469,8 +560,12 @@ export class GraphQLClient {
469
560
  onError: () => { },
470
561
  ...config,
471
562
  };
472
- // Terminal link — does the actual HTTP fetch
473
- const terminal = (op) => this._executeWithAPQ(op.request, op.signal ?? null);
563
+ // Terminal link — does the actual HTTP fetch.
564
+ // `op.config` is honoured, not just `this.config`: links such as
565
+ // `authLink` replace it to inject headers, and the terminal previously
566
+ // discarded the replacement and read the constructor-time object, so
567
+ // nothing a link put there was ever sent.
568
+ const terminal = (op) => this._executeWithAPQ(op.request, op.signal ?? null, op.config ?? this.config);
474
569
  this.executeLink = buildLinkChain(this.config.links, terminal);
475
570
  }
476
571
  // ── APQ (Automatic Persisted Queries) ───────────────────────────────────
@@ -570,7 +665,7 @@ export class GraphQLClient {
570
665
  ...(variables !== undefined ? { variables } : {}),
571
666
  ...(options.operationName !== undefined
572
667
  ? { operationName: options.operationName }
573
- : extractOperationName(query) !== undefined
668
+ : extractOperationName(query) !== null
574
669
  ? { operationName: extractOperationName(query) }
575
670
  : {}),
576
671
  ...(options.extensions !== undefined ? { extensions: options.extensions } : {}),
@@ -627,13 +722,11 @@ export class GraphQLClient {
627
722
  variables,
628
723
  ...(options.operationName !== undefined
629
724
  ? { operationName: options.operationName }
630
- : extractOperationName(query) !== undefined
725
+ : extractOperationName(query) !== null
631
726
  ? { operationName: extractOperationName(query) }
632
727
  : {}),
633
728
  };
634
- const headers = await buildHeaders(this.config, {
635
- /* drop content-type for multipart */
636
- });
729
+ const headers = await buildHeaders(this.config, { /* drop content-type for multipart */});
637
730
  delete headers["content-type"]; // FormData sets it with boundary
638
731
  const form = buildMultipartBody(req, uploads);
639
732
  const controller = new AbortController();
@@ -686,6 +779,12 @@ export class GraphQLClient {
686
779
  * contains GraphQL errors, or is missing data for any request
687
780
  */
688
781
  async batch(requests, options = {}) {
782
+ // `query()` and `upload()` both run the query through the validator before
783
+ // sending; `batch()` did not, so a batch was the one way to bypass the
784
+ // length, depth and injection limits the module applies everywhere else.
785
+ for (const req of requests) {
786
+ validateGraphQLQuery(req.query);
787
+ }
689
788
  const headers = await buildHeaders(this.config);
690
789
  const controller = new AbortController();
691
790
  // Listener removed when the fetch settles — avoids accumulating on the
@@ -707,13 +806,38 @@ export class GraphQLClient {
707
806
  finally {
708
807
  options.signal?.removeEventListener("abort", onExternalAbort);
709
808
  }
710
- // FIX (H6): sanitize untrusted batch response JSON before validation.
711
- const rawResults = sanitizeParsedJSON(await response.json());
809
+ // Every other path routes a non-JSON body through `parseGraphQLResponse`,
810
+ // which turns it into a typed `EPARSE` / `ENETWORK` GraphQLClientError.
811
+ // `batch()` called `response.json()` bare, so a 502 HTML error page threw a
812
+ // raw `SyntaxError` with no `code` at all — a caller switching on
813
+ // `err.code` got `undefined` and had no way to tell this from a bug.
814
+ const ct = response.headers.get("content-type") ?? "";
815
+ if (!response.ok && !ct.includes("application/json")) {
816
+ const text = await response.text().catch(() => "(unreadable)");
817
+ throw new GraphQLClientError(`HTTP ${response.status}: ${response.statusText}\n${text}`, "ENETWORK", undefined, requests[0], undefined, new Error(`HTTP ${response.status}`));
818
+ }
819
+ let rawResults;
820
+ try {
821
+ // FIX (H6): sanitize untrusted batch response JSON before validation.
822
+ rawResults = sanitizeParsedJSON((await response.json()));
823
+ }
824
+ catch (err) {
825
+ throw new GraphQLClientError("Failed to parse batch response as JSON", "EPARSE", undefined, requests[0], undefined, err);
826
+ }
712
827
  // Validate response is array (B-5 fix)
713
828
  if (!Array.isArray(rawResults)) {
714
829
  throw new GraphQLClientError("Batch response must be an array of GraphQL responses", "EINVALIDRESPONSE", undefined, requests[0], rawResults);
715
830
  }
716
831
  const results = rawResults;
832
+ // The contract is "one `data` per request, in order". A server returning a
833
+ // different number was mapped over anyway: two requests answered with one
834
+ // result silently returned one item, and one request answered with three
835
+ // returned three — with `requests[i]` `undefined` for the extra ones, so an
836
+ // `errors` entry there was attributed to no request at all. Positional
837
+ // alignment is the whole point of a batch, so a mismatch is an error.
838
+ if (results.length !== requests.length) {
839
+ throw new GraphQLClientError(`Batch response has ${results.length} result(s) but ${requests.length} request(s) were sent`, "EINVALIDRESPONSE", undefined, requests[0], rawResults);
840
+ }
717
841
  return results.map((res, i) => {
718
842
  if (res.errors?.length)
719
843
  throw buildGraphQLError(res.errors, requests[i], res);
@@ -776,7 +900,7 @@ export class GraphQLClient {
776
900
  ...(variables !== undefined ? { variables } : {}),
777
901
  ...(options.operationName !== undefined
778
902
  ? { operationName: options.operationName }
779
- : extractOperationName(query) !== undefined
903
+ : extractOperationName(query) !== null
780
904
  ? { operationName: extractOperationName(query) }
781
905
  : {}),
782
906
  };
@@ -861,6 +985,13 @@ export class GraphQLClient {
861
985
  });
862
986
  }
863
987
  async _executeWithRetry(req, signal) {
988
+ // A negative count made the loop condition false on the first evaluation,
989
+ // so the body never ran, `lastErr` was never assigned, and the method
990
+ // ended in `throw undefined` — a rejection carrying no value at all, so
991
+ // `err.message` threw a TypeError inside the caller's own error handler.
992
+ if (!Number.isInteger(this.config.retries) || this.config.retries < 0) {
993
+ throw new RangeError(`retries must be a non-negative integer, got ${this.config.retries}`);
994
+ }
864
995
  let lastErr;
865
996
  for (let attempt = 0; attempt <= this.config.retries; attempt++) {
866
997
  if (attempt > 0)
@@ -878,18 +1009,18 @@ export class GraphQLClient {
878
1009
  }
879
1010
  throw lastErr;
880
1011
  }
881
- async _executeWithAPQ(req, signal) {
882
- if (!this.config.enableAPQ) {
883
- return executeHTTP(req, this.config, signal, "none", undefined);
1012
+ async _executeWithAPQ(req, signal, config = this.config) {
1013
+ if (!config.enableAPQ) {
1014
+ return executeHTTP(req, config, signal, "none", undefined);
884
1015
  }
885
1016
  // APQ: first try without query string
886
- const res1 = await executeHTTP(req, this.config, signal, "omitQuery", (q) => this._getAPQHash(q));
1017
+ const res1 = await executeHTTP(req, config, signal, "omitQuery", (q) => this._getAPQHash(q));
887
1018
  // Check if server responded with PersistedQueryNotFound
888
1019
  const notFound = res1.errors?.some((e) => e.extensions?.["code"] === "PERSISTED_QUERY_NOT_FOUND");
889
1020
  if (!notFound)
890
1021
  return res1;
891
1022
  // Retry with full query
892
- return executeHTTP(req, this.config, signal, "full", (q) => this._getAPQHash(q));
1023
+ return executeHTTP(req, config, signal, "full", (q) => this._getAPQHash(q));
893
1024
  }
894
1025
  }
895
1026
  // ============================================================================
@@ -945,7 +1076,9 @@ export function errorLink(handler) {
945
1076
  catch (err) {
946
1077
  if (!(err instanceof GraphQLClientError))
947
1078
  throw err;
948
- const result = handler(err, op, () => next(op));
1079
+ // Must be awaited: an async handler returning `null` — the documented
1080
+ // way to say "re-throw" — would otherwise be a truthy Promise here.
1081
+ const result = await handler(err, op, () => next(op));
949
1082
  if (result)
950
1083
  return result;
951
1084
  throw err;
@@ -997,6 +1130,13 @@ export function loggingLink(logger = (msg, data) => console.log(msg, data)) {
997
1130
  export function retryLink(options = {}) {
998
1131
  const max = options.maxRetries ?? 3;
999
1132
  const delay = options.delayMs ?? 300;
1133
+ // Same `throw undefined` defect as the client's own `retries`.
1134
+ if (!Number.isInteger(max) || max < 0) {
1135
+ throw new RangeError(`retryLink: maxRetries must be a non-negative integer, got ${max}`);
1136
+ }
1137
+ if (!Number.isFinite(delay) || delay < 0) {
1138
+ throw new RangeError(`retryLink: delayMs must be a non-negative number, got ${delay}`);
1139
+ }
1000
1140
  const should = options.shouldRetry ?? ((err) => !(err instanceof GraphQLClientError && err.isGraphQLError));
1001
1141
  return async (op, next) => {
1002
1142
  let lastErr;