@zerotal/broadcasting 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog — @zerotal/broadcasting
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `beta`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.0.0] — 2026-08-05
12
+
13
+ _First public release._
14
+
15
+ ### Fixed
16
+
17
+ - **Presence: an explicit unsubscribe now removes the member and notifies the channel.** `_unsubscribe` only dropped the connection from the channel's subscriber set — it skipped the presence-member cleanup + `presence:member_removed` broadcast that a full disconnect (`handleClose`) performs. So a member who left via `Echo.leave` (a flow component teardown / SPA navigation, not a tab close) lingered in everyone else's "who's here" until they disconnected. Both leave paths now clean up identically.
18
+ - **The broadcasting WebSocket now coexists with flow's.** `BroadcastProvider` registers its WS handler at the configured `path` (default `/app/ws`; pusher stays catch-all for its dynamic `/app/{key}`), relying on core's new path-multiplexed `withWebSocket`. Before, flow's `/__flow/ws` registration clobbered the broadcasting one, so `/app/ws` never handled connections — real-time presence/private/shared channels silently didn't deliver in apps that also use flow.
19
+
20
+ ### Changed
21
+
22
+ - Added a typed error vocabulary (`BroadcastError` + `E_BROADCAST_*` codes).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @zerotal/broadcasting
2
+
3
+ > Real-time WebSocket broadcasting with a Pusher-compatible server built into your app process.
4
+
5
+ Define a `BroadcastingEvent`, dispatch it, and every subscribed client receives the payload over a live WebSocket connection — no separate service required. Supports public, private, and presence channels, a Redis driver for horizontally-scaled deployments, and works unchanged with any Pusher-protocol client.
6
+
7
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ bun add @zerotal/broadcasting
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ Register the provider in `bootstrap/providers.ts`:
18
+
19
+ ```ts
20
+ import { BroadcastProvider } from "@zerotal/broadcasting";
21
+ ```
22
+
23
+ It registers two HTTP endpoints automatically: `GET /app/{appKey}` (WebSocket upgrade) and `POST /broadcasting/auth` (private/presence channel auth).
24
+
25
+ ## Usage
26
+
27
+ Write a broadcast event — only `broadcastOn()` is required:
28
+
29
+ ```ts
30
+ import { BroadcastingEvent, privateChannel } from "@zerotal/broadcasting";
31
+
32
+ export class OrderShipmentStatusUpdated extends BroadcastingEvent {
33
+ constructor(public readonly order: Order) {
34
+ super();
35
+ }
36
+
37
+ broadcastOn() {
38
+ return privateChannel(`orders.${this.order.id}`);
39
+ }
40
+
41
+ broadcastWith() {
42
+ return { id: this.order.id, status: this.order.status };
43
+ }
44
+ }
45
+ ```
46
+
47
+ Dispatch it — static, fluent, or via the facade:
48
+
49
+ ```ts
50
+ import { broadcast, Broadcast } from "@zerotal/broadcasting";
51
+
52
+ OrderShipmentStatusUpdated.dispatch(order); // construct + dispatch + broadcast
53
+ await broadcast(new OrderShipmentStatusUpdated(order)).toOthers(); // exclude current socket
54
+ Broadcast.send(new OrderShipmentStatusUpdated(order)); // explicit send
55
+ ```
56
+
57
+ Pick a channel type with the helpers, then authorize private/presence channels in `routes/channels.ts`:
58
+
59
+ ```ts
60
+ import { channel, privateChannel, presenceChannel } from "@zerotal/broadcasting";
61
+
62
+ channel("posts"); // public
63
+ privateChannel("orders.42"); // private -> "private-orders.42"
64
+ presenceChannel("chat.room1"); // presence -> "presence-chat.room1"
65
+ ```
66
+
67
+ ```ts
68
+ // routes/channels.ts
69
+ import { Broadcast } from "@zerotal/broadcasting";
70
+
71
+ Broadcast.channel("orders.[orderId]", async (user, orderId) => {
72
+ return user.id === (await Order.findOrNew(orderId)).userId; // boolean = private
73
+ });
74
+
75
+ Broadcast.channel("chat.[roomId]", (user, roomId) => {
76
+ return user.canJoin(roomId) ? { id: user.id, name: user.name } : null; // member data = presence
77
+ });
78
+ ```
79
+
80
+ Testing with the in-memory recorder:
81
+
82
+ ```ts
83
+ import { Broadcast } from "@zerotal/broadcasting";
84
+
85
+ const fake = Broadcast.fake();
86
+ await PostController.publish({ http: ctx });
87
+ fake.assertBroadcast("PostPublished", "posts", { id: post.id });
88
+ Broadcast.resetFake();
89
+ ```
90
+
91
+ ## Exports
92
+
93
+ - `BroadcastingEvent` / `broadcastOnce` — base class for broadcastable events.
94
+ - `broadcast` / `PendingBroadcast` — fluent dispatch helper (`.toOthers()`, awaitable).
95
+ - `Broadcast` — facade (`send`, `to`, `getMembers`, `channel`, `on`/`private`/`presence`, `fake`/`resetFake`).
96
+ - Channel helpers: `channel`, `privateChannel`, `presenceChannel`, `isPrivateChannel`.
97
+ - `BroadcastManager` — core manager; `TypedBroadcastManager` for compile-time-checked channel maps.
98
+ - `PusherCompatManager` — Pusher/Reverb-compatible protocol manager.
99
+ - `RedisBroadcastDriver` — Redis Pub/Sub fan-out for multi-instance deployments.
100
+ - `broadcastsModelEvents` — wire a model's `created`/`updated`/`deleted` to broadcasts.
101
+ - `AnonymousBroadcast` — inline broadcasts without an event class.
102
+ - `ChannelRegistry` / `channelRegistry` / `compileChannelPattern` — channel auth rule registry.
103
+ - `BroadcastFake` — test double behind `Broadcast.fake()`.
104
+ - `BroadcastProvider` — service provider.
105
+ - `BroadcastConfig` — config factory.
106
+ - Types: `PresenceMember`, `PresenceAuthFn`, `BroadcastChannelMap`, `ChannelParams`, `EventsOf`, `PayloadOf`, `TypedBroadcastEvent`, `BroadcastEvent`, `WsConnectionData`, `ChannelAuthFn`, `RecordedBroadcast`, and more.
107
+ - Typed error vocabulary re-exported from `./errors`.
108
+
109
+ ## Documentation
110
+
111
+ - [Broadcasting](../../docs/broadcasting/index.md)
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@zerotal/broadcasting",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "beta",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "CHANGELOG.md",
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.test.tsx",
18
+ "!src/**/*.spec.ts",
19
+ "!src/**/__fixtures__/**"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.3.14"
26
+ },
27
+ "scripts": {
28
+ "test": "bun test",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@zerotal/core": "1.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.8.0"
36
+ },
37
+ "description": "Real-time event broadcasting over WebSockets for Zerotal, with public/private/presence channels.",
38
+ "keywords": [
39
+ "zerotal",
40
+ "bun",
41
+ "typescript",
42
+ "framework",
43
+ "broadcasting",
44
+ "websocket",
45
+ "realtime"
46
+ ],
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
50
+ "directory": "packages/broadcasting"
51
+ },
52
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/broadcasting#readme",
53
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
54
+ }
@@ -0,0 +1,52 @@
1
+ import { Broadcast } from "./facades/Broadcast.ts";
2
+ import { currentSocketId } from "./currentSocketId.ts";
3
+
4
+ /**
5
+ * Fluent builder for broadcasting without a dedicated event class. Returned by
6
+ * `Broadcast.on()` / `Broadcast.private()` / `Broadcast.presence()`.
7
+ *
8
+ * @example
9
+ * Broadcast.on(`orders.${order.id}`).as("OrderPlaced").with(order).toOthers().send();
10
+ * Broadcast.private(`orders.${order.id}`).as("OrderPlaced").with({ id: order.id }).send();
11
+ */
12
+ export class AnonymousBroadcast {
13
+ private _event = "AnonymousEvent";
14
+ private _data: unknown = {};
15
+ private _toOthers = false;
16
+
17
+ constructor(private readonly channel: string) {}
18
+
19
+ /** Set the wire event name (default `"AnonymousEvent"`). */
20
+ as(event: string): this {
21
+ this._event = event;
22
+ return this;
23
+ }
24
+
25
+ /** Set the payload. */
26
+ with(data: unknown): this {
27
+ this._data = data;
28
+ return this;
29
+ }
30
+
31
+ /** Exclude the originating connection (its `X-Socket-ID`). */
32
+ toOthers(): this {
33
+ this._toOthers = true;
34
+ return this;
35
+ }
36
+
37
+ /** Broadcast now. (`sendNow()` is an alias; queued sending lands with the broadcast queue.) */
38
+ send(): void {
39
+ const exceptSocketId = this._toOthers ? currentSocketId() : undefined;
40
+ Broadcast.to(
41
+ this.channel,
42
+ this._event,
43
+ this._data,
44
+ exceptSocketId ? { exceptSocketId } : undefined,
45
+ );
46
+ }
47
+
48
+ /** Broadcast immediately. */
49
+ sendNow(): void {
50
+ this.send();
51
+ }
52
+ }
@@ -0,0 +1,114 @@
1
+ import type { BroadcastEvent } from "./types.ts";
2
+
3
+ export interface RecordedBroadcast {
4
+ channel: string;
5
+ event: string;
6
+ data: Record<string, unknown>;
7
+ }
8
+
9
+ /**
10
+ * In-test fake that records broadcast calls without requiring a WS server.
11
+ *
12
+ * @example
13
+ * const fake = Broadcast.fake();
14
+ *
15
+ * // ... controller action that calls Broadcast.send(new PostUpdated(post))
16
+ *
17
+ * fake.assertBroadcast('PostUpdated', 'posts');
18
+ * fake.assertNothingBroadcast();
19
+ */
20
+ export class BroadcastFake {
21
+ private _broadcasts: RecordedBroadcast[] = [];
22
+
23
+ to(
24
+ channel: string,
25
+ eventName: string,
26
+ data: unknown = {},
27
+ _opts?: { exceptSocketId?: string },
28
+ ): void {
29
+ this._broadcasts.push({ channel, event: eventName, data: data as Record<string, unknown> });
30
+ }
31
+
32
+ send(event: BroadcastEvent, opts?: { exceptSocketId?: string }): void {
33
+ const channels = [event.broadcastOn()].flat();
34
+ const name = event.broadcastAs?.() ?? event.constructor.name;
35
+ const data = event.broadcastWith?.() ?? {};
36
+ for (const ch of channels) {
37
+ this.to(ch, name, data, opts);
38
+ }
39
+ }
40
+
41
+ /** Return all recorded broadcasts. */
42
+ recorded(): RecordedBroadcast[] {
43
+ return [...this._broadcasts];
44
+ }
45
+
46
+ /** Reset recorded broadcasts. */
47
+ reset(): void {
48
+ this._broadcasts = [];
49
+ }
50
+
51
+ // ── Assertions ────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Assert that an event was broadcast on the given channel (and optionally with given data).
55
+ *
56
+ * @example
57
+ * fake.assertBroadcast('PostUpdated', 'posts');
58
+ * fake.assertBroadcast('PostUpdated', 'posts', { id: 1 });
59
+ */
60
+ assertBroadcast(eventName: string, channel?: string, data?: Record<string, unknown>): void {
61
+ const match = this._broadcasts.find((b) => {
62
+ if (b.event !== eventName) return false;
63
+ if (channel && b.channel !== channel) return false;
64
+ if (data) {
65
+ for (const [k, v] of Object.entries(data)) {
66
+ if ((b.data as Record<string, unknown>)[k] !== v) return false;
67
+ }
68
+ }
69
+ return true;
70
+ });
71
+ if (!match) {
72
+ const desc = channel ? ` on channel "${channel}"` : "";
73
+ throw new Error(
74
+ `Expected event "${eventName}"${desc} to have been broadcast, but it was not.\n` +
75
+ `Recorded: ${JSON.stringify(
76
+ this._broadcasts.map((b) => `${b.event}@${b.channel}`),
77
+ null,
78
+ 2,
79
+ )}`,
80
+ );
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Assert that the given event was NOT broadcast.
86
+ */
87
+ assertNotBroadcast(eventName: string, channel?: string): void {
88
+ const match = this._broadcasts.find((b) => {
89
+ if (b.event !== eventName) return false;
90
+ if (channel && b.channel !== channel) return false;
91
+ return true;
92
+ });
93
+ if (match) {
94
+ throw new Error(`Expected event "${eventName}" NOT to have been broadcast, but it was.`);
95
+ }
96
+ }
97
+
98
+ /** Assert that no broadcasts were recorded. */
99
+ assertNothingBroadcast(): void {
100
+ if (this._broadcasts.length > 0) {
101
+ throw new Error(
102
+ `Expected nothing to be broadcast, but ${this._broadcasts.length} broadcast(s) were recorded:\n` +
103
+ this._broadcasts.map((b) => ` ${b.event}@${b.channel}`).join("\n"),
104
+ );
105
+ }
106
+ }
107
+
108
+ /** Assert that exactly `count` broadcasts were recorded. */
109
+ assertBroadcastCount(count: number): void {
110
+ if (this._broadcasts.length !== count) {
111
+ throw new Error(`Expected ${count} broadcast(s) but got ${this._broadcasts.length}.`);
112
+ }
113
+ }
114
+ }