@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.
@@ -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
+ });
@@ -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
+ }