Describe the bug
In a remote form handler, an uploaded file passes instanceof File. Appending it to a FormData with the three-argument overload and forwarding it with fetch sends a well-formed multipart part with the right filename and content type, but with a body made up of the 15-byte string "[object Object]". Nothing throws or warns the user of this.
export const upload = form('unchecked', async ({ file }) => {
const body = new FormData();
body.append('file', file); // intact
body.append('file', file, file.name); // body becomes "[object Object]"
await fetch(UPSTREAM, { method: 'POST', body });
});
FormData.set(name, file, filename) and new Blob([file]) lose the contents the same way. A genuine File through the same overload is unaffected.
Only enhanced submissions are affected. deserialize_binary_form rebuilds each upload as a LazyFile wrapped in a Proxy whose getPrototypeOf returns File.prototype. The same form submitted without JavaScript goes through request.formData(), gets a real File, and works, which is part of why this is easy to miss.
The underlying cause is an interaction between the "prototype lie" to get past undici's Blob type check by fooling its instanceOf test and the filename overload which then copies it with new File([value], filename). Node's Blob constructor does a real brand check and treats the part as a string. Without the proxy, append throws TypeError: parameter 2 is not of type 'Blob'. The proxy hides error.
I'd like to submit a PR. What I'd propose is making the failure loud, at least for now. As far as I can tell, undici and Node are behaving reasonably given a value that claims to be a File and isn't, so I don't think this can be fixed downstream. The real fixes I could find either buffer uploads, giving up the laziness LazyFile exists for, or change the API, and that seems like your call rather than mine. The guard can land without deciding it.
Here's my little patch, crafted with love: 46c2292
Reproduction
https://github.com/t-eckert/sveltekit-lazyfile-formdata
Open http://127.0.0.1:5199, choose any small text file, submit. The handler appends the file three ways, forwards each to /sink (a plain +server.ts doing request.formData()), and reports what arrived. With a 10-byte file:
| route |
arrived as |
append(name, file) |
10 bytes, intact |
append(name, file, file.name) |
15 bytes, [object Object] |
control: real File, append(name, real, name) |
10 bytes, intact |
node fixes.mjs in the same repo reproduces the mechanism without SvelteKit or a server.
Logs
Nothing is logged with the failure currently. The file that gets upload just becomes `"[object Object]"`.
System Info
System:
OS: macOS 26.6.2
CPU: (12) arm64 Apple M2 Max
Memory: 764.33 MB / 32.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 22.23.2 - /etc/profiles/per-user/thomaseckert/bin/node
Yarn: 1.22.22 - /etc/profiles/per-user/thomaseckert/bin/yarn
npm: 10.9.8 - /etc/profiles/per-user/thomaseckert/bin/npm
pnpm: 11.25.0 - /etc/profiles/per-user/thomaseckert/bin/pnpm
bun: 1.4.2 - /etc/profiles/per-user/thomaseckert/bin/bun
Deno: 2.9.6 - /etc/profiles/per-user/thomaseckert/bin/deno
Browsers:
Chrome: 154.0.8037.93
Safari: 26.6.2
npmPackages:
@sveltejs/adapter-node: ^6.0.0 => 6.0.0
@sveltejs/kit: 3.0.0 => 3.0.0
@sveltejs/vite-plugin-svelte: ^7.2.0 => 7.3.0
svelte: ^5.57.1 => 5.57.1
vite: ^8.1.5 => 8.3.0
Severity
serious, but I can work around it
Additional Information
Where it surfaced
I was working on a file upload form for a writing portfolio. The service appeared to be working, but all of the files just became "[object Object]".
Proposed fix
Give LazyFile a Symbol.toPrimitive that throws a TypeError naming the cause. All three silent paths fail by string coercion, so all three become loud, while the two-argument append stays lazy and validators still see a File. The commit above adds it with a test. On 3.0.0 the test fails without the change, and with it the kit unit suite passes.
This closes the failure mode, not the underlying mismatch: LazyFile still claims to be a File without being a Blob. Two alternatives were measured and are written up in the README:
Other Runtimes
The two-argument path is only sound on Node. On Bun it is also silently corrupted, and on Deno a LazyFile cannot enter a FormData at all. Table in the README.
Prior Art
#15018 is why the proxy exists (z.file() rejected a bare LazyFile). #16116 and #17147 are adjacent LazyFile issues, and neither covers this.
Describe the bug
In a remote
formhandler, an uploaded file passesinstanceof File. Appending it to aFormDatawith the three-argument overload and forwarding it withfetchsends a well-formed multipart part with the right filename and content type, but with a body made up of the 15-byte string"[object Object]". Nothing throws or warns the user of this.FormData.set(name, file, filename)andnew Blob([file])lose the contents the same way. A genuineFilethrough the same overload is unaffected.Only enhanced submissions are affected.
deserialize_binary_formrebuilds each upload as aLazyFilewrapped in aProxywhosegetPrototypeOfreturnsFile.prototype. The same form submitted without JavaScript goes throughrequest.formData(), gets a realFile, and works, which is part of why this is easy to miss.The underlying cause is an interaction between the "prototype lie" to get past undici's
Blobtype check by fooling itsinstanceOftest and the filename overload which then copies it withnew File([value], filename). Node'sBlobconstructor does a real brand check and treats the part as a string. Without the proxy,appendthrowsTypeError: parameter 2 is not of type 'Blob'. The proxy hides error.I'd like to submit a PR. What I'd propose is making the failure loud, at least for now. As far as I can tell, undici and Node are behaving reasonably given a value that claims to be a File and isn't, so I don't think this can be fixed downstream. The real fixes I could find either buffer uploads, giving up the laziness LazyFile exists for, or change the API, and that seems like your call rather than mine. The guard can land without deciding it.
Here's my little patch, crafted with love: 46c2292
Reproduction
https://github.com/t-eckert/sveltekit-lazyfile-formdata
Open http://127.0.0.1:5199, choose any small text file, submit. The handler appends the file three ways, forwards each to
/sink(a plain+server.tsdoingrequest.formData()), and reports what arrived. With a 10-byte file:append(name, file)append(name, file, file.name)[object Object]File,append(name, real, name)node fixes.mjsin the same repo reproduces the mechanism without SvelteKit or a server.Logs
Nothing is logged with the failure currently. The file that gets upload just becomes `"[object Object]"`.System Info
System: OS: macOS 26.6.2 CPU: (12) arm64 Apple M2 Max Memory: 764.33 MB / 32.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 22.23.2 - /etc/profiles/per-user/thomaseckert/bin/node Yarn: 1.22.22 - /etc/profiles/per-user/thomaseckert/bin/yarn npm: 10.9.8 - /etc/profiles/per-user/thomaseckert/bin/npm pnpm: 11.25.0 - /etc/profiles/per-user/thomaseckert/bin/pnpm bun: 1.4.2 - /etc/profiles/per-user/thomaseckert/bin/bun Deno: 2.9.6 - /etc/profiles/per-user/thomaseckert/bin/deno Browsers: Chrome: 154.0.8037.93 Safari: 26.6.2 npmPackages: @sveltejs/adapter-node: ^6.0.0 => 6.0.0 @sveltejs/kit: 3.0.0 => 3.0.0 @sveltejs/vite-plugin-svelte: ^7.2.0 => 7.3.0 svelte: ^5.57.1 => 5.57.1 vite: ^8.1.5 => 8.3.0Severity
serious, but I can work around it
Additional Information
Where it surfaced
I was working on a file upload form for a writing portfolio. The service appeared to be working, but all of the files just became
"[object Object]".Proposed fix
Give
LazyFileaSymbol.toPrimitivethat throws aTypeErrornaming the cause. All three silent paths fail by string coercion, so all three become loud, while the two-argumentappendstays lazy and validators still see aFile. The commit above adds it with a test. On 3.0.0 the test fails without the change, and with it the kit unit suite passes.This closes the failure mode, not the underlying mismatch:
LazyFilestill claims to be aFilewithout being aBlob. Two alternatives were measured and are written up in the README:File/Blobdoes not work. A subclass built withsuper([])that overridessizeandstream()is written to the wire empty, because Node reads the blob's internal handle rather than callingstream().LazyFileexists for (cf. cloudfare adapter throws error on trying to run stream() from slighlty larger files in form remote function #16116).Other Runtimes
The two-argument path is only sound on Node. On Bun it is also silently corrupted, and on Deno a
LazyFilecannot enter aFormDataat all. Table in the README.Prior Art
#15018 is why the proxy exists (
z.file()rejected a bareLazyFile). #16116 and #17147 are adjacentLazyFileissues, and neither covers this.