@mt-tl/server 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 +21 -0
- package/README.md +50 -0
- package/dist/auth/handshake.d.ts +35 -0
- package/dist/auth/handshake.d.ts.map +1 -0
- package/dist/auth/handshake.js +208 -0
- package/dist/auth/handshake.js.map +1 -0
- package/dist/auth/nonce-store.d.ts +22 -0
- package/dist/auth/nonce-store.d.ts.map +1 -0
- package/dist/auth/nonce-store.js +23 -0
- package/dist/auth/nonce-store.js.map +1 -0
- package/dist/bootstrap.d.ts +36 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +82 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/config.d.ts +103 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -0
- package/dist/core/context.d.ts +57 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.js +27 -0
- package/dist/core/context.js.map +1 -0
- package/dist/core/errors.d.ts +33 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +47 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +5 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/rpc.d.ts +83 -0
- package/dist/core/rpc.d.ts.map +1 -0
- package/dist/core/rpc.js +102 -0
- package/dist/core/rpc.js.map +1 -0
- package/dist/core/updates.d.ts +56 -0
- package/dist/core/updates.d.ts.map +1 -0
- package/dist/core/updates.js +34 -0
- package/dist/core/updates.js.map +1 -0
- package/dist/create-server.d.ts +89 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +109 -0
- package/dist/create-server.js.map +1 -0
- package/dist/crypto/aes-ige.d.ts +3 -0
- package/dist/crypto/aes-ige.d.ts.map +1 -0
- package/dist/crypto/aes-ige.js +55 -0
- package/dist/crypto/aes-ige.js.map +1 -0
- package/dist/crypto/dh.d.ts +21 -0
- package/dist/crypto/dh.d.ts.map +1 -0
- package/dist/crypto/dh.js +99 -0
- package/dist/crypto/dh.js.map +1 -0
- package/dist/crypto/hashes.d.ts +6 -0
- package/dist/crypto/hashes.d.ts.map +1 -0
- package/dist/crypto/hashes.js +14 -0
- package/dist/crypto/hashes.js.map +1 -0
- package/dist/crypto/msg-key.d.ts +15 -0
- package/dist/crypto/msg-key.d.ts.map +1 -0
- package/dist/crypto/msg-key.js +24 -0
- package/dist/crypto/msg-key.js.map +1 -0
- package/dist/crypto/rsa.d.ts +27 -0
- package/dist/crypto/rsa.d.ts.map +1 -0
- package/dist/crypto/rsa.js +50 -0
- package/dist/crypto/rsa.js.map +1 -0
- package/dist/dispatch/dispatcher.d.ts +72 -0
- package/dist/dispatch/dispatcher.d.ts.map +1 -0
- package/dist/dispatch/dispatcher.js +503 -0
- package/dist/dispatch/dispatcher.js.map +1 -0
- package/dist/dispatch/forwarders/in-process.d.ts +12 -0
- package/dist/dispatch/forwarders/in-process.d.ts.map +1 -0
- package/dist/dispatch/forwarders/in-process.js +15 -0
- package/dist/dispatch/forwarders/in-process.js.map +1 -0
- package/dist/dispatch/forwarders/print.d.ts +14 -0
- package/dist/dispatch/forwarders/print.d.ts.map +1 -0
- package/dist/dispatch/forwarders/print.js +23 -0
- package/dist/dispatch/forwarders/print.js.map +1 -0
- package/dist/dispatch/rpc-forwarder.d.ts +14 -0
- package/dist/dispatch/rpc-forwarder.d.ts.map +1 -0
- package/dist/dispatch/rpc-forwarder.js +2 -0
- package/dist/dispatch/rpc-forwarder.js.map +1 -0
- package/dist/dispatch/types.d.ts +32 -0
- package/dist/dispatch/types.d.ts.map +1 -0
- package/dist/dispatch/types.js +23 -0
- package/dist/dispatch/types.js.map +1 -0
- package/dist/gateway.d.ts +57 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +150 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +12 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +13 -0
- package/dist/lib.js.map +1 -0
- package/dist/server/message-pipeline.d.ts +57 -0
- package/dist/server/message-pipeline.d.ts.map +1 -0
- package/dist/server/message-pipeline.js +199 -0
- package/dist/server/message-pipeline.js.map +1 -0
- package/dist/session/inbound-tracker.d.ts +118 -0
- package/dist/session/inbound-tracker.d.ts.map +1 -0
- package/dist/session/inbound-tracker.js +170 -0
- package/dist/session/inbound-tracker.js.map +1 -0
- package/dist/session/message-id.d.ts +19 -0
- package/dist/session/message-id.d.ts.map +1 -0
- package/dist/session/message-id.js +30 -0
- package/dist/session/message-id.js.map +1 -0
- package/dist/session/salts.d.ts +51 -0
- package/dist/session/salts.d.ts.map +1 -0
- package/dist/session/salts.js +132 -0
- package/dist/session/salts.js.map +1 -0
- package/dist/session/session-manager.d.ts +18 -0
- package/dist/session/session-manager.d.ts.map +1 -0
- package/dist/session/session-manager.js +43 -0
- package/dist/session/session-manager.js.map +1 -0
- package/dist/storage/index.d.ts +14 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/memory.d.ts +3 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +91 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/mongo.d.ts +3 -0
- package/dist/storage/mongo.d.ts.map +1 -0
- package/dist/storage/mongo.js +175 -0
- package/dist/storage/mongo.js.map +1 -0
- package/dist/storage/types.d.ts +85 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +3 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/testkit.d.ts +11 -0
- package/dist/testkit.d.ts.map +1 -0
- package/dist/testkit.js +17 -0
- package/dist/testkit.js.map +1 -0
- package/dist/tl/codec.d.ts +37 -0
- package/dist/tl/codec.d.ts.map +1 -0
- package/dist/tl/codec.js +297 -0
- package/dist/tl/codec.js.map +1 -0
- package/dist/tl/layered-registry.d.ts +29 -0
- package/dist/tl/layered-registry.d.ts.map +1 -0
- package/dist/tl/layered-registry.js +118 -0
- package/dist/tl/layered-registry.js.map +1 -0
- package/dist/tl/protocol.d.ts +126 -0
- package/dist/tl/protocol.d.ts.map +1 -0
- package/dist/tl/protocol.js +22 -0
- package/dist/tl/protocol.js.map +1 -0
- package/dist/tl/reader.d.ts +30 -0
- package/dist/tl/reader.d.ts.map +1 -0
- package/dist/tl/reader.js +87 -0
- package/dist/tl/reader.js.map +1 -0
- package/dist/tl/registry.d.ts +39 -0
- package/dist/tl/registry.d.ts.map +1 -0
- package/dist/tl/registry.js +52 -0
- package/dist/tl/registry.js.map +1 -0
- package/dist/tl/writer.d.ts +24 -0
- package/dist/tl/writer.d.ts.map +1 -0
- package/dist/tl/writer.js +95 -0
- package/dist/tl/writer.js.map +1 -0
- package/dist/transport/connection-registry.d.ts +31 -0
- package/dist/transport/connection-registry.d.ts.map +1 -0
- package/dist/transport/connection-registry.js +84 -0
- package/dist/transport/connection-registry.js.map +1 -0
- package/dist/transport/connection.d.ts +62 -0
- package/dist/transport/connection.d.ts.map +1 -0
- package/dist/transport/connection.js +77 -0
- package/dist/transport/connection.js.map +1 -0
- package/dist/transport/framing.d.ts +39 -0
- package/dist/transport/framing.d.ts.map +1 -0
- package/dist/transport/framing.js +212 -0
- package/dist/transport/framing.js.map +1 -0
- package/dist/transport/proxy-protocol.d.ts +23 -0
- package/dist/transport/proxy-protocol.d.ts.map +1 -0
- package/dist/transport/proxy-protocol.js +108 -0
- package/dist/transport/proxy-protocol.js.map +1 -0
- package/dist/transport/server-common.d.ts +27 -0
- package/dist/transport/server-common.d.ts.map +1 -0
- package/dist/transport/server-common.js +27 -0
- package/dist/transport/server-common.js.map +1 -0
- package/dist/transport/tcp-server.d.ts +26 -0
- package/dist/transport/tcp-server.d.ts.map +1 -0
- package/dist/transport/tcp-server.js +91 -0
- package/dist/transport/tcp-server.js.map +1 -0
- package/dist/transport/ws-server.d.ts +19 -0
- package/dist/transport/ws-server.d.ts.map +1 -0
- package/dist/transport/ws-server.js +78 -0
- package/dist/transport/ws-server.js.map +1 -0
- package/dist/update-publisher.d.ts +34 -0
- package/dist/update-publisher.d.ts.map +1 -0
- package/dist/update-publisher.js +29 -0
- package/dist/update-publisher.js.map +1 -0
- package/dist/updates/mongo-update-log.d.ts +13 -0
- package/dist/updates/mongo-update-log.d.ts.map +1 -0
- package/dist/updates/mongo-update-log.js +39 -0
- package/dist/updates/mongo-update-log.js.map +1 -0
- package/dist/updates/presence-binder.d.ts +29 -0
- package/dist/updates/presence-binder.d.ts.map +1 -0
- package/dist/updates/presence-binder.js +36 -0
- package/dist/updates/presence-binder.js.map +1 -0
- package/dist/updates/presence.d.ts +31 -0
- package/dist/updates/presence.d.ts.map +1 -0
- package/dist/updates/presence.js +44 -0
- package/dist/updates/presence.js.map +1 -0
- package/dist/updates/push.d.ts +25 -0
- package/dist/updates/push.d.ts.map +1 -0
- package/dist/updates/push.js +72 -0
- package/dist/updates/push.js.map +1 -0
- package/dist/updates/redis-bus.d.ts +45 -0
- package/dist/updates/redis-bus.d.ts.map +1 -0
- package/dist/updates/redis-bus.js +59 -0
- package/dist/updates/redis-bus.js.map +1 -0
- package/dist/updates/redis-presence.d.ts +43 -0
- package/dist/updates/redis-presence.d.ts.map +1 -0
- package/dist/updates/redis-presence.js +65 -0
- package/dist/updates/redis-presence.js.map +1 -0
- package/dist/updates/render.d.ts +16 -0
- package/dist/updates/render.d.ts.map +1 -0
- package/dist/updates/render.js +46 -0
- package/dist/updates/render.js.map +1 -0
- package/dist/updates/router.d.ts +27 -0
- package/dist/updates/router.d.ts.map +1 -0
- package/dist/updates/router.js +36 -0
- package/dist/updates/router.js.map +1 -0
- package/dist/updates/types.d.ts +23 -0
- package/dist/updates/types.d.ts.map +1 -0
- package/dist/updates/types.js +2 -0
- package/dist/updates/types.js.map +1 -0
- package/dist/updates/update-bus.d.ts +29 -0
- package/dist/updates/update-bus.d.ts.map +1 -0
- package/dist/updates/update-bus.js +24 -0
- package/dist/updates/update-bus.js.map +1 -0
- package/dist/util/bytes.d.ts +12 -0
- package/dist/util/bytes.d.ts.map +1 -0
- package/dist/util/bytes.js +46 -0
- package/dist/util/bytes.js.map +1 -0
- package/package.json +84 -0
- package/src/auth/handshake.ts +262 -0
- package/src/auth/nonce-store.ts +39 -0
- package/src/bootstrap.ts +114 -0
- package/src/config.ts +103 -0
- package/src/core/context.ts +94 -0
- package/src/core/errors.ts +52 -0
- package/src/core/index.ts +4 -0
- package/src/core/rpc.ts +165 -0
- package/src/core/updates.ts +69 -0
- package/src/create-server.ts +181 -0
- package/src/crypto/aes-ige.ts +57 -0
- package/src/crypto/dh.ts +101 -0
- package/src/crypto/hashes.ts +17 -0
- package/src/crypto/msg-key.ts +29 -0
- package/src/crypto/rsa.ts +70 -0
- package/src/dispatch/dispatcher.ts +586 -0
- package/src/dispatch/forwarders/in-process.ts +14 -0
- package/src/dispatch/forwarders/print.ts +22 -0
- package/src/dispatch/rpc-forwarder.ts +15 -0
- package/src/dispatch/types.ts +60 -0
- package/src/gateway.ts +214 -0
- package/src/index.ts +53 -0
- package/src/lib.ts +24 -0
- package/src/server/message-pipeline.ts +256 -0
- package/src/session/inbound-tracker.ts +221 -0
- package/src/session/message-id.ts +43 -0
- package/src/session/salts.ts +162 -0
- package/src/session/session-manager.ts +66 -0
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +101 -0
- package/src/storage/mongo.ts +215 -0
- package/src/storage/types.ts +92 -0
- package/src/testkit.ts +19 -0
- package/src/tl/codec.ts +292 -0
- package/src/tl/layered-registry.ts +132 -0
- package/src/tl/protocol.ts +146 -0
- package/src/tl/reader.ts +99 -0
- package/src/tl/registry.ts +78 -0
- package/src/tl/writer.ts +104 -0
- package/src/transport/connection-registry.ts +91 -0
- package/src/transport/connection.ts +113 -0
- package/src/transport/framing.ts +223 -0
- package/src/transport/proxy-protocol.ts +109 -0
- package/src/transport/server-common.ts +49 -0
- package/src/transport/tcp-server.ts +102 -0
- package/src/transport/ws-server.ts +94 -0
- package/src/update-publisher.ts +47 -0
- package/src/updates/mongo-update-log.ts +49 -0
- package/src/updates/presence-binder.ts +51 -0
- package/src/updates/presence.ts +61 -0
- package/src/updates/push.ts +90 -0
- package/src/updates/redis-bus.ts +86 -0
- package/src/updates/redis-presence.ts +87 -0
- package/src/updates/render.ts +53 -0
- package/src/updates/router.ts +52 -0
- package/src/updates/types.ts +24 -0
- package/src/updates/update-bus.ts +49 -0
- package/src/util/bytes.ts +49 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createRedisUpdateBus } from './lib.js'
|
|
2
|
+
import type { JsonValue } from '@mt-tl/tl'
|
|
3
|
+
|
|
4
|
+
/** A standalone publisher returned by {@link createUpdatePublisher}. */
|
|
5
|
+
export interface UpdatePublisher {
|
|
6
|
+
/** Push a TL update (`{ _: name, ... }`) to a `subject` (internal user id) —
|
|
7
|
+
* delivered to whatever node holds them. */
|
|
8
|
+
push(subject: string, update: unknown): Promise<void>
|
|
9
|
+
/** Push to a specific auth key — including an anonymous connection (no pts). */
|
|
10
|
+
pushToAuthKey(authKeyId: string, update: unknown): Promise<void>
|
|
11
|
+
/** Disconnect from the shared bus. */
|
|
12
|
+
close(): Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Options for {@link createUpdatePublisher}. */
|
|
16
|
+
export interface UpdatePublisherConfig {
|
|
17
|
+
/** Redis URL of the shared pub/sub update bus (the same `REDIS_URL` your servers use). */
|
|
18
|
+
redisUrl: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a server-push publisher for code running **outside** the server — a
|
|
23
|
+
* webhook receiver, a cron worker, another microservice. It drops the update on
|
|
24
|
+
* the shared Redis bus; the server fleet's router looks up presence and delivers
|
|
25
|
+
* it to whichever node holds the user (rendered for that client's layer). No
|
|
26
|
+
* client connection and no running server are needed — only the shared bus.
|
|
27
|
+
*
|
|
28
|
+
* Inside a handler, push with `ctx.push(subject, update)` instead — this is for
|
|
29
|
+
* cross-process pushes, which is why it needs the shared `redisUrl`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const updates = await createUpdatePublisher({ redisUrl: process.env.REDIS_URL! })
|
|
34
|
+
* await updates.push(subject, { _: 'updateNewMessage', message })
|
|
35
|
+
* await updates.close()
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export async function createUpdatePublisher(config: UpdatePublisherConfig): Promise<UpdatePublisher> {
|
|
39
|
+
if (!config.redisUrl) throw new Error('createUpdatePublisher requires redisUrl')
|
|
40
|
+
const handle = await createRedisUpdateBus(config.redisUrl)
|
|
41
|
+
return {
|
|
42
|
+
push: (subject, update) => handle.bus.publishUpdate({ subject, update: update as JsonValue }),
|
|
43
|
+
pushToAuthKey: (authKeyId, update) =>
|
|
44
|
+
handle.bus.publishUpdate({ authKeyId, update: update as JsonValue }),
|
|
45
|
+
close: () => handle.close(),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { JsonValue } from '@mt-tl/tl'
|
|
2
|
+
import type { UpdateLog } from '../core/updates.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Durable {@link UpdateLog} on MongoDB, for protocol-managed updates
|
|
6
|
+
* (`config.updates.managed`). `pts` is assigned atomically per subject via a
|
|
7
|
+
* counters document (`$inc`), so concurrent replicas never collide; each update
|
|
8
|
+
* is stored with its pts for `updates.getDifference`. Opens its own connection
|
|
9
|
+
* (the updates box is logically separate from auth/salt/session storage).
|
|
10
|
+
*/
|
|
11
|
+
export async function createMongoUpdateLog(
|
|
12
|
+
mongoUrl: string,
|
|
13
|
+
dbName: string,
|
|
14
|
+
): Promise<{ log: UpdateLog; close: () => Promise<void> }> {
|
|
15
|
+
const { MongoClient } = await import('mongodb')
|
|
16
|
+
const client = new MongoClient(mongoUrl)
|
|
17
|
+
await client.connect()
|
|
18
|
+
const db = client.db(dbName)
|
|
19
|
+
const counters = db.collection<{ _id: string; pts: number }>('update_counters')
|
|
20
|
+
const log = db.collection<{ subject: string; pts: number; update: JsonValue }>('updates')
|
|
21
|
+
await log.createIndex({ subject: 1, pts: 1 })
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
log: {
|
|
25
|
+
async append(subject, update) {
|
|
26
|
+
const doc = await counters.findOneAndUpdate(
|
|
27
|
+
{ _id: subject },
|
|
28
|
+
{ $inc: { pts: 1 } },
|
|
29
|
+
{ upsert: true, returnDocument: 'after' },
|
|
30
|
+
)
|
|
31
|
+
const pts = doc?.pts ?? 1
|
|
32
|
+
await log.insertOne({ subject, pts, update })
|
|
33
|
+
return { pts }
|
|
34
|
+
},
|
|
35
|
+
async since(subject, sincePts) {
|
|
36
|
+
const docs = await log
|
|
37
|
+
.find({ subject, pts: { $gt: sincePts } })
|
|
38
|
+
.sort({ pts: 1 })
|
|
39
|
+
.toArray()
|
|
40
|
+
return docs.map(d => ({ pts: d.pts, update: d.update }))
|
|
41
|
+
},
|
|
42
|
+
async currentPts(subject) {
|
|
43
|
+
const doc = await counters.findOne({ _id: subject })
|
|
44
|
+
return doc?.pts ?? 0
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
close: () => client.close(),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Connection } from '../transport/connection.js'
|
|
2
|
+
import type { ConnectionRegistry } from '../transport/connection-registry.js'
|
|
3
|
+
import type { Presence } from './presence.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Couples a connection's authorized subject to the local registry and the global
|
|
7
|
+
* presence map. The pipeline binds on authenticated messages; the dispatcher
|
|
8
|
+
* binds/unbinds on bindUser/unbindUser effects; the carriers unbind on close.
|
|
9
|
+
*/
|
|
10
|
+
export interface PresenceBinder {
|
|
11
|
+
bind(conn: Connection, subject: string): void
|
|
12
|
+
/** Register a connection by its auth key (enables push to anonymous connections). */
|
|
13
|
+
bindAuthKey(conn: Connection, authKeyId: string): void
|
|
14
|
+
unbind(conn: Connection): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class NoopPresenceBinder implements PresenceBinder {
|
|
18
|
+
bind(): void {}
|
|
19
|
+
bindAuthKey(): void {}
|
|
20
|
+
unbind(): void {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class NodePresenceBinder implements PresenceBinder {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly nodeId: string,
|
|
26
|
+
private readonly registry: ConnectionRegistry,
|
|
27
|
+
private readonly presence: Presence,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
bind(conn: Connection, subject: string): void {
|
|
31
|
+
if (this.registry.subjectOf(conn) === subject) return // already bound — cheap no-op
|
|
32
|
+
this.registry.register(subject, conn)
|
|
33
|
+
void this.presence.add(subject, this.nodeId).catch(() => {})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
bindAuthKey(conn: Connection, authKeyId: string): void {
|
|
37
|
+
this.registry.registerAuthKey(authKeyId, conn) // no-op if already registered
|
|
38
|
+
void this.presence.addAuthKey(authKeyId, this.nodeId).catch(() => {})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
unbind(conn: Connection): void {
|
|
42
|
+
const { subject, authKeyId } = this.registry.unregister(conn)
|
|
43
|
+
// Only drop this node's presence once no local connection remains for the key.
|
|
44
|
+
if (subject !== undefined && !this.registry.hasSubject(subject)) {
|
|
45
|
+
void this.presence.remove(subject, this.nodeId).catch(() => {})
|
|
46
|
+
}
|
|
47
|
+
if (authKeyId !== undefined && !this.registry.hasAuthKey(authKeyId)) {
|
|
48
|
+
void this.presence.removeAuthKey(authKeyId, this.nodeId).catch(() => {})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global online-presence: which gateway node(s) currently hold a connection for
|
|
3
|
+
* a subject. Written by gateways, read by the Update Router to route updates only
|
|
4
|
+
* to nodes that actually hold the subject (no broadcast fan-out).
|
|
5
|
+
*
|
|
6
|
+
* Eventually-consistent is fine — a stale entry just routes to a node that drops
|
|
7
|
+
* the update, and the client recovers via pts/getDifference.
|
|
8
|
+
*/
|
|
9
|
+
export interface Presence {
|
|
10
|
+
add(subject: string, nodeId: string): Promise<void>
|
|
11
|
+
remove(subject: string, nodeId: string): Promise<void>
|
|
12
|
+
lookup(subject: string): Promise<string[]>
|
|
13
|
+
/** Presence for a specific auth key (anonymous-capable delivery target). */
|
|
14
|
+
addAuthKey(authKeyId: string, nodeId: string): Promise<void>
|
|
15
|
+
removeAuthKey(authKeyId: string, nodeId: string): Promise<void>
|
|
16
|
+
lookupAuthKey(authKeyId: string): Promise<string[]>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** In-memory presence (single-process / tests). Use a Redis impl across nodes. */
|
|
20
|
+
export class InMemoryPresence implements Presence {
|
|
21
|
+
// Keyed by a prefixed target string: `u:<subject>` or `a:<authKeyId>`.
|
|
22
|
+
private map = new Map<string, Set<string>>()
|
|
23
|
+
|
|
24
|
+
private addKey(key: string, nodeId: string): void {
|
|
25
|
+
let set = this.map.get(key)
|
|
26
|
+
if (!set) {
|
|
27
|
+
set = new Set()
|
|
28
|
+
this.map.set(key, set)
|
|
29
|
+
}
|
|
30
|
+
set.add(nodeId)
|
|
31
|
+
}
|
|
32
|
+
private removeKey(key: string, nodeId: string): void {
|
|
33
|
+
const set = this.map.get(key)
|
|
34
|
+
if (!set) return
|
|
35
|
+
set.delete(nodeId)
|
|
36
|
+
if (set.size === 0) this.map.delete(key)
|
|
37
|
+
}
|
|
38
|
+
private lookupKey(key: string): string[] {
|
|
39
|
+
const set = this.map.get(key)
|
|
40
|
+
return set ? [...set] : []
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async add(subject: string, nodeId: string): Promise<void> {
|
|
44
|
+
this.addKey(`u:${subject}`, nodeId)
|
|
45
|
+
}
|
|
46
|
+
async remove(subject: string, nodeId: string): Promise<void> {
|
|
47
|
+
this.removeKey(`u:${subject}`, nodeId)
|
|
48
|
+
}
|
|
49
|
+
async lookup(subject: string): Promise<string[]> {
|
|
50
|
+
return this.lookupKey(`u:${subject}`)
|
|
51
|
+
}
|
|
52
|
+
async addAuthKey(authKeyId: string, nodeId: string): Promise<void> {
|
|
53
|
+
this.addKey(`a:${authKeyId}`, nodeId)
|
|
54
|
+
}
|
|
55
|
+
async removeAuthKey(authKeyId: string, nodeId: string): Promise<void> {
|
|
56
|
+
this.removeKey(`a:${authKeyId}`, nodeId)
|
|
57
|
+
}
|
|
58
|
+
async lookupAuthKey(authKeyId: string): Promise<string[]> {
|
|
59
|
+
return this.lookupKey(`a:${authKeyId}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fromJson,
|
|
3
|
+
MigrationRegistry,
|
|
4
|
+
noopLogger,
|
|
5
|
+
type JsonValue,
|
|
6
|
+
type Logger,
|
|
7
|
+
type TlObject,
|
|
8
|
+
} from '@mt-tl/tl'
|
|
9
|
+
import type { Connection } from '../transport/connection.js'
|
|
10
|
+
import type { ConnectionRegistry } from '../transport/connection-registry.js'
|
|
11
|
+
import type { Responder } from '../dispatch/types.js'
|
|
12
|
+
import type { LayeredRegistry } from '../tl/layered-registry.js'
|
|
13
|
+
import { renderUpdateForLayer } from './render.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Node-side delivery of a routed update to the user's local connections, as an
|
|
17
|
+
* encrypted server notification (msg_id % 4 == 3). Best-effort: if the user has
|
|
18
|
+
* no local connection, the update is dropped (the client recovers via pts).
|
|
19
|
+
*
|
|
20
|
+
* Each connection is rendered for its own negotiated layer: types not
|
|
21
|
+
* representable there become `updateUnsupported` (pts-bearing) or are dropped.
|
|
22
|
+
*/
|
|
23
|
+
export class PushService {
|
|
24
|
+
private readonly migrations: MigrationRegistry
|
|
25
|
+
private readonly log: Logger
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly registry: ConnectionRegistry,
|
|
29
|
+
private readonly responder: Responder,
|
|
30
|
+
private readonly layered?: LayeredRegistry,
|
|
31
|
+
migrations?: MigrationRegistry,
|
|
32
|
+
logger?: Logger,
|
|
33
|
+
) {
|
|
34
|
+
this.migrations = migrations ?? new MigrationRegistry()
|
|
35
|
+
this.log = logger ?? noopLogger
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
deliver(subject: string, update: JsonValue): void {
|
|
39
|
+
this.deliverTo(this.registry.getBySubject(subject), update, { subject })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Deliver to the connections of a specific auth key (anonymous-capable target). */
|
|
43
|
+
deliverToAuthKey(authKeyId: string, update: JsonValue): void {
|
|
44
|
+
this.deliverTo(this.registry.getByAuthKey(authKeyId), update, { authKeyId })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private deliverTo(
|
|
48
|
+
conns: Connection[],
|
|
49
|
+
update: JsonValue,
|
|
50
|
+
target: { subject?: string; authKeyId?: string },
|
|
51
|
+
): void {
|
|
52
|
+
const tl = fromJson(update)
|
|
53
|
+
if (!tl || typeof tl !== 'object' || Array.isArray(tl) || !('_' in tl)) return
|
|
54
|
+
const base = tl as TlObject
|
|
55
|
+
const type = base._
|
|
56
|
+
|
|
57
|
+
// Full update payload at debug — "what we're pushing out" (the canonical
|
|
58
|
+
// form, before per-connection layer rendering).
|
|
59
|
+
this.log.debug('update.data', { ...target, type, update })
|
|
60
|
+
|
|
61
|
+
// No local connection for the target — best-effort drop (the client recovers
|
|
62
|
+
// via pts on its next getDifference). Expected, so debug not error.
|
|
63
|
+
if (conns.length === 0) {
|
|
64
|
+
this.log.debug('update.nodest', { ...target, type })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let delivered = 0
|
|
69
|
+
for (const conn of conns) {
|
|
70
|
+
// The client opted out of updates on this connection (invokeWithoutUpdates).
|
|
71
|
+
if (conn.ctx.noUpdates) continue
|
|
72
|
+
// Render canonical → client layer (non-additive), then per-layer representability.
|
|
73
|
+
let body: TlObject | null = this.migrations.down(base, conn.ctx.apiLayer) as TlObject
|
|
74
|
+
if (this.layered?.hasLayers()) {
|
|
75
|
+
body = renderUpdateForLayer(body, conn.ctx.apiLayer, this.layered)
|
|
76
|
+
}
|
|
77
|
+
if (!body) {
|
|
78
|
+
this.log.debug('update.skip', { ...target, type, conn: conn.id, layer: conn.ctx.apiLayer })
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
this.responder.sendEncrypted(conn, body, { isNotification: true, contentRelated: false })
|
|
83
|
+
delivered++
|
|
84
|
+
} catch (err) {
|
|
85
|
+
this.log.error('update.fail', { ...target, type, conn: conn.id, err })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (delivered) this.log.info('update.push', { ...target, type, conns: delivered })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { NodeDelivery, UpdateMessage } from './types.js'
|
|
2
|
+
import type { UpdateBus } from './update-bus.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal Redis surfaces (for testability). Pub/sub needs TWO connections: a
|
|
6
|
+
* connection in subscriber mode cannot issue regular commands, so publishing and
|
|
7
|
+
* subscribing use separate clients.
|
|
8
|
+
*/
|
|
9
|
+
export interface RedisPubLike {
|
|
10
|
+
publish(channel: string, message: string): Promise<unknown> | unknown
|
|
11
|
+
quit(): Promise<unknown>
|
|
12
|
+
}
|
|
13
|
+
export interface RedisSubLike {
|
|
14
|
+
subscribe(channel: string): Promise<unknown> | unknown
|
|
15
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown
|
|
16
|
+
quit(): Promise<unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CH_IN = 'updates.in'
|
|
20
|
+
const nodeChannel = (nodeId: string) => `updates.node.${nodeId}`
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Redis pub/sub {@link UpdateBus} for multi-instance server-push (infra stays
|
|
24
|
+
* Mongo + Redis). `updates.in` carries emitted updates to the router;
|
|
25
|
+
* `updates.node.{id}` carries routed deliveries to a node.
|
|
26
|
+
*
|
|
27
|
+
* Caveat: Redis pub/sub is **fan-out**, not a work queue. Per-node channels are
|
|
28
|
+
* fine (one subscriber per nodeId). But `updates.in` is delivered to *every*
|
|
29
|
+
* subscriber — run a SINGLE router with this bus (enough for the in-process-first
|
|
30
|
+
* model), or shard `updates.in` by subject across routers. For competing-consumer
|
|
31
|
+
* semantics at very large scale, switch the bus to Redis Streams.
|
|
32
|
+
*/
|
|
33
|
+
export class RedisUpdateBus implements UpdateBus {
|
|
34
|
+
private readonly handlers = new Map<string, (msg: unknown) => void>()
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly pub: RedisPubLike,
|
|
38
|
+
private readonly sub: RedisSubLike,
|
|
39
|
+
) {
|
|
40
|
+
this.sub.on('message', (channel, message) => {
|
|
41
|
+
const handler = this.handlers.get(channel)
|
|
42
|
+
if (!handler) return
|
|
43
|
+
try {
|
|
44
|
+
handler(JSON.parse(message))
|
|
45
|
+
} catch {
|
|
46
|
+
/* drop malformed */
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async publishUpdate(msg: UpdateMessage): Promise<void> {
|
|
52
|
+
await this.pub.publish(CH_IN, JSON.stringify(msg))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
subscribeUpdates(handler: (msg: UpdateMessage) => void): void {
|
|
56
|
+
this.handlers.set(CH_IN, handler as (msg: unknown) => void)
|
|
57
|
+
void this.sub.subscribe(CH_IN)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async publishToNode(nodeId: string, msg: NodeDelivery): Promise<void> {
|
|
61
|
+
await this.pub.publish(nodeChannel(nodeId), JSON.stringify(msg))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
subscribeNode(nodeId: string, handler: (msg: NodeDelivery) => void): void {
|
|
65
|
+
this.handlers.set(nodeChannel(nodeId), handler as (msg: unknown) => void)
|
|
66
|
+
void this.sub.subscribe(nodeChannel(nodeId))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async close(): Promise<void> {
|
|
70
|
+
await Promise.all([this.pub.quit().catch(() => {}), this.sub.quit().catch(() => {})])
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RedisBusHandle {
|
|
75
|
+
bus: RedisUpdateBus
|
|
76
|
+
close: () => Promise<void>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Connects two Redis clients (publish + subscribe) and builds a {@link RedisUpdateBus}. */
|
|
80
|
+
export async function createRedisUpdateBus(url: string): Promise<RedisBusHandle> {
|
|
81
|
+
const IoRedis = (await import('ioredis')).default
|
|
82
|
+
const pub = new IoRedis(url, { lazyConnect: false })
|
|
83
|
+
const sub = new IoRedis(url, { lazyConnect: false })
|
|
84
|
+
const bus = new RedisUpdateBus(pub, sub)
|
|
85
|
+
return { bus, close: () => bus.close() }
|
|
86
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import Redis from 'ioredis'
|
|
2
|
+
import type { Presence } from './presence.js'
|
|
3
|
+
|
|
4
|
+
/** Minimal subset of ioredis used by {@link RedisPresence} (for testability). */
|
|
5
|
+
export interface RedisLike {
|
|
6
|
+
zadd(key: string, score: number, member: string): Promise<unknown>
|
|
7
|
+
zrem(key: string, member: string): Promise<unknown>
|
|
8
|
+
zrangebyscore(key: string, min: number | string, max: number | string): Promise<string[]>
|
|
9
|
+
zremrangebyscore(key: string, min: number | string, max: number | string): Promise<unknown>
|
|
10
|
+
pexpire(key: string, ms: number): Promise<unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RedisPresenceOptions {
|
|
14
|
+
ttlMs?: number
|
|
15
|
+
now?: () => number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Redis-backed presence using a per-subject sorted set `presence:{subject}` with
|
|
20
|
+
* member = nodeId and score = expiry epoch (now + ttl). This gives per-member
|
|
21
|
+
* TTL: a node's entry is considered live while its score is in the future and
|
|
22
|
+
* is refreshed by a heartbeat; stale entries are pruned on lookup. Eventually
|
|
23
|
+
* consistent by design (a crashed node's entry just expires).
|
|
24
|
+
*/
|
|
25
|
+
export class RedisPresence implements Presence {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly redis: RedisLike,
|
|
28
|
+
private readonly opts: RedisPresenceOptions = {},
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
private ttl(): number {
|
|
32
|
+
return this.opts.ttlMs ?? 60_000
|
|
33
|
+
}
|
|
34
|
+
private now(): number {
|
|
35
|
+
return (this.opts.now ?? Date.now)()
|
|
36
|
+
}
|
|
37
|
+
private key(subject: string): string {
|
|
38
|
+
return `presence:${subject}`
|
|
39
|
+
}
|
|
40
|
+
private authKey(authKeyId: string): string {
|
|
41
|
+
return `presence:a:${authKeyId}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async addKey(key: string, nodeId: string): Promise<void> {
|
|
45
|
+
await this.redis.zadd(key, this.now() + this.ttl(), nodeId)
|
|
46
|
+
await this.redis.pexpire(key, this.ttl() * 2)
|
|
47
|
+
}
|
|
48
|
+
private async lookupKey(key: string): Promise<string[]> {
|
|
49
|
+
const now = this.now()
|
|
50
|
+
await this.redis.zremrangebyscore(key, 0, now) // drop expired members
|
|
51
|
+
return this.redis.zrangebyscore(key, now, '+inf') // live members
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async add(subject: string, nodeId: string): Promise<void> {
|
|
55
|
+
await this.addKey(this.key(subject), nodeId)
|
|
56
|
+
}
|
|
57
|
+
async remove(subject: string, nodeId: string): Promise<void> {
|
|
58
|
+
await this.redis.zrem(this.key(subject), nodeId)
|
|
59
|
+
}
|
|
60
|
+
async lookup(subject: string): Promise<string[]> {
|
|
61
|
+
return this.lookupKey(this.key(subject))
|
|
62
|
+
}
|
|
63
|
+
async addAuthKey(authKeyId: string, nodeId: string): Promise<void> {
|
|
64
|
+
await this.addKey(this.authKey(authKeyId), nodeId)
|
|
65
|
+
}
|
|
66
|
+
async removeAuthKey(authKeyId: string, nodeId: string): Promise<void> {
|
|
67
|
+
await this.redis.zrem(this.authKey(authKeyId), nodeId)
|
|
68
|
+
}
|
|
69
|
+
async lookupAuthKey(authKeyId: string): Promise<string[]> {
|
|
70
|
+
return this.lookupKey(this.authKey(authKeyId))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RedisPresenceHandle {
|
|
75
|
+
presence: RedisPresence
|
|
76
|
+
close: () => Promise<void>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createRedisPresence(url: string, ttlMs: number): RedisPresenceHandle {
|
|
80
|
+
const client = new Redis(url, { lazyConnect: false })
|
|
81
|
+
return {
|
|
82
|
+
presence: new RedisPresence(client, { ttlMs }),
|
|
83
|
+
close: async () => {
|
|
84
|
+
await client.quit()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { LayeredRegistry } from '../tl/layered-registry.js'
|
|
2
|
+
import type { TlObject } from '@mt-tl/tl'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a server update for a specific client layer.
|
|
6
|
+
*
|
|
7
|
+
* - Representable at the layer → unchanged.
|
|
8
|
+
* - Not representable but pts-bearing → `updateUnsupported{pts, pts_count}`
|
|
9
|
+
* (preserves the pts accounting so the client resyncs via getDifference).
|
|
10
|
+
* - Not representable and ephemeral (no pts) → dropped (returns null).
|
|
11
|
+
*
|
|
12
|
+
* Recurses into the common containers (`updateShort`, `updates`,
|
|
13
|
+
* `updatesCombined`) so a single unrepresentable inner update doesn't sink the
|
|
14
|
+
* whole batch.
|
|
15
|
+
*/
|
|
16
|
+
export function renderUpdateForLayer(
|
|
17
|
+
update: TlObject,
|
|
18
|
+
layer: number,
|
|
19
|
+
layered: LayeredRegistry,
|
|
20
|
+
): TlObject | null {
|
|
21
|
+
if (layered.representable(update, layer)) return update
|
|
22
|
+
|
|
23
|
+
switch (update._) {
|
|
24
|
+
case 'updateShort': {
|
|
25
|
+
const inner = renderLeaf(update.update as TlObject, layer, layered)
|
|
26
|
+
return inner ? { ...update, update: inner } : null
|
|
27
|
+
}
|
|
28
|
+
case 'updates':
|
|
29
|
+
case 'updatesCombined': {
|
|
30
|
+
const list = Array.isArray(update.updates) ? (update.updates as TlObject[]) : []
|
|
31
|
+
const rendered = list
|
|
32
|
+
.map(u => renderLeaf(u, layer, layered))
|
|
33
|
+
.filter((u): u is TlObject => u !== null)
|
|
34
|
+
return { ...update, updates: rendered }
|
|
35
|
+
}
|
|
36
|
+
default:
|
|
37
|
+
return renderLeaf(update, layer, layered)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderLeaf(update: TlObject, layer: number, layered: LayeredRegistry): TlObject | null {
|
|
42
|
+
if (layered.representable(update, layer)) return update
|
|
43
|
+
const pts = numberField(update, 'pts')
|
|
44
|
+
if (pts !== undefined) {
|
|
45
|
+
return { _: 'updateUnsupported', pts, pts_count: numberField(update, 'pts_count') ?? 0 }
|
|
46
|
+
}
|
|
47
|
+
return null // ephemeral, no pts — safe to drop
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function numberField(obj: TlObject, key: string): number | undefined {
|
|
51
|
+
const v = obj[key]
|
|
52
|
+
return typeof v === 'number' ? v : undefined
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { JsonValue } from '@mt-tl/tl'
|
|
2
|
+
import type { Presence } from './presence.js'
|
|
3
|
+
import type { UpdateBus } from './update-bus.js'
|
|
4
|
+
import type { UpdateMessage } from './types.js'
|
|
5
|
+
|
|
6
|
+
export interface RouterOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Anti-DDoS valve: return false to drop/coalesce an update under load.
|
|
9
|
+
* Safe to drop — clients recover via pts/getDifference. Default: deliver all.
|
|
10
|
+
*/
|
|
11
|
+
shouldDeliver?: (subject: string, update: JsonValue) => boolean
|
|
12
|
+
onError?: (err: unknown, msg: UpdateMessage) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Presence-aware Update Router (standalone service, shard by subject in prod).
|
|
17
|
+
* Consumes worker updates, looks up which nodes hold the subject, and delivers
|
|
18
|
+
* only to those nodes — so 8 idle nodes never receive an update for a subject on
|
|
19
|
+
* node 1. This is the single place to throttle/coalesce per subject.
|
|
20
|
+
*/
|
|
21
|
+
export class UpdateRouter {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly bus: UpdateBus,
|
|
24
|
+
private readonly presence: Presence,
|
|
25
|
+
private readonly opts: RouterOptions = {},
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
start(): void {
|
|
29
|
+
this.bus.subscribeUpdates(msg => {
|
|
30
|
+
void this.route(msg).catch(err => this.opts.onError?.(err, msg))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async route(msg: UpdateMessage): Promise<void> {
|
|
35
|
+
// Auth-key-addressed delivery (anonymous connection); skips the per-user valve.
|
|
36
|
+
if (msg.authKeyId !== undefined) {
|
|
37
|
+
const nodes = await this.presence.lookupAuthKey(msg.authKeyId)
|
|
38
|
+
await Promise.all(
|
|
39
|
+
nodes.map(nodeId =>
|
|
40
|
+
this.bus.publishToNode(nodeId, { authKeyId: msg.authKeyId, update: msg.update }),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (msg.subject === undefined) return
|
|
46
|
+
if (this.opts.shouldDeliver && !this.opts.shouldDeliver(msg.subject, msg.update)) return
|
|
47
|
+
const nodes = await this.presence.lookup(msg.subject)
|
|
48
|
+
await Promise.all(
|
|
49
|
+
nodes.map(nodeId => this.bus.publishToNode(nodeId, { subject: msg.subject, update: msg.update })),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JsonValue } from '@mt-tl/tl'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An update to deliver, addressed to exactly one target: a bound `subject` (the
|
|
5
|
+
* common case, pts-logged) OR an `authKeyId` (a specific, possibly anonymous,
|
|
6
|
+
* connection — e.g. pushing API to a not-yet-registered client; no pts). Set one.
|
|
7
|
+
*/
|
|
8
|
+
export interface UpdateMessage {
|
|
9
|
+
/** The subject (internal user id) to deliver to. */
|
|
10
|
+
subject?: string
|
|
11
|
+
/** Decimal auth-key id, to address a specific (possibly anonymous) connection. */
|
|
12
|
+
authKeyId?: string
|
|
13
|
+
/** A TL update object as tagged JSON ({ _: name, ... }). */
|
|
14
|
+
update: JsonValue
|
|
15
|
+
/** Permanent-update sequence number (for getDifference recovery); opaque here. */
|
|
16
|
+
pts?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A routed delivery from the Update Router to a specific gateway node. */
|
|
20
|
+
export interface NodeDelivery {
|
|
21
|
+
subject?: string
|
|
22
|
+
authKeyId?: string
|
|
23
|
+
update: JsonValue
|
|
24
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import type { NodeDelivery, UpdateMessage } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Message bus connecting publishers -> Update Router -> server nodes. Two impls:
|
|
6
|
+
* an in-memory one (single process) and a Redis pub/sub one (multi-instance:
|
|
7
|
+
* an `updates.in` channel for emissions and per-node `updates.node.{id}` channels
|
|
8
|
+
* for routed deliveries).
|
|
9
|
+
*/
|
|
10
|
+
export interface UpdateBus {
|
|
11
|
+
/** Publisher side: emit an update for a user. Fire-and-forget. */
|
|
12
|
+
publishUpdate(msg: UpdateMessage): Promise<void>
|
|
13
|
+
/** Router side: receive all emitted updates. */
|
|
14
|
+
subscribeUpdates(handler: (msg: UpdateMessage) => void): void
|
|
15
|
+
/** Router side: deliver a routed update to a specific node. */
|
|
16
|
+
publishToNode(nodeId: string, msg: NodeDelivery): Promise<void>
|
|
17
|
+
/** Node side: receive deliveries addressed to this node. */
|
|
18
|
+
subscribeNode(nodeId: string, handler: (msg: NodeDelivery) => void): void
|
|
19
|
+
close(): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** In-memory bus (single process / tests). */
|
|
23
|
+
export class InMemoryUpdateBus implements UpdateBus {
|
|
24
|
+
private emitter = new EventEmitter()
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
this.emitter.setMaxListeners(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async publishUpdate(msg: UpdateMessage): Promise<void> {
|
|
31
|
+
this.emitter.emit('update', msg)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
subscribeUpdates(handler: (msg: UpdateMessage) => void): void {
|
|
35
|
+
this.emitter.on('update', handler)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async publishToNode(nodeId: string, msg: NodeDelivery): Promise<void> {
|
|
39
|
+
this.emitter.emit(`node:${nodeId}`, msg)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
subscribeNode(nodeId: string, handler: (msg: NodeDelivery) => void): void {
|
|
43
|
+
this.emitter.on(`node:${nodeId}`, handler)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async close(): Promise<void> {
|
|
47
|
+
this.emitter.removeAllListeners()
|
|
48
|
+
}
|
|
49
|
+
}
|