From e09348a9b7c9a5aa23a825cebd2189df62423449 Mon Sep 17 00:00:00 2001 From: Daniel Bachler Date: Mon, 19 Aug 2024 09:36:00 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Render=20thumbnails=20for=20grapher?= =?UTF-8?q?s=20by=20uuid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/_common/grapherRenderer.ts | 26 +++++++---------- functions/_common/reusableHandlers.ts | 37 +++++++++++++++++++++++ functions/grapher/[slug].ts | 42 ++++++++++++++++++++++++--- functions/grapher/by-uuid/[uuid].ts | 28 +++++++++++++++++- functions/grapher/thumbnail/[slug].ts | 23 +++++++++++++-- 5 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 functions/_common/reusableHandlers.ts diff --git a/functions/_common/grapherRenderer.ts b/functions/_common/grapherRenderer.ts index 37abd78da54..8115fb15177 100644 --- a/functions/_common/grapherRenderer.ts +++ b/functions/_common/grapherRenderer.ts @@ -23,6 +23,8 @@ declare global { var window: any } +export type Etag = string + const grapherBaseUrl = "https://ourworldindata.org/grapher" // Lots of defaults; these are mostly the same as they are in owid-grapher. @@ -166,17 +168,17 @@ interface FetchGrapherConfigResult { etag: string | undefined } -interface GrapherSlug { +export interface GrapherSlug { type: "slug" id: string } -interface GrapherUuid { +export interface GrapherUuid { type: "uuid" id: string } -type GrapherIdentifier = GrapherSlug | GrapherUuid +export type GrapherIdentifier = GrapherSlug | GrapherUuid export async function fetchUnparsedGrapherConfig( identifier: GrapherIdentifier, @@ -267,17 +269,14 @@ export async function fetchGrapherConfig( } async function fetchAndRenderGrapherToSvg( - slug: string, + id: GrapherIdentifier, options: ImageOptions, searchParams: URLSearchParams, env: Env ): Promise { const grapherLogger = new TimeLogger("grapher") - const grapherConfigResponse = await fetchGrapherConfig( - { type: "slug", id: slug }, - env - ) + const grapherConfigResponse = await fetchGrapherConfig(id, env) if (grapherConfigResponse.status === 404) { // we throw 404 errors instad of returning a 404 response so that the router @@ -320,20 +319,15 @@ async function fetchAndRenderGrapherToSvg( } export const fetchAndRenderGrapher = async ( - slug: string, + id: GrapherIdentifier, searchParams: URLSearchParams, outType: "png" | "svg", env: Env ) => { const options = extractOptions(searchParams) - console.log("Rendering", slug, outType, options) - const svg = await fetchAndRenderGrapherToSvg( - slug, - options, - searchParams, - env - ) + console.log("Rendering", id.id, outType, options) + const svg = await fetchAndRenderGrapherToSvg(id, options, searchParams, env) console.log("fetched svg") switch (outType) { diff --git a/functions/_common/reusableHandlers.ts b/functions/_common/reusableHandlers.ts new file mode 100644 index 00000000000..8a86c2aa280 --- /dev/null +++ b/functions/_common/reusableHandlers.ts @@ -0,0 +1,37 @@ +import { Env } from "./env.js" +import { + Etag, + GrapherIdentifier, + fetchAndRenderGrapher, +} from "./grapherRenderer.js" + +export async function handleThumbnailRequest( + id: GrapherIdentifier, + searchParams: URLSearchParams, + env: Env, + _etag: Etag, + ctx: EventContext>, + extension: "png" | "svg" +) { + const url = new URL(env.url) + const shouldCache = !url.searchParams.has("nocache") + + const cache = caches.default + console.log("Handling", env.url, ctx.request.headers.get("User-Agent")) + if (shouldCache) { + console.log("Checking cache") + const maybeCached = await cache.match(ctx.request) + console.log("Cache check result", maybeCached ? "hit" : "miss") + if (maybeCached) return maybeCached + } + const resp = await fetchAndRenderGrapher(id, searchParams, extension, env) + if (shouldCache) { + resp.headers.set("Cache-Control", "public, s-maxage=3600, max-age=3600") + ctx.waitUntil(caches.default.put(ctx.request, resp.clone())) + } else + resp.headers.set( + "Cache-Control", + "public, s-maxage=0, max-age=0, must-revalidate" + ) + return resp +} diff --git a/functions/grapher/[slug].ts b/functions/grapher/[slug].ts index 48beaa5d422..4b4f8caba58 100644 --- a/functions/grapher/[slug].ts +++ b/functions/grapher/[slug].ts @@ -2,21 +2,54 @@ import { Env } from "../_common/env.js" import { getOptionalRedirectForSlug, createRedirectResponse, + Etag, fetchUnparsedGrapherConfig, } from "../_common/grapherRenderer.js" import { IRequestStrict, Router, StatusError, error } from "itty-router" +import { handleThumbnailRequest } from "../_common/reusableHandlers.js" +// We collect the possible extensions here so we can easily take them into account +// when handling redirects const extensions = { configJson: ".config.json", + png: ".png", + svg: ".svg", } -const router = Router() +const router = Router< + IRequestStrict, + [URL, Env, Etag, EventContext>] +>() router .get( `/grapher/:slug${extensions.configJson}`, async ({ params: { slug } }, { searchParams }, env, etag) => handleConfigRequest(slug, searchParams, env, etag) ) + .get( + `/grapher/:slug${extensions.png}`, + async ({ params: { slug } }, { searchParams }, env, etag, ctx) => + handleThumbnailRequest( + { type: "slug", id: slug }, + searchParams, + env, + etag, + ctx, + "png" + ) + ) + .get( + `/grapher/:slug${extensions.svg}`, + async ({ params: { slug } }, { searchParams }, env, etag, ctx) => + handleThumbnailRequest( + { type: "slug", id: slug }, + searchParams, + env, + etag, + ctx, + "svg" + ) + ) .get( "/grapher/:slug", async ({ params: { slug } }, { searchParams }, env) => @@ -42,7 +75,8 @@ export const onRequestGet: PagesFunction = async (context) => { request, url, { ...env, url }, - request.headers.get("if-none-match") + request.headers.get("if-none-match"), + context ) .catch(async (e) => { // Here we do a unified after the fact handling of 404s to check @@ -112,10 +146,10 @@ async function handleHtmlPageRequest( // In the case of the redirect, the browser will then request the new URL which will again be handled by this worker. if (grapherPageResp.status !== 200) return grapherPageResp - const openGraphThumbnailUrl = `/grapher/thumbnail/${slug}.png?imType=og${ + const openGraphThumbnailUrl = `/grapher/${slug}.png?imType=og${ url.search ? "&" + url.search.slice(1) : "" }` - const twitterThumbnailUrl = `/grapher/thumbnail/${slug}.png?imType=twitter${ + const twitterThumbnailUrl = `/grapher/${slug}.png?imType=twitter${ url.search ? "&" + url.search.slice(1) : "" }` diff --git a/functions/grapher/by-uuid/[uuid].ts b/functions/grapher/by-uuid/[uuid].ts index ada94d0808d..d6a9a590a71 100644 --- a/functions/grapher/by-uuid/[uuid].ts +++ b/functions/grapher/by-uuid/[uuid].ts @@ -1,6 +1,7 @@ import { Env } from "../../_common/env.js" import { fetchGrapherConfig } from "../../_common/grapherRenderer.js" import { IRequestStrict, Router, error, StatusError } from "itty-router" +import { handleThumbnailRequest } from "../../_common/reusableHandlers.js" const router = Router() router @@ -9,6 +10,30 @@ router async ({ params: { uuid } }, { searchParams }, env, etag) => handleConfigRequest(uuid, searchParams, env, etag) ) + .get( + "/grapher/by-uuid/:uuid.png", + async ({ params: { uuid } }, { searchParams }, env, etag, ctx) => + handleThumbnailRequest( + { type: "uuid", id: uuid }, + searchParams, + env, + etag, + ctx, + "png" + ) + ) + .get( + "/grapher/by-uuid/:uuid.svg", + async ({ params: { uuid } }, { searchParams }, env, etag, ctx) => + handleThumbnailRequest( + { type: "uuid", id: uuid }, + searchParams, + env, + etag, + ctx, + "svg" + ) + ) .all("*", () => error(404, "Route not defined")) export const onRequestOptions: PagesFunction = async (_context) => { @@ -29,7 +54,8 @@ export const onRequestGet: PagesFunction = async (context) => { request, url, { ...env, url }, - request.headers.get("if-none-match") + request.headers.get("if-none-match"), + context ) .catch((e) => { if (e instanceof StatusError) { diff --git a/functions/grapher/thumbnail/[slug].ts b/functions/grapher/thumbnail/[slug].ts index d92306bd67d..9b66aebfdb9 100644 --- a/functions/grapher/thumbnail/[slug].ts +++ b/functions/grapher/thumbnail/[slug].ts @@ -2,22 +2,39 @@ import { Env } from "../../_common/env.js" import { fetchAndRenderGrapher } from "../../_common/grapherRenderer.js" import { IRequestStrict, Router, error } from "itty-router" +// TODO: remove the /grapher/thumbnail route two weeks or so after the change to use /grapher/:slug.png is deployed +// We keep this around for another two weeks so that cached html pages etc can still fetch the correct thumbnail const router = Router() router .get( "/grapher/thumbnail/:slug.png", async ({ params: { slug } }, { searchParams }, env) => - fetchAndRenderGrapher(slug, searchParams, "png", env) + fetchAndRenderGrapher( + { type: "slug", id: slug }, + searchParams, + "png", + env + ) ) .get( "/grapher/thumbnail/:slug.svg", async ({ params: { slug } }, { searchParams }, env) => - fetchAndRenderGrapher(slug, searchParams, "svg", env) + fetchAndRenderGrapher( + { type: "slug", id: slug }, + searchParams, + "svg", + env + ) ) .get( "/grapher/thumbnail/:slug", async ({ params: { slug } }, { searchParams }, env) => - fetchAndRenderGrapher(slug, searchParams, "svg", env) + fetchAndRenderGrapher( + { type: "slug", id: slug }, + searchParams, + "svg", + env + ) ) .all("*", () => error(404, "Route not defined"))