Skip to content

Instantly share code, notes, and snippets.

@Overbryd
Last active September 11, 2023 19:14
Show Gist options
  • Select an option

  • Save Overbryd/c070bb1fa769609d404f648cd506340f to your computer and use it in GitHub Desktop.

Select an option

Save Overbryd/c070bb1fa769609d404f648cd506340f to your computer and use it in GitHub Desktop.

Revisions

  1. Overbryd revised this gist Jun 26, 2018. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion worker.js
    Original file line number Diff line number Diff line change
    @@ -189,7 +189,7 @@ async function parseFragmentFallback(state, line) {
    */
    async function endParse(state) {
    if (state.fallbackBuffer !== null) {
    await writer.write(state.fallbackBuffer)
    write(state.writer, state.fallbackBuffer)
    }
    }

  2. Overbryd revised this gist Jun 26, 2018. 1 changed file with 1 addition and 2 deletions.
    3 changes: 1 addition & 2 deletions worker.js
    Original file line number Diff line number Diff line change
    @@ -98,8 +98,7 @@ async function transformBody(body, writable, fragments) {
    const buffer = encoding.decode(value, {stream: !done})
    const lines = (lastLine + buffer).split("\n")

    /* This loop is highly optimized
    * Basically it is a parse-tree keeping state between each line.
    /* 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
  3. Overbryd revised this gist Jun 26, 2018. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion 0-README.md
    Original file line number Diff line number Diff line change
    @@ -23,8 +23,8 @@ Or multiple `X-Fragments` header work too:
    < X-Fragments: footer https://yoursite.com/footer.html
    ```

    Then in your response body, you can have specific html comments that will be replaced by the prefetched responses.
    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.

    ```html
    <!DOCTYPE HTML>
  4. Overbryd revised this gist Jun 26, 2018. 1 changed file with 5 additions and 2 deletions.
    7 changes: 5 additions & 2 deletions worker.js
    Original file line number Diff line number Diff line change
    @@ -98,7 +98,8 @@ async function transformBody(body, writable, fragments) {
    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.
    /* This loop is highly optimized
    * Basically it is 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
    @@ -147,11 +148,13 @@ async function parse(state, line) {
    const fragmentPromise = state.fragments[key]

    if (fragmentEnd && fragmentPromise) {
    await writeFragment(fragmentPromise, state.writer, "")
    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]
    }
    }
  5. Overbryd revised this gist Jun 26, 2018. 1 changed file with 1 addition and 2 deletions.
    3 changes: 1 addition & 2 deletions worker.js
    Original file line number Diff line number Diff line change
    @@ -98,8 +98,7 @@ async function transformBody(body, writable, fragments) {
    const buffer = encoding.decode(value, {stream: !done})
    const lines = (lastLine + buffer).split("\n")

    /* This loop is highly optimized
    * Basically it is a parse-tree keeping state between each line.
    /* 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
  6. Overbryd revised this gist Jun 26, 2018. 1 changed file with 46 additions and 0 deletions.
    46 changes: 46 additions & 0 deletions 0-README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,46 @@
    # 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
    ```

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

    ```html
    <!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>
    ```
  7. Overbryd revised this gist Jun 26, 2018. 1 changed file with 1 addition and 5 deletions.
    6 changes: 1 addition & 5 deletions worker.js
    Original file line number Diff line number Diff line change
    @@ -46,15 +46,10 @@ function prefetchFragments(headers) {
    }, safeTimeout)
    })

    /*
    fragments[key] = Promise.race([
    fetch(request),
    timeout
    ])
    */
    //*
    fragments[key] = fetch(request)
    //*/
    })

    return fragments
    @@ -241,3 +236,4 @@ async function write(writer, str) {
    const bytes = new TextEncoder('utf-8').encode(str)
    await writer.write(bytes)
    }

  8. Overbryd created this gist Jun 26, 2018.
    243 changes: 243 additions & 0 deletions worker.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,243 @@
    /* 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
    ])
    */
    //*
    fragments[key] = fetch(request)
    //*/
    })

    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 highly optimized
    * Basically it is 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, "")
    return [parse, state]
    } else if (fragmentPromise) {
    state.fragmentPromise = state.fragments[key]
    state.fallbackBuffer = ""
    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) {
    await writer.write(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)
    }