From 2dc2d2e4fee549deac1709a13a9528745510321f Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:04:01 +0900 Subject: refactor --- packages/frontend/src/lib/pizzax.ts | 304 ++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 packages/frontend/src/lib/pizzax.ts (limited to 'packages/frontend/src/lib') diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts new file mode 100644 index 0000000000..a232ced75e --- /dev/null +++ b/packages/frontend/src/lib/pizzax.ts @@ -0,0 +1,304 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// PIZZAX --- A lightweight store + +import { onUnmounted, ref, watch } from 'vue'; +import { BroadcastChannel } from 'broadcast-channel'; +import type { Ref } from 'vue'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { get, set } from '@/utility/idb-proxy.js'; +import { store } from '@/store.js'; +import { useStream } from '@/stream.js'; +import { deepClone } from '@/utility/clone.js'; +import { deepMerge } from '@/utility/merge.js'; + +type StateDef = Record; + +type State = { [K in keyof T]: T[K]['default']; }; +type ReactiveState = { [K in keyof T]: Ref; }; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +type PizzaxChannelMessage = { + where: 'device' | 'deviceAccount'; + key: keyof T; + value: T[keyof T]['default']; + userId?: string; +}; + +export class Pizzax { + public readonly ready: Promise; + public readonly loaded: Promise; + + public readonly key: string; + public readonly deviceStateKeyName: `pizzax::${this['key']}`; + public readonly deviceAccountStateKeyName: `pizzax::${this['key']}::${string}` | ''; + public readonly registryCacheKeyName: `pizzax::${this['key']}::cache::${string}` | ''; + + public readonly def: T; + + // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 + /** + * static / state の略 (static が予約語のため) + */ + public readonly s: State; + + /** + * reactive の略 + */ + public readonly r: ReactiveState; + + private pizzaxChannel: BroadcastChannel>; + + // 簡易的にキューイングして占有ロックとする + private currentIdbJob: Promise = Promise.resolve(); + private addIdbSetJob(job: () => Promise) { + const promise = this.currentIdbJob.then(job, err => { + console.error('Pizzax failed to save data to idb!', err); + return job(); + }); + this.currentIdbJob = promise; + return promise; + } + + constructor(key: string, def: T) { + this.key = key; + this.deviceStateKeyName = `pizzax::${key}`; + this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; + this.registryCacheKeyName = $i ? `pizzax::${key}::cache::${$i.id}` : ''; + this.def = def; + + this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); + + this.s = {} as State; + this.r = {} as ReactiveState; + + for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { + this.s[k] = v.default; + this.r[k] = ref(v.default); + } + + this.ready = this.init(); + this.loaded = this.ready.then(() => this.load()); + } + + private isPureObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private mergeState(value: X, def: X): X { + if (this.isPureObject(value) && this.isPureObject(def)) { + const merged = deepMerge(value, def); + + if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); + + return merged as X; + } + return value; + } + + private async init(): Promise { + await this.migrate(); + + const deviceState: State = await get(this.deviceStateKeyName) || {}; + const deviceAccountState = $i ? await get(this.deviceAccountStateKeyName) || {} : {}; + const registryCache = $i ? await get(this.registryCacheKeyName) || {} : {}; + + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { + if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { + this.r[k].value = this.s[k] = this.mergeState(deviceState[k], v.default); + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + this.r[k].value = this.s[k] = this.mergeState(registryCache[k], v.default); + } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { + this.r[k].value = this.s[k] = this.mergeState(deviceAccountState[k], v.default); + } else { + this.r[k].value = this.s[k] = v.default; + } + } + + this.pizzaxChannel.addEventListener('message', ({ where, key, value, userId }) => { + // アカウント変更すればunisonReloadが効くため、このreturnが発火することは + // まずないと思うけど一応弾いておく + if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; + this.r[key].value = this.s[key] = value; + }); + + if ($i) { + const connection = useStream().useChannel('main'); + + // streamingのuser storage updateイベントを監視して更新 + connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { + if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; + + this.r[key].value = this.s[key] = value; + + this.addIdbSetJob(async () => { + const cache = await get(this.registryCacheKeyName); + if (cache[key] !== value) { + cache[key] = value; + await set(this.registryCacheKeyName, cache); + } + }); + }); + } + } + + private load(): Promise { + return new Promise((resolve, reject) => { + if ($i) { + // api関数と循環参照なので一応setTimeoutしておく + window.setTimeout(async () => { + await store.ready; + + misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) + .then(kvs => { + const cache: Partial = {}; + for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + this.r[k].value = this.s[k] = (kvs as Partial)[k]; + cache[k] = (kvs as Partial)[k]; + } else { + this.r[k].value = this.s[k] = v.default; + } + } + } + + return set(this.registryCacheKeyName, cache); + }) + .then(() => resolve()); + }, 1); + } else { + resolve(); + } + }); + } + + public set(key: K, value: T[K]['default']): Promise { + // IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする + // (JSON.parse(JSON.stringify(value))の代わり) + const rawValue = deepClone(value); + + this.r[key].value = this.s[key] = rawValue; + + return this.addIdbSetJob(async () => { + switch (this.def[key].where) { + case 'device': { + this.pizzaxChannel.postMessage({ + where: 'device', + key, + value: rawValue, + }); + const deviceState = await get(this.deviceStateKeyName) || {}; + deviceState[key] = rawValue; + await set(this.deviceStateKeyName, deviceState); + break; + } + case 'deviceAccount': { + if ($i == null) break; + this.pizzaxChannel.postMessage({ + where: 'deviceAccount', + key, + value: rawValue, + userId: $i.id, + }); + const deviceAccountState = await get(this.deviceAccountStateKeyName) || {}; + deviceAccountState[key] = rawValue; + await set(this.deviceAccountStateKeyName, deviceAccountState); + break; + } + case 'account': { + if ($i == null) break; + const cache = await get(this.registryCacheKeyName) || {}; + cache[key] = rawValue; + await set(this.registryCacheKeyName, cache); + await misskeyApi('i/registry/set', { + scope: ['client', this.key], + key: key.toString(), + value: rawValue, + }); + break; + } + } + }); + } + + public push(key: K, value: ArrayElement): void { + const currentState = this.s[key]; + this.set(key, [...currentState, value]); + } + + public reset(key: keyof T) { + this.set(key, this.def[key].default); + return this.def[key].default; + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue上で設定コントロールのmodelとして使う用 + */ + // TODO: 廃止 + public makeGetterSetter( + key: K, + getter?: (v: T[K]['default']) => R, + setter?: (v: R) => T[K]['default'], + ): { + get: () => R; + set: (value: R) => void; + } { + const valueRef = ref(this.s[key]); + + const stop = watch(this.r[key], val => { + valueRef.value = val; + }); + + // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする + onUnmounted(() => { + stop(); + }); + + // TODO: VueのcustomRef使うと良い感じになるかも + return { + get: () => { + if (getter) { + return getter(valueRef.value); + } else { + return valueRef.value; + } + }, + set: (value) => { + const val = setter ? setter(value) : value; + this.set(key, val); + valueRef.value = val; + }, + }; + } + + // localStorage => indexedDBのマイグレーション + private async migrate() { + const deviceState = localStorage.getItem(this.deviceStateKeyName); + if (deviceState) { + await set(this.deviceStateKeyName, JSON.parse(deviceState)); + localStorage.removeItem(this.deviceStateKeyName); + } + + const deviceAccountState = $i && localStorage.getItem(this.deviceAccountStateKeyName); + if ($i && deviceAccountState) { + await set(this.deviceAccountStateKeyName, JSON.parse(deviceAccountState)); + localStorage.removeItem(this.deviceAccountStateKeyName); + } + + const registryCache = $i && localStorage.getItem(this.registryCacheKeyName); + if ($i && registryCache) { + await set(this.registryCacheKeyName, JSON.parse(registryCache)); + localStorage.removeItem(this.registryCacheKeyName); + } + } +} -- cgit v1.2.3-freya