@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.
- package/README.md +23 -46
- package/package.json +20 -3
- package/src/components/ErrorBoundary/ErrorFallback.stories.tsx +2 -2
- package/src/components/ErrorBoundary/ErrorFallback.test.tsx +2 -2
- package/src/components/ErrorBoundary/ErrorFallback.tsx +21 -5
- package/src/components/Modal/Modal.tsx +2 -1
- package/src/components/skipLink/SkipLink.tsx +2 -1
- package/src/errors/ApiError.ts +97 -23
- package/src/http/constants.ts +111 -0
- package/src/http/fetch.ts +263 -0
- package/src/http/index.ts +6 -0
- package/src/http/logger.ts +163 -0
- package/src/http/proxy.ts +269 -0
- package/src/http/query.ts +287 -0
- package/src/http/stream.ts +77 -0
- package/src/types/api.ts +25 -0
- package/src/utils/http.ts +2 -30
- package/src/utils/schema.ts +30 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import z from 'zod/v4';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createSchema as betterFetchCreateSchema,
|
|
6
|
+
createFetch as betterFetchCreateFetch,
|
|
7
|
+
type CreateFetchOption as BetterFetchCreateFetchOption,
|
|
8
|
+
type Schema,
|
|
9
|
+
type FetchSchema,
|
|
10
|
+
ValidationError,
|
|
11
|
+
BetterFetchError,
|
|
12
|
+
type BetterFetch,
|
|
13
|
+
type StandardSchemaV1,
|
|
14
|
+
type BetterFetchPlugin,
|
|
15
|
+
} from '@better-fetch/fetch';
|
|
16
|
+
|
|
17
|
+
import { ApiError } from '../errors';
|
|
18
|
+
import { Header, isJsonMimeType, MimeType, type HttpMethods } from './constants';
|
|
19
|
+
import { HttpStatus } from './constants';
|
|
20
|
+
import { readJsonXLinesStream } from './stream';
|
|
21
|
+
import type { ApiErrorSchemaOutput } from '../errors/ApiError';
|
|
22
|
+
|
|
23
|
+
type NodeGlobal = {
|
|
24
|
+
process?: {
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isNodeEnvironment = (): boolean => {
|
|
30
|
+
const global = globalThis as NodeGlobal;
|
|
31
|
+
|
|
32
|
+
return typeof global.process?.env !== 'undefined';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const getNodeEnvVar = (key: string): string | undefined => {
|
|
36
|
+
if (!isNodeEnvironment()) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const global = globalThis as NodeGlobal;
|
|
41
|
+
|
|
42
|
+
return global.process?.env?.[key];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getAuth = (): CreateFetchOption['auth'] => {
|
|
46
|
+
const apiHasAuth = getNodeEnvVar('API_HAS_AUTH');
|
|
47
|
+
const apiUsername = getNodeEnvVar('API_USERNAME');
|
|
48
|
+
const apiPassword = getNodeEnvVar('API_PASSWORD');
|
|
49
|
+
|
|
50
|
+
if (apiHasAuth === 'true' && apiUsername && apiPassword) {
|
|
51
|
+
return {
|
|
52
|
+
type: 'Basic',
|
|
53
|
+
username: apiUsername,
|
|
54
|
+
password: apiPassword,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Tries to gracefully format a value as a string, for printing in the logger
|
|
62
|
+
const safeString = (value: unknown): string => {
|
|
63
|
+
try {
|
|
64
|
+
const string = typeof value === 'string' ? value : JSON.stringify(value);
|
|
65
|
+
|
|
66
|
+
if (!string) {
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return string;
|
|
71
|
+
} catch {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Captures any possible error from the fetch and turns it into an instance of our class, so all our errors are consistent
|
|
77
|
+
export const normalizeError = async (
|
|
78
|
+
error: unknown,
|
|
79
|
+
options?: CreateFetchOption,
|
|
80
|
+
): Promise<ApiError> => {
|
|
81
|
+
if (error instanceof ApiError) {
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validation errors from schemas
|
|
86
|
+
if (error instanceof ValidationError) {
|
|
87
|
+
return new ApiError(
|
|
88
|
+
'Response schema validation failed.',
|
|
89
|
+
HttpStatus.BadRequest,
|
|
90
|
+
z.prettifyError(error),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Upstream from better-fetch or when server responds with error
|
|
95
|
+
if (error instanceof BetterFetchError) {
|
|
96
|
+
// For some reason the library doesn't implement any functionality with the `errorSchema` option yet,
|
|
97
|
+
// so we do the validation ourselves (using the StandardSchemaV1 interface so it is zod-agnostic)
|
|
98
|
+
const parsed = await options?.errorSchema?.['~standard'].validate(error.error);
|
|
99
|
+
|
|
100
|
+
if (!parsed?.issues && parsed?.value) {
|
|
101
|
+
const { message, details } = parsed.value;
|
|
102
|
+
|
|
103
|
+
return new ApiError(message, error.status, details, {
|
|
104
|
+
// if we're reconstructing this error on the client from an error on the server, don't print the stack trace
|
|
105
|
+
rehydrated: typeof window !== 'undefined',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (parsed?.issues) {
|
|
110
|
+
return new ApiError(error.statusText, error.status, z.prettifyError(parsed));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new ApiError(
|
|
114
|
+
error.statusText,
|
|
115
|
+
error.status,
|
|
116
|
+
'Unknown error: validation returned empty result with no known issues reported.',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Internal errors
|
|
121
|
+
if (error instanceof Error) {
|
|
122
|
+
return new ApiError(error.message, HttpStatus.InternalServerError, safeString(error.cause));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return ApiError.internalServerError('Unknown cause.');
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Override the options to enforce that an error schema is provided
|
|
129
|
+
type CreateFetchOption = Omit<BetterFetchCreateFetchOption, 'errorSchema'> & {
|
|
130
|
+
errorSchema: StandardSchemaV1<unknown, ApiErrorSchemaOutput>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Override the schema options so we enforce a method is specified for each route
|
|
134
|
+
type SchemaRoutes = Record<string, Omit<FetchSchema, 'method'> & { method: HttpMethods }>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wrapper function around `@better-fetch/fetch` `createSchema` which creates a strict schema.
|
|
138
|
+
*
|
|
139
|
+
* A fetch schema is used to describe a list of routes, their HTTP method, inputs, and outputs.
|
|
140
|
+
*
|
|
141
|
+
* @param schema
|
|
142
|
+
* @param config
|
|
143
|
+
* @returns
|
|
144
|
+
*/
|
|
145
|
+
export const createSchema = <Routes extends SchemaRoutes>(schema: Routes) => {
|
|
146
|
+
return betterFetchCreateSchema(schema, {
|
|
147
|
+
strict: true,
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Utility type to merge two objects (used for merging schemas together)
|
|
152
|
+
type MergeTwo<A, B> = {
|
|
153
|
+
[K in keyof A | keyof B]: K extends keyof B ? B[K] : K extends keyof A ? A[K] : never;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Takes an array of schemas and merges all the routes together
|
|
157
|
+
type MergeSchemasInner<T extends Schema[], Key extends keyof Schema> = T extends [
|
|
158
|
+
infer First extends Schema,
|
|
159
|
+
...infer Rest extends Schema[],
|
|
160
|
+
]
|
|
161
|
+
? MergeTwo<First[Key], MergeSchemasInner<Rest, Key>>
|
|
162
|
+
: object;
|
|
163
|
+
|
|
164
|
+
// Merges the schemas from an array, so multiple schemas can be provided.
|
|
165
|
+
type MergeSchemas<T extends Schema[]> = {
|
|
166
|
+
schema: MergeSchemasInner<T, 'schema'>;
|
|
167
|
+
config: MergeSchemasInner<T, 'config'>;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// A custom plugin mainly used to fix a few bugs or add handling to extra content types, etc.
|
|
171
|
+
const TOOLKIT_PLUGIN = {
|
|
172
|
+
id: 'toolkit-plugin',
|
|
173
|
+
name: 'Toolkit Plugin',
|
|
174
|
+
init: (url, options) => {
|
|
175
|
+
// If the body is `null` but no content type was supplied, it will try and detect it but error
|
|
176
|
+
// due to accessing a field on a null value. We handle this by setting the content type header
|
|
177
|
+
// if it doesn't exist and the body is null.
|
|
178
|
+
if (options?.body === null && !(options.headers as any)?.[Header.ContentType]) {
|
|
179
|
+
(options.headers as any)[Header.ContentType] = MimeType.Json;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { url, options };
|
|
183
|
+
},
|
|
184
|
+
hooks: {
|
|
185
|
+
onRequest: (context) => {
|
|
186
|
+
// for some reason if you specify a JSON content type, better-fetch will not stringify the body
|
|
187
|
+
// but as we also have extra JSON mime types, we need to handle that
|
|
188
|
+
if (isJsonMimeType(context.headers.get(Header.ContentType))) {
|
|
189
|
+
context.body = JSON.stringify(context.body);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return context;
|
|
193
|
+
},
|
|
194
|
+
onResponse: (context) => {
|
|
195
|
+
if (context.response.headers.get(Header.ContentType)?.includes(MimeType.XJsonLines)) {
|
|
196
|
+
// `better-fetch` does not allow for handling of content types it doesn't know about, and it will
|
|
197
|
+
// always fall back to `.blob()`. We need to stream JSON X-Lines, so I do a hack to replace
|
|
198
|
+
// the blob function to return the streamed data instead. I am confident this is okay, as not only
|
|
199
|
+
// will it replace the function ONLY for this one content type, our custom plugin is always the LAST
|
|
200
|
+
// in the array, so it wouldn't mess with any other plugins that may use this.
|
|
201
|
+
(context.response as any).blob = async () => {
|
|
202
|
+
return await readJsonXLinesStream(context.response);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return context;
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
} satisfies BetterFetchPlugin;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Creates a fully typed and schema-validated fetch client by combining one or more `better-fetch` schemas.
|
|
213
|
+
*
|
|
214
|
+
* This wrapper extends the base `better-fetch` `createFetch` with:
|
|
215
|
+
* - **Automatic schema merging** — allows multiple schemas to be combined into one fetch instance.
|
|
216
|
+
* - **Enhanced error handling** — all thrown errors are normalized into consistent {@link ApiError} instances.
|
|
217
|
+
* - **Toolkit plugin** — adds extra handling for JSON content types, streaming JSON X-Lines responses,
|
|
218
|
+
* and fixes some edge-case behaviors in the library.
|
|
219
|
+
* - **Automatic basic auth** — reads credentials from environment variables if available.
|
|
220
|
+
*
|
|
221
|
+
* The resulting client can be used exactly like a `better-fetch` instance, with full type inference
|
|
222
|
+
* for routes, inputs, and outputs derived from the merged schemas.
|
|
223
|
+
*
|
|
224
|
+
* @template Schemas - One or more `better-fetch` schemas to merge.
|
|
225
|
+
* @template Option - Configuration options, including enforced `errorSchema` type.
|
|
226
|
+
* @param schemas - An array of fetch schemas to merge into a single client.
|
|
227
|
+
* @param config - Optional configuration object passed to `better-fetch`, extended with defaults and plugins.
|
|
228
|
+
* @returns A `BetterFetch` instance with merged schema routes, automatic plugin registration, and normalized error handling.
|
|
229
|
+
*
|
|
230
|
+
* @see {@link https://github.com/better-fetch/better-fetch | better-fetch on GitHub}
|
|
231
|
+
*/
|
|
232
|
+
export const createFetch = <
|
|
233
|
+
Schemas extends Schema[],
|
|
234
|
+
Option extends CreateFetchOption = CreateFetchOption,
|
|
235
|
+
>(
|
|
236
|
+
schemas: [...Schemas],
|
|
237
|
+
config?: Option,
|
|
238
|
+
): BetterFetch<
|
|
239
|
+
Omit<Option, 'schema'> & {
|
|
240
|
+
schema: MergeSchemas<Schemas>;
|
|
241
|
+
}
|
|
242
|
+
> => {
|
|
243
|
+
const mergedSchema = {
|
|
244
|
+
schema: Object.assign({}, ...schemas.map((s) => s.schema)),
|
|
245
|
+
config: Object.assign({}, ...schemas.map((s) => s.config)),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const instance = betterFetchCreateFetch({
|
|
249
|
+
schema: mergedSchema,
|
|
250
|
+
auth: getAuth(),
|
|
251
|
+
...config,
|
|
252
|
+
plugins: [...(config?.plugins ?? []), TOOLKIT_PLUGIN],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Wrap the fetcher to catch & normalize errors
|
|
256
|
+
return (async (...args) => {
|
|
257
|
+
try {
|
|
258
|
+
return await instance(...args);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
throw await normalizeError(err, config);
|
|
261
|
+
}
|
|
262
|
+
}) as typeof instance;
|
|
263
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { BetterFetchPlugin, RequestContext } from '@better-fetch/fetch';
|
|
2
|
+
|
|
3
|
+
import { Header } from './constants';
|
|
4
|
+
|
|
5
|
+
// Tries to print the request/response body as a string, and clamps the length it it's too long.
|
|
6
|
+
const preview = (data: unknown, maxLength: number): string | null => {
|
|
7
|
+
if (data == null) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
let str = typeof data === 'string' ? data : JSON.stringify(data);
|
|
13
|
+
|
|
14
|
+
if (str.length > maxLength) {
|
|
15
|
+
str = str.slice(0, maxLength) + '…';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return str;
|
|
19
|
+
} catch {
|
|
20
|
+
return '[unserializable]';
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type RequestMeta = {
|
|
25
|
+
requestId: string;
|
|
26
|
+
};
|
|
27
|
+
type MetaContext = RequestContext & {
|
|
28
|
+
meta: RequestMeta;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const UNKNOWN_ID = '???';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Config options for the logger.
|
|
35
|
+
*/
|
|
36
|
+
type RequestLoggerOptions = {
|
|
37
|
+
/**
|
|
38
|
+
* Enables/Disabled the logger. Can be a function/async function to toggle the logger dynamically
|
|
39
|
+
*/
|
|
40
|
+
enabled?: boolean | (() => Promise<boolean> | boolean);
|
|
41
|
+
/**
|
|
42
|
+
* Controls how much of the request and response body is shown in the logs.
|
|
43
|
+
*/
|
|
44
|
+
maxPreviewLength?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A logger for the fetch functions that prints detailed information on each request and respective response.
|
|
49
|
+
* Each request is given a unique ID which is paired with a response to make finding matching request/response pairs easier.
|
|
50
|
+
*
|
|
51
|
+
* It currently, prints the method, url, `Accept` and `Content-Type` headers, and the body of the request, and it prints the
|
|
52
|
+
* `Content-Type` header, status code, response body and duration of the response, for both successful and failed responses.
|
|
53
|
+
*/
|
|
54
|
+
export const requestLogger = (options?: RequestLoggerOptions): BetterFetchPlugin => {
|
|
55
|
+
const enabled =
|
|
56
|
+
typeof options?.enabled !== 'function' ? () => options?.enabled ?? true : options?.enabled;
|
|
57
|
+
|
|
58
|
+
const maxPreview = options?.maxPreviewLength ?? 150;
|
|
59
|
+
|
|
60
|
+
const timings = new Map<string, number>();
|
|
61
|
+
|
|
62
|
+
const genId = () => crypto.randomUUID().split('-')[0].toUpperCase();
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
id: 'logger',
|
|
66
|
+
name: 'Logger',
|
|
67
|
+
version: '0.1.0',
|
|
68
|
+
hooks: {
|
|
69
|
+
hookOptions: {
|
|
70
|
+
cloneResponse: true,
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
onRequest: async (context) => {
|
|
74
|
+
if (!(await enabled?.())) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const metaContext = context as MetaContext;
|
|
79
|
+
|
|
80
|
+
metaContext.meta = metaContext.meta ?? {};
|
|
81
|
+
|
|
82
|
+
const id = genId();
|
|
83
|
+
|
|
84
|
+
metaContext.meta.requestId = id;
|
|
85
|
+
context.headers.set(Header.XRequestId, id);
|
|
86
|
+
|
|
87
|
+
timings.set(id, performance.now());
|
|
88
|
+
|
|
89
|
+
const { url, method, body, headers } = context;
|
|
90
|
+
|
|
91
|
+
const bodyPreview = body ? preview(body, maxPreview) : '';
|
|
92
|
+
const contentType = headers.get(Header.ContentType) ?? 'N/A';
|
|
93
|
+
const accept = headers.get(Header.Accept) ?? 'N/A';
|
|
94
|
+
|
|
95
|
+
const message =
|
|
96
|
+
`🚀 [${method}] (${id}) ${url}\n` +
|
|
97
|
+
`\t↳ Content-Type: ${contentType}\n` +
|
|
98
|
+
`\t↳ Accept: ${accept}` +
|
|
99
|
+
(bodyPreview ? `\n\t↳ Body: ${bodyPreview}` : '');
|
|
100
|
+
|
|
101
|
+
console.log(message);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
onSuccess: async (context) => {
|
|
105
|
+
if (!(await enabled?.())) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { response, data, request } = context;
|
|
110
|
+
|
|
111
|
+
const id = (request as MetaContext).meta.requestId ?? UNKNOWN_ID;
|
|
112
|
+
|
|
113
|
+
const status = response.status;
|
|
114
|
+
const bodyPreview = preview(data, maxPreview);
|
|
115
|
+
const contentType = response.headers.get(Header.ContentType) ?? 'N/A';
|
|
116
|
+
const duration = (performance.now() - (timings.get(id) ?? 0)).toFixed(1);
|
|
117
|
+
|
|
118
|
+
const message =
|
|
119
|
+
`✅ [${request.method}] (${id}) ${request.url}\n` +
|
|
120
|
+
`\t↳ Status: ${status} (${duration}ms)\n` +
|
|
121
|
+
`\t↳ Respone Content-Type: ${contentType}\n` +
|
|
122
|
+
`\t↳ Response: ${bodyPreview}`;
|
|
123
|
+
|
|
124
|
+
console.log(message);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
onRetry: async (ctx) => {
|
|
128
|
+
if (!(await enabled?.())) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const id = (ctx.request as MetaContext).meta.requestId ?? UNKNOWN_ID;
|
|
133
|
+
|
|
134
|
+
console.warn(
|
|
135
|
+
`🔁 [${ctx.request.method}] (${id}) Retrying ${ctx.request.url}... Attempt: ${
|
|
136
|
+
(ctx.request.retryAttempt ?? 0) + 1
|
|
137
|
+
}`,
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
onError: async (context) => {
|
|
142
|
+
if (!(await enabled?.())) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { response, error, request } = context;
|
|
147
|
+
|
|
148
|
+
const id = (request as MetaContext).meta.requestId ?? UNKNOWN_ID;
|
|
149
|
+
|
|
150
|
+
const status = response?.status ?? 'unknown';
|
|
151
|
+
const errorPreview = preview(error, maxPreview);
|
|
152
|
+
const duration = (performance.now() - (timings.get(id) ?? 0)).toFixed(1);
|
|
153
|
+
|
|
154
|
+
const message =
|
|
155
|
+
`❌ [${request.method}] (${id}) ${request.url}\n` +
|
|
156
|
+
`\t↳ Status: ${status} (${duration}ms)\n` +
|
|
157
|
+
`\t↳ Error: ${errorPreview}`;
|
|
158
|
+
|
|
159
|
+
console.error(message);
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
import { type FetchSchemaRoutes, type Schema } from '@better-fetch/fetch';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Header,
|
|
7
|
+
HttpMethod,
|
|
8
|
+
isJsonMimeType,
|
|
9
|
+
isTextMimeType,
|
|
10
|
+
MimeType,
|
|
11
|
+
SPECIAL_FORM_DATA_TYPE,
|
|
12
|
+
SPECIAL_FORM_DOWNLOAD_POST,
|
|
13
|
+
type HttpMethods,
|
|
14
|
+
} from './constants';
|
|
15
|
+
import { normalizeError } from './fetch';
|
|
16
|
+
import type {
|
|
17
|
+
EmptyRoutePrefixes,
|
|
18
|
+
OptionsWithInput,
|
|
19
|
+
SchemaRouteOutput,
|
|
20
|
+
SchemaRoutes,
|
|
21
|
+
} from './query';
|
|
22
|
+
import { ApiError } from '../errors/ApiError';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The type of the Next.JS route handler function.
|
|
26
|
+
* Currently only works when inder the path `/proxy/[...path]/`
|
|
27
|
+
*/
|
|
28
|
+
type ProxyMethod = (
|
|
29
|
+
request: NextRequest,
|
|
30
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
31
|
+
) => Promise<NextResponse>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The invidiual method route handlers that we export.
|
|
35
|
+
*/
|
|
36
|
+
type ProxyMethods = {
|
|
37
|
+
GET: ProxyMethod;
|
|
38
|
+
POST: ProxyMethod;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Each route in a fetch schema must have a corresponding handler. This type defines the *arguments* passed from the proxy
|
|
43
|
+
* to the the handler. The arguments depend on what inputs are defined in the schema, so the handler can have a mix
|
|
44
|
+
* of `query` and `body` arguments.
|
|
45
|
+
*/
|
|
46
|
+
type ProxyEndpointHandlerArguments<
|
|
47
|
+
Routes extends SchemaRoutes,
|
|
48
|
+
Route extends keyof Routes,
|
|
49
|
+
SchemaInputs = OptionsWithInput<Routes, Route>,
|
|
50
|
+
> = object &
|
|
51
|
+
(SchemaInputs extends { query: unknown } ? { query: SchemaInputs['query'] } : object) &
|
|
52
|
+
(SchemaInputs extends { body: unknown } ? { body: SchemaInputs['body'] } : object);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Defines the type of the actual handler, with the arguments from above.
|
|
56
|
+
* It will infer the output type from the schema, otherwise it defaults to `unknown`. The function allows
|
|
57
|
+
* both the output type and a `NextResponse` containing the output type to be returned, which allows for
|
|
58
|
+
* flexibility (for example, if we wanted to return a stream of data instead).
|
|
59
|
+
*/
|
|
60
|
+
type ProxyEndpointHandler<
|
|
61
|
+
Routes extends SchemaRoutes,
|
|
62
|
+
Route extends keyof Routes,
|
|
63
|
+
SchemaOutput = SchemaRouteOutput<Routes, Route>,
|
|
64
|
+
> = (
|
|
65
|
+
args: ProxyEndpointHandlerArguments<Routes, Route>,
|
|
66
|
+
) => Promise<SchemaOutput | NextResponse<SchemaOutput>>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Defines handlers for every route in a given fetch schema.
|
|
70
|
+
*/
|
|
71
|
+
type ProxyEndpointHandlers<S extends Schema> = {
|
|
72
|
+
[K in keyof Omit<S['schema'], EmptyRoutePrefixes>]: ProxyEndpointHandler<S['schema'], K>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const ERORR_PREFIX = '[ProxyError]';
|
|
76
|
+
|
|
77
|
+
// Tiny bit of duplication from `better-fetch` as it has this functionality, but as this is a proxy
|
|
78
|
+
// and the request isnt *received* by `better-fetch`, we have to do it ourselves.
|
|
79
|
+
/**
|
|
80
|
+
* Parses a request body depending on the `Content-Type` header.
|
|
81
|
+
*
|
|
82
|
+
* @param request Incoming request.
|
|
83
|
+
* @returns The parsed body, or throws an error if the header is missing or the mime type is unknown.
|
|
84
|
+
*/
|
|
85
|
+
const parseRequestBody = async (request: Request): Promise<unknown> => {
|
|
86
|
+
const contentType = request.headers.get(Header.ContentType);
|
|
87
|
+
|
|
88
|
+
switch (true) {
|
|
89
|
+
case isJsonMimeType(contentType):
|
|
90
|
+
return await request.json();
|
|
91
|
+
|
|
92
|
+
case contentType === MimeType.Form: {
|
|
93
|
+
const data = await request.formData();
|
|
94
|
+
|
|
95
|
+
if (data.get(SPECIAL_FORM_DATA_TYPE) === SPECIAL_FORM_DOWNLOAD_POST) {
|
|
96
|
+
return JSON.parse((data.get('body') as string) ?? '{}');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return Object.fromEntries(data.entries());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case isTextMimeType(contentType):
|
|
103
|
+
return await request.text();
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
throw ApiError.unprocessableContent(
|
|
107
|
+
contentType
|
|
108
|
+
? `${ERORR_PREFIX} Request has unsupported Content-Type header: ${contentType}`
|
|
109
|
+
: `${ERORR_PREFIX} Request missing Content-Type header`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Creates a `NextResponse` from a given body, with the correct content type from
|
|
116
|
+
* the `Accept` header of the request.
|
|
117
|
+
*
|
|
118
|
+
* @param body The body to be returned.
|
|
119
|
+
* @param request The incoming request.
|
|
120
|
+
* @returns The response (defaults to `text/plain` if the header is missing or the mime type is unknown)
|
|
121
|
+
*/
|
|
122
|
+
const makeResponse = (body: unknown, request: Request): NextResponse => {
|
|
123
|
+
const accept = request.headers.get(Header.Accept) ?? MimeType.Text;
|
|
124
|
+
|
|
125
|
+
let responseBody: BodyInit;
|
|
126
|
+
|
|
127
|
+
switch (true) {
|
|
128
|
+
case isJsonMimeType(accept):
|
|
129
|
+
responseBody = JSON.stringify(body);
|
|
130
|
+
break;
|
|
131
|
+
|
|
132
|
+
case isTextMimeType(accept):
|
|
133
|
+
responseBody = typeof body === 'string' ? body : String(body);
|
|
134
|
+
break;
|
|
135
|
+
|
|
136
|
+
default:
|
|
137
|
+
console.warn(
|
|
138
|
+
`Request to ${request.url} made with unhandled Accept header ${accept}. Defaulting to text/plain.`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
responseBody = '';
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return new NextResponse(responseBody, {
|
|
146
|
+
headers: {
|
|
147
|
+
[Header.ContentType]: accept,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Creates a schema-driven proxy for Next.JS route handlers that forwards requests to
|
|
154
|
+
* internal endpoint handlers while enforcing type safety and transforming results.
|
|
155
|
+
*
|
|
156
|
+
* This function is designed to be used in `app` route handlers in Next.js,
|
|
157
|
+
* allowing you to hide requests to the real API behind a controlled backend layer.
|
|
158
|
+
* The proxy automatically handles:
|
|
159
|
+
* - Parsing request bodies based on `Content-Type` headers
|
|
160
|
+
* - Returning responses in the correct format based on the request's `Accept` header
|
|
161
|
+
* - Mapping incoming requests to the correct handler based on the fetch schema
|
|
162
|
+
* - Normalizing errors into consistent `ApiError` objects with proper HTTP status codes
|
|
163
|
+
*
|
|
164
|
+
* The proxy methods (`GET` and `POST`) are automatically generated from a `better-fetch` schema,
|
|
165
|
+
* ensuring that only valid routes and HTTP methods are accessible.
|
|
166
|
+
*
|
|
167
|
+
* @template S - A `better-fetch` schema describing routes, inputs, and outputs.
|
|
168
|
+
* @param schema - The fetch schema used to validate and route incoming requests.
|
|
169
|
+
* @param endpointHandlers - An object mapping schema routes to handler functions, which process
|
|
170
|
+
* the request and return a typed response or a `NextResponse`.
|
|
171
|
+
* @returns An object containing `GET` and `POST` handlers suitable for Next.js route exports:
|
|
172
|
+
* ```ts
|
|
173
|
+
* export const { GET, POST } = proxy;
|
|
174
|
+
* ```
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* const proxy = createProxy(apiSchema, {
|
|
179
|
+
* 'proxy/users': async ({ query }) => fetchUsers(query),
|
|
180
|
+
* 'proxy/user/new': async ({ body }) => createUser(body),
|
|
181
|
+
* });
|
|
182
|
+
*
|
|
183
|
+
* export const { GET, POST } = proxy;
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* @remarks
|
|
187
|
+
* - This proxy is fully type-safe: route inputs and outputs are inferred from the schema.
|
|
188
|
+
* - Only routes defined in the schema are accessible; invalid routes will return a 404.
|
|
189
|
+
* - Errors thrown by handlers or during processing are normalized to structured API errors.
|
|
190
|
+
*/
|
|
191
|
+
export const createProxy = <S extends Schema>(
|
|
192
|
+
schema: S,
|
|
193
|
+
endpointHandlers: ProxyEndpointHandlers<S>,
|
|
194
|
+
): ProxyMethods => {
|
|
195
|
+
// Factory function that returns an inner function which is the individual Next.JS route handler.
|
|
196
|
+
// This route handler takes the incoming request, parses the body depending on the `Content-Type` header,
|
|
197
|
+
// passes it to the matching route handler, and then responds in the correcy type using the `Accept` header.
|
|
198
|
+
const makeVerbHandler =
|
|
199
|
+
(verb: HttpMethods): ProxyMethod =>
|
|
200
|
+
// route handler function
|
|
201
|
+
async (request, { params }) => {
|
|
202
|
+
const debugRequestId = request.headers.get(Header.XRequestId);
|
|
203
|
+
|
|
204
|
+
if (debugRequestId) {
|
|
205
|
+
console.log(
|
|
206
|
+
`[Proxy ${verb.toUpperCase()}]: (${debugRequestId}) Client request to ${request.url}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const { path } = await params;
|
|
212
|
+
// add `proxy/` back as it's lost when requesting this route but required to match
|
|
213
|
+
// up to an individual handler
|
|
214
|
+
const endpoint = `proxy/${path.join('/')}`;
|
|
215
|
+
|
|
216
|
+
// check if the request to the proxy matches a valid route in the schema
|
|
217
|
+
const route = schema.schema[endpoint as keyof FetchSchemaRoutes];
|
|
218
|
+
|
|
219
|
+
if (!route) {
|
|
220
|
+
throw ApiError.notFound(`${ERORR_PREFIX} Unknown route: ${endpoint}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (route.method?.toLowerCase() !== verb) {
|
|
224
|
+
throw ApiError.notAllowed(
|
|
225
|
+
`${ERORR_PREFIX} ${endpoint} is ${route.method?.toLowerCase()}, got ${verb}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// get the matching route handler
|
|
230
|
+
const handler = endpointHandlers[endpoint as keyof typeof endpointHandlers];
|
|
231
|
+
|
|
232
|
+
if (!handler) {
|
|
233
|
+
throw ApiError.notImplemented(`${ERORR_PREFIX} No handler for ${endpoint}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// We don't need to validate the following data as this is our internal proxy and we control what we pass to the proxy,
|
|
237
|
+
// and what we return.
|
|
238
|
+
// Typescript will enforce the correct object shape when we fetch this proxy, so we can be sure they're correct.
|
|
239
|
+
// It should be trivial to add validation if we do find that we are passing invalid data often.
|
|
240
|
+
let handlerArgs: Record<string, unknown> = {};
|
|
241
|
+
|
|
242
|
+
if (verb === HttpMethod.Post && 'input' in route) {
|
|
243
|
+
handlerArgs.body = await parseRequestBody(request);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if ('query' in route) {
|
|
247
|
+
handlerArgs.query = Object.fromEntries(request.nextUrl.searchParams.entries());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
251
|
+
const response = await handler(handlerArgs as any);
|
|
252
|
+
|
|
253
|
+
// pass through the response otherwise make the correct response with the `Accept` header
|
|
254
|
+
return response instanceof NextResponse ? response : makeResponse(response, request);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const apiError = await normalizeError(error);
|
|
257
|
+
|
|
258
|
+
// use the `status` field to set the status of the response
|
|
259
|
+
// this is important as just throwing an error will always return a 500
|
|
260
|
+
// but we want to return a specific payload with a specific status
|
|
261
|
+
return NextResponse.json(apiError, { status: apiError.status });
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
GET: makeVerbHandler(HttpMethod.Get),
|
|
267
|
+
POST: makeVerbHandler(HttpMethod.Post),
|
|
268
|
+
};
|
|
269
|
+
};
|