From cab5117d854eca1dc0f87ec61871aec8fd1e33d4 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 18 Aug 2025 15:43:22 -0700 Subject: [PATCH 01/43] remove polyfill and add req/resp --- src/browser/env.zig | 2 + src/browser/fetch/Headers.zig | 88 ++++ src/browser/fetch/Request.zig | 68 +++ src/browser/fetch/Response.zig | 0 src/browser/polyfill/fetch.js | 671 ------------------------- src/browser/polyfill/fetch.zig | 31 -- src/browser/polyfill/polyfill.zig | 21 - src/browser/streams/ReadableStream.zig | 0 8 files changed, 158 insertions(+), 723 deletions(-) create mode 100644 src/browser/fetch/Headers.zig create mode 100644 src/browser/fetch/Request.zig create mode 100644 src/browser/fetch/Response.zig delete mode 100644 src/browser/polyfill/fetch.js delete mode 100644 src/browser/polyfill/fetch.zig create mode 100644 src/browser/streams/ReadableStream.zig diff --git a/src/browser/env.zig b/src/browser/env.zig index c6f0469c3..24b06ec74 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/Request.zig"), + @import("fetch/Headers.zig"), }); }; diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig new file mode 100644 index 000000000..71fe52ee8 --- /dev/null +++ b/src/browser/fetch/Headers.zig @@ -0,0 +1,88 @@ +// 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; + +// https://developer.mozilla.org/en-US/docs/Web/API/Headers +const Headers = @This(); + +headers: std.StringHashMapUnmanaged([]const u8), + +// They can either be: +// +// 1. An array of string pairs. +// 2. An object with string keys to string values. +// 3. Another Headers object. +const HeadersInit = union(enum) { + strings: []const []const u8, + // headers: Headers, +}; + +pub fn constructor(_init: ?[]const HeadersInit, page: *Page) !Headers { + const arena = page.arena; + var headers = std.StringHashMapUnmanaged([]const u8).empty; + + if (_init) |init| { + for (init) |item| { + switch (item) { + .strings => |pair| { + // Can only have two string elements if in a pair. + if (pair.len != 2) { + return error.TypeError; + } + + const raw_key = pair[0]; + const value = pair[1]; + const key = try std.ascii.allocLowerString(arena, raw_key); + + try headers.put(arena, key, value); + }, + // .headers => |_| {}, + } + } + } + + return .{ + .headers = headers, + }; +} + +pub fn _get(self: *const Headers, header: []const u8, page: *Page) !?[]const u8 { + const arena = page.arena; + const key = try std.ascii.allocLowerString(arena, header); + + const value = (self.headers.getEntry(key) orelse return null).value_ptr.*; + return try arena.dupe(u8, value); +} + +const testing = @import("../../testing.zig"); +test "fetch: headers" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "let empty_headers = new Headers()", "undefined" }, + }, .{}); + + try runner.testCases(&.{ + .{ "let headers = new Headers([['Set-Cookie', 'name=world']])", "undefined" }, + .{ "headers.get('set-cookie')", "name=world" }, + }, .{}); +} diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig new file mode 100644 index 000000000..90f3d0adf --- /dev/null +++ b/src/browser/fetch/Request.zig @@ -0,0 +1,68 @@ +// 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; + +// https://developer.mozilla.org/en-US/docs/Web/API/Request/Request +const Request = @This(); + +url: []const u8, + +const RequestInput = union(enum) { + string: []const u8, + request: Request, +}; + +pub fn constructor(input: RequestInput, page: *Page) !Request { + const arena = page.arena; + + const url = blk: switch (input) { + .string => |str| { + break :blk try URL.stitch(arena, str, page.url.raw, .{}); + }, + .request => |req| { + break :blk try arena.dupe(u8, req.url); + }, + }; + + return .{ + .url = url, + }; +} + +pub fn get_url(/service/self: *const Request, page: *Page) ![]const u8 { + return try page.arena.dupe(u8, self.url); +} + +const testing = @import("../../testing.zig"); +test "fetch: request" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "let request = new Request('flower.png')", "undefined" }, + .{ "request.url", "https://lightpanda.io/flower.png" }, + }, .{}); + + try runner.testCases(&.{ + .{ "let request2 = new Request('https://google.com/')", "undefined" }, + .{ "request2.url", "https://google.com/" }, + }, .{}); +} diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig new file mode 100644 index 000000000..e69de29bb 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..e69de29bb From 9efc27c2bb7dcf79dee6d4b7cebb994e20a4e4c9 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 22 Aug 2025 06:46:09 -0700 Subject: [PATCH 02/43] initial fetch in zig --- src/browser/env.zig | 3 +- src/browser/fetch/Request.zig | 201 +++++++++++++++++++++++++++++++-- src/browser/fetch/Response.zig | 64 +++++++++++ src/browser/html/window.zig | 7 ++ src/http/Http.zig | 14 +-- src/runtime/js.zig | 11 ++ 6 files changed, 284 insertions(+), 16 deletions(-) diff --git a/src/browser/env.zig b/src/browser/env.zig index 24b06ec74..861e3c0ce 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 90f3d0adf..e4228662f 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(/service/self: *const Request, page: *Page) ![]const u8 { - return try page.arena.dupe(u8, self.url); +pub fn get_url(/service/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 e69de29bb..32da75c6a 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 6f1d5fe8f..cebfc902c 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 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/runtime/js.zig b/src/runtime/js.zig index dbbc26a21..151b1b8d2 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 { From 855583874fbc0670cffc3acd9269192687b4bf62 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 22 Aug 2025 06:57:10 -0700 Subject: [PATCH 03/43] request url as null terminated --- src/browser/fetch/Request.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index e4228662f..4dc4f8b83 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -44,7 +44,7 @@ pub const RequestInit = struct { const Request = @This(); method: Http.Method, -url: []const u8, +url: [:0]const u8, body: []const u8, pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Request { @@ -53,10 +53,10 @@ pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Re const url = blk: switch (input) { .string => |str| { - break :blk try URL.stitch(arena, str, page.url.raw, .{}); + break :blk try URL.stitch(arena, str, page.url.raw, .{ .null_terminated = true }); }, .request => |req| { - break :blk try arena.dupe(u8, req.url); + break :blk try arena.dupeZ(u8, req.url); }, }; @@ -129,14 +129,14 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi const fetch_ctx = try arena.create(FetchContext); fetch_ctx.* = .{ - .arena = page.arena, + .arena = 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), + .url = req.url, .headers = headers, .body = req.body, .cookie_jar = page.cookie_jar, From 9757ea7b0f9e4f2f9425c19217464bde7cfbb15a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 22 Aug 2025 07:26:05 -0700 Subject: [PATCH 04/43] fetch callback logging --- src/browser/fetch/Request.zig | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 4dc4f8b83..0401dcc76 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -17,6 +17,8 @@ // 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; @@ -94,6 +96,8 @@ const FetchContext = struct { js_ctx: *Env.JsContext, promise_resolver: v8.Persistent(v8.PromiseResolver), + method: Http.Method, + url: []const u8, body: std.ArrayListUnmanaged(u8) = .empty, headers: std.ArrayListUnmanaged([]const u8) = .empty, status: u16 = 0, @@ -104,7 +108,7 @@ const FetchContext = struct { /// /// We just return the underlying slices used for `headers` /// and for `body` here to avoid an allocation. - pub fn toResponse(self: FetchContext) !Response { + pub fn toResponse(self: *const FetchContext) !Response { return Response{ .status = self.status, .headers = self.headers.items, @@ -131,7 +135,12 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi fetch_ctx.* = .{ .arena = arena, .js_ctx = page.main_context, - .promise_resolver = v8.Persistent(v8.PromiseResolver).init(page.main_context.isolate, resolver.resolver), + .promise_resolver = v8.Persistent(v8.PromiseResolver).init( + page.main_context.isolate, + resolver.resolver, + ), + .method = req.method, + .url = req.url, }; try client.request(.{ @@ -145,6 +154,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .start_callback = struct { fn startCallback(transfer: *HttpClient.Transfer) !void { const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); + log.debug(.http, "request start", .{ .method = self.method, .url = self.url, .source = "fetch" }); self.transfer = transfer; } }.startCallback, @@ -159,6 +169,12 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); const header = &transfer.response_header.?; + log.debug(.http, "request header", .{ + .source = "fetch", + .url = self.url, + .status = header.status, + }); + if (header.contentType()) |ct| { self.mime = Mime.parse(ct) catch { return error.Todo; @@ -177,6 +193,13 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .done_callback = struct { fn doneCallback(ctx: *anyopaque) !void { const self: *FetchContext = @alignCast(@ptrCast(ctx)); + + log.info(.http, "request complete", .{ + .source = "fetch", + .url = self.url, + .status = self.status, + }); + const response = try self.toResponse(); const promise_resolver: Env.PromiseResolver = .{ .js_context = self.js_ctx, From ab60f64452257dd98439e8646f1c20924f6e127b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 25 Aug 2025 08:03:51 -0700 Subject: [PATCH 05/43] proper fetch method and body setting --- src/browser/fetch/Request.zig | 66 ++++++++++++++++++++-------------- src/browser/fetch/Response.zig | 36 ++++++++++++++++--- src/cdp/domains/fetch.zig | 1 + src/http/Client.zig | 1 + 4 files changed, 73 insertions(+), 31 deletions(-) diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 0401dcc76..95b3eba18 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -38,8 +38,8 @@ pub const RequestInput = union(enum) { // https://developer.mozilla.org/en-US/docs/Web/API/RequestInit pub const RequestInit = struct { - method: []const u8 = "GET", - body: []const u8 = "", + method: ?[]const u8 = null, + body: ?[]const u8 = null, }; // https://developer.mozilla.org/en-US/docs/Web/API/Request/Request @@ -47,7 +47,7 @@ const Request = @This(); method: Http.Method, url: [:0]const u8, -body: []const u8, +body: ?[]const u8, pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Request { const arena = page.arena; @@ -62,15 +62,21 @@ pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Re }, }; - const method: Http.Method = blk: for (std.enums.values(Http.Method)) |method| { - if (std.ascii.eqlIgnoreCase(options.method, @tagName(method))) { - break :blk method; + 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; } - } else { - return error.InvalidMethod; }; - const body = try arena.dupe(u8, options.body); + const body = if (options.body) |body| try arena.dupe(u8, body) else null; return .{ .method = method, @@ -87,9 +93,9 @@ 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; -} +// pub fn get_body(self: *const Request) ?[]const u8 { +// return self.body; +// } const FetchContext = struct { arena: std.mem.Allocator, @@ -123,13 +129,14 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi 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(); + var headers = try Http.Headers.init(); + try page.requestCookie(.{}).headersForRequest(arena, req.url, &headers); const fetch_ctx = try arena.create(FetchContext); fetch_ctx.* = .{ @@ -143,47 +150,51 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .url = req.url, }; - try client.request(.{ - .method = req.method, + 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, - .ctx = @ptrCast(fetch_ctx), + .resource_type = .fetch, .start_callback = struct { fn startCallback(transfer: *HttpClient.Transfer) !void { const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); log.debug(.http, "request start", .{ .method = self.method, .url = self.url, .source = "fetch" }); + 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 { + fn headerCallback(transfer: *HttpClient.Transfer) !void { const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); + const header = &transfer.response_header.?; log.debug(.http, "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.Todo; + return error.MimeParsing; }; } + 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; } - }.headerDoneCallback, + }.headerCallback, .data_callback = struct { fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); @@ -196,6 +207,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi log.info(.http, "request complete", .{ .source = "fetch", + .method = self.method, .url = self.url, .status = self.status, }); @@ -212,6 +224,8 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .error_callback = struct { fn errorCallback(ctx: *anyopaque, err: anyerror) void { const self: *FetchContext = @alignCast(@ptrCast(ctx)); + + self.transfer = null; const promise_resolver: Env.PromiseResolver = .{ .js_context = self.js_ctx, .resolver = self.promise_resolver.castToPromiseResolver(), diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index 32da75c6a..159dfbf43 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -19,6 +19,9 @@ const std = @import("std"); const URL = @import("../../url.zig").URL; const Page = @import("../page.zig").Page; +const Env = @import("../env.zig").Env; + +const v8 = @import("v8"); const Http = @import("../../http/Http.zig"); const HttpClient = @import("../../http/Client.zig"); @@ -36,13 +39,26 @@ const ResponseInput = union(enum) { string: []const u8, }; -pub fn constructor(input: ResponseInput, page: *Page) !Response { +const ResponseOptions = struct { + status: u16 = 200, + statusText: []const u8 = "", + // List of header pairs. + headers: []const []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); - }, + const body = blk: { + if (_input) |input| { + switch (input) { + .string => |str| { + break :blk try arena.dupe(u8, str); + }, + } + } else { + break :blk ""; + } }; return .{ @@ -55,6 +71,16 @@ pub fn get_ok(self: *const Response) bool { return self.status >= 200 and self.status <= 299; } +pub fn _text(self: *const Response, page: *Page) !Env.Promise { + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + try resolver.resolve(self.body); + return resolver.promise(); +} + const testing = @import("../../testing.zig"); test "fetch: response" { var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index ea7d225ab..dbe055ae1 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 }); 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, }; }; From 1d7e7310344979570221ab3d5604272f9cb59eeb Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 26 Aug 2025 13:45:49 -0700 Subject: [PATCH 06/43] basic readable stream working --- src/browser/env.zig | 5 +- src/browser/fetch/Request.zig | 1 - src/browser/fetch/fetch.zig | 23 ++++ src/browser/streams/ReadableStream.zig | 129 ++++++++++++++++++ .../ReadableStreamDefaultController.zig | 58 ++++++++ .../streams/ReadableStreamDefaultReader.zig | 100 ++++++++++++++ src/browser/streams/streams.zig | 24 ++++ 7 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 src/browser/fetch/fetch.zig create mode 100644 src/browser/streams/ReadableStreamDefaultController.zig create mode 100644 src/browser/streams/ReadableStreamDefaultReader.zig create mode 100644 src/browser/streams/streams.zig diff --git a/src/browser/env.zig b/src/browser/env.zig index 861e3c0ce..d7a20cf0d 100644 --- a/src/browser/env.zig +++ b/src/browser/env.zig @@ -36,9 +36,8 @@ const WebApis = struct { @import("xhr/form_data.zig").Interfaces, @import("xhr/File.zig"), @import("xmlserializer/xmlserializer.zig").Interfaces, - @import("fetch/Headers.zig"), - @import("fetch/Request.zig"), - @import("fetch/Response.zig"), + @import("fetch/fetch.zig").Interfaces, + @import("streams/streams.zig").Interfaces, }); }; diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 95b3eba18..782bdf594 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -254,7 +254,6 @@ test "fetch: request" { .{ "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" }, }, .{}); } diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig new file mode 100644 index 000000000..9b776074e --- /dev/null +++ b/src/browser/fetch/fetch.zig @@ -0,0 +1,23 @@ +// 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("Headers.zig"), + @import("Request.zig"), + @import("Response.zig"), +}; diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index e69de29bb..8c9d588f9 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -0,0 +1,129 @@ +// 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 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, + errored: Env.JsObject, +}; + +// This promise resolves when a stream is canceled. +cancel_resolver: Env.PromiseResolver, +locked: bool = false, +state: State = .readable, + +// A queue would be ideal here but I don't want to pay the cost of the priority operation. +queue: std.ArrayListUnmanaged([]const u8) = .empty, + +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: f64 = 1.0, +}; + +pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, page: *Page) !*ReadableStream { + _ = strategy; + + const cancel_resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + const stream = try page.arena.create(ReadableStream); + stream.* = ReadableStream{ .cancel_resolver = cancel_resolver }; + + const controller = ReadableStreamDefaultController{ .stream = stream }; + + // call start + if (underlying) |src| { + if (src.start) |start| { + try start.call(void, .{controller}); + } + } + + log.info(.browser, "rs aux", .{ .queue_len = stream.queue.items.len }); + + return stream; +} + +pub fn _cancel(self: *const ReadableStream) Env.Promise { + return self.cancel_resolver.promise(); +} + +pub fn get_locked(self: *const ReadableStream) bool { + return self.locked; +} + +const GetReaderOptions = struct { + mode: ?[]const u8 = null, +}; + +pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions, page: *Page) ReadableStreamDefaultReader { + const options = _options orelse GetReaderOptions{}; + _ = options; + + return ReadableStreamDefaultReader.constructor(self, page); +} + +const testing = @import("../../testing.zig"); +test "streams: ReadableStream" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "var readResult;", "undefined" }, + .{ + \\ const stream = new ReadableStream({ + \\ start(controller) { + \\ controller.enqueue("hello"); + \\ controller.enqueue("world"); + \\ controller.close(); + \\ } + \\ }); + , + undefined, + }, + .{ + \\ const reader = stream.getReader(); + \\ (async function () { readResult = await reader.read() }()); + \\ false; + , + "false", + }, + .{ "reader", "[object ReadableStreamDefaultReader]" }, + .{ "readResult.value", "hello" }, + .{ "readResult.done", "false" }, + }, .{}); +} diff --git a/src/browser/streams/ReadableStreamDefaultController.zig b/src/browser/streams/ReadableStreamDefaultController.zig new file mode 100644 index 000000000..4cd10c7a2 --- /dev/null +++ b/src/browser/streams/ReadableStreamDefaultController.zig @@ -0,0 +1,58 @@ +// 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 v8 = @import("v8"); + +const ReadableStream = @import("./ReadableStream.zig"); + +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 }; + + // 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; + } + + try self.stream.queue.append(page.arena, chunk); +} + +pub fn _error(self: *ReadableStreamDefaultController, err: Env.JsObject) void { + self.stream.state = .{ .errored = err }; + // set to error. +} diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig new file mode 100644 index 000000000..a824cdd6a --- /dev/null +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -0,0 +1,100 @@ +// 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 v8 = @import("v8"); + +const log = @import("../../log.zig"); +const Env = @import("../env.zig").Env; +const Page = @import("../page.zig").Page; +const ReadableStream = @import("./ReadableStream.zig"); + +const ReadableStreamDefaultReader = @This(); + +stream: *ReadableStream, +// This promise resolves when the stream is closed. +closed_resolver: Env.PromiseResolver, + +pub fn constructor(stream: *ReadableStream, page: *Page) ReadableStreamDefaultReader { + const closed_resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + return .{ + .stream = stream, + .closed_resolver = closed_resolver, + }; +} + +pub fn get_closed(self: *const ReadableStreamDefaultReader) Env.Promise { + return self.closed_resolver.promise(); +} + +pub fn _cancel(self: *ReadableStreamDefaultReader) Env.Promise { + return self.stream._cancel(); +} + +pub const ReadableStreamReadResult = struct { + value: ?[]const u8, + done: bool, + + pub fn get_value(self: *const ReadableStreamReadResult, page: *Page) !?[]const u8 { + return if (self.value) |value| try page.arena.dupe(u8, value) else null; + } + + pub fn get_done(self: *const ReadableStreamReadResult) bool { + return self.done; + } +}; + +pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise { + const stream = self.stream; + + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; + + switch (stream.state) { + .readable => { + if (stream.queue.items.len > 0) { + const data = self.stream.queue.orderedRemove(0); + try resolver.resolve(ReadableStreamReadResult{ .value = data, .done = false }); + } else { + // TODO: need to wait until we have more data + try resolver.reject("TODO!"); + return error.Todo; + } + }, + .closed => |_| { + if (stream.queue.items.len > 0) { + const data = try page.arena.dupe(u8, self.stream.queue.orderedRemove(0)); + try resolver.resolve(ReadableStreamReadResult{ .value = data, .done = false }); + } else { + try resolver.resolve(ReadableStreamReadResult{ .value = null, .done = true }); + } + }, + .errored => |err| { + try resolver.reject(err); + }, + } + + return resolver.promise(); +} diff --git a/src/browser/streams/streams.zig b/src/browser/streams/streams.zig new file mode 100644 index 000000000..c33f5aa62 --- /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("ReadableStreamDefaultReader.zig"), + @import("ReadableStreamDefaultReader.zig").ReadableStreamReadResult, + @import("ReadableStreamDefaultController.zig"), +}; From 4b75b33eb3c81cfb1638a6bc758ad5c67b7ce0eb Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 1 Sep 2025 06:58:51 -0700 Subject: [PATCH 07/43] add json response method --- src/browser/fetch/Response.zig | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index 159dfbf43..dffbfc57f 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -17,15 +17,16 @@ // along with this program. If not, see . const std = @import("std"); -const URL = @import("../../url.zig").URL; -const Page = @import("../page.zig").Page; -const Env = @import("../env.zig").Env; +const log = @import("../../log.zig"); const v8 = @import("v8"); -const Http = @import("../../http/Http.zig"); const HttpClient = @import("../../http/Client.zig"); +const Http = @import("../../http/Http.zig"); +const URL = @import("../../url.zig").URL; +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(); @@ -81,6 +82,26 @@ pub fn _text(self: *const Response, page: *Page) !Env.Promise { return resolver.promise(); } +pub fn _json(self: *const Response, page: *Page) !Env.Promise { + 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.arena, + self.body, + .{}, + ) catch |e| { + log.warn(.browser, "invalid json", .{ .err = e, .source = "fetch" }); + return error.SyntaxError; + }; + + try resolver.resolve(p); + return resolver.promise(); +} + const testing = @import("../../testing.zig"); test "fetch: response" { var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); From ec936417c608c1a0b1c36ef3950ef93a9fd3ffbe Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 17:14:31 -0700 Subject: [PATCH 08/43] add fetch to cdp domain --- src/cdp/domains/fetch.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index dbe055ae1..f6fb302b9 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -406,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", From 4ceca6b90bad19ecda30928186fbfa5e34ab93db Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 20:24:59 -0700 Subject: [PATCH 09/43] more Headers compatibility --- src/browser/fetch/Headers.zig | 136 +++++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 18 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index 71fe52ee8..01528ec03 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -23,39 +23,68 @@ const Page = @import("../page.zig").Page; // https://developer.mozilla.org/en-US/docs/Web/API/Headers const Headers = @This(); -headers: std.StringHashMapUnmanaged([]const u8), +// 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 hasher = std.hash.Wyhash.init(s.len); + for (s) |c| { + hasher.update(&.{std.ascii.toLower(c)}); + } + + return hasher.final(); + } + pub fn eql(_: @This(), a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + + for (a, b) |c1, c2| { + if (std.ascii.toLower(c1) != std.ascii.toLower(c2)) return false; + } + + return true; + } +}, 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. -const HeadersInit = union(enum) { - strings: []const []const u8, - // headers: Headers, +pub const HeadersInit = union(enum) { + // List of Pairs of []const u8 + strings: []const []const []const u8, + headers: *Headers, }; -pub fn constructor(_init: ?[]const HeadersInit, page: *Page) !Headers { +pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { const arena = page.arena; - var headers = std.StringHashMapUnmanaged([]const u8).empty; + var headers: HeaderHashMap = .empty; if (_init) |init| { - for (init) |item| { - switch (item) { - .strings => |pair| { + switch (init) { + .strings => |kvs| { + for (kvs) |pair| { // Can only have two string elements if in a pair. if (pair.len != 2) { return error.TypeError; } - const raw_key = pair[0]; - const value = pair[1]; - const key = try std.ascii.allocLowerString(arena, raw_key); + const key = try page.arena.dupe(u8, pair[0]); + const value = try page.arena.dupe(u8, pair[1]); try headers.put(arena, key, value); - }, - // .headers => |_| {}, - } + } + }, + .headers => |hdrs| { + var iter = hdrs.headers.iterator(); + while (iter.next()) |entry| { + const key = try page.arena.dupe(u8, entry.key_ptr.*); + const value = try page.arena.dupe(u8, entry.value_ptr.*); + try headers.put(arena, key, value); + } + }, } } @@ -64,14 +93,70 @@ pub fn constructor(_init: ?[]const HeadersInit, page: *Page) !Headers { }; } -pub fn _get(self: *const Headers, header: []const u8, page: *Page) !?[]const u8 { +pub fn clone(self: *Headers, allocator: std.mem.Allocator) !Headers { + return Headers{ + .headers = try self.headers.clone(allocator), + }; +} + +pub fn _append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { const arena = page.arena; - const key = try std.ascii.allocLowerString(arena, header); - const value = (self.headers.getEntry(key) orelse return null).value_ptr.*; + if (self.headers.getEntry(name)) |entry| { + // If we found it, append the value. + const new_value = try std.fmt.allocPrint(arena, "{s}, {s}", .{ entry.value_ptr.*, value }); + entry.value_ptr.* = new_value; + } else { + // Otherwise, we should just put it in. + try self.headers.putNoClobber( + arena, + try arena.dupe(u8, name), + try arena.dupe(u8, value), + ); + } +} + +pub fn _delete(self: *Headers, name: []const u8) void { + _ = self.headers.remove(name); +} + +// TODO: entries iterator +// They should be: +// 1. Sorted in lexicographical order. +// 2. Duplicate header names should be combined. + +// TODO: header for each + +pub fn _get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { + const arena = page.arena; + const value = (self.headers.getEntry(name) orelse return null).value_ptr.*; return try arena.dupe(u8, value); } +pub fn _has(self: *const Headers, name: []const u8) bool { + return self.headers.contains(name); +} + +// TODO: keys iterator + +pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { + const arena = page.arena; + + if (self.headers.getEntry(name)) |entry| { + // If we found it, set the value. + entry.value_ptr.* = try arena.dupe(u8, value); + } else { + // Otherwise, we should just put it in. + try self.headers.putNoClobber( + arena, + try arena.dupe(u8, name), + try arena.dupe(u8, value), + ); + } +} + +// TODO: values iterator + const testing = @import("../../testing.zig"); test "fetch: headers" { var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); @@ -85,4 +170,19 @@ test "fetch: headers" { .{ "let headers = new Headers([['Set-Cookie', 'name=world']])", "undefined" }, .{ "headers.get('set-cookie')", "name=world" }, }, .{}); + + // adapted from the mdn examples + try runner.testCases(&.{ + .{ "const myHeaders = new Headers();", "undefined" }, + .{ "myHeaders.append('Content-Type', 'image/jpeg')", "undefined" }, + .{ "myHeaders.has('Picture-Type')", "false" }, + .{ "myHeaders.get('Content-Type')", "image/jpeg" }, + .{ "myHeaders.append('Content-Type', 'image/png')", "undefined" }, + .{ "myHeaders.get('Content-Type')", "image/jpeg, image/png" }, + .{ "myHeaders.delete('Content-Type')", "undefined" }, + .{ "myHeaders.get('Content-Type')", "null" }, + .{ "myHeaders.set('Picture-Type', 'image/svg')", "undefined" }, + .{ "myHeaders.get('Picture-Type')", "image/svg" }, + .{ "myHeaders.has('Picture-Type')", "true" }, + }, .{}); } From 91899912d813b6c0994eb37ee2e92570597b8953 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 20:25:24 -0700 Subject: [PATCH 10/43] add bodyUsed checks on Request and Response --- src/browser/fetch/Request.zig | 218 +++++++++++++-------------------- src/browser/fetch/Response.zig | 43 ++++++- 2 files changed, 128 insertions(+), 133 deletions(-) diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 782bdf594..8ed64add6 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -25,21 +25,24 @@ const Page = @import("../page.zig").Page; const Response = @import("./Response.zig"); const Http = @import("../../http/Http.zig"); -const HttpClient = @import("../../http/Client.zig"); -const Mime = @import("../mime.zig").Mime; 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, + request: *Request, }; // https://developer.mozilla.org/en-US/docs/Web/API/RequestInit pub const RequestInit = struct { method: ?[]const u8 = null, body: ?[]const u8 = null, + integrity: ?[]const u8 = null, + headers: ?HeadersInit = null, }; // https://developer.mozilla.org/en-US/docs/Web/API/Request/Request @@ -47,7 +50,10 @@ const Request = @This(); method: Http.Method, url: [:0]const u8, +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; @@ -77,165 +83,115 @@ pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Re }; const body = if (options.body) |body| try arena.dupe(u8, body) else null; + const integrity = if (options.integrity) |integ| try arena.dupe(u8, integ) else ""; + const headers = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else Headers{}; return .{ .method = method, .url = url, + .headers = headers, .body = body, + .integrity = integrity, }; } -pub fn get_url(/service/self: *const Request) []const u8 { - return self.url; +// pub fn get_body(self: *const Request) ?[]const u8 { +// return self.body; +// } + +pub fn get_bodyUsed(self: *const Request) bool { + return self.body_used; +} + +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_body(self: *const Request) ?[]const u8 { -// return self.body; -// } +pub fn get_url(/service/self: *const Request) []const u8 { + return self.url; +} -const FetchContext = struct { - arena: std.mem.Allocator, - js_ctx: *Env.JsContext, - promise_resolver: v8.Persistent(v8.PromiseResolver), - - 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 { - return Response{ - .status = self.status, - .headers = self.headers.items, - .mime = self.mime, - .body = self.body.items, - }; +pub fn _clone(self: *Request, page: *Page) !Request { + // Not allowed to clone if the body was used. + if (self.body_used) { + return error.TypeError; } -}; -// 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); + return Request{ + .body = if (self.body) |body| try arena.dupe(u8, body) else null, + .body_used = self.body_used, + .headers = try self.headers.clone(arena), + .method = self.method, + .integrity = try arena.dupe(u8, self.integrity), + .url = try arena.dupeZ(u8, 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), }; - var headers = try Http.Headers.init(); - try page.requestCookie(.{}).headersForRequest(arena, req.url, &headers); - - const fetch_ctx = try arena.create(FetchContext); - fetch_ctx.* = .{ - .arena = arena, - .js_ctx = page.main_context, - .promise_resolver = v8.Persistent(v8.PromiseResolver).init( - page.main_context.isolate, - resolver.resolver, - ), - .method = req.method, - .url = req.url, - }; + try resolver.resolve(self.body); + self.body_used = true; + return resolver.promise(); +} - 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, +pub fn _json(self: *Response, page: *Page) !Env.Promise { + if (self.body_used) { + return error.TypeError; + } - .start_callback = struct { - fn startCallback(transfer: *HttpClient.Transfer) !void { - const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); - log.debug(.http, "request start", .{ .method = self.method, .url = self.url, .source = "fetch" }); + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = v8.PromiseResolver.init(page.main_context.v8_context), + }; - self.transfer = transfer; - } - }.startCallback, - .header_callback = struct { - fn headerCallback(transfer: *HttpClient.Transfer) !void { - const self: *FetchContext = @alignCast(@ptrCast(transfer.ctx)); - - const header = &transfer.response_header.?; - - log.debug(.http, "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; - }; - } + const p = std.json.parseFromSliceLeaky( + std.json.Value, + page.arena, + self.body, + .{}, + ) catch |e| { + log.warn(.browser, "invalid json", .{ .err = e, .source = "Request" }); + return error.SyntaxError; + }; - 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); - } + try resolver.resolve(p); + self.body_used = true; + return resolver.promise(); +} - self.status = header.status; - } - }.headerCallback, - .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)); - - log.info(.http, "request complete", .{ - .source = "fetch", - .method = self.method, - .url = self.url, - .status = self.status, - }); - - 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)); - - self.transfer = null; - const promise_resolver: Env.PromiseResolver = .{ - .js_context = self.js_ctx, - .resolver = self.promise_resolver.castToPromiseResolver(), - }; - - promise_resolver.reject(@errorName(err)) catch unreachable; - } - }.errorCallback, - }); +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(); } diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index dffbfc57f..14ae2e177 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -35,6 +35,8 @@ status: u16 = 0, headers: []const []const u8, mime: ?Mime = null, body: []const u8, +body_used: bool = false, +redirected: bool = false, const ResponseInput = union(enum) { string: []const u8, @@ -72,17 +74,38 @@ pub fn get_ok(self: *const Response) bool { return self.status >= 200 and self.status <= 299; } -pub fn _text(self: *const Response, page: *Page) !Env.Promise { +pub fn get_bodyUsed(self: *const Response) bool { + return self.body_used; +} + +pub fn get_redirected(self: *const Response) bool { + return self.redirected; +} + +pub fn get_status(self: *const Response) u16 { + return self.status; +} + +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: *const Response, page: *Page) !Env.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), @@ -99,6 +122,22 @@ pub fn _json(self: *const Response, page: *Page) !Env.Promise { }; 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(); } From 066df87dd433283ae759f0ed329c6fccede15d89 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 20:25:48 -0700 Subject: [PATCH 11/43] move fetch() into fetch.zig --- src/browser/fetch/fetch.zig | 158 ++++++++++++++++++++++++++++++++++++ src/browser/html/window.zig | 3 +- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 9b776074e..4f1c3d3ba 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -16,8 +16,166 @@ // 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 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 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("Request.zig"), @import("Response.zig"), }; + +const FetchContext = struct { + arena: std.mem.Allocator, + js_ctx: *Env.JsContext, + promise_resolver: v8.Persistent(v8.PromiseResolver), + + 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 { + 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), + }; + + var headers = try Http.Headers.init(); + try page.requestCookie(.{}).headersForRequest(arena, req.url, &headers); + + const fetch_ctx = try arena.create(FetchContext); + fetch_ctx.* = .{ + .arena = arena, + .js_ctx = page.main_context, + .promise_resolver = v8.Persistent(v8.PromiseResolver).init( + page.main_context.isolate, + 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(.http, "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(.http, "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; + }; + } + + 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)); + + log.info(.http, "request complete", .{ + .source = "fetch", + .method = self.method, + .url = self.url, + .status = self.status, + }); + + 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 = @ptrCast(@alignCast(ctx)); + + self.transfer = null; + 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(); +} diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index cebfc902c..790e823f6 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -41,6 +41,7 @@ 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"); @@ -99,7 +100,7 @@ pub const Window = struct { } pub fn _fetch(_: *Window, input: Request.RequestInput, options: ?Request.RequestInit, page: *Page) !Env.Promise { - return Request.fetch(input, options, page); + return fetchFn(input, options, page); } pub fn get_window(self: *Window) *Window { From 11016abdd3d9cff4494680331f4066ff02460a1c Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 20:26:02 -0700 Subject: [PATCH 12/43] remove debug logging in ReadableStream --- src/browser/streams/ReadableStream.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index 8c9d588f9..bcb42952c 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -73,8 +73,6 @@ pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, p } } - log.info(.browser, "rs aux", .{ .queue_len = stream.queue.items.len }); - return stream; } From 545d97b5c01754d4873cda1b6f96fe4c0b301351 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 23:21:14 -0700 Subject: [PATCH 13/43] expand Headers interface --- src/browser/fetch/Headers.zig | 43 +++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index 01528ec03..810a0a5b7 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -20,6 +20,9 @@ const std = @import("std"); const URL = @import("../../url.zig").URL; const Page = @import("../page.zig").Page; +const v8 = @import("v8"); +const Env = @import("../env.zig").Env; + // https://developer.mozilla.org/en-US/docs/Web/API/Headers const Headers = @This(); @@ -93,29 +96,32 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { }; } -pub fn clone(self: *Headers, allocator: std.mem.Allocator) !Headers { +pub fn clone(self: *const Headers, allocator: std.mem.Allocator) !Headers { return Headers{ .headers = try self.headers.clone(allocator), }; } -pub fn _append(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { - const arena = page.arena; - +pub fn append(self: *Headers, name: []const u8, value: []const u8, allocator: std.mem.Allocator) !void { if (self.headers.getEntry(name)) |entry| { // If we found it, append the value. - const new_value = try std.fmt.allocPrint(arena, "{s}, {s}", .{ entry.value_ptr.*, value }); + const new_value = try std.fmt.allocPrint(allocator, "{s}, {s}", .{ entry.value_ptr.*, value }); entry.value_ptr.* = new_value; } else { // Otherwise, we should just put it in. try self.headers.putNoClobber( - arena, - try arena.dupe(u8, name), - try arena.dupe(u8, value), + allocator, + try allocator.dupe(u8, name), + 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); } @@ -125,7 +131,26 @@ pub fn _delete(self: *Headers, name: []const u8) void { // 1. Sorted in lexicographical order. // 2. Duplicate header names should be combined. -// TODO: header for each +pub fn _forEach(self: *Headers, callback_fn: Env.Function, this_arg: ?Env.JsObject) !void { + var iter = self.headers.iterator(); + + if (this_arg) |this| { + while (iter.next()) |entry| { + try callback_fn.callWithThis( + void, + this, + .{ entry.key_ptr.*, entry.value_ptr.*, self }, + ); + } + } else { + while (iter.next()) |entry| { + try callback_fn.call( + void, + .{ entry.key_ptr.*, entry.value_ptr.*, self }, + ); + } + } +} pub fn _get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { const arena = page.arena; From 8285cbcaa9bd7e01290753c6391e3e41b55f3108 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 23:23:14 -0700 Subject: [PATCH 14/43] expand Request/Response interfaces --- src/browser/fetch/Request.zig | 73 ++++++++++++++++--- src/browser/fetch/Response.zig | 59 ++++++++++++--- src/browser/mime.zig | 16 ++++ src/browser/streams/ReadableStream.zig | 20 +++-- .../streams/ReadableStreamDefaultReader.zig | 4 +- 5 files changed, 143 insertions(+), 29 deletions(-) diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 8ed64add6..6f4e37513 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -23,8 +23,8 @@ 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; @@ -37,12 +37,49 @@ pub const RequestInput = union(enum) { 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 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; + } + } +}; + // https://developer.mozilla.org/en-US/docs/Web/API/RequestInit pub const RequestInit = struct { - method: ?[]const u8 = null, body: ?[]const u8 = null, - integrity: ?[]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 @@ -50,6 +87,8 @@ const Request = @This(); method: Http.Method, url: [:0]const u8, +cache: RequestCache, +credentials: RequestCredentials, headers: Headers, body: ?[]const u8, body_used: bool = false, @@ -68,6 +107,12 @@ pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Re }, }; + 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 = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else Headers{}; + const method: Http.Method = blk: { if (options.method) |given_method| { for (std.enums.values(Http.Method)) |method| { @@ -82,27 +127,33 @@ pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Re } }; - const body = if (options.body) |body| try arena.dupe(u8, body) else null; - const integrity = if (options.integrity) |integ| try arena.dupe(u8, integ) else ""; - const headers = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else Headers{}; - return .{ .method = method, .url = url, + .cache = cache, + .credentials = credentials, .headers = headers, .body = body, .integrity = integrity, }; } -// pub fn get_body(self: *const Request) ?[]const u8 { -// return self.body; -// } +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_headers(self: *Request) *Headers { return &self.headers; } @@ -133,6 +184,8 @@ pub fn _clone(self: *Request, page: *Page) !Request { return Request{ .body = if (self.body) |body| try arena.dupe(u8, body) else null, .body_used = self.body_used, + .cache = self.cache, + .credentials = self.credentials, .headers = try self.headers.clone(arena), .method = self.method, .integrity = try arena.dupe(u8, self.integrity), diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index 14ae2e177..21a950de1 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -24,6 +24,11 @@ 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; @@ -32,26 +37,28 @@ const Page = @import("../page.zig").Page; const Response = @This(); status: u16 = 0, -headers: []const []const u8, +headers: Headers = .{}, mime: ?Mime = null, -body: []const u8, +url: []const u8 = "", +body: []const u8 = "", body_used: bool = false, redirected: bool = false, -const ResponseInput = union(enum) { +const ResponseBody = union(enum) { string: []const u8, }; const ResponseOptions = struct { status: u16 = 200, statusText: []const u8 = "", - // List of header pairs. - headers: []const []const u8 = &[][].{}, + headers: ?HeadersInit = null, }; -pub fn constructor(_input: ?ResponseInput, page: *Page) !Response { +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) { @@ -64,20 +71,32 @@ pub fn constructor(_input: ?ResponseInput, page: *Page) !Response { } }; + const headers: Headers = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else .{}; + return .{ .body = body, - .headers = &[_][]const u8{}, + .headers = headers, }; } -pub fn get_ok(self: *const Response) bool { - return self.status >= 200 and self.status <= 299; +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; } @@ -86,6 +105,28 @@ pub fn get_status(self: *const Response) u16 { return self.status; } +pub fn get_url(/service/self: *const Response) []const u8 { + return self.url; +} + +pub fn _clone(self: *const Response, page: *Page) !Response { + if (self.body_used) { + return error.TypeError; + } + + const arena = page.arena; + + return Response{ + .body = try arena.dupe(u8, self.body), + .body_used = self.body_used, + .mime = if (self.mime) |mime| try mime.clone(arena) else null, + .headers = try self.headers.clone(arena), + .redirected = self.redirected, + .status = self.status, + .url = try arena.dupe(u8, self.url), + }; +} + pub fn _bytes(self: *Response, page: *Page) !Env.Promise { if (self.body_used) { return error.TypeError; diff --git a/src/browser/mime.zig b/src/browser/mime.zig index 33ab99589..468ceb36f 100644 --- a/src/browser/mime.zig +++ b/src/browser/mime.zig @@ -290,6 +290,22 @@ pub const Mime = struct { fn trimRight(s: []const u8) []const u8 { return std.mem.trimRight(u8, s, &std.ascii.whitespace); } + + pub fn clone(self: *const Mime, allocator: Allocator) !Mime { + return Mime{ + .content_type = blk: { + switch (self.content_type) { + .other => |data| break :blk ContentType{ .other = .{ + .type = try allocator.dupe(u8, data.type), + .sub_type = try allocator.dupe(u8, data.sub_type), + } }, + else => break :blk self.content_type, + } + }, + .params = try allocator.dupe(u8, self.params), + .charset = if (self.charset) |charset| try allocator.dupeZ(u8, charset) else null, + }; + } }; const testing = @import("../testing.zig"); diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index bcb42952c..a25fd635e 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -34,11 +34,10 @@ const State = union(enum) { }; // This promise resolves when a stream is canceled. -cancel_resolver: Env.PromiseResolver, +cancel_resolver: v8.Persistent(v8.PromiseResolver), locked: bool = false, state: State = .readable, -// A queue would be ideal here but I don't want to pay the cost of the priority operation. queue: std.ArrayListUnmanaged([]const u8) = .empty, const UnderlyingSource = struct { @@ -56,10 +55,10 @@ const QueueingStrategy = struct { pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, page: *Page) !*ReadableStream { _ = strategy; - const cancel_resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = v8.PromiseResolver.init(page.main_context.v8_context), - }; + const cancel_resolver = v8.Persistent(v8.PromiseResolver).init( + page.main_context.isolate, + v8.PromiseResolver.init(page.main_context.v8_context), + ); const stream = try page.arena.create(ReadableStream); stream.* = ReadableStream{ .cancel_resolver = cancel_resolver }; @@ -76,8 +75,13 @@ pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, p return stream; } -pub fn _cancel(self: *const ReadableStream) Env.Promise { - return self.cancel_resolver.promise(); +pub fn _cancel(self: *const ReadableStream, page: *Page) Env.Promise { + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = self.cancel_resolver.castToPromiseResolver(), + }; + + return resolver.promise(); } pub fn get_locked(self: *const ReadableStream) bool { diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig index a824cdd6a..b3fd560fb 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -47,8 +47,8 @@ pub fn get_closed(self: *const ReadableStreamDefaultReader) Env.Promise { return self.closed_resolver.promise(); } -pub fn _cancel(self: *ReadableStreamDefaultReader) Env.Promise { - return self.stream._cancel(); +pub fn _cancel(self: *ReadableStreamDefaultReader, page: *Page) Env.Promise { + return self.stream._cancel(page); } pub const ReadableStreamReadResult = struct { From 479cd5ab1a8daa972fdb8eb334ce946f427611d9 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 23:23:26 -0700 Subject: [PATCH 15/43] use proper Headers in fetch() --- src/browser/fetch/fetch.zig | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 4f1c3d3ba..fc8e9b781 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -26,11 +26,12 @@ 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"); +const Response = @import("Response.zig"); pub const Interfaces = .{ @import("Headers.zig"), @@ -56,11 +57,22 @@ const FetchContext = struct { /// 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 = self.headers.items, + .headers = headers, .mime = self.mime, .body = self.body.items, + .url = self.url, }; } }; From 4fd365b52014205e00a36e1c5f2b87bb0692578c Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 2 Sep 2025 23:32:19 -0700 Subject: [PATCH 16/43] cleaning up various Headers routines --- src/browser/fetch/Headers.zig | 56 ++++++++----------- .../streams/ReadableStreamDefaultReader.zig | 6 +- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index 810a0a5b7..74157263b 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -30,21 +30,26 @@ const Headers = @This(); // 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); - for (s) |c| { - hasher.update(&.{std.ascii.toLower(c)}); + + 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 { - if (a.len != b.len) return false; - - for (a, b) |c1, c2| { - if (std.ascii.toLower(c1) != std.ascii.toLower(c2)) return false; - } - return true; + pub fn eql(_: @This(), a: []const u8, b: []const u8) bool { + return std.ascii.eqlIgnoreCase(a, b); } }, 80); @@ -103,17 +108,15 @@ pub fn clone(self: *const Headers, allocator: std.mem.Allocator) !Headers { } pub fn append(self: *Headers, name: []const u8, value: []const u8, allocator: std.mem.Allocator) !void { - if (self.headers.getEntry(name)) |entry| { + const gop = try self.headers.getOrPut(allocator, name); + + if (gop.found_existing) { // If we found it, append the value. - const new_value = try std.fmt.allocPrint(allocator, "{s}, {s}", .{ entry.value_ptr.*, value }); - entry.value_ptr.* = new_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. - try self.headers.putNoClobber( - allocator, - try allocator.dupe(u8, name), - try allocator.dupe(u8, value), - ); + gop.value_ptr.* = try allocator.dupe(u8, value); } } @@ -152,10 +155,8 @@ pub fn _forEach(self: *Headers, callback_fn: Env.Function, this_arg: ?Env.JsObje } } -pub fn _get(self: *const Headers, name: []const u8, page: *Page) !?[]const u8 { - const arena = page.arena; - const value = (self.headers.getEntry(name) orelse return null).value_ptr.*; - return try arena.dupe(u8, value); +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 { @@ -167,17 +168,8 @@ pub fn _has(self: *const Headers, name: []const u8) bool { pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { const arena = page.arena; - if (self.headers.getEntry(name)) |entry| { - // If we found it, set the value. - entry.value_ptr.* = try arena.dupe(u8, value); - } else { - // Otherwise, we should just put it in. - try self.headers.putNoClobber( - arena, - try arena.dupe(u8, name), - try arena.dupe(u8, value), - ); - } + const gop = try self.headers.getOrPut(arena, name); + gop.value_ptr.* = try arena.dupe(u8, value); } // TODO: values iterator diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig index b3fd560fb..307dd75e2 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -55,8 +55,8 @@ pub const ReadableStreamReadResult = struct { value: ?[]const u8, done: bool, - pub fn get_value(self: *const ReadableStreamReadResult, page: *Page) !?[]const u8 { - return if (self.value) |value| try page.arena.dupe(u8, value) else null; + pub fn get_value(self: *const ReadableStreamReadResult) !?[]const u8 { + return self.value; } pub fn get_done(self: *const ReadableStreamReadResult) bool { @@ -85,7 +85,7 @@ pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise }, .closed => |_| { if (stream.queue.items.len > 0) { - const data = try page.arena.dupe(u8, self.stream.queue.orderedRemove(0)); + const data = self.stream.queue.orderedRemove(0); try resolver.resolve(ReadableStreamReadResult{ .value = data, .done = false }); } else { try resolver.resolve(ReadableStreamReadResult{ .value = null, .done = true }); From b5021bd9fa8d262034a0d936b3f5c7b44908557b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 3 Sep 2025 08:12:37 -0700 Subject: [PATCH 17/43] TypeError when Stream is locked --- src/browser/streams/ReadableStream.zig | 27 ++++++++++++++----- .../ReadableStreamDefaultController.zig | 1 - .../streams/ReadableStreamDefaultReader.zig | 4 +-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index a25fd635e..a33459d66 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -75,7 +75,15 @@ pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, p return stream; } -pub fn _cancel(self: *const ReadableStream, page: *Page) Env.Promise { +pub fn get_locked(self: *const ReadableStream) bool { + return self.locked; +} + +pub fn _cancel(self: *const ReadableStream, page: *Page) !Env.Promise { + if (self.locked) { + return error.TypeError; + } + const resolver = Env.PromiseResolver{ .js_context = page.main_context, .resolver = self.cancel_resolver.castToPromiseResolver(), @@ -84,21 +92,28 @@ pub fn _cancel(self: *const ReadableStream, page: *Page) Env.Promise { return resolver.promise(); } -pub fn get_locked(self: *const ReadableStream) bool { - return self.locked; -} - const GetReaderOptions = struct { + // Mode must equal 'byob' or be undefined. RangeError otherwise. mode: ?[]const u8 = null, }; -pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions, page: *Page) ReadableStreamDefaultReader { +pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions, page: *Page) !ReadableStreamDefaultReader { + if (self.locked) { + return error.TypeError; + } + const options = _options orelse GetReaderOptions{}; _ = options; return ReadableStreamDefaultReader.constructor(self, page); } +// TODO: pipeThrough (requires TransformStream) + +// TODO: pipeTo (requires WritableStream) + +// TODO: tee + const testing = @import("../../testing.zig"); test "streams: ReadableStream" { var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); diff --git a/src/browser/streams/ReadableStreamDefaultController.zig b/src/browser/streams/ReadableStreamDefaultController.zig index 4cd10c7a2..ed1ca9a99 100644 --- a/src/browser/streams/ReadableStreamDefaultController.zig +++ b/src/browser/streams/ReadableStreamDefaultController.zig @@ -54,5 +54,4 @@ pub fn _enqueue(self: *ReadableStreamDefaultController, chunk: []const u8, page: pub fn _error(self: *ReadableStreamDefaultController, err: Env.JsObject) void { self.stream.state = .{ .errored = err }; - // set to error. } diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig index 307dd75e2..1646a8553 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -47,8 +47,8 @@ pub fn get_closed(self: *const ReadableStreamDefaultReader) Env.Promise { return self.closed_resolver.promise(); } -pub fn _cancel(self: *ReadableStreamDefaultReader, page: *Page) Env.Promise { - return self.stream._cancel(page); +pub fn _cancel(self: *ReadableStreamDefaultReader, page: *Page) !Env.Promise { + return try self.stream._cancel(page); } pub const ReadableStreamReadResult = struct { From 1c89cfe5d4c68980892774053394697a7b6ed21d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 4 Sep 2025 20:29:41 -0700 Subject: [PATCH 18/43] working Header iterators --- src/browser/fetch/Headers.zig | 72 +++++++++++++++++++++++++++++----- src/browser/fetch/Request.zig | 4 +- src/browser/fetch/Response.zig | 2 +- src/browser/fetch/fetch.zig | 7 +++- 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index 74157263b..e6acd9354 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -79,8 +79,8 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { return error.TypeError; } - const key = try page.arena.dupe(u8, pair[0]); - const value = try page.arena.dupe(u8, pair[1]); + const key = try arena.dupe(u8, pair[0]); + const value = try arena.dupe(u8, pair[1]); try headers.put(arena, key, value); } @@ -88,8 +88,8 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { .headers => |hdrs| { var iter = hdrs.headers.iterator(); while (iter.next()) |entry| { - const key = try page.arena.dupe(u8, entry.key_ptr.*); - const value = try page.arena.dupe(u8, entry.value_ptr.*); + const key = try arena.dupe(u8, entry.key_ptr.*); + const value = try arena.dupe(u8, entry.value_ptr.*); try headers.put(arena, key, value); } }, @@ -129,10 +129,29 @@ pub fn _delete(self: *Headers, name: []const u8) void { _ = self.headers.remove(name); } -// TODO: entries iterator -// They should be: -// 1. Sorted in lexicographical order. -// 2. Duplicate header names should be combined. +pub const HeaderEntryIterator = struct { + slot: [][]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: *HeaderEntryIterator) !?[]const []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, page: *Page) !HeaderEntryIterator { + const iter = try page.arena.create(HeaderHashMap.Iterator); + iter.* = self.headers.iterator(); + + return .{ .slot = try page.arena.alloc([]const u8, 2), .iter = iter }; +} pub fn _forEach(self: *Headers, callback_fn: Env.Function, this_arg: ?Env.JsObject) !void { var iter = self.headers.iterator(); @@ -163,7 +182,24 @@ pub fn _has(self: *const Headers, name: []const u8) bool { return self.headers.contains(name); } -// TODO: keys iterator +pub const HeaderKeyIterator = struct { + iter: *HeaderHashMap.KeyIterator, + + pub fn _next(self: *HeaderKeyIterator) !?[]const u8 { + if (self.iter.next()) |key| { + return key.*; + } else { + return null; + } + } +}; + +pub fn _keys(self: *const Headers, page: *Page) !HeaderKeyIterator { + const iter = try page.arena.create(HeaderHashMap.KeyIterator); + iter.* = self.headers.keyIterator(); + + return .{ .iter = iter }; +} pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { const arena = page.arena; @@ -172,7 +208,23 @@ pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !v gop.value_ptr.* = try arena.dupe(u8, value); } -// TODO: values iterator +pub const HeaderValueIterator = struct { + iter: *HeaderHashMap.ValueIterator, + + pub fn _next(self: *HeaderValueIterator) !?[]const u8 { + if (self.iter.next()) |value| { + return value.*; + } else { + return null; + } + } +}; + +pub fn _values(self: *const Headers, page: *Page) !HeaderValueIterator { + const iter = try page.arena.create(HeaderHashMap.ValueIterator); + iter.* = self.headers.valueIterator(); + return .{ .iter = iter }; +} const testing = @import("../../testing.zig"); test "fetch: headers" { diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 6f4e37513..cf59c32e8 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -98,7 +98,7 @@ pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Re const arena = page.arena; const options: RequestInit = _options orelse .{}; - const url = blk: switch (input) { + const url: [:0]const u8 = blk: switch (input) { .string => |str| { break :blk try URL.stitch(arena, str, page.url.raw, .{ .null_terminated = true }); }, @@ -111,7 +111,7 @@ pub fn constructor(input: RequestInput, _options: ?RequestInit, page: *Page) !Re 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 = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else Headers{}; + const headers: Headers = if (options.headers) |hdrs| try Headers.constructor(hdrs, page) else .{}; const method: Http.Method = blk: { if (options.method) |given_method| { diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index 21a950de1..858e84877 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -37,7 +37,7 @@ const Page = @import("../page.zig").Page; const Response = @This(); status: u16 = 0, -headers: Headers = .{}, +headers: Headers, mime: ?Mime = null, url: []const u8 = "", body: []const u8 = "", diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index fc8e9b781..a9e47ea87 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -26,6 +26,7 @@ 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; @@ -35,6 +36,9 @@ const Response = @import("Response.zig"); pub const Interfaces = .{ @import("Headers.zig"), + @import("Headers.zig").HeaderEntryIterator, + @import("Headers.zig").HeaderKeyIterator, + @import("Headers.zig").HeaderValueIterator, @import("Request.zig"), @import("Response.zig"), }; @@ -157,6 +161,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .done_callback = struct { fn doneCallback(ctx: *anyopaque) !void { const self: *FetchContext = @ptrCast(@alignCast(ctx)); + self.transfer = null; log.info(.http, "request complete", .{ .source = "fetch", @@ -177,8 +182,8 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .error_callback = struct { fn errorCallback(ctx: *anyopaque, err: anyerror) void { const self: *FetchContext = @ptrCast(@alignCast(ctx)); - self.transfer = null; + const promise_resolver: Env.PromiseResolver = .{ .js_context = self.js_ctx, .resolver = self.promise_resolver.castToPromiseResolver(), From 5997be89f618811b5cc15f133343e36545c8aa0f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 4 Sep 2025 23:26:59 -0700 Subject: [PATCH 19/43] implement remaining ReadableStream functionality --- src/browser/streams/ReadableStream.zig | 199 +++++++++++++++++- .../ReadableStreamDefaultController.zig | 41 +++- .../streams/ReadableStreamDefaultReader.zig | 57 +++-- src/browser/streams/streams.zig | 2 +- 4 files changed, 271 insertions(+), 28 deletions(-) diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index a33459d66..bc8fb41b0 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -30,6 +30,7 @@ const ReadableStreamDefaultController = @import("ReadableStreamDefaultController const State = union(enum) { readable, closed: ?[]const u8, + cancelled: ?[]const u8, errored: Env.JsObject, }; @@ -38,8 +39,29 @@ cancel_resolver: v8.Persistent(v8.PromiseResolver), locked: bool = false, state: State = .readable, +cancel_fn: ?Env.Function = null, +pull_fn: ?Env.Function = null, + +strategy: QueueingStrategy, +reader_resolver: ?v8.Persistent(v8.PromiseResolver) = null, 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, @@ -49,11 +71,11 @@ const UnderlyingSource = struct { const QueueingStrategy = struct { size: ?Env.Function = null, - high_water_mark: f64 = 1.0, + high_water_mark: u32 = 1, }; -pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, page: *Page) !*ReadableStream { - _ = strategy; +pub fn constructor(underlying: ?UnderlyingSource, _strategy: ?QueueingStrategy, page: *Page) !*ReadableStream { + const strategy: QueueingStrategy = _strategy orelse .{}; const cancel_resolver = v8.Persistent(v8.PromiseResolver).init( page.main_context.isolate, @@ -61,7 +83,7 @@ pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, p ); const stream = try page.arena.create(ReadableStream); - stream.* = ReadableStream{ .cancel_resolver = cancel_resolver }; + stream.* = ReadableStream{ .cancel_resolver = cancel_resolver, .strategy = strategy }; const controller = ReadableStreamDefaultController{ .stream = stream }; @@ -70,6 +92,15 @@ pub fn constructor(underlying: ?UnderlyingSource, strategy: ?QueueingStrategy, p 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; @@ -79,7 +110,7 @@ pub fn get_locked(self: *const ReadableStream) bool { return self.locked; } -pub fn _cancel(self: *const ReadableStream, page: *Page) !Env.Promise { +pub fn _cancel(self: *ReadableStream, reason: ?[]const u8, page: *Page) !Env.Promise { if (self.locked) { return error.TypeError; } @@ -89,9 +120,31 @@ pub fn _cancel(self: *const ReadableStream, page: *Page) !Env.Promise { .resolver = self.cancel_resolver.castToPromiseResolver(), }; + 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 resolver.resolve({}); return 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, @@ -102,6 +155,7 @@ pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions, page: *Pag return error.TypeError; } + // TODO: Determine if we need the ReadableStreamBYOBReader const options = _options orelse GetReaderOptions{}; _ = options; @@ -144,3 +198,138 @@ test "streams: ReadableStream" { .{ "readResult.done", "false" }, }, .{}); } + +test "streams: ReadableStream cancel and close" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); + defer runner.deinit(); + try runner.testCases(&.{ + .{ "var readResult; var cancelResult; var closeResult;", "undefined" }, + + // Test 1: Stream with controller.close() + .{ + \\ const stream1 = new ReadableStream({ + \\ start(controller) { + \\ controller.enqueue("first"); + \\ controller.enqueue("second"); + \\ controller.close(); + \\ } + \\ }); + , + undefined, + }, + .{ "const reader1 = stream1.getReader();", undefined }, + .{ + \\ (async function () { + \\ readResult = await reader1.read(); + \\ }()); + \\ false; + , + "false", + }, + .{ "readResult.value", "first" }, + .{ "readResult.done", "false" }, + + // Read second chunk + .{ + \\ (async function () { + \\ readResult = await reader1.read(); + \\ }()); + \\ false; + , + "false", + }, + .{ "readResult.value", "second" }, + .{ "readResult.done", "false" }, + + // Read after close - should get done: true + .{ + \\ (async function () { + \\ readResult = await reader1.read(); + \\ }()); + \\ false; + , + "false", + }, + .{ "readResult.value", "undefined" }, + .{ "readResult.done", "true" }, + + // Test 2: Stream with reader.cancel() + .{ + \\ const stream2 = new ReadableStream({ + \\ start(controller) { + \\ controller.enqueue("data1"); + \\ controller.enqueue("data2"); + \\ controller.enqueue("data3"); + \\ }, + \\ cancel(reason) { + \\ closeResult = `Stream cancelled: ${reason}`; + \\ } + \\ }); + , + undefined, + }, + .{ "const reader2 = stream2.getReader();", undefined }, + + // Read one chunk before canceling + .{ + \\ (async function () { + \\ readResult = await reader2.read(); + \\ }()); + \\ false; + , + "false", + }, + .{ "readResult.value", "data1" }, + .{ "readResult.done", "false" }, + + // Cancel the stream + .{ + \\ (async function () { + \\ cancelResult = await reader2.cancel("user requested"); + \\ }()); + \\ false; + , + "false", + }, + .{ "cancelResult", "undefined" }, + .{ "closeResult", "Stream cancelled: user requested" }, + + // Try to read after cancel - should throw or return done + .{ + \\ try { + \\ (async function () { + \\ readResult = await reader2.read(); + \\ }()); + \\ } catch(e) { + \\ readResult = { error: e.name }; + \\ } + \\ false; + , + "false", + }, + + // Test 3: Cancel without reason + .{ + \\ const stream3 = new ReadableStream({ + \\ start(controller) { + \\ controller.enqueue("test"); + \\ }, + \\ cancel(reason) { + \\ closeResult = reason === undefined ? "no reason" : reason; + \\ } + \\ }); + , + undefined, + }, + .{ "const reader3 = stream3.getReader();", undefined }, + .{ + \\ (async function () { + \\ await reader3.cancel(); + \\ }()); + \\ false; + , + "false", + }, + .{ "closeResult", "no reason" }, + }, .{}); +} diff --git a/src/browser/streams/ReadableStreamDefaultController.zig b/src/browser/streams/ReadableStreamDefaultController.zig index ed1ca9a99..df55c85e9 100644 --- a/src/browser/streams/ReadableStreamDefaultController.zig +++ b/src/browser/streams/ReadableStreamDefaultController.zig @@ -24,6 +24,7 @@ const Env = @import("../env.zig").Env; const v8 = @import("v8"); const ReadableStream = @import("./ReadableStream.zig"); +const ReadableStreamReadResult = @import("./ReadableStream.zig").ReadableStreamReadResult; const ReadableStreamDefaultController = @This(); @@ -38,6 +39,16 @@ pub fn _close(self: *ReadableStreamDefaultController, _reason: ?[]const u8, page const reason = if (_reason) |reason| try page.arena.dupe(u8, reason) else null; self.stream.state = .{ .closed = reason }; + if (self.stream.reader_resolver) |rr| { + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = rr.castToPromiseResolver(), + }; + + try resolver.resolve(ReadableStreamReadResult{ .value = .empty, .done = true }); + self.stream.reader_resolver = null; + } + // 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. } @@ -49,9 +60,37 @@ pub fn _enqueue(self: *ReadableStreamDefaultController, chunk: []const u8, page: return error.TypeError; } + if (self.stream.reader_resolver) |rr| { + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = rr.castToPromiseResolver(), + }; + + try resolver.resolve(ReadableStreamReadResult{ .value = .{ .data = chunk }, .done = false }); + self.stream.reader_resolver = null; + + // rr.setWeakFinalizer(@ptrCast(self.stream), struct { + // fn callback(info: ?*v8.c.WeakCallbackInfo) void { + // const inner_stream: *ReadableStream = @ptrCast(@alignCast(v8.c.v8__WeakCallbackInfo__GetParameter(info).?)); + // inner_stream.reader_resolver = null; + // } + // }.callback, .kParameter); + } + try self.stream.queue.append(page.arena, chunk); + try self.stream.pullIf(); } -pub fn _error(self: *ReadableStreamDefaultController, err: Env.JsObject) void { +pub fn _error(self: *ReadableStreamDefaultController, err: Env.JsObject, page: *Page) !void { self.stream.state = .{ .errored = err }; + + if (self.stream.reader_resolver) |rr| { + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = rr.castToPromiseResolver(), + }; + + try resolver.reject(err); + self.stream.reader_resolver = null; + } } diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig index 1646a8553..a339fb7a7 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -24,6 +24,7 @@ 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(); @@ -47,23 +48,10 @@ pub fn get_closed(self: *const ReadableStreamDefaultReader) Env.Promise { return self.closed_resolver.promise(); } -pub fn _cancel(self: *ReadableStreamDefaultReader, page: *Page) !Env.Promise { - return try self.stream._cancel(page); +pub fn _cancel(self: *ReadableStreamDefaultReader, reason: ?[]const u8, page: *Page) !Env.Promise { + return try self.stream._cancel(reason, page); } -pub const ReadableStreamReadResult = struct { - value: ?[]const u8, - done: bool, - - pub fn get_value(self: *const ReadableStreamReadResult) !?[]const u8 { - return self.value; - } - - pub fn get_done(self: *const ReadableStreamReadResult) bool { - return self.done; - } -}; - pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise { const stream = self.stream; @@ -76,21 +64,35 @@ pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise .readable => { if (stream.queue.items.len > 0) { const data = self.stream.queue.orderedRemove(0); - try resolver.resolve(ReadableStreamReadResult{ .value = data, .done = false }); + try resolver.resolve(ReadableStreamReadResult{ .value = .{ .data = data }, .done = false }); } else { - // TODO: need to wait until we have more data - try resolver.reject("TODO!"); - return error.Todo; + if (self.stream.reader_resolver) |rr| { + const r_resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = rr.castToPromiseResolver(), + }; + + return r_resolver.promise(); + } else { + const p_resolver = v8.Persistent(v8.PromiseResolver).init(page.main_context.isolate, resolver.resolver); + self.stream.reader_resolver = p_resolver; + return resolver.promise(); + } + + try self.stream.pullIf(); } }, .closed => |_| { if (stream.queue.items.len > 0) { const data = self.stream.queue.orderedRemove(0); - try resolver.resolve(ReadableStreamReadResult{ .value = data, .done = false }); + try resolver.resolve(ReadableStreamReadResult{ .value = .{ .data = data }, .done = false }); } else { - try resolver.resolve(ReadableStreamReadResult{ .value = null, .done = true }); + try resolver.resolve(ReadableStreamReadResult{ .value = .empty, .done = true }); } }, + .cancelled => |_| { + try resolver.resolve(ReadableStreamReadResult{ .value = .empty, .done = true }); + }, .errored => |err| { try resolver.reject(err); }, @@ -98,3 +100,16 @@ pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise return resolver.promise(); } + +pub fn _releaseLock(self: *const ReadableStreamDefaultReader, page: *Page) !void { + self.stream.locked = false; + + if (self.stream.reader_resolver) |rr| { + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = rr.castToPromiseResolver(), + }; + + try resolver.reject("TypeError"); + } +} diff --git a/src/browser/streams/streams.zig b/src/browser/streams/streams.zig index c33f5aa62..f345640fb 100644 --- a/src/browser/streams/streams.zig +++ b/src/browser/streams/streams.zig @@ -18,7 +18,7 @@ pub const Interfaces = .{ @import("ReadableStream.zig"), + @import("ReadableStream.zig").ReadableStreamReadResult, @import("ReadableStreamDefaultReader.zig"), - @import("ReadableStreamDefaultReader.zig").ReadableStreamReadResult, @import("ReadableStreamDefaultController.zig"), }; From 8295c2abe5a77e1d77247e13d8aac7587e6eb0f5 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:34:07 +0200 Subject: [PATCH 20/43] jsValueToZig for fixed sized arrays --- src/browser/fetch/Headers.zig | 2 +- src/runtime/js.zig | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index e6acd9354..3179c763b 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -62,7 +62,7 @@ headers: HeaderHashMap = .empty, // 3. Another Headers object. pub const HeadersInit = union(enum) { // List of Pairs of []const u8 - strings: []const []const []const u8, + strings: []const [2][]const u8, headers: *Headers, }; diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 151b1b8d2..996a9ebd5 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -1130,6 +1130,19 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }, else => {}, }, + .array => |arr| { + // Retrieve fixed-size array as slice then copy it + 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; + } + + var result: T = undefined; + @memcpy(&result, slice_value[0..arr.len]); + return result; + }, .@"struct" => { return try (self.jsValueToStruct(named_function, T, js_value)) orelse { return error.InvalidArgument; @@ -1413,6 +1426,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 .{ .ok = {} }; + } + 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 valueToString(self.call_arena, js_value, self.isolate, self.v8_context); + if (str.len == 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. From a5e2e8ea15638c697c88924718952846faaa5e85 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:43:28 +0200 Subject: [PATCH 21/43] remove length check of fixed size --- src/browser/fetch/Headers.zig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index 3179c763b..d10a2b1dd 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -74,11 +74,6 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { switch (init) { .strings => |kvs| { for (kvs) |pair| { - // Can only have two string elements if in a pair. - if (pair.len != 2) { - return error.TypeError; - } - const key = try arena.dupe(u8, pair[0]); const value = try arena.dupe(u8, pair[1]); From dc60fac90dd96305bba3efaa2500a478936e3505 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:50:04 +0200 Subject: [PATCH 22/43] avoid explicit memcpy --- src/runtime/js.zig | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 996a9ebd5..d50429686 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -1131,17 +1131,14 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { else => {}, }, .array => |arr| { - // Retrieve fixed-size array as slice then copy it + // 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; } - - var result: T = undefined; - @memcpy(&result, slice_value[0..arr.len]); - return result; + return @as(*T, @ptrCast(slice_value.ptr)).*; }, .@"struct" => { return try (self.jsValueToStruct(named_function, T, js_value)) orelse { From a3c2daf30680833cd9554897f74a6044c3b9d26a Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 5 Sep 2025 17:19:36 +0200 Subject: [PATCH 23/43] retain value, avoid str alloc --- src/runtime/js.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/js.zig b/src/runtime/js.zig index d50429686..d9f7c4700 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -1429,7 +1429,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { switch (try self.probeJsValueToZig(named_function, slice_type, js_value)) { .value => |slice_value| { if (slice_value.len == arr.len) { - return .{ .ok = {} }; + return .{ .value = @as(*T, @ptrCast(slice_value.ptr)).* }; } return .{ .invalid = {} }; }, @@ -1441,8 +1441,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { return .{ .ok = {} }; } } else if (js_value.isString() and arr.child == u8) { - const str = try valueToString(self.call_arena, js_value, self.isolate, self.v8_context); - if (str.len == arr.len) { + const str = try js_value.toString(self.v8_context); + if (str.lenUtf8(self.isolate) == arr.len) { return .{ .ok = {} }; } } From 141d17dd5569d21649ca62cbe5266c10122eb331 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 7 Sep 2025 23:39:00 -0700 Subject: [PATCH 24/43] add logging on fetch error callback --- src/browser/fetch/fetch.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index a9e47ea87..680f91a5c 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -184,6 +184,12 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi const self: *FetchContext = @ptrCast(@alignCast(ctx)); self.transfer = null; + log.err(.http, "error", .{ + .url = self.url, + .err = err, + .source = "fetch error", + }); + const promise_resolver: Env.PromiseResolver = .{ .js_context = self.js_ctx, .resolver = self.promise_resolver.castToPromiseResolver(), From 01966f41ffd1b39224a66b33000845b11fe2d1a7 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 7 Sep 2025 23:48:23 -0700 Subject: [PATCH 25/43] support object as HeadersInit --- src/browser/fetch/Headers.zig | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index d10a2b1dd..557247db5 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -63,6 +63,8 @@ headers: HeaderHashMap = .empty, pub const HeadersInit = union(enum) { // List of Pairs of []const u8 strings: []const [2][]const u8, + // Mappings + mappings: Env.JsObject, headers: *Headers, }; @@ -80,6 +82,19 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { try headers.put(arena, key, value); } }, + .mappings => |obj| { + var iter = obj.nameIterator(); + while (try iter.next()) |name_value| { + const name = try name_value.toString(arena); + const value = Env.Value{ + .js_context = page.main_context, + .value = name_value.value, + }; + const value_string = try value.toString(arena); + + try headers.put(arena, name, value_string); + } + }, .headers => |hdrs| { var iter = hdrs.headers.iterator(); while (iter.next()) |entry| { From 707116a030cdbb6d09d89df4d8c939475b205e6d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Sep 2025 07:07:07 -0700 Subject: [PATCH 26/43] use destructor callback for FetchContext --- src/browser/fetch/fetch.zig | 23 +++++++++++++++-------- src/runtime/js.zig | 4 ++-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 680f91a5c..72fdb8b22 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -43,7 +43,7 @@ pub const Interfaces = .{ @import("Response.zig"), }; -const FetchContext = struct { +pub const FetchContext = struct { arena: std.mem.Allocator, js_ctx: *Env.JsContext, promise_resolver: v8.Persistent(v8.PromiseResolver), @@ -79,6 +79,17 @@ const FetchContext = struct { .url = self.url, }; } + + pub fn destructor(self: *FetchContext) void { + if (self.transfer) |_| { + const resolver = Env.PromiseResolver{ + .js_context = self.js_ctx, + .resolver = self.promise_resolver.castToPromiseResolver(), + }; + + resolver.reject("TypeError") catch unreachable; + } + } }; // https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch @@ -107,6 +118,9 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .url = req.url, }; + // Add destructor callback for FetchContext. + try page.main_context.destructor_callbacks.append(arena, Env.DestructorCallback.init(fetch_ctx)); + try page.http_client.request(.{ .ctx = @ptrCast(fetch_ctx), .url = req.url, @@ -189,13 +203,6 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .err = err, .source = "fetch error", }); - - const promise_resolver: Env.PromiseResolver = .{ - .js_context = self.js_ctx, - .resolver = self.promise_resolver.castToPromiseResolver(), - }; - - promise_resolver.reject(@errorName(err)) catch unreachable; } }.errorCallback, }); diff --git a/src/runtime/js.zig b/src/runtime/js.zig index d9f7c4700..6eec9e61d 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -2901,11 +2901,11 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { // An interface for types that want to have their jsDeinit function to be // called when the call context ends - const DestructorCallback = struct { + pub const DestructorCallback = struct { ptr: *anyopaque, destructorFn: *const fn (ptr: *anyopaque) void, - fn init(ptr: anytype) DestructorCallback { + pub fn init(ptr: anytype) DestructorCallback { const T = @TypeOf(ptr); const ptr_info = @typeInfo(T); From 968c695da170bdbe0cbbc0800abef6463df0885c Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Sep 2025 07:23:13 -0700 Subject: [PATCH 27/43] headers iterators should not allocate --- src/browser/fetch/Headers.zig | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index 557247db5..c798532a7 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -141,11 +141,11 @@ pub fn _delete(self: *Headers, name: []const u8) void { pub const HeaderEntryIterator = struct { slot: [][]const u8, - iter: *HeaderHashMap.Iterator, + iter: HeaderHashMap.Iterator, // TODO: these SHOULD be in lexigraphical order but I'm not sure how actually // important that is. - pub fn _next(self: *HeaderEntryIterator) !?[]const []const u8 { + pub fn _next(self: *HeaderEntryIterator) ?[]const []const u8 { if (self.iter.next()) |entry| { self.slot[0] = entry.key_ptr.*; self.slot[1] = entry.value_ptr.*; @@ -157,10 +157,10 @@ pub const HeaderEntryIterator = struct { }; pub fn _entries(self: *const Headers, page: *Page) !HeaderEntryIterator { - const iter = try page.arena.create(HeaderHashMap.Iterator); - iter.* = self.headers.iterator(); - - return .{ .slot = try page.arena.alloc([]const u8, 2), .iter = iter }; + return .{ + .slot = try page.arena.alloc([]const u8, 2), + .iter = self.headers.iterator(), + }; } pub fn _forEach(self: *Headers, callback_fn: Env.Function, this_arg: ?Env.JsObject) !void { @@ -193,7 +193,7 @@ pub fn _has(self: *const Headers, name: []const u8) bool { } pub const HeaderKeyIterator = struct { - iter: *HeaderHashMap.KeyIterator, + iter: HeaderHashMap.KeyIterator, pub fn _next(self: *HeaderKeyIterator) !?[]const u8 { if (self.iter.next()) |key| { @@ -204,11 +204,8 @@ pub const HeaderKeyIterator = struct { } }; -pub fn _keys(self: *const Headers, page: *Page) !HeaderKeyIterator { - const iter = try page.arena.create(HeaderHashMap.KeyIterator); - iter.* = self.headers.keyIterator(); - - return .{ .iter = iter }; +pub fn _keys(self: *const Headers, _: *Page) HeaderKeyIterator { + return .{ .iter = self.headers.keyIterator() }; } pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !void { @@ -219,7 +216,7 @@ pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !v } pub const HeaderValueIterator = struct { - iter: *HeaderHashMap.ValueIterator, + iter: HeaderHashMap.ValueIterator, pub fn _next(self: *HeaderValueIterator) !?[]const u8 { if (self.iter.next()) |value| { @@ -230,10 +227,8 @@ pub const HeaderValueIterator = struct { } }; -pub fn _values(self: *const Headers, page: *Page) !HeaderValueIterator { - const iter = try page.arena.create(HeaderHashMap.ValueIterator); - iter.* = self.headers.valueIterator(); - return .{ .iter = iter }; +pub fn _values(self: *const Headers, _: *Page) HeaderValueIterator { + return .{ .iter = self.headers.valueIterator() }; } const testing = @import("../../testing.zig"); From e133717f7fce3ac72a263527f81f531a2fa2eb26 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Sep 2025 07:43:25 -0700 Subject: [PATCH 28/43] simplify Headers --- src/browser/fetch/Headers.zig | 82 ++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index c798532a7..a8e5ed074 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -63,9 +63,10 @@ headers: HeaderHashMap = .empty, pub const HeadersInit = union(enum) { // List of Pairs of []const u8 strings: []const [2][]const u8, - // Mappings - mappings: Env.JsObject, + // Headers headers: *Headers, + // Mappings + object: Env.JsObject, }; pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { @@ -82,27 +83,22 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { try headers.put(arena, key, value); } }, - .mappings => |obj| { + .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 = Env.Value{ - .js_context = page.main_context, - .value = name_value.value, - }; + const value = try obj.get(name); const value_string = try value.toString(arena); try headers.put(arena, name, value_string); } }, - .headers => |hdrs| { - var iter = hdrs.headers.iterator(); - while (iter.next()) |entry| { - const key = try arena.dupe(u8, entry.key_ptr.*); - const value = try arena.dupe(u8, entry.value_ptr.*); - try headers.put(arena, key, value); - } - }, } } @@ -140,12 +136,12 @@ pub fn _delete(self: *Headers, name: []const u8) void { } pub const HeaderEntryIterator = struct { - slot: [][]const u8, + 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: *HeaderEntryIterator) ?[]const []const u8 { + pub fn _next(self: *HeaderEntryIterator) ?[2][]const u8 { if (self.iter.next()) |entry| { self.slot[0] = entry.key_ptr.*; self.slot[1] = entry.value_ptr.*; @@ -156,9 +152,9 @@ pub const HeaderEntryIterator = struct { } }; -pub fn _entries(self: *const Headers, page: *Page) !HeaderEntryIterator { +pub fn _entries(self: *const Headers) HeaderEntryIterator { return .{ - .slot = try page.arena.alloc([]const u8, 2), + .slot = undefined, .iter = self.headers.iterator(), }; } @@ -166,21 +162,10 @@ pub fn _entries(self: *const Headers, page: *Page) !HeaderEntryIterator { pub fn _forEach(self: *Headers, callback_fn: Env.Function, this_arg: ?Env.JsObject) !void { var iter = self.headers.iterator(); - if (this_arg) |this| { - while (iter.next()) |entry| { - try callback_fn.callWithThis( - void, - this, - .{ entry.key_ptr.*, entry.value_ptr.*, self }, - ); - } - } else { - while (iter.next()) |entry| { - try callback_fn.call( - void, - .{ entry.key_ptr.*, entry.value_ptr.*, self }, - ); - } + 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 }); } } @@ -195,7 +180,7 @@ pub fn _has(self: *const Headers, name: []const u8) bool { pub const HeaderKeyIterator = struct { iter: HeaderHashMap.KeyIterator, - pub fn _next(self: *HeaderKeyIterator) !?[]const u8 { + pub fn _next(self: *HeaderKeyIterator) ?[]const u8 { if (self.iter.next()) |key| { return key.*; } else { @@ -204,7 +189,7 @@ pub const HeaderKeyIterator = struct { } }; -pub fn _keys(self: *const Headers, _: *Page) HeaderKeyIterator { +pub fn _keys(self: *const Headers) HeaderKeyIterator { return .{ .iter = self.headers.keyIterator() }; } @@ -218,7 +203,7 @@ pub fn _set(self: *Headers, name: []const u8, value: []const u8, page: *Page) !v pub const HeaderValueIterator = struct { iter: HeaderHashMap.ValueIterator, - pub fn _next(self: *HeaderValueIterator) !?[]const u8 { + pub fn _next(self: *HeaderValueIterator) ?[]const u8 { if (self.iter.next()) |value| { return value.*; } else { @@ -227,7 +212,7 @@ pub const HeaderValueIterator = struct { } }; -pub fn _values(self: *const Headers, _: *Page) HeaderValueIterator { +pub fn _values(self: *const Headers) HeaderValueIterator { return .{ .iter = self.headers.valueIterator() }; } @@ -237,11 +222,11 @@ test "fetch: headers" { defer runner.deinit(); try runner.testCases(&.{ - .{ "let empty_headers = new Headers()", "undefined" }, + .{ "let emptyHeaders = new Headers()", "undefined" }, }, .{}); try runner.testCases(&.{ - .{ "let headers = new Headers([['Set-Cookie', 'name=world']])", "undefined" }, + .{ "let headers = new Headers({'Set-Cookie': 'name=world'})", "undefined" }, .{ "headers.get('set-cookie')", "name=world" }, }, .{}); @@ -259,4 +244,21 @@ test "fetch: headers" { .{ "myHeaders.get('Picture-Type')", "image/svg" }, .{ "myHeaders.has('Picture-Type')", "true" }, }, .{}); + + try runner.testCases(&.{ + .{ "const originalHeaders = new Headers([['Content-Type', 'application/json'], ['Authorization', 'Bearer token123']])", "undefined" }, + .{ "originalHeaders.get('Content-Type')", "application/json" }, + .{ "originalHeaders.get('Authorization')", "Bearer token123" }, + .{ "const newHeaders = new Headers(originalHeaders)", "undefined" }, + .{ "newHeaders.get('Content-Type')", "application/json" }, + .{ "newHeaders.get('Authorization')", "Bearer token123" }, + .{ "newHeaders.has('Content-Type')", "true" }, + .{ "newHeaders.has('Authorization')", "true" }, + .{ "newHeaders.has('X-Custom')", "false" }, + // Verify that modifying the new headers doesn't affect the original + .{ "newHeaders.set('X-Custom', 'test-value')", "undefined" }, + .{ "newHeaders.get('X-Custom')", "test-value" }, + .{ "originalHeaders.get('X-Custom')", "null" }, + .{ "originalHeaders.has('X-Custom')", "false" }, + }, .{}); } From 03130a95d83278b63d7ed872d214885f43f7dc4b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Sep 2025 07:49:04 -0700 Subject: [PATCH 29/43] use call arena for json in Req/Resp --- src/browser/fetch/Request.zig | 4 ++-- src/browser/fetch/Response.zig | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index cf59c32e8..67c7c1049 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -220,11 +220,11 @@ pub fn _json(self: *Response, page: *Page) !Env.Promise { const p = std.json.parseFromSliceLeaky( std.json.Value, - page.arena, + page.call_arena, self.body, .{}, ) catch |e| { - log.warn(.browser, "invalid json", .{ .err = e, .source = "Request" }); + log.info(.browser, "invalid json", .{ .err = e, .source = "Request" }); return error.SyntaxError; }; diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index 858e84877..e60189065 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -154,11 +154,11 @@ pub fn _json(self: *Response, page: *Page) !Env.Promise { const p = std.json.parseFromSliceLeaky( std.json.Value, - page.arena, + page.call_arena, self.body, .{}, ) catch |e| { - log.warn(.browser, "invalid json", .{ .err = e, .source = "fetch" }); + log.info(.browser, "invalid json", .{ .err = e, .source = "Response" }); return error.SyntaxError; }; From ebb590250fe630ada7ca83c7870a240969d1fff4 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Sep 2025 07:54:25 -0700 Subject: [PATCH 30/43] simplify cloning of Req/Resp --- src/browser/fetch/Headers.zig | 6 ------ src/browser/fetch/Request.zig | 14 +++++++------- src/browser/fetch/Response.zig | 14 +++++++------- src/browser/mime.zig | 16 ---------------- 4 files changed, 14 insertions(+), 36 deletions(-) diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index a8e5ed074..2ea412cfa 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -107,12 +107,6 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { }; } -pub fn clone(self: *const Headers, allocator: std.mem.Allocator) !Headers { - return Headers{ - .headers = try self.headers.clone(allocator), - }; -} - pub fn append(self: *Headers, name: []const u8, value: []const u8, allocator: std.mem.Allocator) !void { const gop = try self.headers.getOrPut(allocator, name); diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 67c7c1049..67dc7cab1 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -173,23 +173,23 @@ pub fn get_url(/service/self: *const Request) []const u8 { return self.url; } -pub fn _clone(self: *Request, page: *Page) !Request { +pub fn _clone(self: *Request) !Request { // Not allowed to clone if the body was used. if (self.body_used) { return error.TypeError; } - const arena = page.arena; - + // OK to just return the same fields BECAUSE + // all of these fields are read-only and can't be modified. return Request{ - .body = if (self.body) |body| try arena.dupe(u8, body) else null, + .body = self.body, .body_used = self.body_used, .cache = self.cache, .credentials = self.credentials, - .headers = try self.headers.clone(arena), + .headers = self.headers, .method = self.method, - .integrity = try arena.dupe(u8, self.integrity), - .url = try arena.dupeZ(u8, self.url), + .integrity = self.integrity, + .url = self.url, }; } diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index e60189065..be7cc8f19 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -109,21 +109,21 @@ pub fn get_url(/service/self: *const Response) []const u8 { return self.url; } -pub fn _clone(self: *const Response, page: *Page) !Response { +pub fn _clone(self: *const Response) !Response { if (self.body_used) { return error.TypeError; } - const arena = page.arena; - + // OK to just return the same fields BECAUSE + // all of these fields are read-only and can't be modified. return Response{ - .body = try arena.dupe(u8, self.body), + .body = self.body, .body_used = self.body_used, - .mime = if (self.mime) |mime| try mime.clone(arena) else null, - .headers = try self.headers.clone(arena), + .mime = self.mime, + .headers = self.headers, .redirected = self.redirected, .status = self.status, - .url = try arena.dupe(u8, self.url), + .url = self.url, }; } diff --git a/src/browser/mime.zig b/src/browser/mime.zig index 468ceb36f..33ab99589 100644 --- a/src/browser/mime.zig +++ b/src/browser/mime.zig @@ -290,22 +290,6 @@ pub const Mime = struct { fn trimRight(s: []const u8) []const u8 { return std.mem.trimRight(u8, s, &std.ascii.whitespace); } - - pub fn clone(self: *const Mime, allocator: Allocator) !Mime { - return Mime{ - .content_type = blk: { - switch (self.content_type) { - .other => |data| break :blk ContentType{ .other = .{ - .type = try allocator.dupe(u8, data.type), - .sub_type = try allocator.dupe(u8, data.sub_type), - } }, - else => break :blk self.content_type, - } - }, - .params = try allocator.dupe(u8, self.params), - .charset = if (self.charset) |charset| try allocator.dupeZ(u8, charset) else null, - }; - } }; const testing = @import("../testing.zig"); From ef1fece40cc57779c3d5c9687dc332a44d7c26eb Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Sep 2025 10:49:38 -0700 Subject: [PATCH 31/43] deinit persistent promise resolver --- src/browser/fetch/fetch.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 72fdb8b22..d3008d7b7 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -88,6 +88,7 @@ pub const FetchContext = struct { }; resolver.reject("TypeError") catch unreachable; + self.promise_resolver.deinit(); } } }; @@ -175,6 +176,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .done_callback = struct { fn doneCallback(ctx: *anyopaque) !void { const self: *FetchContext = @ptrCast(@alignCast(ctx)); + defer self.promise_resolver.deinit(); self.transfer = null; log.info(.http, "request complete", .{ From 7acf67d668dec712c7539fd1537cb06c0903635f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Sep 2025 11:03:31 -0700 Subject: [PATCH 32/43] properly handle closed for ReadableStream --- src/browser/streams/ReadableStream.zig | 24 +++++++++++++++---- .../ReadableStreamDefaultController.zig | 9 +++++++ .../streams/ReadableStreamDefaultReader.zig | 21 +++++++--------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index bc8fb41b0..8660123b8 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -36,6 +36,9 @@ const State = union(enum) { // This promise resolves when a stream is canceled. cancel_resolver: v8.Persistent(v8.PromiseResolver), +closed_resolver: v8.Persistent(v8.PromiseResolver), +reader_resolver: ?v8.Persistent(v8.PromiseResolver) = null, + locked: bool = false, state: State = .readable, @@ -43,7 +46,6 @@ cancel_fn: ?Env.Function = null, pull_fn: ?Env.Function = null, strategy: QueueingStrategy, -reader_resolver: ?v8.Persistent(v8.PromiseResolver) = null, queue: std.ArrayListUnmanaged([]const u8) = .empty, pub const ReadableStreamReadResult = struct { @@ -82,8 +84,13 @@ pub fn constructor(underlying: ?UnderlyingSource, _strategy: ?QueueingStrategy, v8.PromiseResolver.init(page.main_context.v8_context), ); + const closed_resolver = v8.Persistent(v8.PromiseResolver).init( + page.main_context.isolate, + v8.PromiseResolver.init(page.main_context.v8_context), + ); + const stream = try page.arena.create(ReadableStream); - stream.* = ReadableStream{ .cancel_resolver = cancel_resolver, .strategy = strategy }; + stream.* = ReadableStream{ .cancel_resolver = cancel_resolver, .closed_resolver = closed_resolver, .strategy = strategy }; const controller = ReadableStreamDefaultController{ .stream = stream }; @@ -106,6 +113,15 @@ pub fn constructor(underlying: ?UnderlyingSource, _strategy: ?QueueingStrategy, return stream; } +pub fn destructor(self: *ReadableStream) void { + self.cancel_resolver.deinit(); + self.closed_resolver.deinit(); + + if (self.reader_resolver) |*rr| { + rr.deinit(); + } +} + pub fn get_locked(self: *const ReadableStream) bool { return self.locked; } @@ -150,7 +166,7 @@ const GetReaderOptions = struct { mode: ?[]const u8 = null, }; -pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions, page: *Page) !ReadableStreamDefaultReader { +pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions) !ReadableStreamDefaultReader { if (self.locked) { return error.TypeError; } @@ -159,7 +175,7 @@ pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions, page: *Pag const options = _options orelse GetReaderOptions{}; _ = options; - return ReadableStreamDefaultReader.constructor(self, page); + return ReadableStreamDefaultReader.constructor(self); } // TODO: pipeThrough (requires TransformStream) diff --git a/src/browser/streams/ReadableStreamDefaultController.zig b/src/browser/streams/ReadableStreamDefaultController.zig index df55c85e9..af5057a7a 100644 --- a/src/browser/streams/ReadableStreamDefaultController.zig +++ b/src/browser/streams/ReadableStreamDefaultController.zig @@ -39,6 +39,7 @@ pub fn _close(self: *ReadableStreamDefaultController, _reason: ?[]const u8, page 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| { const resolver = Env.PromiseResolver{ .js_context = page.main_context, @@ -49,6 +50,14 @@ pub fn _close(self: *ReadableStreamDefaultController, _reason: ?[]const u8, page self.stream.reader_resolver = null; } + // Resolve the Closed promise. + const closed_resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = self.stream.closed_resolver.castToPromiseResolver(), + }; + + try 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. } diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig index a339fb7a7..ac7bcf687 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -29,23 +29,18 @@ const ReadableStreamReadResult = @import("./ReadableStream.zig").ReadableStreamR const ReadableStreamDefaultReader = @This(); stream: *ReadableStream, -// This promise resolves when the stream is closed. -closed_resolver: Env.PromiseResolver, -pub fn constructor(stream: *ReadableStream, page: *Page) ReadableStreamDefaultReader { - const closed_resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = v8.PromiseResolver.init(page.main_context.v8_context), - }; +pub fn constructor(stream: *ReadableStream) ReadableStreamDefaultReader { + return .{ .stream = stream }; +} - return .{ - .stream = stream, - .closed_resolver = closed_resolver, +pub fn get_closed(self: *const ReadableStreamDefaultReader, page: *Page) Env.Promise { + const resolver = Env.PromiseResolver{ + .js_context = page.main_context, + .resolver = self.stream.closed_resolver.castToPromiseResolver(), }; -} -pub fn get_closed(self: *const ReadableStreamDefaultReader) Env.Promise { - return self.closed_resolver.promise(); + return resolver.promise(); } pub fn _cancel(self: *ReadableStreamDefaultReader, reason: ?[]const u8, page: *Page) !Env.Promise { From 0423a178e94eecc8e507650aec9f450191caeb46 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Sep 2025 13:09:03 -0700 Subject: [PATCH 33/43] migrate fetch tests to htmlRunner --- src/browser/fetch/Headers.zig | 87 +++++++++------------------- src/browser/fetch/Request.zig | 60 +++++-------------- src/browser/fetch/Response.zig | 19 +++--- src/browser/fetch/fetch.zig | 6 +- src/tests/fetch/headers.html | 102 +++++++++++++++++++++++++++++++++ src/tests/fetch/request.html | 22 +++++++ src/tests/fetch/response.html | 38 ++++++++++++ 7 files changed, 219 insertions(+), 115 deletions(-) create mode 100644 src/tests/fetch/headers.html create mode 100644 src/tests/fetch/request.html create mode 100644 src/tests/fetch/response.html diff --git a/src/browser/fetch/Headers.zig b/src/browser/fetch/Headers.zig index 2ea412cfa..f7d83c3fd 100644 --- a/src/browser/fetch/Headers.zig +++ b/src/browser/fetch/Headers.zig @@ -17,9 +17,12 @@ // 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; @@ -108,7 +111,8 @@ pub fn constructor(_init: ?HeadersInit, page: *Page) !Headers { } pub fn append(self: *Headers, name: []const u8, value: []const u8, allocator: std.mem.Allocator) !void { - const gop = try self.headers.getOrPut(allocator, name); + 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. @@ -129,13 +133,13 @@ pub fn _delete(self: *Headers, name: []const u8) void { _ = self.headers.remove(name); } -pub const HeaderEntryIterator = struct { +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: *HeaderEntryIterator) ?[2][]const u8 { + 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.*; @@ -146,10 +150,12 @@ pub const HeaderEntryIterator = struct { } }; -pub fn _entries(self: *const Headers) HeaderEntryIterator { +pub fn _entries(self: *const Headers) HeadersEntryIterable { return .{ - .slot = undefined, - .iter = self.headers.iterator(), + .inner = .{ + .slot = undefined, + .iter = self.headers.iterator(), + }, }; } @@ -171,10 +177,10 @@ pub fn _has(self: *const Headers, name: []const u8) bool { return self.headers.contains(name); } -pub const HeaderKeyIterator = struct { +pub const HeadersKeyIterator = struct { iter: HeaderHashMap.KeyIterator, - pub fn _next(self: *HeaderKeyIterator) ?[]const u8 { + pub fn _next(self: *HeadersKeyIterator) ?[]const u8 { if (self.iter.next()) |key| { return key.*; } else { @@ -183,21 +189,22 @@ pub const HeaderKeyIterator = struct { } }; -pub fn _keys(self: *const Headers) HeaderKeyIterator { - return .{ .iter = self.headers.keyIterator() }; +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 gop = try self.headers.getOrPut(arena, name); + 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 HeaderValueIterator = struct { +pub const HeadersValueIterator = struct { iter: HeaderHashMap.ValueIterator, - pub fn _next(self: *HeaderValueIterator) ?[]const u8 { + pub fn _next(self: *HeadersValueIterator) ?[]const u8 { if (self.iter.next()) |value| { return value.*; } else { @@ -206,53 +213,15 @@ pub const HeaderValueIterator = struct { } }; -pub fn _values(self: *const Headers) HeaderValueIterator { - return .{ .iter = self.headers.valueIterator() }; +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" { - var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); - defer runner.deinit(); - - try runner.testCases(&.{ - .{ "let emptyHeaders = new Headers()", "undefined" }, - }, .{}); - - try runner.testCases(&.{ - .{ "let headers = new Headers({'Set-Cookie': 'name=world'})", "undefined" }, - .{ "headers.get('set-cookie')", "name=world" }, - }, .{}); - - // adapted from the mdn examples - try runner.testCases(&.{ - .{ "const myHeaders = new Headers();", "undefined" }, - .{ "myHeaders.append('Content-Type', 'image/jpeg')", "undefined" }, - .{ "myHeaders.has('Picture-Type')", "false" }, - .{ "myHeaders.get('Content-Type')", "image/jpeg" }, - .{ "myHeaders.append('Content-Type', 'image/png')", "undefined" }, - .{ "myHeaders.get('Content-Type')", "image/jpeg, image/png" }, - .{ "myHeaders.delete('Content-Type')", "undefined" }, - .{ "myHeaders.get('Content-Type')", "null" }, - .{ "myHeaders.set('Picture-Type', 'image/svg')", "undefined" }, - .{ "myHeaders.get('Picture-Type')", "image/svg" }, - .{ "myHeaders.has('Picture-Type')", "true" }, - }, .{}); - - try runner.testCases(&.{ - .{ "const originalHeaders = new Headers([['Content-Type', 'application/json'], ['Authorization', 'Bearer token123']])", "undefined" }, - .{ "originalHeaders.get('Content-Type')", "application/json" }, - .{ "originalHeaders.get('Authorization')", "Bearer token123" }, - .{ "const newHeaders = new Headers(originalHeaders)", "undefined" }, - .{ "newHeaders.get('Content-Type')", "application/json" }, - .{ "newHeaders.get('Authorization')", "Bearer token123" }, - .{ "newHeaders.has('Content-Type')", "true" }, - .{ "newHeaders.has('Authorization')", "true" }, - .{ "newHeaders.has('X-Custom')", "false" }, - // Verify that modifying the new headers doesn't affect the original - .{ "newHeaders.set('X-Custom', 'test-value')", "undefined" }, - .{ "newHeaders.get('X-Custom')", "test-value" }, - .{ "originalHeaders.get('X-Custom')", "null" }, - .{ "originalHeaders.has('X-Custom')", "false" }, - }, .{}); +test "fetch: Headers" { + try testing.htmlRunner("fetch/headers.html"); } diff --git a/src/browser/fetch/Request.zig b/src/browser/fetch/Request.zig index 67dc7cab1..7cfc77c29 100644 --- a/src/browser/fetch/Request.zig +++ b/src/browser/fetch/Request.zig @@ -54,6 +54,10 @@ pub const RequestCache = enum { return null; } } + + pub fn toString(self: RequestCache) []const u8 { + return @tagName(self); + } }; pub const RequestCredentials = enum { @@ -70,6 +74,10 @@ pub const RequestCredentials = enum { return null; } } + + pub fn toString(self: RequestCredentials) []const u8 { + return @tagName(self); + } }; // https://developer.mozilla.org/en-US/docs/Web/API/RequestInit @@ -154,6 +162,10 @@ 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; } @@ -249,50 +261,6 @@ pub fn _text(self: *Response, page: *Page) !Env.Promise { } const testing = @import("../../testing.zig"); -test "fetch: request" { - var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); - defer runner.deinit(); - - 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/', { method: 'POST', body: 'Hello, World' })", "undefined" }, - .{ "request2.url", "https://google.com/" }, - .{ "request2.method", "POST" }, - }, .{}); -} - -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" }, - }, .{}); +test "fetch: Request" { + try testing.htmlRunner("fetch/request.html"); } diff --git a/src/browser/fetch/Response.zig b/src/browser/fetch/Response.zig index be7cc8f19..af1b615e4 100644 --- a/src/browser/fetch/Response.zig +++ b/src/browser/fetch/Response.zig @@ -36,7 +36,8 @@ const Page = @import("../page.zig").Page; // https://developer.mozilla.org/en-US/docs/Web/API/Response const Response = @This(); -status: u16 = 0, +status: u16 = 200, +status_text: []const u8 = "", headers: Headers, mime: ?Mime = null, url: []const u8 = "", @@ -50,7 +51,7 @@ const ResponseBody = union(enum) { const ResponseOptions = struct { status: u16 = 200, - statusText: []const u8 = "", + statusText: ?[]const u8 = null, headers: ?HeadersInit = null, }; @@ -72,10 +73,13 @@ pub fn constructor(_input: ?ResponseBody, _options: ?ResponseOptions, page: *Pag }; 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, }; } @@ -105,6 +109,10 @@ 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; } @@ -183,9 +191,6 @@ pub fn _text(self: *Response, page: *Page) !Env.Promise { } 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(&.{}, .{}); +test "fetch: Response" { + try testing.htmlRunner("fetch/response.html"); } diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index d3008d7b7..b2c676dfe 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -36,9 +36,9 @@ const Response = @import("Response.zig"); pub const Interfaces = .{ @import("Headers.zig"), - @import("Headers.zig").HeaderEntryIterator, - @import("Headers.zig").HeaderKeyIterator, - @import("Headers.zig").HeaderValueIterator, + @import("Headers.zig").HeadersEntryIterable, + @import("Headers.zig").HeadersKeyIterable, + @import("Headers.zig").HeadersValueIterable, @import("Request.zig"), @import("Response.zig"), }; 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..8002e8b3a --- /dev/null +++ b/src/tests/fetch/response.html @@ -0,0 +1,38 @@ + + + From 37d8d2642d4a923021546963f1b35eef64b32fb5 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 11 Sep 2025 21:19:03 -0700 Subject: [PATCH 34/43] copy our Request headers into the HTTP client --- src/browser/fetch/fetch.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index b2c676dfe..c59e75d0d 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -105,6 +105,20 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi }; 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| { + // This is fine because curl/headers copies it internally. + const combined = try std.fmt.allocPrintSentinel( + page.call_arena, + "{s}: {s}", + .{ entry.key_ptr.*, entry.value_ptr.* }, + 0, + ); + try headers.add(combined.ptr); + } + try page.requestCookie(.{}).headersForRequest(arena, req.url, &headers); const fetch_ctx = try arena.create(FetchContext); From c84634093d06dd99d207b76af08299cd96f0e769 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 11 Sep 2025 21:19:19 -0700 Subject: [PATCH 35/43] use content length to reserve body size --- src/browser/fetch/fetch.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index c59e75d0d..01ec10404 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -172,6 +172,10 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi }; } + 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 }); From 31335fc4fbfd57372d63625d751cb44dc4098eaf Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 27 Aug 2025 09:04:21 +0800 Subject: [PATCH 36/43] Start working on HTMLSlotElement --- src/browser/html/elements.zig | 147 ++++++++++++++------------ src/tests/html/html_slot_element.html | 66 ++++++++++++ 2 files changed, 148 insertions(+), 65 deletions(-) create mode 100644 src/tests/html/html_slot_element.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/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

!!
+ + From af916dea1d8e6c47460d43e29576d8aad7ae4557 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Sep 2025 08:39:46 +0800 Subject: [PATCH 37/43] fix arena, add fetch test --- src/browser/fetch/fetch.zig | 7 ++++++- src/tests/fetch/fetch.html | 16 ++++++++++++++++ src/tests/fetch/response.html | 11 +++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 src/tests/fetch/fetch.html diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 01ec10404..036dc9ee9 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -111,7 +111,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi while (header_iter.next()) |entry| { // This is fine because curl/headers copies it internally. const combined = try std.fmt.allocPrintSentinel( - page.call_arena, + page.arena, "{s}: {s}", .{ entry.key_ptr.*, entry.value_ptr.* }, 0, @@ -229,3 +229,8 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi return resolver.promise(); } + +const testing = @import("../../testing.zig"); +test "fetch: fetch" { + try testing.htmlRunner("fetch/fetch.html"); +} 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/response.html b/src/tests/fetch/response.html index 8002e8b3a..79aa396ef 100644 --- a/src/tests/fetch/response.html +++ b/src/tests/fetch/response.html @@ -36,3 +36,14 @@ let emptyResponse = new Response(""); testing.expectEqual(200, emptyResponse.status); + +ref From 2a7a8bc2a65a2d64d9698e7d0a1675c7514ed2a9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 16 Sep 2025 12:45:20 +0800 Subject: [PATCH 38/43] remove meaningless text from test --- src/tests/fetch/response.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/fetch/response.html b/src/tests/fetch/response.html index 79aa396ef..01b4c8e72 100644 --- a/src/tests/fetch/response.html +++ b/src/tests/fetch/response.html @@ -46,4 +46,4 @@ testing.async(promise1, (json) => { testing.expectEqual([], json); }); -ref + From d0621510ccbb0f8df0c4e6ce6c09fedf62ccfc4f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 16 Sep 2025 12:09:54 -0700 Subject: [PATCH 39/43] use Env.PersistentPromiseResolver --- src/browser/fetch/fetch.zig | 44 ++++++--------- src/browser/streams/ReadableStream.zig | 27 +++------- .../ReadableStreamDefaultController.zig | 47 ++++------------ .../streams/ReadableStreamDefaultReader.zig | 53 +++++++------------ src/runtime/js.zig | 44 +++++++++++++++ 5 files changed, 97 insertions(+), 118 deletions(-) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 036dc9ee9..0b80d5f70 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -19,7 +19,6 @@ const std = @import("std"); const log = @import("../../log.zig"); -const v8 = @import("v8"); const Env = @import("../env.zig").Env; const Page = @import("../page.zig").Page; @@ -46,7 +45,7 @@ pub const Interfaces = .{ pub const FetchContext = struct { arena: std.mem.Allocator, js_ctx: *Env.JsContext, - promise_resolver: v8.Persistent(v8.PromiseResolver), + promise_resolver: Env.PersistentPromiseResolver, method: Http.Method, url: []const u8, @@ -82,13 +81,9 @@ pub const FetchContext = struct { pub fn destructor(self: *FetchContext) void { if (self.transfer) |_| { - const resolver = Env.PromiseResolver{ - .js_context = self.js_ctx, - .resolver = self.promise_resolver.castToPromiseResolver(), - }; - - resolver.reject("TypeError") catch unreachable; + self.promise_resolver.reject("TypeError") catch unreachable; self.promise_resolver.deinit(); + self.transfer = null; } } }; @@ -99,10 +94,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi 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 resolver = page.main_context.createPersistentPromiseResolver(); var headers = try Http.Headers.init(); @@ -125,10 +117,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi fetch_ctx.* = .{ .arena = arena, .js_ctx = page.main_context, - .promise_resolver = v8.Persistent(v8.PromiseResolver).init( - page.main_context.isolate, - resolver.resolver, - ), + .promise_resolver = resolver, .method = req.method, .url = req.url, }; @@ -205,24 +194,23 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi }); 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); + 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; + if (self.transfer != null) { + self.transfer = null; - log.err(.http, "error", .{ - .url = self.url, - .err = err, - .source = "fetch error", - }); + log.err(.http, "error", .{ + .url = self.url, + .err = err, + .source = "fetch error", + }); + + self.promise_resolver.reject(@errorName(err)) catch unreachable; + } } }.errorCallback, }); diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index 8660123b8..4b27ba3c5 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -19,7 +19,6 @@ const std = @import("std"); const log = @import("../../log.zig"); -const v8 = @import("v8"); const Page = @import("../page.zig").Page; const Env = @import("../env.zig").Env; @@ -35,9 +34,9 @@ const State = union(enum) { }; // This promise resolves when a stream is canceled. -cancel_resolver: v8.Persistent(v8.PromiseResolver), -closed_resolver: v8.Persistent(v8.PromiseResolver), -reader_resolver: ?v8.Persistent(v8.PromiseResolver) = null, +cancel_resolver: Env.PersistentPromiseResolver, +closed_resolver: Env.PersistentPromiseResolver, +reader_resolver: ?Env.PersistentPromiseResolver = null, locked: bool = false, state: State = .readable, @@ -79,15 +78,8 @@ const QueueingStrategy = struct { pub fn constructor(underlying: ?UnderlyingSource, _strategy: ?QueueingStrategy, page: *Page) !*ReadableStream { const strategy: QueueingStrategy = _strategy orelse .{}; - const cancel_resolver = v8.Persistent(v8.PromiseResolver).init( - page.main_context.isolate, - v8.PromiseResolver.init(page.main_context.v8_context), - ); - - const closed_resolver = v8.Persistent(v8.PromiseResolver).init( - page.main_context.isolate, - v8.PromiseResolver.init(page.main_context.v8_context), - ); + const cancel_resolver = page.main_context.createPersistentPromiseResolver(); + const closed_resolver = page.main_context.createPersistentPromiseResolver(); const stream = try page.arena.create(ReadableStream); stream.* = ReadableStream{ .cancel_resolver = cancel_resolver, .closed_resolver = closed_resolver, .strategy = strategy }; @@ -131,11 +123,6 @@ pub fn _cancel(self: *ReadableStream, reason: ?[]const u8, page: *Page) !Env.Pro return error.TypeError; } - const resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = self.cancel_resolver.castToPromiseResolver(), - }; - self.state = .{ .cancelled = if (reason) |r| try page.arena.dupe(u8, r) else null }; // Call cancel callback. @@ -147,8 +134,8 @@ pub fn _cancel(self: *ReadableStream, reason: ?[]const u8, page: *Page) !Env.Pro } } - try resolver.resolve({}); - return resolver.promise(); + try self.cancel_resolver.resolve({}); + return self.cancel_resolver.promise(); } pub fn pullIf(self: *ReadableStream) !void { diff --git a/src/browser/streams/ReadableStreamDefaultController.zig b/src/browser/streams/ReadableStreamDefaultController.zig index af5057a7a..84bb5073e 100644 --- a/src/browser/streams/ReadableStreamDefaultController.zig +++ b/src/browser/streams/ReadableStreamDefaultController.zig @@ -21,7 +21,6 @@ const log = @import("../../log.zig"); const Page = @import("../page.zig").Page; const Env = @import("../env.zig").Env; -const v8 = @import("v8"); const ReadableStream = @import("./ReadableStream.zig"); const ReadableStreamReadResult = @import("./ReadableStream.zig").ReadableStreamReadResult; @@ -40,23 +39,14 @@ pub fn _close(self: *ReadableStreamDefaultController, _reason: ?[]const u8, page self.stream.state = .{ .closed = reason }; // Resolve the Reader Promise - if (self.stream.reader_resolver) |rr| { - const resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = rr.castToPromiseResolver(), - }; - - try resolver.resolve(ReadableStreamReadResult{ .value = .empty, .done = true }); + 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. - const closed_resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = self.stream.closed_resolver.castToPromiseResolver(), - }; - - try closed_resolver.resolve({}); + 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. @@ -69,37 +59,22 @@ pub fn _enqueue(self: *ReadableStreamDefaultController, chunk: []const u8, page: return error.TypeError; } - if (self.stream.reader_resolver) |rr| { - const resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = rr.castToPromiseResolver(), - }; - - try resolver.resolve(ReadableStreamReadResult{ .value = .{ .data = chunk }, .done = false }); + if (self.stream.reader_resolver) |*rr| { + defer rr.deinit(); + try rr.resolve(ReadableStreamReadResult{ .value = .{ .data = chunk }, .done = false }); self.stream.reader_resolver = null; - - // rr.setWeakFinalizer(@ptrCast(self.stream), struct { - // fn callback(info: ?*v8.c.WeakCallbackInfo) void { - // const inner_stream: *ReadableStream = @ptrCast(@alignCast(v8.c.v8__WeakCallbackInfo__GetParameter(info).?)); - // inner_stream.reader_resolver = null; - // } - // }.callback, .kParameter); } try self.stream.queue.append(page.arena, chunk); try self.stream.pullIf(); } -pub fn _error(self: *ReadableStreamDefaultController, err: Env.JsObject, page: *Page) !void { +pub fn _error(self: *ReadableStreamDefaultController, err: Env.JsObject) !void { self.stream.state = .{ .errored = err }; - if (self.stream.reader_resolver) |rr| { - const resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = rr.castToPromiseResolver(), - }; - - try resolver.reject(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 index ac7bcf687..5ad863ec9 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -18,8 +18,6 @@ const std = @import("std"); -const v8 = @import("v8"); - const log = @import("../../log.zig"); const Env = @import("../env.zig").Env; const Page = @import("../page.zig").Page; @@ -34,13 +32,8 @@ pub fn constructor(stream: *ReadableStream) ReadableStreamDefaultReader { return .{ .stream = stream }; } -pub fn get_closed(self: *const ReadableStreamDefaultReader, page: *Page) Env.Promise { - const resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = self.stream.closed_resolver.castToPromiseResolver(), - }; - - return resolver.promise(); +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 { @@ -50,61 +43,53 @@ pub fn _cancel(self: *ReadableStreamDefaultReader, reason: ?[]const u8, page: *P pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise { const stream = self.stream; - const resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = v8.PromiseResolver.init(page.main_context.v8_context), - }; - 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| { - const r_resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = rr.castToPromiseResolver(), - }; - - return r_resolver.promise(); + return rr.promise(); } else { - const p_resolver = v8.Persistent(v8.PromiseResolver).init(page.main_context.isolate, resolver.resolver); - self.stream.reader_resolver = p_resolver; - return resolver.promise(); + const persistent_resolver = page.main_context.createPersistentPromiseResolver(); + self.stream.reader_resolver = persistent_resolver; + return persistent_resolver.promise(); } - - try self.stream.pullIf(); } }, .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(); }, } - - return resolver.promise(); } -pub fn _releaseLock(self: *const ReadableStreamDefaultReader, page: *Page) !void { +pub fn _releaseLock(self: *const ReadableStreamDefaultReader) !void { self.stream.locked = false; if (self.stream.reader_resolver) |rr| { - const resolver = Env.PromiseResolver{ - .js_context = page.main_context, - .resolver = rr.castToPromiseResolver(), - }; - - try resolver.reject("TypeError"); + try rr.reject("TypeError"); } } diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 6eec9e61d..f09c7cdd2 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -1261,6 +1261,13 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }; } + pub fn createPersistentPromiseResolver(self: *JsContext) PersistentPromiseResolver { + return .{ + .js_context = self, + .resolver = v8.Persistent(v8.PromiseResolver).init(self.isolate, v8.PromiseResolver.init(self.v8_context)), + }; + } + // 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 @@ -2231,6 +2238,43 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { } }; + 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 { promise: v8.Promise, }; From fcd82b2c14eaa11f90c5ab0315a16711f2ec5b84 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 16 Sep 2025 12:17:05 -0700 Subject: [PATCH 40/43] htmlRunner for ReadableStream tests, fix ReadableStream enqueue --- src/browser/streams/ReadableStream.zig | 163 +----------------- .../ReadableStreamDefaultController.zig | 6 +- .../streams/ReadableStreamDefaultReader.zig | 1 + src/tests/streams/readable_stream.html | 117 +++++++++++++ 4 files changed, 123 insertions(+), 164 deletions(-) create mode 100644 src/tests/streams/readable_stream.html diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index 4b27ba3c5..216152da6 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -173,166 +173,5 @@ pub fn _getReader(self: *ReadableStream, _options: ?GetReaderOptions) !ReadableS const testing = @import("../../testing.zig"); test "streams: ReadableStream" { - var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); - defer runner.deinit(); - - try runner.testCases(&.{ - .{ "var readResult;", "undefined" }, - .{ - \\ const stream = new ReadableStream({ - \\ start(controller) { - \\ controller.enqueue("hello"); - \\ controller.enqueue("world"); - \\ controller.close(); - \\ } - \\ }); - , - undefined, - }, - .{ - \\ const reader = stream.getReader(); - \\ (async function () { readResult = await reader.read() }()); - \\ false; - , - "false", - }, - .{ "reader", "[object ReadableStreamDefaultReader]" }, - .{ "readResult.value", "hello" }, - .{ "readResult.done", "false" }, - }, .{}); -} - -test "streams: ReadableStream cancel and close" { - var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/" }); - defer runner.deinit(); - try runner.testCases(&.{ - .{ "var readResult; var cancelResult; var closeResult;", "undefined" }, - - // Test 1: Stream with controller.close() - .{ - \\ const stream1 = new ReadableStream({ - \\ start(controller) { - \\ controller.enqueue("first"); - \\ controller.enqueue("second"); - \\ controller.close(); - \\ } - \\ }); - , - undefined, - }, - .{ "const reader1 = stream1.getReader();", undefined }, - .{ - \\ (async function () { - \\ readResult = await reader1.read(); - \\ }()); - \\ false; - , - "false", - }, - .{ "readResult.value", "first" }, - .{ "readResult.done", "false" }, - - // Read second chunk - .{ - \\ (async function () { - \\ readResult = await reader1.read(); - \\ }()); - \\ false; - , - "false", - }, - .{ "readResult.value", "second" }, - .{ "readResult.done", "false" }, - - // Read after close - should get done: true - .{ - \\ (async function () { - \\ readResult = await reader1.read(); - \\ }()); - \\ false; - , - "false", - }, - .{ "readResult.value", "undefined" }, - .{ "readResult.done", "true" }, - - // Test 2: Stream with reader.cancel() - .{ - \\ const stream2 = new ReadableStream({ - \\ start(controller) { - \\ controller.enqueue("data1"); - \\ controller.enqueue("data2"); - \\ controller.enqueue("data3"); - \\ }, - \\ cancel(reason) { - \\ closeResult = `Stream cancelled: ${reason}`; - \\ } - \\ }); - , - undefined, - }, - .{ "const reader2 = stream2.getReader();", undefined }, - - // Read one chunk before canceling - .{ - \\ (async function () { - \\ readResult = await reader2.read(); - \\ }()); - \\ false; - , - "false", - }, - .{ "readResult.value", "data1" }, - .{ "readResult.done", "false" }, - - // Cancel the stream - .{ - \\ (async function () { - \\ cancelResult = await reader2.cancel("user requested"); - \\ }()); - \\ false; - , - "false", - }, - .{ "cancelResult", "undefined" }, - .{ "closeResult", "Stream cancelled: user requested" }, - - // Try to read after cancel - should throw or return done - .{ - \\ try { - \\ (async function () { - \\ readResult = await reader2.read(); - \\ }()); - \\ } catch(e) { - \\ readResult = { error: e.name }; - \\ } - \\ false; - , - "false", - }, - - // Test 3: Cancel without reason - .{ - \\ const stream3 = new ReadableStream({ - \\ start(controller) { - \\ controller.enqueue("test"); - \\ }, - \\ cancel(reason) { - \\ closeResult = reason === undefined ? "no reason" : reason; - \\ } - \\ }); - , - undefined, - }, - .{ "const reader3 = stream3.getReader();", undefined }, - .{ - \\ (async function () { - \\ await reader3.cancel(); - \\ }()); - \\ false; - , - "false", - }, - .{ "closeResult", "no reason" }, - }, .{}); + try testing.htmlRunner("streams/readable_stream.html"); } diff --git a/src/browser/streams/ReadableStreamDefaultController.zig b/src/browser/streams/ReadableStreamDefaultController.zig index 84bb5073e..c83a13a57 100644 --- a/src/browser/streams/ReadableStreamDefaultController.zig +++ b/src/browser/streams/ReadableStreamDefaultController.zig @@ -59,13 +59,15 @@ pub fn _enqueue(self: *ReadableStreamDefaultController, chunk: []const u8, page: 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 = chunk }, .done = false }); + try rr.resolve(ReadableStreamReadResult{ .value = .{ .data = duped_chunk }, .done = false }); self.stream.reader_resolver = null; } - try self.stream.queue.append(page.arena, chunk); + try self.stream.queue.append(page.arena, duped_chunk); try self.stream.pullIf(); } diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig index 5ad863ec9..0708eff22 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -48,6 +48,7 @@ pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise 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(); 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 @@ + + + + + + + + + + + From 3badcdbdbdae12d33eb52897df2be0e538f6d08b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 16 Sep 2025 12:38:50 -0700 Subject: [PATCH 41/43] stop using destructor callback for fetch --- src/browser/fetch/fetch.zig | 28 ++++++++++------------------ src/runtime/js.zig | 4 ++-- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 0b80d5f70..9f9bd6a2a 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -78,14 +78,6 @@ pub const FetchContext = struct { .url = self.url, }; } - - pub fn destructor(self: *FetchContext) void { - if (self.transfer) |_| { - self.promise_resolver.reject("TypeError") catch unreachable; - self.promise_resolver.deinit(); - self.transfer = null; - } - } }; // https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch @@ -122,9 +114,6 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .url = req.url, }; - // Add destructor callback for FetchContext. - try page.main_context.destructor_callbacks.append(arena, Env.DestructorCallback.init(fetch_ctx)); - try page.http_client.request(.{ .ctx = @ptrCast(fetch_ctx), .url = req.url, @@ -200,15 +189,18 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .error_callback = struct { fn errorCallback(ctx: *anyopaque, err: anyerror) void { const self: *FetchContext = @ptrCast(@alignCast(ctx)); - if (self.transfer != null) { - self.transfer = null; + defer self.promise_resolver.deinit(); + self.transfer = null; - log.err(.http, "error", .{ - .url = self.url, - .err = err, - .source = "fetch error", - }); + log.err(.http, "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; } } diff --git a/src/runtime/js.zig b/src/runtime/js.zig index f09c7cdd2..e18e73d6a 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -2945,11 +2945,11 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { // An interface for types that want to have their jsDeinit function to be // called when the call context ends - pub const DestructorCallback = struct { + const DestructorCallback = struct { ptr: *anyopaque, destructorFn: *const fn (ptr: *anyopaque) void, - pub fn init(ptr: anytype) DestructorCallback { + fn init(ptr: anytype) DestructorCallback { const T = @TypeOf(ptr); const ptr_info = @typeInfo(T); From 4d1e416299b6b401dfe71dc9b0b2a8b64febca59 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 16 Sep 2025 12:41:35 -0700 Subject: [PATCH 42/43] use fetch logging scope, clean some comments --- src/browser/fetch/fetch.zig | 14 ++++++-------- src/log.zig | 1 + 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 9f9bd6a2a..ee76578fc 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -85,15 +85,11 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi const arena = page.arena; const req = try Request.constructor(input, options, page); - - const resolver = page.main_context.createPersistentPromiseResolver(); - 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| { - // This is fine because curl/headers copies it internally. const combined = try std.fmt.allocPrintSentinel( page.arena, "{s}: {s}", @@ -105,6 +101,8 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi try page.requestCookie(.{}).headersForRequest(arena, req.url, &headers); + const resolver = page.main_context.createPersistentPromiseResolver(); + const fetch_ctx = try arena.create(FetchContext); fetch_ctx.* = .{ .arena = arena, @@ -126,7 +124,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .start_callback = struct { fn startCallback(transfer: *HttpClient.Transfer) !void { const self: *FetchContext = @ptrCast(@alignCast(transfer.ctx)); - log.debug(.http, "request start", .{ .method = self.method, .url = self.url, .source = "fetch" }); + log.debug(.fetch, "request start", .{ .method = self.method, .url = self.url, .source = "fetch" }); self.transfer = transfer; } @@ -137,7 +135,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi const header = &transfer.response_header.?; - log.debug(.http, "request header", .{ + log.debug(.fetch, "request header", .{ .source = "fetch", .method = self.method, .url = self.url, @@ -175,7 +173,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi defer self.promise_resolver.deinit(); self.transfer = null; - log.info(.http, "request complete", .{ + log.info(.fetch, "request complete", .{ .source = "fetch", .method = self.method, .url = self.url, @@ -192,7 +190,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi defer self.promise_resolver.deinit(); self.transfer = null; - log.err(.http, "error", .{ + log.err(.fetch, "error", .{ .url = self.url, .err = err, .source = "fetch error", 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, }; From afa0d5ba123fe29b377f029f34f6350c378b1595 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 17 Sep 2025 20:14:13 +0800 Subject: [PATCH 43/43] Try fixing segfault by not being as aggressive with freeing Persisted Resolvers --- src/browser/fetch/fetch.zig | 4 +--- src/browser/streams/ReadableStream.zig | 7 ++----- src/browser/streams/ReadableStreamDefaultReader.zig | 2 +- src/runtime/js.zig | 13 +++++++++++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index ee76578fc..1feab0e02 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -101,7 +101,7 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi try page.requestCookie(.{}).headersForRequest(arena, req.url, &headers); - const resolver = page.main_context.createPersistentPromiseResolver(); + const resolver = try page.main_context.createPersistentPromiseResolver(); const fetch_ctx = try arena.create(FetchContext); fetch_ctx.* = .{ @@ -170,7 +170,6 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .done_callback = struct { fn doneCallback(ctx: *anyopaque) !void { const self: *FetchContext = @ptrCast(@alignCast(ctx)); - defer self.promise_resolver.deinit(); self.transfer = null; log.info(.fetch, "request complete", .{ @@ -187,7 +186,6 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi .error_callback = struct { fn errorCallback(ctx: *anyopaque, err: anyerror) void { const self: *FetchContext = @ptrCast(@alignCast(ctx)); - defer self.promise_resolver.deinit(); self.transfer = null; log.err(.fetch, "error", .{ diff --git a/src/browser/streams/ReadableStream.zig b/src/browser/streams/ReadableStream.zig index 216152da6..8857c7b31 100644 --- a/src/browser/streams/ReadableStream.zig +++ b/src/browser/streams/ReadableStream.zig @@ -78,8 +78,8 @@ const QueueingStrategy = struct { pub fn constructor(underlying: ?UnderlyingSource, _strategy: ?QueueingStrategy, page: *Page) !*ReadableStream { const strategy: QueueingStrategy = _strategy orelse .{}; - const cancel_resolver = page.main_context.createPersistentPromiseResolver(); - const closed_resolver = page.main_context.createPersistentPromiseResolver(); + 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 }; @@ -106,9 +106,6 @@ pub fn constructor(underlying: ?UnderlyingSource, _strategy: ?QueueingStrategy, } pub fn destructor(self: *ReadableStream) void { - self.cancel_resolver.deinit(); - self.closed_resolver.deinit(); - if (self.reader_resolver) |*rr| { rr.deinit(); } diff --git a/src/browser/streams/ReadableStreamDefaultReader.zig b/src/browser/streams/ReadableStreamDefaultReader.zig index 0708eff22..bf40e4dfa 100644 --- a/src/browser/streams/ReadableStreamDefaultReader.zig +++ b/src/browser/streams/ReadableStreamDefaultReader.zig @@ -56,7 +56,7 @@ pub fn _read(self: *const ReadableStreamDefaultReader, page: *Page) !Env.Promise if (self.stream.reader_resolver) |rr| { return rr.promise(); } else { - const persistent_resolver = page.main_context.createPersistentPromiseResolver(); + const persistent_resolver = try page.main_context.createPersistentPromiseResolver(); self.stream.reader_resolver = persistent_resolver; return persistent_resolver.promise(); } diff --git a/src/runtime/js.zig b/src/runtime/js.zig index e18e73d6a..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| { @@ -1261,10 +1268,12 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }; } - pub fn createPersistentPromiseResolver(self: *JsContext) PersistentPromiseResolver { + 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 = v8.Persistent(v8.PromiseResolver).init(self.isolate, v8.PromiseResolver.init(self.v8_context)), + .resolver = resolver, }; }