Last active
August 30, 2024 07:25
-
-
Save ngbrown/cf6d1c12b2c42acbd7e5fd4e8b54378b to your computer and use it in GitHub Desktop.
Populate Links in Remix for Cloudflare 103 hints
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* By default, Remix will handle generating the HTTP Response for you. | |
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ | |
* For more information, see https://remix.run/file-conventions/entry.server | |
*/ | |
import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare"; | |
import { RemixServer } from "@remix-run/react"; | |
import { isbot } from "isbot"; | |
import { renderToReadableStream } from "react-dom/server"; | |
export default async function handleRequest( | |
request: Request, | |
responseStatusCode: number, | |
responseHeaders: Headers, | |
remixContext: EntryContext, | |
// This is ignored so we can keep it in the template for visibility. Feel | |
// free to delete this parameter in your app if you're not using it! | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
loadContext: AppLoadContext, | |
) { | |
const body = await renderToReadableStream( | |
<RemixServer context={remixContext} url={request.url} />, | |
{ | |
signal: request.signal, | |
onError(error: unknown) { | |
// Log streaming rendering errors from inside the shell | |
console.error(error); | |
responseStatusCode = 500; | |
}, | |
}, | |
); | |
if (isbot(request.headers.get("user-agent") || "")) { | |
await body.allReady; | |
} | |
preloadRouteAssets(remixContext, responseHeaders); | |
responseHeaders.set("Content-Type", "text/html"); | |
return new Response(body, { | |
headers: responseHeaders, | |
status: responseStatusCode, | |
}); | |
} | |
type PreLink = { | |
href: string; | |
rel?: "preload" | "preconnect"; | |
as?: string; | |
cors?: boolean | "anonymous" | "use-credentials"; | |
}; | |
function preloadRouteAssets(context: EntryContext, headers: Headers) { | |
const requestRoutes = context.staticHandlerContext.matches.map( | |
(x) => x.route.id, | |
); | |
const linkPreload = requestRoutes | |
.flatMap((route) => { | |
const module = context.routeModules[route]; | |
return module && module.links instanceof Function ? module.links() : []; | |
}) | |
.map((link) => { | |
if (!("href" in link) || link.href == null) return null; | |
if (!link.href.startsWith("/") || link.rel === "preconnect") { | |
const url = new URL(link.href); | |
return { | |
href: `${url.protocol}//${url.host}`, | |
rel: "preconnect", | |
cors: link.crossOrigin, | |
} as PreLink; | |
} | |
if (link.as != null) { | |
return { | |
href: link.href, | |
as: link.as, | |
cors: link.as === "font" || link.as === "fetch", | |
} as PreLink; | |
} | |
if (link.rel === "stylesheet") | |
return { href: link.href, as: "style" } as PreLink; | |
return null; | |
}) | |
.filter((link): link is PreLink => link != null && link.href != null) | |
.filter((item, index, list) => { | |
// dedupe | |
return index === list.findIndex((link) => link.href === item.href); | |
}); | |
linkPreload.forEach((link) => | |
headers.append( | |
"Link", | |
[ | |
`<${encodeURI(link.href)}>`, | |
`rel=${link.rel ?? "preload"}`, | |
link.as && `as=${link.as}`, | |
link.cors && | |
`crossorigin=${typeof link.cors === "string" ? link.cors : "anonymous"}`, | |
] | |
.filter(isTruthy) | |
.join("; "), | |
), | |
); | |
const scriptPreload = [ | |
context.manifest.url, | |
context.manifest.entry.module, | |
...context.manifest.entry.imports, | |
].concat( | |
requestRoutes | |
.map((route) => context.manifest.routes[route]) | |
.filter(isTruthy) | |
.map((er) => [er.module, ...(er.imports ?? [])]) | |
.flat(1), | |
); | |
// module scripts need CORS, while legacy scripts did not. | |
dedupe(scriptPreload).forEach((script) => | |
headers.append( | |
"Link", | |
`<${encodeURI(script)}>; rel=preload; as=script; crossorigin=anonymous`, | |
), | |
); | |
// When loading assets, font and fetch preloading requires the crossorigin attribute to be set. Also module scripts require crossorigin. | |
// TODO: investigate `modulepreload`. It doesn't appear to work, but there is a request for it: https://community.cloudflare.com/t/support-rel-modulepreload-for-automatic-link-header-generation/550051 | |
// MDN says that only "preload" and "preconnect" are reliable for the Link header. | |
// TODO: Filter to only relative paths (option to include specific origins, must be handled by same certificate?, not supported by Cloudflare) | |
// TODO: Percent-encode relative paths (everything over 255, `<`, and `>`) | |
// TODO: Look at https://github.com/sergiodxa/remix-utils/blob/main/src/server/preload-route-assets.ts | |
} | |
function dedupe<T>(array: T[]): T[] { | |
return [...new Set(array)]; | |
} | |
function isTruthy<T>(x: T | null | undefined): x is T { | |
return !!x; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment