flecks/packages/core/build/dox/concepts/hooks.md
2023-11-30 21:41:42 -06:00

10 KiB
Executable File

Hooks

Hooks are how everything happens in flecks. There are many hooks and the hooks provided by flecks are documented at the hooks reference page.

To define hooks (and turn your plain ol' boring JS modules into beautiful interesting flecks), you only have to export a hooks object:

export const hooks = {
  '@flecks/core.starting': () => {
    console.log('hello, gorgeous');
  },
};

Note: All hooks recieve an extra final argument, which is the flecks instance.

Types

 

flecks.invoke(hook, ...args)

Invokes all hook implementations and returns the results keyed by the implementing flecks' paths.

 

flecks.invokeComposed(hook, initial, ...args)

flecks.invokeComposedAsync(hook, initial, ...args)

See: function composition.

initial is passed to the first implementation, which returns a result which is passed to the second implementation, which returns a result which is passed to the third implementation, etc.

Composed hooks are orderable.

 

flecks.invokeFlat(hook, ...args)

Invokes all hook implementations and returns the results as an array.

 

flecks.invokeFleck(hook, fleck, ...args)

Invoke a single fleck's hook implementation and return the result.

 

flecks.invokeMerge(hook, ...args)

flecks.invokeMergeAsync(hook, ...args)

Invokes all hook implementations and returns the result of merging all implementations' returned objects together.

 

flecks.invokeReduce(hook, reducer, initial, ...args)

flecks.invokeReduceAsync(hook, reducer, initial, ...args)

See: Array.prototype.reduce()

Invokes hook implementations one at a time, their results being passed to the reducer as currentValue. Returns the final reduction.

 

flecks.invokeSequential(hook, ...args)

flecks.invokeSequentialAsync(hook, ...args)

Invokes all hook implementations, one after another. In the async variant, each implementation's result is awaited before invoking the next implementation.

Sequential hooks are orderable.

 

Idioms

flecks.gather(hook, options)

Gathering is useful when your fleck defines some sort of specification, and then expects its sibling flecks to actually implement it. Examples of this in flecks would be:

  • Models, defined through @flecks/db/server.models.
  • Packets, defined through @flecks/socket.packets.

One constraint of using flecks.gather() is that whatever you are gathering must be able to be extended as a class. You can't flecks.gather() plain objects, numbers, strings... you get the idea.

The most basic usage:

const Gathered = flecks.gather('my-gather-hook');

Suppose my-gather-hook above resulted in gathering two classes, Foo and Bar. In this case, Gathered would be such as:

import {ById, ByType} from '@flecks/core';

const Gathered = {
  1: Bar,
  2: Foo,
  'Bar': Bar,
  'Foo': Foo,
  [ById]: {
    1: Bar,
    2: Foo,
  },
  [ByType]: {
    'Bar': Bar,
    'Foo': Foo,
  },
};

flecks.gather() gives each of your classes a numeric (nonzero) ID as well as a type name. It also merges all numeric keys and type labels together into the result, so Gathered[1] === Gathered.Bar would evaluate to true in the example above.

The symbol keys ById and ByType are useful if you need to iterate over either all IDs or all types. Since the numeric IDs and types are merged, iterating over the entire Gathered object would otherwise result in duplicates.

Each class gathered by flecks.gather() will be extended with two properties by default: id and type. These correspond to the ID and type referenced above, and are useful for e.g. serialization.

Following from the example above:

const foo = new Gathered.Foo();
assert(foo.id === 2);
assert(foo.type === 'Foo');

flecks.gather() also supports some options:

{
  // The property added when extending the class to return the numeric ID.
  idProperty = 'id',
  // The property added when extending the class to return the type.
  typeProperty = 'type',
  // A function called with the `Gathered` object to allow checking validity.
  check = () => {},
}

As an example, when @flecks/db/server gathers models, typeProperty is set to name, because Sequelize requires its model classes to have a unique name property.

Note: the numeric IDs are useful for efficient serialization between the client and server, but if you are using this property, ensure that flecks.gather() is called equivalently on both the client and the server. As a rule of thumb, if you have serializable Gathereds, they should be invoked and defined in your-fleck, and not in your-fleck/[platform], so that they are invoked for every platform.

Flecks.provide(context, options)

Complementary to gather hooks above, Flecks.provide() allows you to ergonomically provide your flecks' implementations to a gather hook.

Here's an example of how you could manually provide @flecks/db/server.models in your own fleck:

import SomeModel from './models/some-model';
import AnotherModel from './models/another-model';

export const hooks = {
  '@flecks/db/server.models': () => ({
    SomeModel,
    AnotherModel,
  }),
}

If you think about the example above, you might realize that it will become a lot of typing to keep adding new models over time. Provider hooks exist to reduce this maintenance burden for you.

Webpack provides an API called require.context, and the flecks provider is optimized to work with this API.

Supposing our fleck is structured like so:

index.js
models/
├─ some-model.js
└─ another-model.js

then, this index.js:

import {Flecks} from '@flecks/core';

export const hooks = {
  '@flecks/db/server.models': Flecks.provide(require.context('./models', false, /\.js$/)),
};

is exactly equivalent to the gather example above. By default, Flecks.provide() CamelCases the paths, so some-model becomes SomeModel, just as in the example above.

Flecks.provide() also supports some options:

{
  // Whether to invoke the default export as a function.
  invoke = true,
  // The transformation used on the class path.
  transformer = camelCase,
}

Note: There is no requirement to use Flecks.provide(), it is merely a convenience.

Decorator hooks

When a Model (or any other) is gathered as above, an implicit hook is called: ${hook}.decorate. This allows other flecks to decorate whatever has been gathered:

export const hooks = {
  '@flecks/db/server.models.decorate': (Models) => {
    return {
      ...Models,
      User: class extends Models.User {
        
        // Let's mix in some logging...
        constructor(...args) {
          super(...args);
          console.log ('Another user decorated!');
        }
        
      },
    };
  },
};

Flecks.decorate(context, options)

As with above, there exists an API for making the maintenance of decorators even more ergonomic.

Supposing our fleck is structured like so:

index.js
models/
└─ decorators/
   └─ user.js

and supposing that ./models/decorators/user.js is written like so:

export default (User) => {
  return class extends User {
          
    // Let's mix in some logging...
    constructor(...args) {
      super(...args);
      console.log ('Another user decorated!');
    }
          
  };
};

then, this index.js:

import {Flecks} from '@flecks/core';

export const hooks = {
  '@flecks/db/server.models.decorate': (
    Flecks.decorate(require.context('./models/decorators', false, /\.js$/))
  ),
};

is exactly equivalent to the decorator example above.

Flecks.decorate() also supports some options:

{
  // The transformation used on the class path.
  transformer = camelCase,
}

Decorator hooks are orderable.

Orderable hooks

In many of the instances above, reference was made to the fact that certain hook types are "orderable".

Suppose we are composing an application and we have HTTP session state using cookies. When a user hits a route, we need to load their session and subsequently read a value from said session to determine if the user prefers dark mode. Clearly, we will have to ensure that the session reification happens first. This is one function of orderable hooks.

Flecks uses the name of the hook as a configuration key in order to determine the ordering of a hook. Let's take the hook we alluded to earlier as an example, @flecks/http/server.request.route:

Our flecks.yml could be configured like so:

'@flecks/http/server':
  'request.route':
    - '@flecks/user/session'
    - 'my-cool-fleck'

In this application, when @flecks/http/server.request.route is invoked, @flecks/user/session's implementation is invoked (which reifies the user's session from cookies), followed by my-cool-fleck's (which, we assume, does some kind of very cool dark mode check).

Ellipses/elision

It may not always be ergonomic to configure the order of every single implementation, but enough to specify which implementations must run first (or last).

For example, suppose we have multiple implementations that require there to have been a reified user session, but which order those implementations run might not be a concern. For this, flecks provides you with the ellipses entry:

'@flecks/http/server':
  'request.route':
    - '@flecks/user/session'
    - '...'
    - 'some-final-fleck'

In this application, we first reify the user session as before, but instead of listing my-cool-fleck immediately after, we specify ellipses. After the ellipses we specify some-final-fleck to, we assume, do some finalization work.

Ellipses essentially translate to: "every implementing fleck which has not already been explicitly listed in the ordering configuration".

Using more than one ellipses entry in an ordering configuration is ambiguous and will throw an error.

The default ordering configuration for any orderable hook is: ['...'] which translates to all implementations in an undefined order.