Skip to content
Merged
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
7 changes: 7 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
32 changes: 24 additions & 8 deletions src/routes/api/og/$library[.png].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof generateOgImageResponse>
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
},
},
Expand Down
65 changes: 65 additions & 0 deletions src/server/og/generate.server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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/<pkg>@<version>/node_modules/<pkg>/. 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
Expand Down Expand Up @@ -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',
Expand Down
43 changes: 26 additions & 17 deletions src/utils/og.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand All @@ -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}`
}
Loading