ertk 0.1.1 → 0.1.3
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/LICENSE +21 -21
- package/README.md +864 -717
- package/dist/cli.js +31 -31
- package/dist/generate.js +58 -26
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/rate-limit.d.ts +78 -0
- package/dist/next/rate-limit.js +73 -0
- package/dist/next/route-handler.d.ts +7 -0
- package/dist/next/route-handler.js +46 -5
- package/dist/types.d.ts +17 -1
- package/package.json +83 -74
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/define-config.d.ts.map +0 -1
- package/dist/define-config.js.map +0 -1
- package/dist/endpoint.d.ts.map +0 -1
- package/dist/endpoint.js.map +0 -1
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/next/index.d.ts.map +0 -1
- package/dist/next/index.js.map +0 -1
- package/dist/next/route-handler.d.ts.map +0 -1
- package/dist/next/route-handler.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { loadConfig, resolveConfig } from "./config.js";
|
|
4
4
|
import { runGenerate, runWatch } from "./generate.js";
|
|
5
|
-
const HELP = `
|
|
6
|
-
ertk — Easy RTK Query codegen
|
|
7
|
-
|
|
8
|
-
Usage:
|
|
9
|
-
ertk generate One-shot generation (skips if nothing changed)
|
|
10
|
-
ertk generate --watch Watch mode with incremental regeneration
|
|
11
|
-
ertk init Scaffold config file and directories
|
|
12
|
-
ertk --help Show this help message
|
|
13
|
-
|
|
14
|
-
Options:
|
|
15
|
-
--watch Watch for endpoint file changes and regenerate
|
|
16
|
-
--help Show help
|
|
5
|
+
const HELP = `
|
|
6
|
+
ertk — Easy RTK Query codegen
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
ertk generate One-shot generation (skips if nothing changed)
|
|
10
|
+
ertk generate --watch Watch mode with incremental regeneration
|
|
11
|
+
ertk init Scaffold config file and directories
|
|
12
|
+
ertk --help Show this help message
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--watch Watch for endpoint file changes and regenerate
|
|
16
|
+
--help Show help
|
|
17
17
|
`.trim();
|
|
18
18
|
async function main() {
|
|
19
19
|
const args = process.argv.slice(2);
|
|
@@ -52,25 +52,25 @@ async function runInit() {
|
|
|
52
52
|
console.log("ertk.config.ts already exists, skipping.");
|
|
53
53
|
}
|
|
54
54
|
else {
|
|
55
|
-
fs.writeFileSync(configPath, `import { defineConfig } from "ertk";
|
|
56
|
-
|
|
57
|
-
export default defineConfig({
|
|
58
|
-
\t// Directory containing endpoint definition files
|
|
59
|
-
\tendpoints: "src/endpoints",
|
|
60
|
-
|
|
61
|
-
\t// Directory for generated output (api.ts, store.ts, invalidation.ts)
|
|
62
|
-
\tgenerated: "src/generated",
|
|
63
|
-
|
|
64
|
-
\t// Base URL for RTK Query fetchBaseQuery
|
|
65
|
-
\tbaseUrl: "/api",
|
|
66
|
-
|
|
67
|
-
\t// Route generation (remove to skip route generation for client-only projects)
|
|
68
|
-
\troutes: {
|
|
69
|
-
\t\tdir: "src/app/api",
|
|
70
|
-
\t\thandlerModule: "ertk/next",
|
|
71
|
-
\t\tignoredRoutes: [],
|
|
72
|
-
\t},
|
|
73
|
-
});
|
|
55
|
+
fs.writeFileSync(configPath, `import { defineConfig } from "ertk";
|
|
56
|
+
|
|
57
|
+
export default defineConfig({
|
|
58
|
+
\t// Directory containing endpoint definition files
|
|
59
|
+
\tendpoints: "src/endpoints",
|
|
60
|
+
|
|
61
|
+
\t// Directory for generated output (api.ts, store.ts, invalidation.ts)
|
|
62
|
+
\tgenerated: "src/generated",
|
|
63
|
+
|
|
64
|
+
\t// Base URL for RTK Query fetchBaseQuery
|
|
65
|
+
\tbaseUrl: "/api",
|
|
66
|
+
|
|
67
|
+
\t// Route generation (remove to skip route generation for client-only projects)
|
|
68
|
+
\troutes: {
|
|
69
|
+
\t\tdir: "src/app/api",
|
|
70
|
+
\t\thandlerModule: "ertk/next",
|
|
71
|
+
\t\tignoredRoutes: [],
|
|
72
|
+
\t},
|
|
73
|
+
});
|
|
74
74
|
`);
|
|
75
75
|
console.log("Created ertk.config.ts");
|
|
76
76
|
}
|
package/dist/generate.js
CHANGED
|
@@ -126,6 +126,18 @@ function parseEndpointFile(project, filePath, config) {
|
|
|
126
126
|
.getInitializerOrThrow()
|
|
127
127
|
.getText();
|
|
128
128
|
}
|
|
129
|
+
// Extract maxRetries
|
|
130
|
+
const maxRetriesProp = configObj.getProperty("maxRetries");
|
|
131
|
+
let maxRetries = null;
|
|
132
|
+
if (maxRetriesProp) {
|
|
133
|
+
const val = parseInt(maxRetriesProp
|
|
134
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
135
|
+
.getInitializerOrThrow()
|
|
136
|
+
.getText(), 10);
|
|
137
|
+
if (!Number.isNaN(val) && val > 0) {
|
|
138
|
+
maxRetries = val;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
129
141
|
// Derive route path from file path
|
|
130
142
|
const routePath = deriveRoutePath(filePath, config);
|
|
131
143
|
const endpointType = method === "get" ? "query" : "mutation";
|
|
@@ -170,6 +182,7 @@ function parseEndpointFile(project, filePath, config) {
|
|
|
170
182
|
providesTagsSource,
|
|
171
183
|
invalidatesTagsSource,
|
|
172
184
|
optimisticSource,
|
|
185
|
+
maxRetries,
|
|
173
186
|
typeImports,
|
|
174
187
|
};
|
|
175
188
|
}
|
|
@@ -244,9 +257,15 @@ function generateApiTs(endpoints, config) {
|
|
|
244
257
|
extractTagTypes(ep.providesTagsSource, tagTypes);
|
|
245
258
|
extractTagTypes(ep.invalidatesTagsSource, tagTypes);
|
|
246
259
|
}
|
|
260
|
+
const hasAnyRetries = endpoints.some((ep) => ep.maxRetries != null && ep.maxRetries > 0);
|
|
247
261
|
const lines = [];
|
|
248
262
|
lines.push("// AUTO-GENERATED by ERTK codegen. Do not edit.");
|
|
249
|
-
|
|
263
|
+
if (hasAnyRetries) {
|
|
264
|
+
lines.push('import { createApi, fetchBaseQuery, retry } from "@reduxjs/toolkit/query/react";');
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
lines.push('import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";');
|
|
268
|
+
}
|
|
250
269
|
// Add type imports
|
|
251
270
|
for (const [importPath, types] of [...allTypeImports.entries()].sort()) {
|
|
252
271
|
const typeList = [...types].sort().join(", ");
|
|
@@ -256,7 +275,17 @@ function generateApiTs(endpoints, config) {
|
|
|
256
275
|
lines.push("export const api = createApi({");
|
|
257
276
|
lines.push('\treducerPath: "api",');
|
|
258
277
|
// baseQuery — use custom source if provided, otherwise default
|
|
259
|
-
|
|
278
|
+
// When retries are used, wrap with retry() and set maxRetries: 0 as default
|
|
279
|
+
// so only endpoints with explicit extraOptions.maxRetries will retry.
|
|
280
|
+
if (hasAnyRetries) {
|
|
281
|
+
if (config.baseQuery) {
|
|
282
|
+
lines.push(`\tbaseQuery: retry(${config.baseQuery}, { maxRetries: 0 }),`);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
lines.push(`\tbaseQuery: retry(fetchBaseQuery({ baseUrl: "${config.baseUrl}" }), { maxRetries: 0 }),`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else if (config.baseQuery) {
|
|
260
289
|
lines.push(`\tbaseQuery: ${config.baseQuery},`);
|
|
261
290
|
}
|
|
262
291
|
else {
|
|
@@ -309,6 +338,9 @@ function generateEndpointDef(ep) {
|
|
|
309
338
|
lines.push(...onQueryStarted);
|
|
310
339
|
}
|
|
311
340
|
}
|
|
341
|
+
if (ep.maxRetries != null && ep.maxRetries > 0) {
|
|
342
|
+
lines.push(`\t\t\textraOptions: { maxRetries: ${ep.maxRetries} },`);
|
|
343
|
+
}
|
|
312
344
|
lines.push("\t\t}),");
|
|
313
345
|
return lines;
|
|
314
346
|
}
|
|
@@ -420,33 +452,33 @@ function capitalize(s) {
|
|
|
420
452
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
421
453
|
}
|
|
422
454
|
function generateStoreTs() {
|
|
423
|
-
return `// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
424
|
-
import { configureStore } from "@reduxjs/toolkit";
|
|
425
|
-
import { api } from "./api";
|
|
426
|
-
|
|
427
|
-
export const store = configureStore({
|
|
428
|
-
\treducer: {
|
|
429
|
-
\t\t[api.reducerPath]: api.reducer,
|
|
430
|
-
\t},
|
|
431
|
-
\tmiddleware: (getDefaultMiddleware) =>
|
|
432
|
-
\t\tgetDefaultMiddleware().concat(api.middleware),
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
export type RootState = ReturnType<typeof store.getState>;
|
|
436
|
-
export type AppDispatch = typeof store.dispatch;
|
|
455
|
+
return `// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
456
|
+
import { configureStore } from "@reduxjs/toolkit";
|
|
457
|
+
import { api } from "./api";
|
|
458
|
+
|
|
459
|
+
export const store = configureStore({
|
|
460
|
+
\treducer: {
|
|
461
|
+
\t\t[api.reducerPath]: api.reducer,
|
|
462
|
+
\t},
|
|
463
|
+
\tmiddleware: (getDefaultMiddleware) =>
|
|
464
|
+
\t\tgetDefaultMiddleware().concat(api.middleware),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
export type RootState = ReturnType<typeof store.getState>;
|
|
468
|
+
export type AppDispatch = typeof store.dispatch;
|
|
437
469
|
`;
|
|
438
470
|
}
|
|
439
471
|
function generateInvalidationTs() {
|
|
440
|
-
return `// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
441
|
-
import { api } from "./api";
|
|
442
|
-
|
|
443
|
-
export function invalidateTags(
|
|
444
|
-
\t...args: Parameters<typeof api.util.invalidateTags>
|
|
445
|
-
) {
|
|
446
|
-
\treturn api.util.invalidateTags(...args);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
export const updateQueryData = api.util.updateQueryData;
|
|
472
|
+
return `// AUTO-GENERATED by ERTK codegen. Do not edit.
|
|
473
|
+
import { api } from "./api";
|
|
474
|
+
|
|
475
|
+
export function invalidateTags(
|
|
476
|
+
\t...args: Parameters<typeof api.util.invalidateTags>
|
|
477
|
+
) {
|
|
478
|
+
\treturn api.util.invalidateTags(...args);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export const updateQueryData = api.util.updateQueryData;
|
|
450
482
|
`;
|
|
451
483
|
}
|
|
452
484
|
function generateRouteFile(group, config) {
|
package/dist/next/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { configureHandler, createRouteHandler, type ErtkAuthAdapter, type ErtkErrorHandler, type ConfigureHandlerOptions, } from "./route-handler.js";
|
|
2
|
+
export { InMemoryRateLimitAdapter, defaultKeyFn, type RateLimitAdapter, type RateLimitConfig, type RateLimitResult, } from "./rate-limit.js";
|
|
2
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/next/index.js
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ERTK Rate Limiting
|
|
3
|
+
*
|
|
4
|
+
* Pluggable rate limiting for server-side route handlers.
|
|
5
|
+
* Ships with an in-memory sliding window adapter suitable for
|
|
6
|
+
* single-process deployments. For multi-instance / serverless
|
|
7
|
+
* deployments (e.g., Vercel), provide a distributed adapter
|
|
8
|
+
* (Redis, Upstash, DynamoDB, etc.).
|
|
9
|
+
*/
|
|
10
|
+
/** Result of a rate limit check. */
|
|
11
|
+
export interface RateLimitResult {
|
|
12
|
+
/** Whether the request is allowed */
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
/** Total limit for the window */
|
|
15
|
+
limit: number;
|
|
16
|
+
/** Remaining requests in the current window */
|
|
17
|
+
remaining: number;
|
|
18
|
+
/** Unix timestamp (seconds) when the window resets */
|
|
19
|
+
resetAt: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Pluggable rate limiting adapter interface.
|
|
23
|
+
* Implement this for custom storage backends (Redis, Upstash, DynamoDB, etc.).
|
|
24
|
+
*/
|
|
25
|
+
export interface RateLimitAdapter {
|
|
26
|
+
/**
|
|
27
|
+
* Check and consume a rate limit token for the given key.
|
|
28
|
+
* @param key Identifier for the rate limit bucket (typically IP or user ID)
|
|
29
|
+
* @param windowMs Sliding window duration in milliseconds
|
|
30
|
+
* @param max Maximum requests allowed in the window
|
|
31
|
+
*/
|
|
32
|
+
check(key: string, windowMs: number, max: number): Promise<RateLimitResult>;
|
|
33
|
+
}
|
|
34
|
+
/** Rate limit configuration for `configureHandler()`. */
|
|
35
|
+
export interface RateLimitConfig {
|
|
36
|
+
/** Sliding window duration in milliseconds (e.g., 60_000 for 1 minute) */
|
|
37
|
+
windowMs: number;
|
|
38
|
+
/** Maximum requests allowed within the window */
|
|
39
|
+
max: number;
|
|
40
|
+
/**
|
|
41
|
+
* Function to derive the rate limit key from a request.
|
|
42
|
+
* Receives the authenticated user when available.
|
|
43
|
+
* Default: extract client IP from standard proxy headers.
|
|
44
|
+
*/
|
|
45
|
+
keyFn?: (req: Request, user?: {
|
|
46
|
+
id: string;
|
|
47
|
+
}) => string;
|
|
48
|
+
/**
|
|
49
|
+
* Rate limit storage adapter.
|
|
50
|
+
* Default: InMemoryRateLimitAdapter (suitable for single-process deployments).
|
|
51
|
+
*/
|
|
52
|
+
adapter?: RateLimitAdapter;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Default key function: extracts client IP from standard proxy headers,
|
|
56
|
+
* falling back to a static key if no IP is available.
|
|
57
|
+
*/
|
|
58
|
+
export declare function defaultKeyFn(req: Request): string;
|
|
59
|
+
/**
|
|
60
|
+
* In-memory sliding window rate limiter.
|
|
61
|
+
*
|
|
62
|
+
* Suitable for single-process deployments (e.g., a long-running Node.js server).
|
|
63
|
+
* State resets on process restart and is not shared across instances.
|
|
64
|
+
* For multi-instance or serverless deployments, use a distributed adapter.
|
|
65
|
+
*
|
|
66
|
+
* Periodically prunes expired entries to prevent memory leaks.
|
|
67
|
+
*/
|
|
68
|
+
export declare class InMemoryRateLimitAdapter implements RateLimitAdapter {
|
|
69
|
+
private windows;
|
|
70
|
+
private pruneIntervalMs;
|
|
71
|
+
private lastPrune;
|
|
72
|
+
constructor(options?: {
|
|
73
|
+
pruneIntervalMs?: number;
|
|
74
|
+
});
|
|
75
|
+
check(key: string, windowMs: number, max: number): Promise<RateLimitResult>;
|
|
76
|
+
private maybePrune;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ERTK Rate Limiting
|
|
3
|
+
*
|
|
4
|
+
* Pluggable rate limiting for server-side route handlers.
|
|
5
|
+
* Ships with an in-memory sliding window adapter suitable for
|
|
6
|
+
* single-process deployments. For multi-instance / serverless
|
|
7
|
+
* deployments (e.g., Vercel), provide a distributed adapter
|
|
8
|
+
* (Redis, Upstash, DynamoDB, etc.).
|
|
9
|
+
*/
|
|
10
|
+
// ─── Default Key Function ─────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* Default key function: extracts client IP from standard proxy headers,
|
|
13
|
+
* falling back to a static key if no IP is available.
|
|
14
|
+
*/
|
|
15
|
+
export function defaultKeyFn(req) {
|
|
16
|
+
const forwarded = req.headers.get("x-forwarded-for");
|
|
17
|
+
if (forwarded) {
|
|
18
|
+
return forwarded.split(",")[0].trim();
|
|
19
|
+
}
|
|
20
|
+
const realIp = req.headers.get("x-real-ip");
|
|
21
|
+
if (realIp)
|
|
22
|
+
return realIp.trim();
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* In-memory sliding window rate limiter.
|
|
27
|
+
*
|
|
28
|
+
* Suitable for single-process deployments (e.g., a long-running Node.js server).
|
|
29
|
+
* State resets on process restart and is not shared across instances.
|
|
30
|
+
* For multi-instance or serverless deployments, use a distributed adapter.
|
|
31
|
+
*
|
|
32
|
+
* Periodically prunes expired entries to prevent memory leaks.
|
|
33
|
+
*/
|
|
34
|
+
export class InMemoryRateLimitAdapter {
|
|
35
|
+
windows = new Map();
|
|
36
|
+
pruneIntervalMs;
|
|
37
|
+
lastPrune = Date.now();
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.pruneIntervalMs = options?.pruneIntervalMs ?? 60_000;
|
|
40
|
+
}
|
|
41
|
+
async check(key, windowMs, max) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
this.maybePrune(now, windowMs);
|
|
44
|
+
let entry = this.windows.get(key);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
entry = { timestamps: [] };
|
|
47
|
+
this.windows.set(key, entry);
|
|
48
|
+
}
|
|
49
|
+
// Remove timestamps outside the current window
|
|
50
|
+
const windowStart = now - windowMs;
|
|
51
|
+
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
|
|
52
|
+
const resetAt = Math.ceil((now + windowMs) / 1000);
|
|
53
|
+
if (entry.timestamps.length >= max) {
|
|
54
|
+
return { allowed: false, limit: max, remaining: 0, resetAt };
|
|
55
|
+
}
|
|
56
|
+
entry.timestamps.push(now);
|
|
57
|
+
const remaining = max - entry.timestamps.length;
|
|
58
|
+
return { allowed: true, limit: max, remaining, resetAt };
|
|
59
|
+
}
|
|
60
|
+
maybePrune(now, windowMs) {
|
|
61
|
+
if (now - this.lastPrune < this.pruneIntervalMs)
|
|
62
|
+
return;
|
|
63
|
+
this.lastPrune = now;
|
|
64
|
+
const cutoff = now - windowMs;
|
|
65
|
+
for (const [key, entry] of this.windows) {
|
|
66
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
67
|
+
if (entry.timestamps.length === 0) {
|
|
68
|
+
this.windows.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* auth or database implementation.
|
|
7
7
|
*/
|
|
8
8
|
import type { EndpointDefinition } from "../types.js";
|
|
9
|
+
import { type RateLimitConfig } from "./rate-limit.js";
|
|
9
10
|
/**
|
|
10
11
|
* Auth adapter interface. Consumers implement this to provide
|
|
11
12
|
* user resolution for protected endpoints.
|
|
@@ -37,6 +38,12 @@ export interface ConfigureHandlerOptions {
|
|
|
37
38
|
auth?: ErtkAuthAdapter;
|
|
38
39
|
/** Custom error handlers, processed in order. First non-null response wins. */
|
|
39
40
|
errorHandlers?: ErtkErrorHandler[];
|
|
41
|
+
/**
|
|
42
|
+
* Global rate limiting configuration.
|
|
43
|
+
* Applied to all endpoints unless overridden per-endpoint.
|
|
44
|
+
* Omit to disable rate limiting entirely.
|
|
45
|
+
*/
|
|
46
|
+
rateLimit?: RateLimitConfig;
|
|
40
47
|
}
|
|
41
48
|
/**
|
|
42
49
|
* Create a configured route handler factory. Call this once with your
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Next.js App Router route handlers. Decoupled from any specific
|
|
6
6
|
* auth or database implementation.
|
|
7
7
|
*/
|
|
8
|
+
import { defaultKeyFn, InMemoryRateLimitAdapter, } from "./rate-limit.js";
|
|
8
9
|
// ─── Validation Error ─────────────────────────────────────────
|
|
9
10
|
class ValidationError extends Error {
|
|
10
11
|
issues;
|
|
@@ -25,8 +26,7 @@ async function parseAndValidateRequest(req, schema) {
|
|
|
25
26
|
const url = new URL(req.url);
|
|
26
27
|
const params = {};
|
|
27
28
|
url.searchParams.forEach((value, key) => {
|
|
28
|
-
|
|
29
|
-
params[key] = Number.isNaN(numValue) ? value : numValue;
|
|
29
|
+
params[key] = value;
|
|
30
30
|
});
|
|
31
31
|
try {
|
|
32
32
|
const data = schema.parse(params);
|
|
@@ -82,6 +82,38 @@ function jsonResponse(data, status = 200) {
|
|
|
82
82
|
function errorResponse(message, status) {
|
|
83
83
|
return jsonResponse({ error: message }, status);
|
|
84
84
|
}
|
|
85
|
+
// ─── Rate Limiting ────────────────────────────────────────────
|
|
86
|
+
let defaultAdapter = null;
|
|
87
|
+
function getDefaultAdapter() {
|
|
88
|
+
if (!defaultAdapter) {
|
|
89
|
+
defaultAdapter = new InMemoryRateLimitAdapter();
|
|
90
|
+
}
|
|
91
|
+
return defaultAdapter;
|
|
92
|
+
}
|
|
93
|
+
async function applyRateLimit(req, user, globalConfig, endpointOverride) {
|
|
94
|
+
if (!globalConfig && !endpointOverride)
|
|
95
|
+
return null;
|
|
96
|
+
const windowMs = endpointOverride?.windowMs ?? globalConfig.windowMs;
|
|
97
|
+
const max = endpointOverride?.max ?? globalConfig.max;
|
|
98
|
+
const keyFn = globalConfig?.keyFn ?? defaultKeyFn;
|
|
99
|
+
const adapter = globalConfig?.adapter ?? getDefaultAdapter();
|
|
100
|
+
const key = keyFn(req, user);
|
|
101
|
+
const result = await adapter.check(key, windowMs, max);
|
|
102
|
+
if (!result.allowed) {
|
|
103
|
+
const retryAfter = Math.ceil((result.resetAt * 1000 - Date.now()) / 1000);
|
|
104
|
+
return new Response(JSON.stringify({ error: "Too many requests" }), {
|
|
105
|
+
status: 429,
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Retry-After": String(Math.max(1, retryAfter)),
|
|
109
|
+
"X-RateLimit-Limit": String(result.limit),
|
|
110
|
+
"X-RateLimit-Remaining": "0",
|
|
111
|
+
"X-RateLimit-Reset": String(result.resetAt),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
85
117
|
// ─── Route Handler Factory ───────────────────────────────────
|
|
86
118
|
/**
|
|
87
119
|
* Create a configured route handler factory. Call this once with your
|
|
@@ -120,7 +152,7 @@ export function configureHandler(options = {}) {
|
|
|
120
152
|
// Parse and validate request
|
|
121
153
|
const { body, query } = await parseAndValidateRequest(req, def.request);
|
|
122
154
|
// Resolve user for protected endpoints
|
|
123
|
-
let user =
|
|
155
|
+
let user = null;
|
|
124
156
|
if (def.protected) {
|
|
125
157
|
if (!options.auth) {
|
|
126
158
|
return errorResponse("No auth adapter configured for protected endpoint", 500);
|
|
@@ -130,12 +162,18 @@ export function configureHandler(options = {}) {
|
|
|
130
162
|
return errorResponse("Unauthorized", 401);
|
|
131
163
|
}
|
|
132
164
|
}
|
|
165
|
+
// Rate limiting
|
|
166
|
+
if (options.rateLimit || def.rateLimit) {
|
|
167
|
+
const rateLimitResponse = await applyRateLimit(req, user, options.rateLimit, def.rateLimit);
|
|
168
|
+
if (rateLimitResponse)
|
|
169
|
+
return rateLimitResponse;
|
|
170
|
+
}
|
|
133
171
|
// Call the endpoint handler
|
|
134
172
|
if (!def.handler) {
|
|
135
173
|
return errorResponse("No handler defined for this endpoint", 501);
|
|
136
174
|
}
|
|
137
175
|
const result = await def.handler({
|
|
138
|
-
user:
|
|
176
|
+
user: user ?? null,
|
|
139
177
|
body,
|
|
140
178
|
query,
|
|
141
179
|
params,
|
|
@@ -163,7 +201,10 @@ export function configureHandler(options = {}) {
|
|
|
163
201
|
if (error instanceof Error &&
|
|
164
202
|
"status" in error &&
|
|
165
203
|
typeof error.status === "number") {
|
|
166
|
-
|
|
204
|
+
const status = error.status;
|
|
205
|
+
const isClientSafe = "expose" in error &&
|
|
206
|
+
error.expose === true;
|
|
207
|
+
return errorResponse(isClientSafe ? error.message : "An error occurred", status);
|
|
167
208
|
}
|
|
168
209
|
// Generic error
|
|
169
210
|
if (error instanceof Error) {
|
package/dist/types.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface DefaultUser {
|
|
|
34
34
|
* Context passed to endpoint handlers on the server side.
|
|
35
35
|
*/
|
|
36
36
|
export interface HandlerContext<TBody = unknown, TQuery = unknown, TUser = DefaultUser> {
|
|
37
|
-
user: TUser;
|
|
37
|
+
user: TUser | null;
|
|
38
38
|
body: TBody;
|
|
39
39
|
query: TQuery;
|
|
40
40
|
params: Record<string, string>;
|
|
@@ -69,6 +69,22 @@ export interface EndpointDefinition<TResponse = unknown, TArgs = void> {
|
|
|
69
69
|
};
|
|
70
70
|
/** Optimistic update configuration */
|
|
71
71
|
optimistic?: SingleOptimistic<TArgs> | MultiOptimistic<TArgs>;
|
|
72
|
+
/**
|
|
73
|
+
* Maximum number of retry attempts for transient failures (5xx, network errors, 408, 429).
|
|
74
|
+
* Uses RTK Query's built-in `retry` utility with exponential backoff.
|
|
75
|
+
* 0 or undefined means no retries. A value of 2 means up to 3 total attempts.
|
|
76
|
+
* Client-side only — does not affect server-side route handlers.
|
|
77
|
+
*/
|
|
78
|
+
maxRetries?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Per-endpoint rate limit override for the server-side route handler.
|
|
81
|
+
* Overrides the global `rateLimit` config from `configureHandler()`.
|
|
82
|
+
* Only `windowMs` and `max` can be overridden; `keyFn` and `adapter` come from the global config.
|
|
83
|
+
*/
|
|
84
|
+
rateLimit?: {
|
|
85
|
+
windowMs: number;
|
|
86
|
+
max: number;
|
|
87
|
+
};
|
|
72
88
|
/**
|
|
73
89
|
* Server-side handler. Optional — omit for client-only endpoints
|
|
74
90
|
* that consume an external API.
|