@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.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/auth/handshake.d.ts +35 -0
  4. package/dist/auth/handshake.d.ts.map +1 -0
  5. package/dist/auth/handshake.js +208 -0
  6. package/dist/auth/handshake.js.map +1 -0
  7. package/dist/auth/nonce-store.d.ts +22 -0
  8. package/dist/auth/nonce-store.d.ts.map +1 -0
  9. package/dist/auth/nonce-store.js +23 -0
  10. package/dist/auth/nonce-store.js.map +1 -0
  11. package/dist/bootstrap.d.ts +36 -0
  12. package/dist/bootstrap.d.ts.map +1 -0
  13. package/dist/bootstrap.js +82 -0
  14. package/dist/bootstrap.js.map +1 -0
  15. package/dist/config.d.ts +103 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +2 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/core/context.d.ts +57 -0
  20. package/dist/core/context.d.ts.map +1 -0
  21. package/dist/core/context.js +27 -0
  22. package/dist/core/context.js.map +1 -0
  23. package/dist/core/errors.d.ts +33 -0
  24. package/dist/core/errors.d.ts.map +1 -0
  25. package/dist/core/errors.js +47 -0
  26. package/dist/core/errors.js.map +1 -0
  27. package/dist/core/index.d.ts +5 -0
  28. package/dist/core/index.d.ts.map +1 -0
  29. package/dist/core/index.js +5 -0
  30. package/dist/core/index.js.map +1 -0
  31. package/dist/core/rpc.d.ts +83 -0
  32. package/dist/core/rpc.d.ts.map +1 -0
  33. package/dist/core/rpc.js +102 -0
  34. package/dist/core/rpc.js.map +1 -0
  35. package/dist/core/updates.d.ts +56 -0
  36. package/dist/core/updates.d.ts.map +1 -0
  37. package/dist/core/updates.js +34 -0
  38. package/dist/core/updates.js.map +1 -0
  39. package/dist/create-server.d.ts +89 -0
  40. package/dist/create-server.d.ts.map +1 -0
  41. package/dist/create-server.js +109 -0
  42. package/dist/create-server.js.map +1 -0
  43. package/dist/crypto/aes-ige.d.ts +3 -0
  44. package/dist/crypto/aes-ige.d.ts.map +1 -0
  45. package/dist/crypto/aes-ige.js +55 -0
  46. package/dist/crypto/aes-ige.js.map +1 -0
  47. package/dist/crypto/dh.d.ts +21 -0
  48. package/dist/crypto/dh.d.ts.map +1 -0
  49. package/dist/crypto/dh.js +99 -0
  50. package/dist/crypto/dh.js.map +1 -0
  51. package/dist/crypto/hashes.d.ts +6 -0
  52. package/dist/crypto/hashes.d.ts.map +1 -0
  53. package/dist/crypto/hashes.js +14 -0
  54. package/dist/crypto/hashes.js.map +1 -0
  55. package/dist/crypto/msg-key.d.ts +15 -0
  56. package/dist/crypto/msg-key.d.ts.map +1 -0
  57. package/dist/crypto/msg-key.js +24 -0
  58. package/dist/crypto/msg-key.js.map +1 -0
  59. package/dist/crypto/rsa.d.ts +27 -0
  60. package/dist/crypto/rsa.d.ts.map +1 -0
  61. package/dist/crypto/rsa.js +50 -0
  62. package/dist/crypto/rsa.js.map +1 -0
  63. package/dist/dispatch/dispatcher.d.ts +72 -0
  64. package/dist/dispatch/dispatcher.d.ts.map +1 -0
  65. package/dist/dispatch/dispatcher.js +503 -0
  66. package/dist/dispatch/dispatcher.js.map +1 -0
  67. package/dist/dispatch/forwarders/in-process.d.ts +12 -0
  68. package/dist/dispatch/forwarders/in-process.d.ts.map +1 -0
  69. package/dist/dispatch/forwarders/in-process.js +15 -0
  70. package/dist/dispatch/forwarders/in-process.js.map +1 -0
  71. package/dist/dispatch/forwarders/print.d.ts +14 -0
  72. package/dist/dispatch/forwarders/print.d.ts.map +1 -0
  73. package/dist/dispatch/forwarders/print.js +23 -0
  74. package/dist/dispatch/forwarders/print.js.map +1 -0
  75. package/dist/dispatch/rpc-forwarder.d.ts +14 -0
  76. package/dist/dispatch/rpc-forwarder.d.ts.map +1 -0
  77. package/dist/dispatch/rpc-forwarder.js +2 -0
  78. package/dist/dispatch/rpc-forwarder.js.map +1 -0
  79. package/dist/dispatch/types.d.ts +32 -0
  80. package/dist/dispatch/types.d.ts.map +1 -0
  81. package/dist/dispatch/types.js +23 -0
  82. package/dist/dispatch/types.js.map +1 -0
  83. package/dist/gateway.d.ts +57 -0
  84. package/dist/gateway.d.ts.map +1 -0
  85. package/dist/gateway.js +150 -0
  86. package/dist/gateway.js.map +1 -0
  87. package/dist/index.d.ts +7 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +25 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/lib.d.ts +12 -0
  92. package/dist/lib.d.ts.map +1 -0
  93. package/dist/lib.js +13 -0
  94. package/dist/lib.js.map +1 -0
  95. package/dist/server/message-pipeline.d.ts +57 -0
  96. package/dist/server/message-pipeline.d.ts.map +1 -0
  97. package/dist/server/message-pipeline.js +199 -0
  98. package/dist/server/message-pipeline.js.map +1 -0
  99. package/dist/session/inbound-tracker.d.ts +118 -0
  100. package/dist/session/inbound-tracker.d.ts.map +1 -0
  101. package/dist/session/inbound-tracker.js +170 -0
  102. package/dist/session/inbound-tracker.js.map +1 -0
  103. package/dist/session/message-id.d.ts +19 -0
  104. package/dist/session/message-id.d.ts.map +1 -0
  105. package/dist/session/message-id.js +30 -0
  106. package/dist/session/message-id.js.map +1 -0
  107. package/dist/session/salts.d.ts +51 -0
  108. package/dist/session/salts.d.ts.map +1 -0
  109. package/dist/session/salts.js +132 -0
  110. package/dist/session/salts.js.map +1 -0
  111. package/dist/session/session-manager.d.ts +18 -0
  112. package/dist/session/session-manager.d.ts.map +1 -0
  113. package/dist/session/session-manager.js +43 -0
  114. package/dist/session/session-manager.js.map +1 -0
  115. package/dist/storage/index.d.ts +14 -0
  116. package/dist/storage/index.d.ts.map +1 -0
  117. package/dist/storage/index.js +16 -0
  118. package/dist/storage/index.js.map +1 -0
  119. package/dist/storage/memory.d.ts +3 -0
  120. package/dist/storage/memory.d.ts.map +1 -0
  121. package/dist/storage/memory.js +91 -0
  122. package/dist/storage/memory.js.map +1 -0
  123. package/dist/storage/mongo.d.ts +3 -0
  124. package/dist/storage/mongo.d.ts.map +1 -0
  125. package/dist/storage/mongo.js +175 -0
  126. package/dist/storage/mongo.js.map +1 -0
  127. package/dist/storage/types.d.ts +85 -0
  128. package/dist/storage/types.d.ts.map +1 -0
  129. package/dist/storage/types.js +3 -0
  130. package/dist/storage/types.js.map +1 -0
  131. package/dist/testkit.d.ts +11 -0
  132. package/dist/testkit.d.ts.map +1 -0
  133. package/dist/testkit.js +17 -0
  134. package/dist/testkit.js.map +1 -0
  135. package/dist/tl/codec.d.ts +37 -0
  136. package/dist/tl/codec.d.ts.map +1 -0
  137. package/dist/tl/codec.js +297 -0
  138. package/dist/tl/codec.js.map +1 -0
  139. package/dist/tl/layered-registry.d.ts +29 -0
  140. package/dist/tl/layered-registry.d.ts.map +1 -0
  141. package/dist/tl/layered-registry.js +118 -0
  142. package/dist/tl/layered-registry.js.map +1 -0
  143. package/dist/tl/protocol.d.ts +126 -0
  144. package/dist/tl/protocol.d.ts.map +1 -0
  145. package/dist/tl/protocol.js +22 -0
  146. package/dist/tl/protocol.js.map +1 -0
  147. package/dist/tl/reader.d.ts +30 -0
  148. package/dist/tl/reader.d.ts.map +1 -0
  149. package/dist/tl/reader.js +87 -0
  150. package/dist/tl/reader.js.map +1 -0
  151. package/dist/tl/registry.d.ts +39 -0
  152. package/dist/tl/registry.d.ts.map +1 -0
  153. package/dist/tl/registry.js +52 -0
  154. package/dist/tl/registry.js.map +1 -0
  155. package/dist/tl/writer.d.ts +24 -0
  156. package/dist/tl/writer.d.ts.map +1 -0
  157. package/dist/tl/writer.js +95 -0
  158. package/dist/tl/writer.js.map +1 -0
  159. package/dist/transport/connection-registry.d.ts +31 -0
  160. package/dist/transport/connection-registry.d.ts.map +1 -0
  161. package/dist/transport/connection-registry.js +84 -0
  162. package/dist/transport/connection-registry.js.map +1 -0
  163. package/dist/transport/connection.d.ts +62 -0
  164. package/dist/transport/connection.d.ts.map +1 -0
  165. package/dist/transport/connection.js +77 -0
  166. package/dist/transport/connection.js.map +1 -0
  167. package/dist/transport/framing.d.ts +39 -0
  168. package/dist/transport/framing.d.ts.map +1 -0
  169. package/dist/transport/framing.js +212 -0
  170. package/dist/transport/framing.js.map +1 -0
  171. package/dist/transport/proxy-protocol.d.ts +23 -0
  172. package/dist/transport/proxy-protocol.d.ts.map +1 -0
  173. package/dist/transport/proxy-protocol.js +108 -0
  174. package/dist/transport/proxy-protocol.js.map +1 -0
  175. package/dist/transport/server-common.d.ts +27 -0
  176. package/dist/transport/server-common.d.ts.map +1 -0
  177. package/dist/transport/server-common.js +27 -0
  178. package/dist/transport/server-common.js.map +1 -0
  179. package/dist/transport/tcp-server.d.ts +26 -0
  180. package/dist/transport/tcp-server.d.ts.map +1 -0
  181. package/dist/transport/tcp-server.js +91 -0
  182. package/dist/transport/tcp-server.js.map +1 -0
  183. package/dist/transport/ws-server.d.ts +19 -0
  184. package/dist/transport/ws-server.d.ts.map +1 -0
  185. package/dist/transport/ws-server.js +78 -0
  186. package/dist/transport/ws-server.js.map +1 -0
  187. package/dist/update-publisher.d.ts +34 -0
  188. package/dist/update-publisher.d.ts.map +1 -0
  189. package/dist/update-publisher.js +29 -0
  190. package/dist/update-publisher.js.map +1 -0
  191. package/dist/updates/mongo-update-log.d.ts +13 -0
  192. package/dist/updates/mongo-update-log.d.ts.map +1 -0
  193. package/dist/updates/mongo-update-log.js +39 -0
  194. package/dist/updates/mongo-update-log.js.map +1 -0
  195. package/dist/updates/presence-binder.d.ts +29 -0
  196. package/dist/updates/presence-binder.d.ts.map +1 -0
  197. package/dist/updates/presence-binder.js +36 -0
  198. package/dist/updates/presence-binder.js.map +1 -0
  199. package/dist/updates/presence.d.ts +31 -0
  200. package/dist/updates/presence.d.ts.map +1 -0
  201. package/dist/updates/presence.js +44 -0
  202. package/dist/updates/presence.js.map +1 -0
  203. package/dist/updates/push.d.ts +25 -0
  204. package/dist/updates/push.d.ts.map +1 -0
  205. package/dist/updates/push.js +72 -0
  206. package/dist/updates/push.js.map +1 -0
  207. package/dist/updates/redis-bus.d.ts +45 -0
  208. package/dist/updates/redis-bus.d.ts.map +1 -0
  209. package/dist/updates/redis-bus.js +59 -0
  210. package/dist/updates/redis-bus.js.map +1 -0
  211. package/dist/updates/redis-presence.d.ts +43 -0
  212. package/dist/updates/redis-presence.d.ts.map +1 -0
  213. package/dist/updates/redis-presence.js +65 -0
  214. package/dist/updates/redis-presence.js.map +1 -0
  215. package/dist/updates/render.d.ts +16 -0
  216. package/dist/updates/render.d.ts.map +1 -0
  217. package/dist/updates/render.js +46 -0
  218. package/dist/updates/render.js.map +1 -0
  219. package/dist/updates/router.d.ts +27 -0
  220. package/dist/updates/router.d.ts.map +1 -0
  221. package/dist/updates/router.js +36 -0
  222. package/dist/updates/router.js.map +1 -0
  223. package/dist/updates/types.d.ts +23 -0
  224. package/dist/updates/types.d.ts.map +1 -0
  225. package/dist/updates/types.js +2 -0
  226. package/dist/updates/types.js.map +1 -0
  227. package/dist/updates/update-bus.d.ts +29 -0
  228. package/dist/updates/update-bus.d.ts.map +1 -0
  229. package/dist/updates/update-bus.js +24 -0
  230. package/dist/updates/update-bus.js.map +1 -0
  231. package/dist/util/bytes.d.ts +12 -0
  232. package/dist/util/bytes.d.ts.map +1 -0
  233. package/dist/util/bytes.js +46 -0
  234. package/dist/util/bytes.js.map +1 -0
  235. package/package.json +84 -0
  236. package/src/auth/handshake.ts +262 -0
  237. package/src/auth/nonce-store.ts +39 -0
  238. package/src/bootstrap.ts +114 -0
  239. package/src/config.ts +103 -0
  240. package/src/core/context.ts +94 -0
  241. package/src/core/errors.ts +52 -0
  242. package/src/core/index.ts +4 -0
  243. package/src/core/rpc.ts +165 -0
  244. package/src/core/updates.ts +69 -0
  245. package/src/create-server.ts +181 -0
  246. package/src/crypto/aes-ige.ts +57 -0
  247. package/src/crypto/dh.ts +101 -0
  248. package/src/crypto/hashes.ts +17 -0
  249. package/src/crypto/msg-key.ts +29 -0
  250. package/src/crypto/rsa.ts +70 -0
  251. package/src/dispatch/dispatcher.ts +586 -0
  252. package/src/dispatch/forwarders/in-process.ts +14 -0
  253. package/src/dispatch/forwarders/print.ts +22 -0
  254. package/src/dispatch/rpc-forwarder.ts +15 -0
  255. package/src/dispatch/types.ts +60 -0
  256. package/src/gateway.ts +214 -0
  257. package/src/index.ts +53 -0
  258. package/src/lib.ts +24 -0
  259. package/src/server/message-pipeline.ts +256 -0
  260. package/src/session/inbound-tracker.ts +221 -0
  261. package/src/session/message-id.ts +43 -0
  262. package/src/session/salts.ts +162 -0
  263. package/src/session/session-manager.ts +66 -0
  264. package/src/storage/index.ts +26 -0
  265. package/src/storage/memory.ts +101 -0
  266. package/src/storage/mongo.ts +215 -0
  267. package/src/storage/types.ts +92 -0
  268. package/src/testkit.ts +19 -0
  269. package/src/tl/codec.ts +292 -0
  270. package/src/tl/layered-registry.ts +132 -0
  271. package/src/tl/protocol.ts +146 -0
  272. package/src/tl/reader.ts +99 -0
  273. package/src/tl/registry.ts +78 -0
  274. package/src/tl/writer.ts +104 -0
  275. package/src/transport/connection-registry.ts +91 -0
  276. package/src/transport/connection.ts +113 -0
  277. package/src/transport/framing.ts +223 -0
  278. package/src/transport/proxy-protocol.ts +109 -0
  279. package/src/transport/server-common.ts +49 -0
  280. package/src/transport/tcp-server.ts +102 -0
  281. package/src/transport/ws-server.ts +94 -0
  282. package/src/update-publisher.ts +47 -0
  283. package/src/updates/mongo-update-log.ts +49 -0
  284. package/src/updates/presence-binder.ts +51 -0
  285. package/src/updates/presence.ts +61 -0
  286. package/src/updates/push.ts +90 -0
  287. package/src/updates/redis-bus.ts +86 -0
  288. package/src/updates/redis-presence.ts +87 -0
  289. package/src/updates/render.ts +53 -0
  290. package/src/updates/router.ts +52 -0
  291. package/src/updates/types.ts +24 -0
  292. package/src/updates/update-bus.ts +49 -0
  293. 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
+ }