@hyperfixi/server-bridge 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/LICENSE +20 -0
- package/dist/cli/config.d.ts +5 -0
- package/dist/cli/diff-command.d.ts +9 -0
- package/dist/cli/generate-command.d.ts +12 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/scan-command.d.ts +9 -0
- package/dist/cli/watch-command.d.ts +30 -0
- package/dist/cli.cjs +1853 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/conventions/convention-engine.d.ts +29 -0
- package/dist/conventions/url-patterns.d.ts +37 -0
- package/dist/generate.d.ts +21 -0
- package/dist/generators/django-generator.d.ts +10 -0
- package/dist/generators/express-generator.d.ts +9 -0
- package/dist/generators/fastapi-generator.d.ts +10 -0
- package/dist/generators/hono-generator.d.ts +9 -0
- package/dist/generators/index.d.ts +6 -0
- package/dist/generators/manifest.d.ts +43 -0
- package/dist/generators/openapi-generator.d.ts +14 -0
- package/dist/generators/template-helpers.d.ts +24 -0
- package/dist/generators/types.d.ts +11 -0
- package/dist/index.cjs +1426 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1376 -0
- package/dist/index.js.map +1 -0
- package/dist/scanner/conflict-detector.d.ts +10 -0
- package/dist/scanner/form-scanner.d.ts +12 -0
- package/dist/scanner/htmx-extractor.d.ts +9 -0
- package/dist/scanner/hyperscript-extractor.d.ts +9 -0
- package/dist/scanner/route-scanner.d.ts +21 -0
- package/dist/types.d.ts +123 -0
- package/package.json +64 -0
- package/src/__tests__/cli.test.ts +30 -0
- package/src/__tests__/conflict-detector.test.ts +126 -0
- package/src/__tests__/convention-engine.test.ts +58 -0
- package/src/__tests__/diff-command.test.ts +69 -0
- package/src/__tests__/django-generator.test.ts +129 -0
- package/src/__tests__/express-generator.test.ts +105 -0
- package/src/__tests__/fastapi-generator.test.ts +125 -0
- package/src/__tests__/form-scanner.test.ts +91 -0
- package/src/__tests__/generate.test.ts +89 -0
- package/src/__tests__/hono-generator.test.ts +90 -0
- package/src/__tests__/htmx-extractor.test.ts +84 -0
- package/src/__tests__/hyperscript-extractor.test.ts +89 -0
- package/src/__tests__/integration.test.ts +102 -0
- package/src/__tests__/manifest.test.ts +133 -0
- package/src/__tests__/openapi-generator.test.ts +116 -0
- package/src/__tests__/query-params.test.ts +101 -0
- package/src/__tests__/route-scanner.test.ts +47 -0
- package/src/__tests__/url-patterns.test.ts +74 -0
- package/src/__tests__/watch-command.test.ts +141 -0
- package/src/cli/config.ts +35 -0
- package/src/cli/diff-command.ts +96 -0
- package/src/cli/generate-command.ts +108 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/scan-command.ts +72 -0
- package/src/cli/watch-command.ts +242 -0
- package/src/conventions/convention-engine.ts +68 -0
- package/src/conventions/url-patterns.ts +123 -0
- package/src/generate.ts +44 -0
- package/src/generators/django-generator.ts +210 -0
- package/src/generators/express-generator.ts +137 -0
- package/src/generators/fastapi-generator.ts +248 -0
- package/src/generators/hono-generator.ts +137 -0
- package/src/generators/index.ts +25 -0
- package/src/generators/manifest.ts +164 -0
- package/src/generators/openapi-generator.ts +259 -0
- package/src/generators/template-helpers.ts +77 -0
- package/src/generators/types.ts +12 -0
- package/src/index.ts +42 -0
- package/src/scanner/conflict-detector.ts +92 -0
- package/src/scanner/form-scanner.ts +126 -0
- package/src/scanner/htmx-extractor.ts +174 -0
- package/src/scanner/hyperscript-extractor.ts +184 -0
- package/src/scanner/route-scanner.ts +130 -0
- package/src/types.ts +151 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { RouteDescriptor, GeneratorOptions, GenerateResult, GeneratedFile } from '../types.js';
|
|
2
|
+
import type { RouteGenerator } from './types.js';
|
|
3
|
+
import { groupRoutesByPrefix } from './template-helpers.js';
|
|
4
|
+
|
|
5
|
+
const PY_ROUTE_MARKER = '# @serverbridge-route:';
|
|
6
|
+
const PY_USER_START = '# @serverbridge-user-start';
|
|
7
|
+
const PY_USER_END = '# @serverbridge-user-end';
|
|
8
|
+
|
|
9
|
+
export class FastAPIGenerator implements RouteGenerator {
|
|
10
|
+
readonly framework = 'fastapi';
|
|
11
|
+
|
|
12
|
+
generate(routes: RouteDescriptor[], options: GeneratorOptions): GenerateResult {
|
|
13
|
+
const files: GeneratedFile[] = [];
|
|
14
|
+
const warnings: string[] = [];
|
|
15
|
+
|
|
16
|
+
if (routes.length === 0) {
|
|
17
|
+
warnings.push('No routes to generate');
|
|
18
|
+
return { files, preserved: [], warnings };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const groups = groupRoutesByPrefix(routes);
|
|
22
|
+
|
|
23
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
24
|
+
const content = this.generateRouterFile(groupRoutes, groupKey);
|
|
25
|
+
const pyFilename = groupKey.replace(/-/g, '_');
|
|
26
|
+
files.push({ path: `routers/${pyFilename}.py`, content, isNew: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Main app file that includes all routers
|
|
30
|
+
const mainContent = this.generateMain(groups);
|
|
31
|
+
files.push({ path: 'main.py', content: mainContent, isNew: true });
|
|
32
|
+
|
|
33
|
+
// Pydantic models file if any routes have request bodies
|
|
34
|
+
const allRoutes = [...groups.values()].flat();
|
|
35
|
+
const routesWithBodies = allRoutes.filter(
|
|
36
|
+
r => r.requestBody && r.requestBody.length > 0 && ['POST', 'PUT', 'PATCH'].includes(r.method)
|
|
37
|
+
);
|
|
38
|
+
if (routesWithBodies.length > 0) {
|
|
39
|
+
const models = this.generateModels(routesWithBodies);
|
|
40
|
+
files.push({ path: 'models.py', content: models, isNew: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { files, preserved: [], warnings };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private generateRouterFile(routes: RouteDescriptor[], groupKey: string): string {
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
|
|
49
|
+
lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT scaffold markers`);
|
|
50
|
+
|
|
51
|
+
// Check if we need Query import
|
|
52
|
+
const needsQuery = routes.some(r => r.queryParams && r.queryParams.length > 0);
|
|
53
|
+
if (needsQuery) {
|
|
54
|
+
lines.push(`from fastapi import APIRouter, Query`);
|
|
55
|
+
} else {
|
|
56
|
+
lines.push(`from fastapi import APIRouter`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check if we need models import
|
|
60
|
+
const routesWithBodies = routes.filter(
|
|
61
|
+
r => r.requestBody && r.requestBody.length > 0 && ['POST', 'PUT', 'PATCH'].includes(r.method)
|
|
62
|
+
);
|
|
63
|
+
if (routesWithBodies.length > 0) {
|
|
64
|
+
const modelNames = routesWithBodies.map(r => modelName(r.handlerName));
|
|
65
|
+
lines.push(`from ..models import ${modelNames.join(', ')}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check response types needed
|
|
69
|
+
const needsHtml = routes.some(r => r.responseFormat === 'html');
|
|
70
|
+
const needsPlaintext = routes.some(r => r.responseFormat === 'text');
|
|
71
|
+
if (needsHtml || needsPlaintext) {
|
|
72
|
+
lines.push(`from fastapi.responses import HTMLResponse, PlainTextResponse`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push(`router = APIRouter()`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
|
|
79
|
+
for (const route of routes) {
|
|
80
|
+
lines.push('');
|
|
81
|
+
lines.push(`${PY_ROUTE_MARKER} ${route.method} ${route.path}`);
|
|
82
|
+
lines.push(this.generateEndpoint(route));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lines.push('');
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private generateEndpoint(route: RouteDescriptor): string {
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
const method = route.method.toLowerCase();
|
|
92
|
+
const funcName = toSnakeCase(route.handlerName);
|
|
93
|
+
const fastapiPath = toFastAPIPath(route.path);
|
|
94
|
+
|
|
95
|
+
// Response class for non-JSON
|
|
96
|
+
const extraArgs: string[] = [];
|
|
97
|
+
if (route.responseFormat === 'html') {
|
|
98
|
+
extraArgs.push('response_class=HTMLResponse');
|
|
99
|
+
} else if (route.responseFormat === 'text') {
|
|
100
|
+
extraArgs.push('response_class=PlainTextResponse');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const argsStr = extraArgs.length > 0 ? `, ${extraArgs.join(', ')}` : '';
|
|
104
|
+
lines.push(`@router.${method}('${fastapiPath}'${argsStr})`);
|
|
105
|
+
|
|
106
|
+
// Function signature with typed params
|
|
107
|
+
const params: string[] = [];
|
|
108
|
+
for (const param of route.pathParams) {
|
|
109
|
+
params.push(`${param}: str`);
|
|
110
|
+
}
|
|
111
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
112
|
+
for (const qp of route.queryParams) {
|
|
113
|
+
params.push(`${qp.name}: str = Query(...)`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
route.requestBody &&
|
|
118
|
+
route.requestBody.length > 0 &&
|
|
119
|
+
['POST', 'PUT', 'PATCH'].includes(route.method)
|
|
120
|
+
) {
|
|
121
|
+
params.push(`body: ${modelName(route.handlerName)}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
lines.push(`async def ${funcName}(${params.join(', ')}):`);
|
|
125
|
+
lines.push(` ${PY_USER_START}`);
|
|
126
|
+
|
|
127
|
+
// TODO block
|
|
128
|
+
lines.push(` # TODO: Implement ${route.method} ${route.path}`);
|
|
129
|
+
if (route.pathParams.length > 0) {
|
|
130
|
+
lines.push(` # Path params: ${route.pathParams.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
133
|
+
const fields = route.requestBody
|
|
134
|
+
.map(f => `${f.name}: ${pythonType(f.type)}${f.required ? '' : ' (optional)'}`)
|
|
135
|
+
.join(', ');
|
|
136
|
+
lines.push(` # Expected body: { ${fields} }`);
|
|
137
|
+
}
|
|
138
|
+
for (const note of route.notes) {
|
|
139
|
+
lines.push(` # Note: ${note}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Response stub
|
|
143
|
+
if (route.responseFormat === 'json') {
|
|
144
|
+
lines.push(` return {'message': 'Not implemented'}`);
|
|
145
|
+
} else if (route.responseFormat === 'html') {
|
|
146
|
+
lines.push(` return '<div>Not implemented</div>'`);
|
|
147
|
+
} else {
|
|
148
|
+
lines.push(` return 'Not implemented'`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
lines.push(` ${PY_USER_END}`);
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private generateMain(groups: Map<string, RouteDescriptor[]>): string {
|
|
156
|
+
const lines: string[] = [];
|
|
157
|
+
|
|
158
|
+
lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
|
|
159
|
+
lines.push(`from fastapi import FastAPI`);
|
|
160
|
+
|
|
161
|
+
for (const groupKey of groups.keys()) {
|
|
162
|
+
const pyName = groupKey.replace(/-/g, '_');
|
|
163
|
+
lines.push(`from .routers.${pyName} import router as ${pyName}_router`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push('app = FastAPI()');
|
|
168
|
+
lines.push('');
|
|
169
|
+
|
|
170
|
+
for (const groupKey of groups.keys()) {
|
|
171
|
+
const pyName = groupKey.replace(/-/g, '_');
|
|
172
|
+
lines.push(`app.include_router(${pyName}_router)`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lines.push('');
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private generateModels(routes: RouteDescriptor[]): string {
|
|
180
|
+
const lines: string[] = [];
|
|
181
|
+
|
|
182
|
+
lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
|
|
183
|
+
lines.push(`from pydantic import BaseModel`);
|
|
184
|
+
lines.push(`from typing import Optional`);
|
|
185
|
+
lines.push('');
|
|
186
|
+
|
|
187
|
+
for (const route of routes) {
|
|
188
|
+
if (!route.requestBody || route.requestBody.length === 0) continue;
|
|
189
|
+
|
|
190
|
+
lines.push('');
|
|
191
|
+
lines.push(`class ${modelName(route.handlerName)}(BaseModel):`);
|
|
192
|
+
|
|
193
|
+
for (const field of route.requestBody) {
|
|
194
|
+
const pyType = pythonType(field.type);
|
|
195
|
+
if (field.required) {
|
|
196
|
+
lines.push(` ${field.name}: ${pyType}`);
|
|
197
|
+
} else {
|
|
198
|
+
lines.push(` ${field.name}: Optional[${pyType}] = None`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
lines.push('');
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Convert camelCase handler name to snake_case for Python.
|
|
210
|
+
*/
|
|
211
|
+
function toSnakeCase(str: string): string {
|
|
212
|
+
return str
|
|
213
|
+
.replace(/([A-Z])/g, '_$1')
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
.replace(/^_/, '');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convert Express-style :param path to FastAPI {param} path.
|
|
220
|
+
* "/api/users/:id" → "/api/users/{id}"
|
|
221
|
+
*/
|
|
222
|
+
function toFastAPIPath(path: string): string {
|
|
223
|
+
return path.replace(/:(\w+)/g, '{$1}');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generate a Pydantic model name from handler name.
|
|
228
|
+
* "postApiUsers" → "PostApiUsersBody"
|
|
229
|
+
*/
|
|
230
|
+
function modelName(handlerName: string): string {
|
|
231
|
+
return handlerName.charAt(0).toUpperCase() + handlerName.slice(1) + 'Body';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Map our field types to Python type hints.
|
|
236
|
+
*/
|
|
237
|
+
function pythonType(type: string): string {
|
|
238
|
+
switch (type) {
|
|
239
|
+
case 'number':
|
|
240
|
+
return 'int';
|
|
241
|
+
case 'boolean':
|
|
242
|
+
return 'bool';
|
|
243
|
+
case 'file':
|
|
244
|
+
return 'bytes';
|
|
245
|
+
default:
|
|
246
|
+
return 'str';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { RouteDescriptor, GeneratorOptions, GenerateResult, GeneratedFile } from '../types.js';
|
|
2
|
+
import type { RouteGenerator } from './types.js';
|
|
3
|
+
import {
|
|
4
|
+
routeMarker,
|
|
5
|
+
todoBlock,
|
|
6
|
+
USER_START,
|
|
7
|
+
USER_END,
|
|
8
|
+
groupRoutesByPrefix,
|
|
9
|
+
groupToFilename,
|
|
10
|
+
} from './template-helpers.js';
|
|
11
|
+
|
|
12
|
+
export class HonoGenerator implements RouteGenerator {
|
|
13
|
+
readonly framework = 'hono';
|
|
14
|
+
|
|
15
|
+
generate(routes: RouteDescriptor[], options: GeneratorOptions): GenerateResult {
|
|
16
|
+
const ts = options.typescript !== false;
|
|
17
|
+
const files: GeneratedFile[] = [];
|
|
18
|
+
const warnings: string[] = [];
|
|
19
|
+
|
|
20
|
+
if (routes.length === 0) {
|
|
21
|
+
warnings.push('No routes to generate');
|
|
22
|
+
return { files, preserved: [], warnings };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const groups = groupRoutesByPrefix(routes);
|
|
26
|
+
|
|
27
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
28
|
+
const filename = groupToFilename(groupKey, ts);
|
|
29
|
+
const content = this.generateRouteFile(groupRoutes, groupKey, ts);
|
|
30
|
+
files.push({ path: `routes/${filename}`, content, isNew: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const indexContent = this.generateIndex(groups, ts);
|
|
34
|
+
files.push({ path: `routes/index.${ts ? 'ts' : 'js'}`, content: indexContent, isNew: true });
|
|
35
|
+
|
|
36
|
+
return { files, preserved: [], warnings };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private generateRouteFile(routes: RouteDescriptor[], groupKey: string, ts: boolean): string {
|
|
40
|
+
const lines: string[] = [];
|
|
41
|
+
|
|
42
|
+
lines.push(`// Generated by @hyperfixi/server-bridge — DO NOT EDIT scaffold markers`);
|
|
43
|
+
lines.push(`import { Hono } from 'hono';`);
|
|
44
|
+
lines.push('');
|
|
45
|
+
lines.push('const app = new Hono();');
|
|
46
|
+
lines.push('');
|
|
47
|
+
|
|
48
|
+
for (const route of routes) {
|
|
49
|
+
lines.push(routeMarker(route));
|
|
50
|
+
lines.push(this.generateHandler(route, ts));
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
lines.push('export default app;');
|
|
55
|
+
lines.push('');
|
|
56
|
+
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private generateHandler(route: RouteDescriptor, ts: boolean): string {
|
|
61
|
+
const method = route.method.toLowerCase();
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
|
|
64
|
+
lines.push(`app.${method}('${route.path}', async (c) => {`);
|
|
65
|
+
lines.push(` ${USER_START}`);
|
|
66
|
+
lines.push(todoBlock(route, ' '));
|
|
67
|
+
|
|
68
|
+
// Destructure path params
|
|
69
|
+
if (route.pathParams.length > 0) {
|
|
70
|
+
for (const param of route.pathParams) {
|
|
71
|
+
lines.push(` const ${param} = c.req.param('${param}');`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Extract query params
|
|
76
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
77
|
+
for (const param of route.queryParams) {
|
|
78
|
+
lines.push(` const ${param.name} = c.req.query('${param.name}');`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Parse body for POST/PUT/PATCH
|
|
83
|
+
if (
|
|
84
|
+
route.requestBody &&
|
|
85
|
+
route.requestBody.length > 0 &&
|
|
86
|
+
['POST', 'PUT', 'PATCH'].includes(route.method)
|
|
87
|
+
) {
|
|
88
|
+
lines.push(` const body = await c.req.json();`);
|
|
89
|
+
const fieldList = route.requestBody.map(f => f.name).join(', ');
|
|
90
|
+
lines.push(` const { ${fieldList} } = body;`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Response stub
|
|
94
|
+
if (route.responseFormat === 'json') {
|
|
95
|
+
lines.push(` return c.json({ message: 'Not implemented' });`);
|
|
96
|
+
} else if (route.responseFormat === 'html') {
|
|
97
|
+
lines.push(` return c.html('<div>Not implemented</div>');`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(` return c.text('Not implemented');`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines.push(` ${USER_END}`);
|
|
103
|
+
lines.push(`});`);
|
|
104
|
+
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private generateIndex(groups: Map<string, RouteDescriptor[]>, ts: boolean): string {
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
|
|
111
|
+
lines.push(`// Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
|
|
112
|
+
lines.push(`import { Hono } from 'hono';`);
|
|
113
|
+
|
|
114
|
+
for (const groupKey of groups.keys()) {
|
|
115
|
+
const filename = groupKey;
|
|
116
|
+
lines.push(`import ${toCamelCase(groupKey)}Routes from './${filename}.js';`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('const app = new Hono();');
|
|
121
|
+
lines.push('');
|
|
122
|
+
|
|
123
|
+
for (const groupKey of groups.keys()) {
|
|
124
|
+
lines.push(`app.route('/', ${toCamelCase(groupKey)}Routes);`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('export default app;');
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toCamelCase(str: string): string {
|
|
136
|
+
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
137
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ExpressGenerator } from './express-generator.js';
|
|
2
|
+
import { HonoGenerator } from './hono-generator.js';
|
|
3
|
+
import { OpenAPIGenerator } from './openapi-generator.js';
|
|
4
|
+
import { DjangoGenerator } from './django-generator.js';
|
|
5
|
+
import { FastAPIGenerator } from './fastapi-generator.js';
|
|
6
|
+
import type { RouteGenerator } from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Select the appropriate RouteGenerator for a framework name.
|
|
10
|
+
* Falls back to ExpressGenerator for unrecognized values.
|
|
11
|
+
*/
|
|
12
|
+
export function selectGenerator(framework: string): RouteGenerator {
|
|
13
|
+
switch (framework) {
|
|
14
|
+
case 'hono':
|
|
15
|
+
return new HonoGenerator();
|
|
16
|
+
case 'openapi':
|
|
17
|
+
return new OpenAPIGenerator();
|
|
18
|
+
case 'django':
|
|
19
|
+
return new DjangoGenerator();
|
|
20
|
+
case 'fastapi':
|
|
21
|
+
return new FastAPIGenerator();
|
|
22
|
+
default:
|
|
23
|
+
return new ExpressGenerator();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import type { RouteDescriptor } from '../types.js';
|
|
5
|
+
import { USER_START, USER_END } from './template-helpers.js';
|
|
6
|
+
|
|
7
|
+
const MANIFEST_FILENAME = 'server-bridge.manifest.json';
|
|
8
|
+
|
|
9
|
+
export interface ManifestEntry {
|
|
10
|
+
method: string;
|
|
11
|
+
path: string;
|
|
12
|
+
handlerName: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Manifest {
|
|
17
|
+
version: number;
|
|
18
|
+
generatedAt: string;
|
|
19
|
+
routes: Record<string, ManifestEntry>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a hash for a RouteDescriptor to detect changes.
|
|
24
|
+
*/
|
|
25
|
+
export function hashRoute(route: RouteDescriptor): string {
|
|
26
|
+
const data = JSON.stringify({
|
|
27
|
+
path: route.path,
|
|
28
|
+
method: route.method,
|
|
29
|
+
responseFormat: route.responseFormat,
|
|
30
|
+
pathParams: route.pathParams,
|
|
31
|
+
queryParams: route.queryParams,
|
|
32
|
+
requestBody: route.requestBody,
|
|
33
|
+
});
|
|
34
|
+
return createHash('md5').update(data).digest('hex').slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a manifest key from method + path.
|
|
39
|
+
*/
|
|
40
|
+
function manifestKey(method: string, path: string): string {
|
|
41
|
+
return `${method}:${path}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a new manifest from a set of routes.
|
|
46
|
+
*/
|
|
47
|
+
export function createManifest(routes: RouteDescriptor[]): Manifest {
|
|
48
|
+
const manifest: Manifest = {
|
|
49
|
+
version: 1,
|
|
50
|
+
generatedAt: new Date().toISOString(),
|
|
51
|
+
routes: {},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
for (const route of routes) {
|
|
55
|
+
const key = manifestKey(route.method, route.path);
|
|
56
|
+
manifest.routes[key] = {
|
|
57
|
+
method: route.method,
|
|
58
|
+
path: route.path,
|
|
59
|
+
handlerName: route.handlerName,
|
|
60
|
+
hash: hashRoute(route),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return manifest;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load an existing manifest from the output directory.
|
|
69
|
+
* Returns null if no manifest exists.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadManifest(outputDir: string): Promise<Manifest | null> {
|
|
72
|
+
try {
|
|
73
|
+
const content = await readFile(join(outputDir, MANIFEST_FILENAME), 'utf-8');
|
|
74
|
+
return JSON.parse(content) as Manifest;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Save a manifest to the output directory.
|
|
82
|
+
*/
|
|
83
|
+
export async function saveManifest(outputDir: string, manifest: Manifest): Promise<void> {
|
|
84
|
+
await writeFile(
|
|
85
|
+
join(outputDir, MANIFEST_FILENAME),
|
|
86
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
87
|
+
'utf-8'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract user-written code between @serverbridge-user-start and @serverbridge-user-end markers.
|
|
93
|
+
* Returns a map from route key (METHOD:path) to user code string.
|
|
94
|
+
*/
|
|
95
|
+
export function extractUserCode(fileContent: string): Map<string, string> {
|
|
96
|
+
const result = new Map<string, string>();
|
|
97
|
+
|
|
98
|
+
// Find each route marker followed by a user code block
|
|
99
|
+
const routeMarkerPattern = /\/\/ @serverbridge-route:\s+(\w+)\s+(\/\S+)/g;
|
|
100
|
+
let match;
|
|
101
|
+
while ((match = routeMarkerPattern.exec(fileContent)) !== null) {
|
|
102
|
+
const method = match[1];
|
|
103
|
+
const path = match[2];
|
|
104
|
+
const key = manifestKey(method, path);
|
|
105
|
+
|
|
106
|
+
// Find the user-start/user-end block after this marker
|
|
107
|
+
const afterMarker = fileContent.slice(match.index);
|
|
108
|
+
const startIdx = afterMarker.indexOf(USER_START);
|
|
109
|
+
const endIdx = afterMarker.indexOf(USER_END);
|
|
110
|
+
|
|
111
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
112
|
+
const userCode = afterMarker.slice(
|
|
113
|
+
startIdx + USER_START.length + 1, // +1 for newline
|
|
114
|
+
endIdx
|
|
115
|
+
);
|
|
116
|
+
// Only save if it differs from the default TODO
|
|
117
|
+
if (!userCode.includes('// TODO: Implement')) {
|
|
118
|
+
result.set(key, userCode);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Determine what changed between old and new route sets.
|
|
128
|
+
*/
|
|
129
|
+
export function diffRoutes(
|
|
130
|
+
oldManifest: Manifest | null,
|
|
131
|
+
newRoutes: RouteDescriptor[]
|
|
132
|
+
): {
|
|
133
|
+
added: RouteDescriptor[];
|
|
134
|
+
removed: ManifestEntry[];
|
|
135
|
+
changed: RouteDescriptor[];
|
|
136
|
+
unchanged: RouteDescriptor[];
|
|
137
|
+
} {
|
|
138
|
+
const oldRoutes = oldManifest?.routes ?? {};
|
|
139
|
+
|
|
140
|
+
const added: RouteDescriptor[] = [];
|
|
141
|
+
const changed: RouteDescriptor[] = [];
|
|
142
|
+
const unchanged: RouteDescriptor[] = [];
|
|
143
|
+
|
|
144
|
+
for (const route of newRoutes) {
|
|
145
|
+
const key = manifestKey(route.method, route.path);
|
|
146
|
+
const existing = oldRoutes[key];
|
|
147
|
+
|
|
148
|
+
if (!existing) {
|
|
149
|
+
added.push(route);
|
|
150
|
+
} else if (existing.hash !== hashRoute(route)) {
|
|
151
|
+
changed.push(route);
|
|
152
|
+
} else {
|
|
153
|
+
unchanged.push(route);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Find removed routes
|
|
158
|
+
const newKeys = new Set(newRoutes.map(r => manifestKey(r.method, r.path)));
|
|
159
|
+
const removed = Object.values(oldRoutes).filter(
|
|
160
|
+
entry => !newKeys.has(manifestKey(entry.method, entry.path))
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return { added, removed, changed, unchanged };
|
|
164
|
+
}
|