Skip to content

Instantly share code, notes, and snippets.

@IlCallo
Created September 13, 2021 12:48
Show Gist options
  • Save IlCallo/ae9fdc264849294e65fa1801c92f538f to your computer and use it in GitHub Desktop.
Save IlCallo/ae9fdc264849294e65fa1801c92f538f to your computer and use it in GitHub Desktop.
XHR Uploader + TypeScript
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'],
};
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