@greendrake/rpc-server 0.7.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 +50 -3
- package/package.json +2 -2
- package/src/http.ts +3 -1
- package/src/main.ts +2 -2
- package/src/registry.ts +128 -28
- package/src/types.ts +21 -0
- package/src/ws.ts +12 -3
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
|
|
|
@@ -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
|
|
|
@@ -101,13 +101,15 @@ 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.
|
|
107
109
|
|
|
108
110
|
## Pushing
|
|
109
111
|
|
|
110
|
-
`ConnRegistry` holds every open socket and indexes them by principal:
|
|
112
|
+
`ConnRegistry` holds every open socket and indexes them by principal and by [topic](#topics):
|
|
111
113
|
|
|
112
114
|
```ts
|
|
113
115
|
registry.emitToUser(userId, 'thread.updated', { id }) // {type:'event', event, data} to that user's sockets
|
|
@@ -117,6 +119,50 @@ 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
|
+
|
|
139
|
+
### Topics
|
|
140
|
+
|
|
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:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const methods = {
|
|
145
|
+
'thing.watch': method({
|
|
146
|
+
auth: 'user',
|
|
147
|
+
args: z.object({ id: z.string() }),
|
|
148
|
+
handler: (ctx, { id }) => {
|
|
149
|
+
topicsOf(ctx).join(`thing:${id}`)
|
|
150
|
+
return things.get(id)
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
'thing.unwatch': method({
|
|
154
|
+
auth: 'user',
|
|
155
|
+
args: z.object({ id: z.string() }),
|
|
156
|
+
handler: (ctx, { id }) => topicsOf(ctx).leave(`thing:${id}`)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
registry.publish(`thing:${id}`, 'thing.changed', thing) // {type:'event', event, data} to every socket in the topic
|
|
161
|
+
registry.topics() // the topics with at least one socket in them
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
120
166
|
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
167
|
|
|
122
168
|
## Error codes
|
|
@@ -134,6 +180,7 @@ Codes, not sentences: the client keys on them. A handler's own outcomes are `thr
|
|
|
134
180
|
| `UNAUTHENTICATED` | an `auth.token` frame named nobody |
|
|
135
181
|
| `AUTH_NOT_CONFIGURED` | an auth frame reached a service with no resolver |
|
|
136
182
|
| `DUPLICATE_REQUEST` | this nonce has already been used |
|
|
183
|
+
| `WEBSOCKET_REQUIRED` | a method that subscribes its caller was called over HTTP (`topicsOf`) |
|
|
137
184
|
| `INTERNAL_ERROR` | a handler faulted |
|
|
138
185
|
|
|
139
186
|
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.
|
|
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.
|
|
37
|
+
"@greendrake/rpc": "^0.9.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'
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import type { ServerWebSocket } from 'bun'
|
|
2
|
-
import { identityCodec } from '@greendrake/rpc'
|
|
3
|
-
import type { AuthInfo, Codec, ServerFrame } from './types'
|
|
2
|
+
import { documentOf, identityCodec, mergePatchBetween, type JsonObject } from '@greendrake/rpc'
|
|
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 {
|
|
@@ -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.
|
|
@@ -37,18 +46,64 @@ export interface Audience {
|
|
|
37
46
|
onPresence(listener: (present: boolean) => void): () => void
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
// One entry of a one-to-many index. A key is present exactly while something is
|
|
50
|
+
// under it, so an index never holds a set for a key with nothing left in it.
|
|
51
|
+
const addTo = <K, V>(index: Map<K, Set<V>>, key: K, value: V): void => {
|
|
52
|
+
const under = index.get(key)
|
|
53
|
+
if (under) {
|
|
54
|
+
under.add(value)
|
|
55
|
+
} else {
|
|
56
|
+
index.set(key, new Set([value]))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const removeFrom = <K, V>(index: Map<K, Set<V>>, key: K, value: V): void => {
|
|
61
|
+
const under = index.get(key)
|
|
62
|
+
if (!under) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
under.delete(value)
|
|
66
|
+
if (under.size === 0) {
|
|
67
|
+
index.delete(key)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const eventFrame = (event: string, data: unknown): EventFrame => ({
|
|
72
|
+
type: 'event',
|
|
73
|
+
event,
|
|
74
|
+
data
|
|
75
|
+
})
|
|
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
|
+
|
|
40
84
|
// Every open socket, and the index from principal to the sockets bound as it —
|
|
41
85
|
// a user with three tabs has three. Holding all of them as well as the index
|
|
42
86
|
// is what lets a broadcast reach anonymous connections too, which a per-user
|
|
43
87
|
// index alone cannot.
|
|
88
|
+
//
|
|
89
|
+
// And the topics sockets have joined: subscriptions a method takes out for its
|
|
90
|
+
// caller (see Topics), published to by the application and dropped with the
|
|
91
|
+
// socket.
|
|
92
|
+
//
|
|
93
|
+
// And the documents every socket keeps (see document()).
|
|
44
94
|
export class ConnRegistry implements EventSink, Audience {
|
|
45
95
|
readonly #connections = new Set<Connection>()
|
|
96
|
+
readonly #documents: Published[] = []
|
|
46
97
|
readonly #presence = new Set<(present: boolean) => void>()
|
|
47
98
|
readonly #byUser = new Map<unknown, Set<Connection>>()
|
|
48
99
|
// Where each connection is currently filed, so a principal that changes
|
|
49
100
|
// mid-connection can be re-indexed without the caller tracking what it
|
|
50
101
|
// used to be.
|
|
51
102
|
readonly #filedUnder = new Map<Connection, unknown>()
|
|
103
|
+
// A topic's sockets, and a socket's topics: the second so a closing socket
|
|
104
|
+
// leaves every topic it is in without a search through all of them.
|
|
105
|
+
readonly #topicMembers = new Map<string, Set<Connection>>()
|
|
106
|
+
readonly #joined = new Map<Connection, Set<string>>()
|
|
52
107
|
// Public because the WebSocket handler decodes inbound frames with it:
|
|
53
108
|
// one codec governs the socket in both directions, and it is configured
|
|
54
109
|
// here, once.
|
|
@@ -64,6 +119,9 @@ export class ConnRegistry implements EventSink, Audience {
|
|
|
64
119
|
register(conn: Connection): void {
|
|
65
120
|
const arriving = !this.#connections.has(conn)
|
|
66
121
|
this.#connections.add(conn)
|
|
122
|
+
if (arriving) {
|
|
123
|
+
this.#documents.forEach(published => this.#greet(conn, published))
|
|
124
|
+
}
|
|
67
125
|
if (arriving && this.#connections.size === 1) {
|
|
68
126
|
this.#presence.forEach(listener => listener(true))
|
|
69
127
|
}
|
|
@@ -75,23 +133,50 @@ export class ConnRegistry implements EventSink, Audience {
|
|
|
75
133
|
if (userId === null) {
|
|
76
134
|
return
|
|
77
135
|
}
|
|
78
|
-
|
|
79
|
-
if (!bound) {
|
|
80
|
-
bound = new Set()
|
|
81
|
-
this.#byUser.set(userId, bound)
|
|
82
|
-
}
|
|
83
|
-
bound.add(conn)
|
|
136
|
+
addTo(this.#byUser, userId, conn)
|
|
84
137
|
this.#filedUnder.set(conn, userId)
|
|
85
138
|
}
|
|
86
139
|
|
|
87
140
|
unregister(conn: Connection): void {
|
|
88
141
|
const leaving = this.#connections.delete(conn)
|
|
89
142
|
this.#unfile(conn)
|
|
143
|
+
// A copy: leaving a topic takes it out of the set being walked.
|
|
144
|
+
for (const topic of [...(this.#joined.get(conn) ?? [])]) {
|
|
145
|
+
this.leave(conn, topic)
|
|
146
|
+
}
|
|
90
147
|
if (leaving && this.#connections.size === 0) {
|
|
91
148
|
this.#presence.forEach(listener => listener(false))
|
|
92
149
|
}
|
|
93
150
|
}
|
|
94
151
|
|
|
152
|
+
// Puts the socket in the topic, for publish to reach. A socket that has
|
|
153
|
+
// already closed joins nothing: a handler can outlive its caller, and a
|
|
154
|
+
// membership taken after the close would be a dead socket published to for
|
|
155
|
+
// as long as the process runs.
|
|
156
|
+
join(conn: Connection, topic: string): void {
|
|
157
|
+
if (!this.#connections.has(conn)) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
addTo(this.#topicMembers, topic, conn)
|
|
161
|
+
addTo(this.#joined, conn, topic)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
leave(conn: Connection, topic: string): void {
|
|
165
|
+
removeFrom(this.#topicMembers, topic, conn)
|
|
166
|
+
removeFrom(this.#joined, conn, topic)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// To every socket in the topic, whoever it is bound as — for state only the
|
|
170
|
+
// sockets that asked for it want.
|
|
171
|
+
publish(topic: string, event: string, data: unknown): void {
|
|
172
|
+
this.#topicMembers.get(topic)?.forEach(conn => this.send(conn, eventFrame(event, data)))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The topics with at least one socket in them, as they are now.
|
|
176
|
+
topics(): ReadonlySet<string> {
|
|
177
|
+
return new Set(this.#topicMembers.keys())
|
|
178
|
+
}
|
|
179
|
+
|
|
95
180
|
onPresence(listener: (present: boolean) => void): () => void {
|
|
96
181
|
this.#presence.add(listener)
|
|
97
182
|
return () => {
|
|
@@ -105,14 +190,7 @@ export class ConnRegistry implements EventSink, Audience {
|
|
|
105
190
|
}
|
|
106
191
|
const userId = this.#filedUnder.get(conn)
|
|
107
192
|
this.#filedUnder.delete(conn)
|
|
108
|
-
|
|
109
|
-
if (!bound) {
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
bound.delete(conn)
|
|
113
|
-
if (bound.size === 0) {
|
|
114
|
-
this.#byUser.delete(userId)
|
|
115
|
-
}
|
|
193
|
+
removeFrom(this.#byUser, userId, conn)
|
|
116
194
|
}
|
|
117
195
|
|
|
118
196
|
// The one place a frame becomes bytes: responses and pushes leave a socket
|
|
@@ -126,23 +204,13 @@ export class ConnRegistry implements EventSink, Audience {
|
|
|
126
204
|
}
|
|
127
205
|
|
|
128
206
|
emitToUser(userId: unknown, event: string, data: unknown): void {
|
|
129
|
-
this.sendToUser(userId,
|
|
130
|
-
type: 'event',
|
|
131
|
-
event,
|
|
132
|
-
data
|
|
133
|
-
})
|
|
207
|
+
this.sendToUser(userId, eventFrame(event, data))
|
|
134
208
|
}
|
|
135
209
|
|
|
136
210
|
// To every open socket, whoever it is bound as — for state the whole
|
|
137
211
|
// service shares rather than anything addressed to a user.
|
|
138
212
|
broadcastEvent(event: string, data: unknown): void {
|
|
139
|
-
this.#connections.forEach(conn =>
|
|
140
|
-
this.send(conn, {
|
|
141
|
-
type: 'event',
|
|
142
|
-
event,
|
|
143
|
-
data
|
|
144
|
-
})
|
|
145
|
-
)
|
|
213
|
+
this.#connections.forEach(conn => this.send(conn, eventFrame(event, data)))
|
|
146
214
|
}
|
|
147
215
|
|
|
148
216
|
// The bare `{type, data}` form, for a push whose own type names the
|
|
@@ -151,6 +219,38 @@ export class ConnRegistry implements EventSink, Audience {
|
|
|
151
219
|
this.#connections.forEach(conn => this.send(conn, { type, data }))
|
|
152
220
|
}
|
|
153
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
|
+
|
|
154
254
|
get size(): number {
|
|
155
255
|
return this.#connections.size
|
|
156
256
|
}
|
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
|
@@ -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
|
|
11
|
-
//
|
|
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)
|
|
@@ -121,7 +126,11 @@ export const websocketHandler = (dispatcher: Dispatcher, registry: ConnRegistry,
|
|
|
121
126
|
dispatcher.dispatch(request, {
|
|
122
127
|
auth: conn.data.auth,
|
|
123
128
|
clientIp: conn.data.clientIp,
|
|
124
|
-
signal: conn.data.controller.signal
|
|
129
|
+
signal: conn.data.controller.signal,
|
|
130
|
+
topics: {
|
|
131
|
+
join: topic => registry.join(conn, topic),
|
|
132
|
+
leave: topic => registry.leave(conn, topic)
|
|
133
|
+
}
|
|
125
134
|
})
|
|
126
135
|
)
|
|
127
136
|
.then(response => {
|