mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-28 08:58:07 +00:00
Hierarchical dependency injection framework
Adapted from this experiment: https://stackblitz.com/edit/vitejs-vite-6tenu8?file=src%2Fapp.ts
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
export * from "./lib/decorators/on";
|
export * from "./lib/decorators";
|
||||||
|
export * from "./lib/di";
|
||||||
export { default as spread } from "./lib/directives/spread";
|
export { default as spread } from "./lib/directives/spread";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./on";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Method } from "@storyteller/utility";
|
||||||
|
import { LitElement } from "lit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifies the prototype function at `proto[key]` by adding an invocation of
|
||||||
|
* `callback` to the end of the method body.
|
||||||
|
*/
|
||||||
|
export function appendRoutine<
|
||||||
|
E extends LitElement,
|
||||||
|
M extends Method<E>,
|
||||||
|
>(proto: E, key: string | number | symbol, callback: M): void {
|
||||||
|
const original = getMethodDescriptor(proto, key)?.value;
|
||||||
|
if (!original || typeof original !== "function")
|
||||||
|
return;
|
||||||
|
|
||||||
|
Object.defineProperty(proto, key, {
|
||||||
|
value(this: E): void {
|
||||||
|
original.call(this);
|
||||||
|
callback.call(this);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifies the prototype function at `proto[key]` by adding an invocation of
|
||||||
|
* `callback` to the start of the method body.
|
||||||
|
*/
|
||||||
|
export function prependRoutine<
|
||||||
|
E extends LitElement,
|
||||||
|
M extends Method<E>,
|
||||||
|
>(proto: E, key: string | number | symbol, callback: M): void {
|
||||||
|
const original = getMethodDescriptor(proto, key)?.value;
|
||||||
|
if (!original || typeof original !== "function")
|
||||||
|
return;
|
||||||
|
|
||||||
|
Object.defineProperty(proto, key, {
|
||||||
|
value(this: E): void {
|
||||||
|
callback.call(this);
|
||||||
|
original.call(this);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks for a member in the prototype chain named `key`, starting at `proto`
|
||||||
|
* and recursing up the inheritance tree. If it reaches `HTMLElement` without
|
||||||
|
* finding a match, it stops and returns `undefined` -- otherwise, it returns
|
||||||
|
* the matching property descriptor.
|
||||||
|
*/
|
||||||
|
export function getMethodDescriptor<E extends LitElement>(proto: E, key: string | number | symbol)
|
||||||
|
: TypedPropertyDescriptor<Method<E, [], void>> | undefined
|
||||||
|
{
|
||||||
|
let result = Object.getOwnPropertyDescriptor(proto, key);
|
||||||
|
while (!result) {
|
||||||
|
proto = Object.getPrototypeOf(proto);
|
||||||
|
if (!proto || proto === HTMLElement.prototype)
|
||||||
|
break;
|
||||||
|
|
||||||
|
result = Object.getOwnPropertyDescriptor(proto, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { match } from "@storyteller/utility";
|
import { match } from "@storyteller/utility";
|
||||||
import type { LitElement } from "lit";
|
import type { LitElement } from "lit";
|
||||||
|
import { getMethodDescriptor, prependRoutine } from "./internal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the decorated method as an event listener for the given event name, and
|
* Adds the decorated method as an event listener for the given event name, and
|
||||||
@@ -21,29 +22,16 @@ export function on(eventSelector: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return <T extends LitElement>(proto: T, propName: keyof T, desc: PropertyDescriptor) => {
|
return <T extends LitElement>(proto: T, propName: keyof T, desc: PropertyDescriptor) => {
|
||||||
const handler = desc?.value ?? proto[propName] ?? (() => {});
|
const handler = getMethodDescriptor(proto, propName)?.value ?? (() => {});
|
||||||
|
|
||||||
const connectedCb = proto.connectedCallback;
|
prependRoutine(proto, "connectedCallback", function (this: T) {
|
||||||
const disconnectedCb = proto.disconnectedCallback;
|
target ??= this;
|
||||||
|
target.addEventListener(eventName, handler.bind(this));
|
||||||
Object.defineProperty(proto, "connectedCallback", {
|
|
||||||
value: function (this: HTMLElement) {
|
|
||||||
target ??= this;
|
|
||||||
target.addEventListener(eventName, handler.bind(this));
|
|
||||||
connectedCb.call(this);
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.defineProperty(proto, "disconnectedCallback", {
|
prependRoutine(proto, "disconnectedCallback", function (this: T) {
|
||||||
value: function (this: HTMLElement) {
|
target ??= this;
|
||||||
target ??= this;
|
target.removeEventListener(eventName, handler.bind(this));
|
||||||
target.removeEventListener(eventName, handler.bind(this));
|
|
||||||
disconnectedCb.call(this);
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return desc;
|
return desc;
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Ctor, Method } from "@storyteller/utility";
|
||||||
|
import { LitElement } from "lit";
|
||||||
|
|
||||||
|
import { appendRoutine, prependRoutine } from "../decorators/internal";
|
||||||
|
import { DIEvent, DependencyProvision, InjectionRequest, ProviderRemoval } from "./events";
|
||||||
|
import { Token } from "./types";
|
||||||
|
|
||||||
|
const $injector = Symbol("injector");
|
||||||
|
const $$injector = Symbol("#injector");
|
||||||
|
|
||||||
|
export type ProviderOptions<T, E extends LitElement>
|
||||||
|
= Token<T>
|
||||||
|
| {
|
||||||
|
token: Token<T>,
|
||||||
|
provide(this: E): T,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function provide<T, E extends LitElement>(options: ProviderOptions<T, E>) {
|
||||||
|
type Decorated = E & {
|
||||||
|
[$$injector]?: Map<Token<any>, any> | undefined;
|
||||||
|
readonly [$injector]: Map<Token<any>, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Target: Ctor<E>) => {
|
||||||
|
const proto = Target.prototype;
|
||||||
|
assertType<Decorated>(proto);
|
||||||
|
|
||||||
|
let descriptor = Object.getOwnPropertyDescriptor(proto, $injector);
|
||||||
|
let needsInit = false;
|
||||||
|
if (!($injector in proto) || !descriptor) {
|
||||||
|
needsInit = true;
|
||||||
|
Object.defineProperty(proto, $injector, {
|
||||||
|
get(this: Decorated) { return this[$$injector] ??= new Map() },
|
||||||
|
configurable: false,
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fulfillInjectionRequest<T>(this: Decorated, event: InjectionRequest<T>): void {
|
||||||
|
if (this[$injector].has(event.detail.token)) {
|
||||||
|
event.detail.value = this[$injector].get(event.detail.token);
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prependRoutine(proto, "connectedCallback", function (this: Decorated): void {
|
||||||
|
const { token, value } = "token" in options
|
||||||
|
? { token: options.token, value: options.provide.call(this) }
|
||||||
|
: { token: options, value: this as T };
|
||||||
|
|
||||||
|
this[$injector].set(token, value);
|
||||||
|
this.dispatchEvent(new DependencyProvision(token, value));
|
||||||
|
|
||||||
|
if (needsInit) {
|
||||||
|
const fulfill = fulfillInjectionRequest.bind(this);
|
||||||
|
this.addEventListener(DIEvent.InjectionRequest, fulfill as Method<HTMLElement>);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
appendRoutine(proto, "disconnectedCallback", function (this: Decorated): void {
|
||||||
|
const token = "token" in options ? options.token : options;
|
||||||
|
const value = this[$injector].get(token);
|
||||||
|
|
||||||
|
this.dispatchEvent(new ProviderRemoval(token, value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inject<T, E extends LitElement>(token: Token<T>) {
|
||||||
|
return <K extends keyof E>(proto: E, key: K) => {
|
||||||
|
const $field = Symbol(String(key));
|
||||||
|
const initial = proto[key];
|
||||||
|
|
||||||
|
type Decorated = E & {
|
||||||
|
[$field]?: E[K] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertType<Decorated>(proto);
|
||||||
|
|
||||||
|
Object.defineProperty(proto, key, {
|
||||||
|
get(this: Decorated): E[K] {
|
||||||
|
return this[$field] ??= initial;
|
||||||
|
},
|
||||||
|
set(this: Decorated, value: E[K]): void {
|
||||||
|
this[$field] = value;
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
prependRoutine(proto, "connectedCallback", function (this: E): void {
|
||||||
|
const event = new InjectionRequest(token);
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
|
||||||
|
if (event.detail.value != null) {
|
||||||
|
this[key] = event.detail.value as E[K];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryProviders<T, E extends LitElement>(token: Token<T>) {
|
||||||
|
return <K extends keyof E>(proto: E, key: K) => {
|
||||||
|
function onProvided(this: E, event: DependencyProvision<T>): void {
|
||||||
|
const array = this[key];
|
||||||
|
assertType<E[K] & T[]>(array);
|
||||||
|
|
||||||
|
if (event.detail.token === token)
|
||||||
|
this[key] = array.concat(event.detail.value) as E[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoved(this: E, event: ProviderRemoval<T>): void {
|
||||||
|
const array = this[key];
|
||||||
|
assertType<E[K] & T[]>(array);
|
||||||
|
|
||||||
|
if (event.detail.token === token)
|
||||||
|
this[key] = array.filter(el => el !== event.detail.value) as E[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
appendRoutine(proto, "connectedCallback", function (this: E): void {
|
||||||
|
this[key] = [] as E[K];
|
||||||
|
this.addEventListener(
|
||||||
|
DIEvent.DependencyProvision,
|
||||||
|
onProvided.bind(this) as Method<HTMLElement>,
|
||||||
|
);
|
||||||
|
this.addEventListener(
|
||||||
|
DIEvent.ProviderRemoval,
|
||||||
|
onRemoved.bind(this) as Method<HTMLElement>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
appendRoutine(proto, "disconnectedCallback", function (this: E): void {
|
||||||
|
this.removeEventListener(
|
||||||
|
DIEvent.DependencyProvision,
|
||||||
|
onProvided.bind(this) as Method<HTMLElement>,
|
||||||
|
);
|
||||||
|
this.removeEventListener(
|
||||||
|
DIEvent.ProviderRemoval,
|
||||||
|
onRemoved.bind(this) as Method<HTMLElement>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertType<T>(value: unknown): asserts value is T {}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { WithOpt } from "@storyteller/utility";
|
||||||
|
import { Provider, Token } from "./types";
|
||||||
|
|
||||||
|
export enum DIEvent {
|
||||||
|
InjectionRequest = "di-inject",
|
||||||
|
DependencyProvision = "di-provide",
|
||||||
|
ProviderRemoval = "di-remove",
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingProvider<T> = WithOpt<Provider<T>, "value">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event that can be dispatched to inject a dependency provided by an
|
||||||
|
* ancestor node.
|
||||||
|
*
|
||||||
|
* Providers should listen for this event. If they provide a match for the
|
||||||
|
* event's `detail.token`, they should populate its `detail.value` property and
|
||||||
|
* call `stopPropagation()` to prevent further bubbling.
|
||||||
|
*/
|
||||||
|
export class InjectionRequest<T> extends CustomEvent<PendingProvider<T>> {
|
||||||
|
declare readonly type: DIEvent.InjectionRequest;
|
||||||
|
|
||||||
|
constructor (token: Token<T>) {
|
||||||
|
super(DIEvent.InjectionRequest, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
detail: { token },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event that can be dispatched to inform ancestors that this node provides a
|
||||||
|
* dependency.
|
||||||
|
*/
|
||||||
|
export class DependencyProvision<T> extends CustomEvent<Provider<T>> {
|
||||||
|
declare readonly type: DIEvent.DependencyProvision;
|
||||||
|
|
||||||
|
constructor (token: Token<T>, value: T) {
|
||||||
|
super(DIEvent.DependencyProvision, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
detail: { token, value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event that can be dispatched to inform ancestors when a node providing a
|
||||||
|
* dependency is removed from the tree.
|
||||||
|
*/
|
||||||
|
export class ProviderRemoval<T> extends CustomEvent<Provider<T>> {
|
||||||
|
declare readonly type: DIEvent.ProviderRemoval;
|
||||||
|
|
||||||
|
constructor (token: Token<T>, value: T) {
|
||||||
|
super(DIEvent.ProviderRemoval, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
detail: { token, value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementEventMap {
|
||||||
|
[DIEvent.InjectionRequest]: InjectionRequest<any>;
|
||||||
|
[DIEvent.DependencyProvision]: DependencyProvision<any>;
|
||||||
|
[DIEvent.ProviderRemoval]: ProviderRemoval<any>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./decorators";
|
||||||
|
export * from "./events";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { AbstractCtor, Ctor } from "@storyteller/utility";
|
||||||
|
|
||||||
|
export class UniqueToken {
|
||||||
|
get id() { return this.#id; }
|
||||||
|
readonly #id: symbol;
|
||||||
|
|
||||||
|
constructor (name: string) {
|
||||||
|
this.#id = Symbol(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Token<T>
|
||||||
|
= UniqueToken
|
||||||
|
| Ctor<T>
|
||||||
|
| AbstractCtor<T>
|
||||||
|
;
|
||||||
|
|
||||||
|
export interface Provider<T> {
|
||||||
|
token: Token<T>;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
|
export interface Ctor<T, Args extends any[] = any[]> {
|
||||||
|
new (...args: Args): T;
|
||||||
|
prototype: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AbstractCtor<T, Args extends any[] = any[]>
|
||||||
|
= (abstract new (...args: Args) => T)
|
||||||
|
& { prototype: T }
|
||||||
|
;
|
||||||
|
|
||||||
export interface Fn<Args extends any[] = [], R = void> {
|
export interface Fn<Args extends any[] = [], R = void> {
|
||||||
(...args: Args): R;
|
(...args: Args): R;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Method<T, Args extends any[] = any[], R = any> {
|
||||||
|
(this: T, ...args: Args): R;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Opt<T> = T | null | undefined;
|
||||||
|
|
||||||
export type WithOpt<T, K extends keyof T>
|
export type WithOpt<T, K extends keyof T>
|
||||||
= Omit<T, K>
|
= Omit<T, K>
|
||||||
& Partial<Pick<T, K>>
|
& Partial<Pick<T, K>>
|
||||||
|
|||||||
Reference in New Issue
Block a user