feat(infra): framework

This commit is contained in:
EYHN
2024-04-17 14:12:29 +08:00
parent ab17a05df3
commit 06fda3b62c
467 changed files with 9996 additions and 8697 deletions

View File

@@ -0,0 +1,539 @@
import { describe, expect, test } from 'vitest';
import {
CircularDependencyError,
ComponentNotFoundError,
createEvent,
createIdentifier,
DuplicateDefinitionError,
Entity,
Framework,
MissingDependencyError,
RecursionLimitError,
Scope,
Service,
} from '..';
import { OnEvent } from '../core/event';
describe('framework', () => {
test('basic', () => {
const framework = new Framework();
class TestService extends Service {
a = 'b';
}
framework.service(TestService);
const provider = framework.provider();
expect(provider.get(TestService).a).toBe('b');
});
test('entity', () => {
const framework = new Framework();
class TestService extends Service {
a = 'b';
}
class TestEntity extends Entity<{ name: string }> {
constructor(readonly test: TestService) {
super();
}
}
framework.service(TestService).entity(TestEntity, [TestService]);
const provider = framework.provider();
const entity = provider.createEntity(TestEntity, {
name: 'test',
});
expect(entity.test.a).toBe('b');
expect(entity.props.name).toBe('test');
});
test('componentCount', () => {
const framework = new Framework();
class TestService extends Service {
a = 'b';
}
framework.service(TestService);
expect(framework.componentCount).toEqual(1);
});
test('dependency', () => {
const framework = new Framework();
class A extends Service {
value = 'hello world';
}
class B extends Service {
constructor(public a: A) {
super();
}
}
class C extends Service {
constructor(public b: B) {
super();
}
}
framework.service(A).service(B, [A]).service(C, [B]);
const provider = framework.provider();
expect(provider.get(C).b.a.value).toEqual('hello world');
});
test('identifier', () => {
interface Animal extends Service {
name: string;
}
const Animal = createIdentifier<Animal>('Animal');
class Cat extends Service {
name = 'cat';
}
class Zoo extends Service {
constructor(public animal: Animal) {
super();
}
}
const serviceCollection = new Framework();
serviceCollection.impl(Animal, Cat).service(Zoo, [Animal]);
const provider = serviceCollection.provider();
expect(provider.get(Zoo).animal.name).toEqual('cat');
});
test('variant', () => {
const framework = new Framework();
interface USB extends Service {
speed: number;
}
const USB = createIdentifier<USB>('USB');
class TypeA extends Service implements USB {
speed = 100;
}
class TypeC extends Service implements USB {
speed = 300;
}
class PC extends Service {
constructor(
public typeA: USB,
public ports: USB[]
) {
super();
}
}
framework
.impl(USB('A'), TypeA)
.impl(USB('C'), TypeC)
.service(PC, [USB('A'), [USB]]);
const provider = framework.provider();
expect(provider.get(USB('A')).speed).toEqual(100);
expect(provider.get(USB('C')).speed).toEqual(300);
expect(provider.get(PC).typeA.speed).toEqual(100);
expect(provider.get(PC).ports.length).toEqual(2);
});
test('lazy initialization', () => {
const framework = new Framework();
interface Command {
shortcut: string;
callback: () => void;
}
const Command = createIdentifier<Command>('command');
let pageSystemInitialized = false;
class PageSystem extends Service {
mode = 'page';
name = 'helloworld';
constructor() {
super();
pageSystemInitialized = true;
}
switchToEdgeless() {
this.mode = 'edgeless';
}
rename() {
this.name = 'foobar';
}
}
class CommandSystem extends Service {
constructor(public commands: Command[]) {
super();
}
execute(shortcut: string) {
const command = this.commands.find(c => c.shortcut === shortcut);
if (command) {
command.callback();
}
}
}
framework.service(PageSystem);
framework.service(CommandSystem, [[Command]]);
framework.impl(Command('switch'), p => ({
shortcut: 'option+s',
callback: () => p.get(PageSystem).switchToEdgeless(),
}));
framework.impl(Command('rename'), p => ({
shortcut: 'f2',
callback: () => p.get(PageSystem).rename(),
}));
const provider = framework.provider();
const commandSystem = provider.get(CommandSystem);
expect(
pageSystemInitialized,
"PageSystem won't be initialized until command executed"
).toEqual(false);
commandSystem.execute('option+s');
expect(pageSystemInitialized).toEqual(true);
expect(provider.get(PageSystem).mode).toEqual('edgeless');
expect(provider.get(PageSystem).name).toEqual('helloworld');
expect(commandSystem.commands.length).toEqual(2);
commandSystem.execute('f2');
expect(provider.get(PageSystem).name).toEqual('foobar');
});
test('duplicate, override', () => {
const framework = new Framework();
const something = createIdentifier<any>('USB');
class A {
a = 'i am A';
}
class B {
b = 'i am B';
}
framework.impl(something, A).override(something, B);
const provider = framework.provider();
expect(provider.get(something)).toEqual({ b: 'i am B' });
});
test('event', () => {
const framework = new Framework();
const event = createEvent<{ value: number }>('test-event');
@OnEvent(event, p => p.onTestEvent)
class TestService extends Service {
value = 0;
onTestEvent(payload: { value: number }) {
this.value = payload.value;
}
}
framework.service(TestService);
const provider = framework.provider();
provider.emitEvent(event, { value: 123 });
expect(provider.get(TestService).value).toEqual(123);
});
test('scope', () => {
const framework = new Framework();
class SystemService extends Service {
appName = 'affine';
}
framework.service(SystemService);
class WorkspaceScope extends Scope {}
class WorkspaceService extends Service {
constructor(public system: SystemService) {
super();
}
}
framework.scope(WorkspaceScope).service(WorkspaceService, [SystemService]);
class PageScope extends Scope<{ pageId: string }> {}
class PageService extends Service {
constructor(
public workspace: WorkspaceService,
public system: SystemService
) {
super();
}
}
framework
.scope(WorkspaceScope)
.scope(PageScope)
.service(PageService, [WorkspaceService, SystemService]);
class EditorScope extends Scope {
get pageId() {
return this.framework.get(PageScope).props.pageId;
}
}
class EditorService extends Service {
constructor(public page: PageService) {
super();
}
}
framework
.scope(WorkspaceScope)
.scope(PageScope)
.scope(EditorScope)
.service(EditorService, [PageService]);
const root = framework.provider();
expect(root.get(SystemService).appName).toEqual('affine');
expect(() => root.get(WorkspaceService)).toThrowError(
ComponentNotFoundError
);
const workspaceScope = root.createScope(WorkspaceScope);
const workspaceService = workspaceScope.get(WorkspaceService);
expect(workspaceService.system.appName).toEqual('affine');
expect(() => workspaceScope.get(PageService)).toThrowError(
ComponentNotFoundError
);
const pageScope = workspaceScope.createScope(PageScope, {
pageId: 'test-page',
});
expect(pageScope.props.pageId).toEqual('test-page');
const pageService = pageScope.get(PageService);
expect(pageService.workspace).toBe(workspaceService);
expect(pageService.system.appName).toEqual('affine');
const editorScope = pageScope.createScope(EditorScope);
expect(editorScope.pageId).toEqual('test-page');
const editorService = editorScope.get(EditorService);
expect(editorService.page).toBe(pageService);
});
test('scope event', () => {
const framework = new Framework();
const event = createEvent<{ value: number }>('test-event');
@OnEvent(event, p => p.onTestEvent)
class TestService extends Service {
value = 0;
onTestEvent(payload: { value: number }) {
this.value = payload.value;
}
}
class TestScope extends Scope {}
@OnEvent(event, p => p.onTestEvent)
class TestScopeService extends Service {
value = 0;
onTestEvent(payload: { value: number }) {
this.value = payload.value;
}
}
framework.service(TestService).scope(TestScope).service(TestScopeService);
const provider = framework.provider();
const scope = provider.createScope(TestScope);
scope.emitEvent(event, { value: 123 });
expect(provider.get(TestService).value).toEqual(0);
expect(scope.get(TestScopeService).value).toEqual(123);
});
test('dispose', () => {
const framework = new Framework();
let isSystemDisposed = false;
class System extends Service {
appName = 'affine';
override dispose(): void {
super.dispose();
isSystemDisposed = true;
}
}
framework.service(System);
let isWorkspaceDisposed = false;
class WorkspaceScope extends Scope {
override dispose(): void {
super.dispose();
isWorkspaceDisposed = true;
}
}
let isWorkspacePageServiceDisposed = false;
class WorkspacePageService extends Service {
constructor(
public workspace: WorkspaceScope,
public sysmte: System
) {
super();
}
override dispose(): void {
super.dispose();
isWorkspacePageServiceDisposed = true;
}
}
framework
.scope(WorkspaceScope)
.service(WorkspacePageService, [WorkspaceScope, System]);
{
using root = framework.provider();
{
// create a workspace
using workspaceScope = root.createScope(WorkspaceScope);
const pageService = workspaceScope.get(WorkspacePageService);
expect(pageService).instanceOf(WorkspacePageService);
expect(
isSystemDisposed ||
isWorkspaceDisposed ||
isWorkspacePageServiceDisposed
).toBe(false);
}
expect(isWorkspaceDisposed && isWorkspacePageServiceDisposed).toBe(true);
expect(isSystemDisposed).toBe(false);
}
expect(isSystemDisposed).toBe(true);
});
test('service not found', () => {
const framework = new Framework();
const provider = framework.provider();
expect(() => provider.get(createIdentifier('SomeService'))).toThrowError(
ComponentNotFoundError
);
});
test('missing dependency', () => {
const framework = new Framework();
class A extends Service {
value = 'hello world';
}
class B extends Service {
constructor(public a: A) {
super();
}
}
framework.service(B, [A]);
const provider = framework.provider();
expect(() => provider.get(B)).toThrowError(MissingDependencyError);
});
test('circular dependency', () => {
const framework = new Framework();
class A extends Service {
constructor(public c: C) {
super();
}
}
class B extends Service {
constructor(public a: A) {
super();
}
}
class C extends Service {
constructor(public b: B) {
super();
}
}
framework.service(A, [C]).service(B, [A]).service(C, [B]);
const provider = framework.provider();
expect(() => provider.get(A)).toThrowError(CircularDependencyError);
expect(() => provider.get(B)).toThrowError(CircularDependencyError);
expect(() => provider.get(C)).toThrowError(CircularDependencyError);
});
test('duplicate service definition', () => {
const serviceCollection = new Framework();
class A extends Service {}
serviceCollection.service(A);
expect(() => serviceCollection.service(A)).toThrowError(
DuplicateDefinitionError
);
class B {}
const Something = createIdentifier('something');
serviceCollection.impl(Something, A);
expect(() => serviceCollection.impl(Something, B)).toThrowError(
DuplicateDefinitionError
);
});
test('recursion limit', () => {
// maxmium resolve depth is 100
const serviceCollection = new Framework();
const Something = createIdentifier('something');
let i = 0;
for (; i < 100; i++) {
const next = i + 1;
class Test {
constructor(_next: any) {}
}
serviceCollection.impl(Something(i.toString()), Test, [
Something(next.toString()),
]);
}
class Final {
a = 'b';
}
serviceCollection.impl(Something(i.toString()), Final);
const provider = serviceCollection.provider();
expect(() => provider.get(Something('0'))).toThrowError(
RecursionLimitError
);
});
});

View File

@@ -0,0 +1,27 @@
import { CONSTRUCTOR_CONTEXT } from '../constructor-context';
import type { FrameworkProvider } from '../provider';
// eslint-disable-next-line @typescript-eslint/ban-types
export class Component<Props = {}> {
readonly framework: FrameworkProvider;
readonly props: Props;
get eventBus() {
return this.framework.eventBus;
}
constructor() {
if (!CONSTRUCTOR_CONTEXT.current.provider) {
throw new Error('Component must be created in the context of a provider');
}
this.framework = CONSTRUCTOR_CONTEXT.current.provider;
this.props = CONSTRUCTOR_CONTEXT.current.props;
CONSTRUCTOR_CONTEXT.current = {};
}
dispose() {}
[Symbol.dispose]() {
this.dispose();
}
}

View File

@@ -0,0 +1,6 @@
import { Component } from './component';
// eslint-disable-next-line @typescript-eslint/ban-types
export class Entity<Props = {}> extends Component<Props> {
readonly __isEntity = true;
}

View File

@@ -0,0 +1,43 @@
import { Component } from './component';
// eslint-disable-next-line @typescript-eslint/ban-types
export class Scope<Props = {}> extends Component<Props> {
readonly __injectable = true;
get collection() {
return this.framework.collection;
}
get scope() {
return this.framework.scope;
}
get get() {
return this.framework.get;
}
get getAll() {
return this.framework.getAll;
}
get getOptional() {
return this.framework.getOptional;
}
get createEntity() {
return this.framework.createEntity;
}
get createScope() {
return this.framework.createScope;
}
get emitEvent() {
return this.framework.emitEvent;
}
override dispose(): void {
super.dispose();
this.framework.dispose();
}
}

View File

@@ -0,0 +1,6 @@
import { Component } from './component';
export class Service extends Component {
readonly __isService = true;
readonly __injectable = true;
}

View File

@@ -0,0 +1,6 @@
import { Component } from './component';
export class Store extends Component {
readonly __isStore = true;
readonly __injectable = true;
}

View File

@@ -0,0 +1,23 @@
import type { FrameworkProvider } from './provider';
interface Context {
provider?: FrameworkProvider;
props?: any;
}
export const CONSTRUCTOR_CONTEXT: {
current: Context;
} = { current: {} };
/**
* @internal
*/
export function withContext<T>(cb: () => T, context: Context): T {
const pre = CONSTRUCTOR_CONTEXT.current;
try {
CONSTRUCTOR_CONTEXT.current = context;
return cb();
} finally {
CONSTRUCTOR_CONTEXT.current = pre;
}
}

View File

@@ -0,0 +1,6 @@
import type { ComponentVariant } from './types';
export const DEFAULT_VARIANT: ComponentVariant = 'default';
export const ROOT_SCOPE = [];
export const SUB_COMPONENTS = Symbol('subComponents');

View File

@@ -0,0 +1,59 @@
import { DEFAULT_VARIANT } from './consts';
import type { IdentifierValue } from './types';
export class RecursionLimitError extends Error {
constructor() {
super('Dynamic resolve recursion limit reached');
}
}
export class CircularDependencyError extends Error {
constructor(public readonly dependencyStack: IdentifierValue[]) {
super(
`A circular dependency was detected.\n` +
stringifyDependencyStack(dependencyStack)
);
}
}
export class ComponentNotFoundError extends Error {
constructor(public readonly identifier: IdentifierValue) {
super(
`Component ${stringifyIdentifier(identifier)} not found in container`
);
}
}
export class MissingDependencyError extends Error {
constructor(
public readonly from: IdentifierValue,
public readonly target: IdentifierValue,
public readonly dependencyStack: IdentifierValue[]
) {
super(
`Missing dependency ${stringifyIdentifier(
target
)} in creating ${stringifyIdentifier(
from
)}.\n${stringifyDependencyStack(dependencyStack)}`
);
}
}
export class DuplicateDefinitionError extends Error {
constructor(public readonly identifier: IdentifierValue) {
super(`${stringifyIdentifier(identifier)} already exists`);
}
}
function stringifyIdentifier(identifier: IdentifierValue) {
return `[${identifier.identifierName}]${
identifier.variant !== DEFAULT_VARIANT ? `(${identifier.variant})` : ''
}`;
}
function stringifyDependencyStack(dependencyStack: IdentifierValue[]) {
return dependencyStack
.map(identifier => `${stringifyIdentifier(identifier)}`)
.join(' -> ');
}

View File

@@ -0,0 +1,111 @@
import { DebugLogger } from '@affine/debug';
import { stableHash } from '../../utils';
import type { FrameworkProvider } from '.';
import type { Service } from './components/service';
import { SUB_COMPONENTS } from './consts';
import { createIdentifier } from './identifier';
import type { SubComponent } from './types';
export interface FrameworkEvent<T> {
id: string;
_type: T;
}
export function createEvent<T>(id: string): FrameworkEvent<T> {
return { id, _type: {} as T };
}
export type FrameworkEventType<T> =
T extends FrameworkEvent<infer E> ? E : never;
const logger = new DebugLogger('affine:event-bus');
export class EventBus {
private listeners: Record<string, Array<(payload: any) => void>> = {};
constructor(
provider: FrameworkProvider,
private readonly parent?: EventBus
) {
const handlers = provider.getAll(EventHandler, {
sameScope: true,
});
for (const handler of handlers.values()) {
this.on(handler.event.id, handler.handler);
}
}
on<T>(id: string, listener: (event: FrameworkEvent<T>) => void) {
if (!this.listeners[id]) {
this.listeners[id] = [];
}
this.listeners[id].push(listener);
const off = this.parent?.on(id, listener);
return () => {
this.off(id, listener);
off?.();
};
}
off<T>(id: string, listener: (event: FrameworkEvent<T>) => void) {
if (!this.listeners[id]) {
return;
}
this.listeners[id] = this.listeners[id].filter(l => l !== listener);
}
emit<T>(event: FrameworkEvent<T>, payload: T) {
logger.debug('Emitting event', event.id, payload);
const listeners = this.listeners[event.id];
if (!listeners) {
return;
}
listeners.forEach(listener => {
try {
listener(payload);
} catch (e) {
console.error(e);
}
});
}
}
interface EventHandler {
event: FrameworkEvent<any>;
handler: (payload: any) => void;
}
export const EventHandler = createIdentifier<EventHandler>('EventHandler');
export const OnEvent = <
E extends FrameworkEvent<any>,
C extends abstract new (...args: any) => any,
I = InstanceType<C>,
>(
e: E,
pick: I extends Service ? (i: I) => (e: FrameworkEventType<E>) => void : never
) => {
return (target: C): C => {
const handlers = (target as any)[SUB_COMPONENTS] ?? [];
(target as any)[SUB_COMPONENTS] = [
...handlers,
{
identifier: EventHandler(
target.name + stableHash(e) + stableHash(pick)
),
factory: provider => {
return {
event: e,
handler: (payload: any) => {
const i = provider.get(target);
pick(i).apply(i, [payload]);
},
} satisfies EventHandler;
},
} satisfies SubComponent,
];
return target;
};
};

View File

@@ -0,0 +1,527 @@
import type { Component } from './components/component';
import type { Entity } from './components/entity';
import type { Scope } from './components/scope';
import type { Service } from './components/service';
import type { Store } from './components/store';
import { DEFAULT_VARIANT, ROOT_SCOPE, SUB_COMPONENTS } from './consts';
import { DuplicateDefinitionError } from './error';
import { parseIdentifier } from './identifier';
import type { FrameworkProvider } from './provider';
import { BasicFrameworkProvider } from './provider';
import { stringifyScope } from './scope';
import type {
ComponentFactory,
ComponentVariant,
FrameworkScopeStack,
GeneralIdentifier,
Identifier,
IdentifierType,
IdentifierValue,
SubComponent,
Type,
TypesToDeps,
} from './types';
export class Framework {
private readonly components: Map<
string,
Map<string, Map<ComponentVariant, ComponentFactory>>
> = new Map();
/**
* Create an empty framework.
*
* same as `new Framework()`
*/
static get EMPTY() {
return new Framework();
}
/**
* The number of components in the framework.
*/
get componentCount() {
let count = 0;
for (const [, identifiers] of this.components) {
for (const [, variants] of identifiers) {
count += variants.size;
}
}
return count;
}
/**
* @see {@link FrameworkEditor.service}
*/
get service() {
return new FrameworkEditor(this).service;
}
/**
* @see {@link FrameworkEditor.impl}
*/
get impl() {
return new FrameworkEditor(this).impl;
}
/**
* @see {@link FrameworkEditor.entity}
*/
get entity() {
return new FrameworkEditor(this).entity;
}
/**
* @see {@link FrameworkEditor.scope}
*/
get scope() {
return new FrameworkEditor(this).scope;
}
/**
* @see {@link FrameworkEditor.override}
*/
get override() {
return new FrameworkEditor(this).override;
}
/**
* @see {@link FrameworkEditor.store}
*/
get store() {
return new FrameworkEditor(this).store;
}
/**
* @internal Use {@link impl} instead.
*/
addValue<T>(
identifier: GeneralIdentifier<T>,
value: T,
{
scope,
override,
}: { scope?: FrameworkScopeStack; override?: boolean } = {}
) {
this.addFactory(parseIdentifier(identifier) as Identifier<T>, () => value, {
scope,
override,
});
}
/**
* @internal Use {@link impl} instead.
*/
addFactory<T>(
identifier: GeneralIdentifier<T>,
factory: ComponentFactory<T>,
{
scope,
override,
}: { scope?: FrameworkScopeStack; override?: boolean } = {}
) {
// convert scope to string
const normalizedScope = stringifyScope(scope ?? ROOT_SCOPE);
const normalizedIdentifier = parseIdentifier(identifier);
const normalizedVariant = normalizedIdentifier.variant ?? DEFAULT_VARIANT;
const services =
this.components.get(normalizedScope) ??
new Map<string, Map<ComponentVariant, ComponentFactory>>();
const variants =
services.get(normalizedIdentifier.identifierName) ??
new Map<ComponentVariant, ComponentFactory>();
// throw if service already exists, unless it is an override
if (variants.has(normalizedVariant) && !override) {
throw new DuplicateDefinitionError(normalizedIdentifier);
}
variants.set(normalizedVariant, factory);
services.set(normalizedIdentifier.identifierName, variants);
this.components.set(normalizedScope, services);
}
remove(identifier: IdentifierValue, scope: FrameworkScopeStack = ROOT_SCOPE) {
const normalizedScope = stringifyScope(scope);
const normalizedIdentifier = parseIdentifier(identifier);
const normalizedVariant = normalizedIdentifier.variant ?? DEFAULT_VARIANT;
const services = this.components.get(normalizedScope);
if (!services) {
return;
}
const variants = services.get(normalizedIdentifier.identifierName);
if (!variants) {
return;
}
variants.delete(normalizedVariant);
}
/**
* Create a service provider from the collection.
*
* @example
* ```ts
* provider() // create a service provider for root scope
* provider(ScopeA, parentProvider) // create a service provider for scope A
* ```
*
* @param scope The scope of the service provider, default to the root scope.
* @param parent The parent service provider, it is required if the scope is not the root scope.
*/
provider(
scope: FrameworkScopeStack = ROOT_SCOPE,
parent: FrameworkProvider | null = null
): FrameworkProvider {
return new BasicFrameworkProvider(this, scope, parent);
}
/**
* @internal
*/
getFactory(
identifier: IdentifierValue,
scope: FrameworkScopeStack = ROOT_SCOPE
): ComponentFactory | undefined {
return this.components
.get(stringifyScope(scope))
?.get(identifier.identifierName)
?.get(identifier.variant ?? DEFAULT_VARIANT);
}
/**
* @internal
*/
getFactoryAll(
identifier: IdentifierValue,
scope: FrameworkScopeStack = ROOT_SCOPE
): Map<ComponentVariant, ComponentFactory> {
return new Map(
this.components.get(stringifyScope(scope))?.get(identifier.identifierName)
);
}
/**
* Clone the entire service collection.
*
* This method is quite cheap as it only clones the references.
*
* @returns A new service collection with the same services.
*/
clone(): Framework {
const di = new Framework();
for (const [scope, identifiers] of this.components) {
const s = new Map();
for (const [identifier, variants] of identifiers) {
s.set(identifier, new Map(variants));
}
di.components.set(scope, s);
}
return di;
}
}
/**
* A helper class to edit a framework.
*/
class FrameworkEditor {
private currentScopeStack: FrameworkScopeStack = ROOT_SCOPE;
constructor(private readonly collection: Framework) {}
/**
* Add a service to the framework.
*
* @see {@link Framework}
*
* @example
* ```ts
* service(ServiceClass, [dependencies, ...])
* ```
*/
service = <
Arg1 extends Type<Service>,
Arg2 extends Deps | ComponentFactory<ServiceType> | ServiceType,
ServiceType = IdentifierType<Arg1>,
Deps = Arg1 extends Type<ServiceType>
? TypesToDeps<ConstructorParameters<Arg1>>
: [],
>(
service: Arg1,
...[arg2]: Arg2 extends [] ? [] : [Arg2]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(service as any, arg2 as any, {
scope: this.currentScopeStack,
});
} else if (arg2 instanceof Array || arg2 === undefined) {
this.collection.addFactory<any>(
service as any,
dependenciesToFactory(service, arg2 as any),
{ scope: this.currentScopeStack }
);
} else {
this.collection.addValue<any>(service as any, arg2, {
scope: this.currentScopeStack,
});
}
if (SUB_COMPONENTS in service) {
const subComponents = (service as any)[SUB_COMPONENTS] as SubComponent[];
for (const { identifier, factory } of subComponents) {
this.collection.addFactory(identifier, factory, {
scope: this.currentScopeStack,
});
}
}
return this;
};
/**
* Add a store to the framework.
*
* @see {@link Framework}
*
* @example
* ```ts
* store(StoreClass, [dependencies, ...])
* ```
*/
store = <
Arg1 extends Type<Store>,
Arg2 extends Deps | ComponentFactory<StoreType> | StoreType,
StoreType = IdentifierType<Arg1>,
Deps = Arg1 extends Type<StoreType>
? TypesToDeps<ConstructorParameters<Arg1>>
: [],
>(
store: Arg1,
...[arg2]: Arg2 extends [] ? [] : [Arg2]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(store as any, arg2 as any, {
scope: this.currentScopeStack,
});
} else if (arg2 instanceof Array || arg2 === undefined) {
this.collection.addFactory<any>(
store as any,
dependenciesToFactory(store, arg2 as any),
{ scope: this.currentScopeStack }
);
} else {
this.collection.addValue<any>(store as any, arg2, {
scope: this.currentScopeStack,
});
}
if (SUB_COMPONENTS in store) {
const subComponents = (store as any)[SUB_COMPONENTS] as SubComponent[];
for (const { identifier, factory } of subComponents) {
this.collection.addFactory(identifier, factory, {
scope: this.currentScopeStack,
});
}
}
return this;
};
/**
* Add an entity to the framework.
*/
entity = <
Arg1 extends Type<Entity<any>>,
Arg2 extends Deps | ComponentFactory<EntityType>,
EntityType = IdentifierType<Arg1>,
Deps = Arg1 extends Type<EntityType>
? TypesToDeps<ConstructorParameters<Arg1>>
: [],
>(
entity: Arg1,
...[arg2]: Arg2 extends [] ? [] : [Arg2]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(entity as any, arg2 as any, {
scope: this.currentScopeStack,
});
} else {
this.collection.addFactory<any>(
entity as any,
dependenciesToFactory(entity, arg2 as any),
{ scope: this.currentScopeStack }
);
}
return this;
};
/**
* Add an implementation for identifier to the collection.
*
* @see {@link Framework}
*
* @example
* ```ts
* addImpl(Identifier, Class, [dependencies, ...])
* or
* addImpl(Identifier, Instance)
* or
* addImpl(Identifier, Factory)
* ```
*/
impl = <
Arg1 extends Identifier<any>,
Arg2 extends Type<Trait> | ComponentFactory<Trait> | Trait,
Arg3 extends Deps,
Trait = IdentifierType<Arg1>,
Deps = Arg2 extends Type<Trait>
? TypesToDeps<ConstructorParameters<Arg2>>
: [],
>(
identifier: Arg1,
arg2: Arg2,
...[arg3]: Arg3 extends [] ? [] : [Arg3]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(
identifier,
dependenciesToFactory(arg2, arg3 as any[]),
{ scope: this.currentScopeStack }
);
} else {
this.collection.addValue(identifier, arg2 as any, {
scope: this.currentScopeStack,
});
}
return this;
};
/**
* same as {@link impl} but this method will override the component if it exists.
*
* @see {@link Framework}
*
* @example
* ```ts
* override(OriginClass, NewClass, [dependencies, ...])
* or
* override(Identifier, Class, [dependencies, ...])
* or
* override(Identifier, Instance)
* or
* override(Identifier, Factory)
* ```
*/
override = <
Arg1 extends GeneralIdentifier<any>,
Arg2 extends Type<Trait> | ComponentFactory<Trait> | Trait | null,
Arg3 extends Deps,
Trait extends Component = IdentifierType<Arg1>,
Deps = Arg2 extends Type<Trait>
? TypesToDeps<ConstructorParameters<Arg2>>
: [],
>(
identifier: Arg1,
arg2: Arg2,
...[arg3]: Arg3 extends [] ? [] : [Arg3]
): this => {
if (arg2 === null) {
this.collection.remove(
parseIdentifier(identifier),
this.currentScopeStack
);
return this;
} else if (arg2 instanceof Function) {
this.collection.addFactory<any>(
identifier,
dependenciesToFactory(arg2, arg3 as any[]),
{ scope: this.currentScopeStack, override: true }
);
} else {
this.collection.addValue(identifier, arg2 as any, {
scope: this.currentScopeStack,
override: true,
});
}
return this;
};
/**
* Set the scope for the service registered subsequently
*
* @example
*
* ```ts
* const ScopeA = createScope('a');
*
* services.scope(ScopeA).add(XXXService, ...);
* ```
*/
scope = (scope: Type<Scope<any>>): this => {
this.currentScopeStack = [
...this.currentScopeStack,
parseIdentifier(scope).identifierName,
];
this.collection.addFactory<any>(
scope as any,
dependenciesToFactory(scope, [] as any),
{ scope: this.currentScopeStack, override: true }
);
return this;
};
}
/**
* Convert dependencies definition to a factory function.
*/
function dependenciesToFactory(
cls: any,
deps: any[] = []
): ComponentFactory<any> {
return (provider: FrameworkProvider) => {
const args = [];
for (const dep of deps) {
let isAll;
let identifier;
if (Array.isArray(dep)) {
if (dep.length !== 1) {
throw new Error('Invalid dependency');
}
isAll = true;
identifier = dep[0];
} else {
isAll = false;
identifier = dep;
}
if (isAll) {
args.push(Array.from(provider.getAll(identifier).values()));
} else {
args.push(provider.get(identifier));
}
}
if (isConstructor(cls)) {
return new cls(...args, provider);
} else {
return cls(...args, provider);
}
};
}
// a hack to check if a function is a constructor
// https://github.com/zloirock/core-js/blob/232c8462c26c75864b4397b7f643a4f57c6981d5/packages/core-js/internals/is-constructor.js#L15
function isConstructor(cls: any) {
try {
Reflect.construct(function () {}, [], cls);
return true;
} catch (error) {
return false;
}
}

View File

@@ -0,0 +1,114 @@
import { stableHash } from '../../utils/stable-hash';
import type { Component } from './components/component';
import { DEFAULT_VARIANT } from './consts';
import type {
ComponentVariant,
Identifier,
IdentifierValue,
Type,
} from './types';
/**
* create a Identifier.
*
* Identifier is used to identify a certain type of service. With the identifier, you can reference one or more services
* without knowing the specific implementation, thereby achieving
* [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control).
*
* @example
* ```ts
* // define a interface
* interface Storage {
* get(key: string): string | null;
* set(key: string, value: string): void;
* }
*
* // create a identifier
* // NOTICE: Highly recommend to use the interface name as the identifier name,
* // so that it is easy to understand. and it is legal to do so in TypeScript.
* const Storage = createIdentifier<Storage>('Storage');
*
* // create a implementation
* class LocalStorage implements Storage {
* get(key: string): string | null {
* return localStorage.getItem(key);
* }
* set(key: string, value: string): void {
* localStorage.setItem(key, value);
* }
* }
*
* // register the implementation to the identifier
* framework.impl(Storage, LocalStorage);
*
* // get the implementation from the identifier
* const storage = framework.provider().get(Storage);
* storage.set('foo', 'bar');
* ```
*
* With identifier:
*
* * You can easily replace the implementation of a `Storage` without changing the code that uses it.
* * You can easily mock a `Storage` for testing.
*
* # Variant
*
* Sometimes, you may want to register multiple implementations for the same interface.
* For example, you may want have both `LocalStorage` and `SessionStorage` for `Storage`,
* and use them in same time.
*
* In this case, you can use `variant` to distinguish them.
*
* ```ts
* const Storage = createIdentifier<Storage>('Storage');
* const LocalStorage = Storage('local');
* const SessionStorage = Storage('session');
*
* framework.impl(LocalStorage, LocalStorageImpl);
* framework.impl(SessionStorage, SessionStorageImpl);
*
* // get the implementation from the identifier
* const localStorage = framework.provider().get(LocalStorage);
* const sessionStorage = framework.provider().get(SessionStorage);
* const storage = framework.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
* ```
*
* @param name unique name of the identifier.
* @param variant The default variant name of the identifier, can be overridden by `identifier("variant")`.
*/
export function createIdentifier<T>(
name: string,
variant: ComponentVariant = DEFAULT_VARIANT
): Identifier<T> & ((variant: ComponentVariant) => Identifier<T>) {
return Object.assign(
(variant: ComponentVariant) => {
return createIdentifier<T>(name, variant);
},
{
identifierName: name,
variant,
}
) as any;
}
/**
* Convert the constructor into a ServiceIdentifier.
* As we always deal with ServiceIdentifier in the DI container.
*
* @internal
*/
export function createIdentifierFromConstructor<T extends Component>(
target: Type<T>
): Identifier<T> {
return createIdentifier<T>(`${target.name}${stableHash(target)}`);
}
export function parseIdentifier(input: any): IdentifierValue {
if (input.identifierName) {
return input as IdentifierValue;
} else if (typeof input === 'function' && input.name) {
return createIdentifierFromConstructor(input);
} else {
throw new Error('Input is not a service identifier.');
}
}

View File

@@ -0,0 +1,10 @@
export { Entity } from './components/entity';
export { Scope } from './components/scope';
export { Service } from './components/service';
export { Store } from './components/store';
export * from './error';
export { createEvent, OnEvent } from './event';
export { Framework } from './framework';
export { createIdentifier } from './identifier';
export type { FrameworkProvider, ResolveOptions } from './provider';
export type { GeneralIdentifier } from './types';

View File

@@ -0,0 +1,321 @@
import type { Component } from './components/component';
import type { Entity } from './components/entity';
import type { Scope } from './components/scope';
import { withContext } from './constructor-context';
import {
CircularDependencyError,
ComponentNotFoundError,
MissingDependencyError,
RecursionLimitError,
} from './error';
import { EventBus, type FrameworkEvent } from './event';
import type { Framework } from './framework';
import { parseIdentifier } from './identifier';
import type {
ComponentVariant,
FrameworkScopeStack,
GeneralIdentifier,
IdentifierValue,
} from './types';
export interface ResolveOptions {
sameScope?: boolean;
optional?: boolean;
noCache?: boolean;
props?: any;
}
export abstract class FrameworkProvider {
abstract collection: Framework;
abstract scope: FrameworkScopeStack;
abstract getRaw(identifier: IdentifierValue, options?: ResolveOptions): any;
abstract getAllRaw(
identifier: IdentifierValue,
options?: ResolveOptions
): Map<ComponentVariant, any>;
abstract dispose(): void;
abstract eventBus: EventBus;
get = <T>(identifier: GeneralIdentifier<T>, options?: ResolveOptions): T => {
return this.getRaw(parseIdentifier(identifier), {
...options,
optional: false,
});
};
getAll = <T>(
identifier: GeneralIdentifier<T>,
options?: ResolveOptions
): Map<ComponentVariant, T> => {
return this.getAllRaw(parseIdentifier(identifier), {
...options,
});
};
getOptional = <T>(
identifier: GeneralIdentifier<T>,
options?: ResolveOptions
): T | null => {
return this.getRaw(parseIdentifier(identifier), {
...options,
optional: true,
});
};
createEntity = <
T extends Entity<any>,
Props extends T extends Component<infer P> ? P : never,
>(
identifier: GeneralIdentifier<T>,
...[props]: Props extends Record<string, never> ? [] : [Props]
): T => {
return this.getRaw(parseIdentifier(identifier), {
noCache: true,
sameScope: true,
props,
});
};
createScope = <
T extends Scope<any>,
Props extends T extends Component<infer P> ? P : never,
>(
root: GeneralIdentifier<T>,
...[props]: Props extends Record<string, never> ? [] : [Props]
): T => {
const newProvider = this.collection.provider(
[...this.scope, parseIdentifier(root).identifierName],
this
);
return newProvider.getRaw(parseIdentifier(root), {
sameScope: true,
props,
});
};
emitEvent = <T>(event: FrameworkEvent<T>, payload: T) => {
this.eventBus.emit(event, payload);
};
[Symbol.dispose]() {
this.dispose();
}
}
export class ComponentCachePool {
cache: Map<string, Map<ComponentVariant, any>> = new Map();
getOrInsert(identifier: IdentifierValue, insert: () => any) {
const cache = this.cache.get(identifier.identifierName) ?? new Map();
if (!cache.has(identifier.variant)) {
cache.set(identifier.variant, insert());
}
const cached = cache.get(identifier.variant);
this.cache.set(identifier.identifierName, cache);
return cached;
}
dispose() {
for (const t of this.cache.values()) {
for (const i of t.values()) {
if (typeof i === 'object' && typeof i[Symbol.dispose] === 'function') {
try {
i[Symbol.dispose]();
} catch (err) {
setImmediate(() => {
throw err;
});
}
}
}
}
}
[Symbol.dispose]() {
this.dispose();
}
}
class Resolver extends FrameworkProvider {
constructor(
public readonly provider: BasicFrameworkProvider,
public readonly depth = 0,
public readonly stack: IdentifierValue[] = []
) {
super();
}
scope = this.provider.scope;
collection = this.provider.collection;
eventBus = this.provider.eventBus;
getRaw(
identifier: IdentifierValue,
{
sameScope = false,
optional = false,
noCache = false,
props,
}: ResolveOptions = {}
) {
const factory = this.provider.collection.getFactory(
identifier,
this.provider.scope
);
if (!factory) {
if (this.provider.parent && !sameScope) {
return this.provider.parent.getRaw(identifier, {
sameScope: sameScope,
optional,
noCache,
props,
});
}
if (optional) {
return undefined;
}
throw new ComponentNotFoundError(identifier);
}
const runFactory = () => {
const nextResolver = this.track(identifier);
try {
return withContext(() => factory(nextResolver), {
provider: this.provider,
props,
});
} catch (err) {
if (err instanceof ComponentNotFoundError) {
throw new MissingDependencyError(
identifier,
err.identifier,
this.stack
);
}
throw err;
}
};
if (noCache) {
return runFactory();
}
return this.provider.cache.getOrInsert(identifier, runFactory);
}
getAllRaw(
identifier: IdentifierValue,
{ sameScope = false, noCache, props }: ResolveOptions = {}
): Map<ComponentVariant, any> {
const vars = this.provider.collection.getFactoryAll(
identifier,
this.provider.scope
);
if (vars === undefined) {
if (this.provider.parent && !sameScope) {
return this.provider.parent.getAllRaw(identifier);
}
return new Map();
}
const result = new Map<ComponentVariant, any>();
for (const [variant, factory] of vars) {
// eslint-disable-next-line sonarjs/no-identical-functions
const runFactory = () => {
const nextResolver = this.track(identifier);
try {
return withContext(() => factory(nextResolver), {
provider: this.provider,
props,
});
} catch (err) {
if (err instanceof ComponentNotFoundError) {
throw new MissingDependencyError(
identifier,
err.identifier,
this.stack
);
}
throw err;
}
};
let service;
if (noCache) {
service = runFactory();
} else {
service = this.provider.cache.getOrInsert(
{
identifierName: identifier.identifierName,
variant,
},
runFactory
);
}
result.set(variant, service);
}
return result;
}
track(identifier: IdentifierValue): Resolver {
const depth = this.depth + 1;
if (depth >= 100) {
throw new RecursionLimitError();
}
const circular = this.stack.find(
i =>
i.identifierName === identifier.identifierName &&
i.variant === identifier.variant
);
if (circular) {
throw new CircularDependencyError([...this.stack, identifier]);
}
return new Resolver(this.provider, depth, [...this.stack, identifier]);
}
override dispose(): void {}
}
export class BasicFrameworkProvider extends FrameworkProvider {
public readonly cache = new ComponentCachePool();
public readonly collection: Framework;
public readonly eventBus: EventBus;
disposed = false;
constructor(
collection: Framework,
public readonly scope: string[],
public readonly parent: FrameworkProvider | null
) {
super();
this.collection = collection;
this.eventBus = new EventBus(this, this.parent?.eventBus);
}
getRaw(identifier: IdentifierValue, options?: ResolveOptions) {
const resolver = new Resolver(this);
return resolver.getRaw(identifier, options);
}
getAllRaw(
identifier: IdentifierValue,
options?: ResolveOptions
): Map<ComponentVariant, any> {
const resolver = new Resolver(this);
return resolver.getAllRaw(identifier, options);
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.cache.dispose();
}
}

View File

@@ -0,0 +1,5 @@
import type { FrameworkScopeStack } from './types';
export function stringifyScope(scope: FrameworkScopeStack): string {
return scope.join('/');
}

View File

@@ -0,0 +1,36 @@
import type { FrameworkProvider } from './provider';
// eslint-disable-next-line @typescript-eslint/ban-types
export type Type<T = any> = abstract new (...args: any) => T;
export type ComponentFactory<T = any> = (provider: FrameworkProvider) => T;
export type ComponentVariant = string;
export type FrameworkScopeStack = string[];
export type IdentifierValue = {
identifierName: string;
variant: ComponentVariant;
};
export type GeneralIdentifier<T = any> = Identifier<T> | Type<T>;
export type Identifier<T> = {
identifierName: string;
variant: ComponentVariant;
__TYPE__: T;
};
export type IdentifierType<T> =
T extends Identifier<infer R> ? R : T extends Type<infer R> ? R : never;
export type TypesToDeps<T> = {
[index in keyof T]:
| GeneralIdentifier<T[index]>
| (T[index] extends (infer I)[] ? [GeneralIdentifier<I>] : never);
};
export type SubComponent = {
identifier: Identifier<any>;
factory: ComponentFactory;
};

View File

@@ -0,0 +1,2 @@
export * from './core';
export * from './react';

View File

@@ -0,0 +1,126 @@
import React, { useContext, useMemo } from 'react';
import type { FrameworkProvider, Scope, Service } from '../core';
import { ComponentNotFoundError, Framework } from '../core';
import { parseIdentifier } from '../core/identifier';
import type { GeneralIdentifier, IdentifierType, Type } from '../core/types';
export const FrameworkStackContext = React.createContext<FrameworkProvider[]>([
Framework.EMPTY.provider(),
]);
export function useService<T extends Service>(
identifier: GeneralIdentifier<T>
): T {
const stack = useContext(FrameworkStackContext);
let service: T | null = null;
for (let i = stack.length - 1; i >= 0; i--) {
service = stack[i].getOptional(identifier, {
sameScope: true,
});
if (service) {
break;
}
}
if (!service) {
throw new ComponentNotFoundError(parseIdentifier(identifier));
}
return service;
}
/**
* Hook to get services from the current framework stack.
*
* Automatically converts the service name to camelCase.
*
* @example
* ```ts
* const { authService, userService } = useServices({ AuthService, UserService });
* ```
*/
export function useServices<
const T extends { [key in string]: GeneralIdentifier<Service> },
>(
identifiers: T
): keyof T extends string
? { [key in Uncapitalize<keyof T>]: IdentifierType<T[Capitalize<key>]> }
: never {
const stack = useContext(FrameworkStackContext);
const services: any = {};
for (const [key, value] of Object.entries(identifiers)) {
let service;
for (let i = stack.length - 1; i >= 0; i--) {
service = stack[i].getOptional(value, {
sameScope: true,
});
if (service) {
break;
}
}
if (!service) {
throw new ComponentNotFoundError(parseIdentifier(value));
}
services[key.charAt(0).toLowerCase() + key.slice(1)] = service;
}
return services;
}
export function useServiceOptional<T extends Service>(
identifier: Type<T>
): T | null {
const stack = useContext(FrameworkStackContext);
let service: T | null = null;
for (let i = stack.length - 1; i >= 0; i--) {
service = stack[i].getOptional(identifier, {
sameScope: true,
});
if (service) {
break;
}
}
return service;
}
export const FrameworkRoot = ({
framework,
children,
}: React.PropsWithChildren<{ framework: FrameworkProvider }>) => {
return (
<FrameworkStackContext.Provider value={[framework]}>
{children}
</FrameworkStackContext.Provider>
);
};
export const FrameworkScope = ({
scope,
children,
}: React.PropsWithChildren<{ scope?: Scope }>) => {
const stack = useContext(FrameworkStackContext);
const nextStack = useMemo(() => {
if (!scope) return stack;
return [...stack, scope.framework];
}, [stack, scope]);
return (
<FrameworkStackContext.Provider value={nextStack}>
{children}
</FrameworkStackContext.Provider>
);
};