@toapi/client 0.12.1

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,38 @@
1
+ import type { Logger } from "@toapi/common";
2
+ import type { Observable } from "./client-types.js";
3
+ type ObservablePromise = Promise<unknown> & Observable<unknown>;
4
+ type Subscription = (data: Promise<unknown>) => void;
5
+ interface CacheEntry {
6
+ current?: {
7
+ value: ObservablePromise;
8
+ expiresAt?: number;
9
+ tags: Set<string>;
10
+ };
11
+ next?: ObservablePromise;
12
+ queuedRevalidation?: Promise<void>;
13
+ fetch: () => Promise<Response>;
14
+ subscriptions: Set<Subscription>;
15
+ timeout: ReturnType<typeof setTimeout> | null;
16
+ }
17
+ interface Options {
18
+ minTTL?: number;
19
+ maxOverdueTTL?: number;
20
+ logger?: Logger;
21
+ }
22
+ export declare class Cache {
23
+ private storage;
24
+ private tagIndex;
25
+ private minTTL;
26
+ private maxOverdueTTL;
27
+ private errorLog;
28
+ constructor(options: Options);
29
+ private emplace;
30
+ private revalidateRequest;
31
+ request(url: string, fetch: () => Promise<Response>): ObservablePromise;
32
+ queueRevalidation(url: string, entry: CacheEntry): Promise<void>;
33
+ revalidateUrl(url: string): Promise<void>;
34
+ revalidateTags(tags: string[]): Promise<void>;
35
+ queueClearEntry(url: string): void;
36
+ }
37
+ export {};
38
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGpD,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"}
package/dist/cache.js ADDED
@@ -0,0 +1,207 @@
1
+ import { EXPIRES_AT_HEADER, TAGS_HEADER } from "@toapi/common";
2
+ import { handleResponse } from "./handle-response.js";
3
+ const DEFAULT_MIN_TTL = 5 * 1000;
4
+ const DEFAULT_MAX_OVERDUE_TTL = 1000;
5
+ export class Cache {
6
+ storage = new Map();
7
+ tagIndex = new Map();
8
+ minTTL;
9
+ maxOverdueTTL;
10
+ errorLog;
11
+ constructor(options) {
12
+ this.minTTL = options.minTTL ?? DEFAULT_MIN_TTL;
13
+ this.maxOverdueTTL = options.maxOverdueTTL ?? DEFAULT_MAX_OVERDUE_TTL;
14
+ this.errorLog = options.logger?.error ?? console.error;
15
+ }
16
+ emplace(url, fetch) {
17
+ const currentEntry = this.storage.get(url);
18
+ if (currentEntry) {
19
+ currentEntry.fetch = fetch;
20
+ return currentEntry;
21
+ }
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);
34
+ }
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);
66
+ }
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
84
+ return;
85
+ }
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);
104
+ }
105
+ else {
106
+ this.tagIndex.set(tag, new Set([url]));
107
+ }
108
+ }
109
+ // remove url from tagIndex for removed tags
110
+ for (const tag of oldTags.difference(newTags)) {
111
+ this.tagIndex.get(tag)?.delete(url);
112
+ }
113
+ }
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);
125
+ }
126
+ finally {
127
+ if (entry.subscriptions.size === 0) {
128
+ // clear response after minTTL if no subscribers active
129
+ this.queueClearEntry(url);
130
+ }
131
+ }
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
+ }
147
+ // nothing cached, load fresh
148
+ const { observable } = this.revalidateRequest(url, entry);
149
+ return observable;
150
+ }
151
+ async queueRevalidation(url, entry) {
152
+ await entry.next;
153
+ const { revalidated } = this.revalidateRequest(url, entry);
154
+ await revalidated;
155
+ }
156
+ async revalidateUrl(url) {
157
+ const entry = this.storage.get(url);
158
+ 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
+ }
172
+ }
173
+ else {
174
+ // there is no request in flight, revalidate immediately
175
+ const { revalidated } = this.revalidateRequest(url, entry);
176
+ await revalidated;
177
+ }
178
+ }
179
+ async revalidateTags(tags) {
180
+ // collect urls to revalidate
181
+ let urls = new Set();
182
+ for (const tag of tags) {
183
+ const taggedUrls = this.tagIndex.get(tag);
184
+ if (!taggedUrls)
185
+ continue;
186
+ urls = urls.union(taggedUrls);
187
+ }
188
+ // revalidate urls and wait until all are resolved or rejected
189
+ await Promise.allSettled(Array.from(urls).map((url) => this.revalidateUrl(url)));
190
+ }
191
+ queueClearEntry(url) {
192
+ const entry = this.storage.get(url);
193
+ if (!entry)
194
+ return;
195
+ if (entry.timeout) {
196
+ clearTimeout(entry.timeout);
197
+ }
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);
206
+ }
207
+ }
@@ -0,0 +1,55 @@
1
+ import type { Path as BasePath } from "@toapi/common";
2
+ import type { BaseRoute } from "@toapi/common";
3
+ import type { Handler as BaseHandler } from "@toapi/common";
4
+ import type { MaybePromise } from "@toapi/common";
5
+ type Segment<Path> = Path extends `/${infer Segment}/${string}` ? Segment : Path extends `/${infer Segment}` ? Segment : never;
6
+ type Rest<Path, Segment extends string> = Path extends `/${Segment}/${infer Rest}` ? `/${Rest}` : never;
7
+ export type Client<Routes extends Record<BasePath, MaybePromise<BaseRoute>>> = {
8
+ [segment in Segment<keyof Routes> as segment extends `:${string}` ? string | number : segment extends `*${string}` ? string | number : segment]: (Extract<keyof Routes, `/${segment}`> extends never ? {} : ClientRoute<Routes[`/${segment}`]>) & (Exclude<keyof Routes, `/${segment}`> extends never ? {} : Client<{
9
+ [rest in Rest<keyof Routes, segment>]: Routes[`/${segment}${rest}`];
10
+ }>);
11
+ };
12
+ type ClientRoute<Route extends MaybePromise<BaseRoute>> = {
13
+ get: QueryWithoutBody<Awaited<Route>["GET"]>;
14
+ post: MutationWithBody<Awaited<Route>["POST"]>;
15
+ delete: MutationWithoutBody<Awaited<Route>["DELETE"]>;
16
+ put: MutationWithBody<Awaited<Route>["PUT"]>;
17
+ patch: MutationWithBody<Awaited<Route>["PATCH"]>;
18
+ revalidate: () => Promise<void>;
19
+ };
20
+ export type QueryWithoutBody<Handler extends BaseHandler<any, any, any, undefined> | undefined> = Handler extends undefined ? never : RequiredKeys<QueryType<Handler>> extends never ? (query?: Partial<QueryType<Handler>>, req?: RequestInit) => Promise<ResponseType<Handler>> & Observable<ResponseType<Handler>> : (query: OptionalizeUndefined<QueryType<Handler>>, req?: RequestInit) => Promise<ResponseType<Handler>> & Observable<ResponseType<Handler>>;
21
+ export type Observable<T> = {
22
+ subscribe(callback: (value: Promise<T>) => void): () => void;
23
+ };
24
+ export type Revalidating = {
25
+ revalidated: Promise<void>;
26
+ };
27
+ export type MutationWithoutBody<Handler extends BaseHandler<any, any, any, undefined> | undefined> = Handler extends undefined ? never : RequiredKeys<QueryType<Handler>> extends never ? (query?: Partial<QueryType<Handler>>, req?: RequestInit) => Promise<ResponseType<Handler>> & Revalidating : (query: OptionalizeUndefined<QueryType<Handler>>, req?: RequestInit) => Promise<ResponseType<Handler>> & Revalidating;
28
+ export type MutationWithBody<Handler extends BaseHandler<any, any, any, unknown> | undefined> = Handler extends undefined ? never : (body?: BodyType<Handler> | FormData, req?: RequestInit & {
29
+ query?: OptionalizeUndefined<QueryType<Handler>>;
30
+ }) => Promise<ResponseType<Handler>> & Revalidating;
31
+ type QueryType<Handler extends {
32
+ schema: {
33
+ __q?: any;
34
+ };
35
+ } | undefined> = NonNullable<NonNullable<Handler>["schema"]["__q"]>;
36
+ type RequiredKeys<T> = {
37
+ [K in keyof T]-?: undefined extends T[K] ? never : K;
38
+ }[keyof T];
39
+ type OptionalizeUndefined<T> = {
40
+ [K in keyof T as undefined extends T[K] ? never : K]: T[K];
41
+ } & {
42
+ [K in keyof T as undefined extends T[K] ? K : never]?: T[K];
43
+ };
44
+ type BodyType<Handler extends {
45
+ schema: {
46
+ __b?: any;
47
+ };
48
+ } | undefined> = NonNullable<NonNullable<Handler>["schema"]["__b"]>;
49
+ type ResponseType<Handler extends {
50
+ schema: {
51
+ __r?: any;
52
+ };
53
+ } | undefined> = NonNullable<NonNullable<Handler>["schema"]["__r"]>;
54
+ export {};
55
+ //# sourceMappingURL=client-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-types.d.ts","sourceRoot":"","sources":["../src/client-types.ts"],"names":[],"mappings":"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"}
File without changes
@@ -0,0 +1,15 @@
1
+ import type { Logger } from "@toapi/common";
2
+ import type { MaybePromise } from "@toapi/common";
3
+ import type { Path as BasePath } from "@toapi/common";
4
+ import type { BaseRoute } from "@toapi/common";
5
+ import type { Client } from "./client-types.js";
6
+ interface Options {
7
+ fetch?: (url: string, init: RequestInit) => Promise<Response>;
8
+ minTTL?: number;
9
+ maxOverdueTTL?: number;
10
+ logger?: Logger;
11
+ invalidationsUrl?: string | false;
12
+ }
13
+ export declare function createFetchClient<Routes extends Record<BasePath, MaybePromise<BaseRoute>>>(apiUrl: string, options?: Options): Client<Routes>;
14
+ export {};
15
+ //# sourceMappingURL=create-fetch-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-fetch-client.d.ts","sourceRoot":"","sources":["../src/create-fetch-client.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,KAAK,EAAE,IAAI,IAAI,QAAQ,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE/C,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"}
@@ -0,0 +1,152 @@
1
+ import { INVALIDATION_POST_EVENT, INVALIDATIONS_ROUTE, TAGS_HEADER, } from "@toapi/common";
2
+ import { Cache } from "./cache.js";
3
+ import { handleResponse } from "./handle-response.js";
4
+ 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
+ }
32
+ export function createFetchClient(apiUrl, options = {}) {
33
+ const fetch = options.fetch ?? globalFetch;
34
+ const cache = new Cache({
35
+ minTTL: options.minTTL,
36
+ maxOverdueTTL: options.maxOverdueTTL,
37
+ logger: options.logger,
38
+ });
39
+ const invalidationsUrl = options.invalidationsUrl === false
40
+ ? null
41
+ : (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
+ }
63
+ function load(url, init = {}) {
64
+ return cache.request(url, () => fetch(url, {
65
+ method: "GET",
66
+ ...init,
67
+ }));
68
+ }
69
+ async function revalidate(url) {
70
+ await cache.revalidateUrl(url);
71
+ }
72
+ function mutate(method, url, data, init = {}) {
73
+ const headers = new Headers(init.headers);
74
+ if (!(data instanceof FormData) && !headers.has("Content-Type")) {
75
+ headers.set("Content-Type", "application/json");
76
+ }
77
+ const res = fetch(url, {
78
+ method,
79
+ body: typeof data === "undefined"
80
+ ? undefined
81
+ : data instanceof FormData
82
+ ? data
83
+ : JSON.stringify(data),
84
+ ...init,
85
+ headers,
86
+ });
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) => {
91
+ await options?.logger?.error?.(error);
92
+ throw error;
93
+ }), {
94
+ revalidated: revalidationPromise,
95
+ });
96
+ }
97
+ return new Proxy(() => { }, {
98
+ get(_target, prop) {
99
+ return createProxy({
100
+ revalidate,
101
+ load,
102
+ mutate,
103
+ }, apiUrl, prop);
104
+ },
105
+ });
106
+ }
107
+ function buildUrl(baseUrl, query) {
108
+ if (!query || typeof query !== "object")
109
+ return baseUrl;
110
+ const searchParams = new URLSearchParams();
111
+ for (const [key, value] of Object.entries(query)) {
112
+ if (typeof value === "undefined")
113
+ continue;
114
+ searchParams.append(key, value);
115
+ }
116
+ return searchParams.size > 0 ? baseUrl + "?" + searchParams : baseUrl;
117
+ }
118
+ function createProxy(methods, baseUrl, lastProp) {
119
+ return new Proxy(() => { }, {
120
+ get(_target, prop) {
121
+ if (typeof prop === "symbol") {
122
+ if (prop === Symbol.toPrimitive) {
123
+ return function toPrimitive() {
124
+ return `[TApi Route ${baseUrl}/${lastProp}]`;
125
+ };
126
+ }
127
+ return undefined;
128
+ }
129
+ return createProxy(methods, baseUrl + "/" + lastProp, prop);
130
+ },
131
+ apply(_target, _thisArg, args) {
132
+ switch (lastProp) {
133
+ case "revalidate": {
134
+ return methods.revalidate(buildUrl(baseUrl, args[0]));
135
+ }
136
+ case "get": {
137
+ return methods.load(buildUrl(baseUrl, args[0]), args[1]);
138
+ }
139
+ case "delete": {
140
+ return methods.mutate("DELETE", buildUrl(baseUrl, args[0]), undefined, args[1]);
141
+ }
142
+ case "post":
143
+ case "put":
144
+ case "patch": {
145
+ return methods.mutate(lastProp.toUpperCase(), buildUrl(baseUrl, args[1]?.query), args[0], args[1]);
146
+ }
147
+ default:
148
+ throw new Error(`Tapi: Unsupported method: ${lastProp}`);
149
+ }
150
+ },
151
+ });
152
+ }
@@ -0,0 +1,2 @@
1
+ export declare function handleResponse(res: Response): Promise<any>;
2
+ //# sourceMappingURL=handle-response.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handle-response.d.ts","sourceRoot":"","sources":["../src/handle-response.ts"],"names":[],"mappings":"AAGA,wBAAsB,cAAc,CAAC,GAAG,EAAE,QAAQ,gBAmBjD"}
@@ -0,0 +1,54 @@
1
+ import { ZodError } from "zod";
2
+ import { HttpError } from "@toapi/common";
3
+ export async function handleResponse(res) {
4
+ switch (res.status) {
5
+ case 200:
6
+ return parseBody(res);
7
+ // @ts-expect-error explicit fallthrough
8
+ case 400:
9
+ if (res.headers.get("Content-Type") === "application/json+zodissues") {
10
+ const issues = await res.json();
11
+ throw new ZodError(issues);
12
+ }
13
+ default:
14
+ if (res.headers.get("Content-Type") === "application/json+httperror") {
15
+ const { message, data } = await res.json();
16
+ throw new HttpError(res.status, message, data);
17
+ }
18
+ throw new HttpError(res.status, res.statusText);
19
+ }
20
+ }
21
+ async function parseBody(res) {
22
+ const contentType = res.headers.get("Content-Type");
23
+ if (contentType == "application/x-ndjson") {
24
+ return streamBody(res);
25
+ }
26
+ if (contentType?.startsWith("application/json")) {
27
+ return await res.json();
28
+ }
29
+ if (contentType?.startsWith("text/plain")) {
30
+ return await res.text();
31
+ }
32
+ const contentLength = res.headers.get("Content-Length");
33
+ if (contentLength === "0") {
34
+ return undefined;
35
+ }
36
+ throw new Error(`Tapi: Unsupported content type: ${contentType}`);
37
+ }
38
+ async function* streamBody(res) {
39
+ if (!res.body)
40
+ return;
41
+ let buffer = "";
42
+ const textDecoder = new TextDecoder();
43
+ for await (const chunk of res.body) {
44
+ buffer += textDecoder.decode(chunk);
45
+ const lines = buffer.split("\n");
46
+ buffer = lines.pop() ?? "";
47
+ for (const line of lines) {
48
+ yield JSON.parse(line);
49
+ }
50
+ }
51
+ if (buffer) {
52
+ yield JSON.parse(buffer);
53
+ }
54
+ }
@@ -0,0 +1,7 @@
1
+ export type { Client, Observable } from "./client-types.js";
2
+ export { createFetchClient } from "./create-fetch-client.js";
3
+ export type { GetRoute, PostRoute } from "./route-types.js";
4
+ export { HttpError } from "@toapi/common";
5
+ export { INVALIDATIONS_ROUTE } from "@toapi/common";
6
+ export type { Logger } from "@toapi/common";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACpD,YAAY,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createFetchClient } from "./create-fetch-client.js";
2
+ export { HttpError } from "@toapi/common";
3
+ export { INVALIDATIONS_ROUTE } from "@toapi/common";
@@ -0,0 +1,11 @@
1
+ export declare class PubSub {
2
+ 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;
10
+ }
11
+ //# sourceMappingURL=pub-sub.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,33 @@
1
+ export class PubSub {
2
+ subscriptions = new Map();
3
+ onClear;
4
+ constructor({ onClear }) {
5
+ this.onClear = onClear;
6
+ }
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);
14
+ return () => {
15
+ urlSubscriptions.delete(callback);
16
+ if (urlSubscriptions.size === 0) {
17
+ this.subscriptions.delete(url);
18
+ this.onClear(url);
19
+ }
20
+ };
21
+ }
22
+ publish(url, data) {
23
+ const urlSubscriptions = this.subscriptions.get(url);
24
+ if (urlSubscriptions) {
25
+ for (const callback of urlSubscriptions.values()) {
26
+ callback(data);
27
+ }
28
+ }
29
+ }
30
+ has(url) {
31
+ return this.subscriptions.has(url);
32
+ }
33
+ }
@@ -0,0 +1,4 @@
1
+ import type { Observable } from "./client-types.js";
2
+ export type GetRoute<R, Q = undefined> = Q extends undefined ? (query?: {}, req?: RequestInit) => Promise<R> & Observable<R> : (query: Q, req?: RequestInit) => Promise<R> & Observable<R>;
3
+ export type PostRoute<R, Q = unknown, B = unknown> = (query: Q | FormData, body: B | FormData, req?: RequestInit) => Promise<R>;
4
+ //# sourceMappingURL=route-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-types.d.ts","sourceRoot":"","sources":["../src/route-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,MAAM,MAAM,QAAQ,CAAC,CAAC,EAAE,CAAC,GAAG,SAAS,IAAI,CAAC,SAAS,SAAS,GACxD,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,GAC7D,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;AAIhE,MAAM,MAAM,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO,IAAI,CACnD,KAAK,EAAE,CAAC,GAAG,QAAQ,EACnB,IAAI,EAAE,CAAC,GAAG,QAAQ,EAClB,GAAG,CAAC,EAAE,WAAW,KACd,OAAO,CAAC,CAAC,CAAC,CAAC"}
File without changes
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@toapi/client",
3
+ "version": "0.12.1",
4
+ "author": {
5
+ "name": "Michel Smola",
6
+ "email": "michel.smola@farbenmeer.de"
7
+ },
8
+ "type": "module",
9
+ "module": "dist/index.js",
10
+ "main": "dist/index.js",
11
+ "private": false,
12
+ "license": "MIT",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "@toapi/common": "^0.12.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^25.0.3",
27
+ "vitest": "^4.0.16",
28
+ "zod": "^4.0.17"
29
+ },
30
+ "peerDependencies": {
31
+ "typescript": "^5 || ^6.0.0",
32
+ "zod": "^4.0.17"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/farbenmeer/tapi.git"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc --noEmit false",
40
+ "release": "pnpm build && pnpm publish --no-git-checks",
41
+ "test": "vitest run --passWithNoTests"
42
+ }
43
+ }