Repository: AnthumChris/fetch-progress-indicators
Branch: master
Commit: efaaaf073bc6
Files: 20
Total size: 50.4 KB
Directory structure:
gitextract_baf932zw/
├── .conf/
│ └── nginx/
│ ├── fetch-progress-headers.conf
│ ├── fetch-progress-throttle.conf
│ └── server.conf
├── .gitignore
├── LICENSE
├── README.md
├── fetch-basic/
│ ├── index.html
│ └── supported-browser.js
├── fetch-enhanced/
│ ├── app.css
│ ├── index.html
│ └── supported-browser.js
├── index.html
├── sw-basic/
│ ├── app.js
│ ├── index.html
│ ├── supported-browser-install.js
│ ├── sw-installed.html
│ └── sw-simple.js
└── test/
├── abort-event.html
└── sw-response-with-url-fragments/
├── index.html
└── sw-new-response.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .conf/nginx/fetch-progress-headers.conf
================================================
## Nginx Lua module must be installed https://docs.nginx.com/nginx/admin-guide/dynamic-modules/lua/
## https://github.com/openresty/lua-nginx-module#header_filter_by_lua
header_filter_by_lua_block {
function file_len(file_name)
local file = io.open(file_name, "r")
if (file == nil) then return -1 end
local size = file:seek("end")
file:close()
return size
end
ngx.header["X-File-Size"] = file_len(ngx.var.request_filename);
}
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Expose-Headers "content-encoding, content-length, x-file-size" always;
================================================
FILE: .conf/nginx/fetch-progress-throttle.conf
================================================
rewrite .*/((images|test)/.*) /$1 break;
include conf.d/includes/fetch-progress-headers.conf;
# Force browser to always fetch
etag off;
if_modified_since off;
add_header cache-control no-cache;
add_header Last-Modified "";
================================================
FILE: .conf/nginx/server.conf
================================================
# HTTPs Redirect
server {
listen 80;
listen [::]:80;
server_name fetch-progress.anthum.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name fetch-progress.anthum.com;
include conf.d/includes/anthum.com-ssl.conf;
include conf.d/includes/security.conf;
root /var/www/vhost/fetch-progress-prod;
access_log /var/log/nginx/fetch-progress-prod.access.log;
error_log /var/log/nginx/fetch-progress-prod.error.log;
autoindex on;
charset_types *;
charset utf-8;
gzip on;
gzip_types text/plain;
location / {
include conf.d/includes/fetch-progress-headers.conf;
}
##
## output_buffers adjustments show smoother, non-choppy progress bars for the demo.
## technically, they control number of iterations in ReadableStreamDefaultReader.read() JavaScript
##
location ~ ^/120kbps/ {
limit_rate 120k;
output_buffers 2 32k;
include conf.d/includes/fetch-progress-throttle.conf;
}
location ~ ^/60kbps/ {
limit_rate 60k;
output_buffers 1 4k;
include conf.d/includes/fetch-progress-throttle.conf;
}
location ~ ^/30kbps/ {
limit_rate 30k;
output_buffers 1 2k;
include conf.d/includes/fetch-progress-throttle.conf;
}
location ~ ^/20kbps/ {
limit_rate 20k;
output_buffers 1 1k;
include conf.d/includes/fetch-progress-throttle.conf;
}
location ~ ^/10kbps/ {
limit_rate 10k;
output_buffers 1 1k;
include conf.d/includes/fetch-progress-throttle.conf;
}
location /drop-connection {
return 444;
}
}
================================================
FILE: .gitignore
================================================
/test/data
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 Anthum, Inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
Examples for showing progress bars and progress indicators for `fetch()`. Uses the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API), and [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API).
# Demo
https://fetch-progress.anthum.com/
### Examples
* [Fetch](https://fetch-progress.anthum.com/fetch-basic/): A ReadableStream is used to show download progress during a `fetch()` download.
* [Fetch - Enhanced](https://fetch-progress.anthum.com/fetch-enhanced/): Same as above with robust code for preventing multiple downloads and handling other real-world UI interactions and edge cases.
* [Service Worker](https://fetch-progress.anthum.com/sw-basic/): A ReadableStream is used in a Service Worker to simulatenously show download progress for the `FetchEvent` of an inline `` tag.
### Gzip & Content-Encoding Support
- https://github.com/AnthumChris/fetch-progress-indicators/issues/13
### Browser Support
The aforementioned APIs are new/expermiental and do not currently work on all browsers. Testing was done with the browsers below:
| Browser | Test Results |
| :--- | :--- |
| Chrome 64 | Full support |
| Firefox 58/59 | Full support (requires [activation of experimental flags](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams#Browser_support)) |
| iOS Safari 8 | Unsupported |
| iOs Safari 11 | Fetch support only. Service Workers unsupported |
| Mac Safari 11.0 | Fetch support only. Service Workers unsupported |
| Mac Safari 11.1 | Full Support |
| IE/Edge | Not tested (no device available) |
# Background
Prior to the recent addition of [fetch()](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), the [XMLHttpRequest.onprogress](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestEventTarget/onprogress) callback handler was traditionally used to show progress bars and progress indicators. The Fetch API is great and we should use it, but "onprogress" handlers are not currently implemented with Fetch. These examples allow us to leverage Fetch while providing users with immediate feedback during certain "loading" states. Progress indicators could be especially useful to users on slow networks.
# Lessons & Conclusions
This repository began as a proof of concept for showing progress indicators from Service Workers. Everything seemed to work out and a few important lessons and caveats were discovered:
1. Firefox successfully stops network reading and cancels downloads on fetch events that implement custom `ReadableStream` readers when the user signals the cancelation/abort of a page load (e.g. pressing ESC, clicking stop button, invoking `window.stop()`)
1. Chrome and Safari don't stop network reading and files continue to download when a page load cancel/abort occurs.
1. The `abort` event does not seem to be firing on Chrome, Firefox, or Safari as defined in the HTML spec [7.8.12 Aborting a document load](https://html.spec.whatwg.org/multipage/browsing-the-web.html#abort-a-document).
1. `` callbacks are not called.
1. `window.onabort` callbacks are not called.
2. see [Abort Event Detection Test](https://fetch-progress.anthum.com/test/abort-event.html)
1. A Firefox bug was discovered when using hash fragments in URLs: https://bugzilla.mozilla.org/show_bug.cgi?id=1443850
### Back-End Image Server
To properly exemplify progress indicators for slow downloads or large files, a small (100kb) JPEG is being served from a remote HTTP/2 Nginx server that limits download speeds. The buffer/packet size is also reduced to show smoother, or more frequent progress updates (more iterations of `ReadableStreamDefaultReader.read()`). Otherwise, `read()` would send fewer progress updates that result in choppy progress indicators. Caching is disabled to force network requests for repeated tests.
Both Baseline and Progressive JPEG files are available for testing with other speeds:
https://fetch-progress.anthum.com/10kbps/images/sunrise-baseline.jpg
https://fetch-progress.anthum.com/20kbps/images/sunrise-baseline.jpg
https://fetch-progress.anthum.com/30kbps/images/sunrise-baseline.jpg
https://fetch-progress.anthum.com/60kbps/images/sunrise-baseline.jpg
https://fetch-progress.anthum.com/120kbps/images/sunrise-baseline.jpg
https://fetch-progress.anthum.com/10kbps/images/sunrise-progressive.jpg
https://fetch-progress.anthum.com/20kbps/images/sunrise-progressive.jpg
https://fetch-progress.anthum.com/30kbps/images/sunrise-progressive.jpg
https://fetch-progress.anthum.com/60kbps/images/sunrise-progressive.jpg
https://fetch-progress.anthum.com/120kbps/images/sunrise-progressive.jpg
================================================
FILE: fetch-basic/index.html
================================================
This example uses a ReadableStream to update a progress bar while a remote image is downloaded with a declarative fetch() request.
Users probably prefer seeing images load progressively with inline <img> tags, and
progress bars may be better suited for larger, non-image fetch() requests.
The Service Worker example shows progress indicators while inline images load
natively with <img> tags.
================================================
FILE: fetch-enhanced/supported-browser.js
================================================
'use strict'
class ProgressReportFetcher {
constructor(onProgress = function() {}) {
this.onProgress = onProgress;
}
// mimic native fetch() instantiation and return Promise
fetch(input, init = {}) {
const request = (input instanceof Request)? input : new Request(input)
this._cancelRequested = false;
return fetch(request, init).then(response => {
if (!response.body) {
throw Error('ReadableStream is not yet supported in this browser. More Info')
}
// this occurs if cancel() was called before server responded (before fetch() Promise resolved)
if (this._cancelRequested) {
response.body.getReader().cancel();
return Promise.reject('cancel requested before server responded.');
}
if (!response.ok) {
// HTTP error server response
throw Error(`Server responded ${response.status} ${response.statusText}`);
}
// to access headers, server must send CORS header "Access-Control-Expose-Headers: content-encoding, content-length x-file-size"
// server must send custom x-file-size header if gzip or other content-encoding is used
const contentEncoding = response.headers.get('content-encoding');
const contentLength = response.headers.get(contentEncoding ? 'x-file-size' : 'content-length');
if (contentLength === null) {
// don't evaluate download progress if we can't compare against a total size
throw Error('Response size header unavailable');
}
const total = parseInt(contentLength,10);
let loaded = 0;
this._reader=response.body.getReader()
const me = this;
return new Response(
new ReadableStream({
start(controller) {
if (me.cancelRequested) {
console.log('canceling read')
controller.close();
return;
}
read();
function read() {
me._reader.read().then(({done, value}) => {
if (done) {
// ensure onProgress called when content-length=0
if (total === 0) {
me.onProgress.call(me, {loaded, total});
}
controller.close();
return;
}
loaded += value.byteLength;
me.onProgress.call(me, {loaded, total});
controller.enqueue(value);
read();
}).catch(error => {
console.error(error);
controller.error(error)
});
}
}
})
)
});
}
cancel() {
console.log('download cancel requested.')
this._cancelRequested = true;
if (this._reader) {
console.log('cancelling current download');
return this._reader.cancel();
}
return Promise.resolve();
}
}
const imageLoader = (function() {
const loader = document.getElementById('loader');
const img = loader.querySelector('img');
const errorMsg = loader.querySelector('.error');
const loading = loader.querySelector('.progress-bar');
const progress = loader.querySelector('.progress');
let locked, started, progressFetcher, pct;
function downloadDone(url) {
console.log('downloadDone()')
img.src=url;
img.offsetWidth; // pre-animation enabler
loader.classList.remove('loading');
loader.classList.add('loading-complete');
// progressFetcher = null;
}
function startDownload() {
// Ensure "promise-safe" (aka "thread-safe" JavaScript).
// Caused by slow server response or consequetive calls to startDownload() before stopDownload() Promise resolves
if (locked) {
console.error('startDownload() failed. Previous download not yet initialized');
return;
}
locked = true;
stopDownload()
.then(function() {
locked = false;
progress.style.transform=`scaleX(0)`;
progress.offsetWidth; /* prevent animation when set to zero */
started = false;
pct = 0;
loader.classList.add('loading');
loader.classList.remove('loading-complete');
if (!progressFetcher) {
progressFetcher = new ProgressReportFetcher(updateDownloadProgress);
}
console.log('Starting download...');
progressFetcher.fetch('https://fetch-progress.anthum.com/30kbps/images/sunrise-baseline.jpg')
.then(response => response.blob())
.then(blob => URL.createObjectURL(blob))
.then(url => downloadDone(url))
.catch(error => showError(error))
});
}
function stopDownload() {
// stop previous download
if (progressFetcher) {
return progressFetcher.cancel()
} else {
// no previous download to cancel
return Promise.resolve();
}
}
function showError(error) {
console.error(error);
loader.classList.remove('loading');
loader.classList.remove('loading-complete');
loader.classList.remove('loading-error');
errorMsg.offsetWidth; // pre-animation enabler
errorMsg.innerHTML = 'ERROR: '+ error.message;
loader.classList.add('loading-error');
}
function updateDownloadProgress({loaded, total}) {
if (!started) {
loader.classList.add('loading');
started = true;
}
// handle divide-by-zero edge case when Content-Length=0
pct = total? loaded/total : 1;
progress.style.transform=`scaleX(${pct})`;
// console.log('downloaded', Math.round(pct*100)+'%')
if (loaded === total) {
console.log('download complete')
}
}
return {
startDownload,
stopDownload
}
})()
imageLoader.startDownload();
================================================
FILE: index.html
================================================
Fetch & Streams API Progress Indicators
An inline <img> tag renders on the page while a Service Worker and ReadableStream simultaneously provide download progress. Minimal code is used to exemplify the basic JavaScript requirements.
Similar to above with progress bars and robust code to handle real-world UI interactions. Downloads can be cancelled/restarted, multiple downloads are prevented, race conditions don't exist, errors edge cases are handled.
Service Worker - Enhanced UI
[not yet started]. This will exemplify progress for multiple <img> tags, download cancellations, and other real-world UI interactions.
================================================
FILE: sw-basic/app.js
================================================
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw-fetch-progress.js');
navigator.serviceWorker.addEventListener('message', event => {
ProgressBar.evalProgress(event.data)
})
}
================================================
FILE: sw-basic/index.html
================================================
Basic Service Worker Progress Indicator
Installing Service Worker...
================================================
FILE: sw-basic/supported-browser-install.js
================================================
navigator.serviceWorker.register('sw-simple.js')
.then(reg => {
if (reg.installing) {
const sw = reg.installing || reg.waiting;
sw.onstatechange = function() {
if (sw.state === 'installed') {
onward();
}
};
} else if (reg.active) {
// something's not right or SW is bypassed. previously-installed SW should have redirected this request to different page
status('
Service Worker is installed and not functioning as intended.
Please contact developer.')
}
})
.catch(error => status(error))
// SW installed. Refresh page so SW can respond with SW-enabled page.
function onward() {
setTimeout(function() {
window.location.reload();
},2000);
}
================================================
FILE: sw-basic/sw-installed.html
================================================
Basic Service Worker Progress Indicator
Service Worker updated. Please reload.
This example uses a Service Worker and ReadableStream to simultaneously show download progress of an <img> tag.
Unlike the fetch() examples, the image renders progressively with the browser's native loading behavior.
================================================
FILE: sw-basic/sw-simple.js
================================================
// evaluate progress on *?requestId=* URLs only
const progressIndicatorUrls = /\?requestId=/i;
// always install updated SW immediately
self.addEventListener('install', event => {
self.skipWaiting();
})
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const scope = self.registration.scope;
// redirect index.html to service-worker-enabled page
if (event.request.url === scope || event.request.url === scope+'index.html') {
const newUrl = scope+'sw-installed.html';
console.log('respondWith', newUrl);
event.respondWith(fetch(newUrl))
} else if (progressIndicatorUrls.test(event.request.url)) {
console.log('VER',2,event.request.url)
event.respondWith(fetchWithProgressMonitor(event))
}
})
function fetchWithProgressMonitor(event) {
/* opaque request responses won't give us access to Content-Length and
* Response.body.getReader(), which are required for calculating download
* progress. Respond with a newly-constructed Request from the original Request
* that will give us access to those.
* See https://stackoverflow.com/questions/39109789/what-limitations-apply-to-opaque-responses
* 'Access-Control-Allow-Origin' header in the response must not be the
* wildcard '*' when the request's credentials mode is 'include'. We'll omit credentials in this demo.
*/
const newRequest = new Request(event.request, {
mode: 'cors',
credentials: 'omit'
})
return fetch(newRequest).then(response => respondWithProgressMonitor(event.clientId, response));
}
function respondWithProgressMonitor(clientId, response) {
if (!response.body) {
console.warn("ReadableStream is not yet supported in this browser. See https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream")
return response;
}
if (!response.ok) {
// HTTP error code response
return response;
}
// server must send custom x-file-size header if gzip or other content-encoding is used
const contentEncoding = response.headers.get('content-encoding');
const contentLength = response.headers.get(contentEncoding ? 'x-file-size' : 'content-length');
if (contentLength === null) {
// don't track download progress if we can't compare against a total size
console.warn('Response size header unavailable. Cannot measure progress');
return response;
}
let loaded = 0;
debugReadIterations=0; // direct correlation to server's response buffer size
const total = parseInt(contentLength,10);
const reader = response.body.getReader();
return new Response(
new ReadableStream({
start(controller) {
// get client to post message. Awaiting resolution first read() progress
// is sent for progress indicator accuracy
let client;
clients.get(clientId).then(c => {
client = c;
read();
});
function read() {
debugReadIterations++;
reader.read().then(({done, value}) => {
if (done) {
console.log('read()', debugReadIterations);
controller.close();
return;
}
controller.enqueue(value);
loaded += value.byteLength;
// console.log(' SW', Math.round(loaded/total*100)+'%');
dispatchProgress({client, loaded, total});
read();
})
.catch(error => {
// error only typically occurs if network fails mid-download
console.error('error in read()', error);
controller.error(error)
});
}
},
// Firefox excutes this on page stop, Chrome does not
cancel(reason) {
console.log('cancel()', reason);
}
})
)
}
function dispatchProgress({client, loaded, total}) {
client.postMessage({loaded,total})
}
================================================
FILE: test/abort-event.html
================================================
Abort Event Detection
This page attempts to detect the abort event. Type ESC or press browser's stop button before image download completes
================================================
FILE: test/sw-response-with-url-fragments/index.html
================================================
Service Worker Response Debugger
A Service Worker installs/updates and refreshes the page. It then intercepts "sw-image.svg" img URLs and dynamicaly responds with a static base64 image using the methods below. A bug occurs when in Firefox 59 when URLs contain a fragment value and SW responds with new Response() constructor
respondWith() fetch()
respondWith() new Response(blob)
respondWith() new Response(new ReadableStream())
================================================
FILE: test/sw-response-with-url-fragments/sw-new-response.js
================================================
// base64 SVG image
const img64 = 'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gU3ZnIFZlY3RvciBJY29ucyA6IGh0dHA6Ly93d3cub25saW5ld2ViZm9udHMuY29tL2ljb24gLS0+DQo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTAwMCAxMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMDAwIDEwMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPG1ldGFkYXRhPiBTdmcgVmVjdG9yIEljb25zIDogaHR0cDovL3d3dy5vbmxpbmV3ZWJmb250cy5jb20vaWNvbiA8L21ldGFkYXRhPg0KPGc+PHBhdGggZmlsbD0iIzBiMCIgZD0iTTcyMi43LDEwSDI3Ny4zQzEyOS42LDEwLDEwLDEyOS42LDEwLDI3Ny4zdjQ0NS41QzEwLDg3MC4zLDEyOS42LDk5MCwyNzcuMyw5OTBoNDQ1LjVDODcwLjMsOTkwLDk5MCw4NzAuMyw5OTAsNzIyLjdWMjc3LjNDOTkwLDEyOS42LDg3MC4zLDEwLDcyMi43LDEweiBNODkyLDY3OC4yQzg5Miw3OTYuMiw3OTYuMiw4OTIsNjc4LjIsODkySDMyMS44QzIwMy43LDg5MiwxMDgsNzk2LjIsMTA4LDY3OC4yVjMyMS44QzEwOCwyMDMuNywyMDMuNywxMDgsMzIxLjgsMTA4aDM1Ni40Qzc5Ni4yLDEwOCw4OTIsMjAzLjcsODkyLDMyMS44VjY3OC4yTDg5Miw2NzguMnoiLz48cGF0aCBmaWxsPSIjMGIwIiBkPSJNNTA4LjgsNjYzLjljLTksOS41LTIwLjcsMTQuMi0zMi41LDE0LjJzLTIzLjUtNC44LTMyLjUtMTQuMmwtMTUzLjEtMTYyYy05LjQtMTAtMTMuNi0yMy4yLTEzLjEtMzYuM2MwLjQtMTEuOCw0LjYtMjMuNSwxMy4xLTMyLjRjOC41LTksMTkuNS0xMy40LDMwLjctMTMuOGMxMi4zLTAuNSwyNC44LDMuOSwzNC4zLDEzLjhsMTIwLjcsMTI3LjZMNjg4LjksMzM2YzkuNC0xMCwyMS45LTE0LjQsMzQuMy0xMy44YzExLjEsMC41LDIyLjEsNC45LDMwLjYsMTMuOGM4LjUsOSwxMi43LDIwLjcsMTMuMSwzMi40YzAuNSwxMy4xLTMuNiwyNi4zLTEzLjEsMzYuM0w1MDguOCw2NjMuOXoiLz48L2c+DQo8L3N2Zz4=';
const imgUrl = 'data:image/svg+xml;base64,'+img64;
const imgBytes = atob(img64);
// create blob for testing responses
const imgByteArray = new Uint8Array(imgBytes.length);
for (let i=0; i {
self.skipWaiting();
})
self.addEventListener('fetch', event => {
const url = event.request.url
if (/\/sw-image\.svg/.test(url)) {
if (/\?fetch/.test(url)) {
console.log('fetch', url);
event.respondWith(fetch(imgUrl))
}
else if (/\?blob-response/.test(url)) {
console.log('blob-response', url);
event.respondWith(new Response(blob))
}
else if (/\?stream-response/.test(url)) {
console.log('stream-response', url);
event.respondWith(
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(imgByteArray);
controller.close();
}
}),
{
headers: {
'content-type': 'image/svg+xml'
}
}
)
)
}
}
})