Skip to content

Commit

Permalink
✨ add CF function for getting SLUG.config.json (#3870)
Browse files Browse the repository at this point in the history
This PR adds routing to the `/grapher/SLUG.ts` function and adds an endpoint in the form `/grapher/SLUG.config.json` that just returns the grapher config from R2. It takes redirects into account, just like the related function that rewrites the HTML to have dynamic thumbnails.
  • Loading branch information
danyx23 authored Sep 10, 2024
2 parents e15b42b + 5fdd975 commit d6b9646
Show file tree
Hide file tree
Showing 4 changed files with 210 additions and 54 deletions.
12 changes: 12 additions & 0 deletions functions/_common/env.ts
Original file line number Diff line number Diff line change
@@ -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
}
84 changes: 73 additions & 11 deletions functions/_common/grapherRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<FetchGrapherConfigResult> {
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,
Expand Down Expand Up @@ -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<string, string> = await env.ASSETS.fetch(
new URL("/grapher/_grapherRedirects.json", baseUrl),
{ cf: { cacheTtl: 2 * 60 } }
)
.then((r): Promise<Record<string, string>> => 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}` },
})
}
154 changes: 124 additions & 30 deletions functions/grapher/[slug].ts
Original file line number Diff line number Diff line change
@@ -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<IRequestStrict, [URL, Env, string]>({
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<string, string> = 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<Record<string, string>> => 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:
Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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") ?? "",
},
})
}
14 changes: 1 addition & 13 deletions functions/grapher/thumbnail/[slug].ts
Original file line number Diff line number Diff line change
@@ -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<IRequestStrict, [URL, Env, ExecutionContext]>()
router
.get(
Expand Down

0 comments on commit d6b9646

Please sign in to comment.