From bf1b943ce31259fa5b885eb6de1fbfe242317711 Mon Sep 17 00:00:00 2001 From: Dariusz Filipiak Date: Mon, 29 Apr 2024 11:52:10 +0200 Subject: [PATCH] $$$ update $$$ --- src/app.ts | 7 +- src/lib/base-classes/base-repository.ts | 2 +- src/lib/create-context.ts | 10 + src/lib/endpoint-context.ts | 462 +++++++++++++++++- src/lib/firedev-admin.ts | 81 +++ src/lib/models.ts | 42 +- .../realtime/broadcast-api-io-mock-client.ts | 82 ++++ .../realtime/broadcast-api-io-mock-server.ts | 121 +++++ src/lib/realtime/broadcast-api-io.models.ts | 27 + src/lib/realtime/broadcast-channel-dummy.ts | 80 +++ src/lib/realtime/index.ts | 13 + src/lib/realtime/realtime-browser-rxjs.ts | 203 ++++++++ src/lib/realtime/realtime-nodejs.ts | 261 ++++++++++ src/lib/realtime/realtime-subs-manager.ts | 127 +++++ src/lib/realtime/realtime.ts | 46 ++ src/lib/validators.ts | 28 ++ 16 files changed, 1554 insertions(+), 38 deletions(-) create mode 100644 src/lib/firedev-admin.ts create mode 100644 src/lib/realtime/broadcast-api-io-mock-client.ts create mode 100644 src/lib/realtime/broadcast-api-io-mock-server.ts create mode 100644 src/lib/realtime/broadcast-api-io.models.ts create mode 100644 src/lib/realtime/broadcast-channel-dummy.ts create mode 100644 src/lib/realtime/index.ts create mode 100644 src/lib/realtime/realtime-browser-rxjs.ts create mode 100644 src/lib/realtime/realtime-nodejs.ts create mode 100644 src/lib/realtime/realtime-subs-manager.ts create mode 100644 src/lib/realtime/realtime.ts diff --git a/src/app.ts b/src/app.ts index 90b87359..8b902ff5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -61,11 +61,10 @@ class User extends Firedev.Base.AbstractEntity { path: 'users', }) class UserController extends Firedev.Base.CrudController { - provider = this.inject(UserProvider); + userProviers = this.inject(UserProvider); async initExampleDbData(): Promise { - // console.log({ 'this.provider': this.provider }); - // console.log(this.provider.helloFromUserProvier()); + console.log(this.userProviers.helloFromUserProvier()); } @@ -115,7 +114,7 @@ async function start(portForBackend?: string) { console.log({ portForBackend }) console.log('Helpers.isElectron', Helpers.isElectron) console.log('Your server will start on port ' + HOST_BACKEND_PORT); - console.log(UserContext.ref.allInstances); + console.log(UserContext.ref.allClassesInstances); await UserContext.initialize({ diff --git a/src/lib/base-classes/base-repository.ts b/src/lib/base-classes/base-repository.ts index 8738d4d7..0321a247 100644 --- a/src/lib/base-classes/base-repository.ts +++ b/src/lib/base-classes/base-repository.ts @@ -10,5 +10,5 @@ export class BaseRepository extends Repository //#endregion { - + // TOOD get connection first } diff --git a/src/lib/create-context.ts b/src/lib/create-context.ts index b52146ff..8633544f 100644 --- a/src/lib/create-context.ts +++ b/src/lib/create-context.ts @@ -34,7 +34,17 @@ export const createContext = < beforeDbInited && await Helpers.runSyncOrAsync({ functionFn: beforeDbInited, }); + await ref.initDatabaseConnection(); + await ref.initSubscribers(); ref.initializeMetadata(); + //#region @websql + ref.writeActiveRoutes(); + //#endregion + if (ref.admin && ref.admin.keepWebsqlDbDataAfterReload && !Helpers.isNode) { + Helpers.info(`[firedev] Keep websql data after reload`); + } else { + await ref.reinitControllersData(); + } await ref.reinitControllersData(); afterDbInited && await Helpers.runSyncOrAsync({ functionFn: afterDbInited diff --git a/src/lib/endpoint-context.ts b/src/lib/endpoint-context.ts index 4b157ffd..216c8430 100644 --- a/src/lib/endpoint-context.ts +++ b/src/lib/endpoint-context.ts @@ -14,7 +14,19 @@ import { from, Subject } from "rxjs"; import { EntityProcess } from "./entity-process"; import { getResponseValue } from "./get-response-value"; import type { Application } from 'express'; +import axios from 'axios'; +import { NgZone } from "@angular/core"; +import { FiredevAdmin } from "./firedev-admin"; +import { RealtimeNodejs } from "./realtime"; +import { Connection, DataSource } from 'firedev-typeorm/src'; +//#region @websql +import type { + TransactionRollbackEvent, TransactionCommitEvent, TransactionStartEvent, + RecoverEvent, SoftRemoveEvent, RemoveEvent, UpdateEvent, InsertEvent +} from 'firedev-typeorm/src'; +//#endregion //#region @backend +import * as express from 'express'; import * as expressSession from 'express-session'; import * as cors from 'cors'; import * as bodyParser from 'body-parser'; @@ -23,14 +35,25 @@ import * as methodOverride from 'method-override'; import * as fileUpload from 'express-fileupload'; import { Http2Server } from 'http2'; import { URL } from 'url'; -import { fse } from 'tnp-core/src'; +import { fse, http, https } from 'tnp-core/src'; //#endregion //#endregion export class EndpointContext { + //#region static + private static endpointId = 0; + + public static findForTraget(classFnOrObject: any): EndpointContext { + const obj = ClassHelpers.getClassFnFromObject(classFnOrObject) || {}; + return classFnOrObject[Symbols.ctxInClassOrClassObj] || obj[Symbols.ctxInClassOrClassObj]; + } + //#endregion //#region fields - public readonly allInstances = {}; + /** + * all instances of classes from context + */ + public readonly allClassesInstances = {}; private readonly classInstancesByNameObj = {}; private readonly objWithClassesInstancesArr = {}; @@ -38,18 +61,128 @@ export class EndpointContext { private typesfromContexts = [Models.ClassType.CONTROLLER, Models.ClassType.PROVIDER, Models.ClassType.REPOSITORY]; - public readonly app: Application; + public readonly expressApp: Application; + public readonly serverTcpUdp + //#region @backend + : | http.Server; + //#endregion //#endregion //#region constructor + readonly ngZone: NgZone; + readonly databaseConfig?: Models.DatabaseConfig; + readonly mode: Models.FrameworkMode; + + readonly applicationId: string; + readonly session?: Models.ISession; + readonly disabledRealtime: boolean = false; + public readonly connection: Connection | DataSource; + readonly admin?: FiredevAdmin; + readonly realtimeNodeJs: RealtimeNodejs; + private entitiesTriggers = {}; constructor( private config: Models.ContextOptions ) { this.config = config; + if (Helpers.isBrowser) { + this.admin = window['firedev']; + } - // @LAST initialize whole + //#region resolve mode + if (config.host) { + this.mode = 'backend-frontend(tcp+udp)'; + //#region @websqlOnly + this.mode = 'backend-frontend(websql)'; + //#endregion + } + if (config.remoteHost) { + if (config.host) { + Helpers.error(`[firedev] You can't have remoteHost and host at the same time`, false, true); + } + this.mode = 'remote-backend(tcp+udp)' + } + + if (config.useIpcWhenElectron && Helpers.isElectron) { + this.mode = 'backend-frontend(ipc-electron)'; + } + if (!this.mode) { + Helpers.error(`[firedev] You need to provide host or remoteHost or useIpcWhenElectron`, false, true); + } + //#endregion + + //#region resolve database config + if (config.database === true) { + switch (this.mode) { + + //#region resolve database config for mode backend-frontend(tcp+udp) + case 'backend-frontend(ipc-electron)': + this.databaseConfig = { + database: `firedev-db-${EndpointContext.endpointId++}.sqljs.db`, + type: 'sqljs', + autoSave: true, + synchronize: true, + dropSchema: true, + logging: true, + } + break; + //#endregion + + //#region resolve database config for mode backend-frontend(websql) + case 'backend-frontend(websql)': + this.databaseConfig = { + location: `firedev-db-${EndpointContext.endpointId++}.sqljs.db`, + type: 'sqljs', + useLocalForage: !!window['localforage'], + autoSave: true, + synchronize: true, + dropSchema: true, + logging: true, + }; + if (!this.admin || !this.admin.keepWebsqlDbDataAfterReload) { + this.databaseConfig.dropSchema = true; + this.databaseConfig.synchronize = true; + } else { + this.databaseConfig.dropSchema = false; + delete this.databaseConfig.synchronize // false is not auto synchonize - from what I understand + } + + break; + //#endregion + + //#region resolve database config for mode backend-frontend(tcp+udp) + case 'backend-frontend(tcp+udp)': + this.databaseConfig = { + database: `firedev-db-${EndpointContext.endpointId++}.sqlite`, + type: 'better-sqlite3', + autoSave: true, + synchronize: true, + dropSchema: true, + logging: true, + } + break; + //#endregion + } + + } else if (_.isObject(config.database)) { + this.databaseConfig = _.cloneDeep(config.database) as Models.DatabaseConfig; + } + //#endregion + + //#region resolve session + if (config.session) { + this.session = _.cloneDeep(config.session); + const oneHour = 1000 * 60 * 60 * 1; // 24; + if (!this.session.cookieMaxAge) { + this.session.cookieMaxAge = oneHour; + } + // serever and browser cookie authentication + axios.defaults.withCredentials = true; + } + //#endregion + + //#region prepare classes config.contexts = config.contexts || {}; config.entities = config.entities || {}; config.controllers = config.controllers || {}; @@ -68,8 +201,9 @@ export class EndpointContext { config.providers = this.cloneClassesObjWithNewMetadata({ classesInput: config.providers, config, ctx: this, classType: Models.ClassType.PROVIDER, }); + //#endregion - + //#region prepare instances for (const classTypeName of this.typesfromContexts) { this.classInstancesByNameObj[classTypeName] = {}; this.objWithClassesInstancesArr[classTypeName] = []; @@ -77,8 +211,45 @@ export class EndpointContext { this.createInstances(config.repositories, Models.ClassType.REPOSITORY); this.createInstances(config.providers, Models.ClassType.PROVIDER); this.createInstances(config.controllers, Models.ClassType.CONTROLLER); + //#endregion + + //#region prepares server + if (this.mode === 'backend-frontend(tcp+udp)') { + //#region @backend + this.expressApp = express(); + this.initMidleware(); + this.serverTcpUdp = (this.uri.protocol === 'https:') ? (new https.Server({ + key: config.https?.key, + cert: config.https?.cert + }, this.expressApp)) : (new http.Server(this.expressApp)); + + if (this.mode === 'backend-frontend(tcp+udp)') { + this.serverTcpUdp.listen(this.uri.port, () => { + Helpers.log(`Server listening on port: ${this.uri.port}, hostname: ${this.uri.pathname}, + env: ${this.expressApp.settings.env} + `); + }); + } else { + Helpers.info('Ipc communication enable instead tcp/upd'); + } + this.publicAssets.forEach(asset => { // @ts-ignore + this.expressApp.use(asset.serverPath, express.static(asset.locationOnDisk)) + }); + //#endregion + } + //#endregion + + //#region prepare relatime + this.disabledRealtime = !!config.disabledRealtime; + this.realtimeNodeJs = new RealtimeNodejs(this); + //#endregion } + //#endregion + //#region methods & getters / mode allows database creation + get modeAllowsDatabaseCreation() { + return this.mode === 'backend-frontend(tcp+udp)' || this.mode === 'backend-frontend(websql)' || this.mode === 'backend-frontend(ipc-electron)'; + } //#endregion //#region methods & getters / clone class @@ -170,7 +341,7 @@ export class EndpointContext { //#region methods & getters / inject inject(ctor: new (...args: any[]) => T): T { const className = ClassHelpers.getName(ctor); - return this.allInstances[className]; + return this.allClassesInstances[className]; } /** @@ -209,7 +380,7 @@ export class EndpointContext { // console.log({ classFn, classType, instance, place, className, 'classInstancesByNameObj': this.classInstancesByNameObj }); classInstancesByNameObj[className] = instance; this.objWithClassesInstancesArr[classType].push(instance); - this.allInstances[className] = instance; + this.allClassesInstances[className] = instance; } } //#endregion @@ -273,6 +444,12 @@ export class EndpointContext { } //#endregion + //#region methods & getters / public assets + public get publicAssets() { + return this.config?.publicAssets || []; + } + //#endregion + //#region methods & getters / is production mode get isProductionMode() { return this.config.productionMode; @@ -291,6 +468,254 @@ export class EndpointContext { } //#endregion + + //#region methods & getters / init subscribers + async initSubscribers() { + //#region @websql + const entities = this.getClassFunByArr(Models.ClassType.ENTITY); + for (let index = 0; index < entities.length; index++) { + const Entity = entities[index]; + + const className = ClassHelpers.getName(Entity); + this.entitiesTriggers[className] = _.debounce(() => { + this.realtimeNodeJs.TrigggerEntityTableChanges(Entity); + }, 1000); + + const notifyFn = (nameOfEvent, entityData) => { + // console.log('trigger table event: ',nameOfEvent) + this.entitiesTriggers[className](); + }; + + //#region sub + const sub = { + listenTo() { + return Entity + }, + /** + * Called after entity is loaded. + */ + afterLoad(entity: any) { // TOOD this triggers too much + // notifyFn(`AFTER ENTITY LOADED: `, entity) + } + + /** + * Called before post insertion. + */, + beforeInsert(event: InsertEvent) { + notifyFn(`BEFORE POST INSERTED: `, event.entity) + } + + /** + * Called after entity insertion. + */, + afterInsert(event: InsertEvent) { + notifyFn(`AFTER ENTITY INSERTED: `, event.entity) + } + + /** + * Called before entity update. + */, + beforeUpdate(event: UpdateEvent) { + notifyFn(`BEFORE ENTITY UPDATED: `, event.entity) + } + + /** + * Called after entity update. + */, + afterUpdate(event: UpdateEvent) { + notifyFn(`AFTER ENTITY UPDATED: `, event.entity) + } + + /** + * Called before entity removal. + */, + beforeRemove(event: RemoveEvent) { + notifyFn( + `BEFORE ENTITY WITH ID ${event.entityId} REMOVED: `, + event.entity, + ) + } + + /** + * Called after entity removal. + */, + afterRemove(event: RemoveEvent) { + notifyFn( + `AFTER ENTITY WITH ID ${event.entityId} REMOVED: `, + event.entity, + ) + } + + /** + * Called before entity removal. + */, + beforeSoftRemove(event: SoftRemoveEvent) { + notifyFn( + `BEFORE ENTITY WITH ID ${event.entityId} SOFT REMOVED: `, + event.entity, + ) + } + + /** + * Called after entity removal. + */, + afterSoftRemove(event: SoftRemoveEvent) { + notifyFn( + `AFTER ENTITY WITH ID ${event.entityId} SOFT REMOVED: `, + event.entity, + ) + } + + /** + * Called before entity recovery. + */, + beforeRecover(event: RecoverEvent) { + notifyFn( + `BEFORE ENTITY WITH ID ${event.entityId} RECOVERED: `, + event.entity, + ) + } + + /** + * Called after entity recovery. + */, + afterRecover(event: RecoverEvent) { + notifyFn( + `AFTER ENTITY WITH ID ${event.entityId} RECOVERED: `, + event.entity, + ) + } + + /** + * Called before transaction start. + */, + beforeTransactionStart(event: TransactionStartEvent) { + notifyFn(`BEFORE TRANSACTION STARTED: `, event) + } + + /** + * Called after transaction start. + */, + afterTransactionStart(event: TransactionStartEvent) { + notifyFn(`AFTER TRANSACTION STARTED: `, event) + } + + /** + * Called before transaction commit. + */, + beforeTransactionCommit(event: TransactionCommitEvent) { + notifyFn(`BEFORE TRANSACTION COMMITTED: `, event) + } + + /** + * Called after transaction commit. + */, + afterTransactionCommit(event: TransactionCommitEvent) { + notifyFn(`AFTER TRANSACTION COMMITTED: `, event) + } + + /** + * Called before transaction rollback. + */, + beforeTransactionRollback(event: TransactionRollbackEvent) { + notifyFn(`BEFORE TRANSACTION ROLLBACK: `, event) + } + + /** + * Called after transaction rollback. + */, + afterTransactionRollback(event: TransactionRollbackEvent) { + notifyFn(`AFTER TRANSACTION ROLLBACK: `, event) + } + }; + //#endregion + + this.connection.subscribers.push(sub as any); + } + //#endregion + } + //#endregion + + //#region methods & getters / init connection + async initDatabaseConnection() { + if (this.modeAllowsDatabaseCreation && this.databaseConfig) { + + if (Helpers.isWebSQL) { + //#region @websqlOnly + + Helpers.info('PREPARING WEBSQL TYPEORM CONNECTION') + // Helpers.(this.context.config) + try { + // @ts-ignore + const connection = new DataSource(this.context.config); + // @ts-ignore + this.connection = connection; + await this.connection.initialize(); + } catch (error) { + Helpers.error(error, false, true) + + } + //#endregion + + //#region websql with only one connection + //#region @websqlOnly + // TODO + // try { + // if (!FrameworkContextNodeApp.firstConnection) { + // // @ts-ignore + // const con = new DataSource(this.context.config); + // // @ts-ignore + // FrameworkContextNodeApp.firstConnection = con; + // await FrameworkContextNodeApp.firstConnection.initialize(); + // } + // // @ts-ignore + // this.connection = FrameworkContextNodeApp.firstConnection + // } catch (error) { }; + //#endregion + //#endregion + } else { + + Helpers.info('PREPARING BACKEND TYPEORM CONNECTION') + + try { + // @ts-ignore + const connection = new DataSource(this.context.config); + // @ts-ignore + this.connection = connection; + await this.connection.initialize(); + Helpers.info('this.connection.isInitialized', this.connection.isInitialized) + + } catch (error) { + console.log(error); + } + //#region old way + // try { + // const con = await getConnection(); + + // const connectionExists = !!(con); + // if (connectionExists) { + // console.log('Connection exists') + // await con.close() + // } + // } catch (error) { }; + + // const connections = await createConnections([this.context.config] as any); + // // @ts-ignore + // this.connection = connections[0]; + //#endregion + } + if (!this.connection?.isInitialized) { + // console.log(this.connection); + Helpers.error(`Something wrong with connection init in ${this.mode}`, false, true); + } + Helpers.info(`PREPARING TYPEORM CONNECTION DONE. initialize=${this.connection?.isInitialized}`); + } else { + Helpers.info(`Not initing db for mode ${this.mode}`); + } + } + + //#endregion + //#region methods & getters / initialize metadata initializeMetadata() { const allControllers = this.getClassFunByArr(Models.ClassType.CONTROLLER); @@ -398,7 +823,8 @@ export class EndpointContext { //#endregion //#region methods & getters / write active routes - private writeActiveRoutes(contexts: EndpointContext[], isWorker = false) { + public writeActiveRoutes() { + const contexts: EndpointContext[] = [this]; //#region @websql let routes = []; for (let index = 0; index < contexts.length; index++) { @@ -444,18 +870,10 @@ export class EndpointContext { } //#endregion - //#region methods & getters / session - public get session() { - //#region @backendFunc - return this.config.session; - //#endregion - } - //#endregion - //#region methods & getters / init midleware private initMidleware() { //#region @backend - const app = this.app; + const app = this.expressApp; if (this.middlewares) { this.middlewares.forEach(m => { const [fun, args] = m; @@ -588,9 +1006,9 @@ export class EndpointContext { //#region handle websql request mode //#region @websqlOnly - if (!this.app) { + if (!this.expressApp) { // @ts-ignore - this.app = {} + this.expressApp = {} } //#endregion //#endregion @@ -599,8 +1017,8 @@ export class EndpointContext { //#region apply dummy websql express routers //#region @websql - if (!this.app[type.toLowerCase()]) { - this.app[type.toLowerCase()] = () => { } + if (!this.expressApp[type.toLowerCase()]) { + this.expressApp[type.toLowerCase()] = () => { } } //#region @backend @@ -615,7 +1033,7 @@ export class EndpointContext { //#endregion //#region @backend - this.app[type.toLowerCase()](expressPath, requestHandler, async (req, res) => { + this.expressApp[type.toLowerCase()](expressPath, requestHandler, async (req, res) => { //#region process params const args: any[] = []; diff --git a/src/lib/firedev-admin.ts b/src/lib/firedev-admin.ts new file mode 100644 index 00000000..eb14d617 --- /dev/null +++ b/src/lib/firedev-admin.ts @@ -0,0 +1,81 @@ +//#region import +import { Stor } from 'firedev-storage'; +import { _ } from 'tnp-core'; +import { Subject, take, takeUntil, tap } from 'rxjs'; +//#endregion + +export class FiredevAdmin { + enabledTabs = []; + public scrollableEnabled = false; // TOOD @LAST false by default + private onEditMode = new Subject() + onEditMode$ = this.onEditMode.asObservable(); + + @Stor.property.in.localstorage.for(FiredevAdmin).withDefaultValue('') + selectedFileSrc: string + + //#region fields & getters / files edit mode + /** + * Enable edit mode for each element on page + */ + @Stor.property.in.localstorage.for(FiredevAdmin).withDefaultValue(false) + public filesEditMode: boolean; + //#endregion + + //#region fields & getters / popup is open + @Stor.property.in.localstorage.for(FiredevAdmin).withDefaultValue(false) + public adminPanelIsOpen: boolean; + //#endregion + + //#region fields & getters / draggable popup instead side view for admin + @Stor.property.in.localstorage.for(FiredevAdmin).withDefaultValue(false) + public draggablePopupMode: boolean; + //#endregion + + //#region fields & getters / draggable popup instead side view for admin + @Stor.property.in.localstorage.for(FiredevAdmin).withDefaultValue(false) + public draggablePopupModeFullScreen: boolean; + //#endregion + + //#region fields & getters / kepp websql database data after reload + /** + * Property used in firedev + */ + @Stor.property.in.localstorage.for(FiredevAdmin).withDefaultValue(false) + public keepWebsqlDbDataAfterReload: boolean; + //#endregion + + //#region constructor + constructor( + protected ENV?: any + ) { + this.scrollableEnabled = !!ENV?.useGlobalNgxScrollbar; + } + //#endregion + + //#region methods / set edit mode + setEditMode(value: boolean) { + this.filesEditMode = value; + this.onEditMode.next(value); + } + //#endregion + + //#region methods / set keep websql db data after reload + setKeepWebsqlDbDataAfterReload(value: boolean) { + // if (value && !this.keepWebsqlDbDataAfterReload) { + // this.firstTimeKeepWebsqlDbDataTrue = true; + // } + this.keepWebsqlDbDataAfterReload = value; + } + //#endregion + + + hide() { + this.draggablePopupMode = false; + this.adminPanelIsOpen = false; + } + + show() { + this.draggablePopupMode = false; + this.adminPanelIsOpen = true; + } +} diff --git a/src/lib/models.ts b/src/lib/models.ts index 29a078af..f19baa49 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -7,6 +7,14 @@ import { FiredevControllerOptions } from "./decorators/classes/controller-decora export namespace Models { + + export type FrameworkMode = + 'backend-frontend(tcp+udp)' | + 'remote-backend(tcp+udp)' | + 'backend-frontend(ipc-electron)' | + 'backend-frontend(websql)' + ; + //#region models / class types export enum ClassType { ENTITY = 'ENTITY', CONTROLLER = 'CONTROLLER', REPOSITORY = 'REPOSITORY', PROVIDER = 'PROVIDER' @@ -19,32 +27,35 @@ export namespace Models { //#endregion //#region models / database connection options - export interface ConnectionOptions { - database: string; - type?: CoreModels.DatabaseType; + export interface DatabaseConfig { + database?: string; + location?: string; synchronize: boolean; dropSchema: boolean; logging: boolean; + useLocalForage?: boolean; + autoSave: boolean; // TODO what is this + type?: CoreModels.DatabaseType; } //#endregion //#region models / session - export type SessionExposed = Pick; + export type ISession = { /** * frontend host only needed when we are using * withCredentials for axios * and session cookie */ - frontendHost: string; - secret: string, - saveUninitialized: boolean, + frontendHost?: string; + secret?: string, + saveUninitialized?: boolean, /** * max age of session */ - cookieMaxAge: number; - secure: boolean; - resave: boolean; + cookieMaxAge?: number; + secure?: boolean; + resave?: boolean; } //#endregion @@ -52,13 +63,22 @@ export namespace Models { export interface ContextOptions { host?: string; remoteHost?: string; + useIpcWhenElectron?: boolean; contexts?: CONTEXTS; controllers?: CONTROLLERS; entities?: ENTITIES; repositories?: REPOSITORIES; providers?: PROVIDERS; - session?: SessionExposed; + session?: ISession; productionMode?: boolean; + database?: boolean | DatabaseConfig; + disabledRealtime?: boolean; + https?: { + key: string; + cert: string; + }; + publicAssets?: { serverPath: string; locationOnDisk: string; }[]; + } //#endregion diff --git a/src/lib/realtime/broadcast-api-io-mock-client.ts b/src/lib/realtime/broadcast-api-io-mock-client.ts new file mode 100644 index 00000000..0602dca2 --- /dev/null +++ b/src/lib/realtime/broadcast-api-io-mock-client.ts @@ -0,0 +1,82 @@ +import { Level, Log } from 'ng2-logger/src'; +import { _ } from 'tnp-core/src'; +import { BroadcastApiClient, BroadcastApiIoOptions, BroadcastApiIoOptionsClient } from './broadcast-api-io.models'; +import { IsomorphicBroadCastChannel } from './broadcast-channel-dummy'; +const log = Log.create('[CLIENT] broadcast api mock', + Level.__NOTHING +); + +export class BroadcastApiIoMockClient { + private static _instaceKey(origin: string, pathname: string) { + // return `${origin}${pathname}`; // TODO quick fix + return origin; + } + + private static _instances = {}; + static _isntanceBy(origin: string, options?: BroadcastApiIoOptionsClient) { + const key = BroadcastApiIoMockClient._instaceKey( + origin, + ''// options?.path + ); + if (!BroadcastApiIoMockClient._instances[key]) { + BroadcastApiIoMockClient._instances[key] = new BroadcastApiIoMockClient( + origin, + options + ); + } + return BroadcastApiIoMockClient._instances[key] as BroadcastApiIoMockClient; + } + + static connect(origin: string, options?: BroadcastApiIoOptionsClient) { + return BroadcastApiIoMockClient._isntanceBy(origin, options); + } + + public allowedToListenRooms: string[] = []; + + private readonly _url: URL; + /** + * path name of url.. examples: + * / + * /something + * + */ + readonly nsp: string; + + private constructor(originUrl: string, options?: BroadcastApiIoOptionsClient) { + this._url = new URL(originUrl); + this.nsp = this._url.pathname; + } + + on(roomNameAsEvent: 'connect' | string, callback: (dataFromServer?: any) => any) { + if (roomNameAsEvent === 'connect') { + setTimeout(() => { + callback(); + }) + return; + } + + const room = IsomorphicBroadCastChannel.for(roomNameAsEvent, this._url.href) + room.onmessage = (messageEvent: MessageEvent) => { + + if (this.allowedToListenRooms.includes(roomNameAsEvent)) { + // log.i('PUSHING' + JSON.stringify({ data: messageEvent?.data })) + callback(messageEvent?.data); + } else { + // log.i('NOT PUSHING') + } + + } + } + + emit(roomNameForSubOrUnsub: string, data: any) { + const room = IsomorphicBroadCastChannel.for( + roomNameForSubOrUnsub, + this._url.href, + ); + // log.i('emit data', data) + room.postMessage(data); + } + +} + +export const mockIoClient = BroadcastApiIoMockClient as Pick; diff --git a/src/lib/realtime/broadcast-api-io-mock-server.ts b/src/lib/realtime/broadcast-api-io-mock-server.ts new file mode 100644 index 00000000..7da6a6a1 --- /dev/null +++ b/src/lib/realtime/broadcast-api-io-mock-server.ts @@ -0,0 +1,121 @@ +//#region import +import { Level, Log } from 'ng2-logger/src'; +import { _ } from 'tnp-core/src'; +import { Symbols } from '../symbols'; +import { BroadcastApiIoMockClient } from './broadcast-api-io-mock-client'; +import { BroadcastApiIoOptions } from './broadcast-api-io.models'; +import { IsomorphicBroadCastChannel } from './broadcast-channel-dummy'; +//#endregion + +const log = Log.create('[SERVER] broadcast api mock', + Level.__NOTHING +) + + +//#region server - namespace +export class BroadcastApiIoMockServerSocket { + + constructor( + public readonly server: BroadcastApiIoMockServer + ) { + + } + + private get currentClient() { + + const ins = BroadcastApiIoMockClient._isntanceBy( + this.server._url.origin, + { path: this.server._url.pathname }, + ); + return ins; + } + + get nsp() { + return { + get name() { + return '/'; /// I am actuall not using namespaces + } + } + } + + + /** + * Backend gets notyfications from client + */ + on(roomNameToListen: string, callback: (roomNameForEvents: string) => any) { + + if ([ + Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.CUSTOM, + Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.ENTITY_UPDATE_EVENTS, + Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.ENTITY_PROPERTY_UPDATE_EVENTS, + Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.CUSTOM, + Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.ENTITY_UPDATE_EVENTS, + Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.ENTITY_PROPERTY_UPDATE_EVENTS, + ].includes(roomNameToListen)) { + const room = IsomorphicBroadCastChannel.for( + roomNameToListen, + this.server._url.href, + ); + room.onmessage = (e) => { + callback(e.data); + }; + } + } + + join(roomName: string) { + + this.currentClient.allowedToListenRooms.push(roomName); + } + + leave(roomName: string) { + const roomToClose = IsomorphicBroadCastChannel.for(roomName, this.server._url.href); + if (roomToClose) { + roomToClose.close(); + this.currentClient.allowedToListenRooms = this.currentClient.allowedToListenRooms + .filter(allowedRoomTolisten => allowedRoomTolisten !== roomName); + } + + } +} +//#endregion + +//#region server +export class BroadcastApiIoMockServer { + readonly _url: URL; + + path() { + return this._url.pathname; + } + + readonly socket: BroadcastApiIoMockServerSocket; + constructor(httpServer: any, options?: BroadcastApiIoOptions) { + //#region @browser + this._url = new URL(options.href); + // this.nsp = _.camelCase(options.path).toLowerCase(); + this.socket = new BroadcastApiIoMockServerSocket(this); + //#endregion + } + + on(action: 'connection' | string, callback: (socket: BroadcastApiIoMockServerSocket) => any) { + setTimeout(() => { + callback(this.socket); + }); + } + + in(roomName: string) { + return { + // eventName=roomName + emit: (eventName: string, data: any) => { + const room = IsomorphicBroadCastChannel.for(roomName, this._url.href) + room.postMessage(data); + } + } + } +} +//#endregion + +//#region io +export const mockIoServer = (httpServer: any, options?: BroadcastApiIoOptions) => { + return new BroadcastApiIoMockServer(httpServer, options) +}; +//#endregion diff --git a/src/lib/realtime/broadcast-api-io.models.ts b/src/lib/realtime/broadcast-api-io.models.ts new file mode 100644 index 00000000..4371083f --- /dev/null +++ b/src/lib/realtime/broadcast-api-io.models.ts @@ -0,0 +1,27 @@ +export class BroadcastApiClient { + static from(callback?) { + const ins = new BroadcastApiClient(callback); + return ins; + } + + constructor( + protected callback: () => any, + ) { + + } +} + +export interface BroadcastApiIoOptionsClient { + path: string, +} + + +export interface BroadcastApiIoOptions extends BroadcastApiIoOptionsClient { + + //#region @browser + /** + * only for websql mode + */ + href: string; + //#endregion +} diff --git a/src/lib/realtime/broadcast-channel-dummy.ts b/src/lib/realtime/broadcast-channel-dummy.ts new file mode 100644 index 00000000..e1dfccf3 --- /dev/null +++ b/src/lib/realtime/broadcast-channel-dummy.ts @@ -0,0 +1,80 @@ +import { _ } from 'tnp-core/src'; +import { Log, Level } from "ng2-logger/src"; +import { Observable, Subject, Subscription } from "rxjs"; +//#region @backend +import { URL } from 'url'; +//#endregion +const log = Log.create('[CLIENT/SERVER] broadcast dummy channel', + Level.__NOTHING +); + +export class IsomorphicBroadCastChannel { + + public static hosts: { + [serverHref: string]: { + events: { [eventName: string]: IsomorphicBroadCastChannel; } + } + } = {}; + + static for(eventName: string, serverHref: string) { + const url = new URL(serverHref); + // TODO QUICK FIX + serverHref = url.origin; + if (!IsomorphicBroadCastChannel.hosts[serverHref]) { + IsomorphicBroadCastChannel.hosts[serverHref] = { + events: {} + }; + } + + if (!IsomorphicBroadCastChannel.hosts[serverHref].events[eventName]) { + IsomorphicBroadCastChannel.hosts[serverHref].events[eventName] = new IsomorphicBroadCastChannel( + serverHref, + eventName, + ); + } + const event = IsomorphicBroadCastChannel.hosts[serverHref].events[eventName] as IsomorphicBroadCastChannel; + return event; + } + + private callbacks = []; + + public set onmessage(callback: (a: MessageEvent) => any) { + this.callbacks.push(callback); + } + + private sub = new Subject(); + private subscribtion: Subscription; + + private constructor( + public readonly serverHref: string, + public readonly eventName: string + ) { + // log.info(`Creating room for event: ${eventName} on server href: ${serverHref}`); + this.subscribtion = (this.sub as Observable).subscribe((data) => { + // log.info(`NEW SUBSCRIBE DATA ${eventName} / ${serverHref}`, { + // calbacksCount: this.callbacks.length, + // rooms: IsomorphicBroadCastChannel.hosts, + // }); + this.callbacks.forEach(callback => { + // log.info(`Trigger callback ${eventName} / ${serverHref}`); + // console.log({ callback }) + callback({ + data + }) + }); + }) + } + + postMessage(data) { + setTimeout(() => { + this.sub.next(data); + }); + } + + close() { + // log.info('closing'); + this.subscribtion.unsubscribe() + delete IsomorphicBroadCastChannel.hosts[this.serverHref].events[this.eventName]; + } + +} diff --git a/src/lib/realtime/index.ts b/src/lib/realtime/index.ts new file mode 100644 index 00000000..034bd87e --- /dev/null +++ b/src/lib/realtime/index.ts @@ -0,0 +1,13 @@ +import { RealtimeBase } from './realtime'; +//#region @websql +import { RealtimeNodejs } from './realtime-nodejs'; +//#endregion +import { RealtimeBrowserRxjs } from './realtime-browser-rxjs'; + +export { RealtimeBase } from './realtime'; +//#region @websql +export { RealtimeNodejs } from './realtime-nodejs'; +//#endregion + +export { RealtimeBrowserRxjs } from './realtime-browser-rxjs'; + diff --git a/src/lib/realtime/realtime-browser-rxjs.ts b/src/lib/realtime/realtime-browser-rxjs.ts new file mode 100644 index 00000000..cac8084b --- /dev/null +++ b/src/lib/realtime/realtime-browser-rxjs.ts @@ -0,0 +1,203 @@ +import { Level, Log } from "ng2-logger/src"; +import { RealtimeBase } from "./realtime"; +import { Helpers, _ } from 'tnp-core/src'; +import * as ioClientIo from 'socket.io-client'; +import { Symbols } from "../symbols"; +import { Observable, Subscriber } from 'rxjs'; +import { RealtimeSubsManager, SubscribtionRealtime } from "./realtime-subs-manager"; +import { EndpointContext } from '../endpoint-context' +import { ClassHelpers } from '../helpers/class-helpers'; +//#region @websql +import { mockIoClient } from "./broadcast-api-io-mock-client"; +//#endregion + +const log = Log.create('REALTIME RXJS', + Level.__NOTHING +); + + + +export type RealtimeBrowserRxjsOptions = { + property?: string + overrideContext?: EndpointContext; + customEvent?: string; +}; + + +export class RealtimeBrowserRxjs { + + //#region constructor + constructor(public context: EndpointContext) { + + const base = RealtimeBase.by(context); + // if (Helpers.isElectron) { + // return; + // } + // Helpers.log('INITING SOCKETS') + if (!context.disabledRealtime) { + + let io + //#region @websql + : typeof mockIoClient + //#endregion + = ioClientIo?.default ? ioClientIo.default : ioClientIo; + + if (Helpers.isWebSQL) { + // @ts-ignore + io = mockIoClient; + } + + const nspPath = { + global: base.pathFor(), + realtime: base.pathFor(Symbols.old.REALTIME.NAMESPACE) + }; + + log.i('[CLIENT] NAMESPACE GLOBAL ', nspPath.global.href + ` host: ${context.host}`) + log.i('[CLIENT] NAMESPACE REALTIME', nspPath.realtime.href + ` host: ${context.host}`) + + const global = io.connect(nspPath.global.origin, { + path: nspPath.global.pathname + }); + + base.FE = global as any; + + global.on('connect', () => { + log.i(`[CLIENT] conented to GLOBAL namespace ${global.nsp} of host: ${context.host}`) + }); + // log.i('[CLIENT] IT SHOULD CONNECT TO GLOBAL') + + + const realtime = io.connect(nspPath.realtime.origin, { + path: nspPath.realtime.pathname + }) as any; + + base.FE_REALTIME = realtime; + + realtime.on('connect', () => { + log.i(`[CLIENT] conented to REALTIME namespace ${realtime.nsp} host: ${context.host}`) + }); + + // log.i('IT SHOULD CONNECT TO REALTIME') + } + // Helpers.log('INITING SOCKETS DONE') + + + } + //#endregion + + //#region listen changes entity + /** + * Changes trigger on backend needs to be done manually.. example code: + * + * ... + * Firedev.Realtime.Server.TrigggerEntityChanges(myEntityInstance); + * ... + */ + static listenChangesEntity(entityClassFn, idOrUniqValue: any, options?: RealtimeBrowserRxjsOptions) { + options = options || {} as any; + + //#region parameters validation + const { property, customEvent } = options; + const className = !customEvent && ClassHelpers.getName(entityClassFn); + + if (_.isString(property)) { + if (property.trim() === '') { + throw new Error(`[Firedev][listenChangesEntity.. incorect property '' for ${className}`); + } + } + //#endregion + + return new Observable((observer) => { + + //#region prepare parameters for manager + const context = options.overrideContext + ? options.overrideContext + : EndpointContext.findForTraget(entityClassFn); + + if (context.disabledRealtime) { + Helpers.error(`[Firedev][realtime rxjs] remove firedev config flag: + + ... + disabledRealtime: true + ... + +to use socket realtime connection; + `) + return () => { + // empty nothing to do + }; + } + const base = RealtimeBase.by(context); + const realtime = base.FE_REALTIME; + let roomName: string; + + if (customEvent) { + roomName = Symbols.old.REALTIME.ROOM_NAME.CUSTOM(customEvent); + } else { + roomName = _.isString(property) ? + Symbols.old.REALTIME.ROOM_NAME.UPDATE_ENTITY_PROPERTY(className, property, idOrUniqValue) : + Symbols.old.REALTIME.ROOM_NAME.UPDATE_ENTITY(className, idOrUniqValue); + } + + const roomSubOptions: SubscribtionRealtime = { + context, + property, + roomName, + customEvent, + }; + //#endregion + + const inst = RealtimeSubsManager.from(roomSubOptions); + inst.add(observer); + + inst.startListenIfNotStarted(realtime); + + return () => { + inst.remove(observer); + }; + }); + } + + //#endregion + + //#region listen changes entity table + static listenChangesEntityTable(entityClassFn: Function) { + const className = ClassHelpers.getName(entityClassFn); + return RealtimeBrowserRxjs.listenChangesEntity(entityClassFn, void 0, { + customEvent: Symbols.old.REALTIME.TABLE_CHANGE(className), + }) + } + + //#endregion + + //#region listen change entity object + /** + * Changes trigger on backend needs to be done manually.. example code: + * + * ... + * Firedev.Realtime.Server.TrigggerEntityChanges(myEntityInstance); + * // or + * Firedev.Realtime.Server.TrigggerEntityPropertyChanges(myEntityInstance,{ property: 'geolocationX' }); + * ... + */ + static listenChangesEntityObj(entity, options?: RealtimeBrowserRxjsOptions) { + const classFn = ClassHelpers.getClassFnFromObject(entity); + const uniqueKey = ClassHelpers.getUniquKey(classFn); + return RealtimeBrowserRxjs.listenChangesEntity(classFn, entity[uniqueKey], options); + } + //#endregion + + //#region listen changes custom event + static listenChangesCustomEvent(context: EndpointContext, customEvent: string) { + return RealtimeBrowserRxjs.listenChangesEntity(void 0, void 0, { + overrideContext: context, + customEvent, + }); + } + + listenChangesCustomEvent(customEvent: string) { + return RealtimeBrowserRxjs.listenChangesCustomEvent(this.context, customEvent); + } + //#endregion + +} diff --git a/src/lib/realtime/realtime-nodejs.ts b/src/lib/realtime/realtime-nodejs.ts new file mode 100644 index 00000000..7b0f6e47 --- /dev/null +++ b/src/lib/realtime/realtime-nodejs.ts @@ -0,0 +1,261 @@ +//#region imports +//#region @backend +import * as ioSocketIo from 'socket.io'; +//#endregion +import { _ } from 'tnp-core/src'; +import { Symbols } from '../symbols'; +import { Log, Level } from 'ng2-logger/src'; +import { Helpers } from 'tnp-core/src'; +import { RealtimeBase } from './realtime'; +import { CLASS } from 'typescript-class-helpers/src'; + +import { mockIoServer } from './broadcast-api-io-mock-server'; +import { EndpointContext } from '../endpoint-context'; +//#endregion + +//#region consts +const log = Log.create('RealtimeNodejs', + Level.__NOTHING +); + +const SOCKET_EVENT_DEBOUNCE = 500; +//#endregion + +export class RealtimeNodejs { + + private static jobs = {}; + + //#region constructor + constructor(private context: EndpointContext) { + //#region @websql + const base = RealtimeBase.by(context); + // if (Helpers.isElectron) { + // return; + // } + if (!context.disabledRealtime) { + + // @ts-ignore; + let io: (typeof mockIoServer) + //#region @backend + = ioSocketIo + // #endregion + + if (Helpers.isWebSQL) { + // @ts-ignore + io = mockIoServer; + } + + const nspPath = { + global: base.pathFor(), + realtime: base.pathFor(Symbols.old.REALTIME.NAMESPACE) + }; + + base.BE = io(context.serverTcpUdp, { + path: nspPath.global.pathname, + //#region @browser + href: nspPath.global.href, + //#endregion + }); + + const ioGlobalNsp = base.BE; + + ioGlobalNsp.on('connection', (clientSocket) => { + log.i(`client conected to namespace "${clientSocket.nsp.name}", host: ${context.host}`) + }) + + log.i(`CREATE GLOBAL NAMESPACE: '${ioGlobalNsp.path()}' , path: '${nspPath.global.pathname}'`) + + const ioRealtimeNsp = io(context.serverTcpUdp, { + path: nspPath.realtime.pathname, + //#region @browser + href: nspPath.realtime.href, + //#endregion + }); + + + log.i(`CREATE REALTIME NAMESPACE: '${ioRealtimeNsp.path()}' , path: '${nspPath.realtime.pathname}' `) + + base.BE_REALTIME = ioRealtimeNsp as any; + + ioRealtimeNsp.on('connection', (backendSocketForClient) => { + log.i(`client conected to namespace "${backendSocketForClient.nsp.name}", host: ${context.host}`) + + backendSocketForClient.on(Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.CUSTOM, roomName => { + log.i(`Joining room ${roomName} in namespace REALTIME` + ` host: ${context.host}`) + backendSocketForClient.join(roomName); + }); + + backendSocketForClient.on(Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.ENTITY_UPDATE_EVENTS, roomName => { + log.i(`Joining room ${roomName} in namespace REALTIME` + ` host: ${context.host}`) + backendSocketForClient.join(roomName); + }); + + backendSocketForClient.on(Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.ENTITY_PROPERTY_UPDATE_EVENTS, roomName => { + log.i(`Joining room ${roomName} in namespace REALTIME ` + ` host: ${context.host}`) + backendSocketForClient.join(roomName); + }); + + backendSocketForClient.on(Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.CUSTOM, roomName => { + log.i(`Leaving room ${roomName} in namespace REALTIME` + ` host: ${context.host}`) + backendSocketForClient.leave(roomName); + }); + + backendSocketForClient.on(Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.ENTITY_UPDATE_EVENTS, roomName => { + log.i(`Leaving room ${roomName} in namespace REALTIME ` + ` host: ${context.host}`) + backendSocketForClient.leave(roomName); + }); + + backendSocketForClient.on(Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.ENTITY_PROPERTY_UPDATE_EVENTS, roomName => { + log.i(`Leaving room ${roomName} in namespace REALTIME ` + ` host: ${context.host}`) + backendSocketForClient.leave(roomName); + }); + + }) + } + //#endregion + } + //#endregion + + //#region trigger entity changes + + private static __TrigggerEntityChanges( + context: EndpointContext, + entityObjOrClass: Function, + property?: string, + valueOfUniquPropery?: number | string, + customEvent?: string, + customEventData?: any, + ) { + log.i('__triger entity changes') + //#region @websql + + const base = RealtimeBase.by(context); + let roomName: string; + + if (context.disabledRealtime) { + return; + } + + if (customEvent) { + roomName = Symbols.old.REALTIME.ROOM_NAME.CUSTOM(customEvent); + } else { + + let entityFn = entityObjOrClass as Function; + const enittyIsObject = (!_.isFunction(entityObjOrClass) && _.isObject(entityObjOrClass)); + + if (enittyIsObject) { + entityFn = CLASS.getBy(CLASS.getNameFromObject(entityObjOrClass)) as any; + } + const config = CLASS.getConfig(entityFn); + const uniqueKey = config.uniqueKey; + + if (enittyIsObject) { + valueOfUniquPropery = entityObjOrClass[uniqueKey]; + } + + if (!valueOfUniquPropery) { + Helpers.error(`[Firedev][Realtime] Entity without iD ! ${config.className} `, true, true); + return; + } + + roomName = _.isString(property) ? + Symbols.old.REALTIME.ROOM_NAME.UPDATE_ENTITY_PROPERTY(config.className, property, valueOfUniquPropery) : + Symbols.old.REALTIME.ROOM_NAME.UPDATE_ENTITY(config.className, valueOfUniquPropery); + + } + + const job = () => { + // console.log(`Trigger realtime: ${roomName}`) + base.BE_REALTIME.in(roomName).emit(roomName, // roomName == eventName in room na + customEventData ? customEventData : '' + ); + } + + if (!_.isFunction(RealtimeNodejs.jobs[roomName])) { + RealtimeNodejs.jobs[roomName] = _.debounce(() => { + job() + }, SOCKET_EVENT_DEBOUNCE); + } + + RealtimeNodejs.jobs[roomName](); + //#endregion + } + + public static TrigggerEntityChanges(entityObjOrClass: Function, idToTrigger?: number | string) { + const context = EndpointContext.findForTraget(entityObjOrClass); + if (context.disabledRealtime) { + const className = _.isFunction(entityObjOrClass) + ? CLASS.getName(entityObjOrClass) + : CLASS.getNameFromObject(entityObjOrClass); + + console.warn(`[Firedev][TrigggerEntityChanges] Entity "${className}' is not realtime`); + return; + } + RealtimeNodejs.__TrigggerEntityChanges(context, entityObjOrClass as any, void 0, idToTrigger); + } + //#endregion + + //#region trigger entity property changes + public static TrigggerEntityPropertyChanges( + entityObjOrClass: Function, + property: (keyof ENTITY) | (keyof ENTITY)[], + idToTrigger?: number | string, + ) { + const context = EndpointContext.findForTraget(entityObjOrClass); + if (context.disabledRealtime) { + const className = _.isFunction(entityObjOrClass) + ? CLASS.getName(entityObjOrClass) + : CLASS.getNameFromObject(entityObjOrClass); + + // @ts-ignore + console.warn(`[Firedev][TrigggerEntityPropertyChanges][property=${property}] Entity "${className}' is not realtime`); + return; + } + + if (_.isArray(property)) { + property.forEach(propertyFromArr => { + RealtimeNodejs.__TrigggerEntityChanges(context, entityObjOrClass, propertyFromArr as any, idToTrigger) + }) + } else { + RealtimeNodejs.__TrigggerEntityChanges(context, entityObjOrClass, property as any, idToTrigger) + }; + } + //#endregion + + //#region trigger custom event + public triggerCustomEvent(customEvent: string, dataToPush: any) { + RealtimeNodejs.TrigggerCustomEvent(this.context, customEvent, dataToPush); + } + + public static TrigggerCustomEvent(context: EndpointContext, customEvent: string, dataToPush: any) { + RealtimeNodejs.__TrigggerEntityChanges(context, void 0, void 0, void 0, customEvent, dataToPush); + } + //#endregion + + //#region trigger entity table changes + public static TrigggerEntityTableChanges(entityClass: Function) { + + const context = EndpointContext.findForTraget(entityClass); + const className = CLASS.getName(entityClass) + if (context.disabledRealtime) { + console.warn(`[Firedev][TrigggerEntityTableChanges] Entity "${className}' is not realtime`); + return; + } + + RealtimeNodejs.__TrigggerEntityChanges( + context, + entityClass as any, + void 0, void 0, + Symbols.old.REALTIME.TABLE_CHANGE(className), + ); + } + + public TrigggerEntityTableChanges(entityClass: Function) { + RealtimeNodejs.TrigggerEntityTableChanges(entityClass); + } + + + //#endregion + +} +//#endregion diff --git a/src/lib/realtime/realtime-subs-manager.ts b/src/lib/realtime/realtime-subs-manager.ts new file mode 100644 index 00000000..b8f7669a --- /dev/null +++ b/src/lib/realtime/realtime-subs-manager.ts @@ -0,0 +1,127 @@ +import * as _ from 'lodash'; +import { Level, Log } from 'ng2-logger/src'; +import { Subscriber } from "rxjs"; +import { Helpers } from 'tnp-core/src'; +import { Symbols } from "../symbols"; +import type { BroadcastApiIoMockClient } from './broadcast-api-io-mock-client'; +import { EndpointContext } from '../endpoint-context'; +import { RealtimeBase } from "./realtime"; +//#region @backend +import { URL } from 'url'; +//#endregion + +const log = Log.create('REALTIME SUBS MANAGER', + Level.__NOTHING +) + +export type SubscribtionRealtime = { + context: EndpointContext; + customEvent: string; + roomName: string; + property: string; +} +export class RealtimeSubsManager { + + private static idFrm(options: SubscribtionRealtime) { + const url = new URL(options.context.host); + return `${url.origin}|${options.roomName}|${options.property}|${options.customEvent}`; + } + private static roomSubs = {}; + public static from(options: SubscribtionRealtime) { + const pathToInstance = RealtimeSubsManager.idFrm(options); + if (!RealtimeSubsManager.roomSubs[pathToInstance]) { + RealtimeSubsManager.roomSubs[pathToInstance] = new RealtimeSubsManager(options); + } + return RealtimeSubsManager.roomSubs[pathToInstance] as RealtimeSubsManager; + } + + private isListening = false; + private constructor(private options: SubscribtionRealtime) { } + + private observers: Subscriber[] = [] + + startListenIfNotStarted(realtime: BroadcastApiIoMockClient) { + + if (this.options.context.disabledRealtime) { + console.warn(`[Firedev][startListenIfNotStarted] sockets are disabled`) + return; + } + + if (!realtime) { + console.warn(`[Firedev][startListenIfNotStarted] invalid socket connection`) + return; + } + + if (!this.isListening) { + + log.i(`subscribe to ${this.options?.roomName}`, this.options) + this.isListening = true; + + if (this.options.customEvent) { // this means: send to current client custom event notification + realtime.emit(Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.CUSTOM, this.options.roomName); + } else { + if (_.isString(this.options.property)) { + // this means: send to current client entity property events updates + realtime.emit(Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.ENTITY_PROPERTY_UPDATE_EVENTS, this.options.roomName); + } else { + // this means: send to current client entity update events + realtime.emit(Symbols.old.REALTIME.ROOM_NAME.SUBSCRIBE.ENTITY_UPDATE_EVENTS, this.options.roomName); + } + } + + // subPath -> SYMBOL - (customevnet|entityupdatebyid){..}{..} + realtime.on(this.options.roomName, (data) => { + + this.update(data); + }); + } + } + + add(observer: Subscriber) { + // log.info('Add observer') + this.observers.push(observer); + } + + remove(observer: Subscriber) { + // log.info('Remove observer') + this.observers = this.observers.filter(obs => obs !== observer); + if (this.observers.length === 0) { + // log.info('Emit unsubscribe to server SERVER') + this.isListening = false; + const { context, customEvent, roomName, property } = this.options; + const base = RealtimeBase.by(context); + const realtime = base.FE_REALTIME; + + if (customEvent) { + realtime.emit(Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.CUSTOM, roomName) + } else { + if (_.isString(property)) { + realtime.emit(Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.ENTITY_PROPERTY_UPDATE_EVENTS, roomName) + } else { + realtime.emit(Symbols.old.REALTIME.ROOM_NAME.UNSUBSCRIBE.ENTITY_UPDATE_EVENTS, roomName) + } + } + } + } + + private update(data: any) { + + // log.data(`realtime update!!!!! observers=${this.observers?.length} `) + const ngZone = this.options.context.ngZone; + // console.log('updating', data); + // console.log('ngzone', ngZone); + this.observers.forEach(observer => { + // console.log(`observer closed: ${observer.closed}`,observer); + if (!observer.closed) { + if (ngZone) { + ngZone.run(() => { + observer.next(data); + }) + } else { + observer.next(data); + } + } + }); + } + +} diff --git a/src/lib/realtime/realtime.ts b/src/lib/realtime/realtime.ts new file mode 100644 index 00000000..31def621 --- /dev/null +++ b/src/lib/realtime/realtime.ts @@ -0,0 +1,46 @@ +import { Socket } from 'socket.io'; +import * as socketio from 'socket.io'; + +import type { BroadcastApiIoMockServer } from './broadcast-api-io-mock-server'; +import type { BroadcastApiIoMockClient } from './broadcast-api-io-mock-client'; +import { EndpointContext } from '../endpoint-context'; + +export class RealtimeBase { + + private static contexts = []; + private static instances = []; + public static by(context: EndpointContext): RealtimeBase { + const indexContext = this.contexts.findIndex(c => c === context); + if (indexContext === -1) { + this.contexts.push(context); + const instance = new RealtimeBase(context) + this.instances.push(instance); + return instance; + } else { + return this.instances[indexContext]; + } + } + + public FE: BroadcastApiIoMockClient; // Socket; // TODO QUICK_FIX + public FE_REALTIME: BroadcastApiIoMockClient; // Socket; // TODO QUICK_FIX; + //#region @websql + public BE: BroadcastApiIoMockServer; // socketio.Server; + public BE_REALTIME: BroadcastApiIoMockServer; // socketio.Namespace; + //#endregion + + + private constructor(protected context: EndpointContext) { + + } + + public pathFor(namespace?: string) { + const uri = this.context.uri; + const nsp = namespace ? namespace : ''; + const pathname = uri.pathname !== '/' ? uri.pathname : ''; + const prefix = `socketnodejs`; + const href = `${uri.origin}${pathname}/${prefix}${nsp}`; + // console.log(`HREF: ${href}`) + return new URL(href) as URL; + } + +} diff --git a/src/lib/validators.ts b/src/lib/validators.ts index c547e9d5..1e7cfdb7 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -2,6 +2,8 @@ import { _ } from 'tnp-core/src' import { Models } from './models'; export namespace Validators { + + //#region vlidate class name export const classNameVlidation = (className, target: Function) => { setTimeout(() => { @@ -29,7 +31,9 @@ export namespace Validators { return _.isUndefined(className) ? target.name : className; } + //#endregion + //#region validate method config export const checkIfMethodsWithReponseTYpeAlowed = (methods: Models.MethodConfig[], current: Models.MethodConfig) => { const defaultResponseType = 'text or JSON' @@ -51,5 +55,29 @@ export namespace Validators { } } } + //#endregion + + //#region validate class functions + // TODO + export const validateClassFunctions = (controllers: any[], entities: any[], proviers: any[], repositories: any[]) => { + + if (_.isArray(controllers) && controllers.filter(f => !_.isFunction(f)).length > 0) { + console.error('controllers', controllers) + throw ` + + Incorect value for property "controllers" inside Firedev.Init(...) + + ` + } + + if (_.isArray(entities) && entities.filter(f => !_.isFunction(f)).length > 0) { + console.error('entites', entities) + throw ` + + Incorect value for property "entities" inside Firedev.Init(...) + ` + } + } + //#endregion }