@bigmistqke/rpc 0.1.3 → 0.1.5

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/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.call(methods, ...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,13 +7,13 @@ import {
6
7
  RequestShape,
7
8
  ResponseShape,
8
9
  RPCPayloadShape,
9
- } from './message-protocol'
10
- import { RPC } from './types'
11
- export type { RPC } from './types'
12
- 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'
13
14
 
14
15
  export const $TRANSFER = 'RPC-TRANSFER'
15
- export const $MESSENGER = Symbol('RPC-MESSENGER')
16
+ export const $MESSENGER = Symbol.for('RPC-MESSENGER')
16
17
 
17
18
  /** Wrapper type for transferable values */
18
19
  export type Transferred<T> = T & {
@@ -166,7 +167,10 @@ export function createResponder(
166
167
  try {
167
168
  const result = await callback(data)
168
169
  // Extract transferables from the result
169
- const { args: [processedResult], transferables } = extractTransferables([result])
170
+ const {
171
+ args: [processedResult],
172
+ transferables,
173
+ } = extractTransferables([result])
170
174
  postMessage(ResponseShape.create(data, processedResult), transferables)
171
175
  } catch (error) {
172
176
  postMessage(ErrorShape.create(data, error))
@@ -188,29 +192,64 @@ export function createResponder(
188
192
  /**********************************************************************************/
189
193
 
190
194
  /**
191
- * Exposes a set of methods as an RPC endpoint over the given messenger.
195
+ * Exposes methods as an RPC endpoint over the given messenger.
192
196
  *
193
- * @param methods - An object containing functions to expose
197
+ * @param methods - Object containing methods to expose
194
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
+ * ```
195
219
  */
196
- export function expose<T extends object>(
197
- methods: T,
220
+ export function expose<TMethods extends object>(
221
+ methods: TMethods,
198
222
  { to = self, signal }: { to?: Messenger; signal?: AbortSignal } = {},
199
- ) {
200
- createResponder(
201
- to,
202
- data => {
203
- 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)) {
204
232
  try {
205
- const { topics, args } = data.payload
206
- 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
+ }
207
241
  } catch (error) {
208
- 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))
209
244
  }
210
245
  }
211
246
  },
212
247
  { signal },
213
248
  )
249
+
250
+ if ('start' in to) {
251
+ to.start?.()
252
+ }
214
253
  }
215
254
 
216
255
  /**
@@ -218,18 +257,19 @@ export function expose<T extends object>(
218
257
  *
219
258
  * @param messenger - The Messenger to communicate with (e.g. Worker or Window)
220
259
  * @param options - Optional abort signal
221
- * @returns A proxy object that lets you call methods remotely
260
+ * @returns A proxy object for calling remote methods
222
261
  *
223
262
  * @example
224
263
  * ```ts
225
- * const worker = new Worker('worker.js')
226
- * const api = rpc<WorkerAPI>(worker)
264
+ * // Create RPC proxy (synchronous)
265
+ * const worker = rpc<WorkerMethods>(new Worker('worker.js'))
227
266
  *
228
- * // Call remote methods
229
- * await api.doSomething()
267
+ * // Call methods that return handle() get sub-proxies
268
+ * const renderer = await worker.init(transfer(canvas))
269
+ * await renderer.render()
230
270
  *
231
271
  * // Access underlying messenger
232
- * api[$MESSENGER].terminate()
272
+ * worker[$MESSENGER].terminate()
233
273
  * ```
234
274
  */
235
275
  // Overloads for specific messenger types to enable proper type inference
@@ -269,9 +309,11 @@ export function rpc<T extends object, M extends Messenger>(
269
309
  options?: { signal?: AbortSignal },
270
310
  ): RPC<T> & { [$MESSENGER]: M } {
271
311
  const request = createRequester(messenger, options)
272
- const proxy = createCommander<RPC<T>>((topics, args) => {
312
+
313
+ const proxy = createRpcCommander<T>((topics, args) => {
273
314
  const { args: processedArgs, transferables } = extractTransferables(args)
274
315
  return request(RPCPayloadShape.create(topics, processedArgs), transferables)
275
316
  })
317
+
276
318
  return Object.assign(proxy, { [$MESSENGER]: messenger })
277
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
 
@@ -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<Awaited<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(target, topic) {
70
- if (typeof topic === 'symbol') return (target as any)[topic]
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> {
@@ -0,0 +1,142 @@
1
+ import * as v from 'valibot'
2
+ import { createExposeRequestHandler, createRpcCommander } from '../core'
3
+ import {
4
+ $MESSENGER_ERROR,
5
+ $MESSENGER_RESPONSE,
6
+ ErrorShape,
7
+ RequestShape,
8
+ ResponseShape,
9
+ RPCPayloadShape,
10
+ } from '../protocol'
11
+ import { RPC as BaseRPC } from '../types'
12
+ import { createIdRegistry, createShape, defer } from '../utils'
13
+ export { handle, type Handled } from '../handle'
14
+
15
+ export const $WEBSOCKET = Symbol.for('RPC-WEBSOCKET')
16
+
17
+ export interface WebSocketLike {
18
+ send(data: string): void
19
+ close(): void
20
+ addEventListener(type: string, listener: (event: unknown) => void): void
21
+ }
22
+
23
+ export type RPC<T extends object, WS extends WebSocketLike = WebSocketLike> = BaseRPC<T> & {
24
+ [$WEBSOCKET]: WS
25
+ }
26
+
27
+ /**********************************************************************************/
28
+ /* */
29
+ /* Requester / Responder */
30
+ /* */
31
+ /**********************************************************************************/
32
+
33
+ const MessageEventShape = createShape(v.object({ data: v.unknown() }), (data: unknown) => ({
34
+ data,
35
+ }))
36
+
37
+ function parseData(raw: unknown): unknown {
38
+ return JSON.parse(typeof raw === 'string' ? raw : String(raw))
39
+ }
40
+
41
+ function createRequester(ws: WebSocketLike) {
42
+ const promiseRegistry = createIdRegistry<{
43
+ resolve(value: any): void
44
+ reject(value: unknown): void
45
+ }>()
46
+
47
+ ws.addEventListener('message', (event: unknown) => {
48
+ if (!MessageEventShape.validate(event)) return
49
+ try {
50
+ const data = parseData(event.data)
51
+ if (ErrorShape.validate(data)) {
52
+ promiseRegistry.free(data[$MESSENGER_ERROR])?.reject(data.error)
53
+ } else if (ResponseShape.validate(data)) {
54
+ promiseRegistry.free(data[$MESSENGER_RESPONSE])?.resolve(data.payload)
55
+ }
56
+ } catch (error) {
57
+ console.error(error)
58
+ }
59
+ })
60
+
61
+ return (payload: any) => {
62
+ const { promise, resolve, reject } = defer()
63
+ const id = promiseRegistry.register({ resolve, reject })
64
+ ws.send(JSON.stringify(RequestShape.create(id, payload)))
65
+ return promise
66
+ }
67
+ }
68
+
69
+ /**********************************************************************************/
70
+ /* */
71
+ /* Expose / Rpc */
72
+ /* */
73
+ /**********************************************************************************/
74
+
75
+ /**
76
+ * Exposes methods as an RPC endpoint over the given WebSocket.
77
+ *
78
+ * @param methods - Object containing methods to expose
79
+ * @param options - Target WebSocket and optional abort signal
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * expose({
84
+ * add: (a, b) => a + b,
85
+ * multiply: (a, b) => a * b,
86
+ * }, { to: ws })
87
+ * ```
88
+ */
89
+ export function expose<TMethods extends object>(
90
+ methods: TMethods,
91
+ { to }: { to: WebSocketLike },
92
+ ): void {
93
+ const handleRequest = createExposeRequestHandler(methods)
94
+
95
+ to.addEventListener('message', async (event: unknown) => {
96
+ if (!MessageEventShape.validate(event)) return
97
+ try {
98
+ const data = parseData(event.data)
99
+ if (RequestShape.validate(data)) {
100
+ try {
101
+ if (RPCPayloadShape.validate(data.payload)) {
102
+ const result = await handleRequest(data.payload.topics, data.payload.args)
103
+ to.send(JSON.stringify(ResponseShape.create(data, result)))
104
+ }
105
+ } catch (error) {
106
+ console.error('Error while processing rpc request:', error, data.payload)
107
+ to.send(JSON.stringify(ErrorShape.create(data, error)))
108
+ }
109
+ }
110
+ } catch (error) {
111
+ console.error(error)
112
+ }
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Creates an RPC proxy for calling remote methods on the given WebSocket.
118
+ *
119
+ * @param ws - The WebSocket to communicate with
120
+ * @param options - Optional abort signal
121
+ * @returns A proxy object for calling remote methods
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const server = rpc<ServerMethods>(ws)
126
+ * const result = await server.add(2, 3)
127
+ *
128
+ * // Access underlying WebSocket
129
+ * server[$WEBSOCKET].close()
130
+ * ```
131
+ */
132
+ export function rpc<T extends object, WS extends WebSocketLike = WebSocketLike>(
133
+ ws: WS,
134
+ ): RPC<T, WS> {
135
+ const request = createRequester(ws)
136
+
137
+ const proxy = createRpcCommander<T>((topics, args) => {
138
+ return request(RPCPayloadShape.create(topics, args))
139
+ })
140
+
141
+ return Object.assign(proxy, { [$WEBSOCKET]: ws }) as RPC<T, WS>
142
+ }