@replit/river 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/README.md +15 -0
- package/dist/__tests__/integration.test.d.ts +49 -0
- package/dist/__tests__/integration.test.js +141 -0
- package/dist/codec/codec.test.d.ts +1 -0
- package/dist/codec/codec.test.js +23 -0
- package/dist/codec/json.d.ts +2 -0
- package/dist/codec/json.js +4 -0
- package/dist/codec/types.d.ts +4 -0
- package/dist/codec/types.js +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.js +2 -0
- package/dist/router/builder.d.ts +50 -0
- package/dist/router/builder.js +48 -0
- package/dist/router/client.d.ts +17 -0
- package/dist/router/client.js +54 -0
- package/dist/router/server.d.ts +7 -0
- package/dist/router/server.js +78 -0
- package/dist/router/server.util.d.ts +5 -0
- package/dist/router/server.util.js +30 -0
- package/dist/transport/message.d.ts +62 -0
- package/dist/transport/message.js +46 -0
- package/dist/transport/message.test.d.ts +1 -0
- package/dist/transport/message.test.js +19 -0
- package/dist/transport/stdio.d.ts +7 -0
- package/dist/transport/stdio.js +20 -0
- package/dist/transport/types.d.ts +14 -0
- package/dist/transport/types.js +40 -0
- package/dist/transport/ws.d.ts +9 -0
- package/dist/transport/ws.js +23 -0
- package/dist/transport/ws.test.d.ts +1 -0
- package/dist/transport/ws.test.js +41 -0
- package/dist/transport/ws.util.d.ts +11 -0
- package/dist/transport/ws.util.js +42 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Repl.it
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# river - Streaming Remote Procedure Calls
|
|
2
|
+
|
|
3
|
+
It's like tRPC but...
|
|
4
|
+
- with JSON Schema Support
|
|
5
|
+
- with full-duplex streaming
|
|
6
|
+
- with support for service multiplexing
|
|
7
|
+
- over WebSockets
|
|
8
|
+
|
|
9
|
+
## Levels of abstraction
|
|
10
|
+
- Router
|
|
11
|
+
- Service
|
|
12
|
+
- Procedure
|
|
13
|
+
|
|
14
|
+
## TODO
|
|
15
|
+
- support broadcast
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare const EchoRequest: import("@sinclair/typebox").TObject<{
|
|
2
|
+
msg: import("@sinclair/typebox").TString;
|
|
3
|
+
ignore: import("@sinclair/typebox").TBoolean;
|
|
4
|
+
}>;
|
|
5
|
+
export declare const EchoResponse: import("@sinclair/typebox").TObject<{
|
|
6
|
+
response: import("@sinclair/typebox").TString;
|
|
7
|
+
}>;
|
|
8
|
+
export declare const TestServiceConstructor: () => {
|
|
9
|
+
name: "test";
|
|
10
|
+
state: {
|
|
11
|
+
count: number;
|
|
12
|
+
};
|
|
13
|
+
procedures: {
|
|
14
|
+
add: {
|
|
15
|
+
input: import("@sinclair/typebox").TObject<{
|
|
16
|
+
n: import("@sinclair/typebox").TNumber;
|
|
17
|
+
}>;
|
|
18
|
+
output: import("@sinclair/typebox").TObject<{
|
|
19
|
+
result: import("@sinclair/typebox").TNumber;
|
|
20
|
+
}>;
|
|
21
|
+
handler: (state: {
|
|
22
|
+
count: number;
|
|
23
|
+
}, input: import("../transport/message").TransportMessage<{
|
|
24
|
+
n: number;
|
|
25
|
+
}>) => Promise<import("../transport/message").TransportMessage<{
|
|
26
|
+
result: number;
|
|
27
|
+
}>>;
|
|
28
|
+
type: "rpc";
|
|
29
|
+
};
|
|
30
|
+
echo: {
|
|
31
|
+
input: import("@sinclair/typebox").TObject<{
|
|
32
|
+
msg: import("@sinclair/typebox").TString;
|
|
33
|
+
ignore: import("@sinclair/typebox").TBoolean;
|
|
34
|
+
}>;
|
|
35
|
+
output: import("@sinclair/typebox").TObject<{
|
|
36
|
+
response: import("@sinclair/typebox").TString;
|
|
37
|
+
}>;
|
|
38
|
+
handler: (state: {
|
|
39
|
+
count: number;
|
|
40
|
+
}, input: AsyncIterable<import("../transport/message").TransportMessage<{
|
|
41
|
+
msg: string;
|
|
42
|
+
ignore: boolean;
|
|
43
|
+
}>>, output: import("it-pushable").Pushable<import("../transport/message").TransportMessage<{
|
|
44
|
+
response: string;
|
|
45
|
+
}>, void, unknown>) => Promise<void>;
|
|
46
|
+
type: "stream";
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { ServiceBuilder, serializeService } from '../router/builder';
|
|
4
|
+
import { reply } from '../transport/message';
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
6
|
+
import { createWebSocketServer, createWsTransports, onServerReady } from '../transport/ws.util';
|
|
7
|
+
import { createServer } from '../router/server';
|
|
8
|
+
import { createClient } from '../router/client';
|
|
9
|
+
import { asClientRpc, asClientStream } from '../router/server.util';
|
|
10
|
+
export const EchoRequest = Type.Object({ msg: Type.String(), ignore: Type.Boolean() });
|
|
11
|
+
export const EchoResponse = Type.Object({ response: Type.String() });
|
|
12
|
+
export const TestServiceConstructor = () => ServiceBuilder.create('test')
|
|
13
|
+
.initialState({
|
|
14
|
+
count: 0,
|
|
15
|
+
})
|
|
16
|
+
.defineProcedure('add', {
|
|
17
|
+
type: 'rpc',
|
|
18
|
+
input: Type.Object({ n: Type.Number() }),
|
|
19
|
+
output: Type.Object({ result: Type.Number() }),
|
|
20
|
+
async handler(state, msg) {
|
|
21
|
+
const { n } = msg.payload;
|
|
22
|
+
state.count += n;
|
|
23
|
+
return reply(msg, { result: state.count });
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
.defineProcedure('echo', {
|
|
27
|
+
type: 'stream',
|
|
28
|
+
input: EchoRequest,
|
|
29
|
+
output: EchoResponse,
|
|
30
|
+
async handler(_state, msgStream, returnStream) {
|
|
31
|
+
for await (const msg of msgStream) {
|
|
32
|
+
const req = msg.payload;
|
|
33
|
+
if (!req.ignore) {
|
|
34
|
+
returnStream.push(reply(msg, { response: req.msg }));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.finalize();
|
|
40
|
+
test('serialize service to jsonschema', () => {
|
|
41
|
+
const service = TestServiceConstructor();
|
|
42
|
+
expect(serializeService(service)).toStrictEqual({
|
|
43
|
+
name: 'test',
|
|
44
|
+
state: { count: 0 },
|
|
45
|
+
procedures: {
|
|
46
|
+
add: {
|
|
47
|
+
input: {
|
|
48
|
+
properties: {
|
|
49
|
+
n: { type: 'number' },
|
|
50
|
+
},
|
|
51
|
+
required: ['n'],
|
|
52
|
+
type: 'object',
|
|
53
|
+
},
|
|
54
|
+
output: {
|
|
55
|
+
properties: {
|
|
56
|
+
result: { type: 'number' },
|
|
57
|
+
},
|
|
58
|
+
required: ['result'],
|
|
59
|
+
type: 'object',
|
|
60
|
+
},
|
|
61
|
+
type: 'rpc',
|
|
62
|
+
},
|
|
63
|
+
echo: {
|
|
64
|
+
input: {
|
|
65
|
+
properties: {
|
|
66
|
+
msg: { type: 'string' },
|
|
67
|
+
ignore: { type: 'boolean' },
|
|
68
|
+
},
|
|
69
|
+
required: ['msg', 'ignore'],
|
|
70
|
+
type: 'object',
|
|
71
|
+
},
|
|
72
|
+
output: {
|
|
73
|
+
properties: {
|
|
74
|
+
response: { type: 'string' },
|
|
75
|
+
},
|
|
76
|
+
required: ['response'],
|
|
77
|
+
type: 'object',
|
|
78
|
+
},
|
|
79
|
+
type: 'stream',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('server-side test', () => {
|
|
85
|
+
const service = TestServiceConstructor();
|
|
86
|
+
const initialState = { count: 0 };
|
|
87
|
+
test('rpc basic', async () => {
|
|
88
|
+
const add = asClientRpc(initialState, service.procedures.add);
|
|
89
|
+
await expect(add({ n: 3 })).resolves.toStrictEqual({ result: 3 });
|
|
90
|
+
});
|
|
91
|
+
test('rpc initial state', async () => {
|
|
92
|
+
const add = asClientRpc({ count: 5 }, service.procedures.add);
|
|
93
|
+
await expect(add({ n: 6 })).resolves.toStrictEqual({ result: 11 });
|
|
94
|
+
});
|
|
95
|
+
test('stream basic', async () => {
|
|
96
|
+
const [i, o] = asClientStream(initialState, service.procedures.echo);
|
|
97
|
+
i.push({ msg: 'abc', ignore: false });
|
|
98
|
+
i.push({ msg: 'def', ignore: true });
|
|
99
|
+
i.push({ msg: 'ghi', ignore: false });
|
|
100
|
+
i.end();
|
|
101
|
+
await expect(o.next().then((res) => res.value)).resolves.toStrictEqual({ response: 'abc' });
|
|
102
|
+
await expect(o.next().then((res) => res.value)).resolves.toStrictEqual({ response: 'ghi' });
|
|
103
|
+
expect(o.readableLength).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
const port = 3001;
|
|
107
|
+
describe('client <-> server integration test', () => {
|
|
108
|
+
const server = http.createServer();
|
|
109
|
+
let wss;
|
|
110
|
+
beforeAll(async () => {
|
|
111
|
+
await onServerReady(server, port);
|
|
112
|
+
wss = await createWebSocketServer(server);
|
|
113
|
+
});
|
|
114
|
+
afterAll(() => {
|
|
115
|
+
wss.clients.forEach((socket) => {
|
|
116
|
+
socket.close();
|
|
117
|
+
});
|
|
118
|
+
server.close();
|
|
119
|
+
});
|
|
120
|
+
test('rpc', async () => {
|
|
121
|
+
const [ct, st] = await createWsTransports(port, wss);
|
|
122
|
+
const serviceDefs = { test: TestServiceConstructor() };
|
|
123
|
+
const server = await createServer(st, serviceDefs);
|
|
124
|
+
const client = createClient(ct);
|
|
125
|
+
await expect(client.test.add({ n: 3 })).resolves.toStrictEqual({ result: 3 });
|
|
126
|
+
});
|
|
127
|
+
test('stream', async () => {
|
|
128
|
+
const [ct, st] = await createWsTransports(port, wss);
|
|
129
|
+
const serviceDefs = { test: TestServiceConstructor() };
|
|
130
|
+
const server = await createServer(st, serviceDefs);
|
|
131
|
+
const client = createClient(ct);
|
|
132
|
+
const [i, o, close] = await client.test.echo();
|
|
133
|
+
i.push({ msg: 'abc', ignore: false });
|
|
134
|
+
i.push({ msg: 'def', ignore: true });
|
|
135
|
+
i.push({ msg: 'ghi', ignore: false });
|
|
136
|
+
i.end();
|
|
137
|
+
await expect(o.next().then((res) => res.value)).resolves.toStrictEqual({ response: 'abc' });
|
|
138
|
+
await expect(o.next().then((res) => res.value)).resolves.toStrictEqual({ response: 'ghi' });
|
|
139
|
+
close();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NaiveJsonCodec } from './json';
|
|
2
|
+
import { describe, test, expect } from 'vitest';
|
|
3
|
+
describe('naive json codec', () => {
|
|
4
|
+
test('empty object', () => {
|
|
5
|
+
const msg = {};
|
|
6
|
+
expect(NaiveJsonCodec.fromStringBuf(NaiveJsonCodec.toStringBuf(msg))).toStrictEqual(msg);
|
|
7
|
+
});
|
|
8
|
+
test('simple test', () => {
|
|
9
|
+
const msg = { abc: 123, def: 'cool' };
|
|
10
|
+
expect(NaiveJsonCodec.fromStringBuf(NaiveJsonCodec.toStringBuf(msg))).toStrictEqual(msg);
|
|
11
|
+
});
|
|
12
|
+
test('deeply nested test', () => {
|
|
13
|
+
const msg = {
|
|
14
|
+
array: [{ object: true }],
|
|
15
|
+
deeply: {
|
|
16
|
+
nested: {
|
|
17
|
+
nice: null,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
expect(NaiveJsonCodec.fromStringBuf(NaiveJsonCodec.toStringBuf(msg))).toStrictEqual(msg);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";console.log("hello world");
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { TObject, Static } from '@sinclair/typebox';
|
|
2
|
+
import { Pushable } from 'it-pushable';
|
|
3
|
+
import { TransportMessage } from '../transport/message';
|
|
4
|
+
export type ValidProcType = 'stream' | 'rpc';
|
|
5
|
+
export type ProcListing = Record<string, Procedure<object, ValidProcType, TObject, TObject>>;
|
|
6
|
+
export interface Service<Name extends string = string, State extends object = object, Procs extends ProcListing = Record<string, any>> {
|
|
7
|
+
name: Name;
|
|
8
|
+
state: State;
|
|
9
|
+
procedures: Procs;
|
|
10
|
+
}
|
|
11
|
+
export declare function serializeService(s: Service): object;
|
|
12
|
+
export type ProcHandler<S extends Service, ProcName extends keyof S['procedures']> = S['procedures'][ProcName]['handler'];
|
|
13
|
+
export type ProcInput<S extends Service, ProcName extends keyof S['procedures']> = S['procedures'][ProcName]['input'];
|
|
14
|
+
export type ProcOutput<S extends Service, ProcName extends keyof S['procedures']> = S['procedures'][ProcName]['output'];
|
|
15
|
+
export type ProcType<S extends Service, ProcName extends keyof S['procedures']> = S['procedures'][ProcName]['type'];
|
|
16
|
+
export type Procedure<State extends object | unknown, Ty extends ValidProcType, I extends TObject, O extends TObject> = Ty extends 'rpc' ? {
|
|
17
|
+
input: I;
|
|
18
|
+
output: O;
|
|
19
|
+
handler: (state: State, input: TransportMessage<Static<I>>) => Promise<TransportMessage<Static<O>>>;
|
|
20
|
+
type: Ty;
|
|
21
|
+
} : {
|
|
22
|
+
input: I;
|
|
23
|
+
output: O;
|
|
24
|
+
handler: (state: State, input: AsyncIterable<TransportMessage<Static<I>>>, output: Pushable<TransportMessage<Static<O>>>) => Promise<void>;
|
|
25
|
+
type: Ty;
|
|
26
|
+
};
|
|
27
|
+
export declare class ServiceBuilder<T extends Service<string, object, ProcListing>> {
|
|
28
|
+
private readonly schema;
|
|
29
|
+
private constructor();
|
|
30
|
+
finalize(): T;
|
|
31
|
+
initialState<InitState extends T['state']>(state: InitState): ServiceBuilder<{
|
|
32
|
+
name: T['name'];
|
|
33
|
+
state: InitState;
|
|
34
|
+
procedures: T['procedures'];
|
|
35
|
+
}>;
|
|
36
|
+
defineProcedure<ProcName extends string, Ty extends ValidProcType, I extends TObject, O extends TObject, ProcEntry = {
|
|
37
|
+
[k in ProcName]: Procedure<T['state'], Ty, I, O>;
|
|
38
|
+
}>(procName: ProcName, procDef: Procedure<T['state'], Ty, I, O>): ServiceBuilder<{
|
|
39
|
+
name: T['name'];
|
|
40
|
+
state: T['state'];
|
|
41
|
+
procedures: {
|
|
42
|
+
[Key in keyof (T['procedures'] & ProcEntry)]: (T['procedures'] & ProcEntry)[Key];
|
|
43
|
+
};
|
|
44
|
+
}>;
|
|
45
|
+
static create<Name extends string>(name: Name): ServiceBuilder<{
|
|
46
|
+
name: Name;
|
|
47
|
+
state: {};
|
|
48
|
+
procedures: {};
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
export function serializeService(s) {
|
|
3
|
+
return {
|
|
4
|
+
name: s.name,
|
|
5
|
+
state: s.state,
|
|
6
|
+
procedures: Object.fromEntries(Object.entries(s.procedures).map(([procName, procDef]) => [
|
|
7
|
+
procName,
|
|
8
|
+
{
|
|
9
|
+
input: Type.Strict(procDef.input),
|
|
10
|
+
output: Type.Strict(procDef.output),
|
|
11
|
+
type: procDef.type,
|
|
12
|
+
},
|
|
13
|
+
])),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export class ServiceBuilder {
|
|
17
|
+
schema;
|
|
18
|
+
constructor(schema) {
|
|
19
|
+
this.schema = schema;
|
|
20
|
+
}
|
|
21
|
+
finalize() {
|
|
22
|
+
return this.schema;
|
|
23
|
+
}
|
|
24
|
+
initialState(state) {
|
|
25
|
+
return new ServiceBuilder({
|
|
26
|
+
...this.schema,
|
|
27
|
+
state,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
defineProcedure(procName, procDef) {
|
|
31
|
+
const newProcedure = { [procName]: procDef };
|
|
32
|
+
const procedures = {
|
|
33
|
+
...this.schema.procedures,
|
|
34
|
+
...newProcedure,
|
|
35
|
+
};
|
|
36
|
+
return new ServiceBuilder({
|
|
37
|
+
...this.schema,
|
|
38
|
+
procedures,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
static create(name) {
|
|
42
|
+
return new ServiceBuilder({
|
|
43
|
+
name,
|
|
44
|
+
state: {},
|
|
45
|
+
procedures: {},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Transport } from '../transport/types';
|
|
2
|
+
import { ProcInput, ProcOutput, ProcType, Service } from './builder';
|
|
3
|
+
import { Pushable } from 'it-pushable';
|
|
4
|
+
import { Server } from './server';
|
|
5
|
+
import { Static } from '@sinclair/typebox';
|
|
6
|
+
type ServiceClient<Router extends Service> = {
|
|
7
|
+
[ProcName in keyof Router['procedures']]: ProcType<Router, ProcName> extends 'rpc' ? (input: Static<ProcInput<Router, ProcName>>) => Promise<Static<ProcOutput<Router, ProcName>>> : () => Promise<[
|
|
8
|
+
Pushable<Static<ProcInput<Router, ProcName>>>,
|
|
9
|
+
AsyncIterator<Static<ProcOutput<Router, ProcName>>>,
|
|
10
|
+
() => void
|
|
11
|
+
]>;
|
|
12
|
+
};
|
|
13
|
+
export type ServerClient<Srv extends Server<Record<string, Service>>> = {
|
|
14
|
+
[SvcName in keyof Srv['services']]: ServiceClient<Srv['services'][SvcName]>;
|
|
15
|
+
};
|
|
16
|
+
export declare const createClient: <Srv extends Server<Record<string, Service<string, object, Record<string, any>>>>>(transport: Transport) => ServerClient<Srv>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { pushable } from 'it-pushable';
|
|
2
|
+
import { msg } from '../transport/message';
|
|
3
|
+
import { waitForMessage } from '../transport/ws.util';
|
|
4
|
+
const noop = () => { };
|
|
5
|
+
function _createRecursiveProxy(callback, path) {
|
|
6
|
+
const proxy = new Proxy(noop, {
|
|
7
|
+
get(_obj, key) {
|
|
8
|
+
if (typeof key !== 'string')
|
|
9
|
+
return undefined;
|
|
10
|
+
return _createRecursiveProxy(callback, [...path, key]);
|
|
11
|
+
},
|
|
12
|
+
apply(_target, _this, args) {
|
|
13
|
+
return callback({
|
|
14
|
+
path,
|
|
15
|
+
args,
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
return proxy;
|
|
20
|
+
}
|
|
21
|
+
export const createClient = (transport) => _createRecursiveProxy(async (opts) => {
|
|
22
|
+
const [serviceName, procName] = [...opts.path];
|
|
23
|
+
const [input] = opts.args;
|
|
24
|
+
if (input === undefined) {
|
|
25
|
+
// stream case
|
|
26
|
+
const i = pushable({ objectMode: true });
|
|
27
|
+
const o = pushable({ objectMode: true });
|
|
28
|
+
// i -> transport
|
|
29
|
+
// this gets cleaned up on i.end() which is called by closeHandler
|
|
30
|
+
(async () => {
|
|
31
|
+
for await (const rawIn of i) {
|
|
32
|
+
transport.send(msg(transport.clientId, 'SERVER', serviceName, procName, rawIn));
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
// transport -> o
|
|
36
|
+
const listener = (msg) => {
|
|
37
|
+
if (msg.serviceName === serviceName && msg.procedureName === procName) {
|
|
38
|
+
o.push(msg.payload);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
transport.addMessageListener(listener);
|
|
42
|
+
const closeHandler = () => {
|
|
43
|
+
i.end();
|
|
44
|
+
o.end();
|
|
45
|
+
transport.removeMessageListener(listener);
|
|
46
|
+
};
|
|
47
|
+
return [i, o, closeHandler];
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// rpc case
|
|
51
|
+
const id = transport.send(msg(transport.clientId, 'SERVER', serviceName, procName, input));
|
|
52
|
+
return waitForMessage(transport, (msg) => msg.replyTo === id);
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Transport } from '../transport/types';
|
|
2
|
+
import { Service } from './builder';
|
|
3
|
+
export interface Server<Services> {
|
|
4
|
+
services: Services;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function createServer<Services extends Record<string, Service>>(transport: Transport, services: Services): Promise<Server<Services>>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Value } from '@sinclair/typebox/value';
|
|
2
|
+
import { pushable } from 'it-pushable';
|
|
3
|
+
export async function createServer(transport, services) {
|
|
4
|
+
// create streams for every stream procedure
|
|
5
|
+
const streamMap = new Map();
|
|
6
|
+
for (const [serviceName, service] of Object.entries(services)) {
|
|
7
|
+
for (const [procedureName, proc] of Object.entries(service.procedures)) {
|
|
8
|
+
const procedure = proc;
|
|
9
|
+
if (procedure.type === 'stream') {
|
|
10
|
+
const incoming = pushable({ objectMode: true });
|
|
11
|
+
const outgoing = pushable({ objectMode: true });
|
|
12
|
+
const procStream = {
|
|
13
|
+
incoming,
|
|
14
|
+
outgoing,
|
|
15
|
+
doneCtx: Promise.all([
|
|
16
|
+
// processing the actual procedure
|
|
17
|
+
procedure.handler(service.state, incoming, outgoing),
|
|
18
|
+
// sending outgoing messages back to client
|
|
19
|
+
(async () => {
|
|
20
|
+
for await (const response of outgoing) {
|
|
21
|
+
transport.send(response);
|
|
22
|
+
}
|
|
23
|
+
})(),
|
|
24
|
+
]),
|
|
25
|
+
};
|
|
26
|
+
streamMap.set(`${serviceName}:${procedureName}`, procStream);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const handler = async (msg) => {
|
|
31
|
+
// TODO: log msgs received
|
|
32
|
+
if (msg.to !== 'SERVER') {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (msg.serviceName in services) {
|
|
36
|
+
const service = services[msg.serviceName];
|
|
37
|
+
if (msg.procedureName in service.procedures) {
|
|
38
|
+
const procedure = service.procedures[msg.procedureName];
|
|
39
|
+
const inputMessage = msg;
|
|
40
|
+
if (procedure.type === 'rpc' && Value.Check(procedure.input, inputMessage.payload)) {
|
|
41
|
+
// synchronous rpc
|
|
42
|
+
const response = await procedure.handler(service.state, inputMessage);
|
|
43
|
+
transport.send(response);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
else if (procedure.type === 'stream' &&
|
|
47
|
+
Value.Check(procedure.input, inputMessage.payload)) {
|
|
48
|
+
// async stream, push to associated stream. code above handles sending responses
|
|
49
|
+
// back to the client
|
|
50
|
+
const streams = streamMap.get(`${msg.serviceName}:${msg.procedureName}`);
|
|
51
|
+
if (!streams) {
|
|
52
|
+
// this should never happen but log here if we get here
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
streams.incoming.push(inputMessage);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// TODO: log invalid payload
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
transport.addMessageListener(handler);
|
|
65
|
+
return {
|
|
66
|
+
services,
|
|
67
|
+
async close() {
|
|
68
|
+
// remove listener
|
|
69
|
+
transport.removeMessageListener(handler);
|
|
70
|
+
// end all existing streams
|
|
71
|
+
for (const [_, stream] of streamMap) {
|
|
72
|
+
stream.incoming.end();
|
|
73
|
+
stream.outgoing.end();
|
|
74
|
+
await stream.doneCtx;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Static, TObject } from '@sinclair/typebox';
|
|
2
|
+
import { Procedure } from './builder';
|
|
3
|
+
import { Pushable } from 'it-pushable';
|
|
4
|
+
export declare function asClientRpc<State extends object | unknown, I extends TObject, O extends TObject>(state: State, proc: Procedure<State, 'rpc', I, O>): (msg: Static<I>) => Promise<Static<O>>;
|
|
5
|
+
export declare function asClientStream<State extends object | unknown, I extends TObject, O extends TObject>(state: State, proc: Procedure<State, 'stream', I, O>): [Pushable<Static<I>>, Pushable<Static<O>>];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { payloadToTransportMessage } from '../transport/message';
|
|
2
|
+
import { pushable } from 'it-pushable';
|
|
3
|
+
export function asClientRpc(state, proc) {
|
|
4
|
+
return (msg) => proc.handler(state, payloadToTransportMessage(msg)).then((res) => res.payload);
|
|
5
|
+
}
|
|
6
|
+
export function asClientStream(state, proc) {
|
|
7
|
+
const i = pushable({ objectMode: true });
|
|
8
|
+
const o = pushable({ objectMode: true });
|
|
9
|
+
const ri = pushable({ objectMode: true });
|
|
10
|
+
const ro = pushable({ objectMode: true });
|
|
11
|
+
// wrapping in transport
|
|
12
|
+
(async () => {
|
|
13
|
+
for await (const rawIn of i) {
|
|
14
|
+
ri.push(payloadToTransportMessage(rawIn));
|
|
15
|
+
}
|
|
16
|
+
ri.end();
|
|
17
|
+
})();
|
|
18
|
+
// unwrap from transport
|
|
19
|
+
(async () => {
|
|
20
|
+
for await (const transportRes of ro) {
|
|
21
|
+
o.push(transportRes.payload);
|
|
22
|
+
}
|
|
23
|
+
})();
|
|
24
|
+
// handle
|
|
25
|
+
(async () => {
|
|
26
|
+
await proc.handler(state, ri, ro);
|
|
27
|
+
ro.end();
|
|
28
|
+
})();
|
|
29
|
+
return [i, o];
|
|
30
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Static, TSchema } from '@sinclair/typebox';
|
|
2
|
+
export declare const TransportMessageSchema: <T extends TSchema>(t: T) => import("@sinclair/typebox").TObject<{
|
|
3
|
+
id: import("@sinclair/typebox").TString;
|
|
4
|
+
replyTo: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
5
|
+
from: import("@sinclair/typebox").TString;
|
|
6
|
+
to: import("@sinclair/typebox").TString;
|
|
7
|
+
serviceName: import("@sinclair/typebox").TString;
|
|
8
|
+
procedureName: import("@sinclair/typebox").TString;
|
|
9
|
+
payload: T;
|
|
10
|
+
}>;
|
|
11
|
+
export declare const OpaqueTransportMessageSchema: import("@sinclair/typebox").TObject<{
|
|
12
|
+
id: import("@sinclair/typebox").TString;
|
|
13
|
+
replyTo: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
14
|
+
from: import("@sinclair/typebox").TString;
|
|
15
|
+
to: import("@sinclair/typebox").TString;
|
|
16
|
+
serviceName: import("@sinclair/typebox").TString;
|
|
17
|
+
procedureName: import("@sinclair/typebox").TString;
|
|
18
|
+
payload: import("@sinclair/typebox").TUnknown;
|
|
19
|
+
}>;
|
|
20
|
+
export type TransportMessage<Payload extends Record<string, unknown> | unknown = Record<string, unknown>> = {
|
|
21
|
+
id: string;
|
|
22
|
+
replyTo?: string;
|
|
23
|
+
from: string;
|
|
24
|
+
to: string;
|
|
25
|
+
serviceName: string;
|
|
26
|
+
procedureName: string;
|
|
27
|
+
payload: Payload;
|
|
28
|
+
};
|
|
29
|
+
export type MessageId = string;
|
|
30
|
+
export type OpaqueTransportMessage = TransportMessage<unknown>;
|
|
31
|
+
export type TransportClientId = 'SERVER' | string;
|
|
32
|
+
export declare const TransportAckSchema: import("@sinclair/typebox").TObject<{
|
|
33
|
+
from: import("@sinclair/typebox").TString;
|
|
34
|
+
ack: import("@sinclair/typebox").TString;
|
|
35
|
+
}>;
|
|
36
|
+
export type TransportMessageAck = Static<typeof TransportAckSchema>;
|
|
37
|
+
export declare function msg<Payload extends object>(from: string, to: string, service: string, proc: string, payload: Payload): {
|
|
38
|
+
id: string;
|
|
39
|
+
to: string;
|
|
40
|
+
from: string;
|
|
41
|
+
serviceName: string;
|
|
42
|
+
procedureName: string;
|
|
43
|
+
payload: Payload;
|
|
44
|
+
};
|
|
45
|
+
export declare function payloadToTransportMessage<Payload extends object>(payload: Payload): {
|
|
46
|
+
id: string;
|
|
47
|
+
to: string;
|
|
48
|
+
from: string;
|
|
49
|
+
serviceName: string;
|
|
50
|
+
procedureName: string;
|
|
51
|
+
payload: Payload;
|
|
52
|
+
};
|
|
53
|
+
export declare function ack(msg: OpaqueTransportMessage): TransportMessageAck;
|
|
54
|
+
export declare function reply<Payload extends object>(msg: OpaqueTransportMessage, response: Payload): {
|
|
55
|
+
id: string;
|
|
56
|
+
replyTo: string;
|
|
57
|
+
to: string;
|
|
58
|
+
from: string;
|
|
59
|
+
payload: Payload;
|
|
60
|
+
serviceName: string;
|
|
61
|
+
procedureName: string;
|
|
62
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
// look at https://github.com/websockets/ws#use-the-nodejs-streams-api for a duplex stream we can use
|
|
4
|
+
export const TransportMessageSchema = (t) => Type.Object({
|
|
5
|
+
id: Type.String(),
|
|
6
|
+
replyTo: Type.Optional(Type.String()),
|
|
7
|
+
from: Type.String(),
|
|
8
|
+
to: Type.String(),
|
|
9
|
+
serviceName: Type.String(),
|
|
10
|
+
procedureName: Type.String(),
|
|
11
|
+
payload: t,
|
|
12
|
+
});
|
|
13
|
+
export const OpaqueTransportMessageSchema = TransportMessageSchema(Type.Unknown());
|
|
14
|
+
export const TransportAckSchema = Type.Object({
|
|
15
|
+
from: Type.String(),
|
|
16
|
+
ack: Type.String(),
|
|
17
|
+
});
|
|
18
|
+
export function msg(from, to, service, proc, payload) {
|
|
19
|
+
return {
|
|
20
|
+
id: nanoid(),
|
|
21
|
+
to,
|
|
22
|
+
from,
|
|
23
|
+
serviceName: service,
|
|
24
|
+
procedureName: proc,
|
|
25
|
+
payload,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function payloadToTransportMessage(payload) {
|
|
29
|
+
return msg('client', 'SERVER', 'service', 'procedure', payload);
|
|
30
|
+
}
|
|
31
|
+
export function ack(msg) {
|
|
32
|
+
return {
|
|
33
|
+
from: msg.to,
|
|
34
|
+
ack: msg.id,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function reply(msg, response) {
|
|
38
|
+
return {
|
|
39
|
+
...msg,
|
|
40
|
+
id: nanoid(),
|
|
41
|
+
replyTo: msg.id,
|
|
42
|
+
to: msg.from,
|
|
43
|
+
from: msg.to,
|
|
44
|
+
payload: response,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ack, msg, reply } from './message';
|
|
2
|
+
import { describe, test, expect } from 'vitest';
|
|
3
|
+
describe('message helpers', () => {
|
|
4
|
+
test('ack', () => {
|
|
5
|
+
const m = msg('a', 'b', 'svc', 'proc', { test: 1 });
|
|
6
|
+
const resp = ack(m);
|
|
7
|
+
expect(resp.from).toBe('b');
|
|
8
|
+
expect(resp).toHaveProperty('ack');
|
|
9
|
+
});
|
|
10
|
+
test('reply', () => {
|
|
11
|
+
const m = msg('a', 'b', 'svc', 'proc', { test: 1 });
|
|
12
|
+
const payload = { cool: 2 };
|
|
13
|
+
const resp = reply(m, payload);
|
|
14
|
+
expect(resp.id).not.toBe(m.id);
|
|
15
|
+
expect(resp.payload).toEqual(payload);
|
|
16
|
+
expect(resp.from).toBe('b');
|
|
17
|
+
expect(resp.to).toBe('a');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { OpaqueTransportMessage, TransportClientId } from './message';
|
|
2
|
+
import { Transport } from './types';
|
|
3
|
+
export declare class StdioTransport extends Transport {
|
|
4
|
+
constructor(clientId: TransportClientId);
|
|
5
|
+
send(msg: OpaqueTransportMessage): string;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NaiveJsonCodec } from '../codec/json';
|
|
2
|
+
import { Transport } from './types';
|
|
3
|
+
import readline from 'readline';
|
|
4
|
+
export class StdioTransport extends Transport {
|
|
5
|
+
constructor(clientId) {
|
|
6
|
+
super(NaiveJsonCodec, clientId);
|
|
7
|
+
const { stdin, stdout } = process;
|
|
8
|
+
const rl = readline.createInterface({
|
|
9
|
+
input: stdin,
|
|
10
|
+
output: stdout,
|
|
11
|
+
});
|
|
12
|
+
rl.on('line', this.onMessage);
|
|
13
|
+
}
|
|
14
|
+
send(msg) {
|
|
15
|
+
const id = msg.id;
|
|
16
|
+
process.stdout.write(this.codec.toStringBuf(msg));
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
async close() { }
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Codec } from '../codec/types';
|
|
2
|
+
import { MessageId, OpaqueTransportMessage, TransportClientId, TransportMessageAck } from './message';
|
|
3
|
+
export declare abstract class Transport {
|
|
4
|
+
codec: Codec;
|
|
5
|
+
clientId: TransportClientId;
|
|
6
|
+
handlers: Set<(msg: OpaqueTransportMessage) => void>;
|
|
7
|
+
sendBuffer: Map<MessageId, OpaqueTransportMessage>;
|
|
8
|
+
constructor(codec: Codec, clientId: TransportClientId);
|
|
9
|
+
onMessage(msg: string): void;
|
|
10
|
+
addMessageListener(handler: (msg: OpaqueTransportMessage) => void): void;
|
|
11
|
+
removeMessageListener(handler: (msg: OpaqueTransportMessage) => void): void;
|
|
12
|
+
abstract send(msg: OpaqueTransportMessage | TransportMessageAck): MessageId;
|
|
13
|
+
abstract close(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Value } from '@sinclair/typebox/value';
|
|
2
|
+
import { OpaqueTransportMessageSchema, TransportAckSchema, ack, } from './message';
|
|
3
|
+
export class Transport {
|
|
4
|
+
codec;
|
|
5
|
+
clientId;
|
|
6
|
+
handlers;
|
|
7
|
+
sendBuffer;
|
|
8
|
+
constructor(codec, clientId) {
|
|
9
|
+
this.handlers = new Set();
|
|
10
|
+
this.sendBuffer = new Map();
|
|
11
|
+
this.codec = codec;
|
|
12
|
+
this.clientId = clientId;
|
|
13
|
+
}
|
|
14
|
+
onMessage(msg) {
|
|
15
|
+
const parsedMsg = this.codec.fromStringBuf(msg.toString());
|
|
16
|
+
if (Value.Check(TransportAckSchema, parsedMsg)) {
|
|
17
|
+
// process ack
|
|
18
|
+
if (this.sendBuffer.has(parsedMsg.ack)) {
|
|
19
|
+
this.sendBuffer.delete(parsedMsg.ack);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else if (Value.Check(OpaqueTransportMessageSchema, parsedMsg)) {
|
|
23
|
+
// ignore if not for us
|
|
24
|
+
if (parsedMsg.to !== this.clientId && parsedMsg.to !== 'broadcast') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// handle actual message
|
|
28
|
+
for (const handler of this.handlers) {
|
|
29
|
+
handler(parsedMsg);
|
|
30
|
+
}
|
|
31
|
+
this.send(ack(parsedMsg));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
addMessageListener(handler) {
|
|
35
|
+
this.handlers.add(handler);
|
|
36
|
+
}
|
|
37
|
+
removeMessageListener(handler) {
|
|
38
|
+
this.handlers.delete(handler);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type WebSocket from 'ws';
|
|
2
|
+
import { Transport } from './types';
|
|
3
|
+
import { MessageId, OpaqueTransportMessage, TransportClientId } from './message';
|
|
4
|
+
export declare class WebSocketTransport extends Transport {
|
|
5
|
+
ws: WebSocket;
|
|
6
|
+
constructor(ws: WebSocket, clientId: TransportClientId);
|
|
7
|
+
send(msg: OpaqueTransportMessage): MessageId;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Transport } from './types';
|
|
2
|
+
import { NaiveJsonCodec } from '../codec/json';
|
|
3
|
+
// TODO should answer:
|
|
4
|
+
// - how do we handle graceful client disconnects? (i.e. close tab)
|
|
5
|
+
// - how do we handle graceful service disconnects (i.e. a fuck off message)?
|
|
6
|
+
// - how do we handle forceful client disconnects? (i.e. broken connection, offline)
|
|
7
|
+
// - how do we handle forceful service disconnects (i.e. a crash)?
|
|
8
|
+
export class WebSocketTransport extends Transport {
|
|
9
|
+
ws;
|
|
10
|
+
constructor(ws, clientId) {
|
|
11
|
+
super(NaiveJsonCodec, clientId);
|
|
12
|
+
this.ws = ws;
|
|
13
|
+
ws.on('message', (msg) => this.onMessage(msg.toString()));
|
|
14
|
+
}
|
|
15
|
+
send(msg) {
|
|
16
|
+
const id = msg.id;
|
|
17
|
+
this.ws.send(this.codec.toStringBuf(msg));
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
async close() {
|
|
21
|
+
return this.ws.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { WebSocketTransport } from './ws';
|
|
3
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
|
|
4
|
+
import { createWebSocketClient, createWebSocketServer, onServerReady, waitForMessage, } from './ws.util';
|
|
5
|
+
const port = 3000;
|
|
6
|
+
describe('sending and receiving across websockets works', () => {
|
|
7
|
+
const server = http.createServer();
|
|
8
|
+
let wss;
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
await onServerReady(server, port);
|
|
11
|
+
wss = await createWebSocketServer(server);
|
|
12
|
+
});
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
wss.clients.forEach((socket) => {
|
|
15
|
+
socket.close();
|
|
16
|
+
});
|
|
17
|
+
server.close();
|
|
18
|
+
});
|
|
19
|
+
test('basic send/receive', async () => {
|
|
20
|
+
let serverTransport;
|
|
21
|
+
wss.on('connection', (conn) => {
|
|
22
|
+
serverTransport = new WebSocketTransport(conn, 'SERVER');
|
|
23
|
+
});
|
|
24
|
+
const clientSoc = await createWebSocketClient(port);
|
|
25
|
+
const clientTransport = new WebSocketTransport(clientSoc, 'client');
|
|
26
|
+
const msg = {
|
|
27
|
+
msg: 'cool',
|
|
28
|
+
test: 123,
|
|
29
|
+
};
|
|
30
|
+
clientTransport.send({
|
|
31
|
+
id: '1',
|
|
32
|
+
from: 'client',
|
|
33
|
+
to: 'SERVER',
|
|
34
|
+
serviceName: 'test',
|
|
35
|
+
procedureName: 'test',
|
|
36
|
+
payload: msg,
|
|
37
|
+
});
|
|
38
|
+
expect(serverTransport).toBeTruthy();
|
|
39
|
+
return expect(waitForMessage(serverTransport)).resolves.toStrictEqual(msg);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
4
|
+
import { Transport } from './types';
|
|
5
|
+
import { OpaqueTransportMessage } from './message';
|
|
6
|
+
export declare function createWebSocketServer(server: http.Server): Promise<WebSocket.Server<typeof WebSocket, typeof http.IncomingMessage>>;
|
|
7
|
+
export declare function onServerReady(server: http.Server, port: number): Promise<void>;
|
|
8
|
+
export declare function createWsTransports(port: number, wss: WebSocketServer): Promise<[Transport, Transport]>;
|
|
9
|
+
export declare function waitForSocketReady(socket: WebSocket): Promise<void>;
|
|
10
|
+
export declare function createWebSocketClient(port: number): Promise<WebSocket>;
|
|
11
|
+
export declare function waitForMessage(t: Transport, filter?: (msg: OpaqueTransportMessage) => boolean): Promise<unknown>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
2
|
+
import { WebSocketTransport } from './ws';
|
|
3
|
+
export async function createWebSocketServer(server) {
|
|
4
|
+
return new WebSocketServer({ server });
|
|
5
|
+
}
|
|
6
|
+
export async function onServerReady(server, port) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
server.listen(port, resolve);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export async function createWsTransports(port, wss) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const clientSockPromise = createWebSocketClient(port);
|
|
14
|
+
wss.on('connection', async (serverSock) => {
|
|
15
|
+
resolve([
|
|
16
|
+
new WebSocketTransport(await clientSockPromise, 'client'),
|
|
17
|
+
new WebSocketTransport(serverSock, 'SERVER'),
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export async function waitForSocketReady(socket) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
socket.addEventListener('open', () => resolve());
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export async function createWebSocketClient(port) {
|
|
28
|
+
const client = new WebSocket(`ws://localhost:${port}`);
|
|
29
|
+
await waitForSocketReady(client);
|
|
30
|
+
return client;
|
|
31
|
+
}
|
|
32
|
+
export async function waitForMessage(t, filter) {
|
|
33
|
+
return new Promise((resolve, _reject) => {
|
|
34
|
+
function onMessage(msg) {
|
|
35
|
+
if (!filter || filter?.(msg)) {
|
|
36
|
+
resolve(msg.payload);
|
|
37
|
+
t.removeMessageListener(onMessage);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
t.addMessageListener(onMessage);
|
|
41
|
+
});
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@replit/river",
|
|
3
|
+
"description": "It's like tRPC but... with JSON Schema Support, duplex streaming and support for service multiplexing. Transport agnostic!",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"/dist"
|
|
8
|
+
],
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@sinclair/typebox": "^0.31.8",
|
|
11
|
+
"it-pushable": "^3.2.1",
|
|
12
|
+
"nanoid": "^4.0.2",
|
|
13
|
+
"ws": "^8.13.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/ws": "^8.5.5",
|
|
17
|
+
"prettier": "^3.0.3",
|
|
18
|
+
"tsup": "^7.2.0",
|
|
19
|
+
"typescript": "^5.2.2",
|
|
20
|
+
"vitest": "^0.34.3"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"check": "tsc --noEmit",
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"publish": "npm publish --access public",
|
|
27
|
+
"test": "vitest"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=16"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"rpc",
|
|
34
|
+
"websockets",
|
|
35
|
+
"jsonschema"
|
|
36
|
+
],
|
|
37
|
+
"author": "Jacky Zhao",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"prettier": {
|
|
40
|
+
"printWidth": 100,
|
|
41
|
+
"tabWidth": 2,
|
|
42
|
+
"singleQuote": true,
|
|
43
|
+
"trailingComma": "all",
|
|
44
|
+
"bracketSpacing": true,
|
|
45
|
+
"semi": true,
|
|
46
|
+
"useTabs": false,
|
|
47
|
+
"parser": "typescript",
|
|
48
|
+
"arrowParens": "always"
|
|
49
|
+
}
|
|
50
|
+
}
|