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