@krizaka/realtime 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +115 -2
- package/dist/chunk-GBVW7BAQ.js +90 -0
- package/dist/chunk-GBVW7BAQ.js.map +1 -0
- package/dist/chunk-HJCRR3FF.js +194 -0
- package/dist/chunk-HJCRR3FF.js.map +1 -0
- package/dist/chunk-WHRDA5M4.js +73 -0
- package/dist/chunk-WHRDA5M4.js.map +1 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/pg.d.ts +57 -0
- package/dist/pg.js +90 -0
- package/dist/pg.js.map +1 -0
- package/dist/postgres.d.ts +40 -0
- package/dist/postgres.js +12 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.d.ts +52 -0
- package/dist/redis.js +163 -0
- package/dist/redis.js.map +1 -0
- package/dist/sse.d.ts +41 -0
- package/dist/sse.js +9 -0
- package/dist/sse.js.map +1 -0
- package/dist/types-BP1h40fn.d.ts +64 -0
- package/package.json +151 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReplicatedPresence,
|
|
3
|
+
emitSafely,
|
|
4
|
+
localEmitter
|
|
5
|
+
} from "./chunk-HJCRR3FF.js";
|
|
6
|
+
|
|
7
|
+
// src/postgres.ts
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
var NOTIFY_LIMIT = 7900;
|
|
10
|
+
var DEFAULT_CHANNEL = "realtime_events";
|
|
11
|
+
var PRESENCE_TOPIC = "__rt.presence";
|
|
12
|
+
function postgresBus(options) {
|
|
13
|
+
const channel = options.channel ?? DEFAULT_CHANNEL;
|
|
14
|
+
const emitter = localEmitter();
|
|
15
|
+
const instanceId = randomUUID();
|
|
16
|
+
const intervalMs = options.snapshotIntervalMs ?? 3e4;
|
|
17
|
+
const send = async (topic, event) => {
|
|
18
|
+
const payload = JSON.stringify({ topic, event });
|
|
19
|
+
if (Buffer.byteLength(payload) > NOTIFY_LIMIT) throw new Error(`payload too large for ${topic}`);
|
|
20
|
+
await options.db.notify(channel, payload);
|
|
21
|
+
};
|
|
22
|
+
const presence = new ReplicatedPresence({
|
|
23
|
+
...options,
|
|
24
|
+
instanceId,
|
|
25
|
+
snapshotTtlMs: Math.round(intervalMs * 2.5),
|
|
26
|
+
broadcast: (replica) => void send(PRESENCE_TOPIC, replica).catch(() => void 0)
|
|
27
|
+
});
|
|
28
|
+
const listener = options.db.listen(channel, (payload) => {
|
|
29
|
+
try {
|
|
30
|
+
const { topic, event } = JSON.parse(payload);
|
|
31
|
+
if (topic === PRESENCE_TOPIC) presence.receive(event);
|
|
32
|
+
else emitSafely(emitter, topic, event);
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const timer = setInterval(() => {
|
|
37
|
+
for (const page of presence.snapshot()) void send(PRESENCE_TOPIC, page).catch(() => void 0);
|
|
38
|
+
}, intervalMs);
|
|
39
|
+
timer.unref?.();
|
|
40
|
+
return {
|
|
41
|
+
kind: "postgres",
|
|
42
|
+
get connected() {
|
|
43
|
+
return listener.connected;
|
|
44
|
+
},
|
|
45
|
+
presence,
|
|
46
|
+
async publish(topic, event) {
|
|
47
|
+
const hearsEcho = listener.connected;
|
|
48
|
+
try {
|
|
49
|
+
await send(topic, event);
|
|
50
|
+
if (!hearsEcho) emitSafely(emitter, topic, event);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.warn(`[realtime] publish ${topic} failed, delivered locally`, error.message);
|
|
53
|
+
emitSafely(emitter, topic, event);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
subscribe(topic, handler) {
|
|
57
|
+
emitter.on(topic, handler);
|
|
58
|
+
return () => void emitter.off(topic, handler);
|
|
59
|
+
},
|
|
60
|
+
async close() {
|
|
61
|
+
clearInterval(timer);
|
|
62
|
+
emitter.removeAllListeners();
|
|
63
|
+
await listener.close();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
NOTIFY_LIMIT,
|
|
70
|
+
DEFAULT_CHANNEL,
|
|
71
|
+
postgresBus
|
|
72
|
+
};
|
|
73
|
+
//# sourceMappingURL=chunk-WHRDA5M4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/postgres.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { emitSafely, localEmitter } from \"./memory\";\nimport { type PresenceReplica,ReplicatedPresence } from \"./presence\";\nimport type { PresenceOptions, RealtimeBus } from \"./types\";\n\n/**\n * The two PostgreSQL primitives the adapter needs — injected, so this module depends on no database client. Bring\n * your own (the app's pool for `notify`, a dedicated connection for `listen`), or build both with `pgChannel` from\n * `@krizaka/realtime/pg`.\n */\nexport interface PostgresChannel {\n /** `select pg_notify(channel, payload)` — delivered to every LISTENer at commit. */\n notify(channel: string, payload: string): Promise<void>;\n /** One dedicated LISTEN connection that reconnects by itself. */\n listen(channel: string, onMessage: (payload: string) => void): { readonly connected: boolean; close(): Promise<void> };\n}\n\nexport interface PostgresBusOptions extends Partial<PresenceOptions> {\n db: PostgresChannel;\n /**\n * The NOTIFY channel (one for every topic), a lowercase SQL identifier. Every instance of one app must use the same\n * one; two apps sharing a database use two. Default `DEFAULT_CHANNEL`.\n */\n channel?: string;\n /** How often this instance tells the others who is connected to it (ms). */\n snapshotIntervalMs?: number;\n}\n\n/** PostgreSQL's limit is 8000 bytes per payload; the envelope stays under it. */\nexport const NOTIFY_LIMIT = 7900;\n/** The NOTIFY channel used when none is given. */\nexport const DEFAULT_CHANNEL = \"realtime_events\";\nconst PRESENCE_TOPIC = \"__rt.presence\";\n\n/**\n * PostgreSQL LISTEN/NOTIFY: every instance LISTENs on one channel and hands each event to its local subscribers —\n * no broker, the database the app already has does the fan-out. Every instance receives every event (fine for a\n * handful of instances; past that, Redis subscribes per channel). Presence is kept in each instance's memory and\n * replicated over the same channel (deltas + a snapshot every `snapshotIntervalMs`). While the LISTEN connection is\n * down, events still reach the other instances (NOTIFY needs no LISTEN) and this instance's subscribers directly.\n */\nexport function postgresBus(options: PostgresBusOptions): RealtimeBus {\n const channel = options.channel ?? DEFAULT_CHANNEL;\n const emitter = localEmitter();\n const instanceId = randomUUID();\n const intervalMs = options.snapshotIntervalMs ?? 30_000;\n\n const send = async (topic: string, event: unknown) => {\n const payload = JSON.stringify({ topic, event });\n if (Buffer.byteLength(payload) > NOTIFY_LIMIT) throw new Error(`payload too large for ${topic}`);\n await options.db.notify(channel, payload);\n };\n\n const presence = new ReplicatedPresence({\n ...options,\n instanceId,\n snapshotTtlMs: Math.round(intervalMs * 2.5),\n broadcast: (replica) => void send(PRESENCE_TOPIC, replica).catch(() => undefined),\n });\n\n const listener = options.db.listen(channel, (payload) => {\n try {\n const { topic, event } = JSON.parse(payload) as { topic: string; event: unknown };\n if (topic === PRESENCE_TOPIC) presence.receive(event as PresenceReplica);\n else emitSafely(emitter, topic, event);\n } catch {\n /* not ours */\n }\n });\n\n const timer = setInterval(() => {\n for (const page of presence.snapshot()) void send(PRESENCE_TOPIC, page).catch(() => undefined);\n }, intervalMs);\n timer.unref?.();\n\n return {\n kind: \"postgres\",\n get connected() {\n return listener.connected;\n },\n presence,\n async publish(topic, event) {\n // NOTIFY needs no LISTEN: publish even while this instance's own listener is (re)connecting — the others hear it\n // — and hand it to the local subscribers directly in that case, since the echo will not come back.\n const hearsEcho = listener.connected;\n try {\n await send(topic, event);\n if (!hearsEcho) emitSafely(emitter, topic, event);\n } catch (error) {\n console.warn(`[realtime] publish ${topic} failed, delivered locally`, (error as Error).message);\n emitSafely(emitter, topic, event);\n }\n },\n subscribe(topic, handler) {\n emitter.on(topic, handler);\n return () => void emitter.off(topic, handler);\n },\n async close() {\n clearInterval(timer);\n emitter.removeAllListeners();\n await listener.close();\n },\n };\n}\n"],"mappings":";;;;;;;AAAA,SAAS,kBAAkB;AA8BpB,IAAM,eAAe;AAErB,IAAM,kBAAkB;AAC/B,IAAM,iBAAiB;AAShB,SAAS,YAAY,SAA0C;AACpE,QAAM,UAAU,QAAQ,WAAW;AACnC,QAAM,UAAU,aAAa;AAC7B,QAAM,aAAa,WAAW;AAC9B,QAAM,aAAa,QAAQ,sBAAsB;AAEjD,QAAM,OAAO,OAAO,OAAe,UAAmB;AACpD,UAAM,UAAU,KAAK,UAAU,EAAE,OAAO,MAAM,CAAC;AAC/C,QAAI,OAAO,WAAW,OAAO,IAAI,aAAc,OAAM,IAAI,MAAM,yBAAyB,KAAK,EAAE;AAC/F,UAAM,QAAQ,GAAG,OAAO,SAAS,OAAO;AAAA,EAC1C;AAEA,QAAM,WAAW,IAAI,mBAAmB;AAAA,IACtC,GAAG;AAAA,IACH;AAAA,IACA,eAAe,KAAK,MAAM,aAAa,GAAG;AAAA,IAC1C,WAAW,CAAC,YAAY,KAAK,KAAK,gBAAgB,OAAO,EAAE,MAAM,MAAM,MAAS;AAAA,EAClF,CAAC;AAED,QAAM,WAAW,QAAQ,GAAG,OAAO,SAAS,CAAC,YAAY;AACvD,QAAI;AACF,YAAM,EAAE,OAAO,MAAM,IAAI,KAAK,MAAM,OAAO;AAC3C,UAAI,UAAU,eAAgB,UAAS,QAAQ,KAAwB;AAAA,UAClE,YAAW,SAAS,OAAO,KAAK;AAAA,IACvC,QAAQ;AAAA,IAER;AAAA,EACF,CAAC;AAED,QAAM,QAAQ,YAAY,MAAM;AAC9B,eAAW,QAAQ,SAAS,SAAS,EAAG,MAAK,KAAK,gBAAgB,IAAI,EAAE,MAAM,MAAM,MAAS;AAAA,EAC/F,GAAG,UAAU;AACb,QAAM,QAAQ;AAEd,SAAO;AAAA,IACL,MAAM;AAAA,IACN,IAAI,YAAY;AACd,aAAO,SAAS;AAAA,IAClB;AAAA,IACA;AAAA,IACA,MAAM,QAAQ,OAAO,OAAO;AAG1B,YAAM,YAAY,SAAS;AAC3B,UAAI;AACF,cAAM,KAAK,OAAO,KAAK;AACvB,YAAI,CAAC,UAAW,YAAW,SAAS,OAAO,KAAK;AAAA,MAClD,SAAS,OAAO;AACd,gBAAQ,KAAK,sBAAsB,KAAK,8BAA+B,MAAgB,OAAO;AAC9F,mBAAW,SAAS,OAAO,KAAK;AAAA,MAClC;AAAA,IACF;AAAA,IACA,UAAU,OAAO,SAAS;AACxB,cAAQ,GAAG,OAAO,OAAO;AACzB,aAAO,MAAM,KAAK,QAAQ,IAAI,OAAO,OAAO;AAAA,IAC9C;AAAA,IACA,MAAM,QAAQ;AACZ,oBAAc,KAAK;AACnB,cAAQ,mBAAmB;AAC3B,YAAM,SAAS,MAAM;AAAA,IACvB;AAAA,EACF;AACF;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { P as PresenceOptions, R as RealtimeBus, a as PresenceState, C as ConnectionState, b as PresenceStore, c as PresenceChange, d as PresenceInfo, B as BusKind } from './types-BP1h40fn.js';
|
|
2
|
+
export { D as DEFAULT_PRESENCE, U as Unsubscribe, r as rank } from './types-BP1h40fn.js';
|
|
3
|
+
import { PostgresChannel } from './postgres.js';
|
|
4
|
+
export { DEFAULT_CHANNEL, NOTIFY_LIMIT, PostgresBusOptions, postgresBus } from './postgres.js';
|
|
5
|
+
export { EventStreamControls, EventStreamOptions, SseFrame, eventStream, sseFrame } from './sse.js';
|
|
6
|
+
import { RedisBusOptions } from './redis.js';
|
|
7
|
+
import 'ioredis';
|
|
8
|
+
|
|
9
|
+
declare function memoryBus(options?: Partial<PresenceOptions>): RealtimeBus;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Presence in this process's memory, optionally replicated between instances through the bus that carries it
|
|
13
|
+
* (PostgreSQL LISTEN/NOTIFY). Each instance keeps its own connections (heartbeat + TTL) and tells the others its
|
|
14
|
+
* *local* view of a person when it changes, plus a snapshot of everyone connected to it at a regular pace; a person's
|
|
15
|
+
* presence is the best of the local view and the views of the other instances still heard from.
|
|
16
|
+
*
|
|
17
|
+
* Changes are announced once: by the instance where the event happened (a remote report only updates what this
|
|
18
|
+
* instance believes was announced). An instance that dies stops refreshing its snapshot: its people fade out after
|
|
19
|
+
* `snapshotTtlMs` (no event — clients refresh presence on a slow poll as a fallback).
|
|
20
|
+
*/
|
|
21
|
+
/** What an instance tells the others: one person's local state (`d` = delta), or a page of its connected people. */
|
|
22
|
+
type PresenceReplica = {
|
|
23
|
+
t: "d";
|
|
24
|
+
i: string;
|
|
25
|
+
u: string;
|
|
26
|
+
s: PresenceState;
|
|
27
|
+
at: number;
|
|
28
|
+
} | {
|
|
29
|
+
t: "s";
|
|
30
|
+
i: string;
|
|
31
|
+
at: number;
|
|
32
|
+
users: [string, ConnectionState][];
|
|
33
|
+
};
|
|
34
|
+
interface ReplicatedPresenceOptions extends Partial<PresenceOptions> {
|
|
35
|
+
/** This instance's id (random per process). */
|
|
36
|
+
instanceId: string;
|
|
37
|
+
/** Sends a replica to the other instances (absent: a single instance). */
|
|
38
|
+
broadcast?: (replica: PresenceReplica) => void;
|
|
39
|
+
/** How long another instance's snapshot counts (ms) — about 2.5 snapshot intervals. */
|
|
40
|
+
snapshotTtlMs?: number;
|
|
41
|
+
/** The most people a snapshot page carries (a PostgreSQL NOTIFY payload stays under 8 KB). */
|
|
42
|
+
snapshotPage?: number;
|
|
43
|
+
}
|
|
44
|
+
declare class ReplicatedPresence implements PresenceStore {
|
|
45
|
+
private readonly config;
|
|
46
|
+
readonly options: PresenceOptions;
|
|
47
|
+
private readonly local;
|
|
48
|
+
private readonly remote;
|
|
49
|
+
private readonly announced;
|
|
50
|
+
/** The local view last told to the other instances. */
|
|
51
|
+
private readonly told;
|
|
52
|
+
private readonly lastSeen;
|
|
53
|
+
private readonly listeners;
|
|
54
|
+
private readonly snapshotTtlMs;
|
|
55
|
+
private readonly snapshotPage;
|
|
56
|
+
constructor(config: ReplicatedPresenceOptions);
|
|
57
|
+
/** This instance's view of one person: the best of its live connections (expired ones are dropped). */
|
|
58
|
+
private localState;
|
|
59
|
+
private remoteState;
|
|
60
|
+
private state;
|
|
61
|
+
/**
|
|
62
|
+
* Compares with what was announced; records and returns the change. A change caused by another instance's report is
|
|
63
|
+
* returned only when it is an offline the reporter may not have seen (both instances lost the person at once): the
|
|
64
|
+
* instance with the greater id announces it — at most a duplicate, never a person left online forever.
|
|
65
|
+
*/
|
|
66
|
+
private evaluate;
|
|
67
|
+
private see;
|
|
68
|
+
beat(userId: string, connectionId: string, state: ConnectionState, now?: number): Promise<PresenceChange | null>;
|
|
69
|
+
leave(userId: string, connectionId: string, now?: number): Promise<PresenceChange | null>;
|
|
70
|
+
settle(userId: string, now?: number): Promise<PresenceChange | null>;
|
|
71
|
+
get(userIds: string[], now?: number): Promise<Map<string, PresenceInfo>>;
|
|
72
|
+
onChange(listener: (change: PresenceChange) => void): () => undefined;
|
|
73
|
+
/** Another instance spoke (ignored when it is this one). */
|
|
74
|
+
receive(replica: PresenceReplica, now?: number): void;
|
|
75
|
+
/** This instance's connected people, in pages small enough for the bus (sent at a regular pace). */
|
|
76
|
+
snapshot(now?: number): PresenceReplica[];
|
|
77
|
+
/** How many connections this instance holds (metrics, tests). */
|
|
78
|
+
get connections(): number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @krizaka/realtime — the realtime layer behind one interface (`RealtimeBus`: publish / subscribe by channel, and
|
|
83
|
+
* presence), three adapters chosen by configuration, and Server-Sent Events. It knows no product: the app wires it
|
|
84
|
+
* once (its own `lib/realtime.ts`). The root entry needs no driver: the PostgreSQL adapter takes its connection by
|
|
85
|
+
* injection (`@krizaka/realtime/pg` builds one on `pg`), the Redis adapter lives in `@krizaka/realtime/redis` (`ioredis`).
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
interface BusConfig extends Partial<PresenceOptions> {
|
|
89
|
+
kind: BusKind;
|
|
90
|
+
/** Required by `postgres`: the NOTIFY / LISTEN primitives (`pgChannel` from `@krizaka/realtime/pg`, or the app's own). */
|
|
91
|
+
postgres?: PostgresChannel;
|
|
92
|
+
/** The PostgreSQL NOTIFY channel (`postgres`; default `DEFAULT_CHANNEL`). */
|
|
93
|
+
channel?: string;
|
|
94
|
+
/** Required by `redis`: the `redisBus` of `@krizaka/realtime/redis` — passed in, so `ioredis` loads only when used. */
|
|
95
|
+
redisBus?: (options: RedisBusOptions) => RealtimeBus;
|
|
96
|
+
/** Required by `redis`. */
|
|
97
|
+
redisUrl?: string;
|
|
98
|
+
/** Prefix of every Redis key and channel (`redis`; default `DEFAULT_PREFIX`). */
|
|
99
|
+
prefix?: string;
|
|
100
|
+
}
|
|
101
|
+
/** The bus a deployment asked for — refuses a configuration that cannot work rather than falling back silently. */
|
|
102
|
+
declare function createBus(config: BusConfig): RealtimeBus;
|
|
103
|
+
/** Reads `REALTIME_BUS` (default `postgres`). */
|
|
104
|
+
declare function busKind(value: string | undefined): BusKind;
|
|
105
|
+
|
|
106
|
+
export { type BusConfig, BusKind, ConnectionState, PostgresChannel, PresenceChange, PresenceInfo, PresenceOptions, type PresenceReplica, PresenceState, PresenceStore, RealtimeBus, ReplicatedPresence, type ReplicatedPresenceOptions, busKind, createBus, memoryBus };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
eventStream,
|
|
3
|
+
sseFrame
|
|
4
|
+
} from "./chunk-GBVW7BAQ.js";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CHANNEL,
|
|
7
|
+
NOTIFY_LIMIT,
|
|
8
|
+
postgresBus
|
|
9
|
+
} from "./chunk-WHRDA5M4.js";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_PRESENCE,
|
|
12
|
+
ReplicatedPresence,
|
|
13
|
+
memoryBus,
|
|
14
|
+
rank
|
|
15
|
+
} from "./chunk-HJCRR3FF.js";
|
|
16
|
+
|
|
17
|
+
// src/index.ts
|
|
18
|
+
function createBus(config) {
|
|
19
|
+
if (config.kind === "memory") return memoryBus(config);
|
|
20
|
+
if (config.kind === "postgres") {
|
|
21
|
+
if (!config.postgres) throw new Error("the postgres realtime bus needs a PostgreSQL channel");
|
|
22
|
+
return postgresBus({ ...config, db: config.postgres });
|
|
23
|
+
}
|
|
24
|
+
if (config.kind === "redis") {
|
|
25
|
+
if (!config.redisUrl) throw new Error("REALTIME_BUS=redis needs REDIS_URL");
|
|
26
|
+
if (!config.redisBus) throw new Error("REALTIME_BUS=redis needs redisBus from @krizaka/realtime/redis");
|
|
27
|
+
return config.redisBus({ ...config, url: config.redisUrl });
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`unknown realtime bus "${String(config.kind)}" (memory, postgres or redis)`);
|
|
30
|
+
}
|
|
31
|
+
function busKind(value) {
|
|
32
|
+
const kind = value?.trim().toLowerCase() || "postgres";
|
|
33
|
+
if (kind !== "memory" && kind !== "postgres" && kind !== "redis") throw new Error(`REALTIME_BUS must be memory, postgres or redis, not "${value}"`);
|
|
34
|
+
return kind;
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
DEFAULT_CHANNEL,
|
|
38
|
+
DEFAULT_PRESENCE,
|
|
39
|
+
NOTIFY_LIMIT,
|
|
40
|
+
ReplicatedPresence,
|
|
41
|
+
busKind,
|
|
42
|
+
createBus,
|
|
43
|
+
eventStream,
|
|
44
|
+
memoryBus,
|
|
45
|
+
postgresBus,
|
|
46
|
+
rank,
|
|
47
|
+
sseFrame
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @krizaka/realtime — the realtime layer behind one interface (`RealtimeBus`: publish / subscribe by channel, and\n * presence), three adapters chosen by configuration, and Server-Sent Events. It knows no product: the app wires it\n * once (its own `lib/realtime.ts`). The root entry needs no driver: the PostgreSQL adapter takes its connection by\n * injection (`@krizaka/realtime/pg` builds one on `pg`), the Redis adapter lives in `@krizaka/realtime/redis` (`ioredis`).\n */\nexport { memoryBus } from \"./memory\";\nexport { DEFAULT_CHANNEL, NOTIFY_LIMIT, postgresBus, type PostgresBusOptions, type PostgresChannel } from \"./postgres\";\nexport { type PresenceReplica, ReplicatedPresence, type ReplicatedPresenceOptions } from \"./presence\";\nexport { eventStream, type EventStreamControls, type EventStreamOptions, type SseFrame, sseFrame } from \"./sse\";\nexport * from \"./types\";\n\nimport { memoryBus } from \"./memory\";\nimport { postgresBus, type PostgresChannel } from \"./postgres\";\nimport type { RedisBusOptions } from \"./redis\";\nimport type { BusKind, PresenceOptions, RealtimeBus } from \"./types\";\n\nexport interface BusConfig extends Partial<PresenceOptions> {\n kind: BusKind;\n /** Required by `postgres`: the NOTIFY / LISTEN primitives (`pgChannel` from `@krizaka/realtime/pg`, or the app's own). */\n postgres?: PostgresChannel;\n /** The PostgreSQL NOTIFY channel (`postgres`; default `DEFAULT_CHANNEL`). */\n channel?: string;\n /** Required by `redis`: the `redisBus` of `@krizaka/realtime/redis` — passed in, so `ioredis` loads only when used. */\n redisBus?: (options: RedisBusOptions) => RealtimeBus;\n /** Required by `redis`. */\n redisUrl?: string;\n /** Prefix of every Redis key and channel (`redis`; default `DEFAULT_PREFIX`). */\n prefix?: string;\n}\n\n/** The bus a deployment asked for — refuses a configuration that cannot work rather than falling back silently. */\nexport function createBus(config: BusConfig): RealtimeBus {\n if (config.kind === \"memory\") return memoryBus(config);\n if (config.kind === \"postgres\") {\n if (!config.postgres) throw new Error(\"the postgres realtime bus needs a PostgreSQL channel\");\n return postgresBus({ ...config, db: config.postgres });\n }\n if (config.kind === \"redis\") {\n if (!config.redisUrl) throw new Error(\"REALTIME_BUS=redis needs REDIS_URL\");\n if (!config.redisBus) throw new Error(\"REALTIME_BUS=redis needs redisBus from @krizaka/realtime/redis\");\n return config.redisBus({ ...config, url: config.redisUrl });\n }\n throw new Error(`unknown realtime bus \"${String(config.kind)}\" (memory, postgres or redis)`);\n}\n\n/** Reads `REALTIME_BUS` (default `postgres`). */\nexport function busKind(value: string | undefined): BusKind {\n const kind = (value?.trim().toLowerCase() || \"postgres\") as BusKind;\n if (kind !== \"memory\" && kind !== \"postgres\" && kind !== \"redis\") throw new Error(`REALTIME_BUS must be memory, postgres or redis, not \"${value}\"`);\n return kind;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgCO,SAAS,UAAU,QAAgC;AACxD,MAAI,OAAO,SAAS,SAAU,QAAO,UAAU,MAAM;AACrD,MAAI,OAAO,SAAS,YAAY;AAC9B,QAAI,CAAC,OAAO,SAAU,OAAM,IAAI,MAAM,sDAAsD;AAC5F,WAAO,YAAY,EAAE,GAAG,QAAQ,IAAI,OAAO,SAAS,CAAC;AAAA,EACvD;AACA,MAAI,OAAO,SAAS,SAAS;AAC3B,QAAI,CAAC,OAAO,SAAU,OAAM,IAAI,MAAM,oCAAoC;AAC1E,QAAI,CAAC,OAAO,SAAU,OAAM,IAAI,MAAM,gEAAgE;AACtG,WAAO,OAAO,SAAS,EAAE,GAAG,QAAQ,KAAK,OAAO,SAAS,CAAC;AAAA,EAC5D;AACA,QAAM,IAAI,MAAM,yBAAyB,OAAO,OAAO,IAAI,CAAC,+BAA+B;AAC7F;AAGO,SAAS,QAAQ,OAAoC;AAC1D,QAAM,OAAQ,OAAO,KAAK,EAAE,YAAY,KAAK;AAC7C,MAAI,SAAS,YAAY,SAAS,cAAc,SAAS,QAAS,OAAM,IAAI,MAAM,wDAAwD,KAAK,GAAG;AAClJ,SAAO;AACT;","names":[]}
|
package/dist/pg.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { PostgresChannel } from './postgres.js';
|
|
2
|
+
import './types-BP1h40fn.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `PostgresChannel` on node-postgres (`pg`, an optional peer dependency): one dedicated LISTEN connection per process
|
|
6
|
+
* that reconnects by itself with a growing delay, and NOTIFY through a pool — yours or a small one of its own.
|
|
7
|
+
*
|
|
8
|
+
* LISTEN needs a **session**: give it a direct connection (`listen`), never a transaction pooler (PgBouncer in
|
|
9
|
+
* transaction mode drops the subscription between transactions). NOTIFY is one statement and goes through anything,
|
|
10
|
+
* a transaction pooler included — pass the app's pool as `notify`. Nothing is read from the environment: the app
|
|
11
|
+
* decides which URL is the direct one.
|
|
12
|
+
*/
|
|
13
|
+
/** Connection settings of node-postgres (`connectionString`, `host`, `ssl`, …). */
|
|
14
|
+
type PgConnection = string | ({
|
|
15
|
+
connectionString?: string;
|
|
16
|
+
} & Record<string, unknown>);
|
|
17
|
+
/** What NOTIFY goes through: a `pg.Pool`, a `pg.Client`, or anything with the same `query`. */
|
|
18
|
+
interface PgQueryable {
|
|
19
|
+
query(text: string, values?: unknown[]): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
/** The parts of node-postgres used here — injected by tests or a compatible driver. */
|
|
22
|
+
interface PgDriver {
|
|
23
|
+
Client: new (config: Record<string, unknown>) => PgListenClient;
|
|
24
|
+
Pool: new (config: Record<string, unknown>) => PgQueryable & {
|
|
25
|
+
end(): Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
interface PgListenClient {
|
|
29
|
+
connect(): Promise<unknown>;
|
|
30
|
+
query(text: string): Promise<unknown>;
|
|
31
|
+
end(): Promise<void>;
|
|
32
|
+
on(event: "error" | "end", listener: () => void): unknown;
|
|
33
|
+
on(event: "notification", listener: (msg: {
|
|
34
|
+
channel: string;
|
|
35
|
+
payload?: string;
|
|
36
|
+
}) => void): unknown;
|
|
37
|
+
removeAllListeners(): unknown;
|
|
38
|
+
}
|
|
39
|
+
interface PgChannelOptions {
|
|
40
|
+
/** The LISTEN connection: a direct (session) connection string or settings — never a transaction pooler. */
|
|
41
|
+
listen: PgConnection;
|
|
42
|
+
/** What NOTIFY goes through (the app's pool, a transaction pooler is fine). Absent: a pool of 2 on `listen`. */
|
|
43
|
+
notify?: PgQueryable;
|
|
44
|
+
/** Called when the LISTEN connection comes up or goes down. */
|
|
45
|
+
onState?: (connected: boolean) => void;
|
|
46
|
+
/** Longest wait between two reconnection attempts (ms, default 30 000; the first is 1 000). */
|
|
47
|
+
maxReconnectDelayMs?: number;
|
|
48
|
+
/** Default: node-postgres. */
|
|
49
|
+
driver?: PgDriver;
|
|
50
|
+
}
|
|
51
|
+
interface PgChannel extends PostgresChannel {
|
|
52
|
+
/** Ends the pool this channel opened for NOTIFY (not one passed as `notify`). */
|
|
53
|
+
close(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
declare function pgChannel(options: PgChannelOptions): PgChannel;
|
|
56
|
+
|
|
57
|
+
export { type PgChannel, type PgChannelOptions, type PgConnection, type PgDriver, type PgListenClient, type PgQueryable, pgChannel };
|
package/dist/pg.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NOTIFY_LIMIT
|
|
3
|
+
} from "./chunk-WHRDA5M4.js";
|
|
4
|
+
import "./chunk-HJCRR3FF.js";
|
|
5
|
+
|
|
6
|
+
// src/pg.ts
|
|
7
|
+
import pg from "pg";
|
|
8
|
+
var IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
9
|
+
var settings = (connection) => typeof connection === "string" ? { connectionString: connection } : { ...connection };
|
|
10
|
+
function pgChannel(options) {
|
|
11
|
+
const driver = options.driver ?? pg;
|
|
12
|
+
const maxDelay = options.maxReconnectDelayMs ?? 3e4;
|
|
13
|
+
let ownPool = null;
|
|
14
|
+
const notifier = () => options.notify ?? (ownPool ??= new driver.Pool({ ...settings(options.listen), max: 2 }));
|
|
15
|
+
return {
|
|
16
|
+
async notify(channel, payload) {
|
|
17
|
+
if (Buffer.byteLength(payload) > NOTIFY_LIMIT) throw new Error(`notify payload too large (${channel})`);
|
|
18
|
+
await notifier().query("select pg_notify($1, $2)", [channel, payload]);
|
|
19
|
+
},
|
|
20
|
+
listen(channel, onMessage) {
|
|
21
|
+
if (!IDENTIFIER.test(channel)) throw new Error(`invalid channel name ${channel}`);
|
|
22
|
+
let client = null;
|
|
23
|
+
let closed = false;
|
|
24
|
+
let connected = false;
|
|
25
|
+
let delay = 1e3;
|
|
26
|
+
let timer = null;
|
|
27
|
+
const setState = (value) => {
|
|
28
|
+
if (connected === value) return;
|
|
29
|
+
connected = value;
|
|
30
|
+
options.onState?.(value);
|
|
31
|
+
};
|
|
32
|
+
const retry = () => {
|
|
33
|
+
setState(false);
|
|
34
|
+
client?.removeAllListeners();
|
|
35
|
+
client?.end().catch(() => void 0);
|
|
36
|
+
client = null;
|
|
37
|
+
if (closed || timer) return;
|
|
38
|
+
timer = setTimeout(() => {
|
|
39
|
+
timer = null;
|
|
40
|
+
void connect();
|
|
41
|
+
}, delay);
|
|
42
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
43
|
+
};
|
|
44
|
+
const connect = async () => {
|
|
45
|
+
const next = new driver.Client(settings(options.listen));
|
|
46
|
+
client = next;
|
|
47
|
+
next.on("error", retry);
|
|
48
|
+
next.on("end", retry);
|
|
49
|
+
next.on("notification", (msg) => {
|
|
50
|
+
if (msg.channel === channel && msg.payload) onMessage(msg.payload);
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
await next.connect();
|
|
54
|
+
await next.query(`LISTEN ${channel}`);
|
|
55
|
+
if (closed) return void next.end().catch(() => void 0);
|
|
56
|
+
delay = 1e3;
|
|
57
|
+
setState(true);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn(`[realtime] listen ${channel}: ${error.message}`);
|
|
60
|
+
retry();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
void connect();
|
|
64
|
+
return {
|
|
65
|
+
get connected() {
|
|
66
|
+
return connected;
|
|
67
|
+
},
|
|
68
|
+
async close() {
|
|
69
|
+
closed = true;
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
71
|
+
timer = null;
|
|
72
|
+
setState(false);
|
|
73
|
+
const last = client;
|
|
74
|
+
client = null;
|
|
75
|
+
last?.removeAllListeners();
|
|
76
|
+
await last?.end().catch(() => void 0);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
async close() {
|
|
81
|
+
const pool = ownPool;
|
|
82
|
+
ownPool = null;
|
|
83
|
+
await pool?.end();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
pgChannel
|
|
89
|
+
};
|
|
90
|
+
//# sourceMappingURL=pg.js.map
|
package/dist/pg.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/pg.ts"],"sourcesContent":["import pg from \"pg\";\n\nimport { NOTIFY_LIMIT, type PostgresChannel } from \"./postgres\";\n\n/**\n * `PostgresChannel` on node-postgres (`pg`, an optional peer dependency): one dedicated LISTEN connection per process\n * that reconnects by itself with a growing delay, and NOTIFY through a pool — yours or a small one of its own.\n *\n * LISTEN needs a **session**: give it a direct connection (`listen`), never a transaction pooler (PgBouncer in\n * transaction mode drops the subscription between transactions). NOTIFY is one statement and goes through anything,\n * a transaction pooler included — pass the app's pool as `notify`. Nothing is read from the environment: the app\n * decides which URL is the direct one.\n */\n\n/** Connection settings of node-postgres (`connectionString`, `host`, `ssl`, …). */\nexport type PgConnection = string | ({ connectionString?: string } & Record<string, unknown>);\n\n/** What NOTIFY goes through: a `pg.Pool`, a `pg.Client`, or anything with the same `query`. */\nexport interface PgQueryable {\n query(text: string, values?: unknown[]): Promise<unknown>;\n}\n\n/** The parts of node-postgres used here — injected by tests or a compatible driver. */\nexport interface PgDriver {\n Client: new (config: Record<string, unknown>) => PgListenClient;\n Pool: new (config: Record<string, unknown>) => PgQueryable & { end(): Promise<void> };\n}\n\nexport interface PgListenClient {\n connect(): Promise<unknown>;\n query(text: string): Promise<unknown>;\n end(): Promise<void>;\n on(event: \"error\" | \"end\", listener: () => void): unknown;\n on(event: \"notification\", listener: (msg: { channel: string; payload?: string }) => void): unknown;\n removeAllListeners(): unknown;\n}\n\nexport interface PgChannelOptions {\n /** The LISTEN connection: a direct (session) connection string or settings — never a transaction pooler. */\n listen: PgConnection;\n /** What NOTIFY goes through (the app's pool, a transaction pooler is fine). Absent: a pool of 2 on `listen`. */\n notify?: PgQueryable;\n /** Called when the LISTEN connection comes up or goes down. */\n onState?: (connected: boolean) => void;\n /** Longest wait between two reconnection attempts (ms, default 30 000; the first is 1 000). */\n maxReconnectDelayMs?: number;\n /** Default: node-postgres. */\n driver?: PgDriver;\n}\n\nexport interface PgChannel extends PostgresChannel {\n /** Ends the pool this channel opened for NOTIFY (not one passed as `notify`). */\n close(): Promise<void>;\n}\n\nconst IDENTIFIER = /^[a-z_][a-z0-9_]*$/;\n\nconst settings = (connection: PgConnection): Record<string, unknown> =>\n typeof connection === \"string\" ? { connectionString: connection } : { ...connection };\n\nexport function pgChannel(options: PgChannelOptions): PgChannel {\n const driver: PgDriver = options.driver ?? (pg as unknown as PgDriver);\n const maxDelay = options.maxReconnectDelayMs ?? 30_000;\n let ownPool: (PgQueryable & { end(): Promise<void> }) | null = null;\n const notifier = () => options.notify ?? (ownPool ??= new driver.Pool({ ...settings(options.listen), max: 2 }));\n\n return {\n async notify(channel, payload) {\n if (Buffer.byteLength(payload) > NOTIFY_LIMIT) throw new Error(`notify payload too large (${channel})`);\n await notifier().query(\"select pg_notify($1, $2)\", [channel, payload]);\n },\n\n listen(channel, onMessage) {\n if (!IDENTIFIER.test(channel)) throw new Error(`invalid channel name ${channel}`);\n let client: PgListenClient | null = null;\n let closed = false;\n let connected = false;\n let delay = 1000;\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n const setState = (value: boolean) => {\n if (connected === value) return;\n connected = value;\n options.onState?.(value);\n };\n\n const retry = () => {\n setState(false);\n client?.removeAllListeners();\n client?.end().catch(() => undefined);\n client = null;\n if (closed || timer) return;\n timer = setTimeout(() => {\n timer = null;\n void connect();\n }, delay);\n delay = Math.min(delay * 2, maxDelay);\n };\n\n const connect = async () => {\n const next = new driver.Client(settings(options.listen));\n client = next;\n next.on(\"error\", retry);\n next.on(\"end\", retry);\n next.on(\"notification\", (msg) => {\n if (msg.channel === channel && msg.payload) onMessage(msg.payload);\n });\n try {\n await next.connect();\n await next.query(`LISTEN ${channel}`);\n if (closed) return void next.end().catch(() => undefined);\n delay = 1000;\n setState(true);\n } catch (error) {\n console.warn(`[realtime] listen ${channel}: ${(error as Error).message}`);\n retry();\n }\n };\n\n void connect();\n return {\n get connected() {\n return connected;\n },\n async close() {\n closed = true;\n if (timer) clearTimeout(timer);\n timer = null;\n setState(false);\n const last = client;\n client = null;\n last?.removeAllListeners();\n await last?.end().catch(() => undefined);\n },\n };\n },\n\n async close() {\n const pool = ownPool;\n ownPool = null;\n await pool?.end();\n },\n };\n}\n"],"mappings":";;;;;;AAAA,OAAO,QAAQ;AAuDf,IAAM,aAAa;AAEnB,IAAM,WAAW,CAAC,eAChB,OAAO,eAAe,WAAW,EAAE,kBAAkB,WAAW,IAAI,EAAE,GAAG,WAAW;AAE/E,SAAS,UAAU,SAAsC;AAC9D,QAAM,SAAmB,QAAQ,UAAW;AAC5C,QAAM,WAAW,QAAQ,uBAAuB;AAChD,MAAI,UAA2D;AAC/D,QAAM,WAAW,MAAM,QAAQ,WAAW,YAAY,IAAI,OAAO,KAAK,EAAE,GAAG,SAAS,QAAQ,MAAM,GAAG,KAAK,EAAE,CAAC;AAE7G,SAAO;AAAA,IACL,MAAM,OAAO,SAAS,SAAS;AAC7B,UAAI,OAAO,WAAW,OAAO,IAAI,aAAc,OAAM,IAAI,MAAM,6BAA6B,OAAO,GAAG;AACtG,YAAM,SAAS,EAAE,MAAM,4BAA4B,CAAC,SAAS,OAAO,CAAC;AAAA,IACvE;AAAA,IAEA,OAAO,SAAS,WAAW;AACzB,UAAI,CAAC,WAAW,KAAK,OAAO,EAAG,OAAM,IAAI,MAAM,wBAAwB,OAAO,EAAE;AAChF,UAAI,SAAgC;AACpC,UAAI,SAAS;AACb,UAAI,YAAY;AAChB,UAAI,QAAQ;AACZ,UAAI,QAA8C;AAElD,YAAM,WAAW,CAAC,UAAmB;AACnC,YAAI,cAAc,MAAO;AACzB,oBAAY;AACZ,gBAAQ,UAAU,KAAK;AAAA,MACzB;AAEA,YAAM,QAAQ,MAAM;AAClB,iBAAS,KAAK;AACd,gBAAQ,mBAAmB;AAC3B,gBAAQ,IAAI,EAAE,MAAM,MAAM,MAAS;AACnC,iBAAS;AACT,YAAI,UAAU,MAAO;AACrB,gBAAQ,WAAW,MAAM;AACvB,kBAAQ;AACR,eAAK,QAAQ;AAAA,QACf,GAAG,KAAK;AACR,gBAAQ,KAAK,IAAI,QAAQ,GAAG,QAAQ;AAAA,MACtC;AAEA,YAAM,UAAU,YAAY;AAC1B,cAAM,OAAO,IAAI,OAAO,OAAO,SAAS,QAAQ,MAAM,CAAC;AACvD,iBAAS;AACT,aAAK,GAAG,SAAS,KAAK;AACtB,aAAK,GAAG,OAAO,KAAK;AACpB,aAAK,GAAG,gBAAgB,CAAC,QAAQ;AAC/B,cAAI,IAAI,YAAY,WAAW,IAAI,QAAS,WAAU,IAAI,OAAO;AAAA,QACnE,CAAC;AACD,YAAI;AACF,gBAAM,KAAK,QAAQ;AACnB,gBAAM,KAAK,MAAM,UAAU,OAAO,EAAE;AACpC,cAAI,OAAQ,QAAO,KAAK,KAAK,IAAI,EAAE,MAAM,MAAM,MAAS;AACxD,kBAAQ;AACR,mBAAS,IAAI;AAAA,QACf,SAAS,OAAO;AACd,kBAAQ,KAAK,qBAAqB,OAAO,KAAM,MAAgB,OAAO,EAAE;AACxE,gBAAM;AAAA,QACR;AAAA,MACF;AAEA,WAAK,QAAQ;AACb,aAAO;AAAA,QACL,IAAI,YAAY;AACd,iBAAO;AAAA,QACT;AAAA,QACA,MAAM,QAAQ;AACZ,mBAAS;AACT,cAAI,MAAO,cAAa,KAAK;AAC7B,kBAAQ;AACR,mBAAS,KAAK;AACd,gBAAM,OAAO;AACb,mBAAS;AACT,gBAAM,mBAAmB;AACzB,gBAAM,MAAM,IAAI,EAAE,MAAM,MAAM,MAAS;AAAA,QACzC;AAAA,MACF;AAAA,IACF;AAAA,IAEA,MAAM,QAAQ;AACZ,YAAM,OAAO;AACb,gBAAU;AACV,YAAM,MAAM,IAAI;AAAA,IAClB;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { P as PresenceOptions, R as RealtimeBus } from './types-BP1h40fn.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two PostgreSQL primitives the adapter needs — injected, so this module depends on no database client. Bring
|
|
5
|
+
* your own (the app's pool for `notify`, a dedicated connection for `listen`), or build both with `pgChannel` from
|
|
6
|
+
* `@krizaka/realtime/pg`.
|
|
7
|
+
*/
|
|
8
|
+
interface PostgresChannel {
|
|
9
|
+
/** `select pg_notify(channel, payload)` — delivered to every LISTENer at commit. */
|
|
10
|
+
notify(channel: string, payload: string): Promise<void>;
|
|
11
|
+
/** One dedicated LISTEN connection that reconnects by itself. */
|
|
12
|
+
listen(channel: string, onMessage: (payload: string) => void): {
|
|
13
|
+
readonly connected: boolean;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
interface PostgresBusOptions extends Partial<PresenceOptions> {
|
|
18
|
+
db: PostgresChannel;
|
|
19
|
+
/**
|
|
20
|
+
* The NOTIFY channel (one for every topic), a lowercase SQL identifier. Every instance of one app must use the same
|
|
21
|
+
* one; two apps sharing a database use two. Default `DEFAULT_CHANNEL`.
|
|
22
|
+
*/
|
|
23
|
+
channel?: string;
|
|
24
|
+
/** How often this instance tells the others who is connected to it (ms). */
|
|
25
|
+
snapshotIntervalMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/** PostgreSQL's limit is 8000 bytes per payload; the envelope stays under it. */
|
|
28
|
+
declare const NOTIFY_LIMIT = 7900;
|
|
29
|
+
/** The NOTIFY channel used when none is given. */
|
|
30
|
+
declare const DEFAULT_CHANNEL = "realtime_events";
|
|
31
|
+
/**
|
|
32
|
+
* PostgreSQL LISTEN/NOTIFY: every instance LISTENs on one channel and hands each event to its local subscribers —
|
|
33
|
+
* no broker, the database the app already has does the fan-out. Every instance receives every event (fine for a
|
|
34
|
+
* handful of instances; past that, Redis subscribes per channel). Presence is kept in each instance's memory and
|
|
35
|
+
* replicated over the same channel (deltas + a snapshot every `snapshotIntervalMs`). While the LISTEN connection is
|
|
36
|
+
* down, events still reach the other instances (NOTIFY needs no LISTEN) and this instance's subscribers directly.
|
|
37
|
+
*/
|
|
38
|
+
declare function postgresBus(options: PostgresBusOptions): RealtimeBus;
|
|
39
|
+
|
|
40
|
+
export { DEFAULT_CHANNEL, NOTIFY_LIMIT, type PostgresBusOptions, type PostgresChannel, postgresBus };
|
package/dist/postgres.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/redis.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { RedisOptions, Redis } from 'ioredis';
|
|
2
|
+
import { P as PresenceOptions, b as PresenceStore, C as ConnectionState, a as PresenceState, d as PresenceInfo, R as RealtimeBus } from './types-BP1h40fn.js';
|
|
3
|
+
|
|
4
|
+
interface RedisBusOptions extends Partial<PresenceOptions> {
|
|
5
|
+
/** `redis://` or `rediss://` (Redis or Valkey — DigitalOcean Managed Caching speaks the same protocol). */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Prefix of every key and channel (several products can share one server). Default `DEFAULT_PREFIX`. */
|
|
8
|
+
prefix?: string;
|
|
9
|
+
/** How long "last seen" is kept (seconds). */
|
|
10
|
+
lastSeenTtlSeconds?: number;
|
|
11
|
+
redisOptions?: RedisOptions;
|
|
12
|
+
}
|
|
13
|
+
/** The key and channel prefix used when none is given — give each app its own when they share a server. */
|
|
14
|
+
declare const DEFAULT_PREFIX = "rt:";
|
|
15
|
+
/** Presence in Redis / Valkey: shared by every instance, one atomic script per beat, keys that expire by themselves. */
|
|
16
|
+
declare class RedisPresence implements PresenceStore {
|
|
17
|
+
private readonly redis;
|
|
18
|
+
private readonly prefix;
|
|
19
|
+
readonly options: PresenceOptions;
|
|
20
|
+
private readonly keep;
|
|
21
|
+
constructor(redis: Redis, prefix: string, options?: Partial<PresenceOptions> & {
|
|
22
|
+
lastSeenTtlSeconds?: number;
|
|
23
|
+
});
|
|
24
|
+
private keys;
|
|
25
|
+
private run;
|
|
26
|
+
beat(userId: string, connectionId: string, state: ConnectionState, now?: number): Promise<{
|
|
27
|
+
userId: string;
|
|
28
|
+
state: PresenceState;
|
|
29
|
+
at: number;
|
|
30
|
+
} | null>;
|
|
31
|
+
leave(userId: string, connectionId: string, now?: number): Promise<{
|
|
32
|
+
userId: string;
|
|
33
|
+
state: PresenceState;
|
|
34
|
+
at: number;
|
|
35
|
+
} | null>;
|
|
36
|
+
settle(userId: string, now?: number): Promise<{
|
|
37
|
+
userId: string;
|
|
38
|
+
state: PresenceState;
|
|
39
|
+
at: number;
|
|
40
|
+
} | null>;
|
|
41
|
+
/** Every change is the answer of the call that caused it: nothing is discovered later. */
|
|
42
|
+
onChange(): () => void;
|
|
43
|
+
get(userIds: string[], now?: number): Promise<Map<string, PresenceInfo>>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Redis / Valkey pub/sub: each instance subscribes only to the channels its open streams need (one subscription per
|
|
47
|
+
* channel, counted), so an event crosses the network only to the instances that have someone listening — the choice
|
|
48
|
+
* for several instances and many streams. Presence lives in Redis (`RedisPresence`).
|
|
49
|
+
*/
|
|
50
|
+
declare function redisBus(options: RedisBusOptions): RealtimeBus;
|
|
51
|
+
|
|
52
|
+
export { DEFAULT_PREFIX, type RedisBusOptions, RedisPresence, redisBus };
|