Skip to content

Commit d07ddcf

Browse files
feat: Streaming file uploads (#14775)
* start * pass in form_dat * serialization * start deserializer * finished? deserializer * upload progress via XHR * simplify file offsets, sort small files first * don't cache stream * fix scoped ids * tests * re-add comment * move location & pathname back to headers * skip test on node 18 * changeset * polyfill file for node 18 test * fix refreshes * optimize file offset table * typo * add lazyfile tests * avoid double-sending form keys * remove xhr for next PR * fix requests stalling if files aren't read * Update new-rivers-run.md * encode text before determining length --------- Co-authored-by: Rich Harris <[email protected]>
1 parent dfb41e1 commit d07ddcf

File tree

11 files changed

+633
-75
lines changed

11 files changed

+633
-75
lines changed

.changeset/new-rivers-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: stream file uploads inside `form` remote functions allowing form data to be accessed before large files finish uploading

packages/kit/src/runtime/app/server/remote/form.js

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import { get_request_store } from '@sveltejs/kit/internal/server';
55
import { DEV } from 'esm-env';
66
import {
7-
convert_formdata,
87
create_field_proxy,
98
set_nested_value,
109
throw_on_old_property_access,
@@ -105,19 +104,7 @@ export function form(validate_or_fn, maybe_fn) {
105104
type: 'form',
106105
name: '',
107106
id: '',
108-
/** @param {FormData} form_data */
109-
fn: async (form_data) => {
110-
const validate_only = form_data.get('sveltekit:validate_only') === 'true';
111-
112-
let data = maybe_fn ? convert_formdata(form_data) : undefined;
113-
114-
if (data && data.id === undefined) {
115-
const id = form_data.get('sveltekit:id');
116-
if (typeof id === 'string') {
117-
data.id = JSON.parse(id);
118-
}
119-
}
120-
107+
fn: async (data, meta, form_data) => {
121108
// TODO 3.0 remove this warning
122109
if (DEV && !data) {
123110
const error = () => {
@@ -153,12 +140,12 @@ export function form(validate_or_fn, maybe_fn) {
153140
const { event, state } = get_request_store();
154141
const validated = await schema?.['~standard'].validate(data);
155142

156-
if (validate_only) {
143+
if (meta.validate_only) {
157144
return validated?.issues?.map((issue) => normalize_issue(issue, true)) ?? [];
158145
}
159146

160147
if (validated?.issues !== undefined) {
161-
handle_issues(output, validated.issues, event.isRemoteRequest, form_data);
148+
handle_issues(output, validated.issues, form_data);
162149
} else {
163150
if (validated !== undefined) {
164151
data = validated.value;
@@ -179,7 +166,7 @@ export function form(validate_or_fn, maybe_fn) {
179166
);
180167
} catch (e) {
181168
if (e instanceof ValidationError) {
182-
handle_issues(output, e.issues, event.isRemoteRequest, form_data);
169+
handle_issues(output, e.issues, form_data);
183170
} else {
184171
throw e;
185172
}
@@ -298,15 +285,14 @@ export function form(validate_or_fn, maybe_fn) {
298285
/**
299286
* @param {{ issues?: InternalRemoteFormIssue[], input?: Record<string, any>, result: any }} output
300287
* @param {readonly StandardSchemaV1.Issue[]} issues
301-
* @param {boolean} is_remote_request
302-
* @param {FormData} form_data
288+
* @param {FormData | null} form_data - null if the form is progressively enhanced
303289
*/
304-
function handle_issues(output, issues, is_remote_request, form_data) {
290+
function handle_issues(output, issues, form_data) {
305291
output.issues = issues.map((issue) => normalize_issue(issue, true));
306292

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

312298
for (let key of form_data.keys()) {

packages/kit/src/runtime/client/remote-functions/form.svelte.js

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
set_nested_value,
1919
throw_on_old_property_access,
2020
build_path_string,
21-
normalize_issue
21+
normalize_issue,
22+
serialize_binary_form,
23+
BINARY_FORM_CONTENT_TYPE
2224
} from '../../form-utils.js';
2325

2426
/**
@@ -55,6 +57,7 @@ export function form(id) {
5557

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

@@ -182,17 +185,18 @@ export function form(id) {
182185
try {
183186
await Promise.resolve();
184187

185-
if (updates.length > 0) {
186-
data.set('sveltekit:remote_refreshes', JSON.stringify(updates.map((u) => u._key)));
187-
}
188+
const { blob } = serialize_binary_form(convert(data), {
189+
remote_refreshes: updates.map((u) => u._key)
190+
});
188191

189-
const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
192+
const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
190193
method: 'POST',
191-
body: data,
192194
headers: {
195+
'Content-Type': BINARY_FORM_CONTENT_TYPE,
193196
'x-sveltekit-pathname': location.pathname,
194197
'x-sveltekit-search': location.search
195-
}
198+
},
199+
body: blob
196200
});
197201

198202
if (!response.ok) {
@@ -539,7 +543,9 @@ export function form(id) {
539543
/** @type {InternalRemoteFormIssue[]} */
540544
let array = [];
541545

542-
const validated = await preflight_schema?.['~standard'].validate(convert(form_data));
546+
const data = convert(form_data);
547+
548+
const validated = await preflight_schema?.['~standard'].validate(data);
543549

544550
if (validate_id !== id) {
545551
return;
@@ -548,11 +554,16 @@ export function form(id) {
548554
if (validated?.issues) {
549555
array = validated.issues.map((issue) => normalize_issue(issue, false));
550556
} else if (!preflightOnly) {
551-
form_data.set('sveltekit:validate_only', 'true');
552-
553-
const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
557+
const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
554558
method: 'POST',
555-
body: form_data
559+
headers: {
560+
'Content-Type': BINARY_FORM_CONTENT_TYPE,
561+
'x-sveltekit-pathname': location.pathname,
562+
'x-sveltekit-search': location.search
563+
},
564+
body: serialize_binary_form(data, {
565+
validate_only: true
566+
}).blob
556567
});
557568

558569
const result = await response.json();
@@ -644,12 +655,6 @@ function clone(element) {
644655
*/
645656
function validate_form_data(form_data, enctype) {
646657
for (const key of form_data.keys()) {
647-
if (key.startsWith('sveltekit:')) {
648-
throw new Error(
649-
'FormData keys starting with `sveltekit:` are reserved for internal use and should not be set manually'
650-
);
651-
}
652-
653658
if (/^\$[.[]?/.test(key)) {
654659
throw new Error(
655660
'`$` is used to collect all FormData validation issues and cannot be used as the `name` of a form control'

0 commit comments

Comments
 (0)