Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions docs/analysis/nuxt-express-coldbox-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
# Nuxt + Express → What's Actually Worth Borrowing for ColdBox

*A grounded analysis, checked against ColdBox `development` as of 8.2.0. Every
claim below cites a real file and line; none are guesses.*

## Framing

ColdBox 8.2 is not short on features. Bundled as one framework you get
routing, an HMVC handler pipeline, WireBox DI, CacheBox, LogBox, an async
task/executor subsystem, and — as of the last few release cycles —
route-scoped middleware, HTTP caching primitives, generalized Server-Sent
Events, and AI/MCP route scaffolding. So "what does ColdBox lack compared to
Node's ecosystem" is the wrong question. The useful one is: **which ideas
from Express and Nuxt solve problems ColdBox developers actually hit, and
which would just be imported fashion that doesn't fit a conventions-based
HMVC framework?**

Two frameworks make a useful lens because they sit at opposite ends of the
same ecosystem:

- **Express 5** is minimal and composable. Its entire identity is one idea: a
request is a value threaded through a chain of middleware functions, each
of which can mutate it, short-circuit it, or hand it to the next.
- **Nuxt 4 / Nitro** is maximal and convention-driven: file-based routing,
composable app "layers" via `extends`, declarative route rules, a
pluggable storage/cache abstraction, auto-imports, and a DevTools panel
that makes the running app's internals inspectable.

ColdBox is philosophically much closer to Nuxt than to Express — it already
made the "opinionated conventions over configuration" bet Nuxt makes. That
means the genuine gaps cluster in two places: **composition** (where Express's
narrower model is sometimes sharper) and **introspection / runtime DX**
(where Nitro and Nuxt DevTools lead). The sections below work through both,
and are explicit about what ColdBox already has so this doesn't repeat the
easy mistake of treating a naming difference as a missing capability.

## Express in one idea — and the ColdBox mechanism that already matches it

Express's model: `app.use(middleware)` registers a function in an ordered
chain. Each middleware receives `(req, res, next)`; calling `next()` advances
the chain, throwing or not calling it terminates the request there. Routers
nest via `app.use('/prefix', router)`; a 4-arity function
`(err, req, res, next)` is error middleware; `app.param()` runs before a
route with a matching URL param; sub-apps mount at a path. Express 5's
deltas: middleware that returns a rejected promise is now auto-forwarded to
error handling (no more `.catch(next)` boilerplate), routing got stricter
(no more silently-swallowed regex footguns), and `router.all()` is now
one method instead of a verb enumeration.

A `grep -ri middleware system/` two release cycles ago would have returned
nothing, which invites the conclusion "ColdBox has no middleware." That
conclusion is wrong — it's a naming gap, not a capability gap.
`InterceptorState.cfc` (`system/web/context/InterceptorState.cfc`) has run
an Express-shaped chain since long before this analysis:

- **Ordered chain.** `processSync()` (`InterceptorState.cfc:352`) walks
registered interceptors in registration order.
- **Short-circuit.** An interceptor's `boolean` return of `true` `break`s the
chain (`InterceptorState.cfc:426`) — this *is* Express's
"don't call `next()`."
- **Scoping.** Every interceptor entry carries an `eventPattern` regex
checked against `event.getCurrentEvent()`; a mismatch skips it
(`InterceptorState.cfc:385-396`).
- **Closure listeners.** `listen( point, closure )` /
`unlisten( target, point )` register lambdas at runtime
(`InterceptorService.cfc:272`, `:261`), no component class required.
- Plus roughly 39 built-in interception points
(`InterceptorService.cfc:44-94`), extensible via
`appendInterceptionPoints()` (`InterceptorService.cfc:621`), per-interceptor
`async` execution, and module-scoped registration.

**As of 8.2, this chain is also attachable at the route** — which closes
what used to be the sharpest real gap. `Router.cfc` exposes
`.middleware( target, point = "preProcess" )`, `.middlewareGroup( name,
targets, point )` for named, reusable bundles, and `.withoutMiddleware(
target )` to exclude an inherited entry on a specific route
(`system/web/routing/Router.cfc`, `routeDefinitionShape()` around line 1222
carries `middleware`/`withoutMiddleware` as route-struct keys). `group()`
pushes each nesting level's middleware onto its own stack
(`Router.cfc:527-534`) so nested groups compose correctly — covered by a
dedicated "nested groups each contributing middleware" spec
(`tests/specs/web/routing/RouterTest.cfc:436`).

Execution doesn't route through `InterceptorState`'s point machinery,
though — it's a parallel, purpose-built path.
`RoutingService.runRouteMiddleware()` (`system/web/services/RoutingService.cfc:444-`)
resolves each entry (WireBox ID, component instance, or closure) and runs it
at the matched route's `preProcess`/`postProcess` boundary, fired from
`Bootstrap.cfc:236` and `Bootstrap.cfc:479` — deliberately positioned
"closest to the handler," inside the global interceptor chain rather than
replacing it. A middleware target returning `true` short-circuits the
remaining chain for that route the same way a global interceptor does.

So: **the composition gap that used to justify "ColdBox needs Express-style
middleware" is closed.** What Express still has that ColdBox doesn't is
*wrapping* — a middleware that runs code both before and after calling
`next()`, forming a call stack rather than a flat list. ColdBox's answer to
that is inheritance-based, not compositional:
`RestHandler.aroundHandler( event, rc, prc, targetAction, eventArguments )`
(`system/RestHandler.cfc:40`) wraps a target action by calling
`arguments.eventArguments.targetAction()` itself, but you get it by
extending `RestHandler`, not by composing independent wrapper functions.
That's a legitimate design choice for a conventions-first framework, but
it's worth naming plainly rather than pretending it's the same thing.

## Nuxt/Nitro in five ideas

1. **File-based routing.** A file under `pages/` becomes a route by its path
alone; `[id].vue` becomes a dynamic segment.
2. **Layers (`extends`).** An app config can `extends` a base layer — local
directory, npm package, or git repo — inheriting its components, composables,
server routes, and config, then overriding pieces of it. It's config-time
composition of whole applications, not just of code modules.
3. **Route rules + `cachedEventHandler` + `useStorage`.** `routeRules` in
`nuxt.config` declares per-path behavior (`{ '/blog/**': { swr: 3600 } }`)
without touching the handler. `cachedEventHandler()` wraps any Nitro
handler with cache semantics. `useStorage()` is a single key-value
abstraction over memory, filesystem, Redis, or a KV database, swappable by
config alone.
4. **Auto-imports and typed routes.** Composables and utils are available
without an `import` statement; route params and `$fetch()` calls are
typed from the file-based route tree itself, so a typo in a URL is a
build-time error.
5. **DevTools.** An in-browser panel showing the live route tree, component
tree, active modules, server routes you can invoke directly, and open
payload/state inspection — all without leaving the running app.

## Honest mapping table

| Nuxt/Nitro idea | ColdBox today | Gap real? | Verdict |
|---|---|---|---|
| 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 |
| 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 |
| Route rules / `cachedEventHandler` | `Router.cfc`'s `.withCache()` — route-struct `cache`/`cacheTimeout`/`cacheProvider`/etc, taking precedence over the handler's own annotations | Closed | **Shipped** |
| `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 |
| 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) |
| 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** |

## What already shipped (don't recommend what's already built)

An earlier pass at this analysis flagged HTTP caching primitives,
generalized streaming, and route-scoped middleware as gaps. As of this
`development` snapshot, all three are done, and the current source is the
ground truth:

- **Route-scoped middleware** — `.middleware()` / `.middlewareGroup()` /
`.withoutMiddleware()` on `Router.cfc`, executed via
`RoutingService.runRouteMiddleware()`. See the previous section.
- **HTTP caching primitives** — `event.etag()`, `event.lastModified()`,
`event.cacheControl()` on `RequestContext.cfc`; `withETag()` /
`withCacheControl()` on `Response.cfc`; and a `cache="true"`-annotation-driven
automatic tier that piggybacks on Bootstrap's existing pre-execution cache
lookup to skip both handler execution and body replay on a conditional-GET
hit.
- **Generalized Server-Sent Events** — `event.sse()` on `RequestContext.cfc`
returns an `SSEEmitter` (`system/web/context/SSEEmitter.cfc`) with
`send`/`sendView`/`sendLayout`/`sendData`/`sendError`/`sendIf`/`comment`/
`heartbeat`/`close`, plus `preSSEConnection`/`postSSEConnection`/
`onSSEError` interception points and a `this.sse` settings block. This is
no longer bolted inside `toAi()` only — any handler can stream.
- **AI routing conversational context** — `toAi()`'s `/invoke`, `/stream`,
`/batch` sub-routes resolve `userId` (defaults to
`Controller.getUserSessionIdentifier()`), `conversationId`
(passthrough-only), and `threadId` (generated via `createUUID()` if
absent, always echoed back) via `resolveAiContext()`
(`Router.cfc:2644`).
- **Route-level cache rules** — `.withCache()` on `Router.cfc`, taking
precedence over a handler's own `cache="true"` annotation for any request
matching that route. See Recommendation 1 below for the detail.

The document below only recommends what's still genuinely open.

## Recommendations, prioritized

### 1. Route-level cache rules — shipped

Nitro's `routeRules`/`cachedEventHandler` declare cache behavior where the
URL is declared, not buried in a handler annotation. ColdBox's Event Caching
already did the hard part (CacheBox-backed, wired into `Bootstrap.cfc`'s
pre-execution path) — the gap was purely that `routeDefinitionShape()`
had no cache keys. Closed: `Router.cfc` now exposes `.withCache( timeout,
lastAccessTimeout, provider, suffix, cacheInclude, cacheExclude, cacheFilter,
etag, etagWeak, lastModified, cacheControl )`, mirroring every handler-level
cache annotation one-for-one, plus the Tier 1 HTTP caching flags. A route
that opts in takes full precedence over that same event's handler
annotations for any request matching it — see `HandlerService.cfc`'s
`getRouteCachingMetadata()`, consulted first by both the pre-execution cache
lookup (`getEventMetadataEntry()`) and the post-execution cache write
(`getEventCachingMetadata()`). Deliberately not memoized the way the
handler-annotation dictionary is, since a route record is already a cheap
struct read — which is also what lets two different routes pointing at the
same event carry two different cache policies, something a handler
annotation alone could never do. Zero new subsystem: it rides the exact same
`EventURLFacade`/CacheBox/Bootstrap.cfc plumbing a `cache="true"` annotation
always has.

### 2. First-party DevTools / introspection surface (adopt)

`getRouteDefinitionKeys()` is a start, but there's no live way to ask a
running app "what route would this URL match, in what order do my
interceptors fire, what's in the module dependency graph, what's WireBox's
binder map." Proposal, roughly Nuxt-DevTools-shaped but served from
existing ColdBox introspection points rather than a new subsystem:
a route table you can test-match a URL string against, the interceptor
chain in actual firing order (there's already an `order` key per point,
`InterceptorService.cfc:602`), the module graph from `ModuleService`, and
the WireBox binder map. Bundle it as `cbdebugger`-class tooling rather than
core, matching how profiling already lives outside `system/` today.

Two near-free companions worth doing alongside this:
- **Default `Whoops.cfm` on in `development`.** It already exists fully
built; it's just never wired as the active handler anywhere in `system/`.
- **Resolve `modules.autoReload`.** It appears in sample module configs but
has zero implementation — a `grep -rn autoReload system/web/services/ModuleService.cfc`
returns nothing. Either build it on top of the `reload()`/`unload()` pair
that already exists (`ModuleService.cfc`), or remove the dead setting so
it stops looking like a feature that silently does nothing.

### 3. App-level layers (adapt, low priority)

ColdBox modules already do most of what Nuxt layers do — mountable,
dependency-aware, overridable-by-config packages of handlers/models/views.
The genuine delta is config-level `extends`: starting a new app from a
remote/git-sourced base layer and inheriting its whole config, not just
importing a module. This is real but narrow — most ColdBox teams solve
"share a base app shape" with a CommandBox template or an internal module
today, and template-based scaffolding already covers the common case. Worth
a design spike, not urgent work.

### 4. `aroundHandler`-as-composition, not as a new subsystem (skip / document better)

`RestHandler.aroundHandler()` (`system/RestHandler.cfc:40`) is ColdBox's
answer to Express's wrapping middleware, and it already works. The gap here
isn't code, it's that it's discoverable only by reading `RestHandler`'s
source — worth a docs pass explaining it as "how to get before/after
wrapping around an action" rather than inventing a parallel `aroundHandler()`
concept for route-scoped middleware, which would fragment composition into
two systems instead of one.

## What NOT to copy

- **File-based routing.** ColdBox's `Router.cfc` DSL — typed placeholders,
named routes, conditions, subdomain routing, resource generators — is
strictly more expressive than inferring a route from a filename. Replacing
it with file-based routing would be a downgrade dressed up as modernization.
- **Auto-imports.** WireBox DI already removes the manual-wiring pain
auto-imports solve in Nuxt; CFML/BoxLang's typing model doesn't have the
same payoff for statically inferring imports from usage that TypeScript
does.
- **A second composition system for wrapping.** The temptation after adding
route-scoped middleware is to also give it Express's `next()`-return
wrapping semantics. Don't — `aroundHandler()` already covers that need via
inheritance, and running two different composition models (flat
before/after chain *and* nestable wrapping) for the same problem is a
maintenance and mental-model cost, not a feature.
- **A generic `useStorage()`-style KV facade in core.** CacheBox is already
a richer multi-provider cache abstraction than Nitro's storage layer. A
separate, framework-owned generic KV store would duplicate it for no
clear benefit — this is module territory (as CORS, OpenAPI, HTTP client,
and validation already are).
Loading
Loading