Created
September 13, 2021 12:48
-
-
Save IlCallo/ae9fdc264849294e65fa1801c92f538f to your computer and use it in GitHub Desktop.
XHR Uploader + TypeScript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createUploaderComponent } from 'quasar'; | |
import { computed, ExtractPropTypes, PropType, ref, Ref } from 'vue'; | |
type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>); | |
type ValueOrFunction<ValueType, Param = never> = | |
| ((arg: Param) => ValueType) | |
| ValueType; | |
interface QUpoaderHeaderItem { | |
name: string; | |
value: string; | |
} | |
interface QUpoaderFormFieldsItem { | |
name: string; | |
value: string; | |
} | |
type QUpoaderFactoryObject = { | |
url?: ValueOrFunction<string, File[]>; | |
method?: ValueOrFunction<LiteralUnion<'POST' | 'PUT'>, File[]>; | |
headers?: ValueOrFunction<QUpoaderHeaderItem[], File[]>; | |
formFields?: ValueOrFunction<QUpoaderFormFieldsItem[], File[]>; | |
fieldName?: ValueOrFunction<string, File>; | |
withCredentials?: ValueOrFunction<boolean, File[]>; | |
sendRaw?: ValueOrFunction<boolean, File[]>; | |
}; | |
type QUpoaderFactoryFn = ( | |
files: File[] | |
) => QUpoaderFactoryObject | Promise<QUpoaderFactoryObject>; | |
function getFn<T = unknown | undefined, Arg = unknown>( | |
prop: ((...arg: Arg[]) => T) | T | |
): (...arg: Arg[]) => T { | |
return prop instanceof Function ? prop : () => prop; | |
} | |
const _props = { | |
url: [Function, String] as PropType<Required<QUpoaderFactoryObject['url']>>, | |
method: { | |
type: [Function, String] as PropType< | |
Required<QUpoaderFactoryObject['method']> | |
>, | |
default: 'POST', | |
}, | |
fieldName: { | |
type: [Function, String] as PropType< | |
Required<QUpoaderFactoryObject['fieldName']> | |
>, | |
default: () => { | |
return (file: File) => file.name; | |
}, | |
}, | |
headers: [Function, Array] as PropType< | |
Required<QUpoaderFactoryObject['headers']> | |
>, | |
formFields: [Function, Array] as PropType< | |
Required<QUpoaderFactoryObject['formFields']> | |
>, | |
withCredentials: [Function, Boolean] as PropType< | |
Required<QUpoaderFactoryObject['withCredentials']> | |
>, | |
sendRaw: [Function, Boolean] as PropType< | |
Required<QUpoaderFactoryObject['sendRaw']> | |
>, | |
batch: [Function, Boolean] as PropType<ValueOrFunction<boolean, File[]>>, | |
factory: Function as PropType<QUpoaderFactoryFn>, | |
}; | |
const _emits = ['factory-failed', 'uploaded', 'failed', 'uploading'] as const; | |
function injectPlugin({ | |
props, | |
emit, | |
helpers, | |
}: { | |
props: ExtractPropTypes<typeof _props>; | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
emit: (name: typeof _emits[keyof typeof _emits], ...args: any[]) => void; | |
helpers: { | |
queuedFiles: Ref<File[]>; | |
uploadedSize: Ref<number>; | |
uploadedFiles: Ref<File[]>; | |
isAlive(): boolean; | |
updateFileStatus(file: File, status: string, size?: number): void; | |
}; | |
}) { | |
const xhrs = ref<XMLHttpRequest[]>([]); | |
const promises = ref<Promise<unknown>[]>([]); | |
const workingThreads = ref(0); | |
const xhrProps = computed(() => ({ | |
url: getFn(props.url), | |
method: getFn(props.method), | |
headers: getFn(props.headers), | |
formFields: getFn(props.formFields), | |
fieldName: getFn(props.fieldName), | |
withCredentials: getFn(props.withCredentials), | |
sendRaw: getFn(props.sendRaw), | |
batch: getFn(props.batch), | |
})); | |
const isUploading = computed(() => workingThreads.value > 0); | |
const isBusy = computed(() => promises.value.length > 0); | |
let abortPromises: boolean; | |
function abort() { | |
xhrs.value.forEach((x) => { | |
x.abort(); | |
}); | |
if (promises.value.length > 0) { | |
abortPromises = true; | |
} | |
} | |
function upload() { | |
const queue = helpers.queuedFiles.value.slice(0); | |
helpers.queuedFiles.value = []; | |
if (xhrProps.value.batch(queue)) { | |
runFactory(queue); | |
} else { | |
queue.forEach((file) => { | |
runFactory([file]); | |
}); | |
} | |
} | |
function runFactory(files: File[]) { | |
workingThreads.value++; | |
if (typeof props.factory !== 'function') { | |
performUpload(files, {}); | |
return; | |
} | |
const res = props.factory(files); | |
if (!res) { | |
emit( | |
'factory-failed', | |
new Error('QUploader: factory() does not return properly'), | |
files | |
); | |
workingThreads.value--; | |
} else if (res instanceof Promise) { | |
promises.value.push(res); | |
const failed = (err: unknown) => { | |
if (helpers.isAlive() === true) { | |
promises.value = promises.value.filter((p) => p !== res); | |
if (promises.value.length === 0) { | |
abortPromises = false; | |
} | |
helpers.queuedFiles.value = helpers.queuedFiles.value.concat(files); | |
files.forEach((f) => { | |
helpers.updateFileStatus(f, 'failed'); | |
}); | |
emit('factory-failed', err, files); | |
workingThreads.value--; | |
} | |
}; | |
res | |
.then((factory) => { | |
if (abortPromises === true) { | |
failed(new Error('Aborted')); | |
} else if (helpers.isAlive() === true) { | |
promises.value = promises.value.filter((p) => p !== res); | |
performUpload(files, factory); | |
} | |
}) | |
.catch(failed); | |
} else { | |
performUpload(files, res || {}); | |
} | |
} | |
function performUpload(files: File[], factory: QUpoaderFactoryObject) { | |
const form = new FormData(), | |
xhr = new XMLHttpRequest(); | |
const getProp = < | |
K extends keyof QUpoaderFactoryObject, | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
Arg = Required<QUpoaderFactoryObject>[K] extends (arg: infer P) => any | |
? P | |
: Required<QUpoaderFactoryObject>[K], | |
R = Required<QUpoaderFactoryObject>[K] | |
>( | |
name: K, | |
arg: Arg | |
) => { | |
return ( | |
factory[name] !== void 0 | |
? getFn(factory[name] as unknown)(arg) | |
: xhrProps.value[name](arg) | |
) as R extends (arg: Arg) => infer T ? T : R; | |
}; | |
const url = getProp('url', files); | |
if (!url) { | |
console.error('q-uploader: invalid or no URL specified'); | |
workingThreads.value--; | |
return; | |
} | |
const fields = getProp('formFields', files); | |
fields !== void 0 && | |
fields.forEach((field) => { | |
form.append(field.name, field.value); | |
}); | |
let uploadIndex = 0, | |
uploadIndexSize = 0, | |
localUploadedSize = 0, | |
maxUploadSize = 0, | |
aborted: boolean; | |
xhr.upload.addEventListener( | |
'progress', | |
(e) => { | |
if (aborted === true) { | |
return; | |
} | |
const loaded = Math.min(maxUploadSize, e.loaded); | |
helpers.uploadedSize.value += loaded - localUploadedSize; | |
localUploadedSize = loaded; | |
let size = localUploadedSize - uploadIndexSize; | |
for (let i = uploadIndex; size > 0 && i < files.length; i++) { | |
const file = files[i], | |
uploaded = size > file.size; | |
if (uploaded) { | |
size -= file.size; | |
uploadIndex++; | |
uploadIndexSize += file.size; | |
helpers.updateFileStatus(file, 'uploading', file.size); | |
} else { | |
helpers.updateFileStatus(file, 'uploading', size); | |
return; | |
} | |
} | |
}, | |
false | |
); | |
xhr.onreadystatechange = () => { | |
if (xhr.readyState < 4) { | |
return; | |
} | |
if (xhr.status && xhr.status < 400) { | |
helpers.uploadedFiles.value = helpers.uploadedFiles.value.concat(files); | |
files.forEach((f) => { | |
helpers.updateFileStatus(f, 'uploaded'); | |
}); | |
emit('uploaded', { files, xhr }); | |
} else { | |
aborted = true; | |
helpers.uploadedSize.value -= localUploadedSize; | |
helpers.queuedFiles.value = helpers.queuedFiles.value.concat(files); | |
files.forEach((f) => { | |
helpers.updateFileStatus(f, 'failed'); | |
}); | |
emit('failed', { files, xhr }); | |
} | |
workingThreads.value--; | |
xhrs.value = xhrs.value.filter((x) => x !== xhr); | |
}; | |
xhr.open(getProp('method', files), url); | |
if (getProp('withCredentials', files) === true) { | |
xhr.withCredentials = true; | |
} | |
const headers = getProp('headers', files); | |
headers !== void 0 && | |
headers.forEach((head) => { | |
xhr.setRequestHeader(head.name, head.value); | |
}); | |
const sendRaw = getProp('sendRaw', files); | |
files.forEach((file) => { | |
helpers.updateFileStatus(file, 'uploading', 0); | |
if (sendRaw !== true) { | |
form.append(getProp('fieldName', file), file, file.name); | |
} | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any | |
(file as any).xhr = xhr; | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any | |
(file as any).__abort = () => { | |
xhr.abort(); | |
}; | |
maxUploadSize += file.size; | |
}); | |
emit('uploading', { files, xhr }); | |
xhrs.value.push(xhr); | |
if (sendRaw === true) { | |
xhr.send(new Blob(files)); | |
} else { | |
xhr.send(form); | |
} | |
} | |
return { | |
isUploading, | |
isBusy, | |
abort, | |
upload, | |
}; | |
} | |
// TODO: find a way to make extracting stuff from `createUploaderComponent` easier, or expose the options interface | |
export default { | |
props: _props, | |
// Needed to avoid error because `as const` isn't compatible with `string[]` accepted by EmitsOptions | |
emits: [..._emits], | |
injectPlugin: injectPlugin as Parameters< | |
typeof createUploaderComponent | |
>[0]['injectPlugin'], | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createUploaderComponent } from 'quasar'; | |
import xhrUploaderPlugin from './xhr-uploader-plugin'; | |
export default createUploaderComponent({ | |
name: 'FormulaUploader', | |
props: xhrUploaderPlugin.props, | |
emits: xhrUploaderPlugin.emits, | |
injectPlugin: xhrUploaderPlugin.injectPlugin, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment