@greendrake/rpc-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Greendrake
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @greendrake/rpc-server
2
+
3
+ The server half of the [`@greendrake/rpc`](../rpc) wire format: one dispatch table, an HTTP endpoint and a WebSocket endpoint over it, per-method auth and argument validation, and a connection registry that pushes to clients unasked. **Runs on [bun](https://bun.sh)** — `Bun.serve` owns the sockets. The protocol core (dispatch, auth, nonce, validation) is pure functions over the wire types and touches no runtime API.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add @greendrake/rpc-server
9
+ ```
10
+
11
+ TypeScript source ships as-is (`src/main.ts` is the entry; no build step, no `.d.ts`). Extend `@greendrake/dev-config/tsconfig/base.json` with `"types": ["bun"]` — base rather than the node config because `@greendrake/rpc`, the client this serves and whose wire types it imports, is the same code a browser runs and needs the DOM lib.
12
+
13
+ ## A service, whole
14
+
15
+ ```ts
16
+ import { ConnRegistry, clientIp, createDispatcher, httpHandler, keyAuthResolver, memoryNonceStore, method, websocketHandler } from '@greendrake/rpc-server'
17
+ import { z } from 'zod'
18
+
19
+ const registry = new ConnRegistry()
20
+
21
+ const methods = {
22
+ 'user.get': method({
23
+ auth: 'user',
24
+ args: z.object({ id: z.string() }),
25
+ handler: (ctx, args) => users.get(args.id, ctx.signal)
26
+ }),
27
+ 'user.list': method({ auth: 'admin', handler: () => users.all() })
28
+ }
29
+
30
+ const dispatcher = createDispatcher(methods, {
31
+ auth: keyAuthResolver(process.env.ADMIN_API_KEY!, { userId: 'operator', isAdmin: true, phantom: false }),
32
+ nonces: memoryNonceStore(5 * 60 * 1000)
33
+ })
34
+ const api = httpHandler(dispatcher)
35
+ const ws = websocketHandler(dispatcher, registry)
36
+
37
+ Bun.serve({
38
+ port: 8080,
39
+ websocket: ws.handlers,
40
+ async fetch(request, server) {
41
+ const { pathname } = new URL(request.url)
42
+ if (pathname === '/v1/ws') return (await ws.upgrade(request, server)) ? undefined : new Response(null, { status: 400 })
43
+ if (pathname === '/v1/api') return api(request, clientIp(request, server))
44
+ return new Response(null, { status: 404 })
45
+ }
46
+ })
47
+ ```
48
+
49
+ [`@greendrake/server`](../server) wraps that last call with health probes, CORS and a graceful-shutdown sequence; nothing here requires it.
50
+
51
+ ## Methods
52
+
53
+ A `MethodDef` is what a method requires of its caller, what its argument looks like, and what runs:
54
+
55
+ ```ts
56
+ interface MethodDef<A = void, R = unknown> {
57
+ auth: 'none' | 'user' | 'admin'
58
+ allowPhantom?: boolean
59
+ args?: StandardSchemaV1<unknown, A>
60
+ handler(ctx: HandlerContext, args: A): R | Promise<R>
61
+ }
62
+ ```
63
+
64
+ **A method with no `args` schema takes no argument.** That is the rule, not an omission: validation is where an argument's type comes from, so a handler cannot be handed a value nothing has checked. A method taking a free-form payload declares a permissive schema and says so.
65
+
66
+ `method()` is identity at runtime and exists for inference — written inline in a table, a definition has nowhere to get its argument type from, because the table's own type erases it. Wrap each entry in it and the schema's output type reaches the handler's parameter.
67
+
68
+ `HandlerContext` carries `auth`, `locale`, `clientIp` and a `signal` that aborts when the caller goes away — an HTTP client disconnecting, a WebSocket closing. Pass it to whatever I/O the handler does, so work nobody is waiting for is abandoned rather than run to completion.
69
+
70
+ ### Argument validation
71
+
72
+ `args` takes any [Standard Schema](https://standardschema.dev): zod, valibot and arktype all implement it, and this package depends on none of them. A failure answers `INVALID_ARGUMENT` with the issues in `error_details`:
73
+
74
+ ```json
75
+ { "success": false, "error": "INVALID_ARGUMENT", "error_details": [{ "field": "filters.0.from", "message": "Expected number" }] }
76
+ ```
77
+
78
+ `field` is the issue's path joined with dots, which is what a form keys its per-field messages by.
79
+
80
+ ### Authentication
81
+
82
+ `AuthResolver` turns a credential into an `AuthInfo { userId, isAdmin, phantom }`, or `null` where it names nobody — a stranger, not a refusal, since what a method requires of a principal is the dispatcher's business. `userId` is `unknown`: it is the application's own id type, and nothing here reads it.
83
+
84
+ `keyAuthResolver(key, info)` is the static-key pattern — one pre-shared secret standing for one identity, for a dashboard behind an operator gate or a deployment tool. An empty key matches nothing.
85
+
86
+ The auth rules, in the order a refusal is decided:
87
+
88
+ | `auth` | Refuses with |
89
+ | --- | --- |
90
+ | `'none'` | nothing |
91
+ | `'user'` | `AUTH_REQUIRED` for an anonymous caller |
92
+ | `'admin'` | `AUTH_REQUIRED`, then `ADMIN_REQUIRED` |
93
+
94
+ A phantom — a provisional principal the application has not fully registered — is additionally refused with `PHANTOM_NOT_ALLOWED` unless the method sets `allowPhantom`.
95
+
96
+ ### Nonces
97
+
98
+ A request carrying a `nonce` runs once: `NonceStore.checkAndSet` answers false for a repeat, and the call is refused with `DUPLICATE_REQUEST`. `memoryNonceStore(ttlMs)` is one process's worth — behind a load balancer the replicas would each keep their own set, so use a shared store (Redis `SET NX PX` implements the interface in a few lines).
99
+
100
+ ## Transports
101
+
102
+ `httpHandler(dispatcher, { codec? })` returns `(request, clientIp) => Promise<Response>`: POST only (405 otherwise), the credential from the `Authorization` header (`Bearer <token>` or a bare pre-shared key), and one call per request. `clientIp(request, server)` reads `X-Forwarded-For`, then `X-Real-Ip`, then the socket.
103
+
104
+ `websocketHandler(dispatcher, registry, { onOpen?, onClose? })` returns `{ upgrade, handlers }`. The handshake credential is the `token` query parameter — the only channel a browser's WebSocket constructor leaves open, having no headers, and one a reconnect carries again for free. Calls dispatch concurrently, correlated by the `id` the client puts on each frame.
105
+
106
+ The exception is `auth.token` and `auth.logout`, the in-band frames `@greendrake/rpc`'s `WsTransport` sends: they change who every later call on that socket is made by, so each goes on a per-connection gate the calls wait behind. Without it a call sent right after a bind would race it and come back `UNAUTHENTICATED`.
107
+
108
+ A codec applies to everything on the wire, in both directions, for a service whose clients obscure or encrypt their payloads: `new ConnRegistry(codec)` for the socket, `httpHandler(dispatcher, { codec })` for the endpoint.
109
+
110
+ ## Pushing
111
+
112
+ `ConnRegistry` holds every open socket and indexes them by principal:
113
+
114
+ ```ts
115
+ registry.emitToUser(userId, 'thread.updated', { id }) // {type:'event', event, data} to that user's sockets
116
+ registry.broadcastEvent('service.state', status) // …to every socket
117
+ registry.broadcastData('telemetry', reading) // the bare {type, data} form
118
+ ```
119
+
120
+ Push-producing code takes the `EventSink` interface (`emitToUser` + `broadcastEvent`) rather than the class, so it can be driven by anything. On the client, `@greendrake/rpc`'s `liveBinding` — or `@greendrake/vue-api`'s `useLive` — subscribes to these, folds each push in or re-reads, and re-reads again after a dropped socket returns.
121
+
122
+ ## Error codes
123
+
124
+ Codes, not sentences: the client keys on them. A handler's own outcomes are `throw new ApiError(code, details?)`; anything else it throws is a fault, logged and answered `INTERNAL_ERROR`.
125
+
126
+ | Code | Meaning |
127
+ | --- | --- |
128
+ | `UNKNOWN_METHOD` | no such method in the table |
129
+ | `INVALID_JSON` | the body or frame did not parse |
130
+ | `INVALID_ARGUMENT` | the argument failed the method's schema |
131
+ | `AUTH_REQUIRED` | the method needs a caller and there is none |
132
+ | `ADMIN_REQUIRED` | the caller is not an admin |
133
+ | `PHANTOM_NOT_ALLOWED` | the caller is provisional and this method is not open to one |
134
+ | `UNAUTHENTICATED` | an `auth.token` frame named nobody |
135
+ | `AUTH_NOT_CONFIGURED` | an auth frame reached a service with no resolver |
136
+ | `DUPLICATE_REQUEST` | this nonce has already been used |
137
+ | `INTERNAL_ERROR` | a handler faulted |
138
+
139
+ By convention a code ending `_NOT_FOUND` becomes a `NotFoundError` client-side, which is the one class `@greendrake/rpc` maps specially. Nothing here enforces it.
140
+
141
+ ## The pair's typing
142
+
143
+ `ClientMethods<M>` turns a dispatch table into the method map a client hands `RpcClient` or `ApiClient`:
144
+
145
+ ```ts
146
+ export const methods = { /* … */ }
147
+ export type ApiMethods = ClientMethods<typeof methods>
148
+ ```
149
+
150
+ Declared once, on the side that defines the methods: a method whose argument shape or result changes stops compiling in whatever calls it. `examples/service-pair` is a working pair built this way.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@greendrake/rpc-server",
3
+ "version": "0.1.0",
4
+ "description": "RPC server over JSON for bun: method dispatch with auth, nonce and Standard Schema argument validation, HTTP and WebSocket transports, connection registry and server push",
5
+ "license": "MIT",
6
+ "author": "Eugene Greendrake <eugene@greendrake.nz>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/greendrake/lib.git",
10
+ "directory": "rpc-server"
11
+ },
12
+ "homepage": "https://github.com/greendrake/lib/tree/main/rpc-server#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/greendrake/lib/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "src/main.ts",
18
+ "types": "src/main.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/main.ts",
22
+ "default": "./src/main.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "src"
27
+ ],
28
+ "sideEffects": false,
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "check": "tsc --noEmit"
34
+ },
35
+ "prettier": "@greendrake/dev-config/prettier",
36
+ "dependencies": {
37
+ "@greendrake/rpc": "0.1.0",
38
+ "@standard-schema/spec": "^1.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@greendrake/dev-config": "0.1.0",
42
+ "@types/bun": "^1.3.5",
43
+ "typescript": "6.0.3"
44
+ }
45
+ }
@@ -0,0 +1,33 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+ import { ApiError } from './errors'
3
+
4
+ // A method takes one argument, and the envelope carries it two ways: wrapped
5
+ // in an array, which is what @greendrake/rpc sends, or as the value itself,
6
+ // which is what a hand-written caller or another language's client tends to
7
+ // send. Both unwrap to the same thing.
8
+ export const unwrapArguments = (raw: unknown): unknown => {
9
+ if (raw === undefined || raw === null) {
10
+ return undefined
11
+ }
12
+ return Array.isArray(raw) ? raw[0] : raw
13
+ }
14
+
15
+ // Validates the unwrapped argument against a method's schema. A failure is an
16
+ // ApiError carrying every issue, so a form can mark each offending field
17
+ // rather than showing one message for the whole submission.
18
+ export const validateArguments = async <A>(schema: StandardSchemaV1<unknown, A>, value: unknown): Promise<A> => {
19
+ const result = await schema['~standard'].validate(value)
20
+ if (result.issues) {
21
+ // An issue's path — `['filters', 0, 'from']` — becomes the dotted name a
22
+ // form field is keyed by. Segments arrive bare or wrapped in a `{ key }`
23
+ // object; both name the same step.
24
+ throw new ApiError(
25
+ 'INVALID_ARGUMENT',
26
+ result.issues.map(issue => ({
27
+ field: (issue.path ?? []).map(segment => String(typeof segment === 'object' ? segment.key : segment)).join('.'),
28
+ message: issue.message
29
+ }))
30
+ )
31
+ }
32
+ return result.value
33
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { AuthInfo, AuthResolver } from './types'
2
+
3
+ // A single pre-shared secret — the static API key pattern: a trusted
4
+ // non-browser caller (an admin dashboard behind a webserver-level gate, a
5
+ // deployment tool, an MCP server) authenticates with one configured key
6
+ // instead of a per-user token. The identity a match yields is the
7
+ // application's, so the whole principal is supplied here.
8
+ //
9
+ // An empty key matches nothing: an unconfigured secret must not turn into a
10
+ // hole that lets everyone in.
11
+ export const keyAuthResolver = (key: string, info: AuthInfo): AuthResolver => ({
12
+ resolveToken: token => Promise.resolve(key !== '' && token === key ? info : null)
13
+ })
14
+
15
+ // The credential out of an Authorization header. Two forms are on the wire:
16
+ // `Bearer <token>`, and a bare pre-shared key with no scheme at all (what the
17
+ // key-auth callers above send). Any other scheme is not a credential this
18
+ // understands, and yields none.
19
+ export const bearerToken = (request: Request): string => {
20
+ const header = request.headers.get('authorization')
21
+ if (!header) {
22
+ return ''
23
+ }
24
+ const space = header.indexOf(' ')
25
+ if (space === -1) {
26
+ return header
27
+ }
28
+ return header.slice(0, space).toLowerCase() === 'bearer' ? header.slice(space + 1) : ''
29
+ }
@@ -0,0 +1,75 @@
1
+ import { unwrapArguments, validateArguments } from './arguments'
2
+ import { ApiError } from './errors'
3
+ import type { AuthResolver, CallContext, HandlerContext, IncomingRequest, LocaleResolver, Methods, NonceStore, RpcResponse } from './types'
4
+
5
+ export interface DispatcherOptions {
6
+ // Absent means the service has no notion of identity: every method it
7
+ // serves must be `auth: 'none'`, and an in-band auth frame answers
8
+ // AUTH_NOT_CONFIGURED.
9
+ auth?: AuthResolver
10
+ // Absent means nonces are carried but not checked — fine for a
11
+ // single-caller service, not for one exposed to retries it cannot afford
12
+ // to apply twice.
13
+ nonces?: NonceStore
14
+ locale?: LocaleResolver
15
+ }
16
+
17
+ // The engine both transports share: everything between a parsed envelope and a
18
+ // response, and nothing about how either got there.
19
+ export interface Dispatcher {
20
+ // Exposed because the transports resolve credentials of their own — a
21
+ // bearer header, a query parameter at the WebSocket handshake, an in-band
22
+ // auth frame — before there is a request to dispatch.
23
+ readonly auth?: AuthResolver
24
+ dispatch(request: IncomingRequest, context: CallContext): Promise<RpcResponse>
25
+ }
26
+
27
+ const failure = (error: string, details?: unknown): RpcResponse => ({
28
+ success: false,
29
+ error,
30
+ error_details: details
31
+ })
32
+
33
+ export const createDispatcher = (methods: Methods, options: DispatcherOptions = {}): Dispatcher => ({
34
+ auth: options.auth,
35
+ async dispatch(request, context) {
36
+ try {
37
+ // A repeat of a call that already ran is refused before anything
38
+ // else: the point of the nonce is that its effect happens once.
39
+ if (request.nonce && options.nonces && !(await options.nonces.checkAndSet(request.nonce))) {
40
+ return failure('DUPLICATE_REQUEST')
41
+ }
42
+ // Own properties only: a method named `toString` or `constructor`
43
+ // is not a method this service serves.
44
+ if (!Object.hasOwn(methods, request.method)) {
45
+ return failure('UNKNOWN_METHOD')
46
+ }
47
+ const def = methods[request.method]
48
+ if (def.auth !== 'none') {
49
+ if (context.auth.userId === null) {
50
+ return failure('AUTH_REQUIRED')
51
+ }
52
+ if (def.auth === 'admin' && !context.auth.isAdmin) {
53
+ return failure('ADMIN_REQUIRED')
54
+ }
55
+ if (context.auth.phantom && !def.allowPhantom) {
56
+ return failure('PHANTOM_NOT_ALLOWED')
57
+ }
58
+ }
59
+ const ctx: HandlerContext = { ...context, locale: options.locale?.(request.locale) ?? '' }
60
+ const args = def.args ? await validateArguments(def.args, unwrapArguments(request.arguments)) : undefined
61
+ return { success: true, data: await def.handler(ctx, args) }
62
+ } catch (e) {
63
+ if (e instanceof ApiError) {
64
+ return failure(e.code, e.details)
65
+ }
66
+ // A request whose caller has already gone fails in whatever way the
67
+ // abandoned I/O reports; that is the disconnect, not a fault of the
68
+ // service, and its response is discarded anyway.
69
+ if (!context.signal.aborted) {
70
+ console.error(`[rpc-server] ${request.method}:`, e)
71
+ }
72
+ return failure('INTERNAL_ERROR')
73
+ }
74
+ }
75
+ })
package/src/errors.ts ADDED
@@ -0,0 +1,19 @@
1
+ // A business outcome the caller is meant to see: `code` becomes the response's
2
+ // `error`, `details` its `error_details` (the structured payload a handler
3
+ // attaches alongside a code — the per-field list argument validation produces,
4
+ // the conflicting record's id, whatever the method's contract says).
5
+ //
6
+ // Anything else a handler throws is a fault, not an outcome: it is logged and
7
+ // answered INTERNAL_ERROR, so an unforeseen failure never leaks its internals
8
+ // to a client.
9
+ export class ApiError extends Error {
10
+ readonly code: string
11
+ readonly details?: unknown
12
+
13
+ constructor(code: string, details?: unknown) {
14
+ super(code)
15
+ this.name = 'ApiError'
16
+ this.code = code
17
+ this.details = details
18
+ }
19
+ }
package/src/http.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { Server } from 'bun'
2
+ import { identityCodec } from '@greendrake/rpc'
3
+ import { bearerToken } from './auth'
4
+ import type { Dispatcher } from './dispatcher'
5
+ import { ANONYMOUS } from './types'
6
+ import type { Codec, IncomingRequest, RpcResponse } from './types'
7
+
8
+ export interface HttpHandlerOptions {
9
+ // Applied to the request body and the response body. Identity by default;
10
+ // a service whose clients obscure or encrypt their payloads supplies the
11
+ // same codec both ends run.
12
+ codec?: Codec
13
+ }
14
+
15
+ // Where the call came from, as the proxies in front of this service report it.
16
+ // A service behind nothing still gets the socket's own address.
17
+ export const clientIp = <T>(request: Request, server: Server<T>): string => {
18
+ const forwarded = request.headers.get('x-forwarded-for')
19
+ if (forwarded) {
20
+ // The left-most entry is the original client; the rest are the proxies
21
+ // it passed through.
22
+ return forwarded.split(',')[0].trim()
23
+ }
24
+ return request.headers.get('x-real-ip') ?? server.requestIP(request)?.address ?? ''
25
+ }
26
+
27
+ // The single-endpoint HTTP transport: one POST carries one call. Returns the
28
+ // handler a `Bun.serve` fetch routes its API path to.
29
+ export const httpHandler = (dispatcher: Dispatcher, options: HttpHandlerOptions = {}): ((request: Request, ip: string) => Promise<Response>) => {
30
+ const codec = options.codec ?? identityCodec
31
+ // A codec's output is not JSON any more, and calling it JSON misleads
32
+ // everything that sniffs the body — devtools, proxies, CDNs.
33
+ const contentType = options.codec ? 'text/plain; charset=utf-8' : 'application/json'
34
+ const respond = (response: RpcResponse): Response => new Response(codec.encode(JSON.stringify(response)), { headers: { 'content-type': contentType } })
35
+
36
+ return async (request, ip) => {
37
+ if (request.method !== 'POST') {
38
+ return new Response(null, { status: 405 })
39
+ }
40
+ const token = bearerToken(request)
41
+ // A token nobody recognises leaves the caller anonymous rather than
42
+ // refused: what that costs them is decided per method, by the rule the
43
+ // method declares.
44
+ const auth = (token && (await dispatcher.auth?.resolveToken(token, request.signal))) || ANONYMOUS
45
+
46
+ let parsed: IncomingRequest
47
+ try {
48
+ parsed = JSON.parse(codec.decode(await request.text())) as IncomingRequest
49
+ } catch {
50
+ return respond({ success: false, error: 'INVALID_JSON' })
51
+ }
52
+ // No `type` on an HTTP response: the field exists to tell a WebSocket
53
+ // frame's kind apart, and here there is only one kind.
54
+ return respond(
55
+ await dispatcher.dispatch(parsed, {
56
+ auth,
57
+ clientIp: ip,
58
+ signal: request.signal
59
+ })
60
+ )
61
+ }
62
+ }
package/src/main.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { ANONYMOUS, method, type AuthInfo, type AuthRequirement, type AuthResolver, type CallContext, type ClientMethods, type DataFrame, type EventFrame, type HandlerContext, type IncomingRequest, type LocaleResolver, type MethodDef, type Methods, type NonceStore, type WsResponse } from './types'
2
+ export { ApiError } from './errors'
3
+ export { unwrapArguments, validateArguments } from './arguments'
4
+ export { createDispatcher, type Dispatcher, type DispatcherOptions } from './dispatcher'
5
+ export { bearerToken, keyAuthResolver } from './auth'
6
+ export { memoryNonceStore } from './nonce'
7
+ export { clientIp, httpHandler, type HttpHandlerOptions } from './http'
8
+ export { websocketHandler, type RpcWebSocket, type WebSocketHandlerOptions } from './ws'
9
+ export { ConnRegistry, type Connection, type ConnectionData, type EventSink } from './registry'
10
+ export type { Codec, RpcRequest, RpcResponse } from './types'
package/src/nonce.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { NonceStore } from './types'
2
+
3
+ // In-process nonce store: a Map swept on insert, so it needs no timer and no
4
+ // teardown. One process only — behind a load balancer the replicas would each
5
+ // keep their own set and a retry landing elsewhere would run twice, so a
6
+ // multi-instance deployment needs a shared store (Redis `SET NX PX` with the
7
+ // same TTL implements this interface in a few lines).
8
+ export const memoryNonceStore = (ttlMs: number): NonceStore => {
9
+ // Insertion order is expiry order, the TTL being constant, so the sweep
10
+ // stops at the first entry still alive instead of walking the whole map.
11
+ const expiries = new Map<string, number>()
12
+ return {
13
+ checkAndSet: nonce => {
14
+ const now = Date.now()
15
+ for (const [seen, expiry] of expiries) {
16
+ if (expiry > now) {
17
+ break
18
+ }
19
+ expiries.delete(seen)
20
+ }
21
+ if (expiries.has(nonce)) {
22
+ return Promise.resolve(false)
23
+ }
24
+ expiries.set(nonce, now + ttlMs)
25
+ return Promise.resolve(true)
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,131 @@
1
+ import type { ServerWebSocket } from 'bun'
2
+ import { identityCodec } from '@greendrake/rpc'
3
+ import type { AuthInfo, Codec, ServerFrame } from './types'
4
+
5
+ // Per-socket state, carried as Bun's websocket `data`.
6
+ export interface ConnectionData {
7
+ // Who the socket is bound as: the query-parameter credential it opened
8
+ // with, or whatever an in-band auth frame has since made it.
9
+ auth: AuthInfo
10
+ // Aborted when the socket closes, so every handler still running on it
11
+ // learns its caller is gone.
12
+ controller: AbortController
13
+ clientIp: string
14
+ // Serialises the frames that change who the connection is against the
15
+ // calls that depend on the answer. See the WebSocket handler.
16
+ gate: Promise<unknown>
17
+ }
18
+
19
+ export type Connection = ServerWebSocket<ConnectionData>
20
+
21
+ // What a subsystem needs in order to tell clients something happened, without
22
+ // knowing what a connection is. Push-producing code takes this, not the
23
+ // registry.
24
+ export interface EventSink {
25
+ emitToUser(userId: unknown, event: string, data: unknown): void
26
+ broadcastEvent(event: string, data: unknown): void
27
+ }
28
+
29
+ // Every open socket, and the index from principal to the sockets bound as it —
30
+ // a user with three tabs has three. Holding all of them as well as the index
31
+ // is what lets a broadcast reach anonymous connections too, which a per-user
32
+ // index alone cannot.
33
+ export class ConnRegistry implements EventSink {
34
+ readonly #connections = new Set<Connection>()
35
+ readonly #byUser = new Map<unknown, Set<Connection>>()
36
+ // Where each connection is currently filed, so a principal that changes
37
+ // mid-connection can be re-indexed without the caller tracking what it
38
+ // used to be.
39
+ readonly #filedUnder = new Map<Connection, unknown>()
40
+ // Public because the WebSocket handler decodes inbound frames with it:
41
+ // one codec governs the socket in both directions, and it is configured
42
+ // here, once.
43
+ readonly codec: Codec
44
+
45
+ constructor(codec: Codec = identityCodec) {
46
+ this.codec = codec
47
+ }
48
+
49
+ // Idempotent, and the one way a connection's principal reaches the index:
50
+ // called when a socket opens and again whenever an auth frame changes who
51
+ // it is.
52
+ register(conn: Connection): void {
53
+ this.#connections.add(conn)
54
+ const userId = conn.data.auth.userId
55
+ if (this.#filedUnder.get(conn) === userId) {
56
+ return
57
+ }
58
+ this.#unfile(conn)
59
+ if (userId === null) {
60
+ return
61
+ }
62
+ let bound = this.#byUser.get(userId)
63
+ if (!bound) {
64
+ bound = new Set()
65
+ this.#byUser.set(userId, bound)
66
+ }
67
+ bound.add(conn)
68
+ this.#filedUnder.set(conn, userId)
69
+ }
70
+
71
+ unregister(conn: Connection): void {
72
+ this.#connections.delete(conn)
73
+ this.#unfile(conn)
74
+ }
75
+
76
+ #unfile(conn: Connection): void {
77
+ if (!this.#filedUnder.has(conn)) {
78
+ return
79
+ }
80
+ const userId = this.#filedUnder.get(conn)
81
+ this.#filedUnder.delete(conn)
82
+ const bound = this.#byUser.get(userId)
83
+ if (!bound) {
84
+ return
85
+ }
86
+ bound.delete(conn)
87
+ if (bound.size === 0) {
88
+ this.#byUser.delete(userId)
89
+ }
90
+ }
91
+
92
+ // The one place a frame becomes bytes: responses and pushes leave a socket
93
+ // the same way, so a codec is configured once rather than per call site.
94
+ send(conn: Connection, frame: ServerFrame): void {
95
+ conn.send(this.codec.encode(JSON.stringify(frame)))
96
+ }
97
+
98
+ sendToUser(userId: unknown, frame: ServerFrame): void {
99
+ this.#byUser.get(userId)?.forEach(conn => this.send(conn, frame))
100
+ }
101
+
102
+ emitToUser(userId: unknown, event: string, data: unknown): void {
103
+ this.sendToUser(userId, {
104
+ type: 'event',
105
+ event,
106
+ data
107
+ })
108
+ }
109
+
110
+ // To every open socket, whoever it is bound as — for state the whole
111
+ // service shares rather than anything addressed to a user.
112
+ broadcastEvent(event: string, data: unknown): void {
113
+ this.#connections.forEach(conn =>
114
+ this.send(conn, {
115
+ type: 'event',
116
+ event,
117
+ data
118
+ })
119
+ )
120
+ }
121
+
122
+ // The bare `{type, data}` form, for a push whose own type names the
123
+ // payload and needs no event envelope.
124
+ broadcastData(type: string, data: unknown): void {
125
+ this.#connections.forEach(conn => this.send(conn, { type, data }))
126
+ }
127
+
128
+ get size(): number {
129
+ return this.#connections.size
130
+ }
131
+ }
package/src/types.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+ import type { RpcRequest, RpcResponse } from '@greendrake/rpc'
3
+
4
+ // Who is calling, as the application's resolver reports it. `userId` is opaque
5
+ // — the application's own id type — and null for a caller no credential named,
6
+ // which is the one thing every auth rule but 'none' turns away.
7
+ export interface AuthInfo {
8
+ userId: unknown
9
+ isAdmin: boolean
10
+ phantom: boolean
11
+ }
12
+
13
+ // The principal a request carries until a credential says otherwise. Not a
14
+ // stand-in for "unknown": a resolver that ran and matched nothing yields this
15
+ // too, because a token nobody recognises makes its bearer a stranger, not an
16
+ // error.
17
+ export const ANONYMOUS: AuthInfo = {
18
+ userId: null,
19
+ isAdmin: false,
20
+ phantom: false
21
+ }
22
+
23
+ // Turns a credential into a principal, or null where it names nobody. What a
24
+ // method then requires of that principal is the dispatcher's business, so this
25
+ // never refuses: it only reports.
26
+ export interface AuthResolver {
27
+ resolveToken(token: string, signal: AbortSignal): Promise<AuthInfo | null>
28
+ }
29
+
30
+ // Replay protection for the calls that carry a nonce: true the first time one
31
+ // is seen, false for a repeat.
32
+ export interface NonceStore {
33
+ checkAndSet(nonce: string): Promise<boolean>
34
+ }
35
+
36
+ // Maps the language tag a client asked for onto one the application actually
37
+ // ships. Which locales exist and how a near miss resolves is the application's
38
+ // policy; this package only carries the value.
39
+ export type LocaleResolver = (requested: string | undefined) => string
40
+
41
+ // What the transport knows about a call before it dispatches.
42
+ export interface CallContext {
43
+ auth: AuthInfo
44
+ clientIp: string
45
+ // Aborted when the caller goes away — an HTTP client disconnecting, a
46
+ // WebSocket closing. Handlers pass it to whatever I/O they do, so work
47
+ // nobody is waiting for is abandoned rather than run to completion.
48
+ signal: AbortSignal
49
+ }
50
+
51
+ export interface HandlerContext extends CallContext {
52
+ // Empty unless the dispatcher was given a LocaleResolver.
53
+ locale: string
54
+ }
55
+
56
+ export type AuthRequirement = 'none' | 'user' | 'admin'
57
+
58
+ // One method: what it requires of the caller, what shape its argument has, and
59
+ // what runs. `A` defaults to void — a method with no `args` schema takes none,
60
+ // and ClientMethods turns that into a zero-argument call.
61
+ export interface MethodDef<A = void, R = unknown> {
62
+ auth: AuthRequirement
63
+ // Let a phantom — a provisional principal the application has not fully
64
+ // registered — through. Off by default: a method reached by one says so.
65
+ allowPhantom?: boolean
66
+ // Validates the single argument and types the handler's. Any Standard
67
+ // Schema validator (zod, valibot, arktype) qualifies; failures answer
68
+ // INVALID_ARGUMENT with the per-field issues in `error_details`.
69
+ args?: StandardSchemaV1<unknown, A>
70
+ // Declared as a method rather than a property so the map below can hold
71
+ // definitions of every argument type: parameter bivariance is what makes a
72
+ // MethodDef<Something> a MethodDef<unknown>.
73
+ handler(ctx: HandlerContext, args: A): R | Promise<R>
74
+ }
75
+
76
+ // A dispatch table. The erased argument type is what every concrete MethodDef
77
+ // widens to, so a table mixing schemas and bare methods is one value.
78
+ export type Methods = Record<string, MethodDef<unknown, unknown>>
79
+
80
+ // Identity at runtime; inference is its whole job. A definition written inline
81
+ // in a table has nowhere to get its argument type from — the table's own type
82
+ // erases it — so the schema's output reaches the handler's parameter through
83
+ // this. A definition with no schema takes no argument, and says so.
84
+ export const method = <A = void, R = unknown>(def: MethodDef<A, R>): MethodDef<A, R> => def
85
+
86
+ type MethodArgs<A> = [A] extends [void] ? [] : [args: A]
87
+
88
+ // The client-side view of a dispatch table: the method map a dashboard hands
89
+ // `ApiClient<…>`, derived from the definitions rather than restated beside
90
+ // them. The pair's typing meets here — a method whose argument shape or result
91
+ // changes server-side stops compiling in the frontend that calls it.
92
+ export type ClientMethods<M extends Methods> = {
93
+ [K in keyof M]: M[K] extends MethodDef<infer A, infer R> ? (...args: MethodArgs<A>) => Awaited<R> : never
94
+ }
95
+
96
+ // The WebSocket transport's additions to the wire: an id correlating a
97
+ // response with its request, and the frames the server pushes unasked.
98
+ export interface WsResponse extends RpcResponse {
99
+ type: 'response'
100
+ id?: string
101
+ }
102
+
103
+ export interface EventFrame {
104
+ type: 'event'
105
+ event: string
106
+ data: unknown
107
+ }
108
+
109
+ export interface DataFrame {
110
+ type: string
111
+ data: unknown
112
+ }
113
+
114
+ // The wire types both halves share, re-exported so a server's modules have one
115
+ // place to import from.
116
+ export type { Codec, RpcRequest, RpcResponse } from '@greendrake/rpc'
117
+
118
+ // Everything that goes out on a socket, so the registry's send path is typed
119
+ // rather than `unknown`.
120
+ export type ServerFrame = WsResponse | EventFrame | DataFrame
121
+
122
+ // A request as it arrives: the shared envelope plus the WebSocket's id.
123
+ export interface IncomingRequest extends RpcRequest {
124
+ id?: string
125
+ }
package/src/ws.ts ADDED
@@ -0,0 +1,144 @@
1
+ import type { Server, WebSocketHandler } from 'bun'
2
+ import { unwrapArguments } from './arguments'
3
+ import type { Dispatcher } from './dispatcher'
4
+ import { clientIp } from './http'
5
+ import type { Connection, ConnectionData, ConnRegistry } from './registry'
6
+ import { ANONYMOUS } from './types'
7
+ import type { AuthInfo, IncomingRequest, RpcResponse } from './types'
8
+
9
+ export interface WebSocketHandlerOptions {
10
+ // Run once the socket can be written to. A service that greets a new
11
+ // connection with its current state does it here.
12
+ onOpen?: (conn: Connection) => void
13
+ // Run when the socket has gone, before it leaves the registry.
14
+ onClose?: (conn: Connection) => void
15
+ }
16
+
17
+ export interface RpcWebSocket {
18
+ // Resolves the handshake credential and hands the socket to Bun. False
19
+ // when Bun refused the upgrade, which is the fetch handler's cue to answer
20
+ // the request itself.
21
+ upgrade(request: Request, server: Server<ConnectionData>): Promise<boolean>
22
+ // Pass to `Bun.serve`'s `websocket`.
23
+ handlers: WebSocketHandler<ConnectionData>
24
+ }
25
+
26
+ // The credential a socket presents at the handshake, as a query parameter —
27
+ // the only channel a browser's WebSocket constructor leaves open, having no
28
+ // headers. Reconnects carry it again for free, which is why a dashboard with a
29
+ // static key needs no in-band handshake at all.
30
+ const TOKEN_PARAM = 'token'
31
+
32
+ // The WebSocket transport: many calls over one socket, correlated by id, plus
33
+ // the pushes the server sends unasked. Requests dispatch concurrently — a slow
34
+ // one must not hold up the socket — with one exception, below.
35
+ export const websocketHandler = (dispatcher: Dispatcher, registry: ConnRegistry, options: WebSocketHandlerOptions = {}): RpcWebSocket => {
36
+ const reply = (conn: Connection, id: string | undefined, response: RpcResponse): void =>
37
+ registry.send(conn, {
38
+ type: 'response',
39
+ id,
40
+ ...response
41
+ })
42
+
43
+ // Who the connection is from here on: the index follows, and the frame that
44
+ // asked is answered once it does.
45
+ const bindAs = (conn: Connection, request: IncomingRequest, auth: AuthInfo): void => {
46
+ conn.data.auth = auth
47
+ registry.register(conn)
48
+ reply(conn, request.id, { success: true })
49
+ }
50
+
51
+ const authenticate = async (conn: Connection, request: IncomingRequest): Promise<void> => {
52
+ if (!dispatcher.auth) {
53
+ reply(conn, request.id, { success: false, error: 'AUTH_NOT_CONFIGURED' })
54
+ return
55
+ }
56
+ const token = unwrapArguments(request.arguments)
57
+ if (typeof token !== 'string') {
58
+ reply(conn, request.id, { success: false, error: 'INVALID_ARGUMENT' })
59
+ return
60
+ }
61
+ const auth = await dispatcher.auth.resolveToken(token, conn.data.controller.signal)
62
+ if (!auth || auth.userId === null) {
63
+ reply(conn, request.id, { success: false, error: 'UNAUTHENTICATED' })
64
+ return
65
+ }
66
+ bindAs(conn, request, auth)
67
+ }
68
+
69
+ const logout = (conn: Connection, request: IncomingRequest): void => bindAs(conn, request, ANONYMOUS)
70
+
71
+ // The frames that change who a connection is, rather than asking it to do
72
+ // something. @greendrake/rpc's WsTransport sends exactly these. A Map, so a
73
+ // client naming an inherited property does not reach a function.
74
+ const authFrames = new Map<string, (conn: Connection, request: IncomingRequest) => void | Promise<void>>([
75
+ ['auth.token', authenticate],
76
+ ['auth.logout', logout]
77
+ ])
78
+
79
+ return {
80
+ async upgrade(request, server) {
81
+ const token = new URL(request.url).searchParams.get(TOKEN_PARAM)
82
+ const resolved = token && dispatcher.auth ? await dispatcher.auth.resolveToken(token, request.signal) : null
83
+ const data: ConnectionData = {
84
+ auth: resolved ?? ANONYMOUS,
85
+ controller: new AbortController(),
86
+ clientIp: clientIp(request, server),
87
+ gate: Promise.resolve()
88
+ }
89
+ return server.upgrade(request, { data })
90
+ },
91
+ handlers: {
92
+ open(conn) {
93
+ registry.register(conn)
94
+ options.onOpen?.(conn)
95
+ },
96
+ message(conn, raw) {
97
+ let request: IncomingRequest
98
+ try {
99
+ request = JSON.parse(registry.codec.decode(String(raw))) as IncomingRequest
100
+ } catch {
101
+ reply(conn, undefined, { success: false, error: 'INVALID_JSON' })
102
+ return
103
+ }
104
+ const bind = authFrames.get(request.method)
105
+ if (bind) {
106
+ // An auth frame changes who every later call on this socket
107
+ // is made by, so it goes on the gate the calls wait behind:
108
+ // a request sent right after one must not race it and come
109
+ // back UNAUTHENTICATED. The chain is kept fulfilled — a
110
+ // rejected gate would silently strand every frame behind it.
111
+ conn.data.gate = conn.data.gate
112
+ .then(() => bind(conn, request))
113
+ .catch((e: unknown) => {
114
+ console.error(`[rpc-server] ${request.method}:`, e)
115
+ reply(conn, request.id, { success: false, error: 'INTERNAL_ERROR' })
116
+ })
117
+ return
118
+ }
119
+ void conn.data.gate
120
+ .then(() =>
121
+ dispatcher.dispatch(request, {
122
+ auth: conn.data.auth,
123
+ clientIp: conn.data.clientIp,
124
+ signal: conn.data.controller.signal
125
+ })
126
+ )
127
+ .then(response => {
128
+ // The socket went while the handler ran: the response
129
+ // has nowhere to go, and Bun would throw writing it.
130
+ if (!conn.data.controller.signal.aborted) {
131
+ reply(conn, request.id, response)
132
+ }
133
+ })
134
+ },
135
+ close(conn) {
136
+ // Every handler still running on this socket learns its caller
137
+ // is gone, and abandons whatever I/O it is waiting on.
138
+ conn.data.controller.abort()
139
+ options.onClose?.(conn)
140
+ registry.unregister(conn)
141
+ }
142
+ }
143
+ }
144
+ }