From 37aef53f5e33a4acfa7f56d9dc6b8d3d2b985606 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Fri, 24 Jul 2026 20:38:19 +0200 Subject: [PATCH] net: accept Blob and File values in FormData.append/set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- src/browser/tests/net/form_data.html | 30 +++++++++++++ src/browser/webapi/net/FormData.zig | 67 +++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/browser/tests/net/form_data.html b/src/browser/tests/net/form_data.html index 86c175779..413c7725a 100644 --- a/src/browser/tests/net/form_data.html +++ b/src/browser/tests/net/form_data.html @@ -898,3 +898,33 @@ testing.expectEqual('by-listener', fd.get('added')); } + + diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index d4ffce43c..bf87bd789 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -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();