initial fetch in zig

This commit is contained in:
Muki Kiboigo
2025-08-22 06:46:09 -07:00
parent 56c6e8be06
commit df0b6d5b07
6 changed files with 284 additions and 16 deletions

View File

@@ -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"),
});
};

View File

@@ -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" },
}, .{});
}

View File

@@ -0,0 +1,64 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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(&.{}, .{});
}

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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 {