@grest-ts/ipc 0.0.5
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/src/client/IPCClient.d.ts +15 -0
- package/dist/src/client/IPCClient.d.ts.map +1 -0
- package/dist/src/client/IPCClient.js +75 -0
- package/dist/src/client/IPCClient.js.map +1 -0
- package/dist/src/common/IPCSocket.d.ts +42 -0
- package/dist/src/common/IPCSocket.d.ts.map +1 -0
- package/dist/src/common/IPCSocket.js +206 -0
- package/dist/src/common/IPCSocket.js.map +1 -0
- package/dist/src/common/IPCTypes.d.ts +17 -0
- package/dist/src/common/IPCTypes.d.ts.map +1 -0
- package/dist/src/common/IPCTypes.js +2 -0
- package/dist/src/common/IPCTypes.js.map +1 -0
- package/dist/src/index-node.d.ts +7 -0
- package/dist/src/index-node.d.ts.map +1 -0
- package/dist/src/index-node.js +6 -0
- package/dist/src/index-node.js.map +1 -0
- package/dist/src/server/HttpHandler.d.ts +19 -0
- package/dist/src/server/HttpHandler.d.ts.map +1 -0
- package/dist/src/server/HttpHandler.js +83 -0
- package/dist/src/server/HttpHandler.js.map +1 -0
- package/dist/src/server/IPCServer.d.ts +25 -0
- package/dist/src/server/IPCServer.d.ts.map +1 -0
- package/dist/src/server/IPCServer.js +122 -0
- package/dist/src/server/IPCServer.js.map +1 -0
- package/dist/src/server/SocketHandler.d.ts +28 -0
- package/dist/src/server/SocketHandler.d.ts.map +1 -0
- package/dist/src/server/SocketHandler.js +103 -0
- package/dist/src/server/SocketHandler.js.map +1 -0
- package/dist/src/tsconfig.json +16 -0
- package/dist/tsconfig.publish.tsbuildinfo +1 -0
- package/package.json +60 -0
- package/src/client/IPCClient.ts +99 -0
- package/src/common/IPCSocket.ts +251 -0
- package/src/common/IPCTypes.ts +17 -0
- package/src/index-node.ts +6 -0
- package/src/server/HttpHandler.ts +107 -0
- package/src/server/IPCServer.ts +159 -0
- package/src/server/SocketHandler.ts +146 -0
- package/src/tsconfig.json +16 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import {GGLog} from "@grest-ts/logger";
|
|
3
|
+
import {GGLocator} from "@grest-ts/locator";
|
|
4
|
+
|
|
5
|
+
export enum IPCMessageType {
|
|
6
|
+
MSG = 'm',
|
|
7
|
+
REQ = 'r',
|
|
8
|
+
RES = 's'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IPCMessage {
|
|
12
|
+
type: IPCMessageType;
|
|
13
|
+
id: string;
|
|
14
|
+
path: string;
|
|
15
|
+
data?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type IPCMessageHandler = (data: unknown) => void | Promise<void>;
|
|
19
|
+
export type IPCRequestHandler = (data: unknown) => Promise<unknown>;
|
|
20
|
+
|
|
21
|
+
interface PendingRequest {
|
|
22
|
+
resolve: (data: unknown) => void;
|
|
23
|
+
reject: (error: Error) => void;
|
|
24
|
+
timeout: NodeJS.Timeout;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class IPCSocket {
|
|
28
|
+
|
|
29
|
+
private readonly ws: WebSocket;
|
|
30
|
+
private readonly messageHandlers: Map<string, IPCMessageHandler> = new Map();
|
|
31
|
+
private readonly requestHandlers: Map<string, IPCRequestHandler> = new Map();
|
|
32
|
+
private readonly pendingRequests: Map<string, PendingRequest> = new Map();
|
|
33
|
+
|
|
34
|
+
private requestIdCounter = 0;
|
|
35
|
+
private active = true;
|
|
36
|
+
|
|
37
|
+
private readonly closeCallbacks: Array<() => void> = [];
|
|
38
|
+
private readonly errorCallbacks: Array<(error: Error) => void> = [];
|
|
39
|
+
|
|
40
|
+
constructor(ws: WebSocket) {
|
|
41
|
+
this.ws = ws;
|
|
42
|
+
const scope = GGLocator.getScope();
|
|
43
|
+
|
|
44
|
+
ws.on('message', async (data: Buffer) => {
|
|
45
|
+
scope.ensureEntered();
|
|
46
|
+
await this.handleRawMessage(data.toString('utf8'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
ws.on('close', () => {
|
|
50
|
+
scope.ensureEntered();
|
|
51
|
+
this.active = false;
|
|
52
|
+
for (const pending of this.pendingRequests.values()) {
|
|
53
|
+
clearTimeout(pending.timeout);
|
|
54
|
+
pending.reject(new Error('Connection closed'));
|
|
55
|
+
}
|
|
56
|
+
this.pendingRequests.clear();
|
|
57
|
+
this.closeCallbacks.forEach(cb => {
|
|
58
|
+
try {
|
|
59
|
+
cb();
|
|
60
|
+
} catch (e) { /* ignore */
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
ws.on('error', (error: Error) => {
|
|
66
|
+
scope.ensureEntered();
|
|
67
|
+
this.errorCallbacks.forEach(cb => {
|
|
68
|
+
try {
|
|
69
|
+
cb(error);
|
|
70
|
+
} catch (e) { /* ignore */
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private static DELIMITER = ':';
|
|
77
|
+
|
|
78
|
+
private static serialize(msg: IPCMessage): string {
|
|
79
|
+
const dataStr = msg.data !== undefined ? JSON.stringify(msg.data) : '';
|
|
80
|
+
return `${msg.type}${this.DELIMITER}${msg.id}${this.DELIMITER}${msg.path}${this.DELIMITER}${dataStr}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static parse(raw: string): IPCMessage | undefined {
|
|
84
|
+
const parts = raw.split(this.DELIMITER);
|
|
85
|
+
if (parts.length < 3) return undefined;
|
|
86
|
+
|
|
87
|
+
const type = parts[0] as IPCMessageType;
|
|
88
|
+
const id = parts[1];
|
|
89
|
+
const path = parts[2];
|
|
90
|
+
const dataStr = parts.length > 3 ? parts.slice(3).join(this.DELIMITER) : '';
|
|
91
|
+
|
|
92
|
+
let data: unknown;
|
|
93
|
+
if (dataStr) {
|
|
94
|
+
try {
|
|
95
|
+
data = JSON.parse(dataStr);
|
|
96
|
+
} catch {
|
|
97
|
+
// Invalid JSON
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {type, id, path, data};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async handleRawMessage(raw: string): Promise<void> {
|
|
105
|
+
const msg = IPCSocket.parse(raw);
|
|
106
|
+
if (!msg) {
|
|
107
|
+
GGLog.warn(this, `Invalid IPC message: ${raw.substring(0, 100)}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
switch (msg.type) {
|
|
112
|
+
case IPCMessageType.MSG:
|
|
113
|
+
await this.handleIncomingMessage(msg);
|
|
114
|
+
break;
|
|
115
|
+
case IPCMessageType.REQ:
|
|
116
|
+
await this.handleIncomingRequest(msg);
|
|
117
|
+
break;
|
|
118
|
+
case IPCMessageType.RES:
|
|
119
|
+
this.handleIncomingResponse(msg);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async handleIncomingMessage(msg: IPCMessage): Promise<void> {
|
|
125
|
+
const handler = this.messageHandlers.get(msg.path);
|
|
126
|
+
if (!handler) {
|
|
127
|
+
GGLog.debug(this, `No handler for message: ${msg.path}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await handler(msg.data);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
GGLog.error(this, `Message handler error for ${msg.path}`, error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async handleIncomingRequest(msg: IPCMessage): Promise<void> {
|
|
138
|
+
const handler = this.requestHandlers.get(msg.path);
|
|
139
|
+
|
|
140
|
+
const sendResponse = (success: boolean, data?: unknown, error?: string) => {
|
|
141
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
142
|
+
const response: IPCMessage = {
|
|
143
|
+
type: IPCMessageType.RES,
|
|
144
|
+
id: msg.id,
|
|
145
|
+
path: msg.path,
|
|
146
|
+
data: {success, data, error}
|
|
147
|
+
};
|
|
148
|
+
this.ws.send(IPCSocket.serialize(response));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (!handler) {
|
|
153
|
+
sendResponse(false, undefined, `No handler for: ${msg.path}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const result = await handler(msg.data);
|
|
159
|
+
sendResponse(true, result);
|
|
160
|
+
} catch (error: any) {
|
|
161
|
+
GGLog.error(this, `Request handler error for ${msg.path}`, error);
|
|
162
|
+
sendResponse(false, undefined, error.message || String(error));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private handleIncomingResponse(msg: IPCMessage): void {
|
|
167
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
168
|
+
if (!pending) {
|
|
169
|
+
GGLog.debug(this, `No pending request for response id: ${msg.id}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
clearTimeout(pending.timeout);
|
|
174
|
+
this.pendingRequests.delete(msg.id);
|
|
175
|
+
|
|
176
|
+
const response = msg.data as { success: boolean; data?: unknown; error?: string };
|
|
177
|
+
if (response.success) {
|
|
178
|
+
pending.resolve(response.data);
|
|
179
|
+
} else {
|
|
180
|
+
pending.reject(new Error(response.error || 'Request failed'));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
public onMessage(path: string, handler: IPCMessageHandler): void {
|
|
185
|
+
this.messageHandlers.set(path, handler);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
public onRequest(path: string, handler: IPCRequestHandler): void {
|
|
189
|
+
this.requestHandlers.set(path, handler);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public onClose(callback: () => void): void {
|
|
193
|
+
this.closeCallbacks.push(callback);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
public onError(callback: (error: Error) => void): void {
|
|
197
|
+
this.errorCallbacks.push(callback);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public send(path: string, data?: unknown): void {
|
|
201
|
+
if (!this.active || this.ws.readyState !== WebSocket.OPEN) {
|
|
202
|
+
throw new Error('Socket not connected');
|
|
203
|
+
}
|
|
204
|
+
const msg: IPCMessage = {type: IPCMessageType.MSG, id: '', path, data};
|
|
205
|
+
this.ws.send(IPCSocket.serialize(msg));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public async request<T = unknown>(path: string, data?: unknown, timeoutMs: number = 10000): Promise<T> {
|
|
209
|
+
if (!this.active || this.ws.readyState !== WebSocket.OPEN) {
|
|
210
|
+
throw new Error('Socket not connected');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const id = `${++this.requestIdCounter}`;
|
|
214
|
+
const msg: IPCMessage = {type: IPCMessageType.REQ, id, path, data};
|
|
215
|
+
|
|
216
|
+
return new Promise<T>((resolve, reject) => {
|
|
217
|
+
const timeout = setTimeout(() => {
|
|
218
|
+
this.pendingRequests.delete(id);
|
|
219
|
+
reject(new Error(`Request '${path}' timed out after ${timeoutMs}ms`));
|
|
220
|
+
}, timeoutMs);
|
|
221
|
+
|
|
222
|
+
this.pendingRequests.set(id, {
|
|
223
|
+
resolve: resolve as (data: unknown) => void,
|
|
224
|
+
reject,
|
|
225
|
+
timeout
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
this.ws.send(IPCSocket.serialize(msg));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
public isConnected(): boolean {
|
|
233
|
+
return this.active && this.ws.readyState === WebSocket.OPEN;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public close(): void {
|
|
237
|
+
if (this.active) {
|
|
238
|
+
this.active = false;
|
|
239
|
+
this.ws.terminate();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public teardown(): void {
|
|
244
|
+
for (const pending of this.pendingRequests.values()) {
|
|
245
|
+
clearTimeout(pending.timeout);
|
|
246
|
+
pending.reject(new Error('Socket teardown'));
|
|
247
|
+
}
|
|
248
|
+
this.pendingRequests.clear();
|
|
249
|
+
this.close();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type for messages that the IPC server handles (sent by client, handled by server)
|
|
3
|
+
*/
|
|
4
|
+
export type IPCServerRequest<Request, Response> = string & {
|
|
5
|
+
__IPCServerRequest: never;
|
|
6
|
+
__request: Request;
|
|
7
|
+
__response: Response;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Type for messages that the IPC client handles (sent by server, handled by client)
|
|
12
|
+
*/
|
|
13
|
+
export type IPCClientRequest<Request, Response> = string & {
|
|
14
|
+
__IPCClientRequest: never;
|
|
15
|
+
__request: Request;
|
|
16
|
+
__response: Response;
|
|
17
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import {GGLog} from "@grest-ts/logger";
|
|
3
|
+
import {HttpMethod} from "@grest-ts/common";
|
|
4
|
+
import httpProxy from "http-proxy";
|
|
5
|
+
import findMyWay, {HTTPMethod} from "find-my-way";
|
|
6
|
+
|
|
7
|
+
/** HTTP route handler - receives body, path params, and headers. Returns response data (or void for empty 200) */
|
|
8
|
+
export type HttpRouteHandler = (body: any, pathParams?: Record<string, string>, headers?: Record<string, string>) => Promise<any>;
|
|
9
|
+
|
|
10
|
+
/** Default HTTP handler for unmatched routes (proxying) - gets raw req/res */
|
|
11
|
+
export type DefaultHttpHandler = (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
interface RouteStore {
|
|
14
|
+
handler: HttpRouteHandler;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class HttpHandler {
|
|
18
|
+
|
|
19
|
+
private router = findMyWay<findMyWay.HTTPVersion.V1>();
|
|
20
|
+
private routeResolver: (path: string) => string
|
|
21
|
+
private readonly proxy: httpProxy;
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
this.proxy = httpProxy.createProxyServer({});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public setRouteResolver(routeResolver: (path: string) => string) {
|
|
28
|
+
this.routeResolver = routeResolver
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public addRoute(method: HttpMethod, path: string, handler: HttpRouteHandler): void {
|
|
32
|
+
const store: RouteStore = {handler};
|
|
33
|
+
this.router.on(method as HTTPMethod, path, () => {
|
|
34
|
+
}, store);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public removeRoute(method: HttpMethod, path: string): void {
|
|
38
|
+
this.router.off(method as HTTPMethod, path);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public async handleRequest(
|
|
42
|
+
req: http.IncomingMessage,
|
|
43
|
+
res: http.ServerResponse
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const method = (req.method || 'GET') as HttpMethod;
|
|
46
|
+
const url = req.url || '/';
|
|
47
|
+
// Strip query string for route matching
|
|
48
|
+
const path = url.split('?')[0];
|
|
49
|
+
|
|
50
|
+
// Try to match a route
|
|
51
|
+
const result = this.router.find(method as HTTPMethod, path);
|
|
52
|
+
if (result?.store) {
|
|
53
|
+
const store = result.store as RouteStore;
|
|
54
|
+
const body = await this.readJsonBody(req);
|
|
55
|
+
// Normalize headers to Record<string, string>
|
|
56
|
+
const headers: Record<string, string> = {};
|
|
57
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
58
|
+
if (value !== undefined) {
|
|
59
|
+
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const pathParams = result.params as Record<string, string> || {};
|
|
63
|
+
try {
|
|
64
|
+
const responseData = await store.handler(body, pathParams, headers);
|
|
65
|
+
this.sendResponse(res, 200, responseData);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
GGLog.error(this, `HTTP route error [${method} ${path}]: ${err.message}`, err);
|
|
68
|
+
this.sendResponse(res, 500, {error: err.message});
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const routeToUrl = this.routeResolver(path)
|
|
74
|
+
if (routeToUrl) {
|
|
75
|
+
GGLog.debug(this, `Proxying ${req.method} ${req.url} -> ${routeToUrl}`);
|
|
76
|
+
this.proxy.web(req, res, {target: routeToUrl}, (err: Error) => {
|
|
77
|
+
GGLog.error(this, "Proxy error", err);
|
|
78
|
+
this.sendResponse(res, 502, {issue: "Proxy error: " + err.message + " (Did the service crash?)"});
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.sendResponse(res, 404, {error: `No route for ${method} ${path}! Did you forget to start a service that defines this path?`});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async readJsonBody(req: http.IncomingMessage): Promise<any> {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
let bodyStr = '';
|
|
89
|
+
req.on('data', (chunk: Buffer) => {
|
|
90
|
+
bodyStr += chunk.toString();
|
|
91
|
+
});
|
|
92
|
+
req.on('end', () => {
|
|
93
|
+
try {
|
|
94
|
+
resolve(bodyStr ? JSON.parse(bodyStr) : undefined);
|
|
95
|
+
} catch {
|
|
96
|
+
resolve(undefined);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
req.on('error', () => resolve(undefined));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private sendResponse(res: http.ServerResponse, status: number, data?: any): void {
|
|
104
|
+
res.writeHead(status, {'Content-Type': 'application/json'});
|
|
105
|
+
res.end(data !== undefined ? JSON.stringify(data) : '');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as net from 'net';
|
|
3
|
+
import {GGLog} from "@grest-ts/logger";
|
|
4
|
+
import {Duplex} from "stream";
|
|
5
|
+
import {HttpMethod} from "@grest-ts/common";
|
|
6
|
+
import {HttpHandler} from "./HttpHandler";
|
|
7
|
+
import {SocketHandler, SocketMessage} from "./SocketHandler";
|
|
8
|
+
import {IPCClientRequest, IPCServerRequest} from "../common/IPCTypes";
|
|
9
|
+
import {GGLocator, GGLocatorScope} from "@grest-ts/locator";
|
|
10
|
+
import {GGContext} from "@grest-ts/context";
|
|
11
|
+
import {GG_TRACE} from "@grest-ts/trace";
|
|
12
|
+
|
|
13
|
+
export class IPCServer {
|
|
14
|
+
|
|
15
|
+
private readonly httpServer: http.Server;
|
|
16
|
+
private readonly httpHandler: HttpHandler;
|
|
17
|
+
private readonly socketHandler: SocketHandler;
|
|
18
|
+
|
|
19
|
+
private readonly expectedPort: number;
|
|
20
|
+
private port: number = 0;
|
|
21
|
+
private url: string = '';
|
|
22
|
+
|
|
23
|
+
private readonly connections: Set<net.Socket> = new Set();
|
|
24
|
+
|
|
25
|
+
private readonly scope: GGLocatorScope;
|
|
26
|
+
|
|
27
|
+
constructor(port: number = 0) {
|
|
28
|
+
this.expectedPort = port;
|
|
29
|
+
this.scope = GGLocator.getScope();
|
|
30
|
+
|
|
31
|
+
// Create handlers
|
|
32
|
+
this.httpHandler = new HttpHandler();
|
|
33
|
+
this.socketHandler = new SocketHandler();
|
|
34
|
+
|
|
35
|
+
// Create HTTP server
|
|
36
|
+
this.httpServer = http.createServer(async (req, res) => {
|
|
37
|
+
await new GGContext("LocalRouter").run(async () => {
|
|
38
|
+
GG_TRACE.init();
|
|
39
|
+
await this.httpHandler.handleRequest(req, res);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Handle WebSocket upgrades
|
|
44
|
+
// Note: Incoming TCP connections start with no async context (they originate from libuv/OS layer).
|
|
45
|
+
this.httpServer.on('upgrade', async (req, socket, head) => {
|
|
46
|
+
this.scope.ensureEntered();
|
|
47
|
+
await new GGContext("LocalRouter").run(async () => {
|
|
48
|
+
GG_TRACE.init();
|
|
49
|
+
await this.socketHandler.handleUpgrade(req, socket as Duplex, head);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Track connections for cleanup
|
|
54
|
+
this.httpServer.on('connection', (conn) => {
|
|
55
|
+
this.connections.add(conn);
|
|
56
|
+
conn.on('close', () => this.connections.delete(conn));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public static defineRequest<Req, Res>(name: string): IPCServerRequest<Req, Res> {
|
|
61
|
+
return name as IPCServerRequest<Req, Res>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -----------------------------------------------
|
|
65
|
+
// Lifecycle
|
|
66
|
+
// -----------------------------------------------
|
|
67
|
+
|
|
68
|
+
public async start(): Promise<boolean> {
|
|
69
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
70
|
+
const errorHandler = (err: any) => {
|
|
71
|
+
if (err.code === 'EADDRINUSE') {
|
|
72
|
+
resolve(false);
|
|
73
|
+
} else {
|
|
74
|
+
reject(err);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.httpServer.once('error', errorHandler);
|
|
79
|
+
|
|
80
|
+
this.httpServer.listen(this.expectedPort, '0.0.0.0', () => {
|
|
81
|
+
this.scope.ensureEntered();
|
|
82
|
+
this.port = (this.httpServer.address() as any).port;
|
|
83
|
+
this.url = `http://localhost:${this.port}`;
|
|
84
|
+
this.httpServer.removeListener('error', errorHandler);
|
|
85
|
+
GGLog.info(this, `Internal server started on port ${this.port}`);
|
|
86
|
+
resolve(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public async teardown(): Promise<void> {
|
|
92
|
+
// Teardown socket handler (closes clients and WSS)
|
|
93
|
+
await this.socketHandler.teardown();
|
|
94
|
+
|
|
95
|
+
// Close all HTTP connections
|
|
96
|
+
this.connections.forEach(conn => conn.destroy());
|
|
97
|
+
this.connections.clear();
|
|
98
|
+
|
|
99
|
+
// Close the server
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
this.httpServer.close((err) => {
|
|
102
|
+
this.scope.ensureEntered();
|
|
103
|
+
if (err) reject(err);
|
|
104
|
+
else resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
GGLog.info(this, 'Internal server stopped');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -----------------------------------------------
|
|
112
|
+
// Handling http/socket requests that services send between each other. Can intercept http here.
|
|
113
|
+
// -----------------------------------------------
|
|
114
|
+
|
|
115
|
+
public interceptHttp(method: HttpMethod, path: string, handler: (body: any, pathParams?: Record<string, string>, headers?: Record<string, string>) => Promise<any>): void {
|
|
116
|
+
this.httpHandler.addRoute(method, path, handler);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public removeInterceptHttp(method: HttpMethod, path: string): void {
|
|
120
|
+
this.httpHandler.removeRoute(method, path);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public setRouteProxyResolver(resolver: (path: string) => string) {
|
|
124
|
+
this.httpHandler.setRouteResolver(resolver);
|
|
125
|
+
this.socketHandler.setRouteResolver(resolver);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// -----------------------------------------------
|
|
129
|
+
// Grest Framework internal messaging between either local discovery server or local test server.
|
|
130
|
+
// -----------------------------------------------
|
|
131
|
+
|
|
132
|
+
public onFrameworkMessage<Req, Res>(
|
|
133
|
+
type: IPCServerRequest<Req, Res>,
|
|
134
|
+
handler: (payload: Req, msg: SocketMessage) => Promise<Res>
|
|
135
|
+
): void {
|
|
136
|
+
this.socketHandler.onMessage(type, async (msg) => handler(msg.data as Req, msg));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public async sendFrameworkMessage<Req, Res>(
|
|
140
|
+
runtimeId: string,
|
|
141
|
+
type: IPCClientRequest<Req, Res>,
|
|
142
|
+
payload: Req,
|
|
143
|
+
timeoutMs?: number
|
|
144
|
+
): Promise<Res> {
|
|
145
|
+
return this.socketHandler.request<Res>(runtimeId, type, payload, timeoutMs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -----------------------------------------------
|
|
149
|
+
// Accessors
|
|
150
|
+
// -----------------------------------------------
|
|
151
|
+
|
|
152
|
+
public getPort(): number {
|
|
153
|
+
return this.port;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public getUrl(): string {
|
|
157
|
+
return this.url;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import {WebSocket, WebSocketServer} from 'ws';
|
|
3
|
+
import {GGLog} from "@grest-ts/logger";
|
|
4
|
+
import {Duplex} from "stream";
|
|
5
|
+
import {IPCSocket} from "../common/IPCSocket";
|
|
6
|
+
import httpProxy from "http-proxy";
|
|
7
|
+
|
|
8
|
+
export const INTERNAL_SOCKET_PATH = '/__ggFrameworkConnection';
|
|
9
|
+
|
|
10
|
+
export type WsUpgradeHandler = (req: http.IncomingMessage, socket: Duplex, head: Buffer) => Promise<boolean>;
|
|
11
|
+
export type SocketMessageHandler = (msg: SocketMessage) => Promise<unknown>;
|
|
12
|
+
|
|
13
|
+
export interface SocketMessage {
|
|
14
|
+
clientId: string;
|
|
15
|
+
runtimeId?: string;
|
|
16
|
+
data: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SocketClient {
|
|
20
|
+
id: string;
|
|
21
|
+
runtimeId?: string;
|
|
22
|
+
socket: IPCSocket;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SocketHandler {
|
|
26
|
+
|
|
27
|
+
private readonly wss: WebSocketServer;
|
|
28
|
+
private readonly clients: Map<string, SocketClient> = new Map();
|
|
29
|
+
private readonly clientsByRuntimeId: Map<string, SocketClient> = new Map();
|
|
30
|
+
private readonly handlers: Map<string, SocketMessageHandler> = new Map();
|
|
31
|
+
private clientIdCounter = 0;
|
|
32
|
+
|
|
33
|
+
private routeResolver: (path: string) => string
|
|
34
|
+
private readonly proxy: httpProxy;
|
|
35
|
+
|
|
36
|
+
constructor() {
|
|
37
|
+
this.wss = new WebSocketServer({noServer: true});
|
|
38
|
+
this.proxy = httpProxy.createProxyServer({});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public setRouteResolver(routeResolver: (path: string) => string) {
|
|
42
|
+
this.routeResolver = routeResolver
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public onMessage(path: string, handler: SocketMessageHandler): void {
|
|
46
|
+
this.handlers.set(path, handler);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public async handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
|
|
50
|
+
if (req.url.startsWith(INTERNAL_SOCKET_PATH)) {
|
|
51
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
52
|
+
this.handleNewConnection(ws, req.url);
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const routeToUrl = this.routeResolver(req.url)
|
|
58
|
+
if (routeToUrl) {
|
|
59
|
+
GGLog.debug(this, `Proxying WS ${req.url} -> ${routeToUrl}`);
|
|
60
|
+
this.proxy.ws(req, socket, head, {target: routeToUrl}, (err: Error) => {
|
|
61
|
+
GGLog.error(this, `WebSocket proxy error: ${err.message}`);
|
|
62
|
+
socket.destroy();
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
socket.destroy();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private handleNewConnection(ws: WebSocket, url: string): void {
|
|
71
|
+
const clientId = `client-${++this.clientIdCounter}`;
|
|
72
|
+
|
|
73
|
+
let runtimeId: string | undefined;
|
|
74
|
+
const queryStart = url.indexOf('?');
|
|
75
|
+
if (queryStart !== -1) {
|
|
76
|
+
const params = new URLSearchParams(url.substring(queryStart + 1));
|
|
77
|
+
runtimeId = params.get('runtimeId') ?? undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ipcSocket = new IPCSocket(ws);
|
|
81
|
+
|
|
82
|
+
const client: SocketClient = {id: clientId, runtimeId, socket: ipcSocket};
|
|
83
|
+
this.clients.set(clientId, client);
|
|
84
|
+
if (runtimeId) {
|
|
85
|
+
this.clientsByRuntimeId.set(runtimeId, client);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
GGLog.debug(this, `Socket client connected: ${clientId}` + (runtimeId ? ` (runtimeId: ${runtimeId})` : ''));
|
|
89
|
+
|
|
90
|
+
for (const [path, handler] of this.handlers) {
|
|
91
|
+
ipcSocket.onRequest(path, async (data) => {
|
|
92
|
+
return handler({
|
|
93
|
+
clientId: client.id,
|
|
94
|
+
runtimeId: client.runtimeId,
|
|
95
|
+
data
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ipcSocket.onClose(() => {
|
|
101
|
+
this.clients.delete(clientId);
|
|
102
|
+
if (runtimeId) {
|
|
103
|
+
this.clientsByRuntimeId.delete(runtimeId);
|
|
104
|
+
}
|
|
105
|
+
GGLog.debug(this, `Socket client disconnected: ${clientId}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
ipcSocket.onError((err: Error) => {
|
|
109
|
+
GGLog.debug(this, `Socket client error: ${clientId} - ${err.message}`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public async request<T = unknown>(
|
|
114
|
+
runtimeId: string,
|
|
115
|
+
path: string,
|
|
116
|
+
data?: unknown,
|
|
117
|
+
timeoutMs: number = 10000
|
|
118
|
+
): Promise<T> {
|
|
119
|
+
const client = this.clientsByRuntimeId.get(runtimeId);
|
|
120
|
+
if (!client) {
|
|
121
|
+
throw new Error(`No client connected with runtimeId: ${runtimeId}`);
|
|
122
|
+
}
|
|
123
|
+
return client.socket.request<T>(path, data, timeoutMs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public send(runtimeId: string, path: string, data?: unknown): void {
|
|
127
|
+
const client = this.clientsByRuntimeId.get(runtimeId);
|
|
128
|
+
if (!client) {
|
|
129
|
+
throw new Error(`No client connected with runtimeId: ${runtimeId}`);
|
|
130
|
+
}
|
|
131
|
+
client.socket.send(path, data);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public async teardown(): Promise<void> {
|
|
135
|
+
for (const client of this.clients.values()) {
|
|
136
|
+
client.socket.teardown();
|
|
137
|
+
}
|
|
138
|
+
this.clients.clear();
|
|
139
|
+
this.clientsByRuntimeId.clear();
|
|
140
|
+
this.proxy.close();
|
|
141
|
+
|
|
142
|
+
await new Promise<void>((resolve) => {
|
|
143
|
+
this.wss.close(() => resolve());
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|