@atolljs/devtools 0.1.7
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/README.md +156 -0
- package/app/index.html +388 -0
- package/app/main.js +1823 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +313 -0
- package/dist/broadcast.d.ts +3 -0
- package/dist/chunk-4N3NBFL3.js +474 -0
- package/dist/client.d.ts +34 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/init.d.ts +28 -0
- package/dist/node.d.ts +18 -0
- package/dist/node.js +12 -0
- package/dist/nodeWs.d.ts +9 -0
- package/dist/overlay.d.ts +31 -0
- package/dist/protocol.d.ts +83 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +296 -0
- package/dist/ws.d.ts +43 -0
- package/package.json +49 -0
- package/src/bin.ts +23 -0
- package/src/broadcast.ts +97 -0
- package/src/client.ts +178 -0
- package/src/index.ts +11 -0
- package/src/init.ts +50 -0
- package/src/node.ts +18 -0
- package/src/nodeWs.ts +118 -0
- package/src/overlay.ts +165 -0
- package/src/protocol.ts +69 -0
- package/src/server.ts +218 -0
- package/src/ws.ts +206 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local devtools server: one Node http server on 127.0.0.1 that
|
|
3
|
+
* - serves the static dashboard from ./app,
|
|
4
|
+
* - ingests instrumentation on the /events WebSocket (instrumented apps),
|
|
5
|
+
* - fans events out to /view WebSockets (open dashboards),
|
|
6
|
+
* - replays a bounded tail of recent batches to late-joining viewers.
|
|
7
|
+
*/
|
|
8
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join, normalize, extname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import type { ClientMessage, SessionInfo, ViewerMessage, ViewerRequest } from './protocol';
|
|
13
|
+
import { acceptWebSocket, type WsConnection } from './ws';
|
|
14
|
+
|
|
15
|
+
export interface DevtoolsServerOptions {
|
|
16
|
+
/** Default 4780. 0 picks an ephemeral port (tests). */
|
|
17
|
+
port?: number;
|
|
18
|
+
/** Host — bound to loopback only; the dashboard has no auth. */
|
|
19
|
+
host?: string;
|
|
20
|
+
/** Static dashboard directory — defaults to the packaged app/. */
|
|
21
|
+
appDir?: string;
|
|
22
|
+
/** Batches kept for viewer replay (default 500). */
|
|
23
|
+
replayBatches?: number;
|
|
24
|
+
/** Closed-session retention before auto-cleanup (default 30min). */
|
|
25
|
+
closedTtlMs?: number;
|
|
26
|
+
/** How often the retention sweep runs (default 60s — tests lower it). */
|
|
27
|
+
sweepMs?: number;
|
|
28
|
+
onListen?: (url: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DevtoolsServer {
|
|
32
|
+
readonly port: number;
|
|
33
|
+
readonly url: string;
|
|
34
|
+
sessions(): SessionInfo[];
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MIME: Record<string, string> = {
|
|
39
|
+
'.html': 'text/html; charset=utf-8',
|
|
40
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
41
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
42
|
+
'.css': 'text/css; charset=utf-8',
|
|
43
|
+
'.json': 'application/json',
|
|
44
|
+
'.svg': 'image/svg+xml',
|
|
45
|
+
'.png': 'image/png',
|
|
46
|
+
'.ico': 'image/x-icon',
|
|
47
|
+
'.map': 'application/json',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const defaultAppDir = () => fileURLToPath(new URL('../app/', import.meta.url));
|
|
51
|
+
|
|
52
|
+
const serveStatic = (appDir: string, req: IncomingMessage, res: ServerResponse) => {
|
|
53
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
54
|
+
let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, '');
|
|
55
|
+
if (path === '' || path === '.') path = 'index.html';
|
|
56
|
+
const file = join(appDir, path);
|
|
57
|
+
// Prefix check against path traversal ('..' escaping the app dir).
|
|
58
|
+
if (!file.startsWith(normalize(appDir)) || !existsSync(file)) {
|
|
59
|
+
res.writeHead(404).end('not found');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' });
|
|
63
|
+
res.end(readFileSync(file));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function createDevtoolsServer(opts: DevtoolsServerOptions = {}): Promise<DevtoolsServer> {
|
|
67
|
+
const host = opts.host ?? '127.0.0.1';
|
|
68
|
+
const appDir = opts.appDir ?? defaultAppDir();
|
|
69
|
+
const replayCap = opts.replayBatches ?? 500;
|
|
70
|
+
|
|
71
|
+
const viewers = new Set<WsConnection>();
|
|
72
|
+
const sessions = new Map<WsConnection, SessionInfo>();
|
|
73
|
+
/** Recently-disconnected sessions — bounded, kept so replayed history stays attributed. */
|
|
74
|
+
const closedSessions: SessionInfo[] = [];
|
|
75
|
+
const closedCap = 20;
|
|
76
|
+
/** Closed sessions are evicted after this unless a viewer pinned them. */
|
|
77
|
+
const closedTtlMs = opts.closedTtlMs ?? 30 * 60 * 1000;
|
|
78
|
+
/** Batches kept for replay — sessions updates aren't replayed (stale). */
|
|
79
|
+
const tail: ViewerMessage[] = [];
|
|
80
|
+
|
|
81
|
+
const sessionList = (): SessionInfo[] => [...sessions.values(), ...closedSessions];
|
|
82
|
+
|
|
83
|
+
/** Drop a closed session and its replay tail; returns whether it existed. */
|
|
84
|
+
const evictClosed = (sessionId: string): boolean => {
|
|
85
|
+
const i = closedSessions.findIndex((s) => s.id === sessionId);
|
|
86
|
+
if (i < 0) return false;
|
|
87
|
+
closedSessions.splice(i, 1);
|
|
88
|
+
for (let j = tail.length - 1; j >= 0; j--) {
|
|
89
|
+
const m = tail[j];
|
|
90
|
+
if (m.type === 'batch' && m.session.id === sessionId) tail.splice(j, 1);
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const broadcast = (msg: ViewerMessage) => {
|
|
96
|
+
if (msg.type === 'batch') {
|
|
97
|
+
tail.push(msg);
|
|
98
|
+
if (tail.length > replayCap) tail.shift();
|
|
99
|
+
}
|
|
100
|
+
const text = JSON.stringify(msg);
|
|
101
|
+
for (const v of viewers) v.send(text);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const server: Server = createServer((req, res) => {
|
|
105
|
+
if (req.url === '/health') {
|
|
106
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
107
|
+
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
serveStatic(appDir, req, res);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
server.on('upgrade', (req, socket, head) => {
|
|
114
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
115
|
+
if (url.pathname !== '/events' && url.pathname !== '/view') {
|
|
116
|
+
socket.destroy();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const conn = acceptWebSocket(req, socket);
|
|
120
|
+
if (head?.length) socket.unshift(head);
|
|
121
|
+
|
|
122
|
+
if (url.pathname === '/view') {
|
|
123
|
+
viewers.add(conn);
|
|
124
|
+
conn.onClose = () => viewers.delete(conn);
|
|
125
|
+
conn.onMessage = (text) => {
|
|
126
|
+
let msg: ViewerRequest;
|
|
127
|
+
try {
|
|
128
|
+
msg = JSON.parse(text);
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (msg.type === 'dismiss') {
|
|
133
|
+
// Closed sessions only — a live app's next batch would resurrect it.
|
|
134
|
+
if (evictClosed(msg.sessionId)) {
|
|
135
|
+
broadcast({ type: 'sessions', sessions: sessionList() });
|
|
136
|
+
}
|
|
137
|
+
} else if (msg.type === 'pin') {
|
|
138
|
+
const s = closedSessions.find((s) => s.id === msg.sessionId);
|
|
139
|
+
if (!s || s.pinned === msg.pinned) return;
|
|
140
|
+
s.pinned = msg.pinned;
|
|
141
|
+
broadcast({ type: 'sessions', sessions: sessionList() });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
conn.send(JSON.stringify({ type: 'sessions', sessions: sessionList() }));
|
|
145
|
+
for (const msg of tail) conn.send(JSON.stringify(msg));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// /events — an instrumented app. First frame must be hello.
|
|
150
|
+
let greeted = false;
|
|
151
|
+
conn.onMessage = (text) => {
|
|
152
|
+
let msg: ClientMessage;
|
|
153
|
+
try {
|
|
154
|
+
msg = JSON.parse(text);
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (msg.type === 'hello') {
|
|
159
|
+
greeted = true;
|
|
160
|
+
sessions.set(conn, msg.session);
|
|
161
|
+
broadcast({ type: 'sessions', sessions: sessionList() });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (msg.type === 'batch' && greeted) {
|
|
165
|
+
broadcast({ type: 'batch', session: sessions.get(conn)!, events: msg.events });
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
conn.onClose = () => {
|
|
169
|
+
const info = sessions.get(conn);
|
|
170
|
+
if (sessions.delete(conn) && info) {
|
|
171
|
+
info.closed = true;
|
|
172
|
+
info.closedAt = Date.now();
|
|
173
|
+
closedSessions.push(info);
|
|
174
|
+
if (closedSessions.length > closedCap) {
|
|
175
|
+
// cap applies to unpinned sessions — pins are retained
|
|
176
|
+
const i = closedSessions.findIndex((s) => !s.pinned);
|
|
177
|
+
if (i >= 0) evictClosed(closedSessions[i].id);
|
|
178
|
+
}
|
|
179
|
+
broadcast({ type: 'sessions', sessions: sessionList() });
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Retention sweep — unpinned closed sessions expire after closedTtlMs.
|
|
185
|
+
const sweep = setInterval(() => {
|
|
186
|
+
const cutoff = Date.now() - closedTtlMs;
|
|
187
|
+
let changed = false;
|
|
188
|
+
for (const s of [...closedSessions]) {
|
|
189
|
+
if (!s.pinned && s.closedAt !== undefined && s.closedAt < cutoff) {
|
|
190
|
+
changed = evictClosed(s.id) || changed;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (changed) broadcast({ type: 'sessions', sessions: sessionList() });
|
|
194
|
+
}, opts.sweepMs ?? 60_000);
|
|
195
|
+
sweep.unref();
|
|
196
|
+
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
server.once('error', reject);
|
|
199
|
+
server.listen(opts.port ?? 4780, host, () => {
|
|
200
|
+
const address = server.address();
|
|
201
|
+
const port = typeof address === 'object' && address ? address.port : (opts.port ?? 4780);
|
|
202
|
+
const url = `http://${host}:${port}`;
|
|
203
|
+
opts.onListen?.(url);
|
|
204
|
+
resolve({
|
|
205
|
+
port,
|
|
206
|
+
url,
|
|
207
|
+
sessions: () => [...sessions.values()],
|
|
208
|
+
close: () =>
|
|
209
|
+
new Promise<void>((done) => {
|
|
210
|
+
clearInterval(sweep);
|
|
211
|
+
for (const v of viewers) v.close();
|
|
212
|
+
for (const c of sessions.keys()) c.close();
|
|
213
|
+
server.close(() => done());
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
package/src/ws.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal RFC6455 server — no `ws` dependency. Both ends are ours (the
|
|
3
|
+
* instrumented app's WebSocket client and the dashboard page), so this only
|
|
4
|
+
* needs: the upgrade handshake, masked text frames in, unmasked text frames
|
|
5
|
+
* out, ping/pong, and close. Continuation frames are accumulated but
|
|
6
|
+
* never produced by us.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
|
+
import type { IncomingMessage } from 'node:http';
|
|
10
|
+
import type { Duplex } from 'node:stream';
|
|
11
|
+
|
|
12
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
13
|
+
export const OPCODES = { continuation: 0, text: 1, close: 8, ping: 9, pong: 10 } as const;
|
|
14
|
+
|
|
15
|
+
export interface WsConnection {
|
|
16
|
+
/** Queue a text frame — safe before/without concern for socket state. */
|
|
17
|
+
send(text: string): void;
|
|
18
|
+
close(): void;
|
|
19
|
+
onMessage: ((text: string) => void) | null;
|
|
20
|
+
onClose: (() => void) | null;
|
|
21
|
+
readonly closed: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Sec-WebSocket-Accept for a client key. */
|
|
25
|
+
export const acceptKey = (key: string): string =>
|
|
26
|
+
createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
27
|
+
|
|
28
|
+
/** One unmasked text frame (server→client frames MUST NOT be masked). */
|
|
29
|
+
export function encodeTextFrame(text: string): Buffer {
|
|
30
|
+
const payload = Buffer.from(text, 'utf8');
|
|
31
|
+
const len = payload.length;
|
|
32
|
+
let header: Buffer;
|
|
33
|
+
if (len < 126) {
|
|
34
|
+
header = Buffer.from([0x80 | OPCODES.text, len]);
|
|
35
|
+
} else if (len < 65536) {
|
|
36
|
+
header = Buffer.alloc(4);
|
|
37
|
+
header[0] = 0x80 | OPCODES.text;
|
|
38
|
+
header[1] = 126;
|
|
39
|
+
header.writeUInt16BE(len, 2);
|
|
40
|
+
} else {
|
|
41
|
+
header = Buffer.alloc(10);
|
|
42
|
+
header[0] = 0x80 | OPCODES.text;
|
|
43
|
+
header[1] = 127;
|
|
44
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
45
|
+
}
|
|
46
|
+
return Buffer.concat([header, payload]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const encodeControl = (opcode: number, payload: Buffer<ArrayBufferLike> = Buffer.alloc(0)): Buffer =>
|
|
50
|
+
Buffer.concat([Buffer.from([0x80 | opcode, payload.length]), payload]);
|
|
51
|
+
|
|
52
|
+
export interface WsFrame {
|
|
53
|
+
fin: boolean;
|
|
54
|
+
opcode: number;
|
|
55
|
+
payload: Buffer<ArrayBufferLike>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decode as many complete frames as `buf` holds — shared by the server-side
|
|
60
|
+
* drain (masked client frames) and the Node client (unmasked server frames).
|
|
61
|
+
* Returns the frames plus the unconsumed remainder.
|
|
62
|
+
*/
|
|
63
|
+
export function decodeFrames(
|
|
64
|
+
buf: Buffer<ArrayBufferLike>,
|
|
65
|
+
): { frames: WsFrame[]; rest: Buffer<ArrayBufferLike> } {
|
|
66
|
+
const frames: WsFrame[] = [];
|
|
67
|
+
for (;;) {
|
|
68
|
+
if (buf.length < 2) break;
|
|
69
|
+
const fin = (buf[0] & 0x80) !== 0;
|
|
70
|
+
const opcode = buf[0] & 0x0f;
|
|
71
|
+
const masked = (buf[1] & 0x80) !== 0;
|
|
72
|
+
let len = buf[1] & 0x7f;
|
|
73
|
+
let off = 2;
|
|
74
|
+
if (len === 126) {
|
|
75
|
+
if (buf.length < off + 2) break;
|
|
76
|
+
len = buf.readUInt16BE(off);
|
|
77
|
+
off += 2;
|
|
78
|
+
} else if (len === 127) {
|
|
79
|
+
if (buf.length < off + 8) break;
|
|
80
|
+
len = Number(buf.readBigUInt64BE(off));
|
|
81
|
+
off += 8;
|
|
82
|
+
}
|
|
83
|
+
const maskLen = masked ? 4 : 0;
|
|
84
|
+
if (buf.length < off + maskLen + len) break;
|
|
85
|
+
let payload = buf.subarray(off + maskLen, off + maskLen + len);
|
|
86
|
+
if (masked) {
|
|
87
|
+
const mask = buf.subarray(off, off + 4);
|
|
88
|
+
const un = Buffer.alloc(len);
|
|
89
|
+
for (let i = 0; i < len; i++) un[i] = payload[i] ^ mask[i & 3];
|
|
90
|
+
payload = un;
|
|
91
|
+
}
|
|
92
|
+
buf = buf.subarray(off + maskLen + len);
|
|
93
|
+
frames.push({ fin, opcode, payload });
|
|
94
|
+
}
|
|
95
|
+
return { frames, rest: buf };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One masked text frame (client→server frames MUST be masked per RFC6455). */
|
|
99
|
+
export function encodeMaskedTextFrame(text: string): Buffer {
|
|
100
|
+
const payload = Buffer.from(text, 'utf8');
|
|
101
|
+
const mask = randomBytes(4);
|
|
102
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
103
|
+
const len = payload.length;
|
|
104
|
+
let header: Buffer;
|
|
105
|
+
if (len < 126) {
|
|
106
|
+
header = Buffer.from([0x80 | OPCODES.text, 0x80 | len]);
|
|
107
|
+
} else if (len < 65536) {
|
|
108
|
+
header = Buffer.alloc(4);
|
|
109
|
+
header[0] = 0x80 | OPCODES.text;
|
|
110
|
+
header[1] = 0x80 | 126;
|
|
111
|
+
header.writeUInt16BE(len, 2);
|
|
112
|
+
} else {
|
|
113
|
+
header = Buffer.alloc(10);
|
|
114
|
+
header[0] = 0x80 | OPCODES.text;
|
|
115
|
+
header[1] = 0x80 | 127;
|
|
116
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
117
|
+
}
|
|
118
|
+
return Buffer.concat([header, mask, payload]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const encodeMaskedControl = (opcode: number, payload: Buffer<ArrayBufferLike> = Buffer.alloc(0)): Buffer => {
|
|
122
|
+
const mask = randomBytes(4);
|
|
123
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
124
|
+
return Buffer.concat([Buffer.from([0x80 | opcode, 0x80 | payload.length]), mask, payload]);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Complete the upgrade handshake on `socket` and return a framed connection.
|
|
129
|
+
* Caller routes on req.url first — this writes the 101 response.
|
|
130
|
+
*/
|
|
131
|
+
export function acceptWebSocket(req: IncomingMessage, socket: Duplex): WsConnection {
|
|
132
|
+
const key = req.headers['sec-websocket-key'];
|
|
133
|
+
socket.write(
|
|
134
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
135
|
+
'Upgrade: websocket\r\n' +
|
|
136
|
+
'Connection: Upgrade\r\n' +
|
|
137
|
+
`Sec-WebSocket-Accept: ${acceptKey(String(key ?? randomBytes(16).toString('base64')))}\r\n` +
|
|
138
|
+
'\r\n',
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let buf: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
142
|
+
let fragments: Buffer<ArrayBufferLike>[] = [];
|
|
143
|
+
let closed = false;
|
|
144
|
+
|
|
145
|
+
const conn: WsConnection = {
|
|
146
|
+
send(text) {
|
|
147
|
+
if (!closed) socket.write(encodeTextFrame(text));
|
|
148
|
+
},
|
|
149
|
+
close() {
|
|
150
|
+
if (closed) return;
|
|
151
|
+
closed = true;
|
|
152
|
+
try {
|
|
153
|
+
socket.write(encodeControl(OPCODES.close));
|
|
154
|
+
socket.end();
|
|
155
|
+
} catch {
|
|
156
|
+
/* already gone */
|
|
157
|
+
}
|
|
158
|
+
conn.onClose?.();
|
|
159
|
+
},
|
|
160
|
+
onMessage: null,
|
|
161
|
+
onClose: null,
|
|
162
|
+
get closed() {
|
|
163
|
+
return closed;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const die = () => {
|
|
168
|
+
if (!closed) {
|
|
169
|
+
closed = true;
|
|
170
|
+
conn.onClose?.();
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Parse as many complete frames as the buffer holds.
|
|
175
|
+
const drain = () => {
|
|
176
|
+
const { frames, rest } = decodeFrames(buf);
|
|
177
|
+
buf = rest;
|
|
178
|
+
for (const { fin, opcode, payload } of frames) {
|
|
179
|
+
if (opcode === OPCODES.close) {
|
|
180
|
+
conn.close();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (opcode === OPCODES.ping) {
|
|
184
|
+
socket.write(encodeControl(OPCODES.pong, payload));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (opcode === OPCODES.pong) continue;
|
|
188
|
+
if (opcode === OPCODES.text || opcode === OPCODES.continuation) {
|
|
189
|
+
fragments.push(payload);
|
|
190
|
+
if (fin) {
|
|
191
|
+
const text = Buffer.concat(fragments).toString('utf8');
|
|
192
|
+
fragments = [];
|
|
193
|
+
conn.onMessage?.(text);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
socket.on('data', (chunk: Buffer) => {
|
|
200
|
+
buf = Buffer.concat([buf, chunk]);
|
|
201
|
+
drain();
|
|
202
|
+
});
|
|
203
|
+
socket.on('error', die);
|
|
204
|
+
socket.on('close', die);
|
|
205
|
+
return conn;
|
|
206
|
+
}
|