@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,362 @@
1
+ import { createIdAllocator, streamToAsyncIterable } from '../utils'
2
+
3
+ const MAX_UINT_32 = Math.pow(2, 32) - 1
4
+ const encoder = new TextEncoder()
5
+ const decoder = new TextDecoder()
6
+
7
+ /**********************************************************************************/
8
+ /* */
9
+ /* Codec */
10
+ /* */
11
+ /**********************************************************************************/
12
+
13
+ type Codec = StructuralCodec | PrimitiveCodec | GeneratorCodec
14
+
15
+ class CodecBase<
16
+ TConfig extends {
17
+ test(value: any): boolean
18
+ encode(value: any): any
19
+ decode(value: any): any
20
+ },
21
+ > {
22
+ test: TConfig['test']
23
+ encode: TConfig['encode']
24
+ decode: TConfig['decode']
25
+ constructor({ test, encode, decode }: TConfig) {
26
+ this.test = test
27
+ this.encode = encode
28
+ this.decode = decode
29
+ }
30
+ }
31
+
32
+ export interface PrimitiveCodecMethods<T> {
33
+ test: (value: any) => boolean
34
+ encode: (value: T) => Uint8Array
35
+ decode: (buffer: Uint8Array) => T
36
+ }
37
+ export class PrimitiveCodec<T = any> extends CodecBase<PrimitiveCodecMethods<T>> {
38
+ type = 'primitive' as const
39
+ }
40
+
41
+ export interface StructuralCodecMethods<T> {
42
+ test: (value: any) => boolean
43
+ encode: (value: T) =>
44
+ | {
45
+ length?: never
46
+ keys: Array<string>
47
+ values: Array<any> | IterableIterator<any>
48
+ }
49
+ | {
50
+ length: number
51
+ keys?: never
52
+ values: Array<any> | IterableIterator<any>
53
+ }
54
+ decode: () => { value: T; set(value: any, key: string | number): void }
55
+ }
56
+
57
+ export class StructuralCodec<T = any> extends CodecBase<StructuralCodecMethods<T>> {
58
+ type = 'structural' as const
59
+ }
60
+
61
+ export interface GeneratorCodecMethods<T> {
62
+ test: (value: any) => boolean
63
+ encode: (value: T) => AsyncGenerator<Uint8Array>
64
+ decode: () => AsyncGenerator<T, unknown, Uint8Array>
65
+ }
66
+
67
+ export class GeneratorCodec<T = any> extends CodecBase<GeneratorCodecMethods<T>> {
68
+ type = 'generator' as const
69
+ }
70
+
71
+ const JSONCodec = new PrimitiveCodec({
72
+ test() {
73
+ return true
74
+ },
75
+ encode(value) {
76
+ return encoder.encode(JSON.stringify(value))
77
+ },
78
+ decode(value) {
79
+ const json = decoder.decode(value)
80
+ return json ? JSON.parse(json) : undefined
81
+ },
82
+ })
83
+
84
+ /**********************************************************************************/
85
+ /* */
86
+ /* Binary Schema */
87
+ /* */
88
+ /**********************************************************************************/
89
+
90
+ function createBinarySchema<TConfig extends Record<string, number>>(
91
+ config: TConfig,
92
+ ): { offsets: TConfig; size: number }
93
+ function createBinarySchema<
94
+ TConfig extends Record<string, number>,
95
+ TConstants extends Partial<TConfig> = {},
96
+ >(config: TConfig, constants: TConstants): { offsets: TConfig; constants: TConstants; size: number }
97
+ function createBinarySchema(config: Record<string, number>, constants?: Record<string, number>) {
98
+ let offset = 0
99
+ return {
100
+ offsets: Object.fromEntries(
101
+ Object.entries(config).map(([key, value]) => {
102
+ const entry = [key, offset]
103
+ offset += value
104
+ return entry
105
+ }),
106
+ ),
107
+ size: offset,
108
+ constants,
109
+ }
110
+ }
111
+
112
+ const defaultSchema = createBinarySchema(
113
+ {
114
+ kind: 1,
115
+ header: 1,
116
+ length: 4,
117
+ },
118
+ { kind: 0x01 },
119
+ )
120
+ const chunkSchema = createBinarySchema(
121
+ {
122
+ kind: 1,
123
+ header: 1,
124
+ length: 4,
125
+ id: 4,
126
+ },
127
+ { kind: 0x02 },
128
+ )
129
+ const chunkEndSchema = createBinarySchema(
130
+ {
131
+ kind: 1,
132
+ header: 1,
133
+ id: 4,
134
+ },
135
+ { kind: 0x03 },
136
+ )
137
+ const kindToSchema = {
138
+ [defaultSchema.constants.kind]: defaultSchema,
139
+ [chunkSchema.constants.kind]: chunkSchema,
140
+ [chunkEndSchema.constants.kind]: chunkEndSchema,
141
+ } as const
142
+ type SchemaKind = keyof typeof kindToSchema
143
+
144
+ function packDefault(header: number, encoded: Uint8Array): Uint8Array {
145
+ const { offsets, size, constants } = defaultSchema
146
+ const buffer = new Uint8Array(size + encoded.length)
147
+ buffer[offsets.kind] = constants.kind
148
+ buffer[offsets.header] = header
149
+ const view = new DataView(buffer.buffer)
150
+ view.setUint32(offsets.length, encoded.length)
151
+ if (encoded.length > MAX_UINT_32)
152
+ throw new Error(`Tried to encode something larger than MAX_UINT_32`)
153
+ buffer.set(encoded, size)
154
+ return buffer
155
+ }
156
+
157
+ function packChunk(id: number, header: number, encoded: Uint8Array): Uint8Array {
158
+ const { offsets, size, constants } = chunkSchema
159
+ const buffer = new Uint8Array(size + encoded?.length)
160
+ buffer[offsets.kind] = constants.kind
161
+ buffer[offsets.header] = header
162
+ const view = new DataView(buffer.buffer)
163
+ view.setUint32(offsets.id, id)
164
+ view.setUint32(offsets.length, encoded.length)
165
+ if (encoded.length > MAX_UINT_32)
166
+ throw new Error(`Tried to encode something larger than MAX_UINT_32`)
167
+ buffer.set(encoded, size)
168
+ return buffer
169
+ }
170
+
171
+ function packChunkEnd(id: number, header: number): Uint8Array {
172
+ const { offsets, size, constants } = chunkEndSchema
173
+ const buffer = new Uint8Array(size)
174
+ buffer[offsets.kind] = constants.kind
175
+ buffer[offsets.header] = header
176
+ const view = new DataView(buffer.buffer)
177
+ view.setUint32(offsets.id, id)
178
+ return buffer
179
+ }
180
+
181
+ function unpack(buffer: Uint8Array) {
182
+ const kind = buffer[0]!
183
+ const schema = kindToSchema[kind as SchemaKind]!
184
+ const header = buffer[schema.offsets.header]!
185
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
186
+
187
+ const size =
188
+ schema.size + ('length' in schema.offsets ? view.getUint32(schema.offsets.length) : 0)
189
+ const payload = buffer.length < size ? undefined : buffer.slice(schema.size, size)
190
+ const rest = !payload ? undefined : buffer.slice(size)
191
+ const id = 'id' in schema.offsets ? view.getUint32(schema.offsets.id) : undefined
192
+
193
+ return {
194
+ kind,
195
+ header,
196
+ id,
197
+ payload,
198
+ rest,
199
+ }
200
+ }
201
+
202
+ /**********************************************************************************/
203
+ /* */
204
+ /* Create Stream Codec */
205
+ /* */
206
+ /**********************************************************************************/
207
+
208
+ export function createStreamCodec(config: Array<Codec>, fallback: PrimitiveCodec = JSONCodec) {
209
+ config = [...config, fallback]
210
+ const generatorIdAllocator = createIdAllocator()
211
+ const generators: Record<string, AsyncGenerator<unknown, unknown, Uint8Array>> = {}
212
+
213
+ function getCodecFromHeader(header: number) {
214
+ const codec = config[header - 1]
215
+ if (!codec) throw `Unknown Codec ${header}`
216
+ return codec
217
+ }
218
+
219
+ function getCodecFromValue(value: any) {
220
+ for (let index = 0; index < config.length; index++) {
221
+ const codec = config[index]!
222
+ if (codec.test(value)) {
223
+ return { codec, header: 0x01 + index }
224
+ }
225
+ }
226
+ throw new Error(`Unknown Codec`)
227
+ }
228
+
229
+ async function* createChunkGenerator(stream: ReadableStream<Uint8Array>): AsyncGenerator<{
230
+ kind: number
231
+ header: number
232
+ payload: Uint8Array
233
+ id?: number
234
+ }> {
235
+ let buffer = new Uint8Array(new ArrayBuffer(0))
236
+
237
+ for await (const value of streamToAsyncIterable(stream)) {
238
+ // append new chunk to buffer
239
+ buffer = concat(buffer, value!)
240
+
241
+ while (buffer.length >= defaultSchema.size) {
242
+ const { kind, header, payload, rest, id } = unpack(buffer)
243
+
244
+ if (!payload || !rest) break
245
+
246
+ yield { kind, header, payload, id }
247
+
248
+ buffer = rest
249
+ }
250
+ }
251
+ }
252
+
253
+ const api = {
254
+ serialize(value: any, onChunk: (chunk: Uint8Array) => void): void {
255
+ const { codec, header } = getCodecFromValue(value)
256
+ const currentGenerators = new Array<{
257
+ generator: AsyncGenerator<Uint8Array>
258
+ id: number
259
+ codec: GeneratorCodec
260
+ header: number
261
+ }>()
262
+
263
+ switch (codec.type) {
264
+ case 'structural':
265
+ const { keys, values, length } = codec.encode(value)
266
+ onChunk(packDefault(header, JSONCodec.encode(length ?? keys)))
267
+ for (const value of values) {
268
+ api.serialize(value, onChunk)
269
+ }
270
+ break
271
+ case 'generator':
272
+ const generator = codec.encode(value)
273
+ const id = generatorIdAllocator.create()
274
+ currentGenerators.push({ generator, codec, id, header })
275
+ onChunk(packDefault(header, JSONCodec.encode({ id })))
276
+ break
277
+ case 'primitive':
278
+ onChunk(packDefault(header, codec.encode(value)))
279
+ break
280
+ default:
281
+ throw new Error(`Unknown Codec`)
282
+ }
283
+
284
+ if (currentGenerators.length) {
285
+ ;(async function () {
286
+ for (const { generator, id, header } of currentGenerators) {
287
+ for await (const value of generator) {
288
+ onChunk(packChunk(id, header, value))
289
+ }
290
+ onChunk(packChunkEnd(id, header))
291
+ generatorIdAllocator.free(id)
292
+ }
293
+ })()
294
+ }
295
+ },
296
+ async deserialize(stream: ReadableStream, onChunk: (value: any) => void) {
297
+ const generator = createChunkGenerator(stream)
298
+
299
+ for await (const { payload, header, kind, id } of generator) {
300
+ switch (kind) {
301
+ case defaultSchema.constants.kind:
302
+ onChunk(await handleCodec({ codec: getCodecFromHeader(header), payload }))
303
+ break
304
+ case chunkSchema.constants.kind:
305
+ generators[id!]!.next(payload)
306
+ break
307
+ case chunkEndSchema.constants.kind:
308
+ await generators[id!]!.return(null)
309
+ delete generators[id!]
310
+ break
311
+ }
312
+ }
313
+
314
+ async function handleCodec({ codec, payload }: { codec: Codec; payload: Uint8Array }) {
315
+ switch (codec.type) {
316
+ case 'structural': {
317
+ const paths = JSONCodec.decode(payload) as Array<string> | number
318
+
319
+ const { value, set } = codec.decode()
320
+
321
+ const total = typeof paths === 'number' ? paths : paths.length
322
+
323
+ for (let i = 0; i < total; i++) {
324
+ const key = typeof paths === 'number' ? i : paths[i]!
325
+ const { value, done } = await generator.next()
326
+ if (done) break
327
+ const { payload, header } = value
328
+ const codec = getCodecFromHeader(header)
329
+ set(await handleCodec({ codec, payload }), key)
330
+ }
331
+
332
+ return value
333
+ }
334
+ case 'generator': {
335
+ const { id } = JSONCodec.decode(payload)
336
+ const generator = codec.decode()
337
+ const { value } = await generator.next()
338
+ generators[id] = generator
339
+ return value
340
+ }
341
+ case 'primitive':
342
+ return codec.decode(payload)
343
+ default:
344
+ throw new Error('Unknown codec')
345
+ }
346
+ }
347
+ },
348
+ }
349
+
350
+ return api
351
+ }
352
+
353
+ // helper to concatenate Uint8Arrays
354
+ function concat(...arrays: Array<Uint8Array>): Uint8Array {
355
+ const result = new Uint8Array(new ArrayBuffer(arrays.reduce((a, b) => a + b.length, 0)))
356
+ let index = 0
357
+ return arrays.reduce<Uint8Array>((result, current) => {
358
+ result.set(current, index)
359
+ index += current.length
360
+ return result
361
+ }, result)
362
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ $MESSENGER_RESPONSE,
3
+ RequestShape,
4
+ ResponseShape,
5
+ RPCPayloadShape,
6
+ } from '../message-protocol'
7
+ import { RPC } from '../types'
8
+ import {
9
+ callMethod,
10
+ createCommander,
11
+ createPromiseRegistry,
12
+ createReadableStream,
13
+ defer,
14
+ streamToAsyncIterable,
15
+ } from '../utils'
16
+
17
+ interface StreamCodec {
18
+ serialize(value: any, onChunk: (chunk: any) => void): void
19
+ deserialize(stream: ReadableStream, onChunk: (chunk: any) => void): void
20
+ }
21
+
22
+ const $STREAM_REQUEST_HEADER = 'RPC_STREAM_REQUEST_HEADER'
23
+
24
+ const encoder = new TextEncoder()
25
+ const decoder = new TextDecoder()
26
+
27
+ export const isStreamRequest = (event: { request: Request }) =>
28
+ event.request.headers.has($STREAM_REQUEST_HEADER)
29
+
30
+ /**********************************************************************************/
31
+ /* */
32
+ /* rpc */
33
+ /* */
34
+ /**********************************************************************************/
35
+
36
+ export function rpc<TProxy extends object, TExpose extends object = object>(
37
+ reader:
38
+ | ((stream: ReadableStream) => ReadableStream | Promise<ReadableStream>)
39
+ | ReadableStream
40
+ | Promise<ReadableStream>,
41
+ methods: TExpose,
42
+ codec: StreamCodec = {
43
+ serialize(value: any, onChunk: (chunk: any) => void) {
44
+ onChunk(encoder.encode(`${JSON.stringify(value)}\n`))
45
+ },
46
+ async deserialize(stream, onChunk: (chunk: any) => void) {
47
+ let buffer = ''
48
+
49
+ for await (const value of streamToAsyncIterable(stream)) {
50
+ buffer += decoder.decode(value, { stream: true })
51
+ let newlineIndex
52
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
53
+ const line = buffer.slice(0, newlineIndex)
54
+ buffer = buffer.slice(newlineIndex + 1)
55
+
56
+ onChunk(JSON.parse(line))
57
+ }
58
+ }
59
+ },
60
+ },
61
+ ) {
62
+ const promiseRegistry = createPromiseRegistry()
63
+ const { stream, closed, onClose, enqueue } = createReadableStream()
64
+
65
+ ;(async () => {
66
+ const inputStream = await (typeof reader === 'function' ? reader(stream) : reader)
67
+ codec.deserialize(inputStream, async data => {
68
+ if (methods && RequestShape.validate(data) && RPCPayloadShape.validate(data.payload)) {
69
+ const {
70
+ payload: { topics, args },
71
+ } = data
72
+
73
+ codec.serialize(
74
+ ResponseShape.create(data, await callMethod(methods, topics, args)),
75
+ enqueue,
76
+ )
77
+ } else if (ResponseShape.validate(data)) {
78
+ promiseRegistry.free(data[$MESSENGER_RESPONSE])?.resolve(data.payload)
79
+ }
80
+ })
81
+ })()
82
+
83
+ return {
84
+ proxy: createCommander<RPC<TProxy>>(async (topics, args) => {
85
+ if (closed?.()) {
86
+ throw new Error(`[rpc/sse] Stream is closed.`)
87
+ }
88
+ const { promise, resolve, reject } = defer()
89
+ const id = promiseRegistry.register({ resolve, reject })
90
+
91
+ codec.serialize(RequestShape.create(id, RPCPayloadShape.create(topics, args)), enqueue)
92
+
93
+ return promise
94
+ }),
95
+ closed,
96
+ onClose,
97
+ stream,
98
+ }
99
+ }
100
+
101
+ /**********************************************************************************/
102
+ /* */
103
+ /* Client / Server */
104
+ /* */
105
+ /**********************************************************************************/
106
+
107
+ /**
108
+ * Exposes a set of methods as an RPC endpoint over the given messenger.
109
+ *
110
+ * @param methods - An object containing functions to expose
111
+ * @param options - Optional target Messenger and abort signal
112
+ */
113
+ export function client<TProxy extends object, TExpose extends object = object>(
114
+ url: string,
115
+ methods: TExpose,
116
+ codec?: StreamCodec,
117
+ ) {
118
+ return rpc<TProxy, TExpose>(
119
+ stream =>
120
+ fetch(url, {
121
+ method: 'POST',
122
+ body: stream,
123
+ // @ts-expect-error
124
+ duplex: 'half',
125
+ headers: {
126
+ 'Content-Type': 'text/event-stream',
127
+ 'Cache-Control': 'no-cache',
128
+ Connection: 'keep-alive',
129
+ [$STREAM_REQUEST_HEADER]: '1',
130
+ },
131
+ }).then(response => response.body!),
132
+ methods,
133
+ codec,
134
+ )
135
+ }
136
+
137
+ /**
138
+ * Creates an RPC proxy for calling remote methods on the given Messenger.
139
+ *
140
+ * @param messenger - The Messenger to communicate with (e.g. Worker or Window)
141
+ * @param options - Optional abort signal
142
+ * @returns A proxy object that lets you call methods remotely
143
+ */
144
+ export function server<TProxy extends object, TExpose extends object = object>(
145
+ reader: ReadableStream,
146
+ methods: TExpose,
147
+ codec?: StreamCodec,
148
+ ) {
149
+ const { proxy, closed, onClose, stream } = rpc<TProxy, TExpose>(reader, methods, codec)
150
+ return {
151
+ proxy,
152
+ closed,
153
+ onClose,
154
+ response: new Response(stream, {
155
+ headers: {
156
+ 'Content-Type': 'text/event-stream',
157
+ 'Cache-Control': 'no-cache',
158
+ Connection: 'keep-alive',
159
+ },
160
+ }),
161
+ }
162
+ }
package/src/types.ts ADDED
@@ -0,0 +1,104 @@
1
+ export type Fn = (...arg: Array<any>) => any
2
+ export type MaybePromise<T> = T | Promise<T>
3
+
4
+ // To prevent error: `Type instantiation is excessively deep and possibly infinite.`
5
+ type isObject<T> = T extends object ? true : false
6
+
7
+ type HasMethod<T> = T extends object
8
+ ? {
9
+ [K in keyof T]: T[K] extends Fn ? true : HasMethod<T[K]>
10
+ }[keyof T] extends false
11
+ ? false
12
+ : true
13
+ : false
14
+
15
+ /**********************************************************************************/
16
+ /* */
17
+ /* Sync */
18
+ /* */
19
+ /**********************************************************************************/
20
+
21
+ export interface NoResponseMethod<T extends Fn> {
22
+ (...args: Parameters<T>): void
23
+ }
24
+
25
+ export type NoResponseRPCNode<T> = T extends Fn
26
+ ? NoResponseMethod<T>
27
+ : T extends readonly [any, ...any[]] // is it a tuple?
28
+ ? { [K in keyof T]: NoResponseRPCNode<T[K]> } // preserve tuple structure
29
+ : // To prevent error: `Type instantiation is excessively deep and possibly infinite.`
30
+ isObject<T> extends true
31
+ ? // Filter branches that lead to no method
32
+ HasMethod<T> extends false
33
+ ? never
34
+ : {
35
+ [TKey in keyof FilterNoResponseMethod<T>]: NoResponseRPCNode<T[TKey]>
36
+ }
37
+ : never
38
+
39
+ type FilterNoResponseMethod<T> = {
40
+ [TKey in keyof T as NoResponseRPCNode<T[TKey]> extends never ? never : TKey]: T[TKey]
41
+ }
42
+
43
+ /**********************************************************************************/
44
+ /* */
45
+ /* Async */
46
+ /* */
47
+ /**********************************************************************************/
48
+
49
+ export interface ResponseMethod<T extends Fn> {
50
+ (...args: Parameters<T>): Promise<ReturnType<T>>
51
+ }
52
+
53
+ export type ResponseRPCNode<T> = T extends Fn
54
+ ? ResponseMethod<T>
55
+ : T extends readonly [any, ...any[]] // is it a tuple?
56
+ ? { [K in keyof T]: ResponseRPCNode<T[K]> } // preserve tuple structure
57
+ : // To prevent error: `Type instantiation is excessively deep and possibly infinite.`
58
+ isObject<T> extends true
59
+ ? // Filter branches that lead to no method
60
+ HasMethod<T> extends false
61
+ ? never
62
+ : {
63
+ [TKey in keyof FilterResponseMethod<T>]: ResponseRPCNode<T[TKey]>
64
+ }
65
+ : never
66
+
67
+ type FilterResponseMethod<T> = {
68
+ [TKey in keyof T as ResponseRPCNode<T[TKey]> extends never ? never : TKey]: T[TKey]
69
+ }
70
+
71
+ /**********************************************************************************/
72
+ /* */
73
+ /* RPC */
74
+ /* */
75
+ /**********************************************************************************/
76
+
77
+ export enum RPCKind {
78
+ NoResponse = 'no-response',
79
+ Response = 'response',
80
+ }
81
+
82
+ export type RPC<
83
+ TMethods extends object,
84
+ TKind extends RPCKind = RPCKind.Response,
85
+ > = TKind extends RPCKind.Response ? ResponseRPCNode<TMethods> : NoResponseRPCNode<TMethods>
86
+
87
+ /**********************************************************************************/
88
+ /* */
89
+ /* Worker */
90
+ /* */
91
+ /**********************************************************************************/
92
+
93
+ // /** Branded `MessagePort` */
94
+ // export type RPCPort<T extends object> = MessagePort & { [$WORKER]: RPC<T> }
95
+
96
+ // export type $Transfer<T = Array<any>, U = Array<Transferable>> = T & {
97
+ // [$TRANSFER]: U
98
+ // }
99
+
100
+ // export type $Callback<T = Fn> = T & { [$CALLBACK]: number }
101
+
102
+ // export interface WorkerMethod<T extends Fn> {
103
+ // (...args: Parameters<T> | [$Transfer<Parameters<T>>]): void
104
+ // }