@greendrake/rpc 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/package.json +3 -3
- package/src/ResponseCache.ts +0 -8
- package/src/RpcClient.ts +2 -2
- package/src/liveBinding.ts +9 -30
- package/src/main.ts +1 -1
- package/src/types.ts +6 -0
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ const user = await client.call('user.get', 'U1') // User
|
|
|
54
54
|
await client.call('user.update', { id: 'U1', name: 'Ann' })
|
|
55
55
|
```
|
|
56
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.
|
|
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. A method that takes no arguments is called with none, and its request carries no `arguments` field. `Caller<M>` is that `call`, which `RpcClient` and `@greendrake/vue-api`'s `ApiClient` both implement: the type for code handed either. `MethodMap` and `MethodMapConstraint<M>` are the constraint types; a plain interface qualifies.
|
|
58
58
|
|
|
59
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
60
|
|
|
@@ -177,7 +177,7 @@ The contract:
|
|
|
177
177
|
- **Relevance.** `when` decides whether a push concerns what the binding shows; omitted, every push does.
|
|
178
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
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.
|
|
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. This is `@greendrake/util`'s `coalesced`, which a server building what it pushes can use the same way.
|
|
181
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.
|
|
182
182
|
- **Release.** The returned function detaches every subscription and drops any pending follow-up; calling it twice is harmless.
|
|
183
183
|
|
|
@@ -211,7 +211,6 @@ cache.get('taxonomy.tree', [{ taxonomy: 'category' }]) // { hit: boolean; data?:
|
|
|
211
211
|
cache.set('taxonomy.tree', [{ taxonomy: 'category' }], tree) // no-op for an unlisted method
|
|
212
212
|
cache.invalidate('search.things') // every entry for the method
|
|
213
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
214
|
cache.clear() // everything, e.g. when the session identity changes
|
|
216
215
|
```
|
|
217
216
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@greendrake/rpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "RPC client over JSON: typed method-map API, HTTP and reconnecting WebSocket transports, pluggable codec and headers, TTL response cache",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
},
|
|
35
35
|
"prettier": "@greendrake/dev-config/prettier",
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@greendrake/util": "0.
|
|
37
|
+
"@greendrake/util": "^0.8.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@greendrake/dev-config": "0.
|
|
40
|
+
"@greendrake/dev-config": "0.7.0",
|
|
41
41
|
"typescript": "6.0.3"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/ResponseCache.ts
CHANGED
|
@@ -85,12 +85,4 @@ export class ResponseCache {
|
|
|
85
85
|
clear(): void {
|
|
86
86
|
this.#entries.clear()
|
|
87
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
88
|
}
|
package/src/RpcClient.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MethodMap, MethodMapConstraint, Transport } from './types'
|
|
1
|
+
import type { Caller, MethodMap, MethodMapConstraint, Transport } from './types'
|
|
2
2
|
import { packRequest } from './protocol'
|
|
3
3
|
|
|
4
4
|
// Typed facade over a transport: methods come from the app's declared
|
|
@@ -6,7 +6,7 @@ import { packRequest } from './protocol'
|
|
|
6
6
|
// proxies, no casts at call sites.
|
|
7
7
|
// const client = new RpcClient<Methods>(transport)
|
|
8
8
|
// const user = await client.call('user.get', id)
|
|
9
|
-
export class RpcClient<M extends MethodMapConstraint<M> = MethodMap> {
|
|
9
|
+
export class RpcClient<M extends MethodMapConstraint<M> = MethodMap> implements Caller<M> {
|
|
10
10
|
readonly transport: Transport
|
|
11
11
|
// Supplies the caller's UI language tag per request. Evaluated per call,
|
|
12
12
|
// like HttpTransport's headers provider, so a locale switch takes effect on
|
package/src/liveBinding.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { coalesced } from '@greendrake/util'
|
|
1
2
|
import type { PushMapConstraint, WsTransport } from './WsTransport'
|
|
2
3
|
|
|
3
4
|
// What one push means to the thing the binding keeps current.
|
|
@@ -36,38 +37,17 @@ export interface LiveOptions<PEvents, PData> {
|
|
|
36
37
|
export const liveBinding = <PEvents extends PushMapConstraint<PEvents>, PData extends PushMapConstraint<PData>>(transport: WsTransport<PEvents, PData>, options: LiveOptions<PEvents, PData>): (() => void) => {
|
|
37
38
|
const { resync } = options
|
|
38
39
|
const releases: (() => void)[] = []
|
|
39
|
-
let running = false
|
|
40
|
-
let queued = false
|
|
41
40
|
let released = false
|
|
42
41
|
|
|
42
|
+
// One re-read at a time, and at most one more behind it however many
|
|
43
|
+
// pushes land while it runs: they all ask the same question, and the answer
|
|
44
|
+
// to the last one is the answer to all of them. One still queued when the
|
|
45
|
+
// binding is released has nobody left to answer, and does nothing.
|
|
46
|
+
const reread = coalesced(() => (released ? undefined : resync?.()))
|
|
47
|
+
// The outcome belongs to resync — swallowed here rather than left
|
|
48
|
+
// unhandled because this runs from a subscription callback nobody awaits.
|
|
43
49
|
const runResync = (): void => {
|
|
44
|
-
|
|
45
|
-
// One re-read at a time, and at most one more behind it however many
|
|
46
|
-
// pushes land while it runs: they all ask the same question, and the
|
|
47
|
-
// answer to the last one is the answer to all of them.
|
|
48
|
-
if (running) {
|
|
49
|
-
queued = true
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
running = true
|
|
53
|
-
// `new Promise(resolve => resolve(…))` rather than Promise.resolve(…):
|
|
54
|
-
// it puts the call inside the promise, so a resync that throws
|
|
55
|
-
// synchronously becomes a rejection like any other failure instead of
|
|
56
|
-
// escaping with the slot still held — which would wedge this binding for
|
|
57
|
-
// good, and abort the transport's dispatch to every other subscriber of
|
|
58
|
-
// the same frame.
|
|
59
|
-
void new Promise(resolve => resolve(resync()))
|
|
60
|
-
// Whatever the outcome, the slot is free and a push that arrived
|
|
61
|
-
// during the run gets its re-read. The outcome itself belongs to
|
|
62
|
-
// resync — swallowed here rather than left unhandled because this
|
|
63
|
-
// runs from a subscription callback nobody awaits.
|
|
64
|
-
.catch(() => {})
|
|
65
|
-
.then(() => {
|
|
66
|
-
running = false
|
|
67
|
-
if (!queued) return
|
|
68
|
-
queued = false
|
|
69
|
-
runResync()
|
|
70
|
-
})
|
|
50
|
+
void reread().catch(() => {})
|
|
71
51
|
}
|
|
72
52
|
|
|
73
53
|
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)[] =>
|
|
@@ -109,7 +89,6 @@ export const liveBinding = <PEvents extends PushMapConstraint<PEvents>, PData ex
|
|
|
109
89
|
|
|
110
90
|
return () => {
|
|
111
91
|
released = true
|
|
112
|
-
queued = false
|
|
113
92
|
releases.forEach(release => release())
|
|
114
93
|
releases.length = 0
|
|
115
94
|
}
|
package/src/main.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { identityCodec, type Codec, type HeadersProvider, type RpcRequest, type RpcResponse, type MethodMap, type MethodMapConstraint, type Transport } from './types'
|
|
1
|
+
export { identityCodec, type Caller, type Codec, type HeadersProvider, type RpcRequest, type RpcResponse, type MethodMap, type MethodMapConstraint, type Transport } from './types'
|
|
2
2
|
export { ApiError, NotFoundError, OneOutcomeError, WebSocketClosedError, codeOf } from './errors'
|
|
3
3
|
export { makeError, packRequest, settleResponse } from './protocol'
|
|
4
4
|
export { HttpTransport, type HttpTransportOptions } from './http'
|
package/src/types.ts
CHANGED
|
@@ -51,6 +51,12 @@ export type MethodMap = Record<string, (...args: never[]) => unknown>
|
|
|
51
51
|
|
|
52
52
|
export type MethodMapConstraint<M> = Record<keyof M, (...args: never[]) => unknown>
|
|
53
53
|
|
|
54
|
+
// What calls the methods of M: each with exactly its own arguments, and a method
|
|
55
|
+
// that takes none with none at all.
|
|
56
|
+
export interface Caller<M extends MethodMapConstraint<M>> {
|
|
57
|
+
call<K extends keyof M & string>(method: K, ...args: Parameters<M[K]>): Promise<Awaited<ReturnType<M[K]>>>
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
export interface Transport {
|
|
55
61
|
send(request: RpcRequest): Promise<unknown>
|
|
56
62
|
}
|