diff --git a/functions/_common/env.ts b/functions/_common/env.ts new file mode 100644 index 00000000000..31012379905 --- /dev/null +++ b/functions/_common/env.ts @@ -0,0 +1,12 @@ +export interface Env { + ASSETS: { + fetch: typeof fetch + } + url: URL + GRAPHER_CONFIG_R2_BUCKET_URL: string + GRAPHER_CONFIG_R2_BUCKET_FALLBACK_URL: string + GRAPHER_CONFIG_R2_BUCKET_PATH: string + GRAPHER_CONFIG_R2_BUCKET_FALLBACK_PATH: string + CF_PAGES_BRANCH: string + ENV: string +} diff --git a/functions/_common/grapherRenderer.ts b/functions/_common/grapherRenderer.ts index f189d2b6329..e98d1d26b58 100644 --- a/functions/_common/grapherRenderer.ts +++ b/functions/_common/grapherRenderer.ts @@ -16,7 +16,7 @@ import LatoRegular from "../_common/fonts/LatoLatin-Regular.ttf.bin" import LatoMedium from "../_common/fonts/LatoLatin-Medium.ttf.bin" import LatoBold from "../_common/fonts/LatoLatin-Bold.ttf.bin" import PlayfairSemiBold from "../_common/fonts/PlayfairDisplayLatin-SemiBold.ttf.bin" -import { Env } from "../grapher/thumbnail/[slug].js" +import { Env } from "./env.js" declare global { // eslint-disable-next-line no-var @@ -158,14 +158,17 @@ async function fetchFromR2( return primaryResponse } -async function fetchAndRenderGrapherToSvg( +interface FetchGrapherConfigResult { + grapherConfig: GrapherInterface | null + status: number + etag: string | undefined +} + +export async function fetchUnparsedGrapherConfig( slug: string, - options: ImageOptions, - searchParams: URLSearchParams, - env: Env + env: Env, + etag?: string ) { - const grapherLogger = new TimeLogger("grapher") - // The top level directory is either the bucket path (should be set in dev environments and production) // or the branch name on preview staging environments console.log("branch", env.CF_PAGES_BRANCH) @@ -202,19 +205,54 @@ async function fetchAndRenderGrapherToSvg( } // Fetch grapher config - const fetchResponse = await fetchFromR2(requestUrl, undefined, fallbackUrl) + return fetchFromR2(requestUrl, etag, fallbackUrl) +} + +export async function fetchGrapherConfig( + slug: string, + env: Env, + etag?: string +): Promise { + const fetchResponse = await fetchUnparsedGrapherConfig(slug, env, etag) if (fetchResponse.status !== 200) { - console.log("Failed to fetch grapher config", fetchResponse.status) - return null + console.log( + "Status code is not 200, returning empty response with status code", + fetchResponse.status + ) + return { + grapherConfig: null, + status: fetchResponse.status, + etag: fetchResponse.headers.get("etag"), + } } const grapherConfig: GrapherInterface = await fetchResponse.json() console.log("grapher title", grapherConfig.title) + return { + grapherConfig, + status: 200, + etag: fetchResponse.headers.get("etag"), + } +} + +async function fetchAndRenderGrapherToSvg( + slug: string, + options: ImageOptions, + searchParams: URLSearchParams, + env: Env +) { + const grapherLogger = new TimeLogger("grapher") + + const grapherConfigResponse = await fetchGrapherConfig(slug, env) + + if (grapherConfigResponse.status !== 200) { + return null + } const bounds = new Bounds(0, 0, options.svgWidth, options.svgHeight) const grapher = new Grapher({ - ...grapherConfig, + ...grapherConfigResponse.grapherConfig, bakedGrapherURL: grapherBaseUrl, queryStr: "?" + searchParams.toString(), bounds, @@ -298,3 +336,27 @@ async function renderSvgToPng(svg: string, options: ImageOptions) { pngLogger.log("svg2png") return pngData } + +export async function getOptionalRedirectForSlug( + slug: string, + baseUrl: URL, + env: Env +) { + const redirects: Record = await env.ASSETS.fetch( + new URL("/grapher/_grapherRedirects.json", baseUrl), + { cf: { cacheTtl: 2 * 60 } } + ) + .then((r): Promise> => r.json()) + .catch((e) => { + console.error("Error fetching redirects", e) + return {} + }) + return redirects[slug] +} + +export function createRedirectResponse(redirSlug: string, currentUrl: URL) { + new Response(null, { + status: 302, + headers: { Location: `/grapher/${redirSlug}${currentUrl.search}` }, + }) +} diff --git a/functions/grapher/[slug].ts b/functions/grapher/[slug].ts index 17d6d24563a..435adf87082 100644 --- a/functions/grapher/[slug].ts +++ b/functions/grapher/[slug].ts @@ -1,33 +1,57 @@ -export const onRequestGet: PagesFunction = async (context) => { +import { Env } from "../_common/env.js" +import { + getOptionalRedirectForSlug, + createRedirectResponse, + fetchUnparsedGrapherConfig, +} from "../_common/grapherRenderer.js" +import { IRequestStrict, Router, error, cors } from "itty-router" + +const { preflight, corsify } = cors({ + allowMethods: ["GET", "OPTIONS", "HEAD"], +}) + +const router = Router({ + before: [preflight], + finally: [corsify], +}) +router + .get( + "/grapher/:slug.config.json", + async ({ params: { slug } }, { searchParams }, env, etag) => + handleConfigRequest(slug, searchParams, env, etag) + ) + .get( + "/grapher/:slug", + async ({ params: { slug } }, { searchParams }, env) => + handleHtmlPageRequest(slug, searchParams, env) + ) + .all("*", () => error(404, "Route not defined")) + +export const onRequest: PagesFunction = async (context) => { // Makes it so that if there's an error, we will just deliver the original page before the HTML rewrite. // Only caveat is that redirects will not be taken into account for some reason; but on the other hand the worker is so simple that it's unlikely to fail. context.passThroughOnException() + const { request, env } = context + const url = new URL(request.url) - // Redirects handling is performed by the worker, and is done by fetching the (baked) _grapherRedirects.json file. - // That file is a mapping from old slug to new slug. - const getOptionalRedirectForSlug = async (slug: string, baseUrl: URL) => { - const redirects: Record = await env.ASSETS.fetch( - new URL("/grapher/_grapherRedirects.json", baseUrl), - { cf: { cacheTtl: 2 * 60 } } + return router + .fetch( + request, + url, + { ...env, url }, + request.headers.get("if-none-match") ) - .then((r): Promise> => r.json()) - .catch((e) => { - console.error("Error fetching redirects", e) - return {} - }) - return redirects[slug] - } - - const createRedirectResponse = (redirSlug: string, currentUrl: URL) => - new Response(null, { - status: 302, - headers: { Location: `/grapher/${redirSlug}${currentUrl.search}` }, - }) - - const { request, env, params } = context + .catch((e) => error(500, e)) +} - const originalSlug = params.slug as string - const url = new URL(request.url) +async function handleHtmlPageRequest( + slug: string, + searchParams: URLSearchParams, + env: Env +) { + const url = env.url + // Redirects handling is performed by the worker, and is done by fetching the (baked) _grapherRedirects.json file. + // That file is a mapping from old slug to new slug. /** * REDIRECTS HANDLING: @@ -40,11 +64,12 @@ export const onRequestGet: PagesFunction = async (context) => { // All our grapher slugs are lowercase by convention. // To allow incoming links that may contain uppercase characters to work, we redirect to the lowercase version. - const lowerCaseSlug = originalSlug.toLowerCase() - if (lowerCaseSlug !== originalSlug) { + const lowerCaseSlug = slug.toLowerCase() + if (lowerCaseSlug !== slug) { const redirectSlug = await getOptionalRedirectForSlug( lowerCaseSlug, - url + url, + env ) return createRedirectResponse(redirectSlug ?? lowerCaseSlug, url) @@ -61,8 +86,8 @@ export const onRequestGet: PagesFunction = async (context) => { if (grapherPageResp.status === 404) { // If the request is a 404, we check if there's a redirect for it. // If there is, we redirect to the new page. - const redirectSlug = await getOptionalRedirectForSlug(originalSlug, url) - if (redirectSlug && redirectSlug !== originalSlug) { + const redirectSlug = await getOptionalRedirectForSlug(slug, url, env) + if (redirectSlug && redirectSlug !== slug) { return createRedirectResponse(redirectSlug, url) } else { // Otherwise we just return the 404 page. @@ -110,5 +135,74 @@ export const onRequestGet: PagesFunction = async (context) => { }, }) - return rewriter.transform(grapherPageResp) + return rewriter.transform(grapherPageResp as unknown as Response) +} + +async function handleConfigRequest( + slug: string, + searchParams: URLSearchParams, + env: Env, + etag: string | undefined +) { + const shouldCache = searchParams.get("nocache") === null + console.log("Preparing json response for ", slug) + // All our grapher slugs are lowercase by convention. + // To allow incoming links that may contain uppercase characters to work, we redirect to the lowercase version. + const lowerCaseSlug = slug.toLowerCase() + if (lowerCaseSlug !== slug) { + const redirectSlug = await getOptionalRedirectForSlug( + lowerCaseSlug, + env.url, + env + ) + + return createRedirectResponse( + `${redirectSlug ?? lowerCaseSlug}.config.json`, + env.url + ) + } + + const grapherPageResp = await fetchUnparsedGrapherConfig(slug, env, etag) + + if (grapherPageResp.status === 304) { + console.log("Returning 304 for ", slug) + return new Response(null, { status: 304 }) + } + + if (grapherPageResp.status !== 200) { + // If the request is a 404, we check if there's a redirect for it. + // If there is, we redirect to the new page. + const redirectSlug = await getOptionalRedirectForSlug( + slug, + env.url, + env + ) + if (redirectSlug && redirectSlug !== slug) { + console.log("Redirecting to ", redirectSlug) + return createRedirectResponse( + `${redirectSlug}.config.json`, + env.url + ) + } else { + console.log("Returning 404 for ", slug) + // Otherwise we just return the status code. + return new Response(null, { status: grapherPageResp.status }) + } + } + + console.log("Returning 200 for ", slug) + + const cacheControl = shouldCache + ? "public, s-maxage=3600, max-age=0, must-revalidate" + : "public, s-maxage=0, max-age=0, must-revalidate" + + //grapherPageResp.headers.set("Cache-Control", cacheControl) + return new Response(grapherPageResp.body as any, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": cacheControl, + ETag: grapherPageResp.headers.get("ETag") ?? "", + }, + }) } diff --git a/functions/grapher/thumbnail/[slug].ts b/functions/grapher/thumbnail/[slug].ts index e49e32508fc..d92306bd67d 100644 --- a/functions/grapher/thumbnail/[slug].ts +++ b/functions/grapher/thumbnail/[slug].ts @@ -1,19 +1,7 @@ +import { Env } from "../../_common/env.js" import { fetchAndRenderGrapher } from "../../_common/grapherRenderer.js" import { IRequestStrict, Router, error } from "itty-router" -export interface Env { - ASSETS: { - fetch: typeof fetch - } - url: URL - GRAPHER_CONFIG_R2_BUCKET_URL: string - GRAPHER_CONFIG_R2_BUCKET_FALLBACK_URL: string - GRAPHER_CONFIG_R2_BUCKET_PATH: string - GRAPHER_CONFIG_R2_BUCKET_FALLBACK_PATH: string - CF_PAGES_BRANCH: string - ENV: string -} - const router = Router() router .get(