Seed Protocol
Infrastructure

Gateway Hyper

How @seedprotocol/gateway-hyper exposes an upload API and Arweave gateway over the Hyper network, how to reach one through a local sidecar or an app-server proxy, how an operator restricts who connects, raw TCP mode, and the seed-gateway CLI and library reference.

@seedprotocol/gateway-hyper makes an upload API and Arweave gateway stack reachable over HyperDHT, the peer-to-peer network from the Holepunch project. An operator publishes the stack under a public key instead of a hostname, and anyone with the key can connect to it unless the operator lists the clients it accepts.

The package only carries HTTP. The SDK and @seedprotocol/publish never speak Hyper themselves: they send ordinary HTTP requests to a small local server (a sidecar) or to a route on your own app server, and that forwards them over Hyper.

To use Gateway Hyper you need the key of an operator you have access to. The Seed Protocol team's own gateway is private (Seed's gateway). Reading published data doesn't need any of this: the SDK reads from the public Arweave gateways by default.

How it works

  • The key is the address. The operator's key is an Ed25519 public key, written in z32 (52 characters). A 64-character hex key works too. Connections are found through the public HyperDHT (the library can join another network) and are end-to-end encrypted, so you don't need a hostname, open ports or TLS certificates on the operator's side.
  • By default, knowing the key is the only access check. Anyone with the key reaches everything the operator's upstream serves, including the upload API, which spends the operator's AR. An operator can let in only listed client keys or addresses (Restricting who can connect).
  • Each request is forwarded whole, one per stream. The sidecar or proxy reads the full request body, sends it with the method, path and headers, and waits for the full response. It opens up to four streams to the operator as requests need them (maxStreams), and each stream carries one request at a time, so a slow upload holds up only its own stream. Request and response bodies over 64 MiB are refused; before 0.7.6, anything over 16 MiB failed. Raw TCP mode, for an HTTP proxy in front of the tunnel, has none of these limits.
  • The upstream sees its own host. The operator drops hop-by-hop headers and Content-Length, and passes the client's Host (the sidecar's or your app's host) on as X-Forwarded-Host. Other headers pass through. The upstream has to route by path, not by hostname.

Failures come back as a plain-text response whose body says which side failed:

StatusBodyWhen
502Gateway Hyper tunnel error: Could not connect to the operator within 30000 msOpening a stream took longer than connectTimeoutMs
504Gateway Hyper tunnel error: No bytes sent or received on the tunnel for 150000 msThe request's stream was idle for requestTimeoutMs
502Gateway Hyper tunnel error: PEER_CONNECTION_FAILED: Could not connect to peerThe operator lists client keys only and this client's key isn't one of them (after about 4 seconds)
502Gateway Hyper tunnel error: The other side sent plain HTTP ("HTTP…"), not a framed tunnel message: it runs in raw mode. …The key belongs to a raw operator
502Gateway Hyper tunnel error: …Any other tunnel failure
403Gateway Hyper: this operator's allowlist doesn't include this client (address <host>).The operator has an address list and this client matches neither list
504Gateway Hyper upstream timeout: No response from upstream for 130000 msThe operator's upstream was idle for upstreamTimeoutMs
502Gateway Hyper upstream error: …The upstream failed, for example connect ECONNREFUSED 127.0.0.1:8080 when nothing listens there

The first five come from your sidecar or proxy, which closes the stream; the next request on it opens a new one. The last three come from the operator. After the 403 it closes the stream (Restricting who can connect); after the last two the stream stays open. The operator logs nothing about those two unless it runs with --trace or onEvent, which report them as up.timeout and up.error.

Install

npm install @seedprotocol/gateway-hyper

It needs Node 20 or later and installs Holepunch's native addons. Run it under Node rather than Bun, whose Node compatibility layer can crash in those addons. It can't run in a browser or an Edge runtime. The package has no Seed dependencies; it provides the seed-gateway command, which you can run with npx seed-gateway.

Choose a client shape

Path A: local sidecarPath B: app-server proxy
What runsseed-gateway connect <key> (or connectTunnel) on the same machine as the appcreateGatewayProxy in a Node route on your server
The app talks tohttp://127.0.0.1:1984https://your-app.example.com/api/seed-gateway
Good forLocal development, Electron, Node scriptsHosted web apps, whose users won't run a sidecar
Who holds the keyWhoever runs the sidecarYour server only
SDK gateway settingtransport: 'hyper'proxyBaseUrl
Publish uploadApiBaseUrlhttp://127.0.0.1:1984The proxy's absolute URL

Browsers can't join HyperDHT, which is why a hosted web app needs Path B: its users' browsers can reach your server, but there's no sidecar on their machines.

Path A: local sidecar

Start the sidecar with the operator's key and leave it running:

npx seed-gateway connect <operator key>
[Seed Protocol] Connecting to operator <operator key>
[Seed Protocol] Client key (z32): <client key> (new for this run)
[Seed Protocol] Local sidecar: http://127.0.0.1:1984
[Seed Protocol] Point SDK transport=hyper or hybrid at this URL (default sidecar port 1984).
[Seed Protocol] Press Ctrl+C to stop.

The sidecar connects to the operator on the first request, not at startup. A malformed key stops it straight away, but a well-formed key for an operator that's offline only shows up on the first request, as a 502: Gateway Hyper tunnel error: PEER_NOT_FOUND: Peer not found once the DHT lookup gives up, or Could not connect to the operator within 30000 ms if that takes longer. An operator that doesn't list this client answers the first request with one of the refusals in How it works; to get listed, keep the client's key in a file (Restricting who can connect).

Point the SDK and the publish package at it:

import type { SeedConfig } from '@seedprotocol/sdk'
import { initPublish } from '@seedprotocol/publish'
import schema from '../seed.schema.json'

// Reads go to the sidecar
export const config: SeedConfig = {
  schema,
  gateway: { transport: 'hyper' },
}

// The upload API is behind the same sidecar
initPublish({
  thirdwebClientId: import.meta.env.VITE_THIRDWEB_CLIENT_ID,
  uploadApiBaseUrl: 'http://127.0.0.1:1984',
})

The rest of the recommended publish config is in initPublish's example.

With transport: 'hyper', client.init checks the sidecar first (GET http://127.0.0.1:1984/info, which has to return a JSON object) and throws Gateway Hyper sidecar not reachable at http://127.0.0.1:1984 if it doesn't answer. Use transport: 'hybrid' instead to fall back to the public HTTP gateways for reads when the sidecar isn't running. While a sidecar or proxy is in use, reads try it first and then the public gateways. Checks that an upload has reached Arweave ask only the sidecar or proxy and any preferred read gateways, since a public gateway doesn't have a just-uploaded file yet.

If you run the sidecar on another host or port (--host, --port), tell the SDK:

gateway: {
  transport: 'hyper',
  hyper: { localSidecarHost: '127.0.0.1', localSidecarPort: 8484 },
}

The sidecar adds no CORS headers. A browser page served from another origin (your dev server on http://localhost:5173, say) gets through only if the operator's upload API and gateway allow that origin.

In Electron or a long-running Node process, you can start the sidecar from code instead of a terminal, with connectTunnel, in the main process.

The SDK's gatewayHyperKey setting doesn't connect to anything. The SDK can't dial Hyper; the key is only echoed back in the resolved endpoints. The sidecar or proxy is what holds the key.

Path B: app-server proxy

Mount createGatewayProxy on a route of your app server. It strips the mount path, forwards the rest of the URL through the tunnel and returns the operator's response. With Next.js (App Router), use a catch-all route on the Node runtime:

app/api/seed-gateway/[...path]/route.ts
import { createGatewayProxy } from '@seedprotocol/gateway-hyper'

export const runtime = 'nodejs'

// Reads SEED_GATEWAY_HYPER_KEY from the server's environment
const proxy = createGatewayProxy({ mountPath: '/api/seed-gateway' })

const handler = (request: Request) => proxy.handleFetch(request)

export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE }

With Express or node:http, use handleNode:

import express from 'express'
import { createGatewayProxy } from '@seedprotocol/gateway-hyper'

const proxy = createGatewayProxy({ key: process.env.SEED_GATEWAY_HYPER_KEY })

const app = express()
// Add your own authentication and rate limiting before this line
app.use(proxy.mountPath, (req, res) => proxy.handleNode(req, res))
app.listen(3000)

process.on('SIGTERM', () => void proxy.close())

Then point the browser app at the route. Use an absolute URL for the publish package:

import type { SeedConfig } from '@seedprotocol/sdk'
import { initPublish } from '@seedprotocol/publish'
import schema from '../seed.schema.json'

const proxyBaseUrl = new URL('/api/seed-gateway', window.location.origin).href

export const config: SeedConfig = {
  schema,
  gateway: { proxyBaseUrl },
}

initPublish({
  thirdwebClientId: import.meta.env.VITE_THIRDWEB_CLIENT_ID,
  uploadApiBaseUrl: proxyBaseUrl,
})

proxyBaseUrl can also be relative ('/api/seed-gateway'); the SDK resolves it against the page's origin. With the default transport: 'http-gateway', client.init throws Gateway HTTP proxy not reachable at … if GET <proxyBaseUrl>/info doesn't return JSON. With transport: 'hybrid', it falls back to a local sidecar and then to the public HTTP gateways. In a browser, hybrid tries the sidecar only when the page's origin is a loopback address (localhost, 127.x.x.x, [::1] or a .localhost name) or hyper.probeSidecarFromBrowser is true. hyper.probeProxy: false skips the proxy's /info check; it defaults to hyper.probeSidecar, else true.

The proxy route is an open relay to the operator's upload API, which pays for Arweave storage. Put authentication and rate limiting in front of it before you deploy, and keep the key in a server-side environment variable rather than in the browser bundle. A publish whose authorizePublish hook returned a token sends it in an X-Seed-Publish-Authorization header on its upload requests, which that check can read.

createGatewayProxy throws createGatewayProxy requires options.key or SEED_GATEWAY_HYPER_KEY environment variable as soon as it's called without a key, so the variable has to be set wherever the module is loaded. It needs Node's runtime and the native addons, so it can't run on Edge or on serverless platforms that don't support native modules. A client that hangs up abandons its request: handleFetch passes on the request's signal, and handleNode stops when the response closes, without writing anything.

Publishing through Gateway Hyper

@seedprotocol/publish doesn't read the SDK's gateway settings. It sends its upload API requests to its own uploadApiBaseUrl: POST /api/upload/arweave/batch with the files, GET /api/upload/arweave/data/:id to check each one before attesting, and, after attesting, GET /api/upload/arweave/status/:id from its background worker. Through Gateway Hyper the upload API is behind the same sidecar or proxy as the gateway, so set uploadApiBaseUrl to its URL as in the examples above. The publish config's gatewayTransport (deprecated) and gatewayHyperKey fields aren't read.

Html that publish uploads links its images and seed:property/ references to the public gateway (arweaveDomain) permanently, or as ar://<id> with htmlArweaveRefs: 'ar', never to the sidecar or proxy (links in published content).

You can also resolve the endpoints once and pass them on:

import { resolveSeedGatewayEndpoints, seedGatewayConfigFromSeedConfig } from '@seedprotocol/sdk'
import { initPublish } from '@seedprotocol/publish'

const endpoints = await resolveSeedGatewayEndpoints(seedGatewayConfigFromSeedConfig(config))

initPublish({
  thirdwebClientId: import.meta.env.VITE_THIRDWEB_CLIENT_ID,
  uploadApiBaseUrl: endpoints.uploadApiBaseUrl,
  arweaveGraphqlUrl: endpoints.arweaveGraphqlUrl,
})

When hybrid falls back to HTTP (endpoints.activePath === 'hybrid-fallback-http'), endpoints.uploadApiBaseUrl is your uploadApiBaseUrl setting, or '' when it's unset, since a public gateway serves no upload API. initPublish throws initPublish: uploadApiBaseUrl is required … for '' unless payment.storage is 'user'. Check activePath before you rely on the result.

Arweave and gateways explains how reads pick a gateway, and Uploads and ANS-104 what publish sends to the upload API.

Restricting who can connect

An operator can let in only the clients it lists, by public key or by address:

npx seed-gateway serve --upstream http://127.0.0.1:8080 --allow-keys <client key>,<client key> --allow-ips 192.168.1.0/24

In code, pass allowlist: { keys, ips } to serveTunnel or serveRawTunnel. A client is let in when its key is in keys or its address is in ips; a listed key gets in whatever the address list says. With both lists empty or unset, every client is. Keys are z32 or 64-character hex. Addresses are IPv4 or IPv6, single or CIDR, and an IPv4-mapped IPv6 address (::ffff:203.0.113.7) matches an IPv4 entry. An invalid entry stops serve before it reads or creates the key file, with a message such as Invalid allowlist CIDR prefix (expected 0–32): 10.0.0.0/33 or Invalid allowlist IP (expected an address or CIDR): not-an-ip, and serveTunnel throws the same. serve prints Allowlist: off, or Allowlist: keys=<n> ips=<list or none>.

List keys where you can. A client keeps the same key across runs only when it has a key file; without one, seed-gateway connect, connectTunnel and createGatewayProxy make a new key each run. To get the key to give the operator, and then connect with it:

npx seed-gateway connect --print-key --key-file ./.seed/gateway-tunnel/client.key.json
npx seed-gateway connect <operator key> --key-file ./.seed/gateway-tunnel/client.key.json

--print-key creates the file if it's missing, prints the client's z32 key and exits. The file has the same format and 0600 permissions as the operator's key file. Keep it private: anyone with it connects as that client. In code, connectTunnel and createGatewayProxy take keyFile, or keyPair ({ publicKey, secretKey } Buffers, decoded from an environment variable, say), but not both. connectTunnel's result gives the key as clientKey. GatewayProxy doesn't, so an app server reads it with loadOrCreateOperatorKeypair(keyFile).z32, encodePublicKeyZ32(keyPair.publicKey), or seed-gateway connect --print-key on the same file.

The two lists refuse a client at different points. With keys only, an unlisted client is refused during HyperDHT's handshake and never gets a stream; its sidecar or proxy answers PEER_CONNECTION_FAILED: Could not connect to peer after about 4 seconds, which doesn't say why. With an address list, the operator has to accept the connection to learn the address. It reads nothing from the client, answers the request with 403 Gateway Hyper: this operator's allowlist doesn't include this client (address <host>). and closes the stream. With operator and client both on 0.7.13 or later, the 403 carries connection: close, the client opens a new stream for its next request, and every request gets the 403. If either side runs 0.7.12, the client's next request goes out on the closed stream and gets 502 Gateway Hyper tunnel error: Unexpected end of stream (expected 4 bytes, got 0), so sequential requests alternate between the 403 and the 502.

The address checked is the UDP address the connection uses, which isn't always the client's public IP. A client on the operator's LAN, or on the operator's own machine, connects over the LAN and shows its LAN address (192.168.x.x), so --allow-ips 127.0.0.1 doesn't let in a client on the same machine; list the LAN range or the client's key. A connection relayed through another HyperDHT node shows the relay's address, so it's refused unless its key is listed.

Raw TCP mode

The framed tunnel that the sidecar and proxy use buffers each request and response whole, caps them at 64 MiB and carries one request per stream. Raw mode carries TCP instead: each connection to the client's local port gets its own HyperDHT stream, and the operator pipes it byte for byte to a new TCP connection to its upstream. HTTP/1.1 runs end to end, so keep-alive, streamed bodies of any size, Range requests and Upgrade (WebSockets) pass through.

# Operator
npx seed-gateway serve --raw --upstream http://127.0.0.1:8080 --allow-keys <client key>

# Client, for example on the host of an HTTP proxy
npx seed-gateway connect --raw <raw operator key> --key-file ./.seed/gateway-tunnel/client.key.json

The client listens on 127.0.0.1:1985 by default. Raw mode is for an HTTP proxy in front of the tunnel: each new connection costs a DHT lookup and handshake, so put a client that keeps connections alive in front of it, such as nginx with keepalive on its upstream block. The SDK sidecar and createGatewayProxy speak only the framed protocol, so the SDK and publish can't use a raw operator directly.

A raw operator needs its own key; its default key file is ./.seed/gateway-tunnel/raw-operator.key.json. A client of one mode can't use an operator of the other. A framed client of a raw operator gets a 502 that says so: The other side sent plain HTTP ("HTTP…"), not a framed tunnel message: it runs in raw mode. Use seed-gateway serve --raw / connect --raw (serveRawTunnel, connectRawTunnel) on both sides, with the raw operator's key.

The operator doesn't touch the bytes: it doesn't rewrite Host or add X-Forwarded-Host, and it has no request timeout or request-id header. Tracing reports connections and bytes, not requests. A refused client's connection closes with no HTTP response. The client destroys a connection whose stream hasn't opened within --connect-timeout-ms (30 seconds by default), and closes any connection past --max-connections (64) at once. When one side closes cleanly, the other is ended, and destroyed if it's still open 30 seconds later (closeGraceMs); after an error on either side, it's destroyed at once.

Tracing an operator

seed-gateway serve --trace prints a line for each step of each stream and request:

2026-10-24T12:00:00.000Z tunnel-trace req.recv request=mvmecdox-r1 stream=s1 method=GET path=/info bodyBytes=0 receiveMs=0

In code, serveTunnel({ onEvent }) gets each one as a TunnelEvent, and formatTunnelEvent turns it into the same line. Streams are s1, s2 and so on, and request ids are <run id>-r<N>, with a run id that differs on each start. --request-id-header <name> (or requestIdHeader) sends each request's id upstream under that header, replacing any value the client sent, so upstream logs can be matched to trace lines. Without onEvent or requestIdHeader the operator keeps no tracing state. In raw mode, serve --raw --trace and connect --raw --trace report connections instead.

Private or local DHT networks

Every library entry point (serveTunnel, connectTunnel, createGatewayProxy, createTunnelSession, serveRawTunnel and connectRawTunnel) takes bootstrap, a list of { host, port } HyperDHT bootstrap nodes, to join a private or local network instead of the public one; HyperDHT's hyperdht/testnet module starts a local one for tests. The operator and its clients have to join the same network. The CLI takes the same list as --bootstrap host:port,host:port on serve and connect in both modes, with IPv6 hosts in brackets; an entry that isn't host:port with a port from 1 to 65535 stops the command with --bootstrap entries must be host:port, got "<entry>".

seed-gateway CLI

seed-gateway serve [--upstream <url>] [--key-file <path>] [--upstream-timeout-ms <ms>]
                   [--allow-keys <keys>] [--allow-ips <ips>] [--trace] [--request-id-header <name>]
                   [--bootstrap <nodes>]
seed-gateway serve --raw [--upstream <address>] [--key-file <path>]
                   [--allow-keys <keys>] [--allow-ips <ips>] [--trace] [--bootstrap <nodes>]
seed-gateway connect <key> [--host <host>] [--port <port>] [--key-file <path>]
                     [--request-timeout-ms <ms>] [--connect-timeout-ms <ms>] [--max-streams <n>]
                     [--bootstrap <nodes>]
seed-gateway connect --raw <key> [--host <host>] [--port <port>] [--key-file <path>]
                     [--connect-timeout-ms <ms>] [--max-connections <n>] [--trace]
                     [--bootstrap <nodes>]
seed-gateway connect --print-key --key-file <path>
seed-gateway serve --help | connect --help
seed-gateway help | --help | -h

Running seed-gateway with no command prints the usage; serve --help (or -h) and connect --help print that command's part of it and exit with status 0. Both commands run until you press Ctrl+C (or the process gets SIGTERM), then close the tunnel. Errors print [Seed Protocol] <message> and exit with status 1; a bad flag value, a missing key or an unknown command also prints the usage, while an invalid allowlist entry or key file prints only the message. Timeouts must be whole numbers of milliseconds, --max-streams and --max-connections whole numbers of at least 1, and --port a whole number from 0 to 65535, or the command stops with, for example, --port must be 0–65535. A flag the mode doesn't read, such as --max-streams on serve or --request-timeout-ms with connect --raw, prints [Seed Protocol] --<flag> has no effect with <mode>; ignoring it. and the command carries on.

seed-gateway serve

Run by an operator. It accepts Hyper connections under the operator key and forwards each request to an HTTP upstream, or with --raw pipes each connection to a TCP upstream.

FlagDefaultDescription
--upstream <url>http://127.0.0.1:80The HTTP origin requests are forwarded to. It must be plain http:// and route by path (see How it works); an https:// URL stops the command with serveTunnel speaks plain HTTP to its upstream; got <url>. … before the key file is read or created. With --raw it's a TCP address: http://host:port, host:port, or https://host for TCP port 443, with TLS left to the HTTP client
--key-file <path>./.seed/gateway-tunnel/operator.key.json; with --raw, ./.seed/gateway-tunnel/raw-operator.key.jsonThe operator keypair, relative to the current directory. Created on first run
--upstream-timeout-ms <ms>130000Answer 504 once the upstream has sent nothing for this long. 0 disables. The default is above the 120 seconds publish waits for a batch upload; keep it there in front of an upload API. Ignored with --raw
--allow-keys <keys>noneClient public keys to let in, comma-separated, z32 or hex (Restricting who can connect)
--allow-ips <ips>noneClient addresses or CIDR ranges to let in, comma-separated, IPv4 or IPv6
--traceoffPrint a tunnel-trace line for each event (Tracing an operator)
--request-id-header <name>noneSend each request's id upstream under this header. Ignored with --raw
--rawoffRun a raw TCP operator
--bootstrap <nodes>the public networkHyperDHT bootstrap nodes, host:port comma-separated (Private or local DHT networks)

It prints the upstream, the key file's path, the allowlist (Allowlist: off or Allowlist: keys=<n> ips=<list or none>) and Operator key (z32): <key> (with --raw, Raw operator key (z32): <key>), which is the key to give clients.

The key file holds the operator's secret key in hex, and is created with permissions 0600. Back it up and keep it private: the key is the gateway's address, so losing the file means a new address, and anyone with the file can serve under it.

seed-gateway connect

Starts a local sidecar: an HTTP server that forwards everything it receives to the operator. With --raw, it listens for TCP connections and carries each one to a raw operator.

Argument or flagDefaultDescription
<key>(required)The operator's public key, z32 or 64-character hex
--host <host>127.0.0.1Address the sidecar listens on
--port <port>1984; with --raw, 1985Port the sidecar listens on. 0 picks a free port
--key-file <path>noneThis client's keypair, created on first run, so the operator can list its key. Without it, each run uses a new key
--print-keyoffWith --key-file, print the client's z32 key (creating the file if it's missing) and exit. Without --key-file it fails with --print-key requires --key-file
--request-timeout-ms <ms>150000Abandon a request with 504 once its stream has sent and received nothing for this long. 0 disables. Keep it above the operator's upstream timeout, so the operator's own 504 arrives first. Ignored with --raw
--connect-timeout-ms <ms>30000Answer 502 if a stream to the operator hasn't opened within this long; with --raw, close the connection. 0 disables
--max-streams <n>4Most streams open to the operator at once, one request each. Ignored with --raw
--rawoffConnect to a raw TCP operator
--max-connections <n>64With --raw, most TCP connections open at once; further ones are closed at once
--traceoffWith --raw, print a tunnel-trace line for each connection event
--bootstrap <nodes>the public networkAs for serve

It prints Client key (z32): <key>, followed by (new for this run) when there's no --key-file, then Local sidecar: http://<host>:<port> (with --raw, Raw tunnel listening on <host>:<port>). Flags are only accepted after the command.

Library API

import {
  serveTunnel,
  connectTunnel,
  createGatewayProxy,
  serveRawTunnel,
  connectRawTunnel,
  localGatewayUrl,
  loadOrCreateOperatorKeypair,
  decodePublicKey,
  encodePublicKeyZ32,
  createAllowlist,
  DEFAULT_SEED_GATEWAY_HYPER_KEY,
} from '@seedprotocol/gateway-hyper'

Every function below that joins HyperDHT takes bootstrap (DhtBootstrapNode[], { host: string; port: number } each; default the public network), described in Private or local DHT networks. The client functions take keyFile or keyPair for the key they present to the operator (Restricting who can connect).

createGatewayProxy

Creates the Path B proxy for a Node app server.

function createGatewayProxy(options?: CreateGatewayProxyOptions): GatewayProxy
OptionTypeDefaultDescription
keystringprocess.env.SEED_GATEWAY_HYPER_KEYOperator key, z32 or hex. Throws if neither is set
keyFilestringnoneThis client's keypair file, created on first use. Without it or keyPair, each run uses a new key
keyPair{ publicKey: Buffer; secretKey: Buffer }noneThis client's keypair itself. Not with keyFile
mountPathstring'/api/seed-gateway'Path prefix to strip before forwarding. A path that's already stripped (as with Express's app.use(path, …)) is forwarded as it is
eagerbooleanfalseCreate the tunnel session when the proxy is created rather than on the first request. Streams to the operator still open on the first request, and a bad key, key file or keyPair still shows up there, as a 502
requestTimeoutMsnumber150000Abandon a request with 504 once its stream has sent and received nothing for this long. 0 disables. Keep it above the operator's upstream timeout
connectTimeoutMsnumber30000Answer 502 if a stream to the operator hasn't opened within this long. 0 disables
maxStreamsnumber4Most streams open to the operator at once, one request each
bootstrapDhtBootstrapNode[]the public networkHyperDHT bootstrap nodes

Returns a GatewayProxy:

FieldTypeDescription
mountPathstringThe resolved mount path
handleFetch(request: Request) => Promise<Response>Fetch-style handler, for Next.js App Router routes and similar
handleNode(req: IncomingMessage, res: ServerResponse) => voidnode:http and Express handler
start() => Promise<void>Creates the tunnel session now. Rejects if the key can't be decoded or the key file or keyPair is invalid; it doesn't contact the operator
close() => Promise<void>Closes every stream and the HyperDHT node

Both handlers answer 502 or 504 when the tunnel fails, and pass on the operator's response otherwise (see How it works). A keyPair whose halves don't match fails with Invalid keyPair: publicKey does not belong to secretKey, a key file without both keys with Invalid key file at <path>: missing publicKey or secretKey, and passing both options with Pass keyFile or keyPair, not both.

connectTunnel

Starts a Path A sidecar from code, as seed-gateway connect does.

function connectTunnel(options: ConnectTunnelOptions): Promise<ConnectTunnelResult>
OptionTypeDefaultDescription
keystring(required)Operator key, z32 or hex
keyFilestringnoneAs for createGatewayProxy
keyPair{ publicKey: Buffer; secretKey: Buffer }noneAs for createGatewayProxy
hoststring'127.0.0.1'Listen address
portnumber1984Listen port. 0 picks a free port
requestTimeoutMsnumber150000As for createGatewayProxy
connectTimeoutMsnumber30000As for createGatewayProxy
maxStreamsnumber4As for createGatewayProxy
bootstrapDhtBootstrapNode[]the public networkHyperDHT bootstrap nodes

It resolves once the local server is listening, with { key, clientKey, host, port, baseUrl, close }, where clientKey is the z32 key this client presents. port and baseUrl reflect the port actually bound. It rejects if the key can't be decoded, the key file or keyPair is invalid or both are passed, or the port is taken.

serveTunnel

The operator side, as seed-gateway serve does.

function serveTunnel(options: ServeTunnelOptions): Promise<ServeTunnelResult>
OptionTypeDefaultDescription
upstreamstring(required)HTTP origin to forward to, such as 'http://127.0.0.1:8080'. Throws serveTunnel speaks plain HTTP to its upstream; got <url>. … for an https:// URL; use serveRawTunnel to carry TLS end to end
keyFilestring'.seed/gateway-tunnel/operator.key.json'Operator keypair file, created if it doesn't exist
keyPair{ publicKey: Buffer; secretKey: Buffer }noneThe operator keypair itself, decoded from an environment variable, say. Passing it with keyFile throws Pass keyFile or keyPair, not both
upstreamTimeoutMsnumber130000Answer 504 once the upstream has sent nothing for this long. 0 disables
allowlist{ keys?: string[]; ips?: string[] }noneClients to let in, by key or by address (Restricting who can connect). Throws on an invalid entry
onEvent(event: TunnelEvent) => voidnoneCalled synchronously with each event; a throw from it is dropped
requestIdHeaderstringnoneSend each request's id upstream under this header, replacing any value the client sent
bootstrapDhtBootstrapNode[]the public networkHyperDHT bootstrap nodes

It resolves once it's listening on the DHT, with { key, upstream, close }, where key is the operator's z32 key.

import { serveTunnel, connectTunnel, loadOrCreateOperatorKeypair, localGatewayUrl } from '@seedprotocol/gateway-hyper'

const clientKeyFile = '.seed/gateway-tunnel/client.key.json'
const clientKey = loadOrCreateOperatorKeypair(clientKeyFile).z32

const operator = await serveTunnel({
  upstream: 'http://127.0.0.1:8080',
  keyFile: '.seed/gateway-tunnel/operator.key.json',
  allowlist: { keys: [clientKey] },
})
console.log(operator.key) // share this

const sidecar = await connectTunnel({ key: operator.key, keyFile: clientKeyFile, port: 1984 })
const info = await fetch(localGatewayUrl(sidecar.baseUrl, '/info'))
console.log(info.status)

await sidecar.close()
await operator.close()

serveRawTunnel

The operator side of raw TCP mode, as seed-gateway serve --raw does.

function serveRawTunnel(options: ServeRawTunnelOptions): Promise<ServeRawTunnelResult>
OptionTypeDefaultDescription
upstreamstring(required)TCP address to pipe to: 'http://host:port', 'host:port', or 'https://host' for port 443 with TLS left to the HTTP client. Throws Invalid raw upstream (expected http://host:port or host:port): … when there's no port to use
keyFilestring'.seed/gateway-tunnel/raw-operator.key.json'Operator keypair file, created if it doesn't exist
keyPair{ publicKey: Buffer; secretKey: Buffer }noneThe operator keypair itself. Passing it with keyFile throws Pass keyFile or keyPair, not both
allowlist{ keys?: string[]; ips?: string[] }noneAs for serveTunnel
onEvent(event: RawTunnelEvent) => voidnoneCalled with each connection event
closeGraceMsnumber30000After one side of a connection closes cleanly, how long the other may stay open before it's destroyed
bootstrapDhtBootstrapNode[]the public networkHyperDHT bootstrap nodes

It resolves once it's listening on the DHT, with { key, upstream, close }, where upstream is 'host:port'. close destroys every open connection.

connectRawTunnel

The client side of raw TCP mode, as seed-gateway connect --raw does.

function connectRawTunnel(options: ConnectRawTunnelOptions): Promise<ConnectRawTunnelResult>
OptionTypeDefaultDescription
keystring(required)Raw operator key, z32 or hex
keyFilestringnoneAs for createGatewayProxy
keyPair{ publicKey: Buffer; secretKey: Buffer }noneAs for createGatewayProxy. Not with keyFile
hoststring'127.0.0.1'Listen address
portnumber1985Listen port. 0 picks a free port
maxConnectionsnumber64Most TCP connections open at once; further ones are closed at once
connectTimeoutMsnumber30000Destroy a connection whose stream to the operator hasn't opened within this long. 0 disables
onEvent(event: RawClientEvent) => voidnoneCalled with each connection event
closeGraceMsnumber30000As for serveRawTunnel
bootstrapDhtBootstrapNode[]the public networkHyperDHT bootstrap nodes

It resolves once the local port is listening, with { key, clientKey, host, port, close }.

import { serveRawTunnel, connectRawTunnel } from '@seedprotocol/gateway-hyper'

const operator = await serveRawTunnel({ upstream: 'http://127.0.0.1:8080' })
const client = await connectRawTunnel({ key: operator.key, port: 0 })

const response = await fetch(`http://127.0.0.1:${client.port}/info`)
console.log(response.status)

await client.close()
await operator.close()

Tunnel events

serveTunnel's onEvent gets a TunnelEvent, discriminated by name. Times are milliseconds; receiveMs and totalMs count from the request's first byte on the stream.

nameFieldsWhen
stream.openstream, remoteKey, host, byA client's stream was let in. by is 'open' (no allowlist), 'key' or 'ip'; host is the address it connects from
stream.refusedremoteKey, stage, hostA client was refused: stage: 'handshake' by key before any stream exists (host is null), or 'connection' after connecting
stream.errorstream, error, code?The stream failed
stream.closestream, requests, bytesIn, durationMs, inflightThe stream closed. inflight is the request it was carrying, or null
req.recvrequest, stream, method, path, bodyBytes, receiveMsA whole request arrived
up.responserequest, status, upstreamMsThe upstream sent its status and headers
up.donerequest, status, bytes, upstreamMsThe upstream finished its body
up.errorrequest, phase, error, code?, abortedThe upstream request failed while sending ('send'), waiting ('wait') or reading the body ('body'). aborted is true when the client's stream closed first
up.timeoutrequest, phase, timeoutMsThe upstream was idle for upstreamTimeoutMs
res.sentrequest, status, bytes, totalMsThe response went back to the client

The raw functions report connections, numbered c1, c2 and so on. serveRawTunnel sends a RawTunnelEvent: conn.open (conn, remoteKey, host, by), stream.refused as above, and conn.close. connectRawTunnel sends a RawClientEvent: conn.open (conn, connectMs), conn.refused (open, max) for a connection past maxConnections, and conn.close. conn.close has conn, bytesUp and bytesDown (counted from the HTTP client's side, so up is toward the upstream), durationMs, and error: the first error on either side as hyper:<code>, tcp:<code> or connect-timeout, or null.

Keys and helpers

ExportSignatureDescription
loadOrCreateOperatorKeypair(keyFile: string) => { publicKey: Buffer; secretKey: Buffer; z32: string }Reads the key file, or creates it (and its directory) with a new keypair. Client key files use the same format
encodePublicKeyZ32(publicKey: Buffer) => stringA public key in z32
decodePublicKey(key: string) => BufferAccepts z32 or 64-character hex. Throws Gateway Hyper key is empty or Invalid Gateway Hyper key (expected z32 or 64-char hex): <start of key>…
localGatewayUrl(baseUrl: string, path?: string) => stringJoins a sidecar base URL and a path
createAllowlist(options?: { keys?: string[]; ips?: string[] }) => AllowlistThe check serveTunnel and serveRawTunnel run, as { enabled, summary, firewall, check }. Throws on an invalid entry, so it can validate a list before you start an operator
formatTunnelEvent(event: TunnelEvent | RawTunnelEvent | RawClientEvent, now?: Date) => stringThe tunnel-trace line --trace prints for an event: ISO time (now, default the current time), tunnel-trace, the event name, then each field as key=value, skipping null and undefined and JSON-quoting values with spaces, quotes or =
upstreamRequestHeaders(headers: Record<string, string>) => Record<string, string>The framed operator's header filter: drops hop-by-hop headers, Content-Length, the headers named in Connection and Host, and passes Host on as X-Forwarded-Host unless the request already has one
DEFAULT_RAW_OPERATOR_KEY_FILEstring'.seed/gateway-tunnel/raw-operator.key.json'
DEFAULT_RAW_PORTnumber1985
DEFAULT_MAX_RAW_CONNECTIONSnumber64
RAW_CLOSE_GRACE_MSnumber30000
DEFAULT_SEED_GATEWAY_HYPER_KEYstring''. There's no built-in operator key

The package also exports the pieces the tunnel is built from, for writing your own transport: createTunnelSession (it takes requestTimeoutMs, connectTimeoutMs, maxStreams, keyFile, keyPair and bootstrap; its session has clientKey, and its forwardHttp and runOnTunnel take { signal, timeoutMs }), stripMountPath, the framing functions (writeFrame, readFrame, writeTunnelRequest, readTunnelRequest, writeTunnelResponse, readTunnelResponse), flattenRequestHeaders, filterResponseHeaders, MAX_FRAME_BYTES (64 MiB), the DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_MAX_STREAMS and DEFAULT_UPSTREAM_TIMEOUT_MS constants, the TunnelTimeoutError, TunnelConnectTimeoutError and UpstreamTimeoutError classes, and the TunnelSession, TunnelSessionOptions and TunnelRequestOptions types. Each request and response is two length-prefixed frames: JSON metadata (method, path and headers, or status and headers), then the body.

On this page