@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 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.6.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.6.0"
37
+ "@greendrake/util": "^0.8.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@greendrake/dev-config": "0.6.0",
40
+ "@greendrake/dev-config": "0.7.0",
41
41
  "typescript": "6.0.3"
42
42
  }
43
43
  }
@@ -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
@@ -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
- if (!resync || released) return
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
  }