diff --git a/.changeset/eager-news-serve.md b/.changeset/eager-news-serve.md new file mode 100644 index 000000000000..e3c94394b504 --- /dev/null +++ b/.changeset/eager-news-serve.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: file upload progress is available via `myForm.fields.someFile.progress()` diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index ce45549aa6b3..1583c4518dea 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1897,7 +1897,17 @@ type RemoteFormFieldMethods = { set(input: T): T; /** Validation issues, if any */ issues(): RemoteFormIssue[] | undefined; -}; +} & (T extends File + ? { + /** Current file upload progress */ + progress(): { + /** Bytes uploaded so far */ + readonly uploaded: number; + /** Total bytes to upload */ + readonly total: number; + }; + } + : unknown); export type RemoteFormFieldValue = string | string[] | number | boolean | File | File[]; diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index bf69e0200bce..c3b01aa862a3 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -202,19 +202,25 @@ export function form(validate_or_fn, maybe_fn) { return create_field_proxy( {}, - () => data?.input ?? {}, - (path, value) => { - if (data?.submission) { - // don't override a submission - return; - } - - const input = - path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value); - - (get_cache(__)[''] ??= {}).input = input; - }, - () => issues + { + get_input: () => data?.input ?? {}, + set_input: (path, value) => { + if (data?.submission) { + // don't override a submission + return; + } + + const input = + path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value); + + (get_cache(__)[''] ??= {}).input = input; + }, + get_issues: () => issues, + get_progress: () => ({ + uploaded: 0, + total: 0 + }) /* upload progress is always 0 on the server */ + } ); } }); diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index ba47e7ff0099..bff0aa93d43f 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -20,7 +20,9 @@ import { build_path_string, normalize_issue, serialize_binary_form, - BINARY_FORM_CONTENT_TYPE + BINARY_FORM_CONTENT_TYPE, + deep_get, + get_file_paths } from '../../form-utils.js'; /** @@ -66,6 +68,11 @@ export function form(id) { */ let input = $state({}); + /** + * @type {Record} + */ + let upload_progress = $state({}); + /** @type {InternalRemoteFormIssue[]} */ let raw_issues = $state.raw([]); @@ -160,10 +167,10 @@ export function form(id) { } /** - * @param {FormData} data + * @param {FormData} form_data * @returns {Promise & { updates: (...args: any[]) => any }} */ - function submit(data) { + function submit(form_data) { // Store a reference to the current instance and increment the usage count for the duration // of the request. This ensures that the instance is not deleted in case of an optimistic update // (e.g. when deleting an item in a list) that fails and wants to surface an error to the user afterwards. @@ -185,27 +192,59 @@ export function form(id) { try { await Promise.resolve(); - const { blob } = serialize_binary_form(convert(data), { + const data = convert(form_data); + + const { blob, file_offsets } = serialize_binary_form(data, { remote_refreshes: updates.map((u) => u._key) }); - const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, { - method: 'POST', - headers: { - 'Content-Type': BINARY_FORM_CONTENT_TYPE, - 'x-sveltekit-pathname': location.pathname, - 'x-sveltekit-search': location.search - }, - body: blob + /** @type {string} */ + const response_text = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('readystatechange', () => { + switch (xhr.readyState) { + case 2 /* HEADERS_RECEIVED */: + if (xhr.status !== 200) { + // We only end up here if the server has an internal error + // (which shouldn't happen because we handle errors on the server and always send a 200 response) + reject(new Error('Failed to execute remote function')); + } + break; + case 4 /* DONE */: + if (xhr.status !== 200) { + reject(new Error('Failed to execute remote function')); + break; + } + resolve(xhr.responseText); + break; + } + }); + if (file_offsets) { + const file_paths = get_file_paths(data); + for (const [file, path] of file_paths) { + deep_set(upload_progress, path, { uploaded: 0, total: file.size }); + } + xhr.upload.addEventListener('progress', (ev) => { + for (const file of file_offsets) { + const total = file.file.size; + let uploaded = ev.loaded - file.start; + if (uploaded <= 0) continue; + if (uploaded > total) uploaded = total; + const path = file_paths.get(file.file); + if (!path) continue; + deep_get(upload_progress, path).uploaded = uploaded; + } + }); + } + // Use `action_id_without_key` here because the id is included in the body via `convert(data)` + xhr.open('POST', `${base}/${app_dir}/remote/${action_id_without_key}`); + xhr.setRequestHeader('Content-Type', BINARY_FORM_CONTENT_TYPE); + xhr.setRequestHeader('x-sveltekit-pathname', location.pathname); + xhr.setRequestHeader('x-sveltekit-search', location.search); + xhr.send(blob); }); - if (!response.ok) { - // We only end up here in case of a network error or if the server has an internal error - // (which shouldn't happen because we handle errors on the server and always send a 200 response) - throw new Error('Failed to execute remote function'); - } - - const form_result = /** @type { RemoteFunctionResponse} */ (await response.json()); + const form_result = /** @type { RemoteFunctionResponse} */ (JSON.parse(response_text)); // reset issues in case it's a redirect or error (but issues passed in that case) raw_issues = []; @@ -377,6 +416,7 @@ export function form(id) { if (file) { set_nested_value(input, name, file); + set_nested_value(upload_progress, name, { uploaded: 0, total: file.size }); } else { // Remove the property by setting to undefined and clean up const path_parts = name.split(/\.|\[|\]/).filter(Boolean); @@ -406,6 +446,7 @@ export function form(id) { await tick(); input = convert_formdata(new FormData(form)); + upload_progress = {}; }); return () => { @@ -497,18 +538,21 @@ export function form(id) { get: () => create_field_proxy( {}, - () => input, - (path, value) => { - if (path.length === 0) { - input = value; - } else { - deep_set(input, path.map(String), value); - - const key = build_path_string(path); - touched[key] = true; - } - }, - () => issues + { + get_input: () => input, + set_input: (path, value) => { + if (path.length === 0) { + input = value; + } else { + deep_set(input, path.map(String), value); + + const key = build_path_string(path); + touched[key] = true; + } + }, + get_issues: () => issues, + get_progress: (path) => deep_get(upload_progress, path) ?? { uploaded: 0, total: 0 } + } ) }, result: { diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 5e5cc23e1bb8..c7143aa44b76 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -96,15 +96,19 @@ export function serialize_binary_form(data, meta) { } }); - const encoded_header_buffer = text_encoder.encode(encoded_header); + /** @type {Array | undefined} */ + let file_offsets; let encoded_file_offsets = ''; + + /** @type {Array<[file: File, index: number]>} */ + let unsorted_files; if (files.length) { + unsorted_files = [...files]; // Sort small files to the front files.sort(([a], [b]) => a.size - b.size); - /** @type {Array} */ - const file_offsets = new Array(files.length); + file_offsets = new Array(files.length); let start = 0; for (const [file, index] of files) { file_offsets[index] = start; @@ -112,6 +116,7 @@ export function serialize_binary_form(data, meta) { } encoded_file_offsets = JSON.stringify(file_offsets); } + const encoded_header_buffer = text_encoder.encode(encoded_header); const length_buffer = new Uint8Array(4); const length_view = new DataView(length_buffer.buffer); @@ -129,8 +134,14 @@ export function serialize_binary_form(data, meta) { blob_parts.push(file); } + const file_offset_start = 1 + 4 + 2 + encoded_header.length + encoded_file_offsets.length; + return { - blob: new Blob(blob_parts) + blob: new Blob(blob_parts), + file_offsets: file_offsets?.map((o, i) => ({ + start: o + file_offset_start, + file: unsorted_files[i][0] + })) }; } @@ -426,6 +437,27 @@ function check_prototype_pollution(key) { } } +/** + * Finds the paths to every File in an object + * @param {unknown} object + * @param {Map} paths + * @param {string[]} path + */ +export function get_file_paths(object, paths = new Map(), path = []) { + if (Array.isArray(object)) { + for (let i = 0; i < object.length; i++) { + get_file_paths(object[i], paths, [...path, i.toString()]); + } + } else if (object instanceof File) { + paths.set(object, path); + } else if (typeof object === 'object' && object !== null) { + for (const [key, value] of Object.entries(object)) { + get_file_paths(value, paths, [...path, key]); + } + } + return paths; +} + /** * Sets a value in a nested object using an array of keys, mutating the original object. * @param {Record} object @@ -540,15 +572,14 @@ export function deep_get(object, path) { /** * Creates a proxy-based field accessor for form data * @param {any} target - Function or empty POJO - * @param {() => Record} get_input - Function to get current input data - * @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data - * @param {() => Record} get_issues - Function to get current issues + * @param {{ get_input: () => Record, set_input: (path: (string | number)[], value: any) => void, get_issues: () => Record, get_progress: (path: (string | number)[]) => { uploaded: number, total: number } }} accessors - Accessor functions * @param {(string | number)[]} path - Current access path + * * @returns {any} Proxy object with name(), value(), and issues() methods */ -export function create_field_proxy(target, get_input, set_input, get_issues, path = []) { +export function create_field_proxy(target, accessors, path = []) { const get_value = () => { - return deep_get(get_input(), path); + return deep_get(accessors.get_input(), path); }; return new Proxy(target, { @@ -557,29 +588,26 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat // Handle array access like jobs[0] if (/^\d+$/.test(prop)) { - return create_field_proxy({}, get_input, set_input, get_issues, [ - ...path, - parseInt(prop, 10) - ]); + return create_field_proxy({}, accessors, [...path, parseInt(prop, 10)]); } const key = build_path_string(path); if (prop === 'set') { const set_func = function (/** @type {any} */ newValue) { - set_input(path, newValue); + accessors.set_input(path, newValue); return newValue; }; - return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(set_func, accessors, [...path, prop]); } if (prop === 'value') { - return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(get_value, accessors, [...path, prop]); } if (prop === 'issues' || prop === 'allIssues') { const issues_func = () => { - const all_issues = get_issues()[key === '' ? '$' : key]; + const all_issues = accessors.get_issues()[key === '' ? '$' : key]; if (prop === 'allIssues') { return all_issues?.map((issue) => ({ @@ -596,7 +624,13 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat })); }; - return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(issues_func, accessors, [...path, prop]); + } + + if (prop === 'progress') { + const progress_func = () => accessors.get_progress(path); + + return create_field_proxy(progress_func, accessors, [...path, prop]); } if (prop === 'as') { @@ -622,7 +656,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat const base_props = { name: prefix + key + (is_array ? '[]' : ''), get 'aria-invalid'() { - const issues = get_issues(); + const issues = accessors.get_issues(); return key in issues ? 'true' : undefined; } }; @@ -745,11 +779,11 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat }); }; - return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']); + return create_field_proxy(as_func, accessors, [...path, 'as']); } // Handle property access (nested fields) - return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy({}, accessors, [...path, prop]); } }); } diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/file-upload/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/file-upload/+page.svelte index 850fa70a14db..a441b2d4567c 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/file-upload/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/file-upload/+page.svelte @@ -4,10 +4,14 @@
-

File 1:

+

+ File 1: (progress: {JSON.stringify(upload.fields.file1.progress())}) +

-

File 2:

- +

File 2: (progress: {JSON.stringify(upload.fields.deep.files[0].progress())})

+ +

File 3: (progress: {JSON.stringify(upload.fields.deep.files[1].progress())})

+