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

client: Switch from jQuery.ajax to fetch #1215

Merged
merged 8 commits into from
Sep 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Selfoss uses [composer](https://getcomposer.org/) and [npm](https://www.npmjs.co

For the client side, you will also need JavaScript dependencies installed by calling `npm install` in the `assets` directory. You can use `npm run install-dependencies` as a shortcut for installing both sets of dependencies.

We use [Parcel](https://parceljs.org/) (installed by the command above) to build the client side of selfoss. Every time anything in `assets` directory changes, you will need to run `npm run build` for the client to be built and installed into the `public` directory. When developing, you can also use `npm run dev`; it will watch for asset changes, rebuild the bundles as needed, and reload selfoss automatically.
We use [Parcel](https://parceljs.org/) (installed by the command above) to build the client side of selfoss. Every time anything in `assets` directory changes, you will need to run `npm run build` for the client to be built and installed into the `public` directory. When developing, you can also use `npm run dev`; it will watch for asset changes, rebuild the bundles as needed, and reload selfoss automatically. Upon switching between `npm run dev` and `npm run build`, you may need to delete `assets/.cache`.

If you want to create a package with all the dependencies bundled, you can run `npm run dist` command to produce a zipball.

Expand Down
13 changes: 13 additions & 0 deletions assets/js/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class OfflineStorageNotAvailableError extends Error {
constructor(message = 'Offline storage is not available') {
super(message);
this.name = 'OfflineStorageNotAvailableError';
}
}

export class TimeoutError extends Error {
constructor(message) {
super(message);
this.name = 'TimeoutError';
}
}
183 changes: 183 additions & 0 deletions assets/js/helpers/ajax.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import formurlencoded from 'form-urlencoded';
import mergeDeepLeft from 'ramda/src/mergeDeepLeft.js';
import pipe from 'ramda/src/pipe.js';
import { TimeoutError } from '../errors';

/**
* Passing this function as a Promise handler will make the promise fail when the predicate is not true.
*/
export const rejectUnless = (pred) => (response) => {
if (pred(response)) {
return response;
} else {
let err = new Error(response.statusText);
err.response = response;
throw err;
}
};


/**
* fetch API considers a HTTP error a successful state.
* Passing this function as a Promise handler will make the promise fail when HTTP error occurs.
*/
export const rejectIfNotOkay = (response) => {
return rejectUnless(response => response.ok)(response);
};

/**
* Override fetch options.
*/
export const options = (newOpts) => (fetch) => (url, opts = {}) => fetch(url, mergeDeepLeft(opts, newOpts));

/**
* Override just a single fetch option.
*/
export const option = (name, value) => options({ [name]: value });

/**
* Override just headers in fetch.
*/
export const headers = (value) => option('headers', value);

/**
* Override just a single header in fetch.
*/
export const header = (name, value) => headers({ [name]: value });


/**
* Lift a wrapper function so that it can wrap a function returning more than just a Promise.
*
* For example, a wrapper can be a function that takes a `fetch` function and returns another
* `fetch` function with method defaulting to `POST`. This function allows us to lift the wrapper
* so that it applies on modified `fetch` functions that return an object containing `promise` field
* instead of a single Promise like AbortableFetch.
*
* @sig ((...params → Promise) → (...params → Promise)) → (...params → {promise: Promise, ...}) → (...params → {promise: Promise, ...})
*/
export const liftToPromiseField = (wrapper) => (f) => (...params) => {
let rest;
let promise = wrapper((...innerParams) => {
let {promise, ...innerRest} = f(...innerParams);
rest = innerRest;
return promise;
})(...params);

return {promise, ...rest};
};


/**
* Wrapper for fetch that makes it cancellable using AbortController.
* @return {controller: AbortController, promise: Promise}
*/
export const makeAbortableFetch = (fetch) => (url, opts = {}) => {
let controller = new AbortController();
let promise = fetch(url, {
signal: controller.signal,
...opts
});

return {controller, promise};
};


/**
* Wrapper for abortable fetch that adds timeout support.
* @return {controller: AbortController, promise: Promise}
*/
export const makeFetchWithTimeout = (abortableFetch) => (url, opts = {}) => {
// offline db consistency requires ajax calls to fail reliably,
// so we enforce a default timeout on ajax calls
let { timeout = 60000, ...rest } = opts;
let {controller, promise} = abortableFetch(url, rest);

if (timeout !== 0) {
let newPromise = promise.catch((error) => {
// Change error name in case of time out so that we can
// distinguish it from explicit abort.
if (error.name === 'AbortError' && promise.timedOut) {
error = new TimeoutError(`Request timed out after ${timeout / 1000} seconds`);
}

throw error;
});

setTimeout(() => {
promise.timedOut = true;
controller.abort();
}, timeout);

return {controller, promise: newPromise};
}

return {controller, promise};
};


/**
* Wrapper for fetch that makes it fail on HTTP errors.
* @return Promise
*/
export const makeFetchFailOnHttpErrors = (fetch) => (url, opts = {}) => {
let { failOnHttpErrors = true, ...rest } = opts;
let promise = fetch(url, rest);

if (failOnHttpErrors) {
return promise.then(rejectIfNotOkay);
}

return promise;
};


/**
* Wrapper for fetch that converts URLSearchParams body of GET requests to query string.
*/
export const makeFetchSupportGetBody = (fetch) => (url, opts = {}) => {
let { body, method, ...rest } = opts;

let newUrl = url;
let newOpts = opts;
if (Object.keys(opts).includes('method') && Object.keys(opts).includes('body') && method.toUpperCase() === 'GET' && body instanceof URLSearchParams) {
let [main, ...fragments] = newUrl.split('#');
let separator = main.includes('?') ? '&' : '?';
// append the body to the query string
newUrl = `${main}${separator}${body.toString()}#${fragments.join('#')}`;
// remove the body since it has been moved to URL
newOpts = { method, rest };
}

return fetch(newUrl, newOpts);
};


/**
* Cancellable fetch with timeout support that rejects on HTTP errors.
* In such case, the `response` will be member of the Error object.
* @return {controller: AbortController, promise: Promise}
*/
export const fetch = pipe(
// Same as jQuery.ajax
option('credentials', 'same-origin'),
header('X-Requested-With', 'XMLHttpRequest'),

makeFetchFailOnHttpErrors,
makeFetchSupportGetBody,
makeAbortableFetch,
makeFetchWithTimeout
)(window.fetch);


export const get = liftToPromiseField(option('method', 'GET'))(fetch);


export const post = liftToPromiseField(option('method', 'POST'))(fetch);


/**
* Using URLSearchParams directly handles dictionaries inconveniently.
* For example, it joins arrays with commas or includes undefined keys.
*/
export const makeSearchParams = (data) => new URLSearchParams(formurlencoded(data));
Loading