@tpzdsp/next-toolkit 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ import {
2
+ type BetterFetch,
3
+ type CreateFetchOption,
4
+ type FetchSchemaRoutes,
5
+ type FetchSchema,
6
+ type StandardSchemaV1,
7
+ type InferOptions,
8
+ type Schema,
9
+ type BetterFetchOption,
10
+ } from '@better-fetch/fetch';
11
+ import {
12
+ useQuery,
13
+ useSuspenseQuery,
14
+ type QueryKey,
15
+ type UseQueryOptions,
16
+ type UseQueryResult,
17
+ type UseSuspenseQueryOptions,
18
+ type UseSuspenseQueryResult,
19
+ } from '@tanstack/react-query';
20
+
21
+ import type { ApiError } from '../errors';
22
+
23
+ export type EmptyRoutePrefixes = '@get/' | '@post/' | '@put/' | '@patch/' | '@delete/';
24
+
25
+ /**
26
+ * NOTE: In this file there are mentions of two kinds of schemas:
27
+ * - `validation schema`: A schema from a library like `zod`.
28
+ * - `fetch schema`: A schema from the `better-fetch` library that defines the endpoints of a specific API.
29
+ */
30
+
31
+ // The library allows you to prefix routes with a special string to indicate the response type,
32
+ // but the implementation means they can show up on their own as valid routes, so we remove them to prevent that.
33
+ export type SchemaRoutes = Omit<FetchSchemaRoutes, EmptyRoutePrefixes>;
34
+
35
+ // Get any routes from a fetch schema that define a body as a validation schema.
36
+ type RoutesWithOutput<Routes> = {
37
+ [K in keyof Routes]: Routes[K] extends { output: unknown } ? K : never;
38
+ }[keyof Routes];
39
+
40
+ // Get any routes that DON'T define an output type as a validation schema by removing any that DO.
41
+ type RoutesWithoutOutput<Routes> = Exclude<keyof Routes, RoutesWithOutput<Routes>>;
42
+
43
+ // Get any routes from a fetch schema that define any inputs (body, query params, etc) as a validation schema.
44
+ type RoutesWithInput<Routes> = {
45
+ [K in keyof Routes]: Routes[K] extends
46
+ | { input?: unknown } // body
47
+ | { params?: unknown }
48
+ | { query?: unknown }
49
+ ? K
50
+ : never;
51
+ }[keyof Routes];
52
+
53
+ // `better-fetch` options that prevent any body, query params, etc being passed in.
54
+ // This is `body` instead of `input` as it is overriding fields from the *options* NOT the schema.
55
+ type OptionsNoInputOutput = Omit<BetterFetchOption, 'query' | 'params' | 'body' | 'method'>;
56
+
57
+ // Get the options out of the schema, excluding the output type. This is basically the opposite of above, as it does
58
+ // include the inputs, like body, query params, etc.
59
+ export type OptionsWithInput<
60
+ Schema extends SchemaRoutes | undefined,
61
+ Route extends keyof Schema,
62
+ > = Schema extends SchemaRoutes
63
+ ? Schema[Route] extends FetchSchema
64
+ ? InferOptions<Schema[Route], Route>
65
+ : object
66
+ : object;
67
+
68
+ // Extracts the ouput of a validation schema given a list of routes from a fetch schema.
69
+ export type SchemaRouteOutput<
70
+ Schema extends SchemaRoutes | undefined,
71
+ Route extends keyof Schema,
72
+ > = Schema extends SchemaRoutes
73
+ ? Schema[Route] extends FetchSchema
74
+ ? Schema[Route]['output'] extends StandardSchemaV1
75
+ ? StandardSchemaV1.InferOutput<Schema[Route]['output']>
76
+ : unknown
77
+ : unknown
78
+ : unknown;
79
+
80
+ // Extracts the routes of a fetch schema from a config object.
81
+ type SchemaRoutesOf<Option extends CreateFetchOption> = Option extends {
82
+ schema: { schema: infer Routes };
83
+ }
84
+ ? Routes
85
+ : undefined;
86
+
87
+ // We provide the `queryFn` and `queryKeys` automatically, so no need to let the user provide them.
88
+ type OmitReactQueryKeys = 'queryKey' | 'queryFn';
89
+
90
+ /**
91
+ * A higher-order type that creates React Query hooks (`useBetterQuerySafe` and `useBetterSuspenseQuery`)
92
+ * from a given `BetterFetch` fetch instance.
93
+ *
94
+ * This type uses **function overloads** to support two different ways of determining the
95
+ * query’s output type:
96
+ *
97
+ * 1. **Schema-inferred output** — If the provided route in the fetch schema defines an `output`
98
+ * validation schema (e.g. a Zod schema), the hook automatically infers the result type from
99
+ * that schema. This applies to routes captured by `RoutesWithOutput`.
100
+ *
101
+ * ```ts
102
+ * const { useBetterQuerySafe } = reactQueryBetterFetch(api);
103
+ * const query = useBetterQuerySafe('/users'); // Output inferred from schema
104
+ * ```
105
+ *
106
+ * 2. **Manually specified output** — If the route does **not** define an `output` schema,
107
+ * the caller can manually specify the expected output type using the first generic parameter.
108
+ * This applies to routes captured by `RoutesWithoutOutput`.
109
+ *
110
+ * ```ts
111
+ * const query = useBetterQuerySafe<User[]>('/users'); // Output type provided manually
112
+ * ```
113
+ *
114
+ * This dual overload approach provides flexibility — it enables strict type inference
115
+ * where schema information is available, while still allowing convenient explicit typing for routes
116
+ * that lack schema-based output definitions.
117
+ */
118
+ type ReactQueryBetterFetch = <
119
+ Option extends CreateFetchOption & { schema: Schema },
120
+ Routes extends SchemaRoutes | undefined = SchemaRoutesOf<Option>,
121
+ >(
122
+ instance: BetterFetch<Option>,
123
+ ) => {
124
+ // Safe query (error NOT thrown)
125
+
126
+ // Overload that uses schema output (`RoutesWithOutput`)
127
+ useBetterQuerySafe<
128
+ Error = ApiError,
129
+ Route extends RoutesWithOutput<Routes> = RoutesWithOutput<Routes>,
130
+ >(
131
+ route: Route,
132
+ // If the route requires an input, then expect options that provide that input, otherwise just expect options without inputs.
133
+ fetchOptions?: Route extends RoutesWithInput<Routes>
134
+ ? OptionsWithInput<Routes, Route>
135
+ : OptionsNoInputOutput,
136
+ queryOptions?: Omit<
137
+ UseQueryOptions<unknown, Error, SchemaRouteOutput<Routes, Route>, QueryKey>,
138
+ OmitReactQueryKeys
139
+ >,
140
+ ): UseQueryResult<SchemaRouteOutput<Routes, Route>, Error>;
141
+
142
+ // Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
143
+ useBetterQuerySafe<
144
+ Output,
145
+ Error = ApiError,
146
+ Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
147
+ >(
148
+ route: Route,
149
+ fetchOptions?: Route extends RoutesWithInput<Routes>
150
+ ? OptionsWithInput<Routes, Route>
151
+ : OptionsNoInputOutput,
152
+ queryOptions?: Omit<UseQueryOptions<unknown, Error, Output, QueryKey>, OmitReactQueryKeys>,
153
+ ): UseQueryResult<Output, Error>;
154
+
155
+ // Suspense query (error thrown)
156
+
157
+ // Overload that uses schema output (`RoutesWithOutput`)
158
+ useBetterSuspenseQuery<
159
+ Route extends RoutesWithOutput<Routes>,
160
+ Output = SchemaRouteOutput<Routes, Route>,
161
+ >(
162
+ route: Route,
163
+ fetchOptions?: Route extends RoutesWithInput<Routes>
164
+ ? OptionsWithInput<Routes, Route>
165
+ : OptionsNoInputOutput,
166
+ queryOptions?: Omit<
167
+ UseSuspenseQueryOptions<unknown, never, Output, QueryKey>,
168
+ OmitReactQueryKeys
169
+ >,
170
+ ): UseSuspenseQueryResult<Output>;
171
+
172
+ // Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
173
+ useBetterSuspenseQuery<
174
+ Output,
175
+ Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
176
+ >(
177
+ route: Route,
178
+ fetchOptions?: Route extends RoutesWithInput<Routes>
179
+ ? OptionsWithInput<Routes, Route>
180
+ : OptionsNoInputOutput,
181
+ queryOptions?: Omit<
182
+ UseSuspenseQueryOptions<unknown, never, Output, QueryKey>,
183
+ OmitReactQueryKeys
184
+ >,
185
+ ): UseSuspenseQueryResult<Output>;
186
+ };
187
+
188
+ /**
189
+ * A wrapper around React Query that integrates with `better-fetch` for fully typed,
190
+ * schema-validated data fetching.
191
+ *
192
+ * It provides two hooks:
193
+ * - `useBetterQuerySafe` – standard React Query hook (errors handled in result)
194
+ * - `useBetterSuspenseQuery` – suspense-based version (errors thrown)
195
+ *
196
+ * These hooks automatically infer input and output types from the provided `better-fetch`
197
+ * schema where possible. If a route does not define an output schema, you can manually
198
+ * specify the expected output type via the first generic parameter.
199
+ *
200
+ * ⚠️ **Type inference notes:**
201
+ * Because of the strict type relationships between schemas, routes, and query options,
202
+ * invalid parameter types or missing generic arguments can sometimes produce confusing
203
+ * TypeScript errors (e.g. mentioning `never` types).
204
+ * If you encounter this, double-check that:
205
+ * - The route key is valid for the given schema.
206
+ * - All fetch/query option types align with the route definition.
207
+ * - An explicit output type is provided when the schema does not define one.
208
+ */
209
+
210
+ export const reactQueryBetterFetch: ReactQueryBetterFetch = <
211
+ Option extends CreateFetchOption,
212
+ Routes extends SchemaRoutes | undefined = SchemaRoutesOf<Option>,
213
+ >(
214
+ instance: BetterFetch<Option>,
215
+ ) => {
216
+ // The types on `BetterFetch<Option>` are extremely strict, too strict to easily
217
+ // prevent errors if we just called `instance`, so cast it to `any` for now to
218
+ // prevent type errors at the call site.
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ const anyInstance = instance as any;
221
+
222
+ /**
223
+ * React Query hook for performing a `better-fetch` request safely (errors are returned, not thrown).
224
+ *
225
+ * Automatically infers input and output types from the schema, or allows manual output typing.
226
+ * Wraps {@link https://tanstack.com/query/latest/docs/react/reference/useQuery useQuery} internally.
227
+ *
228
+ * @param route - The API route key defined in the `better-fetch` schema.
229
+ * @param fetchOptions - Optional request options (e.g. `body`, `params`, `query`) inferred from the schema.
230
+ * @param queryOptions - React Query options passed to `useQuery`, excluding `queryKey` and `queryFn`
231
+ * which are handled automatically.
232
+ *
233
+ * @returns A standard React Query result object (`UseQueryResult`), with strongly typed data and error.
234
+ */
235
+ const useBetterQuerySafe = (
236
+ route: keyof Routes,
237
+ fetchOptions?: InferOptions<FetchSchema, ''>,
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
239
+ queryOptions?: Omit<UseQueryOptions<unknown, any, any, QueryKey>, OmitReactQueryKeys>,
240
+ ) => {
241
+ return useQuery({
242
+ ...queryOptions,
243
+ queryKey: [
244
+ route,
245
+ fetchOptions?.body,
246
+ fetchOptions?.params,
247
+ fetchOptions?.query,
248
+ fetchOptions?.headers,
249
+ ],
250
+ queryFn: () => anyInstance(route, fetchOptions),
251
+ });
252
+ };
253
+
254
+ /**
255
+ * React Query hook for performing a `better-fetch` request using Suspense mode.
256
+ *
257
+ * Behaves like `useBetterQuerySafe` but integrates with React Suspense — errors are thrown instead of returned.
258
+ * Wraps {@link https://tanstack.com/query/latest/docs/react/reference/useSuspenseQuery useSuspenseQuery} internally.
259
+ *
260
+ * @param route - The API route key defined in the `better-fetch` schema.
261
+ * @param fetchOptions - Optional request options (e.g. `body`, `params`, `query`) inferred from the schema.
262
+ * @param queryOptions - React Query options passed to `useSuspenseQuery`, excluding `queryKey` and `queryFn`
263
+ * which are handled automatically.
264
+ *
265
+ * @returns A React Query suspense result (`UseSuspenseQueryResult`) with strongly typed data.
266
+ */
267
+ const useBetterSuspenseQuery = <Route extends keyof Routes>(
268
+ route: Route,
269
+ fetchOptions?: InferOptions<FetchSchema, ''>,
270
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
271
+ queryOptions?: Omit<UseQueryOptions<unknown, never, any, QueryKey>, OmitReactQueryKeys>,
272
+ ) => {
273
+ return useSuspenseQuery({
274
+ ...queryOptions,
275
+ queryKey: [
276
+ route,
277
+ fetchOptions?.body,
278
+ fetchOptions?.params,
279
+ fetchOptions?.query,
280
+ fetchOptions?.headers,
281
+ ],
282
+ queryFn: () => anyInstance(route, fetchOptions),
283
+ });
284
+ };
285
+
286
+ return { useBetterQuerySafe, useBetterSuspenseQuery };
287
+ };
@@ -0,0 +1,77 @@
1
+ import { HttpStatus } from '@tpzdsp/next-toolkit/http';
2
+
3
+ import { ApiError } from '../errors';
4
+
5
+ /**
6
+ * Reads streamed JSON X-Lines data from a response object, ignoring any invalid JSON lines.
7
+ *
8
+ * @param data Incoming response
9
+ * @returns Decoded JSON object.
10
+ */
11
+ export const readJsonXLinesStream = async <T>(data: Response): Promise<T[]> => {
12
+ if (data.status === HttpStatus.NoContent) {
13
+ return [];
14
+ }
15
+
16
+ const reader = data.body?.getReader();
17
+
18
+ if (!reader) {
19
+ throw ApiError.badRequest('No readable stream available.');
20
+ }
21
+
22
+ const decoder = new TextDecoder();
23
+ let buffer = '';
24
+ const parsedLines: T[] = [];
25
+
26
+ try {
27
+ while (reader) {
28
+ const { value, done } = await reader.read();
29
+
30
+ if (done) {
31
+ break;
32
+ }
33
+
34
+ buffer += decoder.decode(value, { stream: true });
35
+ buffer = processBufferLines<T>(buffer, parsedLines);
36
+ }
37
+
38
+ // Handle any remaining buffered data
39
+ processFinalBuffer<T>(buffer, parsedLines);
40
+ } finally {
41
+ reader.releaseLock();
42
+ }
43
+
44
+ return parsedLines;
45
+ };
46
+
47
+ // Extract line processing logic to reduce complexity
48
+ const processBufferLines = <T>(buffer: string, parsedLines: T[]): string => {
49
+ const lines = buffer.split('\n');
50
+ const remainingBuffer = lines.pop() ?? ''; // leftover for next chunk
51
+
52
+ lines.forEach((line) => {
53
+ if (line.trim()) {
54
+ parseAndAddLine<T>(line, parsedLines);
55
+ }
56
+ });
57
+
58
+ return remainingBuffer;
59
+ };
60
+
61
+ // Extract final buffer processing
62
+ const processFinalBuffer = <T>(buffer: string, parsedLines: T[]): void => {
63
+ if (buffer.trim()) {
64
+ parseAndAddLine<T>(buffer, parsedLines);
65
+ }
66
+ };
67
+
68
+ // Extract JSON parsing logic
69
+ const parseAndAddLine = <T>(line: string, parsedLines: T[]): void => {
70
+ try {
71
+ const parsed = JSON.parse(line) as T;
72
+
73
+ parsedLines.push(parsed);
74
+ } catch (error) {
75
+ console.warn('Invalid JSON line:', line, 'Error:', error);
76
+ }
77
+ };
package/src/types/api.ts CHANGED
@@ -1,3 +1,28 @@
1
+ import z from 'zod/v4';
2
+
3
+ /**
4
+ * Zod schema representing a standard API error response payload from
5
+ * external(non-proxy) APIs.
6
+ *
7
+ * - Validates that the API returns a `detail` field as a string.
8
+ * - Adds a default `message` for consistency when normalizing errors.
9
+ * - Used internally in `normalizeError` to convert external API errors
10
+ * into a consistent shape (`{ message, detail }`) that the application
11
+ * can safely work with.
12
+ *
13
+ * Note:
14
+ * - The schema does **not** include an HTTP status code; the status is
15
+ * determined separately based on the response or context.
16
+ */
17
+ export const ErrorWithDetailSchema = z
18
+ .strictObject({
19
+ detail: z.string(),
20
+ })
21
+ .transform(({ detail }) => ({
22
+ details: detail,
23
+ message: 'An error occurred in the API.',
24
+ }));
25
+
1
26
  // API related types
2
27
  export type ApiResponse<T = unknown> = {
3
28
  data: T;
package/src/utils/http.ts CHANGED
@@ -1,34 +1,6 @@
1
+ import { HttpMethod, MimeType } from '../http/constants';
1
2
  import type { Response, ApiFailure } from '../types/api';
2
3
 
3
- export const MimeTypes = {
4
- Json: 'application/json',
5
- JsonLd: 'application/ld+json',
6
- GeoJson: 'application/geo+json',
7
- Png: 'image/png',
8
- XJsonLines: 'application/x-jsonlines',
9
- Csv: 'text/csv',
10
- } as const;
11
-
12
- export const Http = {
13
- Ok: 200,
14
- Created: 201,
15
- NoContent: 204,
16
- BadRequest: 400,
17
- Unauthorized: 401,
18
- Forbidden: 403,
19
- NotFound: 404,
20
- NotAllowed: 405,
21
- InternalServerError: 500,
22
- } as const;
23
-
24
- export const HttpMethod = {
25
- Get: 'GET',
26
- Post: 'POST',
27
- Put: 'PUT',
28
- Patch: 'PATCH',
29
- Delete: 'DELETE',
30
- } as const;
31
-
32
4
  type NodeGlobal = {
33
5
  process?: {
34
6
  env?: Record<string, string | undefined>;
@@ -133,7 +105,7 @@ const post = <Ok, Error = ApiFailure>(
133
105
  method: HttpMethod.Post,
134
106
  body: JSON.stringify(body),
135
107
  headers: {
136
- 'Content-Type': MimeTypes.Json,
108
+ 'Content-Type': MimeType.Json,
137
109
  ...options?.headers,
138
110
  },
139
111
  };
@@ -0,0 +1,30 @@
1
+ import type { StandardSchemaV1 } from '@better-fetch/fetch';
2
+
3
+ /**
4
+ * Creates a schema from a TypeScript type that does no validation on its input.
5
+ * This is useful if we want to define a schema for the sake of a type but don't actually want to run any validation on it.
6
+ *
7
+ * An example use is if we want to give query parameters types in a fetch schema, but don't need to validate them as the input
8
+ * values are coming from an input we control, so we know they are already valid. This would still allow the query params to have
9
+ * a typescript type but stops any validation for speed.
10
+ *
11
+ * @param _schema Input schema
12
+ * @returns The input schema but without validation
13
+ */
14
+
15
+ export const typeOnlySchema = <Input = unknown, Output = Input>(): StandardSchemaV1<
16
+ Input,
17
+ Output
18
+ > => ({
19
+ '~standard': {
20
+ version: 1 as const,
21
+ vendor: 'no-validate',
22
+ validate: (value: unknown): StandardSchemaV1.Result<Output> => ({
23
+ value: value as Output,
24
+ }),
25
+ types: {
26
+ input: undefined as unknown as Input,
27
+ output: undefined as unknown as Output,
28
+ },
29
+ },
30
+ });