@toapi/client 1.2.4 → 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;AAY5B,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;YAuBzD,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;IAqBrB,OAAO,CAAC,SAAS;IA4CjB,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,240 +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
- fetch,
33
- subscriptions: new Set(),
34
- timeout: null,
35
- });
36
- return observable;
37
- }
38
- }
39
- async setResolveHook(url, responsePromise, observable) {
40
- try {
41
- const response = await responsePromise;
42
- await observable;
43
- const entry = this.storage.get(url);
44
- switch (entry?.state.status) {
45
- case "pending": {
46
- const { tags, expiresAt } = this.extractMetadata(response);
47
- for (const tag of tags) {
48
- const urls = this.tagIndex.get(tag);
49
- if (urls) {
50
- urls.add(url);
51
- }
52
- else {
53
- this.tagIndex.set(tag, new Set([url]));
54
- }
55
- }
56
- entry.state = resolve(entry.state, observable, tags, expiresAt);
57
- this.setTimer(url, entry);
58
- 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);
59
22
  }
60
- case "revalidating": {
61
- const { tags, expiresAt } = this.extractMetadata(response);
62
- // add url to tagIndex for new tags
63
- for (const tag of tags.difference(entry.state.tags)) {
64
- const urls = this.tagIndex.get(tag);
65
- if (urls) {
66
- urls.add(url);
67
- }
68
- else {
69
- this.tagIndex.set(tag, new Set([url]));
70
- }
71
- }
72
- // remove url from tagIndex for removed tags
73
- for (const tag of entry.state.tags.difference(tags)) {
74
- this.tagIndex.get(tag)?.delete(url);
75
- }
76
- entry.state = resolve(entry.state, observable, tags, expiresAt);
77
- this.setTimer(url, entry);
78
- 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);
79
36
  }
80
37
  }
81
- }
82
- catch (error) {
83
- if (error instanceof HttpError && error.status < 500) {
84
- // not found, no access etc, evict the entry
85
- this.evictEntry(url);
86
- 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]));
87
41
  }
88
- const entry = this.storage.get(url);
89
- if (!entry) {
90
- // evicted, ignore
42
+ if (!expiresAt)
91
43
  return;
92
- }
93
- // log every other error
94
- this.errorLog(error);
95
- switch (entry.state.status) {
96
- case "pending":
97
- // no point to caching a failed request
98
- this.evictEntry(url);
99
- return;
100
- case "revalidating":
101
- // revert to last good state
102
- 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)
103
50
  return;
104
- }
105
- }
51
+ this.storage.delete(url);
52
+ this.pubSub.publish(new Set([url]));
53
+ }, Math.max(0, timeUntilRevalidation));
54
+ })
55
+ .catch(() => { });
106
56
  }
107
- revalidateUrl(url) {
108
- const entry = this.storage.get(url);
109
- // no entry, nothing to do
110
- if (!entry)
111
- return Promise.resolve();
112
- // no subscribers, evict the entry
113
- if (entry.subscriptions.size === 0) {
114
- this.evictEntry(url);
115
- return Promise.resolve();
116
- }
117
- const { observable, resolved } = this.loadFreshData(url, entry.fetch, entry.state.status === "revalidating"
118
- ? entry.state.next
119
- : Promise.resolve());
120
- switch (entry.state.status) {
121
- case "pending":
122
- // pending is stale now, replace it with a fresh request
123
- entry.state = init(observable);
124
- break;
125
- case "cached":
126
- // revalidate
127
- entry.state = revalidate(entry.state, observable);
128
- break;
129
- case "revalidating":
130
- // already revalidating, queue
131
- entry.state = queue(entry.state, observable);
132
- break;
133
- }
134
- // notify subscribers
135
- for (const callback of entry.subscriptions) {
136
- callback(observable);
137
- }
138
- return resolved;
57
+ get(url) {
58
+ return this.storage.get(url);
139
59
  }
140
- async revalidateTags(tags) {
141
- // collect urls to revalidate
142
- 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();
143
66
  for (const tag of tags) {
144
67
  const taggedUrls = this.tagIndex.get(tag);
145
68
  if (!taggedUrls)
146
69
  continue;
147
- urls = urls.union(taggedUrls);
148
- }
149
- // revalidate urls and wait until all are resolved or rejected
150
- await Promise.allSettled(Array.from(urls).map((url) => this.revalidateUrl(url)));
151
- }
152
- evictEntry(url) {
153
- const entry = this.storage.get(url);
154
- if (!entry)
155
- return;
156
- switch (entry.state.status) {
157
- case "cached":
158
- case "revalidating":
159
- // remove tags from index
160
- for (const tag of entry.state.tags) {
161
- this.tagIndex.get(tag)?.delete(url);
70
+ for (const url of taggedUrls) {
71
+ if (this.storage.has(url)) {
72
+ this.storage.delete(url);
162
73
  }
74
+ else {
75
+ taggedUrls.delete(url);
76
+ }
77
+ urls.add(url);
78
+ }
163
79
  }
164
- this.storage.delete(url);
165
- }
166
- loadFreshData(url, fetch, waitFor = Promise.resolve()) {
167
- // actually load fresh data
168
- const responsePromise = waitFor.then(() => fetch());
169
- const observable = Object.assign(responsePromise.then(handleResponse), {
170
- subscribe: (callback) => this.subscribe(url, fetch, callback),
171
- });
172
- const resolved = this.setResolveHook(url, responsePromise, observable);
173
- return { observable, resolved };
174
- }
175
- subscribe(url, fetch, callback) {
176
- const entry = this.storage.get(url);
177
- if (!entry) {
178
- // has been evicted
179
- const { observable } = this.loadFreshData(url, fetch);
180
- const newEntry = {
181
- state: init(observable),
182
- fetch,
183
- subscriptions: new Set([callback]),
184
- timeout: null,
185
- };
186
- this.storage.set(url, newEntry);
187
- callback(observable);
188
- return () => this.unsubscribe(url, newEntry, callback);
189
- }
190
- // set up subscription
191
- entry.subscriptions.add(callback);
192
- if (entry.subscriptions.size === 1) {
193
- // was scheduled for eviction, reset the timer
194
- this.setTimer(url, entry);
195
- }
196
- // Immediately push the current value to the new subscriber
197
- // so it doesn't miss any updates
198
- switch (entry.state.status) {
199
- case "cached":
200
- callback(entry.state.value);
201
- break;
202
- case "pending":
203
- case "revalidating":
204
- callback(entry.state.queued ?? entry.state.next);
205
- break;
206
- }
207
- return () => this.unsubscribe(url, entry, callback);
208
- }
209
- unsubscribe(url, entry, callback) {
210
- entry.subscriptions.delete(callback);
211
- this.setTimer(url, entry);
212
- }
213
- setTimer(url, entry) {
214
- if (entry.timeout) {
215
- clearTimeout(entry.timeout);
216
- }
217
- if (entry.subscriptions.size === 0) {
218
- // no active subscriptions, set up eviction timer
219
- entry.timeout = setTimeout(() => this.evictEntry(url), this.minTTL);
220
- return;
221
- }
222
- if (entry.state.status === "cached" && entry.state.expiresAt) {
223
- // entry expires, set up revalidation timer
224
- const timeUntilRevalidation = entry.state.expiresAt -
225
- Date.now() +
226
- Math.round(Math.random() * this.maxOverdueTTL);
227
- entry.timeout = setTimeout(() => this.revalidateUrl(url), Math.max(0, timeUntilRevalidation));
228
- return;
229
- }
230
- // no timeout necessary
231
- entry.timeout = null;
80
+ return this.pubSub.publish(urls);
232
81
  }
233
- extractMetadata(response) {
234
- const expiresAtHeader = response.headers.get(EXPIRES_AT_HEADER);
235
- return {
236
- tags: new Set(response.headers.get(TAGS_HEADER)?.split(" ") ?? []),
237
- expiresAt: expiresAtHeader ? parseInt(expiresAtHeader, 10) : undefined,
238
- };
82
+ invalidateAll() {
83
+ const urls = new Set(this.storage.keys());
84
+ this.storage.clear();
85
+ this.tagIndex.clear();
86
+ return this.pubSub.publish(urls);
239
87
  }
240
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.4",
3
+ "version": "1.3.0",
4
4
  "author": {
5
5
  "name": "Michel Smola",
6
6
  "email": "michel.smola@farbenmeer.de"
@@ -20,25 +20,25 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
- "@toapi/common": "^1.2.4"
23
+ "@toapi/common": "^1.3.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^25.0.3",
27
- "vitest": "^4.0.16",
27
+ "vitest": "^5.0.0",
28
28
  "zod": "^4.0.17",
29
- "@toapi/server": "^1.2.4"
29
+ "@toapi/server": "^1.3.0"
30
30
  },
31
31
  "peerDependencies": {
32
- "typescript": "^5 || ^6.0.0",
32
+ "typescript": "^5 || ^6.0.0 || ^7.0.0",
33
33
  "zod": "^4.0.17"
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
  }