|
/* 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) |
|
} |
This comment has been minimized.
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?