|
/* 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) |
|
} |
|
|
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?