All skills
neondatabase avatar

/neon-functions

@b870d74 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.

referencessse.md

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

Server-sent events (SSE) on Neon Functions

SSE is the one-way (server → client) streaming counterpart to WebSockets: the browser opens a long-lived GET with EventSource and the server pushes text frames down it. On Neon Functions an SSE endpoint is just a normal fetch handler that returns a Response whose body is a ReadableStream with Content-Type: text/event-stream — there is no library to install and nothing to upgrade. The runtime holds the response open as long as bytes keep flowing (15-minute heartbeat, see Timeouts).

Reach for SSE over WebSockets when you only need server → client updates (live counters, notifications, progress, token streams) — it's simpler to run (plain HTTP, no upgrade), and EventSource reconnects on its own, so there's no client backoff to write.

Minimal SSE endpoint (no framework)

A function's default export is { fetch }; SSE needs nothing more. Return a ReadableStream and write data: frames into it:

// src/index.ts
const encoder = new TextEncoder();

export default {
  fetch(request: Request): Response {
    const url = new URL(request.url);
    if (url.pathname !== "/events") return new Response("ok");

    let timer: ReturnType<typeof setInterval>;
    const stream = new ReadableStream<Uint8Array>({
      start(controller) {
        // An SSE frame is `data: <payload>\n\n`. A line starting with `:` is a
        // comment — used here as a heartbeat to keep the stream from going idle.
        controller.enqueue(encoder.encode("data: hello\n\n"));
        timer = setInterval(
          () => controller.enqueue(encoder.encode(": ping\n\n")),
          25_000,
        );
      },
      // cancel() fires when the client disconnects.
      cancel() {
        clearInterval(timer);
      },
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache, no-transform",
        Connection: "keep-alive",
      },
    });
  },
};

cancel() is a method on the stream's underlying source; it fires when the client disconnects. Use it to drop the client from any broadcast set and clear timers. A cleanup function returned from start() is ignored, so it has to be a real cancel() method.

With Hono

Hono routes the HTTP side; the SSE response is the same ReadableStream. Returning a raw Response keeps full control over the stream (and sidesteps concurrent-write edge cases in stream helpers):

// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";

const app = new Hono();
app.use("*", cors({ origin: process.env.WEB_ORIGIN ?? "*" })); // EventSource is cross-origin from a SPA

app.get("/events", (c) => {
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(new TextEncoder().encode("data: connected\n\n"));
      // ...register `controller` in a broadcast set; see fan-out below.
    },
  });
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
    },
  });
});

export default app;

Push to every client, across isolates

The fan-out rule is identical to WebSockets (Keeping clients in sync across isolates): each isolate keeps its own set of open streams, so broadcasting in-process only reaches the clients on that isolate. Hold a Set of stream controllers and pick a strategy there — poll Postgres by default (keeps Scale to Zero), or LISTEN/NOTIFY (shown below) for lowest latency on always-on compute. Keep the source-of-truth state in Postgres — module state doesn't survive eviction.

import { attachDatabasePool } from "@neon/functions";
import { Pool, Client } from "pg";

const encoder = new TextEncoder();
const clients = new Set<ReadableStreamDefaultController<Uint8Array>>();
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);
const CHANNEL = "events";

// One dedicated DIRECT connection per isolate to receive events (LISTEN needs a
// real session — use DATABASE_URL_UNPOOLED, not the pooled URL).
// Don't call attachDatabasePool here: it would silence the idle drop that killed the feed.
// The error listener keeps the process alive; reconnect the client on error in production (omitted here).
const listener = new Client({
  connectionString: process.env.DATABASE_URL_UNPOOLED,
});
listener.on("error", (err) => {
  console.error(err);
});
listener.connect().then(() => listener.query(`LISTEN ${CHANNEL}`));
listener.on("notification", (msg) => {
  if (!msg.payload) return;
  const frame = encoder.encode(`data: ${msg.payload}\n\n`);
  for (const controller of clients) {
    try {
      controller.enqueue(frame); // enqueue is synchronous — no concurrent-await hazard
    } catch {
      clients.delete(controller); // controller already closed
    }
  }
});

// Anywhere you mutate state, NOTIFY so every isolate pushes to its own streams.
function publish(payload: unknown) {
  return pool.query("SELECT pg_notify($1, $2)", [
    CHANNEL,
    JSON.stringify(payload),
  ]);
}

Register/unregister each connection in clients from the stream's start/cancel, and add a module-scope heartbeat (setInterval, every ~25–30s) that enqueues : ping\n\n to every controller so idle streams stay alive (see Caveats).

Wire format (just text)

Each event is newline-delimited fields ending in a blank line:

data: a one-line payload\n\n
event: count\ndata: 42\n\n          # named event → addEventListener("count", …)
id: 7\ndata: resumable\n\n            # sets EventSource.lastEventId for resume
: this is a comment / heartbeat\n\n   # ignored by the client; keeps the stream warm
retry: 5000\n\n                       # tells the client how long to wait before reconnecting

Send data: with no event: field to deliver the default message event, which the client reads with EventSource.onmessage (no addEventListener needed).

Client

const source = new EventSource(`${FUNCTION_URL}/events`); // GET only
source.onmessage = (e) => console.log("update", e.data);
source.onerror = () => {
  /* EventSource auto-reconnects; nothing to do */
};
// source.close() to stop.

EventSource reconnects automatically with the server's retry: interval, replaying Last-Event-ID if you set id: — so unlike WebSockets you don't write a reconnect loop. Its constraints: it's GET-only and can't set request headers, so authenticate the same way as a WebSocket — a ?token= query param (verify with jwtVerify before streaming) or a cookie. (Use the modern eventsource polyfill if you need Authorization headers.)

Caveats

  • Heartbeat or it dies. Streams stay open only while bytes flow — Neon's window is 15 min (Timeouts), but intermediary proxies are usually far stricter (tens of seconds). Emit a : ping\n\n comment every ~25–30s so the stream never goes quiet.
  • no-transform. Set Cache-Control: no-cache, no-transform so proxies don't buffer or rewrite the stream.
  • Enqueue is synchronous. controller.enqueue() doesn't return a promise, so broadcasting from the LISTEN handler can't interleave awaits mid-write — wrap each in try/catch and drop dead controllers.
  • CORS. A SPA hits the function cross-origin, so set Access-Control-Allow-Origin. EventSource sends no credentials by default, so * is fine for public streams.
  • One-way only. SSE is server → client. For client → server, the browser makes normal fetch/POST calls (often to the same function); reach for WebSockets only when you need bidirectional, low-latency frames.

Together — a Hono fetch SSE endpoint, cross-isolate fan-out, heartbeat, a counter persisted in Postgres, and a client-only TanStack Router SPA consuming it with EventSource — these compose into a complete realtime backend on a single function.

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 b870d74. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last week.

Activeupdated last week
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.