Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upload Progress - revisiting #88

Closed
justinlevi opened this issue Apr 24, 2018 · 10 comments
Closed

Upload Progress - revisiting #88

justinlevi opened this issue Apr 24, 2018 · 10 comments

Comments

@justinlevi
Copy link

I still don't think this is possible with a straight fetch replacement. JakeChampion/fetch#89

#53

All attempts and research has failed for me at this point. Try to do a console.log of progress and I think the issues will be clear.

@jaydenseric
Copy link
Owner

I just threw this together:

function customFetch(url, opts = {}) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()

    xhr.open(opts.method || 'get', url)

    for (let k in opts.headers || {}) xhr.setRequestHeader(k, opts.headers[k])

    xhr.onload = e =>
      resolve({
        ok: true,
        text: () => Promise.resolve(e.target.responseText),
        json: () => Promise.resolve(JSON.parse(e.target.responseText))
      })

    xhr.onerror = reject

    if (xhr.upload)
      xhr.upload.onprogress = event =>
        console.log(`${event.loaded / event.total * 100}% uploaded`)

    xhr.send(opts.body)
  })
}

And it works:

screen shot 2018-04-25 at 3 31 56 pm

Keep in mind that XMLHttpRequest will be undefined in Node.js during SSR, so make sure you do something like:

createUploadLink({
  uri: process.env.API_URI,
  fetch: typeof window === 'undefined' ? global.fetch : customFetch
})

I don't intend to try to support progress with an official API, but good luck experimenting!

@justinlevi
Copy link
Author

@jaydenseric this is amazingly helpful. thank you so much. I don't know why, but I couldn't get the onload resolve object setup correctly.

The last thing I'm struggling with is how you would approach getting access to this progress callback outside of the Apollo Link (i.e. in a react component)?

@justinlevi
Copy link
Author

I was able to solve this with the following:

const client = new ApolloClient({
  link: ApolloLink.from([
    onError(({ graphQLErrors, networkError }) => {
      if (graphQLErrors) {
        graphQLErrors.map(({ message, locations, path }) =>
          console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`));
      }
      if (networkError) {
        console.log(`[Network error]: ${networkError}`);
      }
    }),
    withClientState({
      defaults,
      resolvers,
      cache,
    }),
    createUploadLink({
      uri: `${URL}/graphql${POSTFIX}`,
      credentials: 'include',
      fetch: typeof window === 'undefined' ? global.fetch : customFetch,
      fetchOptions: {
        onProgress: (progress) => {
          console.log(progress);
        },
      },
    }),
  ]),
  cache,
});

And my custom fetch looks like this:


const parseHeaders = (rawHeaders) => {
  const headers = new Headers();
  // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
  // https://tools.ietf.org/html/rfc7230#section-3.2
  const preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ');
  preProcessedHeaders.split(/\r?\n/).forEach((line) => {
    const parts = line.split(':');
    const key = parts.shift().trim();
    if (key) {
      const value = parts.join(':').trim();
      headers.append(key, value);
    }
  });
  return headers;
};

export default (url, options = {}) => new Promise((resolve, reject) => {
  const xhr = new XMLHttpRequest();

  xhr.onload = () => {
    const opts = {
      status: xhr.status,
      statusText: xhr.statusText,
      headers: parseHeaders(xhr.getAllResponseHeaders() || ''),
    };
    opts.url = 'responseURL' in xhr ? xhr.responseURL : opts.headers.get('X-Request-URL');
    const body = 'response' in xhr ? xhr.response : xhr.responseText;
    resolve(new Response(body, opts));
  };

  xhr.onerror = () => {
    reject(new TypeError('Network request failed'));
  };

  xhr.ontimeout = () => {
    reject(new TypeError('Network request failed'));
  };

  xhr.open(options.method, url, true);

  Object.keys(options.headers).forEach((key) => {
    xhr.setRequestHeader(key, options.headers[key]);
  });

  if (xhr.upload) {
    xhr.upload.onprogress = options.onProgress;
  }

  xhr.send(options.body);
});

@vietnd87
Copy link

@justinlevi Can you share me your full source code? Thanks!

@dlerman2
Copy link

dlerman2 commented Nov 1, 2018

FYI for anyone coming across this, I got upload progress bars working by using Axios instead of fetch.

To make the request, I'm doing:

apolloClient.mutate({
  mutation: UPLOAD_FILE,
  variables: { file },
  context: {
    fetchOptions: {
      onUploadProgress: (progress => {
        console.info(progress);
      }),
    }
  },
});

Then for my httpLink, I'm using:

  const httpLink = createUploadLink({
    uri: API_URL,
    fetch: buildAxiosFetch(axios, (config, input, init) => ({
      ...config,
      onUploadProgress: init.onUploadProgress,
    })),
  });

The only gotcha was a minor bug in lifeomic/axios-fetch that prevented multipart requests from working, so temporarily using a fork at bouldercare/axios-fetch.

@n1ru4l
Copy link

n1ru4l commented Feb 28, 2019

This is a slightly modified version that I am using, it also allows canceling a request.

const parseHeaders = (rawHeaders: any) => {
  const headers = new Headers();
  // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
  // https://tools.ietf.org/html/rfc7230#section-3.2
  const preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, " ");
  preProcessedHeaders.split(/\r?\n/).forEach((line: any) => {
    const parts = line.split(":");
    const key = parts.shift().trim();
    if (key) {
      const value = parts.join(":").trim();
      headers.append(key, value);
    }
  });
  return headers;
};

export const uploadFetch = (url: string, options: any) =>
  new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.onload = () => {
      const opts: any = {
        status: xhr.status,
        statusText: xhr.statusText,
        headers: parseHeaders(xhr.getAllResponseHeaders() || "")
      };
      opts.url =
        "responseURL" in xhr
          ? xhr.responseURL
          : opts.headers.get("X-Request-URL");
      const body = "response" in xhr ? xhr.response : (xhr as any).responseText;
      resolve(new Response(body, opts));
    };
    xhr.onerror = () => {
      reject(new TypeError("Network request failed"));
    };
    xhr.ontimeout = () => {
      reject(new TypeError("Network request failed"));
    };
    xhr.open(options.method, url, true);

    Object.keys(options.headers).forEach(key => {
      xhr.setRequestHeader(key, options.headers[key]);
    });

    if (xhr.upload) {
      xhr.upload.onprogress = options.onProgress;
    }

    options.onAbortPossible(() => {
      xhr.abort();
    });

    xhr.send(options.body);
  });

const customFetch = (uri: any, options: any) => {
  if (options.useUpload) {
    return uploadFetch(uri, options);
  }
  return fetch(uri, options);
};
const link = createUploadLink({
  uri: "http://localhost:7001/graphql",
  credentials: "same-origin",
  fetch: customFetch as any
});

Usage Example:

export const Default = withMp3FileUpload(({ mutate }) => {
  const [file, setFile] = useState<null | File>(null);
  const [progress, setProgress] = useState<number>(0);

  useEffect(() => {
    if (!mutate || !file) {
      return;
    }
    let abort: any;
    mutate({
      variables: {
        file
      },
      context: {
        fetchOptions: {
          useUpload: true,
          onProgress: (ev: ProgressEvent) => {
            setProgress(ev.loaded / ev.total);
          },
          onAbortPossible: (abortHandler: any) => {
            abort = abortHandler;
          }
        }
      }
    }).catch(err => console.log(err));

    return () => {
      if (abort) {
        abort();
      }
    };
  }, [file]);
}

@karanpratapsingh
Copy link

xhr.upload.onprogress has stopped working with apollo client v3 strangely

@lasota-piotr
Copy link

The example from @n1ru4l works on Apollo Client v3. Thanks.
BTW, I didn't want to use useUpload variable so I changed customFetch to:

const customFetch = (uri: any, options: any) => {
  if (options.onProgress) {
    // file upload
    return uploadFetch(uri, options);
  }
  return fetch(uri, options);
};

@felix-bose
Copy link

felix-bose commented Apr 5, 2023

For anyone stumbling across this in need of a typescript version:

const parseHeaders = (rawHeaders: string): Headers => {
  const headers = new Headers()
  // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
  // https://tools.ietf.org/html/rfc7230#section-3.2
  const preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, " ")
  preProcessedHeaders.split(/\r?\n/).forEach((line: string) => {
    const parts = line.split(":")
    const key = parts.shift()?.trim()
    if (key) {
      const value = parts.join(":").trim()
      headers.append(key, value)
    }
  })
  return headers
}

type OnloadOptions = { status: number; statusText: string; headers: Headers } & Record<
  string,
  any
>

type AbortHandler = XMLHttpRequest["abort"]

type CustomFetchOptions = RequestInit & {
  useUpload: boolean
  onProgress: (ev: ProgressEvent) => void
  onAbortPossible: (abortHandler: AbortHandler) => void
}

export const uploadFetch = (
  url: URL | RequestInfo,
  options: CustomFetchOptions
): Promise<Response> =>
  new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.onload = () => {
      const opts: OnloadOptions = {
        status: xhr.status,
        statusText: xhr.statusText,
        headers: parseHeaders(xhr.getAllResponseHeaders() || ""),
      }
      opts.url =
        "responseURL" in xhr ? xhr.responseURL : opts.headers.get("X-Request-URL")
      const body = "response" in xhr ? xhr.response : (xhr as any).responseText
      resolve(new Response(body, opts))
    }
    xhr.onerror = () => {
      reject(new TypeError("Network request failed"))
    }
    xhr.ontimeout = () => {
      reject(new TypeError("Network request failed"))
    }
    xhr.open(options.method || "", url as string, true)

    Object.keys(options.headers as Headers).forEach((key) => {
      const headerValue = options.headers
        ? (options.headers[key as keyof HeadersInit] as string)
        : ""
      xhr.setRequestHeader(key, headerValue)
    })

    if (xhr.upload) {
      xhr.upload.onprogress = options.onProgress
    }

    options.onAbortPossible(() => xhr.abort())

    xhr.send(options.body as XMLHttpRequestBodyInit | Document | null | undefined)
  })

export const customFetch = (
  uri: URL | RequestInfo,
  options: CustomFetchOptions
): Promise<Response> => {
  if (options.useUpload) {
    return uploadFetch(uri, options)
  }
  return fetch(uri, options)
}

Which can be used like:

const cache = new InMemoryCache()
const link = new HttpLink({
  uri,
  fetch: customFetch,
})
export const client = new ApolloClient({
  uri,
  cache,
  link,
})

@MarcusCody
Copy link

MarcusCody commented Jun 18, 2024

The current solution seem not working anymore, any one have a solution for this ? On the apollo client v3.8.7

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants