@onebun/core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Socket.IO Protocol Implementation
3
+ *
4
+ * Implements Engine.IO and Socket.IO protocols for compatibility with socket.io-client.
5
+ * Based on:
6
+ * - Engine.IO Protocol: https://socket.io/docs/v4/engine-io-protocol/
7
+ * - Socket.IO Protocol: https://socket.io/docs/v4/socket-io-protocol/
8
+ */
9
+
10
+ // ============================================================================
11
+ // Engine.IO Types
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Engine.IO packet types
16
+ */
17
+ export enum EngineIOPacketType {
18
+ OPEN = 0,
19
+ CLOSE = 1,
20
+ PING = 2,
21
+ PONG = 3,
22
+ MESSAGE = 4,
23
+ UPGRADE = 5,
24
+ NOOP = 6,
25
+ }
26
+
27
+ /**
28
+ * Engine.IO packet
29
+ */
30
+ export interface EngineIOPacket {
31
+ type: EngineIOPacketType;
32
+ data?: string | Buffer;
33
+ }
34
+
35
+ /**
36
+ * Engine.IO handshake data (sent with OPEN packet)
37
+ */
38
+ export interface EngineIOHandshake {
39
+ /** Session ID */
40
+ sid: string;
41
+ /** Available upgrades (always ['websocket'] for our implementation) */
42
+ upgrades: string[];
43
+ /** Ping interval in milliseconds */
44
+ pingInterval: number;
45
+ /** Ping timeout in milliseconds */
46
+ pingTimeout: number;
47
+ /** Maximum payload size */
48
+ maxPayload: number;
49
+ }
50
+
51
+ // ============================================================================
52
+ // Socket.IO Types
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Socket.IO packet types
57
+ */
58
+ export enum SocketIOPacketType {
59
+ CONNECT = 0,
60
+ DISCONNECT = 1,
61
+ EVENT = 2,
62
+ ACK = 3,
63
+ CONNECT_ERROR = 4,
64
+ BINARY_EVENT = 5,
65
+ BINARY_ACK = 6,
66
+ }
67
+
68
+ /**
69
+ * Socket.IO packet
70
+ */
71
+ export interface SocketIOPacket {
72
+ type: SocketIOPacketType;
73
+ /** Namespace */
74
+ nsp: string;
75
+ /** Event data (for EVENT and ACK types) */
76
+ data?: unknown[];
77
+ /** Acknowledgement ID */
78
+ id?: number;
79
+ }
80
+
81
+ /**
82
+ * Socket.IO connect error data
83
+ */
84
+ export interface SocketIOConnectError {
85
+ message: string;
86
+ data?: unknown;
87
+ }
88
+
89
+ // ============================================================================
90
+ // Default Configuration
91
+ // ============================================================================
92
+
93
+ export const DEFAULT_PING_INTERVAL = 25000; // 25 seconds
94
+ export const DEFAULT_PING_TIMEOUT = 20000; // 20 seconds
95
+ export const DEFAULT_MAX_PAYLOAD = 1024 * 1024; // 1 MB
96
+
97
+ // ============================================================================
98
+ // Engine.IO Protocol
99
+ // ============================================================================
100
+
101
+ /**
102
+ * Encode an Engine.IO packet
103
+ */
104
+ export function encodeEngineIOPacket(packet: EngineIOPacket): string {
105
+ if (packet.data === undefined) {
106
+ return String(packet.type);
107
+ }
108
+ if (typeof packet.data === 'string') {
109
+ return String(packet.type) + packet.data;
110
+ }
111
+
112
+ // Buffer data - convert to base64
113
+ return String(packet.type) + 'b' + packet.data.toString('base64');
114
+ }
115
+
116
+ /**
117
+ * Decode an Engine.IO packet
118
+ */
119
+ export function decodeEngineIOPacket(data: string | Buffer): EngineIOPacket {
120
+ const str = typeof data === 'string' ? data : data.toString();
121
+
122
+ if (str.length === 0) {
123
+ return { type: EngineIOPacketType.NOOP };
124
+ }
125
+
126
+ const type = parseInt(str.charAt(0), 10) as EngineIOPacketType;
127
+
128
+ if (str.length === 1) {
129
+ return { type };
130
+ }
131
+
132
+ const packetData = str.substring(1);
133
+
134
+ // Check for binary data (starts with 'b')
135
+ if (packetData.charAt(0) === 'b') {
136
+ return {
137
+ type,
138
+ data: Buffer.from(packetData.substring(1), 'base64'),
139
+ };
140
+ }
141
+
142
+ return { type, data: packetData };
143
+ }
144
+
145
+ /**
146
+ * Create handshake data for new connection
147
+ */
148
+ export function createHandshake(
149
+ sid: string,
150
+ options?: Partial<EngineIOHandshake>,
151
+ ): EngineIOHandshake {
152
+ return {
153
+ sid,
154
+ upgrades: ['websocket'],
155
+ pingInterval: options?.pingInterval ?? DEFAULT_PING_INTERVAL,
156
+ pingTimeout: options?.pingTimeout ?? DEFAULT_PING_TIMEOUT,
157
+ maxPayload: options?.maxPayload ?? DEFAULT_MAX_PAYLOAD,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Create OPEN packet with handshake data
163
+ */
164
+ export function createOpenPacket(handshake: EngineIOHandshake): string {
165
+ return encodeEngineIOPacket({
166
+ type: EngineIOPacketType.OPEN,
167
+ data: JSON.stringify(handshake),
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Create PING packet
173
+ */
174
+ export function createPingPacket(data?: string): string {
175
+ return encodeEngineIOPacket({
176
+ type: EngineIOPacketType.PING,
177
+ data,
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Create PONG packet
183
+ */
184
+ export function createPongPacket(data?: string): string {
185
+ return encodeEngineIOPacket({
186
+ type: EngineIOPacketType.PONG,
187
+ data,
188
+ });
189
+ }
190
+
191
+ // ============================================================================
192
+ // Socket.IO Protocol
193
+ // ============================================================================
194
+
195
+ /**
196
+ * Encode a Socket.IO packet
197
+ */
198
+ export function encodeSocketIOPacket(packet: SocketIOPacket): string {
199
+ let str = String(packet.type);
200
+
201
+ // Add namespace if not default
202
+ if (packet.nsp && packet.nsp !== '/') {
203
+ str += packet.nsp + ',';
204
+ }
205
+
206
+ // Add acknowledgement ID
207
+ if (packet.id !== undefined) {
208
+ str += String(packet.id);
209
+ }
210
+
211
+ // Add data
212
+ if (packet.data !== undefined) {
213
+ str += JSON.stringify(packet.data);
214
+ }
215
+
216
+ return str;
217
+ }
218
+
219
+ /**
220
+ * Decode a Socket.IO packet
221
+ */
222
+ export function decodeSocketIOPacket(data: string): SocketIOPacket {
223
+ let i = 0;
224
+
225
+ // Parse type
226
+ const type = parseInt(data.charAt(i++), 10) as SocketIOPacketType;
227
+
228
+ // Parse namespace
229
+ let nsp = '/';
230
+ if (data.charAt(i) === '/') {
231
+ const start = i;
232
+ while (i < data.length && data.charAt(i) !== ',') {
233
+ i++;
234
+ }
235
+ nsp = data.substring(start, i);
236
+ i++; // Skip comma
237
+ }
238
+
239
+ // Parse acknowledgement ID
240
+ let id: number | undefined;
241
+ if (i < data.length) {
242
+ const start = i;
243
+ while (i < data.length && !isNaN(parseInt(data.charAt(i), 10))) {
244
+ i++;
245
+ }
246
+ if (start !== i) {
247
+ id = parseInt(data.substring(start, i), 10);
248
+ }
249
+ }
250
+
251
+ // Parse data
252
+ let packetData: unknown[] | undefined;
253
+ if (i < data.length) {
254
+ try {
255
+ packetData = JSON.parse(data.substring(i));
256
+ } catch {
257
+ // Invalid JSON, ignore data
258
+ }
259
+ }
260
+
261
+ return {
262
+ type, nsp, id, data: packetData,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Create Socket.IO CONNECT packet
268
+ */
269
+ export function createConnectPacket(nsp: string = '/', authData?: unknown): string {
270
+ const packet: SocketIOPacket = {
271
+ type: SocketIOPacketType.CONNECT,
272
+ nsp,
273
+ };
274
+
275
+ if (authData !== undefined) {
276
+ packet.data = [authData];
277
+ }
278
+
279
+ return encodeSocketIOPacket(packet);
280
+ }
281
+
282
+ /**
283
+ * Create Socket.IO DISCONNECT packet
284
+ */
285
+ export function createDisconnectPacket(nsp: string = '/'): string {
286
+ return encodeSocketIOPacket({
287
+ type: SocketIOPacketType.DISCONNECT,
288
+ nsp,
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Create Socket.IO EVENT packet
294
+ */
295
+ export function createEventPacket(
296
+ event: string,
297
+ data: unknown,
298
+ nsp: string = '/',
299
+ ackId?: number,
300
+ ): string {
301
+ const packet: SocketIOPacket = {
302
+ type: SocketIOPacketType.EVENT,
303
+ nsp,
304
+ data: [event, data],
305
+ id: ackId,
306
+ };
307
+
308
+ return encodeSocketIOPacket(packet);
309
+ }
310
+
311
+ /**
312
+ * Create Socket.IO ACK packet
313
+ */
314
+ export function createAckPacket(ackId: number, data: unknown, nsp: string = '/'): string {
315
+ return encodeSocketIOPacket({
316
+ type: SocketIOPacketType.ACK,
317
+ nsp,
318
+ id: ackId,
319
+ data: Array.isArray(data) ? data : [data],
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Create Socket.IO CONNECT_ERROR packet
325
+ */
326
+ export function createConnectErrorPacket(
327
+ error: SocketIOConnectError,
328
+ nsp: string = '/',
329
+ ): string {
330
+ return encodeSocketIOPacket({
331
+ type: SocketIOPacketType.CONNECT_ERROR,
332
+ nsp,
333
+ data: [error],
334
+ });
335
+ }
336
+
337
+ // ============================================================================
338
+ // Combined Message Handling
339
+ // ============================================================================
340
+
341
+ /**
342
+ * Wrap Socket.IO packet in Engine.IO MESSAGE packet
343
+ */
344
+ export function wrapInEngineIO(socketIOPacket: string): string {
345
+ return encodeEngineIOPacket({
346
+ type: EngineIOPacketType.MESSAGE,
347
+ data: socketIOPacket,
348
+ });
349
+ }
350
+
351
+ /**
352
+ * Extract Socket.IO packet from Engine.IO MESSAGE packet
353
+ */
354
+ export function unwrapFromEngineIO(engineIOPacket: string): string | null {
355
+ const decoded = decodeEngineIOPacket(engineIOPacket);
356
+
357
+ if (decoded.type !== EngineIOPacketType.MESSAGE || typeof decoded.data !== 'string') {
358
+ return null;
359
+ }
360
+
361
+ return decoded.data;
362
+ }
363
+
364
+ /**
365
+ * Parse incoming message (Engine.IO wrapped Socket.IO)
366
+ */
367
+ export function parseMessage(
368
+ data: string | Buffer,
369
+ ): { engineIO: EngineIOPacket; socketIO?: SocketIOPacket } {
370
+ const engineIO = decodeEngineIOPacket(data);
371
+
372
+ if (engineIO.type === EngineIOPacketType.MESSAGE && typeof engineIO.data === 'string') {
373
+ const socketIO = decodeSocketIOPacket(engineIO.data);
374
+
375
+ return { engineIO, socketIO };
376
+ }
377
+
378
+ return { engineIO };
379
+ }
380
+
381
+ /**
382
+ * Create full message (Engine.IO + Socket.IO) for sending event
383
+ */
384
+ export function createFullEventMessage(
385
+ event: string,
386
+ eventData: unknown,
387
+ nsp: string = '/',
388
+ ackId?: number,
389
+ ): string {
390
+ const socketIOPacket = createEventPacket(event, eventData, nsp, ackId);
391
+
392
+ return wrapInEngineIO(socketIOPacket);
393
+ }
394
+
395
+ /**
396
+ * Create full message for acknowledgement
397
+ */
398
+ export function createFullAckMessage(
399
+ ackId: number,
400
+ data: unknown,
401
+ nsp: string = '/',
402
+ ): string {
403
+ const socketIOPacket = createAckPacket(ackId, data, nsp);
404
+
405
+ return wrapInEngineIO(socketIOPacket);
406
+ }
407
+
408
+ // ============================================================================
409
+ // HTTP Polling Support (for Engine.IO)
410
+ // ============================================================================
411
+
412
+ /**
413
+ * Parse Engine.IO HTTP request query parameters
414
+ */
415
+ export function parseEngineIOQuery(url: URL): {
416
+ EIO: string;
417
+ transport: string;
418
+ sid?: string;
419
+ t?: string;
420
+ b64?: boolean;
421
+ } {
422
+ return {
423
+ EIO: url.searchParams.get('EIO') || '4',
424
+ transport: url.searchParams.get('transport') || 'polling',
425
+ sid: url.searchParams.get('sid') || undefined,
426
+ t: url.searchParams.get('t') || undefined,
427
+ b64: url.searchParams.get('b64') === '1',
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Check if request is Engine.IO polling request
433
+ */
434
+ export function isPollingRequest(url: URL, basePath: string = '/socket.io'): boolean {
435
+ return url.pathname.startsWith(basePath) &&
436
+ url.searchParams.get('transport') === 'polling';
437
+ }
438
+
439
+ /**
440
+ * Check if request is Engine.IO websocket upgrade
441
+ */
442
+ export function isWebSocketUpgrade(url: URL, basePath: string = '/socket.io'): boolean {
443
+ return url.pathname.startsWith(basePath) &&
444
+ url.searchParams.get('transport') === 'websocket';
445
+ }
446
+
447
+ /**
448
+ * Create polling response with multiple packets
449
+ */
450
+ export function createPollingResponse(packets: string[]): string {
451
+ // Concatenate packets with length prefix
452
+ return packets.map((p) => `${p.length}:${p}`).join('');
453
+ }
454
+
455
+ /**
456
+ * Parse polling request body with multiple packets
457
+ */
458
+ export function parsePollingRequest(body: string): string[] {
459
+ const packets: string[] = [];
460
+ let i = 0;
461
+
462
+ while (i < body.length) {
463
+ // Find length separator
464
+ const colonIndex = body.indexOf(':', i);
465
+ if (colonIndex === -1) {
466
+ break;
467
+ }
468
+
469
+ const length = parseInt(body.substring(i, colonIndex), 10);
470
+ const start = colonIndex + 1;
471
+ const end = start + length;
472
+
473
+ packets.push(body.substring(start, end));
474
+ i = end;
475
+ }
476
+
477
+ return packets;
478
+ }
479
+
480
+ // ============================================================================
481
+ // Native WebSocket Message Handling (non-Socket.IO)
482
+ // ============================================================================
483
+
484
+ /**
485
+ * Simple message format for native WebSocket clients
486
+ */
487
+ export interface NativeWsMessage<T = unknown> {
488
+ event: string;
489
+ data: T;
490
+ ack?: number;
491
+ }
492
+
493
+ /**
494
+ * Check if message is in native format
495
+ */
496
+ export function isNativeMessage(data: string): boolean {
497
+ try {
498
+ const parsed = JSON.parse(data);
499
+
500
+ return typeof parsed === 'object' &&
501
+ parsed !== null &&
502
+ 'event' in parsed &&
503
+ typeof parsed.event === 'string';
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Parse native WebSocket message
511
+ */
512
+ export function parseNativeMessage(data: string): NativeWsMessage | null {
513
+ try {
514
+ const parsed = JSON.parse(data);
515
+ if (typeof parsed === 'object' &&
516
+ parsed !== null &&
517
+ 'event' in parsed &&
518
+ typeof parsed.event === 'string') {
519
+ return parsed as NativeWsMessage;
520
+ }
521
+
522
+ return null;
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Create native WebSocket message
530
+ */
531
+ export function createNativeMessage<T>(event: string, data: T, ack?: number): string {
532
+ const message: NativeWsMessage<T> = { event, data };
533
+ if (ack !== undefined) {
534
+ message.ack = ack;
535
+ }
536
+
537
+ return JSON.stringify(message);
538
+ }
539
+
540
+ // ============================================================================
541
+ // Protocol Detection
542
+ // ============================================================================
543
+
544
+ /**
545
+ * Detected protocol type
546
+ */
547
+ export type ProtocolType = 'socket.io' | 'native';
548
+
549
+ /**
550
+ * Detect which protocol is being used
551
+ */
552
+ export function detectProtocol(data: string | Buffer): ProtocolType {
553
+ const str = typeof data === 'string' ? data : data.toString();
554
+
555
+ // Socket.IO messages start with a digit (packet type)
556
+ if (/^\d/.test(str)) {
557
+ return 'socket.io';
558
+ }
559
+
560
+ // Try to parse as native JSON
561
+ if (isNativeMessage(str)) {
562
+ return 'native';
563
+ }
564
+
565
+ // Default to socket.io
566
+ return 'socket.io';
567
+ }