77144b058c
It's trivial to have more than 128 requests in flight: open a busy timeline, scroll a bit down, wait for many notes to arrive, scroll to the top. The frontend will send "subscribe to note" messages for each new note that it accumulated, all at once. We don't want to shut down the connection in those common cases!
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import * as WebSocket from 'ws';
|
|
import type { MiUser } from '@/models/User.js';
|
|
import type { MiAccessToken } from '@/models/AccessToken.js';
|
|
import type { Packed } from '@/misc/json-schema.js';
|
|
import type { NoteReadService } from '@/core/NoteReadService.js';
|
|
import type { NotificationService } from '@/core/NotificationService.js';
|
|
import { bindThis } from '@/decorators.js';
|
|
import { CacheService } from '@/core/CacheService.js';
|
|
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
|
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
|
|
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
|
import type { ChannelsService } from './ChannelsService.js';
|
|
import type { EventEmitter } from 'events';
|
|
import type Channel from './channel.js';
|
|
import { LoggerService } from '@/core/LoggerService.js';
|
|
import type Logger from '@/logger.js';
|
|
|
|
const MAX_CHANNELS_PER_CONNECTION = 32;
|
|
|
|
/**
|
|
* Main stream connection
|
|
*/
|
|
// eslint-disable-next-line import/no-default-export
|
|
export default class Connection {
|
|
public user?: MiUser;
|
|
public token?: MiAccessToken;
|
|
private rateLimiter?: () => Promise<boolean>;
|
|
private wsConnection: WebSocket.WebSocket;
|
|
public subscriber: StreamEventEmitter;
|
|
private channels: Channel[] = [];
|
|
private subscribingNotes: any = {};
|
|
private cachedNotes: Packed<'Note'>[] = [];
|
|
public userProfile: MiUserProfile | null = null;
|
|
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
|
public followingChannels: Set<string> = new Set();
|
|
public userIdsWhoMeMuting: Set<string> = new Set();
|
|
public userIdsWhoBlockingMe: Set<string> = new Set();
|
|
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
|
|
public userMutedInstances: Set<string> = new Set();
|
|
private fetchIntervalId: NodeJS.Timeout | null = null;
|
|
private activeRateLimitRequests: number = 0;
|
|
private closingConnection: boolean = false;
|
|
private logger: Logger;
|
|
|
|
constructor(
|
|
private channelsService: ChannelsService,
|
|
private noteReadService: NoteReadService,
|
|
private notificationService: NotificationService,
|
|
private cacheService: CacheService,
|
|
private channelFollowingService: ChannelFollowingService,
|
|
loggerService: LoggerService,
|
|
|
|
user: MiUser | null | undefined,
|
|
token: MiAccessToken | null | undefined,
|
|
private ip: string,
|
|
rateLimiter: () => Promise<boolean>,
|
|
) {
|
|
if (user) this.user = user;
|
|
if (token) this.token = token;
|
|
if (rateLimiter) this.rateLimiter = rateLimiter;
|
|
|
|
this.logger = loggerService.getLogger('streaming', 'coral', false);
|
|
}
|
|
|
|
@bindThis
|
|
public async fetch() {
|
|
if (this.user == null) return;
|
|
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([
|
|
this.cacheService.userProfileCache.fetch(this.user.id),
|
|
this.cacheService.userFollowingsCache.fetch(this.user.id),
|
|
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
|
|
this.cacheService.userMutingsCache.fetch(this.user.id),
|
|
this.cacheService.userBlockedCache.fetch(this.user.id),
|
|
this.cacheService.renoteMutingsCache.fetch(this.user.id),
|
|
]);
|
|
this.userProfile = userProfile;
|
|
this.following = following;
|
|
this.followingChannels = followingChannels;
|
|
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
|
|
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
|
|
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
|
|
this.userMutedInstances = new Set(userProfile.mutedInstances);
|
|
}
|
|
|
|
@bindThis
|
|
public async init() {
|
|
if (this.user != null) {
|
|
await this.fetch();
|
|
|
|
if (!this.fetchIntervalId) {
|
|
this.fetchIntervalId = setInterval(this.fetch, 1000 * 10);
|
|
}
|
|
}
|
|
}
|
|
|
|
@bindThis
|
|
public async listen(subscriber: EventEmitter, wsConnection: WebSocket.WebSocket) {
|
|
this.subscriber = subscriber;
|
|
|
|
this.wsConnection = wsConnection;
|
|
this.wsConnection.on('message', this.onWsConnectionMessage);
|
|
|
|
this.subscriber.on('broadcast', data => {
|
|
this.onBroadcastMessage(data);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* クライアントからメッセージ受信時
|
|
*/
|
|
@bindThis
|
|
private async onWsConnectionMessage(data: WebSocket.RawData) {
|
|
let obj: Record<string, any>;
|
|
|
|
if (this.closingConnection) return;
|
|
|
|
if (this.rateLimiter) {
|
|
// this 4096 should match the `max` of the `rateLimiter`, see
|
|
// StreamingApiServerService
|
|
if (this.activeRateLimitRequests <= 4096) {
|
|
this.activeRateLimitRequests++;
|
|
const shouldRateLimit = await this.rateLimiter();
|
|
this.activeRateLimitRequests--;
|
|
|
|
if (shouldRateLimit) return;
|
|
if (this.closingConnection) return;
|
|
} else {
|
|
let connectionInfo = `IP ${this.ip}`;
|
|
if (this.user) connectionInfo += `, user ID ${this.user.id}`;
|
|
|
|
this.logger.warn(`Closing a connection (${connectionInfo}) due to an excessive influx of messages.`);
|
|
this.closingConnection = true;
|
|
this.wsConnection.close(1008, 'Please stop spamming the streaming API.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
obj = JSON.parse(data.toString());
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
|
|
const { type, body } = obj;
|
|
|
|
switch (type) {
|
|
case 'readNotification': this.onReadNotification(body); break;
|
|
case 'subNote': this.onSubscribeNote(body); break;
|
|
case 's': this.onSubscribeNote(body); break; // alias
|
|
case 'sr': this.onSubscribeNote(body); this.readNote(body); break;
|
|
case 'unsubNote': this.onUnsubscribeNote(body); break;
|
|
case 'un': this.onUnsubscribeNote(body); break; // alias
|
|
case 'connect': this.onChannelConnectRequested(body); break;
|
|
case 'disconnect': this.onChannelDisconnectRequested(body); break;
|
|
case 'channel': this.onChannelMessageRequested(body); break;
|
|
case 'ch': this.onChannelMessageRequested(body); break; // alias
|
|
}
|
|
}
|
|
|
|
@bindThis
|
|
private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) {
|
|
this.sendMessageToWs(data.type, data.body);
|
|
}
|
|
|
|
@bindThis
|
|
public cacheNote(note: Packed<'Note'>) {
|
|
const add = (note: Packed<'Note'>) => {
|
|
const existIndex = this.cachedNotes.findIndex(n => n.id === note.id);
|
|
if (existIndex > -1) {
|
|
this.cachedNotes[existIndex] = note;
|
|
return;
|
|
}
|
|
|
|
this.cachedNotes.unshift(note);
|
|
if (this.cachedNotes.length > 32) {
|
|
this.cachedNotes.splice(32);
|
|
}
|
|
};
|
|
|
|
add(note);
|
|
if (note.reply) add(note.reply);
|
|
if (note.renote) add(note.renote);
|
|
}
|
|
|
|
@bindThis
|
|
private readNote(body: any) {
|
|
const id = body.id;
|
|
|
|
const note = this.cachedNotes.find(n => n.id === id);
|
|
if (note == null) return;
|
|
|
|
if (this.user && (note.userId !== this.user.id)) {
|
|
this.noteReadService.read(this.user.id, [note]);
|
|
}
|
|
}
|
|
|
|
@bindThis
|
|
private onReadNotification(payload: any) {
|
|
this.notificationService.readAllNotification(this.user!.id);
|
|
}
|
|
|
|
/**
|
|
* 投稿購読要求時
|
|
*/
|
|
@bindThis
|
|
private onSubscribeNote(payload: any) {
|
|
if (!payload.id) return;
|
|
|
|
if (this.subscribingNotes[payload.id] == null) {
|
|
this.subscribingNotes[payload.id] = 0;
|
|
}
|
|
|
|
this.subscribingNotes[payload.id]++;
|
|
|
|
if (this.subscribingNotes[payload.id] === 1) {
|
|
this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 投稿購読解除要求時
|
|
*/
|
|
@bindThis
|
|
private onUnsubscribeNote(payload: any) {
|
|
if (!payload.id) return;
|
|
|
|
this.subscribingNotes[payload.id]--;
|
|
if (this.subscribingNotes[payload.id] <= 0) {
|
|
delete this.subscribingNotes[payload.id];
|
|
this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage);
|
|
}
|
|
}
|
|
|
|
@bindThis
|
|
private async onNoteStreamMessage(data: GlobalEvents['note']['payload']) {
|
|
// we must not send to the frontend information about notes from
|
|
// users who blocked the logged-in user, even when they're replies
|
|
// to notes the logged-in user can see
|
|
if (data.type === 'replied') {
|
|
const noteUserId = data.body.body.userId;
|
|
if (noteUserId !== null) {
|
|
if (this.userIdsWhoBlockingMe.has(noteUserId)) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.sendMessageToWs('noteUpdated', {
|
|
id: data.body.id,
|
|
type: data.type,
|
|
body: data.body.body,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* チャンネル接続要求時
|
|
*/
|
|
@bindThis
|
|
private onChannelConnectRequested(payload: any) {
|
|
const { channel, id, params, pong } = payload;
|
|
this.connectChannel(id, params, channel, pong);
|
|
}
|
|
|
|
/**
|
|
* チャンネル切断要求時
|
|
*/
|
|
@bindThis
|
|
private onChannelDisconnectRequested(payload: any) {
|
|
const { id } = payload;
|
|
this.disconnectChannel(id);
|
|
}
|
|
|
|
/**
|
|
* クライアントにメッセージ送信
|
|
*/
|
|
@bindThis
|
|
public sendMessageToWs(type: string, payload: any) {
|
|
this.wsConnection.send(JSON.stringify({
|
|
type: type,
|
|
body: payload,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* チャンネルに接続
|
|
*/
|
|
@bindThis
|
|
public connectChannel(id: string, params: any, channel: string, pong = false) {
|
|
if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) {
|
|
return;
|
|
}
|
|
|
|
const channelService = this.channelsService.getChannelService(channel);
|
|
|
|
if (channelService.requireCredential && this.user == null) {
|
|
return;
|
|
}
|
|
|
|
if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind))
|
|
|| (!channelService.kind && channelService.requireCredential))) {
|
|
return;
|
|
}
|
|
|
|
// 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視
|
|
if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) {
|
|
return;
|
|
}
|
|
|
|
const ch: Channel = channelService.create(id, this);
|
|
this.channels.push(ch);
|
|
ch.init(params ?? {});
|
|
|
|
if (pong) {
|
|
this.sendMessageToWs('connected', {
|
|
id: id,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* チャンネルから切断
|
|
* @param id チャンネルコネクションID
|
|
*/
|
|
@bindThis
|
|
public disconnectChannel(id: string) {
|
|
const channel = this.channels.find(c => c.id === id);
|
|
|
|
if (channel) {
|
|
if (channel.dispose) channel.dispose();
|
|
this.channels = this.channels.filter(c => c.id !== id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* チャンネルへメッセージ送信要求時
|
|
* @param data メッセージ
|
|
*/
|
|
@bindThis
|
|
private onChannelMessageRequested(data: any) {
|
|
const channel = this.channels.find(c => c.id === data.id);
|
|
if (channel != null && channel.onMessage != null) {
|
|
channel.onMessage(data.type, data.body);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ストリームが切れたとき
|
|
*/
|
|
@bindThis
|
|
public dispose() {
|
|
if (this.fetchIntervalId) clearInterval(this.fetchIntervalId);
|
|
for (const c of this.channels.filter(c => c.dispose)) {
|
|
if (c.dispose) c.dispose();
|
|
}
|
|
}
|
|
}
|