diff --git a/package-lock.json b/package-lock.json index 4e2dc88aa5..c721f24c16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,12 @@ "": { "name": "misskey-js", "version": "0.0.0", + "dependencies": { + "@vue/reactivity": "^3.0.11", + "autobind-decorator": "^2.4.0", + "eventemitter3": "^4.0.7", + "reconnecting-websocket": "^4.4.0" + }, "devDependencies": { "@types/mocha": "8.2.x", "@types/node": "14.14.x", @@ -199,6 +205,19 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "node_modules/@vue/reactivity": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.0.11.tgz", + "integrity": "sha512-SKM3YKxtXHBPMf7yufXeBhCZ4XZDKP9/iXeQSC8bBO3ivBuzAi4aZi0bNoeE2IF2iGfP/AHEt1OU4ARj4ao/Xw==", + "dependencies": { + "@vue/shared": "3.0.11" + } + }, + "node_modules/@vue/shared": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.0.11.tgz", + "integrity": "sha512-b+zB8A2so8eCE0JsxjL24J7vdGl8rzPQ09hZNhystm+KqSbKcAej1A+Hbva1rCMmTTqA+hFnUSDc5kouEo0JzA==" + }, "node_modules/ansi-align": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", @@ -349,6 +368,15 @@ "node": ">=0.10.0" } }, + "node_modules/autobind-decorator": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/autobind-decorator/-/autobind-decorator-2.4.0.tgz", + "integrity": "sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw==", + "engines": { + "node": ">=8.10", + "npm": ">=6.4.1" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -866,6 +894,11 @@ "integrity": "sha512-Wnn0ETzE2v2UT0OdRCcdMNPkQtbzyZr3pPPXnkreP0l6ZJaKqnl88dL1DqZ6nCCZZwDGBAnN0Y+nCvGxxLPQLQ==", "dev": true }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/fast-glob": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", @@ -2082,6 +2115,11 @@ "node": ">=8.10.0" } }, + "node_modules/reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3006,6 +3044,19 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "@vue/reactivity": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.0.11.tgz", + "integrity": "sha512-SKM3YKxtXHBPMf7yufXeBhCZ4XZDKP9/iXeQSC8bBO3ivBuzAi4aZi0bNoeE2IF2iGfP/AHEt1OU4ARj4ao/Xw==", + "requires": { + "@vue/shared": "3.0.11" + } + }, + "@vue/shared": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.0.11.tgz", + "integrity": "sha512-b+zB8A2so8eCE0JsxjL24J7vdGl8rzPQ09hZNhystm+KqSbKcAej1A+Hbva1rCMmTTqA+hFnUSDc5kouEo0JzA==" + }, "ansi-align": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", @@ -3119,6 +3170,11 @@ "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", "dev": true }, + "autobind-decorator": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/autobind-decorator/-/autobind-decorator-2.4.0.tgz", + "integrity": "sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw==" + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3525,6 +3581,11 @@ "integrity": "sha512-Wnn0ETzE2v2UT0OdRCcdMNPkQtbzyZr3pPPXnkreP0l6ZJaKqnl88dL1DqZ6nCCZZwDGBAnN0Y+nCvGxxLPQLQ==", "dev": true }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "fast-glob": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", @@ -4424,6 +4485,11 @@ "picomatch": "^2.2.1" } }, + "reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==" + }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index b7be912af3..d982700716 100644 --- a/package.json +++ b/package.json @@ -24,5 +24,11 @@ }, "files": [ "built" - ] + ], + "dependencies": { + "@vue/reactivity": "^3.0.11", + "autobind-decorator": "^2.4.0", + "eventemitter3": "^4.0.7", + "reconnecting-websocket": "^4.4.0" + } } diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000000..b24d090c77 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,42 @@ +import { Endpoints } from './endpoints'; + +export class MisskeyClient { + public i: { token: string; } | null = null; + private apiUrl: string; + + constructor(opts: { + apiUrl: MisskeyClient['apiUrl']; + }) { + this.apiUrl = opts.apiUrl; + } + + public api<E extends keyof Endpoints>( + endpoint: E, data: Endpoints[E]['req'] = {}, token?: string | null | undefined + ): Promise<Endpoints[E]['res']> { + const promise = new Promise<Endpoints[E]['res']>((resolve, reject) => { + // Append a credential + if (this.i) (data as Record<string, any>).i = this.i.token; + if (token !== undefined) (data as Record<string, any>).i = token; + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${this.apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(null); + } else { + reject(body.error); + } + }).catch(reject); + }); + + return promise; + } +} diff --git a/src/endpoints.ts b/src/endpoints.ts new file mode 100644 index 0000000000..f1f141e4df --- /dev/null +++ b/src/endpoints.ts @@ -0,0 +1,8 @@ +import { Instance, User } from './types'; + +type TODO = Record<string, any>; + +export type Endpoints = { + 'i': { req: TODO; res: User; }; + 'meta': { req: { detail?: boolean; }; res: Instance; }; +}; diff --git a/src/streaming.ts b/src/streaming.ts new file mode 100644 index 0000000000..be228ac386 --- /dev/null +++ b/src/streaming.ts @@ -0,0 +1,322 @@ +import autobind from 'autobind-decorator'; +import { EventEmitter } from 'eventemitter3'; +import ReconnectingWebsocket from 'reconnecting-websocket'; +import { stringify } from 'querystring'; +import { markRaw } from '@vue/reactivity'; + +function urlQuery(obj: {}): string { + return stringify(Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + .reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>)); +} + +/** + * Misskey stream connection + */ +export default class Stream extends EventEmitter { + private stream: ReconnectingWebsocket; + public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing'; + private sharedConnectionPools: Pool[] = []; + private sharedConnections: SharedConnection[] = []; + private nonSharedConnections: NonSharedConnection[] = []; + + constructor(wsUrl: string, user: { token: string; } | null, options?: { + }) { + super(); + + const query = urlQuery({ + i: user?.token, + _t: Date.now(), + }); + + this.stream = new ReconnectingWebsocket(`${wsUrl}?${query}`, '', { minReconnectionDelay: 1 }); // https://github.com/pladaria/reconnecting-websocket/issues/91 + this.stream.addEventListener('open', this.onOpen); + this.stream.addEventListener('close', this.onClose); + this.stream.addEventListener('message', this.onMessage); + } + + @autobind + public useSharedConnection(channel: string, name?: string): SharedConnection { + let pool = this.sharedConnectionPools.find(p => p.channel === channel); + + if (pool == null) { + pool = new Pool(this, channel); + this.sharedConnectionPools.push(pool); + } + + const connection = markRaw(new SharedConnection(this, channel, pool, name)); + this.sharedConnections.push(connection); + return connection; + } + + @autobind + public removeSharedConnection(connection: SharedConnection) { + this.sharedConnections = this.sharedConnections.filter(c => c !== connection); + } + + @autobind + public removeSharedConnectionPool(pool: Pool) { + this.sharedConnectionPools = this.sharedConnectionPools.filter(p => p !== pool); + } + + @autobind + public connectToChannel(channel: string, params?: any): NonSharedConnection { + const connection = markRaw(new NonSharedConnection(this, channel, params)); + this.nonSharedConnections.push(connection); + return connection; + } + + @autobind + public disconnectToChannel(connection: NonSharedConnection) { + this.nonSharedConnections = this.nonSharedConnections.filter(c => c !== connection); + } + + /** + * Callback of when open connection + */ + @autobind + private onOpen() { + const isReconnect = this.state === 'reconnecting'; + + this.state = 'connected'; + this.emit('_connected_'); + + // チャンネル再接続 + if (isReconnect) { + for (const p of this.sharedConnectionPools) + p.connect(); + for (const c of this.nonSharedConnections) + c.connect(); + } + } + + /** + * Callback of when close connection + */ + @autobind + private onClose() { + if (this.state === 'connected') { + this.state = 'reconnecting'; + this.emit('_disconnected_'); + } + } + + /** + * Callback of when received a message from connection + */ + @autobind + private onMessage(message: { data: string; }) { + const { type, body } = JSON.parse(message.data); + + if (type === 'channel') { + const id = body.id; + + let connections: Connection[]; + + connections = this.sharedConnections.filter(c => c.id === id); + + if (connections.length === 0) { + const found = this.nonSharedConnections.find(c => c.id === id); + if (found) { + connections = [found]; + } + } + + for (const c of connections.filter(c => c != null)) { + c.emit(body.type, Object.freeze(body.body)); + c.inCount++; + } + } else { + this.emit(type, Object.freeze(body)); + } + } + + /** + * Send a message to connection + */ + @autobind + public send(typeOrPayload: any, payload?: any) { + const data = payload === undefined ? typeOrPayload : { + type: typeOrPayload, + body: payload + }; + + this.stream.send(JSON.stringify(data)); + } + + /** + * Close this connection + */ + @autobind + public close() { + this.stream.removeEventListener('open', this.onOpen); + this.stream.removeEventListener('message', this.onMessage); + } +} + +let idCounter = 0; + +class Pool { + public channel: string; + public id: string; + protected stream: Stream; + public users = 0; + private disposeTimerId: any; + private isConnected = false; + + constructor(stream: Stream, channel: string) { + this.channel = channel; + this.stream = stream; + + this.id = (++idCounter).toString(); + + this.stream.on('_disconnected_', this.onStreamDisconnected); + } + + @autobind + private onStreamDisconnected() { + this.isConnected = false; + } + + @autobind + public inc() { + if (this.users === 0 && !this.isConnected) { + this.connect(); + } + + this.users++; + + // タイマー解除 + if (this.disposeTimerId) { + clearTimeout(this.disposeTimerId); + this.disposeTimerId = null; + } + } + + @autobind + public dec() { + this.users--; + + // そのコネクションの利用者が誰もいなくなったら + if (this.users === 0) { + // また直ぐに再利用される可能性があるので、一定時間待ち、 + // 新たな利用者が現れなければコネクションを切断する + this.disposeTimerId = setTimeout(() => { + this.disconnect(); + }, 3000); + } + } + + @autobind + public connect() { + if (this.isConnected) return; + this.isConnected = true; + this.stream.send('connect', { + channel: this.channel, + id: this.id + }); + } + + @autobind + private disconnect() { + this.stream.off('_disconnected_', this.onStreamDisconnected); + this.stream.send('disconnect', { id: this.id }); + this.stream.removeSharedConnectionPool(this); + } +} + +abstract class Connection extends EventEmitter { + public channel: string; + protected stream: Stream; + public abstract id: string; + + public name?: string; // for debug + public inCount: number = 0; // for debug + public outCount: number = 0; // for debug + + constructor(stream: Stream, channel: string, name?: string) { + super(); + + this.stream = stream; + this.channel = channel; + this.name = name; + } + + @autobind + public send(id: string, typeOrPayload: any, payload?: any) { + const type = payload === undefined ? typeOrPayload.type : typeOrPayload; + const body = payload === undefined ? typeOrPayload.body : payload; + + this.stream.send('ch', { + id: id, + type: type, + body: body + }); + + this.outCount++; + } + + public abstract dispose(): void; +} + +class SharedConnection extends Connection { + private pool: Pool; + + public get id(): string { + return this.pool.id; + } + + constructor(stream: Stream, channel: string, pool: Pool, name?: string) { + super(stream, channel, name); + + this.pool = pool; + this.pool.inc(); + } + + @autobind + public send(typeOrPayload: any, payload?: any) { + super.send(this.pool.id, typeOrPayload, payload); + } + + @autobind + public dispose() { + this.pool.dec(); + this.removeAllListeners(); + this.stream.removeSharedConnection(this); + } +} + +class NonSharedConnection extends Connection { + public id: string; + protected params: any; + + constructor(stream: Stream, channel: string, params?: any) { + super(stream, channel); + + this.params = params; + this.id = (++idCounter).toString(); + + this.connect(); + } + + @autobind + public connect() { + this.stream.send('connect', { + channel: this.channel, + id: this.id, + params: this.params + }); + } + + @autobind + public send(typeOrPayload: any, payload?: any) { + super.send(this.id, typeOrPayload, payload); + } + + @autobind + public dispose() { + this.removeAllListeners(); + this.stream.send('disconnect', { id: this.id }); + this.stream.disconnectToChannel(this); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000000..7dd671d315 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,73 @@ +type ID = string; + +export type User = { + id: ID; + username: string; + host: string | null; + name: string; + onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; + avatarUrl: string; + avatarBlurhash: string; + emojis: { + name: string; + url: string; + }[]; +}; + +export type DriveFile = { + id: ID; + createdAt: string; + isSensitive: boolean; + name: string; + thumbnailUrl: string; + url: string; + type: string; + size: number; + md5: string; + blurhash: string; + properties: Record<string, any>; +}; + +export type Note = { + id: ID; + createdAt: string; + text: string | null; + cw: string | null; + user: User; + userId: User['id']; + reply?: Note; + replyId: Note['id']; + renote?: Note; + renoteId: Note['id']; + files: DriveFile[]; + fileIds: DriveFile['id'][]; + visibility: 'public' | 'home' | 'followers' | 'specified'; + myReaction?: string; + reactions: Record<string, number>; + poll?: { + expiresAt: string | null; + multiple: boolean; + choices: { + isVoted: boolean; + text: string; + votes: number; + }[]; + }; + emojis: { + name: string; + url: string; + }[]; +}; + +export type Instance = { + emojis: { + category: string; + }[]; + ads: { + id: ID; + ratio: number; + place: string; + url: string; + imageUrl: string; + }[]; +}; diff --git a/tsconfig.json b/tsconfig.json index e9859ec768..f882a54c73 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,8 @@ "removeComments": true, "strict": true, "strictFunctionTypes": true, + "strictNullChecks": true, + "experimentalDecorators": true, "noImplicitReturns": true, "esModuleInterop": true, },