@bigmistqke/rpc 0.1.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@bigmistqke/rpc",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "RPC toolkit for type-safe communication across Workers, iframes, and network boundaries.",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ "./messenger": {
10
+ "import": "./dist/messenger.js",
11
+ "types": "./dist/messenger.d.ts"
12
+ },
13
+ "./stream": {
14
+ "import": "./dist/stream/index.js",
15
+ "types": "./dist/stream/index.d.ts"
16
+ },
17
+ "./fetch": {
18
+ "import": "./dist/fetch/index.js",
19
+ "types": "./dist/fetch/index.d.ts"
20
+ },
21
+ "./fetch/node": {
22
+ "import": "./dist/fetch/node.js",
23
+ "types": "./dist/fetch/node.d.ts"
24
+ }
25
+ },
26
+ "license": "MIT",
27
+ "devDependencies": {
28
+ "tsup": "6.6.3",
29
+ "typescript": "^5.7.2",
30
+ "vitest": "0.28.5"
31
+ },
32
+ "dependencies": {
33
+ "@types/node": "^22.15.30",
34
+ "bumpp": "^10.3.2",
35
+ "valibot": "^1.0.0"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "test": "vitest run",
40
+ "bump": "bumpp"
41
+ }
42
+ }
@@ -0,0 +1,84 @@
1
+ import * as v from 'valibot'
2
+ import { RPC } from '../types'
3
+ import { callMethod, createCommander, createShape } from '../utils'
4
+
5
+ const $FETCH_HEADER = 'RPC_RR_PROXY'
6
+
7
+ export const isFetchRequest = (event: { request: Request }) =>
8
+ event.request.headers.has($FETCH_HEADER)
9
+
10
+ export const Payload = createShape(
11
+ v.object({ args: v.array(v.any()), topics: v.array(v.string()) }),
12
+ (topics: Array<string>, args: Array<any>) => ({ topics, args }),
13
+ )
14
+
15
+ /**********************************************************************************/
16
+ /* */
17
+ /* Expose / Rpc */
18
+ /* */
19
+ /**********************************************************************************/
20
+
21
+ /**
22
+ * Exposes a set of methods as an RPC endpoint over the given messenger.
23
+ *
24
+ * @param methods - An object containing functions to expose
25
+ * @param options - Optional target Messenger and abort signal
26
+ */
27
+ export function expose<T extends object>(methods: T) {
28
+ return async (event: { request: Request }) => {
29
+ try {
30
+ const json = await event.request.json()
31
+
32
+ if (!Payload.validate(json)) {
33
+ throw new Error(`Incorrect shape`)
34
+ }
35
+ const { args, topics } = json
36
+
37
+ const payload = await callMethod(methods, topics, args)
38
+ return new Response(JSON.stringify({ payload }), {
39
+ status: 200,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ })
42
+ } catch (error) {
43
+ return new Response(null, {
44
+ statusText:
45
+ typeof error === 'string'
46
+ ? error
47
+ : typeof error === 'object' &&
48
+ error &&
49
+ 'message' in error &&
50
+ typeof error.message === 'string'
51
+ ? error.message
52
+ : undefined,
53
+ status: 500,
54
+ headers: { 'Content-Type': 'application/json' },
55
+ })
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Creates an RPC proxy for calling remote methods on the given Messenger.
62
+ *
63
+ * @param messenger - The Messenger to communicate with (e.g. Worker or Window)
64
+ * @param options - Optional abort signal
65
+ * @returns A proxy object that lets you call methods remotely
66
+ */
67
+ export function rpc<T extends object>(base: string): RPC<T> {
68
+ return createCommander<RPC<T>>(async (topics, args) => {
69
+ const result = await fetch(
70
+ new Request(`${base}/${topics.join('/')}`, {
71
+ method: topics.length > 0 ? 'POST' : 'GET',
72
+ headers: { [$FETCH_HEADER]: 'true' },
73
+ body: JSON.stringify(Payload.create(topics, args)),
74
+ }),
75
+ )
76
+
77
+ if (result.status !== 200) {
78
+ throw result.statusText
79
+ }
80
+
81
+ const { payload } = await result.json()
82
+ return payload
83
+ })
84
+ }
@@ -0,0 +1,44 @@
1
+ /// <reference types="node" />
2
+
3
+ import { IncomingMessage, ServerResponse } from 'http'
4
+ import { Payload } from '.'
5
+ import { callMethod } from '../utils'
6
+
7
+ export function exposeNode<T extends object>(
8
+ methods: T,
9
+ {
10
+ wrap = (res: ServerResponse, result: any) => {
11
+ res.writeHead(200, { 'Content-Type': 'application/json' })
12
+ res.end(JSON.stringify(result))
13
+ },
14
+ unwrap = async (req: IncomingMessage): Promise<{ topics: string[]; args: any[] }> => {
15
+ const body = await new Promise<string>((resolve, reject) => {
16
+ let data = ''
17
+ req.on('data', chunk => (data += chunk))
18
+ req.on('end', () => resolve(data))
19
+ req.on('error', reject)
20
+ })
21
+
22
+ const json = JSON.parse(body)
23
+ if (!Payload.validate(json)) {
24
+ throw new Error(`Incorrect shape`)
25
+ }
26
+
27
+ return json
28
+ },
29
+ }: {
30
+ wrap?: (res: ServerResponse, result: any) => void
31
+ unwrap?: (req: IncomingMessage) => Promise<{ topics: string[]; args: any[] }>
32
+ } = {},
33
+ ) {
34
+ return async (req: IncomingMessage, res: ServerResponse) => {
35
+ try {
36
+ const { topics, args } = await unwrap(req)
37
+ const result = await callMethod(methods, topics, args)
38
+ wrap(res, result)
39
+ } catch (err: any) {
40
+ res.writeHead(500)
41
+ res.statusMessage = err?.message
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,57 @@
1
+ import * as v from 'valibot'
2
+ import { createShape } from './utils'
3
+
4
+ // Schema and protocol for requests
5
+ export const $MESSENGER_REQUEST = 'RPC_PROXY_REQUEST'
6
+
7
+ export const requestSchema = v.object({
8
+ [$MESSENGER_REQUEST]: v.number(),
9
+ payload: v.unknown(),
10
+ })
11
+
12
+ export const RequestShape = createShape(requestSchema, <T>(id: number, payload: T) => ({
13
+ [$MESSENGER_REQUEST]: id,
14
+ payload,
15
+ }))
16
+
17
+ export type RequestData = v.InferOutput<typeof requestSchema>
18
+
19
+ // Schema and protocol for responses
20
+ export const $MESSENGER_RESPONSE = 'RPC_PROXY_RESPONSE'
21
+
22
+ export const ResponseShape = createShape(
23
+ v.object({
24
+ [$MESSENGER_RESPONSE]: v.number(),
25
+ payload: v.unknown(),
26
+ }),
27
+ (request: RequestData, payload: any) => ({
28
+ [$MESSENGER_RESPONSE]: request[$MESSENGER_REQUEST],
29
+ payload,
30
+ }),
31
+ )
32
+
33
+ // Schema and protocol of errors
34
+ export const $MESSENGER_ERROR = 'RPC_PROXY_ERROR'
35
+
36
+ export const ErrorShape = createShape(
37
+ v.object({
38
+ [$MESSENGER_ERROR]: v.number(),
39
+ error: v.unknown(),
40
+ }),
41
+ (data: RequestData, error: any) => ({
42
+ [$MESSENGER_ERROR]: data[$MESSENGER_REQUEST],
43
+ error,
44
+ }),
45
+ )
46
+
47
+ // RPC-specific request payload
48
+ export const $MESSENGER_RPC_REQUEST = 'RPC_PROXY_RPC_REQUEST'
49
+
50
+ export const RPCPayloadShape = createShape(
51
+ v.object({
52
+ [$MESSENGER_RPC_REQUEST]: v.boolean(),
53
+ topics: v.array(v.string()),
54
+ args: v.array(v.any()),
55
+ }),
56
+ (topics: Array<string>, args: Array<any>) => ({ [$MESSENGER_RPC_REQUEST]: true, topics, args }),
57
+ )
@@ -0,0 +1,176 @@
1
+ import {
2
+ $MESSENGER_ERROR,
3
+ $MESSENGER_RESPONSE,
4
+ ErrorShape,
5
+ RequestData,
6
+ RequestShape,
7
+ ResponseShape,
8
+ RPCPayloadShape,
9
+ } from './message-protocol'
10
+ import { RPC } from './types'
11
+ import { callMethod, createCommander, createIdRegistry, defer } from './utils'
12
+
13
+ export const $TRANSFER = 'WORKER-TRANSFER'
14
+
15
+ interface WorkerMessenger {
16
+ postMessage(message: any, transferables?: any[]): void
17
+ addEventListener(key: 'message', callback: (event: MessageEvent) => void): void
18
+ start?(): void
19
+ }
20
+
21
+ type Messenger = Window | WorkerMessenger
22
+
23
+ /**********************************************************************************/
24
+ /* */
25
+ /* Utils */
26
+ /* */
27
+ /**********************************************************************************/
28
+
29
+ /**
30
+ * Checks whether the given target is a `Window` object (WindowProxy).
31
+ */
32
+ function isWindowProxy(target: any): target is Window {
33
+ return (
34
+ typeof target === 'object' &&
35
+ typeof target?.postMessage === 'function' &&
36
+ typeof target?.closed === 'boolean'
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Returns a `postMessage` function compatible with both Window and Worker contexts.
42
+ */
43
+ function usePostMessage(messenger: Messenger) {
44
+ if (isWindowProxy(messenger)) {
45
+ return (message: any, transferables?: any[]) =>
46
+ messenger.postMessage(message, '*', transferables)
47
+ } else {
48
+ return (message: any, transferables?: any[]) => messenger.postMessage(message, transferables)
49
+ }
50
+ }
51
+
52
+ /**********************************************************************************/
53
+ /* */
54
+ /* Requester / Responder */
55
+ /* */
56
+ /**********************************************************************************/
57
+
58
+ /**
59
+ * Sets up a requester that sends messages and returns promises resolving when a response is received.
60
+ *
61
+ * @param messenger - The target Messenger to send messages to
62
+ * @param options - Optional abort signal
63
+ * @returns A function to send payloads and await responses
64
+ */
65
+ function createRequester(messenger: Messenger, options: { signal?: AbortSignal } = {}) {
66
+ const promiseRegistry = createIdRegistry<{
67
+ resolve(value: any): void
68
+ reject(value: unknown): void
69
+ }>()
70
+ const postMessage = usePostMessage(messenger)
71
+
72
+ messenger.addEventListener(
73
+ 'message',
74
+ event => {
75
+ const data = (event as MessageEvent<unknown>).data
76
+ if (ErrorShape.validate(data)) {
77
+ promiseRegistry.free(data[$MESSENGER_ERROR])?.reject(data.error)
78
+ } else if (ResponseShape.validate(data)) {
79
+ promiseRegistry.free(data[$MESSENGER_RESPONSE])?.resolve(data.payload)
80
+ }
81
+ },
82
+ options,
83
+ )
84
+
85
+ if ('start' in messenger) {
86
+ messenger.start?.()
87
+ }
88
+
89
+ return (payload: any, transferables?: any[]) => {
90
+ const { promise, resolve, reject } = defer()
91
+ const id = promiseRegistry.register({ resolve, reject })
92
+ postMessage(RequestShape.create(id, payload), transferables)
93
+ return promise
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Sets up a responder that listens for requests and responds with the result of the callback.
99
+ *
100
+ * @param messenger - The Messenger to receive messages from
101
+ * @param callback - A function called with the validated request event
102
+ * @param options - Optional abort signal
103
+ */
104
+ export function createResponder(
105
+ messenger: Messenger,
106
+ callback: (data: RequestData) => any,
107
+ options: { signal?: AbortSignal } = {},
108
+ ) {
109
+ const postMessage = usePostMessage(messenger)
110
+
111
+ messenger.addEventListener(
112
+ 'message',
113
+ event => {
114
+ const data = (event as MessageEvent).data
115
+ if (RequestShape.validate(data)) {
116
+ try {
117
+ postMessage(ResponseShape.create(data, callback(data)))
118
+ } catch (error) {
119
+ postMessage(ErrorShape.create(data, error))
120
+ }
121
+ }
122
+ },
123
+ options,
124
+ )
125
+
126
+ if ('start' in messenger) {
127
+ messenger.start?.()
128
+ }
129
+ }
130
+
131
+ /**********************************************************************************/
132
+ /* */
133
+ /* Expose / Rpc */
134
+ /* */
135
+ /**********************************************************************************/
136
+
137
+ /**
138
+ * Exposes a set of methods as an RPC endpoint over the given messenger.
139
+ *
140
+ * @param methods - An object containing functions to expose
141
+ * @param options - Optional target Messenger and abort signal
142
+ */
143
+ export function expose<T extends object>(
144
+ methods: T,
145
+ { to = self, signal }: { to?: Messenger; signal?: AbortSignal } = {},
146
+ ) {
147
+ createResponder(
148
+ to,
149
+ data => {
150
+ if (RPCPayloadShape.validate(data.payload)) {
151
+ try {
152
+ const { topics, args } = data.payload
153
+ return callMethod(methods, topics, args)
154
+ } catch (error) {
155
+ console.error('Error while processing rpc request:', error, data.payload, methods)
156
+ }
157
+ }
158
+ },
159
+ { signal },
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Creates an RPC proxy for calling remote methods on the given Messenger.
165
+ *
166
+ * @param messenger - The Messenger to communicate with (e.g. Worker or Window)
167
+ * @param options - Optional abort signal
168
+ * @returns A proxy object that lets you call methods remotely
169
+ */
170
+ export function rpc<T extends object>(
171
+ messenger: Messenger,
172
+ options?: { signal?: AbortSignal },
173
+ ): RPC<T> {
174
+ const request = createRequester(messenger, options)
175
+ return createCommander<RPC<T>>((topics, args) => request(RPCPayloadShape.create(topics, args)))
176
+ }
@@ -0,0 +1,129 @@
1
+ import { $MESSENGER_REQUEST, RequestShape, RPCPayloadShape } from '../message-protocol'
2
+ import * as v from 'valibot'
3
+ import { RPC } from '../types'
4
+ import { callMethod, createCommander, createIdRegistry, createShape, defer } from '../utils'
5
+
6
+ const $SSE_RESPONSE_HEADER = 'RPC_SSE_RESPONSE'
7
+
8
+ export const isSSEResponse = (event: { request: Request }) =>
9
+ event.request.headers.has($SSE_RESPONSE_HEADER)
10
+
11
+ export const Payload = createShape(
12
+ v.object({ args: v.array(v.any()), topics: v.array(v.string()) }),
13
+ (topics: Array<string>, args: Array<any>) => ({ topics, args }),
14
+ )
15
+
16
+ /**********************************************************************************/
17
+ /* */
18
+ /* Expose / Rpc */
19
+ /* */
20
+ /**********************************************************************************/
21
+
22
+ /**
23
+ * Exposes a set of methods as an RPC endpoint over the given messenger.
24
+ *
25
+ * @param methods - An object containing functions to expose
26
+ * @param options - Optional target Messenger and abort signal
27
+ */
28
+ export function expose<T extends object>(url: string, methods: T) {
29
+ const sse = new EventSource(url)
30
+ sse.addEventListener('message', async event => {
31
+ try {
32
+ const data = JSON.parse(event.data)
33
+
34
+ if (RequestShape.validate(data) && RPCPayloadShape.validate(data.payload)) {
35
+ const {
36
+ [$MESSENGER_REQUEST]: id,
37
+ payload: { args, topics },
38
+ } = data
39
+
40
+ fetch(url, {
41
+ method: 'POST',
42
+ headers: {
43
+ [$SSE_RESPONSE_HEADER]: id.toString(),
44
+ },
45
+ body: JSON.stringify({ payload: await callMethod(methods, topics, args) }),
46
+ })
47
+ }
48
+ } catch (error) {
49
+ console.error('ERROR', error, event)
50
+ }
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Creates an RPC proxy for calling remote methods on the given Messenger.
56
+ *
57
+ * @param messenger - The Messenger to communicate with (e.g. Worker or Window)
58
+ * @param options - Optional abort signal
59
+ * @returns A proxy object that lets you call methods remotely
60
+ */
61
+ export function rpc<T extends object>() {
62
+ const promiseRegistry = createIdRegistry<{
63
+ resolve(value: any): void
64
+ reject(value: unknown): void
65
+ }>()
66
+
67
+ return {
68
+ create() {
69
+ const encoder = new TextEncoder()
70
+ const closeHandlers = new Set<() => void>()
71
+ let controller: ReadableStreamDefaultController
72
+ let closed = false
73
+
74
+ function send(value: any) {
75
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
76
+ }
77
+
78
+ const stream = new ReadableStream({
79
+ start(_controller) {
80
+ controller = _controller
81
+ },
82
+ cancel() {
83
+ closeHandlers.forEach(handler => handler())
84
+ closed = true
85
+ },
86
+ })
87
+
88
+ return [
89
+ createCommander<RPC<T>>(async (topics, args) => {
90
+ if (closed) {
91
+ throw new Error(`[rpc/sse] Stream is closed.`)
92
+ }
93
+ const { promise, resolve, reject } = defer()
94
+ const id = promiseRegistry.register({ resolve, reject })
95
+ send(RequestShape.create(id, RPCPayloadShape.create(topics, args)))
96
+ return promise
97
+ }),
98
+ {
99
+ get closed() {
100
+ return closed
101
+ },
102
+ onClose(callback: () => void) {
103
+ closeHandlers.add(callback)
104
+ return () => closeHandlers.delete(callback)
105
+ },
106
+ response: new Response(stream, {
107
+ headers: {
108
+ 'Content-Type': 'text/event-stream',
109
+ 'Cache-Control': 'no-cache',
110
+ Connection: 'keep-alive',
111
+ },
112
+ }),
113
+ },
114
+ ] as const
115
+ },
116
+ handleAnswer: async (event: { request: Request }) => {
117
+ const id = event.request.headers.get($SSE_RESPONSE_HEADER)
118
+ if (id !== null) {
119
+ try {
120
+ const { payload } = await event.request.json()
121
+ promiseRegistry.free(+id)?.resolve(payload)
122
+ } catch (error) {
123
+ console.error('ERROR', error, event)
124
+ }
125
+ }
126
+ return new Response()
127
+ },
128
+ }
129
+ }