From b2ed422d143ae53c00bde26eb7f588f5f7410b2c Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Tue, 5 Dec 2023 17:21:43 -0500 Subject: [PATCH] Hierarchical dependency injection framework Adapted from this experiment: https://stackblitz.com/edit/vitejs-vite-6tenu8?file=src%2Fapp.ts --- framework/src/index.ts | 3 +- framework/src/lib/decorators/index.ts | 1 + framework/src/lib/decorators/internal.ts | 65 ++++++++++ framework/src/lib/decorators/on.ts | 28 ++--- framework/src/lib/di/decorators.ts | 145 +++++++++++++++++++++++ framework/src/lib/di/events.ts | 73 ++++++++++++ framework/src/lib/di/index.ts | 3 + framework/src/lib/di/types.ts | 21 ++++ utility/src/lib/types.ts | 16 +++ 9 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 framework/src/lib/decorators/index.ts create mode 100644 framework/src/lib/decorators/internal.ts create mode 100644 framework/src/lib/di/decorators.ts create mode 100644 framework/src/lib/di/events.ts create mode 100644 framework/src/lib/di/index.ts create mode 100644 framework/src/lib/di/types.ts diff --git a/framework/src/index.ts b/framework/src/index.ts index 49b1fa1..6b91947 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -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"; diff --git a/framework/src/lib/decorators/index.ts b/framework/src/lib/decorators/index.ts new file mode 100644 index 0000000..66c74fd --- /dev/null +++ b/framework/src/lib/decorators/index.ts @@ -0,0 +1 @@ +export * from "./on"; diff --git a/framework/src/lib/decorators/internal.ts b/framework/src/lib/decorators/internal.ts new file mode 100644 index 0000000..026fe5e --- /dev/null +++ b/framework/src/lib/decorators/internal.ts @@ -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, +>(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, +>(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(proto: E, key: string | number | symbol) + : TypedPropertyDescriptor> | 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; +} diff --git a/framework/src/lib/decorators/on.ts b/framework/src/lib/decorators/on.ts index e15ad57..dbd4f5b 100644 --- a/framework/src/lib/decorators/on.ts +++ b/framework/src/lib/decorators/on.ts @@ -1,5 +1,6 @@ import { match } from "@storyteller/utility"; import type { LitElement } from "lit"; +import { getMethodDescriptor, prependRoutine } from "./internal"; /** * Adds the decorated method as an event listener for the given event name, and @@ -21,29 +22,16 @@ export function on(eventSelector: string) { }); return (proto: T, propName: keyof T, desc: PropertyDescriptor) => { - const handler = desc?.value ?? proto[propName] ?? (() => {}); + const handler = getMethodDescriptor(proto, propName)?.value ?? (() => {}); - const connectedCb = proto.connectedCallback; - const disconnectedCb = proto.disconnectedCallback; - - Object.defineProperty(proto, "connectedCallback", { - value: function (this: HTMLElement) { - target ??= this; - target.addEventListener(eventName, handler.bind(this)); - connectedCb.call(this); - }, - configurable: true, - writable: true, + prependRoutine(proto, "connectedCallback", function (this: T) { + target ??= this; + target.addEventListener(eventName, handler.bind(this)); }); - Object.defineProperty(proto, "disconnectedCallback", { - value: function (this: HTMLElement) { - target ??= this; - target.removeEventListener(eventName, handler.bind(this)); - disconnectedCb.call(this); - }, - configurable: true, - writable: true, + prependRoutine(proto, "disconnectedCallback", function (this: T) { + target ??= this; + target.removeEventListener(eventName, handler.bind(this)); }); return desc; diff --git a/framework/src/lib/di/decorators.ts b/framework/src/lib/di/decorators.ts new file mode 100644 index 0000000..84ed7dd --- /dev/null +++ b/framework/src/lib/di/decorators.ts @@ -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 + = Token + | { + token: Token, + provide(this: E): T, + }; + +export function provide(options: ProviderOptions) { + type Decorated = E & { + [$$injector]?: Map, any> | undefined; + readonly [$injector]: Map, any>; + } + + return (Target: Ctor) => { + const proto = Target.prototype; + assertType(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(this: Decorated, event: InjectionRequest): 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); + } + }); + + 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(token: Token) { + return (proto: E, key: K) => { + const $field = Symbol(String(key)); + const initial = proto[key]; + + type Decorated = E & { + [$field]?: E[K] | undefined; + } + + assertType(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(token: Token) { + return (proto: E, key: K) => { + function onProvided(this: E, event: DependencyProvision): void { + const array = this[key]; + assertType(array); + + if (event.detail.token === token) + this[key] = array.concat(event.detail.value) as E[K]; + } + + function onRemoved(this: E, event: ProviderRemoval): void { + const array = this[key]; + assertType(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, + ); + this.addEventListener( + DIEvent.ProviderRemoval, + onRemoved.bind(this) as Method, + ); + }); + + appendRoutine(proto, "disconnectedCallback", function (this: E): void { + this.removeEventListener( + DIEvent.DependencyProvision, + onProvided.bind(this) as Method, + ); + this.removeEventListener( + DIEvent.ProviderRemoval, + onRemoved.bind(this) as Method, + ); + }); + } +} + +function assertType(value: unknown): asserts value is T {} diff --git a/framework/src/lib/di/events.ts b/framework/src/lib/di/events.ts new file mode 100644 index 0000000..629d5c8 --- /dev/null +++ b/framework/src/lib/di/events.ts @@ -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 = WithOpt, "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 extends CustomEvent> { + declare readonly type: DIEvent.InjectionRequest; + + constructor (token: Token) { + 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 extends CustomEvent> { + declare readonly type: DIEvent.DependencyProvision; + + constructor (token: Token, 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 extends CustomEvent> { + declare readonly type: DIEvent.ProviderRemoval; + + constructor (token: Token, value: T) { + super(DIEvent.ProviderRemoval, { + bubbles: true, + cancelable: true, + composed: true, + detail: { token, value }, + }); + } +} + +declare global { + interface HTMLElementEventMap { + [DIEvent.InjectionRequest]: InjectionRequest; + [DIEvent.DependencyProvision]: DependencyProvision; + [DIEvent.ProviderRemoval]: ProviderRemoval; + } +} diff --git a/framework/src/lib/di/index.ts b/framework/src/lib/di/index.ts new file mode 100644 index 0000000..ce72d0c --- /dev/null +++ b/framework/src/lib/di/index.ts @@ -0,0 +1,3 @@ +export * from "./decorators"; +export * from "./events"; +export * from "./types"; diff --git a/framework/src/lib/di/types.ts b/framework/src/lib/di/types.ts new file mode 100644 index 0000000..9eb09e5 --- /dev/null +++ b/framework/src/lib/di/types.ts @@ -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 + = UniqueToken + | Ctor + | AbstractCtor + ; + +export interface Provider { + token: Token; + value: T; +} diff --git a/utility/src/lib/types.ts b/utility/src/lib/types.ts index 34b592b..c2182aa 100644 --- a/utility/src/lib/types.ts +++ b/utility/src/lib/types.ts @@ -1,7 +1,23 @@ +export interface Ctor { + new (...args: Args): T; + prototype: T; +} + +export type AbstractCtor + = (abstract new (...args: Args) => T) + & { prototype: T } + ; + export interface Fn { (...args: Args): R; } +export interface Method { + (this: T, ...args: Args): R; +} + +export type Opt = T | null | undefined; + export type WithOpt = Omit & Partial>