Repository navigation
Send a world-readable realm's events to one shared Matrix room #6906
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8740827
035f235
851cc97
6153ab6
3cd8d6a
3c350a8
37768d3
3686324
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ import { | |
| type PublicRealmSummary, | ||
| type RealmServerTokenSource, | ||
| } from '@cardstack/runtime-common'; | ||
| import { REALMS_LIST_UPDATED_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; | ||
| import { | ||
| joinDMRoom, | ||
| RealmAuthClient, | ||
|
|
@@ -50,13 +51,17 @@ import { | |
| import { createServerRequestTimingMiddleware } from './client-telemetry'; | ||
|
|
||
| import type { ExtendedClient } from './matrix-sdk-loader'; | ||
| import type MessageService from './message-service'; | ||
| import type NetworkService from './network'; | ||
| import type RealmService from './realm'; | ||
| import type SessionService from './session'; | ||
| import type { IEvent } from 'matrix-js-sdk'; | ||
|
|
||
| const { hostsOwnAssets, resolvedBaseRealmURL } = ENV; | ||
|
|
||
| const REALM_EVENT_ROOMS_FIRST_RETRY_MS = 2_000; | ||
| const REALM_EVENT_ROOMS_MAX_RETRY_MS = 60_000; | ||
|
|
||
| export interface RealmServerTokenClaims { | ||
| user: string; | ||
| sessionRoom: string; | ||
|
|
@@ -121,6 +126,7 @@ export default class RealmServerService | |
| extends Service | ||
| implements RealmServerTokenSource | ||
| { | ||
| @service declare private messageService: MessageService; | ||
| @service declare private network: NetworkService; | ||
| @service declare private session: SessionService; | ||
| @service declare private realm: RealmService; | ||
|
|
@@ -145,11 +151,29 @@ export default class RealmServerService | |
| private _ready = new Deferred<void>(); | ||
| private eventSubscribers: Map<string, RealmServerEventSubscriber[]> = | ||
| new Map(); | ||
| // A world-readable realm sends its realm events into a room of its own, | ||
| // which the host joins when something first subscribes to the realm (see | ||
| // `joinRealmEventRoom`). The realms still to be asked about, and every realm | ||
| // asked about this session. | ||
| private realmEventRoomsPending = new Set<string>(); | ||
| private realmEventRoomsRequested = new Set<string>(); | ||
| // How long to wait before asking again about realms whose rooms could not | ||
| // be provided. Doubles on each failure up to a minute; reset on success. | ||
| private realmEventRoomsRetryMs = REALM_EVENT_ROOMS_FIRST_RETRY_MS; | ||
|
|
||
| constructor(owner: Owner) { | ||
| super(owner); | ||
| this.session.register(this); | ||
| this.fetchCatalogRealms(); | ||
| this.messageService.onFirstSubscription((realm) => | ||
| this.joinRealmEventRoom(realm), | ||
| ); | ||
| // A change to which realms the user can read can also change whether a | ||
| // realm they have open is world-readable, and so which room its events | ||
| // arrive in. Ask again about every realm that has a subscriber. | ||
| this.subscribeEvent(REALMS_LIST_UPDATED_EVENT_TYPE, async () => { | ||
| this.reaskRealmEventRooms(); | ||
| }); | ||
| } | ||
|
|
||
| get ready() { | ||
|
|
@@ -178,6 +202,13 @@ export default class RealmServerService | |
| 0, | ||
| this.unreachableRealmServersList.length, | ||
| ); | ||
| // A run in flight belongs to the session that is ending; it must not put | ||
| // that session's realms back in line for the next one. | ||
| this.joinRealmEventRoomsTask.cancelAll(); | ||
| this.retryRealmEventRoomsTask.cancelAll(); | ||
| this.realmEventRoomsPending.clear(); | ||
| this.realmEventRoomsRequested.clear(); | ||
| this.realmEventRoomsRetryMs = REALM_EVENT_ROOMS_FIRST_RETRY_MS; | ||
| // Do NOT clear eventSubscribers here: subscriber *wiring* is app-scoped. | ||
| // Services (e.g. BillingService) call subscribeEvent() once in their | ||
| // constructor, for the app's lifetime. Logout is an in-app reset, not a | ||
|
|
@@ -696,8 +727,98 @@ export default class RealmServerService | |
| for (let [realmURL, token] of Object.entries(tokens)) { | ||
| this.realm.getOrCreateRealmResource(realmURL, token); | ||
| } | ||
| // Realms subscribed to before there was a session to join with. | ||
| void this.joinRealmEventRoomsTask.perform(); | ||
| } | ||
|
|
||
| // Joins the room the given realm sends its realm events into, when it is a | ||
| // world-readable realm on this realm server; other realms' events arrive in | ||
| // the user's session room. Joining once is enough: the host stays in the | ||
| // room, and catches up on what it missed through sync. Realms asked for in | ||
| // the same turn are asked about together. | ||
| private joinRealmEventRoom(realmIdentifier: string) { | ||
| let realmURL = ensureTrailingSlash( | ||
| resolvedRealmURLHref(this.network.virtualNetwork, realmIdentifier), | ||
| ); | ||
| if (this.realmEventRoomsRequested.has(realmURL)) { | ||
| return; | ||
| } | ||
| this.realmEventRoomsRequested.add(realmURL); | ||
| this.realmEventRoomsPending.add(realmURL); | ||
| void this.joinRealmEventRoomsTask.perform(); | ||
| } | ||
|
|
||
| private reaskRealmEventRooms() { | ||
| this.realmEventRoomsRequested.clear(); | ||
| for (let realm of this.messageService.subscribedRealms()) { | ||
| this.joinRealmEventRoom(realm); | ||
| } | ||
| } | ||
|
|
||
| private joinRealmEventRoomsTask = task({ keepLatest: true }, async () => { | ||
| await rawTimeout(0); | ||
| // Without a matrix client there's no session to join with. Signing in | ||
| // runs this again (see `authenticateToAllAccessibleRealms`). | ||
| let client = this.client; | ||
| if (!client || this.realmEventRoomsPending.size === 0) { | ||
| return; | ||
| } | ||
| let realms = [...this.realmEventRoomsPending]; | ||
| this.realmEventRoomsPending.clear(); | ||
| try { | ||
| await this.login(); | ||
| // TODO(ADR 004 §1.7–1.9): every realm is asked about at the home realm | ||
| // server, which answers only for its own realms. That holds while the | ||
| // host serves realms from its home server alone (see | ||
| // `assertOwnRealmServer`). Once realms come from several servers, ask | ||
| // each server about its own realms with that server's session, and | ||
| // join the rooms through that server's homeserver. | ||
| let response = await this.network.fetch( | ||
| `${this.url.href}_realm-event-rooms`, | ||
|
Comment on lines
+776
to
+777
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a subscribed realm belongs to a trusted remote realm server, this always posts the entire batch to the host's home server using its home-server token. That server omits the unknown realm with a successful empty response, after which AGENTS.md reference: AGENTS.md:L299-L302 Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] The host still serves realms from its home realm server only: |
||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| Accept: SupportedMimeType.JSON, | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${this.token}`, | ||
| }, | ||
| body: JSON.stringify({ realms }), | ||
| }, | ||
| ); | ||
| // A 503 still carries the rooms the server could provide; the rest are | ||
| // asked about again below. | ||
| let { rooms } = ( | ||
| response.ok || response.status === 503 ? await response.json() : {} | ||
| ) as { rooms?: Record<string, string> }; | ||
| // Joining a room the user is already in changes nothing, and the SDK | ||
| // answers it locally once sync has the room. | ||
| for (let roomId of new Set(Object.values(rooms ?? {}))) { | ||
| await client.joinRoom(roomId); | ||
| } | ||
| if (!response.ok) { | ||
| throw new Error(`${response.status} from _realm-event-rooms`); | ||
| } | ||
| this.realmEventRoomsRetryMs = REALM_EVENT_ROOMS_FIRST_RETRY_MS; | ||
| } catch (e) { | ||
| // Asked about again after a pause, and again on the next subscription. | ||
| for (let realm of realms) { | ||
| this.realmEventRoomsPending.add(realm); | ||
| } | ||
| console.warn(`Unable to join the realm event rooms of ${realms}`, e); | ||
| let retryMs = this.realmEventRoomsRetryMs; | ||
| this.realmEventRoomsRetryMs = Math.min( | ||
| retryMs * 2, | ||
| REALM_EVENT_ROOMS_MAX_RETRY_MS, | ||
| ); | ||
| void this.retryRealmEventRoomsTask.perform(retryMs); | ||
| } | ||
| }); | ||
|
|
||
| private retryRealmEventRoomsTask = restartableTask(async (ms: number) => { | ||
| await rawTimeout(ms); | ||
| void this.joinRealmEventRoomsTask.perform(); | ||
| }); | ||
|
|
||
| private async ensureJoinedSessionRoom(tokens: { | ||
| [realmUrl: string]: string; | ||
| }) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖]
resetStateclears both sets, but ajoinRealmEventRoomsTaskrun that is already in flight keeps going with the old session'sclientand realm list. If that run fails after logout, itscatchputs those realms back intorealmEventRoomsPending. The next account's sign-in then runs the task (authenticateToAllAccessibleRealms) and joins rooms for realms that user never opened. Addingthis.joinRealmEventRoomsTask.cancelAll()here closes this. Non-blocking.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Fixed in 6153ab6.
resetStatecancelsjoinRealmEventRoomsTaskand the new retry task before clearing the sets, and resets the backoff.