@voltro/plugin-broadcast 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +1634 -0
- package/dist/index.d.ts +241 -0
- package/dist/index.js +280 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { ChangeEvent } from '@voltro/database';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { Schema } from 'effect';
|
|
4
|
+
import { VoltroPlugin } from '@voltro/protocol';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wire a store to a broadcast provider. Publishes local changes outward
|
|
8
|
+
* and injects remote changes inward (skipping own-origin). Returns a
|
|
9
|
+
* handle whose `close()` tears everything down.
|
|
10
|
+
*
|
|
11
|
+
* Publish failures are logged + dropped — they MUST NOT propagate onto
|
|
12
|
+
* the mutation path (the write already committed + inline-emitted
|
|
13
|
+
* locally; only cross-replica fan-out is affected).
|
|
14
|
+
*/
|
|
15
|
+
export declare const attachBroadcastBus: (options: AttachBroadcastBusOptions) => Promise<BroadcastBusHandle>;
|
|
16
|
+
|
|
17
|
+
export declare interface AttachBroadcastBusOptions {
|
|
18
|
+
readonly store: BroadcastStore;
|
|
19
|
+
readonly provider: BroadcastProvider;
|
|
20
|
+
/** This replica's id — stamped as `origin` on every publish and matched
|
|
21
|
+
* against incoming `origin` to skip own writes. */
|
|
22
|
+
readonly replicaId: string;
|
|
23
|
+
readonly logger?: BroadcastBusLogger;
|
|
24
|
+
/** Channel override (tests). Default `voltro:changes`. */
|
|
25
|
+
readonly channel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The channel the bus fans ChangeEvents out on. */
|
|
29
|
+
export declare const BROADCAST_CHANNEL = "voltro:changes";
|
|
30
|
+
|
|
31
|
+
export declare interface BroadcastBusHandle {
|
|
32
|
+
/** Detach the onChange listener, the bus subscription, and close the
|
|
33
|
+
* provider. Idempotent. */
|
|
34
|
+
readonly close: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export declare interface BroadcastBusLogger {
|
|
38
|
+
info: (message: string, fields?: Record<string, unknown>) => void;
|
|
39
|
+
warn: (message: string, fields?: Record<string, unknown>) => void;
|
|
40
|
+
debug: (message: string, fields?: Record<string, unknown>) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What rides the wire on the `voltro:changes` channel. `origin` is the
|
|
45
|
+
* publishing replica's id; subscribers skip their OWN origin (the writer
|
|
46
|
+
* already inline-emitted locally) so there is no double-emit and no need
|
|
47
|
+
* for a dedup table. `event` is the app-mutation ChangeEvent to inject
|
|
48
|
+
* into every OTHER replica's store emitter.
|
|
49
|
+
*/
|
|
50
|
+
export declare interface BroadcastEnvelope {
|
|
51
|
+
readonly origin: string;
|
|
52
|
+
readonly event: ChangeEvent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Transport failure. `transient` flags broker hiccups (connection reset,
|
|
57
|
+
* timeout) worth a retry vs configuration faults (bad URL, missing dep)
|
|
58
|
+
* that won't fix themselves. The wiring never rethrows it onto the
|
|
59
|
+
* mutation path — a publish failure logs + drops; local reactivity is
|
|
60
|
+
* unaffected.
|
|
61
|
+
*
|
|
62
|
+
* A `Schema.TaggedError` (the framework house style, same shape as
|
|
63
|
+
* `@voltro/plugin-storage`'s `StorageError`): it rides the Effect error
|
|
64
|
+
* channel with a stable `_tag`, is `instanceof`-checkable, and — should a
|
|
65
|
+
* future path ever surface it across the rpc wire — is Schema-encodable.
|
|
66
|
+
*/
|
|
67
|
+
export declare class BroadcastError extends BroadcastError_base {
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
declare const BroadcastError_base: Schema.TaggedErrorClass<BroadcastError, "BroadcastError", {
|
|
71
|
+
readonly _tag: Schema.tag<"BroadcastError">;
|
|
72
|
+
} & {
|
|
73
|
+
provider: typeof Schema.String;
|
|
74
|
+
message: typeof Schema.String;
|
|
75
|
+
transient: typeof Schema.Boolean;
|
|
76
|
+
cause: Schema.optional<typeof Schema.Unknown>;
|
|
77
|
+
}>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A broadcast plugin instance also exposes the resolved provider so the
|
|
81
|
+
* CLI's serve pipeline can attach the bus to the live DataStore. The
|
|
82
|
+
* extra fields ride on the returned `VoltroPlugin` and are read by the
|
|
83
|
+
* framework via `getBroadcastProvider`.
|
|
84
|
+
*/
|
|
85
|
+
export declare interface BroadcastPlugin extends VoltroPlugin {
|
|
86
|
+
readonly broadcast: {
|
|
87
|
+
readonly provider: BroadcastProvider;
|
|
88
|
+
readonly url: string | null;
|
|
89
|
+
readonly crossReplica: boolean;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export declare const broadcastPlugin: (options?: BroadcastPluginOptions) => BroadcastPlugin;
|
|
94
|
+
|
|
95
|
+
export declare interface BroadcastPluginOptions {
|
|
96
|
+
/** Provider name (`'redis' | 'nats' | 'memory'`) or a pre-built
|
|
97
|
+
* `BroadcastProvider`. Default: inferred from `BROADCAST_URL` /
|
|
98
|
+
* `BROADCAST_PROVIDER` / `REDIS_URL` env, else `'memory'`. */
|
|
99
|
+
readonly provider?: BroadcastProviderName | BroadcastProvider;
|
|
100
|
+
/** Broker URL — a broker-agnostic override (redis:// or nats://). Takes
|
|
101
|
+
* precedence over the named-connection env. Default `BROADCAST_URL` env. */
|
|
102
|
+
readonly url?: string;
|
|
103
|
+
/** Named connection for the redis provider — resolves the broker URL via the
|
|
104
|
+
* shared convention `<NAME>_REDIS_URL` → `REDIS_URL` (like cache / kv /
|
|
105
|
+
* ratelimit). Default `'broadcast'` (→ `BROADCAST_REDIS_URL`, then
|
|
106
|
+
* `REDIS_URL`). Point broadcast at its own server or the shared one purely
|
|
107
|
+
* by which env var you set. */
|
|
108
|
+
readonly connection?: string;
|
|
109
|
+
/** Channel the bus publishes/subscribes on. Namespace it per app/env
|
|
110
|
+
* (e.g. `myapp:prod:changes`) when several deployments share one
|
|
111
|
+
* broker — otherwise they inject each other's ChangeEvents (spurious
|
|
112
|
+
* wake-ups; matcher-scoped, so no data leak, but wasted work).
|
|
113
|
+
* Default `BROADCAST_CHANNEL` env, else `voltro:changes`. */
|
|
114
|
+
readonly channel?: string;
|
|
115
|
+
/** Disambiguates multiple instances of this plugin in one app. */
|
|
116
|
+
readonly name?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Dumb pub/sub transport. One channel, string payloads, no framing.
|
|
121
|
+
*
|
|
122
|
+
* - `publish` — fire-and-forget a payload onto a channel. Transient
|
|
123
|
+
* broker hiccups should fail the Effect (the caller logs + drops;
|
|
124
|
+
* cross-replica reactivity degrades, local reactivity is untouched).
|
|
125
|
+
* - `subscribe` — register a handler for every payload on a channel.
|
|
126
|
+
* Returns an unsubscribe Effect. The handler is invoked once per
|
|
127
|
+
* received message; it must not throw (errors are swallowed by the
|
|
128
|
+
* wiring's decode guard).
|
|
129
|
+
* - `close` — tear down connections / the pub + sub sockets.
|
|
130
|
+
*
|
|
131
|
+
* Implementations are constructed eagerly but connect lazily where the
|
|
132
|
+
* backend allows, so a process that wires the plugin but never publishes
|
|
133
|
+
* doesn't pay a connect cost on an unrelated path.
|
|
134
|
+
*/
|
|
135
|
+
export declare interface BroadcastProvider {
|
|
136
|
+
/** Backend name — `'redis' | 'nats' | 'memory'`. Surfaced in the boot banner. */
|
|
137
|
+
readonly name: string;
|
|
138
|
+
readonly publish: (channel: string, payload: string) => Effect.Effect<void, BroadcastError>;
|
|
139
|
+
readonly subscribe: (channel: string, handler: (payload: string) => void) => Effect.Effect<() => void, BroadcastError>;
|
|
140
|
+
readonly close: () => Effect.Effect<void>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Provider id understood by `resolveBroadcastProvider`. */
|
|
144
|
+
export declare type BroadcastProviderName = 'redis' | 'nats' | 'memory';
|
|
145
|
+
|
|
146
|
+
/** The minimal store surface the bus drives — a subset of `DataStore` so
|
|
147
|
+
* the bus stays decoupled from the concrete store classes. */
|
|
148
|
+
export declare interface BroadcastStore {
|
|
149
|
+
onChange: (listener: (event: ChangeEvent) => void) => () => void;
|
|
150
|
+
injectExternalChange: (event: ChangeEvent) => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pull the resolved broadcast carrier off a plugin list (the framework's
|
|
155
|
+
* serve pipeline calls this after building the store). Returns the FIRST
|
|
156
|
+
* broadcast plugin found, or `null`.
|
|
157
|
+
*/
|
|
158
|
+
export declare const getBroadcastPlugin: (plugins: ReadonlyArray<VoltroPlugin>) => BroadcastPlugin | null;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* In-process pub/sub over a named bus. Delivery is synchronous within the
|
|
162
|
+
* process. Two providers sharing a `bus` name see each other's publishes —
|
|
163
|
+
* the seam the cross-instance test exercises without a real broker.
|
|
164
|
+
*/
|
|
165
|
+
export declare const memoryProvider: (bus?: string) => BroadcastProvider;
|
|
166
|
+
|
|
167
|
+
declare interface NatsLike {
|
|
168
|
+
publish: (subject: string, data: Uint8Array) => void;
|
|
169
|
+
subscribe: (subject: string) => NatsSubscription;
|
|
170
|
+
drain: () => Promise<void>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export declare const natsProvider: (options: NatsProviderOptions) => BroadcastProvider;
|
|
174
|
+
|
|
175
|
+
export declare interface NatsProviderOptions {
|
|
176
|
+
/** `nats://host:port` (comma-separated for a cluster). Required. */
|
|
177
|
+
readonly url: string;
|
|
178
|
+
/** Bring your own connected NATS connection (e.g. for shared creds). */
|
|
179
|
+
readonly connection?: NatsLike;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
declare interface NatsSubscription extends AsyncIterable<{
|
|
183
|
+
data: Uint8Array;
|
|
184
|
+
}> {
|
|
185
|
+
unsubscribe: () => void;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The slice of ioredis the provider touches — keeps the optional dep out
|
|
189
|
+
* of the type surface. */
|
|
190
|
+
declare interface RedisLike {
|
|
191
|
+
publish: (channel: string, message: string) => Promise<number>;
|
|
192
|
+
subscribe: (channel: string) => Promise<unknown>;
|
|
193
|
+
unsubscribe: (channel: string) => Promise<unknown>;
|
|
194
|
+
on: (event: 'message', listener: (channel: string, message: string) => void) => void;
|
|
195
|
+
off: (event: 'message', listener: (channel: string, message: string) => void) => void;
|
|
196
|
+
duplicate: () => RedisLike;
|
|
197
|
+
quit: () => Promise<unknown>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export declare const redisProvider: (options: RedisProviderOptions) => BroadcastProvider;
|
|
201
|
+
|
|
202
|
+
export declare interface RedisProviderOptions {
|
|
203
|
+
/** `redis://[:pass@]host:port[/db]`. Required. */
|
|
204
|
+
readonly url: string;
|
|
205
|
+
/** Bring your own ioredis client (publisher). A `subscriber` is
|
|
206
|
+
* `client.duplicate()`d internally — a RESP connection in subscribe
|
|
207
|
+
* mode can't issue normal commands. */
|
|
208
|
+
readonly client?: RedisLike;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Excluded from this release type: resetMemoryBus */
|
|
212
|
+
|
|
213
|
+
export declare interface ResolveBroadcastOptions {
|
|
214
|
+
/** Explicit provider name or a pre-built `BroadcastProvider`. */
|
|
215
|
+
readonly provider?: BroadcastProviderName | BroadcastProvider;
|
|
216
|
+
/** Broker URL — a broker-agnostic override (redis:// or nats://). Takes
|
|
217
|
+
* precedence over the named-connection env. Default `BROADCAST_URL` env. */
|
|
218
|
+
readonly url?: string;
|
|
219
|
+
/** Named connection for the redis provider. The broker URL resolves via the
|
|
220
|
+
* shared convention `<NAME>_REDIS_URL` → `REDIS_URL` (like cache / kv /
|
|
221
|
+
* ratelimit), so a deployment points broadcast at the same server or its own
|
|
222
|
+
* purely by env. Default `'broadcast'` (→ `BROADCAST_REDIS_URL`). */
|
|
223
|
+
readonly connection?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the bus backend. Order: an object provider is used verbatim; a
|
|
228
|
+
* named provider builds from `url`/env; absent → `BROADCAST_PROVIDER` env,
|
|
229
|
+
* else inferred from a `redis://` / `nats://` URL, else `memory`.
|
|
230
|
+
*
|
|
231
|
+
* Returns the provider plus the URL it resolved (for the boot banner) and
|
|
232
|
+
* whether the choice is a real cross-replica bus or the single-process
|
|
233
|
+
* memory fallback.
|
|
234
|
+
*/
|
|
235
|
+
export declare const resolveBroadcastProvider: (options: ResolveBroadcastOptions, env: NodeJS.ProcessEnv) => {
|
|
236
|
+
provider: BroadcastProvider;
|
|
237
|
+
url: string | null;
|
|
238
|
+
crossReplica: boolean;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { Effect as e, Schema as t } from "effect";
|
|
2
|
+
import { definePlugin as n } from "@voltro/protocol";
|
|
3
|
+
//#region src/types.ts
|
|
4
|
+
var r = class extends t.TaggedError()("BroadcastError", {
|
|
5
|
+
provider: t.String,
|
|
6
|
+
message: t.String,
|
|
7
|
+
transient: t.Boolean,
|
|
8
|
+
cause: t.optional(t.Unknown)
|
|
9
|
+
}) {}, i = "voltro:changes", a = (e, t, n) => new r({
|
|
10
|
+
provider: e,
|
|
11
|
+
message: t,
|
|
12
|
+
transient: !0,
|
|
13
|
+
...n === void 0 ? {} : { cause: n }
|
|
14
|
+
}), o = (e, t, n) => new r({
|
|
15
|
+
provider: e,
|
|
16
|
+
message: t,
|
|
17
|
+
transient: !1,
|
|
18
|
+
...n === void 0 ? {} : { cause: n }
|
|
19
|
+
}), s = /* @__PURE__ */ new Map(), c = (e) => {
|
|
20
|
+
let t = s.get(e);
|
|
21
|
+
return t || (t = { handlers: /* @__PURE__ */ new Map() }, s.set(e, t)), t;
|
|
22
|
+
}, l = (e) => {
|
|
23
|
+
s.get(e)?.handlers.clear();
|
|
24
|
+
}, u = (t = `default-${Math.random().toString(36).slice(2)}`) => {
|
|
25
|
+
let n = c(t);
|
|
26
|
+
return {
|
|
27
|
+
name: "memory",
|
|
28
|
+
publish: (t, r) => e.sync(() => {
|
|
29
|
+
let e = n.handlers.get(t);
|
|
30
|
+
if (e) for (let t of e) try {
|
|
31
|
+
t(r);
|
|
32
|
+
} catch {}
|
|
33
|
+
}),
|
|
34
|
+
subscribe: (t, r) => e.sync(() => {
|
|
35
|
+
let e = n.handlers.get(t) ?? /* @__PURE__ */ new Set();
|
|
36
|
+
return e.add(r), n.handlers.set(t, e), () => {
|
|
37
|
+
e.delete(r);
|
|
38
|
+
};
|
|
39
|
+
}),
|
|
40
|
+
close: () => e.void
|
|
41
|
+
};
|
|
42
|
+
}, d = (t) => {
|
|
43
|
+
let n = null, r = () => n || (n = (async () => {
|
|
44
|
+
let e;
|
|
45
|
+
if (t.client) e = t.client;
|
|
46
|
+
else {
|
|
47
|
+
let n;
|
|
48
|
+
try {
|
|
49
|
+
n = (await import("ioredis")).default;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw o("redis", "the 'redis' broadcast provider requires the 'ioredis' optional dependency. Install it to enable it.", e);
|
|
52
|
+
}
|
|
53
|
+
e = new n(t.url);
|
|
54
|
+
}
|
|
55
|
+
let n = e.duplicate();
|
|
56
|
+
return {
|
|
57
|
+
pub: e,
|
|
58
|
+
sub: n
|
|
59
|
+
};
|
|
60
|
+
})(), n);
|
|
61
|
+
return {
|
|
62
|
+
name: "redis",
|
|
63
|
+
publish: (t, n) => e.tryPromise({
|
|
64
|
+
try: async () => {
|
|
65
|
+
let { pub: e } = await r();
|
|
66
|
+
await e.publish(t, n);
|
|
67
|
+
},
|
|
68
|
+
catch: (e) => a("redis", `publish failed: ${String(e?.message ?? e)}`, e)
|
|
69
|
+
}),
|
|
70
|
+
subscribe: (t, n) => e.tryPromise({
|
|
71
|
+
try: async () => {
|
|
72
|
+
let { sub: e } = await r(), i = (e, r) => {
|
|
73
|
+
e === t && n(r);
|
|
74
|
+
};
|
|
75
|
+
return e.on("message", i), await e.subscribe(t), () => {
|
|
76
|
+
e.off("message", i), e.unsubscribe(t);
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
catch: (e) => a("redis", `subscribe failed: ${String(e?.message ?? e)}`, e)
|
|
80
|
+
}),
|
|
81
|
+
close: () => e.promise(async () => {
|
|
82
|
+
if (n) try {
|
|
83
|
+
let { pub: e, sub: r } = await n;
|
|
84
|
+
await r.quit(), t.client || await e.quit();
|
|
85
|
+
} catch {}
|
|
86
|
+
})
|
|
87
|
+
};
|
|
88
|
+
}, f = new TextEncoder(), p = new TextDecoder(), m = (t) => {
|
|
89
|
+
let n = null, r = () => import("@nats-io/transport-node").catch((e) => {
|
|
90
|
+
throw o("nats", "the 'nats' broadcast provider requires the '@nats-io/transport-node' optional dependency. Install it to enable it.", e);
|
|
91
|
+
}), i = () => n || (n = (async () => t.connection ? t.connection : (await r()).connect({ servers: t.url }))(), n);
|
|
92
|
+
return {
|
|
93
|
+
name: "nats",
|
|
94
|
+
publish: (t, n) => e.tryPromise({
|
|
95
|
+
try: async () => {
|
|
96
|
+
(await i()).publish(t, f.encode(n));
|
|
97
|
+
},
|
|
98
|
+
catch: (e) => a("nats", `publish failed: ${String(e?.message ?? e)}`, e)
|
|
99
|
+
}),
|
|
100
|
+
subscribe: (t, n) => e.tryPromise({
|
|
101
|
+
try: async () => {
|
|
102
|
+
let e = (await i()).subscribe(t);
|
|
103
|
+
return (async () => {
|
|
104
|
+
for await (let t of e) n(p.decode(t.data));
|
|
105
|
+
})(), () => {
|
|
106
|
+
e.unsubscribe();
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
catch: (e) => a("nats", `subscribe failed: ${String(e?.message ?? e)}`, e)
|
|
110
|
+
}),
|
|
111
|
+
close: () => e.promise(async () => {
|
|
112
|
+
if (n) try {
|
|
113
|
+
let e = await n;
|
|
114
|
+
t.connection || await e.drain();
|
|
115
|
+
} catch {}
|
|
116
|
+
})
|
|
117
|
+
};
|
|
118
|
+
}, h = (e, t) => t[`${e.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_REDIS_URL`], g = (e, t) => {
|
|
119
|
+
if (e.provider && typeof e.provider == "object") {
|
|
120
|
+
let t = e.provider;
|
|
121
|
+
return {
|
|
122
|
+
provider: t,
|
|
123
|
+
url: e.url ?? null,
|
|
124
|
+
crossReplica: t.name !== "memory"
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
let n = e.url ?? t.BROADCAST_URL ?? null, r = h(e.connection ?? "broadcast", t), i = e.connection !== void 0;
|
|
128
|
+
switch ((e.provider ?? t.BROADCAST_PROVIDER)?.toLowerCase() ?? (n?.startsWith("nats://") ? "nats" : n?.startsWith("redis://") || n?.startsWith("rediss://") || r || i && t.REDIS_URL ? "redis" : void 0)) {
|
|
129
|
+
case "redis": {
|
|
130
|
+
let e = n ?? r ?? t.REDIS_URL;
|
|
131
|
+
return e ? {
|
|
132
|
+
provider: d({ url: e }),
|
|
133
|
+
url: e,
|
|
134
|
+
crossReplica: !0
|
|
135
|
+
} : {
|
|
136
|
+
provider: u(),
|
|
137
|
+
url: null,
|
|
138
|
+
crossReplica: !1
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
case "nats": {
|
|
142
|
+
let e = n ?? t.NATS_URL;
|
|
143
|
+
return e ? {
|
|
144
|
+
provider: m({ url: e }),
|
|
145
|
+
url: e,
|
|
146
|
+
crossReplica: !0
|
|
147
|
+
} : {
|
|
148
|
+
provider: u(),
|
|
149
|
+
url: null,
|
|
150
|
+
crossReplica: !1
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
default: return {
|
|
154
|
+
provider: u(),
|
|
155
|
+
url: null,
|
|
156
|
+
crossReplica: !1
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}, _ = ["network:outbound:*"], v = [
|
|
160
|
+
{
|
|
161
|
+
name: "BROADCAST_URL",
|
|
162
|
+
required: !1,
|
|
163
|
+
secret: !0,
|
|
164
|
+
description: "Broker URL for the cross-replica bus, e.g. redis://host:6379 or nats://host:4222 (the scheme selects the provider). Absent → in-process memory fallback (single-process).",
|
|
165
|
+
example: "redis://localhost:6379"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "BROADCAST_REDIS_URL",
|
|
169
|
+
required: !1,
|
|
170
|
+
secret: !0,
|
|
171
|
+
description: "Named-connection redis url for the bus (the shared <NAME>_REDIS_URL convention, like CACHE_REDIS_URL / KV_REDIS_URL). Used when the redis provider is selected and no broker-agnostic BROADCAST_URL is set; falls back to REDIS_URL. Set it to put broadcast on its own server.",
|
|
172
|
+
example: "redis://localhost:6379"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "BROADCAST_PROVIDER",
|
|
176
|
+
required: !1,
|
|
177
|
+
secret: !1,
|
|
178
|
+
description: "Explicit provider: 'redis' | 'nats' | 'memory'. Overrides the scheme inferred from BROADCAST_URL. Default: inferred from the URL, else memory.",
|
|
179
|
+
example: "redis"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "REDIS_URL",
|
|
183
|
+
required: !1,
|
|
184
|
+
secret: !0,
|
|
185
|
+
description: "Fallback broker URL used when the resolved provider is redis and no BROADCAST_URL is set (also infers the redis provider when no provider/URL is given).",
|
|
186
|
+
example: "redis://localhost:6379"
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "NATS_URL",
|
|
190
|
+
required: !1,
|
|
191
|
+
secret: !0,
|
|
192
|
+
description: "Fallback broker URL used when the resolved provider is nats and no BROADCAST_URL is set (comma-separated for a cluster).",
|
|
193
|
+
example: "nats://localhost:4222"
|
|
194
|
+
}
|
|
195
|
+
], y = (t = {}) => {
|
|
196
|
+
let r = g({
|
|
197
|
+
...t.provider === void 0 ? {} : { provider: t.provider },
|
|
198
|
+
...t.url === void 0 ? {} : { url: t.url },
|
|
199
|
+
...t.connection === void 0 ? {} : { connection: t.connection }
|
|
200
|
+
}, process.env);
|
|
201
|
+
return {
|
|
202
|
+
...n({
|
|
203
|
+
name: t.name ? `@voltro/plugin-broadcast#${t.name}` : "@voltro/plugin-broadcast",
|
|
204
|
+
description: "Cross-replica reactivity — fans out app-mutation ChangeEvents to every replica over a pub/sub bus (Redis / NATS). ADDITIVE to inline emit: local reactivity survives a broker outage. Closes the single-instance gap for every non-postgres dialect.",
|
|
205
|
+
permissions: _,
|
|
206
|
+
declaredEnv: v,
|
|
207
|
+
onActivate: (t) => e.sync(() => {
|
|
208
|
+
r.crossReplica ? t.logger.info("broadcast active", {
|
|
209
|
+
provider: r.provider.name,
|
|
210
|
+
url: r.url
|
|
211
|
+
}) : t.logger.warn("broadcast: resolved to the in-process memory provider — NO cross-replica fan-out. Set BROADCAST_URL (redis:// or nats://) for a real bus.");
|
|
212
|
+
}),
|
|
213
|
+
onDeactivate: () => e.void
|
|
214
|
+
}),
|
|
215
|
+
broadcast: {
|
|
216
|
+
provider: r.provider,
|
|
217
|
+
url: r.url,
|
|
218
|
+
crossReplica: r.crossReplica
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}, b = (e) => {
|
|
222
|
+
for (let t of e) if (x(t)) return t;
|
|
223
|
+
return null;
|
|
224
|
+
}, x = (e) => typeof e.broadcast == "object" && e.broadcast !== null && typeof e.broadcast.provider == "object", S = {
|
|
225
|
+
info: () => {},
|
|
226
|
+
warn: () => {},
|
|
227
|
+
debug: () => {}
|
|
228
|
+
}, C = async (t) => {
|
|
229
|
+
let { store: n, provider: r, replicaId: i } = t, a = t.logger ?? S, o = t.channel ?? "voltro:changes", s = !1, c = !1, l = await e.runPromise(r.subscribe(o, (e) => {
|
|
230
|
+
let t;
|
|
231
|
+
try {
|
|
232
|
+
t = JSON.parse(e);
|
|
233
|
+
} catch {
|
|
234
|
+
a.warn("broadcast: bad payload (not JSON)", { channel: o });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (t.origin !== i && !(!t.event || typeof t.event != "object")) {
|
|
238
|
+
s = !0;
|
|
239
|
+
try {
|
|
240
|
+
n.injectExternalChange(t.event);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
a.warn("broadcast: inject failed", { err: e });
|
|
243
|
+
} finally {
|
|
244
|
+
s = !1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
})), u = n.onChange((t) => {
|
|
248
|
+
if (s) return;
|
|
249
|
+
let n = {
|
|
250
|
+
origin: i,
|
|
251
|
+
event: t
|
|
252
|
+
};
|
|
253
|
+
e.runPromise(r.publish(o, JSON.stringify(n)).pipe(e.tap(() => e.sync(() => {
|
|
254
|
+
c = !1;
|
|
255
|
+
})), e.catchAll((t) => e.sync(() => {
|
|
256
|
+
c || (c = !0, a.warn("broadcast: publish failed — cross-replica fan-out degraded, local reactivity unaffected", {
|
|
257
|
+
provider: r.name,
|
|
258
|
+
transient: t.transient,
|
|
259
|
+
message: t.message
|
|
260
|
+
}));
|
|
261
|
+
}))));
|
|
262
|
+
});
|
|
263
|
+
a.info("broadcast bus attached", {
|
|
264
|
+
provider: r.name,
|
|
265
|
+
channel: o,
|
|
266
|
+
replicaId: i
|
|
267
|
+
});
|
|
268
|
+
let d = !1;
|
|
269
|
+
return { close: async () => {
|
|
270
|
+
if (!d) {
|
|
271
|
+
d = !0, u();
|
|
272
|
+
try {
|
|
273
|
+
l();
|
|
274
|
+
} catch {}
|
|
275
|
+
await e.runPromise(r.close());
|
|
276
|
+
}
|
|
277
|
+
} };
|
|
278
|
+
};
|
|
279
|
+
//#endregion
|
|
280
|
+
export { i as BROADCAST_CHANNEL, r as BroadcastError, C as attachBroadcastBus, y as broadcastPlugin, b as getBroadcastPlugin, u as memoryProvider, m as natsProvider, d as redisProvider, l as resetMemoryBus, g as resolveBroadcastProvider };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voltro/plugin-broadcast",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-replica reactivity — a pub/sub message bus (Redis / NATS / memory) that fans out app-mutation ChangeEvents to every replica behind a load balancer. ADDITIVE to inline emit: local reactivity survives a broker outage; cross-replica degrades gracefully. Closes the single-instance gap for every non-postgres dialect (postgres keeps native LISTEN/NOTIFY).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"voltro",
|
|
7
|
+
"typescript",
|
|
8
|
+
"framework"
|
|
9
|
+
],
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"homepage": "https://voltro.dev",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"email": "support@voltro.dev"
|
|
14
|
+
},
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Voltro UG",
|
|
17
|
+
"url": "https://voltro.dev"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@voltro/database": "0.1.0",
|
|
36
|
+
"@voltro/protocol": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"optionalDependencies": {
|
|
39
|
+
"@nats-io/transport-node": "^3.4.0",
|
|
40
|
+
"ioredis": "^5.11.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"effect": "^3.21.4"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|