Skip to content

Commit 731a383

Browse files
authored
Merge pull request #1443 from jaypatrick/copilot/structural-refactor-worker-hono-app
refactor(worker): Phase 4 — split hono-app.ts into domain route modules, remove bare-path double-mount
2 parents a0901c5 + 86b3706 commit 731a383

19 files changed

Lines changed: 1206 additions & 815 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ jobs:
8484
- name: Format check
8585
run: deno fmt --check
8686

87+
- name: Route-order lint
88+
run: deno task lint:routes
89+
8790
typecheck:
8891
name: Type Check
8992
runs-on: ubuntu-latest

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"bench:transformations": "deno bench src/transformations/*.bench.ts",
3333
"bench:json": "deno bench --allow-read --allow-write --allow-net --allow-env --json",
3434
"lint": "deno lint",
35+
"lint:routes": "deno run --allow-read scripts/lint-route-order.ts",
3536
"fmt": "deno fmt",
3637
"fmt:check": "deno fmt --check",
3738
"check": "deno task check:src && deno task check:worker",

deno.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/architecture/hono-routing.md

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,18 @@ These variables are set by middleware and available to all route handlers via `c
5858
The frontend uses `API_BASE_URL = '/api'`, so all API requests from the frontend arrive
5959
as `/api/compile`, `/api/rules`, etc.
6060

61-
Hono's `app.route()` is used to mount the same `routes` sub-app under both `/` and `/api`:
61+
Prior to Phase 4, Hono's `app.route()` was used to mount the `routes` sub-app under
62+
**both** `/` and `/api`. The bare-path mount was removed in Phase 4 (domain route split)
63+
to eliminate the double-execution side-effect and simplify the routing surface:
6264

6365
```typescript
64-
// /api is mounted first — ensures correct prefix-stripping for /api/* requests
65-
// before the root-mount sub-app intercepts them as unrecognised paths.
66+
// Phase 4: /api is the canonical base path — bare-path mount removed.
6667
app.route('/api', routes);
67-
app.route('/', routes);
68+
// app.route('/', routes); ← removed in Phase 4
6869
```
6970

70-
This means `/compile` and `/api/compile` both reach the same handler. No path-stripping
71-
logic is needed in route handlers.
71+
`/api` is the only canonical base path. Bare-path requests (`/compile`, `/health`, etc.)
72+
are no longer served.
7273

7374
---
7475

@@ -209,8 +210,67 @@ async (c) => {
209210

210211
---
211212

212-
## Phase 3 Roadmap
213+
---
214+
215+
## Phase 4 — Domain Route Modules (complete)
216+
217+
Phase 4 split the `worker/hono-app.ts` monolith into domain-scoped route files under
218+
`worker/routes/`. Each file exports a single `OpenAPIHono` sub-app instance that is
219+
mounted on the `routes` sub-app in `hono-app.ts`.
220+
221+
### New file layout
222+
223+
```
224+
worker/
225+
hono-app.ts ← app setup + middleware only; imports route modules
226+
routes/
227+
compile.routes.ts ← /compile/*, /validate, /ast/parse, /ws/compile, /validate-rule
228+
rules.routes.ts ← /rules/*
229+
queue.routes.ts ← /queue/*
230+
configuration.routes.ts ← /configuration/*
231+
admin.routes.ts ← /admin/* (users, neon, agents, auth-config, usage, storage)
232+
monitoring.routes.ts ← /health/*, /metrics/*, /container/status
233+
api-keys.routes.ts ← /keys/*
234+
webhook.routes.ts ← /notify
235+
workflow.routes.ts ← /workflow/*
236+
browser.routes.ts ← /browser/* (stub — routes added in a future PR)
237+
index.ts ← barrel: exports all sub-apps
238+
shared.ts ← shared types (AppContext) and helpers used by route files
239+
```
240+
241+
### Mount strategy
242+
243+
Each domain sub-app is mounted on the `routes` sub-app at the root path:
244+
245+
```typescript
246+
routes.route('/', compileRoutes);
247+
routes.route('/', rulesRoutes);
248+
routes.route('/', queueRoutes);
249+
// ... etc
250+
```
251+
252+
The `routes` sub-app itself is mounted only at `/api`:
253+
254+
```typescript
255+
app.route('/api', routes);
256+
// app.route('/', routes); ← bare-path double-mount removed in Phase 4
257+
```
258+
259+
### Middleware inheritance
260+
261+
All middleware registered on `routes` (logger, compress with `NO_COMPRESS_PATHS`
262+
exclusion, ZTA permission check) still wraps every sub-app route, because the
263+
sub-apps are mounted on `routes` — not directly on `app`. No middleware changes
264+
were needed.
265+
266+
### CI route-order guard
267+
268+
`scripts/lint-route-order.ts` validates four invariants on every CI run:
269+
270+
1. `timing()` is the first `app.use()` call
271+
2. The Better Auth `/api/auth/*` handler is registered before `agentRouter`
272+
3. `app.route('/', routes)` is absent (no bare-path double-mount)
273+
4. The compress middleware uses the `NO_COMPRESS_PATHS` exclusion pattern
274+
275+
Run manually with: `deno task lint:routes`
213276

214-
- Generate OpenAPI spec from route + schema definitions using `hono/openapi`
215-
- Generate a type-safe RPC client with `hono/client` for the frontend
216-
- Extend `zValidator` to additional endpoints (e.g. `/configuration/validate`, `/validate-rule`)

scripts/lint-route-order.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* CI lint guard — validates route-ordering invariants in `worker/hono-app.ts`.
3+
*
4+
* Checks:
5+
* 1. `timing()` appears before all other `app.use()` calls.
6+
* 2. `app.on(['POST', 'GET'], '/api/auth/*', ...)` appears before
7+
* `app.route('/', agentRouter)` (Better Auth before agent routing).
8+
* 3. `app.route('/api', routes)` is NOT preceded by `app.route('/', routes)`
9+
* (double-mount guard — bare `/` mount was removed in Phase 4).
10+
* 4. The compress middleware in the `routes` sub-app uses the `NO_COMPRESS_PATHS`
11+
* exclusion pattern (not a bare `compress()` wildcard).
12+
*
13+
* Usage:
14+
* deno run --allow-read scripts/lint-route-order.ts
15+
*
16+
* Exit codes:
17+
* 0 — all checks pass
18+
* 1 — one or more checks failed (descriptive messages printed to stderr)
19+
*/
20+
21+
import { join } from 'jsr:@std/path@^1.1.4';
22+
23+
const HONO_APP_PATH = join(import.meta.dirname ?? '.', '..', 'worker', 'hono-app.ts');
24+
25+
// Read the file
26+
let src: string;
27+
try {
28+
src = await Deno.readTextFile(HONO_APP_PATH);
29+
} catch (e) {
30+
console.error(`[lint-route-order] ERROR: Cannot read ${HONO_APP_PATH}:`, e);
31+
Deno.exit(1);
32+
}
33+
34+
// Strip single-line and multi-line comments so we don't accidentally match
35+
// commented-out code. This is intentionally lightweight — it does not handle
36+
// every edge case, but is sufficient for the ordered-invariant checks below.
37+
const srcNoComments = src
38+
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
39+
.replace(/\/\/.*/g, ''); // line comments
40+
41+
let passed = true;
42+
43+
// ── Check 1: timing() is the FIRST app.use() call ────────────────────────────
44+
// We look for the position of the first `app.use(` call and the position of
45+
// `timing()` inside it.
46+
47+
const firstAppUseIdx = srcNoComments.indexOf('app.use(');
48+
const timingCallIdx = srcNoComments.indexOf('timing()');
49+
50+
if (timingCallIdx === -1) {
51+
console.error('[lint-route-order] FAIL #1: `timing()` call not found in hono-app.ts');
52+
passed = false;
53+
} else if (firstAppUseIdx === -1) {
54+
console.error('[lint-route-order] FAIL #1: No `app.use(` calls found in hono-app.ts');
55+
passed = false;
56+
} else {
57+
// The timing() middleware is registered as `app.use('*', timing())`.
58+
// The firstAppUseIdx should be <= timingCallIdx (timing is first or included in the first call).
59+
const timingAppUsePattern = /app\.use\s*\([^)]*timing\s*\(\)/;
60+
const timingAppUseMatch = timingAppUsePattern.exec(srcNoComments);
61+
if (!timingAppUseMatch) {
62+
console.error('[lint-route-order] FAIL #1: `app.use(... timing() ...)` pattern not found');
63+
passed = false;
64+
} else {
65+
const timingAppUseIdx = timingAppUseMatch.index;
66+
if (timingAppUseIdx > firstAppUseIdx) {
67+
console.error(
68+
`[lint-route-order] FAIL #1: timing() is not the first app.use() call.\n` +
69+
` First app.use() at char ${firstAppUseIdx}, timing() app.use() at char ${timingAppUseIdx}`,
70+
);
71+
passed = false;
72+
} else {
73+
console.log('[lint-route-order] PASS #1: timing() is first app.use()');
74+
}
75+
}
76+
}
77+
78+
// ── Check 2: Better Auth handler before agent router ─────────────────────────
79+
// `app.on(['POST', 'GET'], '/api/auth/*', ...)` must appear before
80+
// `app.route('/', agentRouter)`.
81+
82+
const betterAuthIdx = srcNoComments.indexOf("app.on(['POST', 'GET'], '/api/auth/*'");
83+
const agentRouterIdx = srcNoComments.indexOf("app.route('/', agentRouter)");
84+
85+
if (betterAuthIdx === -1) {
86+
console.error("[lint-route-order] FAIL #2: `app.on(['POST', 'GET'], '/api/auth/*')` not found");
87+
passed = false;
88+
} else if (agentRouterIdx === -1) {
89+
console.error("[lint-route-order] FAIL #2: `app.route('/', agentRouter)` not found");
90+
passed = false;
91+
} else if (betterAuthIdx > agentRouterIdx) {
92+
console.error(
93+
`[lint-route-order] FAIL #2: Better Auth /api/auth/* handler appears AFTER agentRouter mount.\n` +
94+
` Better Auth at char ${betterAuthIdx}, agentRouter at char ${agentRouterIdx}`,
95+
);
96+
passed = false;
97+
} else {
98+
console.log('[lint-route-order] PASS #2: Better Auth registered before agentRouter');
99+
}
100+
101+
// ── Check 3: No bare-path double-mount guard ──────────────────────────────────
102+
// `app.route('/', routes)` must NOT appear anywhere in the file (the bare-path
103+
// double-mount was intentionally removed in Phase 4).
104+
105+
// We specifically look for `app.route('/', routes)` (where `routes` is the local
106+
// business-routes sub-app, not `agentRouter`). Be precise to avoid false positives.
107+
const doubleMount = /app\.route\s*\(\s*'\/'\s*,\s*routes\s*\)/.exec(srcNoComments);
108+
if (doubleMount) {
109+
console.error(
110+
`[lint-route-order] FAIL #3: Bare-path double-mount detected: app.route('/', routes)\n` +
111+
` This was removed in Phase 4. Only app.route('/api', routes) is allowed.\n` +
112+
` Found at char ${doubleMount.index}`,
113+
);
114+
passed = false;
115+
} else {
116+
console.log("[lint-route-order] PASS #3: No bare-path double-mount (app.route('/', routes) not present)");
117+
}
118+
119+
// ── Check 4: Compress middleware uses NO_COMPRESS_PATHS exclusion ─────────────
120+
// The `routes` sub-app compress middleware must reference `NO_COMPRESS_PATHS`
121+
// (not a bare `routes.use('*', compress())` call).
122+
123+
const bareCompressPattern = /routes\.use\s*\(\s*'\*'\s*,\s*compress\s*\(\s*\)\s*\)/;
124+
if (bareCompressPattern.test(srcNoComments)) {
125+
console.error(
126+
"[lint-route-order] FAIL #4: Bare compress() wildcard detected: routes.use('*', compress()).\n" +
127+
' Use the NO_COMPRESS_PATHS exclusion pattern instead to skip compression on health/metrics endpoints.',
128+
);
129+
passed = false;
130+
} else if (!srcNoComments.includes('NO_COMPRESS_PATHS')) {
131+
console.error(
132+
'[lint-route-order] FAIL #4: NO_COMPRESS_PATHS constant not found in hono-app.ts.\n' +
133+
' The compress middleware must use this exclusion set to skip /health and /metrics routes.',
134+
);
135+
passed = false;
136+
} else {
137+
console.log('[lint-route-order] PASS #4: Compress middleware uses NO_COMPRESS_PATHS exclusion');
138+
}
139+
140+
// ── Summary ───────────────────────────────────────────────────────────────────
141+
142+
if (passed) {
143+
console.log('\n✅ All route-order checks passed.');
144+
Deno.exit(0);
145+
} else {
146+
console.error('\n❌ One or more route-order checks failed. See errors above.');
147+
Deno.exit(1);
148+
}

0 commit comments

Comments
 (0)