@mohasinac/next 1.0.0 → 1.1.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/dist/index.cjs +224 -8
- package/dist/index.d.cts +109 -2
- package/dist/index.d.ts +109 -2
- package/dist/index.js +214 -7
- package/package.json +4 -2
package/dist/index.cjs
CHANGED
|
@@ -35,12 +35,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
35
35
|
var index_exports = {};
|
|
36
36
|
__export(index_exports, {
|
|
37
37
|
createApiErrorHandler: () => createApiErrorHandler,
|
|
38
|
-
|
|
38
|
+
createApiHandlerFactory: () => createApiHandlerFactory,
|
|
39
|
+
createRouteHandler: () => createRouteHandler,
|
|
40
|
+
getBooleanParam: () => getBooleanParam,
|
|
41
|
+
getNumberParam: () => getNumberParam,
|
|
42
|
+
getOptionalSessionCookie: () => getOptionalSessionCookie,
|
|
43
|
+
getRequiredSessionCookie: () => getRequiredSessionCookie,
|
|
44
|
+
getSearchParams: () => getSearchParams,
|
|
45
|
+
getStringParam: () => getStringParam,
|
|
46
|
+
invalidateCache: () => invalidateCache,
|
|
47
|
+
withCache: () => withCache
|
|
39
48
|
});
|
|
40
49
|
module.exports = __toCommonJS(index_exports);
|
|
41
50
|
|
|
42
51
|
// src/api/errorHandler.ts
|
|
43
|
-
var import_server = require("next/server");
|
|
52
|
+
var import_server = require("next/server.js");
|
|
44
53
|
function createApiErrorHandler(options) {
|
|
45
54
|
const {
|
|
46
55
|
isAppError,
|
|
@@ -86,7 +95,7 @@ function createApiErrorHandler(options) {
|
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
// src/api/routeHandler.ts
|
|
89
|
-
var import_server2 = require("next/server");
|
|
98
|
+
var import_server2 = require("next/server.js");
|
|
90
99
|
var import_contracts = require("@mohasinac/contracts");
|
|
91
100
|
function readSessionCookie(request) {
|
|
92
101
|
var _a;
|
|
@@ -171,15 +180,222 @@ function createRouteHandler(options) {
|
|
|
171
180
|
const status = typeof (err == null ? void 0 : err.status) === "number" ? err.status : 500;
|
|
172
181
|
const message = err instanceof Error ? err.message : "Internal server error";
|
|
173
182
|
console.error(`[createRouteHandler] ${request.method} failed`, err);
|
|
174
|
-
return import_server2.NextResponse.json(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
183
|
+
return import_server2.NextResponse.json({ success: false, error: message }, { status });
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/api/apiHandler.ts
|
|
189
|
+
function createApiHandlerFactory(deps) {
|
|
190
|
+
return function createApiHandler(options) {
|
|
191
|
+
return async (request, context) => {
|
|
192
|
+
const startMs = performance.now();
|
|
193
|
+
try {
|
|
194
|
+
let rateLimitHeaders;
|
|
195
|
+
if (options.rateLimit) {
|
|
196
|
+
const rateLimitResult = await deps.applyRateLimit(
|
|
197
|
+
request,
|
|
198
|
+
options.rateLimit
|
|
199
|
+
);
|
|
200
|
+
rateLimitHeaders = {
|
|
201
|
+
"RateLimit-Limit": String(rateLimitResult.limit),
|
|
202
|
+
"RateLimit-Remaining": String(rateLimitResult.remaining),
|
|
203
|
+
"RateLimit-Reset": String(rateLimitResult.reset)
|
|
204
|
+
};
|
|
205
|
+
if (!rateLimitResult.success) {
|
|
206
|
+
return new Response(
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
success: false,
|
|
209
|
+
error: deps.getRateLimitExceededMessage()
|
|
210
|
+
}),
|
|
211
|
+
{
|
|
212
|
+
status: 429,
|
|
213
|
+
headers: __spreadValues({
|
|
214
|
+
"Content-Type": "application/json"
|
|
215
|
+
}, rateLimitHeaders)
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
let user;
|
|
221
|
+
if (options.roles && options.roles.length > 0) {
|
|
222
|
+
user = await deps.requireRoleFromRequest(request, options.roles);
|
|
223
|
+
} else if (options.auth) {
|
|
224
|
+
user = await deps.requireAuthFromRequest(request);
|
|
225
|
+
}
|
|
226
|
+
let validatedBody;
|
|
227
|
+
if (options.schema) {
|
|
228
|
+
if (typeof options.schema.safeParse === "function") {
|
|
229
|
+
const body = await request.json();
|
|
230
|
+
const result = options.schema.safeParse(body);
|
|
231
|
+
if (!result.success) {
|
|
232
|
+
return deps.errorResponse(
|
|
233
|
+
"Validation failed",
|
|
234
|
+
400,
|
|
235
|
+
result.error.issues
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
validatedBody = result.data;
|
|
239
|
+
} else {
|
|
240
|
+
try {
|
|
241
|
+
validatedBody = await request.json();
|
|
242
|
+
} catch (e) {
|
|
243
|
+
validatedBody = void 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const resolvedParams = (context == null ? void 0 : context.params) ? await context.params : void 0;
|
|
248
|
+
const response = await options.handler({
|
|
249
|
+
request,
|
|
250
|
+
user,
|
|
251
|
+
body: validatedBody,
|
|
252
|
+
params: resolvedParams
|
|
253
|
+
});
|
|
254
|
+
response.headers.set("Access-Control-Max-Age", "86400");
|
|
255
|
+
if (rateLimitHeaders) {
|
|
256
|
+
for (const [key, value] of Object.entries(rateLimitHeaders)) {
|
|
257
|
+
response.headers.set(key, value);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
deps.logTiming({
|
|
261
|
+
method: request.method,
|
|
262
|
+
path: new URL(request.url).pathname,
|
|
263
|
+
status: response.status,
|
|
264
|
+
durationMs: Math.round(performance.now() - startMs)
|
|
265
|
+
});
|
|
266
|
+
return response;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
deps.logTiming({
|
|
269
|
+
method: request.method,
|
|
270
|
+
path: new URL(request.url).pathname,
|
|
271
|
+
durationMs: Math.round(performance.now() - startMs),
|
|
272
|
+
error: error instanceof Error ? error.message : String(error)
|
|
273
|
+
});
|
|
274
|
+
return deps.handleApiError(error);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/request-helpers.ts
|
|
281
|
+
var import_errors = require("@mohasinac/errors");
|
|
282
|
+
function readCookieFromHeader(request, name) {
|
|
283
|
+
var _a;
|
|
284
|
+
const cookieHeader = (_a = request.headers.get("cookie")) != null ? _a : "";
|
|
285
|
+
const match = cookieHeader.match(
|
|
286
|
+
new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)
|
|
287
|
+
);
|
|
288
|
+
return match ? decodeURIComponent(match[1]) : void 0;
|
|
289
|
+
}
|
|
290
|
+
function getSearchParams(request) {
|
|
291
|
+
return new URL(request.url).searchParams;
|
|
292
|
+
}
|
|
293
|
+
function getOptionalSessionCookie(request) {
|
|
294
|
+
var _a;
|
|
295
|
+
const cookieValue = (_a = request.cookies) == null ? void 0 : _a.get("__session");
|
|
296
|
+
if (typeof cookieValue === "string") return cookieValue;
|
|
297
|
+
if (cookieValue && typeof cookieValue === "object") return cookieValue.value;
|
|
298
|
+
return readCookieFromHeader(request, "__session");
|
|
299
|
+
}
|
|
300
|
+
function getRequiredSessionCookie(request) {
|
|
301
|
+
const cookie = getOptionalSessionCookie(request);
|
|
302
|
+
if (!cookie) throw new import_errors.AuthenticationError("Unauthorized");
|
|
303
|
+
return cookie;
|
|
304
|
+
}
|
|
305
|
+
function getBooleanParam(searchParams, key) {
|
|
306
|
+
const value = searchParams.get(key);
|
|
307
|
+
if (value === null) return void 0;
|
|
308
|
+
return value === "true";
|
|
309
|
+
}
|
|
310
|
+
function getStringParam(searchParams, key) {
|
|
311
|
+
const value = searchParams.get(key);
|
|
312
|
+
if (!value) return void 0;
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
function getNumberParam(searchParams, key, fallback, options) {
|
|
316
|
+
const rawValue = searchParams.get(key);
|
|
317
|
+
const parsed = rawValue ? Number.parseInt(rawValue, 10) : fallback;
|
|
318
|
+
const safeValue = Number.isNaN(parsed) ? fallback : parsed;
|
|
319
|
+
if (typeof (options == null ? void 0 : options.min) === "number" && safeValue < options.min)
|
|
320
|
+
return options.min;
|
|
321
|
+
if (typeof (options == null ? void 0 : options.max) === "number" && safeValue > options.max)
|
|
322
|
+
return options.max;
|
|
323
|
+
return safeValue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/cache-middleware.ts
|
|
327
|
+
var import_server3 = require("next/server.js");
|
|
328
|
+
var import_core = require("@mohasinac/core");
|
|
329
|
+
var DEFAULT_TTL = 5 * 60 * 1e3;
|
|
330
|
+
var cache = import_core.CacheManager.getInstance(500);
|
|
331
|
+
function buildCacheKey(request, config) {
|
|
332
|
+
if (config.keyGenerator) return config.keyGenerator(request);
|
|
333
|
+
const url = new URL(request.url);
|
|
334
|
+
if (config.includeQuery !== false && url.search)
|
|
335
|
+
return `${url.pathname}${url.search}`;
|
|
336
|
+
return url.pathname;
|
|
337
|
+
}
|
|
338
|
+
function isCacheable(request, config) {
|
|
339
|
+
var _a;
|
|
340
|
+
if (config.bypassCache) return false;
|
|
341
|
+
const methods = (_a = config.methods) != null ? _a : ["GET"];
|
|
342
|
+
return methods.includes(request.method);
|
|
343
|
+
}
|
|
344
|
+
function withCache(handler, config = {}) {
|
|
345
|
+
return async (request, ...args) => {
|
|
346
|
+
var _a;
|
|
347
|
+
if (!isCacheable(request, config)) {
|
|
348
|
+
return handler(request, ...args);
|
|
349
|
+
}
|
|
350
|
+
const key = buildCacheKey(request, config);
|
|
351
|
+
const cached = cache.get(key);
|
|
352
|
+
if (cached) {
|
|
353
|
+
return new import_server3.NextResponse(cached.body, {
|
|
354
|
+
status: cached.status,
|
|
355
|
+
headers: cached.headers
|
|
356
|
+
});
|
|
178
357
|
}
|
|
358
|
+
const response = await handler(request, ...args);
|
|
359
|
+
const body = await response.text();
|
|
360
|
+
const headers = {};
|
|
361
|
+
response.headers.forEach((value, key2) => {
|
|
362
|
+
headers[key2] = value;
|
|
363
|
+
});
|
|
364
|
+
cache.set(
|
|
365
|
+
key,
|
|
366
|
+
{ body, status: response.status, headers },
|
|
367
|
+
{ ttl: (_a = config.ttl) != null ? _a : DEFAULT_TTL }
|
|
368
|
+
);
|
|
369
|
+
return new import_server3.NextResponse(body, { status: response.status, headers });
|
|
179
370
|
};
|
|
180
371
|
}
|
|
372
|
+
function invalidateCache(pattern) {
|
|
373
|
+
if (!pattern) {
|
|
374
|
+
cache.clear();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const keys = cache.keys();
|
|
378
|
+
if (typeof pattern === "string") {
|
|
379
|
+
for (const key of keys) {
|
|
380
|
+
if (key.startsWith(pattern)) cache.delete(key);
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
for (const key of keys) {
|
|
384
|
+
if (pattern.test(key)) cache.delete(key);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
181
388
|
// Annotate the CommonJS export names for ESM import in node:
|
|
182
389
|
0 && (module.exports = {
|
|
183
390
|
createApiErrorHandler,
|
|
184
|
-
|
|
391
|
+
createApiHandlerFactory,
|
|
392
|
+
createRouteHandler,
|
|
393
|
+
getBooleanParam,
|
|
394
|
+
getNumberParam,
|
|
395
|
+
getOptionalSessionCookie,
|
|
396
|
+
getRequiredSessionCookie,
|
|
397
|
+
getSearchParams,
|
|
398
|
+
getStringParam,
|
|
399
|
+
invalidateCache,
|
|
400
|
+
withCache
|
|
185
401
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
1
|
+
import { NextResponse, NextRequest } from 'next/server.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* IAuthVerifier — Injectable auth interface for createApiHandler.
|
|
@@ -172,4 +172,111 @@ declare function createRouteHandler<TInput = unknown, TParams = Record<string, s
|
|
|
172
172
|
params: Promise<TParams>;
|
|
173
173
|
}) => Promise<NextResponse>;
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
interface SafeParseSchema<TInput> {
|
|
176
|
+
safeParse: (input: unknown) => {
|
|
177
|
+
success: true;
|
|
178
|
+
data: TInput;
|
|
179
|
+
} | {
|
|
180
|
+
success: false;
|
|
181
|
+
error: {
|
|
182
|
+
issues?: unknown[];
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
interface ApiRateLimitResult {
|
|
187
|
+
success: boolean;
|
|
188
|
+
limit: number;
|
|
189
|
+
remaining: number;
|
|
190
|
+
reset: number;
|
|
191
|
+
}
|
|
192
|
+
interface ApiHandlerOptions<TInput, TParams extends Record<string, string>, TRole, TRateLimitConfig, TUser> {
|
|
193
|
+
auth?: boolean;
|
|
194
|
+
roles?: TRole[];
|
|
195
|
+
rateLimit?: TRateLimitConfig;
|
|
196
|
+
schema?: SafeParseSchema<TInput>;
|
|
197
|
+
handler: (ctx: {
|
|
198
|
+
request: Request;
|
|
199
|
+
user?: TUser;
|
|
200
|
+
body?: TInput;
|
|
201
|
+
params?: TParams;
|
|
202
|
+
}) => Promise<Response>;
|
|
203
|
+
}
|
|
204
|
+
interface ApiHandlerFactoryDeps<TRole, TRateLimitConfig, TUser> {
|
|
205
|
+
applyRateLimit: (request: Request, config: TRateLimitConfig) => Promise<ApiRateLimitResult>;
|
|
206
|
+
requireAuthFromRequest: (request: Request) => Promise<TUser>;
|
|
207
|
+
requireRoleFromRequest: (request: Request, roles: TRole[]) => Promise<TUser>;
|
|
208
|
+
errorResponse: (message: string, status: number, issues?: unknown) => Response;
|
|
209
|
+
handleApiError: (error: unknown) => Response;
|
|
210
|
+
getRateLimitExceededMessage: () => string;
|
|
211
|
+
logTiming: (entry: {
|
|
212
|
+
method: string;
|
|
213
|
+
path: string;
|
|
214
|
+
status?: number;
|
|
215
|
+
durationMs: number;
|
|
216
|
+
error?: string;
|
|
217
|
+
}) => void;
|
|
218
|
+
}
|
|
219
|
+
declare function createApiHandlerFactory<TRole, TRateLimitConfig, TUser>(deps: ApiHandlerFactoryDeps<TRole, TRateLimitConfig, TUser>): <TInput = unknown, TParams extends Record<string, string> = Record<string, string>>(options: ApiHandlerOptions<TInput, TParams, TRole, TRateLimitConfig, TUser>) => (request: Request, context: {
|
|
220
|
+
params: Promise<TParams>;
|
|
221
|
+
}) => Promise<Response>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* API Request Helpers
|
|
225
|
+
*
|
|
226
|
+
* Generic utilities for API route request parsing and session extraction.
|
|
227
|
+
* Uses structural request types (not NextRequest) to avoid cross-package
|
|
228
|
+
* type identity issues when multiple Next.js installs exist in a monorepo.
|
|
229
|
+
*/
|
|
230
|
+
interface CookieStoreLike {
|
|
231
|
+
get(name: string): {
|
|
232
|
+
value?: string;
|
|
233
|
+
} | string | undefined;
|
|
234
|
+
}
|
|
235
|
+
type CookieCapableRequest = Request & {
|
|
236
|
+
cookies?: CookieStoreLike;
|
|
237
|
+
};
|
|
238
|
+
declare function getSearchParams(request: Request): URLSearchParams;
|
|
239
|
+
declare function getOptionalSessionCookie(request: CookieCapableRequest): string | undefined;
|
|
240
|
+
declare function getRequiredSessionCookie(request: CookieCapableRequest): string;
|
|
241
|
+
declare function getBooleanParam(searchParams: URLSearchParams, key: string): boolean | undefined;
|
|
242
|
+
declare function getStringParam(searchParams: URLSearchParams, key: string): string | undefined;
|
|
243
|
+
declare function getNumberParam(searchParams: URLSearchParams, key: string, fallback: number, options?: {
|
|
244
|
+
min?: number;
|
|
245
|
+
max?: number;
|
|
246
|
+
}): number;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* API Response Cache Middleware
|
|
250
|
+
*
|
|
251
|
+
* Wraps a Next.js API handler with in-memory response caching via CacheManager.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* export const GET = withCache(
|
|
256
|
+
* async (request) => { ... return NextResponse.json(data); },
|
|
257
|
+
* { ttl: 5 * 60 * 1000 }
|
|
258
|
+
* );
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
261
|
+
|
|
262
|
+
interface CacheConfig {
|
|
263
|
+
/** Time to live in milliseconds. Default: 5 minutes. */
|
|
264
|
+
ttl?: number;
|
|
265
|
+
/** Include query string in cache key. Default: true. */
|
|
266
|
+
includeQuery?: boolean;
|
|
267
|
+
/** Custom cache key generator. */
|
|
268
|
+
keyGenerator?: (request: NextRequest) => string;
|
|
269
|
+
/** Bypass caching entirely. Default: false. */
|
|
270
|
+
bypassCache?: boolean;
|
|
271
|
+
/** Only cache for these HTTP methods. Default: ['GET']. */
|
|
272
|
+
methods?: string[];
|
|
273
|
+
}
|
|
274
|
+
type Handler = (request: NextRequest, ...args: unknown[]) => Promise<NextResponse>;
|
|
275
|
+
declare function withCache(handler: Handler, config?: CacheConfig): Handler;
|
|
276
|
+
/**
|
|
277
|
+
* Invalidate cache entries by key prefix or regex pattern.
|
|
278
|
+
* Pass no argument to clear the entire cache.
|
|
279
|
+
*/
|
|
280
|
+
declare function invalidateCache(pattern?: string | RegExp): void;
|
|
281
|
+
|
|
282
|
+
export { type ApiErrorHandlerOptions, type ApiHandlerFactoryDeps, type ApiHandlerOptions, type ApiRateLimitResult, type AuthVerifiedUser, type CacheConfig, type IApiErrorLogger, type IAuthVerifier, type RouteUser, createApiErrorHandler, createApiHandlerFactory, createRouteHandler, getBooleanParam, getNumberParam, getOptionalSessionCookie, getRequiredSessionCookie, getSearchParams, getStringParam, invalidateCache, withCache };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
1
|
+
import { NextResponse, NextRequest } from 'next/server.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* IAuthVerifier — Injectable auth interface for createApiHandler.
|
|
@@ -172,4 +172,111 @@ declare function createRouteHandler<TInput = unknown, TParams = Record<string, s
|
|
|
172
172
|
params: Promise<TParams>;
|
|
173
173
|
}) => Promise<NextResponse>;
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
interface SafeParseSchema<TInput> {
|
|
176
|
+
safeParse: (input: unknown) => {
|
|
177
|
+
success: true;
|
|
178
|
+
data: TInput;
|
|
179
|
+
} | {
|
|
180
|
+
success: false;
|
|
181
|
+
error: {
|
|
182
|
+
issues?: unknown[];
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
interface ApiRateLimitResult {
|
|
187
|
+
success: boolean;
|
|
188
|
+
limit: number;
|
|
189
|
+
remaining: number;
|
|
190
|
+
reset: number;
|
|
191
|
+
}
|
|
192
|
+
interface ApiHandlerOptions<TInput, TParams extends Record<string, string>, TRole, TRateLimitConfig, TUser> {
|
|
193
|
+
auth?: boolean;
|
|
194
|
+
roles?: TRole[];
|
|
195
|
+
rateLimit?: TRateLimitConfig;
|
|
196
|
+
schema?: SafeParseSchema<TInput>;
|
|
197
|
+
handler: (ctx: {
|
|
198
|
+
request: Request;
|
|
199
|
+
user?: TUser;
|
|
200
|
+
body?: TInput;
|
|
201
|
+
params?: TParams;
|
|
202
|
+
}) => Promise<Response>;
|
|
203
|
+
}
|
|
204
|
+
interface ApiHandlerFactoryDeps<TRole, TRateLimitConfig, TUser> {
|
|
205
|
+
applyRateLimit: (request: Request, config: TRateLimitConfig) => Promise<ApiRateLimitResult>;
|
|
206
|
+
requireAuthFromRequest: (request: Request) => Promise<TUser>;
|
|
207
|
+
requireRoleFromRequest: (request: Request, roles: TRole[]) => Promise<TUser>;
|
|
208
|
+
errorResponse: (message: string, status: number, issues?: unknown) => Response;
|
|
209
|
+
handleApiError: (error: unknown) => Response;
|
|
210
|
+
getRateLimitExceededMessage: () => string;
|
|
211
|
+
logTiming: (entry: {
|
|
212
|
+
method: string;
|
|
213
|
+
path: string;
|
|
214
|
+
status?: number;
|
|
215
|
+
durationMs: number;
|
|
216
|
+
error?: string;
|
|
217
|
+
}) => void;
|
|
218
|
+
}
|
|
219
|
+
declare function createApiHandlerFactory<TRole, TRateLimitConfig, TUser>(deps: ApiHandlerFactoryDeps<TRole, TRateLimitConfig, TUser>): <TInput = unknown, TParams extends Record<string, string> = Record<string, string>>(options: ApiHandlerOptions<TInput, TParams, TRole, TRateLimitConfig, TUser>) => (request: Request, context: {
|
|
220
|
+
params: Promise<TParams>;
|
|
221
|
+
}) => Promise<Response>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* API Request Helpers
|
|
225
|
+
*
|
|
226
|
+
* Generic utilities for API route request parsing and session extraction.
|
|
227
|
+
* Uses structural request types (not NextRequest) to avoid cross-package
|
|
228
|
+
* type identity issues when multiple Next.js installs exist in a monorepo.
|
|
229
|
+
*/
|
|
230
|
+
interface CookieStoreLike {
|
|
231
|
+
get(name: string): {
|
|
232
|
+
value?: string;
|
|
233
|
+
} | string | undefined;
|
|
234
|
+
}
|
|
235
|
+
type CookieCapableRequest = Request & {
|
|
236
|
+
cookies?: CookieStoreLike;
|
|
237
|
+
};
|
|
238
|
+
declare function getSearchParams(request: Request): URLSearchParams;
|
|
239
|
+
declare function getOptionalSessionCookie(request: CookieCapableRequest): string | undefined;
|
|
240
|
+
declare function getRequiredSessionCookie(request: CookieCapableRequest): string;
|
|
241
|
+
declare function getBooleanParam(searchParams: URLSearchParams, key: string): boolean | undefined;
|
|
242
|
+
declare function getStringParam(searchParams: URLSearchParams, key: string): string | undefined;
|
|
243
|
+
declare function getNumberParam(searchParams: URLSearchParams, key: string, fallback: number, options?: {
|
|
244
|
+
min?: number;
|
|
245
|
+
max?: number;
|
|
246
|
+
}): number;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* API Response Cache Middleware
|
|
250
|
+
*
|
|
251
|
+
* Wraps a Next.js API handler with in-memory response caching via CacheManager.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* export const GET = withCache(
|
|
256
|
+
* async (request) => { ... return NextResponse.json(data); },
|
|
257
|
+
* { ttl: 5 * 60 * 1000 }
|
|
258
|
+
* );
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
261
|
+
|
|
262
|
+
interface CacheConfig {
|
|
263
|
+
/** Time to live in milliseconds. Default: 5 minutes. */
|
|
264
|
+
ttl?: number;
|
|
265
|
+
/** Include query string in cache key. Default: true. */
|
|
266
|
+
includeQuery?: boolean;
|
|
267
|
+
/** Custom cache key generator. */
|
|
268
|
+
keyGenerator?: (request: NextRequest) => string;
|
|
269
|
+
/** Bypass caching entirely. Default: false. */
|
|
270
|
+
bypassCache?: boolean;
|
|
271
|
+
/** Only cache for these HTTP methods. Default: ['GET']. */
|
|
272
|
+
methods?: string[];
|
|
273
|
+
}
|
|
274
|
+
type Handler = (request: NextRequest, ...args: unknown[]) => Promise<NextResponse>;
|
|
275
|
+
declare function withCache(handler: Handler, config?: CacheConfig): Handler;
|
|
276
|
+
/**
|
|
277
|
+
* Invalidate cache entries by key prefix or regex pattern.
|
|
278
|
+
* Pass no argument to clear the entire cache.
|
|
279
|
+
*/
|
|
280
|
+
declare function invalidateCache(pattern?: string | RegExp): void;
|
|
281
|
+
|
|
282
|
+
export { type ApiErrorHandlerOptions, type ApiHandlerFactoryDeps, type ApiHandlerOptions, type ApiRateLimitResult, type AuthVerifiedUser, type CacheConfig, type IApiErrorLogger, type IAuthVerifier, type RouteUser, createApiErrorHandler, createApiHandlerFactory, createRouteHandler, getBooleanParam, getNumberParam, getOptionalSessionCookie, getRequiredSessionCookie, getSearchParams, getStringParam, invalidateCache, withCache };
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ var __spreadValues = (a, b) => {
|
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
// src/api/errorHandler.ts
|
|
19
|
-
import { NextResponse } from "next/server";
|
|
19
|
+
import { NextResponse } from "next/server.js";
|
|
20
20
|
function createApiErrorHandler(options) {
|
|
21
21
|
const {
|
|
22
22
|
isAppError,
|
|
@@ -62,7 +62,7 @@ function createApiErrorHandler(options) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// src/api/routeHandler.ts
|
|
65
|
-
import { NextResponse as NextResponse2 } from "next/server";
|
|
65
|
+
import { NextResponse as NextResponse2 } from "next/server.js";
|
|
66
66
|
import { getProviders } from "@mohasinac/contracts";
|
|
67
67
|
function readSessionCookie(request) {
|
|
68
68
|
var _a;
|
|
@@ -147,14 +147,221 @@ function createRouteHandler(options) {
|
|
|
147
147
|
const status = typeof (err == null ? void 0 : err.status) === "number" ? err.status : 500;
|
|
148
148
|
const message = err instanceof Error ? err.message : "Internal server error";
|
|
149
149
|
console.error(`[createRouteHandler] ${request.method} failed`, err);
|
|
150
|
-
return NextResponse2.json(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
return NextResponse2.json({ success: false, error: message }, { status });
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/api/apiHandler.ts
|
|
156
|
+
function createApiHandlerFactory(deps) {
|
|
157
|
+
return function createApiHandler(options) {
|
|
158
|
+
return async (request, context) => {
|
|
159
|
+
const startMs = performance.now();
|
|
160
|
+
try {
|
|
161
|
+
let rateLimitHeaders;
|
|
162
|
+
if (options.rateLimit) {
|
|
163
|
+
const rateLimitResult = await deps.applyRateLimit(
|
|
164
|
+
request,
|
|
165
|
+
options.rateLimit
|
|
166
|
+
);
|
|
167
|
+
rateLimitHeaders = {
|
|
168
|
+
"RateLimit-Limit": String(rateLimitResult.limit),
|
|
169
|
+
"RateLimit-Remaining": String(rateLimitResult.remaining),
|
|
170
|
+
"RateLimit-Reset": String(rateLimitResult.reset)
|
|
171
|
+
};
|
|
172
|
+
if (!rateLimitResult.success) {
|
|
173
|
+
return new Response(
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
success: false,
|
|
176
|
+
error: deps.getRateLimitExceededMessage()
|
|
177
|
+
}),
|
|
178
|
+
{
|
|
179
|
+
status: 429,
|
|
180
|
+
headers: __spreadValues({
|
|
181
|
+
"Content-Type": "application/json"
|
|
182
|
+
}, rateLimitHeaders)
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
let user;
|
|
188
|
+
if (options.roles && options.roles.length > 0) {
|
|
189
|
+
user = await deps.requireRoleFromRequest(request, options.roles);
|
|
190
|
+
} else if (options.auth) {
|
|
191
|
+
user = await deps.requireAuthFromRequest(request);
|
|
192
|
+
}
|
|
193
|
+
let validatedBody;
|
|
194
|
+
if (options.schema) {
|
|
195
|
+
if (typeof options.schema.safeParse === "function") {
|
|
196
|
+
const body = await request.json();
|
|
197
|
+
const result = options.schema.safeParse(body);
|
|
198
|
+
if (!result.success) {
|
|
199
|
+
return deps.errorResponse(
|
|
200
|
+
"Validation failed",
|
|
201
|
+
400,
|
|
202
|
+
result.error.issues
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
validatedBody = result.data;
|
|
206
|
+
} else {
|
|
207
|
+
try {
|
|
208
|
+
validatedBody = await request.json();
|
|
209
|
+
} catch (e) {
|
|
210
|
+
validatedBody = void 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const resolvedParams = (context == null ? void 0 : context.params) ? await context.params : void 0;
|
|
215
|
+
const response = await options.handler({
|
|
216
|
+
request,
|
|
217
|
+
user,
|
|
218
|
+
body: validatedBody,
|
|
219
|
+
params: resolvedParams
|
|
220
|
+
});
|
|
221
|
+
response.headers.set("Access-Control-Max-Age", "86400");
|
|
222
|
+
if (rateLimitHeaders) {
|
|
223
|
+
for (const [key, value] of Object.entries(rateLimitHeaders)) {
|
|
224
|
+
response.headers.set(key, value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
deps.logTiming({
|
|
228
|
+
method: request.method,
|
|
229
|
+
path: new URL(request.url).pathname,
|
|
230
|
+
status: response.status,
|
|
231
|
+
durationMs: Math.round(performance.now() - startMs)
|
|
232
|
+
});
|
|
233
|
+
return response;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
deps.logTiming({
|
|
236
|
+
method: request.method,
|
|
237
|
+
path: new URL(request.url).pathname,
|
|
238
|
+
durationMs: Math.round(performance.now() - startMs),
|
|
239
|
+
error: error instanceof Error ? error.message : String(error)
|
|
240
|
+
});
|
|
241
|
+
return deps.handleApiError(error);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/request-helpers.ts
|
|
248
|
+
import { AuthenticationError } from "@mohasinac/errors";
|
|
249
|
+
function readCookieFromHeader(request, name) {
|
|
250
|
+
var _a;
|
|
251
|
+
const cookieHeader = (_a = request.headers.get("cookie")) != null ? _a : "";
|
|
252
|
+
const match = cookieHeader.match(
|
|
253
|
+
new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)
|
|
254
|
+
);
|
|
255
|
+
return match ? decodeURIComponent(match[1]) : void 0;
|
|
256
|
+
}
|
|
257
|
+
function getSearchParams(request) {
|
|
258
|
+
return new URL(request.url).searchParams;
|
|
259
|
+
}
|
|
260
|
+
function getOptionalSessionCookie(request) {
|
|
261
|
+
var _a;
|
|
262
|
+
const cookieValue = (_a = request.cookies) == null ? void 0 : _a.get("__session");
|
|
263
|
+
if (typeof cookieValue === "string") return cookieValue;
|
|
264
|
+
if (cookieValue && typeof cookieValue === "object") return cookieValue.value;
|
|
265
|
+
return readCookieFromHeader(request, "__session");
|
|
266
|
+
}
|
|
267
|
+
function getRequiredSessionCookie(request) {
|
|
268
|
+
const cookie = getOptionalSessionCookie(request);
|
|
269
|
+
if (!cookie) throw new AuthenticationError("Unauthorized");
|
|
270
|
+
return cookie;
|
|
271
|
+
}
|
|
272
|
+
function getBooleanParam(searchParams, key) {
|
|
273
|
+
const value = searchParams.get(key);
|
|
274
|
+
if (value === null) return void 0;
|
|
275
|
+
return value === "true";
|
|
276
|
+
}
|
|
277
|
+
function getStringParam(searchParams, key) {
|
|
278
|
+
const value = searchParams.get(key);
|
|
279
|
+
if (!value) return void 0;
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
function getNumberParam(searchParams, key, fallback, options) {
|
|
283
|
+
const rawValue = searchParams.get(key);
|
|
284
|
+
const parsed = rawValue ? Number.parseInt(rawValue, 10) : fallback;
|
|
285
|
+
const safeValue = Number.isNaN(parsed) ? fallback : parsed;
|
|
286
|
+
if (typeof (options == null ? void 0 : options.min) === "number" && safeValue < options.min)
|
|
287
|
+
return options.min;
|
|
288
|
+
if (typeof (options == null ? void 0 : options.max) === "number" && safeValue > options.max)
|
|
289
|
+
return options.max;
|
|
290
|
+
return safeValue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/cache-middleware.ts
|
|
294
|
+
import { NextResponse as NextResponse3 } from "next/server.js";
|
|
295
|
+
import { CacheManager } from "@mohasinac/core";
|
|
296
|
+
var DEFAULT_TTL = 5 * 60 * 1e3;
|
|
297
|
+
var cache = CacheManager.getInstance(500);
|
|
298
|
+
function buildCacheKey(request, config) {
|
|
299
|
+
if (config.keyGenerator) return config.keyGenerator(request);
|
|
300
|
+
const url = new URL(request.url);
|
|
301
|
+
if (config.includeQuery !== false && url.search)
|
|
302
|
+
return `${url.pathname}${url.search}`;
|
|
303
|
+
return url.pathname;
|
|
304
|
+
}
|
|
305
|
+
function isCacheable(request, config) {
|
|
306
|
+
var _a;
|
|
307
|
+
if (config.bypassCache) return false;
|
|
308
|
+
const methods = (_a = config.methods) != null ? _a : ["GET"];
|
|
309
|
+
return methods.includes(request.method);
|
|
310
|
+
}
|
|
311
|
+
function withCache(handler, config = {}) {
|
|
312
|
+
return async (request, ...args) => {
|
|
313
|
+
var _a;
|
|
314
|
+
if (!isCacheable(request, config)) {
|
|
315
|
+
return handler(request, ...args);
|
|
316
|
+
}
|
|
317
|
+
const key = buildCacheKey(request, config);
|
|
318
|
+
const cached = cache.get(key);
|
|
319
|
+
if (cached) {
|
|
320
|
+
return new NextResponse3(cached.body, {
|
|
321
|
+
status: cached.status,
|
|
322
|
+
headers: cached.headers
|
|
323
|
+
});
|
|
154
324
|
}
|
|
325
|
+
const response = await handler(request, ...args);
|
|
326
|
+
const body = await response.text();
|
|
327
|
+
const headers = {};
|
|
328
|
+
response.headers.forEach((value, key2) => {
|
|
329
|
+
headers[key2] = value;
|
|
330
|
+
});
|
|
331
|
+
cache.set(
|
|
332
|
+
key,
|
|
333
|
+
{ body, status: response.status, headers },
|
|
334
|
+
{ ttl: (_a = config.ttl) != null ? _a : DEFAULT_TTL }
|
|
335
|
+
);
|
|
336
|
+
return new NextResponse3(body, { status: response.status, headers });
|
|
155
337
|
};
|
|
156
338
|
}
|
|
339
|
+
function invalidateCache(pattern) {
|
|
340
|
+
if (!pattern) {
|
|
341
|
+
cache.clear();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const keys = cache.keys();
|
|
345
|
+
if (typeof pattern === "string") {
|
|
346
|
+
for (const key of keys) {
|
|
347
|
+
if (key.startsWith(pattern)) cache.delete(key);
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
for (const key of keys) {
|
|
351
|
+
if (pattern.test(key)) cache.delete(key);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
157
355
|
export {
|
|
158
356
|
createApiErrorHandler,
|
|
159
|
-
|
|
357
|
+
createApiHandlerFactory,
|
|
358
|
+
createRouteHandler,
|
|
359
|
+
getBooleanParam,
|
|
360
|
+
getNumberParam,
|
|
361
|
+
getOptionalSessionCookie,
|
|
362
|
+
getRequiredSessionCookie,
|
|
363
|
+
getSearchParams,
|
|
364
|
+
getStringParam,
|
|
365
|
+
invalidateCache,
|
|
366
|
+
withCache
|
|
160
367
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mohasinac/next",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"zod": ">=3"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@mohasinac/contracts": "^1.
|
|
32
|
+
"@mohasinac/contracts": "^1.1.0",
|
|
33
|
+
"@mohasinac/core": "^1.1.0",
|
|
34
|
+
"@mohasinac/errors": "^1.1.0"
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"tsup": "^8.5.0",
|