UNPKG

js-uploader

Version:
700 lines (638 loc) 22.2 kB
import { ID, StatusCode, EventType, UploaderOptions, UploadFile, UploadTask } from '../interface' import { FileStore, FileDragger, FilePicker, getStorage } from './modules' import { handle as handleTask, TaskHandler } from './handlers' import { tap, map, concatMap, mapTo, mergeMap, filter, first, switchMap, takeUntil, last, bufferCount, } from 'rxjs/operators' import { from, Observable, throwError, Subscription, merge, Subject, fromEvent, race, of, scheduled, Subscriber, asapScheduler, asyncScheduler, iif, } from 'rxjs' import Base from './Base' import { isElectron } from '../utils' import { taskFactory, fileFactory } from './helpers' import { Logger } from '../shared' // import * as packageJson from '../../package.json' const defaultOptions: UploaderOptions = { requestOptions: { url: '/', timeout: 0, }, autoUpload: false, computeFileHash: false, computeChunkHash: false, maxRetryTimes: 3, retryInterval: 5000, chunked: true, chunkSize: 1024 * 1024 * 4, chunkConcurrency: 1, taskConcurrency: 1, resumable: true, } export class Uploader extends Base { readonly id?: ID readonly options: UploaderOptions readonly taskQueue: UploadTask[] = [] readonly filePickers: FilePicker[] = [] readonly fileDraggers: FileDragger[] = [] private taskHandlerMap: Map<ID, TaskHandler> = new Map() private upload$: Nullable<Observable<UploadTask>> = null private subscription: Subscription = new Subscription() private uploadSubscription: Nullable<Subscription> = null private taskSubject: Subject<UploadTask> = new Subject<UploadTask>() private action: Subject<string> = new Subject<string>() private pause$ = this.action.pipe(filter((v) => v === 'pause')) private clear$ = this.action.pipe(filter((v) => v === 'clear')) // static readonly version = (packageJson as any).version constructor(options?: UploaderOptions) { super(options?.id) const opt: UploaderOptions = this.mergeOptions(options) this.validateOptions(opt) this.options = opt this.id = this.options.id this.initFilePickersAndDraggers() this.initEventHandler() this.options.resumable && this.restoreTask() } static create(options?: UploaderOptions): Uploader { return new Uploader(options) } private mergeOptions(options?: UploaderOptions): UploaderOptions { let opt = Object.assign({}, defaultOptions, options) Object.keys(defaultOptions).forEach((key) => { let k = key as keyof UploaderOptions if (typeof defaultOptions[k] === 'object') { Object.assign(defaultOptions[k] as Record<string, any>, opt[k]) let val = Object.assign({}, defaultOptions[k], opt[k]) Object.assign(opt, { [k]: val }) } }) return opt } private validateOptions(options: UploaderOptions) { Logger.info('🚀 ~ file: Uploader.ts ~ line 84 ~ Uploader ~ validateOptions ~ options', options) // TODO if (typeof options !== 'object') { throw new Error('') } } upload(task?: UploadTask, action?: 'resume' | 'retry'): void { if (!this.uploadSubscription || this.uploadSubscription?.closed) { const filteredTaskStatus = [StatusCode.Waiting, StatusCode.Uploading, StatusCode.Complete] this.upload$ = this.taskSubject.pipe( filter((task: UploadTask) => { return !filteredTaskStatus.includes(task.status as StatusCode) }), tap((task: UploadTask) => { Logger.info('🚀 ~ file: 等待上传', task) this.changeUploadTaskStatus(task, StatusCode.Waiting) this.emit(EventType.TaskWaiting, task) }), mergeMap((task: UploadTask) => { return this.executeForResult(task, action) }, this.options.taskConcurrency || 1), ) this.uploadSubscription?.unsubscribe() this.uploadSubscription = this.upload$.subscribe({ next: (v: UploadTask) => { Logger.info('任务结束', v) this.checkComplete() }, error: (e: Error) => { Logger.info('任务出错', e) }, complete: () => { Logger.info('所有任务完成') }, }) this.subscription.add(this.uploadSubscription) } this.putNextTask(task) } private checkComplete(): void { if (this.isComplete()) { this.emit(EventType.Complete) } } private executeForResult(task: UploadTask, action?: string): Observable<UploadTask> { return of(task).pipe( filter((task) => task.status === StatusCode.Waiting), concatMap(() => this.getTaskHandler(task)), tap((handler) => { handler = this.rebindTaskHandlerEvent(handler) action === 'resume' ? handler.resume() : action === 'retry' ? handler.retry() : handler.handle() }), concatMap((handler) => race( fromEvent(handler, EventType.TaskPause).pipe( tap(() => { // 暂停 Logger.info('任务暂停') }), ), fromEvent(handler, EventType.TaskCancel).pipe( tap(() => { // 取消 this.freeHandler(task) }), ), fromEvent(handler, EventType.TaskComplete).pipe( tap(() => { // 完成 this.freeHandler(task) }), ), fromEvent(handler, EventType.TaskError).pipe( switchMap((err) => { // 出错 跳过错误的任务? if (this.options.skipTaskWhenUploadError) { this.freeHandler(task) return of(null) } return throwError(err) }), ), ).pipe(mapTo(task), first()), ), ) } resume(task?: UploadTask): void { this.upload(task, 'resume') } retry(task?: UploadTask): void { this.upload(task, 'retry') } pause(task?: UploadTask): void { this.action.next('pause') const fn = (task: UploadTask) => { const handler = this.taskHandlerMap.get(task.id)?.pause() if (!handler) { task.status = StatusCode.Pause // 任务暂停事件 this.emit(EventType.TaskPause, task) } } let tasks = task ? [task] : this.taskQueue let filteredStatus = [StatusCode.Pause, StatusCode.Complete, StatusCode.Error] from(tasks) .pipe( filter((tsk: UploadTask) => !filteredStatus.includes(tsk.status)), tap((tsk) => fn(tsk)), ) .subscribe({ complete: () => { this.emit(EventType.TasksPause) }, }) } cancel(task?: UploadTask): Promise<void | UploadTask> { if (task) { this.action.next('cancel') return this.removeTask(task) } else { return this.clear() } } clear(): Promise<void> { return new Promise<void>((resolve) => { this.action.next('clear') const unsubscribe = () => { this.uploadSubscription?.unsubscribe() this.uploadSubscription = null this.upload$ = null } unsubscribe() if (this.taskQueue.length === 0) { return resolve() } of(this.taskQueue.splice(0, this.taskQueue.length)) .pipe( tap(() => this.emit(EventType.Clear)), concatMap((list) => this.removeTask(list, true)), concatMap(() => this.clearStorage(this.id)), tap(() => FileStore.clear()), ) .subscribe({ complete: () => { unsubscribe() resolve() }, }) }) } cancelFile(item: { task: UploadTask; files: UploadFile[] }) { let { task, files } = item let handler = this.taskHandlerMap.get(task.id)?.abortFile(...files) let rawTask = this.taskQueue.find((i) => i.id === task.id) if (!handler && rawTask) { files.forEach((file) => { let idIndex = rawTask?.fileIDList.indexOf(file.id)! if (idIndex > -1) { rawTask?.fileIDList.splice(idIndex, 1) rawTask!.fileSize -= file.size this.emit(EventType.FileCancel, rawTask, FileStore.get(file.id)) } let fileIndex = rawTask?.fileList.findIndex((i) => i.id === file.id)! if (fileIndex !== -1) { rawTask?.fileList.splice(fileIndex, 1) } this.emit(EventType.TaskUpdate, rawTask) }) this.emit(EventType.FilesCancel, rawTask, files) } this.once(EventType.FilesCancel, () => { if (this.options.resumable) { this.presistTaskOnly(task) this.removeFileFromStroage(...files) this.removeChunkFromStroage(...files.reduce((arr: ID[], i) => arr.concat(i.chunkIDList), [])) } this.removeFileFromFileStore(...files.map((i) => i.id)) }) } isUploading(): boolean { if (this.uploadSubscription?.closed) { return false } return this.taskQueue.some((task) => task.status === StatusCode.Uploading || task.status === StatusCode.Waiting) } hasError(): boolean { return this.taskQueue.some((task) => task.status === StatusCode.Error) } getErrorTasks(): UploadTask[] { return this.taskQueue.filter((task) => task.status === StatusCode.Error) } isComplete(): boolean { let status = [StatusCode.Uploading, StatusCode.Waiting, StatusCode.Pause] return !this.taskQueue.some((task) => status.includes(task.status)) } destory(): void { this.subscription.unsubscribe() } private removeTask(tasks: UploadTask | UploadTask[], clear?: boolean) { if (!Array.isArray(tasks)) { tasks = [tasks] } return scheduled(tasks || [], asyncScheduler) .pipe( tap((task) => { let index = this.taskQueue.findIndex((i) => i.id === task?.id) index > -1 && this.taskQueue.splice(index, 1) this.taskHandlerMap.get(task.id)?.abort() this.taskHandlerMap.delete(task.id) !clear && this.removeTaskFromStroage(task) // 任务取消事件 this.emit(EventType.TaskCancel, task) }), ) .toPromise() } private rebindTaskHandlerEvent(handler: TaskHandler, ...e: EventType[]): TaskHandler { const events = e?.length ? e : Object.values(EventType) events.forEach((e) => { handler?.off(e) handler?.on(e, (...args) => this.taskHandlerEventCallback(e as EventType, ...args)) }) return handler } private getTaskHandler(task: UploadTask): Observable<TaskHandler> { return iif( () => this.taskHandlerMap.has(task.id), of(this.taskHandlerMap.get(task.id)!), handleTask(task, this.options), ).pipe( tap((handler) => { this.taskHandlerMap.set(task.id, handler) }), ) } private freeHandler(task: UploadTask): void { let id = task.id let handler: Nullable<TaskHandler> = this.taskHandlerMap.get(id) || null if (handler) { Object.values(EventType).forEach((e) => handler!.off(e)) this.taskHandlerMap.delete(id) handler = null } } private putNextTask(task?: UploadTask | ID): void { if (task) { if (typeof task === 'object') { this.taskSubject.next(task) } else { let tsk = this.taskQueue.find((tsk: UploadTask) => tsk.id === task) tsk && this.taskSubject.next(tsk) } } else { let sub: Nullable<Subscription> = scheduled(this.taskQueue, asapScheduler) .pipe( filter((tsk: UploadTask) => tsk.status !== StatusCode.Complete), takeUntil(this.pause$), ) .subscribe({ next: (tsk) => this.taskSubject.next(tsk), complete: () => { sub?.unsubscribe() sub = null }, }) } } private taskHandlerEventCallback(e: EventType, ...args: any[]) { this.emit(e, ...args) } private changeUploadTaskStatus(task: UploadTask, status: StatusCode) { task.status = status } private async restoreTask(): Promise<UploadTask[]> { const taskList: UploadTask[] = await getStorage(this.id).UploadTask.values().toPromise() const { recoverableTaskStatus } = this.options if (!taskList?.length) { return [] } return scheduled(taskList || [], asyncScheduler) .pipe( filter((task) => { if (recoverableTaskStatus?.length) { return recoverableTaskStatus.includes(task.status) } return true }), tap((task) => { if (task.status !== StatusCode.Complete) { task.status = task.status === StatusCode.Error ? task.status : StatusCode.Pause task.progress = task.progress >= 100 ? 99 : task.progress } this.taskQueue.push(task) if (this.options.autoUpload && task.status === StatusCode.Pause) { this.upload(task) } // 任务恢复事件 this.emit(EventType.TaskRestore, task) }), last(), mapTo(taskList), takeUntil(this.clear$), ) .toPromise() // return new Promise((resolve, reject) => { // Storage.UploadTask.list() // .then((list: unknown[]) => { // Logger.info('Uploader -> restoreTask -> list', list) // const taskList: UploadTask[] = [] // const fn = (timeRemaining?: () => number) => { // while (list.length) { // const arr = list.splice(0, 20) as UploadTask[] // arr.forEach((task) => { // if (task.status === StatusCode.Complete) { // return this.removeTaskFromStroage(task) // } // task.status = StatusCode.Pause // task.progress = task.progress >= 100 ? 99 : task.progress // this.taskQueue.push(task) // taskList.push(task) // this.options.autoUpload && this.upload(task) // // 任务恢复事件 // this.emit(EventType.TaskRestore, task) // }) // if (!timeRemaining || !timeRemaining?.()) { // break // } // } // list.length ? scheduleWork(fn) : resolve(taskList) // } // scheduleWork(fn) // }) // .catch((e) => reject(e)) // }) } initFilePickersAndDraggers() { const { filePicker, fileDragger } = this.options const filePickers = Array.isArray(filePicker) ? filePicker : filePicker ? [filePicker] : null const fileDraggers = Array.isArray(fileDragger) ? fileDragger : fileDragger ? [fileDragger] : null const obs: Observable<File[]>[] = [] if (filePickers?.length) { obs.push( ...filePickers.map((opts) => { const picker = new FilePicker(opts) this.filePickers.push(picker) return picker.file$ }), ) } if (fileDraggers?.length) { obs.push( ...fileDraggers.map((opts) => { const dragger = new FileDragger(opts, this.options) this.fileDraggers.push(dragger) return dragger.file$ }), ) } if (obs.length) { this.subscription.add( merge(...obs) .pipe(concatMap((files: File[]) => this.add(files))) .subscribe(), ) } } private initEventHandler(): void { this.on(EventType.TaskCreated, (task: UploadTask) => { this.options.autoUpload && this.upload(task) }) } add(files: File[]) { return of(files).pipe( concatMap((files: File[]) => { // 选择文件后添加文件前hook const beforeAdd = this.hookWrap(this.options.beforeFilesAdd?.(files)) return from(beforeAdd).pipe(mapTo(files)) }), concatMap((files: File[]) => { return from(this.addFilesAsync(...files)).pipe(map((tasks) => ({ files, tasks }))) }), takeUntil(this.clear$), ) } addFilesAsync(...files: Array<File>): Promise<UploadTask[]> { return new Promise((resolve, reject) => { Logger.info('Uploader -> addFile -> files', files) const resolveTasks = (tasks: UploadTask[]) => { resolve(tasks) this.emit(EventType.TasksAdded, tasks) } const finish = (tasks: UploadTask[]) => { if (this.options.resumable) { let ob$ = isElectron() ? this.presistTaskWithoutBlob(tasks, this.clear$) : this.presistTask(tasks, this.clear$) let sub: Nullable<Subscription> = ob$.pipe(takeUntil(this.clear$)).subscribe({ error: (e) => reject(e), complete: () => { // 任务持久化事件 this.emit(EventType.TasksPresist, tasks) sub?.unsubscribe() sub = null resolveTasks(tasks) console.timeEnd('addFilesAsync') }, }) } else { resolveTasks(tasks) console.timeEnd('addFilesAsync') } } if (!files.length) { resolveTasks([]) return } console.time('addFilesAsync') const { fileFilter } = this.options const tasks: Set<UploadTask> = new Set() scheduled(files || [], asapScheduler) .pipe( filter((file) => { let accept: boolean = true if (fileFilter instanceof RegExp) { accept = fileFilter.test(file.name) } else if (typeof fileFilter === 'function') { accept = fileFilter(file.name, file) } return !!accept }), bufferCount(1000), map((files) => { return files.map(fileFactory) }), concatMap((files: UploadFile[]) => this.generateTask(...files)), tap((data) => data?.forEach((i) => i && tasks.add(i))), ) .subscribe({ complete: () => finish([...tasks]), }) // const scheduleWork = (cb: Noop, timeout?: number) => cb() // const fn = async (timeRemaining?: () => number) => { // while (files.length) { // Logger.info(files.length) // const filelist: UploadFile[] = [] // files.splice(0, 100).forEach((file) => { // let ignored = false // if (fileFilter instanceof RegExp) { // ignored = !fileFilter.test(file.name) // } else if (typeof fileFilter === 'function') { // ignored = !fileFilter(file.name, file) // } // if (!ignored) { // filelist.push(fileFactory(file)) // } // }) // const currentTasks: UploadTask[] = await this.generateTask(...filelist).toPromise() // currentTasks.map((i) => tasks.add(i)) // // if (!timeRemaining?.()) { // // break // // } // } // files.length ? scheduleWork(fn, 1000) : finish([...tasks]) // } // scheduleWork(fn) }) } private generateTask(...fileList: UploadFile[]): Observable<UploadTask[]> { return new Observable((subscriber: Subscriber<UploadTask[]>) => { console.time('generateTask') const { ossOptions, singleFileTask } = this.options const updateTasks: Set<UploadTask> = new Set() const newTasks: UploadTask[] = [] const notifier: Subject<void> = new Subject() const existsTaskMap: Map<string, UploadTask> = new Map() const sub: Subscription = from(fileList) .pipe( tap((file: UploadFile) => { let pos = file.relativePath.indexOf('/') let newTask: Nullable<UploadTask> = null let inFolder = !singleFileTask && pos !== -1 if (!inFolder) { newTask = taskFactory(file, singleFileTask) } else { let parentPath: string = file.relativePath.substring(0, pos + 1) let existsTask: UploadTask | undefined = existsTaskMap.get(parentPath) if (!existsTask) { existsTask = this.taskQueue.concat(newTasks).find((tsk) => { return tsk.fileIDList.some((id) => FileStore.get(id)?.relativePath.startsWith(parentPath)) }) existsTask && existsTaskMap.set(parentPath, existsTask) } if (existsTask) { let existsFile = existsTask.fileIDList.find( (id) => FileStore.get(id)?.relativePath === file.relativePath, ) if (!existsFile) { existsTask.fileIDList.push(file.id) existsTask.fileList.push(file) existsTask.fileSize += file.size updateTasks.add(existsTask) } else { Logger.info('existsTask:', existsTask, 'existsFile:', existsFile) } } else { newTask = taskFactory(file, singleFileTask) } } if (newTask) { newTask.oss = ossOptions?.enable ? ossOptions?.provider : newTask.oss newTask.type = singleFileTask ? 'file' : newTask.type newTasks.push(newTask) } }), last(), concatMap(() => (fileList.length ? from(this.hookWrap(this.options.filesAdded?.(fileList))) : of(null))), concatMap(() => (newTasks.length ? from(this.hookWrap(this.options.beforeTasksAdd?.(newTasks))) : of(null))), tap(() => { // 任务创建事件 newTasks.forEach((task) => { this.emit(EventType.TaskCreated, task) this.taskQueue.push(task) }) // 任务更新事件 updateTasks.forEach((task) => this.emit(EventType.TaskUpdate, task)) }), takeUntil(notifier), ) .subscribe({ complete() { subscriber.next(newTasks) subscriber.complete() console.timeEnd('generateTask') }, error: (e) => subscriber.error(e), }) return () => { notifier?.next() notifier?.unsubscribe() sub?.unsubscribe() } }) } }