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 +10 -36
- package/dist/analytics/webVitals.d.ts +64 -0
- package/dist/analytics/webVitals.js +158 -3
- package/dist/backend/cache.d.ts +28 -1
- package/dist/backend/cache.js +90 -8
- package/dist/backend/middleware.d.ts +22 -4
- package/dist/backend/middleware.js +141 -18
- package/dist/performance/securityHeaders.d.ts +14 -2
- package/dist/performance/securityHeaders.js +68 -15
- package/dist/routing/SmartLink.d.ts +26 -6
- package/dist/routing/SmartLink.js +15 -9
- package/dist/seo/MetaManager.js +47 -14
- package/package.json +5 -2
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 `>=
|
|
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
|
|
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
|
}
|
package/dist/backend/cache.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/backend/cache.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
128
|
-
|
|
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?:
|
|
30
|
-
query?:
|
|
31
|
-
headers?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
104
|
-
|
|
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.
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
302
|
+
message,
|
|
180
303
|
stack: error instanceof Error ? error.stack : undefined
|
|
181
304
|
}),
|
|
182
305
|
});
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
export
|
|
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.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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,
|
|
59
|
-
|
|
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;
|
package/dist/seo/MetaManager.js
CHANGED
|
@@ -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, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, """)
|
|
19
|
+
.replace(/'/g, "'")
|
|
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,
|
|
19
|
-
react_1.default.createElement("meta", { name: "description", content:
|
|
20
|
-
|
|
21
|
-
|
|
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:
|
|
26
|
-
react_1.default.createElement("meta", { property: "og:description", content:
|
|
27
|
-
react_1.default.createElement("meta", { property: "og:site_name", content:
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
react_1.default.createElement("meta", { name: "twitter:title", content:
|
|
33
|
-
react_1.default.createElement("meta", { name: "twitter:description", content:
|
|
34
|
-
|
|
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": "
|
|
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": ">=
|
|
34
|
+
"next": ">=14 <16",
|
|
32
35
|
"react": ">=17",
|
|
33
36
|
"react-dom": ">=17"
|
|
34
37
|
},
|