@r0hitsharma/http-client-react 0.12.0-rohit-fork-ci.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.
- package/DESIGN.md +378 -0
- package/README.md +427 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +51 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -0
- package/dist/middleware.d.ts +45 -0
- package/dist/middleware.js +25 -0
- package/dist/query-api.d.ts +114 -0
- package/dist/query-api.js +128 -0
- package/dist/query-client.d.ts +84 -0
- package/dist/query-client.js +152 -0
- package/dist/query-key.d.ts +50 -0
- package/dist/query-key.js +70 -0
- package/dist/tags.d.ts +28 -0
- package/dist/tags.js +53 -0
- package/dist/zod-response.d.ts +61 -0
- package/dist/zod-response.js +91 -0
- package/oxfmt.config.ts +5 -0
- package/oxlint.config.ts +5 -0
- package/package.json +49 -0
- package/src/deprecated.test.ts +27 -0
- package/src/errors.ts +74 -0
- package/src/index.tsx +100 -0
- package/src/middleware.test.ts +88 -0
- package/src/middleware.ts +84 -0
- package/src/query-api.test.ts +644 -0
- package/src/query-api.ts +512 -0
- package/src/query-api.types.test.ts +225 -0
- package/src/query-client.test.ts +315 -0
- package/src/query-client.ts +173 -0
- package/src/query-key.test.ts +121 -0
- package/src/query-key.ts +113 -0
- package/src/tags.ts +100 -0
- package/src/test-fixtures.ts +222 -0
- package/src/zod-response.ts +150 -0
- package/tsconfig.build.json +21 -0
- package/tsconfig.json +7 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { QueryClient, type QueryClientConfig } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
import { isHttpRequestError } from './errors.js';
|
|
4
|
+
import { isZodResponseValidationError } from './zod-response.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The 4xx statuses that describe a *transient* condition rather than a bad
|
|
8
|
+
* request. Everything else in the 4xx range says the request itself is wrong —
|
|
9
|
+
* a 401, a 404, a 422 — and repeating it unchanged produces the same answer
|
|
10
|
+
* more slowly.
|
|
11
|
+
*
|
|
12
|
+
* - **408 Request Timeout** — the server gave up waiting for the request.
|
|
13
|
+
* - **425 Too Early** — a replay-risk refusal on an early-data TLS connection;
|
|
14
|
+
* the retry goes out on the completed handshake.
|
|
15
|
+
* - **429 Too Many Requests** — rate limited, which is by definition temporary.
|
|
16
|
+
*/
|
|
17
|
+
const RETRYABLE_CLIENT_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Retries before a query is reported as failed. With react-query's default
|
|
21
|
+
* exponential `retryDelay` (1s, then 2s) this puts an error on screen about
|
|
22
|
+
* three seconds after the first failure — react-query's own default of 3 takes
|
|
23
|
+
* roughly seven, which is a long time for a panel to sit in a pending state
|
|
24
|
+
* over a fault that is not going to clear.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a response status is worth asking for again.
|
|
30
|
+
*
|
|
31
|
+
* 5xx and the three transient 4xx statuses are; every other 4xx is not, and
|
|
32
|
+
* neither is anything below 400 — a 304 reaching the error path is a caching
|
|
33
|
+
* problem, not a flaky one.
|
|
34
|
+
*/
|
|
35
|
+
export function isRetryableHttpStatus(status: number): boolean {
|
|
36
|
+
return status >= 500 || RETRYABLE_CLIENT_STATUSES.has(status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a rejected query or mutation is worth retrying.
|
|
41
|
+
*
|
|
42
|
+
* A rejection carrying no status is retried by default: a DNS failure, a
|
|
43
|
+
* dropped connection, a CORS refusal, an abort, or a middleware that threw
|
|
44
|
+
* leaves no evidence beyond the request not completing, and treating an
|
|
45
|
+
* unrecognised rejection as fatal would make a single dropped socket a visible
|
|
46
|
+
* error.
|
|
47
|
+
*
|
|
48
|
+
* `ZodResponseValidationError` is the exception, and the reason this is not a
|
|
49
|
+
* bare `isHttpRequestError` check. It carries no status because
|
|
50
|
+
* `createZodResponseMiddleware` rejects *after* a 2xx arrived and parsed — the
|
|
51
|
+
* request completed, and the body it returned does not match the schema. That
|
|
52
|
+
* verdict is a property of the deployed server, so asking again produces the
|
|
53
|
+
* same mismatch two round trips later: exactly the cost this module's `retry`
|
|
54
|
+
* exists to avoid on a 422.
|
|
55
|
+
*
|
|
56
|
+
* Both checks narrow on `name` rather than `instanceof`, so they still hold
|
|
57
|
+
* when a consumer's module graph contains two copies of this package.
|
|
58
|
+
*
|
|
59
|
+
* Exported so a consumer can keep this status policy while changing how many
|
|
60
|
+
* times it asks again: `retry: (count, error) => count < 5 &&
|
|
61
|
+
* isRetryableError(error)` is five retries, so six attempts.
|
|
62
|
+
*/
|
|
63
|
+
export function isRetryableError(error: unknown): boolean {
|
|
64
|
+
if (isHttpRequestError(error)) return isRetryableHttpStatus(error.status);
|
|
65
|
+
|
|
66
|
+
return !isZodResponseValidationError(error);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The retry predicate {@link createQueryClient} installs, in react-query's own
|
|
71
|
+
* `(failureCount, error)` shape. `failureCount` is the number of failures
|
|
72
|
+
* *before* this attempt, so it is 0 on the first rejection.
|
|
73
|
+
*
|
|
74
|
+
* Exported so a consumer can wrap it rather than restate it — dropping an
|
|
75
|
+
* application-specific error out of the policy, say:
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* retry: (count, error) =>
|
|
79
|
+
* isSessionExpired(error) ? false : shouldRetryRequest(count, error)
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function shouldRetryRequest(
|
|
83
|
+
failureCount: number,
|
|
84
|
+
error: unknown,
|
|
85
|
+
): boolean {
|
|
86
|
+
return failureCount < DEFAULT_MAX_RETRIES && isRetryableError(error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `queries` half of react-query's `DefaultOptions`, with no `undefined`. */
|
|
90
|
+
type DefaultQueryOptions = NonNullable<
|
|
91
|
+
NonNullable<QueryClientConfig['defaultOptions']>['queries']
|
|
92
|
+
>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `queries` with its explicitly-`undefined` keys dropped, so that spreading it
|
|
96
|
+
* over the defaults cannot replace one with nothing.
|
|
97
|
+
*
|
|
98
|
+
* A spread does not distinguish an absent key from one present with the value
|
|
99
|
+
* `undefined`, and the second shape is ordinary in conditional config:
|
|
100
|
+
* `{ retry: cond ? false : undefined }`. Spread raw, that copies
|
|
101
|
+
* `retry: undefined` over {@link shouldRetryRequest}, react-query reads it as
|
|
102
|
+
* `retry ?? 3`, and the policy silently becomes retry-everything — while
|
|
103
|
+
* `refetchOnWindowFocus: false` survives the same spread, so the client still
|
|
104
|
+
* looks configured.
|
|
105
|
+
*
|
|
106
|
+
* Dropping the key instead is lossless: react-query resolves every one of these
|
|
107
|
+
* options with `?? <default>` or an `=== undefined` check, so a key that is
|
|
108
|
+
* absent and a key that is `undefined` already mean the same thing to it. There
|
|
109
|
+
* is no option for which "present but undefined" says something an explicit
|
|
110
|
+
* value could not say more clearly.
|
|
111
|
+
*/
|
|
112
|
+
function definedQueryOptions(
|
|
113
|
+
queries: DefaultQueryOptions | undefined,
|
|
114
|
+
): Partial<DefaultQueryOptions> {
|
|
115
|
+
if (!queries) return {};
|
|
116
|
+
|
|
117
|
+
return Object.fromEntries(
|
|
118
|
+
Object.entries(queries).filter(([, value]) => value !== undefined),
|
|
119
|
+
) as Partial<DefaultQueryOptions>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A `QueryClient` with defaults suited to a data-dense application, in place of
|
|
124
|
+
* react-query's, which are tuned for a document-shaped app:
|
|
125
|
+
*
|
|
126
|
+
* - **`refetchOnWindowFocus: false`.** Alt-tabbing back to a dashboard should
|
|
127
|
+
* not reload every panel under the cursor. Freshness is the business of
|
|
128
|
+
* `staleTime` and explicit invalidation, both of which the app controls.
|
|
129
|
+
* - **A status-aware `retry`** — see {@link shouldRetryRequest}. React-query
|
|
130
|
+
* retries every rejection three times, so a 422 costs four round trips to
|
|
131
|
+
* report a validation error the server already decided on the first.
|
|
132
|
+
*
|
|
133
|
+
* Mutations are deliberately left un-retried — react-query's default, and the
|
|
134
|
+
* right one, because a POST that reached the server may well have applied
|
|
135
|
+
* before the failure and this package cannot tell which.
|
|
136
|
+
*
|
|
137
|
+
* Everything else is left at react-query's default deliberately, `staleTime`
|
|
138
|
+
* most of all: how long a given screen may show a stale number is a product
|
|
139
|
+
* decision, and a package-level guess would be wrong quietly.
|
|
140
|
+
*
|
|
141
|
+
* `config` is react-query's own `QueryClientConfig` and every field of it wins.
|
|
142
|
+
* The merge is per-option rather than wholesale, so overriding one default
|
|
143
|
+
* keeps the others:
|
|
144
|
+
*
|
|
145
|
+
* ```ts
|
|
146
|
+
* // Keeps `refetchOnWindowFocus: false`; replaces only the retry policy.
|
|
147
|
+
* const queryClient = createQueryClient({
|
|
148
|
+
* defaultOptions: { queries: { retry: 5 } },
|
|
149
|
+
* });
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* A `queries` key present with the value `undefined` is *not* an override: it
|
|
153
|
+
* is dropped before the merge, so the else-branch of
|
|
154
|
+
* `retry: cond ? false : undefined` leaves the shipped policy standing rather
|
|
155
|
+
* than reverting it to react-query's `retry ?? 3`.
|
|
156
|
+
*/
|
|
157
|
+
export function createQueryClient(config: QueryClientConfig = {}): QueryClient {
|
|
158
|
+
const { defaultOptions, ...rest } = config;
|
|
159
|
+
|
|
160
|
+
return new QueryClient({
|
|
161
|
+
...rest,
|
|
162
|
+
defaultOptions: {
|
|
163
|
+
...defaultOptions,
|
|
164
|
+
queries: {
|
|
165
|
+
refetchOnWindowFocus: false,
|
|
166
|
+
retry: shouldRetryRequest,
|
|
167
|
+
// Spread last: each `queries` option the caller actually gave a value
|
|
168
|
+
// replaces the default of the same name and leaves the rest standing.
|
|
169
|
+
...definedQueryOptions(defaultOptions?.queries),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { hashKey } from '@tanstack/react-query';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildQueryApiKey,
|
|
6
|
+
canonicalizeQueryKeyValue,
|
|
7
|
+
sanitizeQueryInit,
|
|
8
|
+
} from './query-key.js';
|
|
9
|
+
|
|
10
|
+
describe('sanitizeQueryInit', () => {
|
|
11
|
+
it('is insensitive to param key order', () => {
|
|
12
|
+
const a = sanitizeQueryInit({ params: { query: { limit: 10, q: 'ada' } } });
|
|
13
|
+
const b = sanitizeQueryInit({ params: { query: { q: 'ada', limit: 10 } } });
|
|
14
|
+
|
|
15
|
+
expect(a).toEqual(b);
|
|
16
|
+
// Deep equality is not the point — the *serialized* form has to match, or
|
|
17
|
+
// partial-match invalidation and devtools output diverge.
|
|
18
|
+
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('sorts nested object keys, at every depth', () => {
|
|
22
|
+
const sanitized = sanitizeQueryInit({
|
|
23
|
+
body: { z: 1, a: { d: 4, b: 2 } },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(JSON.stringify(sanitized)).toBe(
|
|
27
|
+
'{"body":{"a":{"b":2,"d":4},"z":1}}',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('strips undefined-valued properties', () => {
|
|
32
|
+
expect(
|
|
33
|
+
sanitizeQueryInit({
|
|
34
|
+
params: { query: { limit: 10, search: undefined } },
|
|
35
|
+
}),
|
|
36
|
+
).toEqual({ query: { limit: 10 } });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('collapses absent, empty, and all-undefined params to the same value', () => {
|
|
40
|
+
const forms = [
|
|
41
|
+
undefined,
|
|
42
|
+
{},
|
|
43
|
+
{ params: {} },
|
|
44
|
+
{ params: { query: {} } },
|
|
45
|
+
{ params: { query: { limit: undefined } } },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const form of forms) {
|
|
49
|
+
expect(sanitizeQueryInit(form)).toEqual({});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps array order and canonicalizes array members', () => {
|
|
54
|
+
const sanitized = sanitizeQueryInit({
|
|
55
|
+
params: { query: { ids: ['b', 'a'] } },
|
|
56
|
+
body: [{ y: 2, x: 1 }],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(JSON.stringify(sanitized)).toBe(
|
|
60
|
+
'{"query":{"ids":["b","a"]},"body":[{"x":1,"y":2}]}',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps path params, query params, and the body — and nothing else', () => {
|
|
65
|
+
const sanitized = sanitizeQueryInit({
|
|
66
|
+
params: {
|
|
67
|
+
path: { id: 'u1' },
|
|
68
|
+
query: { expand: 'roles' },
|
|
69
|
+
header: { authorization: 'Bearer secret' },
|
|
70
|
+
cookie: { session: 'secret' },
|
|
71
|
+
},
|
|
72
|
+
body: { name: 'Ada' },
|
|
73
|
+
signal: new AbortController().signal,
|
|
74
|
+
headers: { authorization: 'Bearer secret' },
|
|
75
|
+
baseUrl: 'https://other.test',
|
|
76
|
+
parseAs: 'text',
|
|
77
|
+
fetch: () => Promise.resolve(new Response()),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(sanitized).toEqual({
|
|
81
|
+
path: { id: 'u1' },
|
|
82
|
+
query: { expand: 'roles' },
|
|
83
|
+
body: { name: 'Ada' },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('preserves a falsy body', () => {
|
|
88
|
+
expect(sanitizeQueryInit({ body: 0 })).toEqual({ body: 0 });
|
|
89
|
+
expect(sanitizeQueryInit({ body: null })).toEqual({ body: null });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('leaves non-plain objects by reference', () => {
|
|
93
|
+
const date = new Date(0);
|
|
94
|
+
expect(canonicalizeQueryKeyValue(date)).toBe(date);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('buildQueryApiKey', () => {
|
|
99
|
+
it('derives [method, path, sanitized(init)]', () => {
|
|
100
|
+
expect(
|
|
101
|
+
buildQueryApiKey('get', '/users/{id}', {
|
|
102
|
+
params: { path: { id: 'u1' } },
|
|
103
|
+
}),
|
|
104
|
+
).toEqual(['get', '/users/{id}', { path: { id: 'u1' } }]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('always emits three elements, so [method, path] prefix-matches', () => {
|
|
108
|
+
expect(buildQueryApiKey('get', '/users')).toEqual(['get', '/users', {}]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('hashes two orderings of the same request to one cache entry', () => {
|
|
112
|
+
const a = buildQueryApiKey('get', '/users', {
|
|
113
|
+
params: { query: { limit: 10, search: 'ada' } },
|
|
114
|
+
});
|
|
115
|
+
const b = buildQueryApiKey('get', '/users', {
|
|
116
|
+
params: { query: { search: 'ada', limit: 10 } },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(hashKey(a)).toBe(hashKey(b));
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/query-key.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity-bearing subset of an `openapi-fetch` request init, canonicalized
|
|
3
|
+
* so that two structurally equivalent requests produce one cache entry.
|
|
4
|
+
*
|
|
5
|
+
* Only path params, query params, and the request body identify a resource.
|
|
6
|
+
* Per-call transport concerns (`signal`, `fetch`, serializers, `headers`,
|
|
7
|
+
* `baseUrl`, `parseAs`) are deliberately excluded: they are either
|
|
8
|
+
* non-serializable, or — in the case of headers — carry credentials that have
|
|
9
|
+
* no business being visible in a cache key or in devtools.
|
|
10
|
+
*/
|
|
11
|
+
export type SanitizedQueryInit = {
|
|
12
|
+
path?: Record<string, unknown>;
|
|
13
|
+
query?: Record<string, unknown>;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The derived key shape: `[method, path, sanitized(init)]`. The first two
|
|
19
|
+
* elements are the operation itself, so react-query's default prefix matching
|
|
20
|
+
* invalidates every cached variant of an endpoint via `[method, path]`.
|
|
21
|
+
*/
|
|
22
|
+
// `TPath` is deliberately unconstrained: `PathsWithMethod` resolves to
|
|
23
|
+
// `keyof TPaths`, which TypeScript widens to `string | number | symbol` even
|
|
24
|
+
// though a generated `paths` type only ever has string keys.
|
|
25
|
+
export type QueryApiKey<TMethod = string, TPath = string> = readonly [
|
|
26
|
+
method: TMethod,
|
|
27
|
+
path: TPath,
|
|
28
|
+
init: SanitizedQueryInit,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
33
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
34
|
+
return proto === Object.prototype || proto === null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Recursively rewrites plain objects with their keys in sorted order and their
|
|
39
|
+
* `undefined`-valued properties removed. Arrays keep their order (query-array
|
|
40
|
+
* order is significant on the wire) and every other value is passed through by
|
|
41
|
+
* reference.
|
|
42
|
+
*
|
|
43
|
+
* react-query's own `hashKey` already sorts object keys before hashing, so this
|
|
44
|
+
* is not what makes two orderings hit the same cache entry. What it buys is
|
|
45
|
+
* everything that compares keys *structurally* rather than by hash: partial
|
|
46
|
+
* `invalidateQueries` matching (`partialDeepEqual` is order- and
|
|
47
|
+
* `undefined`-sensitive), `exact` filters, and reading a key in devtools.
|
|
48
|
+
*/
|
|
49
|
+
export function canonicalizeQueryKeyValue(value: unknown): unknown {
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return value.map(canonicalizeQueryKeyValue);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (isPlainObject(value)) {
|
|
55
|
+
const canonical: Record<string, unknown> = {};
|
|
56
|
+
|
|
57
|
+
for (const key of Object.keys(value).sort()) {
|
|
58
|
+
const entry = canonicalizeQueryKeyValue(value[key]);
|
|
59
|
+
if (entry !== undefined) canonical[key] = entry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return canonical;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function canonicalizeRecord(
|
|
69
|
+
value: unknown,
|
|
70
|
+
): Record<string, unknown> | undefined {
|
|
71
|
+
if (!isPlainObject(value)) return undefined;
|
|
72
|
+
const canonical = canonicalizeQueryKeyValue(value) as Record<string, unknown>;
|
|
73
|
+
return Object.keys(canonical).length > 0 ? canonical : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reduces an `openapi-fetch` init to its canonical, identity-bearing form.
|
|
78
|
+
*
|
|
79
|
+
* Empty and all-`undefined` param records collapse to absent, so
|
|
80
|
+
* `undefined`, `{}`, and `{ params: { query: { after: undefined } } }` all
|
|
81
|
+
* sanitize to the same value and therefore share a cache entry.
|
|
82
|
+
*/
|
|
83
|
+
export function sanitizeQueryInit(init: unknown): SanitizedQueryInit {
|
|
84
|
+
if (!isPlainObject(init)) return {};
|
|
85
|
+
|
|
86
|
+
const params = isPlainObject(init.params) ? init.params : undefined;
|
|
87
|
+
const sanitized: SanitizedQueryInit = {};
|
|
88
|
+
|
|
89
|
+
const path = canonicalizeRecord(params?.path);
|
|
90
|
+
if (path) sanitized.path = path;
|
|
91
|
+
|
|
92
|
+
const query = canonicalizeRecord(params?.query);
|
|
93
|
+
if (query) sanitized.query = query;
|
|
94
|
+
|
|
95
|
+
const body = canonicalizeQueryKeyValue(init.body);
|
|
96
|
+
if (body !== undefined) sanitized.body = body;
|
|
97
|
+
|
|
98
|
+
return sanitized;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Builds the untyped `[method, path, sanitized(init)]` key. */
|
|
102
|
+
export function buildQueryApiKey<TMethod extends string, TPath extends string>(
|
|
103
|
+
method: TMethod,
|
|
104
|
+
path: TPath,
|
|
105
|
+
init?: unknown,
|
|
106
|
+
): QueryApiKey<TMethod, TPath> {
|
|
107
|
+
return [method, path, sanitizeQueryInit(init)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The `${method} ${path}` token an endpoint is registered and matched under. */
|
|
111
|
+
export function operationToken(method: string, path: string): string {
|
|
112
|
+
return `${method} ${path}`;
|
|
113
|
+
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { operationToken } from './query-key.js';
|
|
2
|
+
|
|
3
|
+
// Ambient, package-local declaration — this package's `tsconfig.json` has no
|
|
4
|
+
// `"types": ["node"]` reference, so `process` isn't otherwise a known global.
|
|
5
|
+
// Declaring it as possibly `undefined` keeps the `typeof process` guard below
|
|
6
|
+
// meaningful to the type checker without pulling in `@types/node`.
|
|
7
|
+
declare const process: { env?: { NODE_ENV?: string } } | undefined;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bundlers statically replace `process.env.NODE_ENV`, so a production build
|
|
11
|
+
* collapses this to `false` and drops the warning call; the `typeof` guard
|
|
12
|
+
* keeps it safe to evaluate where no global `process` exists at all.
|
|
13
|
+
*/
|
|
14
|
+
const IS_DEV =
|
|
15
|
+
typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maps tags to the endpoints that carry them.
|
|
19
|
+
*
|
|
20
|
+
* Membership is recorded where the tag is declared — on the `queryOptions`
|
|
21
|
+
* call — so the query itself stays the single place an endpoint's cache
|
|
22
|
+
* behaviour is described. Nothing has to be mirrored into a second registry.
|
|
23
|
+
*
|
|
24
|
+
* Granularity is per `${method} ${path}`, not per key: invalidating a tag
|
|
25
|
+
* invalidates every cached variant of the endpoints under it, whatever their
|
|
26
|
+
* params. That is what "refetch the user list" almost always means, and it
|
|
27
|
+
* keeps the registry bounded by endpoint count rather than by cache size.
|
|
28
|
+
*/
|
|
29
|
+
export type TagRegistry<TTag extends string> = {
|
|
30
|
+
register: (tag: TTag, method: string, path: string) => void;
|
|
31
|
+
assertKnown: (tag: TTag) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Whether `tag` may be acted on, warning once per unknown tag rather than
|
|
34
|
+
* throwing. For tags that only materialize *after* a request has already
|
|
35
|
+
* succeeded — the ones an `invalidates` callback derives from a mutation's
|
|
36
|
+
* result — where a throw would report the successful mutation as failed.
|
|
37
|
+
*/
|
|
38
|
+
acceptOrWarn: (tag: TTag) => boolean;
|
|
39
|
+
/** Whether a `[method, path, …]` query key belongs to `tag`. */
|
|
40
|
+
matches: (tag: TTag, queryKey: readonly unknown[]) => boolean;
|
|
41
|
+
/** The `${method} ${path}` tokens currently registered under `tag`. */
|
|
42
|
+
endpoints: (tag: TTag) => readonly string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function createTagRegistry<TTag extends string>(
|
|
46
|
+
vocabulary?: readonly TTag[],
|
|
47
|
+
): TagRegistry<TTag> {
|
|
48
|
+
const known = vocabulary ? new Set<string>(vocabulary) : undefined;
|
|
49
|
+
const endpointsByTag = new Map<string, Set<string>>();
|
|
50
|
+
const warned = new Set<string>();
|
|
51
|
+
|
|
52
|
+
/** Names the vocabulary, for a message that has to report a tag outside it. */
|
|
53
|
+
const describeVocabulary = (declared: Set<string>): string =>
|
|
54
|
+
`Declared tags: ${[...declared].join(', ') || '(none)'}`;
|
|
55
|
+
|
|
56
|
+
// Both checks below read `known` directly rather than through a shared
|
|
57
|
+
// predicate: with no vocabulary there is no closed set, so no tag is unknown
|
|
58
|
+
// and neither branch is reachable.
|
|
59
|
+
const assertKnown = (tag: TTag): void => {
|
|
60
|
+
if (known && !known.has(tag)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`http-client-react: unknown tag "${tag}". ${describeVocabulary(known)}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const acceptOrWarn = (tag: TTag): boolean => {
|
|
68
|
+
if (!known || known.has(tag)) return true;
|
|
69
|
+
|
|
70
|
+
if (IS_DEV && !warned.has(tag)) {
|
|
71
|
+
warned.add(tag);
|
|
72
|
+
console.warn(
|
|
73
|
+
`http-client-react: skipping unknown tag "${tag}" produced by an ` +
|
|
74
|
+
`invalidates callback — nothing was invalidated for it. ` +
|
|
75
|
+
describeVocabulary(known),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
assertKnown,
|
|
84
|
+
acceptOrWarn,
|
|
85
|
+
register: (tag, method, path) => {
|
|
86
|
+
assertKnown(tag);
|
|
87
|
+
const tokens = endpointsByTag.get(tag) ?? new Set<string>();
|
|
88
|
+
tokens.add(operationToken(method, path));
|
|
89
|
+
endpointsByTag.set(tag, tokens);
|
|
90
|
+
},
|
|
91
|
+
matches: (tag, queryKey) => {
|
|
92
|
+
const tokens = endpointsByTag.get(tag);
|
|
93
|
+
if (!tokens) return false;
|
|
94
|
+
const [method, path] = queryKey;
|
|
95
|
+
if (typeof method !== 'string' || typeof path !== 'string') return false;
|
|
96
|
+
return tokens.has(operationToken(method, path));
|
|
97
|
+
},
|
|
98
|
+
endpoints: (tag) => [...(endpointsByTag.get(tag) ?? [])],
|
|
99
|
+
};
|
|
100
|
+
}
|