Skip to content

Fetch Machine

Loading...
Idle, 0
no data

Full Code

174 lines, 5525 chars
import React, { useMemo, useState } from 'react';
import { createApi, createFactoryMachine, defineStates, effect, enter, getAvailableActions, guard } from "matchina";
import { whenEventType, whenState } from "matchina/factory-machine-hooks";
import { useMachine } from 'matchina/integrations/react';
import { MachineActions } from './MachineActions';
import { autotransition } from './autotransition';
import StateMachineMermaidDiagram from './MachineViz';
import { getXStateDefinition } from './StopwatchCommon';
import { delay } from 'matchina/extras/delay';
import { zen } from 'matchina/dev/zen';

type Options = RequestInit & {
  timeout?: number;
  maxTries?: number;
  autoretry?: boolean;
  getData?: (response: Response) => any;
};

export function createFetcher(
  defaultUrl: string, 
  defaultOptions: Options = {}
) {
  const states = defineStates({
    Idle: undefined,
    Fetching: (url = defaultUrl, options = defaultOptions) => ({ url, options, abort: new AbortController() }),
    ProcessingResponse: (response: Response) => response,
    Resolved: (data: any) => data,
    Error: (error: Error) => error,
    NetworkError: (error: Error) => error,
    Aborted: undefined,
    TimedOut: undefined,
    Refetching: undefined
  });
  const canRefetch = { refetch: "Refetching" } as const
  
  const machine = createFactoryMachine(states, 
    {
      Idle: {
        fetch: "Fetching"
      },
      Fetching: {
        fetched: "ProcessingResponse",
        reject: "Error",
        abort: "Aborted",
        timeout: "TimedOut",
        networkError: "NetworkError"
      },
      ProcessingResponse: {
        ...canRefetch,
        resolve: 'Resolved',
        error: "Error"
      },    
      Resolved: canRefetch,
      Error: canRefetch,
      NetworkError: canRefetch,
      Aborted: canRefetch,
      TimedOut: canRefetch,
      Refetching: { "": "Fetching" }
    } , 'Idle')
  const fetcher = extend(zen(machine), {
    fetch: (url?: string, options?: RequestInit) => {
      fetcher.fetch(url, options)
      return fetcher.promise!
    },
    promise: undefined as undefined | Promise<Response>,
    done: undefined as undefined | Promise<void>,
    tries: 0,
  })
  const maxTries = defaultOptions.maxTries ?? Number.POSITIVE_INFINITY
  const runFetch = async (url: string, options: RequestInit) => {
    fetcher.tries = fetcher.tries + 1
    const promise = fetcher.promise = fetch(url, options)
    try {
      const res = await promise
      if (fetcher.promise === promise) {     
        fetcher.fetched(res.clone())
      }
    } catch (error) {
      const { name } = error as Error
      if (name === 'AbortError') {
        fetcher.tries--
      } else if (name === 'TypeError') {
        fetcher.networkError(error as Error)
      } else {
        fetcher.reject(error as Error)
      }
    } finally {
      delete fetcher.promise
    }
  }
  const resolveResponseData = (response: Response) => response.json()
  fetcher.setup(
    enter(whenState('Fetching', ev => {
      (ev as any).promise = runFetch(ev.to.data.url, {
        ...ev.to.data.options, 
        signal: ev.to.data.abort.signal 
      })
      const { timeout } = ev.to.as('Fetching').data.options
      if (timeout) {
        const timer = setTimeout(fetcher.timeout, timeout)
        return () => clearTimeout(timer)
      }
    })),
    effect(whenState('ProcessingResponse', ev => {      
      fetcher.tries = 0
      delay(1000).then(() => {
        resolveResponseData(ev.to.data).then(fetcher.resolve) 
      })
    })),
    autotransition(),
    effect(whenEventType('abort', ev => { ev.from.data.abort.abort() })),
    guard(ev => ev.type === 'refetch'? fetcher.tries < maxTries : true),    
  )  
  if (defaultOptions.autoretry) {
    fetcher.setup(
      effect(whenState('NetworkError' , ev => {
        if (fetcher.tries < maxTries) {
          const backoff = 1000 * fetcher.tries
          const timer = setTimeout(() => {
            fetcher.refetch()
          }, backoff)
          return () => { clearTimeout(timer) }
        }
      }))
    )
  }
  return fetcher
}

export function FetcherDemo () {
  const [config, setConfig] = useState({ 
    retry: {
      limit: 10,
      methods: ['get'],
      statusCodes: [413],
      backoffLimit: 3000
    },
    maxTries: 5 
  })


  const fetcher = useMemo(() => {
    return createFetcher("https://httpbin.org/delay/1", {
      method: "GET",
      maxTries: config.maxTries,
      timeout: 1200,
      autoretry: true,
    });
  }, [config])
  useMachine(fetcher.machine)
  const { tries } = fetcher
  const definition = useMemo(() => getXStateDefinition(fetcher.machine), [fetcher.machine])
  const actions = useMemo(() => createApi(fetcher.machine, fetcher.state.key), [fetcher.state])
  return (
    <div>   
      <StateMachineMermaidDiagram config={definition} stateKey={fetcher.state.key} actions={actions} /> 
      {fetcher.state.key}, {tries}
      <MachineActions transitions={fetcher.machine.transitions} state={fetcher.state.key} send={fetcher.machine.send} children={undefined} />

      <pre>
        {fetcher.state.match({
          Resolved: (data) => JSON.stringify(data, null, 2),
          _: () => 'no data'
        })}
      </pre>
    </div>
  )
}

type Assign<Source, Destination> = Omit<Source, keyof Destination> & Destination;

function extend<Source extends object, Destination>(source: Source, destination: Destination): Assign<Source, Destination> {
  return Object.assign(source, destination) as Assign<Source, Destination>;
}