Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
aa155a6
start
ottomated Oct 21, 2025
3b67fe7
pass in form_dat
ottomated Oct 21, 2025
75005a7
serialization
ottomated Oct 21, 2025
7c60494
start deserializer
ottomated Oct 21, 2025
89d7ce2
Merge branch 'main' into streaming-file-forms
ottomated Oct 21, 2025
8a62a3c
finished? deserializer
ottomated Oct 22, 2025
28d6e90
upload progress via XHR
ottomated Oct 22, 2025
ed58a94
simplify file offsets, sort small files first
ottomated Oct 22, 2025
e604470
don't cache stream
ottomated Oct 22, 2025
ecda6ea
fix scoped ids
ottomated Oct 22, 2025
238dd9a
tests
ottomated Oct 22, 2025
5d2c8a5
re-add comment
ottomated Oct 22, 2025
cd106a2
move location & pathname back to headers
ottomated Oct 22, 2025
b4d41f7
skip test on node 18
ottomated Oct 22, 2025
2284b9f
changeset
ottomated Oct 22, 2025
b903988
Merge branch 'main' into streaming-file-forms
ottomated Oct 22, 2025
bcd016b
polyfill file for node 18 test
ottomated Oct 23, 2025
d6e684d
fix refreshes
ottomated Oct 23, 2025
c31ff7c
optimize file offset table
ottomated Oct 23, 2025
9e4853c
typo
ottomated Oct 23, 2025
86ec52a
add lazyfile tests
ottomated Oct 23, 2025
7cb1fcd
Merge branch 'main' into streaming-file-forms
ottomated Oct 25, 2025
1f45e54
Merge branch 'main' into streaming-file-forms
ottomated Nov 1, 2025
aea26e0
avoid double-sending form keys
ottomated Nov 1, 2025
ca9c53c
remove xhr for next PR
ottomated Nov 2, 2025
d78d00b
Merge branch 'main' into streaming-file-forms
ottomated Nov 2, 2025
eae94ee
fix requests stalling if files aren't read
ottomated Nov 2, 2025
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/new-rivers-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: File uploads inside `form` remote functions are now streamed - form data can be accessed before large files finish uploading.
28 changes: 7 additions & 21 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { get_request_store } from '@sveltejs/kit/internal/server';
import { DEV } from 'esm-env';
import {
convert_formdata,
create_field_proxy,
set_nested_value,
throw_on_old_property_access,
Expand Down Expand Up @@ -104,19 +103,7 @@ export function form(validate_or_fn, maybe_fn) {
type: 'form',
name: '',
id: '',
/** @param {FormData} form_data */
fn: async (form_data) => {
const validate_only = form_data.get('sveltekit:validate_only') === 'true';

let data = maybe_fn ? convert_formdata(form_data) : undefined;

if (data && data.id === undefined) {
const id = form_data.get('sveltekit:id');
if (typeof id === 'string') {
data.id = JSON.parse(id);
}
}

fn: async (data, meta, form_data) => {
// TODO 3.0 remove this warning
if (DEV && !data) {
const error = () => {
Expand Down Expand Up @@ -152,12 +139,12 @@ export function form(validate_or_fn, maybe_fn) {
const { event, state } = get_request_store();
const validated = await schema?.['~standard'].validate(data);

if (validate_only) {
if (meta.validate_only) {
return validated?.issues?.map((issue) => normalize_issue(issue, true)) ?? [];
}

if (validated?.issues !== undefined) {
handle_issues(output, validated.issues, event.isRemoteRequest, form_data);
handle_issues(output, validated.issues, form_data);
} else {
if (validated !== undefined) {
data = validated.value;
Expand All @@ -178,7 +165,7 @@ export function form(validate_or_fn, maybe_fn) {
);
} catch (e) {
if (e instanceof ValidationError) {
handle_issues(output, e.issues, event.isRemoteRequest, form_data);
handle_issues(output, e.issues, form_data);
} else {
throw e;
}
Expand Down Expand Up @@ -297,15 +284,14 @@ export function form(validate_or_fn, maybe_fn) {
/**
* @param {{ issues?: InternalRemoteFormIssue[], input?: Record<string, any>, result: any }} output
* @param {readonly StandardSchemaV1.Issue[]} issues
* @param {boolean} is_remote_request
* @param {FormData} form_data
* @param {FormData | null} form_data - null if the form is progressively enhanced
*/
function handle_issues(output, issues, is_remote_request, form_data) {
function handle_issues(output, issues, form_data) {
output.issues = issues.map((issue) => normalize_issue(issue, true));

// if it was a progressively-enhanced submission, we don't need
// to return the input — it's already there
if (!is_remote_request) {
if (form_data) {
output.input = {};

for (let key of form_data.keys()) {
Expand Down
41 changes: 23 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 @@ -18,7 +18,9 @@ import {
set_nested_value,
throw_on_old_property_access,
build_path_string,
normalize_issue
normalize_issue,
serialize_binary_form,
BINARY_FORM_CONTENT_TYPE
} from '../../form-utils.js';

/**
Expand Down Expand Up @@ -55,6 +57,7 @@ export function form(id) {

/** @param {string | number | boolean} [key] */
function create_instance(key) {
const action_id_without_key = id;
const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : '');
const action = '?/remote=' + encodeURIComponent(action_id);

Expand Down Expand Up @@ -182,17 +185,18 @@ export function form(id) {
try {
await Promise.resolve();

if (updates.length > 0) {
data.set('sveltekit:remote_refreshes', JSON.stringify(updates.map((u) => u._key)));
}
const { blob } = serialize_binary_form(convert(data), {
remote_refreshes: updates.map((u) => u._key)
});

const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
method: 'POST',
body: data,
headers: {
'Content-Type': BINARY_FORM_CONTENT_TYPE,
'x-sveltekit-pathname': location.pathname,
'x-sveltekit-search': location.search
}
},
body: blob
});

if (!response.ok) {
Expand Down Expand Up @@ -532,7 +536,9 @@ export function form(id) {
/** @type {InternalRemoteFormIssue[]} */
let array = [];

const validated = await preflight_schema?.['~standard'].validate(convert(form_data));
const data = convert(form_data);

const validated = await preflight_schema?.['~standard'].validate(data);

if (validate_id !== id) {
return;
Expand All @@ -541,11 +547,16 @@ export function form(id) {
if (validated?.issues) {
array = validated.issues.map((issue) => normalize_issue(issue, false));
} else if (!preflightOnly) {
form_data.set('sveltekit:validate_only', 'true');

const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
method: 'POST',
body: form_data
headers: {
'Content-Type': BINARY_FORM_CONTENT_TYPE,
'x-sveltekit-pathname': location.pathname,
'x-sveltekit-search': location.search
},
body: serialize_binary_form(data, {
validate_only: true
}).blob
});

const result = await response.json();
Expand Down Expand Up @@ -637,12 +648,6 @@ function clone(element) {
*/
function validate_form_data(form_data, enctype) {
for (const key of form_data.keys()) {
if (key.startsWith('sveltekit:')) {
throw new Error(
'FormData keys starting with `sveltekit:` are reserved for internal use and should not be set manually'
);
}

if (/^\$[.[]?/.test(key)) {
throw new Error(
'`$` is used to collect all FormData validation issues and cannot be used as the `name` of a form control'
Expand Down
Loading
Loading