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/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
- lines.push('import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";');
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
- if (config.baseQuery) {
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) {
@@ -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
@@ -1,2 +1,3 @@
1
1
  export { configureHandler, createRouteHandler, } from "./route-handler.js";
2
+ export { InMemoryRateLimitAdapter, defaultKeyFn, } from "./rate-limit.js";
2
3
  //# sourceMappingURL=index.js.map
@@ -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
- const numValue = Number(value);
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 = undefined;
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: (user ?? { id: "" }),
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
- return errorResponse(error.message || "An error occurred", error.status);
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.