Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eager-views-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sveltejs/kit": minor
---

file upload progress is available via `myForm.fields.someFile.progress()`
14 changes: 13 additions & 1 deletion packages/kit/src/runtime/app/server/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,19 @@ type RemoteFormFieldMethods<T> = {
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<T>" we get the keys of both A and B
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
});
}
});
Expand Down
117 changes: 99 additions & 18 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

/**
Expand Down Expand Up @@ -122,6 +128,11 @@ export function form(id) {
/** @type {any} */
let result = $state.raw(initial?.result);

/**
* @type {Record<string, { uploaded: number, total: number, percent: number }>}
*/
let upload_progress = $state({});

/** @type {number} */
let pending_count = $state(0);

Expand Down Expand Up @@ -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', () => {
Comment thread
vercel[bot] marked this conversation as resolved.
reject(new TypeError('Network request failed'));
});

if (file_offsets) {
const file_paths = get_file_paths(data);
/** @type {Map<File, { uploaded: number }>} */
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._ ?? {});

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -563,6 +623,7 @@ export function form(id) {
touched = {};
dirty = {};
can_validate = {};
upload_progress = {};
submitted = false;
};

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | null} [refreshes]
*/
export async function handle_remote_response(response, refreshes) {
const status = response.status;

// detect new deployments from the response header
Expand Down
58 changes: 50 additions & 8 deletions packages/kit/src/runtime/form-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,30 +142,40 @@ export function serialize_binary_form(data, meta) {
}
});

const encoded_header_buffer = text_encoder.encode(encoded_header);
/** @type {Array<number> | undefined} */
let file_offsets;

/** @type {Uint8Array<ArrayBuffer>} */
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<number>} */
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);
Expand All @@ -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<File, string[]>} 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
Expand Down Expand Up @@ -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<string, InternalRemoteFormIssue[]>,
* get_touched: () => Record<string, boolean>,
* get_dirty: () => Record<string, boolean>
* get_dirty: () => Record<string, boolean>,
* get_progress: (path: (string | number)[]) => { uploaded: number, total: number, percent: number }
* }} FieldContext */

/**
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading