From e23734dac7c2977f9efd209ac4bf38f87c5dedb7 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 25 Feb 2024 15:56:11 +0100 Subject: [PATCH 01/31] Updated license date --- LICENSE | 2 +- README.md | 2 +- nuke/Build.cs | 4 ++-- nuke/ReleaseNotesParser.cs | 10 ---------- src/AngleSharp.Io.nuspec | 3 ++- 5 files changed, 6 insertions(+), 15 deletions(-) diff --git a/LICENSE b/LICENSE index 470f190..10b5b1c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 - 2023 AngleSharp +Copyright (c) 2013 - 2024 AngleSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5619f89..2bcba6b 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) The MIT License (MIT) -Copyright (c) 2015 - 2023 AngleSharp +Copyright (c) 2015 - 2024 AngleSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/nuke/Build.cs b/nuke/Build.cs index cbd915b..f44344c 100644 --- a/nuke/Build.cs +++ b/nuke/Build.cs @@ -89,11 +89,11 @@ protected override void OnBuildInitialized() if (ScheduledTargets.Contains(Default)) { - Version = $"{Version}-ci-{buildNumber}"; + Version = $"{Version}-ci.{buildNumber}"; } else if (ScheduledTargets.Contains(PrePublish)) { - Version = $"{Version}-alpha-{buildNumber}"; + Version = $"{Version}-beta.{buildNumber}"; } } diff --git a/nuke/ReleaseNotesParser.cs b/nuke/ReleaseNotesParser.cs index 4b00a07..d911c90 100644 --- a/nuke/ReleaseNotesParser.cs +++ b/nuke/ReleaseNotesParser.cs @@ -17,16 +17,6 @@ /// public sealed class ReleaseNotesParser { - private readonly Regex _versionRegex; - - /// - /// Initializes a new instance of the class. - /// - public ReleaseNotesParser() - { - _versionRegex = new Regex(@"(?\d+(\s*\.\s*\d+){0,3})(?-[a-z][0-9a-z-]*)?"); - } - /// /// Parses all release notes. /// diff --git a/src/AngleSharp.Io.nuspec b/src/AngleSharp.Io.nuspec index 2806336..408d49c 100644 --- a/src/AngleSharp.Io.nuspec +++ b/src/AngleSharp.Io.nuspec @@ -6,13 +6,14 @@ AngleSharp Florian Rappl MIT + https://anglesharp.github.io logo.png README.md false Providers additional requesters and IO helpers for AngleSharp. https://github.com/AngleSharp/AngleSharp.Io/blob/main/CHANGELOG.md - Copyright 2016-2023, AngleSharp + Copyright 2016-2024, AngleSharp html html5 css css3 dom requester http https io filesystem storage httpclient cache From d8175f674d22470e6475ed0b8c6205baf815cd38 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 26 Jan 2025 21:49:37 +0100 Subject: [PATCH 02/31] Updated license ref --- README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/README.md b/README.md index 2bcba6b..0edeeba 100644 --- a/README.md +++ b/README.md @@ -146,12 +146,4 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) ## License -The MIT License (MIT) - -Copyright (c) 2015 - 2024 AngleSharp - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +AngleSharp.Io is released using the MIT license. For more information see the [license file](./LICENSE). From e9c8988b04430335cb18cbdf7f0fed4034cb5371 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 26 Jan 2025 21:49:53 +0100 Subject: [PATCH 03/31] Updated to 2025 --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 10b5b1c..b64c5a7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 - 2024 AngleSharp +Copyright (c) 2013 - 2025 AngleSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From c20900b656fc044ff9d76f42db29ff515ce890ee Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 26 Jan 2025 21:50:05 +0100 Subject: [PATCH 04/31] Updated year --- src/AngleSharp.Io.nuspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AngleSharp.Io.nuspec b/src/AngleSharp.Io.nuspec index 408d49c..22456da 100644 --- a/src/AngleSharp.Io.nuspec +++ b/src/AngleSharp.Io.nuspec @@ -13,7 +13,7 @@ false Providers additional requesters and IO helpers for AngleSharp. https://github.com/AngleSharp/AngleSharp.Io/blob/main/CHANGELOG.md - Copyright 2016-2024, AngleSharp + Copyright 2016-2025, AngleSharp html html5 css css3 dom requester http https io filesystem storage httpclient cache From fabc8701235dd3d211495bce0adaf3b8f3960a77 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 16:06:07 +0200 Subject: [PATCH 05/31] Update funding sources in FUNDING.yml --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1f114d6..b74e64f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ # These are supported funding model platforms github: FlorianRappl -custom: https://salt.bountysource.com/teams/anglesharp +custom: ['https://www.paypal.me/FlorianRappl', 'https://buymeacoffee.com/florianrappl'] From 8b339b4f33fca5711eea7d5651bbfdff03936db5 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 16:06:20 +0200 Subject: [PATCH 06/31] Update copyright year in LICENSE file Updated copyright year from 2025 to 2026. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index b64c5a7..eae958a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 - 2025 AngleSharp +Copyright (c) 2013 - 2026 AngleSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 8564e1cc1d64bacc3757f246cf419bdd4fb47454 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 16:20:40 +0200 Subject: [PATCH 07/31] Started updating versions --- CHANGELOG.md | 6 ++++++ src/AngleSharp.Io.Docs/package.json | 2 +- src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj | 2 +- src/AngleSharp.Io.nuspec | 4 ++-- src/AngleSharp.Io/AngleSharp.Io.csproj | 2 +- src/Directory.Build.props | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f35dce0..5459246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.1.0 + +Released on Thursday, July 31 2026. + +- Updated to use a minimum of AngleSharp 1.5 + # 1.0.0 Released on Sunday, January 15 2023. diff --git a/src/AngleSharp.Io.Docs/package.json b/src/AngleSharp.Io.Docs/package.json index 7f4f014..e0eed31 100644 --- a/src/AngleSharp.Io.Docs/package.json +++ b/src/AngleSharp.Io.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/io", - "version": "0.17.0", + "version": "1.1.0", "preview": true, "description": "The doclet for the AngleSharp.Io documentation.", "keywords": [ diff --git a/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj b/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj index eb013d1..663e28c 100644 --- a/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj +++ b/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/AngleSharp.Io.nuspec b/src/AngleSharp.Io.nuspec index 22456da..2d59249 100644 --- a/src/AngleSharp.Io.nuspec +++ b/src/AngleSharp.Io.nuspec @@ -13,10 +13,10 @@ false Providers additional requesters and IO helpers for AngleSharp. https://github.com/AngleSharp/AngleSharp.Io/blob/main/CHANGELOG.md - Copyright 2016-2025, AngleSharp + Copyright 2016-2026, AngleSharp html html5 css css3 dom requester http https io filesystem storage httpclient cache - + diff --git a/src/AngleSharp.Io/AngleSharp.Io.csproj b/src/AngleSharp.Io/AngleSharp.Io.csproj index 3e07a2c..d1dc5fa 100644 --- a/src/AngleSharp.Io/AngleSharp.Io.csproj +++ b/src/AngleSharp.Io/AngleSharp.Io.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1f27205..9c98288 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,6 +2,6 @@ Providers additional requesters and IO helpers for AngleSharp. AngleSharp.Io - 0.17.0 + 1.1.0 \ No newline at end of file From e6631c1f135ed1b2fe2d6bbb7454741c322968d9 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 19:30:35 +0200 Subject: [PATCH 08/31] Fixed domain handling #36 --- CHANGELOG.md | 1 + .../Cookie/ParsingTests.cs | 26 +++++++++++++++++++ .../Cookie/AdvancedCookieProvider.cs | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5459246..65f3657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on Thursday, July 31 2026. - Updated to use a minimum of AngleSharp 1.5 +- Fixed domain handling in cookies #36 # 1.0.0 diff --git a/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs b/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs index 38c7acc..bd50712 100644 --- a/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs +++ b/src/AngleSharp.Io.Tests/Cookie/ParsingTests.cs @@ -96,6 +96,32 @@ public void SettingSubPathCookieOnSuperDomain() Assert.AreEqual("/subpath", cookie.Path); } + [Test] + public void GettingDottedDomainCookieForSubDomain() + { + var container = CreateContainerWithSetup(c => + { + var url = new Url("http://example.com/index.html"); + c.SetCookie(url, "a=b; Domain=.example.com; Path=/"); + }); + var cookieHeader = ((ICookieProvider)container).GetCookie(new Url("http://www.example.com/home")); + + Assert.AreEqual("a=b", cookieHeader); + } + + [Test] + public void DomainCookieDoesNotMatchSuffixLookalike() + { + var container = CreateContainerWithSetup(c => + { + var url = new Url("http://example.com/index.html"); + c.SetCookie(url, "a=b; Domain=.example.com; Path=/"); + }); + var cookieHeader = ((ICookieProvider)container).GetCookie(new Url("http://notexample.com/home")); + + Assert.AreEqual(String.Empty, cookieHeader); + } + [Test] public void SimpleCookie() { diff --git a/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs b/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs index 883a26e..7118a03 100644 --- a/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs +++ b/src/AngleSharp.Io/Cookie/AdvancedCookieProvider.cs @@ -40,7 +40,7 @@ String ICookieProvider.GetCookie(Url url) var path = String.IsNullOrEmpty(url.Path) ? "/" : $"/{url.Path}"; var secure = url.Scheme.IsOneOf(ProtocolNames.Https, ProtocolNames.Wss); var now = DateTime.UtcNow; - var cookies = FindCookies(host, path) + var cookies = _cookies .ToArray() .Where(c => { From 09f6f5544006f72000b7b75dcfa98bb86e7aa0f1 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 19:54:22 +0200 Subject: [PATCH 09/31] Improved packaging formats --- .github/workflows/ci.yml | 16 +++++++--------- .../AngleSharp.Io.Tests.csproj | 3 +-- src/AngleSharp.Io/AngleSharp.Io.csproj | 7 +++---- src/Directory.Build.props | 4 ++++ src/{AngleSharp.Io => }/Key.snk | Bin 5 files changed, 15 insertions(+), 15 deletions(-) rename src/{AngleSharp.Io => }/Key.snk (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f01d24d..c8c212c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v1 with: - node-version: "14.x" + node-version: "20.x" registry-url: 'https://registry.npmjs.org' - name: Install Dependencies @@ -54,11 +54,10 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 6.0.x - 7.0.x + 10.0.x - name: Build - run: ./build.sh + run: ./build.sh -AngleSharpVersion 1.5.0 windows: runs-on: windows-latest @@ -70,15 +69,14 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 6.0.x - 7.0.x + 10.0.x - name: Build run: | if ($env:GITHUB_REF -eq "refs/heads/main") { - .\build.ps1 -Target Publish + .\build.ps1 -Target Publish -AngleSharpVersion 1.5.0 } elseif ($env:GITHUB_REF -eq "refs/heads/devel") { - .\build.ps1 -Target PrePublish + .\build.ps1 -Target PrePublish -AngleSharpVersion 1.5.0 } else { - .\build.ps1 + .\build.ps1 -AngleSharpVersion 1.5.0 } diff --git a/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj b/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj index 663e28c..c0b9e20 100644 --- a/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj +++ b/src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 true Key.snk false @@ -17,7 +17,6 @@ - diff --git a/src/AngleSharp.Io/AngleSharp.Io.csproj b/src/AngleSharp.Io/AngleSharp.Io.csproj index d1dc5fa..e98f543 100644 --- a/src/AngleSharp.Io/AngleSharp.Io.csproj +++ b/src/AngleSharp.Io/AngleSharp.Io.csproj @@ -2,10 +2,8 @@ AngleSharp.Io AngleSharp.Io - netstandard2.0;net6.0;net7.0 - netstandard2.0;net461;net472;net6.0;net7.0 - true - Key.snk + netstandard2.0;net8.0;net10.0 + $(TargetFrameworks);net462;net472 true 9 https://github.com/AngleSharp/AngleSharp.Io @@ -14,6 +12,7 @@ true true snupkg + 1.* diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 9c98288..e7d70ce 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,5 +3,9 @@ Providers additional requesters and IO helpers for AngleSharp. AngleSharp.Io 1.1.0 + latest + true + true + $(MSBuildThisFileDirectory)\Key.snk \ No newline at end of file diff --git a/src/AngleSharp.Io/Key.snk b/src/Key.snk similarity index 100% rename from src/AngleSharp.Io/Key.snk rename to src/Key.snk From cd434ce8ad96a18b00b6cdb9a483f381956d040b Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 20:17:04 +0200 Subject: [PATCH 10/31] Fixed and improved for recent .NET --- .../Cookie/ClassicTests.cs | 40 +++++++++----- .../Network/CookieHandlingTests.cs | 52 +++++++++++-------- .../Network/HttpClientRequesterTests.cs | 15 ++++-- .../Network/WebSocketTests.cs | 6 +-- src/AngleSharp.Io/Dom/InputFile.cs | 21 ++++++-- src/AngleSharp.Io/Network/FileRequester.cs | 43 +++++++++++---- src/AngleSharp.Io/Network/FtpRequester.cs | 21 +++++--- 7 files changed, 136 insertions(+), 62 deletions(-) diff --git a/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs b/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs index 93e9155..c52a7db 100644 --- a/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs +++ b/src/AngleSharp.Io.Tests/Cookie/ClassicTests.cs @@ -4,8 +4,10 @@ namespace AngleSharp.Io.Tests.Cookie using AngleSharp.Dom; using AngleSharp.Html.Dom; using AngleSharp.Io.Tests.Mocks; + using Newtonsoft.Json.Linq; using NUnit.Framework; using System; + using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; @@ -136,8 +138,10 @@ public async Task SettingMultipleExpiredCookieInRequestDoNotAppearInDocument() [Test] public async Task SettingOneExpiredCookieAndAFutureCookieInRequestDoAppearInDocument() { + var year = DateTime.Today.Year + 1; + var dayOfWeek = new DateTime(year, 1, 28).ToString("ddd", CultureInfo.InvariantCulture); var cookie = await LoadDocumentWithCookie( - "cookie=expiring; Expires=Tue, 10 Nov 2009 23:00:00 GMT, foo=bar; Expires=Tue, 28 Jan 2025 13:37:00 GMT"); + $"cookie=expiring; Expires=Tue, 10 Nov 2009 23:00:00 GMT, foo=bar; Expires={dayOfWeek}, 28 Jan {year} 13:37:00 GMT"); Assert.AreEqual("foo=bar", cookie); } @@ -243,15 +247,11 @@ public async Task SettingThreeCookiesInOneRequestAreTransportedToNextRequest() await context.OpenAsync(url); var document = await context.OpenAsync(baseUrl); - var expected = @"{ - ""foo"": ""bar"", - ""k1"": ""v1"", - ""k2"": ""v2"", - ""test"": ""baz"" -} -".Replace("\r\n", "\n"); - - Assert.AreEqual(expected, document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("foo", "bar"), + new KeyValuePair("k1", "v1"), + new KeyValuePair("k2", "v2"), + new KeyValuePair("test", "baz")); } } @@ -267,10 +267,8 @@ public async Task SettingCookieIsPreservedViaRedirect() await context.OpenAsync(cookieUrl); var document = await context.OpenAsync(redirectUrl); - Assert.AreEqual(@"{ - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("test", "baz")); } } @@ -476,5 +474,19 @@ private static async Task LoadDocumentWithCookie(String cookieValue) var document = await LoadDocumentAloneWithCookie(cookieValue); return document.Cookie; } + + private static void AssertCookies(String body, params KeyValuePair[] expected) + { + var parsed = JObject.Parse(body); + var source = parsed["cookies"] as JObject ?? parsed; + var actual = source.Properties().ToDictionary(m => m.Name, m => m.Value.ToString()); + + CollectionAssert.AreEquivalent(expected.Select(m => m.Key), actual.Keys); + + foreach (var pair in expected) + { + Assert.AreEqual(pair.Value, actual[pair.Key]); + } + } } } diff --git a/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs b/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs index 2d41e18..e79667c 100644 --- a/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs +++ b/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs @@ -1,8 +1,11 @@ namespace AngleSharp.Io.Tests.Network { using AngleSharp.Dom; + using Newtonsoft.Json.Linq; using NUnit.Framework; using System; + using System.Collections.Generic; + using System.Linq; using System.Threading.Tasks; [TestFixture] @@ -14,7 +17,7 @@ public async Task SettingOneCookiesInOneRequestAppearsInDocument() if (Helper.IsNetworkAvailable()) { var url = "https://httpbingo.org/cookies/set?k1=v1"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); var document = await context.OpenAsync(url); @@ -28,7 +31,7 @@ public async Task SettingTwoCookiesInOneRequestAppearsInDocument() if (Helper.IsNetworkAvailable()) { var url = "https://httpbingo.org/cookies/set?k2=v2&k1=v1"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); var document = await context.OpenAsync(url); var cookies = document.Cookie.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries); @@ -42,7 +45,7 @@ public async Task SettingThreeCookiesInOneRequestAppearsInDocument() if (Helper.IsNetworkAvailable()) { var url = "https://httpbingo.org/cookies/set?test=baz&k2=v2&k1=v1&foo=bar"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); var document = await context.OpenAsync(url); var cookies = document.Cookie.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries); @@ -57,18 +60,16 @@ public async Task SettingThreeCookiesInOneRequestAreTransportedToNextRequest() { var baseUrl = "https://httpbingo.org/cookies"; var url = baseUrl + "/set?test=baz&k2=v2&k1=v1&foo=bar"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); await context.OpenAsync(url); var document = await context.OpenAsync(baseUrl); - Assert.AreEqual(@"{ - ""foo"": ""bar"", - ""k1"": ""v1"", - ""k2"": ""v2"", - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("foo", "bar"), + new KeyValuePair("k1", "v1"), + new KeyValuePair("k2", "v2"), + new KeyValuePair("test", "baz")); } } @@ -79,15 +80,13 @@ public async Task SettingCookieIsPreservedViaRedirect() { var cookieUrl = "https://httpbingo.org/cookies/set?test=baz"; var redirectUrl = "https://httpbingo.org/redirect-to?url=https%3A%2F%2Fhttpbingo.org%2Fcookies"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); await context.OpenAsync(cookieUrl); var document = await context.OpenAsync(redirectUrl); - Assert.AreEqual(@"{ - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent, + new KeyValuePair("test", "baz")); } } @@ -98,15 +97,26 @@ public async Task SettingCookieIsPreservedViaRedirectToDifferentProtocol() { var cookieUrl = "https://httpbingo.org/cookies/set?test=baz"; var redirectUrl = "http://httpbingo.org/redirect-to?url=http%3A%2F%2Fhttpbingo.org%2Fcookies"; - var config = Configuration.Default.WithCookies().WithRequesters().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); await context.OpenAsync(cookieUrl); var document = await context.OpenAsync(redirectUrl); - Assert.AreEqual(@"{ - ""test"": ""baz"" -} -".Replace("\r\n", "\n"), document.Body.TextContent); + AssertCookies(document.Body.TextContent); + } + } + + private static void AssertCookies(String body, params KeyValuePair[] expected) + { + var parsed = JObject.Parse(body); + var source = parsed["cookies"] as JObject ?? parsed; + var actual = source.Properties().ToDictionary(m => m.Name, m => m.Value.ToString()); + + CollectionAssert.AreEquivalent(expected.Select(m => m.Key), actual.Keys); + + foreach (var pair in expected) + { + Assert.AreEqual(pair.Value, actual[pair.Key]); } } } diff --git a/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs b/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs index eb10ce9..8244f46 100644 --- a/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs +++ b/src/AngleSharp.Io.Tests/Network/HttpClientRequesterTests.cs @@ -6,6 +6,7 @@ namespace AngleSharp.Io.Tests.Network using FluentAssertions; using NUnit.Framework; using System; + using System.Net; using System.IO; using System.Linq; using System.Net.Http; @@ -34,7 +35,9 @@ public async Task RequestWithContent() ts.HttpRequestMessage.Content.Headers.Select(p => p.Key).Should().BeEquivalentTo(new[] {"Content-Type", "Content-Length"}); ts.HttpRequestMessage.Content.Headers.ContentType.ToString().Should().Be("application/json"); ts.HttpRequestMessage.Content.Headers.ContentLength.Should().Be(9); + #pragma warning disable CS0618 ts.HttpRequestMessage.Properties.Should().BeEmpty(); + #pragma warning restore CS0618 ts.HttpRequestMessage.Headers.Select(p => p.Key).Should().BeEquivalentTo(new[] {"User-Agent", "Cookie"}); ts.HttpRequestMessage.Headers.UserAgent.ToString().Should().Be("Foo/2.0"); ts.HttpRequestMessage.Headers.Single(p => p.Key == "Cookie").Value.Should().BeEquivalentTo(new[] {"foo=bar"}); @@ -53,7 +56,9 @@ public async Task RequestWithoutContent() ts.HttpRequestMessage.Method.Should().Be(NetHttpMethod.Get); ts.HttpRequestMessage.RequestUri.Should().Be(new Uri("http://example/path?query=value")); ts.HttpRequestMessage.Content.Should().BeNull(); + #pragma warning disable CS0618 ts.HttpRequestMessage.Properties.Should().BeEmpty(); + #pragma warning restore CS0618 ts.HttpRequestMessage.Headers.Select(p => p.Key).Should().BeEquivalentTo(new[] {"User-Agent", "Cookie"}); ts.HttpRequestMessage.Headers.UserAgent.ToString().Should().Be("Foo/2.0"); ts.HttpRequestMessage.Headers.Single(p => p.Key == "Cookie").Value.Should().BeEquivalentTo(new[] {"foo=bar"}); @@ -118,18 +123,22 @@ public async Task EndToEnd() if (Helper.IsNetworkAvailable()) { // ARRANGE - var httpClient = new HttpClient(); + var httpClientHandler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; + var httpClient = new HttpClient(httpClientHandler); var requester = new HttpClientRequester(httpClient); var configuration = Configuration.Default.With(requester).WithDefaultLoader(); var context = BrowsingContext.New(configuration); - var request = DocumentRequest.Get(Url.Create("http://httpbingo.org/html")); + var request = DocumentRequest.Get(Url.Create("https://httpbingo.org/html")); // ACT var response = await context.GetService().FetchAsync(request).Task; var document = await context.OpenAsync(response, CancellationToken.None); // ASSERT - document.QuerySelector("h1").ToHtml().Should().Be("

Herman Melville - Moby-Dick

"); + document.QuerySelector("h1")?.TextContent.Should().Be("Herman Melville - Moby-Dick"); } } } diff --git a/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs b/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs index a3b1efa..b710107 100644 --- a/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs +++ b/src/AngleSharp.Io.Tests/Network/WebSocketTests.cs @@ -21,8 +21,8 @@ public async Task ConnectToWebSocketEcho() var haserror = false; var messages = new List(); var closed = new TaskCompletionSource(); - var document = await BrowsingContext.New().OpenNewAsync("https://echo.websocket.events/.ws"); - var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.events/"); + var document = await BrowsingContext.New().OpenNewAsync("https://echo.websocket.org/"); + var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.org/"); // ACT ws.Opened += (s, ev) => ws.Send(message); @@ -50,7 +50,7 @@ public async Task ConnectToWebSocketEcho() // ASSERT haserror.Should().BeFalse(); messages.Count.Should().Be(2); - messages[0].Should().Be("echo.websocket.events sponsored by Lob.com"); + messages[0].Should().StartWith("Request served by "); messages[1].Should().Be(message); } } diff --git a/src/AngleSharp.Io/Dom/InputFile.cs b/src/AngleSharp.Io/Dom/InputFile.cs index 996710b..9fb7c74 100644 --- a/src/AngleSharp.Io/Dom/InputFile.cs +++ b/src/AngleSharp.Io/Dom/InputFile.cs @@ -95,9 +95,24 @@ public IBlob Slice(Int32 start = 0, Int32 end = Int32.MaxValue, String contentTy { var ms = new MemoryStream(); _content.Position = start; - var buffer = new Byte[Math.Max(0, Math.Min(end, _content.Length) - start)]; - _content.Read(buffer, 0, buffer.Length); - ms.Write(buffer, 0, buffer.Length); + var length = Math.Max(0, (Int32)Math.Min((Int64)end, _content.Length) - start); + var buffer = new Byte[length]; + var read = 0; + + while (read < length) + { + var count = _content.Read(buffer, read, length - read); + + if (count == 0) + { + break; + } + + read += count; + } + + ms.Write(buffer, 0, read); + ms.Position = 0; _content.Position = 0; return new InputFile(_fileName, _type, ms); } diff --git a/src/AngleSharp.Io/Network/FileRequester.cs b/src/AngleSharp.Io/Network/FileRequester.cs index be19156..4023ab8 100644 --- a/src/AngleSharp.Io/Network/FileRequester.cs +++ b/src/AngleSharp.Io/Network/FileRequester.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Io.Network { using System; + using System.IO; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -16,22 +17,44 @@ public class FileRequester : BaseRequester /// The options to consider. /// The token for cancelling the task. /// The task that will eventually give the response data. - protected override async Task PerformRequestAsync(Request request, CancellationToken cancel) + protected override Task PerformRequestAsync(Request request, CancellationToken cancel) { - if (FileWebRequest.Create(request.Address.Href) is FileWebRequest requester) + cancel.ThrowIfCancellationRequested(); + + try { - var response = await requester.GetResponseAsync().ConfigureAwait(false); - var content = response.GetResponseStream(); + var uri = new Uri(request.Address.Href, UriKind.Absolute); + + if (uri.IsFile) + { + var content = File.OpenRead(uri.LocalPath); - return new DefaultResponse + return Task.FromResult(new DefaultResponse + { + Address = request.Address, + Content = content, + StatusCode = HttpStatusCode.OK + }); + } + + return Task.FromResult(default); + } + catch (FileNotFoundException) + { + return Task.FromResult(new DefaultResponse { Address = request.Address, - Content = content, - StatusCode = HttpStatusCode.OK - }; + StatusCode = HttpStatusCode.NotFound + }); + } + catch (DirectoryNotFoundException) + { + return Task.FromResult(new DefaultResponse + { + Address = request.Address, + StatusCode = HttpStatusCode.NotFound + }); } - - return default; } /// diff --git a/src/AngleSharp.Io/Network/FtpRequester.cs b/src/AngleSharp.Io/Network/FtpRequester.cs index 17617b9..eedcdff 100644 --- a/src/AngleSharp.Io/Network/FtpRequester.cs +++ b/src/AngleSharp.Io/Network/FtpRequester.cs @@ -18,20 +18,25 @@ public class FtpRequester : BaseRequester /// The task that will eventually give the response data. protected override async Task PerformRequestAsync(Request request, CancellationToken cancel) { + #pragma warning disable SYSLIB0014 if (FtpWebRequest.Create(request.Address.Href) is FtpWebRequest requester) + #pragma warning restore SYSLIB0014 { requester.Method = WebRequestMethods.Ftp.DownloadFile; requester.Credentials = new NetworkCredential("anonymous", String.Empty); - var response = await requester.GetResponseAsync().ConfigureAwait(false); - var content = response.GetResponseStream(); - - return new DefaultResponse + using (cancel.Register(requester.Abort)) { - Address = request.Address, - Content = content, - StatusCode = HttpStatusCode.OK - }; + var response = await requester.GetResponseAsync().ConfigureAwait(false); + var content = response.GetResponseStream(); + + return new DefaultResponse + { + Address = request.Address, + Content = content, + StatusCode = HttpStatusCode.OK + }; + } } return default; From c44493e68226159353824340cd858adec82587b9 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 20:48:06 +0200 Subject: [PATCH 11/31] Migrate NUKE --- .nuke/build.schema.json | 155 ++++++++++++++++++++-------------------- nuke/Build.cs | 46 +++++++----- nuke/_build.csproj | 8 ++- 3 files changed, 113 insertions(+), 96 deletions(-) diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index c1999a9..3166f3e 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -1,19 +1,56 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "title": "Build Schema", - "$ref": "#/definitions/build", "definitions": { - "build": { - "type": "object", + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "Clean", + "Compile", + "CopyFiles", + "CreatePackage", + "Default", + "Package", + "PrePublish", + "Publish", + "PublishPackage", + "PublishPreRelease", + "PublishRelease", + "Restore", + "RunUnitTests" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "NukeBuild": { "properties": { - "Configuration": { - "type": "string", - "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", - "enum": [ - "Debug", - "Release" - ] - }, "Continue": { "type": "boolean", "description": "Indicates to continue a previously failed build attempt" @@ -23,25 +60,8 @@ "description": "Shows the help text for this build assembly" }, "Host": { - "type": "string", "description": "Host for execution. Default is 'automatic'", - "enum": [ - "AppVeyor", - "AzurePipelines", - "Bamboo", - "Bitbucket", - "Bitrise", - "GitHubActions", - "GitLab", - "Jenkins", - "Rider", - "SpaceAutomation", - "TeamCity", - "Terminal", - "TravisCI", - "VisualStudio", - "VSCode" - ] + "$ref": "#/definitions/Host" }, "NoLogo": { "type": "boolean", @@ -62,10 +82,6 @@ "type": "string" } }, - "ReleaseNotesFilePath": { - "type": "string", - "description": "ReleaseNotesFilePath - To determine the SemanticVersion" - }, "Root": { "type": "string", "description": "Root directory during build execution" @@ -74,61 +90,46 @@ "type": "array", "description": "List of targets to be skipped. Empty list skips all dependencies", "items": { - "type": "string", - "enum": [ - "Clean", - "Compile", - "CopyFiles", - "CreatePackage", - "Default", - "Package", - "PrePublish", - "Publish", - "PublishPackage", - "PublishPreRelease", - "PublishRelease", - "Restore", - "RunUnitTests" - ] + "$ref": "#/definitions/ExecutableTarget" } }, - "Solution": { - "type": "string", - "description": "Path to a solution file that is automatically loaded" - }, "Target": { "type": "array", "description": "List of targets to be invoked. Default is '{default_target}'", "items": { - "type": "string", - "enum": [ - "Clean", - "Compile", - "CopyFiles", - "CreatePackage", - "Default", - "Package", - "PrePublish", - "Publish", - "PublishPackage", - "PublishPreRelease", - "PublishRelease", - "Restore", - "RunUnitTests" - ] + "$ref": "#/definitions/ExecutableTarget" } }, "Verbosity": { - "type": "string", "description": "Logging verbosity during build execution. Default is 'Normal'", + "$ref": "#/definitions/Verbosity" + } + } + } + }, + "allOf": [ + { + "properties": { + "Configuration": { + "type": "string", + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", "enum": [ - "Minimal", - "Normal", - "Quiet", - "Verbose" + "Debug", + "Release" ] + }, + "ReleaseNotesFilePath": { + "type": "string", + "description": "ReleaseNotesFilePath - To determine the SemanticVersion" + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" } } + }, + { + "$ref": "#/definitions/NukeBuild" } - } -} \ No newline at end of file + ] +} diff --git a/nuke/Build.cs b/nuke/Build.cs index f44344c..944054d 100644 --- a/nuke/Build.cs +++ b/nuke/Build.cs @@ -14,10 +14,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using static Nuke.Common.IO.FileSystemTasks; -using static Nuke.Common.IO.PathConstruction; +using Nuke.Common.Tooling; + using static Nuke.Common.Tools.DotNet.DotNetTasks; using static Nuke.Common.Tools.NuGet.NuGetTasks; + using Project = Nuke.Common.ProjectModel.Project; class Build : NukeBuild @@ -41,7 +42,7 @@ class Build : NukeBuild string TargetProjectName => "AngleSharp.Io"; - string TargetLibName => $"{TargetProjectName}"; + string TargetLibName => TargetProjectName; AbsolutePath SourceDirectory => RootDirectory / "src"; @@ -99,7 +100,7 @@ protected override void OnBuildInitialized() Log.Information("Building version: {Version}", Version); - TargetProject = Solution.GetProject(SourceDirectory / TargetProjectName / $"{TargetLibName}.csproj" ); + TargetProject = Solution.GetProject(TargetLibName); TargetProject.NotNull("TargetProject could not be loaded!"); TargetFrameworks = TargetProject.GetTargetFrameworks(); @@ -112,7 +113,7 @@ protected override void OnBuildInitialized() .Before(Restore) .Executes(() => { - SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(DeleteDirectory); + SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(x => x.DeleteDirectory()); }); Target Restore => _ => _ @@ -129,6 +130,7 @@ protected override void OnBuildInitialized() DotNetBuild(s => s .SetProjectFile(Solution) .SetConfiguration(Configuration) + .SetContinuousIntegrationBuild(IsServerBuild) .EnableNoRestore()); }); @@ -140,7 +142,19 @@ protected override void OnBuildInitialized() .SetProjectFile(Solution) .SetConfiguration(Configuration) .EnableNoRestore() - .EnableNoBuild()); + .EnableNoBuild() + .SetProcessEnvironmentVariable("prefetched", "false") + .When(_ => GitHubActions.Instance is not null, x => x.SetLoggers("GitHubActions")) + ); + + DotNetTest(s => s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore() + .EnableNoBuild() + .SetProcessEnvironmentVariable("prefetched", "true") + .When(_ => GitHubActions.Instance is not null, x => x.SetLoggers("GitHubActions")) + ); }); Target CopyFiles => _ => _ @@ -152,14 +166,14 @@ protected override void OnBuildInitialized() var targetDir = NugetDirectory / "lib" / item; var srcDir = BuildDirectory / item; - CopyFile(srcDir / $"{TargetProjectName}.dll", targetDir / $"{TargetProjectName}.dll", FileExistsPolicy.OverwriteIfNewer); - CopyFile(srcDir / $"{TargetProjectName}.pdb", targetDir / $"{TargetProjectName}.pdb", FileExistsPolicy.OverwriteIfNewer); - CopyFile(srcDir / $"{TargetProjectName}.xml", targetDir / $"{TargetProjectName}.xml", FileExistsPolicy.OverwriteIfNewer); + (srcDir / $"{TargetProjectName}.dll").Copy(targetDir / $"{TargetProjectName}.dll", policy: ExistsPolicy.FileOverwriteIfNewer); + (srcDir / $"{TargetProjectName}.pdb").Copy(targetDir / $"{TargetProjectName}.pdb", policy: ExistsPolicy.FileOverwriteIfNewer); + (srcDir / $"{TargetProjectName}.xml").Copy(targetDir / $"{TargetProjectName}.xml", policy: ExistsPolicy.FileOverwriteIfNewer); } - CopyFile(SourceDirectory / $"{TargetProjectName}.nuspec", NugetDirectory / $"{TargetProjectName}.nuspec", FileExistsPolicy.OverwriteIfNewer); - CopyFile(RootDirectory / "logo.png", NugetDirectory / "logo.png", FileExistsPolicy.OverwriteIfNewer); - CopyFile(RootDirectory / "README.md", NugetDirectory / "README.md", FileExistsPolicy.OverwriteIfNewer); + (SourceDirectory / $"{TargetProjectName}.nuspec").Copy(NugetDirectory / $"{TargetProjectName}.nuspec", policy: ExistsPolicy.FileOverwriteIfNewer); + (RootDirectory / "logo.png").Copy(NugetDirectory / "logo.png", policy: ExistsPolicy.FileOverwriteIfNewer); + (RootDirectory / "README.md").Copy(NugetDirectory / "README.md", policy: ExistsPolicy.FileOverwriteIfNewer); }); Target CreatePackage => _ => _ @@ -172,9 +186,9 @@ protected override void OnBuildInitialized() .SetTargetPath(nuspec) .SetVersion(Version) .SetOutputDirectory(NugetDirectory) - .SetSymbols(true) - .SetSymbolPackageFormat("snupkg") - .AddProperty("Configuration", Configuration) + .EnableSymbols() + .SetSymbolPackageFormat(NuGetSymbolPackageFormat.snupkg) + .SetConfiguration(Configuration) ); }); @@ -191,7 +205,7 @@ protected override void OnBuildInitialized() throw new BuildAbortedException("Could not resolve the NuGet API key."); } - foreach (var nupkg in GlobFiles(NugetDirectory, "*.nupkg")) + foreach (var nupkg in NugetDirectory.GlobFiles("*.nupkg")) { NuGetPush(s => s .SetTargetPath(nupkg) diff --git a/nuke/_build.csproj b/nuke/_build.csproj index 7521e1b..5a0000d 100644 --- a/nuke/_build.csproj +++ b/nuke/_build.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net10.0 CS0649;CS0169 .. @@ -11,11 +11,13 @@ - + + + - + From cd36a56af6dbaf66ab3bbd5ddb553bca8413cbf8 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 21:00:58 +0200 Subject: [PATCH 12/31] Enhanced CI/CD --- .github/workflows/ci.yml | 12 +++--- nuke/Build.cs | 88 ++++++++++++++++++++++++---------------- 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c212c..61e9edf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,10 @@ jobs: if: needs.can_document.outputs.value == 'true' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v5 with: node-version: "20.x" registry-url: 'https://registry.npmjs.org' @@ -48,10 +48,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 10.0.x @@ -63,10 +63,10 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 10.0.x diff --git a/nuke/Build.cs b/nuke/Build.cs index 944054d..7097aff 100644 --- a/nuke/Build.cs +++ b/nuke/Build.cs @@ -14,11 +14,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Nuke.Common.Tooling; - using static Nuke.Common.Tools.DotNet.DotNetTasks; using static Nuke.Common.Tools.NuGet.NuGetTasks; - using Project = Nuke.Common.ProjectModel.Project; class Build : NukeBuild @@ -37,12 +34,15 @@ class Build : NukeBuild [Nuke.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] readonly AbsolutePath ReleaseNotesFilePath = RootDirectory / "CHANGELOG.md"; + [Nuke.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] + readonly string AngleSharpVersion; + [Solution] readonly Solution Solution; string TargetProjectName => "AngleSharp.Io"; - string TargetLibName => TargetProjectName; + string TargetLibName => $"{TargetProjectName}"; AbsolutePath SourceDirectory => RootDirectory / "src"; @@ -119,42 +119,58 @@ protected override void OnBuildInitialized() Target Restore => _ => _ .Executes(() => { - DotNetRestore(s => s - .SetProjectFile(Solution)); + DotNetRestore(s => + { + var settings = s.SetProjectFile(Solution); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); }); Target Compile => _ => _ .DependsOn(Restore) .Executes(() => { - DotNetBuild(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .SetContinuousIntegrationBuild(IsServerBuild) - .EnableNoRestore()); + DotNetBuild(s => + { + var settings = s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore(); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); }); Target RunUnitTests => _ => _ .DependsOn(Compile) .Executes(() => { - DotNetTest(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore() - .EnableNoBuild() - .SetProcessEnvironmentVariable("prefetched", "false") - .When(_ => GitHubActions.Instance is not null, x => x.SetLoggers("GitHubActions")) - ); + DotNetTest(s => + { + var settings = s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore() + .EnableNoBuild(); - DotNetTest(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore() - .EnableNoBuild() - .SetProcessEnvironmentVariable("prefetched", "true") - .When(_ => GitHubActions.Instance is not null, x => x.SetLoggers("GitHubActions")) - ); + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); }); Target CopyFiles => _ => _ @@ -166,14 +182,14 @@ protected override void OnBuildInitialized() var targetDir = NugetDirectory / "lib" / item; var srcDir = BuildDirectory / item; - (srcDir / $"{TargetProjectName}.dll").Copy(targetDir / $"{TargetProjectName}.dll", policy: ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.pdb").Copy(targetDir / $"{TargetProjectName}.pdb", policy: ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.xml").Copy(targetDir / $"{TargetProjectName}.xml", policy: ExistsPolicy.FileOverwriteIfNewer); + (srcDir / $"{TargetProjectName}.dll").Copy(targetDir / $"{TargetProjectName}.dll", ExistsPolicy.FileOverwriteIfNewer); + (srcDir / $"{TargetProjectName}.pdb").Copy(targetDir / $"{TargetProjectName}.pdb", ExistsPolicy.FileOverwriteIfNewer); + (srcDir / $"{TargetProjectName}.xml").Copy(targetDir / $"{TargetProjectName}.xml", ExistsPolicy.FileOverwriteIfNewer); } - (SourceDirectory / $"{TargetProjectName}.nuspec").Copy(NugetDirectory / $"{TargetProjectName}.nuspec", policy: ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "logo.png").Copy(NugetDirectory / "logo.png", policy: ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "README.md").Copy(NugetDirectory / "README.md", policy: ExistsPolicy.FileOverwriteIfNewer); + (SourceDirectory / $"{TargetProjectName}.nuspec").Copy(NugetDirectory / $"{TargetProjectName}.nuspec", ExistsPolicy.FileOverwriteIfNewer); + (RootDirectory / "logo.png").Copy(NugetDirectory / "logo.png", ExistsPolicy.FileOverwriteIfNewer); + (RootDirectory / "README.md").Copy(NugetDirectory / "README.md", ExistsPolicy.FileOverwriteIfNewer); }); Target CreatePackage => _ => _ @@ -186,9 +202,9 @@ protected override void OnBuildInitialized() .SetTargetPath(nuspec) .SetVersion(Version) .SetOutputDirectory(NugetDirectory) - .EnableSymbols() - .SetSymbolPackageFormat(NuGetSymbolPackageFormat.snupkg) - .SetConfiguration(Configuration) + .SetSymbols(true) + .SetSymbolPackageFormat("snupkg") + .AddProperty("Configuration", Configuration) ); }); From 98aaf6173e48452524678e0e3bb0d4bb1a77cfb3 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 21:07:11 +0200 Subject: [PATCH 13/31] Adjusted frameworks --- src/AngleSharp.Io/AngleSharp.Io.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AngleSharp.Io/AngleSharp.Io.csproj b/src/AngleSharp.Io/AngleSharp.Io.csproj index e98f543..674d60b 100644 --- a/src/AngleSharp.Io/AngleSharp.Io.csproj +++ b/src/AngleSharp.Io/AngleSharp.Io.csproj @@ -23,7 +23,7 @@ - + From 4cd6ab96c5e8759d06e0c944ecee4b39806d69d0 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 21:16:04 +0200 Subject: [PATCH 14/31] Removed not required property --- src/AngleSharp.Io/AngleSharp.Io.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/AngleSharp.Io/AngleSharp.Io.csproj b/src/AngleSharp.Io/AngleSharp.Io.csproj index 674d60b..ae16cd9 100644 --- a/src/AngleSharp.Io/AngleSharp.Io.csproj +++ b/src/AngleSharp.Io/AngleSharp.Io.csproj @@ -5,7 +5,6 @@ netstandard2.0;net8.0;net10.0 $(TargetFrameworks);net462;net472 true - 9 https://github.com/AngleSharp/AngleSharp.Io git true @@ -20,7 +19,7 @@ - + From d1dbd0b131d4d7a45189ac633032768cc0df7ca9 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 22:04:16 +0200 Subject: [PATCH 15/31] Added documentation --- docs/README.md | 5 + docs/general/02-Dom-Objects-and-Helpers.md | 124 ++++++++++++++++++ docs/tutorials/03-Common-Workflows.md | 116 ++++++++++++++++ .../{03-Questions.md => 04-Questions.md} | 0 4 files changed, 245 insertions(+) create mode 100644 docs/general/02-Dom-Objects-and-Helpers.md create mode 100644 docs/tutorials/03-Common-Workflows.md rename docs/tutorials/{03-Questions.md => 04-Questions.md} (100%) diff --git a/docs/README.md b/docs/README.md index d76fadb..b65134f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,4 +2,9 @@ We have more detailed information regarding the following subjects: +- [Getting Started](general/01-Basics.md) +- [DOM Objects and Helpers](general/02-Dom-Objects-and-Helpers.md) - [API Documentation](tutorials/01-API.md) +- [Examples](tutorials/02-Examples.md) +- [Common Workflows](tutorials/03-Common-Workflows.md) +- [Frequently Asked Questions](tutorials/04-Questions.md) diff --git a/docs/general/02-Dom-Objects-and-Helpers.md b/docs/general/02-Dom-Objects-and-Helpers.md new file mode 100644 index 0000000..37333e8 --- /dev/null +++ b/docs/general/02-Dom-Objects-and-Helpers.md @@ -0,0 +1,124 @@ +--- +title: "DOM Objects and Helpers" +section: "AngleSharp.Io" +--- +# DOM Objects and Helpers + +AngleSharp.Io extends AngleSharp with additional DOM-related building blocks and convenience methods. This page summarizes what they are and when to use them. + +## Overview + +The package adds the following DOM-oriented types: + +- `InputFile` in `AngleSharp.Io.Dom` +- `WebSocket` in `AngleSharp.Io.Dom` +- `IStorage` interface in `AngleSharp.Io.Dom` +- `ElementExtensions` helper methods in `AngleSharp.Io.Dom` + +## InputFile + +`InputFile` implements `IFile` and is meant for programmatic file input scenarios, e.g., test automation for ``. + +### Constructors + +- `InputFile(String fileName, String type, Stream content, DateTime modified)` +- `InputFile(String fileName, String type, Stream content)` +- `InputFile(String fileName, String type, Byte[] content)` + +### Key members + +- `Name`: the file name as exposed to DOM consumers +- `Type`: MIME type +- `Length`: stream length +- `LastModified`: last modification timestamp +- `Body`: underlying content stream +- `Slice(...)`: returns an `IBlob` over a selected byte range + +### Notes + +- `InputFile` uses the provided stream directly. Ownership and disposal rules should be considered in tests and long-running automation. +- The `Slice` method returns a new `InputFile`-backed blob over copied bytes. + +## ElementExtensions + +`ElementExtensions` provides utility methods for `IHtmlInputElement` and URL-capable DOM elements. + +### File attachment helpers + +You can attach files to a file input in multiple ways: + +- `AppendFile(input, InputFile file)` +- `AppendFile(input, String filePath)` +- `AppendFile(input, String fileName, Stream content, String mimeType = null)` + +Example: + +```cs +var input = document.QuerySelector("input[type=file]"); + +input + .AppendFile("avatar.png", new MemoryStream(imageBytes), "image/png") + .AppendFile("contract.pdf"); +``` + +If the input element is not of type `file`, no file is appended. + +### Download helper + +`DownloadAsync` allows direct download from a link-like element (`IUrlUtilities` + `IElement`) via the configured loader. + +```cs +var link = document.QuerySelector("a.download"); +var response = await link.DownloadAsync(); +``` + +Requirements: + +- The element must belong to a browsing context. +- A document loader must be registered (usually via `WithDefaultLoader`). + +## WebSocket + +`WebSocket` is a DOM-facing wrapper around `ClientWebSocket` with familiar browser-style events. + +### Events + +- `Opened` (`open`) +- `Message` (`message`) +- `Error` (`error`) +- `Closed` (`close`) + +### Methods + +- `Send(String data)` +- `Close()` + +### Properties + +- `Url` +- `ReadyState` +- `Protocol` +- `Buffered` + +Example: + +```cs +var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.events/"); + +ws.Opened += (s, e) => ws.Send("hello"); +ws.Message += (s, e) => Console.WriteLine(e.Type); +ws.Error += (s, e) => Console.WriteLine("socket error"); +ws.Closed += (s, e) => Console.WriteLine("socket closed"); +``` + +## IStorage + +`IStorage` models the Web Storage API shape (`Storage`) and exposes: + +- `Length` +- `Key(Int32 index)` +- indexer `this[String key]` +- `Remove(String key)` +- `Clear()` + +It exists as a DOM contract abstraction, suitable for integration and host-specific implementations. diff --git a/docs/tutorials/03-Common-Workflows.md b/docs/tutorials/03-Common-Workflows.md new file mode 100644 index 0000000..925c11c --- /dev/null +++ b/docs/tutorials/03-Common-Workflows.md @@ -0,0 +1,116 @@ +--- +title: "Common Workflows" +section: "AngleSharp.Io" +--- +# Common Workflows + +This tutorial collects practical, copy-paste-friendly workflows for common AngleSharp.Io scenarios. + +## 1. Use custom HttpClient behavior with requesters + +Use this when you need proxying, custom decompression, headers, or controlled redirect behavior. + +```cs +var handler = new HttpClientHandler +{ + Proxy = new WebProxy("http://my-proxy.local:8080", false), + PreAuthenticate = true, + UseDefaultCredentials = false, + AutomaticDecompression = DecompressionMethods.Brotli | + DecompressionMethods.GZip | + DecompressionMethods.Deflate, +}; + +var config = Configuration.Default + .WithRequesters(handler) + .WithDefaultLoader(); + +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://httpbingo.org/html"); +``` + +Why this helps: + +- You keep a single place for transport behavior. +- You still use AngleSharp's standard loading pipeline. + +## 2. Persist cookies across runs + +Use this for crawl sessions or login flows that should survive process restarts. + +```cs +var cookiePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "anglesharp.cookies"); + +var config = Configuration.Default + .WithPersistentCookies(cookiePath) + .WithRequesters() + .WithDefaultLoader(); + +var context = BrowsingContext.New(config); +await context.OpenAsync("https://httpbingo.org/cookies/set?k1=v1"); +``` + +If you only need in-memory cookies for a single run, use `WithTemporaryCookies()`. + +## 3. Simulate file upload input in tests + +Use this when you need to test form/file handling without manual browser interaction. + +```cs +var config = Configuration.Default; +var context = BrowsingContext.New(config); +var document = await context.OpenAsync(req => req.Content("")); + +var input = document.QuerySelector("#f"); +input.AppendFile("avatar.png", new MemoryStream(imageBytes), "image/png"); + +var selected = input.Files[0]; +Console.WriteLine($"{selected.Name} ({selected.Type})"); +``` + +Alternative: + +- `AppendFile("/absolute/path/to/avatar.png")` to attach directly from disk. + +## 4. Download linked resources directly + +Use this if you want resource stream access from an anchor/link-like element. + +```cs +var config = Configuration.Default + .WithRequesters() + .WithDefaultLoader(); + +var context = BrowsingContext.New(config); +var document = await context.OpenAsync(req => req.Content("Download")); +var link = document.QuerySelector("a"); + +var response = await link.DownloadAsync(); +using var target = File.Create("file.bin"); +await response.Content.CopyToAsync(target); +``` + +You can also combine this with `WithStandardDownload(...)` if you want callback-based download routing. + +## 5. Open a WebSocket from a DOM context + +Use this for integration tests or headless workflows that need live message channels. + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenNewAsync("https://example.org"); + +var closed = new TaskCompletionSource(); +var ws = new WebSocket(document.DefaultView, "wss://echo.websocket.events/"); + +ws.Opened += (s, e) => ws.Send("hello"); +ws.Message += (s, e) => ws.Close(); +ws.Closed += (s, e) => closed.TrySetResult(true); +ws.Error += (s, e) => closed.TrySetResult(false); + +await closed.Task; +``` + +Tip: for tests that depend on external endpoints, use explicit timeouts to avoid indefinite waits. diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/04-Questions.md similarity index 100% rename from docs/tutorials/03-Questions.md rename to docs/tutorials/04-Questions.md From 9e5651a73bc78de1b149cba6c9ccaafe8308865f Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 30 Jul 2026 00:24:18 +0200 Subject: [PATCH 16/31] Updated time --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f3657..36aecd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 1.1.0 -Released on Thursday, July 31 2026. +Released on Friday, July 31 2026. - Updated to use a minimum of AngleSharp 1.5 - Fixed domain handling in cookies #36 From e7ecb1ac7ae780840a8c45a1e4a1ad95322054d1 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 30 Jul 2026 10:43:28 +0200 Subject: [PATCH 17/31] Added agents info --- AGENTS.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 8 ++++ 2 files changed, 123 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7a5f9d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. `CLAUDE.md` imports this file, so +keep it the single source of truth and every agent reads the same instructions. + +AngleSharp.Io is an AngleSharp extension package that provides additional requesters, +cookie handling, download support, and some DOM/network helpers for headless browsing. + +## Commands + +The orchestrator is NUKE (`nuke/Build.cs`), bootstrapped by `build.ps1` / `build.sh` +(`build.cmd` forwards to either). Default target is `RunUnitTests`. + +```powershell +.\build.ps1 # restore, compile, run the full test suite +.\build.ps1 -Target Compile # other targets: Clean Restore Compile RunUnitTests +.\build.ps1 -Target Package # CopyFiles CreatePackage Package PrePublish Publish +``` + +For the normal edit/test loop use the SDK directly — much faster than the NUKE bootstrap: + +```powershell +dotnet build src/AngleSharp.Io.sln +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "FullyQualifiedName~ClassicTests" +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "Name=SettingSimpleSingleCookieInRequestAppearsInDocument" +``` + +- Always pass `-f net8.0` when iterating. On Windows both projects also target `net462` and + `net472`, so omitting it runs everything three times. +- `TreatWarningsAsErrors` is on (`src/Directory.Build.props`) — a warning breaks the build. + There is no separate lint step; the compiler is it. +- The package version is parsed from the top entry of `CHANGELOG.md` (`ReleaseNotesParser`), + not from a csproj property. Release-worthy changes get a `CHANGELOG.md` line. +- `RunUnitTests` runs the suite twice, differing only in a `prefetched` environment variable + that nothing in this repo currently reads — a single run is equivalent locally. +- There is no `global.json`; the bootstrap scripts use the STS channel, CI installs 10.0.x. + +## Architecture + +The main integration surface is `src/AngleSharp.Io/IoConfigurationExtensions.cs`: + +- `WithRequesters(...)` registers the transport stack (`HttpClientRequester`, `DataRequester`, + `FtpRequester`, `FileRequester`, `AboutRequester`). +- `WithCookies(...)` wires `AdvancedCookieProvider` (memory or file-backed) as the cookie service. +- `WithDownload(...)` / `WithStandardDownload(...)` wraps AngleSharp's document factory to + intercept attachments / binary responses. + +Cookie subsystem layout (`src/AngleSharp.Io/Cookie`): + +- `AdvancedCookieProvider`: storage + RFC-like set/get behavior + expiry pruning. +- `CookieParser` / `WebCookie`: parsing and cookie model. +- `Helpers`: domain/path matching and canonicalization utilities. +- `NetscapeCookieSerializer`: persistence format conversion. +- `LocalFileHandler` / `MemoryFileHandler`: persistence backends. + +Important cookie behavior notes: + +- Domain canonicalization strips a leading dot (`.example.com` -> `example.com`) per RFC6265. +- Host-only cookies must match host exactly; non-host-only cookies use `DomainMatch(...)`. +- Be careful when filtering candidates for `GetCookie(...)`: exact-domain prefiltering can + accidentally exclude valid parent-domain cookies for subdomains (issue #36). +- `GetPublicSuffix(...)` in `Helpers` is currently a placeholder (returns empty string), so avoid + assuming public suffix protections are fully implemented. + +## Code conventions + +From `.github/CONTRIBUTING.md` and `.editorconfig` — these differ from typical modern C#: + +- `using` directives go **inside** the namespace declaration. +- Framework type names, not keywords: `String`, `Int32`, `Boolean`, `Object`. +- Prefer `var` on the left-hand side wherever possible. +- Always use statement blocks; blank line between two non-simple statements. +- `ConfigureAwait(false)` on every `await`. +- 4 spaces, LF, UTF-8, trimmed trailing whitespace; 2 spaces in `*.csproj`. +- Older files use `#region Fields / ctor / Properties / Methods / Helpers`; match the file + you are editing rather than converting it. +- Comments in the newer code explain *why* a non-obvious construct exists (prototype cycles, + laziness, Jint quirks). Keep that density — do not narrate what the code already says. + +## Tests + +NUnit 3 (classic model: `Assert.AreEqual`), fixtures in `src/AngleSharp.Io.Tests`. + +Useful test groups: + +- `Cookie/ParsingTests.cs`: fast, deterministic unit tests for parser + provider behavior. +- `Cookie/ClassicTests.cs`: higher-level cookie behavior, includes some network-dependent tests. +- `Network/*Tests.cs`: requester-focused behavior. + +Fast iteration commands: + +```powershell +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "FullyQualifiedName~AngleSharp.Io.Tests.Cookie.ParsingTests" +dotnet test src/AngleSharp.Io.Tests/AngleSharp.Io.Tests.csproj -f net8.0 --filter "FullyQualifiedName~AngleSharp.Io.Tests.Cookie" +``` + +Test-writing tips for cookie work: + +- Prefer `MemoryFileHandler` to keep tests isolated and deterministic. +- For provider-level checks, exercise `ICookieProvider.SetCookie` + `ICookieProvider.GetCookie` + directly with explicit `Url` instances. +- Network-backed tests (e.g., against httpbingo) can fail when payload formats change; treat those + failures as potential external drift before assuming a regression in cookie logic. + +## Repository notes + +These files are synced from the `AngleSharp.GitBase` repository and should not be edited +here: `.editorconfig`, `.gitignore`, `.gitattributes`, `.github/*`, `build.ps1`, `build.sh`, +`tools/*`, `LICENSE`. + +CI (`.github/workflows/ci.yml`) builds on Linux and Windows; on Windows it selects the NUKE +target from the branch (`main` → `Publish`, `devel` → `PrePublish`, otherwise the default). +`CONTRIBUTING.md` asks for feature branches (`feature/#777`) and pull requests against +`devel`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f02784 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +The guidance is shared with every AI agent working here, so it lives in AGENTS.md and is +imported below. Record new guidance there rather than in this file. + +@AGENTS.md From 013307078a50ee04764ca234d00e82b9fa68110b Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 30 Jul 2026 11:03:01 +0200 Subject: [PATCH 18/31] Added localstorage and sessionstorage --- CHANGELOG.md | 1 + README.md | 40 ++++++- docs/general/01-Basics.md | 40 +++++++ .../Storage/StorageTests.cs | 103 +++++++++++++++++ src/AngleSharp.Io/Dom/WindowExtensions.cs | 29 +++++ src/AngleSharp.Io/Interfaces/ILocalStorage.cs | 9 ++ .../Interfaces/ISessionStorage.cs | 9 ++ src/AngleSharp.Io/Interfaces/IStorage.cs | 2 +- .../IoConfigurationExtensions.cs | 50 ++++++++ src/AngleSharp.Io/Storage/LocalStorage.cs | 108 ++++++++++++++++++ src/AngleSharp.Io/Storage/SessionStorage.cs | 87 ++++++++++++++ src/AngleSharp.Io/StorageExtensions.cs | 27 +++++ 12 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 src/AngleSharp.Io.Tests/Storage/StorageTests.cs create mode 100644 src/AngleSharp.Io/Dom/WindowExtensions.cs create mode 100644 src/AngleSharp.Io/Interfaces/ILocalStorage.cs create mode 100644 src/AngleSharp.Io/Interfaces/ISessionStorage.cs create mode 100644 src/AngleSharp.Io/Storage/LocalStorage.cs create mode 100644 src/AngleSharp.Io/Storage/SessionStorage.cs create mode 100644 src/AngleSharp.Io/StorageExtensions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aecd9..d8895cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Released on Friday, July 31 2026. - Updated to use a minimum of AngleSharp 1.5 - Fixed domain handling in cookies #36 +- Added `LocalStorage` and `SessionStorage` # 1.0.0 diff --git a/README.md b/README.md index 0edeeba..5483672 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,44 @@ var config = Configuration.Default Alternatively, the new overloads for the `WithCookies` extension method can be used. +### Storage + +AngleSharp.Io provides implementations for both `sessionStorage`-style and +`localStorage`-style storage. + +To register in-memory session storage: + +```cs +var config = Configuration.Default + .WithSessionStorage(); +``` + +To register local storage persisted to disk: + +```cs +var syncPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); +var config = Configuration.Default + .WithLocalStorage(syncPath); +``` + +To register both at once: + +```cs +var config = Configuration.Default + .WithStorages(syncPath); +``` + +Once configured, storage services can be resolved from the browsing context: + +```cs +var context = BrowsingContext.New(config); +var localStorage = context.GetLocalStorage(); +var sessionStorage = context.GetSessionStorage(); + +localStorage["token"] = "abc"; +sessionStorage["ephemeral"] = "42"; +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. @@ -124,7 +162,7 @@ The `SaveToAsync` (as well as the `CopyToAsync`) are extension methods for the ` - Supporting file URLs - Enhanced support for about: URLs - WebSockets (mostly interesting for scripting engines, e.g., JS) -- Storage support by providing the `IStorage` interface +- Storage support with `LocalStorage` and `SessionStorage` implementations - Improved cookie container (`AdvancedCookieContainer`) - Enhanced download capabilities for resources / links diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index b14694b..fa2c3b1 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -92,6 +92,46 @@ var config = Configuration.Default Alternatively, the new overloads for the `WithCookies` extension method can be used. +### Storage + +AngleSharp.Io contains two `IStorage` implementations: + +- `SessionStorage` (in-memory) +- `LocalStorage` (file-synced) + +Register session storage: + +```cs +var config = Configuration.Default + .WithSessionStorage(); +``` + +Register local storage with sync to disk: + +```cs +var syncPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); +var config = Configuration.Default + .WithLocalStorage(syncPath); +``` + +Register both: + +```cs +var config = Configuration.Default + .WithStorages(syncPath); +``` + +Use the services from a browsing context: + +```cs +var context = BrowsingContext.New(config); +var localStorage = context.GetLocalStorage(); +var sessionStorage = context.GetSessionStorage(); + +localStorage["persisted"] = "yes"; +sessionStorage["transient"] = "yes"; +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/src/AngleSharp.Io.Tests/Storage/StorageTests.cs b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs new file mode 100644 index 0000000..37157d4 --- /dev/null +++ b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs @@ -0,0 +1,103 @@ +namespace AngleSharp.Io.Tests.Storage +{ + using AngleSharp.Io.Cookie; + using AngleSharp.Io.Dom; + using AngleSharp.Io.Storage; + using NUnit.Framework; + using System; + using System.IO; + + [TestFixture] + public class StorageTests + { + [Test] + public void SessionStorageStoresValuesInMemory() + { + var storage = new SessionStorage(); + storage["a"] = "1"; + storage["b"] = "2"; + + Assert.AreEqual(2, storage.Length); + Assert.AreEqual("1", storage["a"]); + Assert.AreEqual("a", storage.Key(0)); + Assert.AreEqual("b", storage.Key(1)); + } + + [Test] + public void SessionStorageCanRemoveAndClear() + { + var storage = new SessionStorage(); + storage["a"] = "1"; + storage["b"] = "2"; + storage.Remove("a"); + + Assert.AreEqual(1, storage.Length); + Assert.AreEqual(null, storage["a"]); + Assert.AreEqual("b", storage.Key(0)); + + storage.Clear(); + + Assert.AreEqual(0, storage.Length); + Assert.AreEqual(null, storage.Key(0)); + } + + [Test] + public void LocalStoragePersistsValuesViaFileHandler() + { + var handler = new MemoryFileHandler(); + var first = new LocalStorage(handler); + first["name"] = "anglesharp"; + first["version"] = "1"; + + var second = new LocalStorage(handler); + + Assert.AreEqual(2, second.Length); + Assert.AreEqual("anglesharp", second["name"]); + Assert.AreEqual("1", second["version"]); + } + + [Test] + public void LocalStorageCanSyncToDiskByPath() + { + var path = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}.txt"); + + try + { + var first = new LocalStorage(path); + first["user"] = "foo"; + + var second = new LocalStorage(path); + Assert.AreEqual("foo", second["user"]); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Test] + public void ConfigurationCanRegisterLocalAndSessionStorage() + { + var path = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}.txt"); + + try + { + var config = Configuration.Default.WithStorages(path); + var context = BrowsingContext.New(config); + + Assert.IsNotNull(context.GetLocalStorage()); + Assert.IsNotNull(context.GetSessionStorage()); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + } +} diff --git a/src/AngleSharp.Io/Dom/WindowExtensions.cs b/src/AngleSharp.Io/Dom/WindowExtensions.cs new file mode 100644 index 0000000..95fda5d --- /dev/null +++ b/src/AngleSharp.Io/Dom/WindowExtensions.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Attributes; + + /// + /// Defines a set of extensions for the window object. + /// + [DomExposed("Window")] + public static class WindowExtensions + { + /// + /// Gets the localStorage object. + /// + [DomName("localStorage")] + [DomAccessor(Accessors.Getter)] + public static ILocalStorage LocalStorage(this IWindow window) => + window.Document.Context?.GetService(); + + /// + /// Gets the sessionStorage object. + /// + [DomName("sessionStorage")] + [DomAccessor(Accessors.Getter)] + public static ISessionStorage SessionStorage(this IWindow window) => + window.Document.Context?.GetService(); + } +} diff --git a/src/AngleSharp.Io/Interfaces/ILocalStorage.cs b/src/AngleSharp.Io/Interfaces/ILocalStorage.cs new file mode 100644 index 0000000..d535492 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ILocalStorage.cs @@ -0,0 +1,9 @@ +namespace AngleSharp.Io.Dom +{ + /// + /// Represents a local storage implementation. + /// + public interface ILocalStorage : IStorage + { + } +} diff --git a/src/AngleSharp.Io/Interfaces/ISessionStorage.cs b/src/AngleSharp.Io/Interfaces/ISessionStorage.cs new file mode 100644 index 0000000..f497b9a --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ISessionStorage.cs @@ -0,0 +1,9 @@ +namespace AngleSharp.Io.Dom +{ + /// + /// Represents a session storage implementation. + /// + public interface ISessionStorage : IStorage + { + } +} diff --git a/src/AngleSharp.Io/Interfaces/IStorage.cs b/src/AngleSharp.Io/Interfaces/IStorage.cs index 93cc55c..413a131 100644 --- a/src/AngleSharp.Io/Interfaces/IStorage.cs +++ b/src/AngleSharp.Io/Interfaces/IStorage.cs @@ -9,7 +9,7 @@ /// [DomName("Storage")] [DomExposed("Window")] - public interface IStorage + public interface IStorage { /// /// Gets the number of stored keys. diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index 4195466..054e79a 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -3,7 +3,9 @@ namespace AngleSharp using AngleSharp.Dom; using AngleSharp.Io; using AngleSharp.Io.Cookie; + using AngleSharp.Io.Dom; using AngleSharp.Io.Network; + using AngleSharp.Io.Storage; using System; using System.IO; using System.Linq; @@ -180,5 +182,53 @@ public static IConfiguration WithCookies(this IConfiguration configuration, ICoo configuration.WithOnly(provider); #endregion + + #region Storage + + /// + /// Registers a persistent local storage service using sync against the given path. + /// + /// The configuration to extend. + /// The path to the required sync file. + /// The new instance with the service. + public static IConfiguration WithLocalStorage(this IConfiguration configuration, String syncFilePath) => + configuration.WithLocalStorage(new LocalStorage(syncFilePath)); + + /// + /// Registers the local storage service. + /// + /// The configuration to extend. + /// The local storage implementation. + /// The new instance with the service. + public static IConfiguration WithLocalStorage(this IConfiguration configuration, ILocalStorage storage) => + configuration.WithOnly(_ => storage); + + /// + /// Registers a non-persistent session storage service. + /// + /// The configuration to extend. + /// The new instance with the service. + public static IConfiguration WithSessionStorage(this IConfiguration configuration) => + configuration.WithSessionStorage(new SessionStorage()); + + /// + /// Registers the session storage service. + /// + /// The configuration to extend. + /// The session storage implementation. + /// The new instance with the service. + public static IConfiguration WithSessionStorage(this IConfiguration configuration, ISessionStorage storage) => + configuration.WithOnly(_ => storage); + + /// + /// Registers both local and session storage services. + /// + /// The configuration to extend. + /// The path to the required local storage sync file. + /// The new instance with both services. + public static IConfiguration WithStorages(this IConfiguration configuration, String syncFilePath) => + configuration.WithLocalStorage(syncFilePath).WithSessionStorage(); + + #endregion } } \ No newline at end of file diff --git a/src/AngleSharp.Io/Storage/LocalStorage.cs b/src/AngleSharp.Io/Storage/LocalStorage.cs new file mode 100644 index 0000000..b8764f5 --- /dev/null +++ b/src/AngleSharp.Io/Storage/LocalStorage.cs @@ -0,0 +1,108 @@ +namespace AngleSharp.Io.Storage +{ + using AngleSharp.Io.Cookie; + using AngleSharp.Io.Dom; + using System; + using System.Linq; + using System.Text; + + /// + /// Represents a persistent local storage. + /// + public class LocalStorage : SessionStorage, ILocalStorage + { + private readonly ICookieFileHandler _handler; + + /// + /// Creates a new local storage instance with sync against the given file path. + /// + /// The file path used to sync storage state. + public LocalStorage(String syncFilePath) + : this(new LocalFileHandler(syncFilePath)) + { + } + + /// + /// Creates a new local storage instance with the provided file handler. + /// + /// The handler used to read and write storage data. + public LocalStorage(ICookieFileHandler handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + ReadFromHandler(); + } + + /// + public override String this[String key] + { + get => base[key]; + set + { + base[key] = value; + WriteToHandler(); + } + } + + /// + public override void Remove(String key) + { + base.Remove(key); + WriteToHandler(); + } + + /// + public override void Clear() + { + base.Clear(); + WriteToHandler(); + } + + private void ReadFromHandler() + { + var content = _handler.ReadFile(); + + if (String.IsNullOrEmpty(content)) + { + return; + } + + var lines = content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + var index = line.IndexOf('\t'); + + if (index <= 0) + { + continue; + } + + var encodedKey = line.Substring(0, index); + var encodedValue = line.Substring(index + 1); + var key = Decode(encodedKey); + var value = Decode(encodedValue); + base[key] = value; + } + } + + private void WriteToHandler() + { + var sb = new StringBuilder(); + + foreach (var key in Enumerable.Range(0, Length).Select(Key)) + { + var value = base[key] ?? String.Empty; + sb.Append(Encode(key)); + sb.Append('\t'); + sb.Append(Encode(value)); + sb.Append('\n'); + } + + _handler.WriteFile(sb.ToString()); + } + + private static String Encode(String value) => Uri.EscapeDataString(value ?? String.Empty); + + private static String Decode(String value) => Uri.UnescapeDataString(value ?? String.Empty); + } +} diff --git a/src/AngleSharp.Io/Storage/SessionStorage.cs b/src/AngleSharp.Io/Storage/SessionStorage.cs new file mode 100644 index 0000000..8f755dd --- /dev/null +++ b/src/AngleSharp.Io/Storage/SessionStorage.cs @@ -0,0 +1,87 @@ +namespace AngleSharp.Io.Storage +{ + using AngleSharp.Io.Dom; + using System; + using System.Collections.Generic; + + /// + /// Represents an in-memory session storage. + /// + public class SessionStorage : ISessionStorage + { + private readonly Dictionary _entries; + private readonly List _keys; + + /// + /// Creates a new in-memory session storage. + /// + public SessionStorage() + { + _entries = new Dictionary(StringComparer.Ordinal); + _keys = new List(); + } + + /// + public virtual Int32 Length => _keys.Count; + + /// + public virtual String Key(Int32 index) + { + if (index < 0 || index >= _keys.Count) + { + return null; + } + + return _keys[index]; + } + + /// + public virtual String this[String key] + { + get + { + if (String.IsNullOrEmpty(key)) + { + return null; + } + + return _entries.TryGetValue(key, out var value) ? value : null; + } + set + { + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + if (!_entries.ContainsKey(key)) + { + _keys.Add(key); + } + + _entries[key] = value; + } + } + + /// + public virtual void Remove(String key) + { + if (String.IsNullOrEmpty(key)) + { + return; + } + + if (_entries.Remove(key)) + { + _keys.Remove(key); + } + } + + /// + public virtual void Clear() + { + _entries.Clear(); + _keys.Clear(); + } + } +} diff --git a/src/AngleSharp.Io/StorageExtensions.cs b/src/AngleSharp.Io/StorageExtensions.cs new file mode 100644 index 0000000..0cb323e --- /dev/null +++ b/src/AngleSharp.Io/StorageExtensions.cs @@ -0,0 +1,27 @@ +namespace AngleSharp +{ + using AngleSharp.Io.Dom; + + /// + /// Extensions for resolving storage services. + /// + public static class StorageExtensions + { + /// + /// Gets the configured local storage service, if any. + /// + /// The browsing context to inspect. + /// The local storage service or null. + public static ILocalStorage GetLocalStorage(this IBrowsingContext context) => + context?.GetService(); + + /// + /// Gets the configured session storage service, if any. + /// + /// The browsing context to inspect. + /// The session storage service or null. + public static ISessionStorage GetSessionStorage(this IBrowsingContext context) => + context?.GetService(); + + } +} From 2ea5747b138c9eb40e87643358b8eefc56a3a0c3 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 30 Jul 2026 12:00:39 +0200 Subject: [PATCH 19/31] Change to be compatible for differerent domains --- README.md | 33 +-- docs/general/01-Basics.md | 34 +-- .../Storage/StorageTests.cs | 254 +++++++++++++++--- src/AngleSharp.Io/Dom/WindowExtensions.cs | 14 +- .../Interfaces/IStorageProviderFactory.cs | 17 ++ src/AngleSharp.Io/Interfaces/StorageViews.cs | 29 ++ .../IoConfigurationExtensions.cs | 44 +-- src/AngleSharp.Io/Storage/IStorageHandler.cs | 25 ++ src/AngleSharp.Io/Storage/LocalStorage.cs | 108 -------- .../Storage/MemoryStorageHandler.cs | 58 ++++ .../Storage/PersistenceStorageHandler.cs | 110 ++++++++ src/AngleSharp.Io/Storage/SessionStorage.cs | 87 ------ src/AngleSharp.Io/Storage/Storage.cs | 198 ++++++++++++++ .../Storage/StorageProviderFactory.cs | 173 ++++++++++++ src/AngleSharp.Io/StorageExtensions.cs | 27 -- 15 files changed, 868 insertions(+), 343 deletions(-) create mode 100644 src/AngleSharp.Io/Interfaces/IStorageProviderFactory.cs create mode 100644 src/AngleSharp.Io/Interfaces/StorageViews.cs create mode 100644 src/AngleSharp.Io/Storage/IStorageHandler.cs delete mode 100644 src/AngleSharp.Io/Storage/LocalStorage.cs create mode 100644 src/AngleSharp.Io/Storage/MemoryStorageHandler.cs create mode 100644 src/AngleSharp.Io/Storage/PersistenceStorageHandler.cs delete mode 100644 src/AngleSharp.Io/Storage/SessionStorage.cs create mode 100644 src/AngleSharp.Io/Storage/Storage.cs create mode 100644 src/AngleSharp.Io/Storage/StorageProviderFactory.cs delete mode 100644 src/AngleSharp.Io/StorageExtensions.cs diff --git a/README.md b/README.md index 5483672..86f5ac8 100644 --- a/README.md +++ b/README.md @@ -78,37 +78,30 @@ Alternatively, the new overloads for the `WithCookies` extension method can be u ### Storage -AngleSharp.Io provides implementations for both `sessionStorage`-style and -`localStorage`-style storage. +AngleSharp.Io provides a single storage engine with two storage modes: -To register in-memory session storage: +- `localStorage` using persistent per-origin files +- `sessionStorage` using in-memory per-origin buckets -```cs -var config = Configuration.Default - .WithSessionStorage(); -``` - -To register local storage persisted to disk: +To configure storage, create and register an `IStorageProviderFactory`: ```cs -var syncPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); -var config = Configuration.Default - .WithLocalStorage(syncPath); -``` +var syncDirectoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); +var factory = new StorageProviderFactory(); -To register both at once: +factory.EnableLocalStorage(syncDirectoryPath); +factory.EnableSessionStorage(); -```cs -var config = Configuration.Default - .WithStorages(syncPath); +var config = Configuration.Default.WithStorageProviderFactory(factory); ``` Once configured, storage services can be resolved from the browsing context: ```cs var context = BrowsingContext.New(config); -var localStorage = context.GetLocalStorage(); -var sessionStorage = context.GetSessionStorage(); +var document = await context.OpenAsync("https://example.org"); +var localStorage = document.DefaultView.GetLocalStorage(); +var sessionStorage = document.DefaultView.GetSessionStorage(); localStorage["token"] = "abc"; sessionStorage["ephemeral"] = "42"; @@ -162,7 +155,7 @@ The `SaveToAsync` (as well as the `CopyToAsync`) are extension methods for the ` - Supporting file URLs - Enhanced support for about: URLs - WebSockets (mostly interesting for scripting engines, e.g., JS) -- Storage support with `LocalStorage` and `SessionStorage` implementations +- Storage support with a unified `Storage` implementation - Improved cookie container (`AdvancedCookieContainer`) - Enhanced download capabilities for resources / links diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index fa2c3b1..52eb1c0 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -94,39 +94,31 @@ Alternatively, the new overloads for the `WithCookies` extension method can be u ### Storage -AngleSharp.Io contains two `IStorage` implementations: +AngleSharp.Io contains a single `Storage` implementation that is exposed as +`localStorage` and `sessionStorage` depending on the configured handler: -- `SessionStorage` (in-memory) -- `LocalStorage` (file-synced) +- local storage uses persistent per-origin files +- session storage uses in-memory per-origin buckets -Register session storage: +Register storage by providing a storage provider factory: ```cs -var config = Configuration.Default - .WithSessionStorage(); -``` +var syncDirectoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); +var factory = new StorageProviderFactory(); -Register local storage with sync to disk: +factory.EnableLocalStorage(syncDirectoryPath); +factory.EnableSessionStorage(); -```cs -var syncPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "anglesharp.storage"); -var config = Configuration.Default - .WithLocalStorage(syncPath); -``` - -Register both: - -```cs -var config = Configuration.Default - .WithStorages(syncPath); +var config = Configuration.Default.WithStorageProviderFactory(factory); ``` Use the services from a browsing context: ```cs var context = BrowsingContext.New(config); -var localStorage = context.GetLocalStorage(); -var sessionStorage = context.GetSessionStorage(); +var document = await context.OpenAsync("https://example.org"); +var localStorage = document.DefaultView.GetLocalStorage(); +var sessionStorage = document.DefaultView.GetSessionStorage(); localStorage["persisted"] = "yes"; sessionStorage["transient"] = "yes"; diff --git a/src/AngleSharp.Io.Tests/Storage/StorageTests.cs b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs index 37157d4..d69c0d7 100644 --- a/src/AngleSharp.Io.Tests/Storage/StorageTests.cs +++ b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs @@ -1,19 +1,24 @@ namespace AngleSharp.Io.Tests.Storage { - using AngleSharp.Io.Cookie; + using AngleSharp.Dom; using AngleSharp.Io.Dom; using AngleSharp.Io.Storage; using NUnit.Framework; using System; + using System.Linq; using System.IO; + using System.Threading.Tasks; [TestFixture] public class StorageTests { [Test] - public void SessionStorageStoresValuesInMemory() + public void StorageUsesCurrentOriginBucket() { - var storage = new SessionStorage(); + var origin = "https://a.example"; + var buckets = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); + var storage = new Storage(() => origin, o => GetOrCreateBucket(buckets, o), (o, b) => { }); + storage["a"] = "1"; storage["b"] = "2"; @@ -21,81 +26,258 @@ public void SessionStorageStoresValuesInMemory() Assert.AreEqual("1", storage["a"]); Assert.AreEqual("a", storage.Key(0)); Assert.AreEqual("b", storage.Key(1)); + + origin = "https://b.example"; + Assert.AreEqual(0, storage.Length); + Assert.AreEqual(null, storage["a"]); } [Test] - public void SessionStorageCanRemoveAndClear() + public void StoragePersistsViaHandlerOnMutation() { - var storage = new SessionStorage(); + var handler = new MemoryStorageHandler(); + var buckets = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); + var storage = new Storage(() => "https://a.example", o => + { + if (!buckets.TryGetValue(o, out var bucket)) + { + bucket = new StorageBucket(handler.Read(o)); + buckets[o] = bucket; + } + + return bucket; + }, (o, b) => handler.Write(o, b.ToEntries())); + storage["a"] = "1"; storage["b"] = "2"; storage.Remove("a"); - - Assert.AreEqual(1, storage.Length); - Assert.AreEqual(null, storage["a"]); - Assert.AreEqual("b", storage.Key(0)); - storage.Clear(); - Assert.AreEqual(0, storage.Length); - Assert.AreEqual(null, storage.Key(0)); + Assert.IsFalse(handler.Read("https://a.example").Any()); } [Test] - public void LocalStoragePersistsValuesViaFileHandler() + public void MemoryStorageHandlerReadsAndWritesPerOrigin() { - var handler = new MemoryFileHandler(); - var first = new LocalStorage(handler); - first["name"] = "anglesharp"; - first["version"] = "1"; + var handler = new MemoryStorageHandler(); + handler.Write("https://a.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "bar"), + }); + + handler.Write("https://b.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "baz"), + }); - var second = new LocalStorage(handler); + var a = handler.Read("https://a.example").Single(); + var b = handler.Read("https://b.example").Single(); - Assert.AreEqual(2, second.Length); - Assert.AreEqual("anglesharp", second["name"]); - Assert.AreEqual("1", second["version"]); + Assert.AreEqual("bar", a.Value); + Assert.AreEqual("baz", b.Value); } [Test] - public void LocalStorageCanSyncToDiskByPath() + public void PersistenceStorageHandlerStoresFilesPerOrigin() { - var path = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}.txt"); + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); try { - var first = new LocalStorage(path); - first["user"] = "foo"; + var handler = new PersistenceStorageHandler(directory); + handler.Write("https://a.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "bar"), + }); + handler.Write("https://b.example", new[] + { + new System.Collections.Generic.KeyValuePair("foo", "baz"), + }); - var second = new LocalStorage(path); - Assert.AreEqual("foo", second["user"]); + var files = Directory.GetFiles(directory, "*.storage"); + Assert.AreEqual(2, files.Length); + Assert.AreEqual("bar", handler.Read("https://a.example").Single().Value); + Assert.AreEqual("baz", handler.Read("https://b.example").Single().Value); } finally { - if (File.Exists(path)) + if (Directory.Exists(directory)) { - File.Delete(path); + Directory.Delete(directory, true); } } } [Test] - public void ConfigurationCanRegisterLocalAndSessionStorage() + public async Task ConfigurationCanRegisterLocalAndSessionStorage() { - var path = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}.txt"); + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); try { - var config = Configuration.Default.WithStorages(path); + var config = CreateConfigWithStorages(directory); var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://storage.example/"); + + Assert.IsNotNull(window.LocalStorage()); + Assert.IsNotNull(window.SessionStorage()); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task LocalStorageIsOriginDependentAcrossWindows() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + var contextC = BrowsingContext.New(config); + var windowA = await OpenWindowAsync(contextA, "https://storage.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + var windowC = await OpenWindowAsync(contextC, "https://storage.example/again"); - Assert.IsNotNull(context.GetLocalStorage()); - Assert.IsNotNull(context.GetSessionStorage()); + windowA.LocalStorage()["token"] = "x"; + Assert.AreEqual("x", windowA.LocalStorage()["token"]); + Assert.AreEqual(null, windowB.LocalStorage()["token"]); + Assert.AreEqual("x", windowC.LocalStorage()["token"]); } finally { - if (File.Exists(path)) + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task SessionStorageIsAlsoOriginDependentAcrossWindows() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + var contextC = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://alpha.example/"); + var windowB = await OpenWindowAsync(contextB, "https://alpha.example/other"); + var windowC = await OpenWindowAsync(contextC, "https://beta.example/"); + + var localA = windowA.LocalStorage(); + var localB = windowB.LocalStorage(); + var localC = windowC.LocalStorage(); + + localA["id"] = "alpha"; + + Assert.AreEqual("alpha", localB["id"]); + Assert.AreEqual(null, localC["id"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task SessionStorageSharesStateForSameOrigin() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://alpha.example/"); + var windowB = await OpenWindowAsync(contextB, "https://alpha.example/"); + var sessionA = windowA.SessionStorage(); + var sessionB = windowB.SessionStorage(); + + sessionA["sid"] = "1"; + + Assert.AreEqual("1", sessionA["sid"]); + Assert.AreEqual("1", sessionB["sid"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + private static StorageBucket GetOrCreateBucket(System.Collections.Generic.Dictionary buckets, String origin) + { + origin = origin ?? String.Empty; + + if (!buckets.TryGetValue(origin, out var bucket)) + { + bucket = new StorageBucket(); + buckets[origin] = bucket; + } + + return bucket; + } + + private static IConfiguration CreateConfigWithStorages(String directory) + { + var factory = new StorageProviderFactory(); + factory.EnableLocalStorage(directory); + factory.EnableSessionStorage(); + return Configuration.Default.WithStorageProviderFactory(factory); + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("storage")); + return document.DefaultView; + } + + [TearDown] + public void CleanupTempStorageFiles() + { + var tmp = Path.GetTempPath(); + + foreach (var file in Directory.GetFiles(tmp, "anglesharp-io-storage-*.txt")) + { + try + { + File.Delete(file); + } + catch + { + // Best effort cleanup for temporary test files. + } + } + + foreach (var directory in Directory.GetDirectories(tmp, "anglesharp-io-storage-*") ) + { + try + { + Directory.Delete(directory, true); + } + catch { - File.Delete(path); + // Best effort cleanup for temporary test directories. } } } diff --git a/src/AngleSharp.Io/Dom/WindowExtensions.cs b/src/AngleSharp.Io/Dom/WindowExtensions.cs index 95fda5d..84017aa 100644 --- a/src/AngleSharp.Io/Dom/WindowExtensions.cs +++ b/src/AngleSharp.Io/Dom/WindowExtensions.cs @@ -15,15 +15,21 @@ public static class WindowExtensions /// [DomName("localStorage")] [DomAccessor(Accessors.Getter)] - public static ILocalStorage LocalStorage(this IWindow window) => - window.Document.Context?.GetService(); + public static ILocalStorage LocalStorage(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetStorages(window)?.Local; + } /// /// Gets the sessionStorage object. /// [DomName("sessionStorage")] [DomAccessor(Accessors.Getter)] - public static ISessionStorage SessionStorage(this IWindow window) => - window.Document.Context?.GetService(); + public static ISessionStorage SessionStorage(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetStorages(window)?.Session; + } } } diff --git a/src/AngleSharp.Io/Interfaces/IStorageProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IStorageProviderFactory.cs new file mode 100644 index 0000000..dc21ca7 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IStorageProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Dom; + + /// + /// Represents a factory for creating storage views per window. + /// + public interface IStorageProviderFactory + { + /// + /// Gets the storage views for the provided window. + /// + /// The window requesting storage. + /// The storage views or null. + StorageViews GetStorages(IWindow window); + } +} diff --git a/src/AngleSharp.Io/Interfaces/StorageViews.cs b/src/AngleSharp.Io/Interfaces/StorageViews.cs new file mode 100644 index 0000000..785794c --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/StorageViews.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Io.Dom +{ + /// + /// Represents the storage views available for a single window. + /// + public sealed class StorageViews + { + /// + /// Creates a new set of storage views. + /// + /// The local storage view. + /// The session storage view. + public StorageViews(ILocalStorage local, ISessionStorage session) + { + Local = local; + Session = session; + } + + /// + /// Gets the local storage view. + /// + public ILocalStorage Local { get; } + + /// + /// Gets the session storage view. + /// + public ISessionStorage Session { get; } + } +} diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index 054e79a..506cb1b 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -5,7 +5,6 @@ namespace AngleSharp using AngleSharp.Io.Cookie; using AngleSharp.Io.Dom; using AngleSharp.Io.Network; - using AngleSharp.Io.Storage; using System; using System.IO; using System.Linq; @@ -186,48 +185,13 @@ public static IConfiguration WithCookies(this IConfiguration configuration, ICoo #region Storage /// - /// Registers a persistent local storage service using sync against the given path. + /// Registers a storage provider factory. /// /// The configuration to extend. - /// The path to the required sync file. - /// The new instance with the service. - public static IConfiguration WithLocalStorage(this IConfiguration configuration, String syncFilePath) => - configuration.WithLocalStorage(new LocalStorage(syncFilePath)); - - /// - /// Registers the local storage service. - /// - /// The configuration to extend. - /// The local storage implementation. - /// The new instance with the service. - public static IConfiguration WithLocalStorage(this IConfiguration configuration, ILocalStorage storage) => - configuration.WithOnly(_ => storage); - - /// - /// Registers a non-persistent session storage service. - /// - /// The configuration to extend. - /// The new instance with the service. - public static IConfiguration WithSessionStorage(this IConfiguration configuration) => - configuration.WithSessionStorage(new SessionStorage()); - - /// - /// Registers the session storage service. - /// - /// The configuration to extend. - /// The session storage implementation. + /// The storage provider factory to use. /// The new instance with the service. - public static IConfiguration WithSessionStorage(this IConfiguration configuration, ISessionStorage storage) => - configuration.WithOnly(_ => storage); - - /// - /// Registers both local and session storage services. - /// - /// The configuration to extend. - /// The path to the required local storage sync file. - /// The new instance with both services. - public static IConfiguration WithStorages(this IConfiguration configuration, String syncFilePath) => - configuration.WithLocalStorage(syncFilePath).WithSessionStorage(); + public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, IStorageProviderFactory factory) => + configuration.WithOnly(_ => factory); #endregion } diff --git a/src/AngleSharp.Io/Storage/IStorageHandler.cs b/src/AngleSharp.Io/Storage/IStorageHandler.cs new file mode 100644 index 0000000..619100b --- /dev/null +++ b/src/AngleSharp.Io/Storage/IStorageHandler.cs @@ -0,0 +1,25 @@ +namespace AngleSharp.Io.Storage +{ + using System; + using System.Collections.Generic; + + /// + /// Represents a handler for reading and writing storage data per origin. + /// + public interface IStorageHandler + { + /// + /// Reads the storage entries for the given origin. + /// + /// The origin to read. + /// The key-value pairs for the origin. + IEnumerable> Read(String origin); + + /// + /// Writes the storage entries for the given origin. + /// + /// The origin to write. + /// The key-value pairs to write. + void Write(String origin, IEnumerable> entries); + } +} diff --git a/src/AngleSharp.Io/Storage/LocalStorage.cs b/src/AngleSharp.Io/Storage/LocalStorage.cs deleted file mode 100644 index b8764f5..0000000 --- a/src/AngleSharp.Io/Storage/LocalStorage.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace AngleSharp.Io.Storage -{ - using AngleSharp.Io.Cookie; - using AngleSharp.Io.Dom; - using System; - using System.Linq; - using System.Text; - - /// - /// Represents a persistent local storage. - /// - public class LocalStorage : SessionStorage, ILocalStorage - { - private readonly ICookieFileHandler _handler; - - /// - /// Creates a new local storage instance with sync against the given file path. - /// - /// The file path used to sync storage state. - public LocalStorage(String syncFilePath) - : this(new LocalFileHandler(syncFilePath)) - { - } - - /// - /// Creates a new local storage instance with the provided file handler. - /// - /// The handler used to read and write storage data. - public LocalStorage(ICookieFileHandler handler) - { - _handler = handler ?? throw new ArgumentNullException(nameof(handler)); - ReadFromHandler(); - } - - /// - public override String this[String key] - { - get => base[key]; - set - { - base[key] = value; - WriteToHandler(); - } - } - - /// - public override void Remove(String key) - { - base.Remove(key); - WriteToHandler(); - } - - /// - public override void Clear() - { - base.Clear(); - WriteToHandler(); - } - - private void ReadFromHandler() - { - var content = _handler.ReadFile(); - - if (String.IsNullOrEmpty(content)) - { - return; - } - - var lines = content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); - - foreach (var line in lines) - { - var index = line.IndexOf('\t'); - - if (index <= 0) - { - continue; - } - - var encodedKey = line.Substring(0, index); - var encodedValue = line.Substring(index + 1); - var key = Decode(encodedKey); - var value = Decode(encodedValue); - base[key] = value; - } - } - - private void WriteToHandler() - { - var sb = new StringBuilder(); - - foreach (var key in Enumerable.Range(0, Length).Select(Key)) - { - var value = base[key] ?? String.Empty; - sb.Append(Encode(key)); - sb.Append('\t'); - sb.Append(Encode(value)); - sb.Append('\n'); - } - - _handler.WriteFile(sb.ToString()); - } - - private static String Encode(String value) => Uri.EscapeDataString(value ?? String.Empty); - - private static String Decode(String value) => Uri.UnescapeDataString(value ?? String.Empty); - } -} diff --git a/src/AngleSharp.Io/Storage/MemoryStorageHandler.cs b/src/AngleSharp.Io/Storage/MemoryStorageHandler.cs new file mode 100644 index 0000000..59e12e9 --- /dev/null +++ b/src/AngleSharp.Io/Storage/MemoryStorageHandler.cs @@ -0,0 +1,58 @@ +namespace AngleSharp.Io.Storage +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Represents an in-memory storage handler. + /// + public class MemoryStorageHandler : IStorageHandler + { + private readonly Dictionary>> _byOrigin; + + /// + /// Creates a new in-memory storage handler. + /// + public MemoryStorageHandler() + { + _byOrigin = new Dictionary>>(StringComparer.Ordinal); + } + + /// + public IEnumerable> Read(String origin) + { + origin = origin ?? String.Empty; + + if (_byOrigin.TryGetValue(origin, out var entries)) + { + return entries.ToArray(); + } + + return Array.Empty>(); + } + + /// + public void Write(String origin, IEnumerable> entries) + { + origin = origin ?? String.Empty; + + if (entries == null) + { + _byOrigin.Remove(origin); + return; + } + + var values = entries.ToList(); + + if (values.Count == 0) + { + _byOrigin.Remove(origin); + } + else + { + _byOrigin[origin] = values; + } + } + } +} diff --git a/src/AngleSharp.Io/Storage/PersistenceStorageHandler.cs b/src/AngleSharp.Io/Storage/PersistenceStorageHandler.cs new file mode 100644 index 0000000..c91e11d --- /dev/null +++ b/src/AngleSharp.Io/Storage/PersistenceStorageHandler.cs @@ -0,0 +1,110 @@ +namespace AngleSharp.Io.Storage +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Security.Cryptography; + using System.Text; + + /// + /// Represents a persistent storage handler writing files per origin. + /// + public class PersistenceStorageHandler : IStorageHandler + { + private readonly String _directoryPath; + + /// + /// Creates a new persistent storage handler for the given directory. + /// + /// The directory used to store origin-specific files. + public PersistenceStorageHandler(String directoryPath) + { + _directoryPath = directoryPath ?? throw new ArgumentNullException(nameof(directoryPath)); + Directory.CreateDirectory(_directoryPath); + } + + /// + public IEnumerable> Read(String origin) + { + var filePath = GetFilePath(origin); + + if (!File.Exists(filePath)) + { + return Array.Empty>(); + } + + var result = new List>(); + var lines = File.ReadAllLines(filePath); + + foreach (var line in lines) + { + var index = line.IndexOf('\t'); + + if (index <= 0) + { + continue; + } + + var key = Decode(line.Substring(0, index)); + var value = Decode(line.Substring(index + 1)); + result.Add(new KeyValuePair(key, value)); + } + + return result; + } + + /// + public void Write(String origin, IEnumerable> entries) + { + var filePath = GetFilePath(origin); + var values = entries?.ToArray() ?? Array.Empty>(); + + if (values.Length == 0) + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + return; + } + + var lines = values.Select(m => $"{Encode(m.Key)}\t{Encode(m.Value)}").ToArray(); + File.WriteAllLines(filePath, lines); + } + + private String GetFilePath(String origin) + { + origin = origin ?? String.Empty; + var bytes = Encoding.UTF8.GetBytes(origin); + var hash = ComputeHash(bytes); + var hex = ToHex(hash); + return Path.Combine(_directoryPath, $"{hex}.storage"); + } + + private static Byte[] ComputeHash(Byte[] bytes) + { + using (var sha = SHA256.Create()) + { + return sha.ComputeHash(bytes); + } + } + + private static String ToHex(Byte[] bytes) + { + var sb = new StringBuilder(bytes.Length * 2); + + for (var i = 0; i < bytes.Length; i++) + { + sb.Append(bytes[i].ToString("x2")); + } + + return sb.ToString(); + } + + private static String Encode(String value) => Uri.EscapeDataString(value ?? String.Empty); + + private static String Decode(String value) => Uri.UnescapeDataString(value ?? String.Empty); + } +} diff --git a/src/AngleSharp.Io/Storage/SessionStorage.cs b/src/AngleSharp.Io/Storage/SessionStorage.cs deleted file mode 100644 index 8f755dd..0000000 --- a/src/AngleSharp.Io/Storage/SessionStorage.cs +++ /dev/null @@ -1,87 +0,0 @@ -namespace AngleSharp.Io.Storage -{ - using AngleSharp.Io.Dom; - using System; - using System.Collections.Generic; - - /// - /// Represents an in-memory session storage. - /// - public class SessionStorage : ISessionStorage - { - private readonly Dictionary _entries; - private readonly List _keys; - - /// - /// Creates a new in-memory session storage. - /// - public SessionStorage() - { - _entries = new Dictionary(StringComparer.Ordinal); - _keys = new List(); - } - - /// - public virtual Int32 Length => _keys.Count; - - /// - public virtual String Key(Int32 index) - { - if (index < 0 || index >= _keys.Count) - { - return null; - } - - return _keys[index]; - } - - /// - public virtual String this[String key] - { - get - { - if (String.IsNullOrEmpty(key)) - { - return null; - } - - return _entries.TryGetValue(key, out var value) ? value : null; - } - set - { - if (String.IsNullOrEmpty(key)) - { - throw new ArgumentNullException(nameof(key)); - } - - if (!_entries.ContainsKey(key)) - { - _keys.Add(key); - } - - _entries[key] = value; - } - } - - /// - public virtual void Remove(String key) - { - if (String.IsNullOrEmpty(key)) - { - return; - } - - if (_entries.Remove(key)) - { - _keys.Remove(key); - } - } - - /// - public virtual void Clear() - { - _entries.Clear(); - _keys.Clear(); - } - } -} diff --git a/src/AngleSharp.Io/Storage/Storage.cs b/src/AngleSharp.Io/Storage/Storage.cs new file mode 100644 index 0000000..b54a9e9 --- /dev/null +++ b/src/AngleSharp.Io/Storage/Storage.cs @@ -0,0 +1,198 @@ +namespace AngleSharp.Io.Storage +{ + using AngleSharp.Io.Dom; + using System; + using System.Collections.Generic; + + /// + /// Represents a storage view over origin-bound buckets. + /// + public class Storage : ILocalStorage, ISessionStorage + { + private readonly Func _getOrigin; + private readonly Func _getBucket; + private readonly Action _persist; + + /// + /// Creates a new storage view. + /// + /// Gets the current origin. + /// Gets the storage bucket for the given origin. + /// Persists the bucket for the given origin. + public Storage(Func getOrigin, Func getBucket, Action persist) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + _persist = persist; + } + + /// + public Int32 Length => CurrentBucket.Length; + + /// + public String Key(Int32 index) => CurrentBucket.Key(index); + + /// + public String this[String key] + { + get => CurrentBucket.Get(key); + set + { + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + var origin = CurrentOrigin; + var bucket = _getBucket.Invoke(origin); + bucket.Set(key, value); + _persist?.Invoke(origin, bucket); + } + } + + /// + public void Remove(String key) + { + if (String.IsNullOrEmpty(key)) + { + return; + } + + var origin = CurrentOrigin; + var bucket = _getBucket.Invoke(origin); + bucket.Remove(key); + _persist?.Invoke(origin, bucket); + } + + /// + public void Clear() + { + var origin = CurrentOrigin; + var bucket = _getBucket.Invoke(origin); + bucket.Clear(); + _persist?.Invoke(origin, bucket); + } + + private String CurrentOrigin => _getOrigin.Invoke() ?? String.Empty; + + private StorageBucket CurrentBucket => _getBucket.Invoke(CurrentOrigin); + } + + /// + /// Represents the stored key-value pairs of one origin. + /// + public sealed class StorageBucket + { + private readonly Dictionary _entries; + private readonly List _keys; + + /// + /// Creates a new bucket. + /// + public StorageBucket() + { + _entries = new Dictionary(StringComparer.Ordinal); + _keys = new List(); + } + + /// + /// Creates a new bucket from existing entries. + /// + /// The entries used to initialize the bucket. + public StorageBucket(IEnumerable> entries) + : this() + { + if (entries != null) + { + foreach (var entry in entries) + { + Set(entry.Key, entry.Value); + } + } + } + + /// + /// Gets the number of keys. + /// + public Int32 Length => _keys.Count; + + /// + /// Gets the key at the given index. + /// + /// The index of the key. + /// The key or null. + public String Key(Int32 index) + { + if (index < 0 || index >= _keys.Count) + { + return null; + } + + return _keys[index]; + } + + /// + /// Gets the value of the given key. + /// + /// The key to retrieve. + /// The value or null. + public String Get(String key) + { + if (String.IsNullOrEmpty(key)) + { + return null; + } + + return _entries.TryGetValue(key, out var value) ? value : null; + } + + /// + /// Sets the value of the given key. + /// + /// The key to set. + /// The value to set. + public void Set(String key, String value) + { + if (!_entries.ContainsKey(key)) + { + _keys.Add(key); + } + + _entries[key] = value; + } + + /// + /// Removes the given key. + /// + /// The key to remove. + public void Remove(String key) + { + if (_entries.Remove(key)) + { + _keys.Remove(key); + } + } + + /// + /// Clears the bucket. + /// + public void Clear() + { + _entries.Clear(); + _keys.Clear(); + } + + /// + /// Gets the entries in key order. + /// + /// The entries. + public IEnumerable> ToEntries() + { + for (var i = 0; i < _keys.Count; i++) + { + var key = _keys[i]; + yield return new KeyValuePair(key, _entries[key]); + } + } + } +} diff --git a/src/AngleSharp.Io/Storage/StorageProviderFactory.cs b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs new file mode 100644 index 0000000..7202079 --- /dev/null +++ b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs @@ -0,0 +1,173 @@ +namespace AngleSharp.Io.Storage +{ + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using System; + using System.Collections.Generic; + using System.Runtime.CompilerServices; + + /// + /// Represents the default storage provider factory with origin-dependent views. + /// + public class StorageProviderFactory : IStorageProviderFactory + { + private readonly ConditionalWeakTable _views; + private readonly Dictionary _storageByScopeAndOrigin; + + private IStorageHandler _localStorageHandler; + private IStorageHandler _sessionStorageHandler; + + private Boolean _hasLocalStorage; + private Boolean _hasSessionStorage; + + /// + /// Creates a new storage provider factory. + /// + public StorageProviderFactory() + { + _views = new ConditionalWeakTable(); + _storageByScopeAndOrigin = new Dictionary(StringComparer.Ordinal); + } + + /// + /// Enables local storage synchronization with the given directory. + /// + /// The directory used for local storage synchronization. + public void EnableLocalStorage(String syncDirectoryPath) + { + EnableLocalStorage(new PersistenceStorageHandler(syncDirectoryPath)); + } + + /// + /// Enables local storage synchronization with the given storage handler. + /// + /// The handler used for local storage synchronization. + public void EnableLocalStorage(IStorageHandler handler) + { + _hasLocalStorage = true; + _localStorageHandler = handler ?? throw new ArgumentNullException(nameof(handler)); + ClearStorage(ScopeLocal); + } + + /// + /// Enables in-memory local storage. + /// + public void EnableTemporaryLocalStorage() + { + _hasLocalStorage = true; + _localStorageHandler = new MemoryStorageHandler(); + ClearStorage(ScopeLocal); + } + + /// + /// Enables session storage with the given storage handler. + /// + /// The handler used for session storage. + public void EnableSessionStorage(IStorageHandler handler) + { + _hasSessionStorage = true; + _sessionStorageHandler = handler ?? throw new ArgumentNullException(nameof(handler)); + ClearStorage(ScopeSession); + } + + /// + /// Enables in-memory session storage. + /// + public void EnableSessionStorage() + { + EnableSessionStorage(new MemoryStorageHandler()); + } + + /// + public StorageViews GetStorages(IWindow window) + { + if (window == null) + { + return null; + } + + return _views.GetValue(window, CreateStorageViews); + } + + private StorageViews CreateStorageViews(IWindow window) + { + var getOrigin = new Func(() => GetOrigin(window)); + + var local = _hasLocalStorage + ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeLocal, origin), PersistLocalStorage) + : null; + var session = _hasSessionStorage + ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeSession, origin), PersistSessionStorage) + : null; + + return new StorageViews(local, session); + } + + private StorageBucket GetOrCreateStorage(String scope, String origin) + { + origin = origin ?? String.Empty; + var key = scope + origin; + var handler = scope == ScopeLocal ? _localStorageHandler : _sessionStorageHandler; + + if (!_storageByScopeAndOrigin.TryGetValue(key, out var storage)) + { + var entries = handler?.Read(origin); + storage = new StorageBucket(entries); + _storageByScopeAndOrigin[key] = storage; + } + + return storage; + } + + private void PersistLocalStorage(String origin, StorageBucket storage) + { + _localStorageHandler?.Write(origin, storage.ToEntries()); + } + + private void PersistSessionStorage(String origin, StorageBucket storage) + { + _sessionStorageHandler?.Write(origin, storage.ToEntries()); + } + + private void ClearStorage(String scope) + { + var keys = new List(); + + foreach (var key in _storageByScopeAndOrigin.Keys) + { + if (key.StartsWith(scope, StringComparison.Ordinal)) + { + keys.Add(key); + } + } + + foreach (var key in keys) + { + _storageByScopeAndOrigin.Remove(key); + } + } + + private const String ScopeLocal = "l|"; + private const String ScopeSession = "s|"; + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + + } +} diff --git a/src/AngleSharp.Io/StorageExtensions.cs b/src/AngleSharp.Io/StorageExtensions.cs deleted file mode 100644 index 0cb323e..0000000 --- a/src/AngleSharp.Io/StorageExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace AngleSharp -{ - using AngleSharp.Io.Dom; - - /// - /// Extensions for resolving storage services. - /// - public static class StorageExtensions - { - /// - /// Gets the configured local storage service, if any. - /// - /// The browsing context to inspect. - /// The local storage service or null. - public static ILocalStorage GetLocalStorage(this IBrowsingContext context) => - context?.GetService(); - - /// - /// Gets the configured session storage service, if any. - /// - /// The browsing context to inspect. - /// The session storage service or null. - public static ISessionStorage GetSessionStorage(this IBrowsingContext context) => - context?.GetService(); - - } -} From 02a8379470f86079486b90615eecfe76eeeac730 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 30 Jul 2026 12:42:56 +0200 Subject: [PATCH 20/31] Improved API --- src/AngleSharp.Io/IoConfigurationExtensions.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index 506cb1b..0d9778c 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -5,6 +5,7 @@ namespace AngleSharp using AngleSharp.Io.Cookie; using AngleSharp.Io.Dom; using AngleSharp.Io.Network; + using AngleSharp.Io.Storage; using System; using System.IO; using System.Linq; @@ -184,6 +185,20 @@ public static IConfiguration WithCookies(this IConfiguration configuration, ICoo #region Storage + /// + /// Registers a storage provider factory. + /// + /// The configuration to extend. + /// The directory path for local storage synchronization. + /// The new instance with the service. + public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, String syncDirectoryPath) + { + var factory = new StorageProviderFactory(); + factory.EnableLocalStorage(syncDirectoryPath); + factory.EnableSessionStorage(); + return configuration.WithStorageProviderFactory(factory); + } + /// /// Registers a storage provider factory. /// From fd47ffe5fe6b9a67f44d7dada870883237408145 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 31 Jul 2026 09:05:00 +0200 Subject: [PATCH 21/31] Modernized and aligned --- .config/dotnet-tools.json | 12 +++ {.nuke => .fallout}/build.schema.json | 20 +++- {.nuke => .fallout}/parameters.json | 0 .gitignore | 10 +- AGENTS.md | 6 +- build.ps1 | 17 ++-- build.sh | 17 ++-- {nuke => build}/.editorconfig | 0 {nuke => build}/Build.cs | 93 ++++++++----------- {nuke => build}/Configuration.cs | 2 +- {nuke => build}/Directory.Build.props | 0 {nuke => build}/Directory.Build.targets | 0 .../Extensions/StringExtensions.cs | 0 {nuke => build}/ReleaseNotes.cs | 0 {nuke => build}/ReleaseNotesParser.cs | 0 {nuke => build}/SemVersion.cs | 0 build/_build.csproj | 19 ++++ nuke/_build.csproj | 23 ----- .../Properties/AssemblyInfo.cs | 8 -- src/AngleSharp.Io.nuspec | 22 ----- src/AngleSharp.Io.sln | 2 + src/AngleSharp.Io/AngleSharp.Io.csproj | 25 ++++- src/AngleSharp.Io/Properties/AssemblyInfo.cs | 7 -- 23 files changed, 136 insertions(+), 147 deletions(-) create mode 100644 .config/dotnet-tools.json rename {.nuke => .fallout}/build.schema.json (84%) rename {.nuke => .fallout}/parameters.json (100%) rename {nuke => build}/.editorconfig (100%) rename {nuke => build}/Build.cs (73%) rename {nuke => build}/Configuration.cs (93%) rename {nuke => build}/Directory.Build.props (100%) rename {nuke => build}/Directory.Build.targets (100%) rename {nuke => build}/Extensions/StringExtensions.cs (100%) rename {nuke => build}/ReleaseNotes.cs (100%) rename {nuke => build}/ReleaseNotesParser.cs (100%) rename {nuke => build}/SemVersion.cs (100%) create mode 100644 build/_build.csproj delete mode 100644 nuke/_build.csproj delete mode 100644 src/AngleSharp.Io.Tests/Properties/AssemblyInfo.cs delete mode 100644 src/AngleSharp.Io.nuspec delete mode 100644 src/AngleSharp.Io/Properties/AssemblyInfo.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6a8c8a1 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fallout.cli": { + "version": "11.0.18", + "commands": [ + "fallout" + ] + } + } +} diff --git a/.nuke/build.schema.json b/.fallout/build.schema.json similarity index 84% rename from .nuke/build.schema.json rename to .fallout/build.schema.json index 3166f3e..82690d1 100644 --- a/.nuke/build.schema.json +++ b/.fallout/build.schema.json @@ -26,7 +26,6 @@ "enum": [ "Clean", "Compile", - "CopyFiles", "CreatePackage", "Default", "Package", @@ -49,7 +48,7 @@ "Quiet" ] }, - "NukeBuild": { + "FalloutBuild": { "properties": { "Continue": { "type": "boolean", @@ -103,6 +102,13 @@ "Verbosity": { "description": "Logging verbosity during build execution. Default is 'Normal'", "$ref": "#/definitions/Verbosity" + }, + "BuildProjectFile": { + "type": [ + "null", + "string" + ], + "description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner." } } } @@ -110,13 +116,17 @@ "allOf": [ { "properties": { + "AngleSharpVersion": { + "type": "string", + "description": "AngleSharp package version override (e.g. 1.0.0 for compatibility checks)" + }, "Configuration": { "type": "string", - "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", "enum": [ "Debug", "Release" - ] + ], + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)" }, "ReleaseNotesFilePath": { "type": "string", @@ -129,7 +139,7 @@ } }, { - "$ref": "#/definitions/NukeBuild" + "$ref": "#/definitions/FalloutBuild" } ] } diff --git a/.nuke/parameters.json b/.fallout/parameters.json similarity index 100% rename from .nuke/parameters.json rename to .fallout/parameters.json diff --git a/.gitignore b/.gitignore index de3ed79..32b162d 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,8 @@ local.properties [Dd]ebug/ [Rr]elease/ [Bb]in/ -[Bb]uild/ +build/bin/ +build/obj/ [Oo]bj/ # Visual Studio 2015 cache/options directory @@ -171,7 +172,8 @@ Desktop.ini *.egg *.egg-info dist -build +build/bin +build/obj eggs parts var @@ -195,5 +197,5 @@ pip-log.txt # Mac crap .DS_Store -# Nuke build tool -.nuke/temp +# Fallout build tool +.fallout/temp diff --git a/AGENTS.md b/AGENTS.md index 7a5f9d3..1311aec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ cookie handling, download support, and some DOM/network helpers for headless bro ## Commands -The orchestrator is NUKE (`nuke/Build.cs`), bootstrapped by `build.ps1` / `build.sh` +The orchestrator is Fallout (`build/Build.cs`), bootstrapped by `build.ps1` / `build.sh` (`build.cmd` forwards to either). Default target is `RunUnitTests`. ```powershell @@ -17,7 +17,7 @@ The orchestrator is NUKE (`nuke/Build.cs`), bootstrapped by `build.ps1` / `build .\build.ps1 -Target Package # CopyFiles CreatePackage Package PrePublish Publish ``` -For the normal edit/test loop use the SDK directly — much faster than the NUKE bootstrap: +For the normal edit/test loop use the SDK directly — much faster than the Fallout bootstrap: ```powershell dotnet build src/AngleSharp.Io.sln @@ -109,7 +109,7 @@ These files are synced from the `AngleSharp.GitBase` repository and should not b here: `.editorconfig`, `.gitignore`, `.gitattributes`, `.github/*`, `build.ps1`, `build.sh`, `tools/*`, `LICENSE`. -CI (`.github/workflows/ci.yml`) builds on Linux and Windows; on Windows it selects the NUKE +CI (`.github/workflows/ci.yml`) builds on Linux and Windows; on Windows it selects the Fallout target from the branch (`main` → `Publish`, `devel` → `PrePublish`, otherwise the default). `CONTRIBUTING.md` asks for feature branches (`feature/#777`) and pull requests against `devel`. diff --git a/build.ps1 b/build.ps1 index 1f264e7..61c6628 100644 --- a/build.ps1 +++ b/build.ps1 @@ -13,16 +13,14 @@ $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent # CONFIGURATION ########################################################################### -$BuildProjectFile = "$PSScriptRoot\nuke\_build.csproj" -$TempDirectory = "$PSScriptRoot\\.nuke\temp" +$TempDirectory = "$PSScriptRoot\.fallout\temp" -$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetGlobalFile = "$PSScriptRoot\global.json" $DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" -$DotNetChannel = "Current" +$DotNetChannel = "STS" -$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = 1 $env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 -$env:DOTNET_MULTILEVEL_LOOKUP = 0 +$env:DOTNET_NOLOGO = 1 ########################################################################### # EXECUTION @@ -33,7 +31,7 @@ function ExecSafe([scriptblock] $cmd) { if ($LASTEXITCODE) { exit $LASTEXITCODE } } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout\temp. if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` $(dotnet --version) -and $LASTEXITCODE -eq 0) { $env:DOTNET_EXE = (Get-Command "dotnet").Path @@ -61,9 +59,10 @@ else { ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } } $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" + $env:PATH = "$DotNetDirectory;$env:PATH" } Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" -ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } -ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } +ExecSafe { & $env:DOTNET_EXE tool restore } +ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments } diff --git a/build.sh b/build.sh index 7534123..fc76bf8 100755 --- a/build.sh +++ b/build.sh @@ -9,16 +9,14 @@ SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) # CONFIGURATION ########################################################################### -BUILD_PROJECT_FILE="$SCRIPT_DIR/nuke/_build.csproj" -TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" +TEMP_DIRECTORY="$SCRIPT_DIR/.fallout/temp" -DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_GLOBAL_FILE="$SCRIPT_DIR/global.json" DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" -DOTNET_CHANNEL="Current" +DOTNET_CHANNEL="STS" export DOTNET_CLI_TELEMETRY_OPTOUT=1 -export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 -export DOTNET_MULTILEVEL_LOOKUP=0 +export DOTNET_NOLOGO=1 ########################################################################### # EXECUTION @@ -28,7 +26,7 @@ function FirstJsonValue { perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout/temp. if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then export DOTNET_EXE="$(command -v dotnet)" else @@ -54,9 +52,10 @@ else "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path fi export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" + export PATH="$DOTNET_DIRECTORY:$PATH" fi echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" -"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet -"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" +"$DOTNET_EXE" tool restore +exec "$DOTNET_EXE" fallout "$@" diff --git a/nuke/.editorconfig b/build/.editorconfig similarity index 100% rename from nuke/.editorconfig rename to build/.editorconfig diff --git a/nuke/Build.cs b/build/Build.cs similarity index 73% rename from nuke/Build.cs rename to build/Build.cs index 7097aff..b3acbd0 100644 --- a/nuke/Build.cs +++ b/build/Build.cs @@ -1,12 +1,11 @@ +using Fallout.Common; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.IO; +using Fallout.Common.Tools.DotNet; +using Fallout.Common.Tools.GitHub; +using Fallout.Common.Utilities.Collections; +using Fallout.Solutions; using Microsoft.Build.Exceptions; -using Nuke.Common; -using Nuke.Common.CI.GitHubActions; -using Nuke.Common.IO; -using Nuke.Common.ProjectModel; -using Nuke.Common.Tools.DotNet; -using Nuke.Common.Tools.GitHub; -using Nuke.Common.Tools.NuGet; -using Nuke.Common.Utilities.Collections; using Octokit; using Octokit.Internal; using Serilog; @@ -14,27 +13,20 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using static Nuke.Common.Tools.DotNet.DotNetTasks; -using static Nuke.Common.Tools.NuGet.NuGetTasks; -using Project = Nuke.Common.ProjectModel.Project; +using static Fallout.Common.Tools.DotNet.DotNetTasks; +using Project = Fallout.Solutions.Project; -class Build : NukeBuild +class Build : FalloutBuild { - /// Support plugins are available for: - /// - JetBrains ReSharper https://nuke.build/resharper - /// - JetBrains Rider https://nuke.build/rider - /// - Microsoft VisualStudio https://nuke.build/visualstudio - /// - Microsoft VSCode https://nuke.build/vscode - public static int Main () => Execute(x => x.RunUnitTests); - [Nuke.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + [Fallout.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; - [Nuke.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] + [Fallout.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] readonly AbsolutePath ReleaseNotesFilePath = RootDirectory / "CHANGELOG.md"; - [Nuke.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] + [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] readonly string AngleSharpVersion; [Solution] @@ -46,8 +38,6 @@ class Build : NukeBuild AbsolutePath SourceDirectory => RootDirectory / "src"; - AbsolutePath BuildDirectory => SourceDirectory / TargetProjectName / "bin" / Configuration; - AbsolutePath ResultDirectory => RootDirectory / "bin" / Version; AbsolutePath NugetDirectory => ResultDirectory / "nuget"; @@ -56,7 +46,7 @@ class Build : NukeBuild Project TargetProject { get; set; } - // Note: The ChangeLogTasks from Nuke itself look buggy. So using the Cake source code. + // Note: The built-in ChangeLogTasks (inherited from NUKE) look buggy. So using the Cake source code. IReadOnlyList ChangeLog { get; set; } ReleaseNotes LatestReleaseNotes { get; set; } @@ -141,6 +131,8 @@ protected override void OnBuildInitialized() var settings = s .SetProjectFile(Solution) .SetConfiguration(Configuration) + .SetVersion(Version) + .SetContinuousIntegrationBuild(IsServerBuild) .EnableNoRestore(); if (!String.IsNullOrEmpty(AngleSharpVersion)) @@ -161,6 +153,7 @@ protected override void OnBuildInitialized() var settings = s .SetProjectFile(Solution) .SetConfiguration(Configuration) + .SetProperty("Version", Version) .EnableNoRestore() .EnableNoBuild(); @@ -173,39 +166,30 @@ protected override void OnBuildInitialized() }); }); - Target CopyFiles => _ => _ + // The package is produced by `dotnet pack` straight from the project, so the dependency + // groups follow the actual TargetFrameworks instead of a hand-maintained nuspec. + Target CreatePackage => _ => _ .DependsOn(Compile) .Executes(() => { - foreach (var item in TargetFrameworks) + DotNetPack(s => { - var targetDir = NugetDirectory / "lib" / item; - var srcDir = BuildDirectory / item; - - (srcDir / $"{TargetProjectName}.dll").Copy(targetDir / $"{TargetProjectName}.dll", ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.pdb").Copy(targetDir / $"{TargetProjectName}.pdb", ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.xml").Copy(targetDir / $"{TargetProjectName}.xml", ExistsPolicy.FileOverwriteIfNewer); - } + var settings = s + .SetProject(TargetProject) + .SetConfiguration(Configuration) + .SetVersion(Version) + .SetOutputDirectory(NugetDirectory) + .SetContinuousIntegrationBuild(IsServerBuild) + .EnableNoRestore() + .EnableNoBuild(); - (SourceDirectory / $"{TargetProjectName}.nuspec").Copy(NugetDirectory / $"{TargetProjectName}.nuspec", ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "logo.png").Copy(NugetDirectory / "logo.png", ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "README.md").Copy(NugetDirectory / "README.md", ExistsPolicy.FileOverwriteIfNewer); - }); + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } - Target CreatePackage => _ => _ - .DependsOn(CopyFiles) - .Executes(() => - { - var nuspec = NugetDirectory / $"{TargetProjectName}.nuspec"; - - NuGetPack(_ => _ - .SetTargetPath(nuspec) - .SetVersion(Version) - .SetOutputDirectory(NugetDirectory) - .SetSymbols(true) - .SetSymbolPackageFormat("snupkg") - .AddProperty("Configuration", Configuration) - ); + return settings; + }); }); Target PublishPackage => _ => _ @@ -221,9 +205,10 @@ protected override void OnBuildInitialized() throw new BuildAbortedException("Could not resolve the NuGet API key."); } + // Pushing the .nupkg also uploads the matching .snupkg next to it. foreach (var nupkg in NugetDirectory.GlobFiles("*.nupkg")) { - NuGetPush(s => s + DotNetNuGetPush(s => s .SetTargetPath(nupkg) .SetSource("https://api.nuget.org/v3/index.json") .SetApiKey(apiKey)); @@ -253,7 +238,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release @@ -289,7 +274,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release diff --git a/nuke/Configuration.cs b/build/Configuration.cs similarity index 93% rename from nuke/Configuration.cs rename to build/Configuration.cs index 9c08b1a..75c63ea 100644 --- a/nuke/Configuration.cs +++ b/build/Configuration.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel; using System.Linq; -using Nuke.Common.Tooling; +using Fallout.Common.Tooling; [TypeConverter(typeof(TypeConverter))] public class Configuration : Enumeration diff --git a/nuke/Directory.Build.props b/build/Directory.Build.props similarity index 100% rename from nuke/Directory.Build.props rename to build/Directory.Build.props diff --git a/nuke/Directory.Build.targets b/build/Directory.Build.targets similarity index 100% rename from nuke/Directory.Build.targets rename to build/Directory.Build.targets diff --git a/nuke/Extensions/StringExtensions.cs b/build/Extensions/StringExtensions.cs similarity index 100% rename from nuke/Extensions/StringExtensions.cs rename to build/Extensions/StringExtensions.cs diff --git a/nuke/ReleaseNotes.cs b/build/ReleaseNotes.cs similarity index 100% rename from nuke/ReleaseNotes.cs rename to build/ReleaseNotes.cs diff --git a/nuke/ReleaseNotesParser.cs b/build/ReleaseNotesParser.cs similarity index 100% rename from nuke/ReleaseNotesParser.cs rename to build/ReleaseNotesParser.cs diff --git a/nuke/SemVersion.cs b/build/SemVersion.cs similarity index 100% rename from nuke/SemVersion.cs rename to build/SemVersion.cs diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..647ac26 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + CS0649;CS0169 + .. + .. + 1 + + + + + + + + + diff --git a/nuke/_build.csproj b/nuke/_build.csproj deleted file mode 100644 index 5a0000d..0000000 --- a/nuke/_build.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - - CS0649;CS0169 - .. - .. - 1 - - - - - - - - - - - - - diff --git a/src/AngleSharp.Io.Tests/Properties/AssemblyInfo.cs b/src/AngleSharp.Io.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 07a9d40..0000000 --- a/src/AngleSharp.Io.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2019.")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: ComVisible(false)] -[assembly: Guid("863a9ca5-a39b-42a9-967f-301d5fb8e630")] diff --git a/src/AngleSharp.Io.nuspec b/src/AngleSharp.Io.nuspec deleted file mode 100644 index 2d59249..0000000 --- a/src/AngleSharp.Io.nuspec +++ /dev/null @@ -1,22 +0,0 @@ - - - - AngleSharp.Io - $version$ - AngleSharp - Florian Rappl - MIT - - https://anglesharp.github.io - logo.png - README.md - false - Providers additional requesters and IO helpers for AngleSharp. - https://github.com/AngleSharp/AngleSharp.Io/blob/main/CHANGELOG.md - Copyright 2016-2026, AngleSharp - html html5 css css3 dom requester http https io filesystem storage httpclient cache - - - - - diff --git a/src/AngleSharp.Io.sln b/src/AngleSharp.Io.sln index 7051e5e..f433e0f 100644 --- a/src/AngleSharp.Io.sln +++ b/src/AngleSharp.Io.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Io", "AngleSharp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Io.Tests", "AngleSharp.Io.Tests\AngleSharp.Io.Tests.csproj", "{18B0B97B-8795-4DC2-A1E7-8070255BE718}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "..\build\_build.csproj", "{07DA52AA-8F6F-4F43-B09E-6AE899E95E43}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/AngleSharp.Io/AngleSharp.Io.csproj b/src/AngleSharp.Io/AngleSharp.Io.csproj index ae16cd9..3a93671 100644 --- a/src/AngleSharp.Io/AngleSharp.Io.csproj +++ b/src/AngleSharp.Io/AngleSharp.Io.csproj @@ -5,13 +5,22 @@ netstandard2.0;net8.0;net10.0 $(TargetFrameworks);net462;net472 true + + + + https://anglesharp.github.io + MIT + logo.png + README.md + html;html5;css;css3;dom;requester;http;https;io;filesystem;storage;httpclient;cache;anglesharp;angle + https://github.com/AngleSharp/AngleSharp.Io/blob/main/CHANGELOG.md https://github.com/AngleSharp/AngleSharp.Io git true true true snupkg - 1.* + 1.5.0 @@ -19,7 +28,19 @@ - + + + + + + + + + + + + <_Parameter1>false + diff --git a/src/AngleSharp.Io/Properties/AssemblyInfo.cs b/src/AngleSharp.Io/Properties/AssemblyInfo.cs deleted file mode 100644 index 31d3d1b..0000000 --- a/src/AngleSharp.Io/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2019")] -[assembly: ComVisible(false)] -[assembly: InternalsVisibleToAttribute("AngleSharp.Io.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001adf274fa2b375134e8e4558d606f1a0f96f5cd0c6b99970f7cce9887477209d7c29f814e2508d8bd2526e99e8cd273bd1158a3984f1ea74830ec5329a77c6ff201a15edeb8b36ab046abd1bce211fe8dbb076d7d806f46b15bfda44def04ead0669971e96c5f666c9eda677f28824fff7aa90d32929ed91d529a7a41699893")] From a2090636a664d1baec8d9905fa72836a2cc29063 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 31 Jul 2026 21:13:18 +0200 Subject: [PATCH 22/31] Fixes and enhancements for the storage provider --- README.md | 6 +- docs/general/01-Basics.md | 6 +- .../Network/CookieHandlingTests.cs | 2 +- .../Storage/StorageTests.cs | 151 ++++++++++++++++-- src/AngleSharp.Io/Storage/Storage.cs | 55 ++++++- .../Storage/StorageProviderFactory.cs | 120 +++++++++++++- 6 files changed, 305 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 86f5ac8..b6ff818 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Alternatively, the new overloads for the `WithCookies` extension method can be u AngleSharp.Io provides a single storage engine with two storage modes: - `localStorage` using persistent per-origin files -- `sessionStorage` using in-memory per-origin buckets +- `sessionStorage` using in-memory buckets scoped to the top-level browsing context and origin To configure storage, create and register an `IStorageProviderFactory`: @@ -100,8 +100,8 @@ Once configured, storage services can be resolved from the browsing context: ```cs var context = BrowsingContext.New(config); var document = await context.OpenAsync("https://example.org"); -var localStorage = document.DefaultView.GetLocalStorage(); -var sessionStorage = document.DefaultView.GetSessionStorage(); +var localStorage = document.DefaultView.LocalStorage(); +var sessionStorage = document.DefaultView.SessionStorage(); localStorage["token"] = "abc"; sessionStorage["ephemeral"] = "42"; diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 52eb1c0..b47e060 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -98,7 +98,7 @@ AngleSharp.Io contains a single `Storage` implementation that is exposed as `localStorage` and `sessionStorage` depending on the configured handler: - local storage uses persistent per-origin files -- session storage uses in-memory per-origin buckets +- session storage uses in-memory buckets scoped to the top-level browsing context and origin Register storage by providing a storage provider factory: @@ -117,8 +117,8 @@ Use the services from a browsing context: ```cs var context = BrowsingContext.New(config); var document = await context.OpenAsync("https://example.org"); -var localStorage = document.DefaultView.GetLocalStorage(); -var sessionStorage = document.DefaultView.GetSessionStorage(); +var localStorage = document.DefaultView.LocalStorage(); +var sessionStorage = document.DefaultView.SessionStorage(); localStorage["persisted"] = "yes"; sessionStorage["transient"] = "yes"; diff --git a/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs b/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs index e79667c..663e4a1 100644 --- a/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs +++ b/src/AngleSharp.Io.Tests/Network/CookieHandlingTests.cs @@ -60,7 +60,7 @@ public async Task SettingThreeCookiesInOneRequestAreTransportedToNextRequest() { var baseUrl = "https://httpbingo.org/cookies"; var url = baseUrl + "/set?test=baz&k2=v2&k1=v1&foo=bar"; - var config = Configuration.Default.WithCookies().WithDefaultLoader(); + var config = Configuration.Default.WithCookies().WithDefaultLoader(); var context = BrowsingContext.New(config); await context.OpenAsync(url); var document = await context.OpenAsync(baseUrl); diff --git a/src/AngleSharp.Io.Tests/Storage/StorageTests.cs b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs index d69c0d7..1787a3d 100644 --- a/src/AngleSharp.Io.Tests/Storage/StorageTests.cs +++ b/src/AngleSharp.Io.Tests/Storage/StorageTests.cs @@ -1,5 +1,7 @@ namespace AngleSharp.Io.Tests.Storage { + using AngleSharp; + using AngleSharp.Browser; using AngleSharp.Dom; using AngleSharp.Io.Dom; using AngleSharp.Io.Storage; @@ -161,29 +163,69 @@ public async Task LocalStorageIsOriginDependentAcrossWindows() } [Test] - public async Task SessionStorageIsAlsoOriginDependentAcrossWindows() + public async Task LocalStorageSharesStateWithinTheSameBrowsingContextTree() { var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); try { var config = CreateConfigWithStorages(directory); - var contextA = BrowsingContext.New(config); - var contextB = BrowsingContext.New(config); - var contextC = BrowsingContext.New(config); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); - var windowA = await OpenWindowAsync(contextA, "https://alpha.example/"); - var windowB = await OpenWindowAsync(contextB, "https://alpha.example/other"); - var windowC = await OpenWindowAsync(contextC, "https://beta.example/"); + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); + + var localStorage = parentWindow.LocalStorage(); + localStorage["token"] = "x"; + + Assert.AreEqual("x", childWindow.LocalStorage()["token"]); + Assert.AreEqual("x", parallelWindow.LocalStorage()["token"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task LocalStorageFiresStorageEventToOtherSameOriginWindows() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); - var localA = windowA.LocalStorage(); - var localB = windowB.LocalStorage(); - var localC = windowC.LocalStorage(); + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); - localA["id"] = "alpha"; + var parentStorage = parentWindow.LocalStorage(); + childWindow.LocalStorage(); + parallelWindow.LocalStorage(); - Assert.AreEqual("alpha", localB["id"]); - Assert.AreEqual(null, localC["id"]); + var parentEvents = 0; + var childEvents = 0; + var parallelEvents = 0; + + parentWindow.AddEventListener(EventNames.Storage, (_, __) => parentEvents++, false); + childWindow.AddEventListener(EventNames.Storage, (_, __) => childEvents++, false); + parallelWindow.AddEventListener(EventNames.Storage, (_, __) => parallelEvents++, false); + + parentStorage["token"] = "x"; + + Assert.AreEqual(0, parentEvents); + Assert.AreEqual(1, childEvents); + Assert.AreEqual(1, parallelEvents); } finally { @@ -195,7 +237,7 @@ public async Task SessionStorageIsAlsoOriginDependentAcrossWindows() } [Test] - public async Task SessionStorageSharesStateForSameOrigin() + public async Task SessionStorageIsIsolatedAcrossParallelBrowsingContexts() { var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); @@ -207,13 +249,92 @@ public async Task SessionStorageSharesStateForSameOrigin() var windowA = await OpenWindowAsync(contextA, "https://alpha.example/"); var windowB = await OpenWindowAsync(contextB, "https://alpha.example/"); + var sessionA = windowA.SessionStorage(); var sessionB = windowB.SessionStorage(); sessionA["sid"] = "1"; Assert.AreEqual("1", sessionA["sid"]); - Assert.AreEqual("1", sessionB["sid"]); + Assert.AreEqual(null, sessionB["sid"]); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task SessionStorageFiresStorageEventOnlyWithinTheSameBrowsingContextTree() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); + + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); + + var parentStorage = parentWindow.SessionStorage(); + childWindow.SessionStorage(); + parallelWindow.SessionStorage(); + + var parentEvents = 0; + var childEvents = 0; + var parallelEvents = 0; + + parentWindow.AddEventListener(EventNames.Storage, (_, __) => parentEvents++, false); + childWindow.AddEventListener(EventNames.Storage, (_, __) => childEvents++, false); + parallelWindow.AddEventListener(EventNames.Storage, (_, __) => parallelEvents++, false); + + parentStorage["sid"] = "1"; + + Assert.AreEqual(0, parentEvents); + Assert.AreEqual(1, childEvents); + Assert.AreEqual(0, parallelEvents); + } + finally + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + + [Test] + public async Task SessionStorageSharesWithinTheSameBrowsingContextTree() + { + var directory = Path.Combine(Path.GetTempPath(), $"anglesharp-io-storage-{Guid.NewGuid():N}"); + + try + { + var config = CreateConfigWithStorages(directory); + var parentContext = BrowsingContext.New(config); + var childContext = parentContext.CreateChild("child", Sandboxes.None); + var parallelContext = BrowsingContext.New(config); + + var parentWindow = await OpenWindowAsync(parentContext, "https://alpha.example/"); + var childWindow = await OpenWindowAsync(childContext, "https://alpha.example/frame"); + var parallelWindow = await OpenWindowAsync(parallelContext, "https://alpha.example/"); + + var parentSession = parentWindow.SessionStorage(); + var childSession = childWindow.SessionStorage(); + var parallelSession = parallelWindow.SessionStorage(); + + parentSession["sid"] = "1"; + + Assert.AreEqual("1", parentSession["sid"]); + Assert.AreEqual("1", childSession["sid"]); + Assert.AreEqual(null, parallelSession["sid"]); } finally { diff --git a/src/AngleSharp.Io/Storage/Storage.cs b/src/AngleSharp.Io/Storage/Storage.cs index b54a9e9..06a1edb 100644 --- a/src/AngleSharp.Io/Storage/Storage.cs +++ b/src/AngleSharp.Io/Storage/Storage.cs @@ -12,6 +12,7 @@ public class Storage : ILocalStorage, ISessionStorage private readonly Func _getOrigin; private readonly Func _getBucket; private readonly Action _persist; + private readonly Action _persistWithMutation; /// /// Creates a new storage view. @@ -20,10 +21,29 @@ public class Storage : ILocalStorage, ISessionStorage /// Gets the storage bucket for the given origin. /// Persists the bucket for the given origin. public Storage(Func getOrigin, Func getBucket, Action persist) + : this(getOrigin, getBucket, persist, null) + { + } + + /// + /// Creates a new storage view. + /// + /// Gets the current origin. + /// Gets the storage bucket for the given origin. + /// Persists the bucket for the given origin. + public Storage(Func getOrigin, Func getBucket, Action persist) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + _persistWithMutation = persist; + } + + private Storage(Func getOrigin, Func getBucket, Action persist, Action persistWithMutation) { _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); _persist = persist; + _persistWithMutation = persistWithMutation; } /// @@ -45,8 +65,9 @@ public String this[String key] var origin = CurrentOrigin; var bucket = _getBucket.Invoke(origin); + var oldValue = bucket.Get(key); bucket.Set(key, value); - _persist?.Invoke(origin, bucket); + Persist(origin, bucket, key, oldValue, value); } } @@ -60,8 +81,12 @@ public void Remove(String key) var origin = CurrentOrigin; var bucket = _getBucket.Invoke(origin); - bucket.Remove(key); - _persist?.Invoke(origin, bucket); + var oldValue = bucket.Get(key); + + if (bucket.Remove(key)) + { + Persist(origin, bucket, key, oldValue, null); + } } /// @@ -69,13 +94,30 @@ public void Clear() { var origin = CurrentOrigin; var bucket = _getBucket.Invoke(origin); + + if (bucket.Length == 0) + { + return; + } + bucket.Clear(); - _persist?.Invoke(origin, bucket); + Persist(origin, bucket, null, null, null); } private String CurrentOrigin => _getOrigin.Invoke() ?? String.Empty; private StorageBucket CurrentBucket => _getBucket.Invoke(CurrentOrigin); + + private void Persist(String origin, StorageBucket bucket, String key, String oldValue, String newValue) + { + if (_persistWithMutation != null) + { + _persistWithMutation(origin, bucket, key, oldValue, newValue); + return; + } + + _persist?.Invoke(origin, bucket); + } } /// @@ -165,12 +207,15 @@ public void Set(String key, String value) /// Removes the given key. /// /// The key to remove. - public void Remove(String key) + public Boolean Remove(String key) { if (_entries.Remove(key)) { _keys.Remove(key); + return true; } + + return false; } /// diff --git a/src/AngleSharp.Io/Storage/StorageProviderFactory.cs b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs index 7202079..3bf8335 100644 --- a/src/AngleSharp.Io/Storage/StorageProviderFactory.cs +++ b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs @@ -1,17 +1,19 @@ namespace AngleSharp.Io.Storage { using AngleSharp.Dom; + using AngleSharp.Dom.Events; using AngleSharp.Io.Dom; using System; using System.Collections.Generic; using System.Runtime.CompilerServices; /// - /// Represents the default storage provider factory with origin-dependent views. + /// Represents the default storage provider factory with origin- and browsing-context-dependent views. /// public class StorageProviderFactory : IStorageProviderFactory { private readonly ConditionalWeakTable _views; + private readonly List> _windows; private readonly Dictionary _storageByScopeAndOrigin; private IStorageHandler _localStorageHandler; @@ -26,6 +28,7 @@ public class StorageProviderFactory : IStorageProviderFactory public StorageProviderFactory() { _views = new ConditionalWeakTable(); + _windows = new List>(); _storageByScopeAndOrigin = new Dictionary(StringComparer.Ordinal); } @@ -86,6 +89,7 @@ public StorageViews GetStorages(IWindow window) return null; } + TrackWindow(window); return _views.GetValue(window, CreateStorageViews); } @@ -94,24 +98,32 @@ private StorageViews CreateStorageViews(IWindow window) var getOrigin = new Func(() => GetOrigin(window)); var local = _hasLocalStorage - ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeLocal, origin), PersistLocalStorage) + ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeLocal, window, origin), (origin, storage, key, oldValue, newValue) => + { + PersistLocalStorage(origin, storage); + NotifyStorageEvent(window, ScopeLocal, origin, key, oldValue, newValue); + }) : null; var session = _hasSessionStorage - ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeSession, origin), PersistSessionStorage) + ? new Storage(getOrigin, origin => GetOrCreateStorage(ScopeSession, window, origin), (origin, storage, key, oldValue, newValue) => + { + PersistSessionStorage(window, origin, storage); + NotifyStorageEvent(window, ScopeSession, origin, key, oldValue, newValue); + }) : null; return new StorageViews(local, session); } - private StorageBucket GetOrCreateStorage(String scope, String origin) + private StorageBucket GetOrCreateStorage(String scope, IWindow window, String origin) { origin = origin ?? String.Empty; - var key = scope + origin; + var key = GetStorageKey(scope, window, origin); var handler = scope == ScopeLocal ? _localStorageHandler : _sessionStorageHandler; if (!_storageByScopeAndOrigin.TryGetValue(key, out var storage)) { - var entries = handler?.Read(origin); + var entries = handler?.Read(GetStorageHandlerKey(scope, window, origin)); storage = new StorageBucket(entries); _storageByScopeAndOrigin[key] = storage; } @@ -124,9 +136,65 @@ private void PersistLocalStorage(String origin, StorageBucket storage) _localStorageHandler?.Write(origin, storage.ToEntries()); } - private void PersistSessionStorage(String origin, StorageBucket storage) + private void PersistSessionStorage(IWindow window, String origin, StorageBucket storage) + { + _sessionStorageHandler?.Write(GetStorageHandlerKey(ScopeSession, window, origin), storage.ToEntries()); + } + + private void NotifyStorageEvent(IWindow sourceWindow, String scope, String origin, String key, String oldValue, String newValue) + { + var sourceKey = GetStorageKey(scope, sourceWindow, origin); + + CleanupWindows(); + + for (var i = 0; i < _windows.Count; i++) + { + if (!_windows[i].TryGetTarget(out var target)) + { + continue; + } + + if (ReferenceEquals(target, sourceWindow)) + { + continue; + } + + var targetOrigin = GetOrigin(target); + var targetKey = GetStorageKey(scope, target, targetOrigin); + + if (!String.Equals(sourceKey, targetKey, StringComparison.Ordinal)) + { + continue; + } + + target.FireSimpleEvent(EventNames.Storage, false, false); + } + } + + private void TrackWindow(IWindow window) { - _sessionStorageHandler?.Write(origin, storage.ToEntries()); + CleanupWindows(); + + for (var i = 0; i < _windows.Count; i++) + { + if (_windows[i].TryGetTarget(out var target) && ReferenceEquals(target, window)) + { + return; + } + } + + _windows.Add(new WeakReference(window)); + } + + private void CleanupWindows() + { + for (var i = _windows.Count - 1; i >= 0; i--) + { + if (!_windows[i].TryGetTarget(out var _)) + { + _windows.RemoveAt(i); + } + } } private void ClearStorage(String scope) @@ -150,6 +218,42 @@ private void ClearStorage(String scope) private const String ScopeLocal = "l|"; private const String ScopeSession = "s|"; + private static String GetStorageKey(String scope, IWindow window, String origin) + { + if (scope == ScopeLocal) + { + return scope + origin; + } + + var root = GetRootContext(window?.Document?.Context); + var rootKey = root == null ? String.Empty : RuntimeHelpers.GetHashCode(root).ToString(System.Globalization.CultureInfo.InvariantCulture); + + return scope + rootKey + "|" + origin; + } + + private static String GetStorageHandlerKey(String scope, IWindow window, String origin) + { + if (scope == ScopeLocal) + { + return origin; + } + + var root = GetRootContext(window?.Document?.Context); + var rootKey = root == null ? String.Empty : RuntimeHelpers.GetHashCode(root).ToString(System.Globalization.CultureInfo.InvariantCulture); + + return rootKey + "|" + origin; + } + + private static AngleSharp.IBrowsingContext GetRootContext(AngleSharp.IBrowsingContext context) + { + while (context?.Parent != null) + { + context = context.Parent; + } + + return context; + } + private static String GetOrigin(IWindow window) { var href = window?.Location?.Href; From 9132ea106755fcf34148fc896d81771386593496 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 31 Jul 2026 21:34:19 +0200 Subject: [PATCH 23/31] Implemented more useful DOM APIs --- CHANGELOG.md | 4 +- README.md | 47 ++++ docs/general/01-Basics.md | 47 ++++ src/AngleSharp.Io.Tests/Cache/CacheTests.cs | 102 +++++++ .../IndexedDb/IndexedDbTests.cs | 74 +++++ .../Cache/CacheProviderFactory.cs | 258 ++++++++++++++++++ src/AngleSharp.Io/Dom/WindowExtensions.cs | 22 ++ .../IndexedDb/IndexedDbProviderFactory.cs | 216 +++++++++++++++ src/AngleSharp.Io/Interfaces/ICache.cs | 51 ++++ .../Interfaces/ICacheProviderFactory.cs | 17 ++ src/AngleSharp.Io/Interfaces/ICacheStorage.cs | 36 +++ .../Interfaces/IIndexedDbDatabase.cs | 35 +++ .../Interfaces/IIndexedDbFactory.cs | 29 ++ .../Interfaces/IIndexedDbObjectStore.cs | 13 + .../Interfaces/IIndexedDbProviderFactory.cs | 17 ++ .../IoConfigurationExtensions.cs | 27 ++ 16 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 src/AngleSharp.Io.Tests/Cache/CacheTests.cs create mode 100644 src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs create mode 100644 src/AngleSharp.Io/Cache/CacheProviderFactory.cs create mode 100644 src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs create mode 100644 src/AngleSharp.Io/Interfaces/ICache.cs create mode 100644 src/AngleSharp.Io/Interfaces/ICacheProviderFactory.cs create mode 100644 src/AngleSharp.Io/Interfaces/ICacheStorage.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbProviderFactory.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d8895cd..2fed883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ Released on Friday, July 31 2026. - Updated to use a minimum of AngleSharp 1.5 - Fixed domain handling in cookies #36 -- Added `LocalStorage` and `SessionStorage` +- Added `localStorage` and `sessionStorage` for web storages +- Added `indexedDB` using an IndexedDB implementation +- Added `caches` using a Cache API implementation # 1.0.0 diff --git a/README.md b/README.md index b6ff818..871ea47 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,53 @@ localStorage["token"] = "abc"; sessionStorage["ephemeral"] = "42"; ``` +### IndexedDB + +AngleSharp.Io also provides a lightweight IndexedDB surface that is shared per origin: + +- `indexedDB` using in-memory databases and object stores scoped to the origin + +To configure IndexedDB, create and register an `IIndexedDbProviderFactory`: + +```cs +var factory = new IndexedDbProviderFactory(); +var config = Configuration.Default.WithIndexedDbProviderFactory(factory); +``` + +Once configured, IndexedDB services can be resolved from the browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var database = document.DefaultView.IndexedDb().Open("app"); +var store = database.CreateObjectStore("records"); + +store["token"] = "abc"; +``` + +### Cache Storage + +AngleSharp.Io also exposes a lightweight Cache Storage surface that is shared per origin: + +- `caches` using in-memory caches and response snapshots scoped to the origin + +To configure Cache Storage, create and register an `ICacheProviderFactory`: + +```cs +var factory = new CacheProviderFactory(); +var config = Configuration.Default.WithCacheProviderFactory(factory); +``` + +Once configured, caches can be resolved from the browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var cache = document.DefaultView.Caches().Open("app"); + +cache.Put("https://example.org/data", new DefaultResponse()); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index b47e060..0afae37 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -124,6 +124,53 @@ localStorage["persisted"] = "yes"; sessionStorage["transient"] = "yes"; ``` +### IndexedDB + +AngleSharp.Io also exposes a lightweight IndexedDB surface that is shared per origin: + +- `indexedDB` using in-memory databases and object stores scoped to the origin + +Register IndexedDB by providing a provider factory: + +```cs +var factory = new IndexedDbProviderFactory(); +var config = Configuration.Default.WithIndexedDbProviderFactory(factory); +``` + +Use the service from a browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var database = document.DefaultView.IndexedDb().Open("app"); +var store = database.CreateObjectStore("records"); + +store["persisted"] = "yes"; +``` + +### Cache Storage + +AngleSharp.Io also exposes a lightweight Cache Storage surface that is shared per origin: + +- `caches` using in-memory caches and response snapshots scoped to the origin + +Register Cache Storage by providing a provider factory: + +```cs +var factory = new CacheProviderFactory(); +var config = Configuration.Default.WithCacheProviderFactory(factory); +``` + +Use the service from a browsing context: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); +var cache = document.DefaultView.Caches().Open("app"); + +cache.Put("https://example.org/data", new DefaultResponse()); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/src/AngleSharp.Io.Tests/Cache/CacheTests.cs b/src/AngleSharp.Io.Tests/Cache/CacheTests.cs new file mode 100644 index 0000000..22ad34b --- /dev/null +++ b/src/AngleSharp.Io.Tests/Cache/CacheTests.cs @@ -0,0 +1,102 @@ +namespace AngleSharp.Io.Tests.Cache +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Io.Cache; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.IO; + using System.Net; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class CacheTests + { + [Test] + public async Task ConfigurationCanRegisterCacheStorage() + { + var config = CreateConfigWithCache(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://cache.example/"); + + Assert.IsNotNull(window.Caches()); + } + + [Test] + public async Task CacheSharesStateAcrossSameOriginWindows() + { + var config = CreateConfigWithCache(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://cache.example/"); + var windowB = await OpenWindowAsync(contextB, "https://cache.example/other"); + + var cacheA = windowA.Caches().Open("app"); + var cacheB = windowB.Caches().Open("app"); + + cacheA.Put("https://cache.example/data", CreateResponse("https://cache.example/data", "hello")); + + Assert.AreEqual("hello", ReadContent(cacheB.Match("https://cache.example/data"))); + } + + [Test] + public async Task CacheIsOriginBound() + { + var config = CreateConfigWithCache(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://cache.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + + var cacheA = windowA.Caches().Open("app"); + var cacheB = windowB.Caches().Open("app"); + + cacheA.Put("https://cache.example/data", CreateResponse("https://cache.example/data", "hello")); + + Assert.AreEqual("hello", ReadContent(cacheA.Match("https://cache.example/data"))); + Assert.AreEqual(null, cacheB.Match("https://cache.example/data")); + } + + private static IConfiguration CreateConfigWithCache() + { + var factory = new CacheProviderFactory(); + return Configuration.Default.WithCacheProviderFactory(factory); + } + + private static IResponse CreateResponse(String address, String content) + { + return new AngleSharp.Io.DefaultResponse + { + Address = new Url(address), + StatusCode = HttpStatusCode.OK, + Headers = new System.Collections.Generic.Dictionary(), + Content = new MemoryStream(Encoding.UTF8.GetBytes(content ?? String.Empty)), + }; + } + + private static String ReadContent(IResponse response) + { + if (response?.Content == null) + { + return null; + } + + using (response) + using (var reader = new StreamReader(response.Content, Encoding.UTF8, true, 1024, true)) + { + return reader.ReadToEnd(); + } + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("cache")); + return document.DefaultView; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs b/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs new file mode 100644 index 0000000..badae9a --- /dev/null +++ b/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs @@ -0,0 +1,74 @@ +namespace AngleSharp.Io.Tests.IndexedDb +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using AngleSharp.Io.IndexedDb; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class IndexedDbTests + { + [Test] + public async Task ConfigurationCanRegisterIndexedDb() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + + Assert.IsNotNull(window.IndexedDb()); + } + + [Test] + public async Task IndexedDbSharesStateAcrossSameOriginWindows() + { + var config = CreateConfigWithIndexedDb(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://indexeddb.example/"); + var windowB = await OpenWindowAsync(contextB, "https://indexeddb.example/other"); + + var storeA = windowA.IndexedDb().Open("app").CreateObjectStore("records"); + var storeB = windowB.IndexedDb().Open("app").CreateObjectStore("records"); + + storeA["token"] = "abc"; + + Assert.AreEqual("abc", storeB["token"]); + } + + [Test] + public async Task IndexedDbIsOriginBound() + { + var config = CreateConfigWithIndexedDb(); + var contextA = BrowsingContext.New(config); + var contextB = BrowsingContext.New(config); + + var windowA = await OpenWindowAsync(contextA, "https://indexeddb.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + + var storeA = windowA.IndexedDb().Open("app").CreateObjectStore("records"); + var storeB = windowB.IndexedDb().Open("app").CreateObjectStore("records"); + + storeA["token"] = "abc"; + + Assert.AreEqual("abc", storeA["token"]); + Assert.AreEqual(null, storeB["token"]); + } + + private static IConfiguration CreateConfigWithIndexedDb() + { + var factory = new IndexedDbProviderFactory(); + return Configuration.Default.WithIndexedDbProviderFactory(factory); + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("indexeddb")); + return document.DefaultView; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Cache/CacheProviderFactory.cs b/src/AngleSharp.Io/Cache/CacheProviderFactory.cs new file mode 100644 index 0000000..41d9774 --- /dev/null +++ b/src/AngleSharp.Io/Cache/CacheProviderFactory.cs @@ -0,0 +1,258 @@ +namespace AngleSharp.Io.Cache +{ + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net; + using System.Runtime.CompilerServices; + + /// + /// Represents the default Cache Storage provider factory with origin-dependent views. + /// + public class CacheProviderFactory : ICacheProviderFactory + { + private readonly ConditionalWeakTable _storages; + private readonly Dictionary _bucketsByOriginAndName; + + /// + /// Creates a new Cache Storage provider factory. + /// + public CacheProviderFactory() + { + _storages = new ConditionalWeakTable(); + _bucketsByOriginAndName = new Dictionary(StringComparer.Ordinal); + } + + /// + public ICacheStorage GetCaches(IWindow window) + { + if (window == null) + { + return null; + } + + return _storages.GetValue(window, CreateStorage); + } + + private CacheStorage CreateStorage(IWindow window) + { + return new CacheStorage( + () => GetOrigin(window), + (origin, name) => GetOrCreateBucket(origin, name), + (origin, name) => DeleteBucket(origin, name), + origin => GetNames(origin)); + } + + private CacheBucket GetOrCreateBucket(String origin, String name) + { + var key = GetBucketKey(origin, name); + + if (!_bucketsByOriginAndName.TryGetValue(key, out var bucket)) + { + bucket = new CacheBucket(name); + _bucketsByOriginAndName[key] = bucket; + } + + return bucket; + } + + private Boolean DeleteBucket(String origin, String name) + { + return _bucketsByOriginAndName.Remove(GetBucketKey(origin, name)); + } + + private String[] GetNames(String origin) + { + origin = origin ?? String.Empty; + var prefix = origin + "|"; + + return _bucketsByOriginAndName.Keys + .Where(key => key.StartsWith(prefix, StringComparison.Ordinal)) + .Select(key => key.Substring(prefix.Length)) + .ToArray(); + } + + private static String GetBucketKey(String origin, String name) + { + return (origin ?? String.Empty) + "|" + (name ?? String.Empty); + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + internal sealed class CacheStorage : ICacheStorage + { + private readonly Func _getOrigin; + private readonly Func _getBucket; + private readonly Func _deleteBucket; + private readonly Func _getNames; + + public CacheStorage(Func getOrigin, Func getBucket, Func deleteBucket, Func getNames) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + _deleteBucket = deleteBucket ?? throw new ArgumentNullException(nameof(deleteBucket)); + _getNames = getNames ?? throw new ArgumentNullException(nameof(getNames)); + } + + public ICache Open(String name) + { + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + return new Cache(name, _getBucket.Invoke(origin, name)); + } + + public Boolean Delete(String name) + { + name = name ?? String.Empty; + return _deleteBucket.Invoke(_getOrigin.Invoke(), name); + } + + public String[] Keys() => _getNames.Invoke(_getOrigin.Invoke()); + } + + internal sealed class Cache : ICache + { + private readonly CacheBucket _bucket; + + public Cache(String name, CacheBucket bucket) + { + _bucket = bucket ?? throw new ArgumentNullException(nameof(bucket)); + } + + public void Put(String requestAddress, IResponse response) + { + if (String.IsNullOrEmpty(requestAddress) || response == null) + { + return; + } + + _bucket.Put(requestAddress, CacheEntry.FromResponse(response)); + } + + public IResponse Match(String requestAddress) + { + if (String.IsNullOrEmpty(requestAddress)) + { + return null; + } + + return _bucket.Match(requestAddress)?.ToResponse(); + } + + public Boolean Delete(String requestAddress) + { + if (String.IsNullOrEmpty(requestAddress)) + { + return false; + } + + return _bucket.Delete(requestAddress); + } + + public String[] Keys() => _bucket.Keys(); + + public void Clear() => _bucket.Clear(); + } + + internal sealed class CacheBucket + { + private readonly Dictionary _entries; + + public CacheBucket(String name) + { + Name = name ?? String.Empty; + _entries = new Dictionary(StringComparer.Ordinal); + } + + public String Name { get; } + + public void Put(String requestAddress, CacheEntry entry) + { + _entries[requestAddress ?? String.Empty] = entry; + } + + public CacheEntry Match(String requestAddress) + { + return _entries.TryGetValue(requestAddress ?? String.Empty, out var entry) ? entry : null; + } + + public Boolean Delete(String requestAddress) + { + return _entries.Remove(requestAddress ?? String.Empty); + } + + public String[] Keys() => _entries.Keys.ToArray(); + + public void Clear() => _entries.Clear(); + } + + internal sealed class CacheEntry + { + public HttpStatusCode StatusCode { get; set; } + + public Url Address { get; set; } + + public Dictionary Headers { get; set; } + + public Byte[] Content { get; set; } + + public static CacheEntry FromResponse(IResponse response) + { + var content = Array.Empty(); + + if (response.Content != null) + { + using (var memory = new MemoryStream()) + { + if (response.Content.CanSeek) + { + response.Content.Position = 0; + } + + response.Content.CopyTo(memory); + content = memory.ToArray(); + } + } + + return new CacheEntry + { + Address = response.Address, + StatusCode = response.StatusCode, + Headers = response.Headers == null ? new Dictionary(StringComparer.OrdinalIgnoreCase) : new Dictionary(response.Headers, StringComparer.OrdinalIgnoreCase), + Content = content, + }; + } + + public IResponse ToResponse() + { + return new DefaultResponse + { + Address = Address, + StatusCode = StatusCode, + Headers = Headers == null ? new Dictionary(StringComparer.OrdinalIgnoreCase) : new Dictionary(Headers, StringComparer.OrdinalIgnoreCase), + Content = new MemoryStream(Content ?? Array.Empty()), + }; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/WindowExtensions.cs b/src/AngleSharp.Io/Dom/WindowExtensions.cs index 84017aa..f6bcd22 100644 --- a/src/AngleSharp.Io/Dom/WindowExtensions.cs +++ b/src/AngleSharp.Io/Dom/WindowExtensions.cs @@ -31,5 +31,27 @@ public static ISessionStorage SessionStorage(this IWindow window) var factory = window?.Document?.Context?.GetService(); return factory?.GetStorages(window)?.Session; } + + /// + /// Gets the indexedDB object. + /// + [DomName("indexedDB")] + [DomAccessor(Accessors.Getter)] + public static IIndexedDbFactory IndexedDb(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetIndexedDb(window); + } + + /// + /// Gets the caches object. + /// + [DomName("caches")] + [DomAccessor(Accessors.Getter)] + public static ICacheStorage Caches(this IWindow window) + { + var factory = window?.Document?.Context?.GetService(); + return factory?.GetCaches(window); + } } } diff --git a/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs new file mode 100644 index 0000000..08e3879 --- /dev/null +++ b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs @@ -0,0 +1,216 @@ +namespace AngleSharp.Io.IndexedDb +{ + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using AngleSharp.Io.Storage; + using System; + using System.Collections.Generic; + using System.Runtime.CompilerServices; + + /// + /// Represents the default IndexedDB provider factory with origin-dependent views. + /// + public class IndexedDbProviderFactory : IIndexedDbProviderFactory + { + private readonly ConditionalWeakTable _factories; + private readonly Dictionary _databasesByOriginAndName; + + /// + /// Creates a new IndexedDB provider factory. + /// + public IndexedDbProviderFactory() + { + _factories = new ConditionalWeakTable(); + _databasesByOriginAndName = new Dictionary(StringComparer.Ordinal); + } + + /// + public IIndexedDbFactory GetIndexedDb(IWindow window) + { + if (window == null) + { + return null; + } + + return _factories.GetValue(window, CreateFactory); + } + + private IndexedDbFactory CreateFactory(IWindow window) + { + return new IndexedDbFactory( + () => GetOrigin(window), + (origin, name) => GetOrCreateDatabase(origin, name), + (origin, name) => DeleteDatabase(origin, name)); + } + + private IndexedDbDatabaseBucket GetOrCreateDatabase(String origin, String name) + { + var key = GetDatabaseKey(origin, name); + + if (!_databasesByOriginAndName.TryGetValue(key, out var database)) + { + database = new IndexedDbDatabaseBucket(name); + _databasesByOriginAndName[key] = database; + } + + return database; + } + + private Boolean DeleteDatabase(String origin, String name) + { + return _databasesByOriginAndName.Remove(GetDatabaseKey(origin, name)); + } + + private static String GetDatabaseKey(String origin, String name) + { + return (origin ?? String.Empty) + "|" + (name ?? String.Empty); + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + internal sealed class IndexedDbFactory : IIndexedDbFactory + { + private readonly Func _getOrigin; + private readonly Func _getDatabase; + private readonly Func _deleteDatabase; + + public IndexedDbFactory(Func getOrigin, Func getDatabase, Func deleteDatabase) + { + _getOrigin = getOrigin ?? throw new ArgumentNullException(nameof(getOrigin)); + _getDatabase = getDatabase ?? throw new ArgumentNullException(nameof(getDatabase)); + _deleteDatabase = deleteDatabase ?? throw new ArgumentNullException(nameof(deleteDatabase)); + } + + public IIndexedDbDatabase Open(String name) + { + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + return new IndexedDbDatabase(name, database); + } + + public Boolean DeleteDatabase(String name) + { + name = name ?? String.Empty; + return _deleteDatabase.Invoke(_getOrigin.Invoke(), name); + } + } + + internal sealed class IndexedDbDatabase : IIndexedDbDatabase + { + private readonly String _name; + private readonly IndexedDbDatabaseBucket _database; + + public IndexedDbDatabase(String name, IndexedDbDatabaseBucket database) + { + _name = name ?? String.Empty; + _database = database ?? throw new ArgumentNullException(nameof(database)); + } + + public String Name => _name; + + public IIndexedDbObjectStore CreateObjectStore(String name) + { + return new IndexedDbObjectStore(() => _database.GetOrCreateStore(name)); + } + + public Boolean DeleteObjectStore(String name) + { + return _database.DeleteStore(name); + } + } + + internal sealed class IndexedDbObjectStore : IIndexedDbObjectStore + { + private readonly Func _getBucket; + + public IndexedDbObjectStore(Func getBucket) + { + _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + } + + public Int32 Length => CurrentBucket.Length; + + public String Key(Int32 index) => CurrentBucket.Key(index); + + public String this[String key] + { + get => CurrentBucket.Get(key); + set + { + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + CurrentBucket.Set(key, value); + } + } + + public void Remove(String key) + { + if (String.IsNullOrEmpty(key)) + { + return; + } + + CurrentBucket.Remove(key); + } + + public void Clear() + { + CurrentBucket.Clear(); + } + + private StorageBucket CurrentBucket => _getBucket.Invoke(); + } + + internal sealed class IndexedDbDatabaseBucket + { + private readonly Dictionary _stores; + + public IndexedDbDatabaseBucket(String name) + { + Name = name ?? String.Empty; + _stores = new Dictionary(StringComparer.Ordinal); + } + + public String Name { get; } + + public StorageBucket GetOrCreateStore(String name) + { + name = name ?? String.Empty; + + if (!_stores.TryGetValue(name, out var store)) + { + store = new StorageBucket(); + _stores[name] = store; + } + + return store; + } + + public Boolean DeleteStore(String name) + { + return _stores.Remove(name ?? String.Empty); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/ICache.cs b/src/AngleSharp.Io/Interfaces/ICache.cs new file mode 100644 index 0000000..20cf477 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ICache.cs @@ -0,0 +1,51 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Io; + using System; + + /// + /// Represents a Cache object. + /// + [DomName("Cache")] + [DomExposed("Window")] + public interface ICache + { + /// + /// Stores a response for the given request address. + /// + /// The request address. + /// The response to store. + [DomName("put")] + void Put(String requestAddress, IResponse response); + + /// + /// Retrieves the stored response for the given request address. + /// + /// The request address. + /// The stored response or null. + [DomName("match")] + IResponse Match(String requestAddress); + + /// + /// Deletes the stored response for the given request address. + /// + /// The request address. + /// True if a response was removed. + [DomName("delete")] + Boolean Delete(String requestAddress); + + /// + /// Gets the stored request addresses. + /// + /// The stored request addresses. + [DomName("keys")] + String[] Keys(); + + /// + /// Removes all stored responses. + /// + [DomName("clear")] + void Clear(); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/ICacheProviderFactory.cs b/src/AngleSharp.Io/Interfaces/ICacheProviderFactory.cs new file mode 100644 index 0000000..d6c71af --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ICacheProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Dom; + + /// + /// Represents a factory for creating Cache Storage views per window. + /// + public interface ICacheProviderFactory + { + /// + /// Gets the Cache Storage object for the provided window. + /// + /// The window requesting cache access. + /// The Cache Storage object or null. + ICacheStorage GetCaches(IWindow window); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/ICacheStorage.cs b/src/AngleSharp.Io/Interfaces/ICacheStorage.cs new file mode 100644 index 0000000..056a9f1 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/ICacheStorage.cs @@ -0,0 +1,36 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents the Cache Storage interface. + /// + [DomName("CacheStorage")] + [DomExposed("Window")] + public interface ICacheStorage + { + /// + /// Gets or creates the cache with the given name. + /// + /// The cache name. + /// The cache instance. + [DomName("open")] + ICache Open(String name); + + /// + /// Deletes the cache with the given name. + /// + /// The cache name. + /// True if the cache existed and was removed. + [DomName("delete")] + Boolean Delete(String name); + + /// + /// Gets all cache names. + /// + /// The cache names. + [DomName("keys")] + String[] Keys(); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs new file mode 100644 index 0000000..56d9e62 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs @@ -0,0 +1,35 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents an IndexedDB database. + /// + [DomName("IDBDatabase")] + [DomExposed("Window")] + public interface IIndexedDbDatabase + { + /// + /// Gets the database name. + /// + [DomName("name")] + String Name { get; } + + /// + /// Gets or creates the object store with the given name. + /// + /// The object store name. + /// The object store. + [DomName("createObjectStore")] + IIndexedDbObjectStore CreateObjectStore(String name); + + /// + /// Deletes the object store with the given name. + /// + /// The object store name. + /// True if the store existed and was removed. + [DomName("deleteObjectStore")] + Boolean DeleteObjectStore(String name); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs new file mode 100644 index 0000000..c93a1cb --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents an IndexedDB factory. + /// + [DomName("IDBFactory")] + [DomExposed("Window")] + public interface IIndexedDbFactory + { + /// + /// Opens or creates the database with the given name. + /// + /// The database name. + /// The database instance. + [DomName("open")] + IIndexedDbDatabase Open(String name); + + /// + /// Deletes the database with the given name. + /// + /// The database name. + /// True if the database existed and was removed. + [DomName("deleteDatabase")] + Boolean DeleteDatabase(String name); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs new file mode 100644 index 0000000..5a82cd1 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs @@ -0,0 +1,13 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + + /// + /// Represents an IndexedDB object store. + /// + [DomName("IDBObjectStore")] + [DomExposed("Window")] + public interface IIndexedDbObjectStore : IStorage + { + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbProviderFactory.cs new file mode 100644 index 0000000..423457b --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Dom; + + /// + /// Represents a factory for creating IndexedDB views per window. + /// + public interface IIndexedDbProviderFactory + { + /// + /// Gets the IndexedDB factory for the provided window. + /// + /// The window requesting IndexedDB access. + /// The IndexedDB factory or null. + IIndexedDbFactory GetIndexedDb(IWindow window); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index 0d9778c..6870c42 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -4,6 +4,7 @@ namespace AngleSharp using AngleSharp.Io; using AngleSharp.Io.Cookie; using AngleSharp.Io.Dom; + using AngleSharp.Io.IndexedDb; using AngleSharp.Io.Network; using AngleSharp.Io.Storage; using System; @@ -209,5 +210,31 @@ public static IConfiguration WithStorageProviderFactory(this IConfiguration conf configuration.WithOnly(_ => factory); #endregion + + #region IndexedDb + + /// + /// Registers an IndexedDB provider factory. + /// + /// The configuration to extend. + /// The IndexedDB provider factory to use. + /// The new instance with the service. + public static IConfiguration WithIndexedDbProviderFactory(this IConfiguration configuration, IIndexedDbProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion + + #region Cache + + /// + /// Registers a Cache Storage provider factory. + /// + /// The configuration to extend. + /// The Cache Storage provider factory to use. + /// The new instance with the service. + public static IConfiguration WithCacheProviderFactory(this IConfiguration configuration, ICacheProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion } } \ No newline at end of file From eed9e961eb40bc2d88c4ce2159bf01329ae33fb2 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 31 Jul 2026 21:41:11 +0200 Subject: [PATCH 24/31] Added broadcast channel --- CHANGELOG.md | 1 + README.md | 17 ++ docs/general/01-Basics.md | 17 ++ .../BroadcastChannel/BroadcastChannelTests.cs | 76 ++++++ src/AngleSharp.Io/Dom/BroadcastChannel.cs | 243 ++++++++++++++++++ 5 files changed, 354 insertions(+) create mode 100644 src/AngleSharp.Io.Tests/BroadcastChannel/BroadcastChannelTests.cs create mode 100644 src/AngleSharp.Io/Dom/BroadcastChannel.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fed883..5089d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Released on Friday, July 31 2026. - Added `localStorage` and `sessionStorage` for web storages - Added `indexedDB` using an IndexedDB implementation - Added `caches` using a Cache API implementation +- Added `BroadcastChannel` # 1.0.0 diff --git a/README.md b/README.md index 871ea47..112c3e4 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,23 @@ var cache = document.DefaultView.Caches().Open("app"); cache.Put("https://example.org/data", new DefaultResponse()); ``` +### BroadcastChannel + +AngleSharp.Io also provides a lightweight BroadcastChannel surface that is shared per origin: + +- `BroadcastChannel` delivering `message` events to same-origin channels with the same name + +Create a channel from a DOM window: + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenAsync("https://example.org"); + +using var channel = new BroadcastChannel(document.DefaultView, "app"); +channel.Message += (s, e) => Console.WriteLine(((MessageEvent)e).Data); +channel.PostMessage("hello"); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 0afae37..18f565a 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -171,6 +171,23 @@ var cache = document.DefaultView.Caches().Open("app"); cache.Put("https://example.org/data", new DefaultResponse()); ``` +### BroadcastChannel + +AngleSharp.Io also provides a lightweight BroadcastChannel surface that is shared per origin: + +- `BroadcastChannel` delivering `message` events to same-origin channels with the same name + +Create a channel from a DOM window: + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenAsync("https://example.org"); + +using var channel = new BroadcastChannel(document.DefaultView, "app"); +channel.Message += (s, e) => Console.WriteLine(((MessageEvent)e).Data); +channel.PostMessage("hello"); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/src/AngleSharp.Io.Tests/BroadcastChannel/BroadcastChannelTests.cs b/src/AngleSharp.Io.Tests/BroadcastChannel/BroadcastChannelTests.cs new file mode 100644 index 0000000..d5d7a0a --- /dev/null +++ b/src/AngleSharp.Io.Tests/BroadcastChannel/BroadcastChannelTests.cs @@ -0,0 +1,76 @@ +namespace AngleSharp.Io.Tests.BroadcastChannel +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Dom.Events; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class BroadcastChannelTests + { + [Test] + public async Task BroadcastChannelCanBeCreatedFromADocumentWindow() + { + var context = BrowsingContext.New(); + var window = await OpenWindowAsync(context, "https://broadcast.example/"); + + Assert.IsNotNull(new BroadcastChannel(window, "app")); + } + + [Test] + public async Task BroadcastChannelDeliversMessagesToOtherSameOriginChannels() + { + var contextA = BrowsingContext.New(); + var contextB = BrowsingContext.New(); + + var windowA = await OpenWindowAsync(contextA, "https://broadcast.example/"); + var windowB = await OpenWindowAsync(contextB, "https://broadcast.example/other"); + + using var channelA = new BroadcastChannel(windowA, "app"); + using var channelB = new BroadcastChannel(windowB, "app"); + + var received = new TaskCompletionSource(); + var sourceEvents = 0; + + channelA.Message += (_, __) => sourceEvents++; + channelB.Message += (_, ev) => received.TrySetResult(((MessageEvent)ev).Data?.ToString()); + + channelA.PostMessage("hello"); + + Assert.AreEqual("hello", await received.Task); + Assert.AreEqual(0, sourceEvents); + } + + [Test] + public async Task BroadcastChannelDoesNotCrossOrigins() + { + var contextA = BrowsingContext.New(); + var contextB = BrowsingContext.New(); + + var windowA = await OpenWindowAsync(contextA, "https://broadcast.example/"); + var windowB = await OpenWindowAsync(contextB, "https://other.example/"); + + using var channelA = new BroadcastChannel(windowA, "app"); + using var channelB = new BroadcastChannel(windowB, "app"); + + var received = new TaskCompletionSource(); + + channelB.Message += (_, __) => received.TrySetResult(true); + + channelA.PostMessage("hello"); + + var completed = await Task.WhenAny(received.Task, Task.Delay(200)); + Assert.AreNotEqual(received.Task, completed); + } + + private static async Task OpenWindowAsync(IBrowsingContext context, String address) + { + var document = await context.OpenAsync(res => + res.Address(address).Content("broadcast")); + return document.DefaultView; + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/BroadcastChannel.cs b/src/AngleSharp.Io/Dom/BroadcastChannel.cs new file mode 100644 index 0000000..d5662ee --- /dev/null +++ b/src/AngleSharp.Io/Dom/BroadcastChannel.cs @@ -0,0 +1,243 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using AngleSharp.Dom.Events; + using System; + using System.Collections.Generic; + + /// + /// Represents the BroadcastChannel interface. + /// + [DomName("BroadcastChannel")] + public sealed class BroadcastChannel : EventTarget, IDisposable + { + private readonly String _name; + private readonly String _origin; + private readonly IWindow _window; + + private Boolean _isClosed; + + /// + /// Creates a new broadcast channel. + /// + /// The parent window. + /// The channel name. + [DomConstructor] + public BroadcastChannel(IWindow window, String name) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + _name = String.IsNullOrEmpty(name) ? String.Empty : name; + _origin = GetOrigin(window); + + BroadcastChannelRegistry.Register(this); + _window.Unloaded += OnUnload; + } + + /// + /// Gets the channel name. + /// + [DomName("name")] + public String Name => _name; + + /// + /// Adds or removes the handler for the message event. + /// + [DomName("onmessage")] + public event DomEventHandler Message + { + add { AddEventListener(EventNames.Message, value, false); } + remove { RemoveEventListener(EventNames.Message, value, false); } + } + + /// + /// Posts a message to other channels with the same name and origin. + /// + /// The message to broadcast. + [DomName("postMessage")] + public void PostMessage(Object message) + { + if (_isClosed) + { + return; + } + + BroadcastChannelRegistry.PostMessage(this, message); + } + + /// + /// Closes the channel and releases its registration. + /// + [DomName("close")] + public void Close() + { + if (_isClosed) + { + return; + } + + _isClosed = true; + BroadcastChannelRegistry.Unregister(this); + RemoveEventListeners(); + } + + void IDisposable.Dispose() + { + Close(); + } + + internal String Origin => _origin; + + internal Boolean IsClosed => _isClosed; + + internal void Receive(Object message, BroadcastChannel source) + { + if (_isClosed) + { + return; + } + + var evt = new MessageEvent(EventNames.Message, false, false, message, _origin, String.Empty, source?._window, Array.Empty()); + Dispatch(evt); + } + + private void OnUnload(Object sender, Event ev) + { + Close(); + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + internal static class BroadcastChannelRegistry + { + private static readonly Object SyncRoot = new Object(); + private static readonly Dictionary>> ChannelsByOriginAndName = new Dictionary>>(StringComparer.Ordinal); + + public static void Register(BroadcastChannel channel) + { + if (channel == null) + { + return; + } + + lock (SyncRoot) + { + var key = GetKey(channel.Origin, channel.Name); + + if (!ChannelsByOriginAndName.TryGetValue(key, out var channels)) + { + channels = new List>(); + ChannelsByOriginAndName[key] = channels; + } + + Cleanup(channels); + channels.Add(new WeakReference(channel)); + } + } + + public static void Unregister(BroadcastChannel channel) + { + if (channel == null) + { + return; + } + + lock (SyncRoot) + { + var key = GetKey(channel.Origin, channel.Name); + + if (!ChannelsByOriginAndName.TryGetValue(key, out var channels)) + { + return; + } + + Cleanup(channels); + + for (var i = channels.Count - 1; i >= 0; i--) + { + if (!channels[i].TryGetTarget(out var target) || ReferenceEquals(target, channel)) + { + channels.RemoveAt(i); + } + } + + if (channels.Count == 0) + { + ChannelsByOriginAndName.Remove(key); + } + } + } + + public static void PostMessage(BroadcastChannel source, Object message) + { + if (source == null) + { + return; + } + + List targets = null; + + lock (SyncRoot) + { + var key = GetKey(source.Origin, source.Name); + + if (!ChannelsByOriginAndName.TryGetValue(key, out var channels)) + { + return; + } + + Cleanup(channels); + targets = new List(); + + for (var i = 0; i < channels.Count; i++) + { + if (!channels[i].TryGetTarget(out var target) || target.IsClosed || ReferenceEquals(target, source)) + { + continue; + } + + targets.Add(target); + } + } + + for (var i = 0; i < targets.Count; i++) + { + targets[i].Receive(message, source); + } + } + + private static void Cleanup(List> channels) + { + for (var i = channels.Count - 1; i >= 0; i--) + { + if (!channels[i].TryGetTarget(out var target) || target.IsClosed) + { + channels.RemoveAt(i); + } + } + } + + private static String GetKey(String origin, String name) + { + return (origin ?? String.Empty) + "|" + (name ?? String.Empty); + } + } +} \ No newline at end of file From bdc27b33a110ab3a8ff764bf74ce916cded41e81 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 31 Jul 2026 22:02:26 +0200 Subject: [PATCH 25/31] Added web locks --- CHANGELOG.md | 1 + README.md | 22 +++ docs/general/01-Basics.md | 21 ++ .../WebLocks/WebLocksTests.cs | 184 ++++++++++++++++++ src/AngleSharp.Io/Dom/BroadcastChannel.cs | 2 + src/AngleSharp.Io/Dom/LockManager.cs | 149 ++++++++++++++ src/AngleSharp.Io/Dom/NavigatorExtensions.cs | 66 +++++++ 7 files changed, 445 insertions(+) create mode 100644 src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs create mode 100644 src/AngleSharp.Io/Dom/LockManager.cs create mode 100644 src/AngleSharp.Io/Dom/NavigatorExtensions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5089d37..171eae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Released on Friday, July 31 2026. - Added `indexedDB` using an IndexedDB implementation - Added `caches` using a Cache API implementation - Added `BroadcastChannel` +- Added `locks` using the `LockManager` web locks spec # 1.0.0 diff --git a/README.md b/README.md index 112c3e4..89f484c 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,27 @@ channel.Message += (s, e) => Console.WriteLine(((MessageEvent)e).Data); channel.PostMessage("hello"); ``` +### Web Locks + +AngleSharp.Io also provides a minimal Web Locks surface that serializes requests per origin and lock name: + +- `locks` using in-memory request queues scoped to the origin + +Create a lock manager from a navigator implementation: + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenAsync("https://example.org"); +var navigator = document.DefaultView.Navigator; +var locks = navigator.Locks(); + +await locks.Request("app", async _ => +{ + Console.WriteLine("inside the lock"); + await Task.CompletedTask; +}); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. @@ -222,6 +243,7 @@ The `SaveToAsync` (as well as the `CopyToAsync`) are extension methods for the ` - Storage support with a unified `Storage` implementation - Improved cookie container (`AdvancedCookieContainer`) - Enhanced download capabilities for resources / links +- Web Locks support for origin-scoped request serialization ## Participating diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 18f565a..e41993e 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -188,6 +188,27 @@ channel.Message += (s, e) => Console.WriteLine(((MessageEvent)e).Data); channel.PostMessage("hello"); ``` +### Web Locks + +AngleSharp.Io also provides a lightweight Web Locks surface that serializes requests per origin and lock name: + +- `locks` using in-memory request queues scoped to the origin + +Create a lock manager from a navigator implementation: + +```cs +var context = BrowsingContext.New(); +var document = await context.OpenAsync("https://example.org"); +var navigator = document.DefaultView.Navigator; +var locks = navigator.Locks(); + +await locks.Request("app", async _ => +{ + Console.WriteLine("inside the lock"); + await Task.CompletedTask; +}); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs b/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs new file mode 100644 index 0000000..e10fa11 --- /dev/null +++ b/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs @@ -0,0 +1,184 @@ +namespace AngleSharp.Io.Tests.WebLocks +{ + using AngleSharp; + using AngleSharp.Browser.Dom; + using AngleSharp.Dom; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + + [TestFixture] + public class WebLocksTests + { + [Test] + public async Task NavigatorLocksCanBeCreatedFromADocumentWindow() + { + var context = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); + var navigator = new FakeNavigator(context); + + Assert.IsNotNull(navigator.Locks()); + } + + [Test] + public async Task NavigatorLocksSerializeSameOriginRequests() + { + var contextA = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); + var contextB = await OpenContextAsync("https://locks.example/other").ConfigureAwait(false); + var locksA = new FakeNavigator(contextA).Locks(); + var locksB = new FakeNavigator(contextB).Locks(); + + var enteredA = new TaskCompletionSource(); + var enteredB = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + + var requestA = locksA.Request("resource", async _ => + { + enteredA.TrySetResult(true); + await releaseA.Task.ConfigureAwait(false); + }); + + await enteredA.Task.ConfigureAwait(false); + + var requestB = locksB.Request("resource", async _ => + { + enteredB.TrySetResult(true); + await Task.CompletedTask; + }); + + var blocked = await Task.WhenAny(enteredB.Task, Task.Delay(200)).ConfigureAwait(false); + Assert.AreNotEqual(enteredB.Task, blocked); + + releaseA.TrySetResult(true); + + var completed = await Task.WhenAny(enteredB.Task, Task.Delay(1000)).ConfigureAwait(false); + Assert.AreEqual(enteredB.Task, completed); + + await Task.WhenAll(requestA, requestB).ConfigureAwait(false); + } + + [Test] + public async Task NavigatorLocksDoNotCrossOrigins() + { + var contextA = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); + var contextB = await OpenContextAsync("https://other.example/").ConfigureAwait(false); + var locksA = new FakeNavigator(contextA).Locks(); + var locksB = new FakeNavigator(contextB).Locks(); + + var enteredA = new TaskCompletionSource(); + var enteredB = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + + var requestA = locksA.Request("resource", async _ => + { + enteredA.TrySetResult(true); + await releaseA.Task.ConfigureAwait(false); + }); + + await enteredA.Task.ConfigureAwait(false); + + var requestB = locksB.Request("resource", async _ => + { + enteredB.TrySetResult(true); + await Task.CompletedTask; + }); + + var completed = await Task.WhenAny(enteredB.Task, Task.Delay(500)).ConfigureAwait(false); + Assert.AreEqual(enteredB.Task, completed); + + releaseA.TrySetResult(true); + + await Task.WhenAll(requestA, requestB).ConfigureAwait(false); + } + + private static async Task OpenContextAsync(String address) + { + var context = BrowsingContext.New(); + + var document = await context.OpenAsync(res => + res.Address(address).Content("locks")).ConfigureAwait(false); + + Assert.IsNotNull(document.DefaultView); + return context; + } + + private sealed class FakeNavigator : INavigator + { + private readonly HashSet<(String Scheme, String Url, String Title)> _protocolHandlers; + private readonly HashSet<(String MimeType, String Url, String Title)> _contentHandlers; + + public FakeNavigator(IBrowsingContext context) + { + Context = context; + _protocolHandlers = new HashSet<(String Scheme, String Url, String Title)>(); + _contentHandlers = new HashSet<(String MimeType, String Url, String Title)>(); + } + + public IBrowsingContext Context { get; } + + public String Name => "Fake Navigator"; + + public String Version => "1.0"; + + public String Platform => "test"; + + public String UserAgent => "FakeNavigator/1.0"; + + public Boolean IsOnline => true; + + public void WaitForStorageUpdates() + { + } + + public void RegisterProtocolHandler(String scheme, String url, String title) + { + _protocolHandlers.Add((scheme ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); + } + + public void RegisterContentHandler(String mimeType, String url, String title) + { + _contentHandlers.Add((mimeType ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); + } + + public Boolean IsProtocolHandlerRegistered(String scheme, String url) + { + var normalizedScheme = scheme ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + + return _protocolHandlers.Any(m => + String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + + public Boolean IsContentHandlerRegistered(String mimeType, String url) + { + var normalizedMimeType = mimeType ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + + return _contentHandlers.Any(m => + String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + + public void UnregisterProtocolHandler(String scheme, String url) + { + var normalizedScheme = scheme ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + _protocolHandlers.RemoveWhere(m => + String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + + public void UnregisterContentHandler(String mimeType, String url) + { + var normalizedMimeType = mimeType ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + _contentHandlers.RemoveWhere(m => + String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/BroadcastChannel.cs b/src/AngleSharp.Io/Dom/BroadcastChannel.cs index d5662ee..e321772 100644 --- a/src/AngleSharp.Io/Dom/BroadcastChannel.cs +++ b/src/AngleSharp.Io/Dom/BroadcastChannel.cs @@ -10,6 +10,8 @@ namespace AngleSharp.Io.Dom /// Represents the BroadcastChannel interface. /// [DomName("BroadcastChannel")] + [DomExposed("Window")] + [DomExposed("Worker")] public sealed class BroadcastChannel : EventTarget, IDisposable { private readonly String _name; diff --git a/src/AngleSharp.Io/Dom/LockManager.cs b/src/AngleSharp.Io/Dom/LockManager.cs new file mode 100644 index 0000000..06405b0 --- /dev/null +++ b/src/AngleSharp.Io/Dom/LockManager.cs @@ -0,0 +1,149 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp; + using AngleSharp.Attributes; + using AngleSharp.Dom; + using System; + using System.Collections.Concurrent; + using System.Globalization; + using System.Runtime.CompilerServices; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Represents the Web Locks API. + /// + [DomName("LockManager")] + public sealed class LockManager + { + private readonly String _scope; + + internal LockManager(String scope) + { + _scope = scope ?? String.Empty; + } + + /// + /// Requests a lock with the given name. + /// + /// The lock name. + /// The callback to run while the lock is held. + /// The task representing the lock request. + [DomName("request")] + public Task Request(String name, Func callback) + { + return RequestInternal(name, async lockInfo => + { + await callback.Invoke(lockInfo).ConfigureAwait(false); + return true; + }); + } + + /// + /// Requests a lock with the given name. + /// + /// The callback result type. + /// The lock name. + /// The callback to run while the lock is held. + /// The task representing the lock request. + [DomName("request")] + public async Task Request(String name, Func> callback) + { + return await RequestInternal(name, callback).ConfigureAwait(false); + } + + internal static String GetScopeKey(IBrowsingContext context) + { + if (context == null) + { + return String.Empty; + } + + var window = context.Active?.DefaultView; + var origin = GetOrigin(window); + + if (!String.IsNullOrEmpty(origin)) + { + return origin; + } + + return "context:" + RuntimeHelpers.GetHashCode(context).ToString(CultureInfo.InvariantCulture); + } + + private async Task RequestInternal(String name, Func> callback) + { + if (callback == null) + { + throw new ArgumentNullException(nameof(callback)); + } + + var scope = _scope; + var normalizedName = String.IsNullOrEmpty(name) ? String.Empty : name; + var gate = LockRegistry.GetGate(scope, normalizedName); + + await gate.WaitAsync().ConfigureAwait(false); + + try + { + return await callback.Invoke(new WebLock(scope, normalizedName)).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private static String GetOrigin(IWindow window) + { + var href = window?.Location?.Href; + + if (String.IsNullOrEmpty(href)) + { + return String.Empty; + } + + var url = new Url(href); + + if (url.IsInvalid || url.IsRelative) + { + return String.Empty; + } + + return url.Origin ?? String.Empty; + } + } + + /// + /// Represents a granted Web Lock. + /// + [DomName("Lock")] + public sealed class WebLock + { + private readonly String _scope; + + internal WebLock(String scope, String name) + { + _scope = scope ?? String.Empty; + Name = name ?? String.Empty; + } + + /// + /// Gets the lock name. + /// + [DomName("name")] + public String Name { get; } + + internal String Scope => _scope; + } + + internal static class LockRegistry + { + private static readonly ConcurrentDictionary> Scopes = new ConcurrentDictionary>(StringComparer.Ordinal); + + public static SemaphoreSlim GetGate(String scope, String name) + { + var locks = Scopes.GetOrAdd(scope ?? String.Empty, _ => new ConcurrentDictionary(StringComparer.Ordinal)); + return locks.GetOrAdd(name ?? String.Empty, _ => new SemaphoreSlim(1, 1)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/NavigatorExtensions.cs b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs new file mode 100644 index 0000000..b136571 --- /dev/null +++ b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs @@ -0,0 +1,66 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Attributes; + using System; + using System.Reflection; + + /// + /// Defines a set of extensions for the navigator object. + /// + [DomExposed("Navigator")] + public static class NavigatorExtensions + { + /// + /// Gets the locks object. + /// + [DomName("locks")] + [DomAccessor(Accessors.Getter)] + public static LockManager Locks(this INavigator navigator) + { + var context = GetContext(navigator); + + if (context == null) + { + return null; + } + + var scope = LockManager.GetScopeKey(context); + return String.IsNullOrEmpty(scope) ? null : new LockManager(scope); + } + + private static AngleSharp.IBrowsingContext GetContext(INavigator navigator) + { + if (navigator == null) + { + return null; + } + + var type = navigator.GetType(); + var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + var property = type.GetProperty("Context", flags); + + if (property?.GetValue(navigator) is AngleSharp.IBrowsingContext context) + { + return context; + } + + var field = type.GetField("_context", flags); + + if (field?.GetValue(navigator) is AngleSharp.IBrowsingContext fieldContext) + { + return fieldContext; + } + + field = type.GetField("context", flags); + + if (field?.GetValue(navigator) is AngleSharp.IBrowsingContext lowerContext) + { + return lowerContext; + } + + return null; + } + } +} \ No newline at end of file From cfa53455b55f221ce759200ac9f1131164dcf4bf Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 00:07:46 +0200 Subject: [PATCH 26/31] Implemented more IO APIs --- CHANGELOG.md | 2 + README.md | 64 +++++ docs/general/01-Basics.md | 62 +++++ .../Clipboard/ClipboardTests.cs | 79 ++++++ .../Geolocation/GeolocationTests.cs | 109 +++++++++ .../Mocks/FakeNavigator.cs | 85 +++++++ .../Clipboard/ClipboardProviderFactory.cs | 37 +++ src/AngleSharp.Io/Dom/Clipboard.cs | 45 ++++ src/AngleSharp.Io/Dom/Geolocation.cs | 228 ++++++++++++++++++ src/AngleSharp.Io/Dom/NavigatorExtensions.cs | 24 ++ .../Geolocation/GeolocationProviderFactory.cs | 37 +++ .../Interfaces/IClipboardPlatform.cs | 24 ++ .../Interfaces/IClipboardProviderFactory.cs | 17 ++ .../Interfaces/IGeolocationPlatform.cs | 17 ++ .../Interfaces/IGeolocationProviderFactory.cs | 17 ++ .../IoConfigurationExtensions.cs | 52 ++++ 16 files changed, 899 insertions(+) create mode 100644 src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs create mode 100644 src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs create mode 100644 src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs create mode 100644 src/AngleSharp.Io/Clipboard/ClipboardProviderFactory.cs create mode 100644 src/AngleSharp.Io/Dom/Clipboard.cs create mode 100644 src/AngleSharp.Io/Dom/Geolocation.cs create mode 100644 src/AngleSharp.Io/Geolocation/GeolocationProviderFactory.cs create mode 100644 src/AngleSharp.Io/Interfaces/IClipboardPlatform.cs create mode 100644 src/AngleSharp.Io/Interfaces/IClipboardProviderFactory.cs create mode 100644 src/AngleSharp.Io/Interfaces/IGeolocationPlatform.cs create mode 100644 src/AngleSharp.Io/Interfaces/IGeolocationProviderFactory.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 171eae1..ff21341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Released on Friday, July 31 2026. - Added `caches` using a Cache API implementation - Added `BroadcastChannel` - Added `locks` using the `LockManager` web locks spec +- Added `Clipboard` providing the web clipboard API +- Added `Geolocation` providing the web geolocation API # 1.0.0 diff --git a/README.md b/README.md index 89f484c..c7d9ac9 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,68 @@ await locks.Request("app", async _ => }); ``` +### Clipboard + +AngleSharp.Io provides a navigator clipboard surface backed by a user-supplied platform implementation: + +- `clipboard` exposing `readText` / `writeText` + +Register a platform implementation during configuration: + +```cs +public sealed class MyClipboardPlatform : IClipboardPlatform +{ + public Task ReadTextAsync() => Task.FromResult("sample"); + + public Task WriteTextAsync(string text) => Task.CompletedTask; +} + +var config = Configuration.Default.WithClipboard(new MyClipboardPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var clipboard = navigator.Clipboard(); + +await clipboard.WriteText("hello"); +var value = await clipboard.ReadText(); +``` + +### Geolocation + +AngleSharp.Io provides a navigator geolocation surface backed by a user-supplied platform implementation: + +- `geolocation` exposing `getCurrentPosition` + +Register a platform implementation during configuration: + +```cs +public sealed class MyGeolocationPlatform : IGeolocationPlatform +{ + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + return Task.FromResult(new GeolocationReading + { + Latitude = 52.52, + Longitude = 13.405, + Accuracy = 8.0, + }); + } +} + +var config = Configuration.Default.WithGeolocation(new MyGeolocationPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var geolocation = navigator.Geolocation(); +var position = await geolocation.GetCurrentPosition(); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. @@ -244,6 +306,8 @@ The `SaveToAsync` (as well as the `CopyToAsync`) are extension methods for the ` - Improved cookie container (`AdvancedCookieContainer`) - Enhanced download capabilities for resources / links - Web Locks support for origin-scoped request serialization +- Clipboard support with injectable platform implementation +- Geolocation support with injectable platform implementation ## Participating diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index e41993e..d35062d 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -209,6 +209,68 @@ await locks.Request("app", async _ => }); ``` +### Clipboard + +AngleSharp.Io provides a navigator clipboard surface backed by a user-supplied platform implementation: + +- `clipboard` exposing `readText` / `writeText` + +Register a platform implementation during configuration: + +```cs +public sealed class MyClipboardPlatform : IClipboardPlatform +{ + public Task ReadTextAsync() => Task.FromResult("sample"); + + public Task WriteTextAsync(string text) => Task.CompletedTask; +} + +var config = Configuration.Default.WithClipboard(new MyClipboardPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var clipboard = navigator.Clipboard(); + +await clipboard.WriteText("hello"); +var value = await clipboard.ReadText(); +``` + +### Geolocation + +AngleSharp.Io provides a navigator geolocation surface backed by a user-supplied platform implementation: + +- `geolocation` exposing `getCurrentPosition` + +Register a platform implementation during configuration: + +```cs +public sealed class MyGeolocationPlatform : IGeolocationPlatform +{ + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + return Task.FromResult(new GeolocationReading + { + Latitude = 52.52, + Longitude = 13.405, + Accuracy = 8.0, + }); + } +} + +var config = Configuration.Default.WithGeolocation(new MyGeolocationPlatform()); +``` + +Resolve it from `INavigator`: + +```cs +var navigator = document.DefaultView.Navigator; +var geolocation = navigator.Geolocation(); +var position = await geolocation.GetCurrentPosition(); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs b/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs new file mode 100644 index 0000000..4fdf99a --- /dev/null +++ b/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs @@ -0,0 +1,79 @@ +namespace AngleSharp.Io.Tests.Clipboard +{ + using AngleSharp; + using AngleSharp.Io.Dom; + using AngleSharp.Io.Tests.Mocks; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class ClipboardTests + { + [Test] + public async Task ClipboardCanBeResolvedFromNavigatorWhenRegistered() + { + var platform = new FakeClipboardPlatform(); + var context = await OpenContextAsync(Configuration.Default.WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = new FakeNavigator(context); + + Assert.IsNotNull(navigator.Clipboard()); + } + + [Test] + public async Task ClipboardWriteTextUsesPlatformImplementation() + { + var platform = new FakeClipboardPlatform(); + var context = await OpenContextAsync(Configuration.Default.WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = new FakeNavigator(context); + var clipboard = navigator.Clipboard(); + + await clipboard.WriteText("copied").ConfigureAwait(false); + + Assert.AreEqual("copied", platform.LastWrittenText); + } + + [Test] + public async Task ClipboardReadTextUsesPlatformImplementation() + { + var platform = new FakeClipboardPlatform + { + CurrentText = "from-platform" + }; + + var context = await OpenContextAsync(Configuration.Default.WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = new FakeNavigator(context); + var clipboard = navigator.Clipboard(); + + var value = await clipboard.ReadText().ConfigureAwait(false); + + Assert.AreEqual("from-platform", value); + } + + private static async Task OpenContextAsync(IConfiguration configuration, String address) + { + var context = BrowsingContext.New(configuration); + await context.OpenAsync(res => res.Address(address).Content("clipboard")).ConfigureAwait(false); + return context; + } + + private sealed class FakeClipboardPlatform : IClipboardPlatform + { + public String CurrentText { get; set; } = String.Empty; + + public String LastWrittenText { get; private set; } = String.Empty; + + public Task ReadTextAsync() + { + return Task.FromResult(CurrentText); + } + + public Task WriteTextAsync(String text) + { + LastWrittenText = text; + CurrentText = text; + return Task.CompletedTask; + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs b/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs new file mode 100644 index 0000000..c3a1fe2 --- /dev/null +++ b/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs @@ -0,0 +1,109 @@ +namespace AngleSharp.Io.Tests.Geolocation +{ + using AngleSharp; + using AngleSharp.Io.Dom; + using AngleSharp.Io.Tests.Mocks; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [TestFixture] + public class GeolocationTests + { + [Test] + public async Task GeolocationCanBeResolvedFromNavigatorWhenRegistered() + { + var platform = new FakeGeolocationPlatform(); + var context = await OpenContextAsync(Configuration.Default.WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = new FakeNavigator(context); + + Assert.IsNotNull(navigator.Geolocation()); + } + + [Test] + public async Task GeolocationMapsPlatformReadingToDomTypes() + { + var platform = new FakeGeolocationPlatform + { + NextReading = new GeolocationReading + { + Latitude = 52.52, + Longitude = 13.405, + Accuracy = 8.0, + Altitude = 34.5, + AltitudeAccuracy = 1.2, + Heading = 90.0, + Speed = 2.5, + Timestamp = 1234567890, + } + }; + + var context = await OpenContextAsync(Configuration.Default.WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = new FakeNavigator(context); + var geolocation = navigator.Geolocation(); + + var position = await geolocation.GetCurrentPosition().ConfigureAwait(false); + + Assert.AreEqual(52.52, position.Coordinates.Latitude); + Assert.AreEqual(13.405, position.Coordinates.Longitude); + Assert.AreEqual(8.0, position.Coordinates.Accuracy); + Assert.AreEqual(34.5, position.Coordinates.Altitude); + Assert.AreEqual(1.2, position.Coordinates.AltitudeAccuracy); + Assert.AreEqual(90.0, position.Coordinates.Heading); + Assert.AreEqual(2.5, position.Coordinates.Speed); + Assert.AreEqual(1234567890, position.Timestamp); + } + + [Test] + public async Task GeolocationForwardsRequestOptionsToPlatform() + { + var platform = new FakeGeolocationPlatform + { + NextReading = new GeolocationReading + { + Latitude = 1.0, + Longitude = 2.0, + Accuracy = 3.0, + } + }; + + var options = new GeolocationOptions + { + EnableHighAccuracy = true, + Timeout = 1500, + MaximumAge = 200, + }; + + var context = await OpenContextAsync(Configuration.Default.WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = new FakeNavigator(context); + var geolocation = navigator.Geolocation(); + + await geolocation.GetCurrentPosition(options).ConfigureAwait(false); + + Assert.IsNotNull(platform.LastOptions); + Assert.AreEqual(true, platform.LastOptions.EnableHighAccuracy); + Assert.AreEqual(1500, platform.LastOptions.Timeout); + Assert.AreEqual(200, platform.LastOptions.MaximumAge); + } + + private static async Task OpenContextAsync(IConfiguration configuration, String address) + { + var context = BrowsingContext.New(configuration); + await context.OpenAsync(res => res.Address(address).Content("geolocation")).ConfigureAwait(false); + return context; + } + + private sealed class FakeGeolocationPlatform : IGeolocationPlatform + { + public GeolocationReading NextReading { get; set; } = GeolocationReading.Empty; + + public GeolocationOptions LastOptions { get; private set; } + + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + LastOptions = options; + return Task.FromResult(NextReading); + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs b/src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs new file mode 100644 index 0000000..e8361d0 --- /dev/null +++ b/src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs @@ -0,0 +1,85 @@ +namespace AngleSharp.Io.Tests.Mocks +{ + using AngleSharp; + using AngleSharp.Browser.Dom; + using System; + using System.Collections.Generic; + using System.Linq; + + internal sealed class FakeNavigator : INavigator + { + private readonly HashSet<(String Scheme, String Url, String Title)> _protocolHandlers; + private readonly HashSet<(String MimeType, String Url, String Title)> _contentHandlers; + + public FakeNavigator(IBrowsingContext context) + { + Context = context; + _protocolHandlers = new HashSet<(String Scheme, String Url, String Title)>(); + _contentHandlers = new HashSet<(String MimeType, String Url, String Title)>(); + } + + public IBrowsingContext Context { get; } + + public String Name => "Fake Navigator"; + + public String Version => "1.0"; + + public String Platform => "test"; + + public String UserAgent => "FakeNavigator/1.0"; + + public Boolean IsOnline => true; + + public void WaitForStorageUpdates() + { + } + + public void RegisterProtocolHandler(String scheme, String url, String title) + { + _protocolHandlers.Add((scheme ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); + } + + public void RegisterContentHandler(String mimeType, String url, String title) + { + _contentHandlers.Add((mimeType ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); + } + + public Boolean IsProtocolHandlerRegistered(String scheme, String url) + { + var normalizedScheme = scheme ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + + return _protocolHandlers.Any(m => + String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + + public Boolean IsContentHandlerRegistered(String mimeType, String url) + { + var normalizedMimeType = mimeType ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + + return _contentHandlers.Any(m => + String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + + public void UnregisterProtocolHandler(String scheme, String url) + { + var normalizedScheme = scheme ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + _protocolHandlers.RemoveWhere(m => + String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + + public void UnregisterContentHandler(String mimeType, String url) + { + var normalizedMimeType = mimeType ?? String.Empty; + var normalizedUrl = url ?? String.Empty; + _contentHandlers.RemoveWhere(m => + String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && + String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Clipboard/ClipboardProviderFactory.cs b/src/AngleSharp.Io/Clipboard/ClipboardProviderFactory.cs new file mode 100644 index 0000000..30c8b4f --- /dev/null +++ b/src/AngleSharp.Io/Clipboard/ClipboardProviderFactory.cs @@ -0,0 +1,37 @@ +namespace AngleSharp.Io.ClipboardApi +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Dom; + using System; + using System.Runtime.CompilerServices; + + /// + /// Represents the default Clipboard provider factory. + /// + public sealed class ClipboardProviderFactory : IClipboardProviderFactory + { + private readonly IClipboardPlatform _platform; + private readonly ConditionalWeakTable _clipboards; + + /// + /// Creates a new Clipboard provider factory. + /// + /// The platform clipboard implementation. + public ClipboardProviderFactory(IClipboardPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _clipboards = new ConditionalWeakTable(); + } + + /// + public Clipboard GetClipboard(INavigator navigator) + { + if (navigator == null) + { + return null; + } + + return _clipboards.GetValue(navigator, _ => new Clipboard(_platform)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/Clipboard.cs b/src/AngleSharp.Io/Dom/Clipboard.cs new file mode 100644 index 0000000..9db1126 --- /dev/null +++ b/src/AngleSharp.Io/Dom/Clipboard.cs @@ -0,0 +1,45 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Threading.Tasks; + + /// + /// Represents the Clipboard API. + /// + [DomName("Clipboard")] + public sealed class Clipboard + { + private readonly IClipboardPlatform _platform; + + /// + /// Creates a new clipboard wrapper using the provided platform implementation. + /// + /// The platform clipboard implementation. + public Clipboard(IClipboardPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + } + + /// + /// Reads text from the clipboard. + /// + /// The clipboard text. + [DomName("readText")] + public Task ReadText() + { + return _platform.ReadTextAsync(); + } + + /// + /// Writes text to the clipboard. + /// + /// The text to write. + /// The task representing the asynchronous operation. + [DomName("writeText")] + public Task WriteText(String text) + { + return _platform.WriteTextAsync(text ?? String.Empty); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/Geolocation.cs b/src/AngleSharp.Io/Dom/Geolocation.cs new file mode 100644 index 0000000..6b7e1e7 --- /dev/null +++ b/src/AngleSharp.Io/Dom/Geolocation.cs @@ -0,0 +1,228 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Threading.Tasks; + + /// + /// Represents the Geolocation API. + /// + [DomName("Geolocation")] + public sealed class Geolocation + { + private readonly IGeolocationPlatform _platform; + + /// + /// Creates a new geolocation wrapper using the provided platform implementation. + /// + /// The platform geolocation implementation. + public Geolocation(IGeolocationPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + } + + /// + /// Gets the current position with default options. + /// + /// The current position. + [DomName("getCurrentPosition")] + public Task GetCurrentPosition() + { + return GetCurrentPosition(new GeolocationOptions()); + } + + /// + /// Gets the current position with the provided options. + /// + /// The position request options. + /// The current position. + [DomName("getCurrentPosition")] + public async Task GetCurrentPosition(GeolocationOptions options) + { + var reading = await _platform.GetCurrentPositionAsync(options ?? new GeolocationOptions()).ConfigureAwait(false); + return GeolocationPosition.FromReading(reading ?? GeolocationReading.Empty); + } + } + + /// + /// Represents geolocation request options. + /// + [DomName("PositionOptions")] + public sealed class GeolocationOptions + { + /// + /// Gets or sets if high accuracy should be preferred. + /// + [DomName("enableHighAccuracy")] + public Boolean EnableHighAccuracy { get; set; } + + /// + /// Gets or sets the timeout in milliseconds. + /// + [DomName("timeout")] + public Int64 Timeout { get; set; } + + /// + /// Gets or sets the maximum age in milliseconds. + /// + [DomName("maximumAge")] + public Int64 MaximumAge { get; set; } + } + + /// + /// Represents a geolocation reading returned by the platform implementation. + /// + public sealed class GeolocationReading + { + /// + /// Gets an empty geolocation reading. + /// + public static GeolocationReading Empty { get; } = new GeolocationReading(); + + /// + /// Gets or sets the latitude. + /// + public Double Latitude { get; set; } + + /// + /// Gets or sets the longitude. + /// + public Double Longitude { get; set; } + + /// + /// Gets or sets the accuracy in meters. + /// + public Double Accuracy { get; set; } + + /// + /// Gets or sets the altitude in meters. + /// + public Double? Altitude { get; set; } + + /// + /// Gets or sets the altitude accuracy in meters. + /// + public Double? AltitudeAccuracy { get; set; } + + /// + /// Gets or sets the heading in degrees. + /// + public Double? Heading { get; set; } + + /// + /// Gets or sets the speed in meters per second. + /// + public Double? Speed { get; set; } + + /// + /// Gets or sets the timestamp in milliseconds since Unix epoch. + /// + public Int64 Timestamp { get; set; } + } + + /// + /// Represents a geolocation position. + /// + [DomName("GeolocationPosition")] + public sealed class GeolocationPosition + { + internal GeolocationPosition(GeolocationCoordinates coordinates, Int64 timestamp) + { + Coordinates = coordinates; + Timestamp = timestamp; + } + + /// + /// Gets the coordinates. + /// + [DomName("coords")] + public GeolocationCoordinates Coordinates { get; } + + /// + /// Gets the timestamp in milliseconds since Unix epoch. + /// + [DomName("timestamp")] + public Int64 Timestamp { get; } + + internal static GeolocationPosition FromReading(GeolocationReading reading) + { + var timestamp = reading.Timestamp; + + if (timestamp <= 0) + { + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + var coordinates = new GeolocationCoordinates( + reading.Latitude, + reading.Longitude, + reading.Accuracy, + reading.Altitude, + reading.AltitudeAccuracy, + reading.Heading, + reading.Speed); + + return new GeolocationPosition(coordinates, timestamp); + } + } + + /// + /// Represents geolocation coordinates. + /// + [DomName("GeolocationCoordinates")] + public sealed class GeolocationCoordinates + { + internal GeolocationCoordinates(Double latitude, Double longitude, Double accuracy, Double? altitude, Double? altitudeAccuracy, Double? heading, Double? speed) + { + Latitude = latitude; + Longitude = longitude; + Accuracy = accuracy; + Altitude = altitude; + AltitudeAccuracy = altitudeAccuracy; + Heading = heading; + Speed = speed; + } + + /// + /// Gets the latitude. + /// + [DomName("latitude")] + public Double Latitude { get; } + + /// + /// Gets the longitude. + /// + [DomName("longitude")] + public Double Longitude { get; } + + /// + /// Gets the accuracy in meters. + /// + [DomName("accuracy")] + public Double Accuracy { get; } + + /// + /// Gets the altitude in meters. + /// + [DomName("altitude")] + public Double? Altitude { get; } + + /// + /// Gets the altitude accuracy in meters. + /// + [DomName("altitudeAccuracy")] + public Double? AltitudeAccuracy { get; } + + /// + /// Gets the heading in degrees. + /// + [DomName("heading")] + public Double? Heading { get; } + + /// + /// Gets the speed in meters per second. + /// + [DomName("speed")] + public Double? Speed { get; } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/NavigatorExtensions.cs b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs index b136571..87cbd16 100644 --- a/src/AngleSharp.Io/Dom/NavigatorExtensions.cs +++ b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs @@ -11,6 +11,30 @@ namespace AngleSharp.Io.Dom [DomExposed("Navigator")] public static class NavigatorExtensions { + /// + /// Gets the clipboard object. + /// + [DomName("clipboard")] + [DomAccessor(Accessors.Getter)] + public static Clipboard Clipboard(this INavigator navigator) + { + var context = GetContext(navigator); + var factory = context?.GetService(); + return factory?.GetClipboard(navigator); + } + + /// + /// Gets the geolocation object. + /// + [DomName("geolocation")] + [DomAccessor(Accessors.Getter)] + public static Geolocation Geolocation(this INavigator navigator) + { + var context = GetContext(navigator); + var factory = context?.GetService(); + return factory?.GetGeolocation(navigator); + } + /// /// Gets the locks object. /// diff --git a/src/AngleSharp.Io/Geolocation/GeolocationProviderFactory.cs b/src/AngleSharp.Io/Geolocation/GeolocationProviderFactory.cs new file mode 100644 index 0000000..ad4b9aa --- /dev/null +++ b/src/AngleSharp.Io/Geolocation/GeolocationProviderFactory.cs @@ -0,0 +1,37 @@ +namespace AngleSharp.Io.GeolocationApi +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Dom; + using System; + using System.Runtime.CompilerServices; + + /// + /// Represents the default Geolocation provider factory. + /// + public sealed class GeolocationProviderFactory : IGeolocationProviderFactory + { + private readonly IGeolocationPlatform _platform; + private readonly ConditionalWeakTable _geolocations; + + /// + /// Creates a new Geolocation provider factory. + /// + /// The platform geolocation implementation. + public GeolocationProviderFactory(IGeolocationPlatform platform) + { + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _geolocations = new ConditionalWeakTable(); + } + + /// + public Geolocation GetGeolocation(INavigator navigator) + { + if (navigator == null) + { + return null; + } + + return _geolocations.GetValue(navigator, _ => new Geolocation(_platform)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IClipboardPlatform.cs b/src/AngleSharp.Io/Interfaces/IClipboardPlatform.cs new file mode 100644 index 0000000..5071b80 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IClipboardPlatform.cs @@ -0,0 +1,24 @@ +namespace AngleSharp.Io.Dom +{ + using System; + using System.Threading.Tasks; + + /// + /// Represents the platform clipboard implementation used by the DOM clipboard wrapper. + /// + public interface IClipboardPlatform + { + /// + /// Reads text from the platform clipboard. + /// + /// The clipboard text. + Task ReadTextAsync(); + + /// + /// Writes text to the platform clipboard. + /// + /// The text to write. + /// The task representing the asynchronous operation. + Task WriteTextAsync(String text); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IClipboardProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IClipboardProviderFactory.cs new file mode 100644 index 0000000..50328d7 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IClipboardProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Browser.Dom; + + /// + /// Represents a factory for creating Clipboard views per navigator. + /// + public interface IClipboardProviderFactory + { + /// + /// Gets the Clipboard object for the provided navigator. + /// + /// The navigator requesting clipboard access. + /// The Clipboard object or null. + Clipboard GetClipboard(INavigator navigator); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IGeolocationPlatform.cs b/src/AngleSharp.Io/Interfaces/IGeolocationPlatform.cs new file mode 100644 index 0000000..1795a82 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IGeolocationPlatform.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using System.Threading.Tasks; + + /// + /// Represents the platform geolocation implementation used by the DOM geolocation wrapper. + /// + public interface IGeolocationPlatform + { + /// + /// Gets the current geolocation reading. + /// + /// The request options. + /// The current geolocation reading. + Task GetCurrentPositionAsync(GeolocationOptions options); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IGeolocationProviderFactory.cs b/src/AngleSharp.Io/Interfaces/IGeolocationProviderFactory.cs new file mode 100644 index 0000000..36dcb91 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IGeolocationProviderFactory.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Browser.Dom; + + /// + /// Represents a factory for creating Geolocation views per navigator. + /// + public interface IGeolocationProviderFactory + { + /// + /// Gets the Geolocation object for the provided navigator. + /// + /// The navigator requesting geolocation access. + /// The Geolocation object or null. + Geolocation GetGeolocation(INavigator navigator); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index 6870c42..db66703 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -5,8 +5,10 @@ namespace AngleSharp using AngleSharp.Io.Cookie; using AngleSharp.Io.Dom; using AngleSharp.Io.IndexedDb; + using AngleSharp.Io.GeolocationApi; using AngleSharp.Io.Network; using AngleSharp.Io.Storage; + using AngleSharp.Io.ClipboardApi; using System; using System.IO; using System.Linq; @@ -236,5 +238,55 @@ public static IConfiguration WithCacheProviderFactory(this IConfiguration config configuration.WithOnly(_ => factory); #endregion + + #region Clipboard + + /// + /// Registers the clipboard service using the given platform implementation. + /// + /// The configuration to extend. + /// The platform clipboard implementation. + /// The new instance with the service. + public static IConfiguration WithClipboard(this IConfiguration configuration, IClipboardPlatform platform) + { + var factory = new ClipboardProviderFactory(platform); + return configuration.WithClipboardProviderFactory(factory); + } + + /// + /// Registers a clipboard provider factory. + /// + /// The configuration to extend. + /// The clipboard provider factory to use. + /// The new instance with the service. + public static IConfiguration WithClipboardProviderFactory(this IConfiguration configuration, IClipboardProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion + + #region Geolocation + + /// + /// Registers the geolocation service using the given platform implementation. + /// + /// The configuration to extend. + /// The platform geolocation implementation. + /// The new instance with the service. + public static IConfiguration WithGeolocation(this IConfiguration configuration, IGeolocationPlatform platform) + { + var factory = new GeolocationProviderFactory(platform); + return configuration.WithGeolocationProviderFactory(factory); + } + + /// + /// Registers a geolocation provider factory. + /// + /// The configuration to extend. + /// The geolocation provider factory to use. + /// The new instance with the service. + public static IConfiguration WithGeolocationProviderFactory(this IConfiguration configuration, IGeolocationProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion } } \ No newline at end of file From d4c24abd8df7bdd7d57452c926404740c9791438 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 00:24:46 +0200 Subject: [PATCH 27/31] Moved Navigator to AngleSharp.Io --- CHANGELOG.md | 1 + README.md | 19 +- docs/general/01-Basics.md | 19 +- .../Clipboard/ClipboardTests.cs | 21 +- .../Geolocation/GeolocationTests.cs | 21 +- .../Mocks/FakeNavigator.cs | 85 --- .../WebLocks/WebLocksTests.cs | 93 +-- src/AngleSharp.Io/Dom/Navigator.cs | 53 ++ src/AngleSharp.Io/Dom/NavigatorExtensions.cs | 32 +- .../IoConfigurationExtensions.cs | 589 +++++++++--------- src/AngleSharp.Io/NavigatorOptions.cs | 14 + 11 files changed, 450 insertions(+), 497 deletions(-) delete mode 100644 src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs create mode 100644 src/AngleSharp.Io/Dom/Navigator.cs create mode 100644 src/AngleSharp.Io/NavigatorOptions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ff21341..16fdfac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Released on Friday, July 31 2026. - Updated to use a minimum of AngleSharp 1.5 - Fixed domain handling in cookies #36 +- Added the `Navigator` from *AngleSharp.Js* - Added `localStorage` and `sessionStorage` for web storages - Added `indexedDB` using an IndexedDB implementation - Added `caches` using a Cache API implementation diff --git a/README.md b/README.md index c7d9ac9..8b738b5 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,8 @@ AngleSharp.Io also provides a minimal Web Locks surface that serializes requests Create a lock manager from a navigator implementation: ```cs -var context = BrowsingContext.New(); +var config = Configuration.Default.WithNavigator(); +var context = BrowsingContext.New(config); var document = await context.OpenAsync("https://example.org"); var navigator = document.DefaultView.Navigator; var locks = navigator.Locks(); @@ -211,6 +212,14 @@ public sealed class MyClipboardPlatform : IClipboardPlatform var config = Configuration.Default.WithClipboard(new MyClipboardPlatform()); ``` +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithClipboard(new MyClipboardPlatform()); +``` + Resolve it from `INavigator`: ```cs @@ -246,6 +255,14 @@ public sealed class MyGeolocationPlatform : IGeolocationPlatform var config = Configuration.Default.WithGeolocation(new MyGeolocationPlatform()); ``` +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithGeolocation(new MyGeolocationPlatform()); +``` + Resolve it from `INavigator`: ```cs diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index d35062d..d98929d 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -197,7 +197,8 @@ AngleSharp.Io also provides a lightweight Web Locks surface that serializes requ Create a lock manager from a navigator implementation: ```cs -var context = BrowsingContext.New(); +var config = Configuration.Default.WithNavigator(); +var context = BrowsingContext.New(config); var document = await context.OpenAsync("https://example.org"); var navigator = document.DefaultView.Navigator; var locks = navigator.Locks(); @@ -228,6 +229,14 @@ public sealed class MyClipboardPlatform : IClipboardPlatform var config = Configuration.Default.WithClipboard(new MyClipboardPlatform()); ``` +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithClipboard(new MyClipboardPlatform()); +``` + Resolve it from `INavigator`: ```cs @@ -263,6 +272,14 @@ public sealed class MyGeolocationPlatform : IGeolocationPlatform var config = Configuration.Default.WithGeolocation(new MyGeolocationPlatform()); ``` +To expose `navigator`, include navigator registration in the configuration: + +```cs +var config = Configuration.Default + .WithNavigator() + .WithGeolocation(new MyGeolocationPlatform()); +``` + Resolve it from `INavigator`: ```cs diff --git a/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs b/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs index 4fdf99a..df9fd4e 100644 --- a/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs +++ b/src/AngleSharp.Io.Tests/Clipboard/ClipboardTests.cs @@ -1,8 +1,8 @@ namespace AngleSharp.Io.Tests.Clipboard { using AngleSharp; + using AngleSharp.Browser.Dom; using AngleSharp.Io.Dom; - using AngleSharp.Io.Tests.Mocks; using NUnit.Framework; using System; using System.Threading.Tasks; @@ -14,8 +14,8 @@ public class ClipboardTests public async Task ClipboardCanBeResolvedFromNavigatorWhenRegistered() { var platform = new FakeClipboardPlatform(); - var context = await OpenContextAsync(Configuration.Default.WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); - var navigator = new FakeNavigator(context); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); Assert.IsNotNull(navigator.Clipboard()); } @@ -24,8 +24,8 @@ public async Task ClipboardCanBeResolvedFromNavigatorWhenRegistered() public async Task ClipboardWriteTextUsesPlatformImplementation() { var platform = new FakeClipboardPlatform(); - var context = await OpenContextAsync(Configuration.Default.WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); - var navigator = new FakeNavigator(context); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); var clipboard = navigator.Clipboard(); await clipboard.WriteText("copied").ConfigureAwait(false); @@ -41,8 +41,8 @@ public async Task ClipboardReadTextUsesPlatformImplementation() CurrentText = "from-platform" }; - var context = await OpenContextAsync(Configuration.Default.WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); - var navigator = new FakeNavigator(context); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithClipboard(platform), "https://clipboard.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); var clipboard = navigator.Clipboard(); var value = await clipboard.ReadText().ConfigureAwait(false); @@ -57,6 +57,13 @@ private static async Task OpenContextAsync(IConfiguration conf return context; } + private static INavigator GetNavigator(IBrowsingContext context) + { + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; + } + private sealed class FakeClipboardPlatform : IClipboardPlatform { public String CurrentText { get; set; } = String.Empty; diff --git a/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs b/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs index c3a1fe2..2a01a1d 100644 --- a/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs +++ b/src/AngleSharp.Io.Tests/Geolocation/GeolocationTests.cs @@ -1,8 +1,8 @@ namespace AngleSharp.Io.Tests.Geolocation { using AngleSharp; + using AngleSharp.Browser.Dom; using AngleSharp.Io.Dom; - using AngleSharp.Io.Tests.Mocks; using NUnit.Framework; using System; using System.Threading.Tasks; @@ -14,8 +14,8 @@ public class GeolocationTests public async Task GeolocationCanBeResolvedFromNavigatorWhenRegistered() { var platform = new FakeGeolocationPlatform(); - var context = await OpenContextAsync(Configuration.Default.WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); - var navigator = new FakeNavigator(context); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); Assert.IsNotNull(navigator.Geolocation()); } @@ -38,8 +38,8 @@ public async Task GeolocationMapsPlatformReadingToDomTypes() } }; - var context = await OpenContextAsync(Configuration.Default.WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); - var navigator = new FakeNavigator(context); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); var geolocation = navigator.Geolocation(); var position = await geolocation.GetCurrentPosition().ConfigureAwait(false); @@ -74,8 +74,8 @@ public async Task GeolocationForwardsRequestOptionsToPlatform() MaximumAge = 200, }; - var context = await OpenContextAsync(Configuration.Default.WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); - var navigator = new FakeNavigator(context); + var context = await OpenContextAsync(Configuration.Default.WithNavigator().WithGeolocation(platform), "https://geo.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); var geolocation = navigator.Geolocation(); await geolocation.GetCurrentPosition(options).ConfigureAwait(false); @@ -93,6 +93,13 @@ private static async Task OpenContextAsync(IConfiguration conf return context; } + private static INavigator GetNavigator(IBrowsingContext context) + { + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; + } + private sealed class FakeGeolocationPlatform : IGeolocationPlatform { public GeolocationReading NextReading { get; set; } = GeolocationReading.Empty; diff --git a/src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs b/src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs deleted file mode 100644 index e8361d0..0000000 --- a/src/AngleSharp.Io.Tests/Mocks/FakeNavigator.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace AngleSharp.Io.Tests.Mocks -{ - using AngleSharp; - using AngleSharp.Browser.Dom; - using System; - using System.Collections.Generic; - using System.Linq; - - internal sealed class FakeNavigator : INavigator - { - private readonly HashSet<(String Scheme, String Url, String Title)> _protocolHandlers; - private readonly HashSet<(String MimeType, String Url, String Title)> _contentHandlers; - - public FakeNavigator(IBrowsingContext context) - { - Context = context; - _protocolHandlers = new HashSet<(String Scheme, String Url, String Title)>(); - _contentHandlers = new HashSet<(String MimeType, String Url, String Title)>(); - } - - public IBrowsingContext Context { get; } - - public String Name => "Fake Navigator"; - - public String Version => "1.0"; - - public String Platform => "test"; - - public String UserAgent => "FakeNavigator/1.0"; - - public Boolean IsOnline => true; - - public void WaitForStorageUpdates() - { - } - - public void RegisterProtocolHandler(String scheme, String url, String title) - { - _protocolHandlers.Add((scheme ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); - } - - public void RegisterContentHandler(String mimeType, String url, String title) - { - _contentHandlers.Add((mimeType ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); - } - - public Boolean IsProtocolHandlerRegistered(String scheme, String url) - { - var normalizedScheme = scheme ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - - return _protocolHandlers.Any(m => - String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } - - public Boolean IsContentHandlerRegistered(String mimeType, String url) - { - var normalizedMimeType = mimeType ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - - return _contentHandlers.Any(m => - String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } - - public void UnregisterProtocolHandler(String scheme, String url) - { - var normalizedScheme = scheme ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - _protocolHandlers.RemoveWhere(m => - String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } - - public void UnregisterContentHandler(String mimeType, String url) - { - var normalizedMimeType = mimeType ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - _contentHandlers.RemoveWhere(m => - String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } - } -} \ No newline at end of file diff --git a/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs b/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs index e10fa11..c02a184 100644 --- a/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs +++ b/src/AngleSharp.Io.Tests/WebLocks/WebLocksTests.cs @@ -2,12 +2,9 @@ namespace AngleSharp.Io.Tests.WebLocks { using AngleSharp; using AngleSharp.Browser.Dom; - using AngleSharp.Dom; using AngleSharp.Io.Dom; using NUnit.Framework; using System; - using System.Collections.Generic; - using System.Linq; using System.Threading.Tasks; [TestFixture] @@ -17,7 +14,7 @@ public class WebLocksTests public async Task NavigatorLocksCanBeCreatedFromADocumentWindow() { var context = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); - var navigator = new FakeNavigator(context); + var navigator = GetNavigator(context); Assert.IsNotNull(navigator.Locks()); } @@ -27,8 +24,8 @@ public async Task NavigatorLocksSerializeSameOriginRequests() { var contextA = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); var contextB = await OpenContextAsync("https://locks.example/other").ConfigureAwait(false); - var locksA = new FakeNavigator(contextA).Locks(); - var locksB = new FakeNavigator(contextB).Locks(); + var locksA = GetNavigator(contextA).Locks(); + var locksB = GetNavigator(contextB).Locks(); var enteredA = new TaskCompletionSource(); var enteredB = new TaskCompletionSource(); @@ -64,8 +61,8 @@ public async Task NavigatorLocksDoNotCrossOrigins() { var contextA = await OpenContextAsync("https://locks.example/").ConfigureAwait(false); var contextB = await OpenContextAsync("https://other.example/").ConfigureAwait(false); - var locksA = new FakeNavigator(contextA).Locks(); - var locksB = new FakeNavigator(contextB).Locks(); + var locksA = GetNavigator(contextA).Locks(); + var locksB = GetNavigator(contextB).Locks(); var enteredA = new TaskCompletionSource(); var enteredB = new TaskCompletionSource(); @@ -95,7 +92,7 @@ public async Task NavigatorLocksDoNotCrossOrigins() private static async Task OpenContextAsync(String address) { - var context = BrowsingContext.New(); + var context = BrowsingContext.New(Configuration.Default.WithNavigator()); var document = await context.OpenAsync(res => res.Address(address).Content("locks")).ConfigureAwait(false); @@ -104,81 +101,11 @@ private static async Task OpenContextAsync(String address) return context; } - private sealed class FakeNavigator : INavigator + private static INavigator GetNavigator(IBrowsingContext context) { - private readonly HashSet<(String Scheme, String Url, String Title)> _protocolHandlers; - private readonly HashSet<(String MimeType, String Url, String Title)> _contentHandlers; - - public FakeNavigator(IBrowsingContext context) - { - Context = context; - _protocolHandlers = new HashSet<(String Scheme, String Url, String Title)>(); - _contentHandlers = new HashSet<(String MimeType, String Url, String Title)>(); - } - - public IBrowsingContext Context { get; } - - public String Name => "Fake Navigator"; - - public String Version => "1.0"; - - public String Platform => "test"; - - public String UserAgent => "FakeNavigator/1.0"; - - public Boolean IsOnline => true; - - public void WaitForStorageUpdates() - { - } - - public void RegisterProtocolHandler(String scheme, String url, String title) - { - _protocolHandlers.Add((scheme ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); - } - - public void RegisterContentHandler(String mimeType, String url, String title) - { - _contentHandlers.Add((mimeType ?? String.Empty, url ?? String.Empty, title ?? String.Empty)); - } - - public Boolean IsProtocolHandlerRegistered(String scheme, String url) - { - var normalizedScheme = scheme ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - - return _protocolHandlers.Any(m => - String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } - - public Boolean IsContentHandlerRegistered(String mimeType, String url) - { - var normalizedMimeType = mimeType ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - - return _contentHandlers.Any(m => - String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } - - public void UnregisterProtocolHandler(String scheme, String url) - { - var normalizedScheme = scheme ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - _protocolHandlers.RemoveWhere(m => - String.Equals(m.Scheme, normalizedScheme, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } - - public void UnregisterContentHandler(String mimeType, String url) - { - var normalizedMimeType = mimeType ?? String.Empty; - var normalizedUrl = url ?? String.Empty; - _contentHandlers.RemoveWhere(m => - String.Equals(m.MimeType, normalizedMimeType, StringComparison.Ordinal) && - String.Equals(m.Url, normalizedUrl, StringComparison.Ordinal)); - } + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; } } } \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/Navigator.cs b/src/AngleSharp.Io/Dom/Navigator.cs new file mode 100644 index 0000000..27d899e --- /dev/null +++ b/src/AngleSharp.Io/Dom/Navigator.cs @@ -0,0 +1,53 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Browser.Dom; +using System; +using System.Net.NetworkInformation; + +sealed class Navigator : INavigator +{ + private readonly IBrowsingContext _context; + private readonly String _userAgent; + + public Navigator(IBrowsingContext context, NavigatorOptions options) + { + _context = context; + _userAgent = options.UserAgent; + } + + public IBrowsingContext Context => _context; + + public String Name => "Netscape"; + + public String Platform => String.Empty; + + public String UserAgent => _userAgent; + + public String Version => "1.0.0"; + + public Boolean IsContentHandlerRegistered(String mimeType, String url) => false; + + public Boolean IsProtocolHandlerRegistered(String scheme, String url) => false; + + public void RegisterContentHandler(String mimeType, String url, String title) + { + } + + public void RegisterProtocolHandler(String scheme, String url, String title) + { + } + + public void UnregisterContentHandler(String mimeType, String url) + { + } + + public void UnregisterProtocolHandler(String scheme, String url) + { + } + + public void WaitForStorageUpdates() + { + } + + public Boolean IsOnline => NetworkInterface.GetIsNetworkAvailable(); +} diff --git a/src/AngleSharp.Io/Dom/NavigatorExtensions.cs b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs index 87cbd16..3b83e14 100644 --- a/src/AngleSharp.Io/Dom/NavigatorExtensions.cs +++ b/src/AngleSharp.Io/Dom/NavigatorExtensions.cs @@ -3,7 +3,6 @@ namespace AngleSharp.Io.Dom using AngleSharp.Browser.Dom; using AngleSharp.Attributes; using System; - using System.Reflection; /// /// Defines a set of extensions for the navigator object. @@ -55,36 +54,7 @@ public static LockManager Locks(this INavigator navigator) private static AngleSharp.IBrowsingContext GetContext(INavigator navigator) { - if (navigator == null) - { - return null; - } - - var type = navigator.GetType(); - var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - var property = type.GetProperty("Context", flags); - - if (property?.GetValue(navigator) is AngleSharp.IBrowsingContext context) - { - return context; - } - - var field = type.GetField("_context", flags); - - if (field?.GetValue(navigator) is AngleSharp.IBrowsingContext fieldContext) - { - return fieldContext; - } - - field = type.GetField("context", flags); - - if (field?.GetValue(navigator) is AngleSharp.IBrowsingContext lowerContext) - { - return lowerContext; - } - - return null; + return (navigator as Navigator)?.Context; } } } \ No newline at end of file diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index db66703..a5d51a1 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -1,292 +1,317 @@ -namespace AngleSharp +namespace AngleSharp; + +using AngleSharp.Dom; +using AngleSharp.Io; +using AngleSharp.Io.Cookie; +using AngleSharp.Io.Dom; +using AngleSharp.Io.IndexedDb; +using AngleSharp.Io.GeolocationApi; +using AngleSharp.Io.Network; +using AngleSharp.Io.Storage; +using AngleSharp.Io.ClipboardApi; +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using AngleSharp.Browser.Dom; + +/// +/// Additional extensions for improved requesters. +/// +public static class IoConfigurationExtensions { - using AngleSharp.Dom; - using AngleSharp.Io; - using AngleSharp.Io.Cookie; - using AngleSharp.Io.Dom; - using AngleSharp.Io.IndexedDb; - using AngleSharp.Io.GeolocationApi; - using AngleSharp.Io.Network; - using AngleSharp.Io.Storage; - using AngleSharp.Io.ClipboardApi; - using System; - using System.IO; - using System.Linq; - using System.Net.Http; + #region Navigator /// - /// Additional extensions for improved requesters. + /// Registers the navigator service using the default implementation. /// - public static class IoConfigurationExtensions + /// The configuration to extend. + /// The new configuration. + public static IConfiguration WithNavigator(this IConfiguration configuration) { - #region Download - - /// - /// Adds capability to start a download when following some link to the - /// configuration. - /// - /// The configuration to extend. - /// - /// The callback to invoke when a download should be started. Returns true - /// to signal an interest in downloading the response, otherwise false. - /// - /// The new configuration. - public static IConfiguration WithDownload(this IConfiguration configuration, Func download) - { - var oldFactory = configuration.Services.OfType().FirstOrDefault(); - var newFactory = new DownloadFactory(oldFactory, download); - return configuration.WithDefaultLoader(new LoaderOptions - { - Filter = req => false, - }).WithOnly(newFactory); - } - - /// - /// Adds the standard download capability, i.e., when a binary or attachment - /// is received the download callback is triggered. - /// - /// The configuration to extend. - /// - /// The callback with filename and stream as parameters. The stream must be - /// disposed / cleaned up after use. - /// - /// The new configuration. - public static IConfiguration WithStandardDownload(this IConfiguration configuration, Action download) - { - var binary = new MimeType(MimeTypeNames.Binary); - return configuration.WithDownload((type, response) => - { - if (response.IsAttachment() || type == binary) - { - var fileName = response.GetAttachedFileName(); - download.Invoke(fileName, response.Content); - return true; - } - - return false; - }); - } - - #endregion - - #region Requesters - - /// - /// Adds the requesters from the AngleSharp.Io package. - /// - /// The configuration to use. - /// The new configuration. - public static IConfiguration WithRequesters(this IConfiguration configuration) => - configuration.WithRequesters(new HttpClientHandler()); - - /// - /// Adds the requesters from the AngleSharp.Io package. - /// - /// The configuration to use. - /// - /// The HTTP client handler to use for sending requests. - /// - /// The new configuration. - public static IConfiguration WithRequesters(this IConfiguration configuration, HttpClientHandler httpClientHandler) + return configuration.WithNavigator(new NavigatorOptions()); + } + + /// + /// Registers the navigator service using the default implementation. + /// + /// The configuration to extend. + /// The navigator options to use. + /// The new configuration. + public static IConfiguration WithNavigator(this IConfiguration configuration, NavigatorOptions options) + { + return configuration.WithOnly(context => new Navigator(context, options)); + } + + #endregion + + #region Download + + /// + /// Adds capability to start a download when following some link to the + /// configuration. + /// + /// The configuration to extend. + /// + /// The callback to invoke when a download should be started. Returns true + /// to signal an interest in downloading the response, otherwise false. + /// + /// The new configuration. + public static IConfiguration WithDownload(this IConfiguration configuration, Func download) + { + var oldFactory = configuration.Services.OfType().FirstOrDefault(); + var newFactory = new DownloadFactory(oldFactory, download); + return configuration.WithDefaultLoader(new LoaderOptions { - httpClientHandler.UseCookies = false; - httpClientHandler.AllowAutoRedirect = false; - return configuration.WithRequesters((HttpMessageHandler)httpClientHandler); - } - - /// - /// Adds the requesters from the AngleSharp.Io package. - /// - /// The configuration to use. - /// - /// The HTTP message handler to use for sending requests. - /// - /// The new configuration. - public static IConfiguration WithRequesters(this IConfiguration configuration, HttpMessageHandler httpMessageHandler) + Filter = req => false, + }).WithOnly(newFactory); + } + + /// + /// Adds the standard download capability, i.e., when a binary or attachment + /// is received the download callback is triggered. + /// + /// The configuration to extend. + /// + /// The callback with filename and stream as parameters. The stream must be + /// disposed / cleaned up after use. + /// + /// The new configuration. + public static IConfiguration WithStandardDownload(this IConfiguration configuration, Action download) + { + var binary = new MimeType(MimeTypeNames.Binary); + return configuration.WithDownload((type, response) => { - var httpClient = new HttpClient(httpMessageHandler); - return configuration.With(new IRequester[] + if (response.IsAttachment() || type == binary) { - new HttpClientRequester(httpClient), - new DataRequester(), - new FtpRequester(), - new FileRequester(), - new AboutRequester(), - }); - } - - /// - /// Adds the given requester to the configuration. - /// - /// The type of the requester to add. - /// The configuration to use. - /// The requester instance to add. - /// The new configuration. - public static IConfiguration WithRequester(this IConfiguration configuration, T requester) - where T : IRequester => configuration.With(requester); - - /// - /// Adds a new requester of the provided type to the configuration. - /// - /// The type of the requester to add. - /// The configuration to use. - /// The new configuration. - public static IConfiguration WithRequester(this IConfiguration configuration) - where T: IRequester, new() => configuration.WithRequester(new T()); - - #endregion - - #region Cookies - - /// - /// Registers a persistent advanced cookie container using the local file handler. - /// - /// The configuration to extend. - /// The path to the required sync file. - /// The new instance with the service. - public static IConfiguration WithPersistentCookies(this IConfiguration configuration, String syncFilePath) => - configuration.WithCookies(new LocalFileHandler(syncFilePath)); - - /// - /// Registers a non-persistent advanced cookie container using the memory-only file - /// handler. - /// - /// The configuration to extend. - /// The new instance with the service. - public static IConfiguration WithTemporaryCookies(this IConfiguration configuration) => - configuration.WithCookies(new MemoryFileHandler()); - - /// - /// Registers a non-persistent advanced cookie container using the memory-only file - /// handler. - /// Alias for WithTemporaryCookies(). - /// - /// The configuration to extend. - /// The new instance with the service. - public static IConfiguration WithCookies(this IConfiguration configuration) => - configuration.WithTemporaryCookies(); - - /// - /// Registers the advanced cookie service. - /// - /// The configuration to extend. - /// The handler for the cookie source. - /// The new instance with the service. - public static IConfiguration WithCookies(this IConfiguration configuration, ICookieFileHandler fileHandler) => - configuration.WithCookies(new AdvancedCookieProvider(fileHandler)); - - /// - /// Registers a cookie service with the given provider. - /// - /// The configuration to extend. - /// The provider for cookie interactions. - /// The new instance with the service. - public static IConfiguration WithCookies(this IConfiguration configuration, ICookieProvider provider) => - configuration.WithOnly(provider); - - #endregion - - #region Storage - - /// - /// Registers a storage provider factory. - /// - /// The configuration to extend. - /// The directory path for local storage synchronization. - /// The new instance with the service. - public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, String syncDirectoryPath) - { - var factory = new StorageProviderFactory(); - factory.EnableLocalStorage(syncDirectoryPath); - factory.EnableSessionStorage(); - return configuration.WithStorageProviderFactory(factory); - } - - /// - /// Registers a storage provider factory. - /// - /// The configuration to extend. - /// The storage provider factory to use. - /// The new instance with the service. - public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, IStorageProviderFactory factory) => - configuration.WithOnly(_ => factory); - - #endregion - - #region IndexedDb - - /// - /// Registers an IndexedDB provider factory. - /// - /// The configuration to extend. - /// The IndexedDB provider factory to use. - /// The new instance with the service. - public static IConfiguration WithIndexedDbProviderFactory(this IConfiguration configuration, IIndexedDbProviderFactory factory) => - configuration.WithOnly(_ => factory); - - #endregion - - #region Cache - - /// - /// Registers a Cache Storage provider factory. - /// - /// The configuration to extend. - /// The Cache Storage provider factory to use. - /// The new instance with the service. - public static IConfiguration WithCacheProviderFactory(this IConfiguration configuration, ICacheProviderFactory factory) => - configuration.WithOnly(_ => factory); - - #endregion - - #region Clipboard - - /// - /// Registers the clipboard service using the given platform implementation. - /// - /// The configuration to extend. - /// The platform clipboard implementation. - /// The new instance with the service. - public static IConfiguration WithClipboard(this IConfiguration configuration, IClipboardPlatform platform) - { - var factory = new ClipboardProviderFactory(platform); - return configuration.WithClipboardProviderFactory(factory); - } - - /// - /// Registers a clipboard provider factory. - /// - /// The configuration to extend. - /// The clipboard provider factory to use. - /// The new instance with the service. - public static IConfiguration WithClipboardProviderFactory(this IConfiguration configuration, IClipboardProviderFactory factory) => - configuration.WithOnly(_ => factory); - - #endregion - - #region Geolocation - - /// - /// Registers the geolocation service using the given platform implementation. - /// - /// The configuration to extend. - /// The platform geolocation implementation. - /// The new instance with the service. - public static IConfiguration WithGeolocation(this IConfiguration configuration, IGeolocationPlatform platform) + var fileName = response.GetAttachedFileName(); + download.Invoke(fileName, response.Content); + return true; + } + + return false; + }); + } + + #endregion + + #region Requesters + + /// + /// Adds the requesters from the AngleSharp.Io package. + /// + /// The configuration to use. + /// The new configuration. + public static IConfiguration WithRequesters(this IConfiguration configuration) => + configuration.WithRequesters(new HttpClientHandler()); + + /// + /// Adds the requesters from the AngleSharp.Io package. + /// + /// The configuration to use. + /// + /// The HTTP client handler to use for sending requests. + /// + /// The new configuration. + public static IConfiguration WithRequesters(this IConfiguration configuration, HttpClientHandler httpClientHandler) + { + httpClientHandler.UseCookies = false; + httpClientHandler.AllowAutoRedirect = false; + return configuration.WithRequesters((HttpMessageHandler)httpClientHandler); + } + + /// + /// Adds the requesters from the AngleSharp.Io package. + /// + /// The configuration to use. + /// + /// The HTTP message handler to use for sending requests. + /// + /// The new configuration. + public static IConfiguration WithRequesters(this IConfiguration configuration, HttpMessageHandler httpMessageHandler) + { + var httpClient = new HttpClient(httpMessageHandler); + return configuration.With(new IRequester[] { - var factory = new GeolocationProviderFactory(platform); - return configuration.WithGeolocationProviderFactory(factory); - } - - /// - /// Registers a geolocation provider factory. - /// - /// The configuration to extend. - /// The geolocation provider factory to use. - /// The new instance with the service. - public static IConfiguration WithGeolocationProviderFactory(this IConfiguration configuration, IGeolocationProviderFactory factory) => - configuration.WithOnly(_ => factory); - - #endregion + new HttpClientRequester(httpClient), + new DataRequester(), + new FtpRequester(), + new FileRequester(), + new AboutRequester(), + }); + } + + /// + /// Adds the given requester to the configuration. + /// + /// The type of the requester to add. + /// The configuration to use. + /// The requester instance to add. + /// The new configuration. + public static IConfiguration WithRequester(this IConfiguration configuration, T requester) + where T : IRequester => configuration.With(requester); + + /// + /// Adds a new requester of the provided type to the configuration. + /// + /// The type of the requester to add. + /// The configuration to use. + /// The new configuration. + public static IConfiguration WithRequester(this IConfiguration configuration) + where T: IRequester, new() => configuration.WithRequester(new T()); + + #endregion + + #region Cookies + + /// + /// Registers a persistent advanced cookie container using the local file handler. + /// + /// The configuration to extend. + /// The path to the required sync file. + /// The new instance with the service. + public static IConfiguration WithPersistentCookies(this IConfiguration configuration, String syncFilePath) => + configuration.WithCookies(new LocalFileHandler(syncFilePath)); + + /// + /// Registers a non-persistent advanced cookie container using the memory-only file + /// handler. + /// + /// The configuration to extend. + /// The new instance with the service. + public static IConfiguration WithTemporaryCookies(this IConfiguration configuration) => + configuration.WithCookies(new MemoryFileHandler()); + + /// + /// Registers a non-persistent advanced cookie container using the memory-only file + /// handler. + /// Alias for WithTemporaryCookies(). + /// + /// The configuration to extend. + /// The new instance with the service. + public static IConfiguration WithCookies(this IConfiguration configuration) => + configuration.WithTemporaryCookies(); + + /// + /// Registers the advanced cookie service. + /// + /// The configuration to extend. + /// The handler for the cookie source. + /// The new instance with the service. + public static IConfiguration WithCookies(this IConfiguration configuration, ICookieFileHandler fileHandler) => + configuration.WithCookies(new AdvancedCookieProvider(fileHandler)); + + /// + /// Registers a cookie service with the given provider. + /// + /// The configuration to extend. + /// The provider for cookie interactions. + /// The new instance with the service. + public static IConfiguration WithCookies(this IConfiguration configuration, ICookieProvider provider) => + configuration.WithOnly(provider); + + #endregion + + #region Storage + + /// + /// Registers a storage provider factory. + /// + /// The configuration to extend. + /// The directory path for local storage synchronization. + /// The new instance with the service. + public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, String syncDirectoryPath) + { + var factory = new StorageProviderFactory(); + factory.EnableLocalStorage(syncDirectoryPath); + factory.EnableSessionStorage(); + return configuration.WithStorageProviderFactory(factory); } -} \ No newline at end of file + + /// + /// Registers a storage provider factory. + /// + /// The configuration to extend. + /// The storage provider factory to use. + /// The new instance with the service. + public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, IStorageProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion + + #region IndexedDb + + /// + /// Registers an IndexedDB provider factory. + /// + /// The configuration to extend. + /// The IndexedDB provider factory to use. + /// The new instance with the service. + public static IConfiguration WithIndexedDbProviderFactory(this IConfiguration configuration, IIndexedDbProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion + + #region Cache + + /// + /// Registers a Cache Storage provider factory. + /// + /// The configuration to extend. + /// The Cache Storage provider factory to use. + /// The new instance with the service. + public static IConfiguration WithCacheProviderFactory(this IConfiguration configuration, ICacheProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion + + #region Clipboard + + /// + /// Registers the clipboard service using the given platform implementation. + /// + /// The configuration to extend. + /// The platform clipboard implementation. + /// The new instance with the service. + public static IConfiguration WithClipboard(this IConfiguration configuration, IClipboardPlatform platform) + { + var factory = new ClipboardProviderFactory(platform); + return configuration.WithClipboardProviderFactory(factory); + } + + /// + /// Registers a clipboard provider factory. + /// + /// The configuration to extend. + /// The clipboard provider factory to use. + /// The new instance with the service. + public static IConfiguration WithClipboardProviderFactory(this IConfiguration configuration, IClipboardProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion + + #region Geolocation + + /// + /// Registers the geolocation service using the given platform implementation. + /// + /// The configuration to extend. + /// The platform geolocation implementation. + /// The new instance with the service. + public static IConfiguration WithGeolocation(this IConfiguration configuration, IGeolocationPlatform platform) + { + var factory = new GeolocationProviderFactory(platform); + return configuration.WithGeolocationProviderFactory(factory); + } + + /// + /// Registers a geolocation provider factory. + /// + /// The configuration to extend. + /// The geolocation provider factory to use. + /// The new instance with the service. + public static IConfiguration WithGeolocationProviderFactory(this IConfiguration configuration, IGeolocationProviderFactory factory) => + configuration.WithOnly(_ => factory); + + #endregion +} diff --git a/src/AngleSharp.Io/NavigatorOptions.cs b/src/AngleSharp.Io/NavigatorOptions.cs new file mode 100644 index 0000000..93da7cb --- /dev/null +++ b/src/AngleSharp.Io/NavigatorOptions.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Io; + +using System; + +/// +/// Options for setting up the navigator. +/// +public sealed class NavigatorOptions +{ + /// + /// Gets or sets the user agent string to use for the navigator. + /// + public String UserAgent { get; set; } = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36 OPR/32.0.1948.19 (Edition beta)"; +} From 326a763ea937670b980cd5258c04bf3e666389c2 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 00:44:33 +0200 Subject: [PATCH 28/31] Added XmlHttpRequester --- CHANGELOG.md | 1 + .../Network/XmlHttpRequesterTests.cs | 416 +++++++++++++++ src/AngleSharp.Io/Dom/RequesterState.cs | 36 ++ src/AngleSharp.Io/Dom/XmlHttpRequest.cs | 472 ++++++++++++++++++ .../Dom/XmlHttpRequestEventTarget.cs | 102 ++++ .../Dom/XmlHttpRequestResponseType.cs | 42 ++ src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs | 11 + 7 files changed, 1080 insertions(+) create mode 100644 src/AngleSharp.Io.Tests/Network/XmlHttpRequesterTests.cs create mode 100644 src/AngleSharp.Io/Dom/RequesterState.cs create mode 100644 src/AngleSharp.Io/Dom/XmlHttpRequest.cs create mode 100644 src/AngleSharp.Io/Dom/XmlHttpRequestEventTarget.cs create mode 100644 src/AngleSharp.Io/Dom/XmlHttpRequestResponseType.cs create mode 100644 src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 16fdfac..1541747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Released on Friday, July 31 2026. - Updated to use a minimum of AngleSharp 1.5 - Fixed domain handling in cookies #36 - Added the `Navigator` from *AngleSharp.Js* +- Added the `XmlHttpRequester` from *AngleSharp.Js* - Added `localStorage` and `sessionStorage` for web storages - Added `indexedDB` using an IndexedDB implementation - Added `caches` using a Cache API implementation diff --git a/src/AngleSharp.Io.Tests/Network/XmlHttpRequesterTests.cs b/src/AngleSharp.Io.Tests/Network/XmlHttpRequesterTests.cs new file mode 100644 index 0000000..09329f3 --- /dev/null +++ b/src/AngleSharp.Io.Tests/Network/XmlHttpRequesterTests.cs @@ -0,0 +1,416 @@ +namespace AngleSharp.Io.Tests.Network +{ + using AngleSharp.Browser; + using AngleSharp.Common; + using AngleSharp.Dom; + using AngleSharp.Html; + using AngleSharp.Io; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Reflection; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class XmlHttpRequesterTests + { + [Test] + public async Task OpenAndSendShouldCreateRequestWithHeadersAndBody() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.Accepted); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + xhr.Open("POST", "https://api.example.test/endpoint"); + xhr.SetRequestHeader("X-Test", "abc"); + xhr.Send("payload"); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsNotNull(loader.LastRequest); + Assert.AreEqual(HttpMethod.Post, loader.LastRequest.Method); + Assert.AreEqual("https://api.example.test/endpoint", loader.LastRequest.Target.Href); + Assert.AreEqual("abc", loader.LastRequest.Headers["X-Test"]); + + using (var reader = new StreamReader(loader.LastRequest.Body, Encoding.UTF8, true, 1024, true)) + { + Assert.AreEqual("payload", reader.ReadToEnd()); + } + } + + [Test] + public async Task SendWithFormDataShouldSetMultipartContentTypeWhenMissing() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("foo", "bar", "text/plain"); + + xhr.Open("POST", "https://api.example.test/endpoint"); + xhr.Send(formData); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsTrue(loader.LastRequest.Headers.ContainsKey(HeaderNames.ContentType)); + Assert.IsTrue(loader.LastRequest.Headers[HeaderNames.ContentType].StartsWith("multipart/form-data; boundary=")); + } + + [Test] + public async Task SendShouldNotOverrideExplicitContentType() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("foo", "bar", "text/plain"); + + xhr.Open("POST", "https://api.example.test/endpoint"); + xhr.SetRequestHeader(HeaderNames.ContentType, "application/custom"); + xhr.Send(formData); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.AreEqual("application/custom", loader.LastRequest.Headers[HeaderNames.ContentType]); + } + + [Test] + public async Task SendShouldPopulateResponseStatusTextHeadersAndBody() + { + var response = CreateResponse("https://api.example.test/endpoint", "{\"result\":true}", HttpStatusCode.Created); + response.Headers["X-Result"] = "ok"; + + var loader = new TestDocumentLoader(request => + new TestDownload(request.Target, Task.FromResult(response))); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.AreEqual((Int32)HttpStatusCode.Created, xhr.StatusCode); + Assert.AreEqual(HttpStatusCode.Created.ToString(), xhr.StatusText); + Assert.AreEqual("https://api.example.test/endpoint", xhr.ResponseUrl); + Assert.AreEqual("{\"result\":true}", xhr.ResponseText); + Assert.AreEqual("ok", xhr.GetResponseHeader("X-Result")); + Assert.IsTrue(xhr.GetAllResponseHeaders().Contains("X-Result: ok")); + } + + [Test] + public async Task ReadyStateShouldTransitionInExpectedOrderForSuccessfulRequest() + { + var loader = new TestDocumentLoader(request => + { + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + return new TestDownload(request.Target, Task.FromResult(response)); + }); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var states = new List(); + + xhr.ReadyStateChanged += (_, __) => states.Add(xhr.ReadyState); + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + CollectionAssert.AreEqual( + new[] { RequesterState.Opened, RequesterState.HeadersReceived, RequesterState.Loading, RequesterState.Done }, + states); + } + + [Test] + public async Task SendShouldRaiseTimeoutEventWhenDownloadTaskIsCanceled() + { + var canceled = Task.FromCanceled(new CancellationToken(canceled: true)); + var loader = new TestDocumentLoader(request => new TestDownload(request.Target, canceled)); + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + + var timedOut = false; + xhr.Timedout += (_, __) => timedOut = true; + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsTrue(timedOut); + Assert.AreEqual(0, xhr.StatusCode); + } + + [Test] + public async Task SendShouldRaiseErrorEventWhenDownloadTaskFails() + { + var failed = Task.FromException(new InvalidOperationException("boom")); + var loader = new TestDocumentLoader(request => new TestDownload(request.Target, failed)); + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + + var errored = false; + xhr.Error += (_, __) => errored = true; + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.IsTrue(errored); + Assert.AreEqual(0, xhr.StatusCode); + } + + [Test] + public async Task AbortShouldCancelDownloadAndRaiseAbortEventWhenLoading() + { + var loader = new TestDocumentLoader(request => + new TestDownload(request.Target, Task.FromResult(CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK)))); + + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + var aborted = false; + xhr.Aborted += (_, __) => aborted = true; + + xhr.Open("GET", "https://api.example.test/endpoint"); + + var download = new TestDownload(Url.Create("https://api.example.test/endpoint"), Task.FromResult(CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK))); + + typeof(XmlHttpRequest).GetField("_download", BindingFlags.Instance | BindingFlags.NonPublic) + ?.SetValue(xhr, download); + + typeof(XmlHttpRequest).GetProperty("ReadyState", BindingFlags.Instance | BindingFlags.Public) + ?.SetValue(xhr, RequesterState.Loading); + + xhr.Abort(); + + Assert.IsTrue(aborted); + Assert.IsTrue(download.Canceled); + } + + [Test] + public async Task ResponseHeadersShouldBeUnavailableBeforeResponseIsReceived() + { + var pending = new TaskCompletionSource(); + var loader = new TestDocumentLoader(request => new TestDownload(request.Target, pending.Task)); + var xhr = await CreateRequesterAsync(loader, "https://origin.example.test/").ConfigureAwait(false); + + xhr.Open("GET", "https://api.example.test/endpoint"); + xhr.Send(); + + Assert.AreEqual(String.Empty, xhr.GetResponseHeader("X-Anything")); + Assert.AreEqual(String.Empty, xhr.GetAllResponseHeaders()); + + var response = CreateResponse("https://api.example.test/endpoint", "ok", HttpStatusCode.OK); + response.Headers["X-Anything"] = "value"; + pending.TrySetResult(response); + + await WaitForReadyStateAsync(xhr, RequesterState.Done).ConfigureAwait(false); + + Assert.AreEqual("value", xhr.GetResponseHeader("X-Anything")); + } + + [Test] + public void SerializeFormDataSetBodyShouldProduceMultipartContent() + { + var formData = new FormDataSet(); + formData.Append("name", "Ada Lovelace", "text/plain"); + formData.Append("role", "Mathematician", "text/plain"); + + var method = typeof(XmlHttpRequest).GetMethod("Serialize", BindingFlags.Static | BindingFlags.NonPublic); + var serialized = method.Invoke(null, new Object[] { formData }); + var serializedType = serialized.GetType(); + var content = (Stream)serializedType.GetProperty("Content").GetValue(serialized); + var contentType = (String)serializedType.GetProperty("ContentType").GetValue(serialized); + + String body; + + using (var reader = new StreamReader(content, Encoding.UTF8, true, 1024, true)) + { + body = reader.ReadToEnd(); + } + + Assert.IsTrue(body.Contains("name=\"name\""), body); + Assert.IsTrue(body.Contains("Ada Lovelace"), body); + Assert.IsTrue(body.Contains("name=\"role\""), body); + Assert.IsTrue(body.Contains("Mathematician"), body); + Assert.IsTrue(contentType.StartsWith("multipart/form-data; boundary="), contentType); + } + + [Test] + public void SerializeUrlSearchParamsShouldUseWwwFormUrlEncodedContentType() + { + var searchParams = new UrlSearchParams("a=1&b=2"); + var method = typeof(XmlHttpRequest).GetMethod("Serialize", BindingFlags.Static | BindingFlags.NonPublic); + var serialized = method.Invoke(null, new Object[] { searchParams }); + var serializedType = serialized.GetType(); + var content = (Stream)serializedType.GetProperty("Content").GetValue(serialized); + var contentType = (String)serializedType.GetProperty("ContentType").GetValue(serialized); + + String body; + + using (var reader = new StreamReader(content, Encoding.UTF8, true, 1024, true)) + { + body = reader.ReadToEnd(); + } + + Assert.AreEqual("a=1&b=2", body); + Assert.AreEqual("application/x-www-form-urlencoded; charset=UTF-8", contentType); + } + + [Test] + public void SerializeNullShouldProduceEmptyBodyWithoutContentType() + { + var method = typeof(XmlHttpRequest).GetMethod("Serialize", BindingFlags.Static | BindingFlags.NonPublic); + var serialized = method.Invoke(null, new Object[] { null }); + var serializedType = serialized.GetType(); + var content = (Stream)serializedType.GetProperty("Content").GetValue(serialized); + var contentType = (String)serializedType.GetProperty("ContentType").GetValue(serialized); + + Assert.AreEqual(String.Empty, new StreamReader(content, Encoding.UTF8, true, 1024, true).ReadToEnd()); + Assert.IsNull(contentType); + } + + private static async Task CreateRequesterAsync(IDocumentLoader loader, String address) + { + var config = Configuration.Default + .WithOnly(_ => loader) + .WithOnly(_ => new ImmediateEventLoop()); + + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(req => req.Address(address).Content("xhr")).ConfigureAwait(false); + return new XmlHttpRequest(document.DefaultView); + } + + private static async Task WaitForReadyStateAsync(XmlHttpRequest xhr, RequesterState state) + { + for (var i = 0; i < 200; i++) + { + if (xhr.ReadyState == state) + { + return; + } + + await Task.Delay(5).ConfigureAwait(false); + } + + Assert.Fail("Expected ready state {0}, but was {1}.", state, xhr.ReadyState); + } + + private static DefaultResponse CreateResponse(String address, String body, HttpStatusCode statusCode) + { + return new DefaultResponse + { + Address = Url.Create(address), + StatusCode = statusCode, + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase), + Content = new MemoryStream(Encoding.UTF8.GetBytes(body ?? String.Empty)), + }; + } + + private sealed class TestDocumentLoader : IDocumentLoader + { + private readonly Func _createDownload; + + public TestDocumentLoader(Func createDownload) + { + _createDownload = createDownload ?? throw new ArgumentNullException(nameof(createDownload)); + } + + public DocumentRequest LastRequest { get; private set; } + + public TestDownload LastDownload { get; private set; } + + public IDownload FetchAsync(DocumentRequest request) + { + LastRequest = request; + var download = _createDownload.Invoke(request); + + if (download is TestDownload testDownload) + { + LastDownload = testDownload; + } + + return download; + } + + public IEnumerable GetDownloads() + { + if (LastDownload != null) + { + yield return LastDownload; + } + } + } + + private sealed class TestDownload : IDownload + { + private readonly Task _task; + + public TestDownload(Url target, Task task, Object source = null) + { + Target = target; + Source = source; + _task = task ?? throw new ArgumentNullException(nameof(task)); + } + + public Url Target { get; } + + public Object Source { get; } + + public Task Task => _task; + + public Boolean IsCompleted => _task.IsCompleted; + + public Boolean IsRunning => !_task.IsCompleted; + + public Boolean Canceled { get; private set; } + + public void Cancel() + { + Canceled = true; + } + } + + private sealed class ImmediateEventLoop : IEventLoop + { + public ICancellable Enqueue(Action action, TaskPriority priority) + { + action(CancellationToken.None); + return new CompletedCancellable(); + } + + public void CancelAll() + { + } + + public void Spin() + { + } + } + + private sealed class CompletedCancellable : ICancellable + { + public Boolean IsCompleted => true; + + public Boolean IsRunning => false; + + public void Cancel() + { + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/RequesterState.cs b/src/AngleSharp.Io/Dom/RequesterState.cs new file mode 100644 index 0000000..d6c62a4 --- /dev/null +++ b/src/AngleSharp.Io/Dom/RequesterState.cs @@ -0,0 +1,36 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; + +/// +/// Defines the states of the requester. +/// +[DomName("XmlHttpRequest")] +public enum RequesterState : ushort +{ + /// + /// Nothing has been sent yet. + /// + [DomName("UNSENT")] + Unsent = 0, + /// + /// The communication channel is open. + /// + [DomName("OPENED")] + Opened = 1, + /// + /// The response headers have been received. + /// + [DomName("HEADERS_RECEIVED")] + HeadersReceived = 2, + /// + /// The body is still incoming. + /// + [DomName("LOADING")] + Loading = 3, + /// + /// The response has been received. + /// + [DomName("DONE")] + Done = 4 +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequest.cs b/src/AngleSharp.Io/Dom/XmlHttpRequest.cs new file mode 100644 index 0000000..574c990 --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequest.cs @@ -0,0 +1,472 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Browser; +using AngleSharp.Dom; +using AngleSharp.Dom.Events; +using AngleSharp.Html; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +/// +/// Defines the XHR. For more information see: +/// https://xhr.spec.whatwg.org/#interface-xmlhttprequest +/// +[DomName("XMLHttpRequest")] +[DomExposed("Window")] +[DomExposed("DedicatedWorker")] +[DomExposed("SharedWorker")] +public sealed class XmlHttpRequest : XmlHttpRequestEventTarget +{ + #region Fields + + private const Int32 BufferSize = 16384; + + private readonly Dictionary _headers; + private readonly IWindow _window; + + private RequesterState _readyState; + private Int32 _timeout; + private Boolean _credentials; + private HttpMethod _method; + private Url _url; + private Boolean _async; + private String _mime; + private HttpStatusCode _responseStatus; + private String _responseUrl; + private String _responseText; + private IDownload _download; + + #endregion + + #region ctor + + /// + /// Creates a new XHR. + /// + [DomConstructor] + public XmlHttpRequest(IWindow window) + { + _window = window; + _async = true; + _method = HttpMethod.Get; + _headers = new Dictionary(); + _url = null; + _mime = null; + _responseUrl = String.Empty; + _responseText = String.Empty; + _readyState = RequesterState.Unsent; + _responseStatus = (HttpStatusCode)0; + _credentials = false; + _timeout = 45000; + } + + #endregion + + #region Properties + + /// + /// Gets if response headers are accessible. + /// + public Boolean HasResponseHeaders => _readyState == RequesterState.Loading || _readyState == RequesterState.Done; + + /// + /// Adds or removes the handler for the readystatechange event. + /// + [DomName("onreadystatechange")] + public event DomEventHandler ReadyStateChanged + { + add { AddEventListener(ReadyStateChangeEvent, value, false); } + remove { RemoveEventListener(ReadyStateChangeEvent, value, false); } + } + + /// + /// Gets the current ready state. + /// + [DomName("readyState")] + public RequesterState ReadyState + { + get => _readyState; + private set + { + _readyState = value; + Fire(ReadyStateChangeEvent); + } + } + + /// + /// Gets or sets the timeout of the request in milliseconds. + /// + [DomName("timeout")] + public Int32 Timeout + { + get => _timeout; + set => _timeout = value; + } + + /// + /// Gets the associated upload process, if any. + /// + [DomName("upload")] + public XmlHttpRequestUpload Upload => null; + + /// + /// Gets or sets if credentials should be used for the request. + /// + [DomName("withCredentials")] + public Boolean WithCredentials + { + get => _credentials; + set => _credentials = value; + } + + /// + /// Gets the determined response type. + /// + [DomName("responseType")] + public XmlHttpRequestResponseType ResponseType => XmlHttpRequestResponseType.None; + + /// + /// Gets the url of the response. + /// + [DomName("responseURL")] + public String ResponseUrl => _responseUrl; + + /// + /// Gets the status code of the response. + /// + [DomName("status")] + public Int32 StatusCode => (Int32)_responseStatus; + + /// + /// Gets the status text of the response. + /// + [DomName("statusText")] + public String StatusText => StatusCode != 0 ? _responseStatus.ToString() : String.Empty; + + /// + /// Gets the resulting response object. + /// + [DomName("response")] + public Object Response => null; + + /// + /// Gets the body text of the response. + /// + [DomName("responseText")] + public String ResponseText => _responseText; + + /// + /// Gets the XML document of the response, if any. + /// + [DomName("responseXML")] + public IDocument ResponseXml => null; + + #endregion + + #region Methods + + /// + /// Aborts the request. + /// + [DomName("abort")] + public void Abort() + { + if (_readyState == RequesterState.Loading) + { + _download.Cancel(); + Fire(AbortEvent); + } + } + + /// + /// Opens a new request with the provided method and URL. + /// + /// The method to use. + /// The URL to send to request to. + /// Should the request be send async? + /// Should a username be used? + /// Should a password be used? + [DomName("open")] + public void Open(String method, String url, Boolean async = true, String username = null, String password = null) + { + if (_readyState == RequesterState.Unsent) + { + ReadyState = RequesterState.Opened; + + if (!Enum.TryParse(method, true, out _method)) + { + _method = HttpMethod.Get; + } + + _url = new Url(new Url(_window.Document.Url), url); + _async = async; + _url.UserName = username; + _url.Password = password; + } + } + + /// + /// Sends the created request with the potentially provided object. + /// + /// The body to send (e.g., for forms POST). + [DomName("send")] + public void Send(Object body = null) + { + if (_readyState == RequesterState.Opened) + { + var requestBody = Serialize(body); + + if (!String.IsNullOrEmpty(requestBody.ContentType) && !ContainsHeader(_headers, HeaderNames.ContentType)) + { + _headers[HeaderNames.ContentType] = requestBody.ContentType; + } + + var loader = GetLoader(); + + if (loader != null) + { + var request = new DocumentRequest(_url) + { + Body = requestBody.Content, + Method = _method, + MimeType = default, + Referer = _window.Document.DocumentUri, + }; + + foreach (var header in _headers) + { + request.Headers[header.Key] = header.Value; + } + + _headers.Clear(); + + Fire(LoadStartEvent); + ReadyState = RequesterState.HeadersReceived; + var connection = Receive(loader, request); + + if (!_async) + { + connection.Wait(); + } + } + } + } + + /// + /// Sets the request header. + /// + /// The name of the field. + /// The value of the field. + [DomName("setRequestHeader")] + public void SetRequestHeader(String name, String value) + { + if (_readyState == RequesterState.Opened) + { + _headers[name] = value; + } + } + + /// + /// Gets the response header. + /// + /// The name of the field. + /// The value of the field. + [DomName("getResponseHeader")] + public String GetResponseHeader(String name) + { + if (HasResponseHeaders && _headers.TryGetValue(name, out string value)) + { + return value; + } + + return String.Empty; + } + + /// + /// Gets all response headers. + /// + /// The name and values. + [DomName("getAllResponseHeaders")] + public String GetAllResponseHeaders() + { + if (HasResponseHeaders) + { + var headers = _headers; + var lines = new String[headers.Count]; + var index = 0; + + foreach (var header in headers) + { + lines[index] = String.Concat(header.Key, ": ", header.Value); + index++; + } + + return String.Join(Environment.NewLine, lines); + } + + return String.Empty; + } + + /// + /// Overrides the returned mime-type to force a specific mode. + /// + /// The mime-type to use. + [DomName("overrideMimeType")] + public void OverrideMimeType(String mime) + { + if (_readyState == RequesterState.Opened) + { + _mime = mime; + } + } + + #endregion + + #region Helpers + + private async Task Receive(IDocumentLoader loader, DocumentRequest request) + { + var eventName = ErrorEvent; + + try + { + _download = loader.FetchAsync(request); + + using (var response = await _download.Task.ConfigureAwait(false)) + { + if (response != null) + { + eventName = LoadEvent; + + foreach (var header in response.Headers) + { + _headers[header.Key] = header.Value; + } + + _responseUrl = response.Address.Href; + _responseStatus = response.StatusCode; + ReadyState = RequesterState.Loading; + + using (var ms = new MemoryStream()) + { + await response.Content.CopyToAsync(ms, BufferSize).ConfigureAwait(false); + ms.Seek(0, SeekOrigin.Begin); + + using (var reader = new StreamReader(ms)) + { + _responseText = reader.ReadToEnd(); + } + } + + Fire(LoadEndEvent); + } + + ReadyState = RequesterState.Done; + Fire(eventName); + } + } + catch (TaskCanceledException) + { + ReadyState = RequesterState.Done; + Fire(TimeoutEvent); + } + catch (Exception) + { + ReadyState = RequesterState.Done; + Fire(ErrorEvent); + } + } + + private IEventLoop GetEventLoop() => + _window?.Document?.Context.GetService(); + + private IDocumentLoader GetLoader() => + _window?.Document?.Context.GetService(); + + private static SerializedBody Serialize(Object body) + { + if (body == null) + { + return SerializedBody.Empty; + } + + switch (body) + { + case Stream stream: + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(stream); + case Byte[] bytes: + return new SerializedBody(new MemoryStream(bytes)); + case ArraySegment segment when segment.Array != null: + return new SerializedBody(new MemoryStream(segment.Array, segment.Offset, segment.Count, false)); + case FormDataSet formDataSet: + var formDataBody = formDataSet.AsMultipart(null, Encoding.UTF8); + var formDataType = String.Concat("multipart/form-data; boundary=", formDataSet.Boundary); + return new SerializedBody(formDataBody, formDataType); + case UrlSearchParams searchParams: + var query = searchParams.ToString(); + var queryBytes = Encoding.UTF8.GetBytes(query); + return new SerializedBody(new MemoryStream(queryBytes), "application/x-www-form-urlencoded; charset=UTF-8"); + case IBlob blob: + var blobBody = blob.Body; + + if (blobBody != null) + { + if (blobBody.CanSeek) + { + blobBody.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(blobBody, blob.Type); + } + + break; + } + + var content = body.ToString(); + var contentBytes = Encoding.UTF8.GetBytes(content); + return new SerializedBody(new MemoryStream(contentBytes)); + } + + private static Boolean ContainsHeader(Dictionary headers, String name) + { + foreach (var pair in headers) + { + if (String.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private readonly struct SerializedBody + { + public static readonly SerializedBody Empty = new SerializedBody(Stream.Null); + + public SerializedBody(Stream content, String contentType = null) + { + Content = content ?? Stream.Null; + ContentType = contentType; + } + + public Stream Content { get; } + + public String ContentType { get; } + } + + private void Fire(String eventName) => + GetEventLoop().Enqueue(() => Dispatch(new Event(eventName))); + + #endregion +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequestEventTarget.cs b/src/AngleSharp.Io/Dom/XmlHttpRequestEventTarget.cs new file mode 100644 index 0000000..4337bfc --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequestEventTarget.cs @@ -0,0 +1,102 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom; +using System; + +/// +/// Represents the basis for the XHR. +/// +[DomName("XMLHttpRequestEventTarget")] +[DomExposed("Window")] +[DomExposed("DedicatedWorker")] +[DomExposed("SharedWorker")] +public class XmlHttpRequestEventTarget : EventTarget +{ + #region Event Names + + internal static readonly String LoadStartEvent = "loadstart"; + internal static readonly String LoadEndEvent = "loadend"; + internal static readonly String ProgressEvent = "progress"; + internal static readonly String AbortEvent = "abort"; + internal static readonly String ErrorEvent = "error"; + internal static readonly String LoadEvent = "load"; + internal static readonly String TimeoutEvent = "timeout"; + internal static readonly String ReadyStateChangeEvent = "readystatechange"; + + #endregion + + #region Event Handlers + + /// + /// Adds or removes the handler for the loadstart event. + /// + [DomName("onloadstart")] + public event DomEventHandler Start + { + add { AddEventListener(LoadStartEvent, value, false); } + remove { RemoveEventListener(LoadStartEvent, value, false); } + } + + /// + /// Adds or removes the handler for the progress event. + /// + [DomName("onprogress")] + public event DomEventHandler Progress + { + add { AddEventListener(ProgressEvent, value, false); } + remove { RemoveEventListener(ProgressEvent, value, false); } + } + + /// + /// Adds or removes the handler for the abort event. + /// + [DomName("onabort")] + public event DomEventHandler Aborted + { + add { AddEventListener(AbortEvent, value, false); } + remove { RemoveEventListener(AbortEvent, value, false); } + } + + /// + /// Adds or removes the handler for the error event. + /// + [DomName("onerror")] + public event DomEventHandler Error + { + add { AddEventListener(ErrorEvent, value, false); } + remove { RemoveEventListener(ErrorEvent, value, false); } + } + + /// + /// Adds or removes the handler for the load event. + /// + [DomName("onload")] + public event DomEventHandler Loaded + { + add { AddEventListener(LoadEvent, value, false); } + remove { RemoveEventListener(LoadEvent, value, false); } + } + + /// + /// Adds or removes the handler for the timeout event. + /// + [DomName("ontimeout")] + public event DomEventHandler Timedout + { + add { AddEventListener(TimeoutEvent, value, false); } + remove { RemoveEventListener(TimeoutEvent, value, false); } + } + + /// + /// Adds or removes the handler for the loadend event. + /// + [DomName("onloadend")] + public event DomEventHandler End + { + add { AddEventListener(LoadEndEvent, value, false); } + remove { RemoveEventListener(LoadEndEvent, value, false); } + } + + #endregion +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequestResponseType.cs b/src/AngleSharp.Io/Dom/XmlHttpRequestResponseType.cs new file mode 100644 index 0000000..fbadf40 --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequestResponseType.cs @@ -0,0 +1,42 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; + +/// +/// The various response type options. +/// +[DomLiterals] +[DomName("XMLHttpRequestResponseType")] +public enum XmlHttpRequestResponseType +{ + /// + /// No response given. + /// + [DomName("")] + None, + /// + /// A plain array buffer. + /// + [DomName("arraybuffer")] + ArrayBuffer, + /// + /// Some binary large object. + /// + [DomName("blob")] + Blob, + /// + /// An (XML) document. + /// + [DomName("document")] + Document, + /// + /// A JSON object. + /// + [DomName("json")] + Json, + /// + /// Plain text. + /// + [DomName("text")] + Text +} diff --git a/src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs b/src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs new file mode 100644 index 0000000..c4c9618 --- /dev/null +++ b/src/AngleSharp.Io/Dom/XmlHttpRequestUpload.cs @@ -0,0 +1,11 @@ +namespace AngleSharp.Io.Dom; + +using AngleSharp.Attributes; + +/// +/// Specialization for requesting upload information. +/// +[DomName("XMLHttpRequestUpload")] +public sealed class XmlHttpRequestUpload : XmlHttpRequestEventTarget +{ +} From d84c8aab79d50de045d5f13610ce1c6fad6942fc Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 00:50:46 +0200 Subject: [PATCH 29/31] Added fetch --- CHANGELOG.md | 1 + README.md | 31 +++ docs/general/01-Basics.md | 31 +++ src/AngleSharp.Io.Tests/Network/FetchTests.cs | 186 +++++++++++++++ src/AngleSharp.Io/Dom/WindowExtensions.cs | 213 ++++++++++++++++++ 5 files changed, 462 insertions(+) create mode 100644 src/AngleSharp.Io.Tests/Network/FetchTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1541747..53d59d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Released on Friday, July 31 2026. - Fixed domain handling in cookies #36 - Added the `Navigator` from *AngleSharp.Js* - Added the `XmlHttpRequester` from *AngleSharp.Js* +- Added `fetch` for requesting resources - Added `localStorage` and `sessionStorage` for web storages - Added `indexedDB` using an IndexedDB implementation - Added `caches` using a Cache API implementation diff --git a/README.md b/README.md index 8b738b5..21362a5 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,37 @@ var geolocation = navigator.Geolocation(); var position = await geolocation.GetCurrentPosition(); ``` +### Fetch + +AngleSharp.Io also exposes a `fetch` method on `window`: + +- `fetch` delegates to the configured `IDocumentLoader` + +Configure requesters and a default loader: + +```cs +var config = Configuration.Default + .WithRequesters() + .WithDefaultLoader(); +``` + +Use fetch from a DOM window: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); + +var response = await document.DefaultView.Fetch("/api/data", new FetchOptions +{ + Method = "POST", + Headers = new Dictionary + { + ["X-Requested-With"] = "AngleSharp.Io" + }, + Body = "payload" +}); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index d98929d..0f114ad 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -288,6 +288,37 @@ var geolocation = navigator.Geolocation(); var position = await geolocation.GetCurrentPosition(); ``` +### Fetch + +AngleSharp.Io also exposes a `fetch` method on `window`: + +- `fetch` delegates to the configured `IDocumentLoader` + +Configure requesters and a default loader: + +```cs +var config = Configuration.Default + .WithRequesters() + .WithDefaultLoader(); +``` + +Use fetch from a DOM window: + +```cs +var context = BrowsingContext.New(config); +var document = await context.OpenAsync("https://example.org"); + +var response = await document.DefaultView.Fetch("/api/data", new FetchOptions +{ + Method = "POST", + Headers = new Dictionary + { + ["X-Requested-With"] = "AngleSharp.Io" + }, + Body = "payload" +}); +``` + ### Downloads AngleSharp.Io offers you the possibility of a simplified downloading experience. Just use `WithStandardDownload` to redirect resources to a callback. diff --git a/src/AngleSharp.Io.Tests/Network/FetchTests.cs b/src/AngleSharp.Io.Tests/Network/FetchTests.cs new file mode 100644 index 0000000..c22c362 --- /dev/null +++ b/src/AngleSharp.Io.Tests/Network/FetchTests.cs @@ -0,0 +1,186 @@ +namespace AngleSharp.Io.Tests.Network +{ + using AngleSharp; + using AngleSharp.Dom; + using AngleSharp.Html; + using AngleSharp.Io; + using AngleSharp.Io.Dom; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class FetchTests + { + [Test] + public async Task FetchShouldUseGetByDefaultAndResolveRelativeUrl() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/api/data", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var response = await window.Fetch("/api/data").ConfigureAwait(false); + + Assert.IsNotNull(response); + Assert.IsNotNull(loader.LastRequest); + Assert.AreEqual(HttpMethod.Get, loader.LastRequest.Method); + Assert.AreEqual("https://example.org/api/data", loader.LastRequest.Target.Href); + } + + [Test] + public async Task FetchShouldApplyMethodHeadersAndBody() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/api", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var options = new FetchOptions + { + Method = "POST", + Headers = new Dictionary + { + ["X-Test"] = "abc" + }, + Body = "payload", + }; + + await window.Fetch("/api", options).ConfigureAwait(false); + + Assert.AreEqual(HttpMethod.Post, loader.LastRequest.Method); + Assert.AreEqual("abc", loader.LastRequest.Headers["X-Test"]); + + using (var reader = new StreamReader(loader.LastRequest.Body, Encoding.UTF8, true, 1024, true)) + { + Assert.AreEqual("payload", reader.ReadToEnd()); + } + } + + [Test] + public async Task FetchShouldSetMultipartContentTypeForFormDataWhenMissing() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/form", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("name", "Ada", "text/plain"); + + await window.Fetch("/form", new FetchOptions + { + Method = "POST", + Body = formData, + }).ConfigureAwait(false); + + Assert.IsTrue(loader.LastRequest.Headers.ContainsKey(HeaderNames.ContentType)); + Assert.IsTrue(loader.LastRequest.Headers[HeaderNames.ContentType].StartsWith("multipart/form-data; boundary=")); + } + + [Test] + public async Task FetchShouldNotOverrideExplicitContentType() + { + var loader = new TestDocumentLoader(request => + new TestDownload(Task.FromResult(CreateResponse("https://example.org/form", "ok")))); + + var window = await OpenWindowAsync(loader, "https://example.org/base/page").ConfigureAwait(false); + var formData = new FormDataSet(); + formData.Append("name", "Ada", "text/plain"); + + await window.Fetch("/form", new FetchOptions + { + Method = "POST", + Body = formData, + Headers = new Dictionary + { + [HeaderNames.ContentType] = "application/custom" + } + }).ConfigureAwait(false); + + Assert.AreEqual("application/custom", loader.LastRequest.Headers[HeaderNames.ContentType]); + } + + [Test] + public void FetchShouldThrowWhenWindowIsNull() + { + Assert.ThrowsAsync(async () => await WindowExtensions.Fetch(null, "https://example.org")); + } + + [Test] + public async Task FetchShouldThrowWhenLoaderIsMissing() + { + var context = BrowsingContext.New(); + var document = await context.OpenAsync(req => req.Address("https://example.org/").Content("fetch")).ConfigureAwait(false); + + Assert.ThrowsAsync(async () => await document.DefaultView.Fetch("https://example.org/any").ConfigureAwait(false)); + } + + private static async Task OpenWindowAsync(IDocumentLoader loader, String address) + { + var config = Configuration.Default.WithOnly(_ => loader); + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(req => req.Address(address).Content("fetch")).ConfigureAwait(false); + return document.DefaultView; + } + + private static DefaultResponse CreateResponse(String address, String body) + { + return new DefaultResponse + { + Address = Url.Create(address), + StatusCode = System.Net.HttpStatusCode.OK, + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase), + Content = new MemoryStream(Encoding.UTF8.GetBytes(body ?? String.Empty)), + }; + } + + private sealed class TestDocumentLoader : IDocumentLoader + { + private readonly Func _createDownload; + + public TestDocumentLoader(Func createDownload) + { + _createDownload = createDownload; + } + + public DocumentRequest LastRequest { get; private set; } + + public IDownload FetchAsync(DocumentRequest request) + { + LastRequest = request; + return _createDownload.Invoke(request); + } + + public IEnumerable GetDownloads() + { + yield break; + } + } + + private sealed class TestDownload : IDownload + { + private readonly Task _task; + + public TestDownload(Task task) + { + _task = task; + Target = Url.Create("https://example.org/"); + } + + public Url Target { get; } + + public Object Source => null; + + public Task Task => _task; + + public Boolean IsCompleted => _task.IsCompleted; + + public Boolean IsRunning => !_task.IsCompleted; + + public void Cancel() + { + } + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Io/Dom/WindowExtensions.cs b/src/AngleSharp.Io/Dom/WindowExtensions.cs index f6bcd22..a29f8d6 100644 --- a/src/AngleSharp.Io/Dom/WindowExtensions.cs +++ b/src/AngleSharp.Io/Dom/WindowExtensions.cs @@ -1,8 +1,15 @@ namespace AngleSharp.Io.Dom { using AngleSharp; + using AngleSharp.Html; + using AngleSharp.Io; using AngleSharp.Dom; using AngleSharp.Attributes; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading.Tasks; /// /// Defines a set of extensions for the window object. @@ -53,5 +60,211 @@ public static ICacheStorage Caches(this IWindow window) var factory = window?.Document?.Context?.GetService(); return factory?.GetCaches(window); } + + /// + /// Performs a request using the configured document loader. + /// + /// The originating window. + /// The request URL. + /// The fetched response. + [DomName("fetch")] + public static Task Fetch(this IWindow window, String url) + { + return Fetch(window, url, null); + } + + /// + /// Performs a request using the configured document loader. + /// + /// The originating window. + /// The request URL. + /// The optional request options. + /// The fetched response. + [DomName("fetch")] + public static async Task Fetch(this IWindow window, String url, FetchOptions options) + { + if (window == null) + { + throw new ArgumentNullException(nameof(window)); + } + + if (String.IsNullOrEmpty(url)) + { + throw new ArgumentNullException(nameof(url)); + } + + var context = window.Document?.Context; + var loader = context?.GetService(); + + if (loader == null) + { + throw new InvalidOperationException("No document loader service is registered."); + } + + var absoluteUrl = new Url(new Url(window.Document.Url), url); + var request = CreateRequest(window, absoluteUrl, options); + var download = loader.FetchAsync(request); + + return await download.Task.ConfigureAwait(false); + } + + private static DocumentRequest CreateRequest(IWindow window, Url url, FetchOptions options) + { + var requestOptions = options ?? new FetchOptions(); + var serializedBody = SerializeBody(requestOptions.Body); + var request = new DocumentRequest(url) + { + Body = serializedBody.Content, + Method = ParseMethod(requestOptions.Method), + MimeType = requestOptions.MimeType, + Referer = window.Document?.DocumentUri, + }; + + if (requestOptions.Headers != null) + { + foreach (var pair in requestOptions.Headers) + { + request.Headers[pair.Key] = pair.Value; + } + } + + if (!String.IsNullOrEmpty(serializedBody.ContentType) && !HasHeaderValue(request.Headers, HeaderNames.ContentType)) + { + request.Headers[HeaderNames.ContentType] = serializedBody.ContentType; + } + + return request; + } + + private static HttpMethod ParseMethod(String method) + { + if (String.IsNullOrEmpty(method)) + { + return HttpMethod.Get; + } + + return Enum.TryParse(method, true, out HttpMethod parsed) ? parsed : HttpMethod.Get; + } + + private static SerializedBody SerializeBody(Object body) + { + if (body == null) + { + return SerializedBody.Empty; + } + + switch (body) + { + case Stream stream: + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(stream); + case Byte[] bytes: + return new SerializedBody(new MemoryStream(bytes)); + case ArraySegment segment when segment.Array != null: + return new SerializedBody(new MemoryStream(segment.Array, segment.Offset, segment.Count, false)); + case FormDataSet formDataSet: + var formDataBody = formDataSet.AsMultipart(null, Encoding.UTF8); + var formDataType = String.Concat("multipart/form-data; boundary=", formDataSet.Boundary); + return new SerializedBody(formDataBody, formDataType); + case UrlSearchParams searchParams: + var query = searchParams.ToString(); + var queryBytes = Encoding.UTF8.GetBytes(query); + return new SerializedBody(new MemoryStream(queryBytes), "application/x-www-form-urlencoded; charset=UTF-8"); + case IBlob blob: + var blobBody = blob.Body; + + if (blobBody != null) + { + if (blobBody.CanSeek) + { + blobBody.Seek(0, SeekOrigin.Begin); + } + + return new SerializedBody(blobBody, blob.Type); + } + + break; + } + + var content = body.ToString(); + var contentBytes = Encoding.UTF8.GetBytes(content); + return new SerializedBody(new MemoryStream(contentBytes)); + } + + private static Boolean ContainsHeader(Dictionary headers, String name) + { + foreach (var pair in headers) + { + if (String.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static Boolean HasHeaderValue(Dictionary headers, String name) + { + foreach (var pair in headers) + { + if (String.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return !String.IsNullOrEmpty(pair.Value); + } + } + + return false; + } + + private readonly struct SerializedBody + { + public static readonly SerializedBody Empty = new SerializedBody(Stream.Null); + + public SerializedBody(Stream content, String contentType = null) + { + Content = content ?? Stream.Null; + ContentType = contentType; + } + + public Stream Content { get; } + + public String ContentType { get; } + } + } + + /// + /// Represents request options for fetch. + /// + [DomName("RequestInit")] + public sealed class FetchOptions + { + /// + /// Gets or sets the HTTP method. + /// + [DomName("method")] + public String Method { get; set; } + + /// + /// Gets or sets custom request headers. + /// + [DomName("headers")] + public Dictionary Headers { get; set; } + + /// + /// Gets or sets the request body. + /// + [DomName("body")] + public Object Body { get; set; } + + /// + /// Gets or sets an explicit mime type for the request. + /// + [DomName("mimeType")] + public String MimeType { get; set; } } } From e47637d843c4dc2d9ae4367ef6a034b662f73b9d Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 02:24:51 +0200 Subject: [PATCH 30/31] Improved configuration --- README.md | 20 +++ docs/general/01-Basics.md | 20 +++ docs/tutorials/03-Common-Workflows.md | 31 ++++- .../IoConfigurationExtensionsTests.cs | 118 ++++++++++++++++++ .../Cache/CacheProviderFactory.cs | 10 ++ .../IndexedDb/IndexedDbProviderFactory.cs | 10 ++ .../IoConfigurationExtensions.cs | 56 +++++++-- src/AngleSharp.Io/IoOptions.cs | 56 +++++++++ .../Storage/StorageProviderFactory.cs | 25 ++++ 9 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 src/AngleSharp.Io.Tests/IoConfigurationExtensionsTests.cs create mode 100644 src/AngleSharp.Io/IoOptions.cs diff --git a/README.md b/README.md index 21362a5..cc8af01 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,26 @@ AngleSharp.Io extends AngleSharp with powerful requesters, caching mechanisms, a ## Basic Configuration +### All-In-One Setup + +If you want to register all core AngleSharp.Io services in one step, use `WithIo` with `IoOptions`: + +```cs +var options = new IoOptions +{ + // optional; defaults are already provided + ClipboardPlatform = myClipboardPlatform, + GeolocationPlatform = myGeolocationPlatform, +}; + +var config = Configuration.Default + .WithIo(options) + .WithDefaultLoader(); +``` + +`WithIo` wires navigator, cookies, requesters, storage, IndexedDB, and cache in one call. +Clipboard and geolocation are only enabled when platforms are provided. + ### Requesters If you just want to use *all* available requesters provided by AngleSharp.Io you can do the following: diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 0f114ad..7a4d4aa 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -30,6 +30,26 @@ You can also use the graphical library package manager ("Manage NuGet Packages f To use AngleSharp.Io you need to add it to your `Configuration` coming from AngleSharp itself. +### All-In-One Setup + +If you want to register all core AngleSharp.Io services in one step, use `WithIo` with `IoOptions`: + +```cs +var options = new IoOptions +{ + // optional; defaults are already provided + ClipboardPlatform = myClipboardPlatform, + GeolocationPlatform = myGeolocationPlatform, +}; + +var config = Configuration.Default + .WithIo(options) + .WithDefaultLoader(); +``` + +`WithIo` wires navigator, cookies, requesters, storage, IndexedDB, and cache in one call. +Clipboard and geolocation are only enabled when platforms are provided. + ### Requesters If you just want to use *all* available requesters provided by AngleSharp.Io you can do the following: diff --git a/docs/tutorials/03-Common-Workflows.md b/docs/tutorials/03-Common-Workflows.md index 925c11c..a54b0d6 100644 --- a/docs/tutorials/03-Common-Workflows.md +++ b/docs/tutorials/03-Common-Workflows.md @@ -6,7 +6,28 @@ section: "AngleSharp.Io" This tutorial collects practical, copy-paste-friendly workflows for common AngleSharp.Io scenarios. -## 1. Use custom HttpClient behavior with requesters +## 1. Register all core Io services in one call + +Use this when you want a compact setup with sensible defaults and optional platform integrations. + +```cs +var options = new IoOptions +{ + ClipboardPlatform = myClipboardPlatform, // optional + GeolocationPlatform = myGeolocationPlatform, // optional +}; + +var config = Configuration.Default + .WithIo(options) + .WithDefaultLoader(); +``` + +Why this helps: + +- You configure navigator, cookies, requesters, storage, IndexedDB, and cache together. +- You can still override any dependency through `IoOptions`. + +## 2. Use custom HttpClient behavior with requesters Use this when you need proxying, custom decompression, headers, or controlled redirect behavior. @@ -34,7 +55,7 @@ Why this helps: - You keep a single place for transport behavior. - You still use AngleSharp's standard loading pipeline. -## 2. Persist cookies across runs +## 3. Persist cookies across runs Use this for crawl sessions or login flows that should survive process restarts. @@ -54,7 +75,7 @@ await context.OpenAsync("https://httpbingo.org/cookies/set?k1=v1"); If you only need in-memory cookies for a single run, use `WithTemporaryCookies()`. -## 3. Simulate file upload input in tests +## 4. Simulate file upload input in tests Use this when you need to test form/file handling without manual browser interaction. @@ -74,7 +95,7 @@ Alternative: - `AppendFile("/absolute/path/to/avatar.png")` to attach directly from disk. -## 4. Download linked resources directly +## 5. Download linked resources directly Use this if you want resource stream access from an anchor/link-like element. @@ -94,7 +115,7 @@ await response.Content.CopyToAsync(target); You can also combine this with `WithStandardDownload(...)` if you want callback-based download routing. -## 5. Open a WebSocket from a DOM context +## 6. Open a WebSocket from a DOM context Use this for integration tests or headless workflows that need live message channels. diff --git a/src/AngleSharp.Io.Tests/IoConfigurationExtensionsTests.cs b/src/AngleSharp.Io.Tests/IoConfigurationExtensionsTests.cs new file mode 100644 index 0000000..71b4a32 --- /dev/null +++ b/src/AngleSharp.Io.Tests/IoConfigurationExtensionsTests.cs @@ -0,0 +1,118 @@ +namespace AngleSharp.Io.Tests +{ + using AngleSharp.Browser.Dom; + using AngleSharp.Io.Cache; + using AngleSharp.Io.Cookie; + using AngleSharp.Io.Dom; + using AngleSharp.Io.IndexedDb; + using AngleSharp.Io.Storage; + using NUnit.Framework; + using System; + using System.Linq; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public sealed class IoConfigurationExtensionsTests + { + [Test] + public void WithIoRegistersCoreServices() + { + var options = new IoOptions + { + CookieHandler = new MemoryFileHandler(), + HttpHandler = new HttpClientHandler(), + StorageFactory = StorageProviderFactory.CreateTemporary(), + IndexedDbFactory = IndexedDbProviderFactory.CreateTemporary(), + CacheFactory = CacheProviderFactory.CreateTemporary(), + }; + + var configuration = Configuration.Default.WithIo(options); + var requesters = configuration.Services.OfType().ToArray(); + + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.AreSame(options.StorageFactory, configuration.Services.OfType().FirstOrDefault()); + Assert.AreSame(options.IndexedDbFactory, configuration.Services.OfType().FirstOrDefault()); + Assert.AreSame(options.CacheFactory, configuration.Services.OfType().FirstOrDefault()); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("http"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("data"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("ftp"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("file"))); + Assert.IsTrue(requesters.Any(m => m.SupportsProtocol("about"))); + Assert.AreEqual(false, options.HttpHandler.UseCookies); + Assert.AreEqual(false, options.HttpHandler.AllowAutoRedirect); + } + + [Test] + public void WithIoAcceptsNullOptions() + { + var configuration = Configuration.Default.WithIo(null); + + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + Assert.IsNotNull(configuration.Services.OfType().FirstOrDefault()); + } + + [Test] + public async Task WithIoRegistersOptionalNavigatorApisWhenPlatformsAreProvided() + { + var options = new IoOptions + { + ClipboardPlatform = new FakeClipboardPlatform(), + GeolocationPlatform = new FakeGeolocationPlatform(), + }; + + var context = await OpenContextAsync(Configuration.Default.WithIo(options), "https://io.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + + Assert.IsNotNull(navigator.Clipboard()); + Assert.IsNotNull(navigator.Geolocation()); + } + + [Test] + public async Task WithIoLeavesOptionalNavigatorApisUnavailableByDefault() + { + var context = await OpenContextAsync(Configuration.Default.WithIo(new IoOptions()), "https://io.example/").ConfigureAwait(false); + var navigator = GetNavigator(context); + + Assert.IsNull(navigator.Clipboard()); + Assert.IsNull(navigator.Geolocation()); + } + + private static async Task OpenContextAsync(IConfiguration configuration, String address) + { + var context = BrowsingContext.New(configuration); + await context.OpenAsync(res => res.Address(address).Content("with-io")).ConfigureAwait(false); + return context; + } + + private static INavigator GetNavigator(IBrowsingContext context) + { + var navigator = context?.Active?.DefaultView?.Navigator; + Assert.IsNotNull(navigator); + return navigator; + } + + private sealed class FakeClipboardPlatform : IClipboardPlatform + { + public Task ReadTextAsync() + { + return Task.FromResult(String.Empty); + } + + public Task WriteTextAsync(String text) + { + return Task.CompletedTask; + } + } + + private sealed class FakeGeolocationPlatform : IGeolocationPlatform + { + public Task GetCurrentPositionAsync(GeolocationOptions options) + { + return Task.FromResult(GeolocationReading.Empty); + } + } + } +} diff --git a/src/AngleSharp.Io/Cache/CacheProviderFactory.cs b/src/AngleSharp.Io/Cache/CacheProviderFactory.cs index 41d9774..60bbff1 100644 --- a/src/AngleSharp.Io/Cache/CacheProviderFactory.cs +++ b/src/AngleSharp.Io/Cache/CacheProviderFactory.cs @@ -17,6 +17,16 @@ public class CacheProviderFactory : ICacheProviderFactory private readonly ConditionalWeakTable _storages; private readonly Dictionary _bucketsByOriginAndName; + /// + /// Creates a new Cache Storage provider factory with temporary local storage and session storage enabled. + /// + /// The new Cache Storage provider factory. + public static ICacheProviderFactory CreateTemporary() + { + var factory = new CacheProviderFactory(); + return factory; + } + /// /// Creates a new Cache Storage provider factory. /// diff --git a/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs index 08e3879..bcf913a 100644 --- a/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs +++ b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs @@ -15,6 +15,16 @@ public class IndexedDbProviderFactory : IIndexedDbProviderFactory private readonly ConditionalWeakTable _factories; private readonly Dictionary _databasesByOriginAndName; + /// + /// Creates a new IndexedDB provider factory with temporary local storage and session storage enabled. + /// + /// The new IndexedDB provider factory. + public static IIndexedDbProviderFactory CreateTemporary() + { + var factory = new IndexedDbProviderFactory(); + return factory; + } + /// /// Creates a new IndexedDB provider factory. /// diff --git a/src/AngleSharp.Io/IoConfigurationExtensions.cs b/src/AngleSharp.Io/IoConfigurationExtensions.cs index a5d51a1..a7a4c43 100644 --- a/src/AngleSharp.Io/IoConfigurationExtensions.cs +++ b/src/AngleSharp.Io/IoConfigurationExtensions.cs @@ -20,6 +20,34 @@ namespace AngleSharp; /// public static class IoConfigurationExtensions { + #region Combined + + /// + /// Registers the IO services all in one go. + /// + /// The configuration to extend. + /// The options to use. + /// The new configuration. + public static IConfiguration WithIo(this IConfiguration configuration, IoOptions options) + { + if (options == null) + { + options = new IoOptions(); + } + + return configuration + .WithNavigator(options.Navigator) + .WithCookies(options.CookieHandler) + .WithRequesters(options.HttpHandler) + .WithStorageProviderFactory(options.StorageFactory) + .WithIndexedDbProviderFactory(options.IndexedDbFactory) + .WithCacheProviderFactory(options.CacheFactory) + .WithClipboard(options.ClipboardPlatform) + .WithGeolocation(options.GeolocationPlatform); + } + + #endregion + #region Navigator /// @@ -235,7 +263,7 @@ public static IConfiguration WithStorageProviderFactory(this IConfiguration conf /// The storage provider factory to use. /// The new instance with the service. public static IConfiguration WithStorageProviderFactory(this IConfiguration configuration, IStorageProviderFactory factory) => - configuration.WithOnly(_ => factory); + configuration.WithOnly(factory); #endregion @@ -248,7 +276,7 @@ public static IConfiguration WithStorageProviderFactory(this IConfiguration conf /// The IndexedDB provider factory to use. /// The new instance with the service. public static IConfiguration WithIndexedDbProviderFactory(this IConfiguration configuration, IIndexedDbProviderFactory factory) => - configuration.WithOnly(_ => factory); + configuration.WithOnly(factory); #endregion @@ -261,7 +289,7 @@ public static IConfiguration WithIndexedDbProviderFactory(this IConfiguration co /// The Cache Storage provider factory to use. /// The new instance with the service. public static IConfiguration WithCacheProviderFactory(this IConfiguration configuration, ICacheProviderFactory factory) => - configuration.WithOnly(_ => factory); + configuration.WithOnly(factory); #endregion @@ -275,8 +303,13 @@ public static IConfiguration WithCacheProviderFactory(this IConfiguration config /// The new instance with the service. public static IConfiguration WithClipboard(this IConfiguration configuration, IClipboardPlatform platform) { - var factory = new ClipboardProviderFactory(platform); - return configuration.WithClipboardProviderFactory(factory); + if (platform is not null) + { + var factory = new ClipboardProviderFactory(platform); + return configuration.WithClipboardProviderFactory(factory); + } + + return configuration; } /// @@ -286,7 +319,7 @@ public static IConfiguration WithClipboard(this IConfiguration configuration, IC /// The clipboard provider factory to use. /// The new instance with the service. public static IConfiguration WithClipboardProviderFactory(this IConfiguration configuration, IClipboardProviderFactory factory) => - configuration.WithOnly(_ => factory); + configuration.WithOnly(factory); #endregion @@ -300,8 +333,13 @@ public static IConfiguration WithClipboardProviderFactory(this IConfiguration co /// The new instance with the service. public static IConfiguration WithGeolocation(this IConfiguration configuration, IGeolocationPlatform platform) { - var factory = new GeolocationProviderFactory(platform); - return configuration.WithGeolocationProviderFactory(factory); + if (platform is not null) + { + var factory = new GeolocationProviderFactory(platform); + return configuration.WithGeolocationProviderFactory(factory); + } + + return configuration; } /// @@ -311,7 +349,7 @@ public static IConfiguration WithGeolocation(this IConfiguration configuration, /// The geolocation provider factory to use. /// The new instance with the service. public static IConfiguration WithGeolocationProviderFactory(this IConfiguration configuration, IGeolocationProviderFactory factory) => - configuration.WithOnly(_ => factory); + configuration.WithOnly(factory); #endregion } diff --git a/src/AngleSharp.Io/IoOptions.cs b/src/AngleSharp.Io/IoOptions.cs new file mode 100644 index 0000000..ce4017f --- /dev/null +++ b/src/AngleSharp.Io/IoOptions.cs @@ -0,0 +1,56 @@ +namespace AngleSharp.Io; + +using System.Net.Http; +using AngleSharp.Io.Cache; +using AngleSharp.Io.Cookie; +using AngleSharp.Io.Dom; +using AngleSharp.Io.IndexedDb; +using AngleSharp.Io.Storage; + +/// +/// Options for setting up the Io services. +/// +public sealed class IoOptions +{ + /// + /// Gets or sets the options for configuring the navigator. + /// + public NavigatorOptions Navigator { get; set; } = new NavigatorOptions(); + + /// + /// Gets or sets the handler for configuring the cookie jar. + /// + public ICookieFileHandler CookieHandler { get; set; } = new MemoryFileHandler(); + + /// + /// Gets or sets the handler for configuring the HTTP requester. + /// + public HttpClientHandler HttpHandler { get; set; } = new HttpClientHandler(); + + /// + /// Gets or sets the provider for configuring the web storage provider. + /// + public IStorageProviderFactory StorageFactory { get; set; } = StorageProviderFactory.CreateTemporary(); + + /// + /// Gets or sets the provider for configuring the storage provider. + /// + public IIndexedDbProviderFactory IndexedDbFactory { get; set; } = IndexedDbProviderFactory.CreateTemporary(); + + /// + /// Gets or sets the provider for configuring the cache storage provider. + /// + public ICacheProviderFactory CacheFactory { get; set; } = CacheProviderFactory.CreateTemporary(); + + /// + /// Gets or sets the provider for configuring the clipboard platform. + /// By default, this is null, which means that the clipboard API will not be available. + /// + public IClipboardPlatform ClipboardPlatform { get; set; } = null; + + /// + /// Gets or sets the provider for configuring the geolocation platform. + /// By default, this is null, which means that the geolocation API will not be available. + /// + public IGeolocationPlatform GeolocationPlatform { get; set; } = null; +} diff --git a/src/AngleSharp.Io/Storage/StorageProviderFactory.cs b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs index 3bf8335..43cb6c7 100644 --- a/src/AngleSharp.Io/Storage/StorageProviderFactory.cs +++ b/src/AngleSharp.Io/Storage/StorageProviderFactory.cs @@ -22,6 +22,31 @@ public class StorageProviderFactory : IStorageProviderFactory private Boolean _hasLocalStorage; private Boolean _hasSessionStorage; + /// + /// Creates a new storage provider factory with temporary local storage and session storage enabled. + /// + /// The new storage provider factory. + public static IStorageProviderFactory CreateTemporary() + { + var factory = new StorageProviderFactory(); + factory.EnableTemporaryLocalStorage(); + factory.EnableSessionStorage(); + return factory; + } + + /// + /// Creates a new storage provider factory with persistent local storage and session storage enabled. + /// + /// The path used for local storage synchronization. + /// The new storage provider factory. + public static IStorageProviderFactory CreatePersistent(String syncPath) + { + var factory = new StorageProviderFactory(); + factory.EnableLocalStorage(syncPath); + factory.EnableSessionStorage(); + return factory; + } + /// /// Creates a new storage provider factory. /// From 18635d36dba7fbba4f176be37cb8efe8904e166b Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 02:44:05 +0200 Subject: [PATCH 31/31] Improved IndexedDB implementation --- README.md | 71 +- docs/general/01-Basics.md | 71 +- .../IndexedDb/IndexedDbTests.cs | 650 +++++++++ .../IndexedDb/IndexedDbProviderFactory.cs | 1282 ++++++++++++++++- .../Interfaces/IIndexedDbCursor.cs | 48 + .../Interfaces/IIndexedDbDatabase.cs | 41 + .../Interfaces/IIndexedDbFactory.cs | 76 + .../Interfaces/IIndexedDbIndex.cs | 80 + .../Interfaces/IIndexedDbObjectStore.cs | 98 ++ .../Interfaces/IIndexedDbRequest.cs | 91 ++ .../Interfaces/IIndexedDbTransaction.cs | 82 ++ .../IIndexedDbUpgradeTransaction.cs | 49 + .../Interfaces/IndexedDbKeyRange.cs | 100 ++ 13 files changed, 2702 insertions(+), 37 deletions(-) create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbCursor.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbIndex.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbRequest.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbTransaction.cs create mode 100644 src/AngleSharp.Io/Interfaces/IIndexedDbUpgradeTransaction.cs create mode 100644 src/AngleSharp.Io/Interfaces/IndexedDbKeyRange.cs diff --git a/README.md b/README.md index cc8af01..9bb7dd8 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,22 @@ sessionStorage["ephemeral"] = "42"; ### IndexedDB -AngleSharp.Io also provides a lightweight IndexedDB surface that is shared per origin: +AngleSharp.Io provides an in-memory IndexedDB surface that is shared per origin: - `indexedDB` using in-memory databases and object stores scoped to the origin +- versioned open with upgrade callback support +- explicit transactions (`ReadOnly` / `ReadWrite`) with commit and abort +- request-style open wrappers (`OpenRequest(...)`) +- object store record methods (`Add`, `Put`, `Get`, `Delete`, `Count`) +- key-range queries (`IndexedDbKeyRange`) +- indexes with optional uniqueness (`CreateIndex`, `GetIndex`, `DeleteIndex`) +- cursors for store and index iteration (`OpenCursor`) +- request state (`Pending` / `Success` / `Error` / `Blocked`) and close lifecycle +- version-change notifications on open connections +- optional auto-unblock open requests (`OpenRequest(..., waitForUnblock: true)`) +- request-based delete with optional auto-unblock (`DeleteDatabaseRequest(..., waitForUnblock: true)`) +- transaction lifecycle state/events (`IsCompleted`, `IsAborted`, `Completed`, `Aborted`) +- single active read-write transaction per database To configure IndexedDB, create and register an `IIndexedDbProviderFactory`: @@ -145,10 +158,60 @@ Once configured, IndexedDB services can be resolved from the browsing context: ```cs var context = BrowsingContext.New(config); var document = await context.OpenAsync("https://example.org"); -var database = document.DefaultView.IndexedDb().Open("app"); -var store = database.CreateObjectStore("records"); +var database = document.DefaultView.IndexedDb().Open("app", 1, tx => +{ + tx.CreateObjectStore("records"); +}); + +using (var transaction = database.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + var store = transaction.GetObjectStore("records"); + var byType = store.CreateIndex("byType", "type"); + + store.Add("token", "abc"); + store.Put("token", "updated"); + + var notes = byType.GetAll("note"); + var range = store.GetAll(IndexedDbKeyRange.Bound("a", "m")); + + transaction.Commit(); +} + +var openRequest = document.DefaultView.IndexedDb().OpenRequest("app", 2, tx => +{ + tx.CreateObjectStore("events"); +}); -store["token"] = "abc"; +var upgraded = await openRequest.WaitAsync(); + +var cursor = byType.OpenCursor(IndexedDbKeyRange.Bound("note", "task")); + +while (cursor != null) +{ + Console.WriteLine(cursor.Value); + cursor = cursor.Continue(); +} + +database.Close(); + +var waitingRequest = document.DefaultView.IndexedDb().OpenRequest( + "app", + 3, + tx => tx.CreateObjectStore("logs"), + waitForUnblock: true); + +var upgradedAgain = await waitingRequest.WaitAsync(); + +var deleteRequest = document.DefaultView.IndexedDb().DeleteDatabaseRequest("app", waitForUnblock: true); +var deleted = await deleteRequest.WaitAsync(); + +using (var writeTx = upgradedAgain.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + writeTx.Completed += () => Console.WriteLine("transaction committed"); + writeTx.Aborted += () => Console.WriteLine("transaction aborted"); + writeTx.GetObjectStore("records").Put("k", "v"); + writeTx.Commit(); +} ``` ### Cache Storage diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 7a4d4aa..7238962 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -146,9 +146,22 @@ sessionStorage["transient"] = "yes"; ### IndexedDB -AngleSharp.Io also exposes a lightweight IndexedDB surface that is shared per origin: +AngleSharp.Io exposes an in-memory IndexedDB surface that is shared per origin: - `indexedDB` using in-memory databases and object stores scoped to the origin +- versioned open with upgrade callback support +- explicit transactions (`ReadOnly` / `ReadWrite`) with commit and abort +- request-style open wrappers (`OpenRequest(...)`) +- object store record methods (`Add`, `Put`, `Get`, `Delete`, `Count`) +- key-range queries (`IndexedDbKeyRange`) +- indexes with optional uniqueness (`CreateIndex`, `GetIndex`, `DeleteIndex`) +- cursors for store and index iteration (`OpenCursor`) +- request state (`Pending` / `Success` / `Error` / `Blocked`) and close lifecycle +- version-change notifications on open connections +- optional auto-unblock open requests (`OpenRequest(..., waitForUnblock: true)`) +- request-based delete with optional auto-unblock (`DeleteDatabaseRequest(..., waitForUnblock: true)`) +- transaction lifecycle state/events (`IsCompleted`, `IsAborted`, `Completed`, `Aborted`) +- single active read-write transaction per database Register IndexedDB by providing a provider factory: @@ -162,10 +175,60 @@ Use the service from a browsing context: ```cs var context = BrowsingContext.New(config); var document = await context.OpenAsync("https://example.org"); -var database = document.DefaultView.IndexedDb().Open("app"); -var store = database.CreateObjectStore("records"); +var database = document.DefaultView.IndexedDb().Open("app", 1, tx => +{ + tx.CreateObjectStore("records"); +}); + +using (var transaction = database.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + var store = transaction.GetObjectStore("records"); + var byType = store.CreateIndex("byType", "type"); + + store.Add("persisted", "yes"); + store.Put("persisted", "yes-updated"); + + var notes = byType.GetAll("note"); + var range = store.GetAll(IndexedDbKeyRange.Bound("a", "m")); + + transaction.Commit(); +} + +var openRequest = document.DefaultView.IndexedDb().OpenRequest("app", 2, tx => +{ + tx.CreateObjectStore("events"); +}); -store["persisted"] = "yes"; +var upgraded = await openRequest.WaitAsync(); + +var cursor = byType.OpenCursor(IndexedDbKeyRange.Bound("note", "task")); + +while (cursor != null) +{ + Console.WriteLine(cursor.Value); + cursor = cursor.Continue(); +} + +database.Close(); + +var waitingRequest = document.DefaultView.IndexedDb().OpenRequest( + "app", + 3, + tx => tx.CreateObjectStore("logs"), + waitForUnblock: true); + +var upgradedAgain = await waitingRequest.WaitAsync(); + +var deleteRequest = document.DefaultView.IndexedDb().DeleteDatabaseRequest("app", waitForUnblock: true); +var deleted = await deleteRequest.WaitAsync(); + +using (var writeTx = upgradedAgain.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) +{ + writeTx.Completed += () => Console.WriteLine("transaction committed"); + writeTx.Aborted += () => Console.WriteLine("transaction aborted"); + writeTx.GetObjectStore("records").Put("k", "v"); + writeTx.Commit(); +} ``` ### Cache Storage diff --git a/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs b/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs index badae9a..79ac8d9 100644 --- a/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs +++ b/src/AngleSharp.Io.Tests/IndexedDb/IndexedDbTests.cs @@ -5,7 +5,9 @@ namespace AngleSharp.Io.Tests.IndexedDb using AngleSharp.Io.Dom; using AngleSharp.Io.IndexedDb; using NUnit.Framework; + using System.Collections.Generic; using System; + using System.Linq; using System.Threading.Tasks; [TestFixture] @@ -58,6 +60,654 @@ public async Task IndexedDbIsOriginBound() Assert.AreEqual(null, storeB["token"]); } + [Test] + public async Task OpenWithVersionRunsUpgradeAndSetsVersion() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var db = factory.Open("app", 3, tx => + { + var store = tx.CreateObjectStore("records"); + store["hello"] = "world"; + }); + + Assert.AreEqual(3, db.Version); + Assert.AreEqual("world", db.CreateObjectStore("records")["hello"]); + } + + [Test] + public async Task OpenWithLowerVersionThrows() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + factory.Open("app", 2, tx => tx.CreateObjectStore("records")); + + Assert.Throws(() => factory.Open("app", 1)); + } + + [Test] + public async Task ReadWriteTransactionCommitPersistsChanges() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store["token"] = "abc"; + transaction.Commit(); + } + + Assert.AreEqual("abc", db.CreateObjectStore("records")["token"]); + } + + [Test] + public async Task ReadWriteTransactionAbortDiscardsChanges() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store["token"] = "abc"; + transaction.Abort(); + } + + Assert.AreEqual(null, db.CreateObjectStore("records")["token"]); + } + + [Test] + public async Task ReadOnlyTransactionRejectsWrites() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => + { + var store = tx.CreateObjectStore("records"); + store["token"] = "abc"; + }); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + Assert.Throws(() => store["token"] = "updated"); + transaction.Commit(); + } + + Assert.AreEqual("abc", db.CreateObjectStore("records")["token"]); + } + + [Test] + public async Task OpenRequestCompletesWithDatabase() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + + var request = window.IndexedDb().OpenRequest("app", 1, tx => tx.CreateObjectStore("records")); + var db = await request.WaitAsync(); + + Assert.IsTrue(request.IsCompleted); + Assert.AreEqual(false, request.HasError); + Assert.IsNotNull(db); + Assert.AreEqual(1, db.Version); + } + + [Test] + public async Task OpenRequestCapturesVersionError() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + factory.Open("app", 2, tx => tx.CreateObjectStore("records")); + var request = factory.OpenRequest("app", 1); + + Assert.ThrowsAsync(Is.InstanceOf(), async () => await request.WaitAsync()); + Assert.IsTrue(request.IsCompleted); + Assert.IsTrue(request.HasError); + Assert.IsNotNull(request.Exception); + } + + [Test] + public async Task ObjectStoreAddPutDeleteCountBehavesAsExpected() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.Add("k1", "v1"); + store.Put("k2", "v2"); + store.Put("k2", "v2b"); + + Assert.AreEqual(2, store.Count()); + Assert.AreEqual("v1", store.Get("k1")); + Assert.AreEqual("v2b", store.Get("k2")); + Assert.AreEqual(true, store.Delete("k1")); + Assert.AreEqual(false, store.Delete("missing")); + Assert.AreEqual(1, store.Count()); + + transaction.Commit(); + } + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + Assert.AreEqual(1, store.Count()); + Assert.AreEqual(null, store.Get("k1")); + Assert.AreEqual("v2b", store.Get("k2")); + transaction.Commit(); + } + } + + [Test] + public async Task ObjectStoreCanQueryWithKeyRanges() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.Put("a", "id=1;type=note"); + store.Put("b", "id=2;type=task"); + store.Put("c", "id=3;type=note"); + store.Put("d", "id=4;type=note"); + transaction.Commit(); + } + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + var all = store.GetAll(IndexedDbKeyRange.Bound("b", "d")); + var openUpper = store.GetAll(IndexedDbKeyRange.Bound("b", "d", false, true)); + + CollectionAssert.AreEqual(new[] { "id=2;type=task", "id=3;type=note", "id=4;type=note" }, all.ToArray()); + CollectionAssert.AreEqual(new[] { "id=2;type=task", "id=3;type=note" }, openUpper.ToArray()); + Assert.AreEqual(2, store.Count(IndexedDbKeyRange.Bound("b", "d", false, true))); + transaction.Commit(); + } + } + + [Test] + public async Task ObjectStoreIndexesCanQueryAndCount() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + var index = store.CreateIndex("byType", "type"); + + store.Put("a", "id=1;type=note"); + store.Put("b", "id=2;type=task"); + store.Put("c", "id=3;type=note"); + + Assert.AreEqual("id=1;type=note", index.Get("note")); + CollectionAssert.AreEqual(new[] { "id=1;type=note", "id=3;type=note" }, index.GetAll("note").ToArray()); + Assert.AreEqual(2, index.Count("note")); + CollectionAssert.AreEquivalent(new[] { "id=1;type=note", "id=3;type=note", "id=2;type=task" }, index.GetAll(IndexedDbKeyRange.Bound("note", "task")).ToArray()); + + transaction.Commit(); + } + } + + [Test] + public async Task UniqueIndexRejectsDuplicateProjectedValues() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.CreateIndex("byEmail", "email", unique: true); + store.Put("a", "email=alice@example.com;type=user"); + + Assert.Throws(() => store.Put("b", "email=alice@example.com;type=user")); + + transaction.Commit(); + } + } + + [Test] + public async Task ObjectStoreCursorIteratesByKeyDirectionAndRange() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + store.Put("a", "v1"); + store.Put("b", "v2"); + store.Put("c", "v3"); + store.Put("d", "v4"); + transaction.Commit(); + } + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)) + { + var store = transaction.GetObjectStore("records"); + + var next = ReadCursorValues(store.OpenCursor(IndexedDbKeyRange.Bound("b", "d"))); + CollectionAssert.AreEqual(new[] { "v2", "v3", "v4" }, next); + + var prev = ReadCursorValues(store.OpenCursor(IndexedDbKeyRange.Bound("b", "d"), IndexedDbCursorDirection.Prev)); + CollectionAssert.AreEqual(new[] { "v4", "v3", "v2" }, prev); + + transaction.Commit(); + } + } + + [Test] + public async Task IndexCursorIteratesByIndexKeyDirectionAndRange() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = transaction.GetObjectStore("records"); + var index = store.CreateIndex("byType", "type"); + + store.Put("a", "id=1;type=note"); + store.Put("b", "id=2;type=task"); + store.Put("c", "id=3;type=memo"); + store.Put("d", "id=4;type=task"); + + var next = ReadCursorValues(index.OpenCursor(IndexedDbKeyRange.Bound("memo", "task"))); + CollectionAssert.AreEqual(new[] { "id=3;type=memo", "id=1;type=note", "id=2;type=task", "id=4;type=task" }, next); + + var prev = ReadCursorValues(index.OpenCursor(IndexedDbKeyRange.Bound("memo", "task"), IndexedDbCursorDirection.Prev)); + CollectionAssert.AreEqual(new[] { "id=4;type=task", "id=2;type=task", "id=1;type=note", "id=3;type=memo" }, prev); + + transaction.Commit(); + } + } + + [Test] + public async Task OpenRequestReportsBlockedWhenUpgradeHasOpenConnections() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + var request = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(true, request.HasError); + Assert.IsNotNull(request.Exception); + Assert.ThrowsAsync(Is.InstanceOf(), async () => await request.WaitAsync()); + + db.Close(); + + var retry = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + var upgraded = await retry.WaitAsync(); + + Assert.AreEqual(IndexedDbRequestState.Success, retry.State); + Assert.AreEqual(2, upgraded.Version); + } + + [Test] + public async Task OpenRequestCanWaitForUnblockAndResolveAutomatically() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + var request = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events"), waitForUnblock: true); + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(false, request.IsCompleted); + + db.Close(); + + var upgraded = await request.WaitAsync(); + + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(2, upgraded.Version); + } + + [Test] + public async Task OpenConnectionReceivesVersionChangeNotificationWhenUpgradeIsBlocked() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var notifications = 0; + var oldVersion = 0L; + var newVersion = 0L; + + db.VersionChangeRequested += (oldV, newV) => + { + notifications++; + oldVersion = oldV; + newVersion = newV; + }; + + var blocked = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + + Assert.AreEqual(IndexedDbRequestState.Blocked, blocked.State); + Assert.AreEqual(1, notifications); + Assert.AreEqual(1, oldVersion); + Assert.AreEqual(2, newVersion); + + db.Close(); + } + + [Test] + public async Task ClosingDatabasePreventsFurtherUsage() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + db.Close(); + + Assert.AreEqual(true, db.IsClosed); + Assert.Throws(() => db.CreateObjectStore("other")); + Assert.Throws(() => db.BeginTransaction("records", IndexedDbTransactionMode.ReadOnly)); + } + + [Test] + public async Task DeleteDatabaseRequestReportsBlockedWhenConnectionsAreOpen() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var blocked = factory.DeleteDatabaseRequest("app"); + + Assert.AreEqual(IndexedDbRequestState.Blocked, blocked.State); + Assert.AreEqual(true, blocked.HasError); + Assert.ThrowsAsync(Is.InstanceOf(), async () => await blocked.WaitAsync()); + + db.Close(); + + var deleted = await factory.DeleteDatabaseRequest("app").WaitAsync(); + Assert.AreEqual(true, deleted); + } + + [Test] + public async Task DeleteDatabaseRequestCanWaitForUnblockAndResolveAutomatically() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var db = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var request = factory.DeleteDatabaseRequest("app", waitForUnblock: true); + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(false, request.IsCompleted); + + db.Close(); + + var deleted = await request.WaitAsync(); + Assert.AreEqual(true, deleted); + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + } + + [Test] + public async Task TransactionLifecycleFlagsAndEventsAreReported() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + var completedRaised = false; + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + transaction.Completed += () => completedRaised = true; + var store = transaction.GetObjectStore("records"); + store.Put("k1", "v1"); + transaction.Commit(); + + Assert.AreEqual(true, transaction.IsCompleted); + Assert.AreEqual(false, transaction.IsAborted); + Assert.AreEqual(true, completedRaised); + } + + var abortedRaised = false; + + using (var transaction = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + transaction.Aborted += () => abortedRaised = true; + transaction.Abort(); + + Assert.AreEqual(true, transaction.IsCompleted); + Assert.AreEqual(true, transaction.IsAborted); + Assert.AreEqual(true, abortedRaised); + } + } + + [Test] + public async Task OnlyOneReadWriteTransactionCanBeActiveAtATime() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + var first = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite); + + Assert.Throws(() => db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)); + + first.Abort(); + + using (var second = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + var store = second.GetObjectStore("records"); + store.Put("k2", "v2"); + second.Commit(); + } + + Assert.AreEqual("v2", db.CreateObjectStore("records")["k2"]); + } + + [Test] + public async Task OpenRequestSuccessCallbackIsInvoked() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + + var callbackCount = 0; + IIndexedDbDatabase callbackDb = null; + + var request = window.IndexedDb().OpenRequest("app", 1, tx => tx.CreateObjectStore("records")); + request.Succeeded += db => + { + callbackCount++; + callbackDb = db; + }; + + var opened = await request.WaitAsync(); + + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(1, callbackCount); + Assert.AreSame(opened, callbackDb); + } + + [Test] + public async Task OpenRequestWaitForUnblockRaisesBlockedThenSucceeded() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var hold = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var blockedCount = 0; + var failedCount = 0; + var successCount = 0; + + var request = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events"), waitForUnblock: true); + request.Blocked += () => blockedCount++; + request.Failed += _ => failedCount++; + request.Succeeded += _ => successCount++; + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(0, successCount); + + hold.Close(); + + var opened = await request.WaitAsync(); + + Assert.IsNotNull(opened); + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(1, successCount); + } + + [Test] + public async Task DeleteRequestWaitForUnblockRaisesBlockedThenSucceeded() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + var hold = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + + var blockedCount = 0; + var failedCount = 0; + var successCount = 0; + + var request = factory.DeleteDatabaseRequest("app", waitForUnblock: true); + request.Blocked += () => blockedCount++; + request.Failed += _ => failedCount++; + request.Succeeded += _ => successCount++; + + Assert.AreEqual(IndexedDbRequestState.Blocked, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(0, successCount); + + hold.Close(); + + var deleted = await request.WaitAsync(); + + Assert.AreEqual(true, deleted); + Assert.AreEqual(IndexedDbRequestState.Success, request.State); + Assert.AreEqual(1, blockedCount); + Assert.AreEqual(1, failedCount); + Assert.AreEqual(1, successCount); + } + + [Test] + public async Task DisposingUncommittedReadWriteTransactionReleasesWriteLock() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var db = window.IndexedDb().Open("app", 1, tx => tx.CreateObjectStore("records")); + + IIndexedDbTransaction abandoned; + + using (abandoned = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + abandoned.GetObjectStore("records").Put("k1", "v1"); + } + + Assert.AreEqual(true, abandoned.IsCompleted); + Assert.AreEqual(true, abandoned.IsAborted); + + using (var next = db.BeginTransaction("records", IndexedDbTransactionMode.ReadWrite)) + { + next.GetObjectStore("records").Put("k2", "v2"); + next.Commit(); + } + + Assert.AreEqual(null, db.CreateObjectStore("records")["k1"]); + Assert.AreEqual("v2", db.CreateObjectStore("records")["k2"]); + } + + [Test] + public async Task ClosedConnectionDoesNotReceiveVersionChangeNotification() + { + var config = CreateConfigWithIndexedDb(); + var context = BrowsingContext.New(config); + var window = await OpenWindowAsync(context, "https://indexeddb.example/"); + var factory = window.IndexedDb(); + + var closedConnection = factory.Open("app", 1, tx => tx.CreateObjectStore("records")); + var activeConnection = factory.Open("app", 1); + + var closedNotifications = 0; + var activeNotifications = 0; + + closedConnection.VersionChangeRequested += (_, _) => closedNotifications++; + activeConnection.VersionChangeRequested += (_, _) => activeNotifications++; + + closedConnection.Close(); + + var blocked = factory.OpenRequest("app", 2, tx => tx.CreateObjectStore("events")); + + Assert.AreEqual(IndexedDbRequestState.Blocked, blocked.State); + Assert.AreEqual(0, closedNotifications); + Assert.AreEqual(1, activeNotifications); + + activeConnection.Close(); + } + + private static List ReadCursorValues(IIndexedDbCursor cursor) + { + var values = new List(); + + while (cursor != null) + { + values.Add(cursor.Value); + cursor = cursor.Continue(); + } + + return values; + } + private static IConfiguration CreateConfigWithIndexedDb() { var factory = new IndexedDbProviderFactory(); diff --git a/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs index bcf913a..521dfbc 100644 --- a/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs +++ b/src/AngleSharp.Io/IndexedDb/IndexedDbProviderFactory.cs @@ -5,7 +5,9 @@ namespace AngleSharp.Io.IndexedDb using AngleSharp.Io.Storage; using System; using System.Collections.Generic; + using System.Linq; using System.Runtime.CompilerServices; + using System.Threading.Tasks; /// /// Represents the default IndexedDB provider factory with origin-dependent views. @@ -114,13 +116,210 @@ public IIndexedDbDatabase Open(String name) name = name ?? String.Empty; var origin = _getOrigin.Invoke(); var database = _getDatabase.Invoke(origin, name); - return new IndexedDbDatabase(name, database); + + if (database.Version <= 0) + { + database.Version = 1; + } + + var connection = new IndexedDbDatabase(name, database); + database.OpenConnection(connection); + return connection; + } + + public IIndexedDbDatabase Open(String name, Int64 version) + { + return Open(name, version, null); + } + + public IIndexedDbDatabase Open(String name, Int64 version, Action upgrade) + { + if (version <= 0) + { + throw new ArgumentOutOfRangeException(nameof(version)); + } + + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + var currentVersion = database.Version; + + if (currentVersion > version) + { + throw new InvalidOperationException("Cannot open IndexedDB with a lower version than the current one."); + } + + if (currentVersion < version) + { + if (database.OpenConnections > 0) + { + database.NotifyVersionChangeRequested(currentVersion, version); + throw new IndexedDbBlockedException("Cannot upgrade IndexedDB while open connections exist. Close existing database connections first."); + } + + var tx = new IndexedDbUpgradeTransaction(database, currentVersion, version); + upgrade?.Invoke(tx); + tx.Commit(); + } + else if (currentVersion == 0) + { + database.Version = version; + } + + var connection = new IndexedDbDatabase(name, database); + database.OpenConnection(connection); + return connection; + } + + public IIndexedDbRequest OpenRequest(String name) + { + return IndexedDbRequest.Run(() => Open(name)); + } + + public IIndexedDbRequest OpenRequest(String name, Int64 version) + { + return IndexedDbRequest.Run(() => Open(name, version)); + } + + public IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade) + { + return IndexedDbRequest.Run(() => Open(name, version, upgrade)); + } + + public IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade, Boolean waitForUnblock) + { + if (!waitForUnblock) + { + return OpenRequest(name, version, upgrade); + } + + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + var request = IndexedDbRequest.CreatePending(); + + void TryOpen() + { + try + { + var opened = Open(name, version, upgrade); + request.SetSuccess(opened); + } + catch (IndexedDbBlockedException ex) + { + request.SetBlocked(ex); + } + catch (Exception ex) + { + request.SetError(ex); + } + } + + void HandleConnectionsChanged() + { + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + return; + } + + if (database.OpenConnections == 0) + { + TryOpen(); + + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + } + } + } + + TryOpen(); + + if (!request.IsCompleted) + { + database.ConnectionsChanged += HandleConnectionsChanged; + } + + return request; } public Boolean DeleteDatabase(String name) { name = name ?? String.Empty; - return _deleteDatabase.Invoke(_getOrigin.Invoke(), name); + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + + if (database.OpenConnections > 0) + { + database.NotifyVersionChangeRequested(database.Version, 0); + throw new IndexedDbBlockedException("Cannot delete IndexedDB while open connections exist. Close existing database connections first."); + } + + return _deleteDatabase.Invoke(origin, name); + } + + public IIndexedDbRequest DeleteDatabaseRequest(String name) + { + return IndexedDbRequest.Run(() => DeleteDatabase(name)); + } + + public IIndexedDbRequest DeleteDatabaseRequest(String name, Boolean waitForUnblock) + { + if (!waitForUnblock) + { + return DeleteDatabaseRequest(name); + } + + name = name ?? String.Empty; + var origin = _getOrigin.Invoke(); + var database = _getDatabase.Invoke(origin, name); + var request = IndexedDbRequest.CreatePending(); + + void TryDelete() + { + try + { + var deleted = DeleteDatabase(name); + request.SetSuccess(deleted); + } + catch (IndexedDbBlockedException ex) + { + request.SetBlocked(ex); + } + catch (Exception ex) + { + request.SetError(ex); + } + } + + void HandleConnectionsChanged() + { + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + return; + } + + if (database.OpenConnections == 0) + { + TryDelete(); + + if (request.IsCompleted) + { + database.ConnectionsChanged -= HandleConnectionsChanged; + } + } + } + + TryDelete(); + + if (!request.IsCompleted) + { + database.ConnectionsChanged += HandleConnectionsChanged; + } + + return request; } } @@ -129,6 +328,10 @@ internal sealed class IndexedDbDatabase : IIndexedDbDatabase private readonly String _name; private readonly IndexedDbDatabaseBucket _database; + private Boolean _isClosed; + + public event Action VersionChangeRequested; + public IndexedDbDatabase(String name, IndexedDbDatabaseBucket database) { _name = name ?? String.Empty; @@ -137,46 +340,168 @@ public IndexedDbDatabase(String name, IndexedDbDatabaseBucket database) public String Name => _name; + public Int64 Version => _database.Version; + + public Boolean IsClosed => _isClosed; + public IIndexedDbObjectStore CreateObjectStore(String name) { - return new IndexedDbObjectStore(() => _database.GetOrCreateStore(name)); + EnsureOpen(); + return new IndexedDbObjectStore(() => _database.GetOrCreateStore(name), true); } public Boolean DeleteObjectStore(String name) { + EnsureOpen(); return _database.DeleteStore(name); } + + public IIndexedDbTransaction BeginTransaction(String storeName, IndexedDbTransactionMode mode) + { + EnsureOpen(); + + if (String.IsNullOrEmpty(storeName)) + { + throw new ArgumentNullException(nameof(storeName)); + } + + return BeginTransaction(new[] { storeName }, mode); + } + + public IIndexedDbTransaction BeginTransaction(String[] storeNames, IndexedDbTransactionMode mode) + { + EnsureOpen(); + return new IndexedDbTransaction(_database, storeNames, mode); + } + + public void Close() + { + if (_isClosed) + { + return; + } + + _isClosed = true; + _database.CloseConnection(this); + } + + internal void NotifyVersionChangeRequested(Int64 oldVersion, Int64 newVersion) + { + if (!_isClosed) + { + VersionChangeRequested?.Invoke(oldVersion, newVersion); + } + } + + private void EnsureOpen() + { + if (_isClosed) + { + throw new InvalidOperationException("IndexedDB database connection is closed."); + } + } } internal sealed class IndexedDbObjectStore : IIndexedDbObjectStore { - private readonly Func _getBucket; + private readonly Func _getStore; + private readonly Boolean _writable; - public IndexedDbObjectStore(Func getBucket) + public IndexedDbObjectStore(Func getStore, Boolean writable) { - _getBucket = getBucket ?? throw new ArgumentNullException(nameof(getBucket)); + _getStore = getStore ?? throw new ArgumentNullException(nameof(getStore)); + _writable = writable; } public Int32 Length => CurrentBucket.Length; + public Int32 Count() => CurrentBucket.Length; + + public Int32 Count(IndexedDbKeyRange range) + { + return GetAll(range).Count; + } + public String Key(Int32 index) => CurrentBucket.Key(index); - public String this[String key] + public String Get(String key) { - get => CurrentBucket.Get(key); - set + return CurrentBucket.Get(key); + } + + public IReadOnlyList GetAll(IndexedDbKeyRange range) + { + var store = CurrentStore; + var bucket = store.Records; + var matches = new List(); + + for (var i = 0; i < bucket.Length; i++) { - if (String.IsNullOrEmpty(key)) + var key = bucket.Key(i); + + if (range == null || range.Includes(key)) { - throw new ArgumentNullException(nameof(key)); + matches.Add(bucket.Get(key)); } + } + + return matches; + } + + public void Add(String key, String value) + { + EnsureWritable(); + + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + if (CurrentBucket.Get(key) != null) + { + throw new InvalidOperationException(String.Concat("The key already exists: ", key)); + } + + EnsureIndexConstraints(key, value); + + CurrentBucket.Set(key, value); + } + + public void Put(String key, String value) + { + EnsureWritable(); + + if (String.IsNullOrEmpty(key)) + { + throw new ArgumentNullException(nameof(key)); + } + + EnsureIndexConstraints(key, value); + CurrentBucket.Set(key, value); + } + + public Boolean Delete(String key) + { + EnsureWritable(); - CurrentBucket.Set(key, value); + if (String.IsNullOrEmpty(key)) + { + return false; } + + return CurrentBucket.Remove(key); + } + + public String this[String key] + { + get => CurrentBucket.Get(key); + set => Put(key, value); } public void Remove(String key) { + EnsureWritable(); + if (String.IsNullOrEmpty(key)) { return; @@ -187,40 +512,939 @@ public void Remove(String key) public void Clear() { + EnsureWritable(); CurrentBucket.Clear(); } - private StorageBucket CurrentBucket => _getBucket.Invoke(); - } + public IIndexedDbIndex CreateIndex(String name, String keyPath, Boolean unique = false) + { + EnsureWritable(); + var store = CurrentStore; + var index = store.CreateOrGetIndex(name, keyPath, unique); + ValidateExistingRecordsAgainstIndex(index, store.Records); + return new IndexedDbIndex(() => CurrentStore, name); + } - internal sealed class IndexedDbDatabaseBucket - { - private readonly Dictionary _stores; + public IIndexedDbIndex GetIndex(String name) + { + var store = CurrentStore; - public IndexedDbDatabaseBucket(String name) + if (!store.TryGetIndex(name, out _)) + { + return null; + } + + return new IndexedDbIndex(() => CurrentStore, name); + } + + public Boolean DeleteIndex(String name) { - Name = name ?? String.Empty; - _stores = new Dictionary(StringComparer.Ordinal); + EnsureWritable(); + return CurrentStore.DeleteIndex(name); } - public String Name { get; } + public IIndexedDbCursor OpenCursor() + { + return OpenCursor(null, IndexedDbCursorDirection.Next); + } - public StorageBucket GetOrCreateStore(String name) + public IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next) { - name = name ?? String.Empty; + var items = new List>(); + var bucket = CurrentBucket; - if (!_stores.TryGetValue(name, out var store)) + for (var i = 0; i < bucket.Length; i++) { - store = new StorageBucket(); - _stores[name] = store; + var key = bucket.Key(i); + + if (range == null || range.Includes(key)) + { + items.Add(new KeyValuePair(key, bucket.Get(key))); + } } - return store; + if (direction == IndexedDbCursorDirection.Prev) + { + items.Reverse(); + } + + return IndexedDbCursor.From(items); } - public Boolean DeleteStore(String name) + private void EnsureWritable() + { + if (!_writable) + { + throw new InvalidOperationException("Cannot modify data in a read-only IndexedDB transaction."); + } + } + + private void EnsureIndexConstraints(String key, String value) + { + var store = CurrentStore; + var indexes = store.GetIndexes(); + + for (var i = 0; i < indexes.Count; i++) + { + var index = indexes[i]; + + if (!index.Unique) + { + continue; + } + + var indexValue = ExtractIndexValue(value, index.KeyPath); + + if (indexValue == null) + { + continue; + } + + for (var j = 0; j < CurrentBucket.Length; j++) + { + var existingKey = CurrentBucket.Key(j); + + if (String.Equals(existingKey, key, StringComparison.Ordinal)) + { + continue; + } + + var existingValue = CurrentBucket.Get(existingKey); + var existingIndexValue = ExtractIndexValue(existingValue, index.KeyPath); + + if (String.Equals(existingIndexValue, indexValue, StringComparison.Ordinal)) + { + throw new InvalidOperationException(String.Concat("Unique index violation for index: ", index.Name)); + } + } + } + } + + private static void ValidateExistingRecordsAgainstIndex(IndexedDbIndexDefinition index, StorageBucket bucket) + { + if (index == null || !index.Unique) + { + return; + } + + var seen = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var indexValue = ExtractIndexValue(value, index.KeyPath); + + if (indexValue == null) + { + continue; + } + + if (!seen.Add(indexValue)) + { + throw new InvalidOperationException(String.Concat("Unique index violation for index: ", index.Name)); + } + } + } + + internal static String ExtractIndexValue(String value, String keyPath) + { + if (String.IsNullOrEmpty(value) || String.IsNullOrEmpty(keyPath)) + { + return null; + } + + var marker = String.Concat(keyPath, "="); + var start = value.IndexOf(marker, StringComparison.Ordinal); + + if (start < 0) + { + return null; + } + + start += marker.Length; + var end = value.IndexOf(';', start); + + if (end < 0) + { + end = value.Length; + } + + var extracted = value.Substring(start, end - start).Trim(); + return extracted.Length == 0 ? null : extracted; + } + + private IndexedDbStoreBucket CurrentStore => _getStore.Invoke(); + + private StorageBucket CurrentBucket => CurrentStore.Records; + } + + internal sealed class IndexedDbIndex : IIndexedDbIndex + { + private readonly Func _getStore; + private readonly String _name; + + public IndexedDbIndex(Func getStore, String name) + { + _getStore = getStore ?? throw new ArgumentNullException(nameof(getStore)); + _name = name ?? String.Empty; + } + + public String Name => Definition.Name; + + public String KeyPath => Definition.KeyPath; + + public Boolean IsUnique => Definition.Unique; + + public String Get(String indexKey) + { + return GetAll(indexKey).FirstOrDefault(); + } + + public IReadOnlyList GetAll(String indexKey) + { + var values = new List(); + + if (String.IsNullOrEmpty(indexKey)) + { + return values; + } + + var store = _getStore.Invoke(); + var bucket = store.Records; + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var projected = IndexedDbObjectStore.ExtractIndexValue(value, Definition.KeyPath); + + if (String.Equals(projected, indexKey, StringComparison.Ordinal)) + { + values.Add(value); + } + } + + return values; + } + + public IReadOnlyList GetAll(IndexedDbKeyRange range) + { + var values = new List(); + var store = _getStore.Invoke(); + var bucket = store.Records; + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var projected = IndexedDbObjectStore.ExtractIndexValue(value, Definition.KeyPath); + + if (projected != null && (range == null || range.Includes(projected))) + { + values.Add(value); + } + } + + return values; + } + + public Int32 Count(String indexKey) + { + return GetAll(indexKey).Count; + } + + public IIndexedDbCursor OpenCursor() + { + return OpenCursor(null, IndexedDbCursorDirection.Next); + } + + public IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next) + { + var rows = new List(); + var store = _getStore.Invoke(); + var bucket = store.Records; + var definition = Definition; + + for (var i = 0; i < bucket.Length; i++) + { + var key = bucket.Key(i); + var value = bucket.Get(key); + var projected = IndexedDbObjectStore.ExtractIndexValue(value, definition.KeyPath); + + if (projected == null) + { + continue; + } + + if (range != null && !range.Includes(projected)) + { + continue; + } + + rows.Add(new IndexedDbIndexCursorRow(projected, key, value)); + } + + rows.Sort((a, b) => + { + var compare = StringComparer.Ordinal.Compare(a.IndexKey, b.IndexKey); + + if (compare != 0) + { + return compare; + } + + return StringComparer.Ordinal.Compare(a.PrimaryKey, b.PrimaryKey); + }); + + if (direction == IndexedDbCursorDirection.Prev) + { + rows.Reverse(); + } + + var items = rows.Select(m => new KeyValuePair(m.PrimaryKey, m.Value)).ToList(); + return IndexedDbCursor.From(items); + } + + private IndexedDbIndexDefinition Definition + { + get + { + var store = _getStore.Invoke(); + + if (!store.TryGetIndex(_name, out var definition)) + { + throw new InvalidOperationException(String.Concat("Index does not exist: ", _name)); + } + + return definition; + } + } + } + + internal sealed class IndexedDbRequest : IIndexedDbRequest + { + private readonly TaskCompletionSource _source; + + private T _result; + private Exception _exception; + private IndexedDbRequestState _state; + + private event Action SucceededInternal; + private event Action FailedInternal; + private event Action BlockedInternal; + + private IndexedDbRequest(TaskCompletionSource source, IndexedDbRequestState state) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _state = state; + } + + public IndexedDbRequestState State => _state; + + public Boolean IsCompleted => _source.Task.IsCompleted; + + public Boolean HasError => _state == IndexedDbRequestState.Error || _state == IndexedDbRequestState.Blocked; + + public T Result + { + get + { + return _state == IndexedDbRequestState.Success ? _result : default; + } + } + + public Exception Exception => _exception; + + public event Action Succeeded + { + add + { + SucceededInternal += value; + + if (_state == IndexedDbRequestState.Success) + { + value?.Invoke(_result); + } + } + remove + { + SucceededInternal -= value; + } + } + + public event Action Failed + { + add + { + FailedInternal += value; + + if (_state == IndexedDbRequestState.Error || _state == IndexedDbRequestState.Blocked) + { + value?.Invoke(_exception); + } + } + remove + { + FailedInternal -= value; + } + } + + public event Action Blocked + { + add + { + BlockedInternal += value; + + if (_state == IndexedDbRequestState.Blocked) + { + value?.Invoke(); + } + } + remove + { + BlockedInternal -= value; + } + } + + public Task WaitAsync() + { + return _source.Task; + } + + public void SetSuccess(T value) + { + if (IsCompleted) + { + return; + } + + _state = IndexedDbRequestState.Success; + _result = value; + _exception = null; + _source.TrySetResult(value); + SucceededInternal?.Invoke(value); + } + + public void SetBlocked(Exception exception) + { + if (IsCompleted) + { + return; + } + + _state = IndexedDbRequestState.Blocked; + _exception = exception; + BlockedInternal?.Invoke(); + FailedInternal?.Invoke(exception); + } + + public void SetError(Exception exception) + { + if (IsCompleted) + { + return; + } + + _state = IndexedDbRequestState.Error; + _exception = exception; + _source.TrySetException(exception); + FailedInternal?.Invoke(exception); + } + + public static IIndexedDbRequest Run(Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + try + { + var result = action.Invoke(); + var request = new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + request.SetSuccess(result); + return request; + } + catch (IndexedDbBlockedException ex) + { + var request = new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + request.SetBlocked(ex); + request._source.TrySetException(ex); + return request; + } + catch (Exception ex) + { + var request = new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + request.SetError(ex); + return request; + } + } + + public static IndexedDbRequest CreatePending() + { + return new IndexedDbRequest(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IndexedDbRequestState.Pending); + } + } + + internal sealed class IndexedDbCursor : IIndexedDbCursor + { + private readonly IReadOnlyList> _items; + private readonly Int32 _index; + + private IndexedDbCursor(IReadOnlyList> items, Int32 index) + { + _items = items ?? throw new ArgumentNullException(nameof(items)); + _index = index; + } + + public String Key => _items[_index].Key; + + public String Value => _items[_index].Value; + + public IIndexedDbCursor Continue() + { + var next = _index + 1; + + if (next >= _items.Count) + { + return null; + } + + return new IndexedDbCursor(_items, next); + } + + public static IIndexedDbCursor From(IReadOnlyList> items) + { + if (items == null || items.Count == 0) + { + return null; + } + + return new IndexedDbCursor(items, 0); + } + } + + internal sealed class IndexedDbIndexCursorRow + { + public IndexedDbIndexCursorRow(String indexKey, String primaryKey, String value) + { + IndexKey = indexKey ?? String.Empty; + PrimaryKey = primaryKey ?? String.Empty; + Value = value; + } + + public String IndexKey { get; } + + public String PrimaryKey { get; } + + public String Value { get; } + } + + internal sealed class IndexedDbTransaction : IIndexedDbTransaction + { + private readonly IndexedDbDatabaseBucket _database; + private readonly Dictionary _stores; + + private Boolean _isCompleted; + private Boolean _isAborted; + private readonly Boolean _holdsWriteLock; + + public IndexedDbTransaction(IndexedDbDatabaseBucket database, String[] storeNames, IndexedDbTransactionMode mode) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + Mode = mode; + _database.BeginTransaction(mode); + _holdsWriteLock = mode == IndexedDbTransactionMode.ReadWrite; + + if (storeNames == null || storeNames.Length == 0) + { + throw new ArgumentException("At least one object store name is required.", nameof(storeNames)); + } + + _stores = new Dictionary(StringComparer.Ordinal); + + for (var i = 0; i < storeNames.Length; i++) + { + var name = storeNames[i] ?? String.Empty; + + if (_stores.ContainsKey(name)) + { + continue; + } + + var existing = _database.GetStore(name); + + if (existing == null) + { + throw new InvalidOperationException(String.Concat("Object store does not exist: ", name)); + } + + _stores[name] = CloneStore(existing); + } + } + + public IndexedDbTransactionMode Mode { get; } + + public Boolean IsCompleted => _isCompleted; + + public Boolean IsAborted => _isAborted; + + public event Action Completed; + + public event Action Aborted; + + public IIndexedDbObjectStore GetObjectStore(String name) + { + EnsureActive(); + name = name ?? String.Empty; + + if (!_stores.TryGetValue(name, out var store)) + { + throw new InvalidOperationException(String.Concat("Object store is not part of the transaction scope: ", name)); + } + + return new IndexedDbObjectStore(() => store, Mode != IndexedDbTransactionMode.ReadOnly); + } + + public void Commit() + { + EnsureActive(); + + if (Mode != IndexedDbTransactionMode.ReadOnly) + { + foreach (var store in _stores) + { + _database.SetStore(store.Key, CloneStore(store.Value)); + } + } + + _isCompleted = true; + _isAborted = false; + ReleaseLockIfNeeded(); + Completed?.Invoke(); + } + + public void Abort() + { + EnsureActive(); + _isCompleted = true; + _isAborted = true; + ReleaseLockIfNeeded(); + Aborted?.Invoke(); + } + + public void Dispose() + { + if (!_isCompleted) + { + Abort(); + } + } + + private static IndexedDbStoreBucket CloneStore(IndexedDbStoreBucket store) + { + return store?.Clone() ?? new IndexedDbStoreBucket(); + } + + private void EnsureActive() + { + if (_isCompleted) + { + throw new InvalidOperationException("The IndexedDB transaction is already completed."); + } + } + + private void ReleaseLockIfNeeded() + { + if (_holdsWriteLock) + { + _database.EndTransaction(Mode); + } + } + } + + internal sealed class IndexedDbUpgradeTransaction : IIndexedDbUpgradeTransaction + { + private readonly IndexedDbDatabaseBucket _database; + private readonly Dictionary _stores; + + public IndexedDbUpgradeTransaction(IndexedDbDatabaseBucket database, Int64 oldVersion, Int64 newVersion) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + OldVersion = oldVersion; + NewVersion = newVersion; + _stores = _database.CloneStores(); + } + + public Int64 OldVersion { get; } + + public Int64 NewVersion { get; } + + public IIndexedDbObjectStore CreateObjectStore(String name) + { + name = name ?? String.Empty; + + if (!_stores.TryGetValue(name, out var store)) + { + store = new IndexedDbStoreBucket(); + _stores[name] = store; + } + + return new IndexedDbObjectStore(() => store, true); + } + + public Boolean DeleteObjectStore(String name) + { + return _stores.Remove(name ?? String.Empty); + } + + public IIndexedDbObjectStore GetObjectStore(String name) + { + name = name ?? String.Empty; + + if (_stores.TryGetValue(name, out var store)) + { + return new IndexedDbObjectStore(() => store, true); + } + + return null; + } + + public void Commit() + { + _database.ReplaceStores(_stores); + _database.Version = NewVersion; + } + } + + internal sealed class IndexedDbDatabaseBucket + { + private readonly Dictionary _stores; + private readonly List> _connections; + + private Int32 _openConnections; + private Boolean _hasActiveReadWriteTransaction; + + public IndexedDbDatabaseBucket(String name) + { + Name = name ?? String.Empty; + _stores = new Dictionary(StringComparer.Ordinal); + _connections = new List>(); + } + + public String Name { get; } + + public Int64 Version { get; set; } + + public Int32 OpenConnections => _openConnections; + + public event Action ConnectionsChanged; + + public void BeginTransaction(IndexedDbTransactionMode mode) + { + if (mode == IndexedDbTransactionMode.ReadWrite) + { + if (_hasActiveReadWriteTransaction) + { + throw new InvalidOperationException("Another read-write transaction is already active for this database."); + } + + _hasActiveReadWriteTransaction = true; + } + } + + public void EndTransaction(IndexedDbTransactionMode mode) + { + if (mode == IndexedDbTransactionMode.ReadWrite) + { + _hasActiveReadWriteTransaction = false; + } + } + + public void OpenConnection(IndexedDbDatabase connection) + { + if (connection != null) + { + _connections.Add(new WeakReference(connection)); + } + + _openConnections++; + ConnectionsChanged?.Invoke(); + } + + public void CloseConnection(IndexedDbDatabase connection) + { + if (_openConnections > 0) + { + _openConnections--; + } + + CleanupConnections(); + ConnectionsChanged?.Invoke(); + } + + public void NotifyVersionChangeRequested(Int64 oldVersion, Int64 newVersion) + { + CleanupConnections(); + + for (var i = 0; i < _connections.Count; i++) + { + if (_connections[i].TryGetTarget(out var connection)) + { + connection.NotifyVersionChangeRequested(oldVersion, newVersion); + } + } + } + + private void CleanupConnections() + { + for (var i = _connections.Count - 1; i >= 0; i--) + { + if (!_connections[i].TryGetTarget(out var connection) || connection.IsClosed) + { + _connections.RemoveAt(i); + } + } + } + + public IndexedDbStoreBucket GetOrCreateStore(String name) + { + name = name ?? String.Empty; + + if (!_stores.TryGetValue(name, out var store)) + { + store = new IndexedDbStoreBucket(); + _stores[name] = store; + } + + return store; + } + + public IndexedDbStoreBucket GetStore(String name) + { + name = name ?? String.Empty; + return _stores.TryGetValue(name, out var store) ? store : null; + } + + public void SetStore(String name, IndexedDbStoreBucket store) + { + _stores[name ?? String.Empty] = store ?? new IndexedDbStoreBucket(); + } + + public Boolean DeleteStore(String name) + { + return _stores.Remove(name ?? String.Empty); + } + + public Dictionary CloneStores() + { + return _stores.ToDictionary(m => m.Key, m => m.Value.Clone(), StringComparer.Ordinal); + } + + public void ReplaceStores(Dictionary stores) + { + _stores.Clear(); + + if (stores == null) + { + return; + } + + foreach (var store in stores) + { + _stores[store.Key] = store.Value.Clone(); + } + } + } + + internal sealed class IndexedDbStoreBucket + { + private readonly Dictionary _indexes; + + public IndexedDbStoreBucket() + { + Records = new StorageBucket(); + _indexes = new Dictionary(StringComparer.Ordinal); + } + + private IndexedDbStoreBucket(StorageBucket records, Dictionary indexes) + { + Records = records ?? new StorageBucket(); + _indexes = indexes ?? new Dictionary(StringComparer.Ordinal); + } + + public StorageBucket Records { get; } + + public IndexedDbIndexDefinition CreateOrGetIndex(String name, String keyPath, Boolean unique) + { + name = name ?? String.Empty; + keyPath = keyPath ?? String.Empty; + + if (_indexes.TryGetValue(name, out var existing)) + { + if (!String.Equals(existing.KeyPath, keyPath, StringComparison.Ordinal) || existing.Unique != unique) + { + throw new InvalidOperationException(String.Concat("Index already exists with different definition: ", name)); + } + + return existing; + } + + var index = new IndexedDbIndexDefinition(name, keyPath, unique); + _indexes[name] = index; + return index; + } + + public Boolean TryGetIndex(String name, out IndexedDbIndexDefinition index) + { + return _indexes.TryGetValue(name ?? String.Empty, out index); + } + + public Boolean DeleteIndex(String name) + { + return _indexes.Remove(name ?? String.Empty); + } + + public List GetIndexes() + { + return _indexes.Values.ToList(); + } + + public IndexedDbStoreBucket Clone() + { + var records = new StorageBucket(Records.ToEntries()); + var indexes = _indexes.ToDictionary(m => m.Key, m => m.Value.Clone(), StringComparer.Ordinal); + return new IndexedDbStoreBucket(records, indexes); + } + } + + internal sealed class IndexedDbIndexDefinition + { + public IndexedDbIndexDefinition(String name, String keyPath, Boolean unique) + { + Name = name ?? String.Empty; + KeyPath = keyPath ?? String.Empty; + Unique = unique; + } + + public String Name { get; } + + public String KeyPath { get; } + + public Boolean Unique { get; } + + public IndexedDbIndexDefinition Clone() + { + return new IndexedDbIndexDefinition(Name, KeyPath, Unique); + } + } + + internal sealed class IndexedDbBlockedException : InvalidOperationException + { + public IndexedDbBlockedException(String message) + : base(message) { - return _stores.Remove(name ?? String.Empty); } } } \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbCursor.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbCursor.cs new file mode 100644 index 0000000..6a08a74 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbCursor.cs @@ -0,0 +1,48 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents a cursor over IndexedDB records. + /// + [DomName("IDBCursor")] + [DomExposed("Window")] + public interface IIndexedDbCursor + { + /// + /// Gets the current primary key. + /// + [DomName("key")] + String Key { get; } + + /// + /// Gets the current value. + /// + [DomName("value")] + String Value { get; } + + /// + /// Moves the cursor to the next matching record. + /// + /// The next cursor position or null. + [DomName("continue")] + IIndexedDbCursor Continue(); + } + + /// + /// Specifies cursor traversal direction. + /// + public enum IndexedDbCursorDirection + { + /// + /// Ascending key order. + /// + Next, + + /// + /// Descending key order. + /// + Prev, + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs index 56d9e62..c00a184 100644 --- a/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbDatabase.cs @@ -16,6 +16,23 @@ public interface IIndexedDbDatabase [DomName("name")] String Name { get; } + /// + /// Gets the database version. + /// + [DomName("version")] + Int64 Version { get; } + + /// + /// Gets if this connection has been closed. + /// + [DomName("closed")] + Boolean IsClosed { get; } + + /// + /// Raised when another open request needs this connection to close for a version upgrade. + /// + event Action VersionChangeRequested; + /// /// Gets or creates the object store with the given name. /// @@ -31,5 +48,29 @@ public interface IIndexedDbDatabase /// True if the store existed and was removed. [DomName("deleteObjectStore")] Boolean DeleteObjectStore(String name); + + /// + /// Begins a transaction for a single object store. + /// + /// The store included in the transaction scope. + /// The transaction mode. + /// The created transaction. + [DomName("transaction")] + IIndexedDbTransaction BeginTransaction(String storeName, IndexedDbTransactionMode mode); + + /// + /// Begins a transaction for multiple object stores. + /// + /// The stores included in the transaction scope. + /// The transaction mode. + /// The created transaction. + [DomName("transaction")] + IIndexedDbTransaction BeginTransaction(String[] storeNames, IndexedDbTransactionMode mode); + + /// + /// Closes the database connection. + /// + [DomName("close")] + void Close(); } } \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs index c93a1cb..551d73b 100644 --- a/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbFactory.cs @@ -18,6 +18,64 @@ public interface IIndexedDbFactory [DomName("open")] IIndexedDbDatabase Open(String name); + /// + /// Opens the database at the requested version. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The database instance. + [DomName("open")] + IIndexedDbDatabase Open(String name, Int64 version); + + /// + /// Opens the database at the requested version and runs upgrade logic if needed. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The callback used for upgrade changes. + /// The database instance. + [DomName("open")] + IIndexedDbDatabase Open(String name, Int64 version, Action upgrade); + + /// + /// Opens or creates the database with the given name using a request wrapper. + /// + /// The database name. + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name); + + /// + /// Opens the database at the requested version using a request wrapper. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name, Int64 version); + + /// + /// Opens the database at the requested version using a request wrapper and optional upgrade callback. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The callback used for upgrade changes. + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade); + + /// + /// Opens the database at the requested version using a request wrapper and optional upgrade callback. + /// If blocked by open connections, the request waits and retries automatically when connections close. + /// + /// The database name. + /// The requested version (must be greater than 0). + /// The callback used for upgrade changes. + /// True to keep retrying when blocked until the request can proceed. + /// The open request. + [DomName("open")] + IIndexedDbRequest OpenRequest(String name, Int64 version, Action upgrade, Boolean waitForUnblock); + /// /// Deletes the database with the given name. /// @@ -25,5 +83,23 @@ public interface IIndexedDbFactory /// True if the database existed and was removed. [DomName("deleteDatabase")] Boolean DeleteDatabase(String name); + + /// + /// Deletes the database with the given name using a request wrapper. + /// + /// The database name. + /// The delete request. + [DomName("deleteDatabase")] + IIndexedDbRequest DeleteDatabaseRequest(String name); + + /// + /// Deletes the database with the given name using a request wrapper. + /// If blocked by open connections, the request waits and retries automatically when connections close. + /// + /// The database name. + /// True to keep retrying when blocked until the delete can proceed. + /// The delete request. + [DomName("deleteDatabase")] + IIndexedDbRequest DeleteDatabaseRequest(String name, Boolean waitForUnblock); } } \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbIndex.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbIndex.cs new file mode 100644 index 0000000..b754b77 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbIndex.cs @@ -0,0 +1,80 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Collections.Generic; + + /// + /// Represents an IndexedDB index. + /// + [DomName("IDBIndex")] + [DomExposed("Window")] + public interface IIndexedDbIndex + { + /// + /// Gets the index name. + /// + [DomName("name")] + String Name { get; } + + /// + /// Gets the key-path key used for index extraction. + /// + [DomName("keyPath")] + String KeyPath { get; } + + /// + /// Gets whether duplicate index values are disallowed. + /// + [DomName("unique")] + Boolean IsUnique { get; } + + /// + /// Gets the first value for the given index key. + /// + /// The index key. + /// The value if any. + [DomName("get")] + String Get(String indexKey); + + /// + /// Gets all values for the given index key. + /// + /// The index key. + /// The matching values. + [DomName("getAll")] + IReadOnlyList GetAll(String indexKey); + + /// + /// Gets all values matching the index key range. + /// + /// The key range filter. + /// The matching values. + [DomName("getAll")] + IReadOnlyList GetAll(IndexedDbKeyRange range); + + /// + /// Counts records for the given index key. + /// + /// The index key. + /// The record count. + [DomName("count")] + Int32 Count(String indexKey); + + /// + /// Opens a cursor over all index entries. + /// + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(); + + /// + /// Opens a cursor over index entries matching the key range. + /// + /// The index key range filter. + /// The cursor direction. + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next); + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs index 5a82cd1..7f76218 100644 --- a/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbObjectStore.cs @@ -1,6 +1,8 @@ namespace AngleSharp.Io.Dom { using AngleSharp.Attributes; + using System; + using System.Collections.Generic; /// /// Represents an IndexedDB object store. @@ -9,5 +11,101 @@ namespace AngleSharp.Io.Dom [DomExposed("Window")] public interface IIndexedDbObjectStore : IStorage { + /// + /// Adds a value under the given key if the key does not exist. + /// + /// The key to insert. + /// The value to store. + [DomName("add")] + void Add(String key, String value); + + /// + /// Puts a value under the given key, replacing an existing value if present. + /// + /// The key to insert or replace. + /// The value to store. + [DomName("put")] + void Put(String key, String value); + + /// + /// Gets the value under the given key. + /// + /// The key to look up. + /// The value or null. + [DomName("get")] + String Get(String key); + + /// + /// Deletes the value under the given key. + /// + /// The key to delete. + /// True if a value was deleted. + [DomName("delete")] + Boolean Delete(String key); + + /// + /// Gets the number of records in the object store. + /// + /// The number of records. + [DomName("count")] + Int32 Count(); + + /// + /// Gets all values for the given key range. + /// + /// The key range filter. + /// The matching values. + [DomName("getAll")] + IReadOnlyList GetAll(IndexedDbKeyRange range); + + /// + /// Counts values for the given key range. + /// + /// The key range filter. + /// The number of matching values. + [DomName("count")] + Int32 Count(IndexedDbKeyRange range); + + /// + /// Creates or gets an index for the object store. + /// + /// The index name. + /// The key-path key used for extracting index values from records. + /// True if duplicate index keys are disallowed. + /// The index object. + [DomName("createIndex")] + IIndexedDbIndex CreateIndex(String name, String keyPath, Boolean unique = false); + + /// + /// Gets an existing index. + /// + /// The index name. + /// The index object if found. + [DomName("index")] + IIndexedDbIndex GetIndex(String name); + + /// + /// Deletes an existing index. + /// + /// The index name. + /// True if the index existed and was removed. + [DomName("deleteIndex")] + Boolean DeleteIndex(String name); + + /// + /// Opens a cursor over all records in key order. + /// + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(); + + /// + /// Opens a cursor constrained by a key range. + /// + /// The key range filter. + /// The cursor direction. + /// The first cursor position or null. + [DomName("openCursor")] + IIndexedDbCursor OpenCursor(IndexedDbKeyRange range, IndexedDbCursorDirection direction = IndexedDbCursorDirection.Next); } } \ No newline at end of file diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbRequest.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbRequest.cs new file mode 100644 index 0000000..a12ddf8 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbRequest.cs @@ -0,0 +1,91 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Threading.Tasks; + + /// + /// Represents a lightweight request wrapper for IndexedDB operations. + /// + /// The result type. + [DomName("IDBRequest")] + [DomExposed("Window")] + public interface IIndexedDbRequest + { + /// + /// Gets the current request state. + /// + [DomName("state")] + IndexedDbRequestState State { get; } + + /// + /// Gets whether the request has completed. + /// + [DomName("done")] + Boolean IsCompleted { get; } + + /// + /// Gets whether the request failed. + /// + [DomName("error")] + Boolean HasError { get; } + + /// + /// Gets the result value if successful. + /// + [DomName("result")] + T Result { get; } + + /// + /// Gets the request error if any. + /// + [DomName("exception")] + Exception Exception { get; } + + /// + /// Gets the task representing request completion. + /// + Task WaitAsync(); + + /// + /// Raised when the request succeeds. + /// + event Action Succeeded; + + /// + /// Raised when the request fails. + /// + event Action Failed; + + /// + /// Raised when the request is blocked by open connections. + /// + event Action Blocked; + } + + /// + /// Represents the state of an IndexedDB request. + /// + public enum IndexedDbRequestState + { + /// + /// Request has not completed yet. + /// + Pending, + + /// + /// Request finished successfully. + /// + Success, + + /// + /// Request failed with an error. + /// + Error, + + /// + /// Request is blocked by open connections. + /// + Blocked, + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbTransaction.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbTransaction.cs new file mode 100644 index 0000000..95a0ac3 --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbTransaction.cs @@ -0,0 +1,82 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents a transaction scope for IndexedDB operations. + /// + [DomName("IDBTransaction")] + [DomExposed("Window")] + public interface IIndexedDbTransaction : IDisposable + { + /// + /// Gets the transaction mode. + /// + [DomName("mode")] + IndexedDbTransactionMode Mode { get; } + + /// + /// Gets if the transaction has completed. + /// + [DomName("done")] + Boolean IsCompleted { get; } + + /// + /// Gets if the transaction has been aborted. + /// + [DomName("aborted")] + Boolean IsAborted { get; } + + /// + /// Gets an object store in the transaction scope. + /// + /// The object store name. + /// The object store. + [DomName("objectStore")] + IIndexedDbObjectStore GetObjectStore(String name); + + /// + /// Commits the transaction. + /// + [DomName("commit")] + void Commit(); + + /// + /// Aborts the transaction. + /// + [DomName("abort")] + void Abort(); + + /// + /// Raised when the transaction completes successfully. + /// + event Action Completed; + + /// + /// Raised when the transaction is aborted. + /// + event Action Aborted; + } + + /// + /// Specifies the transaction mode. + /// + public enum IndexedDbTransactionMode + { + /// + /// Read-only transaction. + /// + ReadOnly, + + /// + /// Read-write transaction. + /// + ReadWrite, + + /// + /// Version-change transaction used during upgrades. + /// + VersionChange, + } +} diff --git a/src/AngleSharp.Io/Interfaces/IIndexedDbUpgradeTransaction.cs b/src/AngleSharp.Io/Interfaces/IIndexedDbUpgradeTransaction.cs new file mode 100644 index 0000000..428b7fa --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IIndexedDbUpgradeTransaction.cs @@ -0,0 +1,49 @@ +namespace AngleSharp.Io.Dom +{ + using AngleSharp.Attributes; + using System; + + /// + /// Represents the version-change transaction passed during upgrades. + /// + [DomName("IDBVersionChangeTransaction")] + [DomExposed("Window")] + public interface IIndexedDbUpgradeTransaction + { + /// + /// Gets the old database version. + /// + [DomName("oldVersion")] + Int64 OldVersion { get; } + + /// + /// Gets the new database version. + /// + [DomName("newVersion")] + Int64 NewVersion { get; } + + /// + /// Creates or gets an object store within the upgrade transaction. + /// + /// The object store name. + /// The object store. + [DomName("createObjectStore")] + IIndexedDbObjectStore CreateObjectStore(String name); + + /// + /// Deletes an object store within the upgrade transaction. + /// + /// The object store name. + /// True if the store existed and was removed. + [DomName("deleteObjectStore")] + Boolean DeleteObjectStore(String name); + + /// + /// Gets an existing object store in the upgrade transaction. + /// + /// The object store name. + /// The object store. + [DomName("objectStore")] + IIndexedDbObjectStore GetObjectStore(String name); + } +} diff --git a/src/AngleSharp.Io/Interfaces/IndexedDbKeyRange.cs b/src/AngleSharp.Io/Interfaces/IndexedDbKeyRange.cs new file mode 100644 index 0000000..79165ed --- /dev/null +++ b/src/AngleSharp.Io/Interfaces/IndexedDbKeyRange.cs @@ -0,0 +1,100 @@ +namespace AngleSharp.Io.Dom +{ + using System; + + /// + /// Represents a key range used for IndexedDB lookups. + /// + public sealed class IndexedDbKeyRange + { + private IndexedDbKeyRange(String lower, String upper, Boolean lowerOpen, Boolean upperOpen) + { + Lower = lower; + Upper = upper; + LowerOpen = lowerOpen; + UpperOpen = upperOpen; + } + + /// + /// Gets the lower bound key. + /// + public String Lower { get; } + + /// + /// Gets the upper bound key. + /// + public String Upper { get; } + + /// + /// Gets if the lower bound is open. + /// + public Boolean LowerOpen { get; } + + /// + /// Gets if the upper bound is open. + /// + public Boolean UpperOpen { get; } + + /// + /// Creates a range that includes exactly one key. + /// + public static IndexedDbKeyRange Only(String value) + { + return new IndexedDbKeyRange(value, value, false, false); + } + + /// + /// Creates a lower-bounded range. + /// + public static IndexedDbKeyRange LowerBound(String lower, Boolean open = false) + { + return new IndexedDbKeyRange(lower, null, open, false); + } + + /// + /// Creates an upper-bounded range. + /// + public static IndexedDbKeyRange UpperBound(String upper, Boolean open = false) + { + return new IndexedDbKeyRange(null, upper, false, open); + } + + /// + /// Creates a bounded range. + /// + public static IndexedDbKeyRange Bound(String lower, String upper, Boolean lowerOpen = false, Boolean upperOpen = false) + { + return new IndexedDbKeyRange(lower, upper, lowerOpen, upperOpen); + } + + internal Boolean Includes(String key) + { + if (key == null) + { + return false; + } + + if (Lower != null) + { + var lowerCompare = StringComparer.Ordinal.Compare(key, Lower); + + if (lowerCompare < 0 || (lowerCompare == 0 && LowerOpen)) + { + return false; + } + } + + if (Upper != null) + { + var upperCompare = StringComparer.Ordinal.Compare(key, Upper); + + if (upperCompare > 0 || (upperCompare == 0 && UpperOpen)) + { + return false; + } + } + + return true; + } + } +}