@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 +22 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +54 -0
- package/src/AnonymousBroadcast.ts +52 -0
- package/src/BroadcastFake.ts +114 -0
- package/src/BroadcastManager.ts +404 -0
- package/src/BroadcastingEvent.ts +89 -0
- package/src/BroadcastsModelEvents.ts +93 -0
- package/src/Channel.ts +32 -0
- package/src/ChannelRegistry.ts +112 -0
- package/src/PendingBroadcast.ts +53 -0
- package/src/PusherCompatManager.ts +330 -0
- package/src/RedisBroadcastDriver.ts +121 -0
- package/src/TypedBroadcastManager.ts +185 -0
- package/src/commands/ChannelListCommand.ts +35 -0
- package/src/commands/MakeChannelCommand.ts +108 -0
- package/src/config.ts +106 -0
- package/src/currentSocketId.ts +6 -0
- package/src/errors.ts +32 -0
- package/src/facades/Broadcast.ts +135 -0
- package/src/global.d.ts +10 -0
- package/src/index.ts +36 -0
- package/src/provider/BroadcastProvider.ts +243 -0
- package/src/types.ts +79 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { BroadcastManager } from "./BroadcastManager.ts";
|
|
2
|
+
import { MissingChannelParameterError } from "./errors.ts";
|
|
3
|
+
import type { BroadcastEvent } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// ── Channel-map type ──────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shape of a typed broadcast channel map.
|
|
9
|
+
*
|
|
10
|
+
* Keys are channel name patterns (may include `[param]` placeholders).
|
|
11
|
+
* Values are objects mapping event names to their payload types.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* export interface Channels extends BroadcastChannelMap {
|
|
15
|
+
* 'posts': {
|
|
16
|
+
* PostCreated: { id: number; title: string };
|
|
17
|
+
* PostDeleted: { id: number };
|
|
18
|
+
* };
|
|
19
|
+
* 'private-orders.[orderId]': {
|
|
20
|
+
* OrderShipped: { orderId: number; trackingCode: string };
|
|
21
|
+
* OrderCancelled: { orderId: number; reason: string };
|
|
22
|
+
* };
|
|
23
|
+
* 'presence-room.[roomId]': {
|
|
24
|
+
* MessageSent: { userId: number; text: string };
|
|
25
|
+
* };
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* const manager = new TypedBroadcastManager<Channels>();
|
|
29
|
+
*
|
|
30
|
+
* // Static channel — fully typed
|
|
31
|
+
* manager.to('posts', 'PostCreated', { id: 1, title: 'Hello' });
|
|
32
|
+
*
|
|
33
|
+
* // Parameterised channel — pass the pattern + params separately
|
|
34
|
+
* manager.toChannel('private-orders.[orderId]', { orderId: 42 }, 'OrderShipped', {
|
|
35
|
+
* orderId: 42,
|
|
36
|
+
* trackingCode: 'UPS-123',
|
|
37
|
+
* });
|
|
38
|
+
*/
|
|
39
|
+
export type BroadcastChannelMap = Record<string, Record<string, object>>;
|
|
40
|
+
|
|
41
|
+
// ── Type helpers ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Extract `[param]` names from a channel pattern string. */
|
|
44
|
+
export type ChannelParams<Pattern extends string> =
|
|
45
|
+
Pattern extends `${string}[${infer Param}]${infer Tail}` ? Param | ChannelParams<Tail> : never;
|
|
46
|
+
|
|
47
|
+
/** Build the params record for a channel pattern. `undefined` when there are no params. */
|
|
48
|
+
export type ChannelParamRecord<Pattern extends string> = [ChannelParams<Pattern>] extends [never]
|
|
49
|
+
? undefined
|
|
50
|
+
: { [K in ChannelParams<Pattern>]: string | number };
|
|
51
|
+
|
|
52
|
+
/** All event names for a given channel pattern in the map. */
|
|
53
|
+
export type EventsOf<M extends BroadcastChannelMap, Ch extends keyof M & string> = keyof M[Ch] &
|
|
54
|
+
string;
|
|
55
|
+
|
|
56
|
+
/** The payload type for a specific event on a channel. */
|
|
57
|
+
export type PayloadOf<
|
|
58
|
+
M extends BroadcastChannelMap,
|
|
59
|
+
Ch extends keyof M & string,
|
|
60
|
+
Ev extends EventsOf<M, Ch>,
|
|
61
|
+
> = M[Ch][Ev];
|
|
62
|
+
|
|
63
|
+
/** Channel patterns that have NO `[param]` placeholders. */
|
|
64
|
+
export type StaticChannels<M extends BroadcastChannelMap> = {
|
|
65
|
+
[K in keyof M & string]: [ChannelParams<K>] extends [never] ? K : never;
|
|
66
|
+
}[keyof M & string];
|
|
67
|
+
|
|
68
|
+
/** Channel patterns that DO contain `[param]` placeholders. */
|
|
69
|
+
export type ParameterizedChannels<M extends BroadcastChannelMap> = {
|
|
70
|
+
[K in keyof M & string]: [ChannelParams<K>] extends [never] ? never : K;
|
|
71
|
+
}[keyof M & string];
|
|
72
|
+
|
|
73
|
+
// ── Typed event interface ─────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Implement on an event class to make it a typed broadcast event.
|
|
77
|
+
* The type parameters enforce that the event name and payload match the channel map.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* class PostCreated implements TypedBroadcastEvent<Channels, 'posts', 'PostCreated'> {
|
|
81
|
+
* constructor(private post: Post) {}
|
|
82
|
+
*
|
|
83
|
+
* broadcastOn() { return 'posts' as const; }
|
|
84
|
+
* broadcastAs() { return 'PostCreated' as const; }
|
|
85
|
+
* broadcastWith() { return { id: this.post.id, title: this.post.title }; }
|
|
86
|
+
* }
|
|
87
|
+
*/
|
|
88
|
+
export interface TypedBroadcastEvent<
|
|
89
|
+
M extends BroadcastChannelMap,
|
|
90
|
+
Ch extends keyof M & string,
|
|
91
|
+
Ev extends EventsOf<M, Ch> = EventsOf<M, Ch>,
|
|
92
|
+
> extends BroadcastEvent {
|
|
93
|
+
broadcastOn(): Ch | Ch[];
|
|
94
|
+
broadcastAs(): Ev;
|
|
95
|
+
broadcastWith(): PayloadOf<M, Ch, Ev>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Runtime helper ────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function interpolateChannel(pattern: string, params: Record<string, string | number>): string {
|
|
101
|
+
return pattern.replace(/\[(\w+)\]/g, (_, key: string) => {
|
|
102
|
+
const val = params[key];
|
|
103
|
+
if (val === undefined) throw new MissingChannelParameterError(key);
|
|
104
|
+
return String(val);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── TypedBroadcastManager ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A `BroadcastManager` variant that enforces payload types against a channel map.
|
|
112
|
+
*
|
|
113
|
+
* All `BroadcastManager` APIs (WebSocket lifecycle, auth, presence) are inherited
|
|
114
|
+
* unchanged. Only `to()` and `toChannel()` gain type constraints.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const manager = new TypedBroadcastManager<Channels>();
|
|
118
|
+
*
|
|
119
|
+
* // Static channel
|
|
120
|
+
* manager.to('posts', 'PostCreated', { id: 1, title: 'Hello' });
|
|
121
|
+
*
|
|
122
|
+
* // Parameterised channel
|
|
123
|
+
* manager.toChannel('private-orders.[orderId]', { orderId: 42 }, 'OrderShipped', {
|
|
124
|
+
* orderId: 42, trackingCode: 'UPS-123',
|
|
125
|
+
* });
|
|
126
|
+
*/
|
|
127
|
+
export class TypedBroadcastManager<Channels extends BroadcastChannelMap> extends BroadcastManager {
|
|
128
|
+
/**
|
|
129
|
+
* Typed broadcast to a static channel (no `[param]` placeholders).
|
|
130
|
+
*
|
|
131
|
+
* TypeScript will enforce that `event` and `data` match the channel map entry.
|
|
132
|
+
* Falls back to the untyped base method for any string channel (e.g. if you
|
|
133
|
+
* need to broadcast on a dynamically constructed name).
|
|
134
|
+
*/
|
|
135
|
+
to<Ch extends StaticChannels<Channels>, Ev extends EventsOf<Channels, Ch>>(
|
|
136
|
+
channel: Ch,
|
|
137
|
+
event: Ev,
|
|
138
|
+
data: PayloadOf<Channels, Ch, Ev>,
|
|
139
|
+
opts?: { exceptSocketId?: string },
|
|
140
|
+
): void;
|
|
141
|
+
/** Untyped fallback — preserves compatibility with the base class signature. */
|
|
142
|
+
to(channel: string, event: string, data?: unknown, opts?: { exceptSocketId?: string }): void;
|
|
143
|
+
to(channel: string, event: string, data: unknown = {}, opts?: { exceptSocketId?: string }): void {
|
|
144
|
+
// `opts` is forwarded, not dropped. Declaring three parameters and omitting the fourth
|
|
145
|
+
// is not a type error on an override, so `toOthers()` silently stopped excluding
|
|
146
|
+
// anything the moment a broadcast went through this class.
|
|
147
|
+
super.to(channel, event, data, opts);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Typed broadcast to a parameterised channel pattern.
|
|
152
|
+
*
|
|
153
|
+
* The pattern is interpolated with `params` at runtime. TypeScript enforces
|
|
154
|
+
* that the pattern key exists in the channel map and that `event` and `data`
|
|
155
|
+
* match its declared event types.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* manager.toChannel('private-orders.[orderId]', { orderId: 42 }, 'OrderShipped', {
|
|
159
|
+
* orderId: 42, trackingCode: 'UPS-123',
|
|
160
|
+
* });
|
|
161
|
+
*/
|
|
162
|
+
toChannel<
|
|
163
|
+
Pattern extends ParameterizedChannels<Channels>,
|
|
164
|
+
Ev extends EventsOf<Channels, Pattern>,
|
|
165
|
+
>(
|
|
166
|
+
pattern: Pattern,
|
|
167
|
+
params: ChannelParamRecord<Pattern>,
|
|
168
|
+
event: Ev,
|
|
169
|
+
data: PayloadOf<Channels, Pattern, Ev>,
|
|
170
|
+
): void {
|
|
171
|
+
const ch = interpolateChannel(pattern as string, params as Record<string, string | number>);
|
|
172
|
+
super.to(ch, event as string, data);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Dispatch a typed broadcast event.
|
|
177
|
+
*
|
|
178
|
+
* Accepts both `TypedBroadcastEvent` (with map-matched payload types) and the
|
|
179
|
+
* untyped `BroadcastEvent` interface, so this is a drop-in replacement for
|
|
180
|
+
* the base `send()`.
|
|
181
|
+
*/
|
|
182
|
+
send(event: TypedBroadcastEvent<Channels, keyof Channels & string> | BroadcastEvent): void {
|
|
183
|
+
super.send(event);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Command } from "@zerotal/core";
|
|
2
|
+
import { Broadcast } from "../facades/Broadcast.ts";
|
|
3
|
+
|
|
4
|
+
export class ChannelListCommand extends Command {
|
|
5
|
+
static override commandName = "channel:list";
|
|
6
|
+
static override description = "List registered broadcast channel authorization rules";
|
|
7
|
+
static override needsApp = true;
|
|
8
|
+
|
|
9
|
+
async run(): Promise<void> {
|
|
10
|
+
// Ensure routes/channels.ts has registered its rules.
|
|
11
|
+
const channelsFile = `${process.cwd()}/routes/channels.ts`;
|
|
12
|
+
if (await Bun.file(channelsFile).exists()) {
|
|
13
|
+
try {
|
|
14
|
+
await import(channelsFile);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
this.warn(`Failed to load routes/channels.ts: ${(err as Error).message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const channels = Broadcast.channels();
|
|
21
|
+
if (channels.length === 0) {
|
|
22
|
+
this.info("No channel authorization rules registered. Define them in routes/channels.ts.");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.section(`Broadcast channels (${channels.length})`);
|
|
27
|
+
for (const ch of channels) {
|
|
28
|
+
this.table([
|
|
29
|
+
["Pattern", ch.pattern],
|
|
30
|
+
["Params", ch.paramNames.length ? ch.paramNames.join(", ") : "(none)"],
|
|
31
|
+
]);
|
|
32
|
+
this.newLine();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Command, tableNameFor } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
const HEADER = `// Channel authorization rules. Loaded once at boot by BroadcastProvider.
|
|
4
|
+
// Patterns use the file-routing [param] placeholder syntax.
|
|
5
|
+
`;
|
|
6
|
+
|
|
7
|
+
const BROADCAST_IMPORT = `import { Broadcast } from "@zerotal/broadcasting";`;
|
|
8
|
+
const USER_IMPORT = `import type { User } from "../app/models/User.ts";`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scaffolds a channel authorization rule into `routes/channels.ts`. Channels are plain
|
|
12
|
+
* `Broadcast.channel(...)` registrations, not classes — so this appends a rule rather than
|
|
13
|
+
* creating a new file per channel.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* bun zt make:channel Order // -> orders.[id] (private)
|
|
17
|
+
* bun zt make:channel orders.[orderId] // -> orders.[orderId] (private)
|
|
18
|
+
* bun zt make:channel chat.[roomId] -p // -> presence rule
|
|
19
|
+
*/
|
|
20
|
+
export class MakeChannelCommand extends Command {
|
|
21
|
+
static override commandName = "make:channel";
|
|
22
|
+
static override description = "Add a channel authorization rule to routes/channels.ts";
|
|
23
|
+
static override needsApp = false;
|
|
24
|
+
static override args = [
|
|
25
|
+
{
|
|
26
|
+
name: "name",
|
|
27
|
+
required: true,
|
|
28
|
+
description: "Channel pattern or model name (e.g. orders.[orderId] or Order)",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
static override flags = [
|
|
32
|
+
{
|
|
33
|
+
name: "presence",
|
|
34
|
+
short: "p",
|
|
35
|
+
type: "boolean" as const,
|
|
36
|
+
description: "Generate a presence-channel rule (returns member data)",
|
|
37
|
+
default: false,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
async run(): Promise<void> {
|
|
42
|
+
const raw = this.args["name"]!;
|
|
43
|
+
const presence = this.flags["presence"] as boolean;
|
|
44
|
+
const pattern = normalizePattern(raw);
|
|
45
|
+
const path = "routes/channels.ts";
|
|
46
|
+
|
|
47
|
+
let content = (await Bun.file(path).exists())
|
|
48
|
+
? await Bun.file(path).text()
|
|
49
|
+
: `${HEADER}${BROADCAST_IMPORT}\n${USER_IMPORT}\n`;
|
|
50
|
+
|
|
51
|
+
if (content.includes(`Broadcast.channel("${pattern}"`)) {
|
|
52
|
+
this.error(`A rule for "${pattern}" already exists in ${path}.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Ensure the required imports exist when appending to a hand-written file.
|
|
57
|
+
if (!content.includes("@zerotal/broadcasting")) {
|
|
58
|
+
content = `${BROADCAST_IMPORT}\n${content}`;
|
|
59
|
+
}
|
|
60
|
+
if (!content.includes("models/User")) {
|
|
61
|
+
content = content.replace(BROADCAST_IMPORT, `${BROADCAST_IMPORT}\n${USER_IMPORT}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
content = `${content.replace(/\n*$/, "")}\n\n${channelBlock(pattern, presence)}\n`;
|
|
65
|
+
await Bun.write(path, content);
|
|
66
|
+
this.info(`Added ${presence ? "presence" : "private"} channel rule "${pattern}" to ${path}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A bare model-ish name (no dot, no bracket) becomes `<plural snake>.[id]`. `Order` -> `orders.[id]`. */
|
|
71
|
+
function normalizePattern(name: string): string {
|
|
72
|
+
if (!name.includes(".") && !name.includes("[")) {
|
|
73
|
+
return `${tableNameFor(name)}.[id]`;
|
|
74
|
+
}
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Extract `[param]` names from a channel pattern, in order. */
|
|
79
|
+
function paramNames(pattern: string): string[] {
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
const re = /\[(\w+)\]/g;
|
|
82
|
+
let m: RegExpExecArray | null;
|
|
83
|
+
while ((m = re.exec(pattern)) !== null) out.push(m[1]!);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function channelBlock(pattern: string, presence: boolean): string {
|
|
88
|
+
const params = paramNames(pattern);
|
|
89
|
+
// Channel params are unused in the stub body; prefix with `_` so they pass noUnusedParameters.
|
|
90
|
+
// Rename (drop the underscore) when you reference them.
|
|
91
|
+
const sig = ["user: User", ...params.map((p) => `_${p}: string`)].join(", ");
|
|
92
|
+
const subject = params.length ? params.map((p) => `\`${p}\``).join(", ") : "this channel";
|
|
93
|
+
|
|
94
|
+
if (presence) {
|
|
95
|
+
return `// ${pattern} — presence channel: return member data to authorize + publish presence, or null to deny.
|
|
96
|
+
Broadcast.channel("${pattern}", (${sig}) => {
|
|
97
|
+
if (user == null) return null;
|
|
98
|
+
// TODO: authorize \`user\` against ${subject}, then return the info to expose to other members.
|
|
99
|
+
return { id: user.id };
|
|
100
|
+
});`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return `// ${pattern} — private channel: return true when the user may listen.
|
|
104
|
+
Broadcast.channel("${pattern}", (${sig}) => {
|
|
105
|
+
// TODO: authorize \`user\` against ${subject}.
|
|
106
|
+
return user != null;
|
|
107
|
+
});`;
|
|
108
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { deepMerge } from "@zerotal/core";
|
|
2
|
+
import type { ConfigValidator, ConfigIssue } from "@zerotal/core/config";
|
|
3
|
+
|
|
4
|
+
export interface BroadcastConfigShape {
|
|
5
|
+
/** WebSocket upgrade path. Default: '/app/ws'. For the pusher driver, clients connect to /app/{APP_KEY}. */
|
|
6
|
+
path: string;
|
|
7
|
+
/**
|
|
8
|
+
* Broadcast driver.
|
|
9
|
+
* - `'null'` - disabled (default)
|
|
10
|
+
* - `'ws'` - single-server in-process WebSocket (Zerotal native protocol)
|
|
11
|
+
* - `'redis'` - Redis Pub/Sub fan-out for horizontal scaling (Zerotal native protocol)
|
|
12
|
+
* - `'pusher'` - Pusher-compatible wire protocol (works with any Pusher-protocol client)
|
|
13
|
+
*/
|
|
14
|
+
driver: "null" | "ws" | "redis" | "pusher";
|
|
15
|
+
/**
|
|
16
|
+
* Redis connection options - required when driver is `'redis'`.
|
|
17
|
+
* @example
|
|
18
|
+
* redis: { url: Bun.env.REDIS_URL ?? 'redis://localhost:6379' }
|
|
19
|
+
*/
|
|
20
|
+
redis?: { url: string };
|
|
21
|
+
/**
|
|
22
|
+
* Pusher credentials - required when driver is `'pusher'`.
|
|
23
|
+
* Configure a Pusher-compatible client to connect to ws://host/app/{appKey}.
|
|
24
|
+
* @example
|
|
25
|
+
* pusher: {
|
|
26
|
+
* appKey: Bun.env.PUSHER_APP_KEY!,
|
|
27
|
+
* appSecret: Bun.env.PUSHER_APP_SECRET!,
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
pusher?: {
|
|
31
|
+
appKey: string;
|
|
32
|
+
appSecret: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const defaults: BroadcastConfigShape = {
|
|
37
|
+
path: "/app/ws",
|
|
38
|
+
driver: "null",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function BroadcastConfig(
|
|
42
|
+
overrides: Partial<BroadcastConfigShape> = {},
|
|
43
|
+
): BroadcastConfigShape {
|
|
44
|
+
return deepMerge(defaults, overrides);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DRIVERS = new Set<string>(["null", "ws", "redis", "pusher"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate the `broadcasting` config namespace at boot. A driver whose required
|
|
51
|
+
* credentials are missing can never deliver a message, so those are errors in
|
|
52
|
+
* any environment. Registered by {@link BroadcastProvider} via
|
|
53
|
+
* `app.registerConfigValidator("broadcasting", …)`.
|
|
54
|
+
*/
|
|
55
|
+
export const validateBroadcastConfig: ConfigValidator = (value) => {
|
|
56
|
+
const cfg = value as Partial<BroadcastConfigShape> | undefined;
|
|
57
|
+
const issues: ConfigIssue[] = [];
|
|
58
|
+
const driver = cfg?.driver ?? "null";
|
|
59
|
+
|
|
60
|
+
if (!DRIVERS.has(driver)) {
|
|
61
|
+
issues.push({
|
|
62
|
+
level: "error",
|
|
63
|
+
message: `broadcasting.driver "${driver}" is unknown — use "null", "ws", "redis", or "pusher".`,
|
|
64
|
+
});
|
|
65
|
+
return issues;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (driver === "redis") {
|
|
69
|
+
const url = cfg?.redis?.url ?? "";
|
|
70
|
+
if (url.length === 0) {
|
|
71
|
+
issues.push({
|
|
72
|
+
level: "error",
|
|
73
|
+
message:
|
|
74
|
+
'broadcasting.driver is "redis" but broadcasting.redis.url is unset — an unset ' +
|
|
75
|
+
"REDIS_URL is the usual culprit. Point it at the Redis instance all servers share.",
|
|
76
|
+
});
|
|
77
|
+
} else if (!/^rediss?:\/\//.test(url)) {
|
|
78
|
+
issues.push({
|
|
79
|
+
level: "error",
|
|
80
|
+
message:
|
|
81
|
+
"broadcasting.redis.url does not start with redis:// (or rediss:// for TLS) — " +
|
|
82
|
+
"set a full Redis connection URL.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (driver === "pusher") {
|
|
88
|
+
if (!cfg?.pusher?.appKey || !cfg.pusher.appSecret) {
|
|
89
|
+
issues.push({
|
|
90
|
+
level: "error",
|
|
91
|
+
message:
|
|
92
|
+
'broadcasting.driver is "pusher" but pusher.appKey/appSecret are not both set — ' +
|
|
93
|
+
"clients cannot authenticate to private channels without them.",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return issues;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Register this package's config namespace for typed config() dot-paths.
|
|
102
|
+
declare module "@zerotal/core" {
|
|
103
|
+
interface ConfigRegistry {
|
|
104
|
+
broadcasting: BroadcastConfigShape;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { RequestContext } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Read the client's Echo socket id from the current request's `X-Socket-ID` header, if any. */
|
|
4
|
+
export function currentSocketId(): string | undefined {
|
|
5
|
+
return RequestContext.tryGet()?.header("x-socket-id") ?? undefined;
|
|
6
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for all @zerotal/broadcasting errors. */
|
|
4
|
+
export class BroadcastError extends ZerotalError {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
code = "E_BROADCAST",
|
|
8
|
+
status = 500,
|
|
9
|
+
context?: Record<string, unknown>,
|
|
10
|
+
) {
|
|
11
|
+
super(message, code, status, context);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Thrown when the Broadcast facade is used before BroadcastProvider is registered. */
|
|
16
|
+
export class BroadcastProviderNotRegisteredError extends BroadcastError {
|
|
17
|
+
constructor() {
|
|
18
|
+
super("[Zerotal] BroadcastProvider not registered.", "E_BROADCAST_NOT_REGISTERED", 500);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Thrown when a channel pattern is interpolated without a required parameter. */
|
|
23
|
+
export class MissingChannelParameterError extends BroadcastError {
|
|
24
|
+
constructor(key: string) {
|
|
25
|
+
super(
|
|
26
|
+
`[Zerotal Broadcasting] Missing channel parameter: "${key}"`,
|
|
27
|
+
"E_BROADCAST_MISSING_PARAM",
|
|
28
|
+
500,
|
|
29
|
+
{ key },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { currentApp } from "@zerotal/core";
|
|
2
|
+
import { BroadcastProviderNotRegisteredError } from "../errors.ts";
|
|
3
|
+
import { BroadcastFake } from "../BroadcastFake.ts";
|
|
4
|
+
import { channelRegistry } from "../ChannelRegistry.ts";
|
|
5
|
+
import type { ChannelCallback } from "../ChannelRegistry.ts";
|
|
6
|
+
import { AnonymousBroadcast } from "../AnonymousBroadcast.ts";
|
|
7
|
+
import { privateChannel, presenceChannel } from "../Channel.ts";
|
|
8
|
+
import type { BroadcastManager } from "../BroadcastManager.ts";
|
|
9
|
+
import type { BroadcastEvent } from "../types.ts";
|
|
10
|
+
import type { PresenceMember } from "../BroadcastManager.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Test-only override. When set by `Broadcast.fake()`, it takes priority over the
|
|
14
|
+
* container binding so tests can assert broadcasts without a live driver. This
|
|
15
|
+
* is the package's single documented test hatch (cf. `DB._connection`); the
|
|
16
|
+
* container remains the source of truth in production.
|
|
17
|
+
*/
|
|
18
|
+
let _fake: BroadcastFake | null = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Broadcast facade — thin proxy to the active BroadcastManager.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // In a controller
|
|
25
|
+
* Broadcast.send(new PostUpdated(post));
|
|
26
|
+
* Broadcast.to('posts', 'PostDeleted', { id: post.id });
|
|
27
|
+
*
|
|
28
|
+
* // In tests
|
|
29
|
+
* const fake = Broadcast.fake();
|
|
30
|
+
* // ... trigger action ...
|
|
31
|
+
* fake.assertBroadcast('PostUpdated', 'posts');
|
|
32
|
+
* Broadcast.resetFake();
|
|
33
|
+
*/
|
|
34
|
+
export class Broadcast {
|
|
35
|
+
private static _get(): BroadcastManager | BroadcastFake {
|
|
36
|
+
if (_fake) return _fake;
|
|
37
|
+
try {
|
|
38
|
+
return currentApp().container.makeSync("broadcast");
|
|
39
|
+
} catch {
|
|
40
|
+
throw new BroadcastProviderNotRegisteredError();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register a channel authorization callback. Call this in `routes/channels.ts`.
|
|
46
|
+
*
|
|
47
|
+
* Patterns use `[param]` placeholders (one channel segment each), passed to the callback
|
|
48
|
+
* positionally after the authenticated user. Return a boolean for private channels, or a
|
|
49
|
+
* member-data object for presence channels (`false`/`null` to deny).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* // routes/channels.ts
|
|
53
|
+
* Broadcast.channel("orders.[orderId]", async (user: User, orderId: string) => {
|
|
54
|
+
* return user.id === (await Order.findOrNew(orderId)).userId;
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* Broadcast.channel("chat.[roomId]", (user: User, roomId: string) => {
|
|
58
|
+
* return user.canJoin(roomId) ? { id: user.id, name: user.name } : null;
|
|
59
|
+
* });
|
|
60
|
+
*/
|
|
61
|
+
static channel(pattern: string, callback: ChannelCallback): void {
|
|
62
|
+
channelRegistry.register(pattern, callback);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Registered channel patterns (for inspection / `channel:list`). */
|
|
66
|
+
static channels(): { pattern: string; paramNames: string[] }[] {
|
|
67
|
+
return channelRegistry.all();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Begin an anonymous broadcast (no event class) on a public channel.
|
|
72
|
+
* @example Broadcast.on(`orders.${id}`).as("OrderPlaced").with(order).send();
|
|
73
|
+
*/
|
|
74
|
+
static on(channel: string): AnonymousBroadcast {
|
|
75
|
+
return new AnonymousBroadcast(channel);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Anonymous broadcast on a private channel (prefixes `private-`). */
|
|
79
|
+
static private(channel: string): AnonymousBroadcast {
|
|
80
|
+
return new AnonymousBroadcast(privateChannel(channel));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Anonymous broadcast on a presence channel (prefixes `presence-`). */
|
|
84
|
+
static presence(channel: string): AnonymousBroadcast {
|
|
85
|
+
return new AnonymousBroadcast(presenceChannel(channel));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Broadcast an event to all channels declared in broadcastOn().
|
|
90
|
+
* `opts.exceptSocketId` skips one connection (used by `broadcast(event).toOthers()`).
|
|
91
|
+
*/
|
|
92
|
+
static send(event: BroadcastEvent, opts?: { exceptSocketId?: string }): void {
|
|
93
|
+
Broadcast._get().send(event, opts);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Broadcast a raw event to a specific channel.
|
|
98
|
+
* @example
|
|
99
|
+
* Broadcast.to('posts', 'PostViewed', { id: 1, viewedAt: new Date() });
|
|
100
|
+
*/
|
|
101
|
+
static to(
|
|
102
|
+
channel: string,
|
|
103
|
+
eventName: string,
|
|
104
|
+
data?: unknown,
|
|
105
|
+
opts?: { exceptSocketId?: string },
|
|
106
|
+
): void {
|
|
107
|
+
Broadcast._get().to(channel, eventName, data, opts);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Return all members currently subscribed to a presence channel.
|
|
112
|
+
* Only works with the real BroadcastManager (not the fake).
|
|
113
|
+
*/
|
|
114
|
+
static getMembers(channel: string): PresenceMember[] {
|
|
115
|
+
const inst = Broadcast._get();
|
|
116
|
+
if (inst instanceof BroadcastFake) return [];
|
|
117
|
+
return inst.getMembers(channel);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Replace the real manager with a BroadcastFake for the duration of the test.
|
|
122
|
+
* Call `Broadcast.resetFake()` in afterEach to restore.
|
|
123
|
+
*
|
|
124
|
+
* @returns the fake, for assertions
|
|
125
|
+
*/
|
|
126
|
+
static fake(): BroadcastFake {
|
|
127
|
+
_fake = new BroadcastFake();
|
|
128
|
+
return _fake;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Remove the fake and restore container-backed resolution. */
|
|
132
|
+
static resetFake(): void {
|
|
133
|
+
_fake = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Ambient declarations specific to this package.
|
|
2
|
+
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
+
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
+
|
|
5
|
+
interface ServerWebSocket<T = unknown> {
|
|
6
|
+
readonly data: T;
|
|
7
|
+
send(message: string | Uint8Array): void;
|
|
8
|
+
close(code?: number, reason?: string): void;
|
|
9
|
+
readyState: number;
|
|
10
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { BroadcastManager } from "./BroadcastManager.ts";
|
|
2
|
+
export type { PresenceMember, PresenceAuthFn } from "./BroadcastManager.ts";
|
|
3
|
+
export { TypedBroadcastManager } from "./TypedBroadcastManager.ts";
|
|
4
|
+
export type {
|
|
5
|
+
BroadcastChannelMap,
|
|
6
|
+
ChannelParams,
|
|
7
|
+
ChannelParamRecord,
|
|
8
|
+
EventsOf,
|
|
9
|
+
PayloadOf,
|
|
10
|
+
StaticChannels,
|
|
11
|
+
ParameterizedChannels,
|
|
12
|
+
TypedBroadcastEvent,
|
|
13
|
+
} from "./TypedBroadcastManager.ts";
|
|
14
|
+
export { PusherCompatManager } from "./PusherCompatManager.ts";
|
|
15
|
+
export type { PusherPresenceResolver } from "./PusherCompatManager.ts";
|
|
16
|
+
export { RedisBroadcastDriver } from "./RedisBroadcastDriver.ts";
|
|
17
|
+
export { BroadcastFake } from "./BroadcastFake.ts";
|
|
18
|
+
export { Broadcast } from "./facades/Broadcast.ts";
|
|
19
|
+
export { BroadcastProvider } from "./provider/BroadcastProvider.ts";
|
|
20
|
+
export { BroadcastConfig } from "./config.ts";
|
|
21
|
+
export { channel, privateChannel, presenceChannel, isPrivateChannel } from "./Channel.ts";
|
|
22
|
+
export { BroadcastingEvent, broadcastOnce } from "./BroadcastingEvent.ts";
|
|
23
|
+
export { broadcastsModelEvents } from "./BroadcastsModelEvents.ts";
|
|
24
|
+
export type {
|
|
25
|
+
BroadcastsModelEventsOptions,
|
|
26
|
+
ModelBroadcastEventName,
|
|
27
|
+
} from "./BroadcastsModelEvents.ts";
|
|
28
|
+
export { broadcast, PendingBroadcast } from "./PendingBroadcast.ts";
|
|
29
|
+
export { AnonymousBroadcast } from "./AnonymousBroadcast.ts";
|
|
30
|
+
export { ChannelRegistry, channelRegistry, compileChannelPattern } from "./ChannelRegistry.ts";
|
|
31
|
+
export type { ChannelCallback, PresenceMemberData, AuthorizeResult } from "./ChannelRegistry.ts";
|
|
32
|
+
export type { BroadcastEvent, WsConnectionData, ChannelAuthFn } from "./types.ts";
|
|
33
|
+
export type { RecordedBroadcast, RecordedBroadcast as BroadcastRecord } from "./BroadcastFake.ts";
|
|
34
|
+
|
|
35
|
+
// Typed error vocabulary
|
|
36
|
+
export * from "./errors.ts";
|