@greendrake/rpc 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 +21 -0
- package/README.md +229 -0
- package/package.json +43 -0
- package/src/ResponseCache.ts +96 -0
- package/src/RpcClient.ts +33 -0
- package/src/WebSocketPipe.ts +191 -0
- package/src/WsTransport.ts +305 -0
- package/src/errors.ts +84 -0
- package/src/http.ts +34 -0
- package/src/liveBinding.ts +117 -0
- package/src/main.ts +9 -0
- package/src/protocol.ts +51 -0
- package/src/types.ts +56 -0
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,229 @@
|
|
|
1
|
+
# @greendrake/rpc
|
|
2
|
+
|
|
3
|
+
Client for the `@greendrake/rpc` wire format, defined below and served by [`@greendrake/rpc-server`](../rpc-server): a typed method-map facade (`RpcClient`), an HTTP transport and a reconnecting WebSocket transport with server push, the error classes both transports map response codes to, a TTL response cache, and a pluggable payload codec. Framework-free; the Vue/Pinia binding (loading state, error UX, connectivity) is `@greendrake/vue-api`, built on this package.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @greendrake/rpc
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
TypeScript source ships as-is (`src/main.ts` is the entry; no build step, no `.d.ts`), for Vite + Vue 3 apps written in TypeScript. The one runtime dependency, `@greendrake/util` (`NetworkError`, `Emitter`, `safeFetch`, `randomString`), installs with it.
|
|
12
|
+
|
|
13
|
+
## The wire
|
|
14
|
+
|
|
15
|
+
This package and `@greendrake/rpc-server` are the two ends of one format; this section defines it. It is deliberately not JSON-RPC. One request envelope, one response envelope, JSON on both transports:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
interface RpcRequest {
|
|
19
|
+
method: string
|
|
20
|
+
nonce?: string // server-side dedup key; omitted on nonce-less calls
|
|
21
|
+
locale?: string // the caller's UI language tag, when a provider is configured
|
|
22
|
+
arguments?: unknown[] // omitted when empty
|
|
23
|
+
[key: string]: unknown // the WebSocket transport adds `id`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RpcResponse {
|
|
27
|
+
success: boolean
|
|
28
|
+
data?: unknown // on success
|
|
29
|
+
error?: string // the error code, on failure
|
|
30
|
+
error_details?: unknown // structured payload attached alongside the code
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The WebSocket transport puts an `id` on both directions for correlation and additionally receives server-push frames, `{ type: 'event', event, data }` and bare `{ type, data }`.
|
|
35
|
+
|
|
36
|
+
`packRequest(method, args, nonce, locale?)` builds the request: a fresh random `nonce` when `nonce` is true, `arguments` only when non-empty, `locale` only when supplied. `settleResponse(response, method)` returns `data` on success or throws the mapped error (see Errors); in a Vite dev build a non-success response is also logged with `console.warn`.
|
|
37
|
+
|
|
38
|
+
## Typed method map
|
|
39
|
+
|
|
40
|
+
Declare the API surface once as method name → call signature; every call site infers argument and result types from it.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { HttpTransport, RpcClient } from '@greendrake/rpc'
|
|
44
|
+
|
|
45
|
+
interface Methods {
|
|
46
|
+
'user.get': (id: string) => User
|
|
47
|
+
'user.update': (args: { id: string; name: string }) => void
|
|
48
|
+
'search.things': (query: { q: string; limit?: number }) => { data: Thing[]; total: number }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const client = new RpcClient<Methods>(new HttpTransport({ url: 'https://api.example.com/v1/api' }))
|
|
52
|
+
|
|
53
|
+
const user = await client.call('user.get', 'U1') // User
|
|
54
|
+
await client.call('user.update', { id: 'U1', name: 'Ann' })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`new RpcClient<M>(transport, locale?)` — `locale` is `() => string | undefined`, evaluated per call, so a language switch applies from the next call. `call(method, ...args)` is the one way to invoke a method, and every request it sends carries a nonce. `MethodMap` and `MethodMapConstraint<M>` are the constraint types; a plain interface qualifies.
|
|
58
|
+
|
|
59
|
+
`Transport` is one method, `send(request: RpcRequest): Promise<unknown>`, resolving to the response `data` or rejecting with a mapped error. Anything implementing it plugs into the client.
|
|
60
|
+
|
|
61
|
+
## HTTP transport
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { HttpTransport } from '@greendrake/rpc'
|
|
65
|
+
|
|
66
|
+
const transport = new HttpTransport({
|
|
67
|
+
url: 'https://api.example.com/v1/api',
|
|
68
|
+
headers: () => ({ authorization: `Bearer ${session.token}` }),
|
|
69
|
+
requestInit: { keepalive: true }
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`HttpTransportOptions`:
|
|
74
|
+
|
|
75
|
+
- `url` — POSTed to for every call.
|
|
76
|
+
- `headers?: HeadersProvider` — `() => Record<string, string>`, evaluated per request so time-variant values (tokens) are always fresh.
|
|
77
|
+
- `codec?: Codec` — applied to the outbound body and the inbound response text (see Codec).
|
|
78
|
+
- `requestInit?: Partial<RequestInit>` — merged into every `fetch` call.
|
|
79
|
+
|
|
80
|
+
A `fetch` rejection (DNS, refused connection, offline, CORS) surfaces as `@greendrake/util`'s `NetworkError`.
|
|
81
|
+
|
|
82
|
+
## WebSocket transport
|
|
83
|
+
|
|
84
|
+
Two layers. `WebSocketPipe` owns the connection lifecycle and raw string frames; `WsTransport` speaks the wire format over it: id-correlated request/response, typed push subscription, and an injected auth handshake re-applied on every reconnect.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { WebSocketPipe, WsTransport, type WsAuthStrategy } from '@greendrake/rpc'
|
|
88
|
+
|
|
89
|
+
interface Pushes {
|
|
90
|
+
'thread.message': { thread_id: string; body: string }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const auth: WsAuthStrategy = {
|
|
94
|
+
authenticate: (send, token) => send({ method: 'auth.token', arguments: [token] }),
|
|
95
|
+
deauthenticate: send => send({ method: 'auth.logout' })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const transport = new WsTransport<Pushes>({
|
|
99
|
+
pipe: new WebSocketPipe({ url: 'wss://api.example.com/v1/ws' }),
|
|
100
|
+
auth
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const off = transport.onEvent('thread.message', data => {
|
|
104
|
+
// data: Pushes['thread.message']
|
|
105
|
+
})
|
|
106
|
+
await transport.authenticate(session.token)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### WebSocketPipe
|
|
110
|
+
|
|
111
|
+
`WebSocketPipeOptions`:
|
|
112
|
+
|
|
113
|
+
- `url`
|
|
114
|
+
- `queryAuth?: { param: string; token: string }` — a connection-level credential appended to the URL as a query parameter and resolved by the server at the handshake. Because it rides the URL, reconnects re-authenticate by themselves. For per-session, in-band auth use `WsTransport`'s `auth` instead.
|
|
115
|
+
- `reconnectDelayMs?: (attempt: number) => number` — delay before reconnect attempt N (0-based, reset on a successful open). Default: a constant 3000 ms.
|
|
116
|
+
|
|
117
|
+
The first attempt starts on `ensureConnected()`, which is also the eager-connect entry point. Every close — including an attempt that never opened — arms the next attempt behind its delay, until `dispose()`. `ensureConnected()` resolves when the current-or-next attempt opens and rejects with that attempt's `WebSocketClosedError` if it fails; a caller arriving after a failure awaits the upcoming attempt.
|
|
118
|
+
|
|
119
|
+
Members:
|
|
120
|
+
|
|
121
|
+
- `connected` — the socket is `OPEN`.
|
|
122
|
+
- `send(raw)` — throws unless open.
|
|
123
|
+
- `reconnectNow()` — collapses a waiting reconnect's remaining delay and attempts immediately, for a signal that connectivity is likely back (the `online` event, an app resuming from background). A no-op unless a reconnect is actually waiting.
|
|
124
|
+
- `drop()` — abandons a socket whose peer is gone without a close frame: fails its in-flight calls and arms the reconnect immediately, instead of waiting out the browser's own CLOSING timeout. Not terminal.
|
|
125
|
+
- `dispose()` — closes with code 1000 and stops reconnecting for good.
|
|
126
|
+
|
|
127
|
+
The pipe is an `Emitter<WebSocketPipeEvents>` from `@greendrake/util`: `on('open')`, `on('close', (error: WebSocketClosedError) => …)`, `on('message', (raw: string) => …)`; `on` returns the unsubscribe function.
|
|
128
|
+
|
|
129
|
+
### WsTransport
|
|
130
|
+
|
|
131
|
+
`WsTransportOptions`: `pipe`, `codec?` (see Codec), `auth?: WsAuthStrategy`.
|
|
132
|
+
|
|
133
|
+
`WsTransport<PEvents, PData>`: `PEvents` types the `{ type: 'event', event, data }` frames (`onEvent`), `PData` the bare `{ type, data }` frames (`onData`). Both default to an empty map, so a push surface has to be declared to subscribe. `PushMap` and `PushMapConstraint<P>` are the constraint types.
|
|
134
|
+
|
|
135
|
+
- `send(request)` — awaits the connection, writes the frame with a generated `id`, resolves with the correlated response's `data`. Calls in flight when the socket closes reject with a `WebSocketClosedError` flagged `inFlight`.
|
|
136
|
+
- `onEvent(event, handler)` / `onData(type, handler)` — return the unsubscribe function.
|
|
137
|
+
- `authenticate(token)` — records the credential as this transport's identity and runs `auth.authenticate(send, token)` on the live socket; resolves once the server has accepted it, rejects on a refusal. The identity is re-applied on every reconnect, whether or not an earlier bind succeeded. Repeating it with the credential the live socket is already bound with joins that bind rather than sending a second frame.
|
|
138
|
+
- `deauthenticate()` — forgets the identity and runs `auth.deauthenticate(send)`.
|
|
139
|
+
- `connect()` — `pipe.ensureConnected()`.
|
|
140
|
+
- `connected` — whether calls can currently succeed: an open socket whose identity bind has settled, not merely an open one.
|
|
141
|
+
- `onConnectionChange(listener)` — one call per underlying transition, deliberately not deduplicated: consecutive `false` reports are consecutive failed attempts, which is how a consumer tells a blip from an outage. Returns the unsubscribe function.
|
|
142
|
+
- `probe(method, deadlineMs = 5000)` — liveness check for a half-open socket (a backgrounded mobile app, an expired NAT mapping): sends `method` and, if nothing at all arrives on the socket before the deadline, drops it and reconnects at once. Resolves `true` when the socket is alive (any answer counts, a refusal included), `false` when it was dropped or was already down.
|
|
143
|
+
|
|
144
|
+
`WsAuthStrategy` is `{ authenticate(send, token), deauthenticate(send) }`, with `send: WsSend = (payload: RpcRequest) => Promise<unknown>`. Which methods make up the handshake is application protocol; the transport owns only the lifecycle.
|
|
145
|
+
|
|
146
|
+
## Live bindings
|
|
147
|
+
|
|
148
|
+
`liveBinding(transport, options)` binds a view, a store or a cache to the pushes that say it changed, and returns the release function.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { liveBinding } from '@greendrake/rpc'
|
|
152
|
+
|
|
153
|
+
const release = liveBinding(transport, {
|
|
154
|
+
events: {
|
|
155
|
+
// Folded in locally: the push carries what changed.
|
|
156
|
+
'thing.changed': { when: data => data.id === shownId, apply: data => Object.assign(shown, data.thing) },
|
|
157
|
+
// No apply: the push is a signal, so it is answered by re-reading.
|
|
158
|
+
'thing.moderated': { when: data => data.id === shownId }
|
|
159
|
+
},
|
|
160
|
+
resync: async () => {
|
|
161
|
+
shown = await readThing(shownId)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A list panel binds the same way, with an unfiltered handler and its own re-read:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const release = liveBinding(transport, {
|
|
170
|
+
events: { 'row.changed': {} },
|
|
171
|
+
resync: () => table.refresh()
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The contract:
|
|
176
|
+
|
|
177
|
+
- **Relevance.** `when` decides whether a push concerns what the binding shows; omitted, every push does.
|
|
178
|
+
- **Apply or resync.** `apply` folds the push in and nothing else happens. Without it the push runs `resync` — so `{}` means "every such push re-reads". A handler with neither `apply` nor a `resync` to fall back on throws at binding time.
|
|
179
|
+
- **Reconnect.** `resync` also runs when the socket returns from a drop the binding witnessed: a connection has no memory, so what it missed is only recoverable by asking. The first connect after the binding is created is not a return from a drop. A binding without `resync` subscribes to nothing connection-related.
|
|
180
|
+
- **Coalescing.** One `resync` at a time; pushes arriving during a run collapse into a single follow-up after it, however many there are — they ask the same question.
|
|
181
|
+
- **No error policy.** The binding awaits `resync` only to know when the slot is free, and interprets neither outcome: `resync` owns its errors. It must also not be served by a response cache the missed pushes would have invalidated — invalidate the entries its read would hit, then read. (`@greendrake/vue-api`'s `bindLive` carries both rules for app code.)
|
|
182
|
+
- **Release.** The returned function detaches every subscription and drops any pending follow-up; calling it twice is harmless.
|
|
183
|
+
|
|
184
|
+
## Errors
|
|
185
|
+
|
|
186
|
+
- `ApiError` — a business error from the server: `message` is the error code, `details` mirrors `error_details`.
|
|
187
|
+
- `NotFoundError` — extends `OneOutcomeError`. `message` is `'Not found'`; `code` holds the server code it was made from, absent when the app raises one itself.
|
|
188
|
+
- `OneOutcomeError` — base class for errors whose only sensible UX is a single acknowledgement, no retry. Pure data; presentation belongs to the UI layer.
|
|
189
|
+
- `WebSocketClosedError` — extends `@greendrake/util`'s `NetworkError`. Carries `code`, `reason` and `wasClean` from the `CloseEvent`; `inFlight` (whether the failed request was already on the wire — one that never left is safe to re-issue, one in flight may have executed server-side with only its response lost); `isAbnormal` (anything but a clean 1000/1001 close); `asInFlight()`.
|
|
190
|
+
|
|
191
|
+
Mapping rule, `makeError(code, details?)`, shared by both transports: the code `notfound` and any code ending in `_NOT_FOUND` become a `NotFoundError`; every other code becomes an `ApiError` carrying `details`. Transport failures — a `fetch` rejection, a socket closing under a call — are `NetworkError`s, so one `instanceof NetworkError` check classifies all of them.
|
|
192
|
+
|
|
193
|
+
`codeOf(e)` returns the server code wherever the class keeps it (`ApiError.message`, `NotFoundError.code`) and the message for anything else. Key on it rather than on `message`, or every `*_NOT_FOUND` is missed.
|
|
194
|
+
|
|
195
|
+
## Response cache
|
|
196
|
+
|
|
197
|
+
`ResponseCache` is an in-memory TTL cache keyed by method + args. Only for stateless, platform-static reads — never per-user data that changes within a session.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { ResponseCache } from '@greendrake/rpc'
|
|
201
|
+
|
|
202
|
+
const cache = new ResponseCache({
|
|
203
|
+
ttl: 300, // seconds, for endpoints listed as `true`
|
|
204
|
+
endpoints: {
|
|
205
|
+
'taxonomy.tree': true,
|
|
206
|
+
'search.things': 60 // per-endpoint TTL in seconds
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
cache.get('taxonomy.tree', [{ taxonomy: 'category' }]) // { hit: boolean; data?: unknown }
|
|
211
|
+
cache.set('taxonomy.tree', [{ taxonomy: 'category' }], tree) // no-op for an unlisted method
|
|
212
|
+
cache.invalidate('search.things') // every entry for the method
|
|
213
|
+
cache.invalidate('search.things', { q: 'x' }) // that args-key only
|
|
214
|
+
cache.prime('taxonomy.tree', [{ taxonomy: 'category' }], pushed) // seed from a server push
|
|
215
|
+
cache.clear() // everything, e.g. when the session identity changes
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`ttlFor(method)` returns the seconds for a listed method, `false` otherwise. `CacheConfig` and `CacheLookup` are the config and `get` result types. `RpcClient` holds no cache; `@greendrake/vue-api`'s `ApiClient` composes one and derives its nonce rule from the same endpoint list.
|
|
219
|
+
|
|
220
|
+
## Codec
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
interface Codec {
|
|
224
|
+
encode(s: string): string
|
|
225
|
+
decode(s: string): string
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Both transports run `encode` over the serialised request and `decode` over the raw response or frame text before parsing. `identityCodec`, the default, passes text through unchanged. Supply a custom codec through the `codec` option of either transport where the wire is obscured or encrypted.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@greendrake/rpc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RPC client over JSON: typed method-map API, HTTP and reconnecting WebSocket transports, pluggable codec and headers, TTL response cache",
|
|
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"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/greendrake/lib/tree/main/rpc#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/util": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@greendrake/dev-config": "0.1.0",
|
|
41
|
+
"typescript": "6.0.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface CacheConfig {
|
|
2
|
+
// Default TTL in seconds for endpoints enabled with `true`.
|
|
3
|
+
ttl: number
|
|
4
|
+
// Cached endpoints: `true` = default TTL, number = per-endpoint TTL seconds.
|
|
5
|
+
endpoints: Record<string, true | number>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface CacheEntry {
|
|
9
|
+
data: unknown
|
|
10
|
+
expires: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CacheLookup {
|
|
14
|
+
hit: boolean
|
|
15
|
+
data?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// In-memory TTL response cache keyed by scope + method + args. Only for
|
|
19
|
+
// stateless, platform-static reads — never per-user data that mutates
|
|
20
|
+
// in-session.
|
|
21
|
+
export class ResponseCache {
|
|
22
|
+
readonly #config: CacheConfig
|
|
23
|
+
readonly #scope?: () => string | undefined
|
|
24
|
+
readonly #entries = new Map<string, CacheEntry>()
|
|
25
|
+
|
|
26
|
+
// `scope` names the request property that is not an argument yet decides
|
|
27
|
+
// what comes back — the caller's UI language, which every server-resolved
|
|
28
|
+
// label in a response is written in. Answers from different scopes are
|
|
29
|
+
// different answers and are kept apart, so switching back to a language
|
|
30
|
+
// already read costs no round trip.
|
|
31
|
+
constructor(config: CacheConfig, scope?: () => string | undefined) {
|
|
32
|
+
this.#config = config
|
|
33
|
+
this.#scope = scope
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Seconds for a cached endpoint, false for an uncached one.
|
|
37
|
+
ttlFor(method: string): number | false {
|
|
38
|
+
const val = this.#config.endpoints[method]
|
|
39
|
+
if (val === undefined) return false
|
|
40
|
+
return val === true ? this.#config.ttl : val
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#key(method: string, args: unknown[]): string {
|
|
44
|
+
return JSON.stringify([this.#scope?.() ?? null, method, ...args])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(method: string, args: unknown[]): CacheLookup {
|
|
48
|
+
const entry = this.#entries.get(this.#key(method, args))
|
|
49
|
+
if (entry && Date.now() < entry.expires) {
|
|
50
|
+
return { hit: true, data: entry.data }
|
|
51
|
+
}
|
|
52
|
+
return { hit: false }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Store a response for a cached endpoint; no-op for uncached methods so
|
|
56
|
+
// callers can set unconditionally after a read.
|
|
57
|
+
set(method: string, args: unknown[], data: unknown): void {
|
|
58
|
+
const ttl = this.ttlFor(method)
|
|
59
|
+
if (ttl === false) return
|
|
60
|
+
this.#entries.set(this.#key(method, args), { data, expires: Date.now() + ttl * 1000 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Drop cached responses for an endpoint. Called after a mutation so the
|
|
64
|
+
// next read reflects the change rather than a snapshot still inside its
|
|
65
|
+
// TTL. With args, only the entry for that exact args-key is dropped;
|
|
66
|
+
// without args, every entry for the method is dropped (a whole query
|
|
67
|
+
// family). Either way in every scope: what a mutation changed is the data,
|
|
68
|
+
// not the language it would be read back in.
|
|
69
|
+
invalidate(method: string, ...args: unknown[]): void {
|
|
70
|
+
const wanted = args.length ? JSON.stringify(args) : null
|
|
71
|
+
for (const key of this.#entries.keys()) {
|
|
72
|
+
const parts = JSON.parse(key) as unknown[]
|
|
73
|
+
if (parts[1] !== method) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (wanted === null || JSON.stringify(parts.slice(2)) === wanted) {
|
|
77
|
+
this.#entries.delete(key)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Drop everything. For the change that invalidates the whole cache at once
|
|
83
|
+
// rather than one endpoint's worth: the session identity, which every
|
|
84
|
+
// audience-dependent read here was answered under.
|
|
85
|
+
clear(): void {
|
|
86
|
+
this.#entries.clear()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Seed the cache with a known-fresh response — a server push already
|
|
90
|
+
// carrying the full DTO a read would return — so the next read serves it
|
|
91
|
+
// without a round-trip. `args` must match the read's args so the same key
|
|
92
|
+
// is hit.
|
|
93
|
+
prime(method: string, args: unknown[], data: unknown): void {
|
|
94
|
+
this.set(method, args, data)
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/RpcClient.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MethodMap, MethodMapConstraint, Transport } from './types'
|
|
2
|
+
import { packRequest } from './protocol'
|
|
3
|
+
|
|
4
|
+
// Typed facade over a transport: methods come from the app's declared
|
|
5
|
+
// MethodMap, so arguments and results are fully inferred — no bracket-notation
|
|
6
|
+
// proxies, no casts at call sites.
|
|
7
|
+
// const client = new RpcClient<Methods>(transport)
|
|
8
|
+
// const user = await client.call('user.get', id)
|
|
9
|
+
export class RpcClient<M extends MethodMapConstraint<M> = MethodMap> {
|
|
10
|
+
readonly transport: Transport
|
|
11
|
+
// Supplies the caller's UI language tag per request. Evaluated per call,
|
|
12
|
+
// like HttpTransport's headers provider, so a locale switch takes effect on
|
|
13
|
+
// the next call rather than at construction.
|
|
14
|
+
readonly #locale?: () => string | undefined
|
|
15
|
+
|
|
16
|
+
constructor(transport: Transport, locale?: () => string | undefined) {
|
|
17
|
+
this.transport = transport
|
|
18
|
+
this.#locale = locale
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The one way to invoke a method; every request carries a nonce for
|
|
22
|
+
// server-side dedup. Omitting the nonce is only worth doing where a
|
|
23
|
+
// response cache needs byte-identical requests to key on, and this client
|
|
24
|
+
// holds no cache — @greendrake/vue-api's ApiClient owns that rule, derived
|
|
25
|
+
// from the single list of cacheable methods it is configured with.
|
|
26
|
+
call<K extends keyof M & string>(method: K, ...args: Parameters<M[K]>): Promise<Awaited<ReturnType<M[K]>>> {
|
|
27
|
+
return this.#invoke(true, method, args)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async #invoke<K extends keyof M & string>(nonce: boolean, method: K, args: unknown[]): Promise<Awaited<ReturnType<M[K]>>> {
|
|
31
|
+
return (await this.transport.send(packRequest(method, args, nonce, this.#locale?.()))) as Awaited<ReturnType<M[K]>>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Emitter } from '@greendrake/util'
|
|
2
|
+
import { WebSocketClosedError } from './errors'
|
|
3
|
+
|
|
4
|
+
export interface WebSocketPipeOptions {
|
|
5
|
+
url: string
|
|
6
|
+
// Connection-level credential appended to the URL as a query parameter and
|
|
7
|
+
// resolved by the server at the handshake (before the first frame). Because
|
|
8
|
+
// the token rides the URL, reconnects re-authenticate automatically. For
|
|
9
|
+
// in-band per-session auth use WsTransport's auth strategy instead.
|
|
10
|
+
queryAuth?: {
|
|
11
|
+
param: string
|
|
12
|
+
token: string
|
|
13
|
+
}
|
|
14
|
+
// Delay before reconnect attempt N (0-based, reset on successful open).
|
|
15
|
+
reconnectDelayMs?: (attempt: number) => number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WebSocketPipeEvents {
|
|
19
|
+
open: []
|
|
20
|
+
close: [error: WebSocketClosedError]
|
|
21
|
+
message: [raw: string]
|
|
22
|
+
[key: string]: unknown[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_RECONNECT_DELAY_MS = 3000
|
|
26
|
+
|
|
27
|
+
// Reconnecting WebSocket carrier. Owns only the connection lifecycle and raw
|
|
28
|
+
// string frames — request framing, codecs and push-envelope parsing live in
|
|
29
|
+
// WsTransport, above the wire.
|
|
30
|
+
export class WebSocketPipe extends Emitter<WebSocketPipeEvents> {
|
|
31
|
+
readonly #url: string
|
|
32
|
+
readonly #delayMs: (attempt: number) => number
|
|
33
|
+
#ws?: WebSocket
|
|
34
|
+
#promise?: Promise<void>
|
|
35
|
+
// A reconnect waiting out its delay: the timer to cancel, and the resolver
|
|
36
|
+
// that advances the armed promise into the attempt. Set only while
|
|
37
|
+
// waiting — cleared the moment the attempt starts — so its presence is
|
|
38
|
+
// exactly the condition reconnectNow() acts on.
|
|
39
|
+
#scheduledReconnect?: { timer: ReturnType<typeof setTimeout>; start: () => void }
|
|
40
|
+
#attempt = 0
|
|
41
|
+
#disposed = false
|
|
42
|
+
|
|
43
|
+
constructor(options: WebSocketPipeOptions) {
|
|
44
|
+
super()
|
|
45
|
+
const { url, queryAuth } = options
|
|
46
|
+
this.#url = queryAuth ? `${url}${url.includes('?') ? '&' : '?'}${queryAuth.param}=${encodeURIComponent(queryAuth.token)}` : url
|
|
47
|
+
this.#delayMs = options.reconnectDelayMs ?? (() => DEFAULT_RECONNECT_DELAY_MS)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// `#promise` always tracks the current-or-next connection attempt: armed
|
|
51
|
+
// when an attempt starts, re-armed on close (pending through the reconnect
|
|
52
|
+
// delay). Awaiters of a failed attempt get its rejection (err hard), while
|
|
53
|
+
// callers arriving after the failure await the upcoming attempt instead of
|
|
54
|
+
// a stale settled promise.
|
|
55
|
+
#arm(promise: Promise<void>): Promise<void> {
|
|
56
|
+
this.#promise = promise
|
|
57
|
+
// Background attempts (auto-reconnects) can reject with no awaiter;
|
|
58
|
+
// mark the rejection handled so it doesn't surface as
|
|
59
|
+
// unhandledrejection noise. ensureConnected() callers still receive it.
|
|
60
|
+
promise.catch(() => {})
|
|
61
|
+
return promise
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#connect(): Promise<void> {
|
|
65
|
+
const ws = new WebSocket(this.#url)
|
|
66
|
+
this.#ws = ws
|
|
67
|
+
ws.addEventListener('message', event => {
|
|
68
|
+
// A dropped socket may still deliver: it is disowned, not silenced.
|
|
69
|
+
if (this.#ws === ws && typeof event.data === 'string') {
|
|
70
|
+
this.emit('message', event.data)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
return this.#arm(
|
|
74
|
+
new Promise<void>((resolve, reject) => {
|
|
75
|
+
let opened = false
|
|
76
|
+
ws.addEventListener('open', () => {
|
|
77
|
+
opened = true
|
|
78
|
+
this.#attempt = 0
|
|
79
|
+
this.emit('open')
|
|
80
|
+
resolve()
|
|
81
|
+
})
|
|
82
|
+
// The 'close' event (always emitted, and the only one carrying
|
|
83
|
+
// a code/reason — 'error' carries neither) drives three things:
|
|
84
|
+
// notify listeners so in-flight calls fail instead of hanging;
|
|
85
|
+
// reject the connect attempt if it never opened; and arm the
|
|
86
|
+
// reconnect.
|
|
87
|
+
ws.addEventListener('close', event => {
|
|
88
|
+
// drop() disowns the socket before abandoning it, and this
|
|
89
|
+
// fires later on the zombie: everything below already ran.
|
|
90
|
+
if (this.#ws !== ws) {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const closed = new WebSocketClosedError(event)
|
|
94
|
+
this.#reportClosed(closed, opened ? undefined : reject)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// What a close means, wherever it comes from: the socket is let go, its
|
|
101
|
+
// in-flight calls fail instead of hanging, a never-opened attempt rejects,
|
|
102
|
+
// and the next attempt is armed behind its delay. Called by the socket's
|
|
103
|
+
// own close event, and by drop() for a socket whose peer will never send
|
|
104
|
+
// one. Letting go here — rather than in each caller — is what makes "no
|
|
105
|
+
// socket" mean the same thing everywhere: a closed socket left in #ws would
|
|
106
|
+
// let a later drop() run this a second time, arming a second reconnect over
|
|
107
|
+
// the first and orphaning whichever attempt loses.
|
|
108
|
+
#reportClosed(closed: WebSocketClosedError, rejectAttempt?: (e: unknown) => void): void {
|
|
109
|
+
this.#ws = undefined
|
|
110
|
+
this.emit('close', closed)
|
|
111
|
+
rejectAttempt?.(closed)
|
|
112
|
+
if (this.#disposed) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const delay = this.#delayMs(this.#attempt++)
|
|
116
|
+
this.#arm(
|
|
117
|
+
new Promise<void>(res => {
|
|
118
|
+
const start = (): void => {
|
|
119
|
+
this.#scheduledReconnect = undefined
|
|
120
|
+
res()
|
|
121
|
+
}
|
|
122
|
+
this.#scheduledReconnect = { timer: setTimeout(start, delay), start }
|
|
123
|
+
}).then(() => this.#connect())
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Abandon a socket that cannot be trusted to close itself — one whose peer
|
|
128
|
+
// is gone without a FIN, so nothing has failed and nothing will. Closing it
|
|
129
|
+
// and waiting is not enough: the closing handshake has no one to answer it,
|
|
130
|
+
// so the browser holds the socket in CLOSING until its own timeout, and the
|
|
131
|
+
// close path — which is what fails in-flight calls and arms the reconnect —
|
|
132
|
+
// would not run until then. So the close path runs here and now — disowning
|
|
133
|
+
// the socket, which makes its later events no-ops (the handlers registered
|
|
134
|
+
// in #connect check that #ws is still theirs) — and the zombie is closed
|
|
135
|
+
// without being waited for. Unlike dispose() this is not terminal:
|
|
136
|
+
// reconnection continues. A no-op when there is no socket to abandon.
|
|
137
|
+
drop(): void {
|
|
138
|
+
const ws = this.#ws
|
|
139
|
+
if (!ws) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
this.#reportClosed(new WebSocketClosedError(new CloseEvent('close', { code: 1006, reason: 'dropped' })))
|
|
143
|
+
ws.close()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get connected(): boolean {
|
|
147
|
+
return this.#ws?.readyState === WebSocket.OPEN
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Collapse a reconnect's remaining delay and attempt it now — for a signal
|
|
151
|
+
// that connectivity is likely back (the device's online event, a native
|
|
152
|
+
// app resuming from background) which would otherwise be waited out. A
|
|
153
|
+
// no-op unless a reconnect is actually waiting: an attempt already in
|
|
154
|
+
// flight, an open socket, and a disposed pipe all have nothing to bring
|
|
155
|
+
// forward. The armed promise is resolved through its own path, just
|
|
156
|
+
// earlier, so awaiters are unaffected.
|
|
157
|
+
reconnectNow(): void {
|
|
158
|
+
const scheduled = this.#scheduledReconnect
|
|
159
|
+
if (!scheduled) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
clearTimeout(scheduled.timer)
|
|
163
|
+
scheduled.start()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Resolves when the current-or-next connection attempt opens. Starts the
|
|
167
|
+
// first attempt if none has been made — also the eager-connect entry point.
|
|
168
|
+
ensureConnected(): Promise<void> {
|
|
169
|
+
if (this.#disposed) {
|
|
170
|
+
throw new Error('WebSocketPipe disposed')
|
|
171
|
+
}
|
|
172
|
+
return this.#promise ?? this.#connect()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
send(raw: string): void {
|
|
176
|
+
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
|
|
177
|
+
throw new Error('WebSocket not open — await ensureConnected() first')
|
|
178
|
+
}
|
|
179
|
+
this.#ws.send(raw)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Deliberate teardown: closes cleanly and stops reconnecting for good.
|
|
183
|
+
dispose(): void {
|
|
184
|
+
this.#disposed = true
|
|
185
|
+
if (this.#scheduledReconnect) {
|
|
186
|
+
clearTimeout(this.#scheduledReconnect.timer)
|
|
187
|
+
this.#scheduledReconnect = undefined
|
|
188
|
+
}
|
|
189
|
+
this.#ws?.close(1000)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { randomString } from '@greendrake/util'
|
|
2
|
+
import { identityCodec } from './types'
|
|
3
|
+
import type { Codec, RpcRequest, RpcResponse, Transport } from './types'
|
|
4
|
+
import { settleResponse } from './protocol'
|
|
5
|
+
import type { WebSocketPipe } from './WebSocketPipe'
|
|
6
|
+
|
|
7
|
+
export type WsSend = (payload: RpcRequest) => Promise<unknown>
|
|
8
|
+
|
|
9
|
+
// The over-the-wire authentication handshake (which methods to call, the
|
|
10
|
+
// argument shape) is application protocol, not transport, so it is injected
|
|
11
|
+
// rather than hardcoded here. The transport owns only the generic lifecycle:
|
|
12
|
+
// caching the credential and re-applying it whenever the socket (re)connects.
|
|
13
|
+
// Apps with no per-session auth simply omit the strategy (or use the pipe's
|
|
14
|
+
// queryAuth for connection-level credentials).
|
|
15
|
+
export interface WsAuthStrategy {
|
|
16
|
+
authenticate(send: WsSend, token: string): Promise<unknown>
|
|
17
|
+
deauthenticate(send: WsSend): Promise<unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// App-declared server-push surface: name → payload type.
|
|
21
|
+
// interface Pushes { 'threads.updated': ThreadsPatch }
|
|
22
|
+
export type PushMap = Record<string, unknown>
|
|
23
|
+
|
|
24
|
+
export type PushMapConstraint<P> = Record<keyof P, unknown>
|
|
25
|
+
|
|
26
|
+
interface WsResponseFrame extends RpcResponse {
|
|
27
|
+
id: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PushFrame {
|
|
31
|
+
type: string
|
|
32
|
+
event?: string
|
|
33
|
+
data?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WsTransportOptions {
|
|
37
|
+
pipe: WebSocketPipe
|
|
38
|
+
codec?: Codec
|
|
39
|
+
auth?: WsAuthStrategy
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// How long a liveness probe waits for an answer before calling the socket dead.
|
|
43
|
+
// A deadline on a network response, not a wait for something to happen: the
|
|
44
|
+
// probe exists precisely because a half-open socket produces no event to wait
|
|
45
|
+
// for.
|
|
46
|
+
const DEFAULT_PROBE_DEADLINE_MS = 5000
|
|
47
|
+
|
|
48
|
+
interface PendingCall {
|
|
49
|
+
settle(frame: WsResponseFrame): void
|
|
50
|
+
fail(error: Error): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The wire format over a WebSocketPipe: id-correlated request/response, typed
|
|
54
|
+
// server-push subscription, injected auth re-applied on every (re)connect.
|
|
55
|
+
// PEvents types `{type:'event', event, data}` frames; PData types the bare
|
|
56
|
+
// `{type, data}` frames some backends push. Both default to uncallable-empty —
|
|
57
|
+
// an app must declare its push surface to subscribe.
|
|
58
|
+
export class WsTransport<PEvents extends PushMapConstraint<PEvents> = Record<never, never>, PData extends PushMapConstraint<PData> = Record<never, never>> implements Transport {
|
|
59
|
+
readonly pipe: WebSocketPipe
|
|
60
|
+
readonly #codec: Codec
|
|
61
|
+
readonly #auth?: WsAuthStrategy
|
|
62
|
+
// Who this transport is to be bound as — cached the moment the credential
|
|
63
|
+
// is handed over, not once a bind succeeds. It states what every
|
|
64
|
+
// connection of this transport should authenticate as, independently of
|
|
65
|
+
// whether any single attempt got through, so a refused or interrupted bind
|
|
66
|
+
// still re-applies on the next reconnect.
|
|
67
|
+
#token: string | null = null
|
|
68
|
+
// The bind issued for the live socket: the credential it carries and its
|
|
69
|
+
// outcome. Dropped when that socket goes (or the identity is released), so
|
|
70
|
+
// each connection binds exactly once — authenticate() and the reconnect
|
|
71
|
+
// handler converge on this record rather than both putting an auth frame
|
|
72
|
+
// on the same socket.
|
|
73
|
+
#binding?: { token: string; done: Promise<unknown> }
|
|
74
|
+
// In-flight calls keyed by request id. A dropped socket never delivers
|
|
75
|
+
// their responses, so they are failed explicitly on close.
|
|
76
|
+
readonly #pending = new Map<string, PendingCall>()
|
|
77
|
+
readonly #eventHandlers = new Map<string, Set<(data: never) => void>>()
|
|
78
|
+
readonly #dataHandlers = new Map<string, Set<(data: never) => void>>()
|
|
79
|
+
// Whether calls can currently succeed — an open socket that has finished
|
|
80
|
+
// binding its identity, not merely an open one. See the 'open' handler.
|
|
81
|
+
#ready = false
|
|
82
|
+
// Bumped on every open and every close, so work deferred across one of
|
|
83
|
+
// those events can tell whether the connection it belongs to is still the
|
|
84
|
+
// live one.
|
|
85
|
+
#connectionEpoch = 0
|
|
86
|
+
// Bumped on every frame the socket delivers, whatever it carries: the one
|
|
87
|
+
// piece of evidence that distinguishes a live connection from a half-open
|
|
88
|
+
// one. Read by probe() across its deadline.
|
|
89
|
+
#framesReceived = 0
|
|
90
|
+
readonly #connectionListeners = new Set<(connected: boolean) => void>()
|
|
91
|
+
|
|
92
|
+
constructor(options: WsTransportOptions) {
|
|
93
|
+
this.pipe = options.pipe
|
|
94
|
+
this.#codec = options.codec ?? identityCodec
|
|
95
|
+
this.#auth = options.auth
|
|
96
|
+
this.pipe.on('message', raw => this.#onRaw(raw))
|
|
97
|
+
// Bind the identity on every (re)connect so a dropped socket comes
|
|
98
|
+
// back as the same user, and withhold the readiness report until that
|
|
99
|
+
// bind settles. A consumer resuming calls on a bare 'open' would race
|
|
100
|
+
// the bind frame — the loser comes back UNAUTHENTICATED, which an app
|
|
101
|
+
// reasonably reads as a lost session, so a blip could log the user
|
|
102
|
+
// out. Settled either way is enough: a *refused* bind means the
|
|
103
|
+
// session is genuinely gone, and a resumed call surfacing that is the
|
|
104
|
+
// correct outcome. Fire-and-forget otherwise: no caller awaits a
|
|
105
|
+
// reconnect's bind, and an authenticate() joined to this same record
|
|
106
|
+
// still receives it.
|
|
107
|
+
this.pipe.on('open', () => {
|
|
108
|
+
const epoch = ++this.#connectionEpoch
|
|
109
|
+
if (!this.#token) {
|
|
110
|
+
this.#reportConnection(true)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
void this.#bind(this.#token)
|
|
114
|
+
.catch(() => {})
|
|
115
|
+
.then(() => {
|
|
116
|
+
// A bind settling after its socket has gone must not
|
|
117
|
+
// report a connection that no longer exists; whatever
|
|
118
|
+
// replaced it reports for itself.
|
|
119
|
+
if (this.#connectionEpoch === epoch) {
|
|
120
|
+
this.#reportConnection(true)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
// Fail anything in flight when the socket closes: its response can
|
|
125
|
+
// never arrive, so the call would otherwise hang forever (and the UI
|
|
126
|
+
// awaiting it with it). The close error carries the code/reason; the
|
|
127
|
+
// swept calls get the in-flight attribution, while the error as
|
|
128
|
+
// emitted (and as ensureConnected rejects with) stays unflagged —
|
|
129
|
+
// those requests never reached the wire.
|
|
130
|
+
this.pipe.on('close', error => {
|
|
131
|
+
this.#connectionEpoch++
|
|
132
|
+
// The bind belonged to the socket that just went; the next open
|
|
133
|
+
// issues a fresh one.
|
|
134
|
+
this.#binding = undefined
|
|
135
|
+
const pending = [...this.#pending.values()]
|
|
136
|
+
this.#pending.clear()
|
|
137
|
+
this.#reportConnection(false)
|
|
138
|
+
pending.forEach(call => call.fail(error.asInFlight()))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Ask the server whether this socket is still a socket. A connection torn
|
|
143
|
+
// down without a FIN — a backgrounded mobile app, an idle NAT mapping
|
|
144
|
+
// expiring — stays OPEN to the browser: no close event, so nothing fails,
|
|
145
|
+
// nothing reconnects, and calls made on it hang. `method` is the app's
|
|
146
|
+
// cheapest round trip; an answer means live, silence past the deadline
|
|
147
|
+
// means the socket is abandoned and reconnected, which puts the normal
|
|
148
|
+
// close/reconnect path (and whatever the app hangs off it) back in charge.
|
|
149
|
+
//
|
|
150
|
+
// Resolves true when the socket is alive. Nothing to probe when the
|
|
151
|
+
// transport is already down: a reconnect is in flight and will report for
|
|
152
|
+
// itself.
|
|
153
|
+
async probe(method: string, deadlineMs = DEFAULT_PROBE_DEADLINE_MS): Promise<boolean> {
|
|
154
|
+
if (!this.connected) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
const mark = this.#framesReceived
|
|
158
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
159
|
+
const expiry = new Promise<false>(resolve => {
|
|
160
|
+
timer = setTimeout(() => resolve(false), deadlineMs)
|
|
161
|
+
})
|
|
162
|
+
try {
|
|
163
|
+
const answered = await Promise.race([this.send({ method }).then(() => true), expiry])
|
|
164
|
+
if (answered) {
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// A refusal is an answer: the socket carried it.
|
|
169
|
+
return true
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
}
|
|
173
|
+
// What the deadline is really on is silence, not this one answer: the
|
|
174
|
+
// frames share an ordered connection, so a slow link transferring a
|
|
175
|
+
// large page ahead of the probe can outlast the deadline on a socket
|
|
176
|
+
// that is demonstrably delivering. Anything received since the probe
|
|
177
|
+
// went out settles the question the probe was asking, and killing a
|
|
178
|
+
// working socket is not a free retry — it fails every call in flight,
|
|
179
|
+
// each of which then surfaces as a failure the user did not cause.
|
|
180
|
+
if (this.#framesReceived !== mark) {
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
this.pipe.drop()
|
|
184
|
+
this.pipe.reconnectNow()
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Whether calls can currently succeed. Together with onConnectionChange,
|
|
189
|
+
// this is the connectivity oracle an app hands to its UX layer.
|
|
190
|
+
get connected(): boolean {
|
|
191
|
+
return this.#ready
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Connection transitions, one call per underlying event and deliberately
|
|
195
|
+
// NOT deduplicated: consecutive `false` reports are consecutive failed
|
|
196
|
+
// attempts, which is how a consumer tells a blip from a real outage.
|
|
197
|
+
onConnectionChange(listener: (connected: boolean) => void): () => void {
|
|
198
|
+
this.#connectionListeners.add(listener)
|
|
199
|
+
return () => {
|
|
200
|
+
this.#connectionListeners.delete(listener)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#reportConnection(connected: boolean): void {
|
|
205
|
+
this.#ready = connected
|
|
206
|
+
this.#connectionListeners.forEach(listener => listener(connected))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
connect(): Promise<void> {
|
|
210
|
+
return this.pipe.ensureConnected()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#onRaw(raw: string): void {
|
|
214
|
+
this.#framesReceived++
|
|
215
|
+
const frame = JSON.parse(this.#codec.decode(raw)) as WsResponseFrame | PushFrame
|
|
216
|
+
if ('id' in frame && typeof frame.id === 'string') {
|
|
217
|
+
const pending = this.#pending.get(frame.id)
|
|
218
|
+
if (pending) {
|
|
219
|
+
this.#pending.delete(frame.id)
|
|
220
|
+
pending.settle(frame as WsResponseFrame)
|
|
221
|
+
}
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
const push = frame as PushFrame
|
|
225
|
+
if (push.type === 'event' && typeof push.event === 'string') {
|
|
226
|
+
this.#dispatch(this.#eventHandlers, push.event, push.data)
|
|
227
|
+
} else if (push.type && push.type !== 'response') {
|
|
228
|
+
this.#dispatch(this.#dataHandlers, push.type, push.data)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#dispatch(handlers: Map<string, Set<(data: never) => void>>, key: string, data: unknown): void {
|
|
233
|
+
handlers.get(key)?.forEach(handler => (handler as (d: unknown) => void)(data))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#subscribe(handlers: Map<string, Set<(data: never) => void>>, key: string, handler: (data: never) => void): () => void {
|
|
237
|
+
let set = handlers.get(key)
|
|
238
|
+
if (!set) {
|
|
239
|
+
set = new Set()
|
|
240
|
+
handlers.set(key, set)
|
|
241
|
+
}
|
|
242
|
+
set.add(handler)
|
|
243
|
+
return () => {
|
|
244
|
+
set.delete(handler)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Subscribe to `{type:'event', event: name}` server pushes.
|
|
249
|
+
onEvent<K extends keyof PEvents & string>(event: K, handler: (data: PEvents[K]) => void): () => void {
|
|
250
|
+
return this.#subscribe(this.#eventHandlers, event, handler)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Subscribe to bare `{type: name}` server pushes.
|
|
254
|
+
onData<K extends keyof PData & string>(type: K, handler: (data: PData[K]) => void): () => void {
|
|
255
|
+
return this.#subscribe(this.#dataHandlers, type, handler)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async send(request: RpcRequest): Promise<unknown> {
|
|
259
|
+
await this.pipe.ensureConnected()
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const id = randomString('rpc-')
|
|
262
|
+
this.#pending.set(id, {
|
|
263
|
+
settle: frame => {
|
|
264
|
+
try {
|
|
265
|
+
resolve(settleResponse(frame, request.method))
|
|
266
|
+
} catch (e) {
|
|
267
|
+
reject(e)
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
fail: reject
|
|
271
|
+
})
|
|
272
|
+
this.pipe.send(this.#codec.encode(JSON.stringify({ id, ...request })))
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Binds the connection to `token`, resolving once the server has accepted
|
|
277
|
+
// it. Cheap to repeat: naming the credential the live socket is already
|
|
278
|
+
// bound with joins that bind instead of issuing a second one — which is
|
|
279
|
+
// what a boot does, the session store binding on token receipt and the
|
|
280
|
+
// bootstrap then awaiting the same identity.
|
|
281
|
+
async authenticate(token: string): Promise<void> {
|
|
282
|
+
this.#token = token
|
|
283
|
+
await this.#bind(token)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async deauthenticate(): Promise<void> {
|
|
287
|
+
this.#token = null
|
|
288
|
+
// The socket is no longer bound to anyone, so a later authenticate()
|
|
289
|
+
// with that same credential must issue a real bind, not join the one
|
|
290
|
+
// this call is undoing.
|
|
291
|
+
this.#binding = undefined
|
|
292
|
+
await this.#auth?.deauthenticate(payload => this.send(payload))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#bind(token: string): Promise<unknown> {
|
|
296
|
+
if (this.#binding?.token !== token) {
|
|
297
|
+
// send() waits for the socket itself, so this is safe to issue
|
|
298
|
+
// while the handshake is still in flight: the frame goes out when
|
|
299
|
+
// it opens, by which point the 'open' handler finds this record
|
|
300
|
+
// already standing and adds nothing.
|
|
301
|
+
this.#binding = { token, done: this.#auth?.authenticate(payload => this.send(payload), token) ?? Promise.resolve() }
|
|
302
|
+
}
|
|
303
|
+
return this.#binding.done
|
|
304
|
+
}
|
|
305
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { NetworkError } from '@greendrake/util'
|
|
2
|
+
|
|
3
|
+
// An error whose only sensible UX is a single acknowledgement (no retry).
|
|
4
|
+
// Pure data — presentation (toasts, dismiss handlers) belongs to the UI layer.
|
|
5
|
+
export class OneOutcomeError extends Error {}
|
|
6
|
+
|
|
7
|
+
export class NotFoundError extends OneOutcomeError {
|
|
8
|
+
// The backend code this was made from, when there was one. makeError folds
|
|
9
|
+
// every `*_NOT_FOUND` code into this class, and the message is display
|
|
10
|
+
// text rather than the code, so without carrying it here the code would be
|
|
11
|
+
// gone — leaving anything that matches on codes (session teardown, the
|
|
12
|
+
// bubbled-error list) unable to see a `*_NOT_FOUND` at all. Absent when the
|
|
13
|
+
// app raises a not-found of its own, from a route that resolved to nothing.
|
|
14
|
+
readonly code?: string
|
|
15
|
+
|
|
16
|
+
constructor(code?: string) {
|
|
17
|
+
super('Not found')
|
|
18
|
+
this.name = 'NotFoundError'
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The backend error code behind an error, wherever its class keeps it: an
|
|
24
|
+
// ApiError's message IS its code, a NotFoundError holds it apart because its
|
|
25
|
+
// message is display text. Anything else answers with its message, which simply
|
|
26
|
+
// matches no code. The one way to key on a code — matching `message` directly
|
|
27
|
+
// silently misses every `*_NOT_FOUND`.
|
|
28
|
+
export const codeOf = (e: Error): string => (e instanceof NotFoundError ? (e.code ?? '') : e.message)
|
|
29
|
+
|
|
30
|
+
// A backend business error: `message` is the error code, `details` mirrors the
|
|
31
|
+
// response's `error_details` payload (the structured data a handler attaches
|
|
32
|
+
// alongside a code, e.g. the conflicting user's id on OAUTH_ALREADY_LINKED).
|
|
33
|
+
export class ApiError extends Error {
|
|
34
|
+
readonly details?: unknown
|
|
35
|
+
|
|
36
|
+
constructor(code: string, details?: unknown) {
|
|
37
|
+
super(code)
|
|
38
|
+
this.name = 'ApiError'
|
|
39
|
+
this.details = details
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Rejection value for a WebSocket that closes before it opens, or that closes
|
|
44
|
+
// while calls are in flight. Carries the CloseEvent fields so a drop is
|
|
45
|
+
// diagnosable (which close code, was it clean) instead of surfacing as a bare,
|
|
46
|
+
// contentless DOM Event. Extends NetworkError: a transport condition,
|
|
47
|
+
// classified alongside HTTP fetch failures by one instanceof check.
|
|
48
|
+
export class WebSocketClosedError extends NetworkError {
|
|
49
|
+
readonly code: number
|
|
50
|
+
readonly reason: string
|
|
51
|
+
readonly wasClean: boolean
|
|
52
|
+
// Whether the request this error failed was already on the wire when the
|
|
53
|
+
// socket went. A request that never left cannot have had an effect and is
|
|
54
|
+
// always safe to re-issue; one that was in flight may have executed
|
|
55
|
+
// server-side with only its response lost, so re-issuing it is a decision
|
|
56
|
+
// for the caller (see ApiClient's offline park).
|
|
57
|
+
readonly inFlight: boolean
|
|
58
|
+
readonly #event: CloseEvent
|
|
59
|
+
|
|
60
|
+
constructor(event: CloseEvent, inFlight = false) {
|
|
61
|
+
super(`WebSocket closed (code ${event.code}${event.reason ? `, ${event.reason}` : ''}, clean=${event.wasClean})`)
|
|
62
|
+
this.name = 'WebSocketClosedError'
|
|
63
|
+
this.code = event.code
|
|
64
|
+
this.reason = event.reason
|
|
65
|
+
this.wasClean = event.wasClean
|
|
66
|
+
this.inFlight = inFlight
|
|
67
|
+
this.#event = event
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The same close, attributed to a request that was already on the wire.
|
|
71
|
+
// A separate instance rather than a mutation: the emitted error is shared
|
|
72
|
+
// by every close listener, and only the swept calls carry the flag.
|
|
73
|
+
asInFlight(): WebSocketClosedError {
|
|
74
|
+
return new WebSocketClosedError(this.#event, true)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// An unexpected drop worth reporting, vs the clean close a normal
|
|
78
|
+
// navigation or logout produces (mere operational noise). 1000 = normal,
|
|
79
|
+
// 1001 = going away; anything else — 1006 (abnormal, no close frame),
|
|
80
|
+
// 1011/1012/1013 (server error / restart / overload) — is unexpected.
|
|
81
|
+
get isAbnormal(): boolean {
|
|
82
|
+
return !this.wasClean || ![1000, 1001].includes(this.code)
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { safeFetch } from '@greendrake/util'
|
|
2
|
+
import { identityCodec } from './types'
|
|
3
|
+
import type { Codec, HeadersProvider, RpcRequest, RpcResponse, Transport } from './types'
|
|
4
|
+
import { settleResponse } from './protocol'
|
|
5
|
+
|
|
6
|
+
export interface HttpTransportOptions {
|
|
7
|
+
url: string
|
|
8
|
+
codec?: Codec
|
|
9
|
+
headers?: HeadersProvider
|
|
10
|
+
// Merged into every request — e.g. { keepalive: true } for fire-and-forget
|
|
11
|
+
// calls that must survive page unload.
|
|
12
|
+
requestInit?: Partial<RequestInit>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class HttpTransport implements Transport {
|
|
16
|
+
readonly #options: HttpTransportOptions
|
|
17
|
+
readonly #codec: Codec
|
|
18
|
+
|
|
19
|
+
constructor(options: HttpTransportOptions) {
|
|
20
|
+
this.#options = options
|
|
21
|
+
this.#codec = options.codec ?? identityCodec
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async send(request: RpcRequest): Promise<unknown> {
|
|
25
|
+
const response = await safeFetch(this.#options.url, {
|
|
26
|
+
...this.#options.requestInit,
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: this.#options.headers?.(),
|
|
29
|
+
body: this.#codec.encode(JSON.stringify(request))
|
|
30
|
+
})
|
|
31
|
+
const text = this.#codec.decode(await response.text())
|
|
32
|
+
return settleResponse(JSON.parse(text) as RpcResponse, request.method)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { PushMapConstraint, WsTransport } from './WsTransport'
|
|
2
|
+
|
|
3
|
+
// What one push means to the thing the binding keeps current.
|
|
4
|
+
export interface LiveHandler<D> {
|
|
5
|
+
// Whether this push concerns what the binding shows. Omitted: every push does.
|
|
6
|
+
when?: (data: D) => boolean
|
|
7
|
+
// Folds the push in locally. Omitted: the push triggers `resync`.
|
|
8
|
+
apply?: (data: D) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LiveOptions<PEvents, PData> {
|
|
12
|
+
// `{type:'event', event}` frames, subscribed via onEvent.
|
|
13
|
+
events?: { [K in keyof PEvents]?: LiveHandler<PEvents[K]> }
|
|
14
|
+
// Bare `{type, data}` frames, subscribed via onData.
|
|
15
|
+
data?: { [K in keyof PData]?: LiveHandler<PData[K]> }
|
|
16
|
+
// The full re-read of what the binding shows. Runs for a relevant push
|
|
17
|
+
// with no `apply`, and when the socket comes back from a drop — the two
|
|
18
|
+
// are the same event as far as the data is concerned, since a drop is
|
|
19
|
+
// exactly where a push goes missing.
|
|
20
|
+
//
|
|
21
|
+
// It owns its own errors: the binding awaits it only to know when the slot
|
|
22
|
+
// is free, and interprets neither outcome. It must also not be served by a
|
|
23
|
+
// response cache the missed pushes would have invalidated — invalidate,
|
|
24
|
+
// then read (see @greendrake/vue-api's bindLive, which carries that policy
|
|
25
|
+
// for app code).
|
|
26
|
+
resync?: () => Promise<unknown> | void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Binds a view, store or cache to the server pushes that say it changed: each
|
|
30
|
+
// push is filtered for relevance and then either folded in locally (`apply`)
|
|
31
|
+
// or answered by re-reading (`resync`), and a socket that drops and returns
|
|
32
|
+
// re-reads too — a connection has no memory, so whatever it missed is only
|
|
33
|
+
// recoverable by asking again.
|
|
34
|
+
//
|
|
35
|
+
// Returns the release function; call it when what the binding keeps current
|
|
36
|
+
// goes away.
|
|
37
|
+
export const liveBinding = <PEvents extends PushMapConstraint<PEvents>, PData extends PushMapConstraint<PData>>(transport: WsTransport<PEvents, PData>, options: LiveOptions<PEvents, PData>): (() => void) => {
|
|
38
|
+
const { resync } = options
|
|
39
|
+
const releases: (() => void)[] = []
|
|
40
|
+
let running = false
|
|
41
|
+
let queued = false
|
|
42
|
+
let released = false
|
|
43
|
+
|
|
44
|
+
const runResync = (): void => {
|
|
45
|
+
if (!resync || released) return
|
|
46
|
+
// One re-read at a time, and at most one more behind it however many
|
|
47
|
+
// pushes land while it runs: they all ask the same question, and the
|
|
48
|
+
// answer to the last one is the answer to all of them.
|
|
49
|
+
if (running) {
|
|
50
|
+
queued = true
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
running = true
|
|
54
|
+
// `new Promise(resolve => resolve(…))` rather than Promise.resolve(…):
|
|
55
|
+
// it puts the call inside the promise, so a resync that throws
|
|
56
|
+
// synchronously becomes a rejection like any other failure instead of
|
|
57
|
+
// escaping with the slot still held — which would wedge this binding for
|
|
58
|
+
// good, and abort the transport's dispatch to every other subscriber of
|
|
59
|
+
// the same frame.
|
|
60
|
+
void new Promise(resolve => resolve(resync()))
|
|
61
|
+
// Whatever the outcome, the slot is free and a push that arrived
|
|
62
|
+
// during the run gets its re-read. The outcome itself belongs to
|
|
63
|
+
// resync — swallowed here rather than left unhandled because this
|
|
64
|
+
// runs from a subscription callback nobody awaits.
|
|
65
|
+
.catch(() => {})
|
|
66
|
+
.then(() => {
|
|
67
|
+
running = false
|
|
68
|
+
if (!queued) return
|
|
69
|
+
queued = false
|
|
70
|
+
runResync()
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const attach = <P>(handlers: { [K in keyof P]?: LiveHandler<P[K]> } | undefined, subscribe: <K extends keyof P & string>(key: K, handler: (data: P[K]) => void) => () => void): (() => () => void)[] =>
|
|
75
|
+
(Object.keys(handlers ?? {}) as (keyof P & string)[]).map(key => {
|
|
76
|
+
const handler = handlers![key]!
|
|
77
|
+
if (!handler.apply && !resync) {
|
|
78
|
+
throw new Error(`liveBinding: the handler for '${key}' neither applies the push nor has a resync to answer it with`)
|
|
79
|
+
}
|
|
80
|
+
return () =>
|
|
81
|
+
subscribe(key, data => {
|
|
82
|
+
if (handler.when && !handler.when(data)) return
|
|
83
|
+
if (handler.apply) handler.apply(data)
|
|
84
|
+
else runResync()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Every handler is checked before the first subscription goes on, so a
|
|
89
|
+
// misconfigured one throws with nothing left attached behind it.
|
|
90
|
+
const subscriptions = [...attach<PEvents>(options.events, (event, handler) => transport.onEvent(event, handler)), ...attach<PData>(options.data, (type, handler) => transport.onData(type, handler))]
|
|
91
|
+
subscriptions.forEach(subscribe => releases.push(subscribe()))
|
|
92
|
+
|
|
93
|
+
if (resync) {
|
|
94
|
+
// A return from a drop this binding witnessed, not any connect: the
|
|
95
|
+
// first `true` after a boot reports the connection the binding was
|
|
96
|
+
// created for, and there is nothing yet to have missed.
|
|
97
|
+
let dropped = false
|
|
98
|
+
releases.push(
|
|
99
|
+
transport.onConnectionChange(connected => {
|
|
100
|
+
if (!connected) {
|
|
101
|
+
dropped = true
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (!dropped) return
|
|
105
|
+
dropped = false
|
|
106
|
+
runResync()
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
released = true
|
|
113
|
+
queued = false
|
|
114
|
+
releases.forEach(release => release())
|
|
115
|
+
releases.length = 0
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { identityCodec, type Codec, type HeadersProvider, type RpcRequest, type RpcResponse, type MethodMap, type MethodMapConstraint, type Transport } from './types'
|
|
2
|
+
export { ApiError, NotFoundError, OneOutcomeError, WebSocketClosedError, codeOf } from './errors'
|
|
3
|
+
export { makeError, packRequest, settleResponse } from './protocol'
|
|
4
|
+
export { HttpTransport, type HttpTransportOptions } from './http'
|
|
5
|
+
export { WebSocketPipe, type WebSocketPipeEvents, type WebSocketPipeOptions } from './WebSocketPipe'
|
|
6
|
+
export { WsTransport, type PushMap, type PushMapConstraint, type WsAuthStrategy, type WsSend, type WsTransportOptions } from './WsTransport'
|
|
7
|
+
export { liveBinding, type LiveHandler, type LiveOptions } from './liveBinding'
|
|
8
|
+
export { RpcClient } from './RpcClient'
|
|
9
|
+
export { ResponseCache, type CacheConfig, type CacheLookup } from './ResponseCache'
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { randomString } from '@greendrake/util'
|
|
2
|
+
import { ApiError, NotFoundError } from './errors'
|
|
3
|
+
import type { RpcRequest, RpcResponse } from './types'
|
|
4
|
+
|
|
5
|
+
export const packRequest = (method: string, args: unknown[], nonce: boolean, locale?: string): RpcRequest => {
|
|
6
|
+
const request: RpcRequest = { method }
|
|
7
|
+
if (nonce) {
|
|
8
|
+
request.nonce = randomString()
|
|
9
|
+
}
|
|
10
|
+
if (args.length) {
|
|
11
|
+
request.arguments = args
|
|
12
|
+
}
|
|
13
|
+
if (locale) {
|
|
14
|
+
request.locale = locale
|
|
15
|
+
}
|
|
16
|
+
return request
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Single owner of the error-code → Error mapping shared by both transports:
|
|
20
|
+
// 'notfound' / '*_NOT_FOUND' get a NotFoundError (its own toast type),
|
|
21
|
+
// everything else an ApiError carrying the optional error_details payload.
|
|
22
|
+
export const makeError = (code: string, details?: unknown): Error => {
|
|
23
|
+
if (code === 'notfound' || code.endsWith('_NOT_FOUND')) {
|
|
24
|
+
// The code rides along: this class is chosen for its toast, not to
|
|
25
|
+
// replace what the backend said, and a caller keying on the code
|
|
26
|
+
// (codeOf) must still find it.
|
|
27
|
+
return new NotFoundError(code)
|
|
28
|
+
}
|
|
29
|
+
return new ApiError(code, details)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Dev-only diagnostic for non-success responses. These are server-returned
|
|
33
|
+
// business outcomes (NOT_FOUND, INVALID_CREDENTIALS, …) the caller goes on to
|
|
34
|
+
// handle — not program faults — so they log at `warn`, never `error`: a
|
|
35
|
+
// handled outcome must not trip a test's console-error guard or read as a
|
|
36
|
+
// malfunction in the devtools console. Guarded optional access keeps this
|
|
37
|
+
// Node-safe (no import.meta.env outside Vite).
|
|
38
|
+
const logIfDev = (method: string, response: RpcResponse): void => {
|
|
39
|
+
if ((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV) {
|
|
40
|
+
console.warn('[rpc]', method, response)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolve a parsed response to its data, or throw the mapped error.
|
|
45
|
+
export const settleResponse = (response: RpcResponse, method: string): unknown => {
|
|
46
|
+
if (response.success) {
|
|
47
|
+
return response.data
|
|
48
|
+
}
|
|
49
|
+
logIfDev(method, response)
|
|
50
|
+
throw makeError(response.error!, response.error_details)
|
|
51
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Single source of truth for the wire shapes. The protocol:
|
|
2
|
+
// request {method, nonce?, arguments?} (nonce = server-side dedup key for
|
|
3
|
+
// non-idempotent calls; arguments omitted when empty)
|
|
4
|
+
// response {success, data?, error?, error_details?}
|
|
5
|
+
// WS adds an `id` to both directions for request/response correlation, and
|
|
6
|
+
// server-push frames {type: 'event', event, data} / {type, data}.
|
|
7
|
+
|
|
8
|
+
export interface RpcRequest {
|
|
9
|
+
method: string
|
|
10
|
+
nonce?: string
|
|
11
|
+
// The caller's UI language tag, when the app supplies a provider. In the
|
|
12
|
+
// envelope rather than a header: the WebSocket transport has none, and the
|
|
13
|
+
// browser cannot set any on the handshake, while every call rides that
|
|
14
|
+
// socket.
|
|
15
|
+
locale?: string
|
|
16
|
+
arguments?: unknown[]
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RpcResponse {
|
|
21
|
+
success: boolean
|
|
22
|
+
data?: unknown
|
|
23
|
+
error?: string
|
|
24
|
+
error_details?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Payload codec applied to outbound request bodies and inbound response text.
|
|
28
|
+
// Identity by default; an app supplies its own for an obscured or encrypted wire.
|
|
29
|
+
export interface Codec {
|
|
30
|
+
encode(s: string): string
|
|
31
|
+
decode(s: string): string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const identityCodec: Codec = {
|
|
35
|
+
encode: s => s,
|
|
36
|
+
decode: s => s
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Extra request headers, evaluated per request so time-variant values (auth
|
|
40
|
+
// tokens, identity blobs) are always fresh.
|
|
41
|
+
export type HeadersProvider = () => Record<string, string>
|
|
42
|
+
|
|
43
|
+
// App-declared RPC surface: method name → call signature. Enables fully typed
|
|
44
|
+
// client.call('user.get', id) with inferred argument and result types.
|
|
45
|
+
// interface Methods { 'user.get': (id: string) => User }
|
|
46
|
+
// The `never[]` parameter constraint deliberately accepts any concrete
|
|
47
|
+
// signature (contravariance) without resorting to `any`. Generic constraints
|
|
48
|
+
// use the `Record<keyof M, …>` form so plain interfaces (which have no
|
|
49
|
+
// implicit index signature) qualify.
|
|
50
|
+
export type MethodMap = Record<string, (...args: never[]) => unknown>
|
|
51
|
+
|
|
52
|
+
export type MethodMapConstraint<M> = Record<keyof M, (...args: never[]) => unknown>
|
|
53
|
+
|
|
54
|
+
export interface Transport {
|
|
55
|
+
send(request: RpcRequest): Promise<unknown>
|
|
56
|
+
}
|