Skip to content

fix(og): externalize @takumi-rs/core so Netlify ships the native binary#893

Merged
LadyBluenotes merged 15 commits intomainfrom
fix/og-netlify-takumi-binary
May 6, 2026
Merged

fix(og): externalize @takumi-rs/core so Netlify ships the native binary#893
LadyBluenotes merged 15 commits intomainfrom
fix/og-netlify-takumi-binary

Conversation

@AlemTuzlak
Copy link
Copy Markdown
Contributor

@AlemTuzlak AlemTuzlak commented May 6, 2026

Summary

https://tanstack.com/api/og/<library>.png returns 200 OK with content-type: image/png and a zero-byte body in production — the request succeeds but the cached PNG is empty, so Slack/Twitter/etc. show a broken image. Locally everything works.

Root cause

@takumi-rs/core uses napi-rs's runtime-dispatched native binding loader (createRequire(import.meta.url) + platform-conditional require() of @takumi-rs/core-<platform>-<arch>-<libc>/*.node). Netlify's zip-it-and-ship-it bundles the SSR function with esbuild, which can't statically trace those optional platform binaries — so the Linux .node file is missing from the lambda bundle.

At runtime the binding load fails. The error is thrown inside ImageResponse's ReadableStream.start(controller) callback, but at that point the Response(stream, ...) was already constructed with status 200 and image/png. controller.error() errors the body stream, so Netlify's runtime emits an empty body — and Netlify Edge happily caches the 200 + 0 bytes for s-maxage=86400. Hence the symptom.

HTTP/2 200
content-type: image/png
content-length: 0
cache-status: "Netlify Edge"; hit

Fix

  • netlify.toml — add external_node_modules = ["@takumi-rs/core"] so Netlify ships node_modules/@takumi-rs/core (and the matching @takumi-rs/core-linux-x64-gnu that pnpm installs on the Linux build machine via optional deps) as-is. napi-rs's runtime dispatcher then resolves the binary normally instead of bundling failing.
  • src/routes/api/og/$library[.png].tsawait result.ready before returning, so any future render failure surfaces as a real 500 instead of a silently-cached empty 200. (Without this, even a transient renderer error would poison the edge cache.)

Test plan

  • Local smoke tests: pnpm test:smoke — both OG endpoints render real PNGs (OG image · library landing (284748 bytes), OG image · docs page (273744 bytes))
  • pnpm test (tsc + lint) clean on changed files
  • After merge: curl -sI https://tanstack.com/api/og/ai.png returns content-length > 0 and the image renders in og:image previewers (Slack unfurl, Twitter Card validator)
  • Confirm Netlify deploy logs do not show "Failed to load @takumi-rs/core in Node.js runtime"

Summary by CodeRabbit

  • Bug Fixes

    • Improved Open Graph image generation by awaiting readiness, adding error logging, and returning proper 500 responses when rendering fails.
    • Enhanced Open Graph image URL resolution to reliably derive origin from live server-side requests for SSR contexts.
  • Chores

    • Updated deployment configuration to ensure platform-specific native binaries are packaged as external dependencies.

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
Copy link
Copy Markdown

netlify Bot commented May 6, 2026

Deploy Preview for tanstack ready!

Name Link
🔨 Latest commit 7076362
🔍 Latest deploy log https://app.netlify.com/projects/tanstack/deploys/69fbc78c901a7c00082234f4
😎 Deploy Preview https://deploy-preview-893--tanstack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 39 (🔴 down 16 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (🔴 down 9 from production)
SEO: 97 (no change from production)
PWA: 70 (no change from production)
View the detailed breakdown and full score reports
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Refactors OG image origin resolution to be SSR-aware, adds readiness/error handling when generating OG images, and updates Netlify functions config to include platform-specific native packages as externals for deployment.

Changes

OG Image Generation & Netlify Functions

Layer / File(s) Summary
Infrastructure / Dependencies
netlify.toml
Added explanatory comments and external_node_modules = ["@takumi-rs/core", "@takumi-rs/core-linux-x64-gnu"] under [functions] to ensure platform-specific native binaries are shipped.
Data / Request Shape
src/utils/og.ts
Introduced SSR-aware origin derivation by importing getRequest and createIsomorphicFn; removed the canonicalUrl client fallback.
Core Implementation
src/utils/og.ts
Added getOgOrigin() implemented isomorphically: server branch derives origin from getRequest()'s URL, client branch uses window.location.origin or default; ogImageUrl now always uses getOgOrigin().
Error Handling / Control Flow
src/routes/api/og/$library[.png].ts
Await the image-generation result.ready inside a try/catch, log rendering failures, and return HTTP 500 on render errors; preserved existing 404 handling for unknown libraries.
Tests / Docs
(none changed)
No public API, tests, or docs updated in this diff.

Sequence Diagram

sequenceDiagram
  participant Browser
  participant Server
  participant ImageRenderer
  Browser->>Server: GET /api/og/{library}.png
  Server->>Server: getRequest() -> derive origin (SSR) / compute OG URL
  Server->>ImageRenderer: generate image (uses derived origin)
  ImageRenderer-->>Server: returns ImageResponse-like result
  Server->>Server: await result.ready (try/catch)
  alt success
    Server-->>Browser: 200 image/png
  else failure
    Server-->>Browser: 500 error
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐇
I hop from request to root and trace the line,
I check the origin where the SSR lights shine.
I wait for canvas to wake and sing,
Catch the flops, log the missing wing.
Tiny paws, big pixels — OGs align.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly summarizes the main change: externalizing @takumi-rs/core so Netlify ships the native binary, which is the core fix addressing the production issue.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/og-netlify-takumi-binary

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

On Netlify deploy previews, og:image was rendering as
`https://tanstack.com/api/og/<lib>.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/<lib>.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.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/utils/og.ts (1)

1-1: 💤 Low value

Static server-only import: consider a dynamic import for defense-in-depth.

The static top-level import of @tanstack/react-start/server means that if the import.meta.env.SSR guard on getRequest() is ever accidentally bypassed or removed, the server module silently leaks into the client bundle (the importProtection bypass in vite.config.ts means the protection layer no longer catches regressions in this file). A dynamic import in the SSR-only branch completely eliminates this risk without adding real complexity:

♻️ Optional: dynamic import alternative
-import { getRequest } from '@tanstack/react-start/server'
 function getOgOrigin(): string {
   if (!import.meta.env.SSR) return DEFAULT_SITE_URL
   try {
-    const request = getRequest()
+    const { getRequest } = await import('@tanstack/react-start/server')
+    const request = getRequest()
     if (request?.url) return new URL(request.url).origin
   } catch {

Note: making getOgOrigin async also requires making ogImageUrl async and updating all call sites. If the churn is undesirable, the current approach is functionally correct as-is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/og.ts` at line 1, Replace the top-level static import of getRequest
from '@tanstack/react-start/server' with a dynamic import inside the SSR-only
branch so server-only code is never pulled into the client; update the
getOgOrigin implementation to perform await
import('@tanstack/react-start/server') when import.meta.env.SSR is true and call
the imported getRequest there, and then either (a) make getOgOrigin async and
propagate that change to ogImageUrl and its call sites, or (b) keep ogImageUrl
sync by extracting only the SSR-only path into an async helper and guarding
calls so client builds won't import the server module. Ensure references to
getRequest, getOgOrigin, and ogImageUrl are updated accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/utils/og.ts`:
- Line 1: Replace the top-level static import of getRequest from
'@tanstack/react-start/server' with a dynamic import inside the SSR-only branch
so server-only code is never pulled into the client; update the getOgOrigin
implementation to perform await import('@tanstack/react-start/server') when
import.meta.env.SSR is true and call the imported getRequest there, and then
either (a) make getOgOrigin async and propagate that change to ogImageUrl and
its call sites, or (b) keep ogImageUrl sync by extracting only the SSR-only path
into an async helper and guarding calls so client builds won't import the server
module. Ensure references to getRequest, getOgOrigin, and ogImageUrl are updated
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0bf1d02b-5aac-48fe-819e-18687d1deb71

📥 Commits

Reviewing files that changed from the base of the PR and between ccd4a68 and 30bc766.

📒 Files selected for processing (2)
  • src/utils/og.ts
  • vite.config.ts

AlemTuzlak and others added 3 commits May 6, 2026 23:08
…teIsomorphicFn

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.
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/<lib>.png shows the underlying
failure (likely the napi binding load, but want to confirm before
iterating). To be reverted once the binding loads cleanly.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/api/og/`$library[.png].ts:
- Around line 49-66: The response currently returns full stack traces and nested
error causes in the Response body via the detail variable and should be
restricted to non-production; change the logic that builds detail (the error
instanceof Error ternary and nested cause handling) to only include stacks,
cause stacks and full error names/messages when a debug env flag is set (e.g.
process.env.DEBUG_OG === 'true' or process.env.NODE_ENV !== 'production'), and
otherwise return a minimal, non-sensitive message (e.g. "Failed to generate OG
image" plus a short error code or error.name only). Keep the Response status and
headers the same, preserve the existing error instanceof Error checks for
producing any message, and ensure no stack or internal paths are included in
production responses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4d02b96e-df1b-4e43-b07a-ac3de99d8b98

📥 Commits

Reviewing files that changed from the base of the PR and between fe3da62 and 13330a7.

📒 Files selected for processing (1)
  • src/routes/api/og/$library[.png].ts

Comment thread src/routes/api/og/$library[.png].ts Outdated
AlemTuzlak and others added 2 commits May 6, 2026 23:23
`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.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
netlify.toml (1)

23-23: Hard-coded linux-x64-gnu will silently break if Netlify's runtime changes.

The comment correctly documents today's environment (AWS Lambda AL2, glibc, x64). If Netlify migrates to arm64 (Graviton) or a musl-based image, only the linux-x64-gnu binary will be shipped and the function will again fail at runtime with a missing native binding. Consider adding a brief maintenance note or a TODO so it's easy to find when the environment changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@netlify.toml` at line 23, Hard-coded platform entry "linux-x64-gnu" in
external_node_modules will break if Netlify's runtime/arch changes; update the
external_node_modules configuration (the external_node_modules array) to avoid
pinning a single binary — either add a small TODO/maintenance comment noting
runtime assumptions and a reminder to add alternate builds if Netlify moves to
arm64/musl, or replace the single entry with a more flexible approach (e.g.,
include other platform variants or use a build step to populate appropriate
"@takumi-rs/core-<platform>" entries) so the native binding isn't silently
missing at runtime.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@netlify.toml`:
- Line 23: Hard-coded platform entry "linux-x64-gnu" in external_node_modules
will break if Netlify's runtime/arch changes; update the external_node_modules
configuration (the external_node_modules array) to avoid pinning a single binary
— either add a small TODO/maintenance comment noting runtime assumptions and a
reminder to add alternate builds if Netlify moves to arm64/musl, or replace the
single entry with a more flexible approach (e.g., include other platform
variants or use a build step to populate appropriate
"@takumi-rs/core-<platform>" entries) so the native binding isn't silently
missing at runtime.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4cb07457-cf9f-42f0-8327-2fd71aa27e03

📥 Commits

Reviewing files that changed from the base of the PR and between 13330a7 and 51be49f.

📒 Files selected for processing (1)
  • netlify.toml

AlemTuzlak and others added 8 commits May 7, 2026 00:15
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.
@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: <wasm bytes>` 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'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@<version> directory and
reading the binary directly.
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.
@LadyBluenotes LadyBluenotes merged commit b515fac into main May 6, 2026
8 checks passed
@LadyBluenotes LadyBluenotes deleted the fix/og-netlify-takumi-binary branch May 6, 2026 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants