@toapi/client 1.1.0 → 1.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.
@@ -0,0 +1,88 @@
1
+ import { type Logger } from "@toapi/common";
2
+ export declare const mockLogger: Logger;
3
+ export declare const api: import("@toapi/server").ApiDefinition<{
4
+ "/books": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, {
5
+ id: string;
6
+ title: string;
7
+ }[], {}, {
8
+ id: string;
9
+ title: string;
10
+ }, {}, {
11
+ id: string;
12
+ title: string;
13
+ }, never, never, never, never, never, never, never, never>>;
14
+ } & {
15
+ "/books/:id": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{
16
+ id: string;
17
+ }, {
18
+ id: string;
19
+ title: string;
20
+ }, {
21
+ test: string;
22
+ }, never, never, never, never, never, never, never, never, never, never, never>>;
23
+ } & {
24
+ "/movies/:id": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{
25
+ id: string;
26
+ }, {
27
+ id: string;
28
+ title: string;
29
+ }, {
30
+ test: string;
31
+ }, never, never, never, never, never, never, never, never, never, never, never>>;
32
+ } & {
33
+ "/movies": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, never, never, {
34
+ id: string;
35
+ title: string;
36
+ }, {}, {
37
+ id: string;
38
+ title: string;
39
+ }, never, never, never, never, never, never, never, never>>;
40
+ } & {
41
+ "/formData": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, never, never, {
42
+ [k: string]: FormDataEntryValue;
43
+ }, {}, undefined, never, never, never, never, never, never, never, never>>;
44
+ } & {
45
+ "/authorized": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, {
46
+ authorized: boolean;
47
+ }, {}, never, never, never, never, never, never, never, never, never, never, never>>;
48
+ } & {
49
+ "/files/*path": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{
50
+ path: string;
51
+ }, {
52
+ path: string;
53
+ message: string;
54
+ }, {}, never, never, never, never, never, never, never, never, never, never, never>>;
55
+ } & {
56
+ "/wildcard/*": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, {
57
+ pathname: string;
58
+ }, {}, never, never, never, never, never, never, never, never, never, never, never>>;
59
+ } & {
60
+ "/method": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, {
61
+ method: string;
62
+ }, {}, {
63
+ method: string;
64
+ }, {}, undefined, {
65
+ method: string;
66
+ }, {}, {
67
+ method: string;
68
+ }, {}, undefined, {
69
+ method: string;
70
+ }, {}, undefined>>;
71
+ } & {
72
+ "/error/not-found": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, unknown, {}, never, never, never, never, never, never, never, never, never, never, never>>;
73
+ } & {
74
+ "/cached": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, {
75
+ data: string;
76
+ }, {}, never, never, never, never, never, never, never, never, never, never, never>>;
77
+ } & {
78
+ "/stream": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, AsyncGenerator<{
79
+ value: number;
80
+ }, void, unknown>, {}, never, never, never, never, never, never, never, never, never, never, never>>;
81
+ } & {
82
+ "/optionalQuery": import("@toapi/common").MaybePromise<import("@toapi/common").Route<{}, {
83
+ optional: string | undefined;
84
+ }, {
85
+ optional: string | undefined;
86
+ }, never, never, never, never, never, never, never, never, never, never, never>>;
87
+ }>;
88
+ //# sourceMappingURL=api.mock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.mock.d.ts","sourceRoot":"","sources":["../src/api.mock.ts"],"names":[],"mappings":"AAEA,OAAO,EAAwB,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAOlE,eAAO,MAAM,UAAU,EAAE,MAA4B,CAAC;AAEtD,eAAO,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0LZ,CAAC"}
@@ -0,0 +1,133 @@
1
+ import { z } from "zod/v4";
2
+ import { defineApi, defineHandler } from "@toapi/server";
3
+ import { TResponse, HttpError } from "@toapi/common";
4
+ import { beforeEach, vi } from "vitest";
5
+ const logError = vi.fn();
6
+ beforeEach(() => {
7
+ logError.mockClear();
8
+ });
9
+ export const mockLogger = { error: logError };
10
+ export const api = defineApi({
11
+ logger: mockLogger,
12
+ })
13
+ .route("/books", {
14
+ GET: defineHandler({ authorize: () => true }, async () => TResponse.json([
15
+ { id: "1", title: "Book 1" },
16
+ { id: "2", title: "Book 2" },
17
+ ])),
18
+ POST: defineHandler({
19
+ authorize: () => true,
20
+ body: z.object({
21
+ id: z.string(),
22
+ title: z.string(),
23
+ }),
24
+ }, async (req) => TResponse.json(await req.data())),
25
+ })
26
+ .route("/books/:id", {
27
+ GET: defineHandler({
28
+ params: { id: z.string() },
29
+ query: { test: z.string() },
30
+ authorize: () => true,
31
+ }, async (req) => TResponse.json({
32
+ id: req.params().id,
33
+ title: `Book ${req.params().id}`,
34
+ })),
35
+ })
36
+ .route("/movies/:id", {
37
+ GET: defineHandler({
38
+ params: { id: z.string() },
39
+ query: { test: z.string() },
40
+ authorize: () => true,
41
+ }, async (req) => TResponse.json({
42
+ id: req.params().id,
43
+ title: `Movie ${req.params().id}`,
44
+ }, {
45
+ cache: {
46
+ tags: ["movies"],
47
+ },
48
+ })),
49
+ })
50
+ .route("/movies", {
51
+ POST: defineHandler({
52
+ body: z.object({ id: z.string(), title: z.string() }),
53
+ authorize: () => true,
54
+ }, async (req) => TResponse.json(await req.data(), { cache: { tags: ["movies"] } })),
55
+ })
56
+ .route("/formData", {
57
+ POST: defineHandler({
58
+ authorize: () => true,
59
+ }, async (req) => TResponse.json(Object.fromEntries(await req.formData()), {
60
+ cache: { tags: ["movies"] },
61
+ })),
62
+ })
63
+ .route("/authorized", {
64
+ GET: defineHandler({
65
+ authorize: (req) => {
66
+ const header = req.headers.get("Authorization");
67
+ return header === "Bearer token";
68
+ },
69
+ }, async () => TResponse.json({ authorized: true })),
70
+ })
71
+ .route("/files/*path", {
72
+ GET: defineHandler({
73
+ params: { path: z.string() },
74
+ authorize: () => true,
75
+ }, async (req) => TResponse.json({
76
+ path: req.params().path,
77
+ message: `Accessing file: ${req.params().path}`,
78
+ })),
79
+ })
80
+ .route("/wildcard/*", {
81
+ GET: defineHandler({
82
+ authorize: () => true,
83
+ }, async (req) => TResponse.json({ pathname: req.url })),
84
+ })
85
+ .route("/method", {
86
+ GET: defineHandler({
87
+ authorize: () => true,
88
+ }, async () => TResponse.json({ method: "GET" })),
89
+ POST: defineHandler({
90
+ authorize: () => true,
91
+ }, async () => TResponse.json({ method: "POST" })),
92
+ PUT: defineHandler({
93
+ authorize: () => true,
94
+ }, async () => TResponse.json({ method: "PUT" })),
95
+ PATCH: defineHandler({
96
+ authorize: () => true,
97
+ }, async () => TResponse.json({ method: "PATCH" })),
98
+ DELETE: defineHandler({
99
+ authorize: () => true,
100
+ }, async () => TResponse.json({ method: "DELETE" })),
101
+ })
102
+ .route("/error/not-found", {
103
+ GET: defineHandler({
104
+ authorize: () => true,
105
+ }, async () => {
106
+ throw new HttpError(404, "Not Found");
107
+ }),
108
+ })
109
+ .route("/cached", {
110
+ GET: defineHandler({ authorize: () => true }, async () => TResponse.json({ data: "cached" }, { cache: { tags: ["cached"], ttl: 60 } })),
111
+ })
112
+ .route("/stream", {
113
+ GET: defineHandler({
114
+ authorize: () => true,
115
+ }, async () => {
116
+ async function* stream() {
117
+ for (let i = 0; i < 5; i++) {
118
+ yield { value: i };
119
+ }
120
+ }
121
+ return TResponse.ndjson(stream());
122
+ }),
123
+ })
124
+ .route("/optionalQuery", {
125
+ GET: defineHandler({
126
+ query: {
127
+ optional: z.string().optional(),
128
+ },
129
+ authorize: () => true,
130
+ }, async (req) => {
131
+ return TResponse.json(req.query());
132
+ }),
133
+ });
package/dist/cache.d.ts CHANGED
@@ -1,18 +1,5 @@
1
- import type { Logger, Observable } from "@toapi/common";
2
- type ObservablePromise = Promise<unknown> & Observable<unknown>;
3
- type Subscription = (data: Promise<unknown>) => void;
4
- interface CacheEntry {
5
- current?: {
6
- value: ObservablePromise;
7
- expiresAt?: number;
8
- tags: Set<string>;
9
- };
10
- next?: ObservablePromise;
11
- queuedRevalidation?: Promise<void>;
12
- fetch: () => Promise<Response>;
13
- subscriptions: Set<Subscription>;
14
- timeout: ReturnType<typeof setTimeout> | null;
15
- }
1
+ import { type Logger } from "@toapi/common";
2
+ import { type ObservablePromise } from "./state-machine.js";
16
3
  interface Options {
17
4
  minTTL?: number;
18
5
  maxOverdueTTL?: number;
@@ -25,13 +12,16 @@ export declare class Cache {
25
12
  private maxOverdueTTL;
26
13
  private errorLog;
27
14
  constructor(options: Options);
28
- private emplace;
29
- private revalidateRequest;
30
15
  request(url: string, fetch: () => Promise<Response>): ObservablePromise;
31
- queueRevalidation(url: string, entry: CacheEntry): Promise<void>;
16
+ private setResolveHook;
32
17
  revalidateUrl(url: string): Promise<void>;
33
18
  revalidateTags(tags: string[]): Promise<void>;
34
- queueClearEntry(url: string): void;
19
+ private evictEntry;
20
+ private loadFreshData;
21
+ private subscribe;
22
+ private unsubscribe;
23
+ private setTimer;
24
+ private extractMetadata;
35
25
  }
36
26
  export {};
37
27
  //# sourceMappingURL=cache.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGxD,KAAK,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;AAChE,KAAK,YAAY,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;AAErD,UAAU,UAAU;IAClB,OAAO,CAAC,EAAE;QACR,KAAK,EAAE,iBAAiB,CAAC;QACzB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;KACnB,CAAC;IACF,IAAI,CAAC,EAAE,iBAAiB,CAAC;IACzB,kBAAkB,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,KAAK,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC/B,aAAa,EAAE,GAAG,CAAC,YAAY,CAAC,CAAC;IACjC,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,GAAG,IAAI,CAAC;CAC/C;AAED,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,OAAO;IAef,OAAO,CAAC,iBAAiB;IA+HzB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,iBAAiB;IAqBjE,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU;IAMhD,aAAa,CAAC,GAAG,EAAE,MAAM;IAuBzB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE;IAcnC,eAAe,CAAC,GAAG,EAAE,MAAM;CAiB5B"}
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"}
package/dist/cache.js CHANGED
@@ -1,5 +1,6 @@
1
- import { EXPIRES_AT_HEADER, TAGS_HEADER } from "@toapi/common";
1
+ import { EXPIRES_AT_HEADER, HttpError, TAGS_HEADER, } from "@toapi/common";
2
2
  import { handleResponse } from "./handle-response.js";
3
+ import { init, queue, resolve, revalidate, revert, } from "./state-machine.js";
3
4
  const DEFAULT_MIN_TTL = 5 * 1000;
4
5
  const DEFAULT_MAX_OVERDUE_TTL = 1000;
5
6
  export class Cache {
@@ -13,168 +14,128 @@ export class Cache {
13
14
  this.maxOverdueTTL = options.maxOverdueTTL ?? DEFAULT_MAX_OVERDUE_TTL;
14
15
  this.errorLog = options.logger?.error ?? console.error;
15
16
  }
16
- emplace(url, fetch) {
17
- const currentEntry = this.storage.get(url);
18
- if (currentEntry) {
19
- currentEntry.fetch = fetch;
20
- return currentEntry;
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
+ }
21
27
  }
22
- const newEntry = {
23
- subscriptions: new Set(),
24
- timeout: null,
25
- fetch,
26
- };
27
- this.storage.set(url, newEntry);
28
- return newEntry;
29
- }
30
- revalidateRequest(url, entry) {
31
- if (entry.timeout) {
32
- // clear timeout to make sure it the entry doesn't get revalidated again or removed based on TTL
33
- clearTimeout(entry.timeout);
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;
34
37
  }
35
- // actually load fresh data
36
- const responsePromise = entry.fetch();
37
- const observable = Object.assign(responsePromise.then(handleResponse), {
38
- subscribe: (callback) => {
39
- if (entry.subscriptions.size === 0) {
40
- if (entry.timeout) {
41
- clearTimeout(entry.timeout);
42
- }
43
- if (entry.current?.expiresAt) {
44
- entry.timeout = setTimeout(() => {
45
- this.revalidateRequest(url, entry);
46
- }, entry.current.expiresAt - Date.now() +
47
- Math.round(Math.random() * this.maxOverdueTTL));
48
- }
49
- }
50
- entry.subscriptions.add(callback);
51
- // If there's a newer revalidation in-flight or completed since
52
- // this observable was created, notify the new subscriber immediately
53
- // so it doesn't miss the update.
54
- if (entry.next && entry.next !== observable) {
55
- callback(entry.next);
56
- }
57
- else if (entry.current && entry.current.value !== observable) {
58
- // A revalidation completed between cache.request() returning this
59
- // observable and subscribe() being called — notify with the newer value.
60
- callback(entry.current.value);
61
- }
62
- return () => {
63
- entry.subscriptions.delete(callback);
64
- if (entry.subscriptions.size === 0) {
65
- this.queueClearEntry(url);
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
+ }
66
55
  }
67
- };
68
- },
69
- });
70
- entry.next = observable;
71
- // notify subscribers
72
- for (const callback of entry.subscriptions) {
73
- callback(observable);
74
- }
75
- const next = entry.next;
76
- const waitForRevalidation = async () => {
77
- try {
78
- // wait for request to finish
79
- const response = await responsePromise;
80
- // this will throw an error if the response is not ok
81
- await observable;
82
- if (entry.next !== next) {
83
- // request was superseded by a new request
56
+ entry.state = resolve(entry.state, observable, tags, expiresAt);
57
+ this.setTimer(url, entry);
84
58
  return;
85
59
  }
86
- // update entry
87
- const oldTags = entry.current?.tags ?? new Set();
88
- const newTags = new Set(response.headers.get(TAGS_HEADER)?.split(" ") ?? []);
89
- const expiresAtHeader = response.headers.get(EXPIRES_AT_HEADER);
90
- const expiresAt = expiresAtHeader
91
- ? parseInt(expiresAtHeader, 10)
92
- : undefined;
93
- entry.current = {
94
- value: observable,
95
- tags: newTags,
96
- expiresAt,
97
- };
98
- entry.next = undefined;
99
- // add url to tagIndex for new tags
100
- for (const tag of newTags.difference(oldTags)) {
101
- const urls = this.tagIndex.get(tag);
102
- if (urls) {
103
- urls.add(url);
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
+ }
104
71
  }
105
- else {
106
- this.tagIndex.set(tag, new Set([url]));
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);
107
75
  }
108
- }
109
- // remove url from tagIndex for removed tags
110
- for (const tag of oldTags.difference(newTags)) {
111
- this.tagIndex.get(tag)?.delete(url);
76
+ entry.state = resolve(entry.state, observable, tags, expiresAt);
77
+ this.setTimer(url, entry);
78
+ return;
112
79
  }
113
80
  }
114
- catch (error) {
115
- if (!entry.current) {
116
- // no current value, we are stuck with the rejected promise
117
- entry.current = {
118
- value: observable,
119
- tags: new Set(),
120
- };
121
- }
122
- // there is a current valid value, we'll keep that an drop the error
123
- entry.next = undefined;
124
- await this.errorLog(error);
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;
125
87
  }
126
- finally {
127
- if (entry.subscriptions.size === 0) {
128
- // clear response after minTTL if no subscribers active
129
- this.queueClearEntry(url);
130
- }
88
+ const entry = this.storage.get(url);
89
+ if (!entry) {
90
+ // evicted, ignore
91
+ 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);
103
+ return;
131
104
  }
132
- };
133
- return { observable, revalidated: waitForRevalidation() };
134
- }
135
- request(url, fetch) {
136
- const now = Date.now();
137
- let entry = this.emplace(url, fetch);
138
- if (entry.current) {
139
- // has cached value, serve from cache
140
- return entry.current.value;
141
- }
142
- // no cached value
143
- if (entry.next) {
144
- // already loading, serve from next
145
- return entry.next;
146
105
  }
147
- // nothing cached, load fresh
148
- const { observable } = this.revalidateRequest(url, entry);
149
- return observable;
150
106
  }
151
- async queueRevalidation(url, entry) {
152
- await entry.next;
153
- const { revalidated } = this.revalidateRequest(url, entry);
154
- await revalidated;
155
- }
156
- async revalidateUrl(url) {
107
+ revalidateUrl(url) {
157
108
  const entry = this.storage.get(url);
109
+ // no entry, nothing to do
158
110
  if (!entry)
159
- return;
160
- if (entry.next) {
161
- // we are currently fetching that url
162
- if (entry.queuedRevalidation) {
163
- // another revalidation is already queued, wait for it to finish
164
- await entry.queuedRevalidation;
165
- }
166
- else {
167
- // queue another revalidation
168
- const queuedRevalidation = this.queueRevalidation(url, entry);
169
- entry.queuedRevalidation = queuedRevalidation;
170
- await queuedRevalidation;
171
- }
111
+ return Promise.resolve();
112
+ // no subscribers, evict the entry
113
+ if (entry.subscriptions.size === 0) {
114
+ this.evictEntry(url);
115
+ return Promise.resolve();
172
116
  }
173
- else {
174
- // there is no request in flight, revalidate immediately
175
- const { revalidated } = this.revalidateRequest(url, entry);
176
- await revalidated;
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);
177
137
  }
138
+ return resolved;
178
139
  }
179
140
  async revalidateTags(tags) {
180
141
  // collect urls to revalidate
@@ -188,20 +149,92 @@ export class Cache {
188
149
  // revalidate urls and wait until all are resolved or rejected
189
150
  await Promise.allSettled(Array.from(urls).map((url) => this.revalidateUrl(url)));
190
151
  }
191
- queueClearEntry(url) {
152
+ evictEntry(url) {
192
153
  const entry = this.storage.get(url);
193
154
  if (!entry)
194
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);
162
+ }
163
+ }
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) {
195
214
  if (entry.timeout) {
196
215
  clearTimeout(entry.timeout);
197
216
  }
198
- entry.timeout = setTimeout(() => {
199
- if (entry.current) {
200
- for (const tag of entry.current.tags) {
201
- this.tagIndex.get(tag)?.delete(url);
202
- }
203
- }
204
- this.storage.delete(url);
205
- }, this.minTTL);
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;
232
+ }
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
+ };
206
239
  }
207
240
  }
@@ -0,0 +1,2 @@
1
+ export declare const requestHandler: (req: Request) => Promise<Response>;
2
+ //# sourceMappingURL=request-handler.mock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-handler.mock.d.ts","sourceRoot":"","sources":["../src/request-handler.mock.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,cAAc,qCAAkD,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { createRequestHandler } from "@toapi/server";
2
+ import { api } from "./api.mock.js";
3
+ export const requestHandler = createRequestHandler(api, { basePath: "/api" });
@@ -0,0 +1,28 @@
1
+ import type { Observable } from "@toapi/common";
2
+ export type ObservablePromise = Promise<unknown> & Observable<unknown>;
3
+ export type Pending = {
4
+ status: "pending";
5
+ next: ObservablePromise;
6
+ queued?: ObservablePromise;
7
+ };
8
+ export type Cached = {
9
+ status: "cached";
10
+ value: ObservablePromise;
11
+ expiresAt?: number;
12
+ tags: Set<string>;
13
+ };
14
+ export type Revalidating = {
15
+ status: "revalidating";
16
+ value: ObservablePromise;
17
+ tags: Set<string>;
18
+ expiresAt?: number;
19
+ next: ObservablePromise;
20
+ queued?: ObservablePromise;
21
+ };
22
+ export type CacheEntryState = Pending | Cached | Revalidating;
23
+ export declare function init(next: ObservablePromise): Pending;
24
+ export declare function revalidate(state: Cached, next: ObservablePromise): Revalidating;
25
+ export declare function queue<S extends Revalidating | Pending>(state: S, queued: ObservablePromise): S;
26
+ export declare function resolve(state: Pending | Revalidating, value: ObservablePromise, tags: Set<string>, expiresAt?: number): CacheEntryState;
27
+ export declare function revert(state: Revalidating): Cached;
28
+ //# sourceMappingURL=state-machine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;AAEvE,MAAM,MAAM,OAAO,GAAG;IACpB,MAAM,EAAE,SAAS,CAAC;IAClB,IAAI,EAAE,iBAAiB,CAAC;IACxB,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IACnB,MAAM,EAAE,QAAQ,CAAC;IACjB,KAAK,EAAE,iBAAiB,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,cAAc,CAAC;IACvB,KAAK,EAAE,iBAAiB,CAAC;IACzB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,iBAAiB,CAAC;IACxB,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,MAAM,GAAG,YAAY,CAAC;AAE9D,wBAAgB,IAAI,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAKrD;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,iBAAiB,GACtB,YAAY,CAQd;AAED,wBAAgB,KAAK,CAAC,CAAC,SAAS,YAAY,GAAG,OAAO,EACpD,KAAK,EAAE,CAAC,EACR,MAAM,EAAE,iBAAiB,GACxB,CAAC,CAKH;AAED,wBAAgB,OAAO,CACrB,KAAK,EAAE,OAAO,GAAG,YAAY,EAC7B,KAAK,EAAE,iBAAiB,EACxB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,eAAe,CAmBjB;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAOlD"}
@@ -0,0 +1,50 @@
1
+ export function init(next) {
2
+ return {
3
+ status: "pending",
4
+ next,
5
+ };
6
+ }
7
+ export function revalidate(state, next) {
8
+ return {
9
+ status: "revalidating",
10
+ value: state.value,
11
+ tags: state.tags,
12
+ expiresAt: state.expiresAt,
13
+ next,
14
+ };
15
+ }
16
+ export function queue(state, queued) {
17
+ return {
18
+ ...state,
19
+ queued,
20
+ };
21
+ }
22
+ export function resolve(state, value, tags, expiresAt) {
23
+ if (state.next !== value)
24
+ return state;
25
+ if (state.queued) {
26
+ return {
27
+ status: "revalidating",
28
+ value,
29
+ tags,
30
+ expiresAt,
31
+ next: state.queued,
32
+ };
33
+ }
34
+ else {
35
+ return {
36
+ status: "cached",
37
+ value,
38
+ tags,
39
+ expiresAt,
40
+ };
41
+ }
42
+ }
43
+ export function revert(state) {
44
+ return {
45
+ status: "cached",
46
+ value: state.value,
47
+ tags: state.tags,
48
+ expiresAt: state.expiresAt,
49
+ };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toapi/client",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "author": {
5
5
  "name": "Michel Smola",
6
6
  "email": "michel.smola@farbenmeer.de"
@@ -20,12 +20,13 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
- "@toapi/common": "^1.1.0"
23
+ "@toapi/common": "^1.2.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^25.0.3",
27
27
  "vitest": "^4.0.16",
28
- "zod": "^4.0.17"
28
+ "zod": "^4.0.17",
29
+ "@toapi/server": "^1.2.0"
29
30
  },
30
31
  "peerDependencies": {
31
32
  "typescript": "^5 || ^6.0.0",
@@ -38,6 +39,6 @@
38
39
  "scripts": {
39
40
  "build": "tsc --noEmit false",
40
41
  "release": "pnpm build && pnpm publish --no-git-checks",
41
- "test": "vitest run --passWithNoTests"
42
+ "test": "vitest"
42
43
  }
43
44
  }