@livequery/rpc 2.0.81 → 2.0.92

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.
Files changed (2) hide show
  1. package/README.md +250 -102
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,24 +1,28 @@
1
1
  # @livequery/rpc
2
2
 
3
- Lightweight RxJS-based RPC utilities for calling services across a SharedWorker boundary.
3
+ Lightweight RxJS-based RPC utilities for exposing services from a `SharedWorker` and consuming them from the main thread with typed proxies.
4
4
 
5
- This package gives you:
6
- - A message channel abstraction for request/response RPC
7
- - A worker-side manager to expose services
8
- - A client-side linker that creates typed service proxies
9
- - Promise-like Observable calls (await or subscribe)
10
- - A persistent BehaviorSubject helper backed by custom storage
11
- - A concurrency-limiting decorator helper
5
+ This package is built for a simple model:
6
+
7
+ - Expose plain classes as worker services.
8
+ - Call worker methods from the UI as `await`-able functions.
9
+ - Stream worker `Observable`s back to the client.
10
+ - Mirror `BehaviorSubject`-style state across the boundary.
11
+ - Cancel long-running streams when the client unsubscribes.
12
12
 
13
13
  ## Installation
14
14
 
15
15
  ```bash
16
16
  npm install @livequery/rpc rxjs
17
- # or
17
+ ```
18
+
19
+ Or with Bun:
20
+
21
+ ```bash
18
22
  bun add @livequery/rpc rxjs
19
23
  ```
20
24
 
21
- ## Exports
25
+ ## What It Exports
22
26
 
23
27
  ```ts
24
28
  export * from "./RpcChannel"
@@ -28,9 +32,10 @@ export * from "./WorkerService"
28
32
  export * from "./WorkerManager"
29
33
  export * from "./LimitConcurrency"
30
34
  export * from "./StorageBehaviorSubject"
35
+ export * from "./RxjsQueue"
31
36
  ```
32
37
 
33
- ## Architecture
38
+ ## Mental Model
34
39
 
35
40
  ```text
36
41
  Main Thread Shared Worker
@@ -40,9 +45,18 @@ ServiceLinker --(RpcMessage)--> SharedWorkerChannel --> WorkerManager --> your s
40
45
  |--------------------(response stream)-----------------------|
41
46
  ```
42
47
 
43
- ## Quick Start
48
+ ## When To Use This Package
49
+
50
+ Use it when you want to:
51
+
52
+ - move logic or shared state into a `SharedWorker`
53
+ - keep a typed service-style API instead of manually handling `postMessage`
54
+ - return one-shot values, promises, or `Observable`s from worker methods
55
+ - expose `BehaviorSubject` properties as client-consumable reactive state
44
56
 
45
- ### 1. Define a service contract
57
+ ## End-To-End Example
58
+
59
+ ### 1. Define a worker service
46
60
 
47
61
  ```ts
48
62
  import { BehaviorSubject, interval, map } from "rxjs"
@@ -51,8 +65,9 @@ export class CounterService {
51
65
  value = new BehaviorSubject(0)
52
66
 
53
67
  increment(by = 1) {
54
- this.value.next(this.value.getValue() + by)
55
- return this.value.getValue()
68
+ const nextValue = this.value.getValue() + by
69
+ this.value.next(nextValue)
70
+ return nextValue
56
71
  }
57
72
 
58
73
  getCurrent() {
@@ -60,15 +75,14 @@ export class CounterService {
60
75
  }
61
76
 
62
77
  ticker() {
63
- return interval(1000).pipe(map((i) => `tick-${i}`))
78
+ return interval(1000).pipe(map((index) => `tick-${index}`))
64
79
  }
65
80
  }
66
81
  ```
67
82
 
68
- ### 2. Expose the service inside the SharedWorker
83
+ ### 2. Expose it inside the `SharedWorker`
69
84
 
70
85
  ```ts
71
- // worker.ts
72
86
  import { SharedWorkerChannel, WorkerManager } from "@livequery/rpc"
73
87
  import { CounterService } from "./CounterService"
74
88
 
@@ -78,11 +92,10 @@ const manager = new WorkerManager(channel)
78
92
  manager.exposeService("counter", new CounterService())
79
93
  ```
80
94
 
81
- ### 3. Link and use the service on the main thread
95
+ ### 3. Connect from the main thread
82
96
 
83
97
  ```ts
84
- // main.ts
85
- import { SharedWorkerChannel, ServiceLinker, type WorkerService } from "@livequery/rpc"
98
+ import { ServiceLinker, SharedWorkerChannel, type WorkerService } from "@livequery/rpc"
86
99
  import type { CounterService } from "./CounterService"
87
100
 
88
101
  const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), { type: "module" })
@@ -90,24 +103,139 @@ const channel = new SharedWorkerChannel(worker)
90
103
  const linker = new ServiceLinker(channel)
91
104
 
92
105
  const counter = linker.linkService<WorkerService<CounterService>>("counter")
106
+ ```
107
+
108
+ ### 4. Call methods with `await`
109
+
110
+ ```ts
111
+ const nextValue = await counter.increment(2)
112
+ const currentValue = await counter.getCurrent()
113
+
114
+ console.log({ nextValue, currentValue })
115
+ ```
116
+
117
+ ### 5. Subscribe to streamed responses
118
+
119
+ ```ts
120
+ const tickerSubscription = counter.ticker().subscribe((value) => {
121
+ console.log("tick", value)
122
+ })
123
+
124
+ setTimeout(() => {
125
+ tickerSubscription.unsubscribe()
126
+ }, 5000)
127
+ ```
128
+
129
+ ### 6. Consume `BehaviorSubject` state
130
+
131
+ ```ts
132
+ const stateSubscription = counter.value.subscribe((value) => {
133
+ console.log("counter value", value)
134
+ })
135
+ ```
136
+
137
+ ## How Calls Behave
138
+
139
+ Every service method call is exposed on the client as an `Observable` that is also `PromiseLike`.
140
+
141
+ That means you can do either of these:
142
+
143
+ ```ts
144
+ const result = await service.someMethod()
145
+ ```
146
+
147
+ ```ts
148
+ service.someMethod().subscribe((value) => {
149
+ console.log(value)
150
+ })
151
+ ```
152
+
153
+ In practice:
154
+
155
+ - use `await` for one-shot values or promise-returning methods
156
+ - use `subscribe()` for streaming results returned from worker `Observable`s
157
+
158
+ ## BehaviorSubject Mirroring
159
+
160
+ If your service exposes a property with a `getValue()` method, `WorkerManager` will include its initial value during service initialization.
161
+
162
+ This is what allows a worker-side `BehaviorSubject` to feel usable on the client:
163
+
164
+ ```ts
165
+ class SettingsService {
166
+ theme = new BehaviorSubject("light")
167
+ }
168
+ ```
169
+
170
+ ```ts
171
+ const settings = linker.linkService<WorkerService<SettingsService>>("settings")
172
+
173
+ settings.theme.subscribe((theme) => {
174
+ console.log(theme)
175
+ })
176
+ ```
177
+
178
+ Notes:
179
+
180
+ - the initial snapshot is fetched through an internal `____initialize____` call
181
+ - later updates are streamed by subscribing to the remote property
182
+ - this pattern is designed around `BehaviorSubject`-like objects
183
+
184
+ ## Waiting For Multiple Services To Initialize
185
+
186
+ `ServiceLinker` exposes a helper for waiting until all linked services have loaded their initial state.
187
+
188
+ ```ts
189
+ const counter = linker.linkService<WorkerService<CounterService>>("counter")
190
+ const settings = linker.linkService<WorkerService<SettingsService>>("settings")
191
+
192
+ ServiceLinker.ready$({ counter, settings }).subscribe((ready) => {
193
+ if (ready) {
194
+ console.log("all services initialized")
195
+ }
196
+ })
197
+ ```
93
198
 
94
- // Await method calls
95
- const next = await counter.increment(2)
96
- console.log(next)
199
+ ## Nested Access
97
200
 
98
- // Read BehaviorSubject-backed state from worker
99
- counter.value.subscribe((v) => console.log("value", v))
201
+ The client proxy supports nested member access by path.
202
+
203
+ For example, a worker service like this:
204
+
205
+ ```ts
206
+ class UserService {
207
+ profile = {
208
+ getName: () => "Ada",
209
+ }
210
+ }
211
+ ```
100
212
 
101
- // Consume stream responses
102
- const sub = counter.ticker().subscribe((v) => console.log(v))
213
+ can be consumed like this:
103
214
 
104
- // Stop stream
105
- sub.unsubscribe()
215
+ ```ts
216
+ const user = linker.linkService<WorkerService<UserService>>("user")
217
+ const name = await user.profile.getName()
106
218
  ```
107
219
 
108
- ## Core API
220
+ ## Cancellation Model
109
221
 
110
- ## RpcMessage
222
+ If a client unsubscribes from an in-flight request before it completes, `ServiceLinker` sends a cancellation message:
223
+
224
+ ```ts
225
+ { id: 0, cancel: { id: requestId } }
226
+ ```
227
+
228
+ `WorkerManager` uses that request id to unsubscribe from the worker-side stream.
229
+
230
+ This mainly matters for:
231
+
232
+ - infinite or long-lived `Observable`s
233
+ - expensive operations you no longer need
234
+ - UI screens that mount and unmount frequently
235
+
236
+ ## API Reference
237
+
238
+ ### `RpcMessage`
111
239
 
112
240
  ```ts
113
241
  type RpcMessage = {
@@ -126,32 +254,38 @@ type RpcMessage = {
126
254
  }
127
255
  ```
128
256
 
129
- ## RpcChannel
257
+ ### `RpcChannel`
130
258
 
131
- Abstract message transport.
259
+ Abstract transport used by both client and worker.
132
260
 
133
261
  ```ts
134
- abstract class RpcChannel extends Subject<RpcMessage & {
135
- respond: (msg: RpcMessage["response"]) => void
136
- }> {
262
+ abstract class RpcChannel extends Subject<
263
+ RpcMessage & { respond: (msg: RpcMessage["response"]) => void }
264
+ > {
137
265
  abstract send(message: RpcMessage): void
138
266
  }
139
267
  ```
140
268
 
141
- ## SharedWorkerChannel
269
+ ### `SharedWorkerChannel`
142
270
 
143
- `SharedWorkerChannel` is a concrete `RpcChannel` implementation for both contexts:
144
- - Worker context: `new SharedWorkerChannel()`
145
- - Main thread: `new SharedWorkerChannel(sharedWorker)`
271
+ Concrete `RpcChannel` implementation for a `SharedWorker` transport.
146
272
 
147
- It handles:
148
- - Incoming requests/responses via MessagePort events
149
- - Respond function wiring
150
- - Message dispatch with `send`
273
+ Worker side:
151
274
 
152
- ## WorkerManager
275
+ ```ts
276
+ const channel = new SharedWorkerChannel()
277
+ ```
153
278
 
154
- Worker-side router that executes exposed service members.
279
+ Main thread side:
280
+
281
+ ```ts
282
+ const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), { type: "module" })
283
+ const channel = new SharedWorkerChannel(worker)
284
+ ```
285
+
286
+ ### `WorkerManager`
287
+
288
+ Registers named services and routes incoming RPC requests.
155
289
 
156
290
  ```ts
157
291
  class WorkerManager {
@@ -161,47 +295,52 @@ class WorkerManager {
161
295
  ```
162
296
 
163
297
  Behavior:
164
- - Resolves nested method paths sent by the client
165
- - Calls functions with args, or returns property values
166
- - Streams Observable-like results back until completion
167
- - Supports cancellation through a `cancel` message
168
- - Adds an internal `____initialize____` method to gather initial values from properties that expose `getValue()`
169
298
 
170
- ## ServiceLinker
299
+ - resolves nested property or method paths
300
+ - invokes functions with arguments
301
+ - returns plain values for property access
302
+ - streams observable-like results until completion
303
+ - unsubscribes worker-side streams when cancellation arrives
304
+
305
+ ### `ServiceLinker`
171
306
 
172
- Main-thread client that builds typed proxies for services.
307
+ Creates and caches client-side proxies.
173
308
 
174
309
  ```ts
175
310
  class ServiceLinker {
176
311
  constructor(channel: RpcChannel)
177
- linkService<T>(name: string): T
312
+ linkService<T>(name: string): WorkerService<T>
313
+ static ready$(services: Record<string, any>): Observable<boolean>
178
314
  }
179
315
  ```
180
316
 
181
317
  Behavior:
182
- - Caches proxies by service name
183
- - Sends RPC requests with incrementing request ids
184
- - Returns an Observable for each call
185
- - Returned Observable is Promise-like, so you can use `await`
186
- - Subscriptions automatically send cancellation when unsubscribed
187
- - Initializes BehaviorSubject-like remote state via `____initialize____`
188
318
 
189
- ## WorkerService<T>
319
+ - caches proxies by service name
320
+ - assigns incrementing request ids
321
+ - returns an `Observable` that is also `PromiseLike`
322
+ - sends cancellation when a request is unsubscribed early
323
+ - initializes `BehaviorSubject`-style values via `____initialize____`
324
+
325
+ ### `WorkerService<T>`
326
+
327
+ Type helper that converts a worker-side contract into a client-side contract.
328
+
329
+ Rules:
190
330
 
191
- Type helper that maps a service contract into client-consumable types:
192
- - `BehaviorSubject<U>` stays `BehaviorSubject<U>`
193
- - `Observable<U>` stays `Observable<U>`
194
- - Methods become async-compatible call signatures
331
+ - `BehaviorSubject<T>` stays `BehaviorSubject<T>`
332
+ - `Observable<T>` stays `Observable<T>`
333
+ - methods become async call signatures
334
+
335
+ Example:
195
336
 
196
337
  ```ts
197
- type WorkerService<T> = {
198
- [K in keyof T]: ...
199
- }
338
+ type CounterClient = WorkerService<CounterService>
200
339
  ```
201
340
 
202
- Use it to get strong typing for linked services.
341
+ ## Utility Helpers
203
342
 
204
- ## StorageBehaviorSubject<T>
343
+ ### `StorageBehaviorSubject<T>`
205
344
 
206
345
  `BehaviorSubject` with persistence hooks.
207
346
 
@@ -217,55 +356,62 @@ class StorageBehaviorSubject<T> extends BehaviorSubject<T> {
217
356
  }
218
357
  ```
219
358
 
220
- Behavior:
221
- - Reads initial value from storage
222
- - Supports sync or async `getItem`
223
- - Writes on every `next`
224
-
225
359
  Example:
226
360
 
227
361
  ```ts
228
362
  const storage = {
229
363
  getItem: <T>(key: string) => JSON.parse(localStorage.getItem(key) || "null") as T | undefined,
230
- setItem: <T>(key: string, value: T) => localStorage.setItem(key, JSON.stringify(value)),
364
+ setItem: <T>(key: string, value: T) => {
365
+ localStorage.setItem(key, JSON.stringify(value))
366
+ },
231
367
  }
232
368
 
233
369
  const theme$ = new StorageBehaviorSubject(storage, "theme", "light")
234
370
  theme$.next("dark")
235
371
  ```
236
372
 
237
- ## LimitConcurrency
238
-
239
- Decorator factory for queuing decorated method calls and executing them through an internal stream.
373
+ ### `LimitConcurrency`
240
374
 
241
- ```ts
242
- const LimitConcurrency = (limit = 1) => (target, propertyKey, descriptor) => { ... }
243
- ```
244
-
245
- Usage:
375
+ Decorator factory that queues method calls and executes them with a concurrency cap.
246
376
 
247
377
  ```ts
248
- class Api {
249
- @LimitConcurrency(1)
250
- async fetchData(id: string) {
378
+ class ApiService {
379
+ @LimitConcurrency(2)
380
+ async fetchItem(id: string) {
251
381
  return { id }
252
382
  }
253
383
  }
254
384
  ```
255
385
 
256
- Notes:
257
- - Works with values, Promises, and Observables
258
- - Returns an Observable that is also Promise-like
386
+ Useful when you want a service method to:
259
387
 
260
- ## Cancellation Model
388
+ - limit concurrent async work
389
+ - preserve a simple call API
390
+ - still return something `await`-able or subscribable
391
+
392
+ ### `RxjsQueue`
261
393
 
262
- If a client unsubscribes from an in-flight call, `ServiceLinker` sends:
394
+ Simple promise queue built on RxJS operators.
263
395
 
264
396
  ```ts
265
- { id: 0, cancel: { id } }
397
+ const queue = new RxjsQueue(2)
398
+
399
+ const result = await queue.run(async () => {
400
+ return doWork()
401
+ })
266
402
  ```
267
403
 
268
- `WorkerManager` listens for that id and stops streaming output for matching Observable calls.
404
+ Use `updateLimit()` to change concurrency at runtime.
405
+
406
+ ## Constraints And Assumptions
407
+
408
+ Keep these implementation details in mind:
409
+
410
+ - transport is specifically designed around `SharedWorker`
411
+ - service member paths beginning with `#` are treated as invalid
412
+ - missing services return an RPC error response
413
+ - errors are propagated back as `Error(message)` on the client
414
+ - worker-to-client state mirroring is optimized for `BehaviorSubject`-style fields
269
415
 
270
416
  ## Build
271
417
 
@@ -273,13 +419,15 @@ If a client unsubscribes from an in-flight call, `ServiceLinker` sends:
273
419
  bun run build
274
420
  ```
275
421
 
276
- Additional scripts:
277
- - `bun run build:watch`
422
+ Other scripts:
423
+
278
424
  - `bun run clean`
425
+ - `bun run build:js`
426
+ - `bun run build:types`
427
+ - `bun run build:watch`
279
428
 
280
- ## Package Info
429
+ ## Package Output
281
430
 
282
- - Name: `@livequery/rpc`
283
- - ESM output: `dist/index.js`
284
- - Types: `dist/index.d.ts`
285
- - Peer runtime dependency: `rxjs`
431
+ - ESM entry: `dist/index.js`
432
+ - type declarations: `dist/index.d.ts`
433
+ - package name: `@livequery/rpc`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livequery/rpc",
3
- "version": "2.0.81",
3
+ "version": "2.0.92",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",