@greendrake/rpc-server 0.7.0 → 0.8.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
@@ -63,7 +63,7 @@ interface MethodDef<A = void, R = unknown> {
63
63
 
64
64
  `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.
65
65
 
66
- `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.
66
+ `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. It also carries `topics`, the calling socket's subscriptions (see [Pushing](#pushing)), which is `null` for a call that came over HTTP.
67
67
 
68
68
  ### Argument validation
69
69
 
@@ -107,7 +107,7 @@ A codec applies to everything on the wire, in both directions, for a service who
107
107
 
108
108
  ## Pushing
109
109
 
110
- `ConnRegistry` holds every open socket and indexes them by principal:
110
+ `ConnRegistry` holds every open socket and indexes them by principal and by [topic](#topics):
111
111
 
112
112
  ```ts
113
113
  registry.emitToUser(userId, 'thread.updated', { id }) // {type:'event', event, data} to that user's sockets
@@ -117,6 +117,33 @@ registry.broadcastData('telemetry', reading) // the bare {type, data} form
117
117
 
118
118
  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
119
 
120
+ ### Topics
121
+
122
+ 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:
123
+
124
+ ```ts
125
+ const methods = {
126
+ 'thing.watch': method({
127
+ auth: 'user',
128
+ args: z.object({ id: z.string() }),
129
+ handler: (ctx, { id }) => {
130
+ topicsOf(ctx).join(`thing:${id}`)
131
+ return things.get(id)
132
+ }
133
+ }),
134
+ 'thing.unwatch': method({
135
+ auth: 'user',
136
+ args: z.object({ id: z.string() }),
137
+ handler: (ctx, { id }) => topicsOf(ctx).leave(`thing:${id}`)
138
+ })
139
+ }
140
+
141
+ registry.publish(`thing:${id}`, 'thing.changed', thing) // {type:'event', event, data} to every socket in the topic
142
+ registry.topics() // the topics with at least one socket in them
143
+ ```
144
+
145
+ A handler joins and leaves for its own caller, through `ctx.topics`; only the transport knows which socket a call came in on. `topicsOf(ctx)` is that, or `WEBSOCKET_REQUIRED` for a call that came over HTTP, where there is no connection to push to. A socket is in a topic until it leaves it or closes, and a handler that joins after its caller has gone joins nothing. A topic is whatever string the application makes it; a reconnecting client is a new socket and joins again, which `liveBinding`'s re-read on reconnect is the natural place to do.
146
+
120
147
  The registry is also an `Audience`: `size` is how many sockets are open, and `onPresence(listener)` calls the listener with `true` as the first one opens and `false` as the last one closes, returning the function that stops it. It is for a producer whose pushes cost something to produce — a reading taken from somewhere slow — and are worth producing only while somebody is connected to be told.
121
148
 
122
149
  ## Error codes
@@ -134,6 +161,7 @@ Codes, not sentences: the client keys on them. A handler's own outcomes are `thr
134
161
  | `UNAUTHENTICATED` | an `auth.token` frame named nobody |
135
162
  | `AUTH_NOT_CONFIGURED` | an auth frame reached a service with no resolver |
136
163
  | `DUPLICATE_REQUEST` | this nonce has already been used |
164
+ | `WEBSOCKET_REQUIRED` | a method that subscribes its caller was called over HTTP (`topicsOf`) |
137
165
  | `INTERNAL_ERROR` | a handler faulted |
138
166
 
139
167
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/rpc-server",
3
- "version": "0.7.0",
3
+ "version": "0.8.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.7.0",
37
+ "@greendrake/rpc": "^0.8.0",
38
38
  "@standard-schema/spec": "^1.1.0"
39
39
  },
40
40
  "devDependencies": {
package/src/http.ts CHANGED
@@ -55,7 +55,9 @@ export const httpHandler = (dispatcher: Dispatcher, options: HttpHandlerOptions
55
55
  await dispatcher.dispatch(parsed, {
56
56
  auth,
57
57
  clientIp: ip,
58
- signal: request.signal
58
+ signal: request.signal,
59
+ // One request, one response, and no connection left to push to.
60
+ topics: null
59
61
  })
60
62
  )
61
63
  }
package/src/main.ts CHANGED
@@ -1,4 +1,4 @@
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'
1
+ export { ANONYMOUS, method, topicsOf, 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 Topics, type WsResponse } from './types'
2
2
  export { ApiError } from './errors'
3
3
  export { unwrapArguments, validateArguments } from './arguments'
4
4
  export { createDispatcher, type Dispatcher, type DispatcherOptions } from './dispatcher'
package/src/registry.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
2
  import { identityCodec } from '@greendrake/rpc'
3
- import type { AuthInfo, Codec, ServerFrame } from './types'
3
+ import type { AuthInfo, Codec, EventFrame, ServerFrame } from './types'
4
4
 
5
5
  // Per-socket state, carried as Bun's websocket `data`.
6
6
  export interface ConnectionData {
@@ -37,10 +37,42 @@ export interface Audience {
37
37
  onPresence(listener: (present: boolean) => void): () => void
38
38
  }
39
39
 
40
+ // One entry of a one-to-many index. A key is present exactly while something is
41
+ // under it, so an index never holds a set for a key with nothing left in it.
42
+ const addTo = <K, V>(index: Map<K, Set<V>>, key: K, value: V): void => {
43
+ const under = index.get(key)
44
+ if (under) {
45
+ under.add(value)
46
+ } else {
47
+ index.set(key, new Set([value]))
48
+ }
49
+ }
50
+
51
+ const removeFrom = <K, V>(index: Map<K, Set<V>>, key: K, value: V): void => {
52
+ const under = index.get(key)
53
+ if (!under) {
54
+ return
55
+ }
56
+ under.delete(value)
57
+ if (under.size === 0) {
58
+ index.delete(key)
59
+ }
60
+ }
61
+
62
+ const eventFrame = (event: string, data: unknown): EventFrame => ({
63
+ type: 'event',
64
+ event,
65
+ data
66
+ })
67
+
40
68
  // Every open socket, and the index from principal to the sockets bound as it —
41
69
  // a user with three tabs has three. Holding all of them as well as the index
42
70
  // is what lets a broadcast reach anonymous connections too, which a per-user
43
71
  // index alone cannot.
72
+ //
73
+ // And the topics sockets have joined: subscriptions a method takes out for its
74
+ // caller (see Topics), published to by the application and dropped with the
75
+ // socket.
44
76
  export class ConnRegistry implements EventSink, Audience {
45
77
  readonly #connections = new Set<Connection>()
46
78
  readonly #presence = new Set<(present: boolean) => void>()
@@ -49,6 +81,10 @@ export class ConnRegistry implements EventSink, Audience {
49
81
  // mid-connection can be re-indexed without the caller tracking what it
50
82
  // used to be.
51
83
  readonly #filedUnder = new Map<Connection, unknown>()
84
+ // A topic's sockets, and a socket's topics: the second so a closing socket
85
+ // leaves every topic it is in without a search through all of them.
86
+ readonly #topicMembers = new Map<string, Set<Connection>>()
87
+ readonly #joined = new Map<Connection, Set<string>>()
52
88
  // Public because the WebSocket handler decodes inbound frames with it:
53
89
  // one codec governs the socket in both directions, and it is configured
54
90
  // here, once.
@@ -75,23 +111,50 @@ export class ConnRegistry implements EventSink, Audience {
75
111
  if (userId === null) {
76
112
  return
77
113
  }
78
- let bound = this.#byUser.get(userId)
79
- if (!bound) {
80
- bound = new Set()
81
- this.#byUser.set(userId, bound)
82
- }
83
- bound.add(conn)
114
+ addTo(this.#byUser, userId, conn)
84
115
  this.#filedUnder.set(conn, userId)
85
116
  }
86
117
 
87
118
  unregister(conn: Connection): void {
88
119
  const leaving = this.#connections.delete(conn)
89
120
  this.#unfile(conn)
121
+ // A copy: leaving a topic takes it out of the set being walked.
122
+ for (const topic of [...(this.#joined.get(conn) ?? [])]) {
123
+ this.leave(conn, topic)
124
+ }
90
125
  if (leaving && this.#connections.size === 0) {
91
126
  this.#presence.forEach(listener => listener(false))
92
127
  }
93
128
  }
94
129
 
130
+ // Puts the socket in the topic, for publish to reach. A socket that has
131
+ // already closed joins nothing: a handler can outlive its caller, and a
132
+ // membership taken after the close would be a dead socket published to for
133
+ // as long as the process runs.
134
+ join(conn: Connection, topic: string): void {
135
+ if (!this.#connections.has(conn)) {
136
+ return
137
+ }
138
+ addTo(this.#topicMembers, topic, conn)
139
+ addTo(this.#joined, conn, topic)
140
+ }
141
+
142
+ leave(conn: Connection, topic: string): void {
143
+ removeFrom(this.#topicMembers, topic, conn)
144
+ removeFrom(this.#joined, conn, topic)
145
+ }
146
+
147
+ // To every socket in the topic, whoever it is bound as — for state only the
148
+ // sockets that asked for it want.
149
+ publish(topic: string, event: string, data: unknown): void {
150
+ this.#topicMembers.get(topic)?.forEach(conn => this.send(conn, eventFrame(event, data)))
151
+ }
152
+
153
+ // The topics with at least one socket in them, as they are now.
154
+ topics(): ReadonlySet<string> {
155
+ return new Set(this.#topicMembers.keys())
156
+ }
157
+
95
158
  onPresence(listener: (present: boolean) => void): () => void {
96
159
  this.#presence.add(listener)
97
160
  return () => {
@@ -105,14 +168,7 @@ export class ConnRegistry implements EventSink, Audience {
105
168
  }
106
169
  const userId = this.#filedUnder.get(conn)
107
170
  this.#filedUnder.delete(conn)
108
- const bound = this.#byUser.get(userId)
109
- if (!bound) {
110
- return
111
- }
112
- bound.delete(conn)
113
- if (bound.size === 0) {
114
- this.#byUser.delete(userId)
115
- }
171
+ removeFrom(this.#byUser, userId, conn)
116
172
  }
117
173
 
118
174
  // The one place a frame becomes bytes: responses and pushes leave a socket
@@ -126,23 +182,13 @@ export class ConnRegistry implements EventSink, Audience {
126
182
  }
127
183
 
128
184
  emitToUser(userId: unknown, event: string, data: unknown): void {
129
- this.sendToUser(userId, {
130
- type: 'event',
131
- event,
132
- data
133
- })
185
+ this.sendToUser(userId, eventFrame(event, data))
134
186
  }
135
187
 
136
188
  // To every open socket, whoever it is bound as — for state the whole
137
189
  // service shares rather than anything addressed to a user.
138
190
  broadcastEvent(event: string, data: unknown): void {
139
- this.#connections.forEach(conn =>
140
- this.send(conn, {
141
- type: 'event',
142
- event,
143
- data
144
- })
145
- )
191
+ this.#connections.forEach(conn => this.send(conn, eventFrame(event, data)))
146
192
  }
147
193
 
148
194
  // The bare `{type, data}` form, for a push whose own type names the
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { StandardSchemaV1 } from '@standard-schema/spec'
2
2
  import type { RpcRequest, RpcResponse } from '@greendrake/rpc'
3
+ import { ApiError } from './errors'
3
4
 
4
5
  // Who is calling, as the application's resolver reports it. `userId` is opaque
5
6
  // — the application's own id type — and null for a caller no credential named,
@@ -38,6 +39,13 @@ export interface NonceStore {
38
39
  // policy; this package only carries the value.
39
40
  export type LocaleResolver = (requested: string | undefined) => string
40
41
 
42
+ // The calling socket's topic memberships: what a method that subscribes its
43
+ // caller to something joins it to, and leaves. See ConnRegistry's topics.
44
+ export interface Topics {
45
+ join(topic: string): void
46
+ leave(topic: string): void
47
+ }
48
+
41
49
  // What the transport knows about a call before it dispatches.
42
50
  export interface CallContext {
43
51
  auth: AuthInfo
@@ -46,6 +54,9 @@ export interface CallContext {
46
54
  // WebSocket closing. Handlers pass it to whatever I/O they do, so work
47
55
  // nobody is waiting for is abandoned rather than run to completion.
48
56
  signal: AbortSignal
57
+ // The caller's socket, for a method that subscribes it. Null for a call
58
+ // that came over HTTP: there is no connection to push to afterwards.
59
+ topics: Topics | null
49
60
  }
50
61
 
51
62
  export interface HandlerContext extends CallContext {
@@ -77,6 +88,16 @@ export interface MethodDef<A = void, R = unknown> {
77
88
  // widens to, so a table mixing schemas and bare methods is one value.
78
89
  export type Methods = Record<string, MethodDef<unknown, unknown>>
79
90
 
91
+ // The caller's socket, for a method that is nothing without one: a call that
92
+ // came over HTTP is refused with WEBSOCKET_REQUIRED rather than subscribed to
93
+ // pushes it has no connection to receive.
94
+ export const topicsOf = (ctx: CallContext): Topics => {
95
+ if (ctx.topics === null) {
96
+ throw new ApiError('WEBSOCKET_REQUIRED')
97
+ }
98
+ return ctx.topics
99
+ }
100
+
80
101
  // Identity at runtime; inference is its whole job. A definition written inline
81
102
  // in a table has nowhere to get its argument type from — the table's own type
82
103
  // erases it — so the schema's output reaches the handler's parameter through
package/src/ws.ts CHANGED
@@ -121,7 +121,11 @@ export const websocketHandler = (dispatcher: Dispatcher, registry: ConnRegistry,
121
121
  dispatcher.dispatch(request, {
122
122
  auth: conn.data.auth,
123
123
  clientIp: conn.data.clientIp,
124
- signal: conn.data.controller.signal
124
+ signal: conn.data.controller.signal,
125
+ topics: {
126
+ join: topic => registry.join(conn, topic),
127
+ leave: topic => registry.leave(conn, topic)
128
+ }
125
129
  })
126
130
  )
127
131
  .then(response => {