fix: recurriing network issue
All checks were successful
Queue Release Build / prepare (push) Successful in 18s
Deploy Web Apps / deploy (push) Successful in 6m32s
Queue Release Build / build-windows (push) Successful in 26m8s
Queue Release Build / build-linux (push) Successful in 40m18s
Queue Release Build / finalize (push) Successful in 42s

This commit is contained in:
2026-04-30 04:04:34 +02:00
parent b1fe286be8
commit a49e18b9f0
16 changed files with 522 additions and 17 deletions

View File

@@ -70,7 +70,7 @@ graph TD
## Message lifecycle
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations.
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Live room chat also emits a narrow `chat_message` signaling fallback so peers can receive text while the data channel is unavailable. Editing and deletion are sender-only operations.
```mermaid
sequenceDiagram

View File

@@ -1,6 +1,6 @@
# Direct Message Domain
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel.
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
## Structure
@@ -16,9 +16,10 @@ direct-message/
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to the recipient's current peer id.
3. If the peer is connected, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
4. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
5. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
3. If no data channel is connected, `PeerDeliveryService` tries the recipient's known signaling route before leaving the message queued.
4. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.

View File

@@ -0,0 +1,131 @@
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { ChatEvent, User } from '../../../../shared-kernel';
import { PeerDeliveryService } from './peer-delivery.service';
describe('PeerDeliveryService', () => {
it('relays direct messages through signaling when no data channel is connected', () => {
const context = createServiceContext({ connectedPeers: [], routedPeers: ['bob'] });
const event: ChatEvent = {
type: 'direct-message',
directMessage: {
message: {
id: 'message-1',
conversationId: 'dm-alice-bob',
senderId: 'alice',
recipientId: 'bob',
content: 'hello',
timestamp: 1,
status: 'QUEUED'
},
sender: {
userId: 'alice',
username: 'alice',
displayName: 'Alice'
}
}
};
expect(context.service.sendViaWebRTC('bob', event)).toBe(true);
expect(context.realtime.sendToPeer).not.toHaveBeenCalled();
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith({
...event,
targetUserId: 'bob'
});
});
it('keeps messages queued when neither P2P nor signaling can reach the recipient', () => {
const context = createServiceContext({ connectedPeers: [], routedPeers: [] });
expect(context.service.sendViaWebRTC('bob', { type: 'direct-message' })).toBe(false);
expect(context.realtime.sendRawMessage).not.toHaveBeenCalled();
});
it('emits direct messages received over signaling', () => {
const context = createServiceContext({ connectedPeers: [] });
const received: ChatEvent[] = [];
context.service.directMessageEvents$.subscribe((event) => received.push(event));
context.signalingMessages.next({ type: 'direct-message' } as ChatEvent);
expect(received).toEqual([{ type: 'direct-message' }]);
});
});
interface ServiceContextOptions {
connectedPeers: string[];
routedPeers?: string[];
}
interface ServiceContext {
service: PeerDeliveryService;
signalingMessages: Subject<ChatEvent>;
realtime: {
getConnectedPeers: ReturnType<typeof vi.fn>;
hasSignalingRouteForPeer: ReturnType<typeof vi.fn>;
sendRawMessage: ReturnType<typeof vi.fn>;
sendToPeer: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const users = signal<User[]>([createUser('alice', 'Alice'), createUser('bob', 'Bob')]);
const incomingMessages = new Subject<ChatEvent>();
const signalingMessages = new Subject<ChatEvent>();
const peerConnected = new Subject<string>();
const realtime = {
onMessageReceived: incomingMessages.asObservable(),
onSignalingMessage: signalingMessages.asObservable(),
onPeerConnected: peerConnected.asObservable(),
getConnectedPeers: vi.fn(() => options.connectedPeers),
hasSignalingRouteForPeer: vi.fn((peerId: string) => (options.routedPeers ?? []).includes(peerId)),
sendRawMessage: vi.fn(),
sendToPeer: vi.fn()
};
const store = {
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectAllUsers) {
return users;
}
throw new Error('Unexpected selector requested by PeerDeliveryService test.');
})
};
const injector = Injector.create({
providers: [
{
provide: RealtimeSessionFacade,
useValue: realtime
},
{
provide: Store,
useValue: store
}
]
});
return {
service: runInInjectionContext(injector, () => new PeerDeliveryService()),
signalingMessages,
realtime
};
}
function createUser(id: string, displayName: string): User {
return {
id,
oderId: id,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}

View File

@@ -4,6 +4,7 @@ import { Store } from '@ngrx/store';
import {
Subject,
filter,
merge,
type Observable
} from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
@@ -17,7 +18,10 @@ export class PeerDeliveryService {
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly networkRestoredSubject = new Subject<void>();
readonly directMessageEvents$: Observable<ChatEvent> = this.webrtc.onMessageReceived.pipe(
readonly directMessageEvents$: Observable<ChatEvent> = merge(
this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
).pipe(
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation')
);
@@ -35,12 +39,14 @@ export class PeerDeliveryService {
const peerId = this.resolvePeerId(recipientId);
if (!peerId) {
return false;
let sent = false;
if (peerId) {
this.webrtc.sendToPeer(peerId, event);
sent = true;
}
this.webrtc.sendToPeer(peerId, event);
return true;
return this.sendViaSignaling(recipientId, event) || sent;
}
handleAck(recipientId: string, event: ChatEvent): boolean {
@@ -77,6 +83,48 @@ export class PeerDeliveryService {
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
}
private sendViaSignaling(recipientId: string, event: ChatEvent): boolean {
if (event.type !== 'direct-message' && event.type !== 'direct-message-status' && event.type !== 'direct-message-mutation') {
return false;
}
const targetPeerId = this.resolveSignalingPeerId(recipientId);
if (!targetPeerId) {
return false;
}
try {
this.webrtc.sendRawMessage({
...event,
targetUserId: targetPeerId
});
return true;
} catch {
return false;
}
}
private resolveSignalingPeerId(recipientId: string): string | null {
return this.resolveCandidateIds(recipientId).find((candidate) => this.webrtc.hasSignalingRouteForPeer(candidate)) ?? null;
}
private resolveCandidateIds(recipientId: string): string[] {
const user = this.users().find((candidate: User) =>
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId
);
return [
recipientId,
user?.oderId,
user?.peerId,
user?.id
].filter((candidate, index, candidates): candidate is string =>
!!candidate && candidates.indexOf(candidate) === index
);
}
private isOfflineOverrideEnabled(): boolean {
return typeof window !== 'undefined'
&& !!(window as Window & { metoyouDmNetworkOffline?: boolean }).metoyouDmNetworkOffline;

View File

@@ -157,6 +157,10 @@ The `/search` My Servers row and the server rail both read from the active user'
Fallback stays temporary. If the authoritative endpoint is unavailable, the client can probe other active compatible endpoints as a last resort for the current session, but it does not rewrite the room's saved affinity to that fallback endpoint.
Be careful around endpoint failure semantics. `ensureEndpointVersionCompatibility()` returns `false` for both incompatible versions and unreachable/offline endpoints. Only an endpoint with `status === 'incompatible'` should stop the fallback cascade with the update-required message. Cloudflare `521`/`522`, network timeouts, and WebSocket `1006` failures must continue to the next active compatible endpoint.
`ServerDirectoryApiService.getServer()` also returns `null` for both authoritative `SERVER_NOT_FOUND` and retryable endpoint failures. Callers that need recovery must search active endpoints before treating `null` as proof that a saved room is gone or its source is stale.
## Server-owned room metadata
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.

View File

@@ -107,6 +107,10 @@ export class ServerDirectoryApiService {
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => {
// Warning: this API deliberately returns null for both authoritative
// SERVER_NOT_FOUND and retryable endpoint failures. Callers that need
// resilience must try other active endpoints before treating null as
// proof that a room no longer exists.
if (isServerNotFoundError(error)) {
return of(null);
}