npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

fry-fx

v3.1.0

Published

Create self-cancellable fetch effects

Downloads

63

Readme

NPM Version NPM Downloads GitHub issues

Cancellable fetch requests ☄️✨

This lib makes it possible to create self-cancellable request effects. It needs effector as a peer-dependency.

When you trigger an effect, all previous pending fetch requests are cancelled (effects are rejected with AbortError).

In examples request is a fetch on steroids from fry package

fry-fx is written in TypeScript


Installation

npm install effector fry-fx

or

yarn add effector fry-fx

Exports

export { createRequestFx } from './create-request-fx';
export { createController } from './create-controller';

Usage

Simple usage:

export const fetchCountryFx = createRequestFx(
  async (countryId: number, controller?: Controller): Promise<Country> =>
    request({
      url: `api/countries/${countryId}/`,
      signal: controller?.getSignal(),
    })
);

You can provide custom cancel event to cancel request manually:

export const cancelRequest = createEvent();
export const fetchCountryFx = createRequestFx({
  cancel: cancelRequest,
  handler: async (
    countryId: number,
    controller?: Controller
  ): Promise<Country> =>
    request({
      url: `api/countries/${countryId}/`,
      signal: controller?.getSignal(),
    }),
});

Usage of request effect:

// The result of last request is taken
// There is only one request at a time
fetchCountryFx(1); // fetch cancelled!
fetchCountryFx(2); // fetch cancelled!
fetchCountryFx(3); // fetch ok

And you can use it as a normal effect (classic behavior):

// Fetches in parallel
// There are three requests at a time
fetchCountryFx(1, { normal: true }); // fetch ok
fetchCountryFx(2, { normal: true }); // fetch ok
fetchCountryFx(3, { normal: true }); // fetch ok

Initial cancel event doesn't work for normal events. Use your own controller for each normal request (optional):

const controller = createController();
fetchCountryFx(1, { normal: true, controller });
// Later in your code
controller.cancel();

The handler is compatible with createEffect. There is a classic way to create normal effect:

const fetchCountry = async (
  countryId: number,
  controller?: Controller
): Promise<Country> =>
  request({
    url: `api/countries/${countryId}/`,
    signal: controller?.getSignal(),
  });

export const fetchCountryFx = createRequestFx(fetchCountry);
export const fetchCountryFxNormal = createEffect(fetchCountry);

You can provide your own domain to createRequestFx or createController:

export const app = createDomain();
export const fetchCountryFx = createRequestFx({
  domain: app,
  handler: async (
    countryId: number,
    controller?: Controller
  ): Promise<Country> =>
    request({
      url: `api/countries/${countryId}/`,
      signal: controller?.getSignal(),
    }),
});

// ... or `createController`:
export const controller = createController({ domain: app });
fetchCountryFx(1, { normal: true, controller });

You can do a cleanup, use .onCancel method of your controller:

const fx = createRequestFx(async (params: number, controller) => {
  let timeout: number;

  return new Promise((resolve, reject) => {
    void controller?.onCancel(() => {
      clearTimeout(timeout);
      reject(new Error('Cancelled'));
    });
    timeout = setTimeout(() => {
      console.log(`Not cancelled: ${params}`);
      resolve(`Result: ${params}`);
    });
  });
});

fx(1); // No logs, effect fails with "Cancelled" error
fx(2); // No logs, effect fails with "Cancelled" error
fx(3); // Logs "Not cancelled: 3", effect is done with "Result: 3"

The library modifies internal .create method in order to pass options as a second argument to an effect. You can disable this behavior and return unmodified effect by using disableFxOptions config option:

export const app = createDomain();
export const fetchCountryFx = createRequestFx({
  disableFxOptions: true,
  handler: async (
    countryId: number,
    controller?: Controller
  ): Promise<Country> =>
    request({
      url: `api/countries/${countryId}/`,
      signal: controller?.getSignal(),
    }),
});

// The sacond argument is disabled and ignored
fetchCountryFx(1 /*, { normal: true } */);

Types

export type Subscription = {
  unsubscribe: () => void;
  (): void;
};

export interface Controller {
  getSignal: () => AbortSignal;
  cancel: Event<void>;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  onCancel: (fn: () => any) => Subscription;
}

export interface ControllerConfig {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  cancel?: Unit<any>;
  domain?: Domain;
}

export type Handler<Params, Result> = (
  params: Params,
  controller?: Controller
) => Promise<Result> | Result;

export interface Config<Params, Result> {
  handler: Handler<Params, Result>;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  cancel?: Unit<any>;
  domain?: Domain;
  name?: string;
  sid?: string;
  disableFxOptions?: boolean;
}

export interface ConfigType<FN> {
  handler: FN;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  cancel?: Unit<any>;
  domain?: Domain;
  name?: string;
  sid?: string;
  disableFxOptions?: boolean;
}

export type ConfigOrHandler<Params, Result> =
  | Handler<Params, Result>
  | Config<Params, Result>;

export type Options = {
  normal?: boolean;
  controller?: Controller;
};

export interface RequestEffect<Params, Done, Fail = Error>
  extends Effect<Params, Done, Fail> {
  (payload: Params, options?: Options): Promise<Done>;
}

export declare const createController: (
  config?: ControllerConfig | undefined
) => Controller;

export declare function createRequestFx<
  Params = void,
  Done = unknown,
  Fail = Error
>(handler: Handler<Params, Done>): RequestEffect<Params, Done, Fail>;

export declare function createRequestFx<Params, Done, Fail = Error>(
  config: Config<Params, Done>
): RequestEffect<Params, Done, Fail>;

Tests

describe('createRequestFx', () => {
  it('accepts handler', async () => {
    const fx = createRequestFx(async () => Promise.resolve('data'));
    const result = await fx();

    expect(result).toEqual('data');
  });

  it('accepts config', async () => {
    const fx = createRequestFx({
      handler: async () => Promise.resolve('data'),
    });

    const result = await fx();

    expect(result).toEqual('data');
  });

  it('accepts cancel unit', () => {
    let signal: AbortSignal | undefined;
    const cancel = createEvent();
    const fx = createRequestFx({
      cancel,
      handler: (_: void, controller) => {
        signal = controller?.getSignal();
      },
    });

    void fx();
    cancel();

    expect(signal?.aborted).toBe(true);
  });

  it('accepts domain', () => {
    const domain = createDomain();
    const fx = createRequestFx({
      domain,
      handler: async () => Promise.resolve(),
    });

    expect(domain.history.effects.has(fx)).toBe(true);
  });

  it('returns effect', async () => {
    const doneData = jest.fn();
    const pending = jest.fn();

    const fx = createRequestFx(async () => Promise.resolve('data'));
    fx.doneData.watch(doneData);
    fx.pending.watch(pending);

    const result = await fx();

    expect(is.effect(fx)).toEqual(true);
    expect(result).toEqual('data');
    expect(argumentHistory(doneData)).toEqual(['data']);
    expect(argumentHistory(pending)).toEqual([false, true, false]);
  });
});

describe('effect', () => {
  it('cancels previous effects', () => {
    const signals: AbortSignal[] = [];
    const fx = createRequestFx((_: void, controller) => {
      const signal = controller?.getSignal();
      if (signal) signals.push(signal);
    });

    void fx();
    void fx();
    void fx();

    expect(signals.map(signal => signal.aborted)).toEqual([true, true, false]);
  });

  it('supports normal runs', async () => {
    const doneParams = jest.fn();
    const fail = jest.fn();
    const signals: Array<AbortSignal | undefined> = [];

    const fx = createRequestFx((_params: number, controller) => {
      const signal = controller?.getSignal();
      signals.push(signal);
    });

    fx.done.watch(({ params }) => {
      doneParams(params);
    });
    fx.fail.watch(fail);

    void fx(0, { normal: true });
    void fx(1);
    void fx(2);
    void fx(3);
    void fx(0, { normal: true });

    expect(signals.map(signal => signal?.aborted)).toEqual([
      undefined,
      true,
      true,
      false,
      undefined,
    ]);

    await nextTick();

    expect(argumentHistory(doneParams)).toEqual([0, 1, 2, 3, 0]);
    expect(argumentHistory(fail)).toEqual([]);
  });

  it('supports normal runs in separate ticks', async () => {
    const doneParams = jest.fn();
    const fail = jest.fn();
    const signals: Array<AbortSignal | undefined> = [];

    const fx = createRequestFx((_params: number, controller) => {
      const signal = controller?.getSignal();
      signals.push(signal);
    });

    fx.done.watch(({ params }) => {
      doneParams(params);
    });
    fx.fail.watch(fail);

    void fx(0, { normal: true });
    await nextTick();
    void fx(1);
    await nextTick();
    void fx(2);
    await nextTick();
    void fx(3);
    await nextTick();
    void fx(0, { normal: true });

    expect(signals.map(signal => signal?.aborted)).toEqual([
      undefined,
      true,
      true,
      false,
      undefined,
    ]);

    await nextTick();

    expect(argumentHistory(doneParams)).toEqual([0, 1, 2, 3, 0]);
    expect(argumentHistory(fail)).toEqual([]);
  });

  it('supports controller for normal runs', async () => {
    const done = jest.fn();
    const fail = jest.fn();
    const signals: Array<AbortSignal | undefined> = [];

    const fx = createRequestFx((params: number, controller) => {
      const signal = controller?.getSignal();
      signals.push(signal);
      return `-> ${params}`;
    });

    fx.done.watch(done);
    fx.fail.watch(fail);

    const controller = createController();

    void fx(0, { normal: true, controller });
    void fx(1);
    void fx(0, { normal: true });
    void fx(2);
    void fx(0, { normal: true, controller });
    void fx(3);

    await nextTick();

    void controller.cancel();

    expect(signals.map(signal => signal?.aborted)).toEqual([
      true,
      true,
      undefined,
      true,
      true,
      false,
    ]);

    expect(argumentHistory(done)).toEqual([
      {
        params: 0,
        result: '-> 0',
      },
      {
        params: 1,
        result: '-> 1',
      },
      {
        params: 0,
        result: '-> 0',
      },
      {
        params: 2,
        result: '-> 2',
      },
      {
        params: 0,
        result: '-> 0',
      },
      {
        params: 3,
        result: '-> 3',
      },
    ]);
    expect(argumentHistory(fail)).toEqual([]);
  });

  it('supports controller for individual effects', () => {
    const signals: Array<AbortSignal | undefined> = [];
    const fx = createRequestFx((_params: number, controller) => {
      const signal = controller?.getSignal();
      signals.push(signal);
    });

    const controller = createController();
    const controller2 = createController();

    void fx(0, { controller });
    void fx(1);
    void fx(0, { controller: controller2 });
    void fx(2);
    void fx(0, { controller });

    void controller.cancel();

    expect(signals.map(signal => signal?.aborted)).toEqual([
      true,
      true,
      true, // Cancelled anyway
      true,
      true,
    ]);
  });

  it('supports disableFxOptions', async () => {
    const doneParams = jest.fn();
    const fail = jest.fn();
    const signals: Array<AbortSignal | undefined> = [];

    const fx = createRequestFx({
      disableFxOptions: true,
      handler: (_params: number, controller) => {
        const signal = controller?.getSignal();
        signals.push(signal);
      },
    });

    fx.done.watch(({ params }) => {
      doneParams(params);
    });
    fx.fail.watch(fail);

    void fx(0, { normal: true }); // Options are ignored
    void fx(1);
    void fx(2);
    void fx(3);
    void fx(0, { normal: true }); // Options are ignored

    expect(signals.map(signal => signal?.aborted)).toEqual([
      true,
      true,
      true,
      true,
      false,
    ]);

    await nextTick();

    expect(argumentHistory(doneParams)).toEqual([0, 1, 2, 3, 0]);
    expect(argumentHistory(fail)).toEqual([]);
  });

  it('supports onCancel on controller', async () => {
    const done = jest.fn();
    const fail = jest.fn();
    const logs: string[] = [];

    const fx = createRequestFx(async (params: number, controller) => {
      let timeout: number;

      return new Promise((resolve, reject) => {
        void controller?.onCancel(() => {
          clearTimeout(timeout);
          reject(new Error('cancelled'));
        });
        timeout = setTimeout(() => {
          logs.push(`Not cancelled: ${params}`);
          resolve(`Result: ${params}`);
        });
      });
    });

    fx.done.watch(done);
    fx.fail.watch(fail);

    void fx(1);
    void fx(2);
    void fx(3);

    await nextTick();

    expect(logs).toMatchInlineSnapshot(`
      Array [
        "Not cancelled: 3",
      ]
    `);

    expect(argumentHistory(done)).toMatchInlineSnapshot(`
      Array [
        Object {
          "params": 3,
          "result": "Result: 3",
        },
      ]
    `);
    expect(argumentHistory(fail)).toMatchInlineSnapshot(`
      Array [
        Object {
          "error": [Error: cancelled],
          "params": 1,
        },
        Object {
          "error": [Error: cancelled],
          "params": 2,
        },
      ]
    `);
  });
});

describe('forked scope', () => {
  it('works', async () => {
    const app = createDomain();
    const fx = createRequestFx<string, string>(async (params: string) =>
      Promise.resolve(`data: ${params}`)
    );
    const $store = app.createStore('').on(fx.doneData, (_, payload) => payload);
    const scope = fork(app);
    await Promise.all([
      allSettled(fx, { scope, params: '1' }),
      allSettled(fx, { scope, params: '2' }),
      allSettled(fx, { scope, params: '3' }),
    ]);
    expect(scope.getState($store)).toEqual('data: 3');
  });
});

Repository


GitHub ★: https://github.com/doasync/fry-fx