Skip to content
Draft
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: 1 addition & 1 deletion ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ set -euxo pipefail

npm ci
npx nx run studio:build:web
npx nx run studio-frontend:build:production
npx nx run react-frontend:build:production --output-path dist/studio-frontend
3 changes: 2 additions & 1 deletion framework/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions framework/src/lib/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./on";
65 changes: 65 additions & 0 deletions framework/src/lib/decorators/internal.ts
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 8 additions & 20 deletions framework/src/lib/decorators/on.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,29 +22,16 @@ export function on(eventSelector: string) {
});

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;
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;
Expand Down
145 changes: 145 additions & 0 deletions framework/src/lib/di/decorators.ts
Original file line number Diff line number Diff line change
@@ -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 {}
73 changes: 73 additions & 0 deletions framework/src/lib/di/events.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
}
3 changes: 3 additions & 0 deletions framework/src/lib/di/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./decorators";
export * from "./events";
export * from "./types";
21 changes: 21 additions & 0 deletions framework/src/lib/di/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading