|
| 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