diff --git a/netlify.toml b/netlify.toml index 5f17d8ec..58cd29d4 100644 --- a/netlify.toml +++ b/netlify.toml @@ -7,10 +7,17 @@ publish = "dist/client" [functions] directory = "netlify/functions" +# OG image rendering goes through @takumi-rs/wasm (forced via the `module` +# option in src/server/og/generate.server.ts) instead of @takumi-rs/core's +# native napi binding — Netlify's function bundler dropped the platform- +# specific .node optional dep no matter how we configured it, and WASM +# sidesteps the whole binary-resolution dance. The .wasm asset isn't part +# of the JS import graph, so include it explicitly. included_files = [ "public/fonts/Inter-Regular.ttf", "public/fonts/Inter-ExtraBold.ttf", "public/images/logos/splash-dark.png", + "node_modules/.pnpm/@takumi-rs+wasm@*/node_modules/@takumi-rs/wasm/pkg/takumi_wasm_bg.wasm", ] [[headers]] diff --git a/src/routes/api/og/$library[.png].ts b/src/routes/api/og/$library[.png].ts index 8c26c36b..f2d54c82 100644 --- a/src/routes/api/og/$library[.png].ts +++ b/src/routes/api/og/$library[.png].ts @@ -23,19 +23,35 @@ export const Route = createFileRoute('/api/og/$library.png')({ const libraryId = rawParam.replace(/\.png$/, '') const url = new URL(request.url) - const result = generateOgImageResponse( - { - libraryId, - title: url.searchParams.get('title') ?? undefined, - description: url.searchParams.get('description') ?? undefined, - }, - { headers: CACHE_HEADERS }, - ) + let result: ReturnType + try { + result = generateOgImageResponse( + { + libraryId, + title: url.searchParams.get('title') ?? undefined, + description: url.searchParams.get('description') ?? undefined, + }, + { headers: CACHE_HEADERS }, + ) + } catch (error) { + console.error('Failed to construct OG response', error) + return new Response('Failed to generate OG image', { status: 500 }) + } if ('kind' in result) { return new Response(`Unknown library: ${libraryId}`, { status: 404 }) } + // ImageResponse builds the Response synchronously and renders inside + // a ReadableStream. Await the ready promise so render errors surface + // as 500s instead of an empty 200 cached at the edge. + try { + await result.ready + } catch (error) { + console.error('Failed to generate OG image', error) + return new Response('Failed to generate OG image', { status: 500 }) + } + return result }, }, diff --git a/src/server/og/generate.server.ts b/src/server/og/generate.server.ts index dfd6c176..784c56d6 100644 --- a/src/server/og/generate.server.ts +++ b/src/server/og/generate.server.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' import { ImageResponse } from '@takumi-rs/image-response' import { findLibrary } from '~/libraries' import type { LibraryId } from '~/libraries' @@ -12,6 +15,65 @@ import { const ISLAND_KEY = 'island' +// Force takumi to render via @takumi-rs/wasm instead of @takumi-rs/core's +// native napi binding. The native loader requires platform-specific +// .node binaries (e.g. @takumi-rs/core-linux-x64-gnu) which Netlify's +// zip-it-and-ship-it consistently dropped from the function bundle — +// `external_node_modules` and explicit optionalDependencies didn't fix +// it. WASM is platform-agnostic and ships a single .wasm asset (listed +// in netlify.toml `included_files`). +const WASM_REL_PATH = 'node_modules/@takumi-rs/wasm/pkg/takumi_wasm_bg.wasm' +const WASM_PNPM_REL_PATH = + 'node_modules/@takumi-rs/wasm/pkg/takumi_wasm_bg.wasm' + +let cachedWasmBytes: Uint8Array | null = null +function loadTakumiWasm(): Uint8Array { + if (cachedWasmBytes) return cachedWasmBytes + const candidatePaths = [ + // Standard module resolution — works in dev and any environment that + // hoists @takumi-rs/wasm to top-level node_modules. + tryRequireResolve('@takumi-rs/wasm/takumi_wasm_bg.wasm'), + // Top-level pnpm hoist (also via require but without the subpath + // exports indirection). + join(process.cwd(), WASM_REL_PATH), + // Netlify Functions deploy: pnpm packages live under + // node_modules/.pnpm/@/node_modules//. The function + // bundler isn't symlinking @takumi-rs/wasm at top-level, so walk .pnpm + // and find the matching directory. + findInPnpmStore('@takumi-rs+wasm@', WASM_PNPM_REL_PATH), + ].filter((p): p is string => Boolean(p)) + + for (const path of candidatePaths) { + if (existsSync(path)) { + cachedWasmBytes = readFileSync(path) + return cachedWasmBytes + } + } + throw new Error( + `Could not locate @takumi-rs/wasm/pkg/takumi_wasm_bg.wasm. Tried: ${candidatePaths.join(', ')}`, + ) +} + +function tryRequireResolve(specifier: string): string | null { + try { + return createRequire(import.meta.url).resolve(specifier) + } catch { + return null + } +} + +function findInPnpmStore(pkgPrefix: string, relPath: string): string | null { + const pnpmDir = join(process.cwd(), 'node_modules', '.pnpm') + if (!existsSync(pnpmDir)) return null + for (const entry of readdirSync(pnpmDir)) { + if (entry.startsWith(pkgPrefix)) { + const candidate = join(pnpmDir, entry, relPath) + if (existsSync(candidate)) return candidate + } + } + return null +} + type GenerateInput = { libraryId: LibraryId | string title?: string @@ -50,6 +112,9 @@ export function generateOgImageResponse( width: 1200, height: 630, format: 'png', + // Passing `module` switches takumi-js's renderer to WASM (see + // takumi-js/dist/render-*.mjs `getImports`). + module: loadTakumiWasm(), fonts: [ { name: 'Inter', diff --git a/src/utils/og.ts b/src/utils/og.ts index 06541e58..7dbea4ec 100644 --- a/src/utils/og.ts +++ b/src/utils/og.ts @@ -1,5 +1,6 @@ +import { createIsomorphicFn } from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' import type { LibraryId } from '~/libraries' -import { canonicalUrl } from './seo' import { MAX_OG_DESCRIPTION_LENGTH, MAX_OG_TITLE_LENGTH, @@ -16,20 +17,32 @@ type OgImageOptions = { /** * Absolute origin to use for og:image URLs. * - * Unlike canonical links (which must always point to production), - * og:image URLs MUST be reachable on the same deploy that emitted them - * — social-card validators fetch the URL from the meta tag verbatim. + * Unlike canonical links (which always point to production), og:image + * URLs MUST be reachable on the same deploy that emitted them — social- + * card validators fetch the URL from the meta tag verbatim, so on a + * Netlify deploy preview the og:image must point at the preview origin, + * not at production. * - * On Netlify preview/branch deploys, `URL` is still the production URL, - * but `DEPLOY_PRIME_URL` is the deploy's own origin. Prefer that. + * The incoming request URL is the source of truth. `process.env.URL` / + * `DEPLOY_PRIME_URL` etc. turned out to be unreliable inside our bundled + * SSR function, so read the origin from the live request via TanStack + * Start's `getRequest()`. The server import is referenced only inside + * `.server()`, which the start compiler treats as a client-safe boundary + * — the import is tree-shaken from the client bundle. */ -function getOgOrigin(): string { - if (!import.meta.env.SSR) return DEFAULT_SITE_URL - const env = process.env - const origin = - env.DEPLOY_PRIME_URL || env.DEPLOY_URL || env.URL || env.SITE_URL - return (origin ?? DEFAULT_SITE_URL).replace(/\/$/, '') -} +const getOgOrigin = createIsomorphicFn() + .server((): string => { + try { + const request = getRequest() + if (request?.url) return new URL(request.url).origin + } catch { + // getRequest() throws if called outside an SSR request context. + } + return DEFAULT_SITE_URL + }) + .client((): string => + typeof window !== 'undefined' ? window.location.origin : DEFAULT_SITE_URL, + ) /** * Absolute URL for a package-themed OG image. @@ -56,9 +69,5 @@ export function ogImageUrl( const qs = params.toString() const path = `/api/og/${libraryId}.png${qs ? `?${qs}` : ''}` - // On client (which can't happen in head() but guards against misuse), - // fall through to canonicalUrl which uses the production hostname. - if (!import.meta.env.SSR) return canonicalUrl(path) - return `${getOgOrigin()}${path}` }