Skip to content

Commit d310caf

Browse files
committed
docs: Nuxt/Express-informed analysis of ColdBox composition and DX gaps
Grounded pass through system/ to evaluate which ideas from Express 5 (middleware chains) and Nuxt 4/Nitro (layers, route rules, DevTools) are worth adopting. Confirms route-scoped middleware, HTTP caching primitives, generalized SSE, and AI conversational context already close what were previously the sharpest gaps. Narrows remaining recommendations to route-level cache rules, a first-party introspection/DevTools surface, and app-level config layers - each cited against actual file/line sources. Analysis document only. No framework code changes.
1 parent 688739c commit d310caf

1 file changed

Lines changed: 249 additions & 0 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
# Nuxt + Express → What's Actually Worth Borrowing for ColdBox
2+
3+
*A grounded analysis, checked against ColdBox `development` as of 8.2.0. Every
4+
claim below cites a real file and line; none are guesses.*
5+
6+
## Framing
7+
8+
ColdBox 8.2 is not short on features. Bundled as one framework you get
9+
routing, an HMVC handler pipeline, WireBox DI, CacheBox, LogBox, an async
10+
task/executor subsystem, and — as of the last few release cycles —
11+
route-scoped middleware, HTTP caching primitives, generalized Server-Sent
12+
Events, and AI/MCP route scaffolding. So "what does ColdBox lack compared to
13+
Node's ecosystem" is the wrong question. The useful one is: **which ideas
14+
from Express and Nuxt solve problems ColdBox developers actually hit, and
15+
which would just be imported fashion that doesn't fit a conventions-based
16+
HMVC framework?**
17+
18+
Two frameworks make a useful lens because they sit at opposite ends of the
19+
same ecosystem:
20+
21+
- **Express 5** is minimal and composable. Its entire identity is one idea: a
22+
request is a value threaded through a chain of middleware functions, each
23+
of which can mutate it, short-circuit it, or hand it to the next.
24+
- **Nuxt 4 / Nitro** is maximal and convention-driven: file-based routing,
25+
composable app "layers" via `extends`, declarative route rules, a
26+
pluggable storage/cache abstraction, auto-imports, and a DevTools panel
27+
that makes the running app's internals inspectable.
28+
29+
ColdBox is philosophically much closer to Nuxt than to Express — it already
30+
made the "opinionated conventions over configuration" bet Nuxt makes. That
31+
means the genuine gaps cluster in two places: **composition** (where Express's
32+
narrower model is sometimes sharper) and **introspection / runtime DX**
33+
(where Nitro and Nuxt DevTools lead). The sections below work through both,
34+
and are explicit about what ColdBox already has so this doesn't repeat the
35+
easy mistake of treating a naming difference as a missing capability.
36+
37+
## Express in one idea — and the ColdBox mechanism that already matches it
38+
39+
Express's model: `app.use(middleware)` registers a function in an ordered
40+
chain. Each middleware receives `(req, res, next)`; calling `next()` advances
41+
the chain, throwing or not calling it terminates the request there. Routers
42+
nest via `app.use('/prefix', router)`; a 4-arity function
43+
`(err, req, res, next)` is error middleware; `app.param()` runs before a
44+
route with a matching URL param; sub-apps mount at a path. Express 5's
45+
deltas: middleware that returns a rejected promise is now auto-forwarded to
46+
error handling (no more `.catch(next)` boilerplate), routing got stricter
47+
(no more silently-swallowed regex footguns), and `router.all()` is now
48+
one method instead of a verb enumeration.
49+
50+
A `grep -ri middleware system/` two release cycles ago would have returned
51+
nothing, which invites the conclusion "ColdBox has no middleware." That
52+
conclusion is wrong — it's a naming gap, not a capability gap.
53+
`InterceptorState.cfc` (`system/web/context/InterceptorState.cfc`) has run
54+
an Express-shaped chain since long before this analysis:
55+
56+
- **Ordered chain.** `processSync()` (`InterceptorState.cfc:352`) walks
57+
registered interceptors in registration order.
58+
- **Short-circuit.** An interceptor's `boolean` return of `true` `break`s the
59+
chain (`InterceptorState.cfc:426`) — this *is* Express's
60+
"don't call `next()`."
61+
- **Scoping.** Every interceptor entry carries an `eventPattern` regex
62+
checked against `event.getCurrentEvent()`; a mismatch skips it
63+
(`InterceptorState.cfc:385-396`).
64+
- **Closure listeners.** `listen( point, closure )` /
65+
`unlisten( target, point )` register lambdas at runtime
66+
(`InterceptorService.cfc:272`, `:261`), no component class required.
67+
- Plus roughly 39 built-in interception points
68+
(`InterceptorService.cfc:44-94`), extensible via
69+
`appendInterceptionPoints()` (`InterceptorService.cfc:621`), per-interceptor
70+
`async` execution, and module-scoped registration.
71+
72+
**As of 8.2, this chain is also attachable at the route** — which closes
73+
what used to be the sharpest real gap. `Router.cfc` exposes
74+
`.middleware( target, point = "preProcess" )`, `.middlewareGroup( name,
75+
targets, point )` for named, reusable bundles, and `.withoutMiddleware(
76+
target )` to exclude an inherited entry on a specific route
77+
(`system/web/routing/Router.cfc`, `routeDefinitionShape()` around line 1222
78+
carries `middleware`/`withoutMiddleware` as route-struct keys). `group()`
79+
pushes each nesting level's middleware onto its own stack
80+
(`Router.cfc:527-534`) so nested groups compose correctly — covered by a
81+
dedicated "nested groups each contributing middleware" spec
82+
(`tests/specs/web/routing/RouterTest.cfc:436`).
83+
84+
Execution doesn't route through `InterceptorState`'s point machinery,
85+
though — it's a parallel, purpose-built path.
86+
`RoutingService.runRouteMiddleware()` (`system/web/services/RoutingService.cfc:444-`)
87+
resolves each entry (WireBox ID, component instance, or closure) and runs it
88+
at the matched route's `preProcess`/`postProcess` boundary, fired from
89+
`Bootstrap.cfc:236` and `Bootstrap.cfc:479` — deliberately positioned
90+
"closest to the handler," inside the global interceptor chain rather than
91+
replacing it. A middleware target returning `true` short-circuits the
92+
remaining chain for that route the same way a global interceptor does.
93+
94+
So: **the composition gap that used to justify "ColdBox needs Express-style
95+
middleware" is closed.** What Express still has that ColdBox doesn't is
96+
*wrapping* — a middleware that runs code both before and after calling
97+
`next()`, forming a call stack rather than a flat list. ColdBox's answer to
98+
that is inheritance-based, not compositional:
99+
`RestHandler.aroundHandler( event, rc, prc, targetAction, eventArguments )`
100+
(`system/RestHandler.cfc:40`) wraps a target action by calling
101+
`arguments.eventArguments.targetAction()` itself, but you get it by
102+
extending `RestHandler`, not by composing independent wrapper functions.
103+
That's a legitimate design choice for a conventions-first framework, but
104+
it's worth naming plainly rather than pretending it's the same thing.
105+
106+
## Nuxt/Nitro in five ideas
107+
108+
1. **File-based routing.** A file under `pages/` becomes a route by its path
109+
alone; `[id].vue` becomes a dynamic segment.
110+
2. **Layers (`extends`).** An app config can `extends` a base layer — local
111+
directory, npm package, or git repo — inheriting its components, composables,
112+
server routes, and config, then overriding pieces of it. It's config-time
113+
composition of whole applications, not just of code modules.
114+
3. **Route rules + `cachedEventHandler` + `useStorage`.** `routeRules` in
115+
`nuxt.config` declares per-path behavior (`{ '/blog/**': { swr: 3600 } }`)
116+
without touching the handler. `cachedEventHandler()` wraps any Nitro
117+
handler with cache semantics. `useStorage()` is a single key-value
118+
abstraction over memory, filesystem, Redis, or a KV database, swappable by
119+
config alone.
120+
4. **Auto-imports and typed routes.** Composables and utils are available
121+
without an `import` statement; route params and `$fetch()` calls are
122+
typed from the file-based route tree itself, so a typo in a URL is a
123+
build-time error.
124+
5. **DevTools.** An in-browser panel showing the live route tree, component
125+
tree, active modules, server routes you can invoke directly, and open
126+
payload/state inspection — all without leaving the running app.
127+
128+
## Honest mapping table
129+
130+
| Nuxt/Nitro idea | ColdBox today | Gap real? | Verdict |
131+
|---|---|---|---|
132+
| File-based routing | Convention-based handler/action routing (`handlers/`) + an explicit, richly-typed DSL (`Router.cfc`, placeholder constraints like `:id-numeric`, `:slug-alpha`, `:x-regex:`, named routes, `resources()`/`apiResources()`, subdomain routing, route conditions) | Not real — ColdBox's DSL is more expressive than Nuxt's filename conventions, just less "magic" | Skip |
133+
| Layers (`extends`) | HMVC modules (`ModuleService.cfc`, 1549 lines): dependency graphs, inception/nesting, `-bundle` dirs, three-tier settings override, `viewParentLookup`/`layoutParentLookup` (`ModuleService.cfc:1208-1214`), per-module injectors/executors/schedulers, symmetric `reload()`/`unload()` | Partially — modules already cover "package a slice of an app and mount it," but there's no config-level "extend a whole base app/layer" the way Nuxt layers a starter template | Adapt, low priority |
134+
| Route rules / `cachedEventHandler` | Handler-level event caching (`cache="true"` annotations, `Bootstrap.cfc` pre-execution lookup) but **no route-struct cache keys**`routeDefinitionShape()` has no `cache`/`cacheTimeout`/`cacheProvider` | Real gap | **Adopt** |
135+
| `useStorage()` | CacheBox is a strictly richer multi-provider cache abstraction already; no unifying *generic KV* facade at the framework layer, but that's arguably module territory | Small, low urgency | Skip / module territory |
136+
| Auto-imports / typed routes | WireBox DI removes most manual imports already; route names + `buildLink()` give reverse routing, but nothing statically types a URL against the registered route table | Real but narrow | Skip (poor fit for CFML/BoxLang's type system) |
137+
| DevTools | `Whoops.cfm` (`system/exceptions/Whoops.cfm`, 712 lines: stack frames, open-in-editor for 9 editors, scope inspector, reinit button) exists but is **opt-in**, not wired anywhere as the default handler; `getRouteDefinitionKeys()` gives route-shape introspection but no live route table, no interceptor-chain viewer, no module graph | Real gap | **Adopt** |
138+
139+
## What already shipped (don't recommend what's already built)
140+
141+
An earlier pass at this analysis flagged HTTP caching primitives,
142+
generalized streaming, and route-scoped middleware as gaps. As of this
143+
`development` snapshot, all three are done, and the current source is the
144+
ground truth:
145+
146+
- **Route-scoped middleware**`.middleware()` / `.middlewareGroup()` /
147+
`.withoutMiddleware()` on `Router.cfc`, executed via
148+
`RoutingService.runRouteMiddleware()`. See the previous section.
149+
- **HTTP caching primitives**`event.etag()`, `event.lastModified()`,
150+
`event.cacheControl()` on `RequestContext.cfc`; `withETag()` /
151+
`withCacheControl()` on `Response.cfc`; and a `cache="true"`-annotation-driven
152+
automatic tier that piggybacks on Bootstrap's existing pre-execution cache
153+
lookup to skip both handler execution and body replay on a conditional-GET
154+
hit.
155+
- **Generalized Server-Sent Events**`event.sse()` on `RequestContext.cfc`
156+
returns an `SSEEmitter` (`system/web/context/SSEEmitter.cfc`) with
157+
`send`/`sendView`/`sendLayout`/`sendData`/`sendError`/`sendIf`/`comment`/
158+
`heartbeat`/`close`, plus `preSSEConnection`/`postSSEConnection`/
159+
`onSSEError` interception points and a `this.sse` settings block. This is
160+
no longer bolted inside `toAi()` only — any handler can stream.
161+
- **AI routing conversational context**`toAi()`'s `/invoke`, `/stream`,
162+
`/batch` sub-routes resolve `userId` (defaults to
163+
`Controller.getUserSessionIdentifier()`), `conversationId`
164+
(passthrough-only), and `threadId` (generated via `createUUID()` if
165+
absent, always echoed back) via `resolveAiContext()`
166+
(`Router.cfc:2644`).
167+
168+
The document below only recommends what's still genuinely open.
169+
170+
## Recommendations, prioritized
171+
172+
### 1. Route-level cache rules (adopt)
173+
174+
Nitro's `routeRules`/`cachedEventHandler` declare cache behavior where the
175+
URL is declared, not buried in a handler annotation. ColdBox's Event Caching
176+
already does the hard part (CacheBox-backed, wired into `Bootstrap.cfc`'s
177+
pre-execution path) — the gap is purely that `routeDefinitionShape()`
178+
(`Router.cfc:1222`) has no cache keys. Proposal: add `cache`, `cacheTimeout`,
179+
`cacheProvider`, and an optional `cacheKey` closure to the route struct,
180+
consumed the same way route-scoped middleware is — checked at match time in
181+
`RoutingService`, translated into the same event-caching metadata
182+
`HandlerService.cfc` already understands. This is additive, reuses existing
183+
CacheBox plumbing, and needs no new subsystem — the same shape of change
184+
that made route-scoped middleware low-risk.
185+
186+
### 2. First-party DevTools / introspection surface (adopt)
187+
188+
`getRouteDefinitionKeys()` is a start, but there's no live way to ask a
189+
running app "what route would this URL match, in what order do my
190+
interceptors fire, what's in the module dependency graph, what's WireBox's
191+
binder map." Proposal, roughly Nuxt-DevTools-shaped but served from
192+
existing ColdBox introspection points rather than a new subsystem:
193+
a route table you can test-match a URL string against, the interceptor
194+
chain in actual firing order (there's already an `order` key per point,
195+
`InterceptorService.cfc:602`), the module graph from `ModuleService`, and
196+
the WireBox binder map. Bundle it as `cbdebugger`-class tooling rather than
197+
core, matching how profiling already lives outside `system/` today.
198+
199+
Two near-free companions worth doing alongside this:
200+
- **Default `Whoops.cfm` on in `development`.** It already exists fully
201+
built; it's just never wired as the active handler anywhere in `system/`.
202+
- **Resolve `modules.autoReload`.** It appears in sample module configs but
203+
has zero implementation — a `grep -rn autoReload system/web/services/ModuleService.cfc`
204+
returns nothing. Either build it on top of the `reload()`/`unload()` pair
205+
that already exists (`ModuleService.cfc`), or remove the dead setting so
206+
it stops looking like a feature that silently does nothing.
207+
208+
### 3. App-level layers (adapt, low priority)
209+
210+
ColdBox modules already do most of what Nuxt layers do — mountable,
211+
dependency-aware, overridable-by-config packages of handlers/models/views.
212+
The genuine delta is config-level `extends`: starting a new app from a
213+
remote/git-sourced base layer and inheriting its whole config, not just
214+
importing a module. This is real but narrow — most ColdBox teams solve
215+
"share a base app shape" with a CommandBox template or an internal module
216+
today, and template-based scaffolding already covers the common case. Worth
217+
a design spike, not urgent work.
218+
219+
### 4. `aroundHandler`-as-composition, not as a new subsystem (skip / document better)
220+
221+
`RestHandler.aroundHandler()` (`system/RestHandler.cfc:40`) is ColdBox's
222+
answer to Express's wrapping middleware, and it already works. The gap here
223+
isn't code, it's that it's discoverable only by reading `RestHandler`'s
224+
source — worth a docs pass explaining it as "how to get before/after
225+
wrapping around an action" rather than inventing a parallel `aroundHandler()`
226+
concept for route-scoped middleware, which would fragment composition into
227+
two systems instead of one.
228+
229+
## What NOT to copy
230+
231+
- **File-based routing.** ColdBox's `Router.cfc` DSL — typed placeholders,
232+
named routes, conditions, subdomain routing, resource generators — is
233+
strictly more expressive than inferring a route from a filename. Replacing
234+
it with file-based routing would be a downgrade dressed up as modernization.
235+
- **Auto-imports.** WireBox DI already removes the manual-wiring pain
236+
auto-imports solve in Nuxt; CFML/BoxLang's typing model doesn't have the
237+
same payoff for statically inferring imports from usage that TypeScript
238+
does.
239+
- **A second composition system for wrapping.** The temptation after adding
240+
route-scoped middleware is to also give it Express's `next()`-return
241+
wrapping semantics. Don't — `aroundHandler()` already covers that need via
242+
inheritance, and running two different composition models (flat
243+
before/after chain *and* nestable wrapping) for the same problem is a
244+
maintenance and mental-model cost, not a feature.
245+
- **A generic `useStorage()`-style KV facade in core.** CacheBox is already
246+
a richer multi-provider cache abstraction than Nitro's storage layer. A
247+
separate, framework-owned generic KV store would duplicate it for no
248+
clear benefit — this is module territory (as CORS, OpenAPI, HTTP client,
249+
and validation already are).

0 commit comments

Comments
 (0)