getpicked 1.0.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.
Files changed (2) hide show
  1. package/index.js +655 -0
  2. package/package.json +17 -0
package/index.js ADDED
@@ -0,0 +1,655 @@
1
+ #!/usr/bin/env node
2
+
3
+ import blessed from "blessed";
4
+ import WebSocket from "ws";
5
+
6
+ const isLocal = process.argv.includes("--local");
7
+ const SERVER_URL = process.env.PORTO_SERVER ||
8
+ (isLocal ? "ws://localhost:8111/ws" : "wss://shapely-insect.spcf.app/ws");
9
+
10
+ // ─── Colors ──────────────────────────────────────────────────────
11
+ const C = {
12
+ bg: "#0a0a0f",
13
+ panel: "#111118",
14
+ border: "#2a2a3a",
15
+ dim: "#444466",
16
+ text: "#8888aa",
17
+ bright: "#bbbbdd",
18
+ white: "#ffffff",
19
+ green: "#22cc66",
20
+ red: "#ee4444",
21
+ yellow: "#eebb33",
22
+ cyan: "#44dddd",
23
+ purple: "#aa66ff",
24
+ orange: "#ee8833",
25
+ dark: "#181820",
26
+ };
27
+
28
+ // ─── Screen ──────────────────────────────────────────────────────
29
+ const screen = blessed.screen({
30
+ smartCSR: true,
31
+ title: "Porto",
32
+ fullUnicode: true,
33
+ });
34
+
35
+ // Track scene-specific key handlers so we can cleanly remove them
36
+ let sceneKeyHandlers = [];
37
+
38
+ function onKey(keys, fn) {
39
+ screen.key(keys, fn);
40
+ sceneKeyHandlers.push({ keys, fn });
41
+ }
42
+
43
+ function clearSceneKeys() {
44
+ for (const handler of sceneKeyHandlers) {
45
+ if (handler._keypressHandler) {
46
+ screen.removeListener("keypress", handler._keypressHandler);
47
+ } else {
48
+ screen.unkey(handler.keys, handler.fn);
49
+ }
50
+ }
51
+ sceneKeyHandlers = [];
52
+ }
53
+
54
+ function clearScreen() {
55
+ clearSceneKeys();
56
+ // Detach all children
57
+ while (screen.children.length) {
58
+ screen.children[0].detach();
59
+ }
60
+ }
61
+
62
+ // Global exit
63
+ screen.key(["C-c"], () => process.exit(0));
64
+
65
+ // ═══════════════════════════════════════════════════════════════════
66
+ // MAIN MENU
67
+ // ═══════════════════════════════════════════════════════════════════
68
+
69
+ let username = "";
70
+
71
+ function showMainMenu() {
72
+ clearScreen();
73
+
74
+ const container = blessed.box({
75
+ parent: screen,
76
+ top: 0,
77
+ left: 0,
78
+ width: "100%",
79
+ height: "100%",
80
+ style: { bg: C.bg },
81
+ });
82
+
83
+ // ASCII logo
84
+ const logoLines = [
85
+ "██████╗ ██████╗ ██████╗ ████████╗ ██████╗ ",
86
+ "██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██╔═══██╗",
87
+ "██████╔╝██║ ██║██████╔╝ ██║ ██║ ██║",
88
+ "██╔═══╝ ██║ ██║██╔══██╗ ██║ ██║ ██║",
89
+ "██║ ╚██████╔╝██║ ██║ ██║ ╚██████╔╝",
90
+ "╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝",
91
+ ];
92
+
93
+ blessed.box({
94
+ parent: container,
95
+ top: Math.max(2, Math.floor(screen.height / 2) - 10),
96
+ left: "center",
97
+ width: 48,
98
+ height: logoLines.length,
99
+ content: logoLines.join("\n"),
100
+ tags: true,
101
+ style: { fg: C.purple, bg: C.bg },
102
+ });
103
+
104
+ // Subtitle
105
+ blessed.box({
106
+ parent: container,
107
+ top: Math.max(2, Math.floor(screen.height / 2) - 10) + logoLines.length + 1,
108
+ left: "center",
109
+ width: 30,
110
+ height: 1,
111
+ content: "{center}the raffle game{/center}",
112
+ tags: true,
113
+ style: { fg: C.dim, bg: C.bg },
114
+ });
115
+
116
+ // Menu
117
+ const menuItems = [
118
+ {
119
+ label: username ? `Change name` : "Set username",
120
+ hint: username ? ` (${username})` : "",
121
+ action: () => showUsernameInput(),
122
+ },
123
+ {
124
+ label: "Join lobby",
125
+ hint: "",
126
+ disabled: !username,
127
+ action: () => showLobby(),
128
+ },
129
+ {
130
+ label: "Quit",
131
+ hint: "",
132
+ action: () => process.exit(0),
133
+ },
134
+ ];
135
+
136
+ let sel = username ? 1 : 0;
137
+
138
+ const menuTop = Math.max(2, Math.floor(screen.height / 2) - 10) + logoLines.length + 4;
139
+
140
+ const menuBox = blessed.box({
141
+ parent: container,
142
+ top: menuTop,
143
+ left: "center",
144
+ width: 40,
145
+ height: menuItems.length * 2 + 2,
146
+ tags: true,
147
+ style: { bg: C.bg },
148
+ });
149
+
150
+ function renderMenu() {
151
+ let out = "";
152
+ menuItems.forEach((item, i) => {
153
+ const selected = i === sel;
154
+ const disabled = item.disabled;
155
+
156
+ if (selected && !disabled) {
157
+ out += ` {${C.cyan}-fg}{bold} ▸ ${item.label}{/bold}{/}`;
158
+ if (item.hint) out += `{${C.dim}-fg}${item.hint}{/}`;
159
+ out += "\n\n";
160
+ } else if (disabled) {
161
+ out += ` {${C.dim}-fg} ${item.label} {/}`;
162
+ if (selected) out += `{${C.dim}-fg}(set name first){/}`;
163
+ out += "\n\n";
164
+ } else {
165
+ out += ` {${C.text}-fg} ${item.label}{/}`;
166
+ if (item.hint) out += `{${C.dim}-fg}${item.hint}{/}`;
167
+ out += "\n\n";
168
+ }
169
+ });
170
+ menuBox.setContent(out);
171
+ screen.render();
172
+ }
173
+
174
+ onKey(["up", "k"], () => {
175
+ sel = (sel - 1 + menuItems.length) % menuItems.length;
176
+ renderMenu();
177
+ });
178
+
179
+ onKey(["down", "j"], () => {
180
+ sel = (sel + 1) % menuItems.length;
181
+ renderMenu();
182
+ });
183
+
184
+ onKey(["enter", "return"], () => {
185
+ const item = menuItems[sel];
186
+ if (item.disabled) return;
187
+ item.action();
188
+ });
189
+
190
+ // Keyboard hints at bottom
191
+ blessed.box({
192
+ parent: container,
193
+ bottom: 1,
194
+ left: "center",
195
+ width: 50,
196
+ height: 1,
197
+ content: "{center}{" + C.dim + "-fg}↑↓ navigate · enter select · ctrl-c quit{/}{/center}",
198
+ tags: true,
199
+ style: { bg: C.bg },
200
+ });
201
+
202
+ renderMenu();
203
+ }
204
+
205
+ // ═══════════════════════════════════════════════════════════════════
206
+ // USERNAME INPUT
207
+ // ═══════════════════════════════════════════════════════════════════
208
+
209
+ function showUsernameInput(thenJoin = false) {
210
+ clearScreen();
211
+
212
+ const container = blessed.box({
213
+ parent: screen,
214
+ top: 0,
215
+ left: 0,
216
+ width: "100%",
217
+ height: "100%",
218
+ style: { bg: C.bg },
219
+ });
220
+
221
+ const dialog = blessed.box({
222
+ parent: container,
223
+ top: "center",
224
+ left: "center",
225
+ width: 50,
226
+ height: 13,
227
+ border: { type: "line" },
228
+ style: {
229
+ bg: C.panel,
230
+ border: { fg: C.purple },
231
+ },
232
+ tags: true,
233
+ shadow: true,
234
+ });
235
+
236
+ blessed.text({
237
+ parent: dialog,
238
+ top: 1,
239
+ left: "center",
240
+ content: "CHOOSE YOUR NAME",
241
+ style: { fg: C.bright, bg: C.panel, bold: true },
242
+ });
243
+
244
+ blessed.line({
245
+ parent: dialog,
246
+ top: 3,
247
+ left: 1,
248
+ right: 1,
249
+ width: "100%-4",
250
+ orientation: "horizontal",
251
+ style: { fg: C.border },
252
+ });
253
+
254
+ const inputBox = blessed.box({
255
+ parent: dialog,
256
+ top: 5,
257
+ left: 4,
258
+ right: 4,
259
+ height: 1,
260
+ tags: false,
261
+ style: { bg: C.dark },
262
+ });
263
+
264
+ const cursorEl = blessed.box({
265
+ parent: dialog,
266
+ top: 5,
267
+ left: 4,
268
+ width: 1,
269
+ height: 1,
270
+ style: { fg: 0, bg: 15 },
271
+ });
272
+
273
+ let inputValue = username;
274
+ let cursorPos = inputValue.length;
275
+
276
+ function renderInput() {
277
+ // Raw ANSI bright white + bold for the input text
278
+ inputBox.setContent(`\x1b[38;5;231;1m${inputValue}\x1b[0m`);
279
+ cursorEl.left = 4 + cursorPos;
280
+ cursorEl.setContent(inputValue[cursorPos] || " ");
281
+ screen.render();
282
+ }
283
+
284
+ blessed.text({
285
+ parent: dialog,
286
+ top: 8,
287
+ left: "center",
288
+ content: "Enter confirm · Esc cancel",
289
+ style: { fg: C.dim, bg: C.panel },
290
+ });
291
+
292
+ const errText = blessed.text({
293
+ parent: dialog,
294
+ top: 10,
295
+ left: "center",
296
+ content: "",
297
+ style: { fg: C.red, bg: C.panel },
298
+ });
299
+
300
+ function submit() {
301
+ const trimmed = inputValue.trim();
302
+ if (!trimmed) {
303
+ errText.setContent("Name cannot be empty");
304
+ screen.render();
305
+ return;
306
+ }
307
+ username = trimmed.slice(0, 20);
308
+ if (thenJoin) showLobby();
309
+ else showMainMenu();
310
+ }
311
+
312
+ function cancel() {
313
+ if (thenJoin && !username) showMainMenu();
314
+ else showMainMenu();
315
+ }
316
+
317
+ // Single keypress handler for all input — avoids conflicts with screen.key()
318
+ function onKeypress(ch, key) {
319
+ if (!key) return;
320
+
321
+ if (key.name === "return" || key.name === "enter") {
322
+ submit();
323
+ return;
324
+ }
325
+ if (key.name === "escape") {
326
+ cancel();
327
+ return;
328
+ }
329
+ if (key.name === "backspace") {
330
+ if (cursorPos > 0) {
331
+ inputValue = inputValue.slice(0, cursorPos - 1) + inputValue.slice(cursorPos);
332
+ cursorPos--;
333
+ }
334
+ } else if (key.name === "delete") {
335
+ inputValue = inputValue.slice(0, cursorPos) + inputValue.slice(cursorPos + 1);
336
+ } else if (key.name === "left") {
337
+ if (cursorPos > 0) cursorPos--;
338
+ } else if (key.name === "right") {
339
+ if (cursorPos < inputValue.length) cursorPos++;
340
+ } else if (key.name === "home") {
341
+ cursorPos = 0;
342
+ } else if (key.name === "end") {
343
+ cursorPos = inputValue.length;
344
+ } else if (ch && !key.ctrl && !key.meta && ch.length === 1 && inputValue.length < 20) {
345
+ inputValue = inputValue.slice(0, cursorPos) + ch + inputValue.slice(cursorPos);
346
+ cursorPos++;
347
+ } else {
348
+ return;
349
+ }
350
+
351
+ errText.setContent("");
352
+ renderInput();
353
+ }
354
+
355
+ // Defer registration so the Enter key from the menu doesn't leak through
356
+ process.nextTick(() => {
357
+ screen.on("keypress", onKeypress);
358
+ });
359
+ sceneKeyHandlers.push({ _keypressHandler: onKeypress });
360
+
361
+ renderInput();
362
+ }
363
+
364
+ // ═══════════════════════════════════════════════════════════════════
365
+ // LOBBY
366
+ // ═══════════════════════════════════════════════════════════════════
367
+
368
+ function showLobby() {
369
+ clearScreen();
370
+
371
+ if (!username) {
372
+ return showUsernameInput(true);
373
+ }
374
+
375
+ const container = blessed.box({
376
+ parent: screen,
377
+ top: 0,
378
+ left: 0,
379
+ width: "100%",
380
+ height: "100%",
381
+ style: { bg: C.bg },
382
+ });
383
+
384
+ // ── Header ──
385
+ const headerBox = blessed.box({
386
+ parent: container,
387
+ top: 0,
388
+ left: 0,
389
+ width: "100%",
390
+ height: 3,
391
+ style: { bg: C.panel },
392
+ tags: true,
393
+ padding: { left: 1, right: 1 },
394
+ valign: "middle",
395
+ });
396
+
397
+ // ── Progress bar ──
398
+ const progressBox = blessed.box({
399
+ parent: container,
400
+ top: 3,
401
+ left: 0,
402
+ width: "100%",
403
+ height: 1,
404
+ style: { bg: C.bg },
405
+ tags: true,
406
+ });
407
+
408
+ // ── Player grid ──
409
+ const gridBox = blessed.box({
410
+ parent: container,
411
+ top: 4,
412
+ left: 0,
413
+ width: "100%",
414
+ height: "100%-7",
415
+ tags: true,
416
+ style: { bg: C.bg },
417
+ scrollable: true,
418
+ alwaysScroll: true,
419
+ mouse: true,
420
+ scrollbar: { style: { bg: C.dim } },
421
+ padding: { left: 2, right: 2, top: 1, bottom: 1 },
422
+ });
423
+
424
+ // ── Footer ──
425
+ const footerBox = blessed.box({
426
+ parent: container,
427
+ bottom: 0,
428
+ left: 0,
429
+ width: "100%",
430
+ height: 3,
431
+ style: { bg: C.panel },
432
+ tags: true,
433
+ padding: { left: 2, right: 2 },
434
+ valign: "middle",
435
+ });
436
+
437
+ // ── State ──
438
+ let ws = null;
439
+ let lobbyState = null;
440
+ let stateReceivedAt = null;
441
+ let myId = null;
442
+ let pingInterval = null;
443
+ let renderInterval = null;
444
+ let dead = false;
445
+
446
+ function leave() {
447
+ clearInterval(pingInterval);
448
+ clearInterval(renderInterval);
449
+ if (ws) {
450
+ try { ws.close(); } catch {}
451
+ }
452
+ showMainMenu();
453
+ }
454
+
455
+ onKey(["q", "escape"], leave);
456
+
457
+ // ── Connect ──
458
+ headerBox.setContent(`{center}{${C.yellow}-fg}⟳ Connecting...{/}{/center}`);
459
+ screen.render();
460
+
461
+ const wsUrl = `${SERVER_URL}?username=${encodeURIComponent(username)}`;
462
+ ws = new WebSocket(wsUrl);
463
+
464
+ ws.on("open", () => {
465
+ pingInterval = setInterval(() => {
466
+ if (ws.readyState === WebSocket.OPEN) {
467
+ ws.send(JSON.stringify({ type: "ping" }));
468
+ }
469
+ }, 5000);
470
+ });
471
+
472
+ ws.on("message", (raw) => {
473
+ const msg = JSON.parse(raw.toString());
474
+ if (msg.type === "lobby_state") {
475
+ if (msg.your_id) myId = msg.your_id;
476
+ lobbyState = msg;
477
+ stateReceivedAt = Date.now();
478
+ render();
479
+ }
480
+ });
481
+
482
+ ws.on("close", () => {
483
+ clearInterval(pingInterval);
484
+ dead = true;
485
+ headerBox.setContent(`{center}{${C.red}-fg}● Disconnected{/}{/center}`);
486
+ footerBox.setContent(`{center}{${C.dim}-fg}Press Q or Esc to return{/}{/center}`);
487
+ screen.render();
488
+ });
489
+
490
+ ws.on("error", () => {
491
+ clearInterval(pingInterval);
492
+ dead = true;
493
+ headerBox.setContent(`{center}{${C.red}-fg}● Connection failed — is the server running?{/}{/center}`);
494
+ footerBox.setContent(`{center}{${C.dim}-fg}Press Q or Esc to return{/}{/center}`);
495
+ screen.render();
496
+ });
497
+
498
+ // Timer refresh
499
+ renderInterval = setInterval(() => {
500
+ if (lobbyState && !dead) render();
501
+ }, 200);
502
+
503
+ // 256-color index 231 = true #ffffff white, NOT remappable by terminal themes
504
+ // (palette indices 0-15 like SGR 97 are theme-dependent and render as grey)
505
+ const W = "\x1b[38;5;231m";
506
+ const B = "\x1b[1m";
507
+ const R = "\x1b[0m";
508
+
509
+ function render() {
510
+ if (!lobbyState) return;
511
+
512
+ const { state, players, player_count, max_players, time_remaining, countdown_remaining, winner_id } = lobbyState;
513
+
514
+ const elapsed = (Date.now() - stateReceivedAt) / 1000;
515
+
516
+ // ── Header ── uses {|} for left/right split
517
+ if (state === "waiting") {
518
+ const t = Math.max(0, Math.ceil(time_remaining - elapsed));
519
+ const lid = lobbyState.lobby_id;
520
+ headerBox.setContent(
521
+ `{${C.green}-fg}● LOBBY{/} {${C.dim}-fg}#${lid}{/}{|}${W}${player_count}${R}{${C.dim}-fg}/${max_players}{/} {${C.yellow}-fg}⏱ ${t}s{/}`
522
+ );
523
+ const barW = screen.width;
524
+ const filled = Math.round((player_count / max_players) * barW);
525
+ progressBox.setContent(
526
+ `{${C.green}-fg}${"▀".repeat(Math.min(filled, barW))}{/}` +
527
+ `{${C.dark}-fg}${"▀".repeat(Math.max(0, barW - filled))}{/}`
528
+ );
529
+ } else if (state === "countdown") {
530
+ const t = Math.max(0, Math.ceil(countdown_remaining - elapsed));
531
+ const pips = Array.from({ length: 10 }, (_, i) =>
532
+ i < t ? `{${C.orange}-fg}◆{/}` : `{${C.dim}-fg}◇{/}`
533
+ ).join("");
534
+ headerBox.setContent(
535
+ `{center}{${C.orange}-fg}{bold}✦ RAFFLE ✦{/bold} ${pips} {${C.yellow}-fg}${t}s{/}{/center}`
536
+ );
537
+ progressBox.setContent(`{${C.orange}-fg}${"▀".repeat(screen.width)}{/}`);
538
+ } else if (state === "reveal") {
539
+ headerBox.setContent(
540
+ `{center}{${C.green}-fg}{bold}✦ WINNER REVEALED ✦{/bold}{/}{/center}`
541
+ );
542
+ progressBox.setContent(`{${C.green}-fg}${"▀".repeat(screen.width)}{/}`);
543
+ } else {
544
+ headerBox.setContent(
545
+ `{center}{${C.green}-fg}{bold}✦ RAFFLE COMPLETE ✦{/bold}{/}{/center}`
546
+ );
547
+ progressBox.setContent(`{${C.green}-fg}${"▀".repeat(screen.width)}{/}`);
548
+ }
549
+
550
+ // ── Grid ── uses raw ANSI for white names
551
+ const colW = 26;
552
+ const availW = gridBox.width - 4;
553
+ const cols = Math.max(1, Math.floor(availW / colW));
554
+ const lines = [];
555
+ let row = "";
556
+ let c = 0;
557
+
558
+ for (const player of players) {
559
+ let dot, nameAnsi;
560
+
561
+ if (state === "waiting") {
562
+ if (player.status === "online") {
563
+ dot = `{${C.green}-fg}●{/}`;
564
+ nameAnsi = player.id === myId ? `${W}${B}` : `${W}`;
565
+ } else {
566
+ dot = `{${C.dim}-fg}●{/}`;
567
+ nameAnsi = `{${C.dim}-fg}`;
568
+ }
569
+ } else if (state === "countdown") {
570
+ // All dots dark during countdown — suspense, no winner known
571
+ dot = `{${C.dark}-fg}●{/}`;
572
+ nameAnsi = `{${C.dark}-fg}`;
573
+ } else if (state === "reveal") {
574
+ if (player.id === winner_id) {
575
+ dot = `${W}●${R}`;
576
+ nameAnsi = `${W}${B}`;
577
+ } else {
578
+ dot = `{${C.dark}-fg}●{/}`;
579
+ nameAnsi = `{${C.dark}-fg}`;
580
+ }
581
+ } else {
582
+ if (player.id === winner_id) {
583
+ dot = `{${C.green}-fg}★{/}`;
584
+ nameAnsi = `{${C.green}-fg}{bold}`;
585
+ } else {
586
+ dot = `{${C.dark}-fg}●{/}`;
587
+ nameAnsi = `{${C.dark}-fg}`;
588
+ }
589
+ }
590
+
591
+ const name = player.username.length > 20
592
+ ? player.username.slice(0, 19) + "…"
593
+ : player.username;
594
+
595
+ const pad = " ".repeat(Math.max(1, 22 - name.length));
596
+ // Use {/} reset for tag-based styles, \x1b[0m for ANSI-based
597
+ const resetStr = nameAnsi.startsWith("\x1b") ? R : "{/}";
598
+ row += ` ${dot} ${nameAnsi}${name}${resetStr}${pad}`;
599
+ c++;
600
+
601
+ if (c >= cols) {
602
+ lines.push(row);
603
+ row = "";
604
+ c = 0;
605
+ }
606
+ }
607
+ if (row) lines.push(row);
608
+
609
+ if (players.length === 0) {
610
+ lines.push(` {${C.dim}-fg}Waiting for the first player...{/}`);
611
+ }
612
+
613
+ gridBox.setContent(lines.join("\n\n"));
614
+
615
+ // ── Footer ── uses {|} for left/right split
616
+ if (state === "waiting") {
617
+ footerBox.setContent(
618
+ `{${C.text}-fg}You: ${W}${B}${username}${R}{|}{${C.dim}-fg}Q{/} {${C.text}-fg}leave lobby{/}`
619
+ );
620
+ } else if (state === "countdown") {
621
+ footerBox.setContent(
622
+ `{center}{${C.orange}-fg}{bold}✦ Drawing winner...{/bold}{/}{/center}`
623
+ );
624
+ } else if (state === "reveal" || state === "finished") {
625
+ const winner = players.find((p) => p.id === winner_id);
626
+ const wName = winner ? winner.username : "???";
627
+ const isMe = winner_id === myId;
628
+
629
+ if (state === "reveal") {
630
+ if (isMe) {
631
+ footerBox.setContent(`{center}{${C.green}-fg}{bold}★ YOU HAVE BEEN CHOSEN ★{/bold}{/}{/center}`);
632
+ } else {
633
+ footerBox.setContent(`{center}{${C.orange}-fg}Winner: {bold}${wName}{/bold}{/}{/center}`);
634
+ }
635
+ } else {
636
+ if (isMe) {
637
+ footerBox.setContent(
638
+ `{center}{${C.green}-fg}{bold}★ WINNER: YOU ★{/bold}{/} ` +
639
+ `{${C.dim}-fg}Q to return{/}{/center}`
640
+ );
641
+ } else {
642
+ footerBox.setContent(
643
+ `{center}${W}Winner: ${B}${wName}${R} ` +
644
+ `{${C.dim}-fg}Q to return{/}{/center}`
645
+ );
646
+ }
647
+ }
648
+ }
649
+
650
+ screen.render();
651
+ }
652
+ }
653
+
654
+ // ─── Go ──────────────────────────────────────────────────────────
655
+ showMainMenu();
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "getpicked",
3
+ "version": "1.0.0",
4
+ "description": "Join a lobby, enter the raffle, win the draw",
5
+ "bin": {
6
+ "getpicked": "./index.js"
7
+ },
8
+ "type": "module",
9
+ "keywords": ["raffle", "game", "multiplayer", "terminal", "cli"],
10
+ "license": "MIT",
11
+ "files": ["index.js"],
12
+ "dependencies": {
13
+ "blessed": "^0.1.81",
14
+ "blessed-contrib": "^4.11.0",
15
+ "ws": "^8.16.0"
16
+ }
17
+ }