@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,116 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { OpenAPIGenerator } from '../generators/openapi-generator.js';
3
+ import type { RouteDescriptor } from '../types.js';
4
+
5
+ function makeRoute(overrides: Partial<RouteDescriptor> = {}): RouteDescriptor {
6
+ return {
7
+ path: '/api/users',
8
+ method: 'GET',
9
+ responseFormat: 'json',
10
+ source: { file: 'test.html', line: 5, kind: 'fetch', raw: 'fetch /api/users' },
11
+ pathParams: [],
12
+ handlerName: 'getApiUsers',
13
+ notes: [],
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ describe('OpenAPIGenerator', () => {
19
+ const gen = new OpenAPIGenerator();
20
+
21
+ it('has correct framework name', () => {
22
+ expect(gen.framework).toBe('openapi');
23
+ });
24
+
25
+ it('generates a valid OpenAPI 3.1 spec', () => {
26
+ const result = gen.generate([makeRoute()], { outputDir: '/out' });
27
+ expect(result.files).toHaveLength(1);
28
+ expect(result.files[0].path).toBe('openapi.yaml');
29
+
30
+ const yaml = result.files[0].content;
31
+ expect(yaml).toContain("openapi: '3.1.0'");
32
+ expect(yaml).toContain('title: ServerBridge Generated API');
33
+ expect(yaml).toContain('/api/users:');
34
+ expect(yaml).toContain('get:');
35
+ expect(yaml).toContain('operationId: getApiUsers');
36
+ });
37
+
38
+ it('converts :param to {param} in paths', () => {
39
+ const route = makeRoute({
40
+ path: '/api/users/:id',
41
+ pathParams: ['id'],
42
+ handlerName: 'getApiUsersById',
43
+ });
44
+ const result = gen.generate([route], { outputDir: '/out' });
45
+ const yaml = result.files[0].content;
46
+ expect(yaml).toContain('/api/users/{id}:');
47
+ expect(yaml).toContain('name: id');
48
+ expect(yaml).toContain('in: path');
49
+ expect(yaml).toContain('required: true');
50
+ });
51
+
52
+ it('groups multiple methods under same path', () => {
53
+ const routes = [
54
+ makeRoute({ path: '/api/users', method: 'GET', handlerName: 'getApiUsers' }),
55
+ makeRoute({ path: '/api/users', method: 'POST', handlerName: 'postApiUsers' }),
56
+ ];
57
+ const result = gen.generate(routes, { outputDir: '/out' });
58
+ const yaml = result.files[0].content;
59
+
60
+ // Should have one /api/users path with both get: and post:
61
+ const pathCount = (yaml.match(/\/api\/users:/g) || []).length;
62
+ expect(pathCount).toBe(1);
63
+ expect(yaml).toContain('get:');
64
+ expect(yaml).toContain('post:');
65
+ });
66
+
67
+ it('includes request body for POST routes with fields', () => {
68
+ const route = makeRoute({
69
+ method: 'POST',
70
+ handlerName: 'postApiUsers',
71
+ requestBody: [
72
+ { name: 'name', type: 'string', required: true },
73
+ { name: 'email', type: 'string', required: true },
74
+ { name: 'age', type: 'number', required: false },
75
+ ],
76
+ });
77
+ const result = gen.generate([route], { outputDir: '/out' });
78
+ const yaml = result.files[0].content;
79
+ expect(yaml).toContain('requestBody:');
80
+ expect(yaml).toContain("required: ['name', 'email']");
81
+ expect(yaml).toContain('name:');
82
+ expect(yaml).toContain('email:');
83
+ expect(yaml).toContain('age:');
84
+ });
85
+
86
+ it('uses text/html content type for html format', () => {
87
+ const route = makeRoute({ responseFormat: 'html' });
88
+ const result = gen.generate([route], { outputDir: '/out' });
89
+ const yaml = result.files[0].content;
90
+ expect(yaml).toContain('text/html:');
91
+ });
92
+
93
+ it('includes source file in summary', () => {
94
+ const result = gen.generate([makeRoute()], { outputDir: '/out' });
95
+ const yaml = result.files[0].content;
96
+ expect(yaml).toContain("summary: 'Source: test.html:5 (fetch)'");
97
+ });
98
+
99
+ it('generates query parameter specs', () => {
100
+ const route = makeRoute({
101
+ path: '/api/search',
102
+ handlerName: 'getApiSearch',
103
+ queryParams: [{ name: 'q', type: 'string', required: true }],
104
+ });
105
+ const result = gen.generate([route], { outputDir: '/out' });
106
+ const yaml = result.files[0].content;
107
+ expect(yaml).toContain('name: q');
108
+ expect(yaml).toContain('in: query');
109
+ });
110
+
111
+ it('warns when no routes provided', () => {
112
+ const result = gen.generate([], { outputDir: '/out' });
113
+ expect(result.warnings).toContain('No routes to generate');
114
+ expect(result.files).toHaveLength(0);
115
+ });
116
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractQueryParams } from '../conventions/url-patterns.js';
3
+ import { extractHtmxRoutes } from '../scanner/htmx-extractor.js';
4
+ import { extractHyperscriptRoutes } from '../scanner/hyperscript-extractor.js';
5
+ import { scanRoutes } from '../scanner/route-scanner.js';
6
+
7
+ const FILE = 'test.html';
8
+
9
+ describe('extractQueryParams', () => {
10
+ it('extracts single query param', () => {
11
+ const params = extractQueryParams('/api/search?q=hello');
12
+ expect(params).toEqual([{ name: 'q', type: 'string', required: true }]);
13
+ });
14
+
15
+ it('extracts multiple query params', () => {
16
+ const params = extractQueryParams('/api/search?q=hello&limit=10&offset=0');
17
+ expect(params).toHaveLength(3);
18
+ expect(params.map(p => p.name)).toEqual(['q', 'limit', 'offset']);
19
+ });
20
+
21
+ it('handles URL-encoded param names', () => {
22
+ const params = extractQueryParams('/api/search?query%20text=hello');
23
+ expect(params[0].name).toBe('query text');
24
+ });
25
+
26
+ it('returns empty array for no query string', () => {
27
+ expect(extractQueryParams('/api/users')).toEqual([]);
28
+ });
29
+
30
+ it('returns empty array for empty query string', () => {
31
+ expect(extractQueryParams('/api/users?')).toEqual([]);
32
+ });
33
+
34
+ it('strips fragment after query string', () => {
35
+ const params = extractQueryParams('/api/search?q=hello#results');
36
+ expect(params).toHaveLength(1);
37
+ expect(params[0].name).toBe('q');
38
+ });
39
+
40
+ it('deduplicates repeated param names', () => {
41
+ const params = extractQueryParams('/api/search?q=hello&q=world');
42
+ expect(params).toHaveLength(1);
43
+ });
44
+
45
+ it('handles params without values', () => {
46
+ const params = extractQueryParams('/api/search?verbose&q=hello');
47
+ expect(params).toHaveLength(2);
48
+ expect(params[0].name).toBe('verbose');
49
+ expect(params[1].name).toBe('q');
50
+ });
51
+
52
+ it('handles malformed percent-encoded param names gracefully', () => {
53
+ const params = extractQueryParams('/api/search?%ZZ=hello&q=world');
54
+ expect(params).toHaveLength(2);
55
+ expect(params[0].name).toBe('%ZZ');
56
+ expect(params[1].name).toBe('q');
57
+ });
58
+ });
59
+
60
+ describe('query params in htmx extractor', () => {
61
+ it('extracts query params from hx-get URL', () => {
62
+ const html = `<div hx-get="/api/search?q=test&limit=10">`;
63
+ const routes = extractHtmxRoutes(html, { file: FILE });
64
+ expect(routes).toHaveLength(1);
65
+ expect(routes[0].path).toBe('/api/search');
66
+ expect(routes[0].queryParams).toEqual([
67
+ { name: 'q', type: 'string', required: true },
68
+ { name: 'limit', type: 'string', required: true },
69
+ ]);
70
+ });
71
+
72
+ it('omits queryParams when URL has no query string', () => {
73
+ const html = `<div hx-get="/api/users">`;
74
+ const routes = extractHtmxRoutes(html, { file: FILE });
75
+ expect(routes[0].queryParams).toBeUndefined();
76
+ });
77
+ });
78
+
79
+ describe('query params in hyperscript extractor', () => {
80
+ it('extracts query params from fetch URL', () => {
81
+ const html = `<button _="on click fetch /api/data?id=123 as json">`;
82
+ const routes = extractHyperscriptRoutes(html, { file: FILE });
83
+ expect(routes).toHaveLength(1);
84
+ expect(routes[0].path).toBe('/api/data');
85
+ expect(routes[0].queryParams).toEqual([{ name: 'id', type: 'string', required: true }]);
86
+ });
87
+ });
88
+
89
+ describe('query params in scanRoutes', () => {
90
+ it('preserves query params through full scan pipeline', () => {
91
+ const html = `
92
+ <div hx-get="/api/search?q=test&limit=10" hx-target="#results">
93
+ <button _="on click fetch /api/data?format=csv as json">
94
+ `;
95
+ const routes = scanRoutes(html, FILE);
96
+ const searchRoute = routes.find(r => r.path === '/api/search');
97
+ const dataRoute = routes.find(r => r.path === '/api/data');
98
+ expect(searchRoute?.queryParams).toHaveLength(2);
99
+ expect(dataRoute?.queryParams).toHaveLength(1);
100
+ });
101
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { scanRoutes } from '../scanner/route-scanner.js';
3
+
4
+ describe('scanRoutes', () => {
5
+ it('combines hyperscript and htmx routes from one file', () => {
6
+ const html = `
7
+ <button _="on click fetch /api/data as json then put it into #result">
8
+ <div hx-get="/api/users" hx-target="#list">
9
+ `;
10
+ const routes = scanRoutes(html, 'test.html');
11
+ expect(routes).toHaveLength(2);
12
+ expect(routes.map(r => r.path).sort()).toEqual(['/api/data', '/api/users']);
13
+ });
14
+
15
+ it('deduplicates routes across extractors', () => {
16
+ const html = `
17
+ <button _="on click fetch /api/users as html">
18
+ <div hx-get="/api/users">
19
+ `;
20
+ const routes = scanRoutes(html, 'test.html');
21
+ // Both extract GET /api/users — should deduplicate
22
+ expect(routes).toHaveLength(1);
23
+ expect(routes[0].path).toBe('/api/users');
24
+ });
25
+
26
+ it('enriches POST routes with form body fields', () => {
27
+ const html = `
28
+ <form hx-post="/api/users">
29
+ <input name="name" type="text" required>
30
+ <input name="email" type="email" required>
31
+ <button type="submit">Create</button>
32
+ </form>
33
+ `;
34
+ const routes = scanRoutes(html, 'test.html');
35
+ expect(routes).toHaveLength(1);
36
+ expect(routes[0].method).toBe('POST');
37
+ expect(routes[0].requestBody).toHaveLength(2);
38
+ expect(routes[0].requestBody![0].name).toBe('name');
39
+ expect(routes[0].requestBody![1].name).toBe('email');
40
+ });
41
+
42
+ it('handles file with no routes', () => {
43
+ const html = `<div>Hello world</div>`;
44
+ const routes = scanRoutes(html, 'test.html');
45
+ expect(routes).toHaveLength(0);
46
+ });
47
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractPathParams, normalizeUrl, urlToHandlerName } from '../conventions/url-patterns.js';
3
+
4
+ describe('extractPathParams', () => {
5
+ it('extracts :param style parameters', () => {
6
+ expect(extractPathParams('/api/users/:id')).toEqual(['id']);
7
+ });
8
+
9
+ it('extracts {param} style parameters', () => {
10
+ expect(extractPathParams('/api/users/{id}')).toEqual(['id']);
11
+ });
12
+
13
+ it('extracts multiple parameters', () => {
14
+ expect(extractPathParams('/api/users/:userId/orders/:orderId')).toEqual(['userId', 'orderId']);
15
+ });
16
+
17
+ it('returns empty array for no params', () => {
18
+ expect(extractPathParams('/api/users')).toEqual([]);
19
+ });
20
+
21
+ it('deduplicates mixed styles', () => {
22
+ expect(extractPathParams('/api/:id/{id}')).toEqual(['id']);
23
+ });
24
+ });
25
+
26
+ describe('normalizeUrl', () => {
27
+ it('converts {param} to :param', () => {
28
+ expect(normalizeUrl('/api/users/{id}')).toBe('/api/users/:id');
29
+ });
30
+
31
+ it('strips query strings', () => {
32
+ expect(normalizeUrl('/api/search?q=hello')).toBe('/api/search');
33
+ });
34
+
35
+ it('strips fragments', () => {
36
+ expect(normalizeUrl('/api/users#top')).toBe('/api/users');
37
+ });
38
+
39
+ it('ensures leading slash', () => {
40
+ expect(normalizeUrl('api/users')).toBe('/api/users');
41
+ });
42
+
43
+ it('strips trailing slash', () => {
44
+ expect(normalizeUrl('/api/users/')).toBe('/api/users');
45
+ });
46
+
47
+ it('preserves root path', () => {
48
+ expect(normalizeUrl('/')).toBe('/');
49
+ });
50
+ });
51
+
52
+ describe('urlToHandlerName', () => {
53
+ it('generates camelCase from path + method', () => {
54
+ expect(urlToHandlerName('/api/users', 'GET')).toBe('getApiUsers');
55
+ });
56
+
57
+ it('converts params to ByParam', () => {
58
+ expect(urlToHandlerName('/api/users/:id', 'GET')).toBe('getApiUsersById');
59
+ });
60
+
61
+ it('handles POST method', () => {
62
+ expect(urlToHandlerName('/api/users', 'POST')).toBe('postApiUsers');
63
+ });
64
+
65
+ it('handles hyphenated segments', () => {
66
+ expect(urlToHandlerName('/api/user-list', 'GET')).toBe('getApiUserList');
67
+ });
68
+
69
+ it('handles multiple params', () => {
70
+ expect(urlToHandlerName('/api/users/:userId/orders/:orderId', 'DELETE')).toBe(
71
+ 'deleteApiUsersByUserIdOrdersByOrderId'
72
+ );
73
+ });
74
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ patternToRegex,
4
+ shouldScan,
5
+ shouldSkip,
6
+ createDebouncedRunner,
7
+ } from '../cli/watch-command.js';
8
+
9
+ describe('watch mode helpers', () => {
10
+ describe('patternToRegex', () => {
11
+ it('matches **/*.html', () => {
12
+ const re = patternToRegex('**/*.html');
13
+ expect(re.test('pages/index.html')).toBe(true);
14
+ expect(re.test('deep/nested/page.html')).toBe(true);
15
+ expect(re.test('page.html')).toBe(true);
16
+ expect(re.test('page.htm')).toBe(false);
17
+ });
18
+
19
+ it('matches **/*.htm', () => {
20
+ const re = patternToRegex('**/*.htm');
21
+ expect(re.test('page.htm')).toBe(true);
22
+ expect(re.test('page.html')).toBe(false);
23
+ });
24
+
25
+ it('matches node_modules/**', () => {
26
+ const re = patternToRegex('node_modules/**');
27
+ expect(re.test('node_modules/package/index.js')).toBe(true);
28
+ expect(re.test('src/index.html')).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('shouldScan', () => {
33
+ it('returns true for matching patterns', () => {
34
+ expect(shouldScan('index.html', ['**/*.html', '**/*.htm'])).toBe(true);
35
+ expect(shouldScan('page.htm', ['**/*.html', '**/*.htm'])).toBe(true);
36
+ });
37
+
38
+ it('returns false for non-matching files', () => {
39
+ expect(shouldScan('script.js', ['**/*.html', '**/*.htm'])).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('shouldSkip', () => {
44
+ it('skips node_modules', () => {
45
+ expect(shouldSkip('node_modules/pkg/index.html', ['node_modules/**'])).toBe(true);
46
+ });
47
+
48
+ it('does not skip normal files', () => {
49
+ expect(shouldSkip('src/index.html', ['node_modules/**', 'dist/**'])).toBe(false);
50
+ });
51
+ });
52
+ });
53
+
54
+ describe('createDebouncedRunner', () => {
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ });
58
+ afterEach(() => {
59
+ vi.useRealTimers();
60
+ });
61
+
62
+ it('executes after debounce delay', async () => {
63
+ const fn = vi.fn(async () => {});
64
+ const { trigger } = createDebouncedRunner(fn, 100);
65
+
66
+ trigger();
67
+ expect(fn).not.toHaveBeenCalled();
68
+
69
+ await vi.advanceTimersByTimeAsync(100);
70
+ expect(fn).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it('resets timer on rapid triggers', async () => {
74
+ const fn = vi.fn(async () => {});
75
+ const { trigger } = createDebouncedRunner(fn, 100);
76
+
77
+ trigger();
78
+ await vi.advanceTimersByTimeAsync(50);
79
+ trigger(); // reset
80
+ await vi.advanceTimersByTimeAsync(50);
81
+ expect(fn).not.toHaveBeenCalled(); // only 50ms since last trigger
82
+
83
+ await vi.advanceTimersByTimeAsync(50);
84
+ expect(fn).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it('prevents overlapping concurrent executions', async () => {
88
+ let running = 0;
89
+ let maxConcurrent = 0;
90
+ const fn = vi.fn(async () => {
91
+ running++;
92
+ maxConcurrent = Math.max(maxConcurrent, running);
93
+ await new Promise(r => setTimeout(r, 200));
94
+ running--;
95
+ });
96
+ const { trigger } = createDebouncedRunner(fn, 50);
97
+
98
+ trigger();
99
+ await vi.advanceTimersByTimeAsync(50); // starts first run
100
+
101
+ // Trigger again while first is running
102
+ trigger();
103
+ await vi.advanceTimersByTimeAsync(50); // debounce fires, but run() sees running=true → queued
104
+
105
+ expect(fn).toHaveBeenCalledTimes(1); // first call started, second queued
106
+ expect(maxConcurrent).toBe(1);
107
+
108
+ // Let first run complete
109
+ await vi.advanceTimersByTimeAsync(200);
110
+ // queued=true → trigger() called → wait for debounce
111
+ await vi.advanceTimersByTimeAsync(50);
112
+ // second run starts
113
+ await vi.advanceTimersByTimeAsync(200);
114
+
115
+ expect(fn).toHaveBeenCalledTimes(2);
116
+ expect(maxConcurrent).toBe(1);
117
+ });
118
+
119
+ it('queues follow-up when triggered during execution', async () => {
120
+ const calls: number[] = [];
121
+ let callCount = 0;
122
+ const fn = vi.fn(async () => {
123
+ const n = ++callCount;
124
+ calls.push(n);
125
+ await new Promise(r => setTimeout(r, 100));
126
+ });
127
+ const { trigger } = createDebouncedRunner(fn, 50);
128
+
129
+ trigger();
130
+ await vi.advanceTimersByTimeAsync(50); // fn starts (call 1)
131
+ expect(calls).toEqual([1]);
132
+
133
+ trigger(); // during execution → queued
134
+ await vi.advanceTimersByTimeAsync(100); // call 1 finishes, queued → trigger
135
+ await vi.advanceTimersByTimeAsync(50); // debounce fires → call 2 starts
136
+ expect(calls).toEqual([1, 2]);
137
+
138
+ await vi.advanceTimersByTimeAsync(100); // call 2 finishes
139
+ expect(fn).toHaveBeenCalledTimes(2);
140
+ });
141
+ });
@@ -0,0 +1,35 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { ServerBridgeConfig } from '../types.js';
4
+
5
+ const CONFIG_FILENAMES = ['.serverbridgerc.json', '.serverbridgerc', 'serverbridge.config.json'];
6
+
7
+ /**
8
+ * Load config from file, merging with CLI overrides.
9
+ */
10
+ export async function loadConfig(
11
+ cwd: string,
12
+ overrides: Partial<ServerBridgeConfig> = {}
13
+ ): Promise<ServerBridgeConfig> {
14
+ let fileConfig: Partial<ServerBridgeConfig> = {};
15
+
16
+ for (const filename of CONFIG_FILENAMES) {
17
+ try {
18
+ const content = await readFile(join(cwd, filename), 'utf-8');
19
+ fileConfig = JSON.parse(content);
20
+ break;
21
+ } catch {
22
+ // File doesn't exist, try next
23
+ }
24
+ }
25
+
26
+ return {
27
+ framework: overrides.framework ?? fileConfig.framework ?? 'express',
28
+ output: overrides.output ?? fileConfig.output ?? './server/routes',
29
+ typescript: overrides.typescript ?? fileConfig.typescript ?? true,
30
+ include: overrides.include ?? fileConfig.include ?? ['**/*.{html,htm}'],
31
+ exclude: overrides.exclude ?? fileConfig.exclude ?? ['node_modules/**', 'dist/**'],
32
+ basePath: overrides.basePath ?? fileConfig.basePath,
33
+ ignore: overrides.ignore ?? fileConfig.ignore,
34
+ };
35
+ }
@@ -0,0 +1,96 @@
1
+ import { resolve } from 'node:path';
2
+ import { scanDirectory } from '../scanner/route-scanner.js';
3
+ import { loadManifest, diffRoutes } from '../generators/manifest.js';
4
+ import { formatConflicts } from '../scanner/conflict-detector.js';
5
+ import { loadConfig } from './config.js';
6
+
7
+ interface DiffOptions {
8
+ dir: string;
9
+ format?: 'summary' | 'detailed';
10
+ include?: string[];
11
+ exclude?: string[];
12
+ ignore?: string[];
13
+ }
14
+
15
+ export async function runDiff(options: DiffOptions): Promise<void> {
16
+ const cwd = resolve(options.dir);
17
+ const config = await loadConfig(cwd, {
18
+ include: options.include,
19
+ exclude: options.exclude,
20
+ ignore: options.ignore,
21
+ });
22
+
23
+ const outputDir = resolve(cwd, config.output ?? './server/routes');
24
+ const manifest = await loadManifest(outputDir);
25
+
26
+ if (!manifest) {
27
+ console.log('No manifest found. Run "serverbridge generate" first.');
28
+ return;
29
+ }
30
+
31
+ const result = await scanDirectory(cwd, {
32
+ include: config.include,
33
+ exclude: config.exclude,
34
+ ignore: config.ignore,
35
+ });
36
+
37
+ if (result.errors.length > 0) {
38
+ for (const err of result.errors) {
39
+ console.error(`Warning: ${err.file}: ${err.message}`);
40
+ }
41
+ }
42
+
43
+ if (result.conflicts.length > 0) {
44
+ console.error(
45
+ `\nWarning: ${result.conflicts.length} route conflict(s) detected:\n${formatConflicts(result.conflicts)}\n`
46
+ );
47
+ }
48
+
49
+ const diff = diffRoutes(manifest, result.routes);
50
+ const hasChanges = diff.added.length > 0 || diff.changed.length > 0 || diff.removed.length > 0;
51
+ const total =
52
+ diff.added.length + diff.changed.length + diff.removed.length + diff.unchanged.length;
53
+
54
+ console.log(`\nRoute Diff Summary:`);
55
+ console.log(` Total: ${total}`);
56
+ console.log(` Unchanged: ${diff.unchanged.length}`);
57
+ console.log(` Added: ${diff.added.length}`);
58
+ console.log(` Changed: ${diff.changed.length}`);
59
+ console.log(` Removed: ${diff.removed.length}`);
60
+
61
+ if (options.format === 'detailed' && hasChanges) {
62
+ console.log('');
63
+
64
+ if (diff.added.length > 0) {
65
+ console.log('Added:');
66
+ for (const route of diff.added) {
67
+ console.log(
68
+ ` + ${route.method} ${route.path} (${route.source.file}:${route.source.line ?? '?'})`
69
+ );
70
+ }
71
+ }
72
+
73
+ if (diff.changed.length > 0) {
74
+ console.log('Changed:');
75
+ for (const route of diff.changed) {
76
+ console.log(
77
+ ` ~ ${route.method} ${route.path} (${route.source.file}:${route.source.line ?? '?'})`
78
+ );
79
+ }
80
+ }
81
+
82
+ if (diff.removed.length > 0) {
83
+ console.log('Removed:');
84
+ for (const entry of diff.removed) {
85
+ console.log(` - ${entry.method} ${entry.path}`);
86
+ }
87
+ }
88
+ }
89
+
90
+ if (hasChanges) {
91
+ console.log('\nChanges detected. Run "serverbridge generate" to update.');
92
+ process.exitCode = 1;
93
+ } else {
94
+ console.log('\nNo changes detected.');
95
+ }
96
+ }