Client-side component-manager library

Hello all,

I am currently working on the support for the programming model we use server-side, namely component injection support by the component module, for client-side code.

See https://design.xwiki.org/xwiki/bin/view/Proposal/ClientSideComponentManager for the page centralizing design requirements, use cases and decisions.

To be able to present more easily the ideas of this proposal, I have created a standalone project GitHub - manuelleduc/component-module-xp · GitHub that should highlight the main points of this proposal (this is not a perfect match, and subject to evolve, but should give a more concrete idea of what’s in this proposal).

The underlying technology in charge of resolving the dependencies between registered components is https://inversify.io/.
This is to my knowledge the only active project of this kind, with a compatible license (MIT).
This proposal also explains strategies to keep the dependency to a specific dependency injection library shallow.

non-goals

Dynamic registration

I will post another proposal specifically for the integration of this framework in XWiki (component loading and discovery, etc.).
But, the dynamic registration of components is out of scope.
If new components are added, the browser page is expected to be fully reloaded for a new components resolution to happen.

Nested/Hierarchical/Scopes components

While not technically impossible. I’d like to limit the current proposal to a single global space of all the registered components to limit complexity.
Note that this does not prevent client-side components registered only for wikis when the extension providing them is installed. But this is related to what is served by the server.
No additional client-side scoping logic is planned at this point.

Modules

@xwiki/platform-components-annotations

This module defines the publicly available dependency injection annotations (e.g., @inject) and maps them to Inversify.
To the best of my knowledge, they cover the injection use cases we support server-side.

For “bootstraping” reasons, it is not possible to inject a concrete implementation, and developers need to point to a concrete implementation.
Therefore, I propose to follow the following strategy.

  1. Forget the underlying technology from the module id (@component-module-xp/annotations)
  2. Accept that we will not support multiple dependency injection framework at the same time
  3. In case of switch, older module will still point to a package using a technology we don’t support anymore, but the most recent version will be used at runtime

injectable

type injectable = () => ClassDecorator;

// Example
@injectable()
class MyComponent implements MyRole {}

Declares a class a component, i.e., a class that can received annotations.

inject

type inject = (id: symbol) => ParameterDecorator;

// Example
const role2Symbol = Symbol("Role2")

interface Role2 {
  //...
}

@injectable()
class MyComponent implements MyRole {
  constructor(@inject(role2Symbol) private readonly role2: Role2) {}
}

Inject the default implementation of the requested components on another component by its role and symbol.
A symbol is an immutable and unique value. This is the ID that is used to make a role unique.
This is needed because typescript relies on type-erase. Using an interface as we do in Java wouldn’t work because they don’t exist anymore once the code is compiled to JavaScript.
Symbol are lightly, and guaranteed to be unique, therefore I suggest using them.

You’ll notice that an interface Role2 is still needed for type checking.

injectAll

type injectAll = (id: symbol) => ParameterDecorator;

// Example
const role2Symbol = Symbol("Role2")

interface Role2 {
  //...
}

@injectable()
class MyComponent implements MyRole {
  constructor(@injectAll(role2Symbol) private readonly roles2: Role2[]) {}
}

Equivalent of inject but instead injects an array of all the existing registered roles.

named

type named = (name: string) => ParameterDecorator;

// Example
const role2Symbol = Symbol("Role2")

interface Role2 {
  //...
}

@injectable()
class MyComponent implements MyRole {
  constructor(@inject(role2Symbol) @named("othername") private readonly role2: Role2) {}
}

Combined with the inject annotation, injects the component with the requested name.

@xwiki/platform-components-manager-api

type Newable<TInstance = unknown, TArgs extends unknown[] = any[]> = new (
  ...args: TArgs
) => TInstance;

const resolverRole = Symbol("Resolver");

interface Manager {
  registerComponent(
    symbol: symbol,
    component: () => Promise<Newable>,
    options?: { name?: string; priority?: number },
  ): Manager;
  build(): Promise<Resolver>;
}

interface Resolver {
  get<T>(symbol: symbol): T;
  getAll<T>(symbol: symbol): T[];
}
export { resolverRole };
export type { Manager, Resolver, Newable };

Newable

A generic type of any kind of element with a constructor (a.k.a., classes).

Manager

A factory with two methods, registerComponent to register a new component, and build to finalize the registration and return a read-only Resolver.
When registering components, the familiar concept of priority is used (it is optional and the default value is 1000).
When two components with the same role and name are encountered, the one with the lowest priority value will be registered.
Since the component parameter is a lambda function returning a promise, the actual component will only be loaded in the browser if it ends up being the one with the highest priority.

Resolver

The Resolver is the only part providing methods to access components outside the inject/injectAll annotations. The Resolver is itself a component, that is expected to be registered on build. It can therefore be inject in other components when a dynamic resolution is needed.

resolverRole

The globally available symbol for the resolver. Required to inject the resolver in other components.

@xwiki/platform-components-manager

Similarly to @xwiki/platform-components-annotations we need to following the same strategy in case of replacement of the underlying dependency injection library.

Note: While having @xwiki/platform-components-manager and @xwiki/platform-components-annotations separated was better for the explanations above and for prototyping, I’m considering merging them in a single package:

  1. They are expected to change together in case of switch to another underlying dependency injections library
  2. Packages using the annotations to turn classes into components will also need to have access to the manager to register the components.

Example:

import { manager } from "@component-module-xp/manager-inversify";
import { AddSymbol } from "./symbols/addSymbol.js";
import { MultiplySymbol } from "./symbols/multiplySymbol.js";
import type { Multiply } from "./roles/multiply.js";

const resolver = await manager
  .registerComponent(
    AddSymbol,
    async () => (await import("./components/defaultAdd.js")).DefaultAdd,
  )
  .registerComponent(
    MultiplySymbol,
    async () =>
      (await import("./components/defaultMultiply.js")).DefaultMultiply,
  )
  .registerComponent(
    MultiplySymbol,
    async () => (await import("./components/wrongMultiply.js")).WrongMultiply,
    { priority: 500 }, // Register a new component with a higher priority
  )
 .registerComponent(
    MultiplySymbol,
    async () => (await import("./components/otherMultiply.js")).OtherMultiply,
    { name: "othername" }, // Register a new component with a name
  )
  // Resolve and register all components
  .build();

// Resolve a Multiply component and use it
console.log(resolver.get<Multiply>(MultiplySymbol).pair(2, 10));

Conclusion

WDYT of the proposed APIs?

PS: reminder that a follow-up post will be sent ASAP with a strategy to integrate those APIs in a XWiki instance. Focusing in particular steps required to gather all the components from different extensions, their registration, and resolution.