Skip to content
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
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Collectors are the bits of code which gather progress information. Pace includes

- Ajax

Monitors ajax requests (XMLHttpRequest and WebSocket) on the page. Only `GET` requests are tracked by default, see Tracking below
Monitors ajax requests (XMLHttpRequest, fetch and WebSocket) on the page. Only `GET` requests are tracked by default, see Tracking below

- Elements

Expand Down Expand Up @@ -235,6 +235,21 @@ paceOptions = {
}
```

`fetch()` requests are tracked the same way, until their response arrives: Pace can't see a response's body load
without reading it. To stop tracking them, set `trackFetch: false` inside `ajax`.

Pace waits for each request at most `maxWait` milliseconds after it's sent (15000 by default), so a request that never
finishes can't hold the bar forever. The request itself keeps going. Set a different number, or `false` to wait for
every request until it ends (anything other than a number from 0 to 2147483647, about 24 days, means the same):

```javascript
paceOptions = {
ajax: {
maxWait: 30000
}
}
```

You can disable all ajax tracking by setting `ajax` to false:

```javascript
Expand Down Expand Up @@ -280,9 +295,18 @@ paceOptions = {
```

A string matches any ajax request URL that contains it (case-sensitive); a regular expression is tested against
the URL. These patterns only apply to ajax requests, not to links, images or other resources on the page.
the URL. Patterns see the URL as the page wrote it, the full URL (such as `https://example.com/api/items?page=2`), and for
requests to the page's own origin (same protocol, domain and port) the part after it (`/api/items?page=2`), so a
pattern matches however the request's URL was written. They only apply to ajax requests, not to links, images or other
resources on the page.

Requests a page keeps open on purpose, such as long polling for chat or live updates, show the bar each time one
starts, and so do slow requests a page makes in the background, such as prefetching links. Add their URLs to
`ignoreURLs`.

WebSocket connections are tracked too. To stop that, set `trackWebSockets: false` inside `ajax`.
WebSocket connections are tracked too. To stop that, set `trackWebSockets: false` inside `ajax`. When `trackFetch` or
`trackWebSockets` is false the first time Pace starts, Pace leaves `fetch` or `WebSocket` alone, so `Pace.track` can't
track them either.

A few things Pace can't track:

Expand All @@ -292,6 +316,7 @@ A few things Pace can't track:
bundler), call `Pace.start()` early.
- Requests made through a library that replaces `window.XMLHttpRequest` with its own version (such as xhook), if that
library loads before Pace. Load Pace first.
- `fetch()` calls made through a copy of `fetch` that a library saved before Pace started.

Avoid calling `Pace.restart()` for every request, for example from jQuery's `ajaxStart`. It restarts the bar even for
ignored requests, and parallel requests restart it several times. To show the bar for `POST` requests, add `POST` to
Expand Down
133 changes: 107 additions & 26 deletions pace.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
ajax: {
trackMethods: ['GET'],
trackWebSockets: true,
trackFetch: true,
ignoreURLs: [],
maxWait: 15000,
},
};

Expand Down Expand Up @@ -298,9 +300,10 @@
return ret;
};

// true, false, or 'force' (inside Pace.track). With `ajax: false`, nothing is
// Whether to track a request of `kind` ('xhr', 'fetch' or 'socket'): true,
// false, or 'force' (inside Pace.track). With `ajax: false`, nothing is
// tracked, not even inside Pace.track.
const shouldTrack = (method) => {
const shouldTrack = (kind, method) => {
if (!options.ajax) {
return false;
}
Expand All @@ -311,28 +314,53 @@
if (ignoreStack.length) {
return false;
}
if (method === 'socket') {
if (kind === 'socket') {
return Boolean(options.ajax.trackWebSockets);
}
if (kind === 'fetch' && !options.ajax.trackFetch) {
return false;
}
// Array.prototype.includes, so a string or number from a misconfiguration
// matches nothing instead of matching substrings or throwing.
return Array.prototype.includes.call(options.ajax.trackMethods, String(method).toUpperCase());
};

const shouldIgnoreURL = (url) => {
// What ajax.ignoreURLs patterns are matched against: a request's URL as the page
// wrote it, its full URL, and for a request to the page's own origin the part
// after the origin (/api/items?page=2), so a pattern matches however the URL was
// written. `resolved` is the full URL when the browser already resolved it.
const urlsToMatch = (url, resolved) => {
const urls = [String(url)];
try {
const full = new URL(resolved ?? urls[0], document.baseURI);
urls.push(full.href);
if (full.origin === location.origin) {
urls.push(full.pathname + full.search + full.hash);
}
} catch {
// A URL the browser can't parse is matched only as written.
}
return urls;
};

const matches = (pattern, url) => {
if (typeof pattern === 'string') {
return url.includes(pattern);
}
if (pattern instanceof RegExp) {
// A pattern with the g or y flag keeps state between test() calls.
pattern.lastIndex = 0;
return pattern.test(url);
}
return false;
};

// `urls` from urlsToMatch().
const shouldIgnoreURL = (urls) => {
const patterns = options.ajax?.ignoreURLs ?? [];
for (let i = 0, n = patterns.length; i < n; i++) {
const pattern = patterns[i];
if (typeof pattern === 'string') {
if (url.includes(pattern)) {
return true;
}
} else if (pattern instanceof RegExp) {
// A pattern with the g or y flag keeps state between test() calls.
pattern.lastIndex = 0;
if (pattern.test(url)) {
return true;
}
if (urls.some((candidate) => matches(patterns[i], candidate))) {
return true;
}
}
return false;
Expand Down Expand Up @@ -361,6 +389,8 @@
endCallbacks.get(event).push(callback);
};

// Ends the request, either because it ended or because pace stopped waiting
// for it (ajax.maxWait).
const endRequest = (request) => {
const event = openRequests.get(request);
if (!event) {
Expand All @@ -378,6 +408,15 @@
endRequest(event.request);
openRequests.set(event.request, event);
endCallbacks.set(event, []);
// Pace waits for a request at most ajax.maxWait ms from when it was sent, so
// one that never ends can't hold the bar forever. The request keeps going.
// false, or a value a timer can't wait for (setTimeout would treat Infinity,
// null or more than 2^31 - 1 ms as 0), means no limit.
const maxWait = options.ajax?.maxWait;
if (typeof maxWait === 'number' && maxWait >= 0 && maxWait <= 2147483647) {
const timer = setTimeout(() => endRequest(event.request), maxWait);
endCallbacks.get(event).push(() => clearTimeout(timer));
}
intercept.trigger('request', event);
};

Expand All @@ -386,9 +425,10 @@

// Wraps XMLHttpRequest.prototype.open and send, and replaces WebSocket with a
// subclass, so the page keeps the native classes: instanceof, constants,
// subclasses and other libraries' prototype patches keep working. Outside the
// class on purpose: class bodies are strict mode, where assigning to a
// read-only property (a page may lock these) throws.
// subclasses and other libraries' prototype patches keep working. Replaces
// fetch with a function that behaves the same. Outside the class on purpose:
// class bodies are strict mode, where assigning to a read-only property (a
// page may lock these) throws.
const installIntercepts = (intercept) => {
const proto = window.XMLHttpRequest.prototype;
const open = proto.open;
Expand All @@ -397,21 +437,22 @@
const listening = new WeakSet();
proto.open = function (...args) {
const [method, url] = args;
const decision = shouldTrack(method);
const decision = shouldTrack('xhr', method);
const result = open.apply(this, args);
// Opening a request again cancels the one it was making (only once open()
// has succeeded: when it throws, the earlier request keeps going).
endRequest(this);
opened.delete(this);
if (decision) {
opened.set(this, { type: method, url: String(url), force: decision === 'force' });
// The URL as open() used it, before the page can change a URL object.
opened.set(this, { kind: 'xhr', urls: urlsToMatch(url), force: decision === 'force' });
}
return result;
};
proto.send = function (...args) {
const info = opened.get(this);
opened.delete(this);
if (!info || shouldIgnoreURL(info.url)) {
if (!info || shouldIgnoreURL(info.urls)) {
return send.apply(this, args);
}
// One listener per XMLHttpRequest, added before send() because a
Expand Down Expand Up @@ -444,13 +485,51 @@
super(url);
}
const decision = shouldTrack('socket');
if (decision && !shouldIgnoreURL(String(url))) {
if (decision && !shouldIgnoreURL(urlsToMatch(url, this.url))) {
for (const name of ['open', 'error', 'close']) {
this.addEventListener(name, () => endRequest(this), { once: true });
}
beginRequest(intercept, { type: 'socket', url: String(url), protocols, force: decision === 'force', request: this });
beginRequest(intercept, { kind: 'socket', force: decision === 'force', request: this });
}
}
};
}
if (options.ajax.trackFetch && typeof window.fetch === 'function') {
const originalFetch = window.fetch;
// Calls the original with the same `this` and arguments, never reads or
// copies a Request's body, and returns a promise that settles like the
// original's. A request ends when that promise settles: on the response's
// headers, an error or an abort, not at the end of its body.
// Declares only `input`, so its length is 1 like the native fetch's.
window.fetch = function fetch(input) {
const init = arguments[1];
let event = null;
// Deciding whether to track never throws into the page: fetch itself
// rejects input it can't use (a URL whose toString() throws, say).
try {
// Also a Request made in another frame, which instanceof wouldn't see.
const isRequest = Object.prototype.toString.call(input) === '[object Request]';
const decision = shouldTrack('fetch', init?.method ?? (isRequest ? input.method : 'GET'));
if (decision && !shouldIgnoreURL(urlsToMatch(isRequest ? input.url : input))) {
event = { kind: 'fetch', force: decision === 'force' };
}
} catch {}
const promise = originalFetch.apply(this, arguments);
if (!event) {
return promise;
}
event.request = promise;
beginRequest(intercept, event);
return promise.then(
(response) => {
endRequest(promise);
return response;
},
(error) => {
endRequest(promise);
throw error;
},
);
};
}
};
Expand Down Expand Up @@ -488,7 +567,7 @@

let _intercept = null;

// Hooks into XMLHttpRequest and WebSocket the first time a run tracks ajax.
// Hooks into XMLHttpRequest, WebSocket and fetch the first time a run tracks ajax.
const getIntercept = () => {
_intercept ??= new RequestIntercept();
return _intercept;
Expand All @@ -512,7 +591,7 @@
if (!isOpen(event) || this.elements.some((tracker) => tracker.event === event)) {
return;
}
const Tracker = event.type === 'socket' ? SocketRequestTracker : XHRRequestTracker;
const Tracker = event.kind === 'xhr' ? XHRRequestTracker : RequestTracker;
this.elements.push(new Tracker(event, this.complete));
}

Expand Down Expand Up @@ -548,7 +627,9 @@
}
}

class SocketRequestTracker {
// A request that reports no progress until it ends: a fetch (whose progress
// can't be seen without reading its body) or a WebSocket connecting.
class RequestTracker {
constructor(event, completeCallback) {
this.event = event;
this.progress = 0;
Expand Down
Loading
Loading