@ymnuk/chunker-upload-client (0.0.19)

Published 2026-07-02 14:52:43 +03:00 by ymnuk in ymnuk/chunker-upload

Installation

@ymnuk:registry=
npm install @ymnuk/chunker-upload-client@0.0.19
"@ymnuk/chunker-upload-client": "0.0.19"

About this package

@ymnuk/chunker-upload-client

Клиентская библиотека для чанковой загрузки файлов с адаптацией к сети.

Установка

npm install @ymnuk/chunker-upload-client

Реестр — Forgejo:

# .npmrc
@ymnuk:registry=https://git.ymnuktech.ru/api/packages/ymnuk/npm/

Быстрый старт

import { UploadManager, HttpTransport } from '@ymnuk/chunker-upload-client'

const transport = new HttpTransport({
  baseUrl: 'https://fs.example.com/api/uploads',
})

const manager = new UploadManager(transport, {
  initialChunkSize: 256 * 1024,   // 256 КБ
  maxConcurrency: 5,
})

const file = fileInput.files[0]!

manager.upload({
  uploadId: crypto.randomUUID(),
  url: '/uploads',
  file,
})
  .then(result => {
    console.log('Готово:', result.hash)
  })
  .catch(err => {
    console.error('Ошибка:', err)
  })

Колбеки для UI

Подключите колбеки на уровне менеджера, чтобы показывать прогресс, список загрузок и ошибки пользователю:

manager.setCallbacks({
  onUploadStart(uploadId, fileName, fileSize) {
    addUpload({ id: uploadId, name: fileName, size: fileSize })
  },
  onUploadProgress(uploadId, percent, loaded, total) {
    updateProgress(uploadId, percent, loaded, total)
  },
  onChunkComplete({ uploadId, chunkId, offset, size, attempt }) {
    console.log(`Чанк ${chunkId} (${size} байт) — попытка ${attempt}`)
  },
  onChunkRetry({ uploadId, chunkId, offset, size, attempt, error }) {
    console.warn(`Ретрай чанка ${chunkId}: ${error.message}`)
  },
  onUploadComplete(uploadId, success, hash) {
    if (success) {
      console.log(`Файл загружен: ${hash}`)
    }
  },
  onUploadError(uploadId, error) {
    showToast(`Ошибка загрузки: ${error.message}`)
  },
})

Колбеки получают uploadId — через него можно отслеживать прогресс по каждому файлу отдельно:

const uploads = new Map<string, { name: string; loaded: number; total: number }>()

manager.setCallbacks({
  onUploadStart(uploadId, fileName, fileSize) {
    uploads.set(uploadId, { name: fileName, loaded: 0, total: fileSize })
  },
  onUploadProgress(uploadId, percent, loaded, total) {
    const entry = uploads.get(uploadId)
    if (entry) entry.loaded = loaded
  },
  onUploadComplete(uploadId, success, hash) {
    uploads.delete(uploadId)
  },
  onUploadError(uploadId, error) {
    uploads.delete(uploadId)
  },
})

Можно комбинировать с per-upload onProgress из UploadOptions:

manager.upload({
  uploadId: '...',
  url: '/uploads',
  file,
  onProgress(percent, loaded, total) {
    uploadCard.progress = percent
  },
})

Как это работает

  1. Init — клиент получает uploadId от сервера.
  2. Resume — сначала запрашивается /status, чтобы пропустить уже загруженные чанки.
  3. Upload — чанки отправляются параллельно с контролем конкарренси.
  4. Adapt — каждые 5 чанков ансамбль алгоритмов анализирует RTT/loss/ошибки и корректирует размер чанка и параллельность.
  5. Split — при 413/507 чанк делится на 4 равные части рекурсивно. discoveredMaxSize уменьшается, последующие чанки рождаются меньшего размера.
  6. Finish — после всех чанков вычисляется SHA-256 файла и отправляется на сервер.

Важно: retry, resume и адаптация размера чанка — в UploadManager.
HttpTransport — это тонкая прослойка над fetch() без логики восстановления.
Если вызывать HttpTransport.uploadChunk() напрямую (минуя UploadManager),
ошибочный чанк не будет переотправлен автоматически.

Диапазон чанков

Размер чанка автоматически подбирается от 32 КБ до 4 МБ с шагом 32 КБ (128 значений). Алгоритмы поднимают размер на хорошей сети и снижают на плохой или при ошибках.

Обработка ошибок

HTTP-статус Действие
200 чанк принят
409 чанк уже есть (дубль)
422 CRC32 не совпал — PermanentError (без retry)
413/507 Split на 4 части рекурсивно. discoveredMaxSize уменьшается, следующие чанки рождаются меньшего размера.
429 конкарренси снижается на 2
502/503 конкарренси снижается на 1
таймаут/сеть retry до 5 раз с exponential backoff

Пример с Angular

import { HttpClient } from '@angular/common/http'
import { HttpTransport, UploadManager, type ChunkData, type ChunkResult } from '@ymnuk/chunker-upload-client'

class AngularTransport implements Transport {
  constructor(private http: HttpClient, private baseUrl: string) {}

  async uploadChunk(baseUrl: string, uploadId: string, chunk: ChunkData, signal?: AbortSignal): Promise<ChunkResult> {
    return firstValueFrom(this.http.put<ChunkResult>(
      `${baseUrl}/upload/${uploadId}`,
      chunk.data,
      {
        headers: {
          'X-Chunk-Offset': String(chunk.offset),
          'X-Chunk-Size': String(chunk.size),
          'X-Chunk-Crc32': chunk.crc32,
          'X-Chunk-Id': chunk.chunkId,
        },
        reportProgress: false,
      }
    ))
  }

  // finishUpload, getStatus — аналогично
}

// В компоненте:
@Component({ ... })
export class UploadComponent {
  private manager = new UploadManager(new AngularTransport(this.http, '/api/uploads'))

  upload(file: File) {
    this.manager.upload({ uploadId: crypto.randomUUID(), url: '/uploads', file })
  }
}

API

UploadManager

Один экземпляр может загружать сколько угодно файлов. Статистика сети накапливается между загрузками — ансамбль быстрее адаптируется. При необходимости состояние сбрасывается через reset().

class UploadManager {
  constructor(transport: Transport, options?: UploadManagerOptions)

  upload(options: UploadOptions): Promise<UploadResult>
  setCallbacks(callbacks: UploadCallbacks): void
  reset(): void  // сброс монитора сети, ансамбля, счётчика
}

UploadOptions

Поле Тип Описание
uploadId string ID сессии (с сервера)
url string Базовый URL (/uploads)
file File Файл для загрузки
signal? AbortSignal Отмена загрузки
onProgress? (percent, loaded, total) => void Прогресс текущей загрузки
minChunkSize? number Минимальный размер чанка (32 КБ)
maxChunkSize? number Максимальный размер чанка (4 МБ)
maxConcurrency? number Максимум параллельных чанков (5)
debug? boolean Логирование в console.log с префиксом [chunker] (RTT, адаптация, split, retry)

Transport

Интерфейс для реализации своего транспорта:

interface Transport {
  uploadChunk(baseUrl: string, uploadId: string, chunk: ChunkData, signal?: AbortSignal): Promise<ChunkResult>
  finishUpload(baseUrl: string, uploadId: string, hash: string, signal?: AbortSignal): Promise<FinishResult>
  getStatus(baseUrl: string, uploadId: string, signal?: AbortSignal): Promise<UploadStatus>
}

Прямой вызов Transport (без UploadManager)

Если по какой-то причине UploadManager не подходит, retry придётся делать вручную:

import { HttpTransport, type ChunkData } from '@ymnuk/chunker-upload-client'

const transport = new HttpTransport({ baseUrl: 'https://fs.example.com' })

async function uploadWithRetry(basePath: string, uploadId: string, chunk: ChunkData): Promise<void> {
  const maxAttempts = 5
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const result = await transport.uploadChunk(basePath, uploadId, chunk)
      if (result.status === 200) return
      if (result.status === 409) return  // дубль — не ошибка
    } catch {
      // сетевая ошибка — продолжаем
    }

    if (attempt < maxAttempts - 1) {
      const delay = Math.min(1000 * 2 ** attempt, 30000) + Math.random() * 1000
      await new Promise(r => setTimeout(r, delay))
    }
  }
  throw new Error(`chunk ${chunk.chunkId} failed after ${maxAttempts} attempts`)
}

Разработка

npm run test        # vitest run
npm run test:watch  # vitest
npm run lint        # tsc --noEmit
npm run build       # tsup — ESM + CJS + d.ts

Тесты находятся рядом с исходниками: src/**/*.test.ts.

Dependencies

Development dependencies

ID Version
@angular/common ^22.0.3
@types/node ^26.0.1
@vitest/coverage-v8 ^2.0.0
rxjs ^7.8.0
tsup ^8.0.0
typescript ^5.5.0
vitest ^2.0.0

Peer dependencies

ID Version
@angular/common >=18.0.0
rxjs ^7.0.0
Details
npm
2026-07-02 14:52:43 +03:00
4
25 KiB
Assets (1)
Versions (20) View all
0.0.20 2026-07-03
0.0.19 2026-07-02
0.0.18 2026-07-02
0.0.17 2026-07-02
0.0.16 2026-07-02