@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.
Files changed (77) hide show
  1. package/LICENSE +20 -0
  2. package/dist/cli/config.d.ts +5 -0
  3. package/dist/cli/diff-command.d.ts +9 -0
  4. package/dist/cli/generate-command.d.ts +12 -0
  5. package/dist/cli/index.d.ts +2 -0
  6. package/dist/cli/scan-command.d.ts +9 -0
  7. package/dist/cli/watch-command.d.ts +30 -0
  8. package/dist/cli.cjs +1853 -0
  9. package/dist/cli.cjs.map +1 -0
  10. package/dist/conventions/convention-engine.d.ts +29 -0
  11. package/dist/conventions/url-patterns.d.ts +37 -0
  12. package/dist/generate.d.ts +21 -0
  13. package/dist/generators/django-generator.d.ts +10 -0
  14. package/dist/generators/express-generator.d.ts +9 -0
  15. package/dist/generators/fastapi-generator.d.ts +10 -0
  16. package/dist/generators/hono-generator.d.ts +9 -0
  17. package/dist/generators/index.d.ts +6 -0
  18. package/dist/generators/manifest.d.ts +43 -0
  19. package/dist/generators/openapi-generator.d.ts +14 -0
  20. package/dist/generators/template-helpers.d.ts +24 -0
  21. package/dist/generators/types.d.ts +11 -0
  22. package/dist/index.cjs +1426 -0
  23. package/dist/index.cjs.map +1 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +1376 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/scanner/conflict-detector.d.ts +10 -0
  28. package/dist/scanner/form-scanner.d.ts +12 -0
  29. package/dist/scanner/htmx-extractor.d.ts +9 -0
  30. package/dist/scanner/hyperscript-extractor.d.ts +9 -0
  31. package/dist/scanner/route-scanner.d.ts +21 -0
  32. package/dist/types.d.ts +123 -0
  33. package/package.json +64 -0
  34. package/src/__tests__/cli.test.ts +30 -0
  35. package/src/__tests__/conflict-detector.test.ts +126 -0
  36. package/src/__tests__/convention-engine.test.ts +58 -0
  37. package/src/__tests__/diff-command.test.ts +69 -0
  38. package/src/__tests__/django-generator.test.ts +129 -0
  39. package/src/__tests__/express-generator.test.ts +105 -0
  40. package/src/__tests__/fastapi-generator.test.ts +125 -0
  41. package/src/__tests__/form-scanner.test.ts +91 -0
  42. package/src/__tests__/generate.test.ts +89 -0
  43. package/src/__tests__/hono-generator.test.ts +90 -0
  44. package/src/__tests__/htmx-extractor.test.ts +84 -0
  45. package/src/__tests__/hyperscript-extractor.test.ts +89 -0
  46. package/src/__tests__/integration.test.ts +102 -0
  47. package/src/__tests__/manifest.test.ts +133 -0
  48. package/src/__tests__/openapi-generator.test.ts +116 -0
  49. package/src/__tests__/query-params.test.ts +101 -0
  50. package/src/__tests__/route-scanner.test.ts +47 -0
  51. package/src/__tests__/url-patterns.test.ts +74 -0
  52. package/src/__tests__/watch-command.test.ts +141 -0
  53. package/src/cli/config.ts +35 -0
  54. package/src/cli/diff-command.ts +96 -0
  55. package/src/cli/generate-command.ts +108 -0
  56. package/src/cli/index.ts +143 -0
  57. package/src/cli/scan-command.ts +72 -0
  58. package/src/cli/watch-command.ts +242 -0
  59. package/src/conventions/convention-engine.ts +68 -0
  60. package/src/conventions/url-patterns.ts +123 -0
  61. package/src/generate.ts +44 -0
  62. package/src/generators/django-generator.ts +210 -0
  63. package/src/generators/express-generator.ts +137 -0
  64. package/src/generators/fastapi-generator.ts +248 -0
  65. package/src/generators/hono-generator.ts +137 -0
  66. package/src/generators/index.ts +25 -0
  67. package/src/generators/manifest.ts +164 -0
  68. package/src/generators/openapi-generator.ts +259 -0
  69. package/src/generators/template-helpers.ts +77 -0
  70. package/src/generators/types.ts +12 -0
  71. package/src/index.ts +42 -0
  72. package/src/scanner/conflict-detector.ts +92 -0
  73. package/src/scanner/form-scanner.ts +126 -0
  74. package/src/scanner/htmx-extractor.ts +174 -0
  75. package/src/scanner/hyperscript-extractor.ts +184 -0
  76. package/src/scanner/route-scanner.ts +130 -0
  77. package/src/types.ts +151 -0
@@ -0,0 +1,123 @@
1
+ import type { HttpMethod, QueryParam } from '../types.js';
2
+
3
+ /**
4
+ * Extract path parameter names from a URL pattern.
5
+ * Handles both `:param` and `{param}` styles.
6
+ *
7
+ * @example
8
+ * extractPathParams('/api/users/:id') // ['id']
9
+ * extractPathParams('/api/users/{id}/orders/{orderId}') // ['id', 'orderId']
10
+ */
11
+ export function extractPathParams(url: string): string[] {
12
+ const params: string[] = [];
13
+ // Match :param style
14
+ for (const match of url.matchAll(/:(\w+)/g)) {
15
+ params.push(match[1]);
16
+ }
17
+ // Match {param} style
18
+ for (const match of url.matchAll(/\{(\w+)\}/g)) {
19
+ if (!params.includes(match[1])) {
20
+ params.push(match[1]);
21
+ }
22
+ }
23
+ return params;
24
+ }
25
+
26
+ /**
27
+ * Normalize a URL path:
28
+ * - Convert `{param}` to `:param`
29
+ * - Strip trailing slashes
30
+ * - Ensure leading slash
31
+ * - Strip query strings
32
+ * - Strip fragments
33
+ */
34
+ export function normalizeUrl(url: string): string {
35
+ // Strip query string and fragment
36
+ let normalized = url.split('?')[0].split('#')[0];
37
+
38
+ // Convert {param} to :param
39
+ normalized = normalized.replace(/\{(\w+)\}/g, ':$1');
40
+
41
+ // Ensure leading slash
42
+ if (!normalized.startsWith('/')) {
43
+ normalized = '/' + normalized;
44
+ }
45
+
46
+ // Strip trailing slash (but keep root /)
47
+ if (normalized.length > 1 && normalized.endsWith('/')) {
48
+ normalized = normalized.slice(0, -1);
49
+ }
50
+
51
+ return normalized;
52
+ }
53
+
54
+ /**
55
+ * Convert a URL path + method into a handler function name.
56
+ *
57
+ * @example
58
+ * urlToHandlerName('/api/users', 'GET') // 'getApiUsers'
59
+ * urlToHandlerName('/api/users/:id', 'POST') // 'postApiUsersById'
60
+ */
61
+ export function urlToHandlerName(url: string, method: HttpMethod): string {
62
+ const prefix = method.toLowerCase();
63
+
64
+ // Remove leading slash, split by / and :
65
+ const segments = url
66
+ .replace(/^\//, '')
67
+ .split('/')
68
+ .map(segment => {
69
+ if (segment.startsWith(':')) {
70
+ // :id -> ById, :userId -> ByUserId
71
+ const param = segment.slice(1);
72
+ return 'By' + capitalize(param);
73
+ }
74
+ // api -> Api, user-list -> UserList
75
+ return segment.split(/[-_]/).map(capitalize).join('');
76
+ });
77
+
78
+ return prefix + segments.join('');
79
+ }
80
+
81
+ /**
82
+ * Extract query parameter names from a URL string.
83
+ * Must be called BEFORE normalizeUrl (which strips query strings).
84
+ *
85
+ * @example
86
+ * extractQueryParams('/api/search?q=hello&limit=10')
87
+ * // [{ name: 'q', type: 'string', required: true },
88
+ * // { name: 'limit', type: 'string', required: true }]
89
+ */
90
+ export function extractQueryParams(url: string): QueryParam[] {
91
+ const queryIndex = url.indexOf('?');
92
+ if (queryIndex === -1) return [];
93
+
94
+ const queryString = url.slice(queryIndex + 1).split('#')[0];
95
+ if (!queryString) return [];
96
+
97
+ const params: QueryParam[] = [];
98
+ const seen = new Set<string>();
99
+
100
+ for (const pair of queryString.split('&')) {
101
+ const eqIndex = pair.indexOf('=');
102
+ const rawName = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
103
+ if (!rawName) continue;
104
+
105
+ let name: string;
106
+ try {
107
+ name = decodeURIComponent(rawName);
108
+ } catch {
109
+ name = rawName;
110
+ }
111
+ if (seen.has(name)) continue;
112
+ seen.add(name);
113
+
114
+ params.push({ name, type: 'string', required: true });
115
+ }
116
+
117
+ return params;
118
+ }
119
+
120
+ function capitalize(s: string): string {
121
+ if (!s) return '';
122
+ return s[0].toUpperCase() + s.slice(1);
123
+ }
@@ -0,0 +1,44 @@
1
+ import { resolve } from 'node:path';
2
+ import { scanDirectory } from './scanner/route-scanner.js';
3
+ import { selectGenerator } from './generators/index.js';
4
+ import type { GenerateResult, RouteDescriptor, ScanResult } from './types.js';
5
+
6
+ export interface ProgrammaticGenerateOptions {
7
+ dir: string;
8
+ framework?: 'express' | 'hono' | 'openapi' | 'django' | 'fastapi';
9
+ output?: string;
10
+ typescript?: boolean;
11
+ include?: string[];
12
+ exclude?: string[];
13
+ ignore?: string[];
14
+ }
15
+
16
+ export interface GenerateFullResult {
17
+ scan: ScanResult;
18
+ generated: GenerateResult;
19
+ routes: RouteDescriptor[];
20
+ }
21
+
22
+ /**
23
+ * Programmatic scan + generate. Scans a directory for routes and generates
24
+ * framework-specific route files. Returns structured results for the caller
25
+ * to write to disk.
26
+ */
27
+ export async function generate(options: ProgrammaticGenerateOptions): Promise<GenerateFullResult> {
28
+ const cwd = resolve(options.dir);
29
+ const outputDir = resolve(cwd, options.output ?? './server/routes');
30
+
31
+ const scan = await scanDirectory(cwd, {
32
+ include: options.include,
33
+ exclude: options.exclude,
34
+ ignore: options.ignore,
35
+ });
36
+
37
+ const generator = selectGenerator(options.framework ?? 'express');
38
+ const generated = generator.generate(scan.routes, {
39
+ outputDir,
40
+ typescript: options.typescript,
41
+ });
42
+
43
+ return { scan, generated, routes: scan.routes };
44
+ }
@@ -0,0 +1,210 @@
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 DjangoGenerator implements RouteGenerator {
10
+ readonly framework = 'django';
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
+ // Generate views.py and urls.py per group
24
+ for (const [groupKey, groupRoutes] of groups) {
25
+ const views = this.generateViews(groupRoutes, groupKey);
26
+ files.push({ path: `${groupKey}/views.py`, content: views, isNew: true });
27
+
28
+ const urls = this.generateUrls(groupRoutes, groupKey);
29
+ files.push({ path: `${groupKey}/urls.py`, content: urls, isNew: true });
30
+
31
+ // Empty __init__.py for Python package
32
+ files.push({ path: `${groupKey}/__init__.py`, content: '', isNew: true });
33
+ }
34
+
35
+ // Root urls.py that includes all groups
36
+ const rootUrls = this.generateRootUrls(groups);
37
+ files.push({ path: 'urls.py', content: rootUrls, isNew: true });
38
+
39
+ return { files, preserved: [], warnings };
40
+ }
41
+
42
+ private generateViews(routes: RouteDescriptor[], groupKey: string): string {
43
+ const lines: string[] = [];
44
+
45
+ lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT scaffold markers`);
46
+ lines.push(`from django.http import JsonResponse, HttpResponse`);
47
+
48
+ // Check if any route needs request body parsing
49
+ const needsJson = routes.some(
50
+ r => r.requestBody && r.requestBody.length > 0 && ['POST', 'PUT', 'PATCH'].includes(r.method)
51
+ );
52
+ if (needsJson) {
53
+ lines.push(`import json`);
54
+ }
55
+
56
+ // Check if we need decorators
57
+ const hasNonGet = routes.some(r => r.method !== 'GET');
58
+ if (hasNonGet) {
59
+ lines.push(`from django.views.decorators.http import require_http_methods`);
60
+ }
61
+
62
+ lines.push('');
63
+
64
+ for (const route of routes) {
65
+ lines.push('');
66
+ lines.push(`${PY_ROUTE_MARKER} ${route.method} ${route.path}`);
67
+ lines.push(this.generateView(route));
68
+ }
69
+
70
+ lines.push('');
71
+ return lines.join('\n');
72
+ }
73
+
74
+ private generateView(route: RouteDescriptor): string {
75
+ const lines: string[] = [];
76
+ const funcName = toSnakeCase(route.handlerName);
77
+
78
+ // Method decorator for non-GET routes
79
+ if (route.method !== 'GET') {
80
+ lines.push(`@require_http_methods(["${route.method}"])`);
81
+ }
82
+
83
+ // Function signature with path params
84
+ const params = ['request', ...route.pathParams];
85
+ lines.push(`def ${funcName}(${params.join(', ')}):`);
86
+ lines.push(` ${PY_USER_START}`);
87
+
88
+ // TODO block
89
+ lines.push(` # TODO: Implement ${route.method} ${route.path}`);
90
+ if (route.pathParams.length > 0) {
91
+ lines.push(` # Path params: ${route.pathParams.join(', ')}`);
92
+ }
93
+ if (route.requestBody && route.requestBody.length > 0) {
94
+ const fields = route.requestBody
95
+ .map(f => `${f.name}: ${pythonType(f.type)}${f.required ? '' : ' (optional)'}`)
96
+ .join(', ');
97
+ lines.push(` # Expected body: { ${fields} }`);
98
+ }
99
+ for (const note of route.notes) {
100
+ lines.push(` # Note: ${note}`);
101
+ }
102
+
103
+ // Extract query params
104
+ if (route.queryParams && route.queryParams.length > 0) {
105
+ for (const param of route.queryParams) {
106
+ lines.push(` ${param.name} = request.GET.get('${param.name}')`);
107
+ }
108
+ }
109
+
110
+ // Parse body for POST/PUT/PATCH
111
+ if (
112
+ route.requestBody &&
113
+ route.requestBody.length > 0 &&
114
+ ['POST', 'PUT', 'PATCH'].includes(route.method)
115
+ ) {
116
+ lines.push(` data = json.loads(request.body)`);
117
+ for (const field of route.requestBody) {
118
+ const default_ = field.required ? '' : ', None';
119
+ lines.push(` ${field.name} = data.get('${field.name}'${default_})`);
120
+ }
121
+ }
122
+
123
+ // Response stub
124
+ if (route.responseFormat === 'json') {
125
+ lines.push(` return JsonResponse({'message': 'Not implemented'})`);
126
+ } else if (route.responseFormat === 'html') {
127
+ lines.push(` return HttpResponse('<div>Not implemented</div>')`);
128
+ } else {
129
+ lines.push(` return HttpResponse('Not implemented', content_type='text/plain')`);
130
+ }
131
+
132
+ lines.push(` ${PY_USER_END}`);
133
+ return lines.join('\n');
134
+ }
135
+
136
+ private generateUrls(routes: RouteDescriptor[], groupKey: string): string {
137
+ const lines: string[] = [];
138
+
139
+ lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
140
+ lines.push(`from django.urls import path`);
141
+ lines.push(`from . import views`);
142
+ lines.push('');
143
+ lines.push('urlpatterns = [');
144
+
145
+ for (const route of routes) {
146
+ const djangoPath = toDjangoPath(route.path);
147
+ const funcName = toSnakeCase(route.handlerName);
148
+ lines.push(` path('${djangoPath}', views.${funcName}, name='${funcName}'),`);
149
+ }
150
+
151
+ lines.push(']');
152
+ lines.push('');
153
+
154
+ return lines.join('\n');
155
+ }
156
+
157
+ private generateRootUrls(groups: Map<string, RouteDescriptor[]>): string {
158
+ const lines: string[] = [];
159
+
160
+ lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
161
+ lines.push(`from django.urls import path, include`);
162
+ lines.push('');
163
+ lines.push('urlpatterns = [');
164
+
165
+ for (const groupKey of groups.keys()) {
166
+ lines.push(` path('', include('${groupKey}.urls')),`);
167
+ }
168
+
169
+ lines.push(']');
170
+ lines.push('');
171
+
172
+ return lines.join('\n');
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Convert camelCase handler name to snake_case for Python.
178
+ */
179
+ function toSnakeCase(str: string): string {
180
+ return str
181
+ .replace(/([A-Z])/g, '_$1')
182
+ .toLowerCase()
183
+ .replace(/^_/, '');
184
+ }
185
+
186
+ /**
187
+ * Convert Express-style :param path to Django <param> path.
188
+ * "/api/users/:id" → "api/users/<int:id>"
189
+ */
190
+ function toDjangoPath(path: string): string {
191
+ return path
192
+ .replace(/^\//, '') // Strip leading slash (Django convention)
193
+ .replace(/:(\w+)/g, '<str:$1>'); // :param → <str:param>
194
+ }
195
+
196
+ /**
197
+ * Map our field types to Python type hints.
198
+ */
199
+ function pythonType(type: string): string {
200
+ switch (type) {
201
+ case 'number':
202
+ return 'int';
203
+ case 'boolean':
204
+ return 'bool';
205
+ case 'file':
206
+ return 'UploadedFile';
207
+ default:
208
+ return 'str';
209
+ }
210
+ }
@@ -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 ExpressGenerator implements RouteGenerator {
13
+ readonly framework = 'express';
14
+
15
+ generate(routes: RouteDescriptor[], options: GeneratorOptions): GenerateResult {
16
+ const ts = options.typescript !== false; // default true
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
+ // Generate one router file per group
28
+ for (const [groupKey, groupRoutes] of groups) {
29
+ const filename = groupToFilename(groupKey, ts);
30
+ const content = this.generateRouterFile(groupRoutes, groupKey, ts);
31
+ files.push({ path: `routes/${filename}`, content, isNew: true });
32
+ }
33
+
34
+ // Generate index file that imports all routers
35
+ const indexContent = this.generateIndex(groups, ts);
36
+ files.push({ path: `routes/index.${ts ? 'ts' : 'js'}`, content: indexContent, isNew: true });
37
+
38
+ return { files, preserved: [], warnings };
39
+ }
40
+
41
+ private generateRouterFile(routes: RouteDescriptor[], groupKey: string, ts: boolean): string {
42
+ const lines: string[] = [];
43
+
44
+ lines.push(`// Generated by @hyperfixi/server-bridge — DO NOT EDIT scaffold markers`);
45
+ lines.push(`import { Router${ts ? ', Request, Response' : ''} } from 'express';`);
46
+ lines.push('');
47
+ lines.push('const router = Router();');
48
+ lines.push('');
49
+
50
+ for (const route of routes) {
51
+ lines.push(routeMarker(route));
52
+ lines.push(this.generateHandler(route, ts));
53
+ lines.push('');
54
+ }
55
+
56
+ lines.push('export default router;');
57
+ lines.push('');
58
+
59
+ return lines.join('\n');
60
+ }
61
+
62
+ private generateHandler(route: RouteDescriptor, ts: boolean): string {
63
+ const method = route.method.toLowerCase();
64
+ const params = ts ? '(req: Request, res: Response)' : '(req, res)';
65
+ const lines: string[] = [];
66
+
67
+ lines.push(`router.${method}('${route.path}', async ${params} => {`);
68
+ lines.push(` ${USER_START}`);
69
+ lines.push(todoBlock(route, ' '));
70
+
71
+ // Destructure path params
72
+ if (route.pathParams.length > 0) {
73
+ const paramList = route.pathParams.join(', ');
74
+ lines.push(` const { ${paramList} } = req.params;`);
75
+ }
76
+
77
+ // Destructure query params
78
+ if (route.queryParams && route.queryParams.length > 0) {
79
+ const paramList = route.queryParams.map(p => p.name).join(', ');
80
+ lines.push(` const { ${paramList} } = req.query;`);
81
+ }
82
+
83
+ // Destructure body fields for POST/PUT/PATCH
84
+ if (
85
+ route.requestBody &&
86
+ route.requestBody.length > 0 &&
87
+ ['POST', 'PUT', 'PATCH'].includes(route.method)
88
+ ) {
89
+ const fieldList = route.requestBody.map(f => f.name).join(', ');
90
+ lines.push(` const { ${fieldList} } = req.body;`);
91
+ }
92
+
93
+ // Response stub
94
+ if (route.responseFormat === 'json') {
95
+ lines.push(` res.json({ message: 'Not implemented' });`);
96
+ } else if (route.responseFormat === 'html') {
97
+ lines.push(` res.send('<div>Not implemented</div>');`);
98
+ } else {
99
+ lines.push(` res.send('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 { Router } from 'express';`);
113
+
114
+ for (const groupKey of groups.keys()) {
115
+ const filename = groupKey;
116
+ lines.push(`import ${toCamelCase(groupKey)}Router from './${filename}.js';`);
117
+ }
118
+
119
+ lines.push('');
120
+ lines.push('const router = Router();');
121
+ lines.push('');
122
+
123
+ for (const groupKey of groups.keys()) {
124
+ lines.push(`router.use(${toCamelCase(groupKey)}Router);`);
125
+ }
126
+
127
+ lines.push('');
128
+ lines.push('export default router;');
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
+ }