rkk-next 1.1.2 โ†’ 2.0.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 CHANGED
@@ -380,11 +380,20 @@ See [Backend Utilities Documentation](docs/BACKEND.md) for complete API referenc
380
380
 
381
381
  **System Requirements:**
382
382
 
383
- - Next.js `>= 12.0.0`
383
+ - Next.js `>= 14.0.0 < 16.0.0`
384
384
  - React `>= 17.0.0`
385
385
  - Node.js `>= 16.0.0`
386
386
  - TypeScript `>= 4.5.0` (optional but recommended)
387
387
 
388
+ ### Analytics Endpoint (Optional)
389
+
390
+ To send web vitals to your backend, set either environment variable:
391
+
392
+ - `NEXT_PUBLIC_RKK_ANALYTICS_ENDPOINT`
393
+ - `RKK_ANALYTICS_ENDPOINT`
394
+
395
+ If neither is set, metrics are not sent over the network by default.
396
+
388
397
  ---
389
398
 
390
399
  ## ๐ŸŽ“ Learn More
@@ -480,38 +489,3 @@ Full-Stack Developer | Next.js & Web3 Specialist
480
489
  [Get Started](./docs/QUICKSTART.md) ยท [Documentation](./docs/DOCS.md) ยท [Examples](./examples/) ยท [Changelog](./CHANGELOG.md)
481
490
 
482
491
  </div>
483
- MIT License ยฉ 2025 [Rohit Kumar Kundu](https://github.com/ROHIT8759)
484
-
485
- Free to use, modify, and distribute. See [LICENSE](./LICENSE) for details.
486
-
487
- ## โญ Support the Project
488
-
489
- If you find rkk-next helpful:
490
-
491
- - โญ **Star the repo** on GitHub
492
- - ๐Ÿ› **Report issues** to help improve the SDK
493
- - ๐Ÿค **Contribute** with PRs and feature ideas
494
- - ๐Ÿ“ข **Share** with other Next.js developers
495
- - ๐Ÿ’ฌ **Join discussions** and share your use cases
496
-
497
- ---
498
-
499
- **Made with โค๏ธ for the Next.js community**
500
-
501
- [Get Started](./docs/QUICKSTART.md) | [Documentation](./docs/DOCS.md) | [Examples](./examples/) | [Report Issue](https://github.com/ROHIT8759/rkk-next/issues)
502
-
503
- NPM publish
504
-
505
- If you want, I can now:
506
-
507
- โœ” Review this README
508
-
509
- โœ” Add badges (npm, downloads)
510
-
511
- โœ” Prepare NPM publish checklist
512
-
513
- โœ” Create example Next.js app
514
-
515
- โœ” Final SDK audit before release
516
-
517
- Just tell me ๐Ÿ‘
@@ -1,2 +1,66 @@
1
1
  import { NextWebVitalsMetric } from "next/app";
2
+ export type VitalsReporter = (metric: WebVitalPayload) => void | Promise<void>;
3
+ export interface WebVitalPayload {
4
+ id: string;
5
+ name: string;
6
+ value: number;
7
+ rating: "good" | "needs-improvement" | "poor";
8
+ delta: number;
9
+ navigationType: string;
10
+ label: "web-vital" | "custom";
11
+ }
12
+ export interface WebVitalsOptions {
13
+ /**
14
+ * Send metrics to a custom analytics endpoint via POST.
15
+ * The payload is a JSON body matching WebVitalPayload.
16
+ */
17
+ endpoint?: string;
18
+ /**
19
+ * Google Analytics measurement ID (e.g. "G-XXXXXXXXXX" or "UA-XXXXXXX-X").
20
+ * Requires window.gtag to be loaded.
21
+ */
22
+ gaMeasurementId?: string;
23
+ /**
24
+ * Custom reporter function for full control.
25
+ * Called for every metric after built-in reporters.
26
+ */
27
+ reporter?: VitalsReporter;
28
+ /**
29
+ * Log metrics to the console (useful during development).
30
+ * Defaults to true in development, false in production.
31
+ */
32
+ debug?: boolean;
33
+ /**
34
+ * Batch endpoint sends โ€” queue metrics and flush on page unload.
35
+ * Only relevant when `endpoint` is set.
36
+ * @default false
37
+ */
38
+ batch?: boolean;
39
+ /**
40
+ * Flush interval in milliseconds when batching is enabled.
41
+ * @default 5000
42
+ */
43
+ flushIntervalMs?: number;
44
+ }
45
+ /**
46
+ * Creates a configured `reportWebVitals` function ready to drop into
47
+ * `pages/_app.tsx` (or `app/layout.tsx` via useReportWebVitals hook).
48
+ *
49
+ * @example
50
+ * // pages/_app.tsx
51
+ * export const reportWebVitals = createWebVitalsReporter({
52
+ * endpoint: "/api/vitals",
53
+ * gaMeasurementId: "G-XXXXXXXXXX",
54
+ * debug: true,
55
+ * });
56
+ */
57
+ export declare function createWebVitalsReporter(options?: WebVitalsOptions): (metric: NextWebVitalsMetric) => void;
58
+ /**
59
+ * Simple drop-in reporter that logs to console.
60
+ * For production use, prefer `createWebVitalsReporter` with options.
61
+ *
62
+ * @example
63
+ * // pages/_app.tsx
64
+ * export { reportWebVitals } from "rkk-next";
65
+ */
2
66
  export declare function reportWebVitals(metric: NextWebVitalsMetric): void;
@@ -1,8 +1,163 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createWebVitalsReporter = createWebVitalsReporter;
3
4
  exports.reportWebVitals = reportWebVitals;
5
+ /** Map raw CWV value to a rating bucket */
6
+ function getRating(name, value) {
7
+ const thresholds = {
8
+ LCP: [2500, 4000],
9
+ FID: [100, 300],
10
+ CLS: [0.1, 0.25],
11
+ INP: [200, 500],
12
+ TTFB: [800, 1800],
13
+ FCP: [1800, 3000],
14
+ };
15
+ const t = thresholds[name];
16
+ if (!t)
17
+ return "good";
18
+ if (value <= t[0])
19
+ return "good";
20
+ if (value <= t[1])
21
+ return "needs-improvement";
22
+ return "poor";
23
+ }
24
+ /** Build a normalised payload from the Next.js metric object */
25
+ function buildPayload(metric) {
26
+ var _a, _b, _c;
27
+ return {
28
+ id: metric.id,
29
+ name: metric.name,
30
+ value: metric.value,
31
+ delta: (_a = metric.delta) !== null && _a !== void 0 ? _a : metric.value,
32
+ navigationType: (_b = metric.navigationType) !== null && _b !== void 0 ? _b : "navigate",
33
+ label: (_c = metric.label) !== null && _c !== void 0 ? _c : "web-vital",
34
+ rating: getRating(metric.name, metric.value),
35
+ };
36
+ }
37
+ // Internal batch queue (used when batch:true)
38
+ let _queue = [];
39
+ let _batchEndpoint = null;
40
+ let _flushScheduled = false;
41
+ let _batchListenersAttached = false;
42
+ let _flushTimer = null;
43
+ function scheduleBatchFlush() {
44
+ if (_flushScheduled)
45
+ return;
46
+ _flushScheduled = true;
47
+ const flush = () => {
48
+ if (_queue.length === 0 || !_batchEndpoint) {
49
+ return;
50
+ }
51
+ const payload = [..._queue];
52
+ _queue = [];
53
+ // Use sendBeacon so the request survives page unload
54
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
55
+ navigator.sendBeacon(_batchEndpoint, JSON.stringify(payload));
56
+ }
57
+ else {
58
+ fetch(_batchEndpoint, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify(payload),
62
+ keepalive: true,
63
+ }).catch(() => { });
64
+ }
65
+ };
66
+ if (typeof window !== "undefined" && !_batchListenersAttached) {
67
+ _batchListenersAttached = true;
68
+ window.addEventListener("visibilitychange", () => {
69
+ if (document.visibilityState === "hidden")
70
+ flush();
71
+ });
72
+ window.addEventListener("pagehide", flush);
73
+ }
74
+ }
75
+ /**
76
+ * Creates a configured `reportWebVitals` function ready to drop into
77
+ * `pages/_app.tsx` (or `app/layout.tsx` via useReportWebVitals hook).
78
+ *
79
+ * @example
80
+ * // pages/_app.tsx
81
+ * export const reportWebVitals = createWebVitalsReporter({
82
+ * endpoint: "/api/vitals",
83
+ * gaMeasurementId: "G-XXXXXXXXXX",
84
+ * debug: true,
85
+ * });
86
+ */
87
+ function createWebVitalsReporter(options = {}) {
88
+ var _a;
89
+ const { endpoint = process.env.NEXT_PUBLIC_RKK_ANALYTICS_ENDPOINT ||
90
+ process.env.RKK_ANALYTICS_ENDPOINT, gaMeasurementId, reporter, debug = process.env.NODE_ENV === "development", batch = false, flushIntervalMs = 5000, } = options;
91
+ if (batch && endpoint) {
92
+ _batchEndpoint = endpoint;
93
+ scheduleBatchFlush();
94
+ if (!_flushTimer && flushIntervalMs > 0) {
95
+ _flushTimer = setInterval(() => {
96
+ if (_queue.length === 0 || !_batchEndpoint)
97
+ return;
98
+ const payload = [..._queue];
99
+ _queue = [];
100
+ fetch(_batchEndpoint, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify(payload),
104
+ keepalive: true,
105
+ }).catch(() => { });
106
+ }, flushIntervalMs);
107
+ const timerWithUnref = _flushTimer;
108
+ (_a = timerWithUnref.unref) === null || _a === void 0 ? void 0 : _a.call(timerWithUnref);
109
+ }
110
+ }
111
+ return function reportWebVitals(metric) {
112
+ const payload = buildPayload(metric);
113
+ // 1. Debug logging
114
+ if (debug) {
115
+ const emoji = payload.rating === "good"
116
+ ? "โœ…"
117
+ : payload.rating === "needs-improvement"
118
+ ? "โš ๏ธ"
119
+ : "โŒ";
120
+ console.log(`[Vitals] ${emoji} ${payload.name} = ${payload.value.toFixed(1)} (${payload.rating})`);
121
+ }
122
+ // 2. Google Analytics (gtag)
123
+ if (gaMeasurementId && typeof window !== "undefined" && window.gtag) {
124
+ window.gtag("event", payload.name, {
125
+ event_category: payload.label === "web-vital" ? "Web Vitals" : "Custom Metric",
126
+ event_label: payload.id,
127
+ value: Math.round(payload.name === "CLS" ? payload.value * 1000 : payload.value),
128
+ non_interaction: true,
129
+ send_to: gaMeasurementId,
130
+ });
131
+ }
132
+ // 3. Custom endpoint
133
+ if (endpoint) {
134
+ if (batch) {
135
+ _queue.push(payload);
136
+ }
137
+ else {
138
+ fetch(endpoint, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify(payload),
142
+ keepalive: true,
143
+ }).catch(() => { });
144
+ }
145
+ }
146
+ // 4. Custom reporter callback
147
+ if (reporter) {
148
+ reporter(payload);
149
+ }
150
+ };
151
+ }
152
+ /**
153
+ * Simple drop-in reporter that logs to console.
154
+ * For production use, prefer `createWebVitalsReporter` with options.
155
+ *
156
+ * @example
157
+ * // pages/_app.tsx
158
+ * export { reportWebVitals } from "rkk-next";
159
+ */
4
160
  function reportWebVitals(metric) {
5
- const { id, name, value } = metric;
6
- console.log("[Vitals]", name, value);
7
- // future: send to analytics
161
+ const payload = buildPayload(metric);
162
+ console.log("[Vitals]", payload.name, payload.value, `(${payload.rating})`);
8
163
  }
@@ -6,6 +6,14 @@ export interface CacheOptions {
6
6
  keyGenerator?: (req: NextApiRequest) => string;
7
7
  /** Conditional caching based on request */
8
8
  shouldCache?: (req: NextApiRequest) => boolean;
9
+ /** Optional adapter key namespace prefix */
10
+ namespace?: string;
11
+ }
12
+ export interface ExternalCacheAdapter {
13
+ get<T = any>(key: string): Promise<T | null>;
14
+ set<T = any>(key: string, data: T, ttlSeconds: number): Promise<void>;
15
+ delete?(key: string): Promise<void>;
16
+ clear?(): Promise<void>;
9
17
  }
10
18
  /**
11
19
  * In-memory cache store for API responses
@@ -18,18 +26,37 @@ declare class MemoryCache {
18
26
  delete(key: string): void;
19
27
  clear(): void;
20
28
  size(): number;
29
+ /** Return all live (non-expired) keys */
30
+ keys(): string[];
21
31
  /**
22
32
  * Clean up expired entries
23
33
  */
24
34
  cleanup(): void;
25
35
  }
26
36
  export declare const cache: MemoryCache;
37
+ export declare function setCacheAdapter(adapter: ExternalCacheAdapter): void;
38
+ export declare function clearCacheAdapter(): void;
39
+ /**
40
+ * Create an adapter from common Redis-like clients.
41
+ * Supports clients exposing get/setEx/del or get/set with EX arg.
42
+ */
43
+ export declare function createRedisCacheAdapter(client: {
44
+ get: (key: string) => Promise<string | null>;
45
+ setEx?: (key: string, ttl: number, value: string) => Promise<unknown>;
46
+ set?: (key: string, value: string, mode?: string, ttl?: number) => Promise<unknown>;
47
+ del?: (key: string) => Promise<unknown>;
48
+ }): ExternalCacheAdapter;
27
49
  /**
28
50
  * Cache API response middleware
29
51
  */
30
52
  export declare function cacheResponse(options?: CacheOptions): (req: NextApiRequest, res: NextApiResponse, next: () => void | Promise<void>) => Promise<void>;
31
53
  /**
32
- * Invalidate cache by pattern or specific key
54
+ * Invalidate cache entries matching a pattern or an exact key.
55
+ *
56
+ * - No argument / undefined โ†’ clears the entire cache
57
+ * - string that is an exact key โ†’ removes that key only
58
+ * - string with regex metacharacters โ†’ treated as a regex pattern
59
+ * - RegExp โ†’ matched against every live key
33
60
  */
34
61
  export declare function invalidateCache(pattern?: string | RegExp): void;
35
62
  /**
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cache = void 0;
4
+ exports.setCacheAdapter = setCacheAdapter;
5
+ exports.clearCacheAdapter = clearCacheAdapter;
6
+ exports.createRedisCacheAdapter = createRedisCacheAdapter;
4
7
  exports.cacheResponse = cacheResponse;
5
8
  exports.invalidateCache = invalidateCache;
6
9
  exports.getCacheStats = getCacheStats;
@@ -43,6 +46,17 @@ class MemoryCache {
43
46
  size() {
44
47
  return this.cache.size;
45
48
  }
49
+ /** Return all live (non-expired) keys */
50
+ keys() {
51
+ const now = Date.now();
52
+ const result = [];
53
+ for (const [key, entry] of this.cache.entries()) {
54
+ if (now - entry.timestamp <= entry.ttl) {
55
+ result.push(key);
56
+ }
57
+ }
58
+ return result;
59
+ }
46
60
  /**
47
61
  * Clean up expired entries
48
62
  */
@@ -56,6 +70,48 @@ class MemoryCache {
56
70
  }
57
71
  }
58
72
  exports.cache = new MemoryCache();
73
+ let externalCacheAdapter = null;
74
+ function setCacheAdapter(adapter) {
75
+ externalCacheAdapter = adapter;
76
+ }
77
+ function clearCacheAdapter() {
78
+ externalCacheAdapter = null;
79
+ }
80
+ /**
81
+ * Create an adapter from common Redis-like clients.
82
+ * Supports clients exposing get/setEx/del or get/set with EX arg.
83
+ */
84
+ function createRedisCacheAdapter(client) {
85
+ return {
86
+ async get(key) {
87
+ const value = await client.get(key);
88
+ if (!value) {
89
+ return null;
90
+ }
91
+ try {
92
+ return JSON.parse(value);
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ },
98
+ async set(key, data, ttlSeconds) {
99
+ const payload = JSON.stringify(data);
100
+ if (client.setEx) {
101
+ await client.setEx(key, ttlSeconds, payload);
102
+ return;
103
+ }
104
+ if (client.set) {
105
+ await client.set(key, payload, 'EX', ttlSeconds);
106
+ }
107
+ },
108
+ async delete(key) {
109
+ if (client.del) {
110
+ await client.del(key);
111
+ }
112
+ },
113
+ };
114
+ }
59
115
  // Periodic cleanup every 5 minutes
60
116
  if (typeof setInterval !== 'undefined') {
61
117
  setInterval(() => exports.cache.cleanup(), 5 * 60 * 1000);
@@ -74,7 +130,7 @@ function defaultKeyGenerator(req) {
74
130
  */
75
131
  function cacheResponse(options = {}) {
76
132
  const { ttl = 60, // Default 60 seconds
77
- keyGenerator = defaultKeyGenerator, shouldCache = (req) => req.method === 'GET', } = options;
133
+ keyGenerator = defaultKeyGenerator, shouldCache = (req) => req.method === 'GET', namespace = 'rkk-next', } = options;
78
134
  return async (req, res, next) => {
79
135
  // Skip caching if condition not met
80
136
  if (!shouldCache(req)) {
@@ -82,10 +138,23 @@ function cacheResponse(options = {}) {
82
138
  return;
83
139
  }
84
140
  const cacheKey = keyGenerator(req);
141
+ const adapterKey = `${namespace}:${cacheKey}`;
142
+ // Optional external adapter first (e.g. Redis)
143
+ if (externalCacheAdapter) {
144
+ const adapted = await externalCacheAdapter.get(adapterKey);
145
+ if (adapted) {
146
+ res.setHeader('X-Cache', 'HIT');
147
+ res.setHeader('X-Cache-Provider', 'adapter');
148
+ res.setHeader('Cache-Control', `public, max-age=${ttl}`);
149
+ res.status(200).json(adapted);
150
+ return;
151
+ }
152
+ }
85
153
  // Check if cached response exists
86
154
  const cachedData = exports.cache.get(cacheKey);
87
155
  if (cachedData) {
88
156
  res.setHeader('X-Cache', 'HIT');
157
+ res.setHeader('X-Cache-Provider', 'memory');
89
158
  res.setHeader('Cache-Control', `public, max-age=${ttl}`);
90
159
  res.status(200).json(cachedData);
91
160
  return;
@@ -108,7 +177,13 @@ function cacheResponse(options = {}) {
108
177
  if (res.statusCode >= 200 && res.statusCode < 300 && responseData) {
109
178
  exports.cache.set(cacheKey, responseData, ttl);
110
179
  res.setHeader('X-Cache', 'MISS');
180
+ res.setHeader('X-Cache-Provider', 'memory');
111
181
  res.setHeader('Cache-Control', `public, max-age=${ttl}`);
182
+ if (externalCacheAdapter) {
183
+ externalCacheAdapter.set(adapterKey, responseData, ttl).catch(() => {
184
+ // Ignore adapter write errors and keep request successful.
185
+ });
186
+ }
112
187
  }
113
188
  return originalEnd.apply(this, args);
114
189
  };
@@ -116,19 +191,26 @@ function cacheResponse(options = {}) {
116
191
  };
117
192
  }
118
193
  /**
119
- * Invalidate cache by pattern or specific key
194
+ * Invalidate cache entries matching a pattern or an exact key.
195
+ *
196
+ * - No argument / undefined โ†’ clears the entire cache
197
+ * - string that is an exact key โ†’ removes that key only
198
+ * - string with regex metacharacters โ†’ treated as a regex pattern
199
+ * - RegExp โ†’ matched against every live key
120
200
  */
121
201
  function invalidateCache(pattern) {
122
202
  if (!pattern) {
123
203
  exports.cache.clear();
204
+ if (externalCacheAdapter === null || externalCacheAdapter === void 0 ? void 0 : externalCacheAdapter.clear) {
205
+ externalCacheAdapter.clear().catch(() => {
206
+ // noop
207
+ });
208
+ }
124
209
  return;
125
210
  }
126
- const regex = typeof pattern === 'string'
127
- ? new RegExp(pattern)
128
- : pattern;
129
- // This is a simplified implementation
130
- // In production, you'd want a more efficient pattern matching
131
- exports.cache.clear(); // For now, clear all
211
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
212
+ const matched = exports.cache.keys().filter((key) => regex.test(key));
213
+ matched.forEach((key) => exports.cache.delete(key));
132
214
  }
133
215
  /**
134
216
  * Get cache statistics
@@ -1,6 +1,18 @@
1
1
  import { NextApiRequest, NextApiResponse } from 'next';
2
2
  export type NextApiHandler = (req: NextApiRequest, res: NextApiResponse) => Promise<void> | void;
3
3
  export type Middleware = (req: NextApiRequest, res: NextApiResponse, next: () => void | Promise<void>) => void | Promise<void>;
4
+ type ValidationResult = boolean | string | {
5
+ valid: boolean;
6
+ message?: string;
7
+ details?: unknown;
8
+ };
9
+ type FieldValidator = (data: any, req?: NextApiRequest) => ValidationResult | Promise<ValidationResult>;
10
+ type RequestValidator = (req: NextApiRequest) => ValidationResult | Promise<ValidationResult>;
11
+ export declare class HttpError extends Error {
12
+ statusCode: number;
13
+ details?: unknown;
14
+ constructor(message: string, statusCode?: number, details?: unknown);
15
+ }
4
16
  /**
5
17
  * Compose multiple middleware functions into a single handler
6
18
  */
@@ -21,15 +33,20 @@ export declare function rateLimit(options?: {
21
33
  windowMs?: number;
22
34
  max?: number;
23
35
  message?: string;
36
+ cleanupIntervalMs?: number;
24
37
  }): Middleware;
25
38
  /**
26
39
  * Request validation middleware
27
40
  */
28
41
  export declare function validateRequest(schema: {
29
- body?: (data: any) => boolean;
30
- query?: (data: any) => boolean;
31
- headers?: (data: any) => boolean;
32
- }): Middleware;
42
+ body?: FieldValidator;
43
+ query?: FieldValidator;
44
+ headers?: FieldValidator;
45
+ } | RequestValidator): Middleware;
46
+ /**
47
+ * Request timeout middleware
48
+ */
49
+ export declare function requestTimeout(ms?: number): Middleware;
33
50
  /**
34
51
  * Request logging middleware
35
52
  */
@@ -41,3 +58,4 @@ export declare function logger(options?: {
41
58
  * Error handling middleware
42
59
  */
43
60
  export declare function errorHandler(): Middleware;
61
+ export {};
@@ -1,11 +1,35 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HttpError = void 0;
3
4
  exports.composeMiddleware = composeMiddleware;
4
5
  exports.cors = cors;
5
6
  exports.rateLimit = rateLimit;
6
7
  exports.validateRequest = validateRequest;
8
+ exports.requestTimeout = requestTimeout;
7
9
  exports.logger = logger;
8
10
  exports.errorHandler = errorHandler;
11
+ function normalizeValidationResult(result) {
12
+ if (typeof result === 'boolean') {
13
+ return { isValid: result };
14
+ }
15
+ if (typeof result === 'string') {
16
+ return { isValid: false, message: result };
17
+ }
18
+ return {
19
+ isValid: result.valid,
20
+ message: result.message,
21
+ details: result.details,
22
+ };
23
+ }
24
+ class HttpError extends Error {
25
+ constructor(message, statusCode = 500, details) {
26
+ super(message);
27
+ this.name = 'HttpError';
28
+ this.statusCode = statusCode;
29
+ this.details = details;
30
+ }
31
+ }
32
+ exports.HttpError = HttpError;
9
33
  /**
10
34
  * Compose multiple middleware functions into a single handler
11
35
  */
@@ -14,12 +38,17 @@ function composeMiddleware(...middlewares) {
14
38
  return async (req, res) => {
15
39
  let index = 0;
16
40
  const next = async () => {
41
+ if (res.writableEnded) {
42
+ return;
43
+ }
17
44
  if (index < middlewares.length) {
18
45
  const middleware = middlewares[index++];
19
46
  await middleware(req, res, next);
20
47
  }
21
48
  else {
22
- await handler(req, res);
49
+ if (!res.writableEnded) {
50
+ await handler(req, res);
51
+ }
23
52
  }
24
53
  };
25
54
  await next();
@@ -60,10 +89,13 @@ function cors(options = {}) {
60
89
  function rateLimit(options = {}) {
61
90
  const { windowMs = 60000, // 1 minute
62
91
  max = 60, // 60 requests per minute
63
- message = 'Too many requests, please try again later.', } = options;
92
+ message = 'Too many requests, please try again later.', cleanupIntervalMs = windowMs, } = options;
64
93
  const requests = new Map();
94
+ let lastCleanupAt = Date.now();
65
95
  return (req, res, next) => {
66
- const identifier = req.headers['x-forwarded-for'] ||
96
+ var _a;
97
+ const forwarded = req.headers['x-forwarded-for'];
98
+ const identifier = (typeof forwarded === 'string' ? (_a = forwarded.split(',')[0]) === null || _a === void 0 ? void 0 : _a.trim() : '') ||
67
99
  req.socket.remoteAddress ||
68
100
  'unknown';
69
101
  const now = Date.now();
@@ -73,14 +105,23 @@ function rateLimit(options = {}) {
73
105
  // Filter out requests outside the time window
74
106
  userRequests = userRequests.filter(time => time > windowStart);
75
107
  if (userRequests.length >= max) {
108
+ const resetInSeconds = Math.max(1, Math.ceil((userRequests[0] + windowMs - now) / 1000));
109
+ res.setHeader('X-RateLimit-Limit', String(max));
110
+ res.setHeader('X-RateLimit-Remaining', '0');
111
+ res.setHeader('X-RateLimit-Reset', String(resetInSeconds));
76
112
  res.status(429).json({ error: message });
77
113
  return;
78
114
  }
79
115
  // Add current request
80
116
  userRequests.push(now);
81
117
  requests.set(identifier, userRequests);
82
- // Cleanup old entries periodically
83
- if (Math.random() < 0.01) {
118
+ const remaining = Math.max(0, max - userRequests.length);
119
+ res.setHeader('X-RateLimit-Limit', String(max));
120
+ res.setHeader('X-RateLimit-Remaining', String(remaining));
121
+ res.setHeader('X-RateLimit-Reset', String(Math.ceil(windowMs / 1000)));
122
+ // Deterministic cleanup to prevent unbounded growth
123
+ if (now - lastCleanupAt >= cleanupIntervalMs) {
124
+ lastCleanupAt = now;
84
125
  for (const [key, times] of requests.entries()) {
85
126
  const filteredTimes = times.filter(time => time > windowStart);
86
127
  if (filteredTimes.length === 0) {
@@ -98,24 +139,92 @@ function rateLimit(options = {}) {
98
139
  * Request validation middleware
99
140
  */
100
141
  function validateRequest(schema) {
101
- return (req, res, next) => {
142
+ return async (req, res, next) => {
102
143
  try {
103
- if (schema.body && !schema.body(req.body)) {
104
- res.status(400).json({ error: 'Invalid request body' });
144
+ if (typeof schema === 'function') {
145
+ const result = normalizeValidationResult(await schema(req));
146
+ if (!result.isValid) {
147
+ res.status(400).json({
148
+ error: result.message || 'Validation failed',
149
+ details: result.details,
150
+ });
151
+ return;
152
+ }
153
+ next();
105
154
  return;
106
155
  }
107
- if (schema.query && !schema.query(req.query)) {
108
- res.status(400).json({ error: 'Invalid query parameters' });
109
- return;
156
+ if (schema.body) {
157
+ const result = normalizeValidationResult(await schema.body(req.body, req));
158
+ if (!result.isValid) {
159
+ res.status(400).json({
160
+ error: result.message || 'Invalid request body',
161
+ field: 'body',
162
+ details: result.details,
163
+ });
164
+ return;
165
+ }
110
166
  }
111
- if (schema.headers && !schema.headers(req.headers)) {
112
- res.status(400).json({ error: 'Invalid headers' });
113
- return;
167
+ if (schema.query) {
168
+ const result = normalizeValidationResult(await schema.query(req.query, req));
169
+ if (!result.isValid) {
170
+ res.status(400).json({
171
+ error: result.message || 'Invalid query parameters',
172
+ field: 'query',
173
+ details: result.details,
174
+ });
175
+ return;
176
+ }
177
+ }
178
+ if (schema.headers) {
179
+ const result = normalizeValidationResult(await schema.headers(req.headers, req));
180
+ if (!result.isValid) {
181
+ res.status(400).json({
182
+ error: result.message || 'Invalid headers',
183
+ field: 'headers',
184
+ details: result.details,
185
+ });
186
+ return;
187
+ }
114
188
  }
115
189
  next();
116
190
  }
117
191
  catch (error) {
118
- res.status(400).json({ error: 'Validation error' });
192
+ const message = error instanceof Error ? error.message : 'Validation error';
193
+ res.status(400).json({ error: message });
194
+ }
195
+ };
196
+ }
197
+ /**
198
+ * Request timeout middleware
199
+ */
200
+ function requestTimeout(ms = 30000) {
201
+ return async (req, res, next) => {
202
+ let timer;
203
+ const timeoutPromise = new Promise((_, reject) => {
204
+ timer = setTimeout(() => {
205
+ reject(new HttpError(`Request timeout after ${ms}ms`, 408));
206
+ }, ms);
207
+ });
208
+ try {
209
+ await Promise.race([Promise.resolve(next()), timeoutPromise]);
210
+ }
211
+ catch (error) {
212
+ if (res.writableEnded) {
213
+ return;
214
+ }
215
+ if (error instanceof HttpError && error.statusCode === 408) {
216
+ res.status(408).json({
217
+ error: 'Request timeout',
218
+ message: error.message,
219
+ });
220
+ return;
221
+ }
222
+ throw error;
223
+ }
224
+ finally {
225
+ if (timer) {
226
+ clearTimeout(timer);
227
+ }
119
228
  }
120
229
  };
121
230
  }
@@ -171,12 +280,26 @@ function errorHandler() {
171
280
  await next();
172
281
  }
173
282
  catch (error) {
283
+ if (res.writableEnded) {
284
+ return;
285
+ }
174
286
  console.error('[API Error]', error);
175
287
  const isDev = process.env.NODE_ENV === 'development';
176
- res.status(500).json({
177
- error: 'Internal server error',
288
+ const statusCode = error instanceof HttpError
289
+ ? error.statusCode
290
+ : (typeof error === 'object' && error && 'statusCode' in error && typeof error.statusCode === 'number'
291
+ ? error.statusCode
292
+ : 500);
293
+ const message = error instanceof Error ? error.message : 'Unknown error';
294
+ const details = error instanceof HttpError
295
+ ? error.details
296
+ : (typeof error === 'object' && error && 'details' in error ? error.details : undefined);
297
+ res.status(statusCode).json({
298
+ error: statusCode >= 500 ? 'Internal server error' : message,
299
+ ...(statusCode >= 500 ? undefined : { message }),
300
+ ...(details ? { details } : undefined),
178
301
  ...(isDev && {
179
- message: error instanceof Error ? error.message : 'Unknown error',
302
+ message,
180
303
  stack: error instanceof Error ? error.stack : undefined
181
304
  }),
182
305
  });
@@ -1,4 +1,16 @@
1
- export declare const SECURITY_HEADERS: {
1
+ export type SecurityHeader = {
2
2
  key: string;
3
3
  value: string;
4
- }[];
4
+ };
5
+ export type SecurityHeadersOptions = {
6
+ csp?: string;
7
+ hsts?: string;
8
+ permissionsPolicy?: string;
9
+ includeCrossOriginPolicies?: boolean;
10
+ };
11
+ export declare function buildSecurityHeaders(options?: SecurityHeadersOptions): SecurityHeader[];
12
+ export declare const SECURITY_HEADERS: SecurityHeader[];
13
+ export declare const SECURITY_HEADER_PRESETS: {
14
+ STRICT: SecurityHeader[];
15
+ API_FRIENDLY: SecurityHeader[];
16
+ };
@@ -1,17 +1,70 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SECURITY_HEADERS = void 0;
4
- exports.SECURITY_HEADERS = [
5
- {
6
- key: "X-Frame-Options",
7
- value: "DENY",
8
- },
9
- {
10
- key: "X-Content-Type-Options",
11
- value: "nosniff",
12
- },
13
- {
14
- key: "Referrer-Policy",
15
- value: "strict-origin-when-cross-origin",
16
- },
17
- ];
3
+ exports.SECURITY_HEADER_PRESETS = exports.SECURITY_HEADERS = void 0;
4
+ exports.buildSecurityHeaders = buildSecurityHeaders;
5
+ const DEFAULT_CSP = [
6
+ "default-src 'self'",
7
+ "script-src 'self' 'unsafe-inline'",
8
+ "style-src 'self' 'unsafe-inline'",
9
+ "img-src 'self' data: https:",
10
+ "font-src 'self' data:",
11
+ "connect-src 'self' https:",
12
+ "frame-ancestors 'none'",
13
+ "base-uri 'self'",
14
+ "form-action 'self'",
15
+ ].join('; ');
16
+ const DEFAULT_HSTS = 'max-age=63072000; includeSubDomains; preload';
17
+ const DEFAULT_PERMISSIONS_POLICY = [
18
+ 'camera=()',
19
+ 'microphone=()',
20
+ 'geolocation=()',
21
+ 'payment=()',
22
+ 'usb=()',
23
+ ].join(', ');
24
+ function buildSecurityHeaders(options = {}) {
25
+ const { csp = DEFAULT_CSP, hsts = DEFAULT_HSTS, permissionsPolicy = DEFAULT_PERMISSIONS_POLICY, includeCrossOriginPolicies = true, } = options;
26
+ const headers = [
27
+ {
28
+ key: 'X-Frame-Options',
29
+ value: 'DENY',
30
+ },
31
+ {
32
+ key: 'X-Content-Type-Options',
33
+ value: 'nosniff',
34
+ },
35
+ {
36
+ key: 'Referrer-Policy',
37
+ value: 'strict-origin-when-cross-origin',
38
+ },
39
+ {
40
+ key: 'Content-Security-Policy',
41
+ value: csp,
42
+ },
43
+ {
44
+ key: 'Strict-Transport-Security',
45
+ value: hsts,
46
+ },
47
+ {
48
+ key: 'Permissions-Policy',
49
+ value: permissionsPolicy,
50
+ },
51
+ ];
52
+ if (includeCrossOriginPolicies) {
53
+ headers.push({
54
+ key: 'Cross-Origin-Opener-Policy',
55
+ value: 'same-origin',
56
+ }, {
57
+ key: 'Cross-Origin-Resource-Policy',
58
+ value: 'same-origin',
59
+ });
60
+ }
61
+ return headers;
62
+ }
63
+ exports.SECURITY_HEADERS = buildSecurityHeaders();
64
+ exports.SECURITY_HEADER_PRESETS = {
65
+ STRICT: buildSecurityHeaders(),
66
+ API_FRIENDLY: buildSecurityHeaders({
67
+ csp: "default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
68
+ includeCrossOriginPolicies: false,
69
+ }),
70
+ };
@@ -1,17 +1,37 @@
1
1
  import { LinkProps } from "next/link";
2
2
  import React, { AnchorHTMLAttributes } from "react";
3
- type AnchorProps = AnchorHTMLAttributes<HTMLAnchorElement>;
4
- export type SmartLinkProps = LinkProps & AnchorProps & {
5
- /** Enable intelligent prefetching */
3
+ type AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
4
+ export type SmartLinkProps = Omit<LinkProps, "onMouseEnter"> & AnchorProps & {
5
+ /** Enable intelligent prefetching on hover */
6
6
  prefetchOnHover?: boolean;
7
- /** Delay before prefetch (ms) */
7
+ /** Delay before prefetch starts (ms) */
8
8
  prefetchDelay?: number;
9
9
  /** Disable prefetch completely */
10
10
  disablePrefetch?: boolean;
11
+ /** Child content */
12
+ children?: React.ReactNode;
11
13
  };
12
14
  /**
13
15
  * SmartLink
14
- * SEO-safe, accessibility-friendly, and performance-optimized
16
+ *
17
+ * A drop-in replacement for Next.js `<Link>` that adds:
18
+ * - Intelligent hover-based route prefetching
19
+ * - Network-aware prefetch (skips on slow 2g/slow-2g)
20
+ * - Full TypeScript types, accessible, and SSR-safe
21
+ *
22
+ * Compatible with Next.js 13+ App Router and Pages Router.
23
+ *
24
+ * @example
25
+ * <SmartLink href="/blog">Read the blog</SmartLink>
15
26
  */
16
- export declare const SmartLink: React.FC<SmartLinkProps>;
27
+ export declare const SmartLink: React.ForwardRefExoticComponent<Omit<LinkProps<any>, "onMouseEnter"> & AnchorProps & {
28
+ /** Enable intelligent prefetching on hover */
29
+ prefetchOnHover?: boolean;
30
+ /** Delay before prefetch starts (ms) */
31
+ prefetchDelay?: number;
32
+ /** Disable prefetch completely */
33
+ disablePrefetch?: boolean;
34
+ /** Child content */
35
+ children?: React.ReactNode;
36
+ } & React.RefAttributes<HTMLAnchorElement>>;
17
37
  export default SmartLink;
@@ -42,21 +42,27 @@ const react_1 = __importStar(require("react"));
42
42
  const prefetch_1 = require("./prefetch");
43
43
  /**
44
44
  * SmartLink
45
- * SEO-safe, accessibility-friendly, and performance-optimized
45
+ *
46
+ * A drop-in replacement for Next.js `<Link>` that adds:
47
+ * - Intelligent hover-based route prefetching
48
+ * - Network-aware prefetch (skips on slow 2g/slow-2g)
49
+ * - Full TypeScript types, accessible, and SSR-safe
50
+ *
51
+ * Compatible with Next.js 13+ App Router and Pages Router.
52
+ *
53
+ * @example
54
+ * <SmartLink href="/blog">Read the blog</SmartLink>
46
55
  */
47
- const SmartLink = ({ href, children, prefetchOnHover = true, prefetchDelay = 150, disablePrefetch = false, onMouseEnter, ...props }) => {
56
+ exports.SmartLink = react_1.default.forwardRef(({ href, children, prefetchOnHover = true, prefetchDelay = 150, disablePrefetch = false, onMouseEnter, ...props }, ref) => {
48
57
  const handleMouseEnter = (0, react_1.useCallback)((e) => {
49
58
  onMouseEnter === null || onMouseEnter === void 0 ? void 0 : onMouseEnter(e);
50
59
  if (disablePrefetch || !prefetchOnHover)
51
60
  return;
52
61
  if (typeof href === "string" && (0, prefetch_1.isFastConnection)()) {
53
- (0, prefetch_1.prefetchRoute)(href, {
54
- delay: prefetchDelay,
55
- });
62
+ (0, prefetch_1.prefetchRoute)(href, { delay: prefetchDelay });
56
63
  }
57
64
  }, [href, disablePrefetch, prefetchOnHover, prefetchDelay, onMouseEnter]);
58
- return (react_1.default.createElement(link_1.default, { href: href, passHref: true, legacyBehavior: true },
59
- react_1.default.createElement("a", { ...props, onMouseEnter: handleMouseEnter }, children)));
60
- };
61
- exports.SmartLink = SmartLink;
65
+ return (react_1.default.createElement(link_1.default, { href: href, ...props, onMouseEnter: handleMouseEnter, ref: ref }, children));
66
+ });
67
+ exports.SmartLink.displayName = "SmartLink";
62
68
  exports.default = exports.SmartLink;
@@ -7,31 +7,64 @@ exports.MetaManager = void 0;
7
7
  const head_1 = __importDefault(require("next/head"));
8
8
  const router_1 = require("next/router");
9
9
  const react_1 = __importDefault(require("react"));
10
+ function sanitizeText(input) {
11
+ if (typeof input !== "string")
12
+ return undefined;
13
+ return input
14
+ .replace(/[\u0000-\u001F\u007F]/g, "")
15
+ .replace(/&/g, "&amp;")
16
+ .replace(/</g, "&lt;")
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/'/g, "&#39;")
20
+ .trim();
21
+ }
22
+ function sanitizeUrl(input) {
23
+ if (typeof input !== "string" || !input.trim())
24
+ return undefined;
25
+ const raw = input.trim();
26
+ if (/^javascript:/i.test(raw) || /^data:/i.test(raw)) {
27
+ return undefined;
28
+ }
29
+ return raw;
30
+ }
31
+ function sanitizeTwitterHandle(handle) {
32
+ if (!handle)
33
+ return undefined;
34
+ return handle.replace(/^@+/, "").replace(/[^a-zA-Z0-9_]/g, "");
35
+ }
10
36
  const MetaManager = ({ title, description, keywords, canonicalUrl, image, type = "website", noIndex = false, siteName = "My Website", author, twitterHandle, }) => {
11
37
  const router = (0, router_1.useRouter)();
38
+ const safeTitle = sanitizeText(title) || "";
39
+ const safeDescription = sanitizeText(description) || "";
40
+ const safeKeywords = sanitizeText(keywords);
41
+ const safeAuthor = sanitizeText(author);
42
+ const safeSiteName = sanitizeText(siteName) || "My Website";
43
+ const safeImage = sanitizeUrl(image);
44
+ const safeTwitterHandle = sanitizeTwitterHandle(twitterHandle);
12
45
  // Auto canonical URL fallback
13
- const canonical = canonicalUrl ||
46
+ const canonical = sanitizeUrl(canonicalUrl ||
14
47
  (typeof window !== "undefined"
15
48
  ? `${window.location.origin}${router.asPath}`
16
- : "");
49
+ : ""));
17
50
  return (react_1.default.createElement(head_1.default, null,
18
- react_1.default.createElement("title", null, title),
19
- react_1.default.createElement("meta", { name: "description", content: description }),
20
- keywords && react_1.default.createElement("meta", { name: "keywords", content: keywords }),
21
- author && react_1.default.createElement("meta", { name: "author", content: author }),
51
+ react_1.default.createElement("title", null, safeTitle),
52
+ react_1.default.createElement("meta", { name: "description", content: safeDescription }),
53
+ safeKeywords && react_1.default.createElement("meta", { name: "keywords", content: safeKeywords }),
54
+ safeAuthor && react_1.default.createElement("meta", { name: "author", content: safeAuthor }),
22
55
  react_1.default.createElement("meta", { name: "robots", content: noIndex ? "noindex, nofollow" : "index, follow" }),
23
56
  canonical && react_1.default.createElement("link", { rel: "canonical", href: canonical }),
24
57
  react_1.default.createElement("meta", { property: "og:type", content: type }),
25
- react_1.default.createElement("meta", { property: "og:title", content: title }),
26
- react_1.default.createElement("meta", { property: "og:description", content: description }),
27
- react_1.default.createElement("meta", { property: "og:site_name", content: siteName }),
58
+ react_1.default.createElement("meta", { property: "og:title", content: safeTitle }),
59
+ react_1.default.createElement("meta", { property: "og:description", content: safeDescription }),
60
+ react_1.default.createElement("meta", { property: "og:site_name", content: safeSiteName }),
28
61
  canonical && react_1.default.createElement("meta", { property: "og:url", content: canonical }),
29
- image && react_1.default.createElement("meta", { property: "og:image", content: image }),
62
+ safeImage && react_1.default.createElement("meta", { property: "og:image", content: safeImage }),
30
63
  react_1.default.createElement("meta", { name: "twitter:card", content: "summary_large_image" }),
31
- twitterHandle && (react_1.default.createElement("meta", { name: "twitter:site", content: `@${twitterHandle}` })),
32
- react_1.default.createElement("meta", { name: "twitter:title", content: title }),
33
- react_1.default.createElement("meta", { name: "twitter:description", content: description }),
34
- image && react_1.default.createElement("meta", { name: "twitter:image", content: image }),
64
+ safeTwitterHandle && (react_1.default.createElement("meta", { name: "twitter:site", content: `@${safeTwitterHandle}` })),
65
+ react_1.default.createElement("meta", { name: "twitter:title", content: safeTitle }),
66
+ react_1.default.createElement("meta", { name: "twitter:description", content: safeDescription }),
67
+ safeImage && react_1.default.createElement("meta", { name: "twitter:image", content: safeImage }),
35
68
  react_1.default.createElement("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }),
36
69
  react_1.default.createElement("meta", { name: "theme-color", content: "#000000" })));
37
70
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rkk-next",
3
- "version": "1.1.2",
3
+ "version": "2.0.0",
4
4
  "description": "SEO, routing, performance optimization and backend utilities SDK for Next.js",
5
5
  "author": "Rohit Kumar Kundu",
6
6
  "license": "MIT",
@@ -25,10 +25,13 @@
25
25
  "test": "jest",
26
26
  "test:watch": "jest --watch",
27
27
  "test:coverage": "jest --coverage",
28
+ "test:e2e": "jest __tests__/e2e",
29
+ "test:cli": "jest --config cli/jest.config.js",
30
+ "test:ci": "npm run test:coverage && npm run test:e2e && npm run test:cli",
28
31
  "prepublishOnly": "npm run build"
29
32
  },
30
33
  "peerDependencies": {
31
- "next": ">=12",
34
+ "next": ">=14 <16",
32
35
  "react": ">=17",
33
36
  "react-dom": ">=17"
34
37
  },