@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,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
+ }