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.
Files changed (99) hide show
  1. package/README.md +89 -11
  2. package/dist/adapters/cluster.d.ts +1 -26
  3. package/dist/adapters/cluster.js +50 -103
  4. package/dist/api/account.js +407 -354
  5. package/dist/api/admin.js +82 -61
  6. package/dist/api/appservice.js +37 -25
  7. package/dist/api/backup.js +73 -62
  8. package/dist/api/federation.js +172 -138
  9. package/dist/api/filters.js +4 -3
  10. package/dist/api/legacy.js +91 -78
  11. package/dist/api/media.js +71 -55
  12. package/dist/api/misc.js +46 -37
  13. package/dist/api/preview.js +37 -73
  14. package/dist/api/profile.js +108 -97
  15. package/dist/api/push.js +114 -99
  16. package/dist/api/relationships.js +64 -58
  17. package/dist/api/rooms.js +703 -580
  18. package/dist/api/rtc.d.ts +0 -1
  19. package/dist/api/rtc.js +48 -34
  20. package/dist/api/search.js +133 -127
  21. package/dist/api/slidingsync.js +41 -31
  22. package/dist/api/spaces.js +82 -77
  23. package/dist/api/sync.js +325 -135
  24. package/dist/api/threadsubs.js +72 -61
  25. package/dist/api/threepid.js +99 -66
  26. package/dist/api/uia.js +17 -16
  27. package/dist/api/user.js +221 -178
  28. package/dist/core/admin.d.ts +0 -1
  29. package/dist/core/admin.js +6 -5
  30. package/dist/core/adminroom.js +9 -8
  31. package/dist/core/appservices.d.ts +2 -1
  32. package/dist/core/appservices.js +5 -4
  33. package/dist/core/audit.d.ts +0 -1
  34. package/dist/core/authrules.js +26 -25
  35. package/dist/core/cluster.d.ts +12 -5
  36. package/dist/core/cluster.js +46 -29
  37. package/dist/core/delayed.js +2 -1
  38. package/dist/core/events.js +11 -10
  39. package/dist/core/html.js +13 -0
  40. package/dist/core/identity.js +1 -1
  41. package/dist/core/invitefilter.js +2 -1
  42. package/dist/core/json.js +10 -1
  43. package/dist/core/keys.d.ts +10 -1
  44. package/dist/core/keys.js +62 -42
  45. package/dist/core/m.d.ts +209 -0
  46. package/dist/core/m.js +182 -0
  47. package/dist/core/outbound.d.ts +16 -0
  48. package/dist/core/outbound.js +172 -0
  49. package/dist/core/partialstate.js +2 -1
  50. package/dist/core/policy.js +4 -3
  51. package/dist/core/push.d.ts +0 -1
  52. package/dist/core/push.js +41 -28
  53. package/dist/core/pushrules.js +36 -35
  54. package/dist/core/ratelimit.d.ts +1 -1
  55. package/dist/core/rooms.d.ts +10 -1
  56. package/dist/core/rooms.js +101 -74
  57. package/dist/core/semaphore.js +23 -0
  58. package/dist/core/sso.d.ts +15 -1
  59. package/dist/core/sso.js +38 -2
  60. package/dist/core/stateres.js +7 -6
  61. package/dist/core/threepid.js +1 -9
  62. package/dist/federation/client.js +9 -1
  63. package/dist/federation/index.d.ts +2 -2
  64. package/dist/federation/index.js +43 -35
  65. package/dist/homeserver.d.ts +23 -4
  66. package/dist/homeserver.js +12 -3
  67. package/dist/http/handler.js +2 -23
  68. package/dist/http/input.js +2 -2
  69. package/dist/http/procedure.js +25 -0
  70. package/dist/http/router.d.ts +9 -26
  71. package/dist/http/router.js +10 -30
  72. package/dist/index.d.ts +4 -3
  73. package/dist/index.js +3 -2
  74. package/dist/storage/batch.js +11 -0
  75. package/dist/storage/memory.js +1 -0
  76. package/dist/storage/schema.js +1 -1
  77. package/dist/storage/sql.js +20 -1
  78. package/dist/storage/types.d.ts +16 -0
  79. package/dist/vendor/zod/LICENSE +21 -0
  80. package/dist/vendor/zod/v4/classic/errors.js +54 -0
  81. package/dist/vendor/zod/v4/classic/parse.js +17 -0
  82. package/dist/vendor/zod/v4/classic/schemas.js +865 -0
  83. package/dist/vendor/zod/v4/core/api.js +512 -0
  84. package/dist/vendor/zod/v4/core/checks.js +300 -0
  85. package/dist/vendor/zod/v4/core/core.js +109 -0
  86. package/dist/vendor/zod/v4/core/doc.js +35 -0
  87. package/dist/vendor/zod/v4/core/errors.js +118 -0
  88. package/dist/vendor/zod/v4/core/json-schema-processors.js +428 -0
  89. package/dist/vendor/zod/v4/core/memoizer.js +255 -0
  90. package/dist/vendor/zod/v4/core/parse.js +206 -0
  91. package/dist/vendor/zod/v4/core/regexes.js +73 -0
  92. package/dist/vendor/zod/v4/core/registries.js +48 -0
  93. package/dist/vendor/zod/v4/core/schemas.js +1378 -0
  94. package/dist/vendor/zod/v4/core/to-json-schema.js +448 -0
  95. package/dist/vendor/zod/v4/core/util.js +537 -0
  96. package/dist/vendor/zod/v4/core/versions.js +8 -0
  97. package/dist/vendor/zod/v4/locales/en.js +110 -0
  98. package/package.json +8 -6
  99. package/dist/core/pushrules.d.ts +0 -1
package/README.md CHANGED
@@ -1,11 +1,16 @@
1
1
  # matrix-nodejs-homeserver
2
2
 
3
+ [![Load test: SQLite](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fsqlite.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
4
+ [![Load test: Postgres](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-nocluster.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
5
+ [![Load test: Postgres cluster, 1 node](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-1.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
6
+ [![Load test: Postgres cluster, 5 nodes](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-5.json)](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+**. TypeScript with only erasable syntax (`erasableSyntaxOnly`); one runtime dependency, [zod](https://zod.dev), for request validation.
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, redisCluster } from "matrix-nodejs-homeserver";
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), // or: redisCluster({ client: new Redis(), subscriber: new Redis() })
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 (and optionally Redis
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 on by default and needs no configuration beyond a reachable port
143
- 8448, or `.well-known/matrix/server` delegation. The implementation includes:
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
- Set `federation: { enabled: false }` for a private deployment.
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: "m.text", body: "Maintenance tonight" });
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. `npm pack`, `npm publish` and
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, advisory locks,
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 };
@@ -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, advisory locks,
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 client = await pool.connect();
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 client.query("SELECT pg_advisory_lock(hashtextextended($1, 0))", [key]);
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
- client.release();
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, redisCluster };
196
+ export { localCluster, postgresCluster };