From efb9726470ab3af3708a0363e22a61a30668a6f3 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 9 Aug 2022 23:01:36 -0500 Subject: [PATCH] Add CacheSession implementation & make WebSocketBus re-load the session when an event is received --- package.json | 2 +- src/http/session/CacheSession.ts | 119 +++++++++++++++++++++++++++++++ src/index.ts | 1 + src/support/bus/WebSocketBus.ts | 42 ++++++++++- 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/http/session/CacheSession.ts diff --git a/package.json b/package.json index b65faa9..1621b61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@extollo/lib", - "version": "0.13.5", + "version": "0.13.6", "description": "The framework library that lifts up your code.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/http/session/CacheSession.ts b/src/http/session/CacheSession.ts new file mode 100644 index 0000000..1ed89bd --- /dev/null +++ b/src/http/session/CacheSession.ts @@ -0,0 +1,119 @@ +import {NoSessionKeyError, Session, SessionData, SessionNotLoadedError} from './Session' +import {Inject, Injectable} from '../../di' +import {Cache, Maybe} from '../../util' +import {Config} from '../../service/Config' + +/** + * A Session implementation that uses the configured Cache driver for persistence. + */ +@Injectable() +export class CacheSession extends Session { + @Inject() + protected readonly cache!: Cache + + @Inject() + protected readonly config!: Config + + protected key?: string + + protected data?: SessionData + + protected dirty = false + + forget(key: string): void { + if ( !this.data ) { + throw new SessionNotLoadedError() + } + + delete this.data[key] + this.dirty = true + } + + get(key: string, fallback?: unknown): any { + if ( !this.data ) { + throw new SessionNotLoadedError() + } + + return this.data[key] ?? fallback + } + + getData(): SessionData { + if ( !this.data ) { + throw new SessionNotLoadedError() + } + + return {...this.data} + } + + getKey(): string { + if ( !this.key ) { + throw new NoSessionKeyError() + } + + return this.key + } + + async load(): Promise { + const json = await this.cache.fetch(this.formatKey()) + if ( json ) { + this.data = JSON.parse(json) + } else { + this.data = {} + } + + this.dirty = false + } + + async persist(): Promise { + if ( !this.dirty ) { + return + } + + const json = JSON.stringify(this.data) + await this.cache.put(this.formatKey(), json, this.getExpiration()) + this.dirty = false + } + + private getExpiration(): Maybe { + // Get the session expiration. By default, this is 4 hours. + const durationMins = this.config.safe('server.session.durationMins') + .or(4 * 60) + .integer() + + if ( durationMins !== 0 ) { + const date = new Date() + date.setMinutes(date.getMinutes() + durationMins) + return date + } + } + + private formatKey(): string { + if ( !this.key ) { + throw new NoSessionKeyError() + } + + const prefix = this.config.safe('app.name') + .or('Extollo') + .string() + + return `${prefix}_session_${this.key}` + } + + set(key: string, value: unknown): void { + if ( !this.data ) { + throw new SessionNotLoadedError() + } + + this.data[key] = value + this.dirty = true + } + + setData(data: SessionData): void { + this.data = {...data} + this.dirty = true + } + + setKey(key: string): void { + this.key = key + } +} diff --git a/src/index.ts b/src/index.ts index 5e5bc3d..4231820 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,7 @@ export * from './http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule' export * from './http/session/Session' export * from './http/session/SessionFactory' export * from './http/session/MemorySession' +export * from './http/session/CacheSession' export * from './http/Controller' diff --git a/src/support/bus/WebSocketBus.ts b/src/support/bus/WebSocketBus.ts index 8771178..22e6849 100644 --- a/src/support/bus/WebSocketBus.ts +++ b/src/support/bus/WebSocketBus.ts @@ -16,11 +16,25 @@ import {Bus} from './Bus' import {WebSocketCloseEvent} from '../../http/lifecycle/WebSocketCloseEvent' import {apiEvent, error} from '../../http/response/api' import {AsyncResource, executionAsyncId} from 'async_hooks' +import {Session} from '../../http/session/Session' @Injectable() export class WebSocketBus implements EventBus, AwareOfContainerLifecycle { awareOfContainerLifecycle: true = true + /** + * If true, the session will be loaded when an event is received and + * persisted after the event's handlers have executed. + * + * If an event has no handlers, the session will NOT be loaded. + * + * Use `disableSessionLoad()` to disable. + * + * @see disableSessionLoad + * @protected + */ + protected shouldLoadSessionOnEvent = true + @Inject() protected readonly ws!: WebSocket.WebSocket @@ -33,6 +47,9 @@ export class WebSocketBus implements EventBus, AwareOfContainerLifecycle { @Inject() protected readonly logging!: Logging + @Inject() + protected readonly session!: Session + public readonly uuid = uuid4() private connected = false @@ -40,6 +57,15 @@ export class WebSocketBus implements EventBus, AwareOfContainerLifecycle { /** List of local subscriptions on this bus. */ protected subscriptions: Collection> = new Collection() + /** + * Disables re-loading & persisting the session when an event with listeners is received. + * @see shouldLoadSessionOnEvent + */ + disableSessionLoad(): this { + this.shouldLoadSessionOnEvent = false + return this + } + /** Get a Promise that resolves then the socket closes. */ onClose(): Promise { return new Promise(res => { @@ -82,9 +108,21 @@ export class WebSocketBus implements EventBus, AwareOfContainerLifecycle { protected async onMessage(message: string): Promise { const payload = await this.serial.decodeJSON(message) // FIXME validation - await this.subscriptions + const listeners = await this.subscriptions .where('eventName', '=', payload.eventName) - .awaitMapCall('handler', payload) + + // If configured, re-load the session data since it may have changed outside the + // current socket's request. + if ( this.shouldLoadSessionOnEvent && listeners.isNotEmpty() ) { + await this.session.load() + } + + await listeners.awaitMapCall('handler', payload) + + // Persist any changes to the session for other requests. + if ( this.shouldLoadSessionOnEvent && listeners.isNotEmpty() ) { + await this.session.persist() + } } up(): void {