matrix-nodejs-homeserver 0.3.1 → 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/dist/api/sync.js CHANGED
@@ -4,9 +4,12 @@ import { errors } from "../core/errors.js";
4
4
  import { getState } from "../core/authrules.js";
5
5
  import { loadRules } from "../core/pushrules.js";
6
6
  import { inviteDecision } from "../core/invitefilter.js";
7
+ import { deviceListChangesQuery } from "../core/keys.js";
7
8
  import { body } from "../http/input.js";
8
9
  import { procedure } from "../http/procedure.js";
9
10
  import { matchesFilter, roomAllowed, validateSyncFilter } from "./filters.js";
11
+ import { readBatch } from "../storage/batch.js";
12
+ import { Semaphore } from "../core/semaphore.js";
10
13
  import { fullProfile } from "../core/profile.js";
11
14
  //#region src/api/sync.ts
12
15
  function parseSyncToken(raw) {
@@ -64,7 +67,8 @@ async function snapshotAt(hs, roomId, pos) {
64
67
  field: "stream",
65
68
  direction: "desc"
66
69
  },
67
- limit: 1
70
+ limit: 1,
71
+ fields: ["state_snapshot"]
68
72
  }))[0]?.state_snapshot ?? 0;
69
73
  }
70
74
  /** Build the timeline + state section of a room. */
@@ -126,7 +130,8 @@ async function roomTimeline(hs, p, roomId, since, upTo) {
126
130
  }
127
131
  if (limit === 0 && events.length === 0) limited = (await hs.db.findMany("events", {
128
132
  where,
129
- limit: 1
133
+ limit: 1,
134
+ fields: ["stream"]
130
135
  })).length > 0;
131
136
  if (since === null) events.sort((a, b) => (a.pdu.depth ?? 0) - (b.pdu.depth ?? 0) || a.stream - b.stream);
132
137
  else events.reverse();
@@ -144,7 +149,8 @@ async function roomTimeline(hs, p, roomId, since, upTo) {
144
149
  operator: "lt",
145
150
  value: events[0].stream
146
151
  }],
147
- limit: 1
152
+ limit: 1,
153
+ fields: ["stream"]
148
154
  })).length > 0;
149
155
  const startPos = events.length ? Math.min(...events.map((e) => e.stream)) - 1 : upTo;
150
156
  const startState = await hs.rooms.stateAtSnapshot(await snapshotAt(hs, roomId, startPos));
@@ -196,7 +202,7 @@ async function roomTimeline(hs, p, roomId, since, upTo) {
196
202
  timelineEvents: events
197
203
  };
198
204
  }
199
- async function roomEphemeral(hs, p, roomId) {
205
+ async function roomEphemeral(hs, p, roomId, prefetched) {
200
206
  const since = p.since?.pos ?? null;
201
207
  const out = [];
202
208
  const typing = hs.typing.typingSince(roomId, since);
@@ -213,7 +219,7 @@ async function roomEphemeral(hs, p, roomId) {
213
219
  operator: "gt",
214
220
  value: since
215
221
  });
216
- const receipts = (await hs.db.findMany("receipts", { where })).filter((r) => r.stream <= p.to);
222
+ const receipts = (prefetched ?? await hs.db.findMany("receipts", { where })).filter((r) => r.stream <= p.to);
217
223
  const content = {};
218
224
  for (const r of receipts) {
219
225
  if (r.receipt_type === M.READ.PRIVATE && r.user_id !== p.session.userId) continue;
@@ -231,7 +237,7 @@ async function roomEphemeral(hs, p, roomId) {
231
237
  });
232
238
  return out.filter((e) => matchesFilter(p.filter.room?.ephemeral, e));
233
239
  }
234
- async function accountData(hs, p, roomId) {
240
+ async function accountData(hs, p, roomId, prefetched) {
235
241
  const where = [{
236
242
  field: "user_id",
237
243
  value: p.session.userId
@@ -244,7 +250,7 @@ async function accountData(hs, p, roomId) {
244
250
  operator: "gt",
245
251
  value: p.since.pos
246
252
  });
247
- const rows = await hs.db.findMany("account_data", { where });
253
+ const rows = prefetched ?? await hs.db.findMany("account_data", { where });
248
254
  const filter = roomId ? p.filter.room?.account_data : p.filter.account_data;
249
255
  return rows.filter((r) => r.stream <= p.to && (p.since || Object.keys(r.content ?? {}).length > 0)).map((r) => ({
250
256
  type: r.type,
@@ -271,13 +277,193 @@ async function roomSummary(hs, roomId, userId) {
271
277
  if (!getState(state, M.ROOM.NAME)?.content?.name && !getState(state, M.ROOM.CANONICAL_ALIAS)?.content?.alias) summary[M.HEROES] = heroes.sort();
272
278
  return summary;
273
279
  }
280
+ /**
281
+ * What changed for the user since the token, for all of their rooms at once: the rooms with new
282
+ * events or newly complete state, and the new receipts and account data by room.
283
+ *
284
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/33: an incremental sync queried every
285
+ * joined room (timeline, receipts, account data) and only then found that most had nothing new,
286
+ * so a sync cost as many queries as the user had rooms, each time a long-poll woke up. These few
287
+ * queries find the rooms that changed, and only those are built.
288
+ */
289
+ /** One-time key counts by algorithm, as /sync reports them (signed_curve25519 always present). */
290
+ function countAlgorithms(rows) {
291
+ const counts = { signed_curve25519: 0 };
292
+ for (const r of rows) counts[r.algorithm] = (counts[r.algorithm] ?? 0) + 1;
293
+ return counts;
294
+ }
295
+ async function changesSince(hs, p, memberships, since, accountRows) {
296
+ const joined = memberships.filter((m) => m.membership === "join").map((m) => m.room_id);
297
+ const anyMembership = [...new Set(memberships.map((m) => m.room_id))];
298
+ const range = [{
299
+ field: "stream",
300
+ operator: "gt",
301
+ value: since
302
+ }, {
303
+ field: "stream",
304
+ operator: "lte",
305
+ value: p.to
306
+ }];
307
+ const chunks = (list) => Array.from({ length: Math.ceil(list.length / 500) }, (_, i) => list.slice(i * 500, i * 500 + 500));
308
+ const inRooms = (chunk) => ({
309
+ field: "room_id",
310
+ operator: "in",
311
+ value: chunk
312
+ });
313
+ const queries = [];
314
+ for (const chunk of chunks(joined)) {
315
+ queries.push({
316
+ model: "events",
317
+ opts: {
318
+ where: [inRooms(chunk), ...range],
319
+ fields: ["room_id"]
320
+ }
321
+ });
322
+ queries.push({
323
+ model: "receipts",
324
+ opts: { where: [inRooms(chunk), ...range] }
325
+ });
326
+ queries.push({
327
+ model: "partial_state_rooms",
328
+ opts: {
329
+ where: [inRooms(chunk), {
330
+ field: "completed_stream",
331
+ operator: "gt",
332
+ value: since
333
+ }],
334
+ fields: ["room_id"]
335
+ }
336
+ });
337
+ }
338
+ for (const chunk of chunks(anyMembership)) queries.push({
339
+ model: "memberships",
340
+ opts: { where: [inRooms(chunk), {
341
+ field: "stream",
342
+ operator: "gt",
343
+ value: since
344
+ }] }
345
+ });
346
+ const results = await readBatch(hs.db, queries);
347
+ const touched = /* @__PURE__ */ new Set();
348
+ const receipts = /* @__PURE__ */ new Map();
349
+ const joinedChunks = Math.ceil(joined.length / 500);
350
+ for (let c = 0; c < joinedChunks; c++) {
351
+ const [events, receiptRows, completed] = results.slice(c * 3, c * 3 + 3);
352
+ for (const r of events) touched.add(r.room_id);
353
+ for (const r of receiptRows) {
354
+ touched.add(r.room_id);
355
+ receipts.set(r.room_id, [...receipts.get(r.room_id) ?? [], r]);
356
+ }
357
+ for (const r of completed) touched.add(r.room_id);
358
+ }
359
+ const memberChanges = results.slice(joinedChunks * 3).flat();
360
+ const accountData = /* @__PURE__ */ new Map();
361
+ for (const r of accountRows) {
362
+ if (r.room_id) touched.add(r.room_id);
363
+ accountData.set(r.room_id, [...accountData.get(r.room_id) ?? [], r]);
364
+ }
365
+ return {
366
+ touched,
367
+ receipts,
368
+ accountData,
369
+ memberChanges
370
+ };
371
+ }
274
372
  async function buildSync(hs, p) {
275
373
  const userId = p.session.userId;
276
374
  const since = p.since?.pos ?? null;
277
- const memberships = await hs.db.findMany("memberships", { where: [{
375
+ const incremental = since !== null && !p.fullState;
376
+ const deviceWhere = [{
278
377
  field: "user_id",
279
378
  value: userId
280
- }] });
379
+ }, {
380
+ field: "device_id",
381
+ value: p.session.deviceId
382
+ }];
383
+ const acknowledged = p.since ? hs.db.delete("to_device", [...deviceWhere, {
384
+ field: "stream",
385
+ operator: "lte",
386
+ value: p.since.toDevice
387
+ }]) : Promise.resolve(0);
388
+ const [memberships, ignoredRows, toDeviceRows, oneTimeKeyRows, fallbackRows, accountRows = []] = await readBatch(hs.db, [
389
+ {
390
+ model: "memberships",
391
+ opts: { where: [{
392
+ field: "user_id",
393
+ value: userId
394
+ }] }
395
+ },
396
+ {
397
+ model: "account_data",
398
+ opts: { where: [
399
+ {
400
+ field: "user_id",
401
+ value: userId
402
+ },
403
+ {
404
+ field: "room_id",
405
+ value: ""
406
+ },
407
+ {
408
+ field: "type",
409
+ value: M.IGNORED_USER_LIST
410
+ }
411
+ ] }
412
+ },
413
+ {
414
+ model: "to_device",
415
+ opts: {
416
+ where: [...deviceWhere, {
417
+ field: "stream",
418
+ operator: "gt",
419
+ value: p.since?.toDevice ?? 0
420
+ }],
421
+ sortBy: {
422
+ field: "stream",
423
+ direction: "asc"
424
+ },
425
+ limit: 100
426
+ }
427
+ },
428
+ {
429
+ model: "one_time_keys",
430
+ opts: {
431
+ where: [...deviceWhere, {
432
+ field: "fallback",
433
+ value: false
434
+ }],
435
+ fields: ["algorithm"]
436
+ }
437
+ },
438
+ {
439
+ model: "fallback_keys",
440
+ opts: {
441
+ where: [...deviceWhere, {
442
+ field: "used",
443
+ value: false
444
+ }],
445
+ fields: ["algorithm"]
446
+ }
447
+ },
448
+ ...incremental ? [{
449
+ model: "account_data",
450
+ opts: { where: [{
451
+ field: "user_id",
452
+ value: userId
453
+ }, {
454
+ field: "stream",
455
+ operator: "gt",
456
+ value: since
457
+ }] }
458
+ }] : []
459
+ ]);
460
+ p = {
461
+ ...p,
462
+ ignored: new Set(Object.keys(ignoredRows[0]?.content?.ignored_users ?? {}))
463
+ };
464
+ const changes = incremental ? await changesSince(hs, p, memberships, since, accountRows) : null;
465
+ let memberChangesRead = changes ? Promise.resolve(changes.memberChanges) : null;
466
+ const memberChanges = () => memberChangesRead ??= hs.rooms.membershipChangesSince(userId, since ?? 0, memberships);
281
467
  const join = {};
282
468
  const invite = {};
283
469
  const leave = {};
@@ -288,12 +474,13 @@ async function buildSync(hs, p) {
288
474
  const roomId = m.room_id;
289
475
  const changed = since === null || m.stream > since;
290
476
  if (m.membership === "join") {
477
+ if (changes && !changed && !changes.touched.has(roomId) && hs.typing.typingSince(roomId, since) === null) continue;
291
478
  if (!p.filter.room?.state?.lazy_load_members && await hs.partialState.isPartial(roomId)) continue;
292
479
  const completedAt = await hs.partialState.completedAt(roomId);
293
480
  const initial = since === null || p.fullState || m.stream > since || completedAt !== null && completedAt > since && completedAt <= p.to;
294
481
  const { timeline, state, stateAfter, timelineEvents } = await roomTimeline(hs, p, roomId, initial ? null : since, p.to);
295
- const ephemeral = await roomEphemeral(hs, p, roomId);
296
- const roomAccountData = await accountData(hs, p, roomId);
482
+ const ephemeral = await roomEphemeral(hs, p, roomId, changes ? changes.receipts.get(roomId) ?? [] : void 0);
483
+ const roomAccountData = await accountData(hs, p, roomId, changes ? changes.accountData.get(roomId) ?? [] : void 0);
297
484
  if (!initial && timelineEvents.length === 0 && state.length === 0 && stateAfter.length === 0 && ephemeral.length === 0 && roomAccountData.length === 0) continue;
298
485
  join[roomId] = {
299
486
  timeline,
@@ -341,68 +528,67 @@ async function buildSync(hs, p) {
341
528
  leave[roomId] = {
342
529
  timeline,
343
530
  state: { events: state.map((e) => toClientEvent(e, { includeRoomId: false })) },
344
- account_data: { events: await accountData(hs, p, roomId) }
531
+ account_data: { events: await accountData(hs, p, roomId, changes ? changes.accountData.get(roomId) ?? [] : void 0) }
345
532
  };
346
533
  empty = false;
347
534
  }
348
535
  }
349
- const globalAccountData = await accountData(hs, p, "");
536
+ const globalAccountData = await accountData(hs, p, "", changes ? changes.accountData.get("") ?? [] : void 0);
350
537
  if (since === null && !globalAccountData.some((e) => e.type === M.PUSH_RULES)) globalAccountData.push({
351
538
  type: M.PUSH_RULES,
352
539
  content: { global: await loadRules(hs, userId) }
353
540
  });
354
541
  if (globalAccountData.length) empty = false;
355
- const deviceWhere = [{
356
- field: "user_id",
357
- value: userId
358
- }, {
359
- field: "device_id",
360
- value: p.session.deviceId
361
- }];
362
- if (p.since) await hs.db.delete("to_device", [...deviceWhere, {
363
- field: "stream",
364
- operator: "lte",
365
- value: p.since.toDevice
366
- }]);
367
- const toDevice = (await hs.db.findMany("to_device", {
368
- where: [...deviceWhere, {
369
- field: "stream",
370
- operator: "gt",
371
- value: p.since?.toDevice ?? 0
372
- }],
373
- sortBy: {
374
- field: "stream",
375
- direction: "asc"
376
- },
377
- limit: 100
378
- })).filter((r) => r.stream <= p.to);
542
+ await acknowledged;
543
+ const toDevice = toDeviceRows.filter((r) => r.stream <= p.to);
379
544
  const nextToDevice = toDevice.length ? toDevice[toDevice.length - 1].stream : p.since?.toDevice ?? 0;
380
545
  if (toDevice.length) empty = false;
381
546
  const presenceEvents = [];
382
- if (!p.filter.presence?.types || p.filter.presence.types.length) {
383
- const sharing = await hs.rooms.usersSharingRooms(userId);
384
- const newlyShared = /* @__PURE__ */ new Set();
385
- if (since !== null) {
386
- const changes = await hs.db.findMany("memberships", { where: [{
547
+ const wantPresence = !p.filter.presence?.types || p.filter.presence.types.length > 0;
548
+ const sharing = await hs.rooms.usersSharingRooms(userId);
549
+ const joinedRooms = memberships.filter((m) => m.membership === "join").map((m) => m.room_id);
550
+ const newlyShared = /* @__PURE__ */ new Set();
551
+ if (since !== null && wantPresence) {
552
+ const myRooms = new Set(joinedRooms);
553
+ for (const c of await memberChanges()) {
554
+ if (c.membership !== "join" || !myRooms.has(c.room_id)) continue;
555
+ if (c.user_id === userId) for (const u of await hs.rooms.joinedMembers(c.room_id)) newlyShared.add(u);
556
+ else newlyShared.add(c.user_id);
557
+ }
558
+ }
559
+ const sharingList = [...sharing];
560
+ const [presenceRows = [], newlySharedPresence = [], deviceRows] = await readBatch(hs.db, [
561
+ {
562
+ model: "presence",
563
+ opts: { where: [{
564
+ field: "user_id",
565
+ operator: "in",
566
+ value: wantPresence ? sharingList : []
567
+ }, ...since !== null ? [{
387
568
  field: "stream",
388
569
  operator: "gt",
389
570
  value: since
390
- }] });
391
- const myRooms = new Set(memberships.filter((m) => m.membership === "join").map((m) => m.room_id));
392
- for (const c of changes) {
393
- if (c.membership !== "join" || !myRooms.has(c.room_id)) continue;
394
- if (c.user_id === userId) for (const u of await hs.rooms.joinedMembers(c.room_id)) newlyShared.add(u);
395
- else newlyShared.add(c.user_id);
396
- }
397
- }
398
- const where = [{
399
- field: "user_id",
400
- operator: "in",
401
- value: [...sharing]
402
- }];
403
- for (const row of await hs.db.findMany("presence", { where })) {
571
+ }] : []] }
572
+ },
573
+ {
574
+ model: "presence",
575
+ opts: { where: [{
576
+ field: "user_id",
577
+ operator: "in",
578
+ value: [...newlyShared]
579
+ }] }
580
+ },
581
+ ...since !== null ? [{
582
+ model: "device_list_changes",
583
+ opts: deviceListChangesQuery(sharing, since)
584
+ }] : []
585
+ ]);
586
+ if (wantPresence) {
587
+ const seen = /* @__PURE__ */ new Set();
588
+ for (const row of [...presenceRows, ...newlySharedPresence]) {
589
+ if (seen.has(row.user_id)) continue;
590
+ seen.add(row.user_id);
404
591
  if (row.stream > p.to) continue;
405
- if (since !== null && row.stream <= since && !newlyShared.has(row.user_id)) continue;
406
592
  const content = {
407
593
  presence: row.presence,
408
594
  last_active_ago: Date.now() - row.last_active_ts
@@ -418,7 +604,7 @@ async function buildSync(hs, p) {
418
604
  }
419
605
  }
420
606
  if (presenceEvents.length) empty = false;
421
- const profileUpdates = await profileUpdatesFor(hs, p, memberships);
607
+ const profileUpdates = await profileUpdatesFor(hs, p, memberships, memberChanges);
422
608
  if (profileUpdates) empty = false;
423
609
  const body = {
424
610
  next_batch: formatToken({
@@ -438,12 +624,16 @@ async function buildSync(hs, p) {
438
624
  type: r.type,
439
625
  content: r.content
440
626
  })) },
441
- device_one_time_keys_count: await hs.keys.oneTimeKeyCounts(userId, p.session.deviceId),
442
- device_unused_fallback_key_types: await hs.keys.unusedFallbackKeyTypes(userId, p.session.deviceId)
627
+ device_one_time_keys_count: countAlgorithms(oneTimeKeyRows),
628
+ device_unused_fallback_key_types: [...new Set(fallbackRows.map((r) => r.algorithm))]
443
629
  };
444
630
  if (profileUpdates) body[UNSTABLE.ORG.MATRIX.MSC4429.USERS] = profileUpdates;
445
631
  if (since !== null) {
446
- const lists = await hs.keys.changesSince(userId, since, p.to);
632
+ const lists = await hs.keys.changesSince(userId, since, p.to, {
633
+ memberChanges: await memberChanges(),
634
+ deviceRows,
635
+ joinedRooms
636
+ });
447
637
  body.device_lists = lists;
448
638
  if (lists.changed.length || lists.left.length) empty = false;
449
639
  }
@@ -453,6 +643,12 @@ async function buildSync(hs, p) {
453
643
  nextToDevice
454
644
  };
455
645
  }
646
+ const slots = /* @__PURE__ */ new WeakMap();
647
+ function syncSlots(hs) {
648
+ let s = slots.get(hs);
649
+ if (!s) slots.set(hs, s = new Semaphore(hs.config.maxConcurrentSyncs));
650
+ return s;
651
+ }
456
652
  async function sync(ctx) {
457
653
  const hs = ctx.hs;
458
654
  const session = await ctx.auth();
@@ -465,33 +661,21 @@ async function sync(ctx) {
465
661
  const deadline = Date.now() + timeout;
466
662
  for (;;) {
467
663
  const to = await hs.streams.position();
468
- const ignoredRow = await hs.db.findOne("account_data", [
469
- {
470
- field: "user_id",
471
- value: session.userId
472
- },
473
- {
474
- field: "room_id",
475
- value: ""
476
- },
477
- {
478
- field: "type",
479
- value: M.IGNORED_USER_LIST
480
- }
481
- ]);
482
- const ignored = new Set(Object.keys(ignoredRow?.content?.ignored_users ?? {}));
664
+ const ignored = /* @__PURE__ */ new Set();
483
665
  const stable = ctx.query.get("use_state_after") === "true";
484
666
  const unstable = ctx.query.get(UNSTABLE.ORG.MATRIX.MSC4222.USE_STATE_AFTER) === "true";
485
- const { body, empty } = await buildSync(hs, {
667
+ const useStateAfter = stable || unstable;
668
+ const stateAfterKey = stable ? "state_after" : UNSTABLE.ORG.MATRIX.MSC4222.STATE_AFTER;
669
+ const { body, empty } = await syncSlots(hs).run(() => buildSync(hs, {
486
670
  session,
487
671
  ignored,
488
672
  since,
489
673
  to,
490
674
  filter,
491
675
  fullState,
492
- useStateAfter: stable || unstable,
493
- stateAfterKey: stable ? "state_after" : UNSTABLE.ORG.MATRIX.MSC4222.STATE_AFTER
494
- });
676
+ useStateAfter,
677
+ stateAfterKey
678
+ }));
495
679
  const remaining = deadline - Date.now();
496
680
  if (!since || !empty || remaining <= 0 || fullState) return body;
497
681
  if (await hs.streams.position() > to) continue;
@@ -499,6 +683,7 @@ async function sync(ctx) {
499
683
  if (ctx.request.signal?.aborted) return body;
500
684
  }
501
685
  }
686
+ const LAST_ACTIVE_GRANULARITY_MS = 6e4;
502
687
  async function touchPresence(hs, userId, presence) {
503
688
  const state = presence === "unavailable" ? "unavailable" : "online";
504
689
  const row = await hs.db.findOne("presence", [{
@@ -506,7 +691,7 @@ async function touchPresence(hs, userId, presence) {
506
691
  value: userId
507
692
  }]);
508
693
  if (row && row.presence === state) {
509
- await hs.db.update("presence", [{
694
+ if (Date.now() - row.last_active_ts >= LAST_ACTIVE_GRANULARITY_MS) await hs.db.update("presence", [{
510
695
  field: "user_id",
511
696
  value: userId
512
697
  }], { last_active_ts: Date.now() });
@@ -564,7 +749,7 @@ function registerSyncRoutes(r) {
564
749
  * MSC4429: profile changes of users sharing a room, limited to the fields the filter asks for.
565
750
  * The initial sync carries current values; users we no longer share any room with get `null`.
566
751
  */
567
- async function profileUpdatesFor(hs, p, memberships) {
752
+ async function profileUpdatesFor(hs, p, memberships, memberChanges) {
568
753
  const ids = (p.filter.profile_fields ?? p.filter[UNSTABLE.ORG.MATRIX.MSC4429.PROFILE_FIELDS])?.ids;
569
754
  if (!Array.isArray(ids) || !ids.length) return null;
570
755
  const wanted = new Set(ids.filter((x) => typeof x === "string"));
@@ -577,11 +762,7 @@ async function profileUpdatesFor(hs, p, memberships) {
577
762
  const full = new Set(since === null ? sharing : []);
578
763
  if (since !== null) {
579
764
  const myRooms = new Set(memberships.filter((m) => m.membership === "join").map((m) => m.room_id));
580
- const changes = await hs.db.findMany("memberships", { where: [{
581
- field: "stream",
582
- operator: "gt",
583
- value: since
584
- }] });
765
+ const changes = await memberChanges();
585
766
  for (const c of changes) {
586
767
  if (c.stream > p.to || c.user_id === p.session.userId) continue;
587
768
  if (c.membership === "join" && myRooms.has(c.room_id)) {
@@ -1,8 +1,8 @@
1
1
  //#region src/core/cluster.d.ts
2
2
  /**
3
3
  * Coordination between homeserver instances sharing one database. The default
4
- * (`localCluster()`) keeps everything in process; use `postgresCluster()` or
5
- * `redisCluster()` to run several instances behind a load balancer.
4
+ * (`localCluster()`) keeps everything in process; use `postgresCluster()` to run several
5
+ * instances behind a load balancer.
6
6
  */
7
7
  export interface ClusterAdapter {
8
8
  /** Allocate the next value of a cluster-wide, strictly increasing sequence. */
@@ -44,13 +44,20 @@ export declare class StreamManager {
44
44
  private cluster;
45
45
  private instanceId;
46
46
  private inflight;
47
- private published;
48
- private transition;
47
+ /** Allocations waiting for a publish that covers them. */
48
+ private reserving;
49
+ /** Allocations whose publish is done, asking the sequence for their position now. */
50
+ private allocating;
51
+ /** The position of the last published busy state: below every position still being allocated. */
52
+ private lastPos;
49
53
  private heartbeat;
50
54
  private positionCache;
55
+ private publishing;
56
+ private nextPublish;
51
57
  constructor(cluster: ClusterAdapter, instanceId: string);
52
58
  private key;
53
- private publishState;
59
+ /** Publish this writer's state as it is when the publish starts; concurrent callers share it. */
60
+ private flush;
54
61
  /** Allocate a position. The caller must call `done()` once its writes are committed. */
55
62
  allocate(): Promise<StreamLease>;
56
63
  /** Allocate a position, run `fn` with it, then complete it. */
@@ -91,38 +91,64 @@ var StreamManager = class {
91
91
  cluster;
92
92
  instanceId;
93
93
  inflight = /* @__PURE__ */ new Set();
94
- published = {
95
- busy: false,
96
- pos: 0
97
- };
98
- transition = Promise.resolve();
94
+ /** Allocations waiting for a publish that covers them. */
95
+ reserving = 0;
96
+ /** Allocations whose publish is done, asking the sequence for their position now. */
97
+ allocating = 0;
98
+ /** The position of the last published busy state: below every position still being allocated. */
99
+ lastPos = 0;
99
100
  heartbeat;
100
101
  positionCache = null;
102
+ publishing = Promise.resolve();
103
+ nextPublish = null;
101
104
  constructor(cluster, instanceId) {
102
105
  this.cluster = cluster;
103
106
  this.instanceId = instanceId;
104
- this.heartbeat = setInterval(() => void this.publishState().catch(() => {}), WRITER_TTL_MS / 3);
107
+ this.heartbeat = setInterval(() => void this.flush().catch(() => {}), WRITER_TTL_MS / 3);
105
108
  this.heartbeat.unref?.();
106
109
  }
107
110
  key() {
108
111
  return `writer:${this.instanceId}`;
109
112
  }
110
- async publishState() {
111
- await this.cluster.set(this.key(), this.published, WRITER_TTL_MS);
113
+ /** Publish this writer's state as it is when the publish starts; concurrent callers share it. */
114
+ flush() {
115
+ if (this.nextPublish) return this.nextPublish;
116
+ const run = async () => {
117
+ this.nextPublish = null;
118
+ const covered = this.reserving;
119
+ const earlier = this.allocating;
120
+ this.reserving = 0;
121
+ this.allocating += covered;
122
+ const bounds = [];
123
+ if (this.inflight.size) bounds.push(Math.min(...this.inflight) - 1);
124
+ if (earlier) bounds.push(this.lastPos);
125
+ if (covered) bounds.push(await this.cluster.currentId("stream"));
126
+ const state = bounds.length ? {
127
+ busy: true,
128
+ pos: Math.min(...bounds)
129
+ } : {
130
+ busy: false,
131
+ pos: 0
132
+ };
133
+ if (state.busy) this.lastPos = state.pos;
134
+ await this.cluster.set(this.key(), state, WRITER_TTL_MS);
135
+ };
136
+ const p = this.publishing.then(run, run);
137
+ this.nextPublish = p;
138
+ this.publishing = p.catch(() => {});
139
+ return p;
112
140
  }
113
141
  /** Allocate a position. The caller must call `done()` once its writes are committed. */
114
142
  async allocate() {
115
- await (this.transition = this.transition.then(async () => {
116
- if (!this.published.busy) {
117
- this.published = {
118
- busy: true,
119
- pos: await this.cluster.currentId("stream")
120
- };
121
- await this.publishState();
122
- }
123
- }));
124
- const id = await this.cluster.nextId("stream");
125
- this.inflight.add(id);
143
+ this.reserving++;
144
+ let id;
145
+ try {
146
+ await this.flush();
147
+ id = await this.cluster.nextId("stream");
148
+ this.inflight.add(id);
149
+ } finally {
150
+ this.allocating--;
151
+ }
126
152
  let finished = false;
127
153
  const timer = setTimeout(() => void lease.done(), 6e4);
128
154
  timer.unref?.();
@@ -133,16 +159,7 @@ var StreamManager = class {
133
159
  finished = true;
134
160
  clearTimeout(timer);
135
161
  this.inflight.delete(id);
136
- await (this.transition = this.transition.then(async () => {
137
- this.published = this.inflight.size ? {
138
- busy: true,
139
- pos: Math.min(...this.inflight) - 1
140
- } : {
141
- busy: false,
142
- pos: await this.cluster.currentId("stream")
143
- };
144
- await this.publishState();
145
- }));
162
+ await this.flush();
146
163
  this.positionCache = null;
147
164
  }
148
165
  };
@@ -0,0 +1,13 @@
1
+ //#region src/core/html.ts
2
+ /** Escape text for use in HTML content and quoted attributes. */
3
+ function escapeHtml(s) {
4
+ return s.replace(/[&<>"']/g, (c) => ({
5
+ "&": "&amp;",
6
+ "<": "&lt;",
7
+ ">": "&gt;",
8
+ "\"": "&quot;",
9
+ "'": "&#39;"
10
+ })[c]);
11
+ }
12
+ //#endregion
13
+ export { escapeHtml };
@@ -18,7 +18,7 @@ var IdentityServers = class {
18
18
  if (opts.signed) headers.authorization = this.hs.federation.client.authHeader(idServer, method, path, opts.body);
19
19
  let res;
20
20
  try {
21
- res = await this.hs.fetch(`${this.base(idServer)}${path}`, {
21
+ res = await this.hs.publicFetch(`${this.base(idServer)}${path}`, {
22
22
  method,
23
23
  headers,
24
24
  body: opts.body === void 0 ? void 0 : JSON.stringify(opts.body),