diff --git a/src/apiweb.zig b/src/apiweb.zig index a08f9f41..87197b51 100644 --- a/src/apiweb.zig +++ b/src/apiweb.zig @@ -6,6 +6,7 @@ const DOM = @import("dom/dom.zig"); const HTML = @import("html/html.zig"); const Events = @import("events/event.zig"); const XHR = @import("xhr/xhr.zig"); +const Storage = @import("storage/storage.zig"); pub const HTMLDocument = @import("html/document.zig").HTMLDocument; @@ -16,4 +17,5 @@ pub const Interfaces = generate.Tuple(.{ Events.Interfaces, HTML.Interfaces, XHR.Interfaces, + Storage.Interfaces, }); diff --git a/src/browser/browser.zig b/src/browser/browser.zig index 0508a391..7e2033b5 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -17,6 +17,8 @@ const apiweb = @import("../apiweb.zig"); const Window = @import("../html/window.zig").Window; const Walker = @import("../dom/walker.zig").WalkerDepthFirst; +const storage = @import("../storage/storage.zig"); + const FetchResult = std.http.Client.FetchResult; const log = std.log.scoped(.browser); @@ -69,6 +71,8 @@ pub const Session = struct { env: Env = undefined, loop: Loop, window: Window, + // TODO move the shed to the browser? + storageShed: storage.Shed, jstypes: [Types.len]usize = undefined, @@ -81,6 +85,7 @@ pub const Session = struct { .window = Window.create(null), .loader = Loader.init(alloc), .loop = try Loop.init(alloc), + .storageShed = storage.Shed.init(alloc), }; self.env = try Env.init(self.arena.allocator(), &self.loop); @@ -95,6 +100,7 @@ pub const Session = struct { self.loader.deinit(); self.loop.deinit(); + self.storageShed.deinit(); self.alloc.destroy(self); } @@ -116,6 +122,7 @@ pub const Page = struct { // handle url rawuri: ?[]const u8 = null, uri: std.Uri = undefined, + origin: ?[]const u8 = null, raw_data: ?[]const u8 = null, @@ -169,6 +176,15 @@ pub const Page = struct { self.rawuri = try alloc.dupe(u8, uri); self.uri = std.Uri.parse(self.rawuri.?) catch try std.Uri.parseWithoutScheme(self.rawuri.?); + // prepare origin value. + var buf = std.ArrayList(u8).init(alloc); + defer buf.deinit(); + try self.uri.writeToStream(.{ + .scheme = true, + .authority = true, + }, buf.writer()); + self.origin = try buf.toOwnedSlice(); + // TODO handle fragment in url. // load the data @@ -237,6 +253,9 @@ pub const Page = struct { // TODO set the referrer to the document. self.session.window.replaceDocument(html_doc); + self.session.window.setStorageShelf( + try self.session.storageShed.getOrPut(self.origin orelse "null"), + ); // https://html.spec.whatwg.org/#read-html diff --git a/src/html/window.zig b/src/html/window.zig index e7d1ca47..7e79b035 100644 --- a/src/html/window.zig +++ b/src/html/window.zig @@ -4,6 +4,8 @@ const parser = @import("../netsurf.zig"); const EventTarget = @import("../dom/event_target.zig").EventTarget; +const storage = @import("../storage/storage.zig"); + // https://dom.spec.whatwg.org/#interface-window-extensions // https://html.spec.whatwg.org/multipage/nav-history-apis.html#window pub const Window = struct { @@ -17,6 +19,8 @@ pub const Window = struct { document: ?*parser.DocumentHTML = null, target: []const u8, + storageShelf: ?*storage.Shelf = null, + pub fn create(target: ?[]const u8) Window { return Window{ .target = target orelse "", @@ -27,6 +31,10 @@ pub const Window = struct { self.document = doc; } + pub fn setStorageShelf(self: *Window, shelf: *storage.Shelf) void { + self.storageShelf = shelf; + } + pub fn get_window(self: *Window) *Window { return self; } @@ -46,4 +54,14 @@ pub const Window = struct { pub fn get_name(self: *Window) []const u8 { return self.target; } + + pub fn get_localStorage(self: *Window) !*storage.Bottle { + if (self.storageShelf == null) return parser.DOMError.NotSupported; + return &self.storageShelf.?.bucket.local; + } + + pub fn get_sessionStorage(self: *Window) !*storage.Bottle { + if (self.storageShelf == null) return parser.DOMError.NotSupported; + return &self.storageShelf.?.bucket.session; + } }; diff --git a/src/main_shell.zig b/src/main_shell.zig index 43271b3d..f1afae43 100644 --- a/src/main_shell.zig +++ b/src/main_shell.zig @@ -5,6 +5,7 @@ const jsruntime = @import("jsruntime"); const parser = @import("netsurf.zig"); const apiweb = @import("apiweb.zig"); const Window = @import("html/window.zig").Window; +const storage = @import("storage/storage.zig"); const html_test = @import("html_test.zig").html; @@ -20,9 +21,13 @@ fn execJS( try js_env.start(alloc); defer js_env.stop(); + var storageShelf = storage.Shelf.init(alloc); + defer storageShelf.deinit(); + // alias global as self and window var window = Window.create(null); window.replaceDocument(doc); + window.setStorageShelf(&storageShelf); try js_env.bindGlobal(window); // launch shellExec diff --git a/src/run_tests.zig b/src/run_tests.zig index 14a235b7..3261a9eb 100644 --- a/src/run_tests.zig +++ b/src/run_tests.zig @@ -9,6 +9,7 @@ const parser = @import("netsurf.zig"); const apiweb = @import("apiweb.zig"); const Window = @import("html/window.zig").Window; const xhr = @import("xhr/xhr.zig"); +const storage = @import("storage/storage.zig"); const documentTestExecFn = @import("dom/document.zig").testExecFn; const HTMLDocumentTestExecFn = @import("html/document.zig").testExecFn; @@ -28,6 +29,7 @@ const ProcessingInstructionTestExecFn = @import("dom/processing_instruction.zig" const EventTestExecFn = @import("events/event.zig").testExecFn; const XHRTestExecFn = xhr.testExecFn; const ProgressEventTestExecFn = @import("xhr/progress_event.zig").testExecFn; +const StorageTestExecFn = storage.testExecFn; pub const Types = jsruntime.reflect(apiweb.Interfaces); @@ -45,6 +47,9 @@ fn testExecFn( try js_env.start(alloc); defer js_env.stop(); + var storageShelf = storage.Shelf.init(alloc); + defer storageShelf.deinit(); + // document const file = try std.fs.cwd().openFile("test.html", .{}); defer file.close(); @@ -56,7 +61,10 @@ fn testExecFn( // alias global as self and window var window = Window.create(null); + window.replaceDocument(doc); + window.setStorageShelf(&storageShelf); + try js_env.bindGlobal(window); // run test @@ -86,6 +94,7 @@ fn testsAllExecFn( XHRTestExecFn, ProgressEventTestExecFn, ProcessingInstructionTestExecFn, + StorageTestExecFn, }; inline for (testFns) |testFn| { diff --git a/src/storage/storage.zig b/src/storage/storage.zig new file mode 100644 index 00000000..b322645a --- /dev/null +++ b/src/storage/storage.zig @@ -0,0 +1,232 @@ +const std = @import("std"); + +const jsruntime = @import("jsruntime"); +const Case = jsruntime.test_utils.Case; +const checkCases = jsruntime.test_utils.checkCases; +const generate = @import("../generate.zig"); + +const DOMError = @import("../netsurf.zig").DOMError; + +const log = std.log.scoped(.storage); + +pub const Interfaces = generate.Tuple(.{ + Bottle, +}); + +// See https://storage.spec.whatwg.org/#model for storage hierarchy. +// A Shed contains map of Shelves. The key is the document's origin. +// A Shelf contains on default Bucket (it could contain many in the future). +// A Bucket contains a local and a session Bottle. +// A Bottle stores a map of strings and is exposed to the JS. + +pub const Shed = struct { + const Map = std.StringHashMapUnmanaged(Shelf); + + alloc: std.mem.Allocator, + map: Map, + + pub fn init(alloc: std.mem.Allocator) Shed { + return .{ + .alloc = alloc, + .map = .{}, + }; + } + + pub fn deinit(self: *Shed) void { + // loop hover each KV and free the memory. + var it = self.map.iterator(); + while (it.next()) |entry| { + entry.value_ptr.deinit(); + self.alloc.free(entry.key_ptr.*); + } + self.map.deinit(self.alloc); + } + + pub fn getOrPut(self: *Shed, origin: []const u8) !*Shelf { + const shelf = self.map.getPtr(origin); + if (shelf) |s| return s; + + const oorigin = try self.alloc.dupe(u8, origin); + try self.map.put(self.alloc, oorigin, Shelf.init(self.alloc)); + return self.map.getPtr(origin).?; + } +}; + +pub const Shelf = struct { + bucket: Bucket, + + pub fn init(alloc: std.mem.Allocator) Shelf { + return .{ .bucket = Bucket.init(alloc) }; + } + + pub fn deinit(self: *Shelf) void { + self.bucket.deinit(); + } +}; + +pub const Bucket = struct { + local: Bottle, + session: Bottle, + + pub fn init(alloc: std.mem.Allocator) Bucket { + return .{ + .local = Bottle.init(alloc), + .session = Bottle.init(alloc), + }; + } + + pub fn deinit(self: *Bucket) void { + self.local.deinit(); + self.session.deinit(); + } +}; + +// https://html.spec.whatwg.org/multipage/webstorage.html#the-storage-interface +pub const Bottle = struct { + pub const mem_guarantied = true; + const Map = std.StringHashMapUnmanaged([]const u8); + + // allocator is stored. we don't use the JS env allocator b/c the storage + // data could exists longer than a js env lifetime. + alloc: std.mem.Allocator, + map: Map, + + pub fn init(alloc: std.mem.Allocator) Bottle { + return .{ + .alloc = alloc, + .map = .{}, + }; + } + + // loop hover each KV and free the memory. + fn free(self: *Bottle) void { + var it = self.map.iterator(); + while (it.next()) |entry| { + self.alloc.free(entry.key_ptr.*); + self.alloc.free(entry.value_ptr.*); + } + } + + pub fn deinit(self: *Bottle) void { + self.free(); + self.map.deinit(self.alloc); + } + + pub fn get_length(self: *Bottle) u32 { + return @intCast(self.map.count()); + } + + pub fn _key(self: *Bottle, idx: u32) ?[]const u8 { + if (idx >= self.map.count()) return null; + + var it = self.map.valueIterator(); + var i: u32 = 0; + while (it.next()) |v| { + if (i == idx) return v.*; + i += 1; + } + unreachable; + } + + pub fn _getItem(self: *Bottle, k: []const u8) ?[]const u8 { + return self.map.get(k); + } + + pub fn _setItem(self: *Bottle, k: []const u8, v: []const u8) !void { + const old = self.map.get(k); + if (old != null and std.mem.eql(u8, v, old.?)) return; + + // owns k and v by copying them. + const kk = try self.alloc.dupe(u8, k); + errdefer self.alloc.free(kk); + const vv = try self.alloc.dupe(u8, v); + errdefer self.alloc.free(vv); + + self.map.put(self.alloc, kk, vv) catch |e| { + log.debug("set item: {any}", .{e}); + return DOMError.QuotaExceeded; + }; + + // > Broadcast this with key, oldValue, and value. + // https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface + // + // > The storage event of the Window interface fires when a storage + // > area (localStorage or sessionStorage) has been modified in the + // > context of another document. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event + // + // So for now, we won't impement the feature. + } + + pub fn _removeItem(self: *Bottle, k: []const u8) !void { + const old = self.map.fetchRemove(k); + if (old == null) return; + + // > Broadcast this with key, oldValue, and null. + // https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface + // + // > The storage event of the Window interface fires when a storage + // > area (localStorage or sessionStorage) has been modified in the + // > context of another document. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event + // + // So for now, we won't impement the feature. + } + + pub fn _clear(self: *Bottle) void { + self.free(); + self.map.clearRetainingCapacity(); + + // > Broadcast this with null, null, and null. + // https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface + // + // > The storage event of the Window interface fires when a storage + // > area (localStorage or sessionStorage) has been modified in the + // > context of another document. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event + // + // So for now, we won't impement the feature. + } +}; + +// Tests +// ----- + +pub fn testExecFn( + _: std.mem.Allocator, + js_env: *jsruntime.Env, +) anyerror!void { + var storage = [_]Case{ + .{ .src = "localStorage.length", .ex = "0" }, + + .{ .src = "localStorage.setItem('foo', 'bar')", .ex = "undefined" }, + .{ .src = "localStorage.length", .ex = "1" }, + .{ .src = "localStorage.getItem('foo')", .ex = "bar" }, + .{ .src = "localStorage.removeItem('foo')", .ex = "undefined" }, + .{ .src = "localStorage.length", .ex = "0" }, + + // .{ .src = "localStorage['foo'] = 'bar'", .ex = "undefined" }, + // .{ .src = "localStorage['foo']", .ex = "bar" }, + // .{ .src = "localStorage.length", .ex = "1" }, + + .{ .src = "localStorage.clear()", .ex = "undefined" }, + .{ .src = "localStorage.length", .ex = "0" }, + }; + try checkCases(js_env, &storage); +} + +test "storage bottle" { + var bottle = Bottle.init(std.testing.allocator); + defer bottle.deinit(); + + try std.testing.expect(0 == bottle.get_length()); + try std.testing.expect(null == bottle._getItem("foo")); + + try bottle._setItem("foo", "bar"); + try std.testing.expect(std.mem.eql(u8, "bar", bottle._getItem("foo").?)); + + try bottle._removeItem("foo"); + + try std.testing.expect(0 == bottle.get_length()); + try std.testing.expect(null == bottle._getItem("foo")); +} diff --git a/src/wpt/run.zig b/src/wpt/run.zig index 467436a5..6c35ec09 100644 --- a/src/wpt/run.zig +++ b/src/wpt/run.zig @@ -9,6 +9,7 @@ const jsruntime = @import("jsruntime"); const Loop = jsruntime.Loop; const Env = jsruntime.Env; const Window = @import("../html/window.zig").Window; +const storage = @import("../storage/storage.zig"); const Types = @import("../main_wpt.zig").Types; @@ -34,6 +35,9 @@ pub fn run(arena: *std.heap.ArenaAllocator, comptime dir: []const u8, f: []const var js_env = try Env.init(alloc, &loop); defer js_env.deinit(); + var storageShelf = storage.Shelf.init(alloc); + defer storageShelf.deinit(); + // load user-defined types in JS env var js_types: [Types.len]usize = undefined; try js_env.load(&js_types); @@ -54,6 +58,7 @@ pub fn run(arena: *std.heap.ArenaAllocator, comptime dir: []const u8, f: []const // setup global env vars. var window = Window.create(null); window.replaceDocument(html_doc); + window.setStorageShelf(&storageShelf); try js_env.bindGlobal(window); // thanks to the arena, we don't need to deinit res. diff --git a/tests/wpt b/tests/wpt index 735b2182..702189f6 160000 --- a/tests/wpt +++ b/tests/wpt @@ -1 +1 @@ -Subproject commit 735b21823e1d3e59f4ff1946612293be196dfc36 +Subproject commit 702189f6d2f815bb01fe37d90bf134d488155f20