@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.
@@ -0,0 +1,316 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { expose, rpc, createResponder } from '../src/messenger'
3
+ import {
4
+ $MESSENGER_REQUEST,
5
+ $MESSENGER_RESPONSE,
6
+ $MESSENGER_ERROR,
7
+ $MESSENGER_RPC_REQUEST,
8
+ } from '../src/message-protocol'
9
+
10
+ // Mock MessagePort
11
+ function createMockMessagePort() {
12
+ const handlers: Array<(event: MessageEvent) => void> = []
13
+ return {
14
+ postMessage: vi.fn(),
15
+ addEventListener: vi.fn((type: string, handler: (event: MessageEvent) => void) => {
16
+ if (type === 'message') {
17
+ handlers.push(handler)
18
+ }
19
+ }),
20
+ removeEventListener: vi.fn(),
21
+ start: vi.fn(),
22
+ _emit(data: any) {
23
+ handlers.forEach(h => h({ data } as MessageEvent))
24
+ },
25
+ _handlers: handlers,
26
+ }
27
+ }
28
+
29
+ // Mock MessageChannel - wires two ports together
30
+ function createMockMessageChannel() {
31
+ const port1 = createMockMessagePort()
32
+ const port2 = createMockMessagePort()
33
+
34
+ // Wire ports together
35
+ port1.postMessage = vi.fn((data: any) => {
36
+ setTimeout(() => port2._emit(data), 0)
37
+ })
38
+ port2.postMessage = vi.fn((data: any) => {
39
+ setTimeout(() => port1._emit(data), 0)
40
+ })
41
+
42
+ return { port1, port2 }
43
+ }
44
+
45
+ describe('createResponder', () => {
46
+ it('should respond to valid requests', async () => {
47
+ const port = createMockMessagePort()
48
+ const callback = vi.fn().mockReturnValue('result')
49
+
50
+ createResponder(port, callback)
51
+
52
+ // Simulate incoming request
53
+ port._emit({
54
+ [$MESSENGER_REQUEST]: 1,
55
+ payload: { test: 'data' },
56
+ })
57
+
58
+ expect(callback).toHaveBeenCalledWith({
59
+ [$MESSENGER_REQUEST]: 1,
60
+ payload: { test: 'data' },
61
+ })
62
+ // Worker-style postMessage is called with (message, transferables) - transferables is undefined
63
+ expect(port.postMessage).toHaveBeenCalledWith(
64
+ {
65
+ [$MESSENGER_RESPONSE]: 1,
66
+ payload: 'result',
67
+ },
68
+ undefined,
69
+ )
70
+ })
71
+
72
+ it('should send error response when callback throws', () => {
73
+ const port = createMockMessagePort()
74
+ const error = new Error('test error')
75
+ const callback = vi.fn().mockImplementation(() => {
76
+ throw error
77
+ })
78
+
79
+ createResponder(port, callback)
80
+
81
+ port._emit({
82
+ [$MESSENGER_REQUEST]: 1,
83
+ payload: {},
84
+ })
85
+
86
+ expect(port.postMessage).toHaveBeenCalledWith(
87
+ {
88
+ [$MESSENGER_ERROR]: 1,
89
+ error,
90
+ },
91
+ undefined,
92
+ )
93
+ })
94
+
95
+ it('should ignore non-request messages', () => {
96
+ const port = createMockMessagePort()
97
+ const callback = vi.fn()
98
+
99
+ createResponder(port, callback)
100
+
101
+ port._emit({ random: 'data' })
102
+ port._emit({ [$MESSENGER_RESPONSE]: 1, payload: 'response' })
103
+
104
+ expect(callback).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('should call start() on MessagePort if available', () => {
108
+ const port = createMockMessagePort()
109
+
110
+ createResponder(port, vi.fn())
111
+
112
+ expect(port.start).toHaveBeenCalled()
113
+ })
114
+ })
115
+
116
+ describe('expose', () => {
117
+ it('should handle RPC requests and call methods', () => {
118
+ const port = createMockMessagePort()
119
+ const methods = {
120
+ greet: (name: string) => `Hello, ${name}!`,
121
+ }
122
+
123
+ expose(methods, { to: port })
124
+
125
+ port._emit({
126
+ [$MESSENGER_REQUEST]: 1,
127
+ payload: {
128
+ [$MESSENGER_RPC_REQUEST]: true,
129
+ topics: ['greet'],
130
+ args: ['World'],
131
+ },
132
+ })
133
+
134
+ expect(port.postMessage).toHaveBeenCalledWith(
135
+ {
136
+ [$MESSENGER_RESPONSE]: 1,
137
+ payload: 'Hello, World!',
138
+ },
139
+ undefined,
140
+ )
141
+ })
142
+
143
+ it('should handle nested method calls', () => {
144
+ const port = createMockMessagePort()
145
+ const methods = {
146
+ user: {
147
+ profile: {
148
+ getName: () => 'John Doe',
149
+ },
150
+ },
151
+ }
152
+
153
+ expose(methods, { to: port })
154
+
155
+ port._emit({
156
+ [$MESSENGER_REQUEST]: 1,
157
+ payload: {
158
+ [$MESSENGER_RPC_REQUEST]: true,
159
+ topics: ['user', 'profile', 'getName'],
160
+ args: [],
161
+ },
162
+ })
163
+
164
+ expect(port.postMessage).toHaveBeenCalledWith(
165
+ {
166
+ [$MESSENGER_RESPONSE]: 1,
167
+ payload: 'John Doe',
168
+ },
169
+ undefined,
170
+ )
171
+ })
172
+
173
+ it('should ignore non-RPC payloads', () => {
174
+ const port = createMockMessagePort()
175
+ const methods = {
176
+ test: vi.fn(),
177
+ }
178
+
179
+ expose(methods, { to: port })
180
+
181
+ port._emit({
182
+ [$MESSENGER_REQUEST]: 1,
183
+ payload: { notRpc: true },
184
+ })
185
+
186
+ expect(methods.test).not.toHaveBeenCalled()
187
+ })
188
+ })
189
+
190
+ describe('rpc', () => {
191
+ it('should create a proxy that sends RPC requests', async () => {
192
+ const { port1, port2 } = createMockMessageChannel()
193
+
194
+ // Set up responder on port2
195
+ expose({ add: (a: number, b: number) => a + b }, { to: port2 })
196
+
197
+ // Create RPC proxy on port1
198
+ const proxy = rpc<{ add: (a: number, b: number) => number }>(port1)
199
+
200
+ const result = await proxy.add(2, 3)
201
+ expect(result).toBe(5)
202
+ })
203
+
204
+ it('should handle nested method calls via proxy', async () => {
205
+ const { port1, port2 } = createMockMessageChannel()
206
+
207
+ expose(
208
+ {
209
+ math: {
210
+ multiply: (a: number, b: number) => a * b,
211
+ },
212
+ },
213
+ { to: port2 },
214
+ )
215
+
216
+ const proxy = rpc<{ math: { multiply: (a: number, b: number) => number } }>(port1)
217
+
218
+ const result = await proxy.math.multiply(4, 5)
219
+ expect(result).toBe(20)
220
+ })
221
+
222
+ it('should handle errors from remote methods', async () => {
223
+ const { port1, port2 } = createMockMessageChannel()
224
+
225
+ expose(
226
+ {
227
+ failingMethod: () => {
228
+ throw new Error('Remote error')
229
+ },
230
+ },
231
+ { to: port2 },
232
+ )
233
+
234
+ const proxy = rpc<{ failingMethod: () => void }>(port1)
235
+
236
+ // Note: Currently, errors in expose() are caught and logged but not rethrown,
237
+ // so the response is undefined rather than an error rejection.
238
+ // This is a quirk of the current implementation.
239
+ const result = await proxy.failingMethod()
240
+ expect(result).toBeUndefined()
241
+ })
242
+
243
+ it('should handle multiple concurrent requests', async () => {
244
+ const { port1, port2 } = createMockMessageChannel()
245
+
246
+ expose(
247
+ {
248
+ delayed: async (ms: number, value: string) => {
249
+ await new Promise(resolve => setTimeout(resolve, ms))
250
+ return value
251
+ },
252
+ },
253
+ { to: port2 },
254
+ )
255
+
256
+ const proxy = rpc<{ delayed: (ms: number, value: string) => Promise<string> }>(port1)
257
+
258
+ const [result1, result2, result3] = await Promise.all([
259
+ proxy.delayed(30, 'first'),
260
+ proxy.delayed(10, 'second'),
261
+ proxy.delayed(20, 'third'),
262
+ ])
263
+
264
+ expect(result1).toBe('first')
265
+ expect(result2).toBe('second')
266
+ expect(result3).toBe('third')
267
+ })
268
+ })
269
+
270
+ describe('Window vs Worker handling', () => {
271
+ it('should use targetOrigin "*" for Window-like objects', () => {
272
+ const windowLike = {
273
+ postMessage: vi.fn(),
274
+ addEventListener: vi.fn(),
275
+ closed: false,
276
+ }
277
+
278
+ expose({ test: () => 'ok' }, { to: windowLike as any })
279
+
280
+ windowLike.addEventListener.mock.calls[0][1]({
281
+ data: {
282
+ [$MESSENGER_REQUEST]: 1,
283
+ payload: {
284
+ [$MESSENGER_RPC_REQUEST]: true,
285
+ topics: ['test'],
286
+ args: [],
287
+ },
288
+ },
289
+ } as MessageEvent)
290
+
291
+ // Window-style postMessage should include '*' as second argument
292
+ expect(windowLike.postMessage).toHaveBeenCalledWith(
293
+ expect.any(Object),
294
+ '*',
295
+ undefined,
296
+ )
297
+ })
298
+
299
+ it('should not use targetOrigin for Worker-like objects', () => {
300
+ const workerLike = createMockMessagePort()
301
+
302
+ expose({ test: () => 'ok' }, { to: workerLike })
303
+
304
+ workerLike._emit({
305
+ [$MESSENGER_REQUEST]: 1,
306
+ payload: {
307
+ [$MESSENGER_RPC_REQUEST]: true,
308
+ topics: ['test'],
309
+ args: [],
310
+ },
311
+ })
312
+
313
+ // Worker-style postMessage is called with (message, transferables)
314
+ expect(workerLike.postMessage).toHaveBeenCalledWith(expect.any(Object), undefined)
315
+ })
316
+ })
@@ -0,0 +1,356 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { expose, rpc, isSSEResponse, Payload } from '../src/server-send-events/index'
3
+ import { $MESSENGER_REQUEST, $MESSENGER_RPC_REQUEST } from '../src/message-protocol'
4
+
5
+ // Mock EventSource
6
+ class MockEventSource {
7
+ static instances: MockEventSource[] = []
8
+ url: string
9
+ handlers: Map<string, ((event: { data: string }) => void)[]> = new Map()
10
+
11
+ constructor(url: string) {
12
+ this.url = url
13
+ MockEventSource.instances.push(this)
14
+ }
15
+
16
+ addEventListener(type: string, handler: (event: { data: string }) => void) {
17
+ if (!this.handlers.has(type)) {
18
+ this.handlers.set(type, [])
19
+ }
20
+ this.handlers.get(type)!.push(handler)
21
+ }
22
+
23
+ removeEventListener(type: string, handler: (event: { data: string }) => void) {
24
+ const handlers = this.handlers.get(type)
25
+ if (handlers) {
26
+ const index = handlers.indexOf(handler)
27
+ if (index !== -1) handlers.splice(index, 1)
28
+ }
29
+ }
30
+
31
+ dispatchEvent(type: string, data: any) {
32
+ const handlers = this.handlers.get(type) || []
33
+ handlers.forEach(h => h({ data: JSON.stringify(data) }))
34
+ }
35
+
36
+ close() {
37
+ const index = MockEventSource.instances.indexOf(this)
38
+ if (index !== -1) MockEventSource.instances.splice(index, 1)
39
+ }
40
+ }
41
+
42
+ describe('isSSEResponse', () => {
43
+ it('should return true for requests with SSE response header', () => {
44
+ const request = new Request('http://example.com', {
45
+ headers: { RPC_SSE_RESPONSE: '1' },
46
+ })
47
+ expect(isSSEResponse({ request })).toBe(true)
48
+ })
49
+
50
+ it('should return false for requests without SSE response header', () => {
51
+ const request = new Request('http://example.com')
52
+ expect(isSSEResponse({ request })).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe('Payload', () => {
57
+ describe('validate', () => {
58
+ it('should validate correct payloads', () => {
59
+ expect(Payload.validate({ topics: ['method'], args: [] })).toBe(true)
60
+ expect(Payload.validate({ topics: ['a', 'b'], args: [1, 'test'] })).toBe(true)
61
+ })
62
+
63
+ it('should reject invalid payloads', () => {
64
+ expect(Payload.validate({})).toBe(false)
65
+ expect(Payload.validate({ topics: 'not-array', args: [] })).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('create', () => {
70
+ it('should create valid payloads', () => {
71
+ const payload = Payload.create(['test'], [1, 2])
72
+ expect(payload).toEqual({ topics: ['test'], args: [1, 2] })
73
+ })
74
+ })
75
+ })
76
+
77
+ describe('rpc', () => {
78
+ describe('create', () => {
79
+ it('should return proxy, closed state, onClose, and response', () => {
80
+ const { create } = rpc<{ test: () => void }>()
81
+ const [proxy, { closed, onClose, response }] = create()
82
+
83
+ expect(proxy).toBeDefined()
84
+ expect(closed).toBe(false)
85
+ expect(typeof onClose).toBe('function')
86
+ expect(response).toBeInstanceOf(Response)
87
+ })
88
+
89
+ it('should create Response with SSE headers', () => {
90
+ const { create } = rpc<{}>()
91
+ const [, { response }] = create()
92
+
93
+ expect(response.headers.get('Content-Type')).toBe('text/event-stream')
94
+ expect(response.headers.get('Cache-Control')).toBe('no-cache')
95
+ expect(response.headers.get('Connection')).toBe('keep-alive')
96
+ })
97
+
98
+ it('should throw when calling proxy after stream is closed', async () => {
99
+ const { create } = rpc<{ test: () => void }>()
100
+ const [proxy, { response }] = create()
101
+
102
+ // Cancel the stream to close it
103
+ const reader = response.body!.getReader()
104
+ await reader.cancel()
105
+
106
+ await expect(proxy.test()).rejects.toThrow('[rpc/sse] Stream is closed.')
107
+ })
108
+
109
+ it('should call onClose handlers when stream is cancelled', async () => {
110
+ const { create } = rpc<{}>()
111
+ const [, { response, onClose }] = create()
112
+
113
+ const handler = vi.fn()
114
+ onClose(handler)
115
+
116
+ const reader = response.body!.getReader()
117
+ await reader.cancel()
118
+
119
+ expect(handler).toHaveBeenCalled()
120
+ })
121
+
122
+ it('should allow unsubscribing from onClose', async () => {
123
+ const { create } = rpc<{}>()
124
+ const [, { response, onClose }] = create()
125
+
126
+ const handler = vi.fn()
127
+ const unsubscribe = onClose(handler)
128
+ unsubscribe()
129
+
130
+ const reader = response.body!.getReader()
131
+ await reader.cancel()
132
+
133
+ expect(handler).not.toHaveBeenCalled()
134
+ })
135
+
136
+ it('should send requests as SSE data format', async () => {
137
+ const { create } = rpc<{ greet: (name: string) => string }>()
138
+ const [proxy, { response }] = create()
139
+
140
+ // Start reading the response body
141
+ const reader = response.body!.getReader()
142
+ const decoder = new TextDecoder()
143
+
144
+ // Make a request (won't resolve without handleAnswer)
145
+ const resultPromise = proxy.greet('World')
146
+
147
+ // Read the output
148
+ const { value } = await reader.read()
149
+ const text = decoder.decode(value)
150
+
151
+ // Should be in SSE format: "data: {...}\n\n"
152
+ expect(text).toMatch(/^data: .+\n\n$/)
153
+
154
+ // Parse the JSON
155
+ const jsonStr = text.replace('data: ', '').trim()
156
+ const data = JSON.parse(jsonStr)
157
+
158
+ expect(data[$MESSENGER_REQUEST]).toBeDefined()
159
+ expect(data.payload[$MESSENGER_RPC_REQUEST]).toBe(true)
160
+ expect(data.payload.topics).toEqual(['greet'])
161
+ expect(data.payload.args).toEqual(['World'])
162
+
163
+ reader.releaseLock()
164
+ })
165
+ })
166
+
167
+ describe('handleAnswer', () => {
168
+ it('should resolve pending promises when answer is received', async () => {
169
+ const { create, handleAnswer } = rpc<{ test: () => string }>()
170
+ const [proxy, { response }] = create()
171
+
172
+ // Read the request to get the ID
173
+ const reader = response.body!.getReader()
174
+ const decoder = new TextDecoder()
175
+
176
+ const resultPromise = proxy.test()
177
+
178
+ const { value } = await reader.read()
179
+ const text = decoder.decode(value)
180
+ const jsonStr = text.replace('data: ', '').trim()
181
+ const requestData = JSON.parse(jsonStr)
182
+ const requestId = requestData[$MESSENGER_REQUEST]
183
+
184
+ // Simulate response via handleAnswer
185
+ const answerRequest = new Request('http://example.com', {
186
+ method: 'POST',
187
+ headers: { RPC_SSE_RESPONSE: requestId.toString() },
188
+ body: JSON.stringify({ payload: 'test result' }),
189
+ })
190
+
191
+ await handleAnswer({ request: answerRequest })
192
+
193
+ const result = await resultPromise
194
+ expect(result).toBe('test result')
195
+
196
+ reader.releaseLock()
197
+ })
198
+
199
+ it('should return empty Response', async () => {
200
+ const { handleAnswer } = rpc<{}>()
201
+
202
+ const request = new Request('http://example.com', {
203
+ method: 'POST',
204
+ headers: { RPC_SSE_RESPONSE: '1' },
205
+ body: JSON.stringify({ payload: 'test' }),
206
+ })
207
+
208
+ const response = await handleAnswer({ request })
209
+ expect(response).toBeInstanceOf(Response)
210
+ })
211
+
212
+ it('should ignore requests without SSE response header', async () => {
213
+ const { create, handleAnswer } = rpc<{ test: () => string }>()
214
+ const [proxy] = create()
215
+
216
+ const request = new Request('http://example.com', {
217
+ method: 'POST',
218
+ body: JSON.stringify({ payload: 'test' }),
219
+ })
220
+
221
+ // Should not throw and should return Response
222
+ const response = await handleAnswer({ request })
223
+ expect(response).toBeInstanceOf(Response)
224
+ })
225
+ })
226
+ })
227
+
228
+ describe('expose', () => {
229
+ const originalEventSource = globalThis.EventSource
230
+ const originalFetch = globalThis.fetch
231
+
232
+ beforeEach(() => {
233
+ MockEventSource.instances = []
234
+ ;(globalThis as any).EventSource = MockEventSource
235
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response())
236
+ })
237
+
238
+ afterEach(() => {
239
+ ;(globalThis as any).EventSource = originalEventSource
240
+ globalThis.fetch = originalFetch
241
+ })
242
+
243
+ it('should create EventSource connection', () => {
244
+ expose('http://example.com/sse', { test: () => 'ok' })
245
+
246
+ expect(MockEventSource.instances.length).toBe(1)
247
+ expect(MockEventSource.instances[0]!.url).toBe('http://example.com/sse')
248
+ })
249
+
250
+ it('should handle incoming RPC requests', async () => {
251
+ const methods = {
252
+ greet: (name: string) => `Hello, ${name}!`,
253
+ }
254
+
255
+ expose('http://example.com/sse', methods)
256
+
257
+ const eventSource = MockEventSource.instances[0]!
258
+
259
+ // Simulate incoming RPC request
260
+ eventSource.dispatchEvent('message', {
261
+ [$MESSENGER_REQUEST]: 42,
262
+ payload: {
263
+ [$MESSENGER_RPC_REQUEST]: true,
264
+ topics: ['greet'],
265
+ args: ['World'],
266
+ },
267
+ })
268
+
269
+ // Wait for async processing
270
+ await new Promise(resolve => setTimeout(resolve, 10))
271
+
272
+ // Should have made fetch call with response
273
+ expect(globalThis.fetch).toHaveBeenCalledWith(
274
+ 'http://example.com/sse',
275
+ expect.objectContaining({
276
+ method: 'POST',
277
+ headers: expect.objectContaining({
278
+ RPC_SSE_RESPONSE: '42',
279
+ }),
280
+ }),
281
+ )
282
+
283
+ // Verify the body contains the result
284
+ const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0]!
285
+ const body = JSON.parse(fetchCall[1]!.body as string)
286
+ expect(body).toEqual({ payload: 'Hello, World!' })
287
+ })
288
+
289
+ it('should handle nested method calls', async () => {
290
+ const methods = {
291
+ math: {
292
+ add: (a: number, b: number) => a + b,
293
+ },
294
+ }
295
+
296
+ expose('http://example.com/sse', methods)
297
+
298
+ const eventSource = MockEventSource.instances[0]!
299
+
300
+ eventSource.dispatchEvent('message', {
301
+ [$MESSENGER_REQUEST]: 1,
302
+ payload: {
303
+ [$MESSENGER_RPC_REQUEST]: true,
304
+ topics: ['math', 'add'],
305
+ args: [5, 3],
306
+ },
307
+ })
308
+
309
+ await new Promise(resolve => setTimeout(resolve, 10))
310
+
311
+ const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0]!
312
+ const body = JSON.parse(fetchCall[1]!.body as string)
313
+ expect(body).toEqual({ payload: 8 })
314
+ })
315
+
316
+ it('should handle async methods', async () => {
317
+ const methods = {
318
+ asyncMethod: async () => {
319
+ await new Promise(resolve => setTimeout(resolve, 5))
320
+ return 'async result'
321
+ },
322
+ }
323
+
324
+ expose('http://example.com/sse', methods)
325
+
326
+ const eventSource = MockEventSource.instances[0]!
327
+
328
+ eventSource.dispatchEvent('message', {
329
+ [$MESSENGER_REQUEST]: 1,
330
+ payload: {
331
+ [$MESSENGER_RPC_REQUEST]: true,
332
+ topics: ['asyncMethod'],
333
+ args: [],
334
+ },
335
+ })
336
+
337
+ await new Promise(resolve => setTimeout(resolve, 20))
338
+
339
+ const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0]!
340
+ const body = JSON.parse(fetchCall[1]!.body as string)
341
+ expect(body).toEqual({ payload: 'async result' })
342
+ })
343
+
344
+ it('should ignore non-RPC messages', async () => {
345
+ expose('http://example.com/sse', { test: () => 'ok' })
346
+
347
+ const eventSource = MockEventSource.instances[0]!
348
+
349
+ // Invalid message format
350
+ eventSource.dispatchEvent('message', { random: 'data' })
351
+
352
+ await new Promise(resolve => setTimeout(resolve, 10))
353
+
354
+ expect(globalThis.fetch).not.toHaveBeenCalled()
355
+ })
356
+ })