@holo-js/broadcast 0.1.3

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/dist/index.mjs ADDED
@@ -0,0 +1,1445 @@
1
+ import {
2
+ authorizeBroadcastChannel,
3
+ broadcastAuthInternals,
4
+ parseBroadcastAuthEndpointPayload,
5
+ renderBroadcastAuthResponse,
6
+ resolveBroadcastWhisperSchema,
7
+ validateBroadcastWhisperPayload
8
+ } from "./chunk-5XRABYQH.mjs";
9
+ import {
10
+ broadcast,
11
+ broadcastRaw,
12
+ broadcastRegistryInternals,
13
+ broadcastRuntimeInternals,
14
+ configureBroadcastRuntime,
15
+ getBroadcastRuntime,
16
+ getBroadcastRuntimeBindings,
17
+ getRegisteredBroadcastDriver,
18
+ listRegisteredBroadcastDrivers,
19
+ registerBroadcastDriver,
20
+ resetBroadcastDriverRegistry,
21
+ resetBroadcastRuntime
22
+ } from "./chunk-742LGR5P.mjs";
23
+ import {
24
+ broadcastInternals,
25
+ channel,
26
+ defineBroadcast,
27
+ defineChannel,
28
+ isBroadcastDefinition,
29
+ isChannelDefinition,
30
+ presenceChannel,
31
+ privateChannel
32
+ } from "./chunk-QW6MHEWS.mjs";
33
+
34
+ // src/worker.ts
35
+ import { createHash, createHmac, randomInt, randomUUID } from "crypto";
36
+ import { createServer } from "http";
37
+ var MAX_PUBLISH_TIMESTAMP_SKEW_SECONDS = 300;
38
+ function normalizeRequiredString(value, label) {
39
+ const normalized = value.trim();
40
+ if (!normalized) {
41
+ throw new Error(`[@holo-js/broadcast] ${label} must be a non-empty string.`);
42
+ }
43
+ return normalized;
44
+ }
45
+ function escapeRegExp(value) {
46
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+ }
48
+ function parseJsonObject(value, label) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(value);
52
+ } catch {
53
+ throw new Error(`[@holo-js/broadcast] ${label} must be valid JSON.`);
54
+ }
55
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
56
+ throw new Error(`[@holo-js/broadcast] ${label} must be a JSON object.`);
57
+ }
58
+ return parsed;
59
+ }
60
+ function parseSocketMessage(rawMessage) {
61
+ const message = parseJsonObject(rawMessage, "Websocket message");
62
+ const event = normalizeRequiredString(String(message.event ?? ""), "Websocket event");
63
+ const channel2 = typeof message.channel === "string" ? normalizeRequiredString(message.channel, "Websocket channel") : void 0;
64
+ const data = typeof message.data === "string" ? parseJsonObject(message.data, "Websocket message data") : message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data : {};
65
+ return Object.freeze({
66
+ event,
67
+ ...typeof channel2 === "undefined" ? {} : { channel: channel2 },
68
+ data
69
+ });
70
+ }
71
+ function normalizePublishBody(value) {
72
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
73
+ throw new Error("[@holo-js/broadcast] Publish payload must be a JSON object.");
74
+ }
75
+ const body = value;
76
+ const name = typeof body.name === "string" ? normalizeRequiredString(body.name, "Publish name") : typeof body.event === "string" ? normalizeRequiredString(body.event, "Publish event") : "";
77
+ if (!name) {
78
+ throw new Error("[@holo-js/broadcast] Publish payload must include an event name.");
79
+ }
80
+ const channels = Array.isArray(body.channels) ? body.channels.map((channel2) => {
81
+ if (typeof channel2 !== "string") {
82
+ throw new Error("[@holo-js/broadcast] Publish channel must be a non-empty string.");
83
+ }
84
+ return normalizeRequiredString(channel2, "Publish channel");
85
+ }) : typeof body.channel === "string" ? [normalizeRequiredString(body.channel, "Publish channel")] : [];
86
+ if (channels.length === 0) {
87
+ throw new Error("[@holo-js/broadcast] Publish payload must include at least one channel.");
88
+ }
89
+ const data = typeof body.data === "string" ? body.data : JSON.stringify(body.data ?? {});
90
+ const socketId = typeof body.socket_id === "string" ? normalizeRequiredString(body.socket_id, "Publish socket_id") : void 0;
91
+ return Object.freeze({
92
+ name,
93
+ channels: Object.freeze(channels),
94
+ data,
95
+ ...typeof socketId === "undefined" ? {} : { socket_id: socketId }
96
+ });
97
+ }
98
+ function createPusherSignature(secret, method, pathname, params) {
99
+ const sorted = [...params.entries()].filter(([key]) => key !== "auth_signature").sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
100
+ const payload = `${method.toUpperCase()}
101
+ ${pathname}
102
+ ${sorted}`;
103
+ return createHmac("sha256", secret).update(payload).digest("hex");
104
+ }
105
+ function logSocketMessageError(socketId, error) {
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ console.error(`[@holo-js/broadcast] WebSocket message handling failed for socket "${socketId}": ${message}`);
108
+ }
109
+ function logScalingMessageError(error) {
110
+ const message = error instanceof Error ? error.message : String(error);
111
+ console.error(`[@holo-js/broadcast] Scaling message handling failed: ${message}`);
112
+ }
113
+ function logSocketCleanupError(socketId, channel2, error) {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ console.error(`[@holo-js/broadcast] Socket cleanup failed for socket "${socketId}" on "${channel2}": ${message}`);
116
+ }
117
+ function parseChannelKind(channel2) {
118
+ if (channel2.startsWith("private-")) {
119
+ return Object.freeze({
120
+ kind: "private",
121
+ canonical: channel2.slice("private-".length)
122
+ });
123
+ }
124
+ if (channel2.startsWith("presence-")) {
125
+ return Object.freeze({
126
+ kind: "presence",
127
+ canonical: channel2.slice("presence-".length)
128
+ });
129
+ }
130
+ return Object.freeze({
131
+ kind: "public",
132
+ canonical: channel2
133
+ });
134
+ }
135
+ function createSocketId() {
136
+ return `${randomInt(1, 999999)}.${randomInt(1, 999999)}`;
137
+ }
138
+ function createScalingNodeId() {
139
+ return normalizeRequiredString(`${process.env.HOSTNAME ?? "node"}-${randomUUID()}`, "Broadcast worker node id");
140
+ }
141
+ function resolveScalingEventChannel(connection) {
142
+ return `holo:broadcast:scaling:${connection}:events`;
143
+ }
144
+ function resolvePresenceHashKey(connection, appId, channel2) {
145
+ return `holo:broadcast:scaling:${connection}:presence:${appId}:${channel2}`;
146
+ }
147
+ function createNodeSocketRef(nodeId, socketId) {
148
+ return `${nodeId}:${socketId}`;
149
+ }
150
+ function composeSubscriptionKey(appId, channel2) {
151
+ return `${appId}:${channel2}`;
152
+ }
153
+ function resolvePresenceMemberId(member, fallback) {
154
+ const candidate = member.id;
155
+ if (typeof candidate === "string" || typeof candidate === "number") {
156
+ return String(candidate);
157
+ }
158
+ return fallback;
159
+ }
160
+ function serializePresenceMemberRemoved(member, fallback) {
161
+ return JSON.stringify({
162
+ user_id: resolvePresenceMemberId(member, fallback)
163
+ });
164
+ }
165
+ function normalizePresenceMemberMessage(value, label) {
166
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
167
+ throw new Error(`[@holo-js/broadcast] ${label} must be a JSON object.`);
168
+ }
169
+ return Object.freeze(value);
170
+ }
171
+ function parsePresenceHashMembers(values) {
172
+ const members = /* @__PURE__ */ new Map();
173
+ for (const [socketRef, encoded] of Object.entries(values)) {
174
+ try {
175
+ const parsed = parseJsonObject(encoded, `Presence member "${socketRef}"`);
176
+ members.set(socketRef, Object.freeze(parsed));
177
+ } catch {
178
+ members.set(socketRef, Object.freeze({ id: socketRef }));
179
+ }
180
+ }
181
+ return members;
182
+ }
183
+ function resolveRedisScalingConnection(queueConfig, connectionName, redisConfig) {
184
+ const queueConnection = queueConfig?.connections[connectionName];
185
+ if (queueConnection?.driver === "redis") {
186
+ return Object.freeze({
187
+ ...typeof queueConnection.redis.url === "undefined" ? {} : { url: queueConnection.redis.url },
188
+ ...typeof queueConnection.redis.clusters === "undefined" ? {} : { clusters: queueConnection.redis.clusters },
189
+ host: queueConnection.redis.host,
190
+ port: queueConnection.redis.port,
191
+ username: queueConnection.redis.username,
192
+ password: queueConnection.redis.password,
193
+ db: queueConnection.redis.db
194
+ });
195
+ }
196
+ const redisConnection = redisConfig?.connections[connectionName];
197
+ if (redisConnection) {
198
+ return Object.freeze({
199
+ ...typeof redisConnection.url === "undefined" ? {} : { url: redisConnection.url },
200
+ ...typeof redisConnection.clusters === "undefined" ? {} : { clusters: redisConnection.clusters },
201
+ host: redisConnection.host,
202
+ port: redisConnection.port,
203
+ username: redisConnection.username,
204
+ password: redisConnection.password,
205
+ db: redisConnection.db
206
+ });
207
+ }
208
+ if (queueConnection) {
209
+ throw new Error(
210
+ `[@holo-js/broadcast] Broadcast scaling connection "${connectionName}" must use the Redis queue driver.`
211
+ );
212
+ }
213
+ if (redisConfig && typeof redisConfig.connections === "object") {
214
+ const availableConnections = Object.keys(redisConfig.connections);
215
+ throw new Error(
216
+ `[@holo-js/broadcast] Broadcast scaling connection "${connectionName}" was not found in top-level redis connections. Available redis connections: ${availableConnections.join(", ") || "(none)"}.`
217
+ );
218
+ }
219
+ if (!queueConfig) {
220
+ throw new Error("[@holo-js/broadcast] Broadcast scaling requires either redis config or a Redis queue connection so the Redis connection can be resolved.");
221
+ }
222
+ throw new Error(`[@holo-js/broadcast] Broadcast scaling connection "${connectionName}" was not found in queue connections.`);
223
+ }
224
+ function resolveScalingConnectionName(connectionName, queueConfig, redisConfig) {
225
+ const queueDefaultConnection = queueConfig ? queueConfig.connections[queueConfig.default] : void 0;
226
+ if (connectionName !== "default") {
227
+ return connectionName;
228
+ }
229
+ if (queueDefaultConnection?.driver === "redis") {
230
+ return queueConfig.default;
231
+ }
232
+ return redisConfig?.default ?? connectionName;
233
+ }
234
+ function isRedisSocketConnectionTarget(value) {
235
+ return value.startsWith("unix://") || value.startsWith("/");
236
+ }
237
+ function toRedisSocketPath(value) {
238
+ return value.startsWith("unix://") ? value.slice("unix://".length) : value;
239
+ }
240
+ function parseRedisClusterNodeUrl(url, label) {
241
+ try {
242
+ const parsed = new URL(url);
243
+ if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
244
+ throw new Error(`unsupported protocol "${parsed.protocol}"`);
245
+ }
246
+ if (!parsed.hostname) {
247
+ throw new Error("missing hostname");
248
+ }
249
+ return {
250
+ host: parsed.hostname,
251
+ port: parsed.port ? Number.parseInt(parsed.port, 10) : 6379,
252
+ ...parsed.protocol === "rediss:" ? { tls: {} } : {}
253
+ };
254
+ } catch (error) {
255
+ throw new Error(`[@holo-js/broadcast] ${label} is invalid: ${error instanceof Error ? error.message : String(error)}`);
256
+ }
257
+ }
258
+ function resolveRedisClusterStartupNodes(clusters) {
259
+ return clusters.map((node, index) => {
260
+ const label = `Broadcast scaling cluster node ${index + 1}`;
261
+ if (typeof node.url === "string") {
262
+ return parseRedisClusterNodeUrl(node.url, `${label} url`);
263
+ }
264
+ if (isRedisSocketConnectionTarget(node.host)) {
265
+ throw new Error(`[@holo-js/broadcast] ${label} cannot use a Unix socket path in Redis cluster mode.`);
266
+ }
267
+ return {
268
+ host: node.host,
269
+ port: node.port
270
+ };
271
+ });
272
+ }
273
+ async function loadRedisScalingModule(loadModule) {
274
+ try {
275
+ const loadDefaultModule = async () => {
276
+ const specifier = "ioredis";
277
+ return await import(
278
+ /* webpackIgnore: true */
279
+ specifier
280
+ );
281
+ };
282
+ const loaded = await (loadModule ? loadModule() : loadDefaultModule());
283
+ if (!loaded || typeof loaded !== "object" || typeof loaded.default !== "function") {
284
+ throw new Error("missing default Redis export");
285
+ }
286
+ return loaded;
287
+ } catch (error) {
288
+ const code = error && typeof error === "object" ? error.code : void 0;
289
+ const message = error instanceof Error ? error.message : String(error);
290
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find module|Failed to resolve module/i.test(message)) {
291
+ throw new Error(
292
+ '[@holo-js/broadcast] Redis scaling requires the "ioredis" package. Install it in your project dependencies to enable worker scaling.'
293
+ );
294
+ }
295
+ throw error;
296
+ }
297
+ }
298
+ async function createRedisScalingAdapter(connection, dependencies = {}) {
299
+ if (connection.clusters && connection.clusters.length > 0 && connection.db !== 0) {
300
+ throw new Error("[@holo-js/broadcast] Redis Cluster does not support selecting a non-zero database. Remove redis.db or set it to 0.");
301
+ }
302
+ const redisModule = await loadRedisScalingModule(dependencies.loadRedisModule);
303
+ const RedisCtor = redisModule.default;
304
+ const createClient = () => {
305
+ if (connection.clusters && connection.clusters.length > 0) {
306
+ const startupNodes = resolveRedisClusterStartupNodes(connection.clusters);
307
+ return new redisModule.Cluster(
308
+ startupNodes,
309
+ {
310
+ redisOptions: {
311
+ username: connection.username,
312
+ password: connection.password,
313
+ db: connection.db,
314
+ ...startupNodes.some((node) => typeof node.tls !== "undefined") ? { tls: {} } : {}
315
+ }
316
+ }
317
+ );
318
+ }
319
+ if (typeof connection.url === "string") {
320
+ return new RedisCtor(connection.url, {
321
+ username: connection.username,
322
+ password: connection.password,
323
+ db: connection.db
324
+ });
325
+ }
326
+ return new RedisCtor(
327
+ isRedisSocketConnectionTarget(connection.host) ? {
328
+ path: toRedisSocketPath(connection.host),
329
+ username: connection.username,
330
+ password: connection.password,
331
+ db: connection.db
332
+ } : {
333
+ host: connection.host,
334
+ port: connection.port,
335
+ username: connection.username,
336
+ password: connection.password,
337
+ db: connection.db
338
+ }
339
+ );
340
+ };
341
+ const commandClient = createClient();
342
+ const subscriberClient = createClient();
343
+ const listeners = /* @__PURE__ */ new Map();
344
+ return Object.freeze({
345
+ async publish(channel2, payload) {
346
+ await commandClient.publish(channel2, payload);
347
+ },
348
+ async subscribe(channel2, onMessage) {
349
+ const listener = (incomingChannel, payload) => {
350
+ if (incomingChannel === channel2) {
351
+ onMessage(payload);
352
+ }
353
+ };
354
+ listeners.set(channel2, listener);
355
+ subscriberClient.on("message", listener);
356
+ await subscriberClient.subscribe(channel2);
357
+ return async () => {
358
+ const existing = listeners.get(channel2);
359
+ if (existing) {
360
+ await subscriberClient.unsubscribe(channel2);
361
+ subscriberClient.off("message", existing);
362
+ listeners.delete(channel2);
363
+ }
364
+ };
365
+ },
366
+ async hashSet(key, field, value) {
367
+ await commandClient.hset(key, field, value);
368
+ },
369
+ async hashDelete(key, field) {
370
+ await commandClient.hdel(key, field);
371
+ },
372
+ async hashGetAll(key) {
373
+ return Object.freeze(await commandClient.hgetall(key));
374
+ },
375
+ async close() {
376
+ try {
377
+ await subscriberClient.quit();
378
+ } catch {
379
+ subscriberClient.disconnect();
380
+ }
381
+ try {
382
+ await commandClient.quit();
383
+ } catch {
384
+ commandClient.disconnect();
385
+ }
386
+ }
387
+ });
388
+ }
389
+ function buildWorkerApps(config) {
390
+ const appsByKey = /* @__PURE__ */ new Map();
391
+ for (const [name, connection] of Object.entries(config.connections)) {
392
+ if (connection.driver !== "holo" || !("key" in connection) || !("secret" in connection) || !("appId" in connection)) {
393
+ continue;
394
+ }
395
+ const holoConnection = connection;
396
+ const authEndpoint = typeof holoConnection.clientOptions.authEndpoint === "string" ? normalizeRequiredString(holoConnection.clientOptions.authEndpoint, `Broadcast connection "${name}" authEndpoint`) : void 0;
397
+ if (appsByKey.has(holoConnection.key)) {
398
+ throw new Error(`[@holo-js/broadcast] duplicate broadcast app key "${holoConnection.key}" is already configured.`);
399
+ }
400
+ appsByKey.set(holoConnection.key, Object.freeze({
401
+ connection: name,
402
+ appId: holoConnection.appId,
403
+ key: holoConnection.key,
404
+ secret: holoConnection.secret,
405
+ ...typeof authEndpoint === "undefined" ? {} : { authEndpoint }
406
+ }));
407
+ }
408
+ if (appsByKey.size === 0) {
409
+ throw new Error('[@holo-js/broadcast] Broadcast worker requires at least one "holo" broadcast connection.');
410
+ }
411
+ return Object.freeze(Object.fromEntries(appsByKey));
412
+ }
413
+ function pusherEvent(event, data, channel2) {
414
+ return JSON.stringify({
415
+ event,
416
+ ...typeof channel2 === "undefined" ? {} : { channel: channel2 },
417
+ data: typeof data === "string" ? data : JSON.stringify(data)
418
+ });
419
+ }
420
+ async function authenticateSubscription(app, connection, channel2, channelAuth, fetcher) {
421
+ const { kind, canonical } = parseChannelKind(channel2);
422
+ if (kind === "public") {
423
+ return Object.freeze({
424
+ whispers: Object.freeze([])
425
+ });
426
+ }
427
+ if (app.authEndpoint && fetcher) {
428
+ const authRequest = new Request(app.authEndpoint, {
429
+ method: "POST",
430
+ headers: Object.fromEntries(
431
+ [...connection.headers.entries()].filter(([header]) => {
432
+ const normalized = header.toLowerCase();
433
+ return normalized === "authorization" || normalized === "cookie";
434
+ })
435
+ ),
436
+ body: new URLSearchParams({
437
+ channel_name: canonical,
438
+ socket_id: connection.socketId
439
+ })
440
+ });
441
+ const response = await fetcher(authRequest);
442
+ if (!response.ok) {
443
+ throw new Error(`[@holo-js/broadcast] Channel authorization rejected (${response.status}).`);
444
+ }
445
+ const body = await response.json();
446
+ const whispers = Array.isArray(body.whispers) ? Object.freeze(body.whispers.map((value) => normalizeRequiredString(String(value), "Auth whisper"))) : Object.freeze([]);
447
+ const member = body.member && typeof body.member === "object" && !Array.isArray(body.member) ? Object.freeze(body.member) : void 0;
448
+ return Object.freeze({
449
+ whispers,
450
+ ...typeof member === "undefined" ? {} : { member }
451
+ });
452
+ }
453
+ const resolvedUser = typeof channelAuth?.resolveUser === "function" ? await channelAuth.resolveUser({
454
+ headers: connection.headers,
455
+ socketId: connection.socketId,
456
+ channel: canonical,
457
+ appId: app.appId,
458
+ connection: app.connection
459
+ }) : null;
460
+ const authorized = await authorizeBroadcastChannel({
461
+ channel: canonical,
462
+ socketId: connection.socketId,
463
+ user: resolvedUser ?? null
464
+ }, channelAuth);
465
+ if (!authorized.ok) {
466
+ throw new Error(`[@holo-js/broadcast] Channel authorization denied for "${channel2}".`);
467
+ }
468
+ return Object.freeze({
469
+ whispers: authorized.whispers,
470
+ /* v8 ignore next -- exercised in integration tests for private and presence channel auth; expression is kept for TS narrowing. */
471
+ ...authorized.type === "presence" ? { member: authorized.member } : {}
472
+ });
473
+ }
474
+ function createBroadcastWorkerRuntime(options) {
475
+ const appsByKey = buildWorkerApps(options.config);
476
+ const connectedSockets = /* @__PURE__ */ new Map();
477
+ const channels = /* @__PURE__ */ new Map();
478
+ const channelWhispers = /* @__PURE__ */ new Map();
479
+ const presenceMembers = /* @__PURE__ */ new Map();
480
+ const presenceSockets = /* @__PURE__ */ new Map();
481
+ const scaling = options.scaling;
482
+ const startedAt = options.now?.() ?? Date.now();
483
+ const scalingUnsubscribe = options.scalingUnsubscribe ? Promise.resolve(options.scalingUnsubscribe) : scaling && options.scalingAutoSubscribe !== false ? scaling.adapter.subscribe(scaling.eventChannel, (payload) => {
484
+ void handleScalingMessage(payload).catch((error) => {
485
+ logScalingMessageError(error);
486
+ });
487
+ }) : Promise.resolve(async () => {
488
+ });
489
+ function createPresenceSocketRef(channel2, socketId) {
490
+ return scaling && parseChannelKind(channel2).kind === "presence" ? createNodeSocketRef(scaling.nodeId, socketId) : socketId;
491
+ }
492
+ function setPresenceState(key, socketMembers) {
493
+ if (socketMembers.size === 0) {
494
+ presenceMembers.delete(key);
495
+ presenceSockets.delete(key);
496
+ return /* @__PURE__ */ new Map();
497
+ }
498
+ const roster = /* @__PURE__ */ new Map();
499
+ const memberSockets = /* @__PURE__ */ new Map();
500
+ for (const [socketRef, member] of socketMembers) {
501
+ const memberId = resolvePresenceMemberId(member, socketRef);
502
+ memberSockets.set(socketRef, memberId);
503
+ if (!roster.has(memberId)) {
504
+ roster.set(memberId, member);
505
+ }
506
+ }
507
+ presenceMembers.set(key, roster);
508
+ presenceSockets.set(key, memberSockets);
509
+ return roster;
510
+ }
511
+ function getPresenceRosterPayload(key) {
512
+ const roster = presenceMembers.get(key) ?? /* @__PURE__ */ new Map();
513
+ const ids = Object.freeze([...roster.keys()]);
514
+ return Object.freeze({
515
+ ids,
516
+ hash: Object.freeze(Object.fromEntries(roster.entries())),
517
+ count: ids.length
518
+ });
519
+ }
520
+ async function removePresenceMemberFromScaling(app, socketId, channel2) {
521
+ if (!scaling) {
522
+ return;
523
+ }
524
+ const { kind } = parseChannelKind(channel2);
525
+ if (kind !== "presence") {
526
+ return;
527
+ }
528
+ await scaling.adapter.hashDelete(
529
+ resolvePresenceHashKey(scaling.connection, app.appId, channel2),
530
+ createNodeSocketRef(scaling.nodeId, socketId)
531
+ );
532
+ }
533
+ function removeSubscriptionLocal(appId, socketId, channel2) {
534
+ const key = composeSubscriptionKey(appId, channel2);
535
+ const sockets = channels.get(key);
536
+ if (sockets) {
537
+ sockets.delete(socketId);
538
+ if (sockets.size === 0) {
539
+ channels.delete(key);
540
+ }
541
+ }
542
+ let removedPresenceMember;
543
+ let removed = false;
544
+ const roster = presenceMembers.get(key);
545
+ const memberSockets = presenceSockets.get(key);
546
+ if (roster && memberSockets) {
547
+ const presenceSocketRef = createPresenceSocketRef(channel2, socketId);
548
+ const memberId = memberSockets.get(presenceSocketRef);
549
+ if (memberId) {
550
+ memberSockets.delete(presenceSocketRef);
551
+ if (memberSockets.size === 0) {
552
+ presenceSockets.delete(key);
553
+ }
554
+ if (![...memberSockets.values()].includes(memberId)) {
555
+ removedPresenceMember = roster.get(memberId);
556
+ removed = typeof removedPresenceMember !== "undefined";
557
+ roster.delete(memberId);
558
+ if (roster.size === 0) {
559
+ presenceMembers.delete(key);
560
+ }
561
+ }
562
+ }
563
+ }
564
+ const whispersBySocket = channelWhispers.get(key);
565
+ if (whispersBySocket) {
566
+ whispersBySocket.delete(socketId);
567
+ if (whispersBySocket.size === 0) {
568
+ channelWhispers.delete(key);
569
+ }
570
+ }
571
+ return Object.freeze({
572
+ ...typeof removedPresenceMember === "undefined" ? {} : { member: removedPresenceMember },
573
+ removed
574
+ });
575
+ }
576
+ async function synchronizePresenceChannel(app, channel2, member, socketId) {
577
+ const key = composeSubscriptionKey(app.appId, channel2);
578
+ const socketRef = createPresenceSocketRef(channel2, socketId);
579
+ const memberId = resolvePresenceMemberId(member, socketRef);
580
+ if (!scaling) {
581
+ const roster2 = presenceMembers.get(key) ?? /* @__PURE__ */ new Map();
582
+ const memberSockets = presenceSockets.get(key) ?? /* @__PURE__ */ new Map();
583
+ const isNewMember = !roster2.has(memberId);
584
+ memberSockets.set(socketRef, memberId);
585
+ roster2.set(memberId, member);
586
+ presenceSockets.set(key, memberSockets);
587
+ presenceMembers.set(key, roster2);
588
+ return Object.freeze({
589
+ roster: roster2,
590
+ isNewMember
591
+ });
592
+ }
593
+ const presenceKey = resolvePresenceHashKey(scaling.connection, app.appId, channel2);
594
+ await scaling.adapter.hashSet(
595
+ presenceKey,
596
+ socketRef,
597
+ JSON.stringify(member)
598
+ );
599
+ const merged = parsePresenceHashMembers(await scaling.adapter.hashGetAll(presenceKey));
600
+ const hasExistingMember = [...merged.entries()].some(([existingSocketRef, existingMember]) => {
601
+ return existingSocketRef !== socketRef && resolvePresenceMemberId(existingMember, existingSocketRef) === memberId;
602
+ });
603
+ const roster = setPresenceState(key, merged);
604
+ return Object.freeze({
605
+ roster,
606
+ isNewMember: !hasExistingMember
607
+ });
608
+ }
609
+ function deliverEventLocal(appId, channel2, event, data, excludeSocketId) {
610
+ const sockets = channels.get(composeSubscriptionKey(appId, channel2));
611
+ if (!sockets || sockets.size === 0) {
612
+ return Object.freeze({
613
+ deliveredChannels: Object.freeze([]),
614
+ deliveredSockets: 0
615
+ });
616
+ }
617
+ let deliveredSockets = 0;
618
+ for (const socketId of sockets) {
619
+ if (socketId === excludeSocketId) {
620
+ continue;
621
+ }
622
+ const socket = connectedSockets.get(socketId);
623
+ if (!socket) {
624
+ continue;
625
+ }
626
+ socket.send(pusherEvent(event, data, channel2));
627
+ deliveredSockets += 1;
628
+ }
629
+ return Object.freeze({
630
+ deliveredChannels: Object.freeze([channel2]),
631
+ deliveredSockets
632
+ });
633
+ }
634
+ function deliverPresenceMemberAddedLocal(appId, channel2, member, excludeSocketId) {
635
+ deliverEventLocal(
636
+ appId,
637
+ channel2,
638
+ "pusher_internal:member_added",
639
+ JSON.stringify(member),
640
+ excludeSocketId
641
+ );
642
+ }
643
+ function deliverPresenceMemberRemovedLocal(appId, channel2, member, excludeSocketId, fallbackSocketId) {
644
+ deliverEventLocal(
645
+ appId,
646
+ channel2,
647
+ "pusher_internal:member_removed",
648
+ serializePresenceMemberRemoved(member, fallbackSocketId ?? excludeSocketId),
649
+ excludeSocketId
650
+ );
651
+ }
652
+ async function publishScalingEvent(body) {
653
+ if (!scaling) {
654
+ return;
655
+ }
656
+ await scaling.adapter.publish(scaling.eventChannel, JSON.stringify({
657
+ type: "event",
658
+ originNodeId: scaling.nodeId,
659
+ appId: body.appId,
660
+ name: body.name,
661
+ channels: body.channels,
662
+ data: body.data,
663
+ ...typeof body.socket_id === "undefined" ? {} : { socketId: body.socket_id }
664
+ }));
665
+ }
666
+ async function publishScalingPresenceMemberAdded(app, channel2, socketId, member) {
667
+ if (!scaling) {
668
+ return;
669
+ }
670
+ await scaling.adapter.publish(scaling.eventChannel, JSON.stringify({
671
+ type: "presence-member-added",
672
+ originNodeId: scaling.nodeId,
673
+ appId: app.appId,
674
+ channel: channel2,
675
+ socketId,
676
+ member
677
+ }));
678
+ }
679
+ async function publishScalingPresenceMemberRemoved(app, channel2, socketId, member) {
680
+ if (!scaling) {
681
+ return;
682
+ }
683
+ await scaling.adapter.publish(scaling.eventChannel, JSON.stringify({
684
+ type: "presence-member-removed",
685
+ originNodeId: scaling.nodeId,
686
+ appId: app.appId,
687
+ channel: channel2,
688
+ socketId,
689
+ member
690
+ }));
691
+ }
692
+ async function handleScalingMessage(payload) {
693
+ const message = parseJsonObject(payload, "Scaling event payload");
694
+ if (message.originNodeId === scaling?.nodeId) {
695
+ return;
696
+ }
697
+ if (message.type === "event") {
698
+ if (typeof message.name !== "string" || !Array.isArray(message.channels) || typeof message.data !== "string" || typeof message.appId !== "string") {
699
+ return;
700
+ }
701
+ const socketId = typeof message.socketId === "string" ? message.socketId : typeof message.socket_id === "string" ? message.socket_id : void 0;
702
+ for (const channel2 of message.channels) {
703
+ if (typeof channel2 !== "string") {
704
+ continue;
705
+ }
706
+ deliverEventLocal(
707
+ message.appId,
708
+ channel2,
709
+ message.name,
710
+ message.data,
711
+ socketId
712
+ );
713
+ }
714
+ return;
715
+ }
716
+ if (message.type === "presence-member-added" && typeof message.originNodeId === "string" && typeof message.appId === "string" && typeof message.channel === "string" && typeof message.socketId === "string") {
717
+ const key = composeSubscriptionKey(message.appId, message.channel);
718
+ const roster = presenceMembers.get(key) ?? /* @__PURE__ */ new Map();
719
+ const memberSockets = presenceSockets.get(key) ?? /* @__PURE__ */ new Map();
720
+ const member = normalizePresenceMemberMessage(message.member, "Scaling presence member");
721
+ const socketRef = createNodeSocketRef(message.originNodeId, message.socketId);
722
+ const memberId = resolvePresenceMemberId(member, socketRef);
723
+ const isNewMember = !roster.has(memberId);
724
+ memberSockets.set(socketRef, memberId);
725
+ roster.set(memberId, member);
726
+ presenceSockets.set(key, memberSockets);
727
+ presenceMembers.set(key, roster);
728
+ if (isNewMember) {
729
+ deliverPresenceMemberAddedLocal(message.appId, message.channel, member);
730
+ }
731
+ return;
732
+ }
733
+ if (message.type === "presence-member-removed" && typeof message.originNodeId === "string" && typeof message.appId === "string" && typeof message.channel === "string" && typeof message.socketId === "string") {
734
+ const member = normalizePresenceMemberMessage(message.member, "Scaling presence member");
735
+ const key = composeSubscriptionKey(message.appId, message.channel);
736
+ const roster = presenceMembers.get(key);
737
+ const memberSockets = presenceSockets.get(key);
738
+ const socketRef = createNodeSocketRef(message.originNodeId, message.socketId);
739
+ const memberId = memberSockets?.get(socketRef) ?? resolvePresenceMemberId(member, socketRef);
740
+ if (memberSockets) {
741
+ memberSockets.delete(socketRef);
742
+ if (memberSockets.size === 0) {
743
+ presenceSockets.delete(key);
744
+ }
745
+ }
746
+ if (roster && !memberSockets?.has(socketRef)) {
747
+ if (!(memberSockets && [...memberSockets.values()].includes(memberId))) {
748
+ roster.delete(memberId);
749
+ if (roster.size === 0) {
750
+ presenceMembers.delete(key);
751
+ }
752
+ deliverPresenceMemberRemovedLocal(
753
+ message.appId,
754
+ message.channel,
755
+ member,
756
+ void 0,
757
+ memberId
758
+ );
759
+ }
760
+ }
761
+ }
762
+ }
763
+ async function handleSubscribe(socket, rawChannel) {
764
+ const channel2 = normalizeRequiredString(rawChannel, "Subscription channel");
765
+ const authorization = await authenticateSubscription(socket.app, socket, channel2, options.channelAuth, options.fetch);
766
+ if (!socket.active || connectedSockets.get(socket.socketId) !== socket) {
767
+ return;
768
+ }
769
+ socket.subscribedChannels.add(channel2);
770
+ const key = composeSubscriptionKey(socket.app.appId, channel2);
771
+ channels.set(key, /* @__PURE__ */ new Set([...channels.get(key) ?? [], socket.socketId]));
772
+ if (authorization.whispers.length > 0) {
773
+ const whispersBySocket = channelWhispers.get(key) ?? /* @__PURE__ */ new Map();
774
+ whispersBySocket.set(socket.socketId, new Set(authorization.whispers));
775
+ channelWhispers.set(key, whispersBySocket);
776
+ } else {
777
+ const whispersBySocket = channelWhispers.get(key);
778
+ whispersBySocket?.delete(socket.socketId);
779
+ if (whispersBySocket && whispersBySocket.size === 0) {
780
+ channelWhispers.delete(key);
781
+ }
782
+ }
783
+ const { kind } = parseChannelKind(channel2);
784
+ if (kind === "presence") {
785
+ const member = authorization.member ?? Object.freeze({ id: socket.socketId });
786
+ const synchronized = await synchronizePresenceChannel(
787
+ socket.app,
788
+ channel2,
789
+ member,
790
+ socket.socketId
791
+ );
792
+ if (synchronized.isNewMember) {
793
+ deliverPresenceMemberAddedLocal(socket.app.appId, channel2, member, socket.socketId);
794
+ await publishScalingPresenceMemberAdded(socket.app, channel2, socket.socketId, member);
795
+ }
796
+ const presence = getPresenceRosterPayload(key);
797
+ socket.send(pusherEvent("pusher_internal:subscription_succeeded", {
798
+ presence
799
+ }, channel2));
800
+ return;
801
+ }
802
+ socket.send(pusherEvent("pusher_internal:subscription_succeeded", {}, channel2));
803
+ }
804
+ async function handleUnsubscribe(socket, rawChannel) {
805
+ const channel2 = normalizeRequiredString(rawChannel, "Unsubscribe channel");
806
+ socket.subscribedChannels.delete(channel2);
807
+ const removedPresenceMember = removeSubscriptionLocal(socket.app.appId, socket.socketId, channel2);
808
+ if (removedPresenceMember.removed && removedPresenceMember.member) {
809
+ deliverPresenceMemberRemovedLocal(socket.app.appId, channel2, removedPresenceMember.member, socket.socketId);
810
+ }
811
+ await removePresenceMemberFromScaling(socket.app, socket.socketId, channel2);
812
+ if (removedPresenceMember.removed && removedPresenceMember.member) {
813
+ await publishScalingPresenceMemberRemoved(socket.app, channel2, socket.socketId, removedPresenceMember.member);
814
+ }
815
+ socket.send(pusherEvent("pusher_internal:unsubscribed", {}, channel2));
816
+ }
817
+ async function handleClientEvent(socket, message) {
818
+ const channel2 = normalizeRequiredString(message.channel ?? "", "Whisper channel");
819
+ if (!socket.subscribedChannels.has(channel2)) {
820
+ throw new Error(`[@holo-js/broadcast] Socket is not subscribed to "${channel2}".`);
821
+ }
822
+ const { kind, canonical } = parseChannelKind(channel2);
823
+ if (kind === "public") {
824
+ throw new Error("[@holo-js/broadcast] Client events are only allowed on private or presence channels.");
825
+ }
826
+ const whisperName = message.event.replace(/^client-/, "");
827
+ const allowedWhispers = channelWhispers.get(composeSubscriptionKey(socket.app.appId, channel2))?.get(socket.socketId);
828
+ if (!allowedWhispers || !allowedWhispers.has(whisperName)) {
829
+ throw new Error(`[@holo-js/broadcast] Whisper "${whisperName}" is not allowed for "${channel2}".`);
830
+ }
831
+ if (options.channelAuth) {
832
+ await validateBroadcastWhisperPayload(canonical, whisperName, message.data, options.channelAuth);
833
+ }
834
+ const payload = Object.freeze({
835
+ name: message.event,
836
+ channels: Object.freeze([channel2]),
837
+ data: JSON.stringify(message.data),
838
+ appId: socket.app.appId,
839
+ socket_id: socket.socketId
840
+ });
841
+ await publishToChannels(payload, {
842
+ fromScaling: false,
843
+ shouldReplicate: true
844
+ });
845
+ }
846
+ async function publishToChannels(body, options2 = {
847
+ fromScaling: false,
848
+ shouldReplicate: true
849
+ }) {
850
+ let deliveredSockets = 0;
851
+ const deliveredChannels = [];
852
+ for (const channel2 of body.channels) {
853
+ const result = deliverEventLocal(body.appId, channel2, body.name, body.data, body.socket_id);
854
+ if (result.deliveredSockets > 0) {
855
+ deliveredChannels.push(channel2);
856
+ }
857
+ deliveredSockets += result.deliveredSockets;
858
+ }
859
+ if (!options2.fromScaling && options2.shouldReplicate) {
860
+ await publishScalingEvent(body);
861
+ }
862
+ return Object.freeze({
863
+ deliveredChannels: Object.freeze(deliveredChannels),
864
+ deliveredSockets
865
+ });
866
+ }
867
+ async function handlePublishRequest(request) {
868
+ const url = new URL(request.url);
869
+ const match = url.pathname.match(/\/apps\/([^/]+)\/events$/);
870
+ const appId = normalizeRequiredString(match?.[1] ?? "", "Publish appId");
871
+ const app = Object.values(appsByKey).find((candidate) => candidate.appId === appId);
872
+ if (!app) {
873
+ return new Response("App not found", { status: 404 });
874
+ }
875
+ const bodyText = await request.text();
876
+ const bodyMd5 = createHash("md5").update(bodyText).digest("hex");
877
+ if (url.searchParams.get("body_md5") !== bodyMd5) {
878
+ return new Response("Invalid body signature", { status: 401 });
879
+ }
880
+ let authKey;
881
+ try {
882
+ authKey = normalizeRequiredString(url.searchParams.get("auth_key") ?? "", "Publish auth_key");
883
+ } catch (error) {
884
+ const message = error instanceof Error ? error.message : "Invalid credentials";
885
+ return new Response(message, { status: 401 });
886
+ }
887
+ if (authKey !== app.key) {
888
+ return new Response("Invalid credentials", { status: 401 });
889
+ }
890
+ let authTimestamp;
891
+ try {
892
+ authTimestamp = Number.parseInt(
893
+ normalizeRequiredString(url.searchParams.get("auth_timestamp") ?? "", "Publish auth_timestamp"),
894
+ 10
895
+ );
896
+ } catch (error) {
897
+ const message = error instanceof Error ? error.message : "Invalid auth timestamp";
898
+ return new Response(message, { status: 401 });
899
+ }
900
+ if (!Number.isInteger(authTimestamp)) {
901
+ return new Response("Invalid auth timestamp", { status: 401 });
902
+ }
903
+ const nowSeconds = Math.floor((options.now?.() ?? Date.now()) / 1e3);
904
+ if (Math.abs(nowSeconds - authTimestamp) > MAX_PUBLISH_TIMESTAMP_SKEW_SECONDS) {
905
+ return new Response("Publish auth timestamp is stale", { status: 401 });
906
+ }
907
+ let providedSignature;
908
+ let expectedSignature;
909
+ try {
910
+ providedSignature = normalizeRequiredString(url.searchParams.get("auth_signature") ?? "", "Publish auth_signature");
911
+ expectedSignature = createPusherSignature(
912
+ app.secret,
913
+ request.method,
914
+ url.pathname,
915
+ url.searchParams
916
+ );
917
+ } catch (error) {
918
+ const message = error instanceof Error ? error.message : "Invalid auth signature";
919
+ return new Response(message, { status: 401 });
920
+ }
921
+ if (providedSignature !== expectedSignature) {
922
+ return new Response("Invalid auth signature", { status: 401 });
923
+ }
924
+ let publishBody;
925
+ try {
926
+ publishBody = normalizePublishBody(parseJsonObject(bodyText, "Publish body"));
927
+ } catch (error) {
928
+ const message = error instanceof Error ? error.message : "Invalid publish payload";
929
+ return new Response(message, { status: 400 });
930
+ }
931
+ let result;
932
+ try {
933
+ result = await publishToChannels({
934
+ ...publishBody,
935
+ appId: app.appId
936
+ }, {
937
+ fromScaling: false,
938
+ shouldReplicate: true
939
+ });
940
+ } catch (error) {
941
+ const message = error instanceof Error ? error.message : "Broadcast publish failed.";
942
+ return new Response(message, { status: 500 });
943
+ }
944
+ return new Response(JSON.stringify({
945
+ ok: true,
946
+ deliveredChannels: result.deliveredChannels,
947
+ deliveredSockets: result.deliveredSockets
948
+ }), {
949
+ status: 200,
950
+ headers: {
951
+ "content-type": "application/json; charset=utf-8"
952
+ }
953
+ });
954
+ }
955
+ return Object.freeze({
956
+ async fetch(request) {
957
+ const url = new URL(request.url);
958
+ if (request.method.toUpperCase() === "GET" && url.pathname === options.config.worker.healthPath) {
959
+ return new Response(JSON.stringify({
960
+ ok: true
961
+ }), {
962
+ headers: {
963
+ "content-type": "application/json; charset=utf-8"
964
+ }
965
+ });
966
+ }
967
+ if (request.method.toUpperCase() === "GET" && url.pathname === options.config.worker.statsPath) {
968
+ return new Response(JSON.stringify({
969
+ ...this.getStats()
970
+ }), {
971
+ headers: {
972
+ "content-type": "application/json; charset=utf-8"
973
+ }
974
+ });
975
+ }
976
+ if (request.method.toUpperCase() === "POST" && /\/apps\/[^/]+\/events$/.test(url.pathname)) {
977
+ return await handlePublishRequest(request);
978
+ }
979
+ return new Response("Not Found", { status: 404 });
980
+ },
981
+ connectWebSocket(connection) {
982
+ connectedSockets.set(connection.socketId, {
983
+ socketId: connection.socketId,
984
+ app: connection.app,
985
+ headers: connection.headers,
986
+ send: connection.send,
987
+ close: connection.close,
988
+ subscribedChannels: /* @__PURE__ */ new Set(),
989
+ active: true,
990
+ pendingMessage: Promise.resolve()
991
+ });
992
+ connection.send(pusherEvent("pusher:connection_established", {
993
+ socket_id: connection.socketId,
994
+ activity_timeout: 120
995
+ }));
996
+ },
997
+ async receiveWebSocketMessage(socketId, rawMessage) {
998
+ const socket = connectedSockets.get(socketId);
999
+ if (!socket) {
1000
+ return;
1001
+ }
1002
+ const task = socket.pendingMessage.then(async () => {
1003
+ if (!socket.active || connectedSockets.get(socketId) !== socket) {
1004
+ return;
1005
+ }
1006
+ const message = parseSocketMessage(rawMessage);
1007
+ if (message.event === "pusher:ping") {
1008
+ socket.send(pusherEvent("pusher:pong", {}));
1009
+ return;
1010
+ }
1011
+ if (message.event === "pusher:subscribe") {
1012
+ await handleSubscribe(socket, String(message.data.channel ?? ""));
1013
+ return;
1014
+ }
1015
+ if (message.event === "pusher:unsubscribe") {
1016
+ await handleUnsubscribe(socket, String(message.data.channel ?? ""));
1017
+ return;
1018
+ }
1019
+ if (message.event.startsWith("client-")) {
1020
+ await handleClientEvent(socket, message);
1021
+ }
1022
+ });
1023
+ socket.pendingMessage = task.catch(() => {
1024
+ });
1025
+ await task;
1026
+ },
1027
+ async receiveScalingMessage(payload) {
1028
+ await handleScalingMessage(payload);
1029
+ },
1030
+ disconnectWebSocket(socketId) {
1031
+ const socket = connectedSockets.get(socketId);
1032
+ if (!socket) {
1033
+ return;
1034
+ }
1035
+ socket.active = false;
1036
+ connectedSockets.delete(socketId);
1037
+ const channelsToCleanup = [...socket.subscribedChannels];
1038
+ const scalingCleanupTasks = channelsToCleanup.map((channel2) => {
1039
+ const removedPresenceMember = removeSubscriptionLocal(socket.app.appId, socket.socketId, channel2);
1040
+ if (removedPresenceMember.removed && removedPresenceMember.member) {
1041
+ deliverPresenceMemberRemovedLocal(socket.app.appId, channel2, removedPresenceMember.member, socket.socketId);
1042
+ }
1043
+ return async () => {
1044
+ if (removedPresenceMember.removed && removedPresenceMember.member) {
1045
+ await publishScalingPresenceMemberRemoved(socket.app, channel2, socket.socketId, removedPresenceMember.member).catch((error) => {
1046
+ logSocketCleanupError(socket.socketId, channel2, error);
1047
+ });
1048
+ }
1049
+ await removePresenceMemberFromScaling(socket.app, socket.socketId, channel2).catch((error) => {
1050
+ logSocketCleanupError(socket.socketId, channel2, error);
1051
+ });
1052
+ };
1053
+ });
1054
+ socket.subscribedChannels.clear();
1055
+ const cleanupTask = socket.pendingMessage.then(async () => {
1056
+ await Promise.all(scalingCleanupTasks.map(async (task) => {
1057
+ await task();
1058
+ }));
1059
+ }).catch((error) => {
1060
+ logSocketMessageError(socket.socketId, error);
1061
+ });
1062
+ socket.pendingMessage = cleanupTask.catch(() => {
1063
+ });
1064
+ },
1065
+ getStats() {
1066
+ return Object.freeze({
1067
+ nodeId: scaling?.nodeId ?? "standalone",
1068
+ uptimeMs: (options.now?.() ?? Date.now()) - startedAt,
1069
+ apps: Object.freeze(Object.values(appsByKey).map((app) => app.connection)),
1070
+ appScopes: Object.freeze(Object.values(appsByKey).map((app) => Object.freeze({
1071
+ connection: app.connection,
1072
+ appId: app.appId,
1073
+ key: app.key
1074
+ }))),
1075
+ connectionCount: connectedSockets.size,
1076
+ subscribedChannelCount: channels.size,
1077
+ presenceChannelCount: presenceMembers.size,
1078
+ scaling: scaling ? Object.freeze({
1079
+ driver: "redis",
1080
+ connection: scaling.connection,
1081
+ eventChannel: scaling.eventChannel
1082
+ }) : false
1083
+ });
1084
+ },
1085
+ async close() {
1086
+ const unsubscribe = await scalingUnsubscribe;
1087
+ await unsubscribe();
1088
+ if (scaling) {
1089
+ await scaling.adapter.close();
1090
+ }
1091
+ }
1092
+ });
1093
+ }
1094
+ function toNodeHeaders(headers) {
1095
+ const normalized = new Headers();
1096
+ for (const [key, value] of Object.entries(headers)) {
1097
+ if (typeof value === "undefined") {
1098
+ continue;
1099
+ }
1100
+ if (Array.isArray(value)) {
1101
+ for (const item of value) {
1102
+ normalized.append(key, item);
1103
+ }
1104
+ continue;
1105
+ }
1106
+ normalized.set(key, value);
1107
+ }
1108
+ return normalized;
1109
+ }
1110
+ function toNodeRequestUrl(request, fallbackHost) {
1111
+ const path = request.url ?? "/";
1112
+ const host = request.headers.host ?? fallbackHost;
1113
+ return `http://${host}${path}`;
1114
+ }
1115
+ async function readNodeRequestBody(request) {
1116
+ if (request.method === "GET" || request.method === "HEAD") {
1117
+ return void 0;
1118
+ }
1119
+ const chunks = [];
1120
+ for await (const chunk of request) {
1121
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1122
+ }
1123
+ if (chunks.length === 0) {
1124
+ return void 0;
1125
+ }
1126
+ return Buffer.concat(chunks);
1127
+ }
1128
+ async function writeNodeResponse(response, value) {
1129
+ response.statusCode = value.status;
1130
+ response.statusMessage = value.statusText;
1131
+ value.headers.forEach((headerValue, headerName) => {
1132
+ response.setHeader(headerName, headerValue);
1133
+ });
1134
+ const body = await value.arrayBuffer();
1135
+ response.end(Buffer.from(body));
1136
+ }
1137
+ function decodeNodeWebSocketMessage(message) {
1138
+ if (typeof message === "string") {
1139
+ return message;
1140
+ }
1141
+ if (message instanceof ArrayBuffer) {
1142
+ return new TextDecoder().decode(new Uint8Array(message));
1143
+ }
1144
+ if (Array.isArray(message)) {
1145
+ return Buffer.concat(message).toString("utf8");
1146
+ }
1147
+ if (message instanceof Uint8Array) {
1148
+ return Buffer.from(message).toString("utf8");
1149
+ }
1150
+ return String(message);
1151
+ }
1152
+ async function handleSubscribeFailure(runtime, subscribeError) {
1153
+ try {
1154
+ await runtime.close();
1155
+ } catch {
1156
+ }
1157
+ throw subscribeError;
1158
+ }
1159
+ async function startBroadcastWorker(runtimeBindings) {
1160
+ const config = runtimeBindings.config;
1161
+ if (!config) {
1162
+ throw new Error("[@holo-js/broadcast] Broadcast worker requires a loaded broadcast config.");
1163
+ }
1164
+ const scalingConnectionName = config.worker.scaling ? resolveScalingConnectionName(config.worker.scaling.connection, runtimeBindings.queue, runtimeBindings.redis) : void 0;
1165
+ const scalingConfig = config.worker.scaling ? Object.freeze({
1166
+ driver: "redis",
1167
+ connection: scalingConnectionName,
1168
+ nodeId: runtimeBindings.nodeId ?? createScalingNodeId(),
1169
+ eventChannel: resolveScalingEventChannel(scalingConnectionName),
1170
+ adapter: await (runtimeBindings.createScalingAdapter ? runtimeBindings.createScalingAdapter(
1171
+ resolveRedisScalingConnection(runtimeBindings.queue, scalingConnectionName, runtimeBindings.redis)
1172
+ ) : createRedisScalingAdapter(
1173
+ resolveRedisScalingConnection(runtimeBindings.queue, scalingConnectionName, runtimeBindings.redis),
1174
+ { loadRedisModule: runtimeBindings.loadRedisModule }
1175
+ ))
1176
+ }) : void 0;
1177
+ let scalingUnsubscribe;
1178
+ const runtime = createBroadcastWorkerRuntime({
1179
+ config,
1180
+ channelAuth: runtimeBindings.channelAuth,
1181
+ fetch: runtimeBindings.fetch ?? fetch,
1182
+ scaling: scalingConfig,
1183
+ scalingAutoSubscribe: false,
1184
+ scalingUnsubscribe: async () => {
1185
+ await scalingUnsubscribe?.();
1186
+ }
1187
+ });
1188
+ if (scalingConfig) {
1189
+ scalingUnsubscribe = await scalingConfig.adapter.subscribe(scalingConfig.eventChannel, (payload) => {
1190
+ void runtime.receiveScalingMessage(payload).catch((error) => {
1191
+ logScalingMessageError(error);
1192
+ });
1193
+ }).catch((subscribeError) => handleSubscribeFailure(runtime, subscribeError));
1194
+ }
1195
+ const bun = globalThis.Bun;
1196
+ const appsByKey = buildWorkerApps(config);
1197
+ const pathPrefix = config.worker.path.replace(/\/$/, "");
1198
+ const appPathRegex = new RegExp(`^${escapeRegExp(pathPrefix)}/([^/]+)$`);
1199
+ if (bun?.serve) {
1200
+ const server = bun.serve({
1201
+ hostname: config.worker.host,
1202
+ port: config.worker.port,
1203
+ async fetch(request, wsServer2) {
1204
+ const url = new URL(request.url);
1205
+ const appMatch = url.pathname.match(appPathRegex);
1206
+ if (appMatch) {
1207
+ const key = appMatch[1];
1208
+ const app = appsByKey[key];
1209
+ if (!app) {
1210
+ return new Response("Unknown app key", { status: 401 });
1211
+ }
1212
+ const upgraded = wsServer2.upgrade(request, {
1213
+ data: {
1214
+ socketId: createSocketId(),
1215
+ app,
1216
+ headers: request.headers
1217
+ }
1218
+ });
1219
+ if (upgraded) {
1220
+ return new Response(null, { status: 200 });
1221
+ }
1222
+ }
1223
+ return await runtime.fetch(request);
1224
+ },
1225
+ websocket: {
1226
+ open(socket) {
1227
+ runtime.connectWebSocket({
1228
+ ...socket.data,
1229
+ send(payload) {
1230
+ socket.send(payload);
1231
+ },
1232
+ /* v8 ignore next 3 -- Bun websocket close callback forwarding is adapter glue; close is driven by Bun, not by unit tests. */
1233
+ close(code, reason) {
1234
+ socket.close(code, reason);
1235
+ }
1236
+ });
1237
+ },
1238
+ message(socket, message) {
1239
+ const value = typeof message === "string" ? message : new TextDecoder().decode(message);
1240
+ void runtime.receiveWebSocketMessage(socket.data.socketId, value).catch((error) => {
1241
+ logSocketMessageError(socket.data.socketId, error);
1242
+ runtime.disconnectWebSocket(socket.data.socketId);
1243
+ socket.close(4001, "Protocol error");
1244
+ });
1245
+ },
1246
+ close(socket) {
1247
+ runtime.disconnectWebSocket(socket.data.socketId);
1248
+ }
1249
+ }
1250
+ });
1251
+ return Object.freeze({
1252
+ host: config.worker.host,
1253
+ port: config.worker.port,
1254
+ async stop() {
1255
+ server.stop(true);
1256
+ await runtime.close();
1257
+ }
1258
+ });
1259
+ }
1260
+ let nodeWsModule;
1261
+ try {
1262
+ nodeWsModule = await (runtimeBindings.loadWebSocketModule ? runtimeBindings.loadWebSocketModule() : import("ws"));
1263
+ } catch (error) {
1264
+ await runtime.close();
1265
+ const details = error instanceof Error ? error.message : String(error);
1266
+ throw new Error(`[@holo-js/broadcast] Node runtime requires the "ws" package for broadcast:work. ${details}`);
1267
+ }
1268
+ const WebSocketServer = nodeWsModule.WebSocketServer;
1269
+ if (typeof WebSocketServer !== "function") {
1270
+ await runtime.close();
1271
+ throw new Error("[@holo-js/broadcast] Node runtime websocket module is missing WebSocketServer export.");
1272
+ }
1273
+ const requestConnectionInfo = /* @__PURE__ */ new WeakMap();
1274
+ const wsServer = new WebSocketServer({ noServer: true });
1275
+ wsServer.on("connection", (socket, request) => {
1276
+ const connectionInfo = requestConnectionInfo.get(request);
1277
+ const socketId = connectionInfo.socketId;
1278
+ runtime.connectWebSocket({
1279
+ ...connectionInfo,
1280
+ send(payload) {
1281
+ socket.send(payload);
1282
+ },
1283
+ close(code, reason) {
1284
+ socket.close(code, reason);
1285
+ }
1286
+ });
1287
+ socket.on("message", (message) => {
1288
+ const value = decodeNodeWebSocketMessage(message);
1289
+ void runtime.receiveWebSocketMessage(socketId, value).catch((error) => {
1290
+ logSocketMessageError(socketId, error);
1291
+ runtime.disconnectWebSocket(socketId);
1292
+ socket.close(4001, "Protocol error");
1293
+ });
1294
+ });
1295
+ socket.on("close", () => {
1296
+ runtime.disconnectWebSocket(socketId);
1297
+ });
1298
+ });
1299
+ const httpServer = createServer(async (request, response) => {
1300
+ const requestUrl = toNodeRequestUrl(request, `${config.worker.host}:${config.worker.port}`);
1301
+ const requestBody = await readNodeRequestBody(request);
1302
+ const requestInit = {
1303
+ method: request.method,
1304
+ headers: toNodeHeaders(request.headers)
1305
+ };
1306
+ if (typeof requestBody !== "undefined") {
1307
+ requestInit.body = new Uint8Array(requestBody);
1308
+ }
1309
+ const runtimeRequest = new Request(requestUrl, {
1310
+ ...requestInit
1311
+ });
1312
+ const runtimeResponse = await runtime.fetch(runtimeRequest);
1313
+ await writeNodeResponse(response, runtimeResponse);
1314
+ });
1315
+ httpServer.on("upgrade", (request, socket, head) => {
1316
+ const requestUrl = new URL(toNodeRequestUrl(request, `${config.worker.host}:${config.worker.port}`));
1317
+ const appMatch = requestUrl.pathname.match(appPathRegex);
1318
+ if (!appMatch) {
1319
+ socket.destroy();
1320
+ return;
1321
+ }
1322
+ const app = appsByKey[appMatch[1]];
1323
+ if (!app) {
1324
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
1325
+ socket.destroy();
1326
+ return;
1327
+ }
1328
+ requestConnectionInfo.set(request, {
1329
+ socketId: createSocketId(),
1330
+ app,
1331
+ headers: toNodeHeaders(request.headers)
1332
+ });
1333
+ wsServer.handleUpgrade(request, socket, head, (client, upgradedRequest) => {
1334
+ wsServer.emit("connection", client, upgradedRequest);
1335
+ });
1336
+ });
1337
+ try {
1338
+ await new Promise((resolvePromise, rejectPromise) => {
1339
+ httpServer.once("error", rejectPromise);
1340
+ httpServer.listen(config.worker.port, config.worker.host, () => {
1341
+ httpServer.off("error", rejectPromise);
1342
+ resolvePromise();
1343
+ });
1344
+ });
1345
+ } catch (error) {
1346
+ wsServer.close();
1347
+ await runtime.close();
1348
+ throw error;
1349
+ }
1350
+ const address = httpServer.address();
1351
+ const port = typeof address === "object" && address ? address.port : config.worker.port;
1352
+ return Object.freeze({
1353
+ host: config.worker.host,
1354
+ port,
1355
+ async stop() {
1356
+ await new Promise((resolvePromise) => {
1357
+ wsServer.close(() => {
1358
+ resolvePromise();
1359
+ });
1360
+ });
1361
+ httpServer.closeIdleConnections?.();
1362
+ httpServer.closeAllConnections?.();
1363
+ await new Promise((resolvePromise, rejectPromise) => {
1364
+ httpServer.close((error) => {
1365
+ if (error) {
1366
+ rejectPromise(error);
1367
+ return;
1368
+ }
1369
+ resolvePromise();
1370
+ });
1371
+ });
1372
+ await runtime.close();
1373
+ }
1374
+ });
1375
+ }
1376
+ var workerInternals = {
1377
+ buildWorkerApps,
1378
+ createScalingNodeId,
1379
+ createRedisScalingAdapter,
1380
+ createPusherSignature,
1381
+ createSocketId,
1382
+ resolveRedisScalingConnection,
1383
+ resolveScalingEventChannel,
1384
+ normalizePublishBody,
1385
+ parseChannelKind,
1386
+ parsePresenceHashMembers,
1387
+ parseSocketMessage
1388
+ };
1389
+
1390
+ // src/index.ts
1391
+ import { defineBroadcastConfig } from "@holo-js/config";
1392
+ var broadcastPackage = Object.freeze({
1393
+ authorizeBroadcastChannel,
1394
+ broadcast,
1395
+ broadcastRaw,
1396
+ channel,
1397
+ configureBroadcastRuntime,
1398
+ defineBroadcast,
1399
+ defineChannel,
1400
+ getBroadcastRuntime,
1401
+ getBroadcastRuntimeBindings,
1402
+ parseBroadcastAuthEndpointPayload,
1403
+ presenceChannel,
1404
+ privateChannel,
1405
+ renderBroadcastAuthResponse,
1406
+ resetBroadcastRuntime,
1407
+ resolveBroadcastWhisperSchema,
1408
+ startBroadcastWorker,
1409
+ validateBroadcastWhisperPayload,
1410
+ createBroadcastWorkerRuntime
1411
+ });
1412
+ var src_default = broadcastPackage;
1413
+ export {
1414
+ authorizeBroadcastChannel,
1415
+ broadcast,
1416
+ broadcastAuthInternals,
1417
+ broadcastInternals,
1418
+ broadcastRaw,
1419
+ broadcastRegistryInternals,
1420
+ broadcastRuntimeInternals,
1421
+ channel,
1422
+ configureBroadcastRuntime,
1423
+ createBroadcastWorkerRuntime,
1424
+ src_default as default,
1425
+ defineBroadcast,
1426
+ defineBroadcastConfig,
1427
+ defineChannel,
1428
+ getBroadcastRuntime,
1429
+ getBroadcastRuntimeBindings,
1430
+ getRegisteredBroadcastDriver,
1431
+ isBroadcastDefinition,
1432
+ isChannelDefinition,
1433
+ listRegisteredBroadcastDrivers,
1434
+ parseBroadcastAuthEndpointPayload,
1435
+ presenceChannel,
1436
+ privateChannel,
1437
+ registerBroadcastDriver,
1438
+ renderBroadcastAuthResponse,
1439
+ resetBroadcastDriverRegistry,
1440
+ resetBroadcastRuntime,
1441
+ resolveBroadcastWhisperSchema,
1442
+ startBroadcastWorker,
1443
+ validateBroadcastWhisperPayload,
1444
+ workerInternals
1445
+ };