Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/lib/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { createFontdueFetch, FontdueNotFoundError } from 'fontdue-js/server'
// there's no transport boilerplate in the route loaders.
//
// There's no per-request binding: because the global request middleware (see
// src/start.ts) wraps every request in runWithPreview, this fetcher
// src/start.ts) wraps every request in runWithFontdue, this fetcher
// automatically forwards the admin preview token when an admin is previewing
// (revealing unpublished fonts), and sends a plain request otherwise. The same
// is true of every fontdue-js preload helper (loadTypeTesterQuery,
// (revealing unpublished fonts) and the visitor's node-access token for a
// collection they've unlocked, and sends a plain request otherwise. The same is
// true of every fontdue-js preload helper (loadTypeTesterQuery,
// loadFontdueProviderQuery, …) — call them with just their variables and they
// pick up preview from the ambient context.
// pick up the ambient context.
//
// Use it at the top of a loader:
//
Expand Down
46 changes: 28 additions & 18 deletions src/lib/preview.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,41 @@
import { createMiddleware } from '@tanstack/react-start'
import { readPreviewToken } from 'fontdue-js/preview'
import { runWithPreview } from 'fontdue-js/preview/server'
import { runWithFontdue } from 'fontdue-js/server/middleware'

// Global request middleware — runs once per HTTP request, wrapping every route
// loader and server route. It has two responsibilities, both per request:
//
// 1. Preview. runWithPreview puts the logged-in admin's token (from the preview
// cookie set by /api/preview) into an ambient AsyncLocalStorage context for
// the whole render, so every GraphQL fetch and fontdue-js preload reveals
// unpublished ("hidden") fonts with no per-loader plumbing — and it forces
// preview responses out of the shared CDN cache so an admin render is never
// served to the public. This relies on the middleware running in the same
// runtime as the render, which is the case for the Netlify Functions SSR
// target (Node). If you move to a split edge runtime where the context can't
// cross to the render, fall back to reading the token here and threading
// previewAuthHeaders(token) into fetches/preloads explicitly.
// 1. Fontdue request context. runWithFontdue puts two request-scoped tokens into
// an ambient AsyncLocalStorage context for the whole render: the admin preview
// token (from the cookie set by /api/preview — reveals unpublished "hidden"
// fonts) and the visitor's per-collection node-access token (a collection they
// unlocked with a password). Every GraphQL fetch and fontdue-js preload
// forwards them with no per-loader plumbing — and it forces a per-visitor
// response out of the shared CDN cache so an admin's (or an unlocked
// visitor's) render is never served to the public. (runWithFontdue is
// runWithPreview composed with runWithNodeAccess; mount either alone for one.)
// This relies on the middleware running in the same runtime as the render,
// which is the case for the Netlify Functions SSR target (Node). If you move
// to a split edge runtime where the context can't cross to the render, fall
// back to reading the cookies here and threading previewAuthHeaders(token) /
// nodeAccessHeadersFromCookie(cookie) into fetches/preloads explicitly.
//
// 2. CDN caching for public pages. Netlify's edge serves the cached HTML
// instantly while regenerating in the background, so the page feels static
// (sub-100ms TTFB) without prerendering. Browsers always revalidate
// (`max-age=0`) so users see whatever the edge currently holds. Tag every
// page with `fontdue` so /api/revalidate can purge them all at once when
// Fontdue data changes. Preview and non-HTML/API responses are left
// uncacheable (runWithPreview already marked preview responses no-store).
// Fontdue data changes. Per-visitor and non-HTML/API responses are left
// uncacheable (runWithFontdue already marked per-visitor responses no-store).
//
// Registered via createStart({ requestMiddleware: [...] }) in src/start.ts.
export const previewMiddleware = createMiddleware({ type: 'request' }).server(
async ({ request, next }) => {
const previewing = readPreviewToken(request.headers.get('cookie')) != null

// runWithPreview runs `next` inside the ambient preview context, then
// returns the rendered Response (rewriting it to no-store when previewing).
const response = await runWithPreview(
// runWithFontdue runs `next` inside the ambient context, then returns the
// rendered Response (rewriting it to no-store for a per-visitor render).
const response = await runWithFontdue(
request,
async () => (await next()).response,
)
Expand All @@ -41,10 +45,16 @@ export const previewMiddleware = createMiddleware({ type: 'request' }).server(
.get('content-type')
?.includes('text/html')

// Only public, non-preview HTML gets the long-lived CDN cache. Preview
// responses were already marked uncacheable by runWithPreview.
// Only public HTML gets the long-lived CDN cache. runWithFontdue already
// marked per-visitor responses (admin preview, or a collection this visitor
// unlocked via the node-access cookie) `no-store`; don't override that, or an
// unlocked render could be cached and served to someone who hasn't unlocked.
const uncacheable = response.headers
.get('cache-control')
?.includes('no-store')
if (
!previewing &&
!uncacheable &&
response.status === 200 &&
isHtml &&
!url.pathname.startsWith('/api/')
Expand Down
2 changes: 1 addition & 1 deletion src/routes/api.preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { handlePreviewRequest } from 'fontdue-js/preview'
// admins by <FontdueProvider> — POSTs a short-lived token here to turn preview
// on, and DELETEs to turn it off. handlePreviewRequest sets the preview cookies
// (an httpOnly token + a readable marker that the toolbar checks); the global
// request middleware (src/start.ts) wraps each request in runWithPreview, which
// request middleware (src/start.ts) wraps each request in runWithFontdue, which
// forwards the token to GraphQL and keeps preview pages out of the shared CDN
// cache so the public never sees unpublished fonts.
//
Expand Down
56 changes: 42 additions & 14 deletions src/routes/fonts.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import CharacterViewer, {
loadCharacterViewerQuery,
} from 'fontdue-js/CharacterViewer'
import BuyButton, { loadBuyButtonQuery } from 'fontdue-js/BuyButton'
import { FontduePasswordProtectedError } from 'fontdue-js/server'
import NodePasswordForm from 'fontdue-js/NodePasswordForm'

import { fetchGraphql } from '../lib/graphql'
import FontDoc from '../queries/Font.graphql?raw'
Expand All @@ -20,31 +22,43 @@ import type {
// the global request middleware (src/start.ts), so nothing is threaded here.
export const Route = createFileRoute('/fonts/$slug')({
loader: async ({ params }) => {
const [
fontData,
typeTestersPreload,
characterViewerPreload,
buyButtonPreload,
] = await Promise.all([
fetchGraphql<FontQuery, FontQueryVariables>('Font', FontDoc, {
slug: params.slug,
}),
loadTypeTestersQuery({ collectionSlug: params.slug }),
loadCharacterViewerQuery({ collectionSlug: params.slug }),
loadBuyButtonQuery({ collectionSlug: params.slug }),
])
let fontData, typeTestersPreload, characterViewerPreload, buyButtonPreload
try {
;[fontData, typeTestersPreload, characterViewerPreload, buyButtonPreload] =
await Promise.all([
fetchGraphql<FontQuery, FontQueryVariables>('Font', FontDoc, {
slug: params.slug,
}),
loadTypeTestersQuery({ collectionSlug: params.slug }),
loadCharacterViewerQuery({ collectionSlug: params.slug }),
loadBuyButtonQuery({ collectionSlug: params.slug }),
])
} catch (error) {
// The collection is password-protected and the visitor hasn't unlocked
// it. Render the password form instead of a 404 — it exists, it's gated.
if (error instanceof FontduePasswordProtectedError) {
return { locked: true as const, slug: params.slug }
}
throw error
}

const collection = fontData.viewer.slug?.fontCollection
if (!collection) throw notFound()

return {
locked: false as const,
collection,
typeTestersPreload,
characterViewerPreload,
buyButtonPreload,
}
},
head: ({ loaderData }) => {
if (loaderData?.locked) {
return {
meta: [{ title: 'Password required — fontdue-js on TanStack Start' }],
}
}
const collection = loaderData?.collection
const title =
collection?.pageMetadata?.title ?? collection?.name ?? 'Font detail'
Expand All @@ -54,12 +68,26 @@ export const Route = createFileRoute('/fonts/$slug')({
})

function FontDetail() {
const data = Route.useLoaderData()

if (data.locked) {
return (
<>
<h1 className="my-2 mb-4 text-6xl leading-none">Password required</h1>
<p className="mb-6 text-lg text-gray-700">
This collection is password-protected. Enter the password to view it.
</p>
<NodePasswordForm collectionSlug={data.slug} />
</>
)
}

const {
collection,
typeTestersPreload,
characterViewerPreload,
buyButtonPreload,
} = Route.useLoaderData()
} = data

const feature = collection.featureStyle
const featureSrc = feature?.webfontSources?.find(
Expand Down
9 changes: 5 additions & 4 deletions src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { previewMiddleware } from './lib/preview'
// request, around every route loader and server route — the framework-level
// equivalent of Astro's src/middleware.ts or an RR7 root-route middleware.
//
// previewMiddleware wraps each request in fontdue-js's runWithPreview (ambient
// admin preview token) and applies the public CDN cache headers. The Start
// plugin auto-discovers this file (src/start.ts, exporting `startInstance`); no
// import or registration elsewhere is needed.
// previewMiddleware wraps each request in fontdue-js's runWithFontdue (ambient
// admin preview token + a visitor's collection-unlock token) and applies the
// public CDN cache headers. The Start plugin auto-discovers this file
// (src/start.ts, exporting `startInstance`); no import or registration elsewhere
// is needed.
export const startInstance = createStart(() => ({
requestMiddleware: [previewMiddleware],
}))