Skip to content

Commit 8a867bc

Browse files
authored
Merge pull request #1190 from lightpanda-io/nikneym/blob
`Blob` support
2 parents cc83d85 + 7aafab9 commit 8a867bc

File tree

7 files changed

+350
-7
lines changed

7 files changed

+350
-7
lines changed

src/browser/file/Blob.zig

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
2+
//
3+
// Francis Bouvier <francis@lightpanda.io>
4+
// Pierre Tachoire <pierre@lightpanda.io>
5+
//
6+
// This program is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU Affero General Public License as
8+
// published by the Free Software Foundation, either version 3 of the
9+
// License, or (at your option) any later version.
10+
//
11+
// This program is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU Affero General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU Affero General Public License
17+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
19+
const std = @import("std");
20+
const Writer = std.Io.Writer;
21+
22+
const Page = @import("../page.zig").Page;
23+
const js = @import("../js/js.zig");
24+
25+
const ReadableStream = @import("../streams/ReadableStream.zig");
26+
27+
/// https://w3c.github.io/FileAPI/#blob-section
28+
/// https://developer.mozilla.org/en-US/docs/Web/API/Blob
29+
const Blob = @This();
30+
31+
/// Immutable slice of blob.
32+
/// Note that another blob may hold a pointer/slice to this,
33+
/// so its better to leave the deallocation of it to arena allocator.
34+
slice: []const u8,
35+
/// MIME attached to blob. Can be an empty string.
36+
mime: []const u8,
37+
38+
const ConstructorOptions = struct {
39+
/// MIME type.
40+
type: []const u8 = "",
41+
/// How to handle line endings (CR and LF).
42+
/// `transparent` means do nothing, `native` expects CRLF (\r\n) on Windows.
43+
endings: []const u8 = "transparent",
44+
};
45+
46+
/// Creates a new Blob.
47+
pub fn constructor(
48+
maybe_blob_parts: ?[]const []const u8,
49+
maybe_options: ?ConstructorOptions,
50+
page: *Page,
51+
) !Blob {
52+
const options: ConstructorOptions = maybe_options orelse .{};
53+
// Setup MIME; This can be any string according to my observations.
54+
const mime: []const u8 = blk: {
55+
const t = options.type;
56+
if (t.len == 0) {
57+
break :blk "";
58+
}
59+
60+
break :blk try page.arena.dupe(u8, t);
61+
};
62+
63+
if (maybe_blob_parts) |blob_parts| {
64+
var w: Writer.Allocating = .init(page.arena);
65+
const use_native_endings = std.mem.eql(u8, options.endings, "native");
66+
try writeBlobParts(&w.writer, blob_parts, use_native_endings);
67+
68+
return .{ .slice = w.written(), .mime = mime };
69+
}
70+
71+
// We don't have `blob_parts`, why would you want a Blob anyway then?
72+
return .{ .slice = "", .mime = mime };
73+
}
74+
75+
/// Writes blob parts to given `Writer` with desired endings.
76+
fn writeBlobParts(
77+
writer: *Writer,
78+
blob_parts: []const []const u8,
79+
use_native_endings: bool,
80+
) !void {
81+
// Transparent.
82+
if (!use_native_endings) {
83+
for (blob_parts) |part| {
84+
try writer.writeAll(part);
85+
}
86+
87+
return;
88+
}
89+
90+
// TODO: Windows support.
91+
// TODO: Vector search.
92+
93+
// Linux & Unix.
94+
// Both Firefox and Chrome implement it as such:
95+
// CRLF => LF
96+
// CR => LF
97+
// So even though CR is not followed by LF, it gets replaced.
98+
//
99+
// I believe this is because such scenario is possible:
100+
// ```
101+
// let parts = [ "the quick\r", "\nbrown fox" ];
102+
// ```
103+
// In the example, one should have to check the part before in order to
104+
// understand that CRLF is being presented in the final buffer.
105+
// So they took a simpler approach, here's what given blob parts produce:
106+
// ```
107+
// "the quick\n\nbrown fox"
108+
// ```
109+
scan_parts: for (blob_parts) |part| {
110+
var end: usize = 0;
111+
var start = end;
112+
while (end < part.len) {
113+
if (part[end] == '\r') {
114+
_ = try writer.writeVec(&.{ part[start..end], "\n" });
115+
116+
// Part ends with CR. We can continue to next part.
117+
if (end + 1 == part.len) {
118+
continue :scan_parts;
119+
}
120+
121+
// If next char is LF, skip it too.
122+
if (part[end + 1] == '\n') {
123+
start = end + 2;
124+
} else {
125+
start = end + 1;
126+
}
127+
}
128+
129+
end += 1;
130+
}
131+
132+
// Write the remaining. We get this in such situations:
133+
// `the quick brown\rfox`
134+
// `the quick brown\r\nfox`
135+
try writer.writeAll(part[start..end]);
136+
}
137+
}
138+
139+
/// Returns a Promise that resolves with the contents of the blob
140+
/// as binary data contained in an ArrayBuffer.
141+
pub fn _arrayBuffer(self: *const Blob, page: *Page) !js.Promise {
142+
return page.js.resolvePromise(js.ArrayBuffer{ .values = self.slice });
143+
}
144+
145+
/// Returns a ReadableStream which upon reading returns the data
146+
/// contained within the Blob.
147+
pub fn _stream(self: *const Blob, page: *Page) !*ReadableStream {
148+
const stream = try ReadableStream.constructor(null, null, page);
149+
try stream.queue.append(page.arena, .{
150+
.uint8array = .{ .values = self.slice },
151+
});
152+
return stream;
153+
}
154+
155+
/// Returns a Promise that resolves with a string containing
156+
/// the contents of the blob, interpreted as UTF-8.
157+
pub fn _text(self: *const Blob, page: *Page) !js.Promise {
158+
return page.js.resolvePromise(self.slice);
159+
}
160+
161+
/// Extension to Blob; works on Firefox and Safari.
162+
/// https://developer.mozilla.org/en-US/docs/Web/API/Blob/bytes
163+
/// Returns a Promise that resolves with a Uint8Array containing
164+
/// the contents of the blob as an array of bytes.
165+
pub fn _bytes(self: *const Blob, page: *Page) !js.Promise {
166+
return page.js.resolvePromise(js.TypedArray(u8){ .values = self.slice });
167+
}
168+
169+
/// Returns a new Blob object which contains data
170+
/// from a subset of the blob on which it's called.
171+
pub fn _slice(
172+
self: *const Blob,
173+
maybe_start: ?i32,
174+
maybe_end: ?i32,
175+
maybe_content_type: ?[]const u8,
176+
page: *Page,
177+
) !Blob {
178+
const mime: []const u8 = blk: {
179+
if (maybe_content_type) |content_type| {
180+
if (content_type.len == 0) {
181+
break :blk "";
182+
}
183+
184+
break :blk try page.arena.dupe(u8, content_type);
185+
}
186+
187+
break :blk "";
188+
};
189+
190+
const slice = self.slice;
191+
if (maybe_start) |_start| {
192+
const start = blk: {
193+
if (_start < 0) {
194+
break :blk slice.len -| @abs(_start);
195+
}
196+
197+
break :blk @min(slice.len, @as(u31, @intCast(_start)));
198+
};
199+
200+
const end: usize = blk: {
201+
if (maybe_end) |_end| {
202+
if (_end < 0) {
203+
break :blk @max(start, slice.len -| @abs(_end));
204+
}
205+
206+
break :blk @min(slice.len, @max(start, @as(u31, @intCast(_end))));
207+
}
208+
209+
break :blk slice.len;
210+
};
211+
212+
return .{ .slice = slice[start..end], .mime = mime };
213+
}
214+
215+
return .{ .slice = slice, .mime = mime };
216+
}
217+
218+
/// Returns the size of the Blob in bytes.
219+
pub fn get_size(self: *const Blob) usize {
220+
return self.slice.len;
221+
}
222+
223+
/// Returns the type of Blob; likely a MIME type, yet anything can be given.
224+
pub fn get_type(self: *const Blob) []const u8 {
225+
return self.mime;
226+
}
227+
228+
const testing = @import("../../testing.zig");
229+
test "Browser: File.Blob" {
230+
try testing.htmlRunner("file/blob.html");
231+
}

src/browser/xhr/File.zig renamed to src/browser/file/File.zig

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,12 @@ const std = @import("std");
2121
// https://w3c.github.io/FileAPI/#file-section
2222
const File = @This();
2323

24-
// Very incomplete. The prototype for this is Blob, which we don't have.
25-
// This minimum "implementation" is added because some JavaScript code just
26-
// checks: if (x instanceof File) throw Error(...)
24+
/// TODO: Implement File API.
2725
pub fn constructor() File {
2826
return .{};
2927
}
3028

3129
const testing = @import("../../testing.zig");
32-
test "Browser: File" {
33-
try testing.htmlRunner("xhr/file.html");
30+
test "Browser: File.File" {
31+
try testing.htmlRunner("file/file.html");
3432
}

src/browser/file/root.zig

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! File API.
2+
//! https://developer.mozilla.org/en-US/docs/Web/API/File_API
3+
4+
pub const Interfaces = .{
5+
@import("./Blob.zig"),
6+
@import("./File.zig"),
7+
};

src/browser/js/js.zig

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ pub fn TypedArray(comptime T: type) type {
5858
};
5959
}
6060

61+
pub const ArrayBuffer = struct {
62+
values: []const u8,
63+
};
64+
6165
pub const PromiseResolver = struct {
6266
context: *Context,
6367
resolver: v8.PromiseResolver,
@@ -324,6 +328,19 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo
324328
},
325329
.@"struct" => {
326330
const T = @TypeOf(value);
331+
332+
if (T == ArrayBuffer) {
333+
const values = value.values;
334+
const len = values.len;
335+
var array_buffer: v8.ArrayBuffer = undefined;
336+
const backing_store = v8.BackingStore.init(isolate, len);
337+
const data: [*]u8 = @ptrCast(@alignCast(backing_store.getData()));
338+
@memcpy(data[0..len], @as([]const u8, @ptrCast(values))[0..len]);
339+
array_buffer = v8.ArrayBuffer.initWithBackingStore(isolate, &backing_store.toSharedPtr());
340+
341+
return .{ .handle = array_buffer.handle };
342+
}
343+
327344
if (@hasDecl(T, "_TYPED_ARRAY_ID_KLUDGE")) {
328345
const values = value.values;
329346
const value_type = @typeInfo(@TypeOf(values)).pointer.child;

src/browser/js/types.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ const Interfaces = generate.Tuple(.{
1717
@import("../url/url.zig").Interfaces,
1818
@import("../xhr/xhr.zig").Interfaces,
1919
@import("../navigation/root.zig").Interfaces,
20+
@import("../file/root.zig").Interfaces,
2021
@import("../xhr/form_data.zig").Interfaces,
21-
@import("../xhr/File.zig"),
2222
@import("../xmlserializer/xmlserializer.zig").Interfaces,
2323
@import("../fetch/fetch.zig").Interfaces,
2424
@import("../streams/streams.zig").Interfaces,

src/tests/file/blob.html

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<!DOCTYPE html>
2+
<script src="../testing.js"></script>
3+
4+
<script id=Blob/Blob.text>
5+
{
6+
const parts = ["\r\nthe quick brown\rfo\rx\r", "\njumps over\r\nthe\nlazy\r", "\ndog"];
7+
// "transparent" ending should not modify the final buffer.
8+
const blob = new Blob(parts, { type: "text/html" });
9+
10+
const expected = parts.join("");
11+
testing.expectEqual(expected.length, blob.size);
12+
testing.expectEqual("text/html", blob.type);
13+
testing.async(blob.text(), result => testing.expectEqual(expected, result));
14+
}
15+
16+
{
17+
const parts = ["\rhello\r", "\nwor\r\nld"];
18+
// "native" ending should modify the final buffer.
19+
const blob = new Blob(parts, { endings: "native" });
20+
21+
const expected = "\nhello\n\nwor\nld";
22+
testing.expectEqual(expected.length, blob.size);
23+
testing.async(blob.text(), result => testing.expectEqual(expected, result));
24+
25+
testing.async(blob.arrayBuffer(), result => testing.expectEqual(true, result instanceof ArrayBuffer));
26+
}
27+
</script>
28+
29+
<script id=Blob.stream>
30+
{
31+
const parts = ["may", "thy", "knife", "chip", "and", "shatter"];
32+
const blob = new Blob(parts);
33+
const reader = blob.stream().getReader();
34+
35+
testing.async(reader.read(), ({ done, value }) => {
36+
const expected = new Uint8Array([109, 97, 121, 116, 104, 121, 107, 110,
37+
105, 102, 101, 99, 104, 105, 112, 97,
38+
110, 100, 115, 104, 97, 116, 116, 101,
39+
114]);
40+
testing.expectEqual(false, done);
41+
testing.expectEqual(true, value instanceof Uint8Array);
42+
testing.expectEqual(expected, value);
43+
});
44+
}
45+
</script>
46+
47+
<script id=Blob.arrayBuffer/Blob.slice>
48+
{
49+
const parts = ["la", "symphonie", "des", "éclairs"];
50+
const blob = new Blob(parts);
51+
testing.async(blob.arrayBuffer(), result => testing.expectEqual(true, result instanceof ArrayBuffer));
52+
53+
let temp = blob.slice(0);
54+
testing.expectEqual(blob.size, temp.size);
55+
testing.async(temp.text(), result => {
56+
testing.expectEqual("lasymphoniedeséclairs", result);
57+
});
58+
59+
temp = blob.slice(-4, -2, "custom");
60+
testing.expectEqual(2, temp.size);
61+
testing.expectEqual("custom", temp.type);
62+
testing.async(temp.text(), result => testing.expectEqual("ai", result));
63+
64+
temp = blob.slice(14);
65+
testing.expectEqual(8, temp.size);
66+
testing.async(temp.text(), result => testing.expectEqual("éclairs", result));
67+
68+
temp = blob.slice(6, -10, "text/eclair");
69+
testing.expectEqual(6, temp.size);
70+
testing.expectEqual("text/eclair", temp.type);
71+
testing.async(temp.text(), result => testing.expectEqual("honied", result));
72+
}
73+
</script>
74+
75+
<!-- Firefox and Safari only -->
76+
<script id=Blob.bytes>
77+
{
78+
const parts = ["light ", "panda ", "rocks ", "!"];
79+
const blob = new Blob(parts);
80+
81+
testing.async(blob.bytes(), result => {
82+
const expected = new Uint8Array([108, 105, 103, 104, 116, 32, 112, 97,
83+
110, 100, 97, 32, 114, 111, 99, 107, 115,
84+
32, 33]);
85+
testing.expectEqual(true, result instanceof Uint8Array);
86+
testing.expectEqual(expected, result);
87+
});
88+
}
89+
</script>
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<!DOCTYPE html>
22
<script src="../testing.js"></script>
3+
34
<script id=file>
4-
let f = new File()
5+
let f = new File();
56
testing.expectEqual(true, f instanceof File);
67
</script>

0 commit comments

Comments
 (0)