@coderbuzz/ken 0.1.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.
@@ -0,0 +1,815 @@
1
+ // src/context/types.ts
2
+ var EMPTY_PARAMS = Object.freeze({});
3
+ var EMPTY_QUERY = Object.freeze({});
4
+
5
+ // src/core/router.ts
6
+ var NodeType = {
7
+ Param: 1,
8
+ OptionalParam: 2,
9
+ Wildcard: 3
10
+ };
11
+ function createRouteState() {
12
+ return {
13
+ staticChildren: /* @__PURE__ */ new Map(),
14
+ dynamicChildren: [],
15
+ handlers: /* @__PURE__ */ new Map()
16
+ };
17
+ }
18
+ var TreeNode = class {
19
+ type;
20
+ paramName;
21
+ state;
22
+ constructor(type, paramName) {
23
+ this.type = type;
24
+ this.paramName = paramName;
25
+ this.state = createRouteState();
26
+ }
27
+ };
28
+ function searchDynamic(method, parts, index, state, params) {
29
+ if (index === parts.length) {
30
+ const entry = state.handlers.get(method);
31
+ if (entry !== void 0) return entry;
32
+ const dynamicChildren2 = state.dynamicChildren;
33
+ for (let i = 0; i < dynamicChildren2.length; i++) {
34
+ const node = dynamicChildren2[i];
35
+ if (node.type === NodeType.OptionalParam) {
36
+ return node.state.handlers.get(method);
37
+ }
38
+ }
39
+ return void 0;
40
+ }
41
+ const part = parts[index];
42
+ const nextState = state.staticChildren.get(part);
43
+ if (nextState !== void 0) {
44
+ const result = searchDynamic(method, parts, index + 1, nextState, params);
45
+ if (result !== void 0) return result;
46
+ }
47
+ if (part === "") return void 0;
48
+ const dynamicChildren = state.dynamicChildren;
49
+ const len = dynamicChildren.length;
50
+ for (let i = 0; i < len; i++) {
51
+ const node = dynamicChildren[i];
52
+ const nodeType = node.type;
53
+ if (nodeType === NodeType.Param || nodeType === NodeType.OptionalParam) {
54
+ const paramName = node.paramName;
55
+ params[paramName] = part;
56
+ const result = searchDynamic(method, parts, index + 1, node.state, params);
57
+ if (result !== void 0) return result;
58
+ delete params[paramName];
59
+ } else if (nodeType === NodeType.Wildcard) {
60
+ let wildcardValue = parts[index];
61
+ for (let j = index + 1; j < parts.length; j++) {
62
+ wildcardValue += "/" + parts[j];
63
+ }
64
+ params["*"] = wildcardValue;
65
+ return node.state.handlers.get(method);
66
+ }
67
+ }
68
+ return void 0;
69
+ }
70
+ var Router = class {
71
+ // Static routes: path -> method -> entry
72
+ staticRoutes = /* @__PURE__ */ new Map();
73
+ // Dynamic routes tree
74
+ dynamicRoot = createRouteState();
75
+ // Raw route registrations (for runtime compilation)
76
+ routes = [];
77
+ /**
78
+ * Register a compiled route (called by runtime during compilation)
79
+ */
80
+ registerCompiled(method, path, handler, schema, response) {
81
+ const isDynamic = path.includes(":") || path.includes("*");
82
+ if (!isDynamic) {
83
+ let methodMap = this.staticRoutes.get(path);
84
+ if (!methodMap) {
85
+ methodMap = /* @__PURE__ */ new Map();
86
+ this.staticRoutes.set(path, methodMap);
87
+ }
88
+ const cachedResult = {
89
+ handler,
90
+ schema,
91
+ params: EMPTY_PARAMS,
92
+ response
93
+ };
94
+ methodMap.set(method, { handler, schema, response, cachedResult });
95
+ return;
96
+ }
97
+ this.insertDynamic(method, path, handler, schema, response);
98
+ }
99
+ insertDynamic(method, path, handler, schema, response) {
100
+ const parts = path === "/" ? [] : path.substring(1).split("/");
101
+ let currentState = this.dynamicRoot;
102
+ for (let i = 0; i < parts.length; i++) {
103
+ const part = parts[i];
104
+ if (part.startsWith(":") || part === "*") {
105
+ let type;
106
+ let paramName;
107
+ if (part === "*") {
108
+ type = NodeType.Wildcard;
109
+ paramName = "*";
110
+ } else if (part.endsWith("?")) {
111
+ type = NodeType.OptionalParam;
112
+ paramName = part.substring(1, part.length - 1);
113
+ } else {
114
+ type = NodeType.Param;
115
+ paramName = part.substring(1);
116
+ }
117
+ let node = currentState.dynamicChildren.find(
118
+ (c) => c.type === type && c.paramName === paramName
119
+ );
120
+ if (!node) {
121
+ node = new TreeNode(type, paramName);
122
+ currentState.dynamicChildren.push(node);
123
+ currentState.dynamicChildren.sort((a, b) => a.type - b.type);
124
+ }
125
+ currentState = node.state;
126
+ } else {
127
+ let nextState = currentState.staticChildren.get(part);
128
+ if (!nextState) {
129
+ nextState = createRouteState();
130
+ currentState.staticChildren.set(part, nextState);
131
+ }
132
+ currentState = nextState;
133
+ }
134
+ }
135
+ currentState.handlers.set(method, { handler, schema, response });
136
+ }
137
+ /**
138
+ * Create matcher function for route lookup
139
+ */
140
+ matcher() {
141
+ const staticRoutes = this.staticRoutes;
142
+ const dynamicRoot = this.dynamicRoot;
143
+ return (method, pathname) => {
144
+ const staticMethodMap = staticRoutes.get(pathname);
145
+ if (staticMethodMap !== void 0) {
146
+ const entry = staticMethodMap.get(method);
147
+ if (entry !== void 0) {
148
+ return entry.cachedResult;
149
+ }
150
+ }
151
+ const parts = pathname === "/" ? [] : pathname.substring(1).split("/");
152
+ const params = {};
153
+ const result = searchDynamic(method, parts, 0, dynamicRoot, params);
154
+ if (result !== void 0) {
155
+ return {
156
+ handler: result.handler,
157
+ schema: result.schema,
158
+ params,
159
+ response: result.response
160
+ };
161
+ }
162
+ return void 0;
163
+ };
164
+ }
165
+ /**
166
+ * Dynamic-only matcher (skips static routes)
167
+ * Used when native routing handles static routes
168
+ */
169
+ dynamicOnlyMatcher() {
170
+ const dynamicRoot = this.dynamicRoot;
171
+ return (method, pathname) => {
172
+ const parts = pathname === "/" ? [] : pathname.substring(1).split("/");
173
+ const params = {};
174
+ const result = searchDynamic(method, parts, 0, dynamicRoot, params);
175
+ if (result !== void 0) {
176
+ return {
177
+ handler: result.handler,
178
+ schema: result.schema,
179
+ params,
180
+ response: result.response
181
+ };
182
+ }
183
+ return void 0;
184
+ };
185
+ }
186
+ /**
187
+ * Get all registered routes as an array of { method, path } objects.
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * const routes = app.getRoutes();
192
+ * // [{ method: 'GET', path: '/' }, { method: 'POST', path: '/users' }, ...]
193
+ * ```
194
+ */
195
+ getRoutes() {
196
+ return this.routes.map((r) => ({ method: r.method, path: r.path }));
197
+ }
198
+ /**
199
+ * Clear all compiled routes (for recompilation)
200
+ */
201
+ clear() {
202
+ this.staticRoutes.clear();
203
+ this.dynamicRoot = createRouteState();
204
+ }
205
+ };
206
+
207
+ // src/ws/types.ts
208
+ var WsReadyState = {
209
+ CONNECTING: 0,
210
+ OPEN: 1,
211
+ CLOSING: 2,
212
+ CLOSED: 3
213
+ };
214
+ var WS_DEFAULTS = {
215
+ maxPayloadLength: 16777216,
216
+ backpressureLimit: 16777216,
217
+ pingInterval: 30,
218
+ pongTimeout: 10,
219
+ perMessageDeflate: false,
220
+ idleTimeout: 120
221
+ };
222
+
223
+ // src/runtime/compiler.ts
224
+ function isAsyncHandler(handler, schema) {
225
+ if (handler.constructor.name === "AsyncFunction") {
226
+ return true;
227
+ }
228
+ if (schema?.state) {
229
+ for (const key in schema.state) {
230
+ if (schema.state[key].constructor.name === "AsyncFunction") {
231
+ return true;
232
+ }
233
+ }
234
+ }
235
+ return false;
236
+ }
237
+ var JSON_ERROR_HEADERS = { "content-type": "application/json" };
238
+ var INTERNAL_SERVER_ERROR = "Internal Server Error";
239
+ function defaultErrorHandler(err) {
240
+ if (err instanceof Response) return err;
241
+ const message = err instanceof Error ? err.message : INTERNAL_SERVER_ERROR;
242
+ return new Response(
243
+ JSON.stringify({ status: 500, message }),
244
+ { status: 500, headers: JSON_ERROR_HEADERS }
245
+ );
246
+ }
247
+ function handleError(err, ctx, onError) {
248
+ if (onError) {
249
+ try {
250
+ const result = onError(err, ctx);
251
+ if (result && typeof result.then === "function") {
252
+ return result.then((resp2) => {
253
+ ctx._executeFinishCallbacks(resp2);
254
+ return resp2;
255
+ }).catch((innerErr) => {
256
+ const resp2 = defaultErrorHandler(innerErr);
257
+ ctx._executeFinishCallbacks(resp2);
258
+ return resp2;
259
+ });
260
+ }
261
+ ctx._executeFinishCallbacks(result);
262
+ return result;
263
+ } catch (innerErr) {
264
+ const resp2 = defaultErrorHandler(innerErr);
265
+ ctx._executeFinishCallbacks(resp2);
266
+ return resp2;
267
+ }
268
+ }
269
+ const resp = defaultErrorHandler(err);
270
+ ctx._executeFinishCallbacks(resp);
271
+ return resp;
272
+ }
273
+ function createSyncExecutorWithMiddleware(createContext, handler, schema) {
274
+ const state = schema.state;
275
+ const onError = schema.onError;
276
+ return (request, params, getRemoteInfo, ...args) => {
277
+ const ctx = createContext(request, params, getRemoteInfo, schema, ...args);
278
+ try {
279
+ const stateObj = ctx.state;
280
+ for (const key in state) {
281
+ const result = state[key](ctx);
282
+ if (result !== void 0) {
283
+ if (result instanceof Response) {
284
+ ctx._executeFinishCallbacks(result);
285
+ return result;
286
+ }
287
+ stateObj[key] = result;
288
+ }
289
+ }
290
+ const response = handler(ctx);
291
+ ctx._executeFinishCallbacks(response);
292
+ return response;
293
+ } catch (err) {
294
+ return handleError(err, ctx, onError);
295
+ }
296
+ };
297
+ }
298
+ function createSyncExecutorNoMiddleware(createContext, handler, schema) {
299
+ const onError = schema?.onError;
300
+ return (request, params, getRemoteInfo, ...args) => {
301
+ const ctx = createContext(request, params, getRemoteInfo, schema, ...args);
302
+ try {
303
+ const response = handler(ctx);
304
+ ctx._executeFinishCallbacks(response);
305
+ return response;
306
+ } catch (err) {
307
+ return handleError(err, ctx, onError);
308
+ }
309
+ };
310
+ }
311
+ function createAsyncExecutorWithMiddleware(createContext, handler, schema) {
312
+ const state = schema.state;
313
+ const onError = schema.onError;
314
+ return async (request, params, getRemoteInfo, ...args) => {
315
+ const ctx = createContext(request, params, getRemoteInfo, schema, ...args);
316
+ try {
317
+ const stateObj = ctx.state;
318
+ for (const key in state) {
319
+ const result = await state[key](ctx);
320
+ if (result !== void 0) {
321
+ if (result instanceof Response) {
322
+ ctx._executeFinishCallbacks(result);
323
+ return result;
324
+ }
325
+ stateObj[key] = result;
326
+ }
327
+ }
328
+ const response = await handler(ctx);
329
+ ctx._executeFinishCallbacks(response);
330
+ return response;
331
+ } catch (err) {
332
+ return handleError(err, ctx, onError);
333
+ }
334
+ };
335
+ }
336
+ function createAsyncExecutorNoMiddleware(createContext, handler, schema) {
337
+ const onError = schema?.onError;
338
+ return async (request, params, getRemoteInfo, ...args) => {
339
+ const ctx = createContext(request, params, getRemoteInfo, schema, ...args);
340
+ try {
341
+ const response = await handler(ctx);
342
+ ctx._executeFinishCallbacks(response);
343
+ return response;
344
+ } catch (err) {
345
+ return handleError(err, ctx, onError);
346
+ }
347
+ };
348
+ }
349
+ function createExecutor(createContext, handler, schema) {
350
+ const hasMiddleware = schema?.state && Object.keys(schema.state).length > 0;
351
+ const isAsync = isAsyncHandler(handler, schema);
352
+ if (isAsync) {
353
+ return hasMiddleware ? createAsyncExecutorWithMiddleware(createContext, handler, schema) : createAsyncExecutorNoMiddleware(createContext, handler, schema);
354
+ }
355
+ return hasMiddleware ? createSyncExecutorWithMiddleware(createContext, handler, schema) : createSyncExecutorNoMiddleware(createContext, handler, schema);
356
+ }
357
+ function createNotFoundExecutor(router, createContext) {
358
+ const entries = [];
359
+ if (router._notFoundHandler) {
360
+ entries.push({ prefix: "", handler: router._notFoundHandler });
361
+ }
362
+ if (router._notFoundEntries?.length > 0) {
363
+ entries.push(...router._notFoundEntries);
364
+ }
365
+ if (entries.length > 0) {
366
+ return buildPrefixNotFoundExecutor(router, createContext, entries);
367
+ }
368
+ if (typeof router.matchMiddleware !== "function" || typeof router.mergeSchemas !== "function") {
369
+ return null;
370
+ }
371
+ const globalMiddleware = router.matchMiddleware("/*");
372
+ const schema = router.mergeSchemas(globalMiddleware, void 0);
373
+ if (!schema || !schema.state || Object.keys(schema.state).length === 0) {
374
+ return null;
375
+ }
376
+ const notFoundHandler = () => new Response("Not Found", { status: 404 });
377
+ const executor = createExecutor(createContext, notFoundHandler, schema);
378
+ return (request, getRemoteInfo, _pathname, ...args) => {
379
+ return executor(request, {}, getRemoteInfo, ...args);
380
+ };
381
+ }
382
+ function buildPrefixNotFoundExecutor(router, createContext, entries) {
383
+ const hasMatchMiddleware = typeof router.matchMiddleware === "function" && typeof router.mergeSchemas === "function";
384
+ const compiledEntries = [];
385
+ for (const entry of entries) {
386
+ let schema = entry.schema;
387
+ if ((!schema || !schema.state) && hasMatchMiddleware) {
388
+ const matched = router.matchMiddleware(entry.prefix || "/*");
389
+ schema = router.mergeSchemas(matched, schema);
390
+ }
391
+ const executor = createExecutor(createContext, entry.handler, schema);
392
+ compiledEntries.push({ prefix: entry.prefix, executor });
393
+ }
394
+ const hasGlobalHandler = compiledEntries.some((e) => e.prefix === "");
395
+ if (!hasGlobalHandler) {
396
+ const defaultHandler = () => new Response("Not Found", { status: 404 });
397
+ let fallbackSchema;
398
+ if (hasMatchMiddleware) {
399
+ const globalMiddleware = router.matchMiddleware("/*");
400
+ fallbackSchema = router.mergeSchemas(globalMiddleware, void 0);
401
+ }
402
+ const fallbackExecutor = createExecutor(createContext, defaultHandler, fallbackSchema);
403
+ compiledEntries.push({ prefix: "", executor: fallbackExecutor });
404
+ }
405
+ compiledEntries.sort((a, b) => b.prefix.length - a.prefix.length);
406
+ const emptyParams = {};
407
+ if (compiledEntries.length === 1 && compiledEntries[0].prefix === "") {
408
+ const singleExecutor = compiledEntries[0].executor;
409
+ return (request, getRemoteInfo, _pathname, ...args) => {
410
+ return singleExecutor(request, emptyParams, getRemoteInfo, ...args);
411
+ };
412
+ }
413
+ const len = compiledEntries.length;
414
+ return (request, getRemoteInfo, pathname, ...args) => {
415
+ for (let i = 0; i < len; i++) {
416
+ const entry = compiledEntries[i];
417
+ if (entry.prefix === "" || pathname === entry.prefix || pathname.startsWith(entry.prefix + "/")) {
418
+ return entry.executor(request, emptyParams, getRemoteInfo, ...args);
419
+ }
420
+ }
421
+ return new Response("Not Found", { status: 404 });
422
+ };
423
+ }
424
+
425
+ // src/utils/response.ts
426
+ var NO_CONTENT_RESPONSE = new Response(null, { status: 204 });
427
+ function toResponse(value) {
428
+ if (value instanceof Response) {
429
+ return value;
430
+ }
431
+ if (value === null || value === void 0) {
432
+ return NO_CONTENT_RESPONSE.clone();
433
+ }
434
+ if (typeof value === "object") {
435
+ const textValue2 = JSON.stringify(value);
436
+ return new Response(textValue2, {
437
+ status: 200,
438
+ headers: {
439
+ "content-type": "application/json"
440
+ // 'content-length': Buffer.byteLength(textValue).toString()
441
+ }
442
+ });
443
+ }
444
+ const textValue = String(value);
445
+ return new Response(textValue, {
446
+ status: 200,
447
+ headers: {
448
+ "content-type": "text/plain"
449
+ // 'content-length': Buffer.byteLength(textValue).toString()
450
+ }
451
+ });
452
+ }
453
+
454
+ // src/ws/pubsub.ts
455
+ var PubSubHub = class {
456
+ /** topic -> set of subscribers */
457
+ topics = /* @__PURE__ */ new Map();
458
+ /** peer -> set of subscribed topics (for fast cleanup) */
459
+ peerTopics = /* @__PURE__ */ new Map();
460
+ /** peer -> last-alive timestamp (ms) — for dead peer detection */
461
+ _lastPong = /* @__PURE__ */ new Map();
462
+ /**
463
+ * Subscribe a peer to a topic.
464
+ */
465
+ subscribe(peer, topic) {
466
+ let topicSet = this.topics.get(topic);
467
+ if (!topicSet) {
468
+ topicSet = /* @__PURE__ */ new Set();
469
+ this.topics.set(topic, topicSet);
470
+ }
471
+ topicSet.add(peer);
472
+ let peerSet = this.peerTopics.get(peer);
473
+ if (!peerSet) {
474
+ peerSet = /* @__PURE__ */ new Set();
475
+ this.peerTopics.set(peer, peerSet);
476
+ }
477
+ peerSet.add(topic);
478
+ if (!this._lastPong.has(peer)) {
479
+ this._lastPong.set(peer, Date.now());
480
+ }
481
+ }
482
+ /**
483
+ * Unsubscribe a peer from a topic.
484
+ */
485
+ unsubscribe(peer, topic) {
486
+ const topicSet = this.topics.get(topic);
487
+ if (topicSet) {
488
+ topicSet.delete(peer);
489
+ if (topicSet.size === 0) {
490
+ this.topics.delete(topic);
491
+ }
492
+ }
493
+ const peerSet = this.peerTopics.get(peer);
494
+ if (peerSet) {
495
+ peerSet.delete(topic);
496
+ if (peerSet.size === 0) {
497
+ this.peerTopics.delete(peer);
498
+ this._lastPong.delete(peer);
499
+ }
500
+ }
501
+ }
502
+ /**
503
+ * Remove a peer from all topics.
504
+ * Called on connection close.
505
+ */
506
+ removeAll(peer) {
507
+ const peerSet = this.peerTopics.get(peer);
508
+ if (!peerSet) return;
509
+ for (const topic of peerSet) {
510
+ const topicSet = this.topics.get(topic);
511
+ if (topicSet) {
512
+ topicSet.delete(peer);
513
+ if (topicSet.size === 0) {
514
+ this.topics.delete(topic);
515
+ }
516
+ }
517
+ }
518
+ this.peerTopics.delete(peer);
519
+ this._lastPong.delete(peer);
520
+ }
521
+ /**
522
+ * Check if a peer is subscribed to a topic.
523
+ */
524
+ isSubscribed(peer, topic) {
525
+ const topicSet = this.topics.get(topic);
526
+ return topicSet ? topicSet.has(peer) : false;
527
+ }
528
+ /**
529
+ * Publish a message to all subscribers of a topic, excluding the sender.
530
+ */
531
+ publish(sender, topic, data, compress) {
532
+ const topicSet = this.topics.get(topic);
533
+ if (!topicSet) return;
534
+ for (const peer of topicSet) {
535
+ if (peer !== sender) {
536
+ peer.send(data, compress);
537
+ }
538
+ }
539
+ }
540
+ /**
541
+ * Broadcast a message to ALL subscribers of a topic (including sender).
542
+ * Used internally by the server for ping broadcasts, etc.
543
+ */
544
+ broadcast(topic, data, compress) {
545
+ const topicSet = this.topics.get(topic);
546
+ if (!topicSet) return;
547
+ for (const peer of topicSet) {
548
+ peer.send(data, compress);
549
+ }
550
+ }
551
+ /**
552
+ * Get the number of subscribers for a topic.
553
+ */
554
+ subscriberCount(topic) {
555
+ return this.topics.get(topic)?.size ?? 0;
556
+ }
557
+ /**
558
+ * Get all topics a peer is subscribed to.
559
+ */
560
+ getTopics(peer) {
561
+ return this.peerTopics.get(peer) ?? /* @__PURE__ */ new Set();
562
+ }
563
+ // ── Dead peer detection ─────────────────────────────────────────────────
564
+ /**
565
+ * Mark a peer as alive.
566
+ *
567
+ * Call from `WsHandler.pong()` on runtimes that support protocol ping/pong
568
+ * (Bun, Node.js, uWS). On Deno, call from `WsHandler.message()` since
569
+ * protocol pong is unavailable and any inbound message signals liveness.
570
+ *
571
+ * No-op if the peer is not subscribed to any topic.
572
+ */
573
+ markAlive(peer) {
574
+ if (this._lastPong.has(peer)) {
575
+ this._lastPong.set(peer, Date.now());
576
+ }
577
+ }
578
+ /**
579
+ * Returns true if the peer was last marked alive within `timeoutMs` ms.
580
+ */
581
+ isPeerAlive(peer, timeoutMs) {
582
+ const last = this._lastPong.get(peer);
583
+ return last !== void 0 && Date.now() - last < timeoutMs;
584
+ }
585
+ /**
586
+ * Close and evict all peers that are either:
587
+ * - Already disconnected (`readyState !== OPEN`), or
588
+ * - Silent for longer than `timeoutMs` milliseconds.
589
+ *
590
+ * Collects dead peers before mutating state to avoid iterator interference.
591
+ * Called by the runtime adapters' `startHeartbeat()` loop, which owns the
592
+ * timer lifecycle — PubSubHub does not manage its own interval timer.
593
+ */
594
+ pruneDeadPeers(timeoutMs) {
595
+ const now = Date.now();
596
+ const dead = [];
597
+ for (const [peer, lastPong] of this._lastPong) {
598
+ if (peer.readyState !== WsReadyState.OPEN || now - lastPong >= timeoutMs) {
599
+ dead.push(peer);
600
+ }
601
+ }
602
+ for (const peer of dead) {
603
+ if (peer.readyState === WsReadyState.OPEN) {
604
+ try {
605
+ peer.close(1001, "ping timeout");
606
+ } catch {
607
+ }
608
+ }
609
+ this.removeAll(peer);
610
+ }
611
+ }
612
+ };
613
+ var WsTopicHub = class {
614
+ /** topic → Set<peer> — O(1) publish iteration */
615
+ _topics = /* @__PURE__ */ new Map();
616
+ /** peer → Map<topic, callback> — O(1) dispatch and cleanup */
617
+ _peerTopics = /* @__PURE__ */ new Map();
618
+ /** peer → last-pong timestamp (ms) — for dead peer detection */
619
+ _lastPong = /* @__PURE__ */ new Map();
620
+ _deadPeerTimer = null;
621
+ // ── Subscription management ───────────────────────────────────────────────
622
+ /**
623
+ * Subscribe a peer to a topic with a per-message callback.
624
+ *
625
+ * The callback is invoked whenever `hub.dispatch(peer, msg)` is called while
626
+ * the peer is subscribed to this topic.
627
+ *
628
+ * A peer may be subscribed to multiple topics simultaneously, each with its
629
+ * own callback. Subsequent calls with the same topic replace the handler.
630
+ *
631
+ * @param peer - The connected WebSocket peer
632
+ * @param topic - Topic name (e.g. 'chat', 'notifications', 'room/42')
633
+ * @param handler - Called with (peer, message) on each dispatched message
634
+ */
635
+ subscribe(peer, topic, handler) {
636
+ let topicSet = this._topics.get(topic);
637
+ if (!topicSet) {
638
+ topicSet = /* @__PURE__ */ new Set();
639
+ this._topics.set(topic, topicSet);
640
+ }
641
+ topicSet.add(peer);
642
+ let peerMap = this._peerTopics.get(peer);
643
+ if (!peerMap) {
644
+ peerMap = /* @__PURE__ */ new Map();
645
+ this._peerTopics.set(peer, peerMap);
646
+ }
647
+ peerMap.set(topic, handler);
648
+ if (!this._lastPong.has(peer)) {
649
+ this._lastPong.set(peer, Date.now());
650
+ }
651
+ }
652
+ /**
653
+ * Unsubscribe a peer from a specific topic.
654
+ * The peer remains subscribed to any other topics.
655
+ */
656
+ unsubscribe(peer, topic) {
657
+ const topicSet = this._topics.get(topic);
658
+ if (topicSet) {
659
+ topicSet.delete(peer);
660
+ if (topicSet.size === 0) this._topics.delete(topic);
661
+ }
662
+ const peerMap = this._peerTopics.get(peer);
663
+ if (peerMap) {
664
+ peerMap.delete(topic);
665
+ if (peerMap.size === 0) {
666
+ this._peerTopics.delete(peer);
667
+ this._lastPong.delete(peer);
668
+ }
669
+ }
670
+ }
671
+ /**
672
+ * Remove a peer from ALL topics and clean up liveness state.
673
+ * Call this from `WsHandler.close()` to prevent memory leaks.
674
+ */
675
+ leave(peer) {
676
+ const peerMap = this._peerTopics.get(peer);
677
+ if (!peerMap) return;
678
+ for (const topic of peerMap.keys()) {
679
+ const topicSet = this._topics.get(topic);
680
+ if (topicSet) {
681
+ topicSet.delete(peer);
682
+ if (topicSet.size === 0) this._topics.delete(topic);
683
+ }
684
+ }
685
+ this._peerTopics.delete(peer);
686
+ this._lastPong.delete(peer);
687
+ }
688
+ // ── Message routing ───────────────────────────────────────────────────────
689
+ /**
690
+ * Route an incoming message from a peer to all its registered topic handlers.
691
+ * Call this from `WsHandler.message()`.
692
+ *
693
+ * If the peer is subscribed to multiple topics, the message is dispatched
694
+ * to each topic's callback in insertion order.
695
+ */
696
+ dispatch(peer, msg) {
697
+ const peerMap = this._peerTopics.get(peer);
698
+ if (!peerMap) return;
699
+ for (const handler of peerMap.values()) {
700
+ try {
701
+ handler(peer, msg);
702
+ } catch {
703
+ }
704
+ }
705
+ }
706
+ // ── Broadcasting ──────────────────────────────────────────────────────────
707
+ /**
708
+ * Broadcast a message to ALL subscribers of a topic, including the sender.
709
+ *
710
+ * For "exclude self" semantics (the sender does not receive their own message),
711
+ * use `peer.publish(topic, data)` instead — which on Bun and uWS routes through
712
+ * the native C++ pub/sub engine.
713
+ */
714
+ publish(topic, data, compress) {
715
+ const topicSet = this._topics.get(topic);
716
+ if (!topicSet) return;
717
+ for (const peer of topicSet) {
718
+ peer.send(data, compress);
719
+ }
720
+ }
721
+ // ── Dead peer detection ───────────────────────────────────────────────────
722
+ /**
723
+ * Mark a peer as alive. Call from `WsHandler.pong()`.
724
+ *
725
+ * Works with native runtime protocol ping/pong (Bun, Node, uWS).
726
+ * On Deno, calling this has no effect as `handler.pong` is never triggered.
727
+ */
728
+ markAlive(peer) {
729
+ if (this._lastPong.has(peer)) {
730
+ this._lastPong.set(peer, Date.now());
731
+ }
732
+ }
733
+ /**
734
+ * Returns true if the peer last responded within `timeoutMs` milliseconds.
735
+ */
736
+ isPeerAlive(peer, timeoutMs) {
737
+ const last = this._lastPong.get(peer);
738
+ return last !== void 0 && Date.now() - last < timeoutMs;
739
+ }
740
+ /**
741
+ * Close and remove all peers that have not called `markAlive()` within `timeoutMs`.
742
+ * Recommended value: `(pingInterval + pongTimeout) * 1000`.
743
+ */
744
+ pruneDeadPeers(timeoutMs) {
745
+ const now = Date.now();
746
+ for (const [peer, lastPong] of this._lastPong) {
747
+ if (now - lastPong >= timeoutMs) {
748
+ try {
749
+ peer.close(1001, "ping timeout");
750
+ } catch {
751
+ }
752
+ this.leave(peer);
753
+ }
754
+ }
755
+ }
756
+ /**
757
+ * Start automatic dead peer detection at a fixed interval.
758
+ *
759
+ * @param checkIntervalMs - How often to scan for dead peers (ms)
760
+ * @param timeoutMs - Peers unseen longer than this are closed and removed
761
+ *
762
+ * @example
763
+ * ```ts
764
+ * // Check every 5 seconds; close peers silent for 2 minutes
765
+ * hub.startDeadPeerCheck(5_000, 120_000);
766
+ *
767
+ * // Integrate with WsOptions:
768
+ * // { pingInterval: 30, pongTimeout: 10 }
769
+ * // → hub.startDeadPeerCheck(5_000, (30 + 10) * 1000);
770
+ * ```
771
+ */
772
+ startDeadPeerCheck(checkIntervalMs, timeoutMs) {
773
+ this.stopDeadPeerCheck();
774
+ this._deadPeerTimer = setInterval(() => {
775
+ this.pruneDeadPeers(timeoutMs);
776
+ }, checkIntervalMs);
777
+ }
778
+ /**
779
+ * Stop the automatic dead peer check started by `startDeadPeerCheck()`.
780
+ */
781
+ stopDeadPeerCheck() {
782
+ if (this._deadPeerTimer !== null) {
783
+ clearInterval(this._deadPeerTimer);
784
+ this._deadPeerTimer = null;
785
+ }
786
+ }
787
+ // ── Introspection ─────────────────────────────────────────────────────────
788
+ /** Number of peers currently subscribed to a topic. */
789
+ subscriberCount(topic) {
790
+ return this._topics.get(topic)?.size ?? 0;
791
+ }
792
+ /** Returns true if a peer is subscribed to the given topic. */
793
+ isSubscribed(peer, topic) {
794
+ return this._topics.get(topic)?.has(peer) ?? false;
795
+ }
796
+ /** Returns an array of all active topic names. */
797
+ topicNames() {
798
+ return [...this._topics.keys()];
799
+ }
800
+ };
801
+
802
+ export {
803
+ EMPTY_PARAMS,
804
+ EMPTY_QUERY,
805
+ Router,
806
+ WsReadyState,
807
+ WS_DEFAULTS,
808
+ defaultErrorHandler,
809
+ createExecutor,
810
+ createNotFoundExecutor,
811
+ toResponse,
812
+ PubSubHub,
813
+ WsTopicHub
814
+ };
815
+ //# sourceMappingURL=chunk-DPU3PBLP.js.map