@bigmistqke/rpc 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigmistqke/rpc",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "RPC toolkit for type-safe communication across Workers, iframes, and network boundaries.",
6
6
  "module": "./dist/index.js",
@@ -10,6 +10,10 @@
10
10
  "import": "./dist/messenger.js",
11
11
  "types": "./dist/messenger.d.ts"
12
12
  },
13
+ "./websocket": {
14
+ "import": "./dist/websocket.js",
15
+ "types": "./dist/websocket.d.ts"
16
+ },
13
17
  "./stream": {
14
18
  "import": "./dist/stream.js",
15
19
  "types": "./dist/stream.d.ts"
@@ -23,6 +27,13 @@
23
27
  "types": "./dist/fetch-node.d.ts"
24
28
  }
25
29
  },
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "test": "vitest run",
33
+ "types": "pnpm exec tsc --noEmit",
34
+ "bump": "bumpp",
35
+ "prepublishOnly": "pnpm test && pnpm build"
36
+ },
26
37
  "license": "MIT",
27
38
  "devDependencies": {
28
39
  "tsup": "6.6.3",
@@ -33,10 +44,5 @@
33
44
  "@types/node": "^22.15.30",
34
45
  "bumpp": "^10.3.2",
35
46
  "valibot": "^1.0.0"
36
- },
37
- "scripts": {
38
- "build": "tsup",
39
- "test": "vitest run",
40
- "bump": "bumpp"
41
47
  }
42
- }
48
+ }
package/src/core.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { HANDLE_NAMESPACE_PREFIX, isHandleMarker, nextHandleNamespaceId } from './handle'
2
+ import { $MESSENGER_HANDLE, HandleResponseShape } from './protocol'
3
+ import type { RPC } from './types'
4
+
5
+ export function createCommander<T extends object = object>(
6
+ apply: (topics: Array<string>, args: Array<any>) => void,
7
+ ): T {
8
+ function _createCommander(
9
+ topics: Array<string>,
10
+ apply: (topics: Array<string>, args: Array<any>) => void,
11
+ ): T {
12
+ return new Proxy(function () {} as T, {
13
+ get(target, topic) {
14
+ if (typeof topic === 'symbol') return (target as any)[topic]
15
+ // Return undefined for 'then' so proxy isn't treated as thenable
16
+ if (topic === 'then') return undefined
17
+ return _createCommander([...topics, topic], apply)
18
+ },
19
+ apply(_, __, args) {
20
+ return apply(topics, args)
21
+ },
22
+ })
23
+ }
24
+ return _createCommander([], apply)
25
+ }
26
+
27
+ export function callMethod(methods: object, topics: string[], args: unknown[]) {
28
+ const method = topics.reduce((acc, topic) => {
29
+ const result = (acc as any)?.[topic]
30
+ return result
31
+ }, methods)
32
+ if (typeof method !== 'function') {
33
+ throw new Error(`Topics did not resolve to a function: [${topics.join(',')}]`)
34
+ }
35
+ return method(...args)
36
+ }
37
+
38
+ /**
39
+ * Check if a response payload is a handle response
40
+ */
41
+ export function isHandleResponse(value: unknown): value is { [$MESSENGER_HANDLE]: string } {
42
+ return (
43
+ !!value &&
44
+ typeof value === 'object' &&
45
+ $MESSENGER_HANDLE in value &&
46
+ typeof (value as any)[$MESSENGER_HANDLE] === 'string'
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Creates a request handler for expose() that manages namespace routing and handle() sub-proxies.
52
+ * Returns the processed result ready to be sent back over the transport.
53
+ */
54
+ export function createExposeRequestHandler(methods: object) {
55
+ const namespaceHandlers = new Map<string, object>()
56
+
57
+ const processResult = (result: unknown): unknown => {
58
+ if (isHandleMarker(result)) {
59
+ const namespaceId = nextHandleNamespaceId()
60
+ namespaceHandlers.set(namespaceId, result.methods)
61
+ return HandleResponseShape.create(namespaceId)
62
+ }
63
+ return result
64
+ }
65
+
66
+ return async (topics: string[], args: unknown[]): Promise<unknown> => {
67
+ const firstTopic = topics[0]
68
+ if (firstTopic && firstTopic.startsWith(HANDLE_NAMESPACE_PREFIX)) {
69
+ const handler = namespaceHandlers.get(firstTopic)
70
+ if (!handler) {
71
+ throw new Error(`Unknown namespace: ${firstTopic}`)
72
+ }
73
+ const result = await callMethod(handler, topics.slice(1), args)
74
+ return processResult(result)
75
+ }
76
+ const result = await callMethod(methods, topics, args)
77
+ return processResult(result)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Creates an RPC commander proxy that handles handle() sub-proxy responses.
83
+ */
84
+ export function createRpcCommander<T extends object>(
85
+ request: (topics: string[], args: any[]) => Promise<unknown>,
86
+ topicPrefix: string[] = [],
87
+ ): RPC<T> {
88
+ return createCommander<RPC<T>>((topics, methodArgs) => {
89
+ const fullTopics = [...topicPrefix, ...topics]
90
+ return request(fullTopics, methodArgs).then((result: unknown) => {
91
+ if (isHandleResponse(result)) {
92
+ return createRpcCommander(
93
+ request,
94
+ result[$MESSENGER_HANDLE] ? [result[$MESSENGER_HANDLE]] : [],
95
+ )
96
+ }
97
+ return result
98
+ })
99
+ })
100
+ }
@@ -1,6 +1,7 @@
1
1
  import * as v from 'valibot'
2
2
  import { RPC } from '../types'
3
- import { callMethod, createCommander, createShape } from '../utils'
3
+ import { callMethod, createCommander } from '../core'
4
+ import { createShape } from '../utils'
4
5
 
5
6
  const $FETCH_HEADER = 'RPC_RR_PROXY'
6
7
 
package/src/fetch/node.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { IncomingMessage, ServerResponse } from 'http'
4
4
  import { Payload } from '.'
5
- import { callMethod } from '../utils'
5
+ import { callMethod } from '../core'
6
6
 
7
7
  export function exposeNode<T extends object>(
8
8
  methods: T,
package/src/handle.ts ADDED
@@ -0,0 +1,48 @@
1
+ const $HANDLE_MARKER = Symbol('RPC-HANDLE-MARKER')
2
+
3
+ /** Internal marker type for handle() */
4
+ interface HandleMarker<T extends object> {
5
+ [$HANDLE_MARKER]: true
6
+ methods: T
7
+ }
8
+
9
+ /** Type for values returned by handle() - unwrapped to RPC<T> by RPC system */
10
+ export type Handled<T extends object> = T & { readonly ['__rpc_handled__']: T }
11
+
12
+ /**
13
+ * Mark methods to be returned as a sub-proxy from an RPC method.
14
+ * Use this when a method needs to return an object with callable methods.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * expose({
19
+ * init(canvas: OffscreenCanvas) {
20
+ * const renderer = createRenderer(canvas)
21
+ * return handle({
22
+ * render: () => renderer.render(),
23
+ * resize: (w, h) => renderer.resize(w, h),
24
+ * })
25
+ * }
26
+ * })
27
+ * ```
28
+ */
29
+ export function handle<T extends object>(methods: T): Handled<T> {
30
+ return { [$HANDLE_MARKER]: true, methods } as unknown as Handled<T>
31
+ }
32
+
33
+ /**
34
+ * Check if a value is a handle marker
35
+ */
36
+ export function isHandleMarker<T extends object>(value: unknown): value is HandleMarker<T> {
37
+ return !!value && typeof value === 'object' && $HANDLE_MARKER in value
38
+ }
39
+
40
+ // Prefix for namespace IDs to avoid collisions
41
+ export const HANDLE_NAMESPACE_PREFIX = '__rpc_handle_'
42
+
43
+ // Counter for generating unique namespace IDs
44
+ export let handleNamespaceCounter = 0
45
+
46
+ export function nextHandleNamespaceId(): string {
47
+ return `${HANDLE_NAMESPACE_PREFIX}${handleNamespaceCounter++}`
48
+ }
@@ -1,3 +1,4 @@
1
+ import { createExposeRequestHandler, createRpcCommander } from '../core'
1
2
  import {
2
3
  $MESSENGER_ERROR,
3
4
  $MESSENGER_RESPONSE,
@@ -6,11 +7,61 @@ import {
6
7
  RequestShape,
7
8
  ResponseShape,
8
9
  RPCPayloadShape,
9
- } from './message-protocol'
10
- import { RPC } from './types'
11
- import { callMethod, createCommander, createIdRegistry, defer } from './utils'
10
+ } from '../protocol'
11
+ import type { RPC } from '../types'
12
+ import { createIdRegistry, defer } from '../utils'
13
+ export { handle, type Handled } from '../handle'
12
14
 
13
- export const $TRANSFER = 'WORKER-TRANSFER'
15
+ export const $TRANSFER = 'RPC-TRANSFER'
16
+ export const $MESSENGER = Symbol.for('RPC-MESSENGER')
17
+
18
+ /** Wrapper type for transferable values */
19
+ export type Transferred<T> = T & {
20
+ [$TRANSFER]: true
21
+ }
22
+
23
+ /**
24
+ * Mark a value as transferable for postMessage.
25
+ * Use this to transfer ownership of ArrayBuffer, ReadableStream, etc.
26
+ */
27
+ export function transfer<T extends object>(value: T): Transferred<T> {
28
+ return Object.assign(value, { [$TRANSFER]: true } as const)
29
+ }
30
+
31
+ /**
32
+ * Check if a value is marked for transfer
33
+ */
34
+ function isTransferred(value: unknown): value is Transferred<unknown> {
35
+ return !!value && typeof value === 'object' && $TRANSFER in value
36
+ }
37
+
38
+ /**
39
+ * Extract transferables from args and unwrap transferred values
40
+ */
41
+ function extractTransferables(args: any[]): { args: any[]; transferables: Transferable[] } {
42
+ const transferables: Transferable[] = []
43
+
44
+ const processValue = (value: any): any => {
45
+ if (isTransferred(value)) {
46
+ transferables.push(value)
47
+ return value
48
+ }
49
+ if (Array.isArray(value)) {
50
+ return value.map(processValue)
51
+ }
52
+ if (value && typeof value === 'object' && value.constructor === Object) {
53
+ const result: any = {}
54
+ for (const key in value) {
55
+ result[key] = processValue(value[key])
56
+ }
57
+ return result
58
+ }
59
+ return value
60
+ }
61
+
62
+ const processedArgs = args.map(processValue)
63
+ return { args: processedArgs, transferables }
64
+ }
14
65
 
15
66
  interface WorkerMessenger {
16
67
  postMessage(message: any, transferables?: any[]): void
@@ -115,7 +166,12 @@ export function createResponder(
115
166
  if (RequestShape.validate(data)) {
116
167
  try {
117
168
  const result = await callback(data)
118
- postMessage(ResponseShape.create(data, result))
169
+ // Extract transferables from the result
170
+ const {
171
+ args: [processedResult],
172
+ transferables,
173
+ } = extractTransferables([result])
174
+ postMessage(ResponseShape.create(data, processedResult), transferables)
119
175
  } catch (error) {
120
176
  postMessage(ErrorShape.create(data, error))
121
177
  }
@@ -136,29 +192,64 @@ export function createResponder(
136
192
  /**********************************************************************************/
137
193
 
138
194
  /**
139
- * Exposes a set of methods as an RPC endpoint over the given messenger.
195
+ * Exposes methods as an RPC endpoint over the given messenger.
140
196
  *
141
- * @param methods - An object containing functions to expose
197
+ * @param methods - Object containing methods to expose
142
198
  * @param options - Optional target Messenger and abort signal
199
+ *
200
+ * @example
201
+ * ```ts
202
+ * // Worker side - simple methods
203
+ * expose({
204
+ * add: (a, b) => a + b,
205
+ * multiply: (a, b) => a * b,
206
+ * })
207
+ *
208
+ * // Worker side - with initialization returning sub-proxy
209
+ * expose({
210
+ * init(canvas: OffscreenCanvas) {
211
+ * const renderer = createRenderer(canvas)
212
+ * return handle({
213
+ * render: () => renderer.render(),
214
+ * resize: (w, h) => renderer.resize(w, h),
215
+ * })
216
+ * }
217
+ * })
218
+ * ```
143
219
  */
144
- export function expose<T extends object>(
145
- methods: T,
220
+ export function expose<TMethods extends object>(
221
+ methods: TMethods,
146
222
  { to = self, signal }: { to?: Messenger; signal?: AbortSignal } = {},
147
- ) {
148
- createResponder(
149
- to,
150
- data => {
151
- if (RPCPayloadShape.validate(data.payload)) {
223
+ ): void {
224
+ const postMessage = usePostMessage(to)
225
+ const handleRequest = createExposeRequestHandler(methods)
226
+
227
+ to.addEventListener(
228
+ 'message',
229
+ async event => {
230
+ const data = (event as MessageEvent).data
231
+ if (RequestShape.validate(data)) {
152
232
  try {
153
- const { topics, args } = data.payload
154
- return callMethod(methods, topics, args)
233
+ if (RPCPayloadShape.validate(data.payload)) {
234
+ const result = await handleRequest(data.payload.topics, data.payload.args)
235
+ const {
236
+ args: [finalResult],
237
+ transferables,
238
+ } = extractTransferables([result])
239
+ postMessage(ResponseShape.create(data, finalResult), transferables)
240
+ }
155
241
  } catch (error) {
156
- console.error('Error while processing rpc request:', error, data.payload, methods)
242
+ console.error('Error while processing rpc request:', error, data.payload)
243
+ postMessage(ErrorShape.create(data, error))
157
244
  }
158
245
  }
159
246
  },
160
247
  { signal },
161
248
  )
249
+
250
+ if ('start' in to) {
251
+ to.start?.()
252
+ }
162
253
  }
163
254
 
164
255
  /**
@@ -166,12 +257,63 @@ export function expose<T extends object>(
166
257
  *
167
258
  * @param messenger - The Messenger to communicate with (e.g. Worker or Window)
168
259
  * @param options - Optional abort signal
169
- * @returns A proxy object that lets you call methods remotely
260
+ * @returns A proxy object for calling remote methods
261
+ *
262
+ * @example
263
+ * ```ts
264
+ * // Create RPC proxy (synchronous)
265
+ * const worker = rpc<WorkerMethods>(new Worker('worker.js'))
266
+ *
267
+ * // Call methods that return handle() get sub-proxies
268
+ * const renderer = await worker.init(transfer(canvas))
269
+ * await renderer.render()
270
+ *
271
+ * // Access underlying messenger
272
+ * worker[$MESSENGER].terminate()
273
+ * ```
170
274
  */
275
+ // Overloads for specific messenger types to enable proper type inference
171
276
  export function rpc<T extends object>(
172
- messenger: Messenger,
277
+ messenger: Worker,
278
+ options?: { signal?: AbortSignal },
279
+ ): RPC<T> & { [$MESSENGER]: Worker }
280
+
281
+ export function rpc<T extends object>(
282
+ messenger: MessagePort,
283
+ options?: { signal?: AbortSignal },
284
+ ): RPC<T> & { [$MESSENGER]: MessagePort }
285
+
286
+ export function rpc<T extends object>(
287
+ messenger: Window,
173
288
  options?: { signal?: AbortSignal },
174
- ): RPC<T> {
289
+ ): RPC<T> & { [$MESSENGER]: Window }
290
+
291
+ export function rpc<T extends object>(
292
+ messenger: BroadcastChannel,
293
+ options?: { signal?: AbortSignal },
294
+ ): RPC<T> & { [$MESSENGER]: BroadcastChannel }
295
+
296
+ export function rpc<T extends object>(
297
+ messenger: ServiceWorker,
298
+ options?: { signal?: AbortSignal },
299
+ ): RPC<T> & { [$MESSENGER]: ServiceWorker }
300
+
301
+ export function rpc<T extends object, M extends Messenger = Messenger>(
302
+ messenger: M,
303
+ options?: { signal?: AbortSignal },
304
+ ): RPC<T> & { [$MESSENGER]: M }
305
+
306
+ // Implementation
307
+ export function rpc<T extends object, M extends Messenger>(
308
+ messenger: M,
309
+ options?: { signal?: AbortSignal },
310
+ ): RPC<T> & { [$MESSENGER]: M } {
175
311
  const request = createRequester(messenger, options)
176
- return createCommander<RPC<T>>((topics, args) => request(RPCPayloadShape.create(topics, args)))
312
+
313
+ const proxy = createRpcCommander<T>((topics, args) => {
314
+ const { args: processedArgs, transferables } = extractTransferables(args)
315
+ return request(RPCPayloadShape.create(topics, processedArgs), transferables)
316
+ })
317
+
318
+ return Object.assign(proxy, { [$MESSENGER]: messenger })
177
319
  }
@@ -22,7 +22,7 @@ export const $MESSENGER_RESPONSE = 'RPC_PROXY_RESPONSE'
22
22
  export const ResponseShape = createShape(
23
23
  v.object({
24
24
  [$MESSENGER_RESPONSE]: v.number(),
25
- payload: v.unknown(),
25
+ payload: v.optional(v.unknown()),
26
26
  }),
27
27
  (request: RequestData, payload: any) => ({
28
28
  [$MESSENGER_RESPONSE]: request[$MESSENGER_REQUEST],
@@ -55,3 +55,13 @@ export const RPCPayloadShape = createShape(
55
55
  }),
56
56
  (topics: Array<string>, args: Array<any>) => ({ [$MESSENGER_RPC_REQUEST]: true, topics, args }),
57
57
  )
58
+
59
+ // Handle response - when a method returns handle(), client creates sub-proxy
60
+ export const $MESSENGER_HANDLE = 'RPC_PROXY_HANDLE'
61
+
62
+ export const HandleResponseShape = createShape(
63
+ v.object({
64
+ [$MESSENGER_HANDLE]: v.string(), // namespace ID
65
+ }),
66
+ (namespaceId: string) => ({ [$MESSENGER_HANDLE]: namespaceId }),
67
+ )
@@ -1,7 +1,8 @@
1
- import { $MESSENGER_REQUEST, RequestShape, RPCPayloadShape } from '../message-protocol'
2
1
  import * as v from 'valibot'
2
+ import { $MESSENGER_REQUEST, RequestShape, RPCPayloadShape } from '../protocol'
3
3
  import { RPC } from '../types'
4
- import { callMethod, createCommander, createIdRegistry, createShape, defer } from '../utils'
4
+ import { callMethod, createCommander } from '../core'
5
+ import { createIdRegistry, createShape, defer } from '../utils'
5
6
 
6
7
  const $SSE_RESPONSE_HEADER = 'RPC_SSE_RESPONSE'
7
8
 
@@ -351,10 +351,10 @@ export function createStreamCodec(config: Array<Codec>, fallback: PrimitiveCodec
351
351
  }
352
352
 
353
353
  // helper to concatenate Uint8Arrays
354
- function concat(...arrays: Array<Uint8Array>): Uint8Array {
354
+ function concat(...arrays: Array<Uint8Array>): Uint8Array<ArrayBuffer> {
355
355
  const result = new Uint8Array(new ArrayBuffer(arrays.reduce((a, b) => a + b.length, 0)))
356
356
  let index = 0
357
- return arrays.reduce<Uint8Array>((result, current) => {
357
+ return arrays.reduce<Uint8Array<ArrayBuffer>>((result, current) => {
358
358
  result.set(current, index)
359
359
  index += current.length
360
360
  return result
@@ -1,18 +1,7 @@
1
- import {
2
- $MESSENGER_RESPONSE,
3
- RequestShape,
4
- ResponseShape,
5
- RPCPayloadShape,
6
- } from '../message-protocol'
1
+ import { $MESSENGER_RESPONSE, RequestShape, ResponseShape, RPCPayloadShape } from '../protocol'
7
2
  import { RPC } from '../types'
8
- import {
9
- callMethod,
10
- createCommander,
11
- createPromiseRegistry,
12
- createReadableStream,
13
- defer,
14
- streamToAsyncIterable,
15
- } from '../utils'
3
+ import { callMethod, createCommander } from '../core'
4
+ import { createPromiseRegistry, createReadableStream, defer, streamToAsyncIterable } from '../utils'
16
5
 
17
6
  interface StreamCodec {
18
7
  serialize(value: any, onChunk: (chunk: any) => void): void
package/src/types.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export type Fn = (...arg: Array<any>) => any
2
2
  export type MaybePromise<T> = T | Promise<T>
3
3
 
4
+ /** Check if T is a Handled type and extract the inner type */
5
+ type UnwrapHandled<T> = T extends { readonly ['__rpc_handled__']: infer U extends object }
6
+ ? RPC<U>
7
+ : T
8
+
4
9
  // To prevent error: `Type instantiation is excessively deep and possibly infinite.`
5
10
  type isObject<T> = T extends object ? true : false
6
11
 
@@ -47,7 +52,7 @@ type FilterNoResponseMethod<T> = {
47
52
  /**********************************************************************************/
48
53
 
49
54
  export interface ResponseMethod<T extends Fn> {
50
- (...args: Parameters<T>): Promise<ReturnType<T>>
55
+ (...args: Parameters<T>): Promise<UnwrapHandled<Awaited<ReturnType<T>>>>
51
56
  }
52
57
 
53
58
  export type ResponseRPCNode<T> = T extends Fn
package/src/utils.ts CHANGED
@@ -58,26 +58,6 @@ export function defer<T = void>() {
58
58
  }
59
59
  }
60
60
 
61
- export function createCommander<T extends object = object>(
62
- apply: (topics: Array<string>, args: Array<any>) => void,
63
- ): T {
64
- function _createCommander(
65
- topics: Array<string>,
66
- apply: (topics: Array<string>, args: Array<any>) => void,
67
- ): T {
68
- return new Proxy(function () {} as T, {
69
- get(_, topic) {
70
- if (typeof topic === 'symbol') return undefined
71
- return _createCommander([...topics, topic], apply)
72
- },
73
- apply(_, __, args) {
74
- return apply(topics, args)
75
- },
76
- })
77
- }
78
- return _createCommander([], apply)
79
- }
80
-
81
61
  /**
82
62
  * Creates a schema-backed shape definition with a validator and constructor.
83
63
  *
@@ -94,18 +74,6 @@ export function createShape<
94
74
  }
95
75
  }
96
76
 
97
- // expose-core.ts
98
- export function callMethod(methods: object, topics: string[], args: unknown[]) {
99
- const method = topics.reduce((acc, topic) => {
100
- const result = (acc as any)?.[topic]
101
- return result
102
- }, methods)
103
- if (typeof method !== 'function') {
104
- throw new Error(`Topics did not resolve to a function: [${topics.join(',')}]`)
105
- }
106
- return method(...args)
107
- }
108
-
109
77
  // NOTE: safari does not implement AsyncIterator for ReadableStream
110
78
  // see https://caniuse.com/mdn-api_readablestream_--asynciterator
111
79
  export function streamToAsyncIterable<T>(stream: ReadableStream<T>): AsyncIterable<T> {