diff --git a/src/browser/env.zig b/src/browser/env.zig index c6f0469c3..d7a20cf0d 100644 --- a/src/browser/env.zig +++ b/src/browser/env.zig @@ -36,6 +36,8 @@ const WebApis = struct { @import("xhr/form_data.zig").Interfaces, @import("xhr/File.zig"), @import("xmlserializer/xmlserializer.zig").Interfaces, + @import("fetch/fetch.zig").Interfaces, + @import("streams/streams.zig").Interfaces, }); }; diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig new file mode 100644 index 000000000..f7d83c3fd --- /dev/null +++ b/src/browser/fetch/Headers.zig @@ -0,0 +1,227 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); +const URL = @import("../../url.zig").URL; +const Page = @import("../page.zig").Page; + +const iterator = @import("../iterator/iterator.zig"); + +const v8 = @import("v8"); +const Env = @import("../env.zig").Env; + +// https://developer.mozilla.org/en-US/docs/Web/API/Headers +const Headers = @This(); + +// Case-Insensitive String HashMap. +// This allows us to avoid having to allocate lowercase keys all the time. +const HeaderHashMap = std.HashMapUnmanaged([]const u8, []const u8, struct { + pub fn hash(_: @This(), s: []const u8) u64 { + var buf: [64]u8 = undefined; + var hasher = std.hash.Wyhash.init(s.len); + + var key = s; + while (key.len >= 64) { + const lower = std.ascii.lowerString(buf[0..], key[0..64]); + hasher.update(lower); + key = key[64..]; + } + + if (key.len > 0) { + const lower = std.ascii.lowerString(buf[0..key.len], key); + hasher.update(lower); + } + + return hasher.final(); + } + + pub fn eql(_: @This(), a: []const u8, b: []const u8) bool { + return std.ascii.eqlIgnoreCase(a, b); + } +}, 80); + +headers: HeaderHashMap = .empty, + +// They can either be: +// +// 1. An array of string pairs. +// 2. An object with string keys to string values. +// 3. Another Headers object. +pub const HeadersInit = union(enum) { + // List of Pairs of []const u8 + strings: []const [2][]const u8, + // Headers + headers: *Headers, + // Mappings + object: Env.JsObject, +}; + +pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { + const arena = page.arena; + var headers: HeaderHashMap = .empty; + + if (_init) |init| { + switch (init) { + .strings => |kvs| { + for (kvs) |pair| { + const key = try arena.dupe(u8, pair[0]); + const value = try arena.dupe(u8, pair[1]); + + try headers.put(arena, key, value); + } + }, + .headers => |hdrs| { + var iter = hdrs.headers.iterator(); + while (iter.next()) |entry| { + try headers.put(arena, entry.key_ptr.*, entry.value_ptr.*); + } + }, + .object => |obj| { + var iter = obj.nameIterator(); + while (try iter.next()) |name_value| { + const name = try name_value.toString(arena); + const value = try obj.get(name); + const value_string = try value.toString(arena); + + try headers.put(arena, name, value_string); + } + }, + } + } + + return .{ + .headers = headers, + }; +} + +pub fn append(self: *Headers, name: []const u8, value: []const u8, allocator: std.mem.Allocator) !void { + const key = try allocator.dupe(u8, name); + const gop = try self.headers.getOrPut(allocator, key); + + if (gop.found_existing) { + // If we found it, append the value. + const new_value = try std.fmt.allocPrint(allocator, "{s}, {s}", .{ gop.value_ptr.*, value }); + gop.value_ptr.* = new_value; + } else { + // Otherwise, we should just put it in. + gop.value_ptr.* = try allocator.dupe(u8, value); + } +} + +pub fn _append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { + const arena = page.arena; + try self.append(name, value, arena); +} + +pub fn _delete(self: *Headers, name: []const u8) void { + _ = self.headers.remove(name); +} + +pub const HeadersEntryIterator = struct { + slot: [2][]const u8, + iter: HeaderHashMap.Iterator, + + // TODO: these SHOULD be in lexigraphical order but I'm not sure how actually + // important that is. + pub fn _next(self: *HeadersEntryIterator) ?[2][]const u8 { + if (self.iter.next()) |entry| { + self.slot[0] = entry.key_ptr.*; + self.slot[1] = entry.value_ptr.*; + return self.slot; + } else { + return null; + } + } +}; + +pub fn _entries(self: *const Headers) HeadersEntryIterable { + return .{ + .inner = .{ + .slot = undefined, + .iter = self.headers.iterator(), + }, + }; +} + +pub fn _forEach(self: *Headers, callback_fn: Env.Function, this_arg: ?Env.JsObject) !void { + var iter = self.headers.iterator(); + + const cb = if (this_arg) |this| try callback_fn.withThis(this) else callback_fn; + + while (iter.next()) |entry| { + try cb.call(void, .{ entry.key_ptr.*, entry.value_ptr.*, self }); + } +} + +pub fn _get(self: *const Headers, name: []const u8) ?[]const u8 { + return self.headers.get(name); +} + +pub fn _has(self: *const Headers, name: []const u8) bool { + return self.headers.contains(name); +} + +pub const HeadersKeyIterator = struct { + iter: HeaderHashMap.KeyIterator, + + pub fn _next(self: *HeadersKeyIterator) ?[]const u8 { + if (self.iter.next()) |key| { + return key.*; + } else { + return null; + } + } +}; + +pub fn _keys(self: *const Headers) HeadersKeyIterable { + return .{ .inner = .{ .iter = self.headers.keyIterator() } }; +} + +pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { + const arena = page.arena; + + const key = try arena.dupe(u8, name); + const gop = try self.headers.getOrPut(arena, key); + gop.value_ptr.* = try arena.dupe(u8, value); +} + +pub const HeadersValueIterator = struct { + iter: HeaderHashMap.ValueIterator, + + pub fn _next(self: *HeadersValueIterator) ?[]const u8 { + if (self.iter.next()) |value| { + return value.*; + } else { + return null; + } + } +}; + +pub fn _values(self: *const Headers) HeadersValueIterable { + return .{ .inner = .{ .iter = self.headers.valueIterator() } }; +} + +pub const HeadersKeyIterable = iterator.Iterable(HeadersKeyIterator, "HeadersKeyIterator"); +pub const HeadersValueIterable = iterator.Iterable(HeadersValueIterator, "HeadersValueIterator"); +pub const HeadersEntryIterable = iterator.Iterable(HeadersEntryIterator, "HeadersEntryIterator"); + +const testing = @import("../../testing.zig"); +test "fetch: Headers" { + try testing.htmlRunner("fetch/headers.html"); +} diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig new file mode 100644 index 000000000..7cfc77c29 --- /dev/null +++ b/src/browser/fetch/Request.zig @@ -0,0 +1,266 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); + +const URL = @import("../../url.zig").URL; +const Page = @import("../page.zig").Page; + +const Response = @import("./Response.zig"); +const Http = @import("../../http/Http.zig"); +const ReadableStream = @import("../streams/ReadableStream.zig"); + +const v8 = @import("v8"); +const Env = @import("../env.zig").Env; + +const Headers = @import("Headers.zig"); +const HeadersInit = @import("Headers.zig").HeadersInit; + +pub const RequestInput = union(enum) { + string: []const u8, + request: *Request, +}; + +pub const RequestCache = enum { + default, + @"no-store", + reload, + @"no-cache", + @"force-cache", + @"only-if-cached", + + pub fn fromString(str: []const u8) ?RequestCache { + for (std.enums.values(RequestCache)) |cache| { + if (std.ascii.eqlIgnoreCase(str, @tagName(cache))) { + return cache; + } + } else { + return null; + } + } + + pub fn toString(self: RequestCache) []const u8 { + return @tagName(self); + } +}; + +pub const RequestCredentials = enum { + omit, + @"same-origin", + include, + + pub fn fromString(str: []const u8) ?RequestCredentials { + for (std.enums.values(RequestCredentials)) |cache| { + if (std.ascii.eqlIgnoreCase(str, @tagName(cache))) { + return cache; + } + } else { + return null; + } + } + + pub fn toString(self: RequestCredentials) []const u8 { + return @tagName(self); + } +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/RequestInit +pub const RequestInit = struct { + body: ?[]const u8 = null, + cache: ?[]const u8 = null, + credentials: ?[]const u8 = null, + headers: ?HeadersInit = null, + integrity: ?[]const u8 = null, + method: ?[]const u8 = null, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/Request/Request +const Request = @This(); + +method: Http.Method, +url: [:0]const u8, +cache: RequestCache, +credentials: RequestCredentials, +headers: Headers, +body: ?[]const u8, +body_used: bool = false, +integrity: []const u8, + +pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Request { + const arena = page.arena; + const options: RequestInit = _options orelse .{}; + + const url: [:0]const u8 = blk: switch (input) { + .string => |str| { + break :blk try URL.stitch(arena, str, page.url.raw, .{ .null_terminated = true }); + }, + .request => |req| { + break :blk try arena.dupeZ(u8, req.url); + }, + }; + + const body = if (options.body) |body| try arena.dupe(u8, body) else null; + const cache = (if (options.cache) |cache| RequestCache.fromString(cache) else null) orelse RequestCache.default; + const credentials = (if (options.credentials) |creds| RequestCredentials.fromString(creds) else null) orelse RequestCredentials.@"same-origin"; + const integrity = if (options.integrity) |integ| try arena.dupe(u8, integ) else ""; + const headers: Headers = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else .{}; + + const method: Http.Method = blk: { + if (options.method) |given_method| { + for (std.enums.values(Http.Method)) |method| { + if (std.ascii.eqlIgnoreCase(given_method, @tagName(method))) { + break :blk method; + } + } else { + return error.TypeError; + } + } else { + break :blk Http.Method.GET; + } + }; + + return .{ + .method = method, + .url = url, + .cache = cache, + .credentials = credentials, + .headers = headers, + .body = body, + .integrity = integrity, + }; +} + +pub fn get_body(self: *const Request, page: *Page) !?*ReadableStream { + if (self.body) |body| { + const stream = try ReadableStream.constructor(null, null, page); + try stream.queue.append(page.arena, body); + return stream; + } else return null; +} + +pub fn get_bodyUsed(self: *const Request) bool { + return self.body_used; +} + +pub fn get_cache(self: *const Request) RequestCache { + return self.cache; +} + +pub fn get_credentials(self: *const Request) RequestCredentials { + return self.credentials; +} + +pub fn get_headers(self: *Request) *Headers { + return &self.headers; +} + +pub fn get_integrity(self: *const Request) []const u8 { + return self.integrity; +} + +// TODO: If we ever support the Navigation API, we need isHistoryNavigation +// https://developer.mozilla.org/en-US/docs/Web/API/Request/isHistoryNavigation + +pub fn get_method(self: *const Request) []const u8 { + return @tagName(self.method); +} + +pub fn get_url(/service/self: *const Request) []const u8 { + return self.url; +} + +pub fn _clone(self: *Request) !Request { + // Not allowed to clone if the body was used. + if (self.body_used) { + return error.TypeError; + } + + // OK to just return the same fields BECAUSE + // all of these fields are read-only and can't be modified. + return Request{ + .body = self.body, + .body_used = self.body_used, + .cache = self.cache, + .credentials = self.credentials, + .headers = self.headers, + .method = self.method, + .integrity = self.integrity, + .url = self.url, + }; +} + +pub fn _bytes(self: *Response, page: *Page) !Env.Promise { + if (self.body_used) { + return error.TypeError; + } + + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + try resolver.resolve(self.body); + self.body_used = true; + return resolver.promise(); +} + +pub fn _json(self: *Response, page: *Page) !Env.Promise { + if (self.body_used) { + return error.TypeError; + } + + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + const p = std.json.parseFromSliceLeaky( + std.json.Value, + page.call_arena, + self.body, + .{}, + ) catch |e| { + log.info(.browser, "invalid json", .{ .err = e, .source = "Request" }); + return error.SyntaxError; + }; + + try resolver.resolve(p); + self.body_used = true; + return resolver.promise(); +} + +pub fn _text(self: *Response, page: *Page) !Env.Promise { + if (self.body_used) { + return error.TypeError; + } + + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + try resolver.resolve(self.body); + self.body_used = true; + return resolver.promise(); +} + +const testing = @import("../../testing.zig"); +test "fetch: Request" { + try testing.htmlRunner("fetch/request.html"); +} diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig new file mode 100644 index 000000000..af1b615e4 --- /dev/null +++ b/src/browser/fetch/Response.zig @@ -0,0 +1,196 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); + +const v8 = @import("v8"); + +const HttpClient = @import("../../http/Client.zig"); +const Http = @import("../../http/Http.zig"); +const URL = @import("../../url.zig").URL; + +const ReadableStream = @import("../streams/ReadableStream.zig"); +const Headers = @import("Headers.zig"); +const HeadersInit = @import("Headers.zig").HeadersInit; + +const Env = @import("../env.zig").Env; +const Mime = @import("../mime.zig").Mime; +const Page = @import("../page.zig").Page; + +// https://developer.mozilla.org/en-US/docs/Web/API/Response +const Response = @This(); + +status: u16 = 200, +status_text: []const u8 = "", +headers: Headers, +mime: ?Mime = null, +url: []const u8 = "", +body: []const u8 = "", +body_used: bool = false, +redirected: bool = false, + +const ResponseBody = union(enum) { + string: []const u8, +}; + +const ResponseOptions = struct { + status: u16 = 200, + statusText: ?[]const u8 = null, + headers: ?HeadersInit = null, +}; + +pub fn constructor(_input: ?ResponseBody, _options: ?ResponseOptions, page: *Page) !Response { + const arena = page.arena; + + const options: ResponseOptions = _options orelse .{}; + + const body = blk: { + if (_input) |input| { + switch (input) { + .string => |str| { + break :blk try arena.dupe(u8, str); + }, + } + } else { + break :blk ""; + } + }; + + const headers: Headers = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else .{}; + const status_text = if (options.statusText) |st| try arena.dupe(u8, st) else ""; + + return .{ + .body = body, + .headers = headers, + .status = options.status, + .status_text = status_text, + }; +} + +pub fn get_body(self: *const Response, page: *Page) !*ReadableStream { + const stream = try ReadableStream.constructor(null, null, page); + try stream.queue.append(page.arena, self.body); + return stream; +} + +pub fn get_bodyUsed(self: *const Response) bool { + return self.body_used; +} + +pub fn get_headers(self: *Response) *Headers { + return &self.headers; +} + +pub fn get_ok(self: *const Response) bool { + return self.status >= 200 and self.status <= 299; +} + +pub fn get_redirected(self: *const Response) bool { + return self.redirected; +} + +pub fn get_status(self: *const Response) u16 { + return self.status; +} + +pub fn get_statusText(self: *const Response) []const u8 { + return self.status_text; +} + +pub fn get_url(/service/self: *const Response) []const u8 { + return self.url; +} + +pub fn _clone(self: *const Response) !Response { + if (self.body_used) { + return error.TypeError; + } + + // OK to just return the same fields BECAUSE + // all of these fields are read-only and can't be modified. + return Response{ + .body = self.body, + .body_used = self.body_used, + .mime = self.mime, + .headers = self.headers, + .redirected = self.redirected, + .status = self.status, + .url = self.url, + }; +} + +pub fn _bytes(self: *Response, page: *Page) !Env.Promise { + if (self.body_used) { + return error.TypeError; + } + + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + try resolver.resolve(self.body); + self.body_used = true; + return resolver.promise(); +} + +pub fn _json(self: *Response, page: *Page) !Env.Promise { + if (self.body_used) { + return error.TypeError; + } + + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + const p = std.json.parseFromSliceLeaky( + std.json.Value, + page.call_arena, + self.body, + .{}, + ) catch |e| { + log.info(.browser, "invalid json", .{ .err = e, .source = "Response" }); + return error.SyntaxError; + }; + + try resolver.resolve(p); + self.body_used = true; + return resolver.promise(); +} + +pub fn _text(self: *Response, page: *Page) !Env.Promise { + if (self.body_used) { + return error.TypeError; + } + + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + try resolver.resolve(self.body); + self.body_used = true; + return resolver.promise(); +} + +const testing = @import("../../testing.zig"); +test "fetch: Response" { + try testing.htmlRunner("fetch/response.html"); +} diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig new file mode 100644 index 000000000..1feab0e02 --- /dev/null +++ b/src/browser/fetch/fetch.zig @@ -0,0 +1,212 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); + +const Env = @import("../env.zig").Env; +const Page = @import("../page.zig").Page; + +const Http = @import("../../http/Http.zig"); +const HttpClient = @import("../../http/Client.zig"); +const Mime = @import("../mime.zig").Mime; + +const Headers = @import("Headers.zig"); + +const RequestInput = @import("Request.zig").RequestInput; +const RequestInit = @import("Request.zig").RequestInit; +const Request = @import("Request.zig"); +const Response = @import("Response.zig"); + +pub const Interfaces = .{ + @import("Headers.zig"), + @import("Headers.zig").HeadersEntryIterable, + @import("Headers.zig").HeadersKeyIterable, + @import("Headers.zig").HeadersValueIterable, + @import("Request.zig"), + @import("Response.zig"), +}; + +pub const FetchContext = struct { + arena: std.mem.Allocator, + js_ctx: *Env.JsContext, + promise_resolver: Env.PersistentPromiseResolver, + + method: Http.Method, + url: []const u8, + body: std.ArrayListUnmanaged(u8) = .empty, + headers: std.ArrayListUnmanaged([]const u8) = .empty, + status: u16 = 0, + mime: ?Mime = null, + transfer: ?*HttpClient.Transfer = null, + + /// This effectively takes ownership of the FetchContext. + /// + /// We just return the underlying slices used for `headers` + /// and for `body` here to avoid an allocation. + pub fn toResponse(self: *const FetchContext) !Response { + var headers: Headers = .{}; + + // convert into Headers + for (self.headers.items) |hdr| { + var iter = std.mem.splitScalar(u8, hdr, ':'); + const name = iter.next() orelse ""; + const value = iter.next() orelse ""; + try headers.append(name, value, self.arena); + } + + return Response{ + .status = self.status, + .headers = headers, + .mime = self.mime, + .body = self.body.items, + .url = self.url, + }; + } +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch +pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promise { + const arena = page.arena; + + const req = try Request.constructor(input, options, page); + var headers = try Http.Headers.init(); + + // Copy our headers into the HTTP headers. + var header_iter = req.headers.headers.iterator(); + while (header_iter.next()) |entry| { + const combined = try std.fmt.allocPrintSentinel( + page.arena, + "{s}: {s}", + .{ entry.key_ptr.*, entry.value_ptr.* }, + 0, + ); + try headers.add(combined.ptr); + } + + try page.requestCookie(.{}).headersForRequest(arena, req.url, &headers); + + const resolver = try page.main_context.createPersistentPromiseResolver(); + + const fetch_ctx = try arena.create(FetchContext); + fetch_ctx.* = .{ + .arena = arena, + .js_ctx = page.main_context, + .promise_resolver = resolver, + .method = req.method, + .url = req.url, + }; + + try page.http_client.request(.{ + .ctx = @ptrCast(fetch_ctx), + .url = req.url, + .method = req.method, + .headers = headers, + .body = req.body, + .cookie_jar = page.cookie_jar, + .resource_type = .fetch, + + .start_callback = struct { + fn startCallback(transfer: *HttpClient.Transfer) !void { + const self: *FetchContext = @ptrCast(@alignCast(transfer.ctx)); + log.debug(.fetch, "request start", .{ .method = self.method, .url = self.url, .source = "fetch" }); + + self.transfer = transfer; + } + }.startCallback, + .header_callback = struct { + fn headerCallback(transfer: *HttpClient.Transfer) !void { + const self: *FetchContext = @ptrCast(@alignCast(transfer.ctx)); + + const header = &transfer.response_header.?; + + log.debug(.fetch, "request header", .{ + .source = "fetch", + .method = self.method, + .url = self.url, + .status = header.status, + }); + + if (header.contentType()) |ct| { + self.mime = Mime.parse(ct) catch { + return error.MimeParsing; + }; + } + + if (transfer.getContentLength()) |cl| { + try self.body.ensureTotalCapacity(self.arena, cl); + } + + var it = transfer.responseHeaderIterator(); + while (it.next()) |hdr| { + const joined = try std.fmt.allocPrint(self.arena, "{s}: {s}", .{ hdr.name, hdr.value }); + try self.headers.append(self.arena, joined); + } + + self.status = header.status; + } + }.headerCallback, + .data_callback = struct { + fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { + const self: *FetchContext = @ptrCast(@alignCast(transfer.ctx)); + try self.body.appendSlice(self.arena, data); + } + }.dataCallback, + .done_callback = struct { + fn doneCallback(ctx: *anyopaque) !void { + const self: *FetchContext = @ptrCast(@alignCast(ctx)); + self.transfer = null; + + log.info(.fetch, "request complete", .{ + .source = "fetch", + .method = self.method, + .url = self.url, + .status = self.status, + }); + + const response = try self.toResponse(); + try self.promise_resolver.resolve(response); + } + }.doneCallback, + .error_callback = struct { + fn errorCallback(ctx: *anyopaque, err: anyerror) void { + const self: *FetchContext = @ptrCast(@alignCast(ctx)); + self.transfer = null; + + log.err(.fetch, "error", .{ + .url = self.url, + .err = err, + .source = "fetch error", + }); + + // We throw an Abort error when the page is getting closed so, + // in this case, we don't need to reject the promise. + if (err != error.Abort) { + self.promise_resolver.reject(@errorName(err)) catch unreachable; + } + } + }.errorCallback, + }); + + return resolver.promise(); +} + +const testing = @import("../../testing.zig"); +test "fetch: fetch" { + try testing.htmlRunner("fetch/fetch.html"); +} diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 5b5fdcd75..5a9121557 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -1042,84 +1042,68 @@ pub const HTMLSlotElement = struct { flatten: bool = false, }; pub fn _assignedNodes(self: *parser.Slot, opts_: ?AssignedNodesOpts, page: *Page) ![]NodeUnion { - return findAssignedSlotNodes(self, opts_, false, page); - } - - // This should return Union, instead of NodeUnion, but we want to re-use - // findAssignedSlotNodes. Returning NodeUnion is fine, as long as every element - // within is an Element. This could be more efficient - pub fn _assignedElements(self: *parser.Slot, opts_: ?AssignedNodesOpts, page: *Page) ![]NodeUnion { - return findAssignedSlotNodes(self, opts_, true, page); - } - - fn findAssignedSlotNodes(self: *parser.Slot, opts_: ?AssignedNodesOpts, element_only: bool, page: *Page) ![]NodeUnion { const opts = opts_ orelse AssignedNodesOpts{ .flatten = false }; - if (opts.flatten) { - log.debug(.web_api, "not implemented", .{ .feature = "HTMLSlotElement flatten assignedNodes" }); + if (try findAssignedSlotNodes(self, opts, page)) |nodes| { + return nodes; } - const node: *parser.Node = @ptrCast(@alignCast(self)); + if (!opts.flatten) { + return &.{}; + } - // First we look for any explicitly assigned nodes (via the slot attribute) - { - const slot_name = try parser.elementGetAttribute(@ptrCast(@alignCast(self)), "name"); - var root = try parser.nodeGetRootNode(node); - if (page.getNodeState(root)) |state| { - if (state.shadow_root) |sr| { - root = @ptrCast(@alignCast(sr.host)); - } - } + const node: *parser.Node = @ptrCast(@alignCast(self)); + const nl = try parser.nodeGetChildNodes(node); + const len = try parser.nodeListLength(nl); + if (len == 0) { + return &.{}; + } - var arr: std.ArrayList(NodeUnion) = .empty; - const w = @import("../dom/walker.zig").WalkerChildren{}; - var next: ?*parser.Node = null; - while (true) { - next = try w.get_next(root, next) orelse break; - if (try parser.nodeType(next.?) != .element) { - if (slot_name == null and !element_only) { - // default slot (with no name), takes everything - try arr.append(page.call_arena, try Node.toInterface(next.?)); - } - continue; - } - const el: *parser.Element = @ptrCast(@alignCast(next.?)); - const element_slot = try parser.elementGetAttribute(el, "slot"); + var assigned = try page.call_arena.alloc(NodeUnion, len); + var i: usize = 0; + while (true) : (i += 1) { + const child = try parser.nodeListItem(nl, @intCast(i)) orelse break; + assigned[i] = try Node.toInterface(child); + } + return assigned[0..i]; + } - if (nullableStringsAreEqual(slot_name, element_slot)) { - // either they're the same string or they are both null - try arr.append(page.call_arena, try Node.toInterface(next.?)); - continue; - } - } - if (arr.items.len > 0) { - return arr.items; - } + fn findAssignedSlotNodes(self: *parser.Slot, opts: AssignedNodesOpts, page: *Page) !?[]NodeUnion { + if (opts.flatten) { + log.warn(.web_api, "not implemented", .{ .feature = "HTMLSlotElement flatten assignedNodes" }); + } - if (!opts.flatten) { - return &.{}; + const slot_name = try parser.elementGetAttribute(@ptrCast(@alignCast(self)), "name"); + const node: *parser.Node = @ptrCast(@alignCast(self)); + var root = try parser.nodeGetRootNode(node); + if (page.getNodeState(root)) |state| { + if (state.shadow_root) |sr| { + root = @ptrCast(@alignCast(sr.host)); } } - // Since, we have no explicitly assigned nodes and flatten == false, - // we'll collect the children of the slot - the defaults. - { - const nl = try parser.nodeGetChildNodes(node); - const len = try parser.nodeListLength(nl); - if (len == 0) { - return &.{}; + var arr: std.ArrayList(NodeUnion) = .empty; + const w = @import("../dom/walker.zig").WalkerChildren{}; + var next: ?*parser.Node = null; + while (true) { + next = try w.get_next(root, next) orelse break; + if (try parser.nodeType(next.?) != .element) { + if (slot_name == null) { + // default slot (with no name), takes everything + try arr.append(page.call_arena, try Node.toInterface(next.?)); + } + continue; } + const el: *parser.Element = @ptrCast(@alignCast(next.?)); + const element_slot = try parser.elementGetAttribute(el, "slot"); - var assigned = try page.call_arena.alloc(NodeUnion, len); - var i: usize = 0; - while (true) : (i += 1) { - const child = try parser.nodeListItem(nl, @intCast(i)) orelse break; - if (!element_only or try parser.nodeType(child) == .element) { - assigned[i] = try Node.toInterface(child); - } + if (nullableStringsAreEqual(slot_name, element_slot)) { + // either they're the same string or they are both null + try arr.append(page.call_arena, try Node.toInterface(next.?)); + continue; } - return assigned[0..i]; } + return if (arr.items.len == 0) null else arr.items; } fn nullableStringsAreEqual(a: ?[]const u8, b: ?[]const u8) bool { @@ -1345,6 +1329,39 @@ test "Browser: HTML.HtmlScriptElement" { try testing.htmlRunner("html/script/inline_defer.html"); } -test "Browser: HTML.HtmlSlotElement" { - try testing.htmlRunner("html/slot.html"); +test "Browser: HTML.HTMLSlotElement" { + try testing.htmlRunner("html/html_slot_element.html"); +} + +const Check = struct { + input: []const u8, + expected: ?[]const u8 = null, // Needed when input != expected +}; +const bool_valids = [_]Check{ + .{ .input = "true" }, + .{ .input = "''", .expected = "false" }, + .{ .input = "13.5", .expected = "true" }, +}; +const str_valids = [_]Check{ + .{ .input = "'foo'", .expected = "foo" }, + .{ .input = "5", .expected = "5" }, + .{ .input = "''", .expected = "" }, + .{ .input = "document", .expected = "[object HTMLDocument]" }, +}; + +// .{ "elem.type = '5'", "5" }, +// .{ "elem.type", "text" }, +fn testProperty( + arena: std.mem.Allocator, + runner: *testing.JsRunner, + elem_dot_prop: []const u8, + always: ?[]const u8, // Ignores checks' expected if set + checks: []const Check, +) !void { + for (checks) |check| { + try runner.testCases(&.{ + .{ try std.mem.concat(arena, u8, &.{ elem_dot_prop, " = ", check.input }), null }, + .{ elem_dot_prop, always orelse check.expected orelse check.input }, + }, .{}); + } } diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index 6f1d5fe8f..790e823f6 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -39,6 +39,10 @@ const Css = @import("../css/css.zig").Css; const Function = Env.Function; const JsObject = Env.JsObject; +const v8 = @import("v8"); +const Request = @import("../fetch/Request.zig"); +const fetchFn = @import("../fetch/fetch.zig").fetch; + const storage = @import("../storage/storage.zig"); // https://dom.spec.whatwg.org/#interface-window-extensions @@ -95,6 +99,10 @@ pub const Window = struct { self.storage_shelf = shelf; } + pub fn _fetch(_: *Window, input: Request.RequestInput, options: ?Request.RequestInit, page: *Page) !Env.Promise { + return fetchFn(input, options, page); + } + pub fn get_window(self: *Window) *Window { return self; } diff --git a/src/browser/polyfill/fetch.js b/src/browser/polyfill/fetch.js deleted file mode 100644 index 75efab54c..000000000 --- a/src/browser/polyfill/fetch.js +++ /dev/null @@ -1,671 +0,0 @@ -// fetch.js code comes from -// https://github.com/JakeChampion/fetch/blob/main/fetch.js -// -// The original code source is available in MIT license. -// -// The script comes from the built version from npm. -// You can get the package with the command: -// -// wget $(npm view whatwg-fetch dist.tarball) -// -// The source is the content of `package/dist/fetch.umd.js` file. -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : - typeof define === 'function' && define.amd ? define(['exports'], factory) : - (factory((global.WHATWGFetch = {}))); -}(this, (function (exports) { 'use strict'; - - /* eslint-disable no-prototype-builtins */ - var g = - (typeof globalThis !== 'undefined' && globalThis) || - (typeof self !== 'undefined' && self) || - // eslint-disable-next-line no-undef - (typeof global !== 'undefined' && global) || - {}; - - var support = { - searchParams: 'URLSearchParams' in g, - iterable: 'Symbol' in g && 'iterator' in Symbol, - blob: - 'FileReader' in g && - 'Blob' in g && - (function() { - try { - new Blob(); - return true - } catch (e) { - return false - } - })(), - formData: 'FormData' in g, - - // Arraybuffer is available but xhr doesn't implement it for now. - // arrayBuffer: 'ArrayBuffer' in g - arrayBuffer: false - }; - - function isDataView(obj) { - return obj && DataView.prototype.isPrototypeOf(obj) - } - - if (support.arrayBuffer) { - var viewClasses = [ - '[object Int8Array]', - '[object Uint8Array]', - '[object Uint8ClampedArray]', - '[object Int16Array]', - '[object Uint16Array]', - '[object Int32Array]', - '[object Uint32Array]', - '[object Float32Array]', - '[object Float64Array]' - ]; - - var isArrayBufferView = - ArrayBuffer.isView || - function(obj) { - return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1 - }; - } - - function normalizeName(name) { - if (typeof name !== 'string') { - name = String(name); - } - if (/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name) || name === '') { - throw new TypeError('Invalid character in header field name: "' + name + '"') - } - return name.toLowerCase() - } - - function normalizeValue(value) { - if (typeof value !== 'string') { - value = String(value); - } - return value - } - - // Build a destructive iterator for the value list - function iteratorFor(items) { - var iterator = { - next: function() { - var value = items.shift(); - return {done: value === undefined, value: value} - } - }; - - if (support.iterable) { - iterator[Symbol.iterator] = function() { - return iterator - }; - } - - return iterator - } - - function Headers(headers) { - this.map = {}; - - if (headers instanceof Headers) { - headers.forEach(function(value, name) { - this.append(name, value); - }, this); - } else if (Array.isArray(headers)) { - headers.forEach(function(header) { - if (header.length != 2) { - throw new TypeError('Headers constructor: expected name/value pair to be length 2, found' + header.length) - } - this.append(header[0], header[1]); - }, this); - } else if (headers) { - Object.getOwnPropertyNames(headers).forEach(function(name) { - this.append(name, headers[name]); - }, this); - } - } - - Headers.prototype.append = function(name, value) { - name = normalizeName(name); - value = normalizeValue(value); - var oldValue = this.map[name]; - this.map[name] = oldValue ? oldValue + ', ' + value : value; - }; - - Headers.prototype['delete'] = function(name) { - delete this.map[normalizeName(name)]; - }; - - Headers.prototype.get = function(name) { - name = normalizeName(name); - return this.has(name) ? this.map[name] : null - }; - - Headers.prototype.has = function(name) { - return this.map.hasOwnProperty(normalizeName(name)) - }; - - Headers.prototype.set = function(name, value) { - this.map[normalizeName(name)] = normalizeValue(value); - }; - - Headers.prototype.forEach = function(callback, thisArg) { - for (var name in this.map) { - if (this.map.hasOwnProperty(name)) { - callback.call(thisArg, this.map[name], name, this); - } - } - }; - - Headers.prototype.keys = function() { - var items = []; - this.forEach(function(value, name) { - items.push(name); - }); - return iteratorFor(items) - }; - - Headers.prototype.values = function() { - var items = []; - this.forEach(function(value) { - items.push(value); - }); - return iteratorFor(items) - }; - - Headers.prototype.entries = function() { - var items = []; - this.forEach(function(value, name) { - items.push([name, value]); - }); - return iteratorFor(items) - }; - - if (support.iterable) { - Headers.prototype[Symbol.iterator] = Headers.prototype.entries; - } - - function consumed(body) { - if (body._noBody) return - if (body.bodyUsed) { - return Promise.reject(new TypeError('Already read')) - } - body.bodyUsed = true; - } - - function fileReaderReady(reader) { - return new Promise(function(resolve, reject) { - reader.onload = function() { - resolve(reader.result); - }; - reader.onerror = function() { - reject(reader.error); - }; - }) - } - - function readBlobAsArrayBuffer(blob) { - var reader = new FileReader(); - var promise = fileReaderReady(reader); - reader.readAsArrayBuffer(blob); - return promise - } - - function readBlobAsText(blob) { - var reader = new FileReader(); - var promise = fileReaderReady(reader); - var match = /charset=([A-Za-z0-9_-]+)/.exec(blob.type); - var encoding = match ? match[1] : 'utf-8'; - reader.readAsText(blob, encoding); - return promise - } - - function readArrayBufferAsText(buf) { - var view = new Uint8Array(buf); - var chars = new Array(view.length); - - for (var i = 0; i < view.length; i++) { - chars[i] = String.fromCharCode(view[i]); - } - return chars.join('') - } - - function bufferClone(buf) { - if (buf.slice) { - return buf.slice(0) - } else { - var view = new Uint8Array(buf.byteLength); - view.set(new Uint8Array(buf)); - return view.buffer - } - } - - function Body() { - this.bodyUsed = false; - - this._initBody = function(body) { - /* - fetch-mock wraps the Response object in an ES6 Proxy to - provide useful test harness features such as flush. However, on - ES5 browsers without fetch or Proxy support pollyfills must be used; - the proxy-pollyfill is unable to proxy an attribute unless it exists - on the object before the Proxy is created. This change ensures - Response.bodyUsed exists on the instance, while maintaining the - semantic of setting Request.bodyUsed in the constructor before - _initBody is called. - */ - // eslint-disable-next-line no-self-assign - this.bodyUsed = this.bodyUsed; - this._bodyInit = body; - if (!body) { - this._noBody = true; - this._bodyText = ''; - } else if (typeof body === 'string') { - this._bodyText = body; - } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { - this._bodyBlob = body; - } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { - this._bodyFormData = body; - } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { - this._bodyText = body.toString(); - } else if (support.arrayBuffer && support.blob && isDataView(body)) { - this._bodyArrayBuffer = bufferClone(body.buffer); - // IE 10-11 can't handle a DataView body. - this._bodyInit = new Blob([this._bodyArrayBuffer]); - } else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) { - this._bodyArrayBuffer = bufferClone(body); - } else { - this._bodyText = body = Object.prototype.toString.call(body); - } - - if (!this.headers.get('content-type')) { - if (typeof body === 'string') { - this.headers.set('content-type', 'text/plain;charset=UTF-8'); - } else if (this._bodyBlob && this._bodyBlob.type) { - this.headers.set('content-type', this._bodyBlob.type); - } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { - this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8'); - } - } - }; - - if (support.blob) { - this.blob = function() { - var rejected = consumed(this); - if (rejected) { - return rejected - } - - if (this._bodyBlob) { - return Promise.resolve(this._bodyBlob) - } else if (this._bodyArrayBuffer) { - return Promise.resolve(new Blob([this._bodyArrayBuffer])) - } else if (this._bodyFormData) { - throw new Error('could not read FormData body as blob') - } else { - return Promise.resolve(new Blob([this._bodyText])) - } - }; - } - - this.arrayBuffer = function() { - if (this._bodyArrayBuffer) { - var isConsumed = consumed(this); - if (isConsumed) { - return isConsumed - } else if (ArrayBuffer.isView(this._bodyArrayBuffer)) { - return Promise.resolve( - this._bodyArrayBuffer.buffer.slice( - this._bodyArrayBuffer.byteOffset, - this._bodyArrayBuffer.byteOffset + this._bodyArrayBuffer.byteLength - ) - ) - } else { - return Promise.resolve(this._bodyArrayBuffer) - } - } else if (support.blob) { - return this.blob().then(readBlobAsArrayBuffer) - } else { - throw new Error('could not read as ArrayBuffer') - } - }; - - this.text = function() { - var rejected = consumed(this); - if (rejected) { - return rejected - } - - if (this._bodyBlob) { - return readBlobAsText(this._bodyBlob) - } else if (this._bodyArrayBuffer) { - return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer)) - } else if (this._bodyFormData) { - throw new Error('could not read FormData body as text') - } else { - return Promise.resolve(this._bodyText) - } - }; - - if (support.formData) { - this.formData = function() { - return this.text().then(decode) - }; - } - - this.json = function() { - return this.text().then(JSON.parse) - }; - - return this - } - - // HTTP methods whose capitalization should be normalized - var methods = ['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE']; - - function normalizeMethod(method) { - var upcased = method.toUpperCase(); - return methods.indexOf(upcased) > -1 ? upcased : method - } - - function Request(input, options) { - if (!(this instanceof Request)) { - throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.') - } - - options = options || {}; - var body = options.body; - - if (input instanceof Request) { - if (input.bodyUsed) { - throw new TypeError('Already read') - } - this.url = input.url; - this.credentials = input.credentials; - if (!options.headers) { - this.headers = new Headers(input.headers); - } - this.method = input.method; - this.mode = input.mode; - this.signal = input.signal; - if (!body && input._bodyInit != null) { - body = input._bodyInit; - input.bodyUsed = true; - } - } else { - this.url = String(input); - } - - this.credentials = options.credentials || this.credentials || 'same-origin'; - if (options.headers || !this.headers) { - this.headers = new Headers(options.headers); - } - this.method = normalizeMethod(options.method || this.method || 'GET'); - this.mode = options.mode || this.mode || null; - this.signal = options.signal || this.signal || (function () { - if ('AbortController' in g) { - var ctrl = new AbortController(); - return ctrl.signal; - } - }()); - this.referrer = null; - - if ((this.method === 'GET' || this.method === 'HEAD') && body) { - throw new TypeError('Body not allowed for GET or HEAD requests') - } - this._initBody(body); - - if (this.method === 'GET' || this.method === 'HEAD') { - if (options.cache === 'no-store' || options.cache === 'no-cache') { - // Search for a '_' parameter in the query string - var reParamSearch = /([?&])_=[^&]*/; - if (reParamSearch.test(this.url)) { - // If it already exists then set the value with the current time - this.url = this.url.replace(reParamSearch, '$1_=' + new Date().getTime()); - } else { - // Otherwise add a new '_' parameter to the end with the current time - var reQueryString = /\?/; - this.url += (reQueryString.test(this.url) ? '&' : '?') + '_=' + new Date().getTime(); - } - } - } - } - - Request.prototype.clone = function() { - return new Request(this, {body: this._bodyInit}) - }; - - function decode(body) { - var form = new FormData(); - body - .trim() - .split('&') - .forEach(function(bytes) { - if (bytes) { - var split = bytes.split('='); - var name = split.shift().replace(/\+/g, ' '); - var value = split.join('=').replace(/\+/g, ' '); - form.append(decodeURIComponent(name), decodeURIComponent(value)); - } - }); - return form - } - - function parseHeaders(rawHeaders) { - var headers = new Headers(); - // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space - // https://tools.ietf.org/html/rfc7230#section-3.2 - var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' '); - // Avoiding split via regex to work around a common IE11 bug with the core-js 3.6.0 regex polyfill - // https://github.com/github/fetch/issues/748 - // https://github.com/zloirock/core-js/issues/751 - preProcessedHeaders - .split('\r') - .map(function(header) { - return header.indexOf('\n') === 0 ? header.substr(1, header.length) : header - }) - .forEach(function(line) { - var parts = line.split(':'); - var key = parts.shift().trim(); - if (key) { - var value = parts.join(':').trim(); - try { - headers.append(key, value); - } catch (error) { - console.warn('Response ' + error.message); - } - } - }); - return headers - } - - Body.call(Request.prototype); - - function Response(bodyInit, options) { - if (!(this instanceof Response)) { - throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.') - } - if (!options) { - options = {}; - } - - this.type = 'default'; - this.status = options.status === undefined ? 200 : options.status; - if (this.status < 200 || this.status > 599) { - throw new RangeError("Failed to construct 'Response': The status provided (0) is outside the range [200, 599].") - } - this.ok = this.status >= 200 && this.status < 300; - this.statusText = options.statusText === undefined ? '' : '' + options.statusText; - this.headers = new Headers(options.headers); - this.url = options.url || ''; - this._initBody(bodyInit); - } - - Body.call(Response.prototype); - - Response.prototype.clone = function() { - return new Response(this._bodyInit, { - status: this.status, - statusText: this.statusText, - headers: new Headers(this.headers), - url: this.url - }) - }; - - Response.error = function() { - var response = new Response(null, {status: 200, statusText: ''}); - response.ok = false; - response.status = 0; - response.type = 'error'; - return response - }; - - var redirectStatuses = [301, 302, 303, 307, 308]; - - Response.redirect = function(url, status) { - if (redirectStatuses.indexOf(status) === -1) { - throw new RangeError('Invalid status code') - } - - return new Response(null, {status: status, headers: {location: url}}) - }; - - exports.DOMException = g.DOMException; - try { - new exports.DOMException(); - } catch (err) { - exports.DOMException = function(message, name) { - this.message = message; - this.name = name; - var error = Error(message); - this.stack = error.stack; - }; - exports.DOMException.prototype = Object.create(Error.prototype); - exports.DOMException.prototype.constructor = exports.DOMException; - } - - function fetch(input, init) { - return new Promise(function(resolve, reject) { - var request = new Request(input, init); - - if (request.signal && request.signal.aborted) { - return reject(new exports.DOMException('Aborted', 'AbortError')) - } - - var xhr = new XMLHttpRequest(); - - function abortXhr() { - xhr.abort(); - } - - xhr.onload = function() { - var options = { - statusText: xhr.statusText, - headers: parseHeaders(xhr.getAllResponseHeaders() || '') - }; - // This check if specifically for when a user fetches a file locally from the file system - // Only if the status is out of a normal range - if (request.url.indexOf('file://') === 0 && (xhr.status < 200 || xhr.status > 599)) { - options.status = 200; - } else { - options.status = xhr.status; - } - options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL'); - var body = 'response' in xhr ? xhr.response : xhr.responseText; - setTimeout(function() { - resolve(new Response(body, options)); - }, 0); - }; - - xhr.onerror = function() { - setTimeout(function() { - reject(new TypeError('Network request failed')); - }, 0); - }; - - xhr.ontimeout = function() { - setTimeout(function() { - reject(new TypeError('Network request timed out')); - }, 0); - }; - - xhr.onabort = function() { - setTimeout(function() { - reject(new exports.DOMException('Aborted', 'AbortError')); - }, 0); - }; - - function fixUrl(url) { - try { - return url === '' && g.location.href ? g.location.href : url - } catch (e) { - return url - } - } - - xhr.open(request.method, fixUrl(request.url), true); - - if (request.credentials === 'include') { - xhr.withCredentials = true; - } else if (request.credentials === 'omit') { - xhr.withCredentials = false; - } - - if ('responseType' in xhr) { - if (support.blob) { - xhr.responseType = 'blob'; - } else if ( - support.arrayBuffer - ) { - xhr.responseType = 'arraybuffer'; - } - } - - if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers || (g.Headers && init.headers instanceof g.Headers))) { - var names = []; - Object.getOwnPropertyNames(init.headers).forEach(function(name) { - names.push(normalizeName(name)); - xhr.setRequestHeader(name, normalizeValue(init.headers[name])); - }); - request.headers.forEach(function(value, name) { - if (names.indexOf(name) === -1) { - xhr.setRequestHeader(name, value); - } - }); - } else { - request.headers.forEach(function(value, name) { - xhr.setRequestHeader(name, value); - }); - } - - if (request.signal) { - request.signal.addEventListener('abort', abortXhr); - - xhr.onreadystatechange = function() { - // DONE (success or failure) - if (xhr.readyState === 4) { - request.signal.removeEventListener('abort', abortXhr); - } - }; - } - - xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit); - }) - } - - fetch.polyfill = true; - - if (!g.fetch) { - g.fetch = fetch; - g.Headers = Headers; - g.Request = Request; - g.Response = Response; - } - - exports.Headers = Headers; - exports.Request = Request; - exports.Response = Response; - exports.fetch = fetch; - - Object.defineProperty(exports, '__esModule', { value: true }); - -}))); diff --git a/src/browser/polyfill/fetch.zig b/src/browser/polyfill/fetch.zig deleted file mode 100644 index 9ed134de7..000000000 --- a/src/browser/polyfill/fetch.zig +++ /dev/null @@ -1,31 +0,0 @@ -// fetch.js code comes from -// https://github.com/JakeChampion/fetch/blob/main/fetch.js -// -// The original code source is available in MIT license. -// -// The script comes from the built version from npm. -// You can get the package with the command: -// -// wget $(npm view whatwg-fetch dist.tarball) -// -// The source is the content of `package/dist/fetch.umd.js` file. -pub const source = @embedFile("fetch.js"); - -const testing = @import("../../testing.zig"); -test "Browser.fetch" { - var runner = try testing.jsRunner(testing.tracking_allocator, .{}); - defer runner.deinit(); - - try runner.testCases(&.{ - .{ - \\ var ok = false; - \\ const request = new Request("http://127.0.0.1:9582/loader"); - \\ fetch(request).then((response) => { ok = response.ok; }); - \\ false; - , - "false", - }, - // all events have been resolved. - .{ "ok", "true" }, - }, .{}); -} diff --git a/src/browser/polyfill/polyfill.zig b/src/browser/polyfill/polyfill.zig index 59822c62e..617bdf862 100644 --- a/src/browser/polyfill/polyfill.zig +++ b/src/browser/polyfill/polyfill.zig @@ -27,7 +27,6 @@ pub const Loader = struct { state: enum { empty, loading } = .empty, done: struct { - fetch: bool = false, webcomponents: bool = false, } = .{}, @@ -56,18 +55,6 @@ pub const Loader = struct { return false; } - if (!self.done.fetch and isFetch(name)) { - const source = @import("fetch.zig").source; - self.load("fetch", source, js_context); - - // We return false here: We want v8 to continue the calling chain - // to finally find the polyfill we just inserted. If we want to - // return false and stops the call chain, we have to use - // `info.GetReturnValue.Set()` function, or `undefined` will be - // returned immediately. - return false; - } - if (!self.done.webcomponents and isWebcomponents(name)) { const source = @import("webcomponents.zig").source; self.load("webcomponents", source, js_context); @@ -89,14 +76,6 @@ pub const Loader = struct { return false; } - fn isFetch(name: []const u8) bool { - if (std.mem.eql(u8, name, "fetch")) return true; - if (std.mem.eql(u8, name, "Request")) return true; - if (std.mem.eql(u8, name, "Response")) return true; - if (std.mem.eql(u8, name, "Headers")) return true; - return false; - } - fn isWebcomponents(name: []const u8) bool { if (std.mem.eql(u8, name, "customElements")) return true; return false; diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig new file mode 100644 index 000000000..8857c7b31 --- /dev/null +++ b/src/browser/streams/ReadableStream.zig @@ -0,0 +1,174 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); + +const Page = @import("../page.zig").Page; +const Env = @import("../env.zig").Env; + +const ReadableStream = @This(); +const ReadableStreamDefaultReader = @import("ReadableStreamDefaultReader.zig"); +const ReadableStreamDefaultController = @import("ReadableStreamDefaultController.zig"); + +const State = union(enum) { + readable, + closed: ?[]const u8, + cancelled: ?[]const u8, + errored: Env.JsObject, +}; + +// This promise resolves when a stream is canceled. +cancel_resolver: Env.PersistentPromiseResolver, +closed_resolver: Env.PersistentPromiseResolver, +reader_resolver: ?Env.PersistentPromiseResolver = null, + +locked: bool = false, +state: State = .readable, + +cancel_fn: ?Env.Function = null, +pull_fn: ?Env.Function = null, + +strategy: QueueingStrategy, +queue: std.ArrayListUnmanaged([]const u8) = .empty, + +pub const ReadableStreamReadResult = struct { + const ValueUnion = + union(enum) { data: []const u8, empty: void }; + + value: ValueUnion, + done: bool, + + pub fn get_value(self: *const ReadableStreamReadResult) ValueUnion { + return self.value; + } + + pub fn get_done(self: *const ReadableStreamReadResult) bool { + return self.done; + } +}; + +const UnderlyingSource = struct { + start: ?Env.Function = null, + pull: ?Env.Function = null, + cancel: ?Env.Function = null, + type: ?[]const u8 = null, +}; + +const QueueingStrategy = struct { + size: ?Env.Function = null, + high_water_mark: u32 = 1, +}; + +pub fn constructor(underlying: ?UnderlyingSource, _strategy: ?QueueingStrategy, page: *Page) !*ReadableStream { + const strategy: QueueingStrategy = _strategy orelse .{}; + + const cancel_resolver = try page.main_context.createPersistentPromiseResolver(); + const closed_resolver = try page.main_context.createPersistentPromiseResolver(); + + const stream = try page.arena.create(ReadableStream); + stream.* = ReadableStream{ .cancel_resolver = cancel_resolver, .closed_resolver = closed_resolver, .strategy = strategy }; + + const controller = ReadableStreamDefaultController{ .stream = stream }; + + // call start + if (underlying) |src| { + if (src.start) |start| { + try start.call(void, .{controller}); + } + + if (src.cancel) |cancel| { + stream.cancel_fn = cancel; + } + + if (src.pull) |pull| { + stream.pull_fn = pull; + try stream.pullIf(); + } + } + + return stream; +} + +pub fn destructor(self: *ReadableStream) void { + if (self.reader_resolver) |*rr| { + rr.deinit(); + } +} + +pub fn get_locked(self: *const ReadableStream) bool { + return self.locked; +} + +pub fn _cancel(self: *ReadableStream, reason: ?[]const u8, page: *Page) !Env.Promise { + if (self.locked) { + return error.TypeError; + } + + self.state = .{ .cancelled = if (reason) |r| try page.arena.dupe(u8, r) else null }; + + // Call cancel callback. + if (self.cancel_fn) |cancel| { + if (reason) |r| { + try cancel.call(void, .{r}); + } else { + try cancel.call(void, .{}); + } + } + + try self.cancel_resolver.resolve({}); + return self.cancel_resolver.promise(); +} + +pub fn pullIf(self: *ReadableStream) !void { + if (self.pull_fn) |pull_fn| { + // Must be under the high water mark AND readable. + if ((self.queue.items.len < self.strategy.high_water_mark) and self.state == .readable) { + const controller = ReadableStreamDefaultController{ .stream = self }; + try pull_fn.call(void, .{controller}); + } + } +} + +const GetReaderOptions = struct { + // Mode must equal 'byob' or be undefined. RangeError otherwise. + mode: ?[]const u8 = null, +}; + +pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions) !ReadableStreamDefaultReader { + if (self.locked) { + return error.TypeError; + } + + // TODO: Determine if we need the ReadableStreamBYOBReader + const options = _options orelse GetReaderOptions{}; + _ = options; + + return ReadableStreamDefaultReader.constructor(self); +} + +// TODO: pipeThrough (requires TransformStream) + +// TODO: pipeTo (requires WritableStream) + +// TODO: tee + +const testing = @import("../../testing.zig"); +test "streams: ReadableStream" { + try testing.htmlRunner("streams/readable_stream.html"); +} diff --git a/src/browser/streams/ReadableStreamDefaultController.zig b/src/browser/streams/ReadableStreamDefaultController.zig new file mode 100644 index 000000000..c83a13a57 --- /dev/null +++ b/src/browser/streams/ReadableStreamDefaultController.zig @@ -0,0 +1,82 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); + +const Page = @import("../page.zig").Page; +const Env = @import("../env.zig").Env; + +const ReadableStream = @import("./ReadableStream.zig"); +const ReadableStreamReadResult = @import("./ReadableStream.zig").ReadableStreamReadResult; + +const ReadableStreamDefaultController = @This(); + +stream: *ReadableStream, + +pub fn get_desiredSize(self: *const ReadableStreamDefaultController) i32 { + // TODO: This may need tuning at some point if it becomes a performance issue. + return @intCast(self.stream.queue.capacity - self.stream.queue.items.len); +} + +pub fn _close(self: *ReadableStreamDefaultController, _reason: ?[]const u8, page: *Page) !void { + const reason = if (_reason) |reason| try page.arena.dupe(u8, reason) else null; + self.stream.state = .{ .closed = reason }; + + // Resolve the Reader Promise + if (self.stream.reader_resolver) |*rr| { + defer rr.deinit(); + try rr.resolve(ReadableStreamReadResult{ .value = .empty, .done = true }); + self.stream.reader_resolver = null; + } + + // Resolve the Closed promise. + try self.stream.closed_resolver.resolve({}); + + // close just sets as closed meaning it wont READ any more but anything in the queue is fine to read. + // to discard, must use cancel. +} + +pub fn _enqueue(self: *ReadableStreamDefaultController, chunk: []const u8, page: *Page) !void { + const stream = self.stream; + + if (stream.state != .readable) { + return error.TypeError; + } + + const duped_chunk = try page.arena.dupe(u8, chunk); + + if (self.stream.reader_resolver) |*rr| { + defer rr.deinit(); + try rr.resolve(ReadableStreamReadResult{ .value = .{ .data = duped_chunk }, .done = false }); + self.stream.reader_resolver = null; + } + + try self.stream.queue.append(page.arena, duped_chunk); + try self.stream.pullIf(); +} + +pub fn _error(self: *ReadableStreamDefaultController, err: Env.JsObject) !void { + self.stream.state = .{ .errored = err }; + + if (self.stream.reader_resolver) |*rr| { + defer rr.deinit(); + try rr.reject(err); + self.stream.reader_resolver = null; + } +} diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig new file mode 100644 index 000000000..bf40e4dfa --- /dev/null +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -0,0 +1,96 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const log = @import("../../log.zig"); +const Env = @import("../env.zig").Env; +const Page = @import("../page.zig").Page; +const ReadableStream = @import("./ReadableStream.zig"); +const ReadableStreamReadResult = @import("./ReadableStream.zig").ReadableStreamReadResult; + +const ReadableStreamDefaultReader = @This(); + +stream: *ReadableStream, + +pub fn constructor(stream: *ReadableStream) ReadableStreamDefaultReader { + return .{ .stream = stream }; +} + +pub fn get_closed(self: *const ReadableStreamDefaultReader) Env.Promise { + return self.stream.closed_resolver.promise(); +} + +pub fn _cancel(self: *ReadableStreamDefaultReader, reason: ?[]const u8, page: *Page) !Env.Promise { + return try self.stream._cancel(reason, page); +} + +pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise { + const stream = self.stream; + + switch (stream.state) { + .readable => { + if (stream.queue.items.len > 0) { + const data = self.stream.queue.orderedRemove(0); + const resolver = page.main_context.createPromiseResolver(); + + try resolver.resolve(ReadableStreamReadResult{ .value = .{ .data = data }, .done = false }); + try self.stream.pullIf(); + return resolver.promise(); + } else { + if (self.stream.reader_resolver) |rr| { + return rr.promise(); + } else { + const persistent_resolver = try page.main_context.createPersistentPromiseResolver(); + self.stream.reader_resolver = persistent_resolver; + return persistent_resolver.promise(); + } + } + }, + .closed => |_| { + const resolver = page.main_context.createPromiseResolver(); + + if (stream.queue.items.len > 0) { + const data = self.stream.queue.orderedRemove(0); + try resolver.resolve(ReadableStreamReadResult{ .value = .{ .data = data }, .done = false }); + } else { + try resolver.resolve(ReadableStreamReadResult{ .value = .empty, .done = true }); + } + + return resolver.promise(); + }, + .cancelled => |_| { + const resolver = page.main_context.createPromiseResolver(); + try resolver.resolve(ReadableStreamReadResult{ .value = .empty, .done = true }); + return resolver.promise(); + }, + .errored => |err| { + const resolver = page.main_context.createPromiseResolver(); + try resolver.reject(err); + return resolver.promise(); + }, + } +} + +pub fn _releaseLock(self: *const ReadableStreamDefaultReader) !void { + self.stream.locked = false; + + if (self.stream.reader_resolver) |rr| { + try rr.reject("TypeError"); + } +} diff --git a/src/browser/streams/streams.zig b/src/browser/streams/streams.zig new file mode 100644 index 000000000..f345640fb --- /dev/null +++ b/src/browser/streams/streams.zig @@ -0,0 +1,24 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pub const Interfaces = .{ + @import("ReadableStream.zig"), + @import("ReadableStream.zig").ReadableStreamReadResult, + @import("ReadableStreamDefaultReader.zig"), + @import("ReadableStreamDefaultController.zig"), +}; diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index ea7d225ab..f6fb302b9 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -200,6 +200,7 @@ pub fn requestIntercept(arena: Allocator, bc: anytype, intercept: *const Notific .script => "Script", .xhr => "XHR", .document => "Document", + .fetch => "Fetch", }, .networkId = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}), }, .{ .session_id = session_id }); @@ -405,6 +406,7 @@ pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Noti .script => "Script", .xhr => "XHR", .document => "Document", + .fetch => "Fetch", }, .authChallenge = .{ .source = if (challenge.source == .server) "Server" else "Proxy", diff --git a/src/http/Client.zig b/src/http/Client.zig index 11e8512dc..07a33390b 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -649,6 +649,7 @@ pub const Request = struct { document, xhr, script, + fetch, }; }; diff --git a/src/http/Http.zig b/src/http/Http.zig index 21b884693..949503b87 100644 --- a/src/http/Http.zig +++ b/src/http/Http.zig @@ -339,13 +339,13 @@ pub const Opts = struct { proxy_bearer_token: ?[:0]const u8 = null, }; -pub const Method = enum { - GET, - PUT, - POST, - DELETE, - HEAD, - OPTIONS, +pub const Method = enum(u8) { + GET = 0, + PUT = 1, + POST = 2, + DELETE = 3, + HEAD = 4, + OPTIONS = 5, }; // TODO: on BSD / Linux, we could just read the PEM file directly. diff --git a/src/log.zig b/src/log.zig index 24ae9ff89..d6e429fe3 100644 --- a/src/log.zig +++ b/src/log.zig @@ -39,6 +39,7 @@ pub const Scope = enum { unknown_prop, web_api, xhr, + fetch, polyfill, mouse_event, }; diff --git a/src/runtime/js.zig b/src/runtime/js.zig index dbbc26a21..a486fc35a 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -677,6 +677,9 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { // we now simply persist every time persist() is called. js_object_list: std.ArrayListUnmanaged(PersistentObject) = .empty, + + persisted_promise_resolvers: std.ArrayListUnmanaged(v8.Persistent(v8.PromiseResolver)) = .empty, + // When we need to load a resource (i.e. an external script), we call // this function to get the source. This is always a reference to the // Page's fetchModuleSource, but we use a function pointer @@ -733,6 +736,10 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { p.deinit(); } + for (self.persisted_promise_resolvers.items) |*p| { + p.deinit(); + } + { var it = self.module_cache.valueIterator(); while (it.next()) |p| { @@ -1130,6 +1137,16 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }, else => {}, }, + .array => |arr| { + // Retrieve fixed-size array as slice + const slice_type = []arr.child; + const slice_value = try self.jsValueToZig(named_function, slice_type, js_value); + if (slice_value.len != arr.len) { + // Exact length match, we could allow smaller arrays, but we would not be able to communicate how many were written + return error.InvalidArgument; + } + return @as(*T, @ptrCast(slice_value.ptr)).*; + }, .@"struct" => { return try (self.jsValueToStruct(named_function, T, js_value)) orelse { return error.InvalidArgument; @@ -1251,6 +1268,15 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }; } + pub fn createPersistentPromiseResolver(self: *JsContext) !PersistentPromiseResolver { + const resolver = v8.Persistent(v8.PromiseResolver).init(self.isolate, v8.PromiseResolver.init(self.v8_context)); + try self.persisted_promise_resolvers.append(self.context_arena, resolver); + return .{ + .js_context = self, + .resolver = resolver, + }; + } + // Probing is part of trying to map a JS value to a Zig union. There's // a lot of ambiguity in this process, in part because some JS values // can almost always be coerced. For example, anything can be coerced @@ -1413,6 +1439,36 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }, else => {}, }, + .array => |arr| { + // Retrieve fixed-size array as slice then probe + const slice_type = []arr.child; + switch (try self.probeJsValueToZig(named_function, slice_type, js_value)) { + .value => |slice_value| { + if (slice_value.len == arr.len) { + return .{ .value = @as(*T, @ptrCast(slice_value.ptr)).* }; + } + return .{ .invalid = {} }; + }, + .ok => { + // Exact length match, we could allow smaller arrays as .compatible, but we would not be able to communicate how many were written + if (js_value.isArray()) { + const js_arr = js_value.castTo(v8.Array); + if (js_arr.length() == arr.len) { + return .{ .ok = {} }; + } + } else if (js_value.isString() and arr.child == u8) { + const str = try js_value.toString(self.v8_context); + if (str.lenUtf8(self.isolate) == arr.len) { + return .{ .ok = {} }; + } + } + return .{ .invalid = {} }; + }, + .compatible => return .{ .compatible = {} }, + .coerce => return .{ .coerce = {} }, + .invalid => return .{ .invalid = {} }, + } + }, .@"struct" => { // We don't want to duplicate the code for this, so we call // the actual conversion function. @@ -2178,6 +2234,54 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { return error.FailedToResolvePromise; } } + + pub fn reject(self: PromiseResolver, value: anytype) !void { + const js_context = self.js_context; + const js_value = try js_context.zigValueToJs(value); + + // resolver.reject will return null if the promise isn't pending + const ok = self.resolver.reject(js_context.v8_context, js_value) orelse return; + if (!ok) { + return error.FailedToRejectPromise; + } + } + }; + + pub const PersistentPromiseResolver = struct { + js_context: *JsContext, + resolver: v8.Persistent(v8.PromiseResolver), + + pub fn deinit(self: *PersistentPromiseResolver) void { + self.resolver.deinit(); + } + + pub fn promise(self: PersistentPromiseResolver) Promise { + return .{ + .promise = self.resolver.castToPromiseResolver().getPromise(), + }; + } + + pub fn resolve(self: PersistentPromiseResolver, value: anytype) !void { + const js_context = self.js_context; + const js_value = try js_context.zigValueToJs(value); + + // resolver.resolve will return null if the promise isn't pending + const ok = self.resolver.castToPromiseResolver().resolve(js_context.v8_context, js_value) orelse return; + if (!ok) { + return error.FailedToResolvePromise; + } + } + + pub fn reject(self: PersistentPromiseResolver, value: anytype) !void { + const js_context = self.js_context; + const js_value = try js_context.zigValueToJs(value); + + // resolver.reject will return null if the promise isn't pending + const ok = self.resolver.castToPromiseResolver().reject(js_context.v8_context, js_value) orelse return; + if (!ok) { + return error.FailedToRejectPromise; + } + } }; pub const Promise = struct { diff --git a/src/tests/fetch/fetch.html b/src/tests/fetch/fetch.html new file mode 100644 index 000000000..8b4019d49 --- /dev/null +++ b/src/tests/fetch/fetch.html @@ -0,0 +1,16 @@ + + diff --git a/src/tests/fetch/headers.html b/src/tests/fetch/headers.html new file mode 100644 index 000000000..57d6ce2ee --- /dev/null +++ b/src/tests/fetch/headers.html @@ -0,0 +1,102 @@ + + + + + + + + + diff --git a/src/tests/fetch/request.html b/src/tests/fetch/request.html new file mode 100644 index 000000000..7bfdfe56e --- /dev/null +++ b/src/tests/fetch/request.html @@ -0,0 +1,22 @@ + + + diff --git a/src/tests/fetch/response.html b/src/tests/fetch/response.html new file mode 100644 index 000000000..01b4c8e72 --- /dev/null +++ b/src/tests/fetch/response.html @@ -0,0 +1,49 @@ + + + + + diff --git a/src/tests/html/html_slot_element.html b/src/tests/html/html_slot_element.html new file mode 100644 index 000000000..058b67853 --- /dev/null +++ b/src/tests/html/html_slot_element.html @@ -0,0 +1,66 @@ + + + + + +default +

default

+

default

xx other
+More

default2

!!
+ + diff --git a/src/tests/streams/readable_stream.html b/src/tests/streams/readable_stream.html new file mode 100644 index 000000000..a8d71d666 --- /dev/null +++ b/src/tests/streams/readable_stream.html @@ -0,0 +1,117 @@ + + + + + + + + + + +