personal-lifecycle-tracker / .agents / skills / neon-functions / references / native-binaries.md
native-binaries.md
Raw

Shipping native binaries with a Function

The default deploy bundles source into a single index.mjs with esbuild. A compiled binary cannot be inlined into that file, so it has to ship as a separate file in the deploy archive. There are three ways to put it there. Pick by what the binary is:

The binary is Use
An npm package backed by a Node-API addon (sharp, most @napi-rs/* packages) externalPackages
A file your own build step produces: a .node addon, a .so, a .wasm, model weights bundler: "none" or a custom bundler
A standalone executable you spawn (ffmpeg, a Go or Rust CLI) A custom bundler or "none", plus a copy to /run at startup

The examples use top-level functions, which needs neon CLI 4.20 or newer and @neon/config 1.6 or newer.

The runtime a binary lands on

  • linux, arm64, glibc, Node.js 24. A binary built for macOS or x64 cannot load there.
  • The archive is extracted to /opt/function, a read-only filesystem. import.meta.url in the root index.mjs points there, so new URL("./bin/tool", import.meta.url) resolves to /opt/function/bin/tool.
  • Every file arrives with mode 644. Unix permission bits in the zip are dropped on extraction, and chmod in place fails with EROFS. A .node or .so loads fine (it is dlopened, not executed). Spawning a file from /opt/function fails with EACCES.
  • /tmp is writable and mounted noexec: a copied binary still fails with EACCES after chmod 755.
  • /run is a writable tmpfs that allows exec (about 990 MiB, backed by the function's 2048 MiB of memory).

The filesystem layout is observed behavior, not documented by Neon. Re-check it if spawning starts failing.

externalPackages: npm packages with a Node addon

// neon.ts
import { defineConfig } from "@neon/config/v1";

export default defineConfig({
  functions: {
    resize: {
      name: "Resize",
      source: "./src/resize.ts",
      externalPackages: ["sharp"],
    },
  },
});

esbuild leaves import sharp from "sharp" unresolved. At deploy, the CLI installs the version of sharp from your project with npm install --cpu=arm64 --os=linux --libc=glibc --ignore-scripts into a temp directory, traces the files it reaches with @vercel/nft, and copies them into the archive under node_modules/ with the tree layout intact. Only the installed version is read from your node_modules; the shipped files come from the temp install, and your node_modules is not modified.

The deploy fails with a named error when:

  • the package is not installed in your project (no version to pin)
  • the declared package itself does not install for linux-arm64 glibc (EBADPLATFORM)
  • a staged .node or .so is not an AArch64 ELF binary
  • npm is not on PATH
  • the archive exceeds the size limits

It does not fail when a platform-specific optional dependency is silently skipped, or when a package that compiles from source at install time ships no binary (the staging install runs with --ignore-scripts). Those deploy and then fail at invoke. Use packages that publish a linux-arm64 glibc prebuild (sharp does), and invoke the deployed function once to confirm the addon loads.

A deploy and neon dev print an advisory warning for any bundled package that carries native code and is not declared. A package with a working JavaScript fallback (ws with bufferutil) triggers it too and needs no change. Do not silence it with { name, includeFiles: false }: that externalizes the package and ships nothing, so a reached import throws Cannot find module on every invoke. includeFiles: false is only for an import the function never evaluates.

Under neon dev, the package is only kept out of the bundle and resolves from your own node_modules for your host.

Pros: one line in neon.ts; the target-platform install, file tracing, arch check, and size check are automatic; local dev uses your host build.

Cons: only for npm packages that publish a linux-arm64 glibc prebuild; transitive pins, overrides, and patches from your lockfile are not carried into the staged install; esbuild bundler only (externalPackages with bundler: "none" or a function fails validation).

bundler: "none": ship a prebuilt directory

Run your own build, then point source at its output. The directory root must contain index.mjs or index.js. Everything else in the directory ships as-is:

dist/fn/
  index.mjs        # built by your step, reads ./native/addon.node
  native/addon.node
// neon.ts
import { defineConfig } from "@neon/config/v1";

export default defineConfig({
  functions: {
    api: { name: "API", source: "./dist/fn", bundler: "none" },
  },
});
npm run build:fn && neon deploy --env .env.local
neon functions deploy api --src dist/fn --no-bundle   # same thing, without neon.ts

neon deploy does not run your build step. Run it first, or use a custom bundler to keep the build inside neon deploy.

Pros: works with any build tool or framework output (.mastra/output is the common one); full control over the archive layout.

Cons: no architecture check, so a binary built for your laptop deploys and then fails at invoke; the build step is yours to keep in sync with neon deploy; TypeScript cannot ship unbundled.

Custom bundler: return the file map

An inline function in neon.ts returns archive paths mapped to bytes. neon deploy zips the map, and neon dev serves the same map locally:

// neon.ts
import { readFile } from "node:fs/promises";
import { build } from "esbuild";
import { defineConfig } from "@neon/config/v1";

export default defineConfig({
  functions: {
    transcode: {
      name: "Transcode",
      source: "./src/transcode.ts",
      bundler: async (fn) => {
        const out = await build({
          entryPoints: [fn.source],
          bundle: true,
          platform: "node",
          format: "esm",
          write: false,
          outfile: "index.mjs",
          // CommonJS dependencies call require(); ESM output has none in scope.
          banner: {
            js: "import{createRequire}from'module';const require=createRequire(import.meta.url);",
          },
        });
        return {
          "index.mjs": out.outputFiles[0].contents,
          "bin/ffmpeg": new Uint8Array(
            await readFile(new URL("./vendor/linux-arm64/ffmpeg", import.meta.url)),
          ),
        };
      },
    },
  },
});

esbuild must be a dependency of your project. The map needs index.mjs or index.js at the root.

Pros: the build lives in neon.ts, so neon deploy and neon dev always build the same thing; any file from anywhere can go in the archive.

Cons: no architecture check; externalPackages cannot be combined with it, so an npm addon has to be copied into the map by hand, with its node_modules/ layout.

Standalone executables

Ship the executable with a custom bundler or bundler: "none", then copy it to /run and mark it executable once per isolate:

// src/transcode.ts
import { execFile } from "node:child_process";
import { chmod, copyFile, mkdir } from "node:fs/promises";
import { promisify } from "node:util";

const run = promisify(execFile);

let ffmpeg: Promise<string> | undefined;
function ffmpegPath(): Promise<string> {
  if (process.env.FFMPEG_PATH) return Promise.resolve(process.env.FFMPEG_PATH);
  ffmpeg ??= (async () => {
    await mkdir("/run/bin", { recursive: true });
    await copyFile(new URL("./bin/ffmpeg", import.meta.url), "/run/bin/ffmpeg");
    await chmod("/run/bin/ffmpeg", 0o755);
    return "/run/bin/ffmpeg";
  })();
  return ffmpeg;
}

export default {
  async fetch() {
    const { stdout } = await run(await ffmpegPath(), ["-version"]);
    return new Response(stdout);
  },
};

neon dev runs this code on your machine, where /run may not exist and a linux-arm64 binary cannot execute. Point it at a host install from the shell, and leave FFMPEG_PATH out of the function's deployed env:

FFMPEG_PATH="$(command -v ffmpeg)" neon dev

The binary must be statically linked, or its shared libraries must exist in the runtime image. The copy costs memory: /run is RAM-backed and counts against the function's 2048 MiB.

Size limits

bundler: "none", a custom bundler, and externalPackages staging check the archive before upload:

Limit Value
Compressed zip 10 MiB
Total uncompressed bytes 64 MiB
Files in the archive 4,096

Exceeding one fails the deploy. The byte-limit errors list the four largest files. Check an executable's size before building around it: a single static binary can use most of the 64 MiB.