fix(og): externalize @takumi-rs/core so Netlify ships the native binary#893
fix(og): externalize @takumi-rs/core so Netlify ships the native binary#893LadyBluenotes merged 15 commits intomainfrom
Conversation
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.
✅ Deploy Preview for tanstack ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughRefactors 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. ChangesOG Image Generation & Netlify Functions
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/utils/og.ts (1)
1-1: 💤 Low valueStatic server-only import: consider a dynamic import for defense-in-depth.
The static top-level import of
@tanstack/react-start/servermeans that if theimport.meta.env.SSRguard ongetRequest()is ever accidentally bypassed or removed, the server module silently leaks into the client bundle (theimportProtectionbypass invite.config.tsmeans 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
getOgOriginasync also requires makingogImageUrlasync 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
📒 Files selected for processing (2)
src/utils/og.tsvite.config.ts
…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.
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
src/routes/api/og/$library[.png].ts
`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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
netlify.toml (1)
23-23: Hard-codedlinux-x64-gnuwill 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-gnubinary 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.
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.

Summary
https://tanstack.com/api/og/<library>.pngreturns 200 OK withcontent-type: image/pngand 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/coreuses napi-rs's runtime-dispatched native binding loader (createRequire(import.meta.url)+ platform-conditionalrequire()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.nodefile is missing from the lambda bundle.At runtime the binding load fails. The error is thrown inside
ImageResponse'sReadableStream.start(controller)callback, but at that point theResponse(stream, ...)was already constructed with status 200 andimage/png.controller.error()errors the body stream, so Netlify's runtime emits an empty body — and Netlify Edge happily caches the 200 + 0 bytes fors-maxage=86400. Hence the symptom.Fix
netlify.toml— addexternal_node_modules = ["@takumi-rs/core"]so Netlify shipsnode_modules/@takumi-rs/core(and the matching@takumi-rs/core-linux-x64-gnuthat 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].ts—await result.readybefore returning, so any future render failure surfaces as a real500instead of a silently-cached empty 200. (Without this, even a transient renderer error would poison the edge cache.)Test plan
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 filescurl -sI https://tanstack.com/api/og/ai.pngreturnscontent-length > 0and the image renders in og:image previewers (Slack unfurl, Twitter Card validator)Summary by CodeRabbit
Bug Fixes
Chores