diff --git a/docs/analysis/nuxt-express-coldbox-analysis.md b/docs/analysis/nuxt-express-coldbox-analysis.md new file mode 100644 index 000000000..03858dca2 --- /dev/null +++ b/docs/analysis/nuxt-express-coldbox-analysis.md @@ -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). 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..2bf2454a8 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 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..d4b7a3dc8 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. + var buildRouteRecord = function( 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();