diff --git a/src/browser/env.zig b/src/browser/env.zig index 24b06ec7..861e3c0c 100644 --- a/src/browser/env.zig +++ b/src/browser/env.zig @@ -36,8 +36,9 @@ const WebApis = struct { @import("xhr/form_data.zig").Interfaces, @import("xhr/File.zig"), @import("xmlserializer/xmlserializer.zig").Interfaces, - @import("fetch/Request.zig"), @import("fetch/Headers.zig"), + @import("fetch/Request.zig"), + @import("fetch/Response.zig"), }); }; diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 90f3d0ad..e4228662 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -20,18 +20,36 @@ const std = @import("std"); const URL = @import("../../url.zig").URL; const Page = @import("../page.zig").Page; -// https://developer.mozilla.org/en-US/docs/Web/API/Request/Request -const Request = @This(); +const Response = @import("./Response.zig"); -url: []const u8, +const Http = @import("../../http/Http.zig"); +const HttpClient = @import("../../http/Client.zig"); +const Mime = @import("../mime.zig").Mime; -const RequestInput = union(enum) { +const v8 = @import("v8"); +const Env = @import("../env.zig").Env; + +pub const RequestInput = union(enum) { string: []const u8, request: Request, }; -pub fn constructor(input: RequestInput, page: *Page) !Request { +// https://developer.mozilla.org/en-US/docs/Web/API/RequestInit +pub const RequestInit = struct { + method: []const u8 = "GET", + body: []const u8 = "", +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/Request/Request +const Request = @This(); + +method: Http.Method, +url: []const u8, +body: []const u8, + +pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Request { const arena = page.arena; + const options: RequestInit = _options orelse .{}; const url = blk: switch (input) { .string => |str| { @@ -42,13 +60,146 @@ pub fn constructor(input: RequestInput, page: *Page) !Request { }, }; + const method: Http.Method = blk: for (std.enums.values(Http.Method)) |method| { + if (std.ascii.eqlIgnoreCase(options.method, @tagName(method))) { + break :blk method; + } + } else { + return error.InvalidMethod; + }; + + const body = try arena.dupe(u8, options.body); + return .{ + .method = method, .url = url, + .body = body, }; } -pub fn get_url(self: *const Request, page: *Page) ![]const u8 { - return try page.arena.dupe(u8, self.url); +pub fn get_url(self: *const Request) []const u8 { + return self.url; +} + +pub fn get_method(self: *const Request) []const u8 { + return @tagName(self.method); +} + +pub fn get_body(self: *const Request) []const u8 { + return self.body; +} + +const FetchContext = struct { + arena: std.mem.Allocator, + js_ctx: *Env.JsContext, + promise_resolver: v8.Persistent(v8.PromiseResolver), + + 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: FetchContext) !Response { + return Response{ + .status = self.status, + .headers = self.headers.items, + .mime = self.mime, + .body = self.body.items, + }; + } +}; + +// 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); + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + const client = page.http_client; + const headers = try HttpClient.Headers.init(); + + const fetch_ctx = try arena.create(FetchContext); + fetch_ctx.* = .{ + .arena = page.arena, + .js_ctx = page.main_context, + .promise_resolver = v8.Persistent(v8.PromiseResolver).init(page.main_context.isolate, resolver.resolver), + }; + + try client.request(.{ + .method = req.method, + .url = try arena.dupeZ(u8, req.url), + .headers = headers, + .body = req.body, + .cookie_jar = page.cookie_jar, + .ctx = @ptrCast(fetch_ctx), + + .start_callback = struct { + fn startCallback(transfer: *HttpClient.Transfer) !void { + const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); + self.transfer = transfer; + } + }.startCallback, + .header_callback = struct { + fn headerCallback(transfer: *HttpClient.Transfer, header: []const u8) !void { + const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); + try self.headers.append(self.arena, try self.arena.dupe(u8, header)); + } + }.headerCallback, + .header_done_callback = struct { + fn headerDoneCallback(transfer: *HttpClient.Transfer) !void { + const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); + const header = &transfer.response_header.?; + + if (header.contentType()) |ct| { + self.mime = Mime.parse(ct) catch { + return error.Todo; + }; + } + + self.status = header.status; + } + }.headerDoneCallback, + .data_callback = struct { + fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { + const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); + try self.body.appendSlice(self.arena, data); + } + }.dataCallback, + .done_callback = struct { + fn doneCallback(ctx: *anyopaque) !void { + const self: *FetchContext = @alignCast(@ptrCast(ctx)); + const response = try self.toResponse(); + const promise_resolver: Env.PromiseResolver = .{ + .js_context = self.js_ctx, + .resolver = self.promise_resolver.castToPromiseResolver(), + }; + + try promise_resolver.resolve(response); + } + }.doneCallback, + .error_callback = struct { + fn errorCallback(ctx: *anyopaque, err: anyerror) void { + const self: *FetchContext = @alignCast(@ptrCast(ctx)); + const promise_resolver: Env.PromiseResolver = .{ + .js_context = self.js_ctx, + .resolver = self.promise_resolver.castToPromiseResolver(), + }; + + promise_resolver.reject(@errorName(err)) catch unreachable; + } + }.errorCallback, + }); + + return resolver.promise(); } const testing = @import("../../testing.zig"); @@ -59,10 +210,44 @@ test "fetch: request" { try runner.testCases(&.{ .{ "let request = new Request('flower.png')", "undefined" }, .{ "request.url", "https://lightpanda.io/flower.png" }, + .{ "request.method", "GET" }, }, .{}); try runner.testCases(&.{ - .{ "let request2 = new Request('https://google.com')", "undefined" }, + .{ "let request2 = new Request('https://google.com', { method: 'POST', body: 'Hello, World' })", "undefined" }, .{ "request2.url", "https://google.com" }, + .{ "request2.method", "POST" }, + .{ "request2.body", "Hello, World" }, + }, .{}); +} + +test "fetch: 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" }, + }, .{}); + + try runner.testCases(&.{ + .{ + \\ var ok2 = false; + \\ const request2 = new Request("http://127.0.0.1:9582/loader"); + \\ (async function () { resp = await fetch(request2); ok2 = resp.ok; }()); + \\ false; + , + "false", + }, + // all events have been resolved. + .{ "ok2", "true" }, }, .{}); } diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index e69de29b..32da75c6 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -0,0 +1,64 @@ +// 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 URL = @import("../../url.zig").URL; +const Page = @import("../page.zig").Page; + +const Http = @import("../../http/Http.zig"); +const HttpClient = @import("../../http/Client.zig"); +const Mime = @import("../mime.zig").Mime; + +// https://developer.mozilla.org/en-US/docs/Web/API/Response +const Response = @This(); + +status: u16 = 0, +headers: []const []const u8, +mime: ?Mime = null, +body: []const u8, + +const ResponseInput = union(enum) { + string: []const u8, +}; + +pub fn constructor(input: ResponseInput, page: *Page) !Response { + const arena = page.arena; + + const body = blk: switch (input) { + .string => |str| { + break :blk try arena.dupe(u8, str); + }, + }; + + return .{ + .body = body, + .headers = &[_][]const u8{}, + }; +} + +pub fn get_ok(self: *const Response) bool { + return self.status >= 200 and self.status <= 299; +} + +const testing = @import("../../testing.zig"); +test "fetch: response" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io" }); + defer runner.deinit(); + + try runner.testCases(&.{}, .{}); +} diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index 6f1d5fe8..cebfc902 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -39,6 +39,9 @@ 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 storage = @import("../storage/storage.zig"); // https://dom.spec.whatwg.org/#interface-window-extensions @@ -95,6 +98,10 @@ pub const Window = struct { self.storage_shelf = shelf; } + pub fn _fetch(_: *Window, input: Request.RequestInput, options: ?Request.RequestInit, page: *Page) !Env.Promise { + return Request.fetch(input, options, page); + } + pub fn get_window(self: *Window) *Window { return self; } diff --git a/src/http/Http.zig b/src/http/Http.zig index 21b88469..949503b8 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/runtime/js.zig b/src/runtime/js.zig index dbbc26a2..151b1b8d 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -2178,6 +2178,17 @@ 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 Promise = struct {