@toapi/client 1.2.5 → 1.3.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/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @toapi/client
2
+
3
+ The typed fetch client for Toapi: a proxy that mirrors your [@toapi/server](https://www.npmjs.com/package/@toapi/server) API, with deduplication, tag-based revalidation, and subscriptions.
4
+
5
+ 📚 [Docs](https://toapi-js.github.io/toapi/client/)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @toapi/client
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { createFetchClient } from "@toapi/client";
17
+ import type { api } from "./api"; // the type of your server's defineApi(...)
18
+
19
+ const client = createFetchClient<typeof api.routes>("https://example.com/api");
20
+
21
+ const users = await client.users.get();
22
+ await client.users.post({ name: "Alice" });
23
+ const user = await client.users["123"].get();
24
+ ```
@@ -0,0 +1,13 @@
1
+ import type { Observable } from "@toapi/common";
2
+ import type { PubSub } from "./pub-sub.js";
3
+ import type { Cache } from "./cache.js";
4
+ interface Options {
5
+ fetch(url: string, init: RequestInit): Promise<Response>;
6
+ url: string;
7
+ init: RequestInit;
8
+ pubSub: PubSub;
9
+ cache: Cache;
10
+ }
11
+ export declare function buildObservable(options: Options): Observable<unknown> & Promise<unknown>;
12
+ export {};
13
+ //# sourceMappingURL=build-observable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-observable.d.ts","sourceRoot":"","sources":["../src/build-observable.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAGxC,UAAU,OAAO;IACf,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzD,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,WAAW,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,KAAK,CAAC;CACd;AAMD,wBAAgB,eAAe,CAC7B,OAAO,EAAE,OAAO,GACf,UAAU,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAmBxC"}
@@ -0,0 +1,19 @@
1
+ import { request } from "./request.js";
2
+ export function buildObservable(options) {
3
+ const { url, pubSub } = options;
4
+ const { data } = request(options);
5
+ function subscribe(callback) {
6
+ const { data } = request(options);
7
+ callback(data);
8
+ return pubSub.subscribe(async (invalidUrls) => {
9
+ if (invalidUrls.has(url)) {
10
+ const { data } = request(options);
11
+ callback(data);
12
+ }
13
+ });
14
+ }
15
+ return Object.assign(data, {
16
+ subscribe,
17
+ queryKey: url,
18
+ });
19
+ }
package/dist/cache.d.ts CHANGED
@@ -1,27 +1,30 @@
1
1
  import { type Logger } from "@toapi/common";
2
- import { type ObservablePromise } from "./state-machine.js";
2
+ import type { Metadata } from "./extract-metadata.js";
3
+ import type { PubSub } from "./pub-sub.js";
4
+ export interface CacheEntry {
5
+ response: Promise<Response>;
6
+ meta: Promise<Metadata>;
7
+ data: Promise<unknown>;
8
+ }
3
9
  interface Options {
4
- minTTL?: number;
5
10
  maxOverdueTTL?: number;
6
11
  logger?: Logger;
12
+ minTTL: number;
13
+ pubSub: PubSub;
7
14
  }
8
15
  export declare class Cache {
9
16
  private storage;
10
17
  private tagIndex;
11
- private minTTL;
12
18
  private maxOverdueTTL;
19
+ private minTTL;
13
20
  private errorLog;
21
+ private pubSub;
14
22
  constructor(options: Options);
15
- request(url: string, fetch: () => Promise<Response>): ObservablePromise;
16
- private setResolveHook;
17
- revalidateUrl(url: string): Promise<void>;
18
- revalidateTags(tags: string[]): Promise<void>;
19
- private evictEntry;
20
- private loadFreshData;
21
- private subscribe;
22
- private unsubscribe;
23
- private setTimer;
24
- private extractMetadata;
23
+ set(url: string, entry: CacheEntry): void;
24
+ get(url: string): CacheEntry | undefined;
25
+ invalidateUrl(url: string): Promise<void>;
26
+ invalidateTags(tags: string[]): Promise<void>;
27
+ invalidateAll(): Promise<void>;
25
28
  }
26
29
  export {};
27
30
  //# sourceMappingURL=cache.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,MAAM,EAEZ,MAAM,eAAe,CAAC;AAEvB,OAAO,EAGL,KAAK,iBAAiB,EAKvB,MAAM,oBAAoB,CAAC;AAa5B,UAAU,OAAO;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAKD,qBAAa,KAAK;IAChB,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAA2C;gBAE/C,OAAO,EAAE,OAAO;IAM5B,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,iBAAiB;YAwBzD,cAAc;IA+E5B,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6CnC,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE;IAenC,OAAO,CAAC,UAAU;IAgBlB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,SAAS;IA6CjB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,QAAQ;IA4BhB,OAAO,CAAC,eAAe;CAUxB"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3C,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5B,IAAI,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;IACxB,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;CACxB;AAED,UAAU,OAAO;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAID,qBAAa,KAAK;IAChB,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAA2C;IAC3D,OAAO,CAAC,MAAM,CAAS;gBAEX,OAAO,EAAE,OAAO;IAOrB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU;IA8ClC,GAAG,CAAC,GAAG,EAAE,MAAM;IAIf,aAAa,CAAC,GAAG,EAAE,MAAM;IAKzB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE;IAiB7B,aAAa;CAMrB"}
package/dist/cache.js CHANGED
@@ -1,245 +1,88 @@
1
- import { EXPIRES_AT_HEADER, HttpError, TAGS_HEADER, } from "@toapi/common";
2
- import { handleResponse } from "./handle-response.js";
3
- import { init, queue, resolve, revalidate, revert, } from "./state-machine.js";
4
- const DEFAULT_MIN_TTL = 5 * 1000;
1
+ import {} from "@toapi/common";
5
2
  const DEFAULT_MAX_OVERDUE_TTL = 1000;
6
3
  export class Cache {
7
4
  storage = new Map();
8
5
  tagIndex = new Map();
9
- minTTL;
10
6
  maxOverdueTTL;
7
+ minTTL;
11
8
  errorLog;
9
+ pubSub;
12
10
  constructor(options) {
13
- this.minTTL = options.minTTL ?? DEFAULT_MIN_TTL;
14
11
  this.maxOverdueTTL = options.maxOverdueTTL ?? DEFAULT_MAX_OVERDUE_TTL;
15
12
  this.errorLog = options.logger?.error ?? console.error;
13
+ this.pubSub = options.pubSub;
14
+ this.minTTL = options.minTTL;
16
15
  }
17
- request(url, fetch) {
18
- const entry = this.storage.get(url);
19
- if (entry) {
20
- switch (entry.state.status) {
21
- case "pending":
22
- case "revalidating":
23
- return entry.state.queued ?? entry.state.next;
24
- case "cached":
25
- return entry.state.value;
26
- }
27
- }
28
- else {
29
- const { observable } = this.loadFreshData(url, fetch);
30
- this.storage.set(url, {
31
- state: init(observable),
32
- queryKey: observable.queryKey,
33
- fetch,
34
- subscriptions: new Set(),
35
- timeout: null,
36
- });
37
- return observable;
38
- }
39
- }
40
- async setResolveHook(url, responsePromise, observable) {
41
- try {
42
- const response = await responsePromise;
43
- await observable;
44
- const entry = this.storage.get(url);
45
- switch (entry?.state.status) {
46
- case "pending": {
47
- const { tags, expiresAt } = this.extractMetadata(response);
48
- for (const tag of tags) {
49
- const urls = this.tagIndex.get(tag);
50
- if (urls) {
51
- urls.add(url);
52
- }
53
- else {
54
- this.tagIndex.set(tag, new Set([url]));
55
- }
56
- }
57
- entry.state = resolve(entry.state, observable, tags, expiresAt);
58
- this.setTimer(url, entry);
59
- return;
16
+ set(url, entry) {
17
+ this.storage.set(url, entry);
18
+ entry.data.catch((error) => {
19
+ setTimeout(() => {
20
+ if (this.storage.get(url) === entry) {
21
+ this.storage.delete(url);
60
22
  }
61
- case "revalidating": {
62
- const { tags, expiresAt } = this.extractMetadata(response);
63
- // add url to tagIndex for new tags
64
- for (const tag of tags.difference(entry.state.tags)) {
65
- const urls = this.tagIndex.get(tag);
66
- if (urls) {
67
- urls.add(url);
68
- }
69
- else {
70
- this.tagIndex.set(tag, new Set([url]));
71
- }
72
- }
73
- // remove url from tagIndex for removed tags
74
- for (const tag of entry.state.tags.difference(tags)) {
75
- this.tagIndex.get(tag)?.delete(url);
76
- }
77
- entry.state = resolve(entry.state, observable, tags, expiresAt);
78
- this.setTimer(url, entry);
79
- return;
23
+ }, this.minTTL);
24
+ return this.errorLog(error);
25
+ });
26
+ entry.meta
27
+ .then(({ tags, expiresAt }) => {
28
+ const tagsToSet = new Set(tags);
29
+ // sync with tagIndex
30
+ for (const [tag, urls] of this.tagIndex) {
31
+ if (tagsToSet.delete(tag)) {
32
+ urls.add(url);
33
+ }
34
+ else {
35
+ urls.delete(url);
80
36
  }
81
37
  }
82
- }
83
- catch (error) {
84
- if (error instanceof HttpError && error.status < 500) {
85
- // not found, no access etc, evict the entry
86
- this.evictEntry(url);
87
- return;
38
+ for (const tag of tagsToSet) {
39
+ // these are only tags that are not in the index yet
40
+ this.tagIndex.set(tag, new Set([url]));
88
41
  }
89
- const entry = this.storage.get(url);
90
- if (!entry) {
91
- // evicted, ignore
42
+ if (!expiresAt)
92
43
  return;
93
- }
94
- // log every other error
95
- this.errorLog(error);
96
- switch (entry.state.status) {
97
- case "pending":
98
- // no point to caching a failed request
99
- this.evictEntry(url);
100
- return;
101
- case "revalidating":
102
- // revert to last good state
103
- entry.state = revert(entry.state);
44
+ const timeUntilRevalidation = expiresAt -
45
+ Date.now() +
46
+ Math.round(Math.random() * this.maxOverdueTTL);
47
+ setTimeout(() => {
48
+ const cached = this.storage.get(url);
49
+ if (cached !== entry)
104
50
  return;
105
- }
106
- }
51
+ this.storage.delete(url);
52
+ this.pubSub.publish(new Set([url]));
53
+ }, Math.max(0, timeUntilRevalidation));
54
+ })
55
+ .catch(() => { });
107
56
  }
108
- revalidateUrl(url) {
109
- const entry = this.storage.get(url);
110
- // no entry, nothing to do
111
- if (!entry)
112
- return Promise.resolve();
113
- // no subscribers, evict the entry
114
- if (entry.subscriptions.size === 0) {
115
- this.evictEntry(url);
116
- return Promise.resolve();
117
- }
118
- const { observable, resolved } = this.loadFreshData(url, entry.fetch, entry.state.status === "revalidating"
119
- ? entry.state.next
120
- : Promise.resolve());
121
- switch (entry.state.status) {
122
- case "pending":
123
- // pending is stale now, replace it with a fresh request
124
- entry.state = init(observable);
125
- break;
126
- case "cached":
127
- // revalidate
128
- entry.state = revalidate(entry.state, observable);
129
- break;
130
- case "revalidating":
131
- // already revalidating, queue
132
- entry.state = queue(entry.state, observable);
133
- break;
134
- }
135
- // notify subscribers
136
- for (const callback of entry.subscriptions) {
137
- callback(observable);
138
- }
139
- return resolved;
57
+ get(url) {
58
+ return this.storage.get(url);
140
59
  }
141
- async revalidateTags(tags) {
142
- // collect urls to revalidate
143
- let urls = new Set();
60
+ invalidateUrl(url) {
61
+ this.storage.delete(url);
62
+ return this.pubSub.publish(new Set([url]));
63
+ }
64
+ invalidateTags(tags) {
65
+ const urls = new Set();
144
66
  for (const tag of tags) {
145
67
  const taggedUrls = this.tagIndex.get(tag);
146
68
  if (!taggedUrls)
147
69
  continue;
148
- urls = urls.union(taggedUrls);
149
- }
150
- // revalidate urls and wait until all are resolved or rejected
151
- await Promise.allSettled(Array.from(urls).map((url) => this.revalidateUrl(url)));
152
- }
153
- evictEntry(url) {
154
- const entry = this.storage.get(url);
155
- if (!entry)
156
- return;
157
- switch (entry.state.status) {
158
- case "cached":
159
- case "revalidating":
160
- // remove tags from index
161
- for (const tag of entry.state.tags) {
162
- this.tagIndex.get(tag)?.delete(url);
70
+ for (const url of taggedUrls) {
71
+ if (this.storage.has(url)) {
72
+ this.storage.delete(url);
163
73
  }
74
+ else {
75
+ taggedUrls.delete(url);
76
+ }
77
+ urls.add(url);
78
+ }
164
79
  }
165
- this.storage.delete(url);
166
- }
167
- loadFreshData(url, fetch, waitFor = Promise.resolve()) {
168
- // actually load fresh data
169
- const responsePromise = waitFor.then(() => fetch());
170
- const observable = Object.assign(responsePromise.then(handleResponse), {
171
- // A response promise changes on every refresh, but the query does not.
172
- // An object also separates identical URLs on different client caches.
173
- queryKey: this.storage.get(url)?.queryKey ?? {},
174
- subscribe: (callback) => this.subscribe(url, fetch, callback),
175
- });
176
- const resolved = this.setResolveHook(url, responsePromise, observable);
177
- return { observable, resolved };
178
- }
179
- subscribe(url, fetch, callback) {
180
- const entry = this.storage.get(url);
181
- if (!entry) {
182
- // has been evicted
183
- const { observable } = this.loadFreshData(url, fetch);
184
- const newEntry = {
185
- state: init(observable),
186
- queryKey: observable.queryKey,
187
- fetch,
188
- subscriptions: new Set([callback]),
189
- timeout: null,
190
- };
191
- this.storage.set(url, newEntry);
192
- callback(observable);
193
- return () => this.unsubscribe(url, newEntry, callback);
194
- }
195
- // set up subscription
196
- entry.subscriptions.add(callback);
197
- if (entry.subscriptions.size === 1) {
198
- // was scheduled for eviction, reset the timer
199
- this.setTimer(url, entry);
200
- }
201
- // Immediately push the current value to the new subscriber
202
- // so it doesn't miss any updates
203
- switch (entry.state.status) {
204
- case "cached":
205
- callback(entry.state.value);
206
- break;
207
- case "pending":
208
- case "revalidating":
209
- callback(entry.state.queued ?? entry.state.next);
210
- break;
211
- }
212
- return () => this.unsubscribe(url, entry, callback);
213
- }
214
- unsubscribe(url, entry, callback) {
215
- entry.subscriptions.delete(callback);
216
- this.setTimer(url, entry);
217
- }
218
- setTimer(url, entry) {
219
- if (entry.timeout) {
220
- clearTimeout(entry.timeout);
221
- }
222
- if (entry.subscriptions.size === 0) {
223
- // no active subscriptions, set up eviction timer
224
- entry.timeout = setTimeout(() => this.evictEntry(url), this.minTTL);
225
- return;
226
- }
227
- if (entry.state.status === "cached" && entry.state.expiresAt) {
228
- // entry expires, set up revalidation timer
229
- const timeUntilRevalidation = entry.state.expiresAt -
230
- Date.now() +
231
- Math.round(Math.random() * this.maxOverdueTTL);
232
- entry.timeout = setTimeout(() => this.revalidateUrl(url), Math.max(0, timeUntilRevalidation));
233
- return;
234
- }
235
- // no timeout necessary
236
- entry.timeout = null;
80
+ return this.pubSub.publish(urls);
237
81
  }
238
- extractMetadata(response) {
239
- const expiresAtHeader = response.headers.get(EXPIRES_AT_HEADER);
240
- return {
241
- tags: new Set(response.headers.get(TAGS_HEADER)?.split(" ") ?? []),
242
- expiresAt: expiresAtHeader ? parseInt(expiresAtHeader, 10) : undefined,
243
- };
82
+ invalidateAll() {
83
+ const urls = new Set(this.storage.keys());
84
+ this.storage.clear();
85
+ this.tagIndex.clear();
86
+ return this.pubSub.publish(urls);
244
87
  }
245
88
  }
@@ -1 +1 @@
1
- {"version":3,"file":"create-fetch-client.d.ts","sourceRoot":"","sources":["../src/create-fetch-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,IAAI,QAAQ,EAChB,SAAS,EACT,MAAM,EACN,YAAY,EACb,MAAM,eAAe,CAAC;AAOvB,OAAO,KAAK,EAAE,MAAM,EAAgB,MAAM,mBAAmB,CAAC;AAiC9D,UAAU,OAAO;IACf,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACnC;AAED,wBAAgB,iBAAiB,CAC/B,MAAM,SAAS,MAAM,CAAC,QAAQ,EAAE,YAAY,CAAC,SAAS,CAAC,CAAC,EACxD,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,OAAY,GAyGpB,MAAM,CAAC,MAAM,CAAC,CAChC"}
1
+ {"version":3,"file":"create-fetch-client.d.ts","sourceRoot":"","sources":["../src/create-fetch-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,IAAI,QAAQ,EAChB,SAAS,EACT,MAAM,EACN,YAAY,EACb,MAAM,eAAe,CAAC;AAIvB,OAAO,KAAK,EAAE,MAAM,EAAgB,MAAM,mBAAmB,CAAC;AAO9D,UAAU,OAAO;IACf,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACnC;AAID,wBAAgB,iBAAiB,CAC/B,MAAM,SAAS,MAAM,CAAC,QAAQ,EAAE,YAAY,CAAC,SAAS,CAAC,CAAC,EACxD,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,OAAY,GAkGpB,MAAM,CAAC,MAAM,CAAC,CAChC"}
@@ -1,80 +1,54 @@
1
- import { INVALIDATION_POST_EVENT, INVALIDATIONS_ROUTE, TAGS_HEADER, } from "@toapi/common";
1
+ import { INVALIDATIONS_ROUTE, TAGS_HEADER } from "@toapi/common";
2
+ import { buildObservable } from "./build-observable.js";
2
3
  import { Cache } from "./cache.js";
3
4
  import { handleResponse } from "./handle-response.js";
5
+ import { listenForInvalidations } from "./invalidation-stream.js";
6
+ import { PubSub } from "./pub-sub.js";
4
7
  const globalFetch = fetch;
5
- async function listenForInvalidations(url, cache) {
6
- const MAX_ATTEMPTS = 1000;
7
- for (let retry = 0; retry < MAX_ATTEMPTS; retry++) {
8
- try {
9
- const res = await globalFetch(url);
10
- if (!res.ok || !res.body)
11
- break;
12
- let buffer = "";
13
- const decoder = new TextDecoder();
14
- for await (const chunk of res.body) {
15
- buffer += decoder.decode(chunk);
16
- const lines = buffer.split("\n");
17
- buffer = lines.pop() ?? "";
18
- for (const line of lines) {
19
- const rawTags = line.trim();
20
- if (!rawTags)
21
- continue;
22
- await cache.revalidateTags(rawTags.split(" "));
23
- }
24
- }
25
- }
26
- catch {
27
- // network error — retry below
28
- }
29
- await new Promise((resolve) => setTimeout(resolve, 500 * Math.pow(2, Math.min(retry, 10))));
30
- }
31
- }
8
+ const DEFAULT_MIN_TTL = 100;
32
9
  export function createFetchClient(apiUrl, options = {}) {
33
10
  const fetch = options.fetch ?? globalFetch;
11
+ const minTTL = options?.minTTL ?? DEFAULT_MIN_TTL;
12
+ const pubSub = new PubSub({
13
+ minTTL,
14
+ });
34
15
  const cache = new Cache({
35
- minTTL: options.minTTL,
36
16
  maxOverdueTTL: options.maxOverdueTTL,
37
17
  logger: options.logger,
18
+ pubSub,
19
+ minTTL,
38
20
  });
39
21
  const invalidationsUrl = options.invalidationsUrl === false
40
22
  ? null
41
23
  : (options.invalidationsUrl ?? apiUrl + INVALIDATIONS_ROUTE);
42
- if ("serviceWorker" in navigator) {
43
- navigator.serviceWorker.addEventListener("message", async (event) => {
44
- if (typeof event.data === "object" &&
45
- event.data !== null &&
46
- "type" in event.data &&
47
- event.data.type === INVALIDATION_POST_EVENT) {
48
- try {
49
- await cache.revalidateTags(event.data.tags);
50
- }
51
- catch (error) {
52
- console.warn("TApi: Failed to revalidate tags: received invalid post message", event.data);
53
- }
54
- }
55
- });
56
- if (invalidationsUrl && !navigator.serviceWorker.controller) {
57
- listenForInvalidations(invalidationsUrl, cache);
58
- }
59
- }
60
- else if (invalidationsUrl && typeof window !== "undefined") {
61
- listenForInvalidations(invalidationsUrl, cache);
62
- }
24
+ listenForInvalidations({
25
+ fetch,
26
+ onInvalidate: (tags) => cache.invalidateTags(tags),
27
+ onConnect: () => cache.invalidateAll(),
28
+ logger: options.logger,
29
+ invalidationsUrl,
30
+ });
63
31
  function load(url, init = {}) {
64
- return cache.request(url, () => fetch(url, {
65
- method: "GET",
66
- ...init,
67
- }));
32
+ return buildObservable({
33
+ fetch,
34
+ url,
35
+ init: {
36
+ method: "GET",
37
+ ...init,
38
+ },
39
+ pubSub,
40
+ cache,
41
+ });
68
42
  }
69
43
  async function revalidate(url) {
70
- await cache.revalidateUrl(url);
44
+ await cache.invalidateUrl(url);
71
45
  }
72
46
  function mutate(method, url, data, init = {}) {
73
47
  const headers = new Headers(init.headers);
74
48
  if (!(data instanceof FormData) && !headers.has("Content-Type")) {
75
49
  headers.set("Content-Type", "application/json");
76
50
  }
77
- const res = fetch(url, {
51
+ const response = fetch(url, {
78
52
  method,
79
53
  body: typeof data === "undefined"
80
54
  ? undefined
@@ -84,14 +58,18 @@ export function createFetchClient(apiUrl, options = {}) {
84
58
  ...init,
85
59
  headers,
86
60
  });
87
- const revalidationPromise = res.then((res) => cache.revalidateTags(res.headers.get(TAGS_HEADER)?.split(" ") ?? []));
88
- return Object.assign(res
89
- .then((res) => handleResponse(res))
90
- .catch(async (error) => {
61
+ const body = response.then(handleResponse).catch(async (error) => {
91
62
  await options?.logger?.error?.(error);
92
63
  throw error;
93
- }), {
94
- revalidated: revalidationPromise,
64
+ });
65
+ const revalidated = response.then(async (res) => {
66
+ await Promise.all([
67
+ body,
68
+ cache.invalidateTags(res.headers.get(TAGS_HEADER)?.split(" ") ?? []),
69
+ ]);
70
+ });
71
+ return Object.assign(body, {
72
+ revalidated,
95
73
  });
96
74
  }
97
75
  return new Proxy(() => { }, {
@@ -0,0 +1,6 @@
1
+ export interface Metadata {
2
+ tags: Set<string>;
3
+ expiresAt?: number;
4
+ }
5
+ export declare function extractMetadata(response: Response): Metadata;
6
+ //# sourceMappingURL=extract-metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extract-metadata.d.ts","sourceRoot":"","sources":["../src/extract-metadata.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAM5D"}
@@ -0,0 +1,8 @@
1
+ import { EXPIRES_AT_HEADER, TAGS_HEADER } from "@toapi/common";
2
+ export function extractMetadata(response) {
3
+ const expiresAtHeader = response.headers.get(EXPIRES_AT_HEADER);
4
+ return {
5
+ tags: new Set(response.headers.get(TAGS_HEADER)?.split(" ") ?? []),
6
+ expiresAt: expiresAtHeader ? parseInt(expiresAtHeader, 10) : undefined,
7
+ };
8
+ }
@@ -0,0 +1,11 @@
1
+ import { type Logger } from "@toapi/common";
2
+ interface Options {
3
+ fetch: (url: string, init: RequestInit) => Promise<Response>;
4
+ invalidationsUrl: string | null;
5
+ onInvalidate(tags: string[]): Promise<void>;
6
+ onConnect(): Promise<void>;
7
+ logger?: Logger;
8
+ }
9
+ export declare function listenForInvalidations(options: Options): void;
10
+ export {};
11
+ //# sourceMappingURL=invalidation-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invalidation-stream.d.ts","sourceRoot":"","sources":["../src/invalidation-stream.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EACZ,MAAM,eAAe,CAAC;AAEvB,UAAU,OAAO;IACf,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,QAoCtD"}
@@ -0,0 +1,85 @@
1
+ import { INVALIDATION_POST_EVENT, CONNECT_POST_EVENT, } from "@toapi/common";
2
+ export function listenForInvalidations(options) {
3
+ const { onInvalidate, onConnect, logger } = options;
4
+ const warn = logger?.warn ?? console.warn;
5
+ if ("serviceWorker" in navigator) {
6
+ navigator.serviceWorker.addEventListener("message", async (event) => {
7
+ if (typeof event.data === "object" &&
8
+ event.data !== null &&
9
+ "type" in event.data) {
10
+ switch (event.data.type) {
11
+ case INVALIDATION_POST_EVENT: {
12
+ try {
13
+ await onInvalidate(event.data.tags);
14
+ }
15
+ catch (error) {
16
+ warn(`TApi: Failed to revalidate tags: received invalid post message ${JSON.stringify(event.data)}`);
17
+ }
18
+ return;
19
+ }
20
+ case CONNECT_POST_EVENT: {
21
+ await onConnect();
22
+ return;
23
+ }
24
+ }
25
+ }
26
+ });
27
+ if (!navigator.serviceWorker.controller) {
28
+ fetchInvalidationStream(options);
29
+ }
30
+ }
31
+ else if (typeof window !== "undefined") {
32
+ fetchInvalidationStream(options);
33
+ }
34
+ }
35
+ async function fetchInvalidationStream({ fetch, onInvalidate, invalidationsUrl, onConnect, logger, }) {
36
+ if (!invalidationsUrl)
37
+ return;
38
+ const warn = logger?.warn ?? console.warn;
39
+ const info = logger?.info ?? console.info;
40
+ const MAX_ATTEMPTS = 500;
41
+ for (let retry = 0; retry < MAX_ATTEMPTS; retry++) {
42
+ try {
43
+ const res = await fetch(invalidationsUrl, {});
44
+ if (!res.ok)
45
+ throw `${res.status} ${res.statusText}`;
46
+ if (!res.body) {
47
+ throw `Invalidation stream response has no body`;
48
+ }
49
+ // reset retry counter
50
+ retry = 0;
51
+ // invalidate everything in the cache, it might have gone stale while we were not listening
52
+ await onConnect();
53
+ info("Invalidations stream connection established");
54
+ let buffer = "";
55
+ const decoder = new TextDecoder();
56
+ for await (const chunk of res.body) {
57
+ buffer += decoder.decode(chunk);
58
+ const lines = buffer.split("\n");
59
+ buffer = lines.pop() ?? "";
60
+ for (const line of lines) {
61
+ const rawTags = line.trim();
62
+ if (!rawTags)
63
+ continue;
64
+ await onInvalidate(rawTags.split(" "));
65
+ }
66
+ }
67
+ }
68
+ catch (error) {
69
+ const formattedError = error instanceof Error
70
+ ? `${error.name} ${error.message}`
71
+ : String(error);
72
+ warn(`Reconnecting to invalidation stream after error: ${formattedError}`);
73
+ }
74
+ finally {
75
+ // error or stream ended, retry with exponential backoff
76
+ await new Promise((resolve) =>
77
+ // so the retry interval is roughly
78
+ // 0.55s, 0.61s, 0.66s, 0.73s
79
+ // growing exponentially up to 1.9hours and then stays constant
80
+ // for about 63 days before it throws an error
81
+ setTimeout(resolve, 500 * Math.pow(1.1, Math.min(retry, 100))));
82
+ }
83
+ }
84
+ throw new Error("Failed to reconnect to invalidation stream");
85
+ }
package/dist/pub-sub.d.ts CHANGED
@@ -1,11 +1,15 @@
1
+ type Subscription = (urls: Set<string>) => Promise<void>;
2
+ interface Options {
3
+ minTTL: number;
4
+ }
1
5
  export declare class PubSub {
2
6
  private subscriptions;
3
- private onClear;
4
- constructor({ onClear }: {
5
- onClear: (url: string) => void;
6
- });
7
- subscribe(url: string, callback: (data: Promise<unknown>) => void): () => void;
8
- publish(url: string, data: Promise<unknown>): void;
9
- has(url: string): boolean;
7
+ private debounceTimeouts;
8
+ private requestedUrls;
9
+ private minTTL;
10
+ constructor(options: Options);
11
+ subscribe(callback: Subscription): () => void;
12
+ publish(urls: Set<string>): Promise<void>;
10
13
  }
14
+ export {};
11
15
  //# sourceMappingURL=pub-sub.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pub-sub.d.ts","sourceRoot":"","sources":["../src/pub-sub.ts"],"names":[],"mappings":"AAAA,qBAAa,MAAM;IACjB,OAAO,CAAC,aAAa,CAGjB;IACJ,OAAO,CAAC,OAAO,CAAwB;gBAE3B,EAAE,OAAO,EAAE,EAAE;QAAE,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE;IAI3D,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI;IAgBjE,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC;IAS3C,GAAG,CAAC,GAAG,EAAE,MAAM;CAGhB"}
1
+ {"version":3,"file":"pub-sub.d.ts","sourceRoot":"","sources":["../src/pub-sub.ts"],"names":[],"mappings":"AAAA,KAAK,YAAY,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEzD,UAAU,OAAO;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,qBAAa,MAAM;IACjB,OAAO,CAAC,aAAa,CAA2B;IAChD,OAAO,CAAC,gBAAgB,CAAoD;IAC5E,OAAO,CAAC,aAAa,CAAqB;IAC1C,OAAO,CAAC,MAAM,CAAS;gBAEX,OAAO,EAAE,OAAO;IAI5B,SAAS,CAAC,QAAQ,EAAE,YAAY;IAO1B,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC;CAkChC"}
package/dist/pub-sub.js CHANGED
@@ -1,33 +1,39 @@
1
1
  export class PubSub {
2
- subscriptions = new Map();
3
- onClear;
4
- constructor({ onClear }) {
5
- this.onClear = onClear;
2
+ subscriptions = new Set();
3
+ debounceTimeouts = new Map();
4
+ requestedUrls = new Set();
5
+ minTTL;
6
+ constructor(options) {
7
+ this.minTTL = options.minTTL;
6
8
  }
7
- subscribe(url, callback) {
8
- let urlSubscriptions = this.subscriptions.get(url);
9
- if (!urlSubscriptions) {
10
- urlSubscriptions = new Set();
11
- this.subscriptions.set(url, urlSubscriptions);
12
- }
13
- urlSubscriptions.add(callback);
9
+ subscribe(callback) {
10
+ this.subscriptions.add(callback);
14
11
  return () => {
15
- urlSubscriptions.delete(callback);
16
- if (urlSubscriptions.size === 0) {
17
- this.subscriptions.delete(url);
18
- this.onClear(url);
19
- }
12
+ this.subscriptions.delete(callback);
20
13
  };
21
14
  }
22
- publish(url, data) {
23
- const urlSubscriptions = this.subscriptions.get(url);
24
- if (urlSubscriptions) {
25
- for (const callback of urlSubscriptions.values()) {
26
- callback(data);
15
+ async publish(urls) {
16
+ for (const url of urls) {
17
+ if (this.debounceTimeouts.has(url)) {
18
+ // debounced, mark as requested and ignore now
19
+ this.requestedUrls.add(url);
20
+ urls.delete(url);
27
21
  }
28
22
  }
29
- }
30
- has(url) {
31
- return this.subscriptions.has(url);
23
+ const timeout = setTimeout(() => Promise.all(Array.from(urls).map((url) => {
24
+ if (this.requestedUrls.has(url)) {
25
+ this.debounceTimeouts.delete(url);
26
+ // needs to be refetched
27
+ return this.publish(urls);
28
+ }
29
+ else {
30
+ // debounce timeout over
31
+ this.debounceTimeouts.delete(url);
32
+ }
33
+ })), this.minTTL);
34
+ for (const url of urls) {
35
+ this.debounceTimeouts.set(url, timeout);
36
+ }
37
+ await Promise.all(this.subscriptions.values().map((callback) => callback(urls)));
32
38
  }
33
39
  }
@@ -0,0 +1,10 @@
1
+ import type { Cache, CacheEntry } from "./cache.js";
2
+ interface Options {
3
+ fetch(url: string, init: RequestInit): Promise<Response>;
4
+ url: string;
5
+ cache: Cache;
6
+ init: RequestInit;
7
+ }
8
+ export declare function request(options: Options): CacheEntry;
9
+ export {};
10
+ //# sourceMappingURL=request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEpD,UAAU,OAAO;IACf,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzD,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,KAAK,CAAC;IACb,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,UAAU,CAqBpD"}
@@ -0,0 +1,21 @@
1
+ import { extractMetadata } from "./extract-metadata.js";
2
+ import { handleResponse } from "./handle-response.js";
3
+ export function request(options) {
4
+ const { fetch, url, cache, init } = options;
5
+ const cached = cache.get(url);
6
+ if (cached)
7
+ return cached;
8
+ const response = fetch(url, init);
9
+ const data = response.then(handleResponse);
10
+ const meta = response.then(extractMetadata);
11
+ cache.set(url, {
12
+ response,
13
+ data,
14
+ meta,
15
+ });
16
+ return {
17
+ response,
18
+ data,
19
+ meta,
20
+ };
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toapi/client",
3
- "version": "1.2.5",
3
+ "version": "1.3.0",
4
4
  "author": {
5
5
  "name": "Michel Smola",
6
6
  "email": "michel.smola@farbenmeer.de"
@@ -20,13 +20,13 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
- "@toapi/common": "^1.2.5"
23
+ "@toapi/common": "^1.3.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^25.0.3",
27
27
  "vitest": "^5.0.0",
28
28
  "zod": "^4.0.17",
29
- "@toapi/server": "^1.2.5"
29
+ "@toapi/server": "^1.3.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "typescript": "^5 || ^6.0.0 || ^7.0.0",
@@ -34,11 +34,11 @@
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "git+https://github.com/farbenmeer/tapi.git"
37
+ "url": "git+https://github.com/toapi-js/toapi.git"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsc --noEmit false",
41
41
  "release": "pnpm build && pnpm publish --no-git-checks",
42
- "test": "vitest"
42
+ "test": "vitest run"
43
43
  }
44
44
  }