Skip to content

Commit cc314a5

Browse files
authored
Merge pull request #500 from streamich/ws
Native node server
2 parents 19164cb + c3ac50c commit cc314a5

24 files changed

+2072
-9
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
"webpack": "^5.84.1",
161161
"webpack-cli": "^5.1.1",
162162
"webpack-dev-server": "^4.15.0",
163+
"websocket": "^1.0.34",
163164
"ws": "^8.14.2",
164165
"yjs": "13.6.9",
165166
"ywasm": "0.16.10"

src/reactive-rpc/__demos__/ws.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// npx ts-node src/reactive-rpc/__demos__/ws.ts
2+
3+
import {createCaller} from '../common/rpc/__tests__/sample-api';
4+
import {RpcServer} from '../server/http1/RpcServer';
5+
6+
const server = RpcServer.startWithDefaults({
7+
port: 3000,
8+
caller: createCaller(),
9+
logger: console,
10+
});
11+
12+
// tslint:disable-next-line no-console
13+
console.log(server + '');

src/reactive-rpc/common/rpc/caller/error/RpcError.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export enum RpcErrorCodes {
3434
export type RpcErrorValue = RpcValue<RpcError>;
3535

3636
export class RpcError extends Error implements IRpcError {
37-
public static from(error: unknown) {
37+
public static from(error: unknown): RpcError {
3838
if (error instanceof RpcError) return error;
3939
return RpcError.internal(error);
4040
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import * as http from 'http';
2+
import * as net from 'net';
3+
import {WsServerConnection} from '../ws/server/WsServerConnection';
4+
import {WsFrameEncoder} from '../ws/codec/WsFrameEncoder';
5+
import {Writer} from '../../../util/buffers/Writer';
6+
import {RouteMatcher} from '../../../util/router/codegen';
7+
import {Router} from '../../../util/router';
8+
import {Printable} from '../../../util/print/types';
9+
import {printTree} from '../../../util/print/printTree';
10+
import {PayloadTooLarge} from './errors';
11+
import {findTokenInText, setCodecs} from './util';
12+
import {Http1ConnectionContext, WsConnectionContext} from './context';
13+
import {RpcCodecs} from '../../common/codec/RpcCodecs';
14+
import {Codecs} from '../../../json-pack/codecs/Codecs';
15+
import {RpcMessageCodecs} from '../../common/codec/RpcMessageCodecs';
16+
import {NullObject} from '../../../util/NullObject';
17+
18+
export type Http1Handler = (ctx: Http1ConnectionContext) => void | Promise<void>;
19+
export type Http1NotFoundHandler = (res: http.ServerResponse, req: http.IncomingMessage) => void;
20+
export type Http1InternalErrorHandler = (error: unknown, res: http.ServerResponse, req: http.IncomingMessage) => void;
21+
22+
export class Http1EndpointMatch {
23+
constructor(public readonly handler: Http1Handler) {}
24+
}
25+
26+
export interface Http1EndpointDefinition {
27+
/**
28+
* The HTTP method to match. If not specified, then the handler will be
29+
* invoked for any method.
30+
*/
31+
method?: string | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE' | 'CONNECT';
32+
33+
/**
34+
* The path to match. Should start with a slash.
35+
*/
36+
path: string;
37+
38+
/**
39+
* The handler function.
40+
*/
41+
handler: Http1Handler;
42+
}
43+
44+
export interface WsEndpointDefinition {
45+
path: string;
46+
maxIncomingMessage?: number;
47+
maxOutgoingBackpressure?: number;
48+
onUpgrade?(req: http.IncomingMessage, connection: WsServerConnection): void;
49+
handler(ctx: WsConnectionContext, req: http.IncomingMessage): void;
50+
}
51+
52+
export interface Http1ServerOpts {
53+
server: http.Server;
54+
codecs?: RpcCodecs;
55+
writer?: Writer;
56+
}
57+
58+
export class Http1Server implements Printable {
59+
public static start(opts: http.ServerOptions = {}, port = 8000): Http1Server {
60+
const rawServer = http.createServer(opts);
61+
rawServer.listen(port);
62+
const server = new Http1Server({server: rawServer});
63+
return server;
64+
}
65+
66+
public readonly codecs: RpcCodecs;
67+
public readonly server: http.Server;
68+
69+
constructor(protected readonly opts: Http1ServerOpts) {
70+
this.server = opts.server;
71+
const writer = opts.writer ?? new Writer();
72+
this.codecs = opts.codecs ?? new RpcCodecs(opts.codecs ?? new Codecs(writer), new RpcMessageCodecs());
73+
this.wsEncoder = new WsFrameEncoder(writer);
74+
}
75+
76+
public start(): void {
77+
const server = this.server;
78+
this.httpMatcher = this.httpRouter.compile();
79+
this.wsMatcher = this.wsRouter.compile();
80+
server.on('request', this.onRequest);
81+
server.on('upgrade', this.onWsUpgrade);
82+
server.on('clientError', (err, socket) => {
83+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
84+
});
85+
}
86+
87+
// ------------------------------------------------------------- HTTP routing
88+
89+
public onnotfound: Http1NotFoundHandler = (res) => {
90+
res.writeHead(404, 'Not Found');
91+
res.end();
92+
};
93+
94+
public oninternalerror: Http1InternalErrorHandler = (error: unknown, res) => {
95+
if (error instanceof PayloadTooLarge) {
96+
res.statusCode = 413;
97+
res.statusMessage = 'Payload Too Large';
98+
res.end();
99+
return;
100+
}
101+
res.statusCode = 500;
102+
res.statusMessage = 'Internal Server Error';
103+
res.end();
104+
};
105+
106+
protected readonly httpRouter = new Router<Http1EndpointMatch>();
107+
protected httpMatcher: RouteMatcher<Http1EndpointMatch> = () => undefined;
108+
109+
public route(def: Http1EndpointDefinition): void {
110+
let path = def.path;
111+
if (path[0] !== '/') path = '/' + path;
112+
const method = def.method ? def.method.toUpperCase() : 'GET';
113+
const route = method + path;
114+
Number(route);
115+
const match = new Http1EndpointMatch(def.handler);
116+
this.httpRouter.add(route, match);
117+
}
118+
119+
private readonly onRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
120+
try {
121+
res.sendDate = false;
122+
const url = req.url ?? '';
123+
const queryStartIndex = url.indexOf('?');
124+
let path = url;
125+
let query = '';
126+
if (queryStartIndex >= 0) {
127+
path = url.slice(0, queryStartIndex);
128+
query = url.slice(queryStartIndex + 1);
129+
}
130+
const route = (req.method || '') + path;
131+
const match = this.httpMatcher(route);
132+
if (!match) {
133+
this.onnotfound(res, req);
134+
return;
135+
}
136+
const codecs = this.codecs;
137+
const ip = this.findIp(req);
138+
const token = this.findToken(req);
139+
const ctx = new Http1ConnectionContext(
140+
req,
141+
res,
142+
path,
143+
query,
144+
ip,
145+
token,
146+
match.params,
147+
new NullObject(),
148+
codecs.value.json,
149+
codecs.value.json,
150+
codecs.messages.compact,
151+
);
152+
const headers = req.headers;
153+
const contentType = headers['content-type'];
154+
if (typeof contentType === 'string') setCodecs(ctx, contentType, codecs);
155+
const handler = match.data.handler;
156+
await handler(ctx);
157+
} catch (error) {
158+
this.oninternalerror(error, res, req);
159+
}
160+
};
161+
162+
// --------------------------------------------------------------- WebSockets
163+
164+
protected readonly wsEncoder: WsFrameEncoder;
165+
protected readonly wsRouter = new Router<WsEndpointDefinition>();
166+
protected wsMatcher: RouteMatcher<WsEndpointDefinition> = () => undefined;
167+
168+
private readonly onWsUpgrade = (req: http.IncomingMessage, socket: net.Socket) => {
169+
const url = req.url ?? '';
170+
const queryStartIndex = url.indexOf('?');
171+
let path = url;
172+
let query = '';
173+
if (queryStartIndex >= 0) {
174+
path = url.slice(0, queryStartIndex);
175+
query = url.slice(queryStartIndex + 1);
176+
}
177+
const match = this.wsMatcher(path);
178+
if (!match) {
179+
socket.end();
180+
return;
181+
}
182+
const def = match.data;
183+
const headers = req.headers;
184+
const connection = new WsServerConnection(this.wsEncoder, socket as net.Socket);
185+
connection.maxIncomingMessage = def.maxIncomingMessage ?? 2 * 1024 * 1024;
186+
connection.maxBackpressure = def.maxOutgoingBackpressure ?? 2 * 1024 * 1024;
187+
if (def.onUpgrade) def.onUpgrade(req, connection);
188+
else {
189+
const secWebSocketKey = headers['sec-websocket-key'] ?? '';
190+
const secWebSocketProtocol = headers['sec-websocket-protocol'] ?? '';
191+
const secWebSocketExtensions = headers['sec-websocket-extensions'] ?? '';
192+
connection.upgrade(secWebSocketKey, secWebSocketProtocol, secWebSocketExtensions);
193+
}
194+
const codecs = this.codecs;
195+
const ip = this.findIp(req);
196+
const token = this.findToken(req);
197+
const ctx = new WsConnectionContext(
198+
connection,
199+
path,
200+
query,
201+
ip,
202+
token,
203+
match.params,
204+
new NullObject(),
205+
codecs.value.json,
206+
codecs.value.json,
207+
codecs.messages.compact,
208+
);
209+
const contentType = headers['content-type'];
210+
if (typeof contentType === 'string') setCodecs(ctx, contentType, codecs);
211+
else {
212+
const secWebSocketProtocol = headers['sec-websocket-protocol'] ?? '';
213+
if (typeof secWebSocketProtocol === 'string') setCodecs(ctx, secWebSocketProtocol, codecs);
214+
}
215+
def.handler(ctx, req);
216+
};
217+
218+
public ws(def: WsEndpointDefinition): void {
219+
this.wsRouter.add(def.path, def);
220+
}
221+
222+
// ------------------------------------------------------- Context management
223+
224+
public findIp(req: http.IncomingMessage): string {
225+
const headers = req.headers;
226+
const ip = headers['x-forwarded-for'] || headers['x-real-ip'] || req.socket.remoteAddress || '';
227+
return ip instanceof Array ? ip[0] : ip;
228+
}
229+
230+
/**
231+
* Looks for an authentication token in the following places:
232+
*
233+
* 1. The `Authorization` header.
234+
* 2. The URI query parameters.
235+
* 3. The `Cookie` header.
236+
* 4. The `Sec-Websocket-Protocol` header.
237+
*
238+
* @param req HTTP request
239+
* @returns Authentication token, if any.
240+
*/
241+
public findToken(req: http.IncomingMessage): string {
242+
let token: string = '';
243+
const headers = req.headers;
244+
let header: string | string[] | undefined;
245+
header = headers.authorization;
246+
if (typeof header === 'string') token = findTokenInText(header);
247+
if (token) return token;
248+
const url = req.url;
249+
if (typeof url === 'string') token = findTokenInText(url);
250+
if (token) return token;
251+
header = headers.cookie;
252+
if (typeof header === 'string') token = findTokenInText(header);
253+
if (token) return token;
254+
header = headers['sec-websocket-protocol'];
255+
if (typeof header === 'string') token = findTokenInText(header);
256+
return token;
257+
}
258+
259+
// ------------------------------------------------------- High-level routing
260+
261+
public enableHttpPing(path: string = '/ping') {
262+
this.route({
263+
path,
264+
handler: (ctx) => {
265+
ctx.res.end('"pong"');
266+
},
267+
});
268+
}
269+
270+
// ---------------------------------------------------------------- Printable
271+
272+
public toString(tab: string = ''): string {
273+
return (
274+
`${this.constructor.name}` +
275+
printTree(tab, [
276+
(tab) => `HTTP ${this.httpRouter.toString(tab)}`,
277+
(tab) => `WebSocket ${this.wsRouter.toString(tab)}`,
278+
])
279+
);
280+
}
281+
}

0 commit comments

Comments
 (0)