diff options
Diffstat (limited to 'src/misc')
| -rw-r--r-- | src/misc/before-shutdown.ts | 90 | ||||
| -rw-r--r-- | src/misc/cache.ts | 43 | ||||
| -rw-r--r-- | src/misc/fetch-meta.ts | 2 | ||||
| -rw-r--r-- | src/misc/keypair-store.ts | 10 | ||||
| -rw-r--r-- | src/misc/populate-emojis.ts | 119 |
5 files changed, 263 insertions, 1 deletions
diff --git a/src/misc/before-shutdown.ts b/src/misc/before-shutdown.ts new file mode 100644 index 0000000000..8639d42b04 --- /dev/null +++ b/src/misc/before-shutdown.ts @@ -0,0 +1,90 @@ +// https://gist.github.com/nfantone/1eaa803772025df69d07f4dbf5df7e58 + +'use strict'; + +/** + * @callback BeforeShutdownListener + * @param {string} [signalOrEvent] The exit signal or event name received on the process. + */ + +/** + * System signals the app will listen to initiate shutdown. + * @const {string[]} + */ +const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM']; + +/** + * Time in milliseconds to wait before forcing shutdown. + * @const {number} + */ +const SHUTDOWN_TIMEOUT = 15000; + +/** + * A queue of listener callbacks to execute before shutting + * down the process. + * @type {BeforeShutdownListener[]} + */ +const shutdownListeners = []; + +/** + * Listen for signals and execute given `fn` function once. + * @param {string[]} signals System signals to listen to. + * @param {function(string)} fn Function to execute on shutdown. + */ +const processOnce = (signals, fn) => { + for (const sig of signals) { + process.once(sig, fn); + } +}; + +/** + * Sets a forced shutdown mechanism that will exit the process after `timeout` milliseconds. + * @param {number} timeout Time to wait before forcing shutdown (milliseconds) + */ +const forceExitAfter = timeout => () => { + setTimeout(() => { + // Force shutdown after timeout + console.warn(`Could not close resources gracefully after ${timeout}ms: forcing shutdown`); + return process.exit(1); + }, timeout).unref(); +}; + +/** + * Main process shutdown handler. Will invoke every previously registered async shutdown listener + * in the queue and exit with a code of `0`. Any `Promise` rejections from any listener will + * be logged out as a warning, but won't prevent other callbacks from executing. + * @param {string} signalOrEvent The exit signal or event name received on the process. + */ +async function shutdownHandler(signalOrEvent) { + console.warn(`Shutting down: received [${signalOrEvent}] signal`); + + for (const listener of shutdownListeners) { + try { + await listener(signalOrEvent); + } catch (err) { + console.warn(`A shutdown handler failed before completing with: ${err.message || err}`); + } + } + + return process.exit(0); +} + +/** + * Registers a new shutdown listener to be invoked before exiting + * the main process. Listener handlers are guaranteed to be called in the order + * they were registered. + * @param {BeforeShutdownListener} listener The shutdown listener to register. + * @returns {BeforeShutdownListener} Echoes back the supplied `listener`. + */ +export function beforeShutdown(listener) { + shutdownListeners.push(listener); + return listener; +} + +// Register shutdown callback that kills the process after `SHUTDOWN_TIMEOUT` milliseconds +// This prevents custom shutdown handlers from hanging the process indefinitely +processOnce(SHUTDOWN_SIGNALS, forceExitAfter(SHUTDOWN_TIMEOUT)); + +// Register process shutdown callback +// Will listen to incoming signal events and execute all registered handlers in the stack +processOnce(SHUTDOWN_SIGNALS, shutdownHandler); diff --git a/src/misc/cache.ts b/src/misc/cache.ts new file mode 100644 index 0000000000..71fbbd8a4c --- /dev/null +++ b/src/misc/cache.ts @@ -0,0 +1,43 @@ +export class Cache<T> { + private cache: Map<string | null, { date: number; value: T; }>; + private lifetime: number; + + constructor(lifetime: Cache<never>['lifetime']) { + this.cache = new Map(); + this.lifetime = lifetime; + } + + public set(key: string | null, value: T): void { + this.cache.set(key, { + date: Date.now(), + value + }); + } + + public get(key: string | null): T | undefined { + const cached = this.cache.get(key); + if (cached == null) return undefined; + if ((Date.now() - cached.date) > this.lifetime) { + this.cache.delete(key); + return undefined; + } + return cached.value; + } + + public delete(key: string | null) { + this.cache.delete(key); + } + + public async fetch(key: string | null, fetcher: () => Promise<T>): Promise<T> { + const cachedValue = this.get(key); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await fetcher(); + this.set(key, value); + return value; + } +} diff --git a/src/misc/fetch-meta.ts b/src/misc/fetch-meta.ts index 680cf37a72..e7a945dc9e 100644 --- a/src/misc/fetch-meta.ts +++ b/src/misc/fetch-meta.ts @@ -32,4 +32,4 @@ setInterval(() => { fetchMeta(true).then(meta => { cache = meta; }); -}, 5000); +}, 1000 * 10); diff --git a/src/misc/keypair-store.ts b/src/misc/keypair-store.ts new file mode 100644 index 0000000000..c78fdd7555 --- /dev/null +++ b/src/misc/keypair-store.ts @@ -0,0 +1,10 @@ +import { UserKeypairs } from '../models'; +import { User } from '../models/entities/user'; +import { UserKeypair } from '../models/entities/user-keypair'; +import { Cache } from './cache'; + +const cache = new Cache<UserKeypair>(Infinity); + +export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> { + return await cache.fetch(userId, () => UserKeypairs.findOneOrFail(userId)); +} diff --git a/src/misc/populate-emojis.ts b/src/misc/populate-emojis.ts new file mode 100644 index 0000000000..8052c71489 --- /dev/null +++ b/src/misc/populate-emojis.ts @@ -0,0 +1,119 @@ +import { In } from 'typeorm'; +import { Emojis } from '../models'; +import { Emoji } from '../models/entities/emoji'; +import { Note } from '../models/entities/note'; +import { Cache } from './cache'; +import { isSelfHost, toPunyNullable } from './convert-host'; +import { decodeReaction } from './reaction-lib'; + +const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12); + +/** + * 添付用絵文字情報 + */ +type PopulatedEmoji = { + name: string; + url: string; +}; + +function normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { + // クエリに使うホスト + let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : isSelfHost(src) ? null // 自ホスト指定 + : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = toPunyNullable(host); + + return host; +} + +function parseEmojiStr(emojiName: string, noteUserHost: string | null) { + const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + if (!match) return { name: null, host: null }; + + const name = match[1]; + + // ホスト正規化 + const host = toPunyNullable(normalizeHost(match[2], noteUserHost)); + + return { name, host }; +} + +/** + * 添付用絵文字情報を解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns 絵文字情報, nullは未マッチを意味する + */ +export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> { + const { name, host } = parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + + const queryOrNull = async () => (await Emojis.findOne({ + name, + host + })) || null; + + const emoji = await cache.fetch(`${name} ${host}`, queryOrNull); + + if (emoji == null) return null; + + return { + name: emojiName, + url: emoji.url, + }; +} + +/** + * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される) + */ +export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> { + const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost))); + return emojis.filter((x): x is PopulatedEmoji => x != null); +} + +export function aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[]; +} + +/** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ +export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { + const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map(e => e.host)); + for (const host of hosts) { + emojisQuery.push({ + name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), + host: host + }); + } + const _emojis = emojisQuery.length > 0 ? await Emojis.find({ + where: emojisQuery, + select: ['name', 'host', 'url'] + }) : []; + for (const emoji of _emojis) { + cache.set(`${emoji.name} ${emoji.host}`, emoji); + } +} |