@http-client-toolkit/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,6 +1,25 @@
1
1
  import { z } from 'zod';
2
2
  import { createHash } from 'crypto';
3
3
 
4
+ var __defProp = Object.defineProperty;
5
+ var __defProps = Object.defineProperties;
6
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
7
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
10
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
11
+ var __spreadValues = (a, b) => {
12
+ for (var prop in b || (b = {}))
13
+ if (__hasOwnProp.call(b, prop))
14
+ __defNormalProp(a, prop, b[prop]);
15
+ if (__getOwnPropSymbols)
16
+ for (var prop of __getOwnPropSymbols(b)) {
17
+ if (__propIsEnum.call(b, prop))
18
+ __defNormalProp(a, prop, b[prop]);
19
+ }
20
+ return a;
21
+ };
22
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
4
23
  var __async = (__this, __arguments, generator) => {
5
24
  return new Promise((resolve, reject) => {
6
25
  var fulfilled = (value) => {
@@ -22,6 +41,231 @@ var __async = (__this, __arguments, generator) => {
22
41
  });
23
42
  };
24
43
 
44
+ // src/cache/cache-control-parser.ts
45
+ var EMPTY_DIRECTIVES = {
46
+ noCache: false,
47
+ noStore: false,
48
+ mustRevalidate: false,
49
+ proxyRevalidate: false,
50
+ public: false,
51
+ private: false,
52
+ immutable: false
53
+ };
54
+ function parseSeconds(raw) {
55
+ if (raw === void 0) return void 0;
56
+ const n = Number.parseInt(raw.trim(), 10);
57
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
58
+ }
59
+ function parseCacheControl(header) {
60
+ if (!header) return __spreadValues({}, EMPTY_DIRECTIVES);
61
+ const result = __spreadValues({}, EMPTY_DIRECTIVES);
62
+ for (const part of header.split(",")) {
63
+ const trimmed = part.trim();
64
+ if (!trimmed) continue;
65
+ const eqIdx = trimmed.indexOf("=");
66
+ const key = (eqIdx === -1 ? trimmed : trimmed.slice(0, eqIdx)).trim().toLowerCase();
67
+ const value = eqIdx === -1 ? void 0 : trimmed.slice(eqIdx + 1).trim();
68
+ switch (key) {
69
+ case "max-age":
70
+ result.maxAge = parseSeconds(value);
71
+ break;
72
+ case "s-maxage":
73
+ result.sMaxAge = parseSeconds(value);
74
+ break;
75
+ case "no-cache":
76
+ result.noCache = true;
77
+ break;
78
+ case "no-store":
79
+ result.noStore = true;
80
+ break;
81
+ case "must-revalidate":
82
+ result.mustRevalidate = true;
83
+ break;
84
+ case "proxy-revalidate":
85
+ result.proxyRevalidate = true;
86
+ break;
87
+ case "public":
88
+ result.public = true;
89
+ break;
90
+ case "private":
91
+ result.private = true;
92
+ break;
93
+ case "immutable":
94
+ result.immutable = true;
95
+ break;
96
+ case "stale-while-revalidate":
97
+ result.staleWhileRevalidate = parseSeconds(value);
98
+ break;
99
+ case "stale-if-error":
100
+ result.staleIfError = parseSeconds(value);
101
+ break;
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+
107
+ // src/cache/cache-entry.ts
108
+ function isCacheEntry(value) {
109
+ return typeof value === "object" && value !== null && value.__cacheEntry === true && "value" in value && "metadata" in value;
110
+ }
111
+ function parseHttpDate(value) {
112
+ if (value === null || value === void 0) return void 0;
113
+ const trimmed = value.trim();
114
+ if (!trimmed) return void 0;
115
+ if (trimmed === "0") return 0;
116
+ const ms = Date.parse(trimmed);
117
+ return Number.isFinite(ms) ? ms : void 0;
118
+ }
119
+ function createCacheEntry(value, headers, statusCode) {
120
+ var _a, _b, _c, _d;
121
+ const now = Date.now();
122
+ const dateMs = (_a = parseHttpDate(headers.get("date"))) != null ? _a : now;
123
+ const ageRaw = headers.get("age");
124
+ const ageHeader = ageRaw !== null ? Number.parseInt(ageRaw.trim(), 10) || 0 : 0;
125
+ return {
126
+ __cacheEntry: true,
127
+ value,
128
+ metadata: {
129
+ etag: (_b = headers.get("etag")) != null ? _b : void 0,
130
+ lastModified: (_c = headers.get("last-modified")) != null ? _c : void 0,
131
+ cacheControl: parseCacheControl(headers.get("cache-control")),
132
+ responseDate: dateMs,
133
+ storedAt: now,
134
+ ageHeader,
135
+ varyHeaders: (_d = headers.get("vary")) != null ? _d : void 0,
136
+ statusCode,
137
+ expires: parseHttpDate(headers.get("expires"))
138
+ }
139
+ };
140
+ }
141
+ function refreshCacheEntry(existing, newHeaders) {
142
+ var _a;
143
+ const now = Date.now();
144
+ const dateMs = (_a = parseHttpDate(newHeaders.get("date"))) != null ? _a : now;
145
+ const ageRaw = newHeaders.get("age");
146
+ const ageHeader = ageRaw !== null ? Number.parseInt(ageRaw.trim(), 10) || 0 : 0;
147
+ const newCacheControl = newHeaders.get("cache-control");
148
+ const newEtag = newHeaders.get("etag");
149
+ const newLastModified = newHeaders.get("last-modified");
150
+ const newExpires = newHeaders.get("expires");
151
+ const newVary = newHeaders.get("vary");
152
+ return {
153
+ __cacheEntry: true,
154
+ value: existing.value,
155
+ metadata: __spreadProps(__spreadValues({}, existing.metadata), {
156
+ cacheControl: newCacheControl ? parseCacheControl(newCacheControl) : existing.metadata.cacheControl,
157
+ etag: newEtag != null ? newEtag : existing.metadata.etag,
158
+ lastModified: newLastModified != null ? newLastModified : existing.metadata.lastModified,
159
+ responseDate: dateMs,
160
+ storedAt: now,
161
+ ageHeader,
162
+ expires: newExpires !== null ? parseHttpDate(newExpires) : existing.metadata.expires,
163
+ varyHeaders: newVary != null ? newVary : existing.metadata.varyHeaders
164
+ // statusCode stays the same (the original 200, not 304)
165
+ })
166
+ };
167
+ }
168
+
169
+ // src/cache/freshness.ts
170
+ function calculateFreshnessLifetime(metadata) {
171
+ const { cacheControl } = metadata;
172
+ if (cacheControl.maxAge !== void 0) {
173
+ return cacheControl.maxAge;
174
+ }
175
+ if (metadata.expires !== void 0) {
176
+ if (metadata.expires === 0) return 0;
177
+ const delta = (metadata.expires - metadata.responseDate) / 1e3;
178
+ return Math.max(0, delta);
179
+ }
180
+ if (metadata.lastModified) {
181
+ const lastModMs = Date.parse(metadata.lastModified);
182
+ if (Number.isFinite(lastModMs)) {
183
+ const age = (metadata.responseDate - lastModMs) / 1e3;
184
+ if (age > 0) {
185
+ return Math.floor(age * 0.1);
186
+ }
187
+ }
188
+ }
189
+ return 0;
190
+ }
191
+ function calculateCurrentAge(metadata, now) {
192
+ const currentTime = now != null ? now : Date.now();
193
+ const responseTime = metadata.storedAt;
194
+ const apparentAge = Math.max(
195
+ 0,
196
+ (responseTime - metadata.responseDate) / 1e3
197
+ );
198
+ const correctedAgeValue = metadata.ageHeader;
199
+ const correctedInitialAge = Math.max(apparentAge, correctedAgeValue);
200
+ const residentTime = (currentTime - responseTime) / 1e3;
201
+ return correctedInitialAge + residentTime;
202
+ }
203
+ function getFreshnessStatus(metadata, now) {
204
+ const { cacheControl } = metadata;
205
+ if (cacheControl.noCache) {
206
+ return "no-cache";
207
+ }
208
+ const freshnessLifetime = calculateFreshnessLifetime(metadata);
209
+ const currentAge = calculateCurrentAge(metadata, now);
210
+ if (freshnessLifetime > currentAge) {
211
+ return "fresh";
212
+ }
213
+ const staleness = currentAge - freshnessLifetime;
214
+ if (cacheControl.mustRevalidate) {
215
+ return "must-revalidate";
216
+ }
217
+ if (cacheControl.staleWhileRevalidate !== void 0 && staleness <= cacheControl.staleWhileRevalidate) {
218
+ return "stale-while-revalidate";
219
+ }
220
+ if (cacheControl.staleIfError !== void 0 && staleness <= cacheControl.staleIfError) {
221
+ return "stale-if-error";
222
+ }
223
+ return "stale";
224
+ }
225
+ function calculateStoreTTL(metadata, defaultTTL) {
226
+ var _a, _b;
227
+ const freshness = calculateFreshnessLifetime(metadata);
228
+ if (freshness === 0 && metadata.cacheControl.maxAge === void 0) {
229
+ return defaultTTL;
230
+ }
231
+ const swrWindow = (_a = metadata.cacheControl.staleWhileRevalidate) != null ? _a : 0;
232
+ const sieWindow = (_b = metadata.cacheControl.staleIfError) != null ? _b : 0;
233
+ const staleWindow = Math.max(swrWindow, sieWindow);
234
+ return freshness + staleWindow;
235
+ }
236
+
237
+ // src/cache/vary.ts
238
+ function parseVaryHeader(varyHeader) {
239
+ if (!varyHeader) return [];
240
+ const trimmed = varyHeader.trim();
241
+ if (trimmed === "*") return ["*"];
242
+ return trimmed.split(",").map((f) => f.trim().toLowerCase()).filter(Boolean);
243
+ }
244
+ function captureVaryValues(varyFields, requestHeaders) {
245
+ var _a;
246
+ const values = {};
247
+ for (const field of varyFields) {
248
+ const lower = field.toLowerCase();
249
+ values[lower] = (_a = requestHeaders[lower]) != null ? _a : requestHeaders[field];
250
+ }
251
+ return values;
252
+ }
253
+ function varyMatches(cachedVaryValues, cachedVaryHeader, currentRequestHeaders) {
254
+ var _a;
255
+ if (!cachedVaryHeader) return true;
256
+ const fields = parseVaryHeader(cachedVaryHeader);
257
+ if (fields.length === 0) return true;
258
+ if (fields[0] === "*") return false;
259
+ if (!cachedVaryValues) return false;
260
+ for (const field of fields) {
261
+ const lower = field.toLowerCase();
262
+ const cachedVal = cachedVaryValues[lower];
263
+ const currentVal = (_a = currentRequestHeaders[lower]) != null ? _a : currentRequestHeaders[field];
264
+ if (cachedVal !== currentVal) return false;
265
+ }
266
+ return true;
267
+ }
268
+
25
269
  // src/errors/http-client-error.ts
26
270
  var HttpClientError = class extends Error {
27
271
  constructor(message, statusCode) {
@@ -256,15 +500,18 @@ function wait(ms, signal) {
256
500
  var HttpClient = class {
257
501
  constructor(stores = {}, options = {}) {
258
502
  this.serverCooldowns = /* @__PURE__ */ new Map();
259
- var _a, _b, _c;
503
+ this.pendingRevalidations = [];
504
+ var _a, _b, _c, _d;
260
505
  this.stores = stores;
261
506
  this.options = {
262
507
  defaultCacheTTL: (_a = options.defaultCacheTTL) != null ? _a : 3600,
263
508
  throwOnRateLimit: (_b = options.throwOnRateLimit) != null ? _b : true,
264
509
  maxWaitTime: (_c = options.maxWaitTime) != null ? _c : 6e4,
510
+ respectCacheHeaders: (_d = options.respectCacheHeaders) != null ? _d : false,
265
511
  responseTransformer: options.responseTransformer,
266
512
  errorHandler: options.errorHandler,
267
513
  responseHandler: options.responseHandler,
514
+ cacheHeaderOverrides: options.cacheHeaderOverrides,
268
515
  rateLimitHeaders: this.normalizeRateLimitHeaders(
269
516
  options.rateLimitHeaders
270
517
  )
@@ -520,6 +767,81 @@ var HttpClient = class {
520
767
  return hasAtomicAcquire;
521
768
  });
522
769
  }
770
+ /**
771
+ * Wait for all pending background revalidations to complete.
772
+ * Primarily useful in tests to avoid dangling promises.
773
+ */
774
+ flushRevalidations() {
775
+ return __async(this, null, function* () {
776
+ yield Promise.allSettled(this.pendingRevalidations);
777
+ this.pendingRevalidations = [];
778
+ });
779
+ }
780
+ backgroundRevalidate(url, hash, entry) {
781
+ return __async(this, null, function* () {
782
+ var _a, _b;
783
+ const headers = new Headers();
784
+ if (entry.metadata.etag) {
785
+ headers.set("If-None-Match", entry.metadata.etag);
786
+ }
787
+ if (entry.metadata.lastModified) {
788
+ headers.set("If-Modified-Since", entry.metadata.lastModified);
789
+ }
790
+ try {
791
+ const response = yield fetch(url, { headers });
792
+ this.applyServerRateLimitHints(url, response.headers, response.status);
793
+ if (response.status === 304) {
794
+ const refreshed = refreshCacheEntry(entry, response.headers);
795
+ const ttl = this.clampTTL(
796
+ calculateStoreTTL(refreshed.metadata, this.options.defaultCacheTTL)
797
+ );
798
+ yield (_a = this.stores.cache) == null ? void 0 : _a.set(hash, refreshed, ttl);
799
+ return;
800
+ }
801
+ if (response.ok) {
802
+ const parsedBody = yield this.parseResponseBody(response);
803
+ let data = parsedBody.data;
804
+ if (this.options.responseTransformer && data) {
805
+ data = this.options.responseTransformer(data);
806
+ }
807
+ if (this.options.responseHandler) {
808
+ data = this.options.responseHandler(data);
809
+ }
810
+ const newEntry = createCacheEntry(
811
+ data,
812
+ response.headers,
813
+ response.status
814
+ );
815
+ const ttl = this.clampTTL(
816
+ calculateStoreTTL(newEntry.metadata, this.options.defaultCacheTTL)
817
+ );
818
+ yield (_b = this.stores.cache) == null ? void 0 : _b.set(hash, newEntry, ttl);
819
+ }
820
+ } catch (e) {
821
+ }
822
+ });
823
+ }
824
+ clampTTL(ttl) {
825
+ const overrides = this.options.cacheHeaderOverrides;
826
+ if (!overrides) return ttl;
827
+ let clamped = ttl;
828
+ if (overrides.minimumTTL !== void 0) {
829
+ clamped = Math.max(clamped, overrides.minimumTTL);
830
+ }
831
+ if (overrides.maximumTTL !== void 0) {
832
+ clamped = Math.min(clamped, overrides.maximumTTL);
833
+ }
834
+ return clamped;
835
+ }
836
+ isServerErrorOrNetworkFailure(error) {
837
+ var _a;
838
+ if (typeof error === "object" && error !== null && "response" in error) {
839
+ const status = (_a = error.response) == null ? void 0 : _a.status;
840
+ if (typeof status === "number" && status >= 500) return true;
841
+ }
842
+ if (error instanceof TypeError) return true;
843
+ return false;
844
+ }
523
845
  generateClientError(err) {
524
846
  var _a, _b;
525
847
  if (this.options.errorHandler) {
@@ -565,16 +887,58 @@ var HttpClient = class {
565
887
  }
566
888
  get(_0) {
567
889
  return __async(this, arguments, function* (url, options = {}) {
890
+ var _a, _b;
568
891
  const { signal, priority = "background" } = options;
569
892
  const { endpoint, params } = this.parseUrlForHashing(url);
570
893
  const hash = hashRequest(endpoint, params);
571
894
  const resource = this.inferResource(url);
895
+ let staleEntry;
896
+ let staleCandidate;
572
897
  try {
573
898
  yield this.enforceServerCooldown(url, signal);
574
899
  if (this.stores.cache) {
575
900
  const cachedResult = yield this.stores.cache.get(hash);
576
901
  if (cachedResult !== void 0) {
577
- return cachedResult;
902
+ if (this.options.respectCacheHeaders && isCacheEntry(cachedResult)) {
903
+ const entry = cachedResult;
904
+ const status = getFreshnessStatus(entry.metadata);
905
+ switch (status) {
906
+ case "fresh":
907
+ return entry.value;
908
+ case "no-cache":
909
+ if ((_a = this.options.cacheHeaderOverrides) == null ? void 0 : _a.ignoreNoCache) {
910
+ return entry.value;
911
+ }
912
+ staleEntry = entry;
913
+ break;
914
+ case "must-revalidate":
915
+ staleEntry = entry;
916
+ break;
917
+ case "stale-while-revalidate": {
918
+ const revalidation = this.backgroundRevalidate(
919
+ url,
920
+ hash,
921
+ entry
922
+ );
923
+ this.pendingRevalidations.push(revalidation);
924
+ revalidation.finally(() => {
925
+ this.pendingRevalidations = this.pendingRevalidations.filter(
926
+ (p) => p !== revalidation
927
+ );
928
+ });
929
+ return entry.value;
930
+ }
931
+ case "stale-if-error":
932
+ staleCandidate = entry;
933
+ staleEntry = entry;
934
+ break;
935
+ case "stale":
936
+ staleEntry = entry;
937
+ break;
938
+ }
939
+ } else if (!this.options.respectCacheHeaders) {
940
+ return cachedResult;
941
+ }
578
942
  }
579
943
  }
580
944
  if (this.stores.dedupe) {
@@ -602,8 +966,38 @@ var HttpClient = class {
602
966
  signal
603
967
  );
604
968
  }
605
- const response = yield fetch(url, { signal });
969
+ const fetchInit = { signal };
970
+ if (this.options.respectCacheHeaders && staleEntry) {
971
+ const conditionalHeaders = new Headers();
972
+ if (staleEntry.metadata.etag) {
973
+ conditionalHeaders.set("If-None-Match", staleEntry.metadata.etag);
974
+ }
975
+ if (staleEntry.metadata.lastModified) {
976
+ conditionalHeaders.set(
977
+ "If-Modified-Since",
978
+ staleEntry.metadata.lastModified
979
+ );
980
+ }
981
+ if ([...conditionalHeaders].length > 0) {
982
+ fetchInit.headers = conditionalHeaders;
983
+ }
984
+ }
985
+ const response = yield fetch(url, fetchInit);
606
986
  this.applyServerRateLimitHints(url, response.headers, response.status);
987
+ if (this.options.respectCacheHeaders && response.status === 304 && staleEntry) {
988
+ const refreshed = refreshCacheEntry(staleEntry, response.headers);
989
+ const ttl = this.clampTTL(
990
+ calculateStoreTTL(refreshed.metadata, this.options.defaultCacheTTL)
991
+ );
992
+ if (this.stores.cache) {
993
+ yield this.stores.cache.set(hash, refreshed, ttl);
994
+ }
995
+ const result2 = refreshed.value;
996
+ if (this.stores.dedupe) {
997
+ yield this.stores.dedupe.complete(hash, result2);
998
+ }
999
+ return result2;
1000
+ }
607
1001
  const parsedBody = yield this.parseResponseBody(response);
608
1002
  if (!response.ok) {
609
1003
  const error = {
@@ -629,13 +1023,40 @@ var HttpClient = class {
629
1023
  yield rateLimit.record(resource, priority);
630
1024
  }
631
1025
  if (this.stores.cache) {
632
- yield this.stores.cache.set(hash, result, this.options.defaultCacheTTL);
1026
+ if (this.options.respectCacheHeaders) {
1027
+ const cc = parseCacheControl(response.headers.get("cache-control"));
1028
+ const shouldStore = !cc.noStore || ((_b = this.options.cacheHeaderOverrides) == null ? void 0 : _b.ignoreNoStore);
1029
+ if (shouldStore) {
1030
+ const entry = createCacheEntry(
1031
+ result,
1032
+ response.headers,
1033
+ response.status
1034
+ );
1035
+ const ttl = this.clampTTL(
1036
+ calculateStoreTTL(entry.metadata, this.options.defaultCacheTTL)
1037
+ );
1038
+ yield this.stores.cache.set(hash, entry, ttl);
1039
+ }
1040
+ } else {
1041
+ yield this.stores.cache.set(
1042
+ hash,
1043
+ result,
1044
+ this.options.defaultCacheTTL
1045
+ );
1046
+ }
633
1047
  }
634
1048
  if (this.stores.dedupe) {
635
1049
  yield this.stores.dedupe.complete(hash, result);
636
1050
  }
637
1051
  return result;
638
1052
  } catch (error) {
1053
+ if (this.options.respectCacheHeaders && staleCandidate && this.isServerErrorOrNetworkFailure(error)) {
1054
+ const result = staleCandidate.value;
1055
+ if (this.stores.dedupe) {
1056
+ yield this.stores.dedupe.complete(hash, result);
1057
+ }
1058
+ return result;
1059
+ }
639
1060
  if (this.stores.dedupe) {
640
1061
  yield this.stores.dedupe.fail(hash, error);
641
1062
  }
@@ -651,6 +1072,6 @@ var HttpClient = class {
651
1072
  }
652
1073
  };
653
1074
 
654
- export { AdaptiveCapacityCalculator, AdaptiveConfigSchema, DEFAULT_RATE_LIMIT, HttpClient, HttpClientError, hashRequest };
1075
+ export { AdaptiveCapacityCalculator, AdaptiveConfigSchema, DEFAULT_RATE_LIMIT, HttpClient, HttpClientError, calculateCurrentAge, calculateFreshnessLifetime, calculateStoreTTL, captureVaryValues, createCacheEntry, getFreshnessStatus, hashRequest, isCacheEntry, parseCacheControl, parseHttpDate, parseVaryHeader, refreshCacheEntry, varyMatches };
655
1076
  //# sourceMappingURL=index.js.map
656
1077
  //# sourceMappingURL=index.js.map