Skip to content

Resumable file upload #312

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

Merged
merged 4 commits into from
Feb 8, 2022
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
62 changes: 31 additions & 31 deletions 5-network/09-resume-upload/article.md
Original file line number Diff line number Diff line change
@@ -1,82 +1,82 @@
# Resumable file upload
# Відновлюване завантаження файлу

With `fetch` method it's fairly easy to upload a file.
За допомогою методу `fetch` досить легко завантажити файл.

How to resume the upload after lost connection? There's no built-in option for that, but we have the pieces to implement it.
Але як відновити завантаження після втрати з’єднання? Для цього немає вбудованого функціонала, але у нас є все необхідне для його реалізації.

Resumable uploads should come with upload progress indication, as we expect big files (if we may need to resume). So, as `fetch` doesn't allow to track upload progress, we'll use [XMLHttpRequest](info:xmlhttprequest).
Відновлювані завантаження файлів повинні мати індикацію прогресу, оскільки ми очікуємо завантаження великих файлів. Отже, оскільки `fetch` не дозволяє відстежувати хід завантаження на сервер, ми будемо використовувати [XMLHttpRequest](info:xmlhttprequest).

## Not-so-useful progress event
## Не дуже корисна подія progress

To resume upload, we need to know how much was uploaded till the connection was lost.
Щоб відновити завантаження, нам потрібно знати, скільки даних було завантажено до втрати з’єднання.

There's `xhr.upload.onprogress` to track upload progress.
Існує подія `xhr.upload.onprogress`, яка використовується для відстежування ходу завантаження на сервер.

Unfortunately, it won't help us to resume the upload here, as it triggers when the data is *sent*, but was it received by the server? The browser doesn't know.
Але, на жаль, вона нам не допоможе відновити завантаження, оскільки ця подія спрацьовує в момент, коли дані *відсилаються*, але чи отримав їх сервер? Браузер цього не знає.

Maybe it was buffered by a local network proxy, or maybe the remote server process just died and couldn't process them, or it was just lost in the middle and didn't reach the receiver.
Можливо, дані були буферизовані проксі-сервером локальної мережі, або, можливо, процес сервера просто завершився і не зміг їх обробити, або дані просто загубилися в процесі передачі і не досягли одержувача.

That's why this event is only useful to show a nice progress bar.
Тому ця подія корисна лише для того, щоб показати гарний індикатор прогресу.

To resume upload, we need to know *exactly* the number of bytes received by the server. And only the server can tell that, so we'll make an additional request.
Для відновлення завантаження, нам потрібно *точно* знати кількість байтів, отриманих сервером. І тільки сервер має цю інформацію, тому ми зробимо додатковий запит.

## Algorithm
## Алгоритм

1. First, create a file id, to uniquely identify the file we're going to upload:
1. Спочатку створюємо ідентифікатор файлу, щоб однозначно ідентифікувати файл, який ми збираємося завантажити на сервер:
```js
let fileId = file.name + '-' + file.size + '-' + file.lastModified;
```
That's needed for resume upload, to tell the server what we're resuming.
Це потрібно, щоб повідомити серверу, для якого саме файлу ми відновлюємо завантаження.

If the name or the size or the last modification date changes, then there'll be another `fileId`.
Якщо змінюється назва, розмір або дата останньої модифікації файлу, `fileId` буде відрізнятися.

2. Send a request to the server, asking how many bytes it already has, like this:
2. Надсилаємо запит серверу, щоб дізнатися, скільки байтів вже завантажено, наприклад:
```js
let response = await fetch('status', {
headers: {
'X-File-Id': fileId
}
});

// The server has that many bytes
// сервер отримав стільки байтів
let startByte = +await response.text();
```

This assumes that the server tracks file uploads by `X-File-Id` header. Should be implemented at server-side.
Передбачається, що сервер відстежує завантаження файлів за допомогою заголовків `X-File-Id`. Це повинно бути реалізовано на стороні сервера.

If the file doesn't yet exist at the server, then the server response should be `0`
Якщо файл ще не існує на сервері, тоді відповідь сервера має бути `0`

3. Then, we can use `Blob` method `slice` to send the file from `startByte`:
3. Після цього ми можемо використати метод `slice` об’єкта `Blob`, щоб надіслати файл починаючи з байта вказаного в `startByte`:
```js
xhr.open("POST", "upload", true);

// File id, so that the server knows which file we upload
// Ідентифікатор файлу, щоб сервер знав, який файл ми завантажуємо
xhr.setRequestHeader('X-File-Id', fileId);

// The byte we're resuming from, so the server knows we're resuming
// Байт, починаючи з якого ми відновлюємо завантаження
xhr.setRequestHeader('X-Start-Byte', startByte);

xhr.upload.onprogress = (e) => {
console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
console.log(`Завантажено ${startByte + e.loaded} з ${startByte + e.total}`);
};

// file can be from input.files[0] or another source
// файл може бути з input.files[0] або з іншого джерела
xhr.send(file.slice(startByte));
```

Here we send the server both file id as `X-File-Id`, so it knows which file we're uploading, and the starting byte as `X-Start-Byte`, so it knows we're not uploading it initially, but resuming.
Ми надсилаємо серверу ідентифікатор файлу у заголовку `X-File-Id`, щоб він знав, який саме файл ми завантажуємо, та початковий байт у заголовку `X-Start-Byte`, щоб повідомити, що ми відновлюємо завантаження, а не завантажуємо його спочатку.

The server should check its records, and if there was an upload of that file, and the current uploaded size is exactly `X-Start-Byte`, then append the data to it.
Сервер повинен перевірити свої записи, і якщо було завантаження цього файлу, а також поточний розмір завантаженого файлу точно дорівнює `X-Start-Byte`, то додати дані до нього.


Here's the demo with both client and server code, written on Node.js.
Ось приклад з кодом клієнта і сервера, написаний на Node.js.

It works only partially on this site, as Node.js is behind another server named Nginx, that buffers uploads, passing them to Node.js when fully complete.
На цьому сайті він працює лише частково, оскільки Node.js знаходиться за іншим сервером під назвою Nginx, який буферує завантаження і передає їх у Node.js тільки після повного завершення.

But you can download it and run locally for the full demonstration:
Але ви можете завантажити та запустити його локально для повної демонстрації:

[codetabs src="upload-resume" height=200]

As we can see, modern networking methods are close to file managers in their capabilities -- control over headers, progress indicator, sending file parts, etc.
Як бачимо, сучасні мережеві методи за своїми можливостями близькі до файлових менеджерів -- контроль над заголовками, індикатор прогресу, надсилання частин файлу тощо.

We can implement resumable upload and much more.
Ми можемо реалізувати як відновлюване завантаження файлів, так і багато іншого.
14 changes: 7 additions & 7 deletions 5-network/09-resume-upload/upload-resume.view/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

<form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
<input type="file" name="myfile">
<input type="submit" name="submit" value="Upload (Resumes automatically)">
<input type="submit" name="submit" value="Завантажити файл (відновлюється автоматично)">
</form>

<button onclick="uploader.stop()">Stop upload</button>
<button onclick="uploader.stop()">Зупинити завантаження</button>


<div id="log">Progress indication</div>
<div id="log">Індикація прогресу</div>

<script>
function log(html) {
Expand All @@ -19,7 +19,7 @@
}

function onProgress(loaded, total) {
log("progress " + loaded + ' / ' + total);
log("завантажується " + loaded + ' / ' + total);
}

let uploader;
Expand All @@ -36,14 +36,14 @@
let uploaded = await uploader.upload();

if (uploaded) {
log('success');
log('успішно');
} else {
log('stopped');
log('зупинено');
}

} catch(err) {
console.error(err);
log('error');
log('помилка');
}
};

Expand Down
22 changes: 11 additions & 11 deletions 5-network/09-resume-upload/upload-resume.view/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,36 @@ function onUpload(req, res) {
res.end();
}

// we'll files "nowhere"
// ми будемо зберігати файли в "нікуди"
let filePath = '/dev/null';
// could use a real path instead, e.g.
// замість цього можна використовувати реальний шлях, наприклад
// let filePath = path.join('/tmp', fileId);

debug("onUpload fileId: ", fileId);

// initialize a new upload
// ініціалізуємо нове завантаження
if (!uploads[fileId]) uploads[fileId] = {};
let upload = uploads[fileId];

debug("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte)

let fileStream;

// if startByte is 0 or not set, create a new file, otherwise check the size and append to existing one
// якщо startByte не встановлений або дорівнює 0, то створюємо новий файл, в противному випадку перевіряємо розмір і додаємо дані до наявного файлу
if (!startByte) {
upload.bytesReceived = 0;
fileStream = fs.createWriteStream(filePath, {
flags: 'w'
});
debug("New file created: " + filePath);
} else {
// we can check on-disk file size as well to be sure
// ми також можемо перевірити розмір файлу на диску, щоб бути впевненими
if (upload.bytesReceived != startByte) {
res.writeHead(400, "Wrong start byte");
res.end(upload.bytesReceived);
return;
}
// append to existing file
// додати дані до наявного файлу
fileStream = fs.createWriteStream(filePath, {
flags: 'a'
});
Expand All @@ -59,26 +59,26 @@ function onUpload(req, res) {
upload.bytesReceived += data.length;
});

// send request body to file
// відправляємо тіло запиту у файл
req.pipe(fileStream);

// when the request is finished, and all its data is written
// коли запит буде завершено, і всі його дані будуть записані
fileStream.on('close', function() {
if (upload.bytesReceived == req.headers['x-file-size']) {
debug("Upload finished");
delete uploads[fileId];

// can do something else with the uploaded file here
// тут можна зробити ще щось інше із завантаженим файлом

res.end("Success " + upload.bytesReceived);
} else {
// connection lost, we leave the unfinished file around
// з’єднання втрачено, ми зберігаємо незавершений файл
debug("File unfinished, stopped at " + upload.bytesReceived);
res.end();
}
});

// in case of I/O error - finish the request
// у разі помилки введення/виводу - завершити запит
fileStream.on('error', function(err) {
debug("fileStream error");
res.writeHead(500, "File error");
Expand Down
18 changes: 9 additions & 9 deletions 5-network/09-resume-upload/upload-resume.view/uploader.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ class Uploader {
this.file = file;
this.onProgress = onProgress;

// create fileId that uniquely identifies the file
// we could also add user session identifier (if had one), to make it even more unique
// створюємо fileId, який однозначно ідентифікує файл
// ми також могли б додати ідентифікатор сесії користувача (якщо він є), щоб зробити його ще більш унікальним
this.fileId = file.name + '-' + file.size + '-' + file.lastModified;
}

Expand All @@ -31,9 +31,9 @@ class Uploader {
let xhr = this.xhr = new XMLHttpRequest();
xhr.open("POST", "upload", true);

// send file id, so that the server knows which file to resume
// надсилаємо ідентифікатор файлу, щоб сервер знав, завантаження якого файлу ми відновлюємо
xhr.setRequestHeader('X-File-Id', this.fileId);
// send the byte we're resuming from, so the server knows we're resuming
// надсилаємо байт, з якого ми відновлюємо завантаження
xhr.setRequestHeader('X-Start-Byte', this.startByte);

xhr.upload.onprogress = (e) => {
Expand All @@ -43,10 +43,10 @@ class Uploader {
console.log("send the file, starting from", this.startByte);
xhr.send(this.file.slice(this.startByte));

// return
// true if upload was successful,
// false if aborted
// throw in case of an error
// повертає
// true якщо завантаження було успішним,
// false якщо перервано
// throw в разі помилки
return await new Promise((resolve, reject) => {

xhr.onload = xhr.onerror = () => {
Expand All @@ -59,7 +59,7 @@ class Uploader {
}
};

// onabort triggers only when xhr.abort() is called
// onabort запускається лише тоді, коли викликається xhr.abort()
xhr.onabort = () => resolve(false);

});
Expand Down