All skills
neondatabase avatar

/neon-functions

@b8250e6 official

Long-running, serverless Node.js HTTP functions deployed onto your Neon branch, with DATABASE_URL injected automatically and compute that runs next to your data. Use when a user wants to host an API, an AI agent with long streaming responses, a WebSocket or server-sent-events (SSE) server, a webhook handler, a Discord bot, an MCP server, or any request/response workload that risks timing out on short, lambda-style serverless functions — and wants it to branch with their database. Also use for Function Triggers: a cron or an object-storage event that POSTs to a function. Triggers include "serverless function", "deploy an API", "long-running function", "streaming agent", "SSE server", "WebSocket server", "webhook handler", "MCP server", "cron", "function trigger", "scheduled function", "cron job", "object storage trigger", "on upload", "run code next to my database", "function that won't time out", "function logs", "Neon Functions", "Neon Compute", "DDoS protection", "rate limiting", and "production hardening".

Use this Skill: https://skilld.dev/gh/neondatabase/agent-skills/neon-functions

This session only. Nothing lands on disk.

referencesnative-binaries.md

≈2.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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.

Source: SKILL.md on GitHub

1 warningtoday3 checks · Risk SAFE
  • Gen Agent Trust Hubtoday

    This skill provides a comprehensive environment for deploying long-running serverless Node.js functions. It emphasizes robust security practices, including mandatory JWT authentication for public routes, origin secret verification, and input validation. While it facilitates building AI agents which are inherently susceptible to indirect prompt injection, it includes detailed remediation guidance and mitigation strategies.

  • Sockettoday

    No alerts

  • Snyktoday

    Risk: MEDIUM · 1 issue

Signed by skilld at b8250e6. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 hours ago.

Activeupdated yesterday
Other metadata
metadata
{
  "parent": "neon",
  "source": "https://github.com/neondatabase/agent-skills/tree/main/skills/neon-functions"
}
  • API
  • nodejs
  • serverless
  • websocket
  • streaming
  • sse
  • neon
  • postgres
  • agent
  • long-running

README badge

README badge for neondatabase/agent-skills/neon-functions

Deploys long-running Node.js HTTP functions to a Neon branch with automatic DATABASE_URL injection and zero cold starts. Use this for APIs, WebSocket/SSE servers, agent backends with streaming responses, webhook handlers, and any workload that exceeds lambda-style timeouts — all co-located with your Postgres database.

Generated from the current SKILL.md.

Does this work outside us-east-2?
No. Neon Functions are currently only available in us-east-2.
What happens to my function when I create a new branch?
If neon.ts is present, the function is automatically deployed to the new branch at its own URL against the branch's isolated database and storage.
Can I use this for long-running agent workloads?
Yes. Functions are designed for agents that make multiple LLM calls and tool invocations per request; the handler can stay open as long as bytes keep flowing, with no hard execution timeout like lambda-style serverless.
Do I need to manage DATABASE_URL myself?
No. DATABASE_URL is injected automatically at runtime when the branch has Postgres. You retrieve it via parseEnv(config).
Can I host a WebSocket or SSE server on this?
Yes. Functions stay alive across requests, so you can hold WebSocket and SSE connections open in-process without needing an external state store like Redis.

Generated from the current SKILL.md. These answers refresh after source changes.