@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 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
+ }
@@ -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'
@@ -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
+ }