@tpzdsp/next-toolkit 1.6.0 → 1.8.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,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
+ };