@zerotal/broadcasting 1.7.0 → 1.7.2
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/package.json +2 -2
- package/src/BroadcastManager.ts +8 -2
- package/src/BroadcastsModelEvents.ts +1 -1
- package/src/ChannelRegistry.ts +17 -4
- package/src/PusherCompatManager.ts +3 -2
- package/src/config.ts +13 -0
- package/src/currentSocketId.ts +1 -1
- package/src/facades/Broadcast.ts +1 -1
- package/src/index.ts +3 -0
- package/src/provider/BroadcastProvider.ts +17 -2
- package/src/types.ts +2 -0
- package/src/global.d.ts +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/broadcasting",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.7.
|
|
33
|
+
"@zerotal/core": "1.7.2"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.8.0"
|
package/src/BroadcastManager.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
1
2
|
import { safeEqual, hmacHex } from "@zerotal/core";
|
|
2
3
|
import { isPrivateChannel } from "./Channel.ts";
|
|
3
4
|
import type { BroadcastEvent, WsConnectionData, ClientMessage, ChannelAuthFn } from "./types.ts";
|
|
@@ -290,8 +291,13 @@ export class BroadcastManager {
|
|
|
290
291
|
channelData?: string,
|
|
291
292
|
): Promise<void> {
|
|
292
293
|
const isPresence = channel.startsWith("presence-");
|
|
293
|
-
|
|
294
|
+
// Block body, not an expression body: `ServerWebSocket.send` returns a send
|
|
295
|
+
// status, and `return deny()` in a `Promise<void>` method would otherwise
|
|
296
|
+
// try to return that number. The status is not useful here — a client that
|
|
297
|
+
// failed to receive its own denial is already gone.
|
|
298
|
+
const deny = (): void => {
|
|
294
299
|
ws.send(JSON.stringify({ event: "subscription_error", channel, message: "Unauthorized" }));
|
|
300
|
+
};
|
|
295
301
|
|
|
296
302
|
if (isPresence) {
|
|
297
303
|
// Two ways to authorize a presence subscription:
|
|
@@ -352,7 +358,7 @@ export class BroadcastManager {
|
|
|
352
358
|
|
|
353
359
|
private _unsubscribe(ws: WS, channel: string): void {
|
|
354
360
|
this._subs.get(channel)?.delete(ws.data.id);
|
|
355
|
-
// Presence: an explicit leave (e.g.
|
|
361
|
+
// Presence: an explicit leave (e.g. Socket.leave on a component teardown / SPA navigation) must
|
|
356
362
|
// remove the member and notify the remaining subscribers — the same cleanup a full disconnect
|
|
357
363
|
// does in handleClose. Without this, a member who navigated away lingers in others' "who's here"
|
|
358
364
|
// until they close the tab.
|
|
@@ -45,7 +45,7 @@ type ModelClassWithEvents<M> = (new (...args: never[]) => M) & {
|
|
|
45
45
|
* // optional: with: (order) => ({ id: order.id, status: order.status }),
|
|
46
46
|
* });
|
|
47
47
|
*
|
|
48
|
-
* // Client:
|
|
48
|
+
* // Client: Socket.private(`orders.${id}`).listen("OrderUpdated", (e) => ...)
|
|
49
49
|
*/
|
|
50
50
|
export function broadcastsModelEvents<M extends object>(
|
|
51
51
|
ModelClass: ModelClassWithEvents<M>,
|
package/src/ChannelRegistry.ts
CHANGED
|
@@ -21,8 +21,19 @@ export interface PresenceMemberData {
|
|
|
21
21
|
* - Presence channel: return a member-data object to authorize + publish presence, or
|
|
22
22
|
* `false`/`null`/`undefined` to deny.
|
|
23
23
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* `User` is a type parameter so a rule can annotate the user it expects:
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* Broadcast.channel("orders.[orderId]", (user: User, orderId: string) => …);
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* infers `User` from the callback rather than failing against a fixed `unknown`.
|
|
32
|
+
* The default keeps every existing rule — and the registry's own storage, which
|
|
33
|
+
* cannot know the app's user model — working unchanged.
|
|
34
|
+
*/
|
|
35
|
+
export type ChannelCallback<User = unknown> = (
|
|
36
|
+
user: User,
|
|
26
37
|
...params: string[]
|
|
27
38
|
) =>
|
|
28
39
|
| boolean
|
|
@@ -77,9 +88,11 @@ export class ChannelRegistry {
|
|
|
77
88
|
* @example
|
|
78
89
|
* registry.register("orders.[orderId]", (user, orderId) => user.id === ownerOf(orderId));
|
|
79
90
|
*/
|
|
80
|
-
register(pattern: string, callback: ChannelCallback): void {
|
|
91
|
+
register<User = unknown>(pattern: string, callback: ChannelCallback<User>): void {
|
|
81
92
|
const { regex, paramNames } = compileChannelPattern(pattern);
|
|
82
|
-
|
|
93
|
+
// Stored as the unknown-user form: the registry calls every rule with whatever
|
|
94
|
+
// the auth middleware produced, which it has no way to type.
|
|
95
|
+
this._channels.push({ pattern, regex, paramNames, callback: callback as ChannelCallback });
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
/** Registered channel patterns (for `channel:list` / introspection). */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
1
2
|
import { safeEqual, hmacHex } from "@zerotal/core";
|
|
2
3
|
import { BroadcastManager, _isValidChannel } from "./BroadcastManager.ts";
|
|
3
4
|
import type { PresenceMember } from "./BroadcastManager.ts";
|
|
@@ -44,9 +45,9 @@ export type PusherPresenceResolver = (
|
|
|
44
45
|
* - Auth for private/presence channels uses HMAC-SHA256 signatures
|
|
45
46
|
*
|
|
46
47
|
* Auth flow:
|
|
47
|
-
* 1.
|
|
48
|
+
* 1. The client calls POST /broadcasting/auth with socket_id + channel_name
|
|
48
49
|
* 2. Server signs with `signAuth()` and returns `{auth: "key:sig"}`
|
|
49
|
-
* 3.
|
|
50
|
+
* 3. The client includes `auth` in the pusher:subscribe message
|
|
50
51
|
* 4. Manager verifies HMAC before allowing subscription
|
|
51
52
|
*
|
|
52
53
|
* @example
|
package/src/config.ts
CHANGED
|
@@ -31,6 +31,19 @@ export interface BroadcastConfigShape {
|
|
|
31
31
|
appKey: string;
|
|
32
32
|
appSecret: string;
|
|
33
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Where the `Broadcast.channel(...)` authorization rules live, relative to the
|
|
36
|
+
* project root (or absolute).
|
|
37
|
+
*
|
|
38
|
+
* Defaults to `routes/channels.ts`. Set it when the app keeps its routes
|
|
39
|
+
* somewhere else — an app that scaffolded its HTTP routes into `app/routes`
|
|
40
|
+
* would otherwise grow a second, unrelated `routes/` directory holding one
|
|
41
|
+
* file.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* channels: "app/routes/channels.ts"
|
|
45
|
+
*/
|
|
46
|
+
channels?: string;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
const defaults: BroadcastConfigShape = {
|
package/src/currentSocketId.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RequestContext } from "@zerotal/core";
|
|
2
2
|
|
|
3
|
-
/** Read the client's
|
|
3
|
+
/** Read the client's socket id from the current request's `X-Socket-ID` header, if any. */
|
|
4
4
|
export function currentSocketId(): string | undefined {
|
|
5
5
|
return RequestContext.tryGet()?.header("x-socket-id") ?? undefined;
|
|
6
6
|
}
|
package/src/facades/Broadcast.ts
CHANGED
|
@@ -58,7 +58,7 @@ export class Broadcast {
|
|
|
58
58
|
* return user.canJoin(roomId) ? { id: user.id, name: user.name } : null;
|
|
59
59
|
* });
|
|
60
60
|
*/
|
|
61
|
-
static channel(pattern: string, callback: ChannelCallback): void {
|
|
61
|
+
static channel<User = unknown>(pattern: string, callback: ChannelCallback<User>): void {
|
|
62
62
|
channelRegistry.register(pattern, callback);
|
|
63
63
|
}
|
|
64
64
|
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export { BroadcastFake } from "./BroadcastFake.ts";
|
|
|
18
18
|
export { Broadcast } from "./facades/Broadcast.ts";
|
|
19
19
|
export { BroadcastProvider } from "./provider/BroadcastProvider.ts";
|
|
20
20
|
export { BroadcastConfig } from "./config.ts";
|
|
21
|
+
// Exported so an app can cast an `env()`-derived driver to the literal union it
|
|
22
|
+
// has to satisfy, the way `config/queue.ts` does with `QueueConfigShape`.
|
|
23
|
+
export type { BroadcastConfigShape } from "./config.ts";
|
|
21
24
|
export { channel, privateChannel, presenceChannel, isPrivateChannel } from "./Channel.ts";
|
|
22
25
|
export { BroadcastingEvent, broadcastOnce } from "./BroadcastingEvent.ts";
|
|
23
26
|
export { broadcastsModelEvents } from "./BroadcastsModelEvents.ts";
|
|
@@ -19,11 +19,21 @@ declare module "@zerotal/core" {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Where channel rules live when `config.broadcasting.channels` says nothing. */
|
|
23
|
+
const DEFAULT_CHANNELS_PATH = "routes/channels.ts";
|
|
24
|
+
|
|
25
|
+
/** POSIX `/…` and Windows `C:\…` both, since this joins a path by hand. */
|
|
26
|
+
function isAbsolutePath(path: string): boolean {
|
|
27
|
+
return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
export class BroadcastProvider extends ServiceProvider {
|
|
23
31
|
static override provides = ["broadcast"] as const;
|
|
24
32
|
static override environments: AppEnvironment[] = ["web", "worker", "test", "console"];
|
|
25
33
|
|
|
26
34
|
private _redisDriver: RedisBroadcastDriver | undefined;
|
|
35
|
+
/** Resolved in `onRegister`, read in `onBooting`, so config is read once. */
|
|
36
|
+
private _channelsPath = DEFAULT_CHANNELS_PATH;
|
|
27
37
|
|
|
28
38
|
override onRegister(): void {
|
|
29
39
|
// Refuse a production boot when the configured driver is missing the
|
|
@@ -36,6 +46,7 @@ export class BroadcastProvider extends ServiceProvider {
|
|
|
36
46
|
const configManager = this.app.container.tryMake("config") as ConfigManager | null;
|
|
37
47
|
const raw = configManager?.get<Partial<BroadcastConfigShape>>("broadcasting") ?? {};
|
|
38
48
|
const cfg = BroadcastConfig(raw);
|
|
49
|
+
this._channelsPath = cfg.channels ?? DEFAULT_CHANNELS_PATH;
|
|
39
50
|
|
|
40
51
|
let manager: BroadcastManager;
|
|
41
52
|
|
|
@@ -104,11 +115,15 @@ export class BroadcastProvider extends ServiceProvider {
|
|
|
104
115
|
* rules register before the first `/broadcasting/auth` request. Missing file is fine.
|
|
105
116
|
*/
|
|
106
117
|
private async _loadChannelRoutes(): Promise<void> {
|
|
107
|
-
const
|
|
118
|
+
const configured = this._channelsPath;
|
|
119
|
+
// Absolute stays as given; relative resolves from the project root, so an app
|
|
120
|
+
// that keeps its routes under `app/routes` can point at
|
|
121
|
+
// `app/routes/channels.ts` instead of growing a second `routes/` directory.
|
|
122
|
+
const path = isAbsolutePath(configured) ? configured : `${process.cwd()}/${configured}`;
|
|
108
123
|
try {
|
|
109
124
|
if (await Bun.file(path).exists()) await import(path);
|
|
110
125
|
} catch (err) {
|
|
111
|
-
frameworkLog("broadcast").error(
|
|
126
|
+
frameworkLog("broadcast").error(`Failed to load ${configured}`, undefined, err);
|
|
112
127
|
}
|
|
113
128
|
}
|
|
114
129
|
|
package/src/types.ts
CHANGED
package/src/global.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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
|
-
}
|