Skip to content

Instantly share code, notes, and snippets.

@ngbrown
Last active August 30, 2024 07:25
Show Gist options
  • Save ngbrown/cf6d1c12b2c42acbd7e5fd4e8b54378b to your computer and use it in GitHub Desktop.
Save ngbrown/cf6d1c12b2c42acbd7e5fd4e8b54378b to your computer and use it in GitHub Desktop.
Populate Links in Remix for Cloudflare 103 hints
/**
* 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