# @neon/functions

> Runtime helpers for Neon Functions: `waitUntil` for deferring async work past a response, `upgradeWebSocket` for serving WebSockets from a fetch handler or a Hono route, `attachDatabasePool` for node-postgres idle-client errors, and `parseTriggerDelivery`

Latest version **0.11.0** (published 2026-09-16) · Apache-2.0 license · 0 weekly downloads

## Install

```sh
npm install @neon/functions
pnpm add @neon/functions
yarn add @neon/functions
bun add @neon/functions
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.11.0 |
| Published | 2026-09-16 |
| First published | 2026-06-25 |
| Weekly downloads | 0 |
| License | Apache-2.0 |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=20.19.0 |
| Dependencies | 0 |
| Unpacked size | 83.7 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| GitHub stars | 61 |
| Author | Neon |
| Maintainers | andrelandgraf, neonteam |
| Keywords | neon, database, postgres, functions, serverless, platform, websocket, triggers |

## Links

- npm: https://www.npmjs.com/package/@neon/functions
- Repository: https://github.com/neondatabase/neon-pkgs
- Homepage: https://github.com/neondatabase/neon-pkgs#readme
- Issues: https://github.com/neondatabase/neon-pkgs/issues
- npm.io page: https://npm.io/package/@neon/functions

## Alternatives

- [@opentelemetry/exporter-zipkin](https://npm.io/package/@opentelemetry/exporter-zipkin.md) — 14.8M weekly downloads
- [pusher-js](https://npm.io/package/pusher-js.md) — 2.0M weekly downloads
- [browserify](https://npm.io/package/browserify.md) — 1.7M weekly downloads
- [sqs-consumer](https://npm.io/package/sqs-consumer.md) — 1.7M weekly downloads
- [@sanity/eventsource](https://npm.io/package/@sanity/eventsource.md) — 930.8K weekly downloads

## Recent versions

- 0.11.0 (latest) — 2026-09-16
- 0.10.0 — 2026-09-11
- 0.9.0 — 2026-09-02
- 0.8.0 — 2026-08-17
- 0.7.0 — 2026-08-06
- 0.6.0 — 2026-07-02
- 0.5.0 — 2026-06-30
- 0.0.0 — 2026-06-25

## README

# @neon/functions

Runtime helpers for [Neon Functions](https://neon.com):

- **`waitUntil`** — defer background work past a response.
- **`upgradeWebSocket`** — serve WebSockets from a `fetch` handler, or from a
  [Hono](https://hono.dev) route via `@neon/functions/hono`.
- **`attachDatabasePool`** — keep a module-scope `pg.Pool` from killing the isolate when Postgres drops an idle client.
- **`parseTriggerDelivery`** — parse a Function Trigger delivery (`@neon/functions/triggers`), including schedule and `storage_object_created`. `parseTriggerInvocation` and Hono `parseTrigger(c)` remain schedule-only.

## Install

```bash
npm install @neon/functions
```

For the Hono route helper, install Hono alongside it:

```bash
npm install @neon/functions hono
```

> **Requirements:** Node.js >= 20.19. The `@neon/functions/hono` subpath declares
> `hono` `^4.7.8` as an optional peer — installing `@neon/functions` on its own
> pulls in nothing and warns about nothing.

## `waitUntil`

The API mirrors [`@vercel/functions`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package): import `waitUntil` and call it directly with the promise you want to keep alive.

```ts
import { waitUntil } from "@neon/functions";

export default {
	async fetch(req: Request): Promise<Response> {
		// Fire-and-forget background work that should outlive the response.
		waitUntil(logRequest(req));
		return new Response("ok");
	},
};
```

`waitUntil(promise)` forwards the promise to the Neon Functions runtime, which keeps
the invocation alive until the promise settles (up to the 15-minute `waitUntil` limit).
When no invocation context is in scope — local dev, tests, or any non-Neon host — it is
a **no-op**: the promise is accepted and ignored (it still runs on its own, it just
isn't tracked), so the same code runs everywhere without branching. Passing a
non-`Promise` throws a `TypeError`.

## `upgradeWebSocket`

Turn an incoming WebSocket handshake into a live connection from inside your normal
`fetch` handler. The API mirrors
[`Deno.upgradeWebSocket`](https://docs.deno.com/api/deno/~/Deno.upgradeWebSocket):

```ts
import { upgradeWebSocket } from "@neon/functions";

export default {
	async fetch(req: Request): Promise<Response> {
		if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
			return new Response("expected a websocket upgrade", { status: 426 });
		}

		const { socket, response } = upgradeWebSocket(req);
		socket.addEventListener("message", (event) => socket.send(event.data));
		return response;
	},
};
```

`socket` is a standard [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket),
so both `addEventListener` and the `onmessage`/`onopen`/`onclose`/`onerror` properties work.
It is still `CONNECTING` when you get it: the runtime writes the `101` only once your
handler returns `response`, and the socket opens (firing `open`) at that point.

**Return `response` unchanged.** A `101` cannot be expressed as a plain `Response` — the
fetch spec restricts constructed responses to statuses 200–599 — so the runtime hands back
a response object that carries the pending upgrade. Cloning it, or rebuilding it
(`new Response(res.body, res)`, which response-rewriting middleware does), discards the
upgrade; the runtime detects that and fails the request loudly rather than leaving your
client waiting on a connection nobody upgraded.

### Subprotocols

Pass `protocol` to select one of the subprotocols the client offered, which is echoed back
in `Sec-WebSocket-Protocol`:

```ts
const { socket, response } = upgradeWebSocket(req, { protocol: "chat.v2" });
```

Per [RFC 6455 §4.2.2](https://datatracker.ietf.org/doc/html/rfc6455#section-4.2.2) a server
may only select a protocol the client offered, so passing one the client did not offer
throws a `TypeError` instead of producing a handshake the client will reject. Omit it and
no protocol is negotiated: the response header is absent and `socket.protocol` is `""`.

### Notes

- `binaryType` defaults to `"arraybuffer"` rather than the browser default of `"blob"`,
  matching Deno and other server runtimes. Setting it to `"blob"` is supported.
- `extensions` is always `""`. No extensions — including `permessage-deflate` — are
  negotiated.
- Unlike `waitUntil`, this **throws a `TypeError` off-platform** (and on a request that is
  not a WebSocket handshake). There is no meaningful degraded WebSocket, so an error that
  says so beats a socket that could never open.

### Requires a runtime with WebSocket support

`upgradeWebSocket` needs a Neon Functions runtime that provides the upgrade — deployed, or
locally under `neon dev`. On an older runtime it throws the "only available inside a Neon
Functions invocation" `TypeError` rather than misbehaving.


## `@neon/functions/hono`

The same primitive, shaped as Hono's own WebSocket helper, so a route can serve a socket
directly. It is `defineWebSocketHelper` over `upgradeWebSocket` and nothing else — no `ws`
dependency, and not the deprecated `@hono/node-ws`.

```ts
import { Hono } from "hono";
import { upgradeWebSocket } from "@neon/functions/hono";

const app = new Hono();

app.get(
	"/ws",
	upgradeWebSocket(() => ({
		onOpen(_event, ws) {
			ws.send("welcome");
		},
		onMessage(event, ws) {
			ws.send(`echo: ${event.data}`);
		},
		onClose() {
			console.log("client disconnected");
		},
	})),
);

export default app;
```

A request without `Upgrade: websocket` is passed to the next handler, so an ordinary `GET`
on the same path still reaches the route below the helper.

Hono awaits your event factory before handing the request to the adapter, so the factory
runs on those ordinary requests too. Keep it to returning the handler object; if it does
real work, do that work inside `onOpen`, where it only runs for a connection that opened.

### Running it

`upgradeWebSocket` needs a runtime that provides the upgrade, so a Hono app serving a socket
runs under `neon dev` locally and `neon deploy` in production. Serving it yourself with
`@hono/node-server` will not work — there is no upgrade to claim, and every handshake throws:

```
TypeError: upgradeWebSocket() is only available inside a Neon Functions invocation.
Run your function with `neon dev` locally, or deploy it.
```

Keep `hono` in `dependencies`, not `devDependencies`. `neon deploy` bundles from
`node_modules` and never reads your manifest, so a devDependency deploys fine from a machine
where it happens to be installed — and breaks on a CI that ran `npm ci --omit=dev`, or on a
teammate's fresh clone.

### Types

The subpath exports the types the handlers need, so a factory pulled out of the route does
not have to reach into `hono/ws` or remember the type argument:

```ts
import type { NeonWSEvents, UpgradeWebSocketOptions } from "@neon/functions/hono";

const createEvents = (): NeonWSEvents => ({
	onMessage: (event, ws) => ws.send(`echo: ${event.data}`),
});

const options: UpgradeWebSocketOptions = { protocol: "chat.v2" };
```

`NeonWSEvents` and `NeonWSContext` are Hono's `WSEvents` and `WSContext` with `ws.raw`
already bound to the platform `WebSocket`. Naming Hono's own types without that argument
leaves `ws.raw` as `unknown`.

### The direct form

Hono's other call form works, when you want the context in hand:

```ts
app.get("/ws", (c) => upgradeWebSocket(c, { onMessage: (e, ws) => ws.send(`echo: ${e.data}`) }));
```

**This form is what the `hono` `^4.7.8` requirement is for.** Below that version
`defineWebSocketHelper` implements only the middleware form, so the call returns a
middleware function instead of upgrading. The client gets a 500, nothing reaches `onError`,
and the runtime logs:

```
WebSocket upgrade failed:
TypeError: response.arrayBuffer is not a function
```

npm refuses the install; bun installs it silently and pnpm warns and proceeds, so on those
two the version is yours to check. The middleware form above works on any version that has
the helper at all.

### Subprotocols

Pass `protocol` as the second argument. Nothing is negotiated unless you pass one, and it
must be one the client offered:

```ts
app.get("/ws", upgradeWebSocket(createEvents, { protocol: "chat.v2" }));
```

### Middleware

Gating the route is ordinary middleware — read the token or headers and return before
`next()`:

```ts
app.use("/ws", async (c, next) => {
	if (c.req.query("token") !== expected) return c.text("unauthorized", 401);
	await next();
});
```

**Middleware that touches `c.res` before `await next()`, or calls `c.header()` after it,
breaks the upgrade.** Hono materialises and rebuilds the response in both cases, and
rebuilding the 101 discards the pending upgrade. Reading the request is always fine, and so
is returning your own response before `next()`.

| On an upgrade route | Upgrade survives |
| --- | --- |
| `cors()` | no, at any option including the default |
| reading `c.res` before `await next()`, even without modifying it | no |
| `c.header(...)` after `await next()` | no |
| `secureHeaders()`, `logger()`, `requestId()`, `timing()`, `bodyLimit()` | yes |
| `c.header(...)` before `await next()` | yes, but the header is dropped from the 101 |
| reading `c.res` after `await next()` | yes |

When it breaks, the first thing you see comes from inside Hono and names none of this:

```
RangeError: init["status"] must be in the range of 200 to 599, inclusive.
```

Under `neon dev`, the runtime logs `websocket_upgrade_response_lost` behind it with
the `cors()` / `c.res` / `c.header()` wording from this PR. The deployed runtime
still carries its own copy of that string until it ships separately.

### Lifecycle

A WebSocket is a request that returns a `101`, so it lives under the same rules as any other
Neon Function invocation:

- **Each connection keeps its isolate alive**, and a connection exchanging no bytes for 15
  minutes may be terminated. Protocol ping/pong counts as activity; the runtime answers
  pings without involving your handlers.
- **Isolates are evictable.** In-memory state does not survive, and clients are expected to
  reconnect. Durable state belongs in Postgres.
- **Connections are local to the isolate that accepted them.** A module-scope `Set` of
  connected clients only reaches the clients on that isolate — which is every client under
  `neon dev`, where there is one, and a fraction of them deployed, where there are several.
  Broadcasting across isolates needs an out-of-band channel: Postgres `LISTEN`/`NOTIFY` over
  the unpooled connection to start with, serverless Redis Pub/Sub when the per-isolate idle
  connection becomes the cost.

### Notes

- `ws.raw` is the underlying `WebSocket`, for anything the Hono context does not surface.
- `ws.binaryType` is Hono's own field and does not propagate to the socket. Both default to
  `"arraybuffer"`, so inbound binary arrives as an `ArrayBuffer`; set `ws.raw.binaryType` if
  you need to change it.
- **`ws.send(event.data)` does not type-check.** Hono types inbound data as
  `string | Blob | ArrayBufferLike` while `send` refuses a `Blob`, so echoing the raw value
  back is an error even though the runtime only ever delivers a `string` or an `ArrayBuffer`
  unless you asked for `"blob"`. Interpolate it, or narrow it, before sending it back.
- `SendOptions.compress` is ignored: no extensions are negotiated.
- Importing both helpers into one file needs an alias — the root export and this one share
  the name `upgradeWebSocket`.

## `attachDatabasePool`

A Neon Function reuses a `pg.Pool` across requests on the same isolate. When Postgres
closes an idle client — compute scale-to-zero, pooler reclaim, a TCP reset — node-postgres
emits `error` on the pool. With no listener, that is an uncaught exception and the isolate
exits.

Call this once after constructing the pool. The pool has already discarded the dead client;
the next checkout opens a new connection.

```ts
import { attachDatabasePool } from "@neon/functions";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
attachDatabasePool(pool);
```

This helper does not need a Neon Functions runtime. The same call works in plain Node
and under `neon dev`.

Expected idle disconnects (`ECONNRESET`, `EPIPE`, `ETIMEDOUT`, Postgres `57P01`, and
node-postgres's `Connection terminated unexpectedly`) are silent. Anything else is
logged with `console.error`.

To send unexpected errors to your own reporter instead of `console.error`, pass it on
the first call, next to `new Pool`:

```ts
import * as Sentry from "@sentry/node";
import { attachDatabasePool } from "@neon/functions";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
attachDatabasePool(pool, {
	onUnexpectedError: (err) => Sentry.captureException(err),
});
```

The first call wins. A second `attachDatabasePool(pool)` is a no-op. A second call that
passes `onUnexpectedError` is also a no-op and logs a warning.

If `onUnexpectedError` throws, or returns a promise that rejects, both the pool error and
the reporter error are logged. Neither is rethrown from the listener, so the isolate stays up.

This does not close the pool. Isolate teardown tears the connections down with the process.

## Function Trigger deliveries

A [Function Trigger](https://neon.com/docs/cli/triggers) POSTs JSON to your function.
`parseTriggerInvocation` is schedule-only: it checks `x-neon-trigger-invocation-id`
against `invocation_id` and returns a `TriggerInvocation` (`ScheduleTriggerInvocation`).
Storage-object-created deliveries use `parseTriggerDelivery`, which returns a
`TriggerDelivery`. Schedule members of that union have `type: "schedule"`, so
both `if (invocation.type === "schedule")` and
`if (invocation.type === "storage_object_created")` narrow `data`.

```ts
import { parseTriggerDelivery } from "@neon/functions/triggers";

export default {
	async fetch(request: Request): Promise<Response> {
		const parsed = await parseTriggerDelivery(request);
		if (!parsed.ok) {
			const status = parsed.error === "invalid_body" ? 400 : 401;
			return new Response(parsed.error, { status });
		}

		const invocation = parsed.invocation;
		if (invocation.type === "storage_object_created") {
			return Response.json({
				bucketName: invocation.data.bucketName,
				objectKey: invocation.data.objectKey,
			});
		}

		return Response.json({
			scheduledAt: invocation.data.scheduledAt,
		});
	},
};
```

`parseTriggerDelivery(request)` and `parseTriggerInvocation(request)` check the
header first, then clone the Request and read JSON from the clone, so
`request.json()` still works afterwards.

If you already have the JSON:

```ts
const body = await request.json();
const parsed = parseTriggerDelivery({
	headers: request.headers,
	body,
});
```

On success, `parsed.invocation` is camelCase: `invocationId`, `trigger.id`,
`trigger.name`, `trigger.type`. `parseTriggerDelivery` also sets a top-level
`type`. Schedule deliveries have `data.scheduledAt`. Storage-object-created
deliveries have `data.bucketName` and `data.objectKey`. Narrow on
`invocation.type` (or use `isScheduleTriggerInvocation` /
`isStorageObjectCreatedTriggerInvocation`) before reading `data` — a check on
`trigger.type` does not narrow the sibling `data` field.

On failure, `parsed.error` is `missing_header`, `invalid_body`, or
`invocation_id_mismatch`. Invalid JSON on the Request path is `invalid_body`.
Unknown `trigger.type` values fail as `invalid_body` until this package adds them.

### `parseTrigger` (Hono)

`parseTrigger(c)` runs the Request overload on `c.req.raw` and throws
`HTTPException`. `c.req.json()` still works afterwards. It returns a
`ScheduleTriggerInvocation`, so existing `invocation.data.scheduledAt`
callers keep compiling. A `storage_object_created` delivery is
`invalid_body`; parse those with `parseTriggerDelivery(c.req.raw)`.

| Failure | Status | Message |
| --- | --- | --- |
| missing header | 401 | `Missing x-neon-trigger-invocation-id header` |
| header ≠ `invocation_id` | 401 | `Invocation id mismatch` |
| invalid JSON or payload | 400 | `Invalid trigger payload` |

```ts
import { Hono } from "hono";
import { parseTrigger } from "@neon/functions/hono";

const app = new Hono();

app.post("/cron", async (c) => {
	const invocation = await parseTrigger(c);
	return c.json({ ok: true, scheduledAt: invocation.data.scheduledAt });
});
```

```ts
import { parseTriggerDelivery } from "@neon/functions/hono";

app.post("/object", async (c) => {
	const parsed = await parseTriggerDelivery(c.req.raw);
	if (!parsed.ok) {
		const status = parsed.error === "invalid_body" ? 400 : 401;
		return c.text(parsed.error, status);
	}
	if (parsed.invocation.type !== "storage_object_created") {
		return c.text("invalid_body", 400);
	}
	return c.json({
		bucketName: parsed.invocation.data.bucketName,
		objectKey: parsed.invocation.data.objectKey,
	});
});
```

## Runtime integration

The runtime publishes the active invocation context on `globalThis.NEON_REQUEST_CONTEXT`
as a getter that returns the live context object directly — `{ waitUntil }` during an
invocation, `undefined` outside one. `waitUntil` reads that value straight off the
global, so there is nothing for application code to wire up.

`upgradeWebSocket` works the same way, reading a bridge the runtime publishes under
`Symbol.for("neon.websocket.bridge")`. All of the protocol work — the handshake, framing,
fragmentation, ping/pong and the close handshake — lives in the runtime; this package is
only a typed facade over it.

---
_Source: https://npm.io/package/@neon/functions · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
