Skip to content

Instantly share code, notes, and snippets.

@Overbryd
Last active September 11, 2023 19:14
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save Overbryd/c070bb1fa769609d404f648cd506340f to your computer and use it in GitHub Desktop.
Save Overbryd/c070bb1fa769609d404f648cd506340f to your computer and use it in GitHub Desktop.
Cloudflare Fragment Caching

Cloudflare fragment rendering/caching

This worker script will evaluate your origin response, and replace html comments marked as fragment:key with a respective prefetch defined in a X-Fragments response header.

Usage

Your origin must include the X-Fragments header, specifying the a comma separated list of prefetch requests to make for that response.

< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 3825
< X-Fragments: header http://localhost:8080/header.html, footer http://localhost:8080/footer.html

Or multiple X-Fragments header work too:

< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 3825
< X-Fragments: header https://yoursite.com/header.html
< X-Fragments: footer https://yoursite.com/footer.html

All prefetches happen asynchronously in parallel. Then in your response body, you can have specific html comments that will be replaced by the prefetched responses.

<!DOCTYPE HTML>
<html>
  <head>
    <!-- fragment:header -->
    <title>Cloudflare Fragments</title>
  </head>
  <body>
    Some of your body content.
    <!-- fragment:footer
      <p>This would be the fallback content if
         'footer' does not fetch in time,
         is unspecified
         or does not respond successfully</p>
    -->
  </body>
</html>
/* Define regular expressions at top to have them precompiled.
*/
const htmlContentType = new RegExp('text\/html', 'i')
const fragmentStart = new RegExp('<!-- fragment:(\\w+)( -->)?')
const commentEnd = new RegExp('-->')
addEventListener('fetch', event => {
event.respondWith(main(event.request))
})
/* The main entry function
*/
async function main(request) {
const response = await fetch(request)
const fragments = prefetchFragments(response.headers)
return transformResponse(response, fragments)
}
/* Build a dictionary of promises that we can evaluate later.
* These fetch or timeout.
*
* The overall timeout is shared by each promise. The cumulative amount of time, that
* all fetch-requests can spend is 10 seconds.
*
* Each fetch request defined in the headers gets a fair share.
* We let the promises race and later fail gracefully when the fetch does not return in time.
*
* This is an important circuit-breaker mechanism, to not fail the main request.
*/
function prefetchFragments(headers) {
const header = headers.get('X-Fragments')
if (header === null) return {}
const fragments = {}
const values = header.split(',')
const safeTimeout = 10000 / values.length
values.forEach((entry) => {
const [key, url] = entry.trim().split(' ')
const request = new Request(url)
const timeout = new Promise((resolve, reject) => {
const wait = setTimeout(() => {
clearTimeout(wait)
reject()
}, safeTimeout)
})
fragments[key] = Promise.race([
fetch(request),
timeout
])
})
return fragments
}
/*
* Here we decide whether we are going to stream & parse the response body,
* or just return the response as is, since the request is not eligble for fragments.
*
* Only Content-Type: text/html responses with one or more fragments are going to be evaluated.
*/
function transformResponse(response, fragments) {
const contentType = response.headers.get('Content-Type')
if (
contentType
&& htmlContentType.test(contentType)
&& Object.keys(fragments).length > 0
) {
const { readable, writable } = new TransformStream()
transformBody(response.body, writable, fragments)
return new Response(readable, response)
} else {
return response
}
}
/*
* This function transforms the origin response body.
*
* It assumes the response to be utf-8 encoded
*/
async function transformBody(body, writable, fragments) {
const encoding = new TextDecoder('utf-8')
const reader = body.getReader()
const writer = writable.getWriter()
// initialise the parser state
let state = {writer: writer, fragments: fragments}
let fun = parse
let lastLine = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
const buffer = encoding.decode(value, {stream: !done})
const lines = (lastLine + buffer).split("\n")
/* This loop is basically a parse-tree keeping state between each line.
*
* But most important, is to not include the last line.
* The response chunks, might be cut-off just in the middle of a line, and thus not representing
* a full line that can be reasoned about.
*
* Therefore we keep the last line, and concatenate it with the next lines.
*/
let i = 0;
const length = lines.length - 1;
for (; i < length; i++) {
const line = lines[i]
const resp = await fun(state, line)
let [nextFun, newState] = resp
fun = nextFun
state = newState
}
lastLine = lines[length] || ""
}
endParse(state)
await writer.close()
}
/*
* This is the main parser function.
* The state machine goes like this:
*
* parse
* -> ON fragmentMatch with fallback
* > parseFragmentFallback
*
* -> ON fragmentMatch without fallback
* > parse
*
* parseFragmentFallback
* -> ON closing comment
* > parse
*/
async function parse(state, line) {
const fragmentMatch = line.match(fragmentStart)
if (fragmentMatch) {
const [match, key, fragmentEnd] = fragmentMatch
const fragmentPromise = state.fragments[key]
if (fragmentEnd && fragmentPromise) {
await writeFragment(fragmentPromise, state.writer, line + "\n")
return [parse, state]
} else if (fragmentPromise) {
state.fragmentPromise = state.fragments[key]
state.fallbackBuffer = ""
write(state.writer, line.replace(fragmentStart, ""))
return [parseFragmentFallback, state]
}
}
write(state.writer, line + "\n")
return [parse, state]
}
/*
* This is a sub-state, that is looking for a closing comment --> to terminate the fallback.
* It will keep buffering the response to build the fallback buffer.
*
* When it finds a `-->` on a line, it will attempt to write the fragment.
*/
async function parseFragmentFallback(state, line) {
if (commentEnd.test(line)) {
await writeFragment(state.fragmentPromise, state.writer, state.fallbackBuffer)
state.fragmentPromise = null
state.fallbackBuffer = null
write(state.writer, line.replace(commentEnd, "\n"))
return [parse, state]
} else {
state.fallbackBuffer = state.fallbackBuffer + line + "\n"
return [parseFragmentFallback, state]
}
}
/*
* This is called after traversing all lines.
* If we have accumulated fallback buffer until here,
* we might want to dump the response, because someone forgot to add an closing '-->' comment tag.
*/
async function endParse(state) {
if (state.fallbackBuffer !== null) {
write(state.writer, state.fallbackBuffer)
}
}
/*
* This function handles a fragment.
* In order for a fragment to render, it must fetch in time and respond with a success state.
*
* The function will attempt to resolve the promise and pipe any successful response directly
* to the main response. Blocking until the fragment response is consumed.
*
* If the fragment does not respond in time (a timeout happened), we attempt to render a fallback.
*
* If the fragment response is not succesful, we attempt to render a fallback.
*/
async function writeFragment(fragmentPromise, writer, fallbackResponse) {
try {
const fragmentResponse = await fragmentPromise
if (fragmentResponse.ok) {
await pipe(fragmentResponse.body.getReader(), writer)
} else {
write(writer, fallbackResponse)
}
} catch(e) {
write(writer, fallbackResponse)
}
}
/*
* Helper function to pipe one stream into the other.
*/
async function pipe(reader, writer) {
while (true) {
const { done, value } = await reader.read()
if (done) break
await writer.write(value)
}
}
/*
* Helper function to write an utf-8 string to a stream.
*/
async function write(writer, str) {
const bytes = new TextEncoder('utf-8').encode(str)
await writer.write(bytes)
}
@kingbiscit
Copy link

kingbiscit commented Oct 22, 2018

Hi

Really hoping you can help me with this. I'm attempting to use this code but the fragments are only replaced when inside head the body just gets a blank space where the fragment should be?

@kingbiscit
Copy link

The script is working better now bit I have noticed (end of string is always removed?

@kingbiscit
Copy link

It basically doesn't put the last line back on, so a HTML page is always missing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment