diff --git a/.changeset/eager-views-enjoy.md b/.changeset/eager-views-enjoy.md new file mode 100644 index 000000000000..4539972de321 --- /dev/null +++ b/.changeset/eager-views-enjoy.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": minor +--- + +file upload progress is available via `myForm.fields.someFile.progress()` diff --git a/packages/kit/src/runtime/app/server/public.d.ts b/packages/kit/src/runtime/app/server/public.d.ts index 8ad4728407b5..1c4ae050cc75 100644 --- a/packages/kit/src/runtime/app/server/public.d.ts +++ b/packages/kit/src/runtime/app/server/public.d.ts @@ -116,7 +116,19 @@ type RemoteFormFieldMethods = { dirty(): boolean; /** Validation issues, if any */ issues(): RemoteFormIssue[] | undefined; -}; +} & (T extends File + ? { + /** Current file upload progress */ + progress(): { + /** Percentage of upload progress, from 0.0 to 1.0 */ + readonly percent: number; + /** Bytes uploaded so far */ + readonly uploaded: number; + /** Total bytes to upload */ + readonly total: number; + }; + } + : unknown); // These two types use "T extends unknown ? .. : .." to distribute over unions. // Example: if "type T = A | b" then "keyof T" only contains keys that both A and B have, with "KeysOfUnion" we get the keys of both A and B diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 127df8210f3b..ad80678aa259 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -194,7 +194,9 @@ export function form(validate_or_fn, maybe_fn) { get_issues: () => flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + // upload progress is always 0 on the server + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); } }); 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 f690e9288e31..e3c4e60b1343 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -15,7 +15,12 @@ import { } from '../client.js'; import { page } from '#app/state/client'; import { tick } from 'svelte'; -import { categorize_updates, fail_unhandled_refreshes, remote_request } from './shared.svelte.js'; +import { + categorize_updates, + fail_unhandled_refreshes, + handle_remote_response, + remote_request +} from './shared.svelte.js'; import { createAttachmentKey } from 'svelte/attachments'; import { convert_formdata, @@ -30,7 +35,8 @@ import { DELETE_KEY, BINARY_FORM_CONTENT_TYPE, parse_form_key, - coerce_form_value + coerce_form_value, + get_file_paths } from '../../form-utils.js'; /** @@ -122,6 +128,11 @@ export function form(id) { /** @type {any} */ let result = $state.raw(initial?.result); + /** + * @type {Record} + */ + let upload_progress = $state({}); + /** @type {number} */ let pending_count = $state(0); @@ -238,24 +249,65 @@ export function form(id) { if (!valid) return false; } - const { blob } = serialize_binary_form(convert(form_data), { + const data = convert(form_data); + const { blob, file_offsets } = serialize_binary_form(data, { remote_refreshes: Array.from(refreshes ?? []) }); - const response = await remote_request( - `${base}/${app_dir}/remote/${action_id_without_key}`, - { - method: 'POST', - headers: { - 'Content-Type': BINARY_FORM_CONTENT_TYPE, - // Forms cannot be called during rendering, so it's save to use location here - 'x-sveltekit-pathname': location.pathname, - 'x-sveltekit-search': location.search - }, - body: blob - }, - refreshes - ); + /** @type {Response} */ + const xhr_response = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('load', () => { + const body = (xhr.response ?? xhr.responseText) || null; + resolve( + new Response(body, { + status: xhr.status, + statusText: xhr.statusText, + headers: parse_xhr_headers(xhr.getAllResponseHeaders()) + }) + ); + }); + xhr.addEventListener('error', () => { + reject(new TypeError('Network request failed')); + }); + + if (file_offsets) { + const file_paths = get_file_paths(data); + /** @type {Map} */ + const progress_objects = new Map(); + for (const [file, path] of file_paths) { + const progress = $state({ + uploaded: 0, + total: file.size, + get percent() { + if (this.total === 0) return 0; + return this.uploaded / this.total; + } + }); + progress_objects.set(file, progress); + deep_set(upload_progress, path, progress); + } + 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 progress = progress_objects.get(file.file); + if (!progress) continue; + progress.uploaded = uploaded; + } + }); + } + + 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); + }); + + const response = await handle_remote_response(xhr_response, refreshes); ({ issues: raw_issues = [], result } = response._ ?? {}); @@ -536,6 +588,14 @@ export function form(id) { if (file) { set_nested_value(input, field, file); + set_nested_value(upload_progress, field, { + uploaded: 0, + total: file.size, + get percent() { + if (this.total === 0) return 0; + return this.uploaded / this.total; + } + }); } else { set_nested_value(input, field, DELETE_KEY); } @@ -563,6 +623,7 @@ export function form(id) { touched = {}; dirty = {}; can_validate = {}; + upload_progress = {}; submitted = false; }; @@ -662,7 +723,9 @@ export function form(id) { return issues; }, get_touched: () => touched, - get_dirty: () => dirty + get_dirty: () => dirty, + get_progress: (path) => + deep_get(upload_progress, path) ?? { uploaded: 0, total: 0, percent: 0 } }) }, result: { @@ -828,3 +891,21 @@ function validate_form_data(form_data, enctype) { } } } + +/** + * @param {string} xhr_headers + * @returns {Headers} + */ +export function parse_xhr_headers(xhr_headers) { + const headers = new Headers(); + const lines = xhr_headers.trim().split('\n'); + for (const line of lines) { + const colon_index = line.indexOf(':'); + if (colon_index === -1) continue; + const key = line.substring(0, colon_index).trim(); + if (!key) continue; + const value = line.substring(colon_index + 1).trim(); + headers.append(key, value); + } + return headers; +} diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js index 213ff9aae018..b2e829e487f6 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js @@ -1,5 +1,6 @@ import { flushSync, tick } from 'svelte'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, it, test, vi } from 'vitest'; +import { parse_xhr_headers } from './form.svelte.js'; const env = vi.hoisted(() => ({ DEV: true })); vi.mock('esm-env', () => ({ @@ -232,3 +233,54 @@ test('form.for keeps its instance when the derived that holds it reconnects', as cleanup(); } }); + +describe('parse_xhr_headers', () => { + it('returns a Headers instance', () => { + expect(parse_xhr_headers('X-A: 1')).toBeInstanceOf(Headers); + }); + + it('parses a single header', () => { + const headers = parse_xhr_headers('Content-Type: text/html'); + expect(headers.get('Content-Type')).toBe('text/html'); + }); + + it('parses multiple headers', () => { + const headers = parse_xhr_headers( + 'Content-Type: application/json\nX-Request-Id: abc123\nCache-Control: no-store' + ); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-request-id')).toBe('abc123'); + expect(headers.get('cache-control')).toBe('no-store'); + }); + + it('trims whitespace around names and values', () => { + const headers = parse_xhr_headers(' Content-Type : application/json '); + expect(headers.get('content-type')).toBe('application/json'); + }); + + it('accepts values containing colons', () => { + const headers = parse_xhr_headers('Date: Mon, 23 Oct 2026 10:00:00 GMT'); + expect(headers.get('date')).toBe('Mon, 23 Oct 2026 10:00:00 GMT'); + }); + + it('accepts CRLF line endings', () => { + const headers = parse_xhr_headers('Content-Type: text/plain\r\nX-Foo: bar'); + expect(headers.get('content-type')).toBe('text/plain'); + expect(headers.get('x-foo')).toBe('bar'); + }); + + it('combines repeated names into one comma-joined entry', () => { + const headers = parse_xhr_headers('X-Multi: one\nX-Multi: two'); + expect([...headers.entries()]).toEqual([['x-multi', 'one, two']]); + }); + + it('allows an empty header value', () => { + const headers = parse_xhr_headers('X-Empty: '); + expect(headers.get('x-empty')).toBe(''); + }); + + it('works with empty input', () => { + const headers = parse_xhr_headers(''); + expect([...headers.entries()].length).toBe(0); + }); +}); diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js index 354da4230d10..0e0b25207f07 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -114,6 +114,14 @@ export function get_remote_request_headers() { */ export async function remote_request(url, init, refreshes) { const response = await fetch(url, init); + return handle_remote_response(response, refreshes); +} + +/** + * @param {Response} response + * @param {Set | null} [refreshes] + */ +export async function handle_remote_response(response, refreshes) { const status = response.status; // detect new deployments from the response header diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index f40a2adbf83a..3bd45984cbc5 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -142,30 +142,40 @@ export function serialize_binary_form(data, meta) { } }); - const encoded_header_buffer = text_encoder.encode(encoded_header); + /** @type {Array | undefined} */ + let file_offsets; + + /** @type {Uint8Array} */ + let encoded_file_offsets; + + /** @type {Array<[file: File, index: number]>} */ + let unsorted_files; - let encoded_file_offsets = ''; 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; start += file.size; } - encoded_file_offsets = JSON.stringify(file_offsets); + encoded_file_offsets = text_encoder.encode(JSON.stringify(file_offsets)); + } else { + encoded_file_offsets = new Uint8Array(); } + const encoded_header_buffer = text_encoder.encode(encoded_header); + const length_buffer = new Uint8Array(4); const length_view = new DataView(length_buffer.buffer); length_view.setUint32(0, encoded_header_buffer.byteLength, true); blob_parts.push(length_buffer.slice()); - length_view.setUint16(0, encoded_file_offsets.length, true); + length_view.setUint16(0, encoded_file_offsets.byteLength, true); blob_parts.push(length_buffer.slice(0, 2)); blob_parts.push(encoded_header_buffer); @@ -175,11 +185,39 @@ export function serialize_binary_form(data, meta) { blob_parts.push(file); } + const file_offset_start = + HEADER_BYTES + encoded_header_buffer.byteLength + encoded_file_offsets.byteLength; + 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] + })) }; } +/** + * 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; +} + /** * @param {Request} request * @param {string} form_id @@ -719,7 +757,8 @@ const public_issue = (issue) => ({ path: issue.path, message: issue.message }); * set: (path: (string | number)[], value: any) => void, * get_issues: (path?: (string | number)[], all?: boolean) => Record, * get_touched: () => Record, - * get_dirty: () => Record + * get_dirty: () => Record, + * get_progress: (path: (string | number)[]) => { uploaded: number, total: number, percent: number } * }} FieldContext */ /** @@ -766,6 +805,9 @@ function create_field_method(context, path, prop) { return false; }; } + case 'progress': { + return () => context.get_progress(path); + } case 'as': { const key = build_path_string(path); diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 6d152c13b656..e8ae7985ebd1 100644 --- a/packages/kit/src/runtime/form-utils.spec.js +++ b/packages/kit/src/runtime/form-utils.spec.js @@ -955,7 +955,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(() => proxy.field.as(type)).toThrowKitError('form_input_missing_value'); }); @@ -966,7 +967,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(proxy.position.as('image')).toEqual({ @@ -990,7 +992,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); const cloned = proxy.created_at.value(); @@ -1010,7 +1013,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({ bad: [] }), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); /** @@ -1085,7 +1089,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({ a: true }) + get_dirty: () => ({ a: true }), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(edited.a.as('number', 3).value).toBe(''); @@ -1100,7 +1105,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(Symbol.iterator in proxy).toBe(false); diff --git a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte index 850fa70a14db..50ff4c8439c9 100644 --- a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte @@ -4,17 +4,22 @@
-

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())})

+

- + +
{JSON.stringify(upload.result)}
diff --git a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts index 0282c8ff9264..9c36d1a75a77 100644 --- a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts +++ b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts @@ -5,7 +5,9 @@ export const upload = form( v.object({ text: v.string(), file1: v.file(), - file2: v.file(), + deep: v.object({ + files: v.array(v.file()) + }), read_files: v.optional(v.boolean()) }), async (data) => { @@ -13,13 +15,13 @@ export const upload = form( return { text: data.text, file1: data.file1.size, - file2: data.file2.size + files: data.deep.files.map((f) => f.size) }; } return { text: data.text, file1: await data.file1.text(), - file2: await data.file2.text() + files: await Promise.all(data.deep.files.map((f) => f.text())) }; } ); diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index d836acb658b0..761af1bf3be5 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -993,44 +993,107 @@ test.describe('remote functions', () => { mimeType: 'text/plain', buffer: Buffer.from('a') }); - await page.locator('input[name^="file2"]').setInputFiles({ + await page.locator('input[name^="deep.files[0]"]').setInputFiles({ name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') }); + await page.locator('input[name^="deep.files[1]"]').setInputFiles({ + name: 'c.txt', + mimeType: 'text/plain', + buffer: Buffer.from('c') + }); await page.locator('input[type="checkbox"]').check(); - await page.locator('button').click(); + await page.locator('#submit').click(); await expect(page.locator('pre')).toHaveText( JSON.stringify({ text: 'Hello world', file1: 'a', - file2: 'b' + files: ['b', 'c'] }) ); }); - test('large file uploads work', async ({ page }) => { + + const big_file = Buffer.alloc(1024 * 1024 * 10); + + test('replacing a file mid-upload', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; await page.goto('/remote/form/file-upload'); await page.locator('input[name^="file1"]').setInputFiles({ name: 'a.txt', mimeType: 'text/plain', - buffer: Buffer.alloc(1024 * 1024 * 10) + buffer: big_file }); - await page.locator('input[name^="file2"]').setInputFiles({ - name: 'b.txt', + const progress = page.locator('#progress1'); + expect(progress).toHaveText(`{"uploaded":0,"total":${big_file.byteLength},"percent":0}`); + await page.locator('#submit').click(); + await page.locator('input[name^="file1"]').setInputFiles({ + name: 'a.txt', mimeType: 'text/plain', - buffer: Buffer.from('b') + buffer: Buffer.from('a') }); - await page.locator('button').click(); + expect(progress).toHaveText(`{"uploaded":0,"total":1,"percent":0}`); + }); - await expect(page.locator('pre')).toHaveText( - JSON.stringify({ - text: 'Hello world', - file1: 1024 * 1024 * 10, - file2: 1 - }) - ); + test('file upload progress works', async ({ page, context, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + await page.goto('/remote/form/file-upload'); + const cdp = await context.newCDPSession(page); + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: 1024 * 1024 * 5 // throttle so it'll take 2 seconds to upload + }); + try { + const progress = page.locator('#progress1'); + expect(progress).toHaveText('{"uploaded":0,"total":0,"percent":0}'); + await page.locator('input[name^="file1"]').setInputFiles({ + name: 'a.txt', + mimeType: 'text/plain', + buffer: big_file + }); + expect(progress).toHaveText('{"uploaded":0,"total":10485760,"percent":0}'); + await page.locator('input[name^="deep.files[0]"]').setInputFiles({ + name: 'b.txt', + mimeType: 'text/plain', + buffer: Buffer.from('b') + }); + await page.locator('input[name^="deep.files[1]"]').setInputFiles({ + name: 'c.txt', + mimeType: 'text/plain', + buffer: Buffer.from('cc') + }); + await page.locator('#submit').click(); + await expect(progress).not.toHaveText(/"uploaded":0/); + async function check_percent() { + const progress_value = JSON.parse(/** @type {string} */ (await progress.textContent())); + expect(progress_value.percent).toBeGreaterThan(0); + expect(progress_value.percent).toBeCloseTo(progress_value.uploaded / progress_value.total); + } + const progress_text = /** @type {string} */ (await progress.textContent()); + await check_percent(); + await expect(progress).not.toHaveText(progress_text); + await check_percent(); + await expect(page.locator('pre')).toHaveText( + JSON.stringify({ + text: 'Hello world', + file1: big_file.byteLength, + files: [1, 2] + }) + ); + await page.locator('#reset').click(); + await expect(progress).toHaveText('{"uploaded":0,"total":0,"percent":0}'); + } finally { + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1 + }); + } }); test('query stored as variable does not block SSR inside boundary', async ({ diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index ed8824b0f9a8..91d9ca7c802c 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -780,6 +780,21 @@ function form_tests() { }>, () => {} ); + + // file upload progress + const f13 = form(null as any as StandardSchemaV1<{ file: File; nonfile: number }>, () => {}); + f13.fields.file.progress().uploaded; + f13.fields.file.progress().total; + f13.fields.file.progress().percent; + // readonly: + // @ts-expect-error + f13.fields.file.progress().uploaded = 123; + // @ts-expect-error + f13.fields.file.progress().total = 123; + // @ts-expect-error + f13.fields.file.progress().percent = 123; + // @ts-expect-error + f13.fields.nonfile.progress(); } form_tests(); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c2b56afff601..da1f0338c822 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -3105,7 +3105,19 @@ declare module '$app/server' { dirty(): boolean; /** Validation issues, if any */ issues(): RemoteFormIssue[] | undefined; - }; + } & (T extends File + ? { + /** Current file upload progress */ + progress(): { + /** Percentage of upload progress, from 0.0 to 1.0 */ + readonly percent: number; + /** Bytes uploaded so far */ + readonly uploaded: number; + /** Total bytes to upload */ + readonly total: number; + }; + } + : unknown); // These two types use "T extends unknown ? .. : .." to distribute over unions. // Example: if "type T = A | b" then "keyof T" only contains keys that both A and B have, with "KeysOfUnion" we get the keys of both A and B