matrix-nodejs-homeserver 0.2.0 → 0.3.2
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/README.md +89 -11
- package/dist/adapters/cluster.d.ts +1 -26
- package/dist/adapters/cluster.js +50 -103
- package/dist/api/account.js +407 -354
- package/dist/api/admin.js +82 -61
- package/dist/api/appservice.js +37 -25
- package/dist/api/backup.js +73 -62
- package/dist/api/federation.js +172 -138
- package/dist/api/filters.js +4 -3
- package/dist/api/legacy.js +91 -78
- package/dist/api/media.js +71 -55
- package/dist/api/misc.js +46 -37
- package/dist/api/preview.js +37 -73
- package/dist/api/profile.js +108 -97
- package/dist/api/push.js +114 -99
- package/dist/api/relationships.js +64 -58
- package/dist/api/rooms.js +703 -580
- package/dist/api/rtc.d.ts +0 -1
- package/dist/api/rtc.js +48 -34
- package/dist/api/search.js +133 -127
- package/dist/api/slidingsync.js +41 -31
- package/dist/api/spaces.js +82 -77
- package/dist/api/sync.js +325 -135
- package/dist/api/threadsubs.js +72 -61
- package/dist/api/threepid.js +99 -66
- package/dist/api/uia.js +17 -16
- package/dist/api/user.js +221 -178
- package/dist/core/admin.d.ts +0 -1
- package/dist/core/admin.js +6 -5
- package/dist/core/adminroom.js +9 -8
- package/dist/core/appservices.d.ts +2 -1
- package/dist/core/appservices.js +5 -4
- package/dist/core/audit.d.ts +0 -1
- package/dist/core/authrules.js +26 -25
- package/dist/core/cluster.d.ts +12 -5
- package/dist/core/cluster.js +46 -29
- package/dist/core/delayed.js +2 -1
- package/dist/core/events.js +11 -10
- package/dist/core/html.js +13 -0
- package/dist/core/identity.js +1 -1
- package/dist/core/invitefilter.js +2 -1
- package/dist/core/json.js +10 -1
- package/dist/core/keys.d.ts +10 -1
- package/dist/core/keys.js +62 -42
- package/dist/core/m.d.ts +209 -0
- package/dist/core/m.js +182 -0
- package/dist/core/outbound.d.ts +16 -0
- package/dist/core/outbound.js +172 -0
- package/dist/core/partialstate.js +2 -1
- package/dist/core/policy.js +4 -3
- package/dist/core/push.d.ts +0 -1
- package/dist/core/push.js +41 -28
- package/dist/core/pushrules.js +36 -35
- package/dist/core/ratelimit.d.ts +1 -1
- package/dist/core/rooms.d.ts +10 -1
- package/dist/core/rooms.js +101 -74
- package/dist/core/semaphore.js +23 -0
- package/dist/core/sso.d.ts +15 -1
- package/dist/core/sso.js +38 -2
- package/dist/core/stateres.js +7 -6
- package/dist/core/threepid.js +1 -9
- package/dist/federation/client.js +9 -1
- package/dist/federation/index.d.ts +2 -2
- package/dist/federation/index.js +43 -35
- package/dist/homeserver.d.ts +23 -4
- package/dist/homeserver.js +12 -3
- package/dist/http/handler.js +2 -23
- package/dist/http/input.js +2 -2
- package/dist/http/procedure.js +25 -0
- package/dist/http/router.d.ts +9 -26
- package/dist/http/router.js +10 -30
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/storage/batch.js +11 -0
- package/dist/storage/memory.js +1 -0
- package/dist/storage/schema.js +1 -1
- package/dist/storage/sql.js +20 -1
- package/dist/storage/types.d.ts +16 -0
- package/dist/vendor/zod/LICENSE +21 -0
- package/dist/vendor/zod/v4/classic/errors.js +54 -0
- package/dist/vendor/zod/v4/classic/parse.js +17 -0
- package/dist/vendor/zod/v4/classic/schemas.js +865 -0
- package/dist/vendor/zod/v4/core/api.js +512 -0
- package/dist/vendor/zod/v4/core/checks.js +300 -0
- package/dist/vendor/zod/v4/core/core.js +109 -0
- package/dist/vendor/zod/v4/core/doc.js +35 -0
- package/dist/vendor/zod/v4/core/errors.js +118 -0
- package/dist/vendor/zod/v4/core/json-schema-processors.js +428 -0
- package/dist/vendor/zod/v4/core/memoizer.js +255 -0
- package/dist/vendor/zod/v4/core/parse.js +206 -0
- package/dist/vendor/zod/v4/core/regexes.js +73 -0
- package/dist/vendor/zod/v4/core/registries.js +48 -0
- package/dist/vendor/zod/v4/core/schemas.js +1378 -0
- package/dist/vendor/zod/v4/core/to-json-schema.js +448 -0
- package/dist/vendor/zod/v4/core/util.js +537 -0
- package/dist/vendor/zod/v4/core/versions.js +8 -0
- package/dist/vendor/zod/v4/locales/en.js +110 -0
- package/package.json +8 -6
- package/dist/core/pushrules.d.ts +0 -1
package/README.md
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# matrix-nodejs-homeserver
|
|
2
2
|
|
|
3
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
4
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
5
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
6
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
7
|
+
|
|
3
8
|
A [Matrix](https://matrix.org) homeserver as an importable Node.js library. It isn't
|
|
4
9
|
tied to any HTTP framework, database, auth system or deployment shape. You can use
|
|
5
10
|
it for a demo on SQLite, embed it in a monolith next to your existing users, or run
|
|
6
11
|
it as a horizontally scaled chat backend that replaces Slack or Teams.
|
|
7
12
|
|
|
8
|
-
- Node.js **26
|
|
13
|
+
- Node.js **22+** (tested on 22, 24 and 26; development uses 26). TypeScript with only erasable syntax (`erasableSyntaxOnly`), and no runtime dependencies ([zod](https://zod.dev), used for request validation, is bundled).
|
|
9
14
|
- Client-Server API, Server-Server API (federation), end-to-end encryption, sliding sync, VoIP/MatrixRTC and push
|
|
10
15
|
- Runs on `node:http`, Express, Fastify, Hono or any fetch runtime
|
|
11
16
|
- Stores data in SQLite, PostgreSQL, MySQL, memory or your own adapter
|
|
@@ -75,12 +80,12 @@ By default a homeserver assumes it is the only process using its database. To ru
|
|
|
75
80
|
several instances behind a load balancer, give them a shared **cluster adapter**:
|
|
76
81
|
|
|
77
82
|
```ts
|
|
78
|
-
import { postgresCluster
|
|
83
|
+
import { postgresCluster } from "matrix-nodejs-homeserver";
|
|
79
84
|
|
|
80
85
|
createHomeserver({
|
|
81
86
|
homeserver: "example.com",
|
|
82
87
|
databaseAdapter: postgresAdapter(pool),
|
|
83
|
-
cluster: postgresCluster(pool),
|
|
88
|
+
cluster: postgresCluster(pool),
|
|
84
89
|
});
|
|
85
90
|
```
|
|
86
91
|
|
|
@@ -95,8 +100,7 @@ The cluster adapter provides:
|
|
|
95
100
|
- **Shared ephemeral state:** UIA sessions, SSO logins and transaction deduplication.
|
|
96
101
|
|
|
97
102
|
[examples/multi-node.ts](examples/multi-node.ts) is meant to run unchanged on several
|
|
98
|
-
machines behind a load balancer, all sharing one Postgres database
|
|
99
|
-
for coordination). It also shows a media adapter backed by that database, since local
|
|
103
|
+
machines behind a load balancer, all sharing one Postgres database, which also coordinates them. It also shows a media adapter backed by that database, since local
|
|
100
104
|
disks differ between machines.
|
|
101
105
|
|
|
102
106
|
The database adapter has to support concurrent access from several processes, so
|
|
@@ -130,6 +134,9 @@ createHomeserver({
|
|
|
130
134
|
clientId, clientSecret, mapUser: (claims) => ({ localpart: claims.email.split("@")[0] }) }] },
|
|
131
135
|
publicBaseUrl: "https://matrix.example.com",
|
|
132
136
|
```
|
|
137
|
+
After signing in, users confirm the client the login token goes to, on a page
|
|
138
|
+
that names it. Clients you trust skip that page:
|
|
139
|
+
`sso: { providers, clientAllowlist: ["https://app.element.io/"] }`.
|
|
133
140
|
- **Delegated OAuth 2.0** (the Matrix OAuth API, e.g. with
|
|
134
141
|
matrix-authentication-service, Keycloak or Authentik). The provider owns the
|
|
135
142
|
accounts, and the homeserver validates its access tokens by introspection:
|
|
@@ -139,8 +146,10 @@ createHomeserver({
|
|
|
139
146
|
|
|
140
147
|
## Federation
|
|
141
148
|
|
|
142
|
-
Federation is
|
|
143
|
-
|
|
149
|
+
Federation is **off by default**, so a server only joins the Matrix network on purpose.
|
|
150
|
+
Turn it on with `federation: { enabled: true }`. It then needs no configuration beyond a
|
|
151
|
+
reachable port 8448, or `.well-known/matrix/server` delegation (`federationAddress`). The
|
|
152
|
+
implementation includes:
|
|
144
153
|
|
|
145
154
|
- Server discovery (`.well-known` and SRV) and signed requests
|
|
146
155
|
- A persistent outbound queue with retries
|
|
@@ -155,7 +164,8 @@ Federation is on by default and needs no configuration beyond a reachable port
|
|
|
155
164
|
federation: { enabled: true, allowedServers: ["partner.org"], blockedServers: ["spam.example"] },
|
|
156
165
|
```
|
|
157
166
|
|
|
158
|
-
|
|
167
|
+
Without federation, local users can only talk to each other: federation endpoints answer
|
|
168
|
+
`M_UNRECOGNIZED`, and remote users, rooms and media cannot be reached.
|
|
159
169
|
|
|
160
170
|
## VoIP and MatrixRTC
|
|
161
171
|
|
|
@@ -302,9 +312,14 @@ await hs.admin.createUser("alice", { password: "…", admin: true });
|
|
|
302
312
|
await hs.admin.createRegistrationToken({ usesAllowed: 10 });
|
|
303
313
|
await hs.admin.deactivateUser("@spammer:example.com");
|
|
304
314
|
await hs.admin.deleteRoom(roomId, { purge: true });
|
|
305
|
-
await hs.admin.sendServerNotice("@alice:example.com", { msgtype:
|
|
315
|
+
await hs.admin.sendServerNotice("@alice:example.com", { msgtype: M.TEXT, body: "Maintenance tonight" });
|
|
306
316
|
```
|
|
307
317
|
|
|
318
|
+
`M` (exported from the package) holds the Matrix identifiers as one readonly object:
|
|
319
|
+
`M.ROOM.MEMBER` is `"m.room.member"`, `M.LOGIN.PASSWORD` is `"m.login.password"`.
|
|
320
|
+
`UNSTABLE` does the same for unstable and vendor identifiers:
|
|
321
|
+
`UNSTABLE.ORG.MATRIX.CROSS_SIGNING_RESET` is `"org.matrix.cross_signing_reset"`.
|
|
322
|
+
|
|
308
323
|
The same operations are available over HTTP at Synapse-compatible `/_synapse/admin/…`
|
|
309
324
|
paths (server admins only), so existing tools keep working.
|
|
310
325
|
|
|
@@ -319,7 +334,8 @@ paths (server admins only), so existing tools keep working.
|
|
|
319
334
|
| `audit` | `{ enabled, database, sink, actions, retentionDays }`. Disabled by default. |
|
|
320
335
|
| `publicBaseUrl` | Advertised via `/.well-known/matrix/client`. Required for SSO. |
|
|
321
336
|
| `oauth`, `sso` | Delegated OAuth or upstream OIDC SSO. |
|
|
322
|
-
| `federation` | `{ enabled, verifyTls, allowedServers, blockedServers }` |
|
|
337
|
+
| `federation` | `{ enabled, verifyTls, allowedServers, blockedServers, fasterJoins }`. Off unless `enabled: true`. |
|
|
338
|
+
| `allowPrivateAddresses` | Lets requests to addresses that users or remote servers choose (federation, push gateways, identity servers, URL previews, remote media) reach private addresses: `true`, or ranges such as `["127.0.0.1/32"]`. Blocked by default. |
|
|
323
339
|
| `rtc` | `{ turn, transports }` |
|
|
324
340
|
| `delayedEvents` | `{ maxDelayMs, maxPerUser }` |
|
|
325
341
|
| `rateLimits` | `false`, or `{ limiter, rules }`. In-memory token buckets by default. Plug in a shared limiter when running a cluster. |
|
|
@@ -363,12 +379,26 @@ npm run build # tsdown → dist/ (what the published package
|
|
|
363
379
|
node examples/node-http.ts # development runs the TypeScript sources directly (type stripping)
|
|
364
380
|
```
|
|
365
381
|
|
|
382
|
+
Endpoints are procedures (`src/http/procedure.ts`): a route declares what it needs and a
|
|
383
|
+
handler, and the procedure checks the request in a fixed order (authentication, rate limit,
|
|
384
|
+
body, then the response) before and after the handler runs:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
r.client("POST", "/rooms/:roomId/invite", procedure({
|
|
388
|
+
auth: "user", // ctx.session (or "optional", "none")
|
|
389
|
+
rateLimit: "invite",
|
|
390
|
+
input: body({ user_id: lenient(z.string()), reason: lenient(z.string()) }), // ctx.input
|
|
391
|
+
handler: async (ctx) => { /* ctx.hs, ctx.session, ctx.input, ctx.params */ },
|
|
392
|
+
}));
|
|
393
|
+
```
|
|
394
|
+
|
|
366
395
|
Inside `src`, modules import each other through the package's
|
|
367
396
|
[subpath imports](https://nodejs.org/api/packages.html#subpath-imports): `#core/errors.ts` is
|
|
368
397
|
`src/core/errors.ts`. The build turns them back into relative imports.
|
|
369
398
|
|
|
370
399
|
Node does not strip types from files inside `node_modules`, so the package ships `dist/`,
|
|
371
|
-
built by tsdown: one `.js` and one `.d.ts` per source module
|
|
400
|
+
built by tsdown: one `.js` and one `.d.ts` per source module, with zod bundled under
|
|
401
|
+
`dist/vendor/`. `npm pack`, `npm publish` and
|
|
372
402
|
installs from GitHub build it through the `prepare` script. Recent npm versions ask you to
|
|
373
403
|
approve install scripts of git dependencies (`npm install-scripts approve`).
|
|
374
404
|
|
|
@@ -429,6 +459,7 @@ Live status, updated after every Complement run (full or targeted) with
|
|
|
429
459
|
|
|
430
460
|
| Run | Top-level | Subtests | Note |
|
|
431
461
|
|---|---|---|---|
|
|
462
|
+
| 9 | 214 / 214 | 851 / 851 | zod, procedures, M/UNSTABLE, SSO confirmation, outbound address policy, admin guard (ec5aa79) |
|
|
432
463
|
| 8 | 214 / 214 | 851 / 851 | Complement runs in a Go container (no submodule, no local Go); account locking/suspension, policy servers, 3PIDs, legacy sync endpoints |
|
|
433
464
|
| 7 | 214 / 214 | 851 / 851 | All Complement tests pass (previous full run: 211/214). Fallback keys stored apart from one-time keys; user directory public-room rules; MSC4311 create event required from v12 |
|
|
434
465
|
| 6 | 189 / 214 | 803 / 851 | Partial-state joins (MSC3902): all subtests pass |
|
|
@@ -449,6 +480,53 @@ Live status, updated after every Complement run (full or targeted) with
|
|
|
449
480
|
CI runs the whole suite with the same script. `complement/passing.txt` lists the tests
|
|
450
481
|
(and subtests) that are known to pass, and CI fails if any of them regress.
|
|
451
482
|
|
|
483
|
+
### Load testing
|
|
484
|
+
|
|
485
|
+
Tuning and investigation runs are local; a [workflow](.github/workflows/loadtest.yml) runs the same scenarios on main for the badges. [loadtest/](loadtest/) holds a k6 scenario and a
|
|
486
|
+
homeserver that reports, every 10 s, requests per second, memory, event-loop delay and
|
|
487
|
+
(with Postgres) how many queries wait for a pool connection and how long locks are held.
|
|
488
|
+
|
|
489
|
+
The scenario: `ONLINE_USERS` each hold a long-polling `/sync` open in one of `ROOMS` rooms,
|
|
490
|
+
while `MESSAGES_PER_SECOND` messages are sent in total. Every user registers and joins
|
|
491
|
+
through the client API. It measures each request type and the time from sending a message to
|
|
492
|
+
its arrival in other users' `/sync` (`message_delivery`).
|
|
493
|
+
|
|
494
|
+
```sh
|
|
495
|
+
# Postgres with N homeserver nodes behind nginx (round-robin, no sticky sessions)
|
|
496
|
+
ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25 RAMP=30s DURATION=30s \
|
|
497
|
+
docker compose -p matrix-loadtest -f loadtest/docker-compose.yml up --build \
|
|
498
|
+
--scale homeserver=5 --abort-on-container-exit --exit-code-from k6 k6
|
|
499
|
+
# CLUSTER=0: one process on Postgres without a cluster adapter
|
|
500
|
+
|
|
501
|
+
# SQLite, one process, no Docker (needs k6 installed)
|
|
502
|
+
DATA_DIR=/tmp/lt node loadtest/server.ts &
|
|
503
|
+
BASE_URL=http://localhost:8008 ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25 k6 run loadtest/k6/matrix.js
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
<!-- loadtest-results:start -->
|
|
507
|
+
The badges at the top come from the [Load test workflow](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml) on main: the same
|
|
508
|
+
scenario on one GitHub runner (4 CPUs), with k6 on the same machine. Each run's job summary has
|
|
509
|
+
the full table.
|
|
510
|
+
|
|
511
|
+
Latest results: 1000 users online in 25 rooms (40 per room), 50 messages/s, 30 s ramp-up then
|
|
512
|
+
30 s at full load, on one developer machine (WSL2, Docker Desktop). Times are p50 / p95.
|
|
513
|
+
|
|
514
|
+
| Setup | req/s | send | /sync | join | delivery | messages delivered | failed |
|
|
515
|
+
|---|---|---|---|---|---|---|---|
|
|
516
|
+
| SQLite, 1 process (before #28–#35) | ~800 | 46 ms / 0.69 s | 0.50 s / 2.4 s | 61 ms / 0.15 s | 0.58 s / 0.78 s | 5,816 | 0% |
|
|
517
|
+
| Postgres, no cluster | 404 | 49 ms / 71 ms | 2.6 s / 3.6 s | 18 ms / 27 ms | 4.6 s / 6.5 s | 56,297 | 0% |
|
|
518
|
+
| Postgres, cluster, 1 node | 314 | 81 ms / 124 ms | 2.8 s / 4.4 s | 50 ms / 70 ms | 5.2 s / 7.5 s | 52,071 | 0% |
|
|
519
|
+
| Postgres, cluster, 5 nodes | 840 | 118 ms / 197 ms | 0.90 s / 1.4 s | 86 ms / 151 ms | 1.6 s / 2.3 s | 58,298 | 0% |
|
|
520
|
+
|
|
521
|
+
What the load tests found and fixed so far: presence written on every `/sync` (#26), one commit
|
|
522
|
+
per push notification (#27), a missing index (#28), Postgres cluster locks that could deadlock
|
|
523
|
+
the pool (#29), stream writes serialized per instance (#30), `/sync` reading every change on the
|
|
524
|
+
server (#31) and every joined room (#33), lock handover by polling (#32). Then `/sync` reads were batched
|
|
525
|
+
(#34) and a process builds at most four sync responses at once (#35), so sends no longer queue
|
|
526
|
+
behind the syncs a message wakes. A Postgres node now builds ~330 syncs/s here; delivery time is
|
|
527
|
+
syncs waiting for their turn, and more nodes shorten it.
|
|
528
|
+
<!-- loadtest-results:end -->
|
|
529
|
+
|
|
452
530
|
## License
|
|
453
531
|
|
|
454
532
|
[PolyForm Noncommercial 1.0.0](LICENSE). Free to use, modify and share for any noncommercial
|
|
@@ -19,37 +19,12 @@ export interface PgClusterPoolLike {
|
|
|
19
19
|
connect(): Promise<PgClientLike>;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* Cluster adapter backed by PostgreSQL (`pg` Pool): sequences,
|
|
22
|
+
* Cluster adapter backed by PostgreSQL (`pg` Pool): sequences, leased locks,
|
|
23
23
|
* LISTEN/NOTIFY pub/sub and an expiring key-value table. Lets several homeserver
|
|
24
24
|
* instances share one database.
|
|
25
25
|
*/
|
|
26
26
|
export declare function postgresCluster(pool: PgClusterPoolLike, options?: {
|
|
27
27
|
tablePrefix?: string;
|
|
28
28
|
}): ClusterAdapter;
|
|
29
|
-
/** Minimal ioredis-compatible client. */
|
|
30
|
-
export interface RedisLike {
|
|
31
|
-
incr(key: string): Promise<number>;
|
|
32
|
-
get(key: string): Promise<string | null>;
|
|
33
|
-
set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
|
|
34
|
-
del(...keys: string[]): Promise<number>;
|
|
35
|
-
eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
|
|
36
|
-
publish(channel: string, message: string): Promise<number>;
|
|
37
|
-
scan(cursor: string | number, ...args: (string | number)[]): Promise<[string, string[]]>;
|
|
38
|
-
mget(...keys: string[]): Promise<(string | null)[]>;
|
|
39
|
-
}
|
|
40
|
-
/** A Redis connection in subscriber mode (a separate connection, as Redis requires). */
|
|
41
|
-
export interface RedisSubscriberLike {
|
|
42
|
-
subscribe(...channels: string[]): Promise<unknown>;
|
|
43
|
-
on(event: "message", listener: (channel: string, message: string) => void): unknown;
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Cluster adapter backed by Redis (ioredis API). Pass two connections: one for
|
|
47
|
-
* commands and one dedicated to subscriptions.
|
|
48
|
-
*/
|
|
49
|
-
export declare function redisCluster(opts: {
|
|
50
|
-
client: RedisLike;
|
|
51
|
-
subscriber: RedisSubscriberLike;
|
|
52
|
-
prefix?: string;
|
|
53
|
-
}): ClusterAdapter;
|
|
54
29
|
//#endregion
|
|
55
30
|
export { type ClusterAdapter, localCluster };
|
package/dist/adapters/cluster.js
CHANGED
|
@@ -16,9 +16,13 @@ function localQueue() {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
const PG_CHANNEL = "matrix_cluster";
|
|
19
|
+
/** Internal pub/sub channel announcing released locks. */
|
|
20
|
+
const RELEASED = "__lock_released";
|
|
21
|
+
/** How long a lock lease lasts without renewal (it is renewed every third of that while held). */
|
|
22
|
+
const LOCK_TTL_MS = 3e4;
|
|
19
23
|
const MAX_NOTIFY_PAYLOAD = 7e3;
|
|
20
24
|
/**
|
|
21
|
-
* Cluster adapter backed by PostgreSQL (`pg` Pool): sequences,
|
|
25
|
+
* Cluster adapter backed by PostgreSQL (`pg` Pool): sequences, leased locks,
|
|
22
26
|
* LISTEN/NOTIFY pub/sub and an expiring key-value table. Lets several homeserver
|
|
23
27
|
* instances share one database.
|
|
24
28
|
*/
|
|
@@ -27,6 +31,20 @@ function postgresCluster(pool, options = {}) {
|
|
|
27
31
|
const kv = `"${options.tablePrefix ?? ""}cluster_kv"`;
|
|
28
32
|
const handlers = /* @__PURE__ */ new Map();
|
|
29
33
|
const queue = localQueue();
|
|
34
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
35
|
+
const releasedOrAfter = (lockKey, ms) => new Promise((resolve) => {
|
|
36
|
+
let set = waiting.get(lockKey);
|
|
37
|
+
if (!set) waiting.set(lockKey, set = /* @__PURE__ */ new Set());
|
|
38
|
+
const wake = () => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
set.delete(wake);
|
|
41
|
+
if (set.size === 0 && waiting.get(lockKey) === set) waiting.delete(lockKey);
|
|
42
|
+
resolve();
|
|
43
|
+
};
|
|
44
|
+
const timer = setTimeout(wake, ms);
|
|
45
|
+
set.add(wake);
|
|
46
|
+
});
|
|
47
|
+
handlers.set(RELEASED, /* @__PURE__ */ new Set([(lockKey) => [...waiting.get(lockKey) ?? []].forEach((wake) => wake())]));
|
|
30
48
|
let listener = null;
|
|
31
49
|
let cleanup = null;
|
|
32
50
|
const ready = (async () => {
|
|
@@ -80,16 +98,38 @@ function postgresCluster(pool, options = {}) {
|
|
|
80
98
|
lock(key, fn) {
|
|
81
99
|
return queue(key, async () => {
|
|
82
100
|
await ready;
|
|
83
|
-
const
|
|
101
|
+
const lockKey = `lock:${key}`;
|
|
102
|
+
const token = randomUUID();
|
|
103
|
+
const acquire = async () => {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
return ((await pool.query(`INSERT INTO ${kv} (key, value, expires_at) VALUES ($1, $2, $3)
|
|
106
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at
|
|
107
|
+
WHERE ${kv}.expires_at IS NOT NULL AND ${kv}.expires_at <= $4 RETURNING key`, [
|
|
108
|
+
lockKey,
|
|
109
|
+
token,
|
|
110
|
+
now + LOCK_TTL_MS,
|
|
111
|
+
now
|
|
112
|
+
])).rowCount ?? 0) > 0;
|
|
113
|
+
};
|
|
114
|
+
for (let delay = 5; !await acquire(); delay = Math.min(delay * 2, 200)) await releasedOrAfter(lockKey, delay);
|
|
115
|
+
const renew = setInterval(() => void pool.query(`UPDATE ${kv} SET expires_at = $3 WHERE key = $1 AND value = $2`, [
|
|
116
|
+
lockKey,
|
|
117
|
+
token,
|
|
118
|
+
Date.now() + LOCK_TTL_MS
|
|
119
|
+
]).catch(() => {}), LOCK_TTL_MS / 3);
|
|
84
120
|
try {
|
|
85
|
-
await
|
|
86
|
-
try {
|
|
87
|
-
return await fn();
|
|
88
|
-
} finally {
|
|
89
|
-
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]);
|
|
90
|
-
}
|
|
121
|
+
return await fn();
|
|
91
122
|
} finally {
|
|
92
|
-
|
|
123
|
+
clearInterval(renew);
|
|
124
|
+
await pool.query(`WITH released AS (DELETE FROM ${kv} WHERE key = $1 AND value = $2 RETURNING key) SELECT pg_notify($3, $4) FROM released`, [
|
|
125
|
+
lockKey,
|
|
126
|
+
token,
|
|
127
|
+
PG_CHANNEL,
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
channel: RELEASED,
|
|
130
|
+
message: lockKey
|
|
131
|
+
})
|
|
132
|
+
]).catch(() => {});
|
|
93
133
|
}
|
|
94
134
|
});
|
|
95
135
|
},
|
|
@@ -152,98 +192,5 @@ function postgresCluster(pool, options = {}) {
|
|
|
152
192
|
}
|
|
153
193
|
};
|
|
154
194
|
}
|
|
155
|
-
const UNLOCK = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
|
156
|
-
const EXTEND = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`;
|
|
157
|
-
/**
|
|
158
|
-
* Cluster adapter backed by Redis (ioredis API). Pass two connections: one for
|
|
159
|
-
* commands and one dedicated to subscriptions.
|
|
160
|
-
*/
|
|
161
|
-
function redisCluster(opts) {
|
|
162
|
-
const { client, subscriber } = opts;
|
|
163
|
-
const prefix = opts.prefix ?? "matrix:";
|
|
164
|
-
const channel = `${prefix}cluster`;
|
|
165
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
166
|
-
const queue = localQueue();
|
|
167
|
-
const ready = subscriber.subscribe(channel);
|
|
168
|
-
subscriber.on("message", (ch, raw) => {
|
|
169
|
-
if (ch !== channel) return;
|
|
170
|
-
try {
|
|
171
|
-
const { channel: name, message } = JSON.parse(raw);
|
|
172
|
-
for (const h of handlers.get(name) ?? []) h(message);
|
|
173
|
-
} catch {}
|
|
174
|
-
});
|
|
175
|
-
const k = (key) => `${prefix}${key}`;
|
|
176
|
-
return {
|
|
177
|
-
async nextId(name) {
|
|
178
|
-
return Number(await client.incr(k(`seq:${name}`)));
|
|
179
|
-
},
|
|
180
|
-
async currentId(name) {
|
|
181
|
-
return Number(await client.get(k(`seq:${name}`)) ?? 0);
|
|
182
|
-
},
|
|
183
|
-
async advance(name, value) {
|
|
184
|
-
await client.eval(`local v = tonumber(redis.call("get", KEYS[1]) or "0") if v < tonumber(ARGV[1]) then redis.call("set", KEYS[1], ARGV[1]) end return 1`, 1, k(`seq:${name}`), value);
|
|
185
|
-
},
|
|
186
|
-
lock(key, fn) {
|
|
187
|
-
return queue(key, async () => {
|
|
188
|
-
const lockKey = k(`lock:${key}`);
|
|
189
|
-
const token = randomUUID();
|
|
190
|
-
const ttl = 3e4;
|
|
191
|
-
for (let delay = 5;; delay = Math.min(delay * 2, 200)) {
|
|
192
|
-
if (await client.set(lockKey, token, "PX", ttl, "NX") === "OK") break;
|
|
193
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
194
|
-
}
|
|
195
|
-
const renew = setInterval(() => void client.eval(EXTEND, 1, lockKey, token, ttl).catch(() => {}), ttl / 3);
|
|
196
|
-
try {
|
|
197
|
-
return await fn();
|
|
198
|
-
} finally {
|
|
199
|
-
clearInterval(renew);
|
|
200
|
-
await client.eval(UNLOCK, 1, lockKey, token).catch(() => {});
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
},
|
|
204
|
-
async publish(name, message) {
|
|
205
|
-
await ready;
|
|
206
|
-
await client.publish(channel, JSON.stringify({
|
|
207
|
-
channel: name,
|
|
208
|
-
message
|
|
209
|
-
}));
|
|
210
|
-
},
|
|
211
|
-
subscribe(name, handler) {
|
|
212
|
-
let set = handlers.get(name);
|
|
213
|
-
if (!set) handlers.set(name, set = /* @__PURE__ */ new Set());
|
|
214
|
-
set.add(handler);
|
|
215
|
-
},
|
|
216
|
-
async get(key) {
|
|
217
|
-
const v = await client.get(k(`kv:${key}`));
|
|
218
|
-
return v === null ? null : JSON.parse(v);
|
|
219
|
-
},
|
|
220
|
-
async set(key, value, ttlMs) {
|
|
221
|
-
if (ttlMs) await client.set(k(`kv:${key}`), JSON.stringify(value), "PX", ttlMs);
|
|
222
|
-
else await client.set(k(`kv:${key}`), JSON.stringify(value));
|
|
223
|
-
},
|
|
224
|
-
async setIfAbsent(key, value, ttlMs) {
|
|
225
|
-
return (ttlMs ? await client.set(k(`kv:${key}`), JSON.stringify(value), "PX", ttlMs, "NX") : await client.set(k(`kv:${key}`), JSON.stringify(value), "NX")) === "OK";
|
|
226
|
-
},
|
|
227
|
-
async delete(key) {
|
|
228
|
-
await client.del(k(`kv:${key}`));
|
|
229
|
-
},
|
|
230
|
-
async entries(keyPrefix) {
|
|
231
|
-
const out = [];
|
|
232
|
-
let cursor = "0";
|
|
233
|
-
const pattern = `${k(`kv:${keyPrefix}`).replace(/[*?[\]]/g, (c) => `\\${c}`)}*`;
|
|
234
|
-
do {
|
|
235
|
-
const [next, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
|
|
236
|
-
cursor = next;
|
|
237
|
-
if (keys.length) {
|
|
238
|
-
const values = await client.mget(...keys);
|
|
239
|
-
keys.forEach((key, i) => {
|
|
240
|
-
if (values[i] !== null) out.push([key.slice(k("kv:").length), JSON.parse(values[i])]);
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
} while (cursor !== "0");
|
|
244
|
-
return out;
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
195
|
//#endregion
|
|
249
|
-
export { localCluster, postgresCluster
|
|
196
|
+
export { localCluster, postgresCluster };
|