fix(dispatcher): proper lifetime management for state storage

This commit is contained in:
alina 🌸 2023-11-27 14:58:48 +03:00
parent ec0865c746
commit 51e67a5113
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
4 changed files with 163 additions and 68 deletions

View file

@ -1,4 +1,3 @@
/* eslint-disable no-restricted-globals */
const ts = require('typescript') const ts = require('typescript')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
@ -428,7 +427,7 @@ async function main() {
output.write( output.write(
'/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging, @typescript-eslint/unified-signatures */\n' + '/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging, @typescript-eslint/unified-signatures */\n' +
'/* THIS FILE WAS AUTO-GENERATED */\n' '/* THIS FILE WAS AUTO-GENERATED */\n',
) )
Object.entries(state.imports).forEach(([module, items]) => { Object.entries(state.imports).forEach(([module, items]) => {
items = [...items] items = [...items]
@ -469,6 +468,10 @@ async function main() {
on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this\n`) on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this\n`)
}) })
output.write(`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(name: string, handler: (...args: any[]) => void): this\n`)
const printer = ts.createPrinter() const printer = ts.createPrinter()
const classContents = [] const classContents = []

View file

@ -475,6 +475,9 @@ export interface TelegramClient extends BaseTelegramClient {
*/ */
on(name: 'delete_story', handler: (upd: DeleteStoryUpdate) => void): this on(name: 'delete_story', handler: (upd: DeleteStoryUpdate) => void): this
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(name: string, handler: (...args: any[]) => void): this
getAuthState(): AuthState getAuthState(): AuthState
/** /**
* Check your Two-Step verification password and log in * Check your Two-Step verification password and log in

View file

@ -77,7 +77,7 @@ export interface DispatcherParams {
sceneName?: string sceneName?: string
/** /**
* Custom storage for the dispatcher. * Custom storage for this dispatcher and its children.
* *
* @default Client's storage * @default Client's storage
*/ */
@ -93,7 +93,7 @@ export interface DispatcherParams {
* Updates dispatcher * Updates dispatcher
*/ */
export class Dispatcher<State extends object = never> { export class Dispatcher<State extends object = never> {
private _groups: Record<number, Record<UpdateHandler['name'], UpdateHandler[]>> = {} private _groups: Map<number, Map<UpdateHandler['name'], UpdateHandler[]>> = new Map()
private _groupsOrder: number[] = [] private _groupsOrder: number[] = []
private _client?: TelegramClient private _client?: TelegramClient
@ -101,7 +101,7 @@ export class Dispatcher<State extends object = never> {
private _parent?: Dispatcher<any> private _parent?: Dispatcher<any>
private _children: Dispatcher<any>[] = [] private _children: Dispatcher<any>[] = []
private _scenes?: Record<string, Dispatcher<any>> private _scenes?: Map<string, Dispatcher<any>>
private _scene?: string private _scene?: string
private _sceneScoped?: boolean private _sceneScoped?: boolean
@ -131,6 +131,8 @@ export class Dispatcher<State extends object = never> {
protected constructor(client?: TelegramClient, params?: DispatcherParams) { protected constructor(client?: TelegramClient, params?: DispatcherParams) {
this.dispatchRawUpdate = this.dispatchRawUpdate.bind(this) this.dispatchRawUpdate = this.dispatchRawUpdate.bind(this)
this.dispatchUpdate = this.dispatchUpdate.bind(this) this.dispatchUpdate = this.dispatchUpdate.bind(this)
this._onClientBeforeConnect = this._onClientBeforeConnect.bind(this)
this._onClientBeforeClose = this._onClientBeforeClose.bind(this)
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { storage, key, sceneName } = params ?? {} let { storage, key, sceneName } = params ?? {}
@ -204,36 +206,89 @@ export class Dispatcher<State extends object = never> {
return this._scene return this._scene
} }
private _onClientBeforeConnect() {
(async () => {
if (
!this._parent &&
this._storage &&
this._storage !== (this._client!.storage as unknown as IStateStorage)
) {
// this is a root dispatcher with custom storage
await this._storage.load?.()
}
if (this._parent && this._customStorage) {
// this is a child dispatcher with custom storage
await this._customStorage.load?.()
}
for (const child of this._children) {
child._onClientBeforeConnect()
}
if (this._scenes) {
for (const scene of this._scenes.values()) {
scene._onClientBeforeConnect()
}
}
})().catch((err) => this._client!._emitError(err))
}
private _onClientBeforeClose() {
(async () => {
if (
!this._parent &&
this._storage &&
this._storage !== (this._client!.storage as unknown as IStateStorage)
) {
// this is a root dispatcher with custom storage
await this._storage.save?.()
await this._storage.destroy?.()
}
if (this._parent && this._customStorage) {
// this is a child dispatcher with custom storage
await this._customStorage.save?.()
await this._customStorage.destroy?.()
}
for (const child of this._children) {
child._onClientBeforeClose()
}
if (this._scenes) {
for (const scene of this._scenes.values()) {
scene._onClientBeforeClose()
}
}
})().catch((err) => this._client!._emitError(err))
}
/** /**
* Bind the dispatcher to the client. * Bind the dispatcher to the client.
* Called by the constructor automatically if * Called by the constructor automatically if
* `client` was passed. * `client` was passed.
* *
* Under the hood, this replaces client's `dispatchUpdate`
* function, meaning you can't bind two different
* dispatchers to the same client at the same time.
* Instead, use {@link extend}, {@link addChild}
* or {@link addScene} on the existing, already bound dispatcher.
*
* Dispatcher also uses bound client to throw errors * Dispatcher also uses bound client to throw errors
*/ */
bindToClient(client: TelegramClient): void { bindToClient(client: TelegramClient): void {
client.on('update', this.dispatchUpdate) client.on('update', this.dispatchUpdate)
client.on('raw_update', this.dispatchRawUpdate) client.on('raw_update', this.dispatchRawUpdate)
client.on('before_connect', this._onClientBeforeConnect)
client.on('before_close', this._onClientBeforeClose)
this._client = client this._client = client
} }
/** /**
* Unbind a dispatcher from the client. * Unbind a dispatcher from the client.
*
* This will replace client's dispatchUpdate with a no-op.
* If this dispatcher is not bound, nothing will happen.
*/ */
unbind(): void { unbind(): void {
if (this._client) { if (this._client) {
this._client.off('update', this.dispatchUpdate) this._client.off('update', this.dispatchUpdate)
this._client.off('raw_update', this.dispatchRawUpdate) this._client.off('raw_update', this.dispatchRawUpdate)
this._client.off('before_connect', this._onClientBeforeConnect)
this._client.off('before_close', this._onClientBeforeClose)
this._client = undefined this._client = undefined
} }
@ -275,10 +330,10 @@ export class Dispatcher<State extends object = never> {
let handled = false let handled = false
outer: for (const grp of this._groupsOrder) { outer: for (const grp of this._groupsOrder) {
const group = this._groups[grp] const group = this._groups.get(grp)!
if ('raw' in group) { if (group.has('raw')) {
const handlers = group.raw as RawUpdateHandler[] const handlers = group.get('raw')! as RawUpdateHandler[]
for (const h of handlers) { for (const h of handlers) {
let result: void | PropagationAction let result: void | PropagationAction
@ -383,12 +438,12 @@ export class Dispatcher<State extends object = never> {
return false return false
} }
} else { } else {
if (!this._scenes || !(parsedScene in this._scenes)) { if (!this._scenes || !this._scenes.has(parsedScene)) {
// not registered scene // not registered scene
return false return false
} }
return this._scenes[parsedScene]._dispatchUpdateNowImpl(update, parsedState, parsedScene, true) return this._scenes.get(parsedScene)!._dispatchUpdateNowImpl(update, parsedState, parsedScene, true)
} }
} }
@ -441,11 +496,11 @@ export class Dispatcher<State extends object = never> {
if (shouldDispatch) { if (shouldDispatch) {
outer: for (const grp of this._groupsOrder) { outer: for (const grp of this._groupsOrder) {
const group = this._groups[grp] const group = this._groups.get(grp)!
if (update.name in group) { if (group.has(update.name)) {
// raw is not handled here, so we can safely assume this // raw is not handled here, so we can safely assume this
const handlers = group[update.name] as Exclude<UpdateHandler, RawUpdateHandler>[] const handlers = group.get(update.name)! as Exclude<UpdateHandler, RawUpdateHandler>[]
try { try {
for (const h of handlers) { for (const h of handlers) {
@ -477,7 +532,12 @@ export class Dispatcher<State extends object = never> {
throw new MtArgumentError('Cannot use ToScene without entering a scene') throw new MtArgumentError('Cannot use ToScene without entering a scene')
} }
return this._scenes![scene]._dispatchUpdateNowImpl(update, undefined, scene, true) return this._scenes!.get(scene)!._dispatchUpdateNowImpl(
update,
undefined,
scene,
true,
)
} }
} }
@ -514,17 +574,17 @@ export class Dispatcher<State extends object = never> {
* @param group Handler group index * @param group Handler group index
*/ */
addUpdateHandler(handler: UpdateHandler, group = 0): void { addUpdateHandler(handler: UpdateHandler, group = 0): void {
if (!(group in this._groups)) { if (!this._groups.has(group)) {
this._groups[group] = {} as any this._groups.set(group, new Map())
this._groupsOrder.push(group) this._groupsOrder.push(group)
this._groupsOrder.sort((a, b) => a - b) this._groupsOrder.sort((a, b) => a - b)
} }
if (!(handler.name in this._groups[group])) { if (!this._groups.get(group)!.has(handler.name)) {
this._groups[group][handler.name] = [] this._groups.get(group)!.set(handler.name, [])
} }
this._groups[group][handler.name].push(handler) this._groups.get(group)!.get(handler.name)!.push(handler)
} }
/** /**
@ -532,35 +592,37 @@ export class Dispatcher<State extends object = never> {
* handler group. * handler group.
* *
* @param handler Update handler to remove, its name or `'all'` to remove all * @param handler Update handler to remove, its name or `'all'` to remove all
* @param group Handler group index (-1 to affect all groups) * @param group Handler group index (null to affect all groups)
*/ */
removeUpdateHandler(handler: UpdateHandler | UpdateHandler['name'] | 'all', group = 0): void { removeUpdateHandler(handler: UpdateHandler | UpdateHandler['name'] | 'all', group: number | null = 0): void {
if (group !== -1 && !(group in this._groups)) { if (group !== null && !this._groups.has(group)) {
return return
} }
if (typeof handler === 'string') { if (typeof handler === 'string') {
if (handler === 'all') { if (handler === 'all') {
if (group === -1) { if (group === null) {
this._groups = {} this._groups = new Map()
} else { } else {
delete this._groups[group] this._groups.delete(group)
} }
} else { } else if (group !== null) {
delete this._groups[group][handler] this._groups.get(group)!.delete(handler)
} }
return return
} }
if (!(handler.name in this._groups[group])) { if (group === null) return
if (!this._groups.get(group)!.has(handler.name)) {
return return
} }
const idx = this._groups[group][handler.name].indexOf(handler) const idx = this._groups.get(group)!.get(handler.name)!.indexOf(handler)
if (idx > -1) { if (idx > -1) {
this._groups[group][handler.name].splice(idx, 1) this._groups.get(group)!.get(handler.name)!.splice(idx, 1)
} }
} }
@ -666,6 +728,8 @@ export class Dispatcher<State extends object = never> {
child._client = this._client child._client = this._client
child._storage = this._storage child._storage = this._storage
child._stateKeyDelegate = this._stateKeyDelegate child._stateKeyDelegate = this._stateKeyDelegate
child._customStorage ??= this._customStorage
child._customStateKeyDelegate ??= this._customStateKeyDelegate
} }
/** /**
@ -719,7 +783,7 @@ export class Dispatcher<State extends object = never> {
*/ */
addScene(scene: Dispatcher<any>, scoped?: true): void addScene(scene: Dispatcher<any>, scoped?: true): void
addScene(scene: Dispatcher<any>, scoped = true): void { addScene(scene: Dispatcher<any>, scoped = true): void {
if (!this._scenes) this._scenes = {} if (!this._scenes) this._scenes = new Map()
if (!scene._scene) { if (!scene._scene) {
throw new MtArgumentError( throw new MtArgumentError(
@ -727,13 +791,13 @@ export class Dispatcher<State extends object = never> {
) )
} }
if (scene._scene in this._scenes) { if (this._scenes.has(scene._scene)) {
throw new MtArgumentError(`Scene with name ${scene._scene} is already registered!`) throw new MtArgumentError(`Scene with name ${scene._scene} is already registered!`)
} }
this._prepareChild(scene) this._prepareChild(scene)
scene._sceneScoped = scoped scene._sceneScoped = scoped
this._scenes[scene._scene] = scene this._scenes.set(scene._scene, scene)
} }
/** /**
@ -780,19 +844,21 @@ export class Dispatcher<State extends object = never> {
} }
other._groupsOrder.forEach((group) => { other._groupsOrder.forEach((group) => {
if (!(group in this._groups)) { if (!this._groups.has(group)) {
this._groups[group] = other._groups[group] this._groups.set(group, other._groups.get(group)!)
this._groupsOrder.push(group) this._groupsOrder.push(group)
} else { } else {
const otherGrp = other._groups[group] as any const otherGrp = other._groups.get(group)!
const selfGrp = this._groups[group] as any const selfGrp = this._groups.get(group)!
Object.keys(otherGrp).forEach((typ) => {
if (!(typ in selfGrp)) { for (const typ of otherGrp.keys()) {
selfGrp[typ] = otherGrp[typ] if (!selfGrp.has(typ)) {
selfGrp.set(typ, otherGrp.get(typ)!)
} else { } else {
selfGrp[typ].push(...otherGrp[typ]) // selfGrp[typ].push(...otherGrp[typ])
selfGrp.get(typ)!.push(...otherGrp.get(typ)!)
}
} }
})
} }
}) })
@ -803,19 +869,19 @@ export class Dispatcher<State extends object = never> {
if (other._scenes) { if (other._scenes) {
const otherScenes = other._scenes const otherScenes = other._scenes
if (!this._scenes) this._scenes = {} if (!this._scenes) this._scenes = new Map()
const myScenes = this._scenes const myScenes = this._scenes
Object.keys(otherScenes).forEach((key) => { for (const key of otherScenes.keys()) {
otherScenes[key]._unparent() otherScenes.get(key)!._unparent()
if (key in myScenes) { if (myScenes.has(key)) {
// will be overwritten // will be overwritten
delete myScenes[key] myScenes.delete(key)
} }
this.addScene(myScenes[key] as any, myScenes[key]._sceneScoped as any) this.addScene(otherScenes.get(key) as any, otherScenes.get(key)!._sceneScoped as any)
}) }
} }
this._groupsOrder.sort((a, b) => a - b) this._groupsOrder.sort((a, b) => a - b)
@ -837,15 +903,16 @@ export class Dispatcher<State extends object = never> {
const dp = new Dispatcher<State>() const dp = new Dispatcher<State>()
// copy handlers. // copy handlers.
Object.keys(this._groups).forEach((key) => { for (const key of this._groups.keys()) {
const idx = key as any as number const idx = key as any as number
dp._groups[idx] = {} as any dp._groups.set(idx, new Map())
Object.keys(this._groups[idx]).forEach((type) => { for (const type of this._groups.get(idx)!.keys()) {
dp._groups[idx][type as UpdateHandler['name']] = [...this._groups[idx][type as UpdateHandler['name']]] // dp._groups.get(idx)!.set(type, [...this._groups.get(idx)!].get(type)!])
}) dp._groups.get(idx)!.set(type, [...this._groups.get(idx)!.get(type)!])
}) }
}
dp._groupsOrder = [...this._groupsOrder] dp._groupsOrder = [...this._groupsOrder]
dp._errorHandler = this._errorHandler dp._errorHandler = this._errorHandler
@ -859,10 +926,10 @@ export class Dispatcher<State extends object = never> {
}) })
if (this._scenes) { if (this._scenes) {
Object.keys(this._scenes).forEach((key) => { for (const key of this._scenes.keys()) {
const scene = this._scenes![key].clone(true) const scene = this._scenes.get(key)!.clone(true)
dp.addScene(scene as any, this._scenes![key]._sceneScoped as any) dp.addScene(scene as any, this._scenes.get(key)!._sceneScoped as any)
}) }
} }
} }

View file

@ -16,6 +16,28 @@ import { MaybeAsync } from '@mtcute/client'
* Alternatively, you can store them as simple strings * Alternatively, you can store them as simple strings
*/ */
export interface IStateStorage { export interface IStateStorage {
/**
* Load state from some external storage.
* Should be used either to load session content from file/network/etc
* to memory, or to open required connections to fetch session content later
*
* This method may be called multiple times and should handle that.
*/
load?(): MaybeAsync<void>
/**
* Save state to some external storage.
* Should be used to commit pending changes in the session.
* For example, saving session content to file/network/etc,
* or committing a database transaction
*/
save?(): MaybeAsync<void>
/**
* Cleanup storage and release all used resources.
*
* This method may be called multiple times and should handle that.
*/
destroy?(): MaybeAsync<void>
/** /**
* Retrieve state from the storage * Retrieve state from the storage
* *