cyclejs/cycle-core

View on GitHub
isolate/src/index.ts

Summary

Maintainability
A
0 mins
Test Coverage
import xs, {Stream} from 'xstream';
import {adapt} from '@cycle/run/lib/adapt';
export type Component<So, Si> = (sources: So, ...rest: Array<any>) => Si;

export type FirstArg<
  T extends (r: any, ...args: Array<any>) => any
> = T extends (r: infer R, ...args: Array<any>) => any ? R : any;

export type IsolateableSource<A = any, B = any> = {
  isolateSource(
    source: IsolateableSource<A, B>,
    scope: any
  ): IsolateableSource<A, B>;
  isolateSink(sink: A, scope: any): B;
};

export type Sources = {
  [name: string]: IsolateableSource;
};

export type WildcardScope = {
  ['*']?: string;
};

export type ScopesPerChannel<So> = {[K in keyof So]: any};

export type Scopes<So> =
  | (Partial<ScopesPerChannel<So>> & WildcardScope)
  | string;

function checkIsolateArgs<So, Si>(
  dataflowComponent: Component<So, Si>,
  scope: any
) {
  if (typeof dataflowComponent !== `function`) {
    throw new Error(
      `First argument given to isolate() must be a ` +
        `'dataflowComponent' function`
    );
  }
  if (scope === null) {
    throw new Error(`Second argument given to isolate() must not be null`);
  }
}

function normalizeScopes<So>(
  sources: So,
  scopes: Scopes<So>,
  randomScope: string
): ScopesPerChannel<So> {
  const perChannel = {} as ScopesPerChannel<So>;
  Object.keys(sources).forEach(channel => {
    if (typeof scopes === 'string') {
      perChannel[channel] = scopes;
      return;
    }
    const candidate = (scopes as ScopesPerChannel<So>)[channel];
    if (typeof candidate !== 'undefined') {
      perChannel[channel] = candidate;
      return;
    }
    const wildcard = (scopes as WildcardScope)['*'];
    if (typeof wildcard !== 'undefined') {
      perChannel[channel] = wildcard;
      return;
    }
    perChannel[channel] = randomScope;
  });
  return perChannel;
}

function isolateAllSources<So extends Sources>(
  outerSources: So,
  scopes: ScopesPerChannel<So>
): So {
  const innerSources = {} as So;
  for (const channel in outerSources) {
    const outerSource = outerSources[channel] as IsolateableSource;
    if (
      outerSources.hasOwnProperty(channel) &&
      outerSource &&
      scopes[channel] !== null &&
      typeof outerSource.isolateSource === 'function'
    ) {
      innerSources[channel] = outerSource.isolateSource(
        outerSource,
        scopes[channel]
      ) as any;
    } else if (outerSources.hasOwnProperty(channel)) {
      innerSources[channel] = outerSources[channel];
    }
  }
  return innerSources;
}

function isolateAllSinks<So extends Sources, Si>(
  sources: So,
  innerSinks: Si,
  scopes: ScopesPerChannel<So>
): Si {
  const outerSinks = {} as Si;
  for (const channel in innerSinks) {
    const source = sources[channel] as IsolateableSource;
    const innerSink = innerSinks[channel];
    if (
      innerSinks.hasOwnProperty(channel) &&
      source &&
      scopes[channel] !== null &&
      typeof source.isolateSink === 'function'
    ) {
      outerSinks[channel] = adapt(
        source.isolateSink(xs.fromObservable(innerSink as any), scopes[channel])
      );
    } else if (innerSinks.hasOwnProperty(channel)) {
      outerSinks[channel] = innerSinks[channel];
    }
  }
  return outerSinks;
}

/**
 * `isolate` takes a small component as input, and returns a big component.
 * A "small" component is a component that operates in a deeper scope.
 * A "big" component is a component that operates on a scope that
 * includes/wraps/nests the small component's scope. This is specially true for
 * isolation contexts such as onionify.
 *
 * Notice that we type BigSo/BigSi as any. This is unfortunate, since ideally
 * these would be generics in `isolate`. TypeScript's inference isn't strong
 * enough yet for us to automatically provide the typings that would make
 * `isolate` return a big component. However, we still keep these aliases here
 * in case TypeScript's inference becomes better, then we know how to proceed
 * to provide proper types.
 */

export type OuterSo<ISo> = {
  [K in keyof ISo]: ISo[K] extends IsolateableSource
    ? FirstArg<IsolateableSource['isolateSource']>
    : ISo[K]
};

export type OuterSi<ISo, ISi> = {
  [K in keyof ISo & keyof ISi]: ISo[K] extends IsolateableSource
    ? (ReturnType<ISo[K]['isolateSink']> extends Stream<infer T>
        ? Stream<T>
        : (ReturnType<ISo[K]['isolateSink']> extends Stream<any>
            ? Stream<unknown>
            : unknown))
    : ISi[K]
} &
  {[K in Exclude<keyof ISi, keyof ISo>]: ISi[K]};

let counter = 0;
function newScope(): string {
  return `cycle${++counter}`;
}

/**
 * Takes a `component` function and a `scope`, and returns an isolated version
 * of the `component` function.
 *
 * When the isolated component is invoked, each source provided to it is
 * isolated to the given `scope` using `source.isolateSource(source, scope)`,
 * if possible. Likewise, the sinks returned from the isolated component are
 * isolated to the given `scope` using `source.isolateSink(sink, scope)`.
 *
 * The `scope` can be a string or an object. If it is anything else than those
 * two types, it will be converted to a string. If `scope` is an object, it
 * represents "scopes per channel", allowing you to specify a different scope
 * for each key of sources/sinks. For instance
 *
 * ```js
 * const childSinks = isolate(Child, {DOM: 'foo', HTTP: 'bar'})(sources);
 * ```
 *
 * You can also use a wildcard `'*'` to use as a default for source/sinks
 * channels that did not receive a specific scope:
 *
 * ```js
 * // Uses 'bar' as the isolation scope for HTTP and other channels
 * const childSinks = isolate(Child, {DOM: 'foo', '*': 'bar'})(sources);
 * ```
 *
 * If a channel's value is null, then that channel's sources and sinks won't be
 * isolated. If the wildcard is null and some channels are unspecified, those
 * channels won't be isolated. If you don't have a wildcard and some channels
 * are unspecified, then `isolate` will generate a random scope.
 *
 * ```js
 * // Does not isolate HTTP requests
 * const childSinks = isolate(Child, {DOM: 'foo', HTTP: null})(sources);
 * ```
 *
 * If the `scope` argument is not provided at all, a new scope will be
 * automatically created. This means that while **`isolate(component, scope)` is
 * pure** (referentially transparent), **`isolate(component)` is impure** (not
 * referentially transparent). Two calls to `isolate(Foo, bar)` will generate
 * the same component. But, two calls to `isolate(Foo)` will generate two
 * distinct components.
 *
 * ```js
 * // Uses some arbitrary string as the isolation scope for HTTP and other channels
 * const childSinks = isolate(Child, {DOM: 'foo'})(sources);
 * ```
 *
 * Note that both `isolateSource()` and `isolateSink()` are static members of
 * `source`. The reason for this is that drivers produce `source` while the
 * application produces `sink`, and it's the driver's responsibility to
 * implement `isolateSource()` and `isolateSink()`.
 *
 * _Note for Typescript users:_ `isolate` is not currently type-transparent and
 * will explicitly convert generic type arguments to `any`. To preserve types in
 * your components, you can use a type assertion:
 *
 * ```ts
 * // if Child is typed `Component<Sources, Sinks>`
 * const isolatedChild = isolate( Child ) as Component<Sources, Sinks>;
 * ```
 *
 * @param {Function} component a function that takes `sources` as input
 * and outputs a collection of `sinks`.
 * @param {String} scope an optional string that is used to isolate each
 * `sources` and `sinks` when the returned scoped component is invoked.
 * @return {Function} the scoped component function that, as the original
 * `component` function, takes `sources` and returns `sinks`.
 * @function isolate
 */
function isolate<InnerSo, InnerSi>(
  component: Component<InnerSo, InnerSi>,
  scope: any = newScope()
): Component<OuterSo<InnerSo>, OuterSi<InnerSo, InnerSi>> {
  checkIsolateArgs(component, scope);
  const randomScope = typeof scope === 'object' ? newScope() : '';
  const scopes: any =
    typeof scope === 'string' || typeof scope === 'object'
      ? scope
      : scope.toString();
  return function wrappedComponent(
    outerSources: OuterSo<InnerSo>,
    ...rest: Array<any>
  ): OuterSi<InnerSo, InnerSi> {
    const scopesPerChannel = normalizeScopes(outerSources, scopes, randomScope);
    const innerSources = isolateAllSources(
      outerSources as any,
      scopesPerChannel
    );
    const innerSinks = component(innerSources, ...rest);
    const outerSinks = isolateAllSinks(
      outerSources as any,
      innerSinks,
      scopesPerChannel
    );
    return outerSinks as any;
  };
}

(isolate as any).reset = () => (counter = 0);

export default isolate;

export function toIsolated<InnerSo, InnerSi>(
  scope: any = newScope()
): (
  c: Component<InnerSo, InnerSi>
) => Component<OuterSo<InnerSo>, OuterSi<InnerSo, InnerSi>> {
  return component => isolate(component, scope);
}