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
598 changes: 598 additions & 0 deletions docs/specs/http-caching.md

Large diffs are not rendered by default.

92 changes: 78 additions & 14 deletions system/Bootstrap.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -266,26 +266,53 @@ component serializable="false" accessors="true" {
event.setHTTPHeader( name = key, value = value );
} );

// Cached Status Code
// ****** HTTP CACHING - TIER 1 conditional-GET (docs/specs/http-caching.md §4.2) ******
// Replay whatever conditional-GET headers were stored alongside this entry, reusing
// event.etag()/event.lastModified() for the actual header-set + match logic rather
// than re-implementing it here - same matching rules (weak comparison, If-None-Match
// lists/`*`, the If-Modified-Since-is-ignored-when-If-None-Match-is-present
// precedence) whether the tag was just computed or is being replayed from cache.
var cachedNotModified = false;
if ( structKeyExists( local.refResults.eventCaching, "etag" ) ) {
cachedNotModified = event.etag(
value = local.refResults.eventCaching.etag,
weak = local.refResults.eventCaching.etagWeak ?: false
);
}
if ( structKeyExists( local.refResults.eventCaching, "lastModified" ) ) {
cachedNotModified = event.lastModified( local.refResults.eventCaching.lastModified ) || cachedNotModified;
}
if ( structKeyExists( local.refResults.eventCaching, "cacheControl" ) ) {
event.setHTTPHeader(
name = "Cache-Control",
value = local.refResults.eventCaching.cacheControl
);
}

// Cached Status Code - a conditional-GET match already set 304 via etag()/lastModified() above.
if (
!cachedNotModified &&
isNumeric( local.refResults.eventCaching.statusCode ) && local.refResults.eventCaching.statusCode > 0
) {
event.setHTTPHeader( statusCode = local.refResults.eventCaching.statusCode );
}

// Render Content as binary or just output
if ( local.refResults.eventCaching.isBinary ) {
cbController
.getDataMarshaller()
.renderContent(
type = "#local.refResults.eventCaching.contentType#",
variable = "#local.refResults.eventCaching.renderedContent#"
);
} else {
cbController
.getDataMarshaller()
.renderContent( type = "#local.refResults.eventCaching.contentType#", reset = true );
writeOutput( local.refResults.eventCaching.renderedContent );
// Render Content as binary or just output - skipped entirely on a conditional-GET
// match, which is the whole point: no body write at all, not even a replay.
if ( !cachedNotModified ) {
if ( local.refResults.eventCaching.isBinary ) {
cbController
.getDataMarshaller()
.renderContent(
type = "#local.refResults.eventCaching.contentType#",
variable = "#local.refResults.eventCaching.renderedContent#"
);
} else {
cbController
.getDataMarshaller()
.renderContent( type = "#local.refResults.eventCaching.contentType#", reset = true );
writeOutput( local.refResults.eventCaching.renderedContent );
}
}
} else {
// ****** EXECUTE MAIN EVENT *******/
Expand Down Expand Up @@ -361,6 +388,43 @@ component serializable="false" accessors="true" {
responseHeaders : event.getResponseHeaders()
};

// ****** HTTP CACHING - TIER 1 (docs/specs/http-caching.md §4.2/§4.4) ******
// Opt-in via etag/lastModified/cacheControl annotations alongside cache=true.
// Computed once, right here at write time, and stored on the entry so every
// subsequent cache hit can compare against it for free - no per-request hashing.
if ( eCacheEntry.etag ) {
cacheEntry.etag = hash( renderedContent, "MD5" );
cacheEntry.etagWeak = eCacheEntry.etagWeak;
event.setHTTPHeader(
name = "ETag",
value = ( eCacheEntry.etagWeak ? "W/" : "" ) & """#cacheEntry.etag#"""
);
}
if ( eCacheEntry.lastModified ) {
cacheEntry.lastModified = now();
event.setHTTPHeader(
name = "Last-Modified",
value = event.toHTTPDate( cacheEntry.lastModified )
);
}
if ( len( eCacheEntry.cacheControl ) ) {
cacheEntry.cacheControl = eCacheEntry.cacheControl;
} else if (
( eCacheEntry.etag || eCacheEntry.lastModified ) &&
isNumeric( eCacheEntry.timeout )
) {
// No explicit directive, but the handler opted into conditional-GET
// support - default to telling the client the same lifetime the
// handler already told CacheBox (in minutes; Cache-Control wants
// seconds), rather than saying nothing at all. A blank cacheTimeout
// means "use the provider's default", which we can't translate to a
// max-age, so no default is inferred in that case.
cacheEntry.cacheControl = "private, max-age=#eCacheEntry.timeout * 60#";
}
if ( structKeyExists( cacheEntry, "cacheControl" ) ) {
event.setHTTPHeader( name = "Cache-Control", value = cacheEntry.cacheControl );
}

// is this a render data entry? If So, append data
if ( !renderData.isEmpty() ) {
structAppend( cacheEntry, renderData, true );
Expand Down
8 changes: 5 additions & 3 deletions system/RestHandler.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,11 @@ component extends="EventHandler" {
// end timer
arguments.prc.response.setResponseTime( getTickCount() - stime );

// SSE streams have already committed the response. Both the marshalling below and the
// header flush further down would be write-after-commit, so bail out entirely.
if ( arguments.event.isSSE() ) {
// SSE streams, and a conditional-GET already resolved with event.etag()/lastModified()
// (docs/specs/http-caching.md §6), have both already committed the response - the
// marshalling below and the header flush further down would be write-after-commit
// against either, so bail out entirely.
if ( arguments.event.isSSE() || arguments.event.isNoExecution() ) {
if ( !isNull( local.actionResults ) ) {
return local.actionResults;
}
Expand Down
197 changes: 197 additions & 0 deletions system/web/context/RequestContext.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1832,6 +1832,203 @@ component serializable="false" accessors="true" {
return structKeyExists( variables.controller, "mockController" );
}

/**
* Is this request currently flagged to skip event execution?
*
* Set by `noExecution()`. Framework guard points (e.g. `RestHandler.aroundHandler`) use this
* to avoid a write-after-commit against a response that a conditional-GET already resolved
* with a bare status code, the same way `isSSE()` guards against writing to a committed stream.
*/
boolean function isNoExecution(){
return variables.isNoExecution;
}

/**
* Sets the ETag response header and checks it against an incoming If-None-Match.
*
* On a match, short-circuits the request: calls `noExecution()` and responds `304` with no
* body. Never short-circuits unsafe HTTP methods (anything but GET/HEAD), regardless of
* whether the entity tags match, since a conditional-GET result has no meaning for a mutation.
*
* <pre>
* function show( event, rc, prc ){
* prc.product = productService.get( rc.id )
* if( event.etag( prc.product.getHash() ) ){
* return
* }
* event.setView( "products/show" )
* }
* </pre>
*
* @value The entity tag value. Quoting is handled here - pass the raw value.
* @weak Mark as a weak validator (`W/"..."`) - use for a semantically-but-not-byte-identical representation.
*
* @return True if the request was short-circuited with a 304
*/
boolean function etag( required string value, boolean weak = false ){
var tag = ( arguments.weak ? "W/" : "" ) & """#arguments.value#""";
setHTTPHeader( name = "ETag", value = tag );

if ( isSafeHTTPMethod() && matchesIfNoneMatch( tag ) ) {
noExecution();
setHTTPHeader( statusCode = 304 );
return true;
}
return false;
}

/**
* Sets the Last-Modified response header and checks it against an incoming If-Modified-Since.
*
* On a match, short-circuits the request the same way `etag()` does. HTTP-date granularity is
* seconds - callers with sub-second timestamps should round down, never up, to avoid a false
* negative (reporting the resource as modified when it was not).
*
* Per RFC 7232 §3.3, a request carrying an If-None-Match header MUST have its If-Modified-Since
* ignored - the entity tag is the more precise signal, so a request with both never short-circuits
* here, even if the date matches (call `etag()` for that comparison instead).
*
* @value The last-modified timestamp of the resource
*
* @return True if the request was short-circuited with a 304
*/
boolean function lastModified( required date value ){
setHTTPHeader( name = "Last-Modified", value = toHTTPDate( arguments.value ) );

var since = getHTTPHeader( "If-Modified-Since", "" );
if (
isSafeHTTPMethod() &&
!len( getHTTPHeader( "If-None-Match", "" ) ) &&
len( since ) &&
isDate( since ) &&
parseDateTime( since ) >= arguments.value
) {
noExecution();
setHTTPHeader( statusCode = 304 );
return true;
}
return false;
}

/**
* Sets the Cache-Control response header from a directive struct.
*
* Boolean `true` values become bare directives (`"public"`, `"no-cache"`); any other value
* becomes `"key=value"`.
*
* @directives e.g. `{ "public" : true, "max-age" : 60, "stale-while-revalidate" : 30 }`
*
* @return RequestContext
*/
function cacheControl( struct directives = { "no-cache" : true } ){
setHTTPHeader(
name = "Cache-Control",
value = arguments.directives
.reduce( ( acc, key, val ) => {
// isBoolean() is loosely true for any castable value (isBoolean(60) is true in
// CFML/BoxLang), so numerics must be excluded explicitly or a directive like
// max-age=60 silently loses its value and becomes the bare token "max-age".
acc.append( ( isBoolean( val ) && !isNumeric( val ) && val ) ? key : "#key#=#val#" );
return acc;
}, [] )
.toList( ", " )
);
return this;
}

/**
* Is the current request's HTTP method safe to answer with a conditional-GET short-circuit?
*
* Only GET and HEAD are safe - a 304 in response to a POST/PUT/PATCH/DELETE would be a
* specification violation and a correctness hazard, so `etag()`/`lastModified()` refuse to
* short-circuit anything else regardless of whether the entity tags/dates match.
*/
private boolean function isSafeHTTPMethod(){
return listFindNoCase( "GET,HEAD", getHTTPMethod() ) > 0;
}

/**
* Checks a fully-quoted (and, if weak, `W/`-prefixed) entity tag against the incoming
* If-None-Match header, per RFC 7232 §3.2/§2.3.2:
* - `*` always matches - a GET/HEAD that reached this point has *some* current representation,
* which is all `If-None-Match: *` asks about.
* - The header may be a comma-separated list of entity tags; a match against any one counts.
* - If-None-Match always uses *weak* comparison, so the `W/` prefix is stripped from both sides
* before comparing - a weak and a strong tag with the same opaque value are still a match.
*
* Splits on a bare comma rather than a quoted-string-aware parser - sufficient for the opaque
* hash-style values this framework generates and accepts, which never contain a literal comma.
*
* @tag The tag to check for a match
*/
private boolean function matchesIfNoneMatch( required string tag ){
var header = trim( getHTTPHeader( "If-None-Match", "" ) );
if ( !len( header ) ) {
return false;
}
if ( header == "*" ) {
return true;
}

var normalizedTag = reReplace( arguments.tag, "^W/", "" );
for ( var candidate in listToArray( header, "," ) ) {
if ( reReplace( trim( candidate ), "^W/", "" ) == normalizedTag ) {
return true;
}
}
return false;
}

/**
* Format a date as an RFC 7231 HTTP-date (e.g. `Sun, 06 Nov 1994 08:49:37 GMT`), for use in
* `Last-Modified`, `Expires` and similar headers.
*
* Built from individual date parts rather than a `dateTimeFormat()` mask: CFML's classic mask
* letters ("ddd" for an abbreviated weekday name) and Java's `DateTimeFormatter` pattern
* letters ("EEE" for the same thing) are not the same dialect, and which one a given engine's
* `dateTimeFormat()` actually implements is not something to gamble on in framework code that
* has to run identically on BoxLang, Lucee and Adobe.
*
* `now()` and date literals) - converted to UTC internally so the trailing "GMT" is accurate
* regardless of the server's own timezone.
*
* @value The date/time to format, as a local server-time value (the CFML/BoxLang default for
*/
string function toHTTPDate( required date value ){
var utcValue = dateConvert( "local2utc", arguments.value );
var dayNames = [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
];
var monthNames = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
];

return dayNames[ dayOfWeek( utcValue ) ] & ", " &
numberFormat( day( utcValue ), "00" ) & " " &
monthNames[ month( utcValue ) ] & " " &
year( utcValue ) & " " &
numberFormat( hour( utcValue ), "00" ) & ":" &
numberFormat( minute( utcValue ), "00" ) & ":" &
numberFormat( second( utcValue ), "00" ) & " GMT";
}

/**
* Get the routed structure of key-value pairs. What the ses interceptor could match.
*
Expand Down
37 changes: 37 additions & 0 deletions system/web/context/Response.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,43 @@ component accessors="true" {
return this
}

/**
* Sets the ETag response header
*
* @value The entity tag value. Quoting is handled here - pass the raw value.
* @weak Mark as a weak validator (`W/"..."`)
*
* @return Returns the Response object for chaining
*/
Response function withETag( required string value, boolean weak = false ){
return setHeader( "ETag", ( arguments.weak ? "W/" : "" ) & """#arguments.value#""" )
}

/**
* Sets the Cache-Control response header from a directive struct
*
* Boolean `true` values become bare directives (`"public"`, `"no-cache"`); any other value
* becomes `"key=value"`.
*
* @directives e.g. `{ "public" : true, "max-age" : 60, "stale-while-revalidate" : 30 }`
*
* @return Returns the Response object for chaining
*/
Response function withCacheControl( struct directives = { "no-cache" : true } ){
return setHeader(
"Cache-Control",
arguments.directives
.reduce( ( acc, key, val ) => {
// isBoolean() is loosely true for any castable value (isBoolean(60) is true in
// CFML/BoxLang), so numerics must be excluded explicitly or a directive like
// max-age=60 silently loses its value and becomes the bare token "max-age".
acc.append( ( isBoolean( val ) && !isNumeric( val ) && val ) ? key : "#key#=#val#" )
return acc
}, [] )
.toList( ", " )
)
}

/**
* Set the pagination data
*
Expand Down
Loading
Loading