@greendrake/rpc-server 0.5.0 → 0.6.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
@@ -117,6 +117,8 @@ 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
+ 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
+
120
122
  ## Error codes
121
123
 
122
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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/rpc-server",
3
- "version": "0.5.0",
3
+ "version": "0.6.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,11 +34,11 @@
34
34
  },
35
35
  "prettier": "@greendrake/dev-config/prettier",
36
36
  "dependencies": {
37
- "@greendrake/rpc": "0.5.0",
37
+ "@greendrake/rpc": "0.6.0",
38
38
  "@standard-schema/spec": "^1.1.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@greendrake/dev-config": "0.5.0",
41
+ "@greendrake/dev-config": "0.6.0",
42
42
  "@types/bun": "^1.3.5",
43
43
  "typescript": "6.0.3"
44
44
  }
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 Connection, type ConnectionData, type EventSink } from './registry'
9
+ export { ConnRegistry, type Audience, type Connection, type ConnectionData, type EventSink } from './registry'
10
10
  export type { Codec, RpcRequest, RpcResponse } from './types'
package/src/registry.ts CHANGED
@@ -26,12 +26,24 @@ export interface EventSink {
26
26
  broadcastEvent(event: string, data: unknown): void
27
27
  }
28
28
 
29
+ // Whether anybody is connected to hear a broadcast, for a producer whose
30
+ // pushes cost something to produce — a reading taken from somewhere slow —
31
+ // and are worth producing only while somebody is there to be told.
32
+ export interface Audience {
33
+ // How many sockets are open.
34
+ readonly size: number
35
+ // Called with true as the first socket opens and false as the last one
36
+ // closes. The function returned stops the calls.
37
+ onPresence(listener: (present: boolean) => void): () => void
38
+ }
39
+
29
40
  // Every open socket, and the index from principal to the sockets bound as it —
30
41
  // a user with three tabs has three. Holding all of them as well as the index
31
42
  // is what lets a broadcast reach anonymous connections too, which a per-user
32
43
  // index alone cannot.
33
- export class ConnRegistry implements EventSink {
44
+ export class ConnRegistry implements EventSink, Audience {
34
45
  readonly #connections = new Set<Connection>()
46
+ readonly #presence = new Set<(present: boolean) => void>()
35
47
  readonly #byUser = new Map<unknown, Set<Connection>>()
36
48
  // Where each connection is currently filed, so a principal that changes
37
49
  // mid-connection can be re-indexed without the caller tracking what it
@@ -50,7 +62,11 @@ export class ConnRegistry implements EventSink {
50
62
  // called when a socket opens and again whenever an auth frame changes who
51
63
  // it is.
52
64
  register(conn: Connection): void {
65
+ const arriving = !this.#connections.has(conn)
53
66
  this.#connections.add(conn)
67
+ if (arriving && this.#connections.size === 1) {
68
+ this.#presence.forEach(listener => listener(true))
69
+ }
54
70
  const userId = conn.data.auth.userId
55
71
  if (this.#filedUnder.get(conn) === userId) {
56
72
  return
@@ -69,8 +85,18 @@ export class ConnRegistry implements EventSink {
69
85
  }
70
86
 
71
87
  unregister(conn: Connection): void {
72
- this.#connections.delete(conn)
88
+ const leaving = this.#connections.delete(conn)
73
89
  this.#unfile(conn)
90
+ if (leaving && this.#connections.size === 0) {
91
+ this.#presence.forEach(listener => listener(false))
92
+ }
93
+ }
94
+
95
+ onPresence(listener: (present: boolean) => void): () => void {
96
+ this.#presence.add(listener)
97
+ return () => {
98
+ this.#presence.delete(listener)
99
+ }
74
100
  }
75
101
 
76
102
  #unfile(conn: Connection): void {