diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b49d4..44716d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added +- Implement proxied services, allowing dynamic service replacement at runtime, closes #5. ## 0.0.8 - 2026-01-24 diff --git a/core/.gitignore b/core/.gitignore index 0d334be..531b9eb 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -10,6 +10,7 @@ yarn.lock # Coverage coverage/ +.local/ # Transpiled files dist/ diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..eef217d --- /dev/null +++ b/core/README.md @@ -0,0 +1,10 @@ +# Ember Nexus: App Core + +The app core library provides the foundational runtime components and definitions that enable modular and extensible +plugin integration. +It is programmed in a framework-agnostic way. + +## Quick Links + +- [Check out the documentation](https://ember-nexus.github.io/app-core) +- [Find us on NPM](https://www.npmjs.com/package/@ember-nexus/app-core) diff --git a/core/src/Service/ServiceResolver.ts b/core/src/Service/ServiceResolver.ts index 239a603..e64bb9d 100644 --- a/core/src/Service/ServiceResolver.ts +++ b/core/src/Service/ServiceResolver.ts @@ -1,34 +1,90 @@ -import { ServiceIdentifier } from '../Type/Definition/index.js'; +import { ProxyObject, ServiceIdentifier } from '../Type/Definition/index.js'; class ServiceResolver { - private readonly services: Map = new Map(); + private readonly services: Map> = new Map(); constructor() {} + createServiceProxy(initialTarget: T): ProxyObject { + let currentTarget: T = initialTarget; + + const proxy = new Proxy({} as T, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(_target, propertyKey, receiver): any { + const value = Reflect.get(currentTarget, propertyKey, receiver); + if (typeof value === 'function') { + return value.bind(currentTarget); + } + return value; + }, + set(_target, propertyKey, value): boolean { + return Reflect.set(currentTarget, propertyKey, value); + }, + has(_target, propertyKey): boolean { + return propertyKey in currentTarget; + }, + ownKeys(): (string | symbol)[] { + return Reflect.ownKeys(currentTarget); + }, + getOwnPropertyDescriptor(_target, propertyKey): PropertyDescriptor | undefined { + const descriptor = Object.getOwnPropertyDescriptor(currentTarget, propertyKey); + if (!descriptor) return undefined; + return { + ...descriptor, + configurable: true, + }; + }, + defineProperty(_target, propertyKey, descriptor): boolean { + return Reflect.defineProperty(currentTarget, propertyKey, descriptor); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getPrototypeOf(): any { + return Object.getPrototypeOf(currentTarget); + }, + }); + + return { + proxy, + targetReplaceFunction(newTarget: T): void { + currentTarget = newTarget; + }, + }; + } + hasService(serviceIdentifier: ServiceIdentifier): boolean { return this.services.has(String(serviceIdentifier)); } - getService(serviceIdentifier: ServiceIdentifier): null | T { - if (!this.hasService(String(serviceIdentifier))) { + getService(serviceIdentifier: ServiceIdentifier): null | T { + const serviceEntry = this.services.get(String(serviceIdentifier)); + if (serviceEntry === undefined) { return null; } - return this.services.get(String(serviceIdentifier)) as T; + return serviceEntry.proxy as T; } - getServiceOrFail(serviceIdentifier: ServiceIdentifier): T { - if (!this.hasService(String(serviceIdentifier))) { + getServiceOrFail(serviceIdentifier: ServiceIdentifier): T { + const serviceEntry = this.services.get(String(serviceIdentifier)); + if (serviceEntry === undefined) { throw new Error(`Requested service with identifier ${String(serviceIdentifier)} could not be resolved.`); } - return this.services.get(String(serviceIdentifier)) as T; + return serviceEntry.proxy as T; } - setService(serviceIdentifier: ServiceIdentifier, service: unknown): ServiceResolver { - this.services.set(String(serviceIdentifier), service); + setService(serviceIdentifier: ServiceIdentifier, service: object): ServiceResolver { + let serviceEntry = this.services.get(String(serviceIdentifier)); + if (serviceEntry === undefined) { + serviceEntry = this.createServiceProxy(service); + this.services.set(String(serviceIdentifier), serviceEntry); + return this; + } + serviceEntry.targetReplaceFunction(service); return this; } deleteService(serviceIdentifier: ServiceIdentifier): ServiceResolver { + // todo: add warning that deleting and re-defining identical service identifiers will lead to issues + // todo: add warning that already queried services will remain access to deleted service this.services.delete(String(serviceIdentifier)); return this; } @@ -38,7 +94,7 @@ class ServiceResolver { } getServices(): unknown[] { - return [...this.services.values()]; + return [...this.services.values()].map((service) => service.proxy); } clearServices(): ServiceResolver { diff --git a/core/src/Type/Definition/Proxy.ts b/core/src/Type/Definition/Proxy.ts new file mode 100644 index 0000000..ed01e96 --- /dev/null +++ b/core/src/Type/Definition/Proxy.ts @@ -0,0 +1,7 @@ +type ProxyReplaceFunction = (newTarget: T) => void; +type ProxyObject = { + proxy: T; + targetReplaceFunction: ProxyReplaceFunction; +}; + +export { ProxyObject, ProxyReplaceFunction }; diff --git a/core/src/Type/Definition/index.ts b/core/src/Type/Definition/index.ts index 43eb64f..36b01b0 100644 --- a/core/src/Type/Definition/index.ts +++ b/core/src/Type/Definition/index.ts @@ -15,6 +15,7 @@ export * from './Node.js'; export * from './NodeWithOptionalId.js'; export * from './OptionalPromise.js'; export * from './PriorityRegistry.js'; +export * from './Proxy.js'; export * from './Registry.js'; export * from './Relation.js'; export * from './RelationWithOptionalId.js';