Skip to content

Instantly share code, notes, and snippets.

@misiek08
Forked from Overbryd/0-README.md
Created December 6, 2018 23:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save misiek08/4eaa0464f079e4b43946aad7e1cc3550 to your computer and use it in GitHub Desktop.
Save misiek08/4eaa0464f079e4b43946aad7e1cc3550 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)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment