@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/LICENSE +21 -0
- package/dist/fetch-node.d.ts +11 -0
- package/dist/fetch-node.js +342 -0
- package/dist/fetch-node.js.map +1 -0
- package/dist/fetch.d.ts +36 -0
- package/dist/fetch.js +369 -0
- package/dist/fetch.js.map +1 -0
- package/dist/messenger.d.ts +50 -0
- package/dist/messenger.js +540 -0
- package/dist/messenger.js.map +1 -0
- package/dist/stream.d.ts +46 -0
- package/dist/stream.js +601 -0
- package/dist/stream.js.map +1 -0
- package/dist/types-4d4495dd.d.ts +40 -0
- package/package.json +42 -0
- package/src/fetch/index.ts +84 -0
- package/src/fetch/node.ts +44 -0
- package/src/message-protocol.ts +57 -0
- package/src/messenger.ts +176 -0
- package/src/server-send-events/index.ts +129 -0
- package/src/stream/encoding.ts +362 -0
- package/src/stream/index.ts +162 -0
- package/src/types.ts +104 -0
- package/src/utils.ts +159 -0
- package/test/encoding.test.ts +413 -0
- package/test/fetch.test.ts +310 -0
- package/test/message-protocol.test.ts +166 -0
- package/test/messenger.test.ts +316 -0
- package/test/sse.test.ts +356 -0
- package/test/stream.test.ts +351 -0
- package/test/utils.test.ts +336 -0
- package/tsconfig.json +23 -0
- package/tsup.config.ts +17 -0
|
@@ -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
|
+
})
|
package/test/sse.test.ts
ADDED
|
@@ -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
|
+
})
|