@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.
- package/dist/api.mock.d.ts +88 -0
- package/dist/api.mock.d.ts.map +1 -0
- package/dist/api.mock.js +133 -0
- package/dist/cache.d.ts +9 -19
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +188 -155
- package/dist/request-handler.mock.d.ts +2 -0
- package/dist/request-handler.mock.d.ts.map +1 -0
- package/dist/request-handler.mock.js +3 -0
- package/dist/state-machine.d.ts +28 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +50 -0
- package/package.json +5 -4
|
@@ -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"}
|
package/dist/api.mock.js
ADDED
|
@@ -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
|
|
2
|
-
type ObservablePromise
|
|
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
|
-
|
|
16
|
+
private setResolveHook;
|
|
32
17
|
revalidateUrl(url: string): Promise<void>;
|
|
33
18
|
revalidateTags(tags: string[]): Promise<void>;
|
|
34
|
-
|
|
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
|
package/dist/cache.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"
|
|
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
|
-
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 @@
|
|
|
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,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.
|
|
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.
|
|
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
|
|
42
|
+
"test": "vitest"
|
|
42
43
|
}
|
|
43
44
|
}
|