Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions core/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ yarn.lock

# Coverage
coverage/
.local/

# Transpiled files
dist/
Expand Down
10 changes: 10 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
@@ -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)
78 changes: 67 additions & 11 deletions core/src/Service/ServiceResolver.ts
Original file line number Diff line number Diff line change
@@ -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<ServiceIdentifier, unknown> = new Map();
private readonly services: Map<ServiceIdentifier, ProxyObject<object>> = new Map();

constructor() {}

createServiceProxy<T extends object>(initialTarget: T): ProxyObject<T> {
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<T = unknown>(serviceIdentifier: ServiceIdentifier): null | T {
if (!this.hasService(String(serviceIdentifier))) {
getService<T = object>(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<T = unknown>(serviceIdentifier: ServiceIdentifier): T {
if (!this.hasService(String(serviceIdentifier))) {
getServiceOrFail<T = object>(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;
}
Expand All @@ -38,7 +94,7 @@ class ServiceResolver {
}

getServices(): unknown[] {
return [...this.services.values()];
return [...this.services.values()].map((service) => service.proxy);
}

clearServices(): ServiceResolver {
Expand Down
7 changes: 7 additions & 0 deletions core/src/Type/Definition/Proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type ProxyReplaceFunction<T extends object> = (newTarget: T) => void;
type ProxyObject<T extends object> = {
proxy: T;
targetReplaceFunction: ProxyReplaceFunction<T>;
};

export { ProxyObject, ProxyReplaceFunction };
1 change: 1 addition & 0 deletions core/src/Type/Definition/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down