net: accept Blob and File values in FormData.append/set

The JS-facing append() and set() took `value: []const u8`, so js.Bridge
coerced any object argument through toString(). Passing a Blob or File stored
the literal text "[object Blob]" / "[object File]" and the bytes never reached
the wire: a FormData carrying a 200 KB File produced a 209-byte request body,
with no filename parameter and no per-part Content-Type. The request still
succeeded with a 200, so nothing surfaced the loss.

Only the binding was string-typed. Entry.Value already carries a `file: *File`
variant with refcounting, and the multipart writer already emits filename= and
a per-part Content-Type for it — that is how <input type=file> submissions
work today.

Take a tagged union instead, following the shape body_init.zig already uses
for BodyInit (`bytes` last, so js.Bridge only falls back to a string once the
JsApi classes have been tried), and implement the entry-creation steps from
https://xhr.spec.whatwg.org/#create-an-entry: a Blob that is not a File
becomes a File named "blob", and an explicit filename produces a new File over
the same bytes rather than renaming the caller's object.

The internal append(name, value) helper keeps its string signature — it backs
form collection and the existing unit tests, neither of which goes through JS.

Closes #3050
This commit is contained in:
Navid EMAD
2026-07-24 20:38:19 +02:00
parent 044d5c9186
commit 37aef53f5e
2 changed files with 91 additions and 6 deletions

View File

@@ -898,3 +898,33 @@
testing.expectEqual('by-listener', fd.get('added'));
}
</script>
<script id=appendBlobAndFileValues>
{
// https://xhr.spec.whatwg.org/#create-an-entry
// A Blob value becomes a File named "blob"; a File keeps its own name; an
// explicit filename overrides both. Before this was implemented the value
// was coerced with toString() and every entry read back "[object Blob]".
const fd = new FormData();
fd.append('plain', 'still-a-string');
fd.append('fromBlob', new Blob(['blob-bytes'], { type: 'text/plain' }));
fd.append('fromFile', new File(['file-bytes'], 'notes.txt', { type: 'text/plain' }));
fd.append('renamed', new Blob(['renamed-bytes'], { type: 'text/plain' }), 'given.txt');
testing.expectEqual('still-a-string', fd.get('plain'));
// A file entry read through get() collapses to its name, per WHATWG.
testing.expectEqual('blob', fd.get('fromBlob'));
testing.expectEqual('notes.txt', fd.get('fromFile'));
testing.expectEqual('given.txt', fd.get('renamed'));
// set() goes through the same path.
fd.set('fromFile', new File(['replaced'], 'replaced.txt', { type: 'text/plain' }));
testing.expectEqual('replaced.txt', fd.get('fromFile'));
// Overwriting a file entry with a string still yields a string.
fd.set('fromBlob', 'back-to-string');
testing.expectEqual('back-to-string', fd.get('fromBlob'));
}
</script>

View File

@@ -25,6 +25,7 @@ const Frame = @import("../../Frame.zig");
const Form = @import("../element/html/Form.zig");
const Element = @import("../Element.zig");
const File = @import("../File.zig");
const Blob = @import("../Blob.zig");
const KeyValueList = @import("../KeyValueList.zig");
const log = lp.log;
@@ -165,9 +166,65 @@ pub fn has(self: *const FormData, name: String) bool {
return false;
}
pub fn set(self: *FormData, name: String, value: []const u8, exec: *Execution) !void {
// The value half of the JS-facing append()/set(). `bytes` must stay last:
// js.Bridge tries the JsApi classes first and maps anything else to a string,
// so an earlier position would swallow File and Blob arguments.
pub const EntryValue = union(enum) {
file: *File,
blob: *Blob,
bytes: []const u8,
};
pub fn jsSet(self: *FormData, name: String, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void {
self.deleteByName(name, exec);
return self.append(name.str(), value);
return self.jsAppend(name.str(), value, filename, exec);
}
// https://xhr.spec.whatwg.org/#create-an-entry
pub fn jsAppend(self: *FormData, name: []const u8, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void {
const entry_value: Entry.Value = switch (value) {
// A Blob that is not a File becomes a File named "blob", unless a
// filename was supplied.
.blob => |blob| blk: {
if (blob._type == .file and filename == null) {
const file = blob._type.file;
file.acquireRef();
break :blk .{ .file = file };
}
break :blk .{ .file = try fileFrom(blob, filename orelse "blob", exec.page) };
},
// A File keeps its bytes; a supplied filename means a new File over
// the same bytes rather than a rename of the caller's object.
.file => |file| blk: {
if (filename) |n| break :blk .{ .file = try fileFrom(file._proto, n, exec.page) };
file.acquireRef();
break :blk .{ .file = file };
},
.bytes => |b| .{ .string = try String.init(self._arena, b, .{}) },
};
try self._entries.append(self._arena, .{
.name = try String.init(self._arena, name, .{}),
.value = entry_value,
});
}
// Mirrors File.init — a Blob and File sharing one reference-counted arena —
// but over bytes we already hold rather than JS parts. Returned at refcount 1:
// the entry owns that reference and deleteByName releases it.
fn fileFrom(source: *Blob, name: []const u8, page: *Page) !*File {
const blob = try Blob.initFromBytes(source._slice, source._mime, page);
errdefer blob.deinit(page);
const file = try blob._arena.create(File);
file.* = .{
._proto = blob,
._name = try blob._arena.dupe(u8, name),
._last_modified = std.Io.Clock.now(.real, lp.io).toMilliseconds(),
};
blob._type = .{ .file = file };
blob.acquireRef();
return file;
}
pub fn append(self: *FormData, name: []const u8, value: []const u8) !void {
@@ -511,8 +568,8 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(FormData.init, .{});
pub const has = bridge.function(FormData.has, .{});
pub const get = bridge.function(FormData.get, .{});
pub const set = bridge.function(FormData.set, .{});
pub const append = bridge.function(FormData.append, .{});
pub const set = bridge.function(FormData.jsSet, .{});
pub const append = bridge.function(FormData.jsAppend, .{});
pub const getAll = bridge.function(FormData.getAll, .{});
pub const delete = bridge.function(FormData.delete, .{});
pub const keys = bridge.function(FormData.keys, .{});
@@ -599,8 +656,6 @@ test "FormData: multipart empty body" {
try testing.expectString("--B--\r\n", buf.written());
}
const Blob = @import("../Blob.zig");
fn buildTestFile(arena: Allocator, page: *@import("../../Page.zig"), name: []const u8, mime: []const u8, body: []const u8) !*File {
const blob = try Blob.initFromBytes(body, mime, page);
blob.acquireRef();