Skip to content

Commit b515fac

Browse files
authored
fix(og): externalize @takumi-rs/core so Netlify ships the native binary (#893)
1 parent c40f7b3 commit b515fac

4 files changed

Lines changed: 122 additions & 25 deletions

File tree

netlify.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ publish = "dist/client"
77

88
[functions]
99
directory = "netlify/functions"
10+
# OG image rendering goes through @takumi-rs/wasm (forced via the `module`
11+
# option in src/server/og/generate.server.ts) instead of @takumi-rs/core's
12+
# native napi binding — Netlify's function bundler dropped the platform-
13+
# specific .node optional dep no matter how we configured it, and WASM
14+
# sidesteps the whole binary-resolution dance. The .wasm asset isn't part
15+
# of the JS import graph, so include it explicitly.
1016
included_files = [
1117
"public/fonts/Inter-Regular.ttf",
1218
"public/fonts/Inter-ExtraBold.ttf",
1319
"public/images/logos/splash-dark.png",
20+
"node_modules/.pnpm/@takumi-rs+wasm@*/node_modules/@takumi-rs/wasm/pkg/takumi_wasm_bg.wasm",
1421
]
1522

1623
[[headers]]

src/routes/api/og/$library[.png].ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,35 @@ export const Route = createFileRoute('/api/og/$library.png')({
2323
const libraryId = rawParam.replace(/\.png$/, '')
2424

2525
const url = new URL(request.url)
26-
const result = generateOgImageResponse(
27-
{
28-
libraryId,
29-
title: url.searchParams.get('title') ?? undefined,
30-
description: url.searchParams.get('description') ?? undefined,
31-
},
32-
{ headers: CACHE_HEADERS },
33-
)
26+
let result: ReturnType<typeof generateOgImageResponse>
27+
try {
28+
result = generateOgImageResponse(
29+
{
30+
libraryId,
31+
title: url.searchParams.get('title') ?? undefined,
32+
description: url.searchParams.get('description') ?? undefined,
33+
},
34+
{ headers: CACHE_HEADERS },
35+
)
36+
} catch (error) {
37+
console.error('Failed to construct OG response', error)
38+
return new Response('Failed to generate OG image', { status: 500 })
39+
}
3440

3541
if ('kind' in result) {
3642
return new Response(`Unknown library: ${libraryId}`, { status: 404 })
3743
}
3844

45+
// ImageResponse builds the Response synchronously and renders inside
46+
// a ReadableStream. Await the ready promise so render errors surface
47+
// as 500s instead of an empty 200 cached at the edge.
48+
try {
49+
await result.ready
50+
} catch (error) {
51+
console.error('Failed to generate OG image', error)
52+
return new Response('Failed to generate OG image', { status: 500 })
53+
}
54+
3955
return result
4056
},
4157
},

src/server/og/generate.server.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
3+
import { join } from 'node:path'
14
import { ImageResponse } from '@takumi-rs/image-response'
25
import { findLibrary } from '~/libraries'
36
import type { LibraryId } from '~/libraries'
@@ -12,6 +15,65 @@ import {
1215

1316
const ISLAND_KEY = 'island'
1417

18+
// Force takumi to render via @takumi-rs/wasm instead of @takumi-rs/core's
19+
// native napi binding. The native loader requires platform-specific
20+
// .node binaries (e.g. @takumi-rs/core-linux-x64-gnu) which Netlify's
21+
// zip-it-and-ship-it consistently dropped from the function bundle —
22+
// `external_node_modules` and explicit optionalDependencies didn't fix
23+
// it. WASM is platform-agnostic and ships a single .wasm asset (listed
24+
// in netlify.toml `included_files`).
25+
const WASM_REL_PATH = 'node_modules/@takumi-rs/wasm/pkg/takumi_wasm_bg.wasm'
26+
const WASM_PNPM_REL_PATH =
27+
'node_modules/@takumi-rs/wasm/pkg/takumi_wasm_bg.wasm'
28+
29+
let cachedWasmBytes: Uint8Array | null = null
30+
function loadTakumiWasm(): Uint8Array {
31+
if (cachedWasmBytes) return cachedWasmBytes
32+
const candidatePaths = [
33+
// Standard module resolution — works in dev and any environment that
34+
// hoists @takumi-rs/wasm to top-level node_modules.
35+
tryRequireResolve('@takumi-rs/wasm/takumi_wasm_bg.wasm'),
36+
// Top-level pnpm hoist (also via require but without the subpath
37+
// exports indirection).
38+
join(process.cwd(), WASM_REL_PATH),
39+
// Netlify Functions deploy: pnpm packages live under
40+
// node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/. The function
41+
// bundler isn't symlinking @takumi-rs/wasm at top-level, so walk .pnpm
42+
// and find the matching directory.
43+
findInPnpmStore('@takumi-rs+wasm@', WASM_PNPM_REL_PATH),
44+
].filter((p): p is string => Boolean(p))
45+
46+
for (const path of candidatePaths) {
47+
if (existsSync(path)) {
48+
cachedWasmBytes = readFileSync(path)
49+
return cachedWasmBytes
50+
}
51+
}
52+
throw new Error(
53+
`Could not locate @takumi-rs/wasm/pkg/takumi_wasm_bg.wasm. Tried: ${candidatePaths.join(', ')}`,
54+
)
55+
}
56+
57+
function tryRequireResolve(specifier: string): string | null {
58+
try {
59+
return createRequire(import.meta.url).resolve(specifier)
60+
} catch {
61+
return null
62+
}
63+
}
64+
65+
function findInPnpmStore(pkgPrefix: string, relPath: string): string | null {
66+
const pnpmDir = join(process.cwd(), 'node_modules', '.pnpm')
67+
if (!existsSync(pnpmDir)) return null
68+
for (const entry of readdirSync(pnpmDir)) {
69+
if (entry.startsWith(pkgPrefix)) {
70+
const candidate = join(pnpmDir, entry, relPath)
71+
if (existsSync(candidate)) return candidate
72+
}
73+
}
74+
return null
75+
}
76+
1577
type GenerateInput = {
1678
libraryId: LibraryId | string
1779
title?: string
@@ -50,6 +112,9 @@ export function generateOgImageResponse(
50112
width: 1200,
51113
height: 630,
52114
format: 'png',
115+
// Passing `module` switches takumi-js's renderer to WASM (see
116+
// takumi-js/dist/render-*.mjs `getImports`).
117+
module: loadTakumiWasm(),
53118
fonts: [
54119
{
55120
name: 'Inter',

src/utils/og.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { createIsomorphicFn } from '@tanstack/react-start'
2+
import { getRequest } from '@tanstack/react-start/server'
13
import type { LibraryId } from '~/libraries'
2-
import { canonicalUrl } from './seo'
34
import {
45
MAX_OG_DESCRIPTION_LENGTH,
56
MAX_OG_TITLE_LENGTH,
@@ -16,20 +17,32 @@ type OgImageOptions = {
1617
/**
1718
* Absolute origin to use for og:image URLs.
1819
*
19-
* Unlike canonical links (which must always point to production),
20-
* og:image URLs MUST be reachable on the same deploy that emitted them
21-
* — social-card validators fetch the URL from the meta tag verbatim.
20+
* Unlike canonical links (which always point to production), og:image
21+
* URLs MUST be reachable on the same deploy that emitted them — social-
22+
* card validators fetch the URL from the meta tag verbatim, so on a
23+
* Netlify deploy preview the og:image must point at the preview origin,
24+
* not at production.
2225
*
23-
* On Netlify preview/branch deploys, `URL` is still the production URL,
24-
* but `DEPLOY_PRIME_URL` is the deploy's own origin. Prefer that.
26+
* The incoming request URL is the source of truth. `process.env.URL` /
27+
* `DEPLOY_PRIME_URL` etc. turned out to be unreliable inside our bundled
28+
* SSR function, so read the origin from the live request via TanStack
29+
* Start's `getRequest()`. The server import is referenced only inside
30+
* `.server()`, which the start compiler treats as a client-safe boundary
31+
* — the import is tree-shaken from the client bundle.
2532
*/
26-
function getOgOrigin(): string {
27-
if (!import.meta.env.SSR) return DEFAULT_SITE_URL
28-
const env = process.env
29-
const origin =
30-
env.DEPLOY_PRIME_URL || env.DEPLOY_URL || env.URL || env.SITE_URL
31-
return (origin ?? DEFAULT_SITE_URL).replace(/\/$/, '')
32-
}
33+
const getOgOrigin = createIsomorphicFn()
34+
.server((): string => {
35+
try {
36+
const request = getRequest()
37+
if (request?.url) return new URL(request.url).origin
38+
} catch {
39+
// getRequest() throws if called outside an SSR request context.
40+
}
41+
return DEFAULT_SITE_URL
42+
})
43+
.client((): string =>
44+
typeof window !== 'undefined' ? window.location.origin : DEFAULT_SITE_URL,
45+
)
3346

3447
/**
3548
* Absolute URL for a package-themed OG image.
@@ -56,9 +69,5 @@ export function ogImageUrl(
5669
const qs = params.toString()
5770
const path = `/api/og/${libraryId}.png${qs ? `?${qs}` : ''}`
5871

59-
// On client (which can't happen in head() but guards against misuse),
60-
// fall through to canonicalUrl which uses the production hostname.
61-
if (!import.meta.env.SSR) return canonicalUrl(path)
62-
6372
return `${getOgOrigin()}${path}`
6473
}

0 commit comments

Comments
 (0)