@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
package/dist/redis.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PRESENCE,
|
|
3
|
+
emitSafely,
|
|
4
|
+
localEmitter,
|
|
5
|
+
rank
|
|
6
|
+
} from "./chunk-HJCRR3FF.js";
|
|
7
|
+
|
|
8
|
+
// src/redis.ts
|
|
9
|
+
import { Redis } from "ioredis";
|
|
10
|
+
var DEFAULT_PREFIX = "rt:";
|
|
11
|
+
var UPDATE = `
|
|
12
|
+
local key, seen = KEYS[1], KEYS[2]
|
|
13
|
+
local op, conn, st = ARGV[1], ARGV[2], ARGV[3]
|
|
14
|
+
local now, ttl, grace, keep = tonumber(ARGV[4]), tonumber(ARGV[5]), tonumber(ARGV[6]), tonumber(ARGV[7])
|
|
15
|
+
if op == "beat" then
|
|
16
|
+
redis.call("HSET", key, "c:" .. conn, st .. "|" .. (now + ttl))
|
|
17
|
+
elseif op == "leave" then
|
|
18
|
+
local cur = redis.call("HGET", key, "c:" .. conn)
|
|
19
|
+
if cur then
|
|
20
|
+
local s, exp = string.match(cur, "^(%a+)|(%d+)$")
|
|
21
|
+
redis.call("HSET", key, "c:" .. conn, s .. "|" .. math.min(tonumber(exp), now + grace))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
local best = 0
|
|
25
|
+
local all = redis.call("HGETALL", key)
|
|
26
|
+
for i = 1, #all, 2 do
|
|
27
|
+
local f, v = all[i], all[i + 1]
|
|
28
|
+
if string.sub(f, 1, 2) == "c:" then
|
|
29
|
+
local s, exp = string.match(v, "^(%a+)|(%d+)$")
|
|
30
|
+
if tonumber(exp) <= now then
|
|
31
|
+
redis.call("HDEL", key, f)
|
|
32
|
+
else
|
|
33
|
+
local r = 1
|
|
34
|
+
if s == "online" then r = 2 end
|
|
35
|
+
if r > best then best = r end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
local nextState = "offline"
|
|
40
|
+
if best == 2 then nextState = "online" elseif best == 1 then nextState = "away" end
|
|
41
|
+
local prev = redis.call("HGET", key, "a") or "offline"
|
|
42
|
+
if nextState == "offline" then
|
|
43
|
+
if prev ~= "offline" then redis.call("SET", seen, now, "EX", keep) end
|
|
44
|
+
redis.call("DEL", key)
|
|
45
|
+
else
|
|
46
|
+
redis.call("HSET", key, "a", nextState)
|
|
47
|
+
redis.call("PEXPIRE", key, ttl * 2)
|
|
48
|
+
redis.call("SET", seen, now, "EX", keep)
|
|
49
|
+
end
|
|
50
|
+
if nextState == prev then return false end
|
|
51
|
+
return nextState
|
|
52
|
+
`;
|
|
53
|
+
var RedisPresence = class {
|
|
54
|
+
constructor(redis, prefix, options = {}) {
|
|
55
|
+
this.redis = redis;
|
|
56
|
+
this.prefix = prefix;
|
|
57
|
+
this.options = { ...DEFAULT_PRESENCE, ...options };
|
|
58
|
+
this.keep = options.lastSeenTtlSeconds ?? 90 * 24 * 3600;
|
|
59
|
+
}
|
|
60
|
+
redis;
|
|
61
|
+
prefix;
|
|
62
|
+
options;
|
|
63
|
+
keep;
|
|
64
|
+
// The {user} hash tag keeps both keys in one slot (Redis Cluster).
|
|
65
|
+
keys(userId) {
|
|
66
|
+
return [`${this.prefix}p:{${userId}}`, `${this.prefix}s:{${userId}}`];
|
|
67
|
+
}
|
|
68
|
+
async run(op, userId, connectionId, state, now) {
|
|
69
|
+
const [key, seen] = this.keys(userId);
|
|
70
|
+
const result = await this.redis.eval(UPDATE, 2, key, seen, op, connectionId, state, now, this.options.ttlMs, this.options.graceMs, this.keep);
|
|
71
|
+
return result ? { userId, state: result, at: now } : null;
|
|
72
|
+
}
|
|
73
|
+
beat(userId, connectionId, state, now = Date.now()) {
|
|
74
|
+
return this.run("beat", userId, connectionId, state, now);
|
|
75
|
+
}
|
|
76
|
+
leave(userId, connectionId, now = Date.now()) {
|
|
77
|
+
return this.run("leave", userId, connectionId, "online", now);
|
|
78
|
+
}
|
|
79
|
+
settle(userId, now = Date.now()) {
|
|
80
|
+
return this.run("settle", userId, "", "online", now);
|
|
81
|
+
}
|
|
82
|
+
/** Every change is the answer of the call that caused it: nothing is discovered later. */
|
|
83
|
+
onChange() {
|
|
84
|
+
return () => void 0;
|
|
85
|
+
}
|
|
86
|
+
async get(userIds, now = Date.now()) {
|
|
87
|
+
const out = /* @__PURE__ */ new Map();
|
|
88
|
+
if (userIds.length === 0) return out;
|
|
89
|
+
const pipeline = this.redis.pipeline();
|
|
90
|
+
for (const id of userIds) {
|
|
91
|
+
const [key, seen] = this.keys(id);
|
|
92
|
+
pipeline.hgetall(key);
|
|
93
|
+
pipeline.get(seen);
|
|
94
|
+
}
|
|
95
|
+
const replies = await pipeline.exec() ?? [];
|
|
96
|
+
userIds.forEach((id, i) => {
|
|
97
|
+
const hash = replies[i * 2]?.[1] ?? {};
|
|
98
|
+
const seen = replies[i * 2 + 1]?.[1];
|
|
99
|
+
let state = "offline";
|
|
100
|
+
for (const [field, value] of Object.entries(hash)) {
|
|
101
|
+
if (!field.startsWith("c:")) continue;
|
|
102
|
+
const [s, exp] = value.split("|");
|
|
103
|
+
if (Number(exp) > now && rank(s) > rank(state)) state = s;
|
|
104
|
+
}
|
|
105
|
+
out.set(id, { state, lastSeenAt: state === "offline" ? seen ? Number(seen) : null : now });
|
|
106
|
+
});
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
function redisBus(options) {
|
|
111
|
+
const prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
112
|
+
const base = { maxRetriesPerRequest: 2, enableOfflineQueue: true, ...options.redisOptions };
|
|
113
|
+
const pub = new Redis(options.url, base);
|
|
114
|
+
const sub = new Redis(options.url, { ...base, maxRetriesPerRequest: null });
|
|
115
|
+
const emitter = localEmitter();
|
|
116
|
+
const counts = /* @__PURE__ */ new Map();
|
|
117
|
+
for (const client of [pub, sub]) client.on("error", (error) => console.warn(`[realtime] redis: ${error.message}`));
|
|
118
|
+
sub.on("message", (channel, message) => {
|
|
119
|
+
if (!channel.startsWith(prefix)) return;
|
|
120
|
+
try {
|
|
121
|
+
emitSafely(emitter, channel.slice(prefix.length), JSON.parse(message));
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
kind: "redis",
|
|
127
|
+
get connected() {
|
|
128
|
+
return sub.status === "ready" && pub.status === "ready";
|
|
129
|
+
},
|
|
130
|
+
presence: new RedisPresence(pub, prefix, options),
|
|
131
|
+
async publish(channel, event) {
|
|
132
|
+
try {
|
|
133
|
+
await pub.publish(prefix + channel, JSON.stringify(event));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.warn(`[realtime] publish ${channel} failed, delivered locally`, error.message);
|
|
136
|
+
emitSafely(emitter, channel, event);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
subscribe(channel, handler) {
|
|
140
|
+
emitter.on(channel, handler);
|
|
141
|
+
const n = (counts.get(channel) ?? 0) + 1;
|
|
142
|
+
counts.set(channel, n);
|
|
143
|
+
if (n === 1) void sub.subscribe(prefix + channel).catch((error) => console.warn(`[realtime] subscribe ${channel}: ${error.message}`));
|
|
144
|
+
return () => {
|
|
145
|
+
emitter.off(channel, handler);
|
|
146
|
+
const left = (counts.get(channel) ?? 1) - 1;
|
|
147
|
+
if (left > 0) return void counts.set(channel, left);
|
|
148
|
+
counts.delete(channel);
|
|
149
|
+
void sub.unsubscribe(prefix + channel).catch(() => void 0);
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
async close() {
|
|
153
|
+
emitter.removeAllListeners();
|
|
154
|
+
await Promise.allSettled([pub.quit(), sub.quit()]);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export {
|
|
159
|
+
DEFAULT_PREFIX,
|
|
160
|
+
RedisPresence,
|
|
161
|
+
redisBus
|
|
162
|
+
};
|
|
163
|
+
//# sourceMappingURL=redis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/redis.ts"],"sourcesContent":["import { Redis, type RedisOptions } from \"ioredis\";\n\nimport { emitSafely, localEmitter } from \"./memory\";\nimport { type ConnectionState, DEFAULT_PRESENCE, type PresenceInfo, type PresenceOptions, type PresenceState, type PresenceStore, rank, type RealtimeBus } from \"./types\";\n\nexport interface RedisBusOptions extends Partial<PresenceOptions> {\n /** `redis://` or `rediss://` (Redis or Valkey — DigitalOcean Managed Caching speaks the same protocol). */\n url: string;\n /** Prefix of every key and channel (several products can share one server). Default `DEFAULT_PREFIX`. */\n prefix?: string;\n /** How long \"last seen\" is kept (seconds). */\n lastSeenTtlSeconds?: number;\n redisOptions?: RedisOptions;\n}\n\n/** The key and channel prefix used when none is given — give each app its own when they share a server. */\nexport const DEFAULT_PREFIX = \"rt:\";\n\n/**\n * One person's presence, atomically: apply the operation, drop expired connections, compare the best remaining state\n * with the announced one. Returns the new state when it changed (announce it), nil otherwise.\n * KEYS: the person's hash (`c:<connection>` = \"<state>|<expiresAt>\", `a` = announced), their last-seen key.\n */\nconst UPDATE = `\nlocal key, seen = KEYS[1], KEYS[2]\nlocal op, conn, st = ARGV[1], ARGV[2], ARGV[3]\nlocal now, ttl, grace, keep = tonumber(ARGV[4]), tonumber(ARGV[5]), tonumber(ARGV[6]), tonumber(ARGV[7])\nif op == \"beat\" then\n redis.call(\"HSET\", key, \"c:\" .. conn, st .. \"|\" .. (now + ttl))\nelseif op == \"leave\" then\n local cur = redis.call(\"HGET\", key, \"c:\" .. conn)\n if cur then\n local s, exp = string.match(cur, \"^(%a+)|(%d+)$\")\n redis.call(\"HSET\", key, \"c:\" .. conn, s .. \"|\" .. math.min(tonumber(exp), now + grace))\n end\nend\nlocal best = 0\nlocal all = redis.call(\"HGETALL\", key)\nfor i = 1, #all, 2 do\n local f, v = all[i], all[i + 1]\n if string.sub(f, 1, 2) == \"c:\" then\n local s, exp = string.match(v, \"^(%a+)|(%d+)$\")\n if tonumber(exp) <= now then\n redis.call(\"HDEL\", key, f)\n else\n local r = 1\n if s == \"online\" then r = 2 end\n if r > best then best = r end\n end\n end\nend\nlocal nextState = \"offline\"\nif best == 2 then nextState = \"online\" elseif best == 1 then nextState = \"away\" end\nlocal prev = redis.call(\"HGET\", key, \"a\") or \"offline\"\nif nextState == \"offline\" then\n if prev ~= \"offline\" then redis.call(\"SET\", seen, now, \"EX\", keep) end\n redis.call(\"DEL\", key)\nelse\n redis.call(\"HSET\", key, \"a\", nextState)\n redis.call(\"PEXPIRE\", key, ttl * 2)\n redis.call(\"SET\", seen, now, \"EX\", keep)\nend\nif nextState == prev then return false end\nreturn nextState\n`;\n\n/** Presence in Redis / Valkey: shared by every instance, one atomic script per beat, keys that expire by themselves. */\nexport class RedisPresence implements PresenceStore {\n readonly options: PresenceOptions;\n private readonly keep: number;\n\n constructor(\n private readonly redis: Redis,\n private readonly prefix: string,\n options: Partial<PresenceOptions> & { lastSeenTtlSeconds?: number } = {},\n ) {\n this.options = { ...DEFAULT_PRESENCE, ...options };\n this.keep = options.lastSeenTtlSeconds ?? 90 * 24 * 3600;\n }\n\n // The {user} hash tag keeps both keys in one slot (Redis Cluster).\n private keys(userId: string) {\n return [`${this.prefix}p:{${userId}}`, `${this.prefix}s:{${userId}}`];\n }\n\n private async run(op: \"beat\" | \"leave\" | \"settle\", userId: string, connectionId: string, state: ConnectionState, now: number) {\n const [key, seen] = this.keys(userId);\n const result = (await this.redis.eval(UPDATE, 2, key, seen, op, connectionId, state, now, this.options.ttlMs, this.options.graceMs, this.keep)) as string | null;\n return result ? { userId, state: result as PresenceState, at: now } : null;\n }\n\n beat(userId: string, connectionId: string, state: ConnectionState, now = Date.now()) {\n return this.run(\"beat\", userId, connectionId, state, now);\n }\n\n leave(userId: string, connectionId: string, now = Date.now()) {\n return this.run(\"leave\", userId, connectionId, \"online\", now);\n }\n\n settle(userId: string, now = Date.now()) {\n return this.run(\"settle\", userId, \"\", \"online\", now);\n }\n\n /** Every change is the answer of the call that caused it: nothing is discovered later. */\n onChange(): () => void {\n return () => undefined;\n }\n\n async get(userIds: string[], now = Date.now()) {\n const out = new Map<string, PresenceInfo>();\n if (userIds.length === 0) return out;\n const pipeline = this.redis.pipeline();\n for (const id of userIds) {\n const [key, seen] = this.keys(id);\n pipeline.hgetall(key);\n pipeline.get(seen);\n }\n const replies = (await pipeline.exec()) ?? [];\n userIds.forEach((id, i) => {\n const hash = (replies[i * 2]?.[1] ?? {}) as Record<string, string>;\n const seen = replies[i * 2 + 1]?.[1] as string | null;\n let state: PresenceState = \"offline\";\n for (const [field, value] of Object.entries(hash)) {\n if (!field.startsWith(\"c:\")) continue;\n const [s, exp] = value.split(\"|\");\n if (Number(exp) > now && rank(s as ConnectionState) > rank(state)) state = s as ConnectionState;\n }\n out.set(id, { state, lastSeenAt: state === \"offline\" ? (seen ? Number(seen) : null) : now });\n });\n return out;\n }\n}\n\n/**\n * Redis / Valkey pub/sub: each instance subscribes only to the channels its open streams need (one subscription per\n * channel, counted), so an event crosses the network only to the instances that have someone listening — the choice\n * for several instances and many streams. Presence lives in Redis (`RedisPresence`).\n */\nexport function redisBus(options: RedisBusOptions): RealtimeBus {\n const prefix = options.prefix ?? DEFAULT_PREFIX;\n const base: RedisOptions = { maxRetriesPerRequest: 2, enableOfflineQueue: true, ...options.redisOptions };\n const pub = new Redis(options.url, base);\n const sub = new Redis(options.url, { ...base, maxRetriesPerRequest: null });\n const emitter = localEmitter();\n const counts = new Map<string, number>();\n for (const client of [pub, sub]) client.on(\"error\", (error) => console.warn(`[realtime] redis: ${error.message}`));\n\n sub.on(\"message\", (channel: string, message: string) => {\n if (!channel.startsWith(prefix)) return;\n try {\n emitSafely(emitter, channel.slice(prefix.length), JSON.parse(message));\n } catch {\n /* not ours */\n }\n });\n\n return {\n kind: \"redis\",\n get connected() {\n return sub.status === \"ready\" && pub.status === \"ready\";\n },\n presence: new RedisPresence(pub, prefix, options),\n async publish(channel, event) {\n try {\n await pub.publish(prefix + channel, JSON.stringify(event));\n } catch (error) {\n console.warn(`[realtime] publish ${channel} failed, delivered locally`, (error as Error).message);\n emitSafely(emitter, channel, event);\n }\n },\n subscribe(channel, handler) {\n emitter.on(channel, handler);\n const n = (counts.get(channel) ?? 0) + 1;\n counts.set(channel, n);\n if (n === 1) void sub.subscribe(prefix + channel).catch((error: Error) => console.warn(`[realtime] subscribe ${channel}: ${error.message}`));\n return () => {\n emitter.off(channel, handler);\n const left = (counts.get(channel) ?? 1) - 1;\n if (left > 0) return void counts.set(channel, left);\n counts.delete(channel);\n void sub.unsubscribe(prefix + channel).catch(() => undefined);\n };\n },\n async close() {\n emitter.removeAllListeners();\n await Promise.allSettled([pub.quit(), sub.quit()]);\n },\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events, framed by hand (the format is four fields). `eventStream` turns a few callbacks into a web
|
|
3
|
+
* `Response` any runtime serves: heartbeats keep proxies from closing an idle stream, `retry` tells the browser how
|
|
4
|
+
* fast to come back, and a maximum lifetime ends the stream cleanly before a platform's own request timeout does
|
|
5
|
+
* (the client reconnects at once, with `Last-Event-ID`).
|
|
6
|
+
*/
|
|
7
|
+
interface SseFrame {
|
|
8
|
+
/** The event's id: the browser sends the last one back as `Last-Event-ID` when it reconnects. */
|
|
9
|
+
id?: string;
|
|
10
|
+
/** A named event (`addEventListener(name)`); absent: `message`. */
|
|
11
|
+
event?: string;
|
|
12
|
+
data?: unknown;
|
|
13
|
+
/** Reconnection delay the client should use (ms). */
|
|
14
|
+
retry?: number;
|
|
15
|
+
/** A comment line (heartbeats). */
|
|
16
|
+
comment?: string;
|
|
17
|
+
}
|
|
18
|
+
declare function sseFrame(frame: SseFrame): string;
|
|
19
|
+
interface EventStreamControls {
|
|
20
|
+
send(frame: SseFrame): void;
|
|
21
|
+
/** Ends the stream from the server side (the client reconnects). */
|
|
22
|
+
close(): void;
|
|
23
|
+
/** Aborted when the client goes away or the stream ends. */
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
interface EventStreamOptions {
|
|
27
|
+
/** Runs once the stream is open; returns the cleanup to run when it ends. */
|
|
28
|
+
start(controls: EventStreamControls): void | (() => void) | Promise<void | (() => void)>;
|
|
29
|
+
/** Heartbeat period (ms) — below every proxy's idle timeout. */
|
|
30
|
+
heartbeatMs?: number;
|
|
31
|
+
/** Called at each heartbeat (presence refreshes its TTL there). */
|
|
32
|
+
onHeartbeat?: () => void;
|
|
33
|
+
/** Ends the stream after this long (ms, 0: never) — under a platform's hard request timeout. */
|
|
34
|
+
maxLifetimeMs?: number;
|
|
35
|
+
/** Reconnection delay sent to the client (ms). */
|
|
36
|
+
retryMs?: number;
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
declare function eventStream(options: EventStreamOptions): Response;
|
|
40
|
+
|
|
41
|
+
export { type EventStreamControls, type EventStreamOptions, type SseFrame, eventStream, sseFrame };
|
package/dist/sse.js
ADDED
package/dist/sse.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The realtime contract. Application code knows these interfaces only; which adapter carries them (memory, PostgreSQL
|
|
3
|
+
* LISTEN/NOTIFY, Redis / Valkey) is a deployment choice (`createBus`).
|
|
4
|
+
*/
|
|
5
|
+
type Unsubscribe = () => void;
|
|
6
|
+
/** What a connection says about its person: in front of the screen, or connected but idle / hidden. */
|
|
7
|
+
type ConnectionState = "online" | "away";
|
|
8
|
+
/** A person's presence: the best state among their connections, `offline` when none is left. */
|
|
9
|
+
type PresenceState = ConnectionState | "offline";
|
|
10
|
+
interface PresenceInfo {
|
|
11
|
+
state: PresenceState;
|
|
12
|
+
/** Epoch ms the person was last connected (null: never seen by this store — the caller may know better). */
|
|
13
|
+
lastSeenAt: number | null;
|
|
14
|
+
}
|
|
15
|
+
/** A person's presence changed (the store announces each change once, whichever instance noticed it). */
|
|
16
|
+
interface PresenceChange {
|
|
17
|
+
userId: string;
|
|
18
|
+
state: PresenceState;
|
|
19
|
+
at: number;
|
|
20
|
+
}
|
|
21
|
+
interface PresenceOptions {
|
|
22
|
+
/** How long a connection counts without a new beat (ms). Beats come at a third of it. */
|
|
23
|
+
ttlMs: number;
|
|
24
|
+
/** How long a closed connection still counts (ms): a reconnection inside it changes nothing (no offline flicker). */
|
|
25
|
+
graceMs: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Who is connected, kept by heartbeat and time-to-live — never a database write per beat. Every method answers the
|
|
29
|
+
* change it caused, if any, so the caller publishes it once.
|
|
30
|
+
*/
|
|
31
|
+
interface PresenceStore {
|
|
32
|
+
/** A connection is (still) there, in this state: opens it or refreshes its TTL. */
|
|
33
|
+
beat(userId: string, connectionId: string, state: ConnectionState, now?: number): Promise<PresenceChange | null>;
|
|
34
|
+
/** A connection closed: it lingers `graceMs`, then the person may be offline — `settle` tells. */
|
|
35
|
+
leave(userId: string, connectionId: string, now?: number): Promise<PresenceChange | null>;
|
|
36
|
+
/** Re-evaluates a person after expiries (the caller runs it once the grace has passed). */
|
|
37
|
+
settle(userId: string, now?: number): Promise<PresenceChange | null>;
|
|
38
|
+
/** The presence of these people (absent from the store: offline, last seen unknown). */
|
|
39
|
+
get(userIds: string[], now?: number): Promise<Map<string, PresenceInfo>>;
|
|
40
|
+
/**
|
|
41
|
+
* Changes this instance discovers on its own rather than as the answer to a call (another instance's report that
|
|
42
|
+
* makes a person offline). Announce them like the others. Returns the unsubscribe function.
|
|
43
|
+
*/
|
|
44
|
+
onChange(listener: (change: PresenceChange) => void): Unsubscribe;
|
|
45
|
+
}
|
|
46
|
+
type BusKind = "memory" | "postgres" | "redis";
|
|
47
|
+
/**
|
|
48
|
+
* The bus: events by channel to every subscriber of every instance, and presence. Best-effort by design — a publish
|
|
49
|
+
* never throws; durable facts (messages) live in the database and are replayed from it, never from the bus.
|
|
50
|
+
*/
|
|
51
|
+
interface RealtimeBus {
|
|
52
|
+
readonly kind: BusKind;
|
|
53
|
+
/** True while events published by other instances reach this one (always true for `memory`). */
|
|
54
|
+
readonly connected: boolean;
|
|
55
|
+
readonly presence: PresenceStore;
|
|
56
|
+
publish(channel: string, event: unknown): Promise<void>;
|
|
57
|
+
subscribe(channel: string, handler: (event: unknown) => void): Unsubscribe;
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
declare const DEFAULT_PRESENCE: PresenceOptions;
|
|
61
|
+
/** The rank of a state: a person is as present as their most present connection. */
|
|
62
|
+
declare const rank: (state: PresenceState) => 0 | 2 | 1;
|
|
63
|
+
|
|
64
|
+
export { type BusKind as B, type ConnectionState as C, DEFAULT_PRESENCE as D, type PresenceOptions as P, type RealtimeBus as R, type Unsubscribe as U, type PresenceState as a, type PresenceStore as b, type PresenceChange as c, type PresenceInfo as d, rank as r };
|
package/package.json
CHANGED
|
@@ -1,6 +1,154 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/realtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Realtime for Node apps behind one interface: a bus (publish / subscribe by channel) and presence (who is connected, by heartbeat and TTL), with memory, PostgreSQL LISTEN/NOTIFY and Redis / Valkey adapters, plus Server-Sent Events framing.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"krizaka",
|
|
7
|
+
"realtime",
|
|
8
|
+
"server-sent-events",
|
|
9
|
+
"sse",
|
|
10
|
+
"presence",
|
|
11
|
+
"pubsub",
|
|
12
|
+
"postgresql",
|
|
13
|
+
"listen-notify",
|
|
14
|
+
"redis",
|
|
15
|
+
"valkey"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/krizaka/krizaka-ui/tree/main/packages/realtime#readme",
|
|
18
|
+
"bugs": "https://github.com/krizaka/krizaka-ui/issues",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/krizaka/krizaka-ui.git",
|
|
22
|
+
"directory": "packages/realtime"
|
|
23
|
+
},
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"author": "Krizaka (https://www.krizaka.com)",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./sse": {
|
|
36
|
+
"types": "./dist/sse.d.ts",
|
|
37
|
+
"import": "./dist/sse.js"
|
|
38
|
+
},
|
|
39
|
+
"./postgres": {
|
|
40
|
+
"types": "./dist/postgres.d.ts",
|
|
41
|
+
"import": "./dist/postgres.js"
|
|
42
|
+
},
|
|
43
|
+
"./pg": {
|
|
44
|
+
"types": "./dist/pg.d.ts",
|
|
45
|
+
"import": "./dist/pg.js"
|
|
46
|
+
},
|
|
47
|
+
"./redis": {
|
|
48
|
+
"types": "./dist/redis.d.ts",
|
|
49
|
+
"import": "./dist/redis.js"
|
|
50
|
+
},
|
|
51
|
+
"./package.json": "./package.json"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"dist",
|
|
55
|
+
"README.md",
|
|
56
|
+
"LICENSE"
|
|
57
|
+
],
|
|
58
|
+
"sideEffects": false,
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"ioredis": "^5.4.0",
|
|
61
|
+
"pg": "^8.11.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"ioredis": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"pg": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
73
|
+
"@size-limit/preset-small-lib": "^14.2.0",
|
|
74
|
+
"@types/node": "^22.15.0",
|
|
75
|
+
"@types/pg": "^8.15.0",
|
|
76
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
77
|
+
"eslint": "^9.39.0",
|
|
78
|
+
"ioredis": "^5.11.1",
|
|
79
|
+
"pg": "^8.16.0",
|
|
80
|
+
"publint": "^0.3.25",
|
|
81
|
+
"size-limit": "^14.2.0",
|
|
82
|
+
"tsup": "^8.5.1",
|
|
83
|
+
"typescript": "^5.9.0",
|
|
84
|
+
"vitest": "^4.1.11",
|
|
85
|
+
"@krizaka/config": "0.1.0"
|
|
86
|
+
},
|
|
87
|
+
"engines": {
|
|
88
|
+
"node": ">=20"
|
|
89
|
+
},
|
|
90
|
+
"publishConfig": {
|
|
91
|
+
"access": "public"
|
|
92
|
+
},
|
|
93
|
+
"size-limit": [
|
|
94
|
+
{
|
|
95
|
+
"name": ".",
|
|
96
|
+
"path": "dist/index.js",
|
|
97
|
+
"limit": "4 kB",
|
|
98
|
+
"gzip": true,
|
|
99
|
+
"ignore": [
|
|
100
|
+
"node:crypto",
|
|
101
|
+
"node:events",
|
|
102
|
+
"crypto",
|
|
103
|
+
"events"
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "./sse",
|
|
108
|
+
"path": "dist/sse.js",
|
|
109
|
+
"limit": "1 kB",
|
|
110
|
+
"gzip": true,
|
|
111
|
+
"ignore": [
|
|
112
|
+
"node:crypto",
|
|
113
|
+
"node:events",
|
|
114
|
+
"crypto",
|
|
115
|
+
"events"
|
|
116
|
+
]
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"name": "./pg",
|
|
120
|
+
"path": "dist/pg.js",
|
|
121
|
+
"ignore": [
|
|
122
|
+
"pg",
|
|
123
|
+
"node:crypto",
|
|
124
|
+
"node:events",
|
|
125
|
+
"crypto",
|
|
126
|
+
"events"
|
|
127
|
+
],
|
|
128
|
+
"limit": "2 kB",
|
|
129
|
+
"gzip": true
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "./redis",
|
|
133
|
+
"path": "dist/redis.js",
|
|
134
|
+
"ignore": [
|
|
135
|
+
"ioredis",
|
|
136
|
+
"node:crypto",
|
|
137
|
+
"node:events",
|
|
138
|
+
"crypto",
|
|
139
|
+
"events"
|
|
140
|
+
],
|
|
141
|
+
"limit": "3 kB",
|
|
142
|
+
"gzip": true
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
"scripts": {
|
|
146
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup",
|
|
147
|
+
"lint": "eslint . --max-warnings 0 && tsc --noEmit --noUnusedLocals --noUnusedParameters --noFallthroughCasesInSwitch --noImplicitReturns",
|
|
148
|
+
"typecheck": "tsc --noEmit",
|
|
149
|
+
"test": "vitest run --coverage",
|
|
150
|
+
"test:integration": "REALTIME_IT=1 vitest run test/bus.integration.test.ts",
|
|
151
|
+
"size": "size-limit",
|
|
152
|
+
"publint": "publint && attw --pack . --profile esm-only"
|
|
153
|
+
}
|
|
6
154
|
}
|