ertk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +93 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +122 -0
- package/dist/config.js.map +1 -0
- package/dist/endpoint.d.ts +9 -0
- package/dist/endpoint.d.ts.map +1 -0
- package/dist/endpoint.js +15 -0
- package/dist/endpoint.js.map +1 -0
- package/dist/generate.d.ts +20 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +653 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +2 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/route-handler.d.ts +82 -0
- package/dist/next/route-handler.d.ts.map +1 -0
- package/dist/next/route-handler.js +190 -0
- package/dist/next/route-handler.js.map +1 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +75 -0
package/dist/generate.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ERTK Codegen Engine
|
|
3
|
+
*
|
|
4
|
+
* Reads endpoint definition files and generates:
|
|
5
|
+
* - api.ts (RTK Query API + hooks)
|
|
6
|
+
* - store.ts (Redux store config)
|
|
7
|
+
* - invalidation.ts (cache invalidation helpers)
|
|
8
|
+
* - route.ts files (Next.js route handlers) — if routes config is present
|
|
9
|
+
*/
|
|
10
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
11
|
+
import * as crypto from "node:crypto";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
// ─── AST Parsing ──────────────────────────────────────────────
|
|
15
|
+
function parseEndpointFile(project, filePath, config) {
|
|
16
|
+
const absPath = path.join(config.endpointsDir, filePath);
|
|
17
|
+
const sourceFile = project.addSourceFileAtPath(absPath);
|
|
18
|
+
// Find the default export
|
|
19
|
+
const defaultExport = sourceFile.getDefaultExportSymbol();
|
|
20
|
+
if (!defaultExport) {
|
|
21
|
+
console.warn(`ERTK: No default export in ${filePath}, skipping`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
// Find the endpoint.{method}(...) call
|
|
25
|
+
const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
26
|
+
let endpointCall = null;
|
|
27
|
+
let method = "";
|
|
28
|
+
for (const call of callExpressions) {
|
|
29
|
+
const expr = call.getExpression();
|
|
30
|
+
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
31
|
+
const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
|
|
32
|
+
const objectText = propAccess.getExpression().getText();
|
|
33
|
+
const methodName = propAccess.getName();
|
|
34
|
+
if (objectText === "endpoint" &&
|
|
35
|
+
["get", "post", "put", "patch", "delete"].includes(methodName)) {
|
|
36
|
+
endpointCall = call;
|
|
37
|
+
method = methodName;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!endpointCall || !method) {
|
|
43
|
+
console.warn(`ERTK: No endpoint.{method}() call found in ${filePath}, skipping`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// Extract type arguments
|
|
47
|
+
const typeArgs = endpointCall.getTypeArguments();
|
|
48
|
+
const responseType = typeArgs[0]?.getText() ?? "unknown";
|
|
49
|
+
const argsType = typeArgs[1]?.getText() ?? "void";
|
|
50
|
+
// Extract config object
|
|
51
|
+
const configArg = endpointCall.getArguments()[0];
|
|
52
|
+
if (!configArg ||
|
|
53
|
+
configArg.getKind() !== SyntaxKind.ObjectLiteralExpression) {
|
|
54
|
+
console.warn(`ERTK: Config argument not found in ${filePath}, skipping`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const configObj = configArg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
58
|
+
// Extract name
|
|
59
|
+
const nameProp = configObj.getProperty("name");
|
|
60
|
+
const name = nameProp
|
|
61
|
+
? nameProp
|
|
62
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
63
|
+
.getInitializerOrThrow()
|
|
64
|
+
.getText()
|
|
65
|
+
.replace(/['"]/g, "")
|
|
66
|
+
: "";
|
|
67
|
+
if (!name) {
|
|
68
|
+
console.warn(`ERTK: No name property in ${filePath}, skipping`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// Extract protected
|
|
72
|
+
const protectedProp = configObj.getProperty("protected");
|
|
73
|
+
const isProtected = protectedProp
|
|
74
|
+
? protectedProp
|
|
75
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
76
|
+
.getInitializerOrThrow()
|
|
77
|
+
.getText() !== "false"
|
|
78
|
+
: true;
|
|
79
|
+
// Check for request schema
|
|
80
|
+
const requestProp = configObj.getProperty("request");
|
|
81
|
+
const hasRequest = !!requestProp;
|
|
82
|
+
// Check for handler
|
|
83
|
+
const handlerProp = configObj.getProperty("handler");
|
|
84
|
+
const hasHandler = !!handlerProp;
|
|
85
|
+
// Extract query function source
|
|
86
|
+
const queryProp = configObj.getProperty("query");
|
|
87
|
+
let queryFnSource = "";
|
|
88
|
+
if (queryProp) {
|
|
89
|
+
const init = queryProp
|
|
90
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
91
|
+
.getInitializerOrThrow();
|
|
92
|
+
queryFnSource = init.getText();
|
|
93
|
+
}
|
|
94
|
+
// Extract tags
|
|
95
|
+
let providesTagsSource = null;
|
|
96
|
+
let invalidatesTagsSource = null;
|
|
97
|
+
const tagsProp = configObj.getProperty("tags");
|
|
98
|
+
if (tagsProp) {
|
|
99
|
+
const tagsObj = tagsProp
|
|
100
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
101
|
+
.getInitializerOrThrow();
|
|
102
|
+
if (tagsObj.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
103
|
+
const tagsObjLit = tagsObj.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
104
|
+
const providesProp = tagsObjLit.getProperty("provides");
|
|
105
|
+
if (providesProp) {
|
|
106
|
+
providesTagsSource = providesProp
|
|
107
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
108
|
+
.getInitializerOrThrow()
|
|
109
|
+
.getText();
|
|
110
|
+
}
|
|
111
|
+
const invalidatesProp = tagsObjLit.getProperty("invalidates");
|
|
112
|
+
if (invalidatesProp) {
|
|
113
|
+
invalidatesTagsSource = invalidatesProp
|
|
114
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
115
|
+
.getInitializerOrThrow()
|
|
116
|
+
.getText();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Extract optimistic updates
|
|
121
|
+
const optimisticProp = configObj.getProperty("optimistic");
|
|
122
|
+
let optimisticSource = null;
|
|
123
|
+
if (optimisticProp) {
|
|
124
|
+
optimisticSource = optimisticProp
|
|
125
|
+
.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
|
126
|
+
.getInitializerOrThrow()
|
|
127
|
+
.getText();
|
|
128
|
+
}
|
|
129
|
+
// Derive route path from file path
|
|
130
|
+
const routePath = deriveRoutePath(filePath, config);
|
|
131
|
+
const endpointType = method === "get" ? "query" : "mutation";
|
|
132
|
+
// Resolve type imports
|
|
133
|
+
const typeImports = new Map();
|
|
134
|
+
let responseTypeImport = null;
|
|
135
|
+
let argsTypeImport = null;
|
|
136
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
137
|
+
const namedImports = importDecl.getNamedImports();
|
|
138
|
+
for (const named of namedImports) {
|
|
139
|
+
const importName = named.getName();
|
|
140
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue();
|
|
141
|
+
const aliasPath = resolveToAlias(moduleSpecifier, absPath, config);
|
|
142
|
+
if (responseType.includes(importName)) {
|
|
143
|
+
responseTypeImport = aliasPath;
|
|
144
|
+
addToMapSet(typeImports, aliasPath, importName);
|
|
145
|
+
}
|
|
146
|
+
if (argsType.includes(importName)) {
|
|
147
|
+
argsTypeImport = aliasPath;
|
|
148
|
+
addToMapSet(typeImports, aliasPath, importName);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Build import path for endpoint file
|
|
153
|
+
const endpointsRelative = path.relative(config.aliasRoot, config.endpointsDir);
|
|
154
|
+
const importPath = `${config.pathAlias}/${endpointsRelative}/${filePath.replace(/\.ts$/, "")}`;
|
|
155
|
+
return {
|
|
156
|
+
name,
|
|
157
|
+
method,
|
|
158
|
+
filePath,
|
|
159
|
+
importPath,
|
|
160
|
+
routePath,
|
|
161
|
+
isProtected,
|
|
162
|
+
hasRequest,
|
|
163
|
+
hasHandler,
|
|
164
|
+
responseType,
|
|
165
|
+
responseTypeImport,
|
|
166
|
+
argsType,
|
|
167
|
+
argsTypeImport,
|
|
168
|
+
queryFnSource,
|
|
169
|
+
endpointType,
|
|
170
|
+
providesTagsSource,
|
|
171
|
+
invalidatesTagsSource,
|
|
172
|
+
optimisticSource,
|
|
173
|
+
typeImports,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function resolveToAlias(moduleSpecifier, fromFile, config) {
|
|
177
|
+
if (moduleSpecifier.startsWith(config.pathAlias + "/")) {
|
|
178
|
+
return moduleSpecifier;
|
|
179
|
+
}
|
|
180
|
+
// Resolve relative path to absolute, then convert to alias
|
|
181
|
+
const dir = path.dirname(fromFile);
|
|
182
|
+
const resolved = path.resolve(dir, moduleSpecifier);
|
|
183
|
+
if (resolved.startsWith(config.aliasRoot)) {
|
|
184
|
+
return `${config.pathAlias}/${path.relative(config.aliasRoot, resolved).replace(/\.ts$/, "")}`;
|
|
185
|
+
}
|
|
186
|
+
return moduleSpecifier;
|
|
187
|
+
}
|
|
188
|
+
function addToMapSet(map, key, value) {
|
|
189
|
+
if (!map.has(key)) {
|
|
190
|
+
map.set(key, new Set());
|
|
191
|
+
}
|
|
192
|
+
map.get(key).add(value);
|
|
193
|
+
}
|
|
194
|
+
function deriveRoutePath(filePath, config) {
|
|
195
|
+
const parts = filePath.replace(/\.ts$/, "").split("/");
|
|
196
|
+
const fileName = parts.pop();
|
|
197
|
+
const segments = [];
|
|
198
|
+
for (const part of parts) {
|
|
199
|
+
segments.push(part);
|
|
200
|
+
}
|
|
201
|
+
if (!config.crudFilenames.has(fileName)) {
|
|
202
|
+
segments.push(fileName);
|
|
203
|
+
}
|
|
204
|
+
return `/api/${segments.join("/")}`;
|
|
205
|
+
}
|
|
206
|
+
// ─── Route Grouping ───────────────────────────────────────────
|
|
207
|
+
function groupEndpointsByRoute(endpoints, config) {
|
|
208
|
+
const groups = new Map();
|
|
209
|
+
if (!config.routes)
|
|
210
|
+
return groups;
|
|
211
|
+
for (const ep of endpoints) {
|
|
212
|
+
if (!ep.hasHandler)
|
|
213
|
+
continue; // Skip client-only endpoints
|
|
214
|
+
if (!groups.has(ep.routePath)) {
|
|
215
|
+
const appRouteDir = path.join(config.routes.dir, ep.routePath.replace(/^\/api\//, ""));
|
|
216
|
+
groups.set(ep.routePath, {
|
|
217
|
+
routePath: ep.routePath,
|
|
218
|
+
appRouteDir,
|
|
219
|
+
methods: new Map(),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const httpMethod = ep.method.toUpperCase();
|
|
223
|
+
groups.get(ep.routePath).methods.set(httpMethod, ep);
|
|
224
|
+
}
|
|
225
|
+
return groups;
|
|
226
|
+
}
|
|
227
|
+
// ─── Code Generation ──────────────────────────────────────────
|
|
228
|
+
function generateApiTs(endpoints, config) {
|
|
229
|
+
// Collect all type imports
|
|
230
|
+
const allTypeImports = new Map();
|
|
231
|
+
for (const ep of endpoints) {
|
|
232
|
+
for (const [importPath, types] of ep.typeImports) {
|
|
233
|
+
if (!allTypeImports.has(importPath)) {
|
|
234
|
+
allTypeImports.set(importPath, new Set());
|
|
235
|
+
}
|
|
236
|
+
for (const t of types) {
|
|
237
|
+
allTypeImports.get(importPath).add(t);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Collect all tag types used
|
|
242
|
+
const tagTypes = new Set();
|
|
243
|
+
for (const ep of endpoints) {
|
|
244
|
+
extractTagTypes(ep.providesTagsSource, tagTypes);
|
|
245
|
+
extractTagTypes(ep.invalidatesTagsSource, tagTypes);
|
|
246
|
+
}
|
|
247
|
+
const lines = [];
|
|
248
|
+
lines.push("// AUTO-GENERATED by ERTK codegen. Do not edit.");
|
|
249
|
+
lines.push('import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";');
|
|
250
|
+
// Add type imports
|
|
251
|
+
for (const [importPath, types] of [...allTypeImports.entries()].sort()) {
|
|
252
|
+
const typeList = [...types].sort().join(", ");
|
|
253
|
+
lines.push(`import type { ${typeList} } from "${importPath}";`);
|
|
254
|
+
}
|
|
255
|
+
lines.push("");
|
|
256
|
+
lines.push("export const api = createApi({");
|
|
257
|
+
lines.push('\treducerPath: "api",');
|
|
258
|
+
// baseQuery — use custom source if provided, otherwise default
|
|
259
|
+
if (config.baseQuery) {
|
|
260
|
+
lines.push(`\tbaseQuery: ${config.baseQuery},`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
lines.push(`\tbaseQuery: fetchBaseQuery({ baseUrl: "${config.baseUrl}" }),`);
|
|
264
|
+
}
|
|
265
|
+
const tagTypesList = [...tagTypes].sort();
|
|
266
|
+
lines.push(`\ttagTypes: [${tagTypesList.map((t) => `"${t}"`).join(", ")}],`);
|
|
267
|
+
lines.push("\trefetchOnFocus: false,");
|
|
268
|
+
lines.push("\trefetchOnReconnect: true,");
|
|
269
|
+
lines.push("\tendpoints: (builder) => ({");
|
|
270
|
+
for (const ep of endpoints) {
|
|
271
|
+
lines.push(...generateEndpointDef(ep));
|
|
272
|
+
}
|
|
273
|
+
lines.push("\t}),");
|
|
274
|
+
lines.push("});");
|
|
275
|
+
// Export hooks
|
|
276
|
+
lines.push("");
|
|
277
|
+
const hookExports = [];
|
|
278
|
+
for (const ep of endpoints) {
|
|
279
|
+
if (ep.endpointType === "query") {
|
|
280
|
+
hookExports.push(`use${capitalize(ep.name)}Query`);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
hookExports.push(`use${capitalize(ep.name)}Mutation`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
lines.push("export const {");
|
|
287
|
+
for (const hook of hookExports) {
|
|
288
|
+
lines.push(`\t${hook},`);
|
|
289
|
+
}
|
|
290
|
+
lines.push("} = api;");
|
|
291
|
+
return lines.join("\n") + "\n";
|
|
292
|
+
}
|
|
293
|
+
function generateEndpointDef(ep) {
|
|
294
|
+
const lines = [];
|
|
295
|
+
const builderType = ep.endpointType === "query" ? "builder.query" : "builder.mutation";
|
|
296
|
+
lines.push(`\t\t${ep.name}: ${builderType}<${ep.responseType}, ${ep.argsType}>({`);
|
|
297
|
+
if (ep.queryFnSource) {
|
|
298
|
+
lines.push(`\t\t\tquery: ${ep.queryFnSource},`);
|
|
299
|
+
}
|
|
300
|
+
if (ep.providesTagsSource) {
|
|
301
|
+
lines.push(`\t\t\tprovidesTags: ${ep.providesTagsSource},`);
|
|
302
|
+
}
|
|
303
|
+
if (ep.invalidatesTagsSource) {
|
|
304
|
+
lines.push(`\t\t\tinvalidatesTags: ${ep.invalidatesTagsSource},`);
|
|
305
|
+
}
|
|
306
|
+
if (ep.optimisticSource) {
|
|
307
|
+
const onQueryStarted = generateOnQueryStarted(ep);
|
|
308
|
+
if (onQueryStarted) {
|
|
309
|
+
lines.push(...onQueryStarted);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
lines.push("\t\t}),");
|
|
313
|
+
return lines;
|
|
314
|
+
}
|
|
315
|
+
function generateOnQueryStarted(ep) {
|
|
316
|
+
if (!ep.optimisticSource)
|
|
317
|
+
return null;
|
|
318
|
+
const lines = [];
|
|
319
|
+
const isSingle = ep.optimisticSource.includes("target:");
|
|
320
|
+
const isMulti = ep.optimisticSource.includes("updates:");
|
|
321
|
+
if (isSingle && !isMulti) {
|
|
322
|
+
const targetMatch = ep.optimisticSource.match(/target:\s*["'](\w+)["']/);
|
|
323
|
+
const argsMatch = ep.optimisticSource.match(/args:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,\s*update:)/);
|
|
324
|
+
const updateMatch = ep.optimisticSource.match(/update:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,?\s*}$)/);
|
|
325
|
+
if (targetMatch && argsMatch && updateMatch) {
|
|
326
|
+
const target = targetMatch[1];
|
|
327
|
+
const argsFn = argsMatch[1].trim();
|
|
328
|
+
const updateFn = updateMatch[1].trim();
|
|
329
|
+
lines.push(`\t\t\tasync onQueryStarted(params, { dispatch, queryFulfilled }) {`);
|
|
330
|
+
lines.push(`\t\t\t\tconst patchResult = dispatch(`);
|
|
331
|
+
lines.push(`\t\t\t\t\tapi.util.updateQueryData("${target}", (${argsFn})(params), (draft) => {`);
|
|
332
|
+
lines.push(`\t\t\t\t\t\t(${updateFn})(draft, params);`);
|
|
333
|
+
lines.push(`\t\t\t\t\t}),`);
|
|
334
|
+
lines.push(`\t\t\t\t);`);
|
|
335
|
+
lines.push(`\t\t\t\ttry { await queryFulfilled; } catch { patchResult.undo(); }`);
|
|
336
|
+
lines.push(`\t\t\t},`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (isMulti) {
|
|
340
|
+
lines.push(`\t\t\tasync onQueryStarted(params, { dispatch, queryFulfilled }) {`);
|
|
341
|
+
lines.push(`\t\t\t\tconst patches: Array<{ undo: () => void }> = [];`);
|
|
342
|
+
const updatesContent = extractUpdatesArray(ep.optimisticSource);
|
|
343
|
+
if (updatesContent) {
|
|
344
|
+
for (const update of updatesContent) {
|
|
345
|
+
const targetMatch = update.match(/target:\s*["'](\w+)["']/);
|
|
346
|
+
const conditionMatch = update.match(/condition:\s*((?:\([^)]*\)|\w+)\s*=>[^,}]*)/);
|
|
347
|
+
const argsMatch = update.match(/args:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,\s*(?:update|condition):)/);
|
|
348
|
+
const updateMatch = update.match(/update:\s*((?:\([^)]*\)|\w+)\s*=>[\s\S]*?)(?=,?\s*}$)/);
|
|
349
|
+
if (targetMatch && argsMatch && updateMatch) {
|
|
350
|
+
const target = targetMatch[1];
|
|
351
|
+
const argsFn = argsMatch[1].trim();
|
|
352
|
+
const updateFn = updateMatch[1].trim();
|
|
353
|
+
if (conditionMatch) {
|
|
354
|
+
const conditionFn = conditionMatch[1].trim();
|
|
355
|
+
lines.push(`\t\t\t\tif ((${conditionFn})(params)) {`);
|
|
356
|
+
lines.push(`\t\t\t\t\tpatches.push(`);
|
|
357
|
+
lines.push(`\t\t\t\t\t\tdispatch(`);
|
|
358
|
+
lines.push(`\t\t\t\t\t\t\tapi.util.updateQueryData("${target}", (${argsFn})(params), (draft) => {`);
|
|
359
|
+
lines.push(`\t\t\t\t\t\t\t\t(${updateFn})(draft, params);`);
|
|
360
|
+
lines.push(`\t\t\t\t\t\t\t}),`);
|
|
361
|
+
lines.push(`\t\t\t\t\t\t),`);
|
|
362
|
+
lines.push(`\t\t\t\t\t);`);
|
|
363
|
+
lines.push(`\t\t\t\t}`);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
lines.push(`\t\t\t\tpatches.push(`);
|
|
367
|
+
lines.push(`\t\t\t\t\tdispatch(`);
|
|
368
|
+
lines.push(`\t\t\t\t\t\tapi.util.updateQueryData("${target}", (${argsFn})(params), (draft) => {`);
|
|
369
|
+
lines.push(`\t\t\t\t\t\t\t(${updateFn})(draft, params);`);
|
|
370
|
+
lines.push(`\t\t\t\t\t\t}),`);
|
|
371
|
+
lines.push(`\t\t\t\t\t),`);
|
|
372
|
+
lines.push(`\t\t\t\t);`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
lines.push(`\t\t\t\ttry { await queryFulfilled; } catch { for (const p of patches) p.undo(); }`);
|
|
378
|
+
lines.push(`\t\t\t},`);
|
|
379
|
+
}
|
|
380
|
+
return lines.length > 0 ? lines : null;
|
|
381
|
+
}
|
|
382
|
+
function extractUpdatesArray(source) {
|
|
383
|
+
const match = source.match(/updates:\s*\[([\s\S]*)\]/);
|
|
384
|
+
if (!match)
|
|
385
|
+
return null;
|
|
386
|
+
const content = match[1];
|
|
387
|
+
const updates = [];
|
|
388
|
+
let depth = 0;
|
|
389
|
+
let current = "";
|
|
390
|
+
for (const char of content) {
|
|
391
|
+
if (char === "{") {
|
|
392
|
+
depth++;
|
|
393
|
+
current += char;
|
|
394
|
+
}
|
|
395
|
+
else if (char === "}") {
|
|
396
|
+
depth--;
|
|
397
|
+
current += char;
|
|
398
|
+
if (depth === 0) {
|
|
399
|
+
updates.push(current.trim());
|
|
400
|
+
current = "";
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (depth > 0) {
|
|
404
|
+
current += char;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return updates.length > 0 ? updates : null;
|
|
408
|
+
}
|
|
409
|
+
function extractTagTypes(source, tags) {
|
|
410
|
+
if (!source)
|
|
411
|
+
return;
|
|
412
|
+
const matches = source.matchAll(/["'](\w+)["']/g);
|
|
413
|
+
for (const m of matches) {
|
|
414
|
+
if (m[1][0] === m[1][0].toUpperCase()) {
|
|
415
|
+
tags.add(m[1]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function capitalize(s) {
|
|
420
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
421
|
+
}
|
|
422
|
+
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;
|
|
437
|
+
`;
|
|
438
|
+
}
|
|
439
|
+
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;
|
|
450
|
+
`;
|
|
451
|
+
}
|
|
452
|
+
function generateRouteFile(group, config) {
|
|
453
|
+
const lines = [];
|
|
454
|
+
lines.push("// AUTO-GENERATED by ERTK codegen. Do not edit.");
|
|
455
|
+
const handlerModule = config.routes.handlerModule;
|
|
456
|
+
const importLines = [];
|
|
457
|
+
importLines.push(`import { createRouteHandler } from "${handlerModule}";`);
|
|
458
|
+
for (const [, ep] of group.methods) {
|
|
459
|
+
const varName = `${ep.name}Endpoint`;
|
|
460
|
+
importLines.push(`import ${varName} from "${ep.importPath}";`);
|
|
461
|
+
}
|
|
462
|
+
importLines.sort((a, b) => {
|
|
463
|
+
const pathA = a.match(/from "(.+)"/)?.[1] ?? "";
|
|
464
|
+
const pathB = b.match(/from "(.+)"/)?.[1] ?? "";
|
|
465
|
+
return pathA.localeCompare(pathB);
|
|
466
|
+
});
|
|
467
|
+
lines.push(...importLines);
|
|
468
|
+
lines.push("");
|
|
469
|
+
for (const [method, ep] of group.methods) {
|
|
470
|
+
const varName = `${ep.name}Endpoint`;
|
|
471
|
+
lines.push(`export const ${method} = createRouteHandler(${varName});`);
|
|
472
|
+
}
|
|
473
|
+
return lines.join("\n") + "\n";
|
|
474
|
+
}
|
|
475
|
+
// ─── Incremental Build Helpers ────────────────────────────────
|
|
476
|
+
function scanEndpointFiles(config) {
|
|
477
|
+
if (!fs.existsSync(config.endpointsDir))
|
|
478
|
+
return [];
|
|
479
|
+
const allFiles = fs.readdirSync(config.endpointsDir, {
|
|
480
|
+
recursive: true,
|
|
481
|
+
});
|
|
482
|
+
return allFiles
|
|
483
|
+
.filter((f) => f.endsWith(".ts"))
|
|
484
|
+
.map((f) => f.replace(/\\/g, "/"))
|
|
485
|
+
.sort();
|
|
486
|
+
}
|
|
487
|
+
function hashFile(filePath) {
|
|
488
|
+
return crypto
|
|
489
|
+
.createHash("md5")
|
|
490
|
+
.update(fs.readFileSync(filePath))
|
|
491
|
+
.digest("hex");
|
|
492
|
+
}
|
|
493
|
+
function loadManifest(config) {
|
|
494
|
+
try {
|
|
495
|
+
return JSON.parse(fs.readFileSync(config.manifestPath, "utf-8"));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return {};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function saveManifest(manifest, config) {
|
|
502
|
+
fs.writeFileSync(config.manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
503
|
+
}
|
|
504
|
+
function buildManifest(files, config) {
|
|
505
|
+
const manifest = {};
|
|
506
|
+
for (const file of files) {
|
|
507
|
+
manifest[file] = hashFile(path.join(config.endpointsDir, file));
|
|
508
|
+
}
|
|
509
|
+
return manifest;
|
|
510
|
+
}
|
|
511
|
+
function manifestsMatch(a, b) {
|
|
512
|
+
const keysA = Object.keys(a).sort();
|
|
513
|
+
const keysB = Object.keys(b).sort();
|
|
514
|
+
if (keysA.length !== keysB.length)
|
|
515
|
+
return false;
|
|
516
|
+
for (let i = 0; i < keysA.length; i++) {
|
|
517
|
+
if (keysA[i] !== keysB[i])
|
|
518
|
+
return false;
|
|
519
|
+
if (a[keysA[i]] !== b[keysB[i]])
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
function writeIfChanged(filePath, content) {
|
|
525
|
+
try {
|
|
526
|
+
const existing = fs.readFileSync(filePath, "utf-8");
|
|
527
|
+
if (existing === content)
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// File doesn't exist yet
|
|
532
|
+
}
|
|
533
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
534
|
+
fs.writeFileSync(filePath, content);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
function isIgnoredRoute(routeDir, config) {
|
|
538
|
+
if (!config.routes)
|
|
539
|
+
return true;
|
|
540
|
+
const relative = path.relative(config.routes.dir, routeDir);
|
|
541
|
+
const topLevel = relative.split(path.sep)[0];
|
|
542
|
+
return config.routes.ignoredRoutes.has(topLevel);
|
|
543
|
+
}
|
|
544
|
+
// ─── Core Generate Function ──────────────────────────────────
|
|
545
|
+
function parseAllEndpoints(project, config) {
|
|
546
|
+
const files = scanEndpointFiles(config);
|
|
547
|
+
const cache = new Map();
|
|
548
|
+
for (const file of files) {
|
|
549
|
+
const parsed = parseEndpointFile(project, file, config);
|
|
550
|
+
if (parsed) {
|
|
551
|
+
cache.set(file, parsed);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return cache;
|
|
555
|
+
}
|
|
556
|
+
function generate(endpoints, config) {
|
|
557
|
+
fs.mkdirSync(config.generatedDir, { recursive: true });
|
|
558
|
+
// 1. Generate api.ts
|
|
559
|
+
const apiContent = generateApiTs(endpoints, config);
|
|
560
|
+
writeIfChanged(path.join(config.generatedDir, "api.ts"), apiContent);
|
|
561
|
+
// 2. Generate store.ts
|
|
562
|
+
writeIfChanged(path.join(config.generatedDir, "store.ts"), generateStoreTs());
|
|
563
|
+
// 3. Generate invalidation.ts
|
|
564
|
+
writeIfChanged(path.join(config.generatedDir, "invalidation.ts"), generateInvalidationTs());
|
|
565
|
+
// 4. Generate route handlers (if routes config is present)
|
|
566
|
+
let routeCount = 0;
|
|
567
|
+
if (config.routes) {
|
|
568
|
+
const routeGroups = groupEndpointsByRoute(endpoints, config);
|
|
569
|
+
for (const [, group] of routeGroups) {
|
|
570
|
+
if (isIgnoredRoute(group.appRouteDir, config))
|
|
571
|
+
continue;
|
|
572
|
+
const routeContent = generateRouteFile(group, config);
|
|
573
|
+
writeIfChanged(path.join(group.appRouteDir, "route.ts"), routeContent);
|
|
574
|
+
routeCount++;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return routeCount;
|
|
578
|
+
}
|
|
579
|
+
// ─── Public API ───────────────────────────────────────────────
|
|
580
|
+
/**
|
|
581
|
+
* Run a one-shot generation. Skips if nothing changed (manifest comparison).
|
|
582
|
+
*/
|
|
583
|
+
export function runGenerate(config) {
|
|
584
|
+
const tsProject = new Project({
|
|
585
|
+
tsConfigFilePath: path.join(config.root, "tsconfig.json"),
|
|
586
|
+
skipAddingFilesFromTsConfig: true,
|
|
587
|
+
});
|
|
588
|
+
const files = scanEndpointFiles(config);
|
|
589
|
+
if (files.length === 0) {
|
|
590
|
+
console.log("ERTK: No endpoint files found.");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const oldManifest = loadManifest(config);
|
|
594
|
+
const newManifest = buildManifest(files, config);
|
|
595
|
+
if (manifestsMatch(oldManifest, newManifest)) {
|
|
596
|
+
console.log("ERTK: Nothing changed.");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const cache = parseAllEndpoints(tsProject, config);
|
|
600
|
+
const routeCount = generate([...cache.values()], config);
|
|
601
|
+
saveManifest(newManifest, config);
|
|
602
|
+
const routeMsg = config.routes ? `, ${routeCount} routes` : "";
|
|
603
|
+
console.log(`ERTK: Generated ${cache.size} endpoints${routeMsg}.`);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Run generation in watch mode. Does an initial full build, then
|
|
607
|
+
* watches for changes and incrementally regenerates.
|
|
608
|
+
*/
|
|
609
|
+
export function runWatch(config) {
|
|
610
|
+
const tsProject = new Project({
|
|
611
|
+
tsConfigFilePath: path.join(config.root, "tsconfig.json"),
|
|
612
|
+
skipAddingFilesFromTsConfig: true,
|
|
613
|
+
});
|
|
614
|
+
const cache = parseAllEndpoints(tsProject, config);
|
|
615
|
+
const routeCount = generate([...cache.values()], config);
|
|
616
|
+
const manifest = buildManifest(scanEndpointFiles(config), config);
|
|
617
|
+
saveManifest(manifest, config);
|
|
618
|
+
const routeMsg = config.routes ? `, ${routeCount} routes ready` : "";
|
|
619
|
+
console.log(`ERTK: Watching — ${cache.size} endpoints${routeMsg}.`);
|
|
620
|
+
let debounceTimer = null;
|
|
621
|
+
fs.watch(config.endpointsDir, { recursive: true }, (_event, filename) => {
|
|
622
|
+
if (!filename?.endsWith(".ts"))
|
|
623
|
+
return;
|
|
624
|
+
if (debounceTimer)
|
|
625
|
+
clearTimeout(debounceTimer);
|
|
626
|
+
debounceTimer = setTimeout(() => {
|
|
627
|
+
const relPath = filename.replace(/\\/g, "/");
|
|
628
|
+
const fullPath = path.join(config.endpointsDir, relPath);
|
|
629
|
+
if (fs.existsSync(fullPath)) {
|
|
630
|
+
const hash = hashFile(fullPath);
|
|
631
|
+
if (manifest[relPath] === hash)
|
|
632
|
+
return;
|
|
633
|
+
manifest[relPath] = hash;
|
|
634
|
+
const existing = tsProject.getSourceFile(fullPath);
|
|
635
|
+
if (existing)
|
|
636
|
+
existing.forget();
|
|
637
|
+
const parsed = parseEndpointFile(tsProject, relPath, config);
|
|
638
|
+
if (parsed) {
|
|
639
|
+
cache.set(relPath, parsed);
|
|
640
|
+
console.log(`ERTK: Updated ${parsed.name}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
delete manifest[relPath];
|
|
645
|
+
cache.delete(relPath);
|
|
646
|
+
console.log(`ERTK: Removed ${relPath}`);
|
|
647
|
+
}
|
|
648
|
+
generate([...cache.values()], config);
|
|
649
|
+
saveManifest(manifest, config);
|
|
650
|
+
}, 300);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
//# sourceMappingURL=generate.js.map
|