diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index 6ea548e..731d641 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -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: // diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 42deca4..ceb02fe 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -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, ) @@ -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/') diff --git a/src/routes/api.preview.ts b/src/routes/api.preview.ts index e7ac39e..94f6df2 100644 --- a/src/routes/api.preview.ts +++ b/src/routes/api.preview.ts @@ -5,7 +5,7 @@ import { handlePreviewRequest } from 'fontdue-js/preview' // admins by — 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. // diff --git a/src/routes/fonts.$slug.tsx b/src/routes/fonts.$slug.tsx index 0fca54d..f00835b 100644 --- a/src/routes/fonts.$slug.tsx +++ b/src/routes/fonts.$slug.tsx @@ -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' @@ -20,24 +22,31 @@ 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('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('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, @@ -45,6 +54,11 @@ export const Route = createFileRoute('/fonts/$slug')({ } }, 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' @@ -54,12 +68,26 @@ export const Route = createFileRoute('/fonts/$slug')({ }) function FontDetail() { + const data = Route.useLoaderData() + + if (data.locked) { + return ( + <> +

Password required

+

+ This collection is password-protected. Enter the password to view it. +

+ + + ) + } + const { collection, typeTestersPreload, characterViewerPreload, buyButtonPreload, - } = Route.useLoaderData() + } = data const feature = collection.featureStyle const featureSrc = feature?.webfontSources?.find( diff --git a/src/start.ts b/src/start.ts index d2b6526..72b7bbb 100644 --- a/src/start.ts +++ b/src/start.ts @@ -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], }))