From d310caf9adb1396287b27d6fdc9c07bc7f7d66b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 03:21:12 +0000 Subject: [PATCH 1/4] 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. --- .../analysis/nuxt-express-coldbox-analysis.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/analysis/nuxt-express-coldbox-analysis.md diff --git a/docs/analysis/nuxt-express-coldbox-analysis.md b/docs/analysis/nuxt-express-coldbox-analysis.md new file mode 100644 index 000000000..a8e83fe41 --- /dev/null +++ b/docs/analysis/nuxt-express-coldbox-analysis.md @@ -0,0 +1,249 @@ +# 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` | 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** | +| `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`). + +The document below only recommends what's still genuinely open. + +## Recommendations, prioritized + +### 1. Route-level cache rules (adopt) + +Nitro's `routeRules`/`cachedEventHandler` declare cache behavior where the +URL is declared, not buried in a handler annotation. ColdBox's Event Caching +already does the hard part (CacheBox-backed, wired into `Bootstrap.cfc`'s +pre-execution path) — the gap is purely that `routeDefinitionShape()` +(`Router.cfc:1222`) has no cache keys. Proposal: add `cache`, `cacheTimeout`, +`cacheProvider`, and an optional `cacheKey` closure to the route struct, +consumed the same way route-scoped middleware is — checked at match time in +`RoutingService`, translated into the same event-caching metadata +`HandlerService.cfc` already understands. This is additive, reuses existing +CacheBox plumbing, and needs no new subsystem — the same shape of change +that made route-scoped middleware low-risk. + +### 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). From 147971cd4804cf0139dd9f3df937ed307bf5ff1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 03:58:05 +0000 Subject: [PATCH 2/4] feat: route-level cache rules via Router.withCache() Adds a route-scoped alternative to handler cache="true" annotations, matching Nitro's routeRules idea from the Nuxt/Express analysis. A route that calls .withCache() gets cache, cacheTimeout, cacheLastAccessTimeout, cacheProvider, cacheSuffix, cacheInclude, cacheExclude, cacheFilter, and the Tier 1 HTTP caching flags (etag, etagWeak, lastModified, cacheControl) on its route record - the same set a handler annotation already supports, one-for-one. HandlerService.getRouteCachingMetadata() reads the matched route's record and, when it declares cache=true, takes full precedence over that handler's own annotations for the request - checked first by both the pre-execution cache lookup (getEventMetadataEntry()) and the post-execution cache write (getEventCachingMetadata()). Deliberately not memoized like the handler- annotation dictionary: a route record is already a cheap struct read, and recomputing it fresh per request is what lets two different routes to the same event carry two different cache policies, which the event-name-keyed handler dictionary could never do. Falls through unchanged to the existing handler-annotation path when a route doesn't opt in - zero behavior change for existing apps. Rides the same EventURLFacade/CacheBox/Bootstrap.cfc plumbing a cache="true" annotation already uses, so no other file needed to change. Updates the Nuxt/Express analysis doc to mark this recommendation shipped. --- .../analysis/nuxt-express-coldbox-analysis.md | 33 ++-- system/web/routing/Router.cfc | 177 ++++++++++++++---- system/web/services/HandlerService.cfc | 70 +++++++ tests/specs/web/routing/RouterTest.cfc | 101 ++++++++++ .../specs/web/services/HandlerServiceTest.cfc | 106 +++++++++++ 5 files changed, 437 insertions(+), 50 deletions(-) diff --git a/docs/analysis/nuxt-express-coldbox-analysis.md b/docs/analysis/nuxt-express-coldbox-analysis.md index a8e83fe41..03858dca2 100644 --- a/docs/analysis/nuxt-express-coldbox-analysis.md +++ b/docs/analysis/nuxt-express-coldbox-analysis.md @@ -131,7 +131,7 @@ it's worth naming plainly rather than pretending it's the same thing. |---|---|---|---| | 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` | 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** | +| 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** | @@ -164,24 +164,35 @@ ground truth: (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 (adopt) +### 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 does the hard part (CacheBox-backed, wired into `Bootstrap.cfc`'s -pre-execution path) — the gap is purely that `routeDefinitionShape()` -(`Router.cfc:1222`) has no cache keys. Proposal: add `cache`, `cacheTimeout`, -`cacheProvider`, and an optional `cacheKey` closure to the route struct, -consumed the same way route-scoped middleware is — checked at match time in -`RoutingService`, translated into the same event-caching metadata -`HandlerService.cfc` already understands. This is additive, reuses existing -CacheBox plumbing, and needs no new subsystem — the same shape of change -that made route-scoped middleware low-risk. +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) diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index dd525dfe9..3dc81bf46 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -817,7 +817,19 @@ component boolean mcp = "false", string mcpServer = "", array middleware = [], - array withoutMiddleware = [] + array withoutMiddleware = [], + boolean cache = "false", + any cacheTimeout = "", + any cacheLastAccessTimeout = "", + string cacheProvider = "template", + any cacheSuffix = "", + string cacheInclude = "*", + string cacheExclude = "", + any cacheFilter = "", + boolean etag = "false", + boolean etagWeak = "false", + boolean lastModified = "false", + string cacheControl = "" ){ // The route construct we will save var thisRoute = {}; @@ -1221,46 +1233,59 @@ component */ private struct function routeDefinitionShape(){ return { - "action" : "", // The action to execute - "append" : true, // Was this route appended or pre/prended - "condition" : "", // The condition closure which must be true for the route to match - "constraints" : {}, // If we have any regex constraints on placeholders. - "domain" : "", // The domain attached to the route - "event" : "", // The full event syntax to execute - "handler" : "", // The handler to execute - "headers" : {}, // The HTTP response headers to respond with - "layout" : "", // The layout to proxy to - "layoutModule" : "", // If the layout comes from a module - "meta" : {}, // Route metadata if any - "middleware" : [], // Route-scoped middleware entries: [ { target, point } ] - "module" : "", // The module event we must execute - "moduleRouting" : "", // This routes to a module - "name" : "", // The named route - "namespace" : "", // The namespace this route belongs to - "namespaceRouting" : "", // This routes to a namespace - "packageResolverExempt" : false, // If true, it does not resolve packages by convention, by default we do - "pattern" : "", // The regex pattern used for matching - "prc" : {}, // The PRC params to add incorporate if matched - "rc" : {}, // The RC params to add incorporate if matched - "redirect" : "", // The redirection location - "response" : "", // Do we have an inline response closure - "responsePlaceholders" : [], // Pre-parsed {token} list for string responses - "sse" : false, // Flag indicating this route streams Server-Sent Events - "sseCallback" : "", // The streaming closure for SSE routes - "ssl" : false, // Are we forcing SSL - "statusCode" : 200, // The response status code - "valuePairTranslation" : true, // If we translate name-value pairs in the URL by convention - "verbs" : "", // The HTTP Verbs allowed - "view" : "", // The view to proxy to - "viewModule" : "", // If the view comes from a module - "viewNoLayout" : false, // If we use a layout or not - "withoutMiddleware" : [], // Middleware target/group names excluded from this route + "action" : "", // The action to execute + "append" : true, // Was this route appended or pre/prended + "condition" : "", // The condition closure which must be true for the route to match + "constraints" : {}, // If we have any regex constraints on placeholders. + "domain" : "", // The domain attached to the route + "event" : "", // The full event syntax to execute + "handler" : "", // The handler to execute + "headers" : {}, // The HTTP response headers to respond with + "layout" : "", // The layout to proxy to + "layoutModule" : "", // If the layout comes from a module + "meta" : {}, // Route metadata if any + "middleware" : [], // Route-scoped middleware entries: [ { target, point } ] + "module" : "", // The module event we must execute + "moduleRouting" : "", // This routes to a module + "name" : "", // The named route + "namespace" : "", // The namespace this route belongs to + "namespaceRouting" : "", // This routes to a namespace + "packageResolverExempt" : false, // If true, it does not resolve packages by convention, by default we do + "pattern" : "", // The regex pattern used for matching + "prc" : {}, // The PRC params to add incorporate if matched + "rc" : {}, // The RC params to add incorporate if matched + "redirect" : "", // The redirection location + "response" : "", // Do we have an inline response closure + "responsePlaceholders" : [], // Pre-parsed {token} list for string responses + "sse" : false, // Flag indicating this route streams Server-Sent Events + "sseCallback" : "", // The streaming closure for SSE routes + "ssl" : false, // Are we forcing SSL + "statusCode" : 200, // The response status code + "valuePairTranslation" : true, // If we translate name-value pairs in the URL by convention + "verbs" : "", // The HTTP Verbs allowed + "view" : "", // The view to proxy to + "viewModule" : "", // If the view comes from a module + "viewNoLayout" : false, // If we use a layout or not + "withoutMiddleware" : [], // Middleware target/group names excluded from this route // AI Routing - "ai" : false, // Flag indicating this is an AI runnable route - "aiRunnable" : "", // The AI runnable WireBox ID or instance + "ai" : false, // Flag indicating this is an AI runnable route + "aiRunnable" : "", // The AI runnable WireBox ID or instance // MCP Routing - "mcp" : false, // Flag indicating this is an MCP server route - "mcpServer" : "" // The MCP server name to expose + "mcp" : false, // Flag indicating this is an MCP server route + "mcpServer" : "", // The MCP server name to expose + // Route-Level Caching - overrides the handler's own cache="true" annotation when true + "cache" : false, // Flag indicating this route caches its output + "cacheTimeout" : "", // Cache timeout, in minutes. Blank uses the cache provider's default + "cacheLastAccessTimeout" : "", // Cache last access timeout, in minutes + "cacheProvider" : "template", // The CacheBox provider to store the cached output in + "cacheSuffix" : "", // A static string or a closure( event ) evaluated per-request for the cache key suffix + "cacheInclude" : "*", // RC keys to include in the cache key, comma-delimited, "*" for all + "cacheExclude" : "", // RC keys to exclude from the cache key, comma-delimited + "cacheFilter" : "", // A closure( rc ):struct to fully customize which RC keys build the cache key + "etag" : false, // Tier 1 HTTP caching: compute a strong ETag alongside the cached entry + "etagWeak" : false, // Compute the ETag above as a weak validator (W/"...") instead of strong + "lastModified" : false, // Tier 1 HTTP caching: stamp the cached entry with a Last-Modified time + "cacheControl" : "" // Cache-Control header value to send; defaults to a max-age derived from cacheTimeout when etag/lastModified is set }; } @@ -1562,6 +1587,80 @@ component return this; } + /** + * Cache this route's output - the route-level equivalent of a handler action's `cache="true"` + * annotation, declared where the URL is declared instead of on the handler. When a route opts + * in here, its rules take full precedence over that handler's own cache annotations for any + * request matching this route: the handler's `cache`/`cacheTimeout`/etc are ignored entirely, + * which lets two different routes pointing at the same event carry two different cache + * policies - something a handler annotation alone can never do, since it's shared by every + * route that reaches that action. + * + * Reuses the exact same CacheBox-backed Event Caching machinery a handler annotation drives: + * same cache providers, same conditional-GET Tier 1 layer (`etag`/`etagWeak`/`lastModified`/ + * `cacheControl`), same `cacheInclude`/`cacheExclude`/`cacheFilter` request-collection scoping. + * See `docs/specs/http-caching.md` for the Tier 1 conditional-GET contract these four params opt into. + * + *
+	 * // cache for 60 minutes, default RC-based key
+	 * route( "/api/products" ).withCache( timeout = 60 ).to( "products.index" );
+	 *
+	 * // add conditional-GET support - a client with a matching ETag gets a 304, no body
+	 * route( "/api/products/:id" ).withCache( timeout = 60, etag = true ).to( "products.show" );
+	 *
+	 * // scope the cache key to just :id, ignoring any other querystring noise
+	 * route( "/api/products/:id" ).withCache( timeout = 60, cacheInclude = "id" ).to( "products.show" );
+	 *
+	 * // per-request dynamic suffix, e.g. multi-tenant isolation
+	 * route( "/api/products" ).withCache( suffix = ( event ) => event.getValue( "tenant", "" ) ).to( "products.index" );
+	 * 
+ * + * @timeout Cache timeout, in minutes. Blank uses the cache provider's default. + * @lastAccessTimeout Cache last access timeout, in minutes. + * @provider The CacheBox provider to store the cached output in. Defaults to `template`. + * @suffix A static string, or a closure/lambda `function( event )` evaluated fresh on every request, appended to the cache key. + * @cacheInclude RC keys to include in the cache key, comma-delimited. Defaults to `*` (all). + * @cacheExclude RC keys to exclude from the cache key, comma-delimited. + * @cacheFilter A closure/lambda `function( rc ):struct` to fully customize which RC keys build the cache key, in place of `cacheInclude`/`cacheExclude`. + * @etag Tier 1 HTTP caching: compute an ETag alongside the cached entry so a matching `If-None-Match` gets a 304 with no body. + * @etagWeak Compute the ETag above as a weak validator (`W/"..."`) instead of strong. + * @lastModified Tier 1 HTTP caching: stamp the cached entry with a Last-Modified time so a matching `If-Modified-Since` gets a 304. + * @cacheControl `Cache-Control` header value to send. Defaults to a `max-age` derived from `timeout` when `etag`/`lastModified` is set and no explicit value is given. + */ + function withCache( + any timeout = "", + any lastAccessTimeout = "", + string provider = "template", + any suffix = "", + string cacheInclude = "*", + string cacheExclude = "", + any cacheFilter = "", + boolean etag = false, + boolean etagWeak = false, + boolean lastModified = false, + string cacheControl = "" + ){ + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( arguments ); + } + + variables.thisRoute.cache = true; + variables.thisRoute.cacheTimeout = arguments.timeout; + variables.thisRoute.cacheLastAccessTimeout = arguments.lastAccessTimeout; + variables.thisRoute.cacheProvider = arguments.provider; + variables.thisRoute.cacheSuffix = arguments.suffix; + variables.thisRoute.cacheInclude = arguments.cacheInclude; + variables.thisRoute.cacheExclude = arguments.cacheExclude; + variables.thisRoute.cacheFilter = arguments.cacheFilter; + variables.thisRoute.etag = arguments.etag; + variables.thisRoute.etagWeak = arguments.etagWeak; + variables.thisRoute.lastModified = arguments.lastModified; + variables.thisRoute.cacheControl = arguments.cacheControl; + + return this; + } + /** * Normalize a mixed set of middleware targets - closures, WireBox IDs, object instances, * `{ target, point }` structs, or the name of a previously registered `middlewareGroup()` - into diff --git a/system/web/services/HandlerService.cfc b/system/web/services/HandlerService.cfc index 2660a235a..b24e06239 100644 --- a/system/web/services/HandlerService.cfc +++ b/system/web/services/HandlerService.cfc @@ -573,6 +573,16 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { * @requestContext The request context for the current request, passed through to a closure suffix untouched. */ struct function getEventMetadataEntry( required targetEvent, required requestContext ){ + // Route-level cache rules (Router.cfc's .withCache()) take full precedence over this + // handler's own annotations for this request - see getRouteCachingMetadata()'s docblock. + var routeCacheEntry = getRouteCachingMetadata( + arguments.requestContext.getCurrentRouteRecord(), + arguments.requestContext + ); + if ( !isNull( routeCacheEntry ) ) { + return routeCacheEntry; + } + if ( NOT structKeyExists( variables.eventCacheDictionary, arguments.targetEvent ) ) { return getNewMDEntry() } @@ -795,6 +805,56 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { } } + /** + * Build a cache metadata entry from route-level cache rules (`Router.cfc`'s `.withCache()`), + * used INSTEAD of the handler-annotation dictionary (`getNewMDEntry()`/`eventCacheDictionary`) + * whenever the current request's matched route opted into caching. Returns `null` when the + * route didn't match (an empty route record) or didn't declare `cache=true`, so callers fall + * through to the existing handler-annotation-driven path with zero behavior change. + * + * Deliberately NOT memoized the way the handler-annotation dictionary is: a route record is + * already a plain struct sitting on the matched route (no reflection needed to read it), so + * re-deriving this fresh on every request is cheap - and it's what lets two different routes + * that point at the same event carry two different cache policies, which the event-name-keyed + * handler dictionary can never do (it only knows the event, not which route reached it). + * + * The returned struct matches `getNewMDEntry()`'s shape exactly, so every downstream consumer + * (`EventURLFacade.buildEventKey()`, Bootstrap.cfc's Tier 1 conditional-GET block) needs no + * changes to understand a route-driven entry vs a handler-driven one. + * + * @routeRecord The current request's matched route record, i.e. `event.getCurrentRouteRecord()`. An empty struct when no route matched. + * @requestContext The request context for the current request, passed to a closure `cacheSuffix` untouched. + */ + private struct function getRouteCachingMetadata( required struct routeRecord, required requestContext ){ + if ( !arguments.routeRecord.keyExists( "cache" ) || !arguments.routeRecord.cache ) { + return; + } + + var mdEntry = getNewMDEntry(); + mdEntry.cacheable = true; + mdEntry.timeout = arguments.routeRecord.cacheTimeout; + mdEntry.lastAccessTimeout = arguments.routeRecord.cacheLastAccessTimeout; + mdEntry.provider = arguments.routeRecord.cacheProvider; + mdEntry.cacheInclude = arguments.routeRecord.cacheInclude; + mdEntry.cacheExclude = arguments.routeRecord.cacheExclude; + mdEntry.cacheFilter = arguments.routeRecord.cacheFilter; + mdEntry.etag = arguments.routeRecord.etag; + mdEntry.etagWeak = arguments.routeRecord.etagWeak; + mdEntry.lastModified = arguments.routeRecord.lastModified; + mdEntry.cacheControl = arguments.routeRecord.cacheControl; + + // A route-level suffix closure receives ( event ) only - unlike EVENT_CACHE_SUFFIX's + // ( eventHandlerBean, event ), a route has no reflected handler action metadata to hand it. + // Evaluated now, on every call, the same "never freeze a request-time value" contract + // resolveCacheSuffix() documents for the handler-annotation suffix. + var suffix = arguments.routeRecord.cacheSuffix; + mdEntry.suffix = ( isClosure( suffix ) || isCustomFunction( suffix ) ) + ? suffix( arguments.requestContext ) + : suffix; + + return mdEntry; + } + /** * Return the event caching metadata for an action execution context. * @@ -809,6 +869,16 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { required oEventHandler, required requestContext ){ + // Route-level cache rules (Router.cfc's .withCache()) take full precedence over this + // handler's own annotations for this request - see getRouteCachingMetadata()'s docblock. + var routeCacheEntry = getRouteCachingMetadata( + arguments.requestContext.getCurrentRouteRecord(), + arguments.requestContext + ); + if ( !isNull( routeCacheEntry ) ) { + return routeCacheEntry; + } + var cacheKey = arguments.ehBean.getFullEvent(); // Double lock for race conditions diff --git a/tests/specs/web/routing/RouterTest.cfc b/tests/specs/web/routing/RouterTest.cfc index 8162aebff..527f02f9b 100644 --- a/tests/specs/web/routing/RouterTest.cfc +++ b/tests/specs/web/routing/RouterTest.cfc @@ -629,6 +629,107 @@ component extends="coldbox.system.testing.BaseModelTest" { } ); } ); + story( "I want to cache a route's output via route-level rules", function(){ + given( "a route with no withCache() call", function(){ + then( "it defaults to non-cacheable with the standard cache key defaults", function(){ + router.route( "/luis" ).toHandler( "main" ); + var thisRoute = router.getRoutes()[ 1 ]; + + expect( thisRoute.cache ).toBeFalse(); + expect( thisRoute.cacheTimeout ).toBe( "" ); + expect( thisRoute.cacheLastAccessTimeout ).toBe( "" ); + expect( thisRoute.cacheProvider ).toBe( "template" ); + expect( thisRoute.cacheSuffix ).toBe( "" ); + expect( thisRoute.cacheInclude ).toBe( "*" ); + expect( thisRoute.cacheExclude ).toBe( "" ); + expect( thisRoute.cacheFilter ).toBe( "" ); + expect( thisRoute.etag ).toBeFalse(); + expect( thisRoute.etagWeak ).toBeFalse(); + expect( thisRoute.lastModified ).toBeFalse(); + expect( thisRoute.cacheControl ).toBe( "" ); + } ); + } ); + + given( "withCache() with only a timeout", function(){ + then( "cache flips on and the timeout is stored, everything else stays default", function(){ + router + .route( "/products" ) + .withCache( timeout = 60 ) + .toHandler( "products" ); + var thisRoute = router.getRoutes()[ 1 ]; + + expect( thisRoute.cache ).toBeTrue(); + expect( thisRoute.cacheTimeout ).toBe( 60 ); + expect( thisRoute.cacheProvider ).toBe( "template" ); + expect( thisRoute.etag ).toBeFalse(); + } ); + } ); + + given( "withCache() with a provider, includes and excludes", function(){ + then( "each is stored on the route untouched", function(){ + router + .route( "/reports" ) + .withCache( + timeout = 30, + provider = "reports", + cacheInclude = "id,type", + cacheExclude = "debug" + ) + .toHandler( "reports" ); + var thisRoute = router.getRoutes()[ 1 ]; + + expect( thisRoute.cacheProvider ).toBe( "reports" ); + expect( thisRoute.cacheInclude ).toBe( "id,type" ); + expect( thisRoute.cacheExclude ).toBe( "debug" ); + } ); + } ); + + given( "withCache() with etag/etagWeak/lastModified/cacheControl", function(){ + then( "the Tier 1 HTTP caching flags are stored on the route", function(){ + router + .route( "/api/products/:id" ) + .withCache( + timeout = 60, + etag = true, + etagWeak = true, + lastModified = true, + cacheControl = "private, max-age=120" + ) + .toHandler( "products" ); + var thisRoute = router.getRoutes()[ 1 ]; + + expect( thisRoute.etag ).toBeTrue(); + expect( thisRoute.etagWeak ).toBeTrue(); + expect( thisRoute.lastModified ).toBeTrue(); + expect( thisRoute.cacheControl ).toBe( "private, max-age=120" ); + } ); + } ); + + given( "withCache() with a closure suffix", function(){ + then( "the closure is stored untouched, not evaluated at registration time", function(){ + router + .route( "/tenant/products" ) + .withCache( suffix = ( event ) => "tenant-scoped" ) + .toHandler( "products" ); + var thisRoute = router.getRoutes()[ 1 ]; + + expect( isClosure( thisRoute.cacheSuffix ) || isCustomFunction( thisRoute.cacheSuffix ) ).toBeTrue(); + } ); + } ); + + given( "withCache() with a static string suffix", function(){ + then( "the string is stored as-is", function(){ + router + .route( "/products" ) + .withCache( suffix = "v2" ) + .toHandler( "products" ); + var thisRoute = router.getRoutes()[ 1 ]; + + expect( thisRoute.cacheSuffix ).toBe( "v2" ); + } ); + } ); + } ); + story( "Router will throw exception if a non-closure or string is passed to the body of a toResponse()", function(){ given( "Anything but a closure or string to the toResponse() body", function(){ then( "an InvalidArgumentException will be thrown", function(){ diff --git a/tests/specs/web/services/HandlerServiceTest.cfc b/tests/specs/web/services/HandlerServiceTest.cfc index 86ce1c260..c03302596 100755 --- a/tests/specs/web/services/HandlerServiceTest.cfc +++ b/tests/specs/web/services/HandlerServiceTest.cfc @@ -195,6 +195,112 @@ component extends="tests.resources.BaseIntegrationTest" { } ); } ); + describe( "Route-level cache rules (Router.cfc's withCache())", () => { + beforeEach( () => { + setup(); + variables.handlerService = controller.getHandlerService(); + makePublic( variables.handlerService, "getRouteCachingMetadata" ); + } ); + + // Mirrors the keys Router.cfc's routeDefinitionShape()/addRoute() put on a matched + // route record - tests build one by hand so they don't depend on the Router at all. + function buildRouteRecord( struct overrides = {} ){ + var base = { + "cache" : true, + "cacheTimeout" : 60, + "cacheLastAccessTimeout" : "", + "cacheProvider" : "template", + "cacheSuffix" : "", + "cacheInclude" : "*", + "cacheExclude" : "", + "cacheFilter" : "", + "etag" : false, + "etagWeak" : false, + "lastModified" : false, + "cacheControl" : "" + }; + base.append( arguments.overrides, true ); + return base; + } + + it( "returns null for a route record with no cache key at all", () => { + var result = variables.handlerService.getRouteCachingMetadata( {}, getRequestContext() ); + expect( isNull( result ) ).toBeTrue(); + } ); + + it( "returns null when the route record declares cache=false", () => { + var result = variables.handlerService.getRouteCachingMetadata( + buildRouteRecord( { cache : false } ), + getRequestContext() + ); + expect( isNull( result ) ).toBeTrue(); + } ); + + it( "builds a cacheable entry from a route record with cache=true", () => { + var result = variables.handlerService.getRouteCachingMetadata( + buildRouteRecord(), + getRequestContext() + ); + expect( result.cacheable ).toBeTrue(); + expect( result.timeout ).toBe( 60 ); + expect( result.provider ).toBe( "template" ); + } ); + + it( "evaluates a closure cacheSuffix immediately, passing it the event", () => { + var context = getRequestContext(); + context.setValue( "tenant", "acme" ); + var record = buildRouteRecord( { cacheSuffix : ( event ) => event.getValue( "tenant", "" ) } ); + + var result = variables.handlerService.getRouteCachingMetadata( record, context ); + + expect( result.suffix ).toBe( "acme" ); + } ); + + it( "stores a static string cacheSuffix untouched", () => { + var result = variables.handlerService.getRouteCachingMetadata( + buildRouteRecord( { cacheSuffix : "v2" } ), + getRequestContext() + ); + expect( result.suffix ).toBe( "v2" ); + } ); + + it( "carries the Tier 1 HTTP caching flags through", () => { + var record = buildRouteRecord( { + etag : true, + etagWeak : true, + lastModified : true, + cacheControl : "private, max-age=30" + } ); + + var result = variables.handlerService.getRouteCachingMetadata( record, getRequestContext() ); + + expect( result.etag ).toBeTrue(); + expect( result.etagWeak ).toBeTrue(); + expect( result.lastModified ).toBeTrue(); + expect( result.cacheControl ).toBe( "private, max-age=30" ); + } ); + + it( "getEventMetadataEntry() prefers route rules over an event with no handler cache annotation", () => { + var context = getRequestContext(); + context.setPrivateValue( "currentRouteRecord", buildRouteRecord() ); + + var entry = variables.handlerService.getEventMetadataEntry( "main.index", context ); + + expect( entry.cacheable ).toBeTrue(); + expect( entry.timeout ).toBe( 60 ); + } ); + + it( "getEventMetadataEntry() falls back to the handler-annotation path when the route declares no cache rule", () => { + var context = getRequestContext(); + // no currentRouteRecord set on this context - defaults to {} + + var entry = variables.handlerService.getEventMetadataEntry( "main.index", context ); + + // main.index carries no cache="true" annotation, so behavior is unchanged from before this feature + expect( entry.cacheable ).toBeFalse(); + } ); + } ); + describe( "Hot-path caching optimizations", () => { beforeEach( () => { setup(); From 5111ffc118195bbedcaa2863548c60108649ddc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 18:11:42 +0000 Subject: [PATCH 3/4] fix: Lucee/Adobe UDFCasterException on getRouteCachingMetadata() null return private struct function getRouteCachingMetadata() returned a bare `return;` (null) for the common "route doesn't opt into caching" case. Lucee enforces the declared struct return type strictly and throws UDFCasterException: Cannot cast null value to value of type [struct] on every request, which is why CI failed with 62 TestBox failures on lucee@5/lucee@6 - any spec touching HandlerService (event execution, handler bean lookups, etc) exercises getEventMetadataEntry(), which now always calls this function. Drop the explicit struct return type, matching the same "function that may return null" convention RequestService.cfc's getContextFromScope() already uses elsewhere in this codebase. Runtime behavior is unchanged - callers already null-check with isNull(). --- system/web/services/HandlerService.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/web/services/HandlerService.cfc b/system/web/services/HandlerService.cfc index b24e06239..2bf2454a8 100644 --- a/system/web/services/HandlerService.cfc +++ b/system/web/services/HandlerService.cfc @@ -825,7 +825,7 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { * @routeRecord The current request's matched route record, i.e. `event.getCurrentRouteRecord()`. An empty struct when no route matched. * @requestContext The request context for the current request, passed to a closure `cacheSuffix` untouched. */ - private struct function getRouteCachingMetadata( required struct routeRecord, required requestContext ){ + private function getRouteCachingMetadata( required struct routeRecord, required requestContext ){ if ( !arguments.routeRecord.keyExists( "cache" ) || !arguments.routeRecord.cache ) { return; } From 09b1e8c31e5e3adc5e99c0aa81b58ca240826e87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 18:53:16 +0000 Subject: [PATCH 4/4] test: address Copilot review - avoid nested named function in describe() buildRouteRecord() was declared as a named function nested inside a describe() closure. This spec suite's convention (and TestBox specs generally) is to assign test helpers to a local var closure instead, sidestepping any engine differences around nested named function declarations. No behavior change - same signature, same call sites. --- tests/specs/web/services/HandlerServiceTest.cfc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/specs/web/services/HandlerServiceTest.cfc b/tests/specs/web/services/HandlerServiceTest.cfc index c03302596..d4b7a3dc8 100755 --- a/tests/specs/web/services/HandlerServiceTest.cfc +++ b/tests/specs/web/services/HandlerServiceTest.cfc @@ -204,7 +204,7 @@ component extends="tests.resources.BaseIntegrationTest" { // Mirrors the keys Router.cfc's routeDefinitionShape()/addRoute() put on a matched // route record - tests build one by hand so they don't depend on the Router at all. - function buildRouteRecord( struct overrides = {} ){ + var buildRouteRecord = function( struct overrides = {} ){ var base = { "cache" : true, "cacheTimeout" : 60, @@ -221,7 +221,7 @@ component extends="tests.resources.BaseIntegrationTest" { }; base.append( arguments.overrides, true ); return base; - } + }; it( "returns null for a route record with no cache key at all", () => { var result = variables.handlerService.getRouteCachingMetadata( {}, getRequestContext() );