Serve PostgreSQL over HTTP and WebSockets from Go, speaking the request and response shapes the @neondatabase/serverless driver sends.
A Cloudflare Worker, a Vercel Edge Function or a Deno Deploy isolate cannot open a TCP socket, so it cannot speak the PostgreSQL wire protocol. The way around that has settled into a shape: POST /sql carries a query and its parameters as JSON, and GET /v2 upgrades to a WebSocket that tunnels the wire protocol. More than one provider answers on it. This package is the server half, written against the shape rather than one vendor's endpoint.
go get github.com/sferarc/pgserverlessThe protocol itself is specified in spec: SPEC.md is the normative text, and cases/v1.json is a language-neutral conformance corpus that cmd/pgsconform runs against any server over the network. This package is one implementation of that spec, and the spec is versioned separately from it.
Requires Go 1.26 or later.
With net/http:
package main
import (
"log"
"net/http"
"github.com/sferarc/pgserverless"
)
func main() {
const db = "db.internal:5432"
h := pgserverless.NewHTTPHandler(
pgserverless.StaticHTTPUpstream(db),
// The tunnel carries credentials inside the wire protocol, so it needs
// an address and nothing else from the request.
pgserverless.WithHTTPWebSocketAdmission(func(*http.Request) (pgserverless.Upstream, error) {
return pgserverless.Upstream{Addr: db}, nil
}),
)
mux := http.NewServeMux()
mux.Handle("/", h) // POST /sql and GET /v2
log.Fatal(http.ListenAndServe(":4000", mux))
}With Echo, which the handler also supports:
package main
import (
"github.com/labstack/echo/v4"
"github.com/sferarc/pgserverless"
)
func main() {
const db = "db.internal:5432"
h := pgserverless.NewHandler(
pgserverless.StaticUpstream(db),
pgserverless.WithWebSocketAdmission(func(echo.Context) (pgserverless.Upstream, error) {
return pgserverless.Upstream{Addr: db}, nil
}),
)
e := echo.New()
h.Register(e) // POST /sql
h.RegisterWebSocket(e) // GET /v2
e.Logger.Fatal(e.Start(":4000"))
}Point the driver at it:
import { neon, neonConfig } from "@neondatabase/serverless";
neonConfig.fetchEndpoint = "http://localhost:4000/sql";
const sql = neon("postgres://user:pass@localhost/mydb");
await sql`select now()`;For Pool and Client, which use the WebSocket transport:
import { Pool, neonConfig } from "@neondatabase/serverless";
neonConfig.wsProxy = "localhost:4000/v2";
neonConfig.useSecureWebSocket = false; // true behind TLS
neonConfig.pipelineConnect = false; // required for SCRAM, see below
const pool = new Pool({
connectionString: "postgres://user:pass@localhost/mydb",
});
const { rows } = await pool.query("select now()");Upstream TLS is on by default. For a database that does not serve TLS, return an Upstream with TLSMode: pgserverless.TLSDisable from your own Admission.
What the driver conformance suite exercises, at the versions it pins:
| Client | Version | Transport | Status |
|---|---|---|---|
@neondatabase/serverless, neon() |
1.2.0 | HTTP /sql |
Covered: queries, templates, array and raw-text modes, transactions with isolation level and read-only, errors, data types |
@neondatabase/serverless, Client, Pool |
1.2.0 | WebSocket /v2 |
Covered |
Drizzle ORM, drizzle-orm/neon-http |
0.45.4 | HTTP /sql |
Covered, including db.batch (Drizzle's neon-http driver has no interactive transactions) |
Drizzle ORM, drizzle-orm/neon-serverless |
0.45.4 | WebSocket /v2 |
Covered: CRUD, data types, errors, transactions with commit and rollback |
Kysely with kysely-neon |
0.29.6, 3.0.0 | HTTP | Covered |
Waddler, waddler/neon-http |
0.1.1 | HTTP | Covered |
Prisma, @prisma/adapter-neon: PrismaNeon, PrismaNeonHttp |
7.10.1 | WebSocket, HTTP | Covered through the adapter interface, with no generated client: raw queries, typed arguments, errors and WebSocket transactions. No HTTP transactions |
Not covered, so not claimed:
- Real edge runtimes. The runtime files simulate Workers, Vercel and browser conditions under Node.
Most of the work is not the routing. It is the value coercion, which is where an implementation written from the request and response examples goes quietly wrong.
Every rule below exists because the obvious encoding either loses information or produces something the driver cannot parse:
int8, numeric |
Strings. JSON numbers are float64 and lose precision beyond 2^53. |
NaN, Infinity, -Infinity |
PostgreSQL's own text. encoding/json cannot marshal them at all and fails the whole response. |
| Infinite timestamps | The words infinity and -infinity, not the integers a pgtype.InfinityModifier marshals to, which are indistinguishable from data. |
macaddr, bit, varbit |
Text, not the base64 their Go representations marshal to. |
| Multi-dimensional arrays | Re-nested from the value's own binary array header. A flat decode loses the shape. |
inet |
Drops the netmask when it covers a single host, matching what PostgreSQL prints. A real netmask and every cidr pass through unchanged. |
| Ranges, multiranges | PostgreSQL literals, not the Go structs behind them. |
Raw-text mode, the driver's primary path since it runs its own type parsers client-side keyed by dataTypeID, bypasses most of this and returns every value as PostgreSQL text.
Everything a host has to decide before a query runs (which database, is this caller allowed, is the tenant paying) is one function:
type HTTPAdmission func(r *http.Request) (Upstream, error)Everything after that decision (parse the body, dial, run, coerce, encode) is this package. Return an *AdmissionError to choose the HTTP status of a refusal, for example 403 for a suspended tenant. StaticHTTPUpstream (StaticUpstream for echo) is the whole implementation for a deployment in front of a single database. A multi-tenant platform's version is much larger, and that asymmetry is the point: the protocol is the same either way, and the policy is not something a library can guess.
POST /sql carries one request and one response. It handles single queries, array mode, raw text mode, and batches of up to 100 queries run in a transaction, with isolation level, read-only and deferrable taken from request headers. Each request has a 30 second budget.
GET /v2 upgrades to a WebSocket carrying the PostgreSQL wire protocol. It declines the client's SSLRequest, because the WebSocket already carries the encryption, and then relays without parsing. Authentication runs end to end between the client and the database, so this package never holds a credential on that path. Admission runs before the upgrade, so a refusal is an ordinary HTTP error.
- Only HTTP and WebSocket are served. There is no raw TCP listener for the PostgreSQL wire protocol.
NewHandlerand its options take anecho.Context, and the module still depends on Echo.NewHTTPHandleris thenet/httpform of the same handler; both run the same code.StaticHTTPUpstreamandStaticUpstreamread credentials from the request's connection string header, which the WebSocket path does not carry. Give the tunnel its own admission withWithHTTPWebSocketAdmissionorWithWebSocketAdmission.- On the WebSocket path only
Addr,ServerName,InsecureSkipVerify,RootCAsandTLSModeof the returnedUpstreamare used. User, password and database come from the client's own startup message. - Each HTTP request opens a connection to the database and closes it. There is no pooling; put a pooler in front of the database if it cannot take that. The driver's
Pool-Opt-Inheader is accepted and ignored. - The upstream dial uses TLS unless
Upstream.TLSModeisTLSDisable.StaticUpstreamdoes not set it. WithTLSPrefera failed TLS attempt, refused or not, is dialed again in the clear, as libpq'ssslmode=preferdoes.cmd/pgserverlessuses it whenDATABASE_URLsayssslmode=preferorsslmode=allow. Upstream.RootCAschecks the database certificate against those roots instead of the system's.cmd/pgserverlessloads it from the fileDATABASE_URL'ssslrootcertnames, as libpq does, and refuses to start if the file holds no PEM certificate;sslrootcert=systemkeeps the system roots.Upstream.VerifyCAOnlychecks that one ofRootCAsissued the database certificate but not that it names the host, as libpq'ssslmode=verify-cadoes. WithoutRootCAsit is ignored and the name is still checked, since any publicly issued certificate would otherwise pass.cmd/pgserverlesssets it forverify-ca,require,preferandallowwith ansslrootcertfile.verify-cawithoutsslrootcertkeeps the name check, stricter than libpq, which fails without~/.postgresql/root.crt.ParseConnectionStringdecodes each%XXin the user, password and database and keeps anything else as written, as Neon's proxy does, so a password such as50%offorpa^ssconnects. An escaped NUL (%00) is refused.- The
POST /sqldial reaches only theUpstreamadmission returned. The server process's own libpq environment (PGHOST,PGPORT,PGTZ,PGOPTIONS,PGAPPNAME,PGTARGETSESSIONATTRSand the rest) is ignored, so it adds no fallback host and no session setting. - A WebSocket
CancelRequestis forwarded to the upstream that admission resolves for its connection. The tunnel keeps no session state, so the key the client holds is the upstream's own and any instance can deliver it. - The tunnel does not check the
Originheader.Admissionis the authentication point. - The tunnel bounds the client's startup (30 seconds from the upgrade to its StartupMessage) and the upstream dial (10 seconds). An open session has no time limit.
- A
POST /sqlbody overMaxRequestBodyBytes(10 MiB, Neon's default) is refused with 413 andrequest is too large (max is 10485760 bytes), before the upstream is dialed. - Responses are uncapped unless
WithMaxResponseBytesorWithHTTPMaxResponseBytesis set. With a cap, a response whose row values pass it fails with 507 andresponse is too large (max is N bytes), and a batch counts all its statements together. A capped batch runs in one transaction that commits only once every result fit, so a 507 persists none of its writes; a single statement runs in autocommit, so its own write can still commit.MaxResponseBytesis Neon's default of 10 MiB, whichcmd/pgserverlessuses. The cap counts the bytes PostgreSQL sent, so the JSON can be somewhat larger. - SCRAM databases need
neonConfig.pipelineConnect = falseon the client. The driver's default pipelining assumes a cleartext password request and hangs otherwise.
The package's own tests drive the real HTTP handler end to end against an in-process PostgreSQL backend, using a real jackc/pgx client, the same client path a production deployment uses.
That backend is exported as pgserverless/pgfake and is useful on its own: it speaks SSL negotiation, startup and authentication, the extended query protocol and SendBatch pipelines, over a real socket with a real TLS certificate, with no container and no initdb.
pg := pgfake.New(t)
pg.SetResult("SELECT id FROM users", pgfake.Result{
Cols: []pgfake.Col{{Name: "id", TypeOID: pgtype.Int4OID}},
Rows: [][]any{{int32(1)}},
Tag: "SELECT 1",
})Run the Go tests with go test ./....
The corpus in spec/cases/v1.json is the protocol's own test: each case is one HTTP exchange against a database in a known state, so it checks a server written in any language. pgsconform applies the corpus fixtures to a scratch database through -dsn, sends every case to the server, and reports the conformance levels passed. Point the server under test at the same database.
go run ./cmd/pgsconform -cases spec/cases/v1.json \
-server http://127.0.0.1:4000 \
-dsn 'postgres://user:pass@127.0.0.1:5432/scratch' \
-report report.jsonspec/CONFORMANCE.md explains the case format, the levels and the report.
The suite in driver-conformance drives the real @neondatabase/serverless driver, Drizzle, Kysely and Waddler against one DATABASE_URL. It creates and drops tables, so point it at a disposable database. It needs Node and pnpm.
Against a server you already run, with DATABASE_URL set to a connection string whose host is that server:
cd driver-conformance
DATABASE_URL='postgres://user:pass@host/db' pnpm test| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
none, required | Connection string of the server under test. The host is where the driver sends HTTP and WebSocket traffic. |
PORT |
port in the URL, else 443 (80 without TLS) | Port for /sql and /v2. |
PGSERVERLESS_TLS |
true |
false selects http and ws, for a local server. |
PIPELINE_CONNECT |
password |
false disables the driver's connect pipelining, which a SCRAM database needs. |
To have the suite start your server for you, build one from this package and supply the database it should front:
UPSTREAM_URL='postgres://app:pw@127.0.0.1:5432/app?sslmode=disable' \
PGSERVERLESS_BIN=/path/to/your-server \
pnpm test:go-serverThe binary receives DATABASE_URL, PGSERVERLESS_ADDR and must answer GET /health. Arguments after the script name pass through to vitest, for example pnpm test:go-server tests/http. Use SERVER_PORT to move off 4000.
For a parity run, point DATABASE_URL at a Neon database and compare the two results. A failure on both is a wrong expectation in the suite.
jackc/pgx/v5 for the client and the type map, labstack/echo/v4 for the echo entry points, coder/websocket for the upgrade.
Issues and pull requests are welcome here. go test ./... runs the unit and conformance tests with no database; the driver suite above needs one.
Used in production at PgBeam.
Apache-2.0. See LICENSE.