From ccd4a680683360d908716874d0ed70d191fc402e Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 6 May 2026 22:34:29 +0200 Subject: [PATCH 01/15] fix(og): externalize @takumi-rs/core for Netlify so native binary ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OG image endpoint returned 200 OK with `content-type: image/png` but a zero-byte body in production. Cause: `@takumi-rs/core` uses napi-rs's runtime-dispatched native binding loader (createRequire + platform-conditional require). Netlify's zip-it-and-ship-it bundles the SSR function with esbuild, which can't statically trace the optional `@takumi-rs/core-linux-x64-gnu` .node file, so it's missing from the lambda. At runtime the binding load fails inside ImageResponse's ReadableStream start() — but the Response was already constructed with status 200 and image/png, so the errored stream produces an empty body that gets cached at the edge. - netlify.toml: add `external_node_modules = ["@takumi-rs/core"]` so Netlify ships the package directory as-is, letting napi-rs's runtime dispatcher resolve the platform binary that pnpm installs on the build machine. - og route: await `result.ready` so future render failures surface as a 500 instead of a silently-cached empty 200. --- netlify.toml | 6 ++++++ src/routes/api/og/$library[.png].ts | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/netlify.toml b/netlify.toml index 5f17d8ec..f71efa0e 100644 --- a/netlify.toml +++ b/netlify.toml @@ -12,6 +12,12 @@ included_files = [ "public/fonts/Inter-ExtraBold.ttf", "public/images/logos/splash-dark.png", ] +# @takumi-rs/core ships platform-specific .node binaries via napi-rs's runtime +# require() dispatcher — esbuild can't statically trace the optional deps, so +# the Linux binary is missing from the zipped function. Keep the package +# external so node_modules/@takumi-rs/core (and the matching @takumi-rs/core- +# linux-x64-gnu installed by pnpm on the build machine) is shipped as-is. +external_node_modules = ["@takumi-rs/core"] [[headers]] for = "/*" diff --git a/src/routes/api/og/$library[.png].ts b/src/routes/api/og/$library[.png].ts index 8c26c36b..be13c1c7 100644 --- a/src/routes/api/og/$library[.png].ts +++ b/src/routes/api/og/$library[.png].ts @@ -36,6 +36,19 @@ export const Route = createFileRoute('/api/og/$library.png')({ return new Response(`Unknown library: ${libraryId}`, { status: 404 }) } + // ImageResponse builds the Response synchronously (status 200, image + // content-type) and renders inside a ReadableStream. If the render + // throws — e.g. takumi's native binding fails to load — the stream + // is errored but the response headers are already sent, producing + // an empty 200 OK that gets cached at the edge. Await the ready + // promise so render errors surface as 500s. + 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 }, }, From 30bc76636cf2bc64bfaa6139a4ed8c028fb70988 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 6 May 2026 22:52:13 +0200 Subject: [PATCH 02/15] fix(og): derive og:image origin from incoming request, not env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Netlify deploy previews, og:image was rendering as `https://tanstack.com/api/og/.png` — pointing at production rather than the preview origin — making the new takumi binary fix impossible to validate from the preview HTML. The previous attempt read `process.env.DEPLOY_PRIME_URL` etc. inside the SSR function, but those variables turn out to be unreliable (or absent) in our bundled function context, so the chain fell through to `URL`, which is the production hostname even on a deploy preview. Use `getRequest()` from `@tanstack/react-start/server` instead — the incoming Request URL is the source of truth for which origin served this page, and it always matches the deploy that's about to fetch the og:image. Verified locally that og:image now renders as `http://localhost:3000/api/og/.png`. - vite.config.ts: allow `src/utils/og.ts` to import `@tanstack/react-start/server`. Uses are gated by `import.meta.env.SSR` so the import is tree-shaken from the client bundle; allowlisting just lets the static import through the protection check. - og.ts: prefer `new URL(getRequest().url).origin` for the SSR origin, with the env-var chain kept as a fallback for non-request contexts. --- src/utils/og.ts | 15 +++++++++++++-- vite.config.ts | 7 ++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/utils/og.ts b/src/utils/og.ts index 06541e58..14835bb9 100644 --- a/src/utils/og.ts +++ b/src/utils/og.ts @@ -1,3 +1,4 @@ +import { getRequest } from '@tanstack/react-start/server' import type { LibraryId } from '~/libraries' import { canonicalUrl } from './seo' import { @@ -20,11 +21,21 @@ type OgImageOptions = { * og:image URLs MUST be reachable on the same deploy that emitted them * — social-card validators fetch the URL from the meta tag verbatim. * - * 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: on a Netlify deploy + * preview the request hits `deploy-preview-N--tanstack.netlify.app`, so + * the og:image must point there too. `process.env.DEPLOY_PRIME_URL` and + * friends turn out to be unreliable inside the bundled SSR function, so + * we read the origin from the live request instead. */ function getOgOrigin(): string { if (!import.meta.env.SSR) return DEFAULT_SITE_URL + try { + const request = getRequest() + if (request?.url) return new URL(request.url).origin + } catch { + // getRequest() throws if called outside an SSR request context + // (e.g. build-time prerender). Fall through to the env-var fallback. + } const env = process.env const origin = env.DEPLOY_PRIME_URL || env.DEPLOY_URL || env.URL || env.SITE_URL diff --git a/vite.config.ts b/vite.config.ts index 7031675e..d0ecd2f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -226,7 +226,12 @@ export default defineConfig({ importProtection: { behavior: 'error', client: { - files: ['**/*.server.*', '**/server/**'], + // src/utils/og.ts imports getRequest from @tanstack/react-start/server + // to derive the og:image origin from the live request — uses are + // gated by `import.meta.env.SSR`, so Vite tree-shakes the import out + // of the client bundle. Allowlist the file so the static import + // doesn't trip the protection check during bundling. + files: ['**/*.server.*', '**/server/**', '**/utils/og.ts'], specifiers: [ '@tanstack/react-start/server', 'uploadthing/server', From fc60921499d4e2bff4cb14424a9ad8a2f0c73e92 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 6 May 2026 23:08:24 +0200 Subject: [PATCH 03/15] refactor(og): bake getRequest()-based origin into ogImageUrl via createIsomorphicFn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempt put the iso fn in src/utils/og.ts and added that path to the import-protection denylist files. That misread the config — `files` is a denylist, not an allowlist — so og.ts ended up flagged as a server-only module and CI failed: every route that imports ogImageUrl ("/$libraryId/route.tsx" etc.) tripped "Import denied in client environment". Fix: drop the og.ts entry from the protection config and rely on the start compiler's recognition of `createIsomorphicFn().server(...)` as a client-safe boundary. The `getRequest` import is referenced only inside `.server()`, so import-protection lets it through and the bundler tree-shakes the import out of the client output. Verified locally with `pnpm build` (clean) and the dev server's og:image meta tag rendering as `http://localhost:3000/api/og/...` (request origin) instead of the hardcoded production URL. --- src/utils/og.ts | 54 ++++++++++++++++++++++++------------------------- vite.config.ts | 7 +------ 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/utils/og.ts b/src/utils/og.ts index 14835bb9..78998a0a 100644 --- a/src/utils/og.ts +++ b/src/utils/og.ts @@ -1,6 +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, @@ -17,30 +17,34 @@ 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. * - * The incoming request URL is the source of truth: on a Netlify deploy - * preview the request hits `deploy-preview-N--tanstack.netlify.app`, so - * the og:image must point there too. `process.env.DEPLOY_PRIME_URL` and - * friends turn out to be unreliable inside the bundled SSR function, so - * we read the origin from the live request instead. + * 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 - try { - const request = getRequest() - if (request?.url) return new URL(request.url).origin - } catch { - // getRequest() throws if called outside an SSR request context - // (e.g. build-time prerender). Fall through to the env-var fallback. - } - 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. @@ -67,9 +71,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}` } diff --git a/vite.config.ts b/vite.config.ts index d0ecd2f4..7031675e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -226,12 +226,7 @@ export default defineConfig({ importProtection: { behavior: 'error', client: { - // src/utils/og.ts imports getRequest from @tanstack/react-start/server - // to derive the og:image origin from the live request — uses are - // gated by `import.meta.env.SSR`, so Vite tree-shakes the import out - // of the client bundle. Allowlist the file so the static import - // doesn't trip the protection check during bundling. - files: ['**/*.server.*', '**/server/**', '**/utils/og.ts'], + files: ['**/*.server.*', '**/server/**'], specifiers: [ '@tanstack/react-start/server', 'uploadthing/server', From fe3da62706da264a43223e21f011cd74c175bef7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 21:09:29 +0000 Subject: [PATCH 04/15] ci: apply automated fixes --- src/utils/og.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/og.ts b/src/utils/og.ts index 78998a0a..7dbea4ec 100644 --- a/src/utils/og.ts +++ b/src/utils/og.ts @@ -41,9 +41,7 @@ const getOgOrigin = createIsomorphicFn() return DEFAULT_SITE_URL }) .client((): string => - typeof window !== 'undefined' - ? window.location.origin - : DEFAULT_SITE_URL, + typeof window !== 'undefined' ? window.location.origin : DEFAULT_SITE_URL, ) /** From 13330a7014a310bd477d9d00f999ed54d3d023ea Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 6 May 2026 23:18:36 +0200 Subject: [PATCH 05/15] debug(og): surface render error message in 500 response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary diagnostic — without Netlify function log access we can't see why takumi still fails on the deploy preview after the external_node_modules fix. Bake the error name/message/stack/cause into the 500 body so a `curl` against /api/og/.png shows the underlying failure (likely the napi binding load, but want to confirm before iterating). To be reverted once the binding loads cleanly. --- src/routes/api/og/$library[.png].ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/routes/api/og/$library[.png].ts b/src/routes/api/og/$library[.png].ts index be13c1c7..1b396058 100644 --- a/src/routes/api/og/$library[.png].ts +++ b/src/routes/api/og/$library[.png].ts @@ -46,7 +46,24 @@ export const Route = createFileRoute('/api/og/$library.png')({ await result.ready } catch (error) { console.error('Failed to generate OG image', error) - return new Response('Failed to generate OG image', { status: 500 }) + // Surface the underlying message+stack in the response body so we + // can diagnose Netlify-only render failures without log access. + // TODO: trim back to "Failed to generate OG image" once the takumi + // binding load on Netlify is verified working. + const detail = + error instanceof Error + ? `${error.name}: ${error.message}\n${error.stack ?? ''}\n${ + error.cause instanceof Error + ? `caused by ${error.cause.name}: ${error.cause.message}\n${error.cause.stack ?? ''}` + : error.cause + ? `caused by ${String(error.cause)}` + : '' + }` + : String(error) + return new Response(`Failed to generate OG image\n\n${detail}`, { + status: 500, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) } return result From 7e199d42655c400ecae1e8e8372c54207ca8f470 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 6 May 2026 23:23:46 +0200 Subject: [PATCH 06/15] fix(og): include @takumi-rs/core-linux-x64-gnu binary in function bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `external_node_modules = ["@takumi-rs/core"]` alone wasn't enough — Netlify's bundler ships the package and its declared deps but doesn't trace optional platform-specific deps loaded via napi-rs's runtime require dispatcher. Confirmed via diagnostic 500 body on the deploy preview: "Cannot find native binding. npm has a bug related to optional dependencies". List the Linux x64 binary package explicitly so the .node file is included alongside the loader. --- netlify.toml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/netlify.toml b/netlify.toml index f71efa0e..0177f393 100644 --- a/netlify.toml +++ b/netlify.toml @@ -13,11 +13,17 @@ included_files = [ "public/images/logos/splash-dark.png", ] # @takumi-rs/core ships platform-specific .node binaries via napi-rs's runtime -# require() dispatcher — esbuild can't statically trace the optional deps, so -# the Linux binary is missing from the zipped function. Keep the package -# external so node_modules/@takumi-rs/core (and the matching @takumi-rs/core- -# linux-x64-gnu installed by pnpm on the build machine) is shipped as-is. -external_node_modules = ["@takumi-rs/core"] +# require() dispatcher — esbuild can't statically trace the optional deps. Keep +# the napi loader external so node_modules/@takumi-rs/core ships as-is, and +# also list the Linux x64 binary package explicitly: Netlify's bundler only +# follows declared deps for `external_node_modules`, so the optional platform +# package would otherwise be dropped (verified — function failed at runtime +# with "Cannot find native binding"). Netlify functions run on AWS Lambda +# Amazon Linux 2 (glibc, x64), hence linux-x64-gnu. +external_node_modules = [ + "@takumi-rs/core", + "@takumi-rs/core-linux-x64-gnu", +] [[headers]] for = "/*" From 51be49fb9451e90347066d11cde3e538b4a59b39 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 21:24:41 +0000 Subject: [PATCH 07/15] ci: apply automated fixes --- netlify.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netlify.toml b/netlify.toml index 0177f393..96d00937 100644 --- a/netlify.toml +++ b/netlify.toml @@ -20,10 +20,7 @@ included_files = [ # package would otherwise be dropped (verified — function failed at runtime # with "Cannot find native binding"). Netlify functions run on AWS Lambda # Amazon Linux 2 (glibc, x64), hence linux-x64-gnu. -external_node_modules = [ - "@takumi-rs/core", - "@takumi-rs/core-linux-x64-gnu", -] +external_node_modules = ["@takumi-rs/core", "@takumi-rs/core-linux-x64-gnu"] [[headers]] for = "/*" From 4ce42945904371260b5e1ed1cdd8217c0a109403 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 7 May 2026 00:15:21 +0200 Subject: [PATCH 08/15] fix(og): pin @takumi-rs/core-linux-x64-gnu as top-level optional dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External_node_modules with the platform package alone wasn't enough on Netlify — runtime still threw "Cannot find native binding". Root cause: when @takumi-rs/core-linux-x64-gnu is only a transitive optional dep under @takumi-rs/core, pnpm tucks it inside the .pnpm store and the Netlify deploy zip drops the platform-specific symlink, leaving @takumi-rs/core's napi loader unable to resolve the binary. Declare it as our own optional dep so pnpm hoists the binary (when matching platform — i.e. on Netlify's Linux x64 build, no-op locally). That puts node_modules/@takumi-rs/core-linux-x64-gnu/ at a stable top-level path that Netlify's bundler ships reliably and that Node's walk-up require resolution finds without depending on pnpm's symlink graph. Also pin supportedArchitectures so the lockfile carries the Linux x64 glibc binary even when regenerated from a darwin-arm64 dev machine. --- package.json | 8 ++++++++ pnpm-lock.yaml | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/package.json b/package.json index 4f50b342..e9048c26 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,9 @@ "zod": "^4.3.6", "zustand": "^5.0.12" }, + "optionalDependencies": { + "@takumi-rs/core-linux-x64-gnu": "^1.1.2" + }, "devDependencies": { "@content-collections/core": "^0.14.3", "@content-collections/vite": "^0.2.9", @@ -165,6 +168,11 @@ "minimatch@3.1.5>brace-expansion": "1.1.12", "minimatch@5.1.9>brace-expansion": "2.0.3", "minimatch@10.2.5>brace-expansion": "5.0.5" + }, + "supportedArchitectures": { + "os": ["current", "linux"], + "cpu": ["current", "x64"], + "libc": ["current", "glibc"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe67974a..5d6d55ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,6 +375,10 @@ importers: vite: specifier: ^8.0.3 version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) + optionalDependencies: + '@takumi-rs/core-linux-x64-gnu': + specifier: ^1.1.2 + version: 1.1.2 packages: From 7969bdd94553c22720b217ce748790f1f834dbea Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 22:16:26 +0000 Subject: [PATCH 09/15] ci: apply automated fixes --- package.json | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e9048c26..2a7be216 100644 --- a/package.json +++ b/package.json @@ -170,9 +170,18 @@ "minimatch@10.2.5>brace-expansion": "5.0.5" }, "supportedArchitectures": { - "os": ["current", "linux"], - "cpu": ["current", "x64"], - "libc": ["current", "glibc"] + "os": [ + "current", + "linux" + ], + "cpu": [ + "current", + "x64" + ], + "libc": [ + "current", + "glibc" + ] } } } From 6cbc0b260fca861ad16d5c78c28386c4c10810ff Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 7 May 2026 00:23:32 +0200 Subject: [PATCH 10/15] debug(og): dump function fs structure on render failure --- src/routes/api/og/$library[.png].ts | 41 +++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/routes/api/og/$library[.png].ts b/src/routes/api/og/$library[.png].ts index 1b396058..fc066bfa 100644 --- a/src/routes/api/og/$library[.png].ts +++ b/src/routes/api/og/$library[.png].ts @@ -1,3 +1,5 @@ +import { readdirSync, existsSync, realpathSync } from 'node:fs' +import { dirname } from 'node:path' import { createFileRoute } from '@tanstack/react-router' import { generateOgImageResponse } from '~/server/og/generate.server' @@ -6,6 +8,17 @@ const CACHE_HEADERS = { 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800', } as const +function listOrError(path: string): string { + try { + if (!existsSync(path)) return `(missing) ${path}` + const real = realpathSync(path) + const entries = readdirSync(path).slice(0, 80).join(', ') + return `${path}${path === real ? '' : ` -> ${real}`}: ${entries}` + } catch (err) { + return `${path}: ${err instanceof Error ? err.message : String(err)}` + } +} + export const Route = createFileRoute('/api/og/$library.png')({ server: { handlers: { @@ -46,10 +59,6 @@ export const Route = createFileRoute('/api/og/$library.png')({ await result.ready } catch (error) { console.error('Failed to generate OG image', error) - // Surface the underlying message+stack in the response body so we - // can diagnose Netlify-only render failures without log access. - // TODO: trim back to "Failed to generate OG image" once the takumi - // binding load on Netlify is verified working. const detail = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}\n${ @@ -60,10 +69,26 @@ export const Route = createFileRoute('/api/og/$library.png')({ : '' }` : String(error) - return new Response(`Failed to generate OG image\n\n${detail}`, { - status: 500, - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, - }) + + const fsDump = [ + `cwd: ${process.cwd()}`, + `__dirname approx: /var/task`, + listOrError('/var/task'), + listOrError('/var/task/node_modules'), + listOrError('/var/task/node_modules/@takumi-rs'), + listOrError('/var/task/node_modules/.pnpm'), + listOrError( + '/var/task/node_modules/.pnpm/@takumi-rs+core@1.1.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@takumi-rs', + ), + ].join('\n\n') + + return new Response( + `Failed to generate OG image\n\n${detail}\n\n--- fs dump ---\n${fsDump}`, + { + status: 500, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }, + ) } return result From 52df45c8df6492d6fa792241e14a69675b6b26b0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 7 May 2026 00:37:32 +0200 Subject: [PATCH 11/15] fix(og): render via @takumi-rs/wasm to avoid Netlify napi binary issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @takumi-rs/core relies on platform-specific .node binaries loaded via napi-rs's runtime require() dispatcher. Netlify's function bundler consistently dropped the optional Linux x64 binary from the deploy zip no matter how we configured `external_node_modules`, `optionalDependencies`, or `supportedArchitectures` — fs dump from the function showed @takumi-rs/core-linux-x64-gnu wasn't even present in .pnpm/, only @takumi-rs/core itself. Switch the renderer to WASM. Pass `module: ` to ImageResponse so takumi-js's render path takes the WASM branch (`getImports()` initializes via @takumi-rs/wasm when `module` is set). The .wasm file is exposed via the package's `./takumi_wasm_bg.wasm` subpath; resolve and read it once at module scope and reuse the bytes. Also list the .wasm asset in netlify.toml `included_files` so the bundler ships the binary alongside the function — it's not part of the JS import graph so it isn't auto-traced. Drop the external_node_modules + optionalDependencies + supportedArchitectures hacks now that the native loader is no longer in play. Local smoke tests render valid PNGs via WASM (~280KB, 1200x630). --- netlify.toml | 16 +++++++--------- package.json | 17 ----------------- pnpm-lock.yaml | 4 ---- src/server/og/generate.server.ts | 23 +++++++++++++++++++++++ 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/netlify.toml b/netlify.toml index 96d00937..58cd29d4 100644 --- a/netlify.toml +++ b/netlify.toml @@ -7,20 +7,18 @@ 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", ] -# @takumi-rs/core ships platform-specific .node binaries via napi-rs's runtime -# require() dispatcher — esbuild can't statically trace the optional deps. Keep -# the napi loader external so node_modules/@takumi-rs/core ships as-is, and -# also list the Linux x64 binary package explicitly: Netlify's bundler only -# follows declared deps for `external_node_modules`, so the optional platform -# package would otherwise be dropped (verified — function failed at runtime -# with "Cannot find native binding"). Netlify functions run on AWS Lambda -# Amazon Linux 2 (glibc, x64), hence linux-x64-gnu. -external_node_modules = ["@takumi-rs/core", "@takumi-rs/core-linux-x64-gnu"] [[headers]] for = "/*" diff --git a/package.json b/package.json index 2a7be216..4f50b342 100644 --- a/package.json +++ b/package.json @@ -119,9 +119,6 @@ "zod": "^4.3.6", "zustand": "^5.0.12" }, - "optionalDependencies": { - "@takumi-rs/core-linux-x64-gnu": "^1.1.2" - }, "devDependencies": { "@content-collections/core": "^0.14.3", "@content-collections/vite": "^0.2.9", @@ -168,20 +165,6 @@ "minimatch@3.1.5>brace-expansion": "1.1.12", "minimatch@5.1.9>brace-expansion": "2.0.3", "minimatch@10.2.5>brace-expansion": "5.0.5" - }, - "supportedArchitectures": { - "os": [ - "current", - "linux" - ], - "cpu": [ - "current", - "x64" - ], - "libc": [ - "current", - "glibc" - ] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d6d55ee..fe67974a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,10 +375,6 @@ importers: vite: specifier: ^8.0.3 version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) - optionalDependencies: - '@takumi-rs/core-linux-x64-gnu': - specifier: ^1.1.2 - version: 1.1.2 packages: diff --git a/src/server/og/generate.server.ts b/src/server/og/generate.server.ts index dfd6c176..a4b93704 100644 --- a/src/server/og/generate.server.ts +++ b/src/server/og/generate.server.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { ImageResponse } from '@takumi-rs/image-response' import { findLibrary } from '~/libraries' import type { LibraryId } from '~/libraries' @@ -12,6 +14,24 @@ 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`). +let cachedWasmBytes: Uint8Array | null = null +function loadTakumiWasm(): Uint8Array { + if (cachedWasmBytes) return cachedWasmBytes + // @takumi-rs/wasm exposes the binary via the `./takumi_wasm_bg.wasm` + // subpath in its `exports` map. + const require = createRequire(import.meta.url) + const wasmPath = require.resolve('@takumi-rs/wasm/takumi_wasm_bg.wasm') + cachedWasmBytes = readFileSync(wasmPath) + return cachedWasmBytes +} + type GenerateInput = { libraryId: LibraryId | string title?: string @@ -50,6 +70,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', From 3aa49442247f5bd036b5c39056d94579875764f6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 7 May 2026 00:42:28 +0200 Subject: [PATCH 12/15] debug(og): also catch synchronous errors from generateOgImageResponse --- src/routes/api/og/$library[.png].ts | 98 ++++++++++++++++------------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/src/routes/api/og/$library[.png].ts b/src/routes/api/og/$library[.png].ts index fc066bfa..86b78591 100644 --- a/src/routes/api/og/$library[.png].ts +++ b/src/routes/api/og/$library[.png].ts @@ -1,5 +1,4 @@ import { readdirSync, existsSync, realpathSync } from 'node:fs' -import { dirname } from 'node:path' import { createFileRoute } from '@tanstack/react-router' import { generateOgImageResponse } from '~/server/og/generate.server' @@ -19,6 +18,41 @@ function listOrError(path: string): string { } } +function diagnosticErrorResponse(error: unknown): Response { + const detail = + error instanceof Error + ? `${error.name}: ${error.message}\n${error.stack ?? ''}\n${ + error.cause instanceof Error + ? `caused by ${error.cause.name}: ${error.cause.message}\n${error.cause.stack ?? ''}` + : error.cause + ? `caused by ${String(error.cause)}` + : '' + }` + : String(error) + + const fsDump = [ + `cwd: ${process.cwd()}`, + listOrError('/var/task'), + listOrError('/var/task/node_modules'), + listOrError('/var/task/node_modules/@takumi-rs'), + listOrError('/var/task/node_modules/.pnpm'), + listOrError( + '/var/task/node_modules/.pnpm/@takumi-rs+wasm@1.1.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@takumi-rs/wasm', + ), + listOrError( + '/var/task/node_modules/.pnpm/@takumi-rs+wasm@1.1.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@takumi-rs/wasm/pkg', + ), + ].join('\n\n') + + return new Response( + `Failed to generate OG image\n\n${detail}\n\n--- fs dump ---\n${fsDump}`, + { + status: 500, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }, + ) +} + export const Route = createFileRoute('/api/og/$library.png')({ server: { handlers: { @@ -36,59 +70,33 @@ 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 diagnosticErrorResponse(error) + } if ('kind' in result) { return new Response(`Unknown library: ${libraryId}`, { status: 404 }) } - // ImageResponse builds the Response synchronously (status 200, image - // content-type) and renders inside a ReadableStream. If the render - // throws — e.g. takumi's native binding fails to load — the stream - // is errored but the response headers are already sent, producing - // an empty 200 OK that gets cached at the edge. Await the ready - // promise so render errors surface as 500s. + // 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) - const detail = - error instanceof Error - ? `${error.name}: ${error.message}\n${error.stack ?? ''}\n${ - error.cause instanceof Error - ? `caused by ${error.cause.name}: ${error.cause.message}\n${error.cause.stack ?? ''}` - : error.cause - ? `caused by ${String(error.cause)}` - : '' - }` - : String(error) - - const fsDump = [ - `cwd: ${process.cwd()}`, - `__dirname approx: /var/task`, - listOrError('/var/task'), - listOrError('/var/task/node_modules'), - listOrError('/var/task/node_modules/@takumi-rs'), - listOrError('/var/task/node_modules/.pnpm'), - listOrError( - '/var/task/node_modules/.pnpm/@takumi-rs+core@1.1.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@takumi-rs', - ), - ].join('\n\n') - - return new Response( - `Failed to generate OG image\n\n${detail}\n\n--- fs dump ---\n${fsDump}`, - { - status: 500, - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, - }, - ) + return diagnosticErrorResponse(error) } return result From d49ae4babc22e16c4d4d61f5b27d989309f8712a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 7 May 2026 00:49:09 +0200 Subject: [PATCH 13/15] fix(og): resolve takumi wasm path through pnpm store as fallback Netlify's function bundler ships @takumi-rs/wasm under node_modules/.pnpm but doesn't create a top-level node_modules/@takumi-rs/wasm symlink, so require.resolve('@takumi-rs/wasm/takumi_wasm_bg.wasm') fails at runtime even though the .wasm file is present (verified via fs dump from the preview deploy). Try standard resolution first (works locally), then fall back to walking node_modules/.pnpm for the @takumi-rs+wasm@ directory and reading the binary directly. --- src/server/og/generate.server.ts | 55 ++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/server/og/generate.server.ts b/src/server/og/generate.server.ts index a4b93704..794f75ee 100644 --- a/src/server/og/generate.server.ts +++ b/src/server/og/generate.server.ts @@ -1,5 +1,6 @@ -import { readFileSync } from 'node:fs' +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' @@ -21,15 +22,55 @@ const ISLAND_KEY = 'island' // `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 - // @takumi-rs/wasm exposes the binary via the `./takumi_wasm_bg.wasm` - // subpath in its `exports` map. - const require = createRequire(import.meta.url) - const wasmPath = require.resolve('@takumi-rs/wasm/takumi_wasm_bg.wasm') - cachedWasmBytes = readFileSync(wasmPath) - 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 = { From e4ec92eafb7428502f59826646a85a8b6cbb6b4f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 22:50:16 +0000 Subject: [PATCH 14/15] ci: apply automated fixes --- src/server/og/generate.server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/og/generate.server.ts b/src/server/og/generate.server.ts index 794f75ee..784c56d6 100644 --- a/src/server/og/generate.server.ts +++ b/src/server/og/generate.server.ts @@ -23,7 +23,8 @@ const ISLAND_KEY = 'island' // 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' +const WASM_PNPM_REL_PATH = + 'node_modules/@takumi-rs/wasm/pkg/takumi_wasm_bg.wasm' let cachedWasmBytes: Uint8Array | null = null function loadTakumiWasm(): Uint8Array { From 7076362dd91f0237e61f32382f3697312be21ba9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 7 May 2026 00:57:47 +0200 Subject: [PATCH 15/15] chore(og): drop diagnostic stack-trace from 500 response Now that takumi-on-Netlify is verified working through WASM + the pnpm store walk, trim the route handler back to a plain "Failed to generate OG image" body. Errors still log to console.error for Netlify function logs. --- src/routes/api/og/$library[.png].ts | 51 ++--------------------------- 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/src/routes/api/og/$library[.png].ts b/src/routes/api/og/$library[.png].ts index 86b78591..f2d54c82 100644 --- a/src/routes/api/og/$library[.png].ts +++ b/src/routes/api/og/$library[.png].ts @@ -1,4 +1,3 @@ -import { readdirSync, existsSync, realpathSync } from 'node:fs' import { createFileRoute } from '@tanstack/react-router' import { generateOgImageResponse } from '~/server/og/generate.server' @@ -7,52 +6,6 @@ const CACHE_HEADERS = { 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800', } as const -function listOrError(path: string): string { - try { - if (!existsSync(path)) return `(missing) ${path}` - const real = realpathSync(path) - const entries = readdirSync(path).slice(0, 80).join(', ') - return `${path}${path === real ? '' : ` -> ${real}`}: ${entries}` - } catch (err) { - return `${path}: ${err instanceof Error ? err.message : String(err)}` - } -} - -function diagnosticErrorResponse(error: unknown): Response { - const detail = - error instanceof Error - ? `${error.name}: ${error.message}\n${error.stack ?? ''}\n${ - error.cause instanceof Error - ? `caused by ${error.cause.name}: ${error.cause.message}\n${error.cause.stack ?? ''}` - : error.cause - ? `caused by ${String(error.cause)}` - : '' - }` - : String(error) - - const fsDump = [ - `cwd: ${process.cwd()}`, - listOrError('/var/task'), - listOrError('/var/task/node_modules'), - listOrError('/var/task/node_modules/@takumi-rs'), - listOrError('/var/task/node_modules/.pnpm'), - listOrError( - '/var/task/node_modules/.pnpm/@takumi-rs+wasm@1.1.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@takumi-rs/wasm', - ), - listOrError( - '/var/task/node_modules/.pnpm/@takumi-rs+wasm@1.1.2_react-dom@19.2.3_react@19.2.3__react@19.2.3/node_modules/@takumi-rs/wasm/pkg', - ), - ].join('\n\n') - - return new Response( - `Failed to generate OG image\n\n${detail}\n\n--- fs dump ---\n${fsDump}`, - { - status: 500, - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, - }, - ) -} - export const Route = createFileRoute('/api/og/$library.png')({ server: { handlers: { @@ -82,7 +35,7 @@ export const Route = createFileRoute('/api/og/$library.png')({ ) } catch (error) { console.error('Failed to construct OG response', error) - return diagnosticErrorResponse(error) + return new Response('Failed to generate OG image', { status: 500 }) } if ('kind' in result) { @@ -96,7 +49,7 @@ export const Route = createFileRoute('/api/og/$library.png')({ await result.ready } catch (error) { console.error('Failed to generate OG image', error) - return diagnosticErrorResponse(error) + return new Response('Failed to generate OG image', { status: 500 }) } return result