@greendrake/rpc-server 0.8.0 → 0.9.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @greendrake/rpc-server
2
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.
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 — events, and the state documents every socket keeps. **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
4
 
5
5
  ## Install
6
6
 
@@ -101,6 +101,8 @@ A request carrying a `nonce` runs once: `NonceStore.checkAndSet` answers false f
101
101
 
102
102
  `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.
103
103
 
104
+ Nothing pushed is ever dropped. Bun drops a frame once a socket's send buffer passes its `backpressureLimit`; `handlers` sets `closeOnBackpressureLimit`, so such a socket is closed instead. A client that has stopped reading would otherwise be left silently wrong. Once it reconnects, its re-read (`liveBinding`) or the documents it is greeted with put it right.
105
+
104
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`.
105
107
 
106
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.
@@ -117,6 +119,23 @@ registry.broadcastData('telemetry', reading) // the bare {type, data} form
117
119
 
118
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` subscribes to these, folds each push in or re-reads, and re-reads again after a dropped socket returns.
119
121
 
122
+ ### Documents
123
+
124
+ State the whole service shares, kept current on every socket, is a document rather than a stream of events:
125
+
126
+ ```ts
127
+ const cameras = registry.document('cameras', () => fleet.states()) // { gate: {…}, porch: {…} }
128
+
129
+ fleet.onChange(() => cameras.refresh())
130
+ ```
131
+
132
+ - **Greeting.** `document(type, produce)` produces the state now, and every socket that opens is sent it whole: `{type, data: {doc}}`.
133
+ - **Patches.** Each `refresh()` produces the state again and sends every socket the RFC 7386 merge patch from the last one: `{type, data: {patch}}`. Objects merge, `null` removes a member, and anything else replaces, so collections are objects keyed by id. Nothing is sent when nothing changed, so a producer calls `refresh` on any change it knows of.
134
+ - **Shape.** The state must be a JSON object, or `document` throws. A member produced as `null` is dropped, the same as one left out, since a patch could not tell the two apart.
135
+ - **Ordering.** Sends are synchronous, so no patch can precede or overtake a socket's document.
136
+
137
+ On the client, `@greendrake/rpc`'s `liveDocument` keeps the document. A reconnect is greeted with it afresh, so no re-read is needed.
138
+
120
139
  ### Topics
121
140
 
122
141
  For state only the sockets that asked for it want — one record's changes, followed by whoever has it open — a socket joins a topic, and the application publishes to the topic:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/rpc-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
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
5
  "license": "MIT",
6
6
  "author": "Eugene Greendrake <eugene@greendrake.nz>",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "prettier": "@greendrake/dev-config/prettier",
36
36
  "dependencies": {
37
- "@greendrake/rpc": "^0.8.0",
37
+ "@greendrake/rpc": "^0.9.0",
38
38
  "@standard-schema/spec": "^1.1.0"
39
39
  },
40
40
  "devDependencies": {
package/src/main.ts CHANGED
@@ -6,5 +6,5 @@ export { bearerToken, keyAuthResolver } from './auth'
6
6
  export { memoryNonceStore } from './nonce'
7
7
  export { clientIp, httpHandler, type HttpHandlerOptions } from './http'
8
8
  export { websocketHandler, type RpcWebSocket, type WebSocketHandlerOptions } from './ws'
9
- export { ConnRegistry, type Audience, type Connection, type ConnectionData, type EventSink } from './registry'
9
+ export { ConnRegistry, type Audience, type Connection, type ConnectionData, type Document, type EventSink } from './registry'
10
10
  export type { Codec, RpcRequest, RpcResponse } from './types'
package/src/registry.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
- import { identityCodec } from '@greendrake/rpc'
2
+ import { documentOf, identityCodec, mergePatchBetween, type JsonObject } from '@greendrake/rpc'
3
3
  import type { AuthInfo, Codec, EventFrame, ServerFrame } from './types'
4
4
 
5
5
  // Per-socket state, carried as Bun's websocket `data`.
@@ -26,6 +26,15 @@ export interface EventSink {
26
26
  broadcastEvent(event: string, data: unknown): void
27
27
  }
28
28
 
29
+ // What a producer holds of its document: publishing it is the producer's whole part.
30
+ export interface Document {
31
+ // Publishes the state as `produce` now has it: what changed since it was last
32
+ // published goes to every socket as one patch, and nothing goes when nothing
33
+ // changed. So a producer calls it on any change it knows of; a call too many
34
+ // costs a diff.
35
+ refresh(): void
36
+ }
37
+
29
38
  // Whether anybody is connected to hear a broadcast, for a producer whose
30
39
  // pushes cost something to produce — a reading taken from somewhere slow —
31
40
  // and are worth producing only while somebody is there to be told.
@@ -65,6 +74,13 @@ const eventFrame = (event: string, data: unknown): EventFrame => ({
65
74
  data
66
75
  })
67
76
 
77
+ // A document the registry publishes: what it is called on the wire, and the state as
78
+ // last published, which is what an opening socket is sent.
79
+ interface Published {
80
+ type: string
81
+ doc: JsonObject
82
+ }
83
+
68
84
  // Every open socket, and the index from principal to the sockets bound as it —
69
85
  // a user with three tabs has three. Holding all of them as well as the index
70
86
  // is what lets a broadcast reach anonymous connections too, which a per-user
@@ -73,8 +89,11 @@ const eventFrame = (event: string, data: unknown): EventFrame => ({
73
89
  // And the topics sockets have joined: subscriptions a method takes out for its
74
90
  // caller (see Topics), published to by the application and dropped with the
75
91
  // socket.
92
+ //
93
+ // And the documents every socket keeps (see document()).
76
94
  export class ConnRegistry implements EventSink, Audience {
77
95
  readonly #connections = new Set<Connection>()
96
+ readonly #documents: Published[] = []
78
97
  readonly #presence = new Set<(present: boolean) => void>()
79
98
  readonly #byUser = new Map<unknown, Set<Connection>>()
80
99
  // Where each connection is currently filed, so a principal that changes
@@ -100,6 +119,9 @@ export class ConnRegistry implements EventSink, Audience {
100
119
  register(conn: Connection): void {
101
120
  const arriving = !this.#connections.has(conn)
102
121
  this.#connections.add(conn)
122
+ if (arriving) {
123
+ this.#documents.forEach(published => this.#greet(conn, published))
124
+ }
103
125
  if (arriving && this.#connections.size === 1) {
104
126
  this.#presence.forEach(listener => listener(true))
105
127
  }
@@ -197,6 +219,38 @@ export class ConnRegistry implements EventSink, Audience {
197
219
  this.#connections.forEach(conn => this.send(conn, { type, data }))
198
220
  }
199
221
 
222
+ // A state stream: the whole of some state as one JSON object, kept by every
223
+ // socket. A socket is sent the document as last published when it opens,
224
+ // `{type, data: {doc}}`, and each change after that as an RFC 7386 merge
225
+ // patch over it, `{type, data: {patch}}` — objects merge, null removes a
226
+ // member, anything else replaces — so what it holds is always the document.
227
+ // A member produced as null is dropped, the same as one left out, a patch
228
+ // being unable to tell the two apart (`@greendrake/rpc`'s documentOf, which
229
+ // the client end shares). `produce` runs now, so every greeting has a
230
+ // document, and again on each refresh. A state that is no JSON object
231
+ // throws. Sends are synchronous, so no patch can precede or overtake a
232
+ // socket's document.
233
+ document(type: string, produce: () => object): Document {
234
+ const published: Published = { type, doc: documentOf(type, produce()) }
235
+ this.#documents.push(published)
236
+ this.#connections.forEach(conn => this.#greet(conn, published))
237
+ return {
238
+ refresh: () => {
239
+ const next = documentOf(type, produce())
240
+ const patch = mergePatchBetween(published.doc, next)
241
+ if (!patch) {
242
+ return
243
+ }
244
+ published.doc = next
245
+ this.#connections.forEach(conn => this.send(conn, { type, data: { patch } }))
246
+ }
247
+ }
248
+ }
249
+
250
+ #greet(conn: Connection, published: Published): void {
251
+ this.send(conn, { type: published.type, data: { doc: published.doc } })
252
+ }
253
+
200
254
  get size(): number {
201
255
  return this.#connections.size
202
256
  }
package/src/ws.ts CHANGED
@@ -7,8 +7,8 @@ import { ANONYMOUS } from './types'
7
7
  import type { AuthInfo, IncomingRequest, RpcResponse } from './types'
8
8
 
9
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.
10
+ // Run once the socket can be written to, after the registry has greeted it
11
+ // with every document.
12
12
  onOpen?: (conn: Connection) => void
13
13
  // Run when the socket has gone, before it leaves the registry.
14
14
  onClose?: (conn: Connection) => void
@@ -89,6 +89,11 @@ export const websocketHandler = (dispatcher: Dispatcher, registry: ConnRegistry,
89
89
  return server.upgrade(request, { data })
90
90
  },
91
91
  handlers: {
92
+ // A socket whose client has stopped reading, once its send buffer
93
+ // passes Bun's backpressure limit, is closed rather than having its
94
+ // frames dropped: a frame lost would leave the client silently wrong,
95
+ // and a closed socket reconnects to be greeted afresh.
96
+ closeOnBackpressureLimit: true,
92
97
  open(conn) {
93
98
  registry.register(conn)
94
99
  options.onOpen?.(conn)