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/.fallout/build.schema.json b/.fallout/build.schema.json new file mode 100644 index 0000000..82690d1 --- /dev/null +++ b/.fallout/build.schema.json @@ -0,0 +1,145 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "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", + "CreatePackage", + "Default", + "Package", + "PrePublish", + "Publish", + "PublishPackage", + "PublishPreRelease", + "PublishRelease", + "Restore", + "RunUnitTests" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "FalloutBuild": { + "properties": { + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "description": "Host for execution. Default is 'automatic'", + "$ref": "#/definitions/Host" + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "Partition": { + "type": "string", + "description": "Partition to use on CI" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "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." + } + } + } + }, + "allOf": [ + { + "properties": { + "AngleSharpVersion": { + "type": "string", + "description": "AngleSharp package version override (e.g. 1.0.0 for compatibility checks)" + }, + "Configuration": { + "type": "string", + "enum": [ + "Debug", + "Release" + ], + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)" + }, + "ReleaseNotesFilePath": { + "type": "string", + "description": "ReleaseNotesFilePath - To determine the SemanticVersion" + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + } + } + }, + { + "$ref": "#/definitions/FalloutBuild" + } + ] +} diff --git a/.fallout/parameters.json b/.fallout/parameters.json new file mode 100644 index 0000000..1f4de4c --- /dev/null +++ b/.fallout/parameters.json @@ -0,0 +1,4 @@ +{ + "$schema": "./build.schema.json", + "Solution": "src/AngleSharp.Wasm.sln" +} \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c3cc16d..f63cc11 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -43,7 +43,7 @@ AngleSharp and its libraries uses features from the latest versions of C# (e.g., AngleSharp itself does not have dependencies, however, the tests are dependent on NUnit. -The build system of AngleSharp uses Cake. A bootstrap script (build.ps1 for Windows or build.sh for *nix systems) is included. Note, that at the moment AngleSharp may require NuGet 3.5, which looks for MSBuild pre-15, i.e., before Visual Studio 2017 on Windows systems. We aim to drop this requirement entirely soon. +The build system of AngleSharp uses Fallout. Bootstrap scripts (build.cmd, build.ps1, and build.sh) are included. ### Code Conventions @@ -64,7 +64,7 @@ There are a couple of rules, which are definitely not standard, but highly recom 1. If no issue already exists for the work you'll be doing, create one to document the problem(s) being solved and self-assign. 2. Otherwise please let us know that you are working on the problem. Regular status updates (e.g. "still in progress", "no time anymore", "practically done", "pull request issued") are highly welcome. -3. Create a new branch—please don't work in the `master` branch directly. It is reserved for releases. We recommend naming the branch to match the issue being addressed (`feature/#777` or `issue-777`). +3. Create a new branch—please don't work in the `main` branch directly. It is reserved for releases. We recommend naming the branch to match the issue being addressed (`feature/#777` or `issue-777`). 4. Add failing tests for the change you want to make. Tests are crucial and should be taken from W3C (or other specification). 5. Fix stuff. Always go from edge case to edge case. 6. All tests should pass now. Also your new implementation should not break existing tests. @@ -73,7 +73,7 @@ There are a couple of rules, which are definitely not standard, but highly recom Just to illustrate the git workflow for AngleSharp a little bit more we've added the following graphs. -Initially, AngleSharp starts at the `master` branch. This branch should contain the latest stable (or released) version. +Initially, AngleSharp starts at the `main` branch. This branch should contain the latest stable (or released) version. Here we now created a new branch called `devel`. This is the development branch. @@ -101,7 +101,7 @@ git push Finally, we may have all the features that are needed to release a new version of AngleSharp. Here we tag the release. For instance for the 1.0 release we use `v1.0`. ```sh -git checkout master +git checkout main git merge devel git tag v1.0 ``` @@ -119,8 +119,8 @@ The following files should not be edited directly in the current repository, but .github/* build.ps1 build.sh -tools/anglesharp.cake -tools/packages.config +build.cmd +build/* LICENSE ``` @@ -128,7 +128,7 @@ To sync manually: ```sh git remote add gitbase git@github.com:AngleSharp/AngleSharp.GitBase.git -git pull gitbase master +git pull gitbase main ``` ### Versioning 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'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e224ca8..abf3672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,12 +26,12 @@ 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: "14.x" + node-version: "20.x" registry-url: 'https://registry.npmjs.org' - name: Install Dependencies @@ -48,23 +48,35 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 + + - name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x - name: Build - run: ./build.sh + run: ./build.sh -AngleSharpVersion 1.5.0 windows: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 + + - name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 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/.gitignore b/.gitignore index 39a64c7..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 @@ -194,3 +196,6 @@ pip-log.txt # Mac crap .DS_Store + +# Fallout build tool +.fallout/temp diff --git a/CHANGELOG.md b/CHANGELOG.md index fad7309..79e0d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.0.0 + +Released on Friday, July 31 2026. + +- Added `wasmtime` as Wasm engine +- Added integration with exposure as DOM objects + # 0.13.0 Released on Sunday, June 6 2021. diff --git a/LICENSE b/LICENSE index fddded2..eae958a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 - 2021 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 diff --git a/README.md b/README.md index 175d29e..11982fd 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,25 @@ -![logo](https://raw.githubusercontent.com/AngleSharp/AngleSharp.Wasm/master/header.png) +![logo](https://raw.githubusercontent.com/AngleSharp/AngleSharp.Wasm/main/header.png) # AngleSharp.Wasm -[![Build Status](https://img.shields.io/appveyor/ci/FlorianRappl/AngleSharp-Wasm.svg?style=flat-square)](https://ci.appveyor.com/project/FlorianRappl/AngleSharp-Wasm) +[![CI](https://github.com/AngleSharp/AngleSharp.Wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/AngleSharp/AngleSharp.Wasm/actions/workflows/ci.yml) [![GitHub Tag](https://img.shields.io/github/tag/AngleSharp/AngleSharp.Wasm.svg?style=flat-square)](https://github.com/AngleSharp/AngleSharp.Wasm/releases) [![NuGet Count](https://img.shields.io/nuget/dt/AngleSharp.Wasm.svg?style=flat-square)](https://www.nuget.org/packages/AngleSharp.Wasm/) [![Issues Open](https://img.shields.io/github/issues/AngleSharp/AngleSharp.Wasm.svg?style=flat-square)](https://github.com/AngleSharp/AngleSharp.Wasm/issues) -[![Gitter Chat](http://img.shields.io/badge/gitter-AngleSharp/AngleSharp-blue.svg?style=flat-square)](https://gitter.im/AngleSharp/AngleSharp) -[![StackOverflow Questions](https://img.shields.io/stackexchange/stackoverflow/t/anglesharp.svg?style=flat-square)](https://stackoverflow.com/tags/anglesharp) [![CLA Assistant](https://cla-assistant.io/readme/badge/AngleSharp/AngleSharp.Wasm?style=flat-square)](https://cla-assistant.io/AngleSharp/AngleSharp.Wasm) AngleSharp.Wasm extends the core AngleSharp library with the ability to run WebAssembly. This repository is the home of the source for the AngleSharp.Wasm NuGet package. +## Documentation + +Further documentation is available in the `docs` folder: + +- [Getting Started](docs/general/01-Basics.md) +- [Spec Coverage Matrix](docs/general/02-Spec-Coverage.md) +- [API Documentation](docs/tutorials/01-API.md) +- [Examples](docs/tutorials/02-Examples.md) +- [Frequently Asked Questions](docs/tutorials/03-Questions.md) + ## Basic Configuration If you just want a configuration *that works* you should use the following code: @@ -23,9 +31,82 @@ var config = Configuration.Default This will register everything related for running WebAssembly. +## Host Imports + +If your module imports host functions, register imports via `WithWasmImports(...)`: + +```cs +var config = Configuration.Default + .WithWasm() + .WithWasmImports(_ => + { + return new[] + { + new WasmImportFunction( + "host", + "answer", + Array.Empty(), + new[] { WasmValueType.Int32 }, + _ => 41), + }; + }); +``` + +## DOM / JS Integration + +When using a scripting setup that discovers DOM-annotated members (for example, AngleSharp.Js with registered assemblies), AngleSharp.Wasm exposes a WebAssembly surface under `window.WebAssembly`. + +Available bridge capabilities include: + +- Compile module bytes +- Instantiate compiled modules +- Inspect module imports / exports / custom sections +- Access instance exports and invoke exported functions + +Typical flow: + +```cs +var module = WebAssembly.Compile(context.Current!, wasmBytes); +var instance = WebAssembly.Instantiate(context.Current!, module); +var result = instance.Invoke("answer"); +``` + +## Runtime Backend + +The current default backend uses [Wasmtime](https://github.com/bytecodealliance/wasmtime-dotnet) and targets modern .NET runtimes. + +## Target Frameworks + +The current package targets `net8.0` and `net10.0`. + ## Features -(tbd) +- Runtime registration via `WithWasm()` +- Optional host import registration via `WithWasmImports(...)` +- Default Wasmtime backend +- Module metadata extraction + - `exports()` + - `imports()` + - `customSections(name)` +- Instance export access + - `exports` lookup by export name + - export key enumeration +- Export invocation helpers + - `instance.Invoke(...)` + - `WasmJsExportedFunction.Invoke(...)` +- Multi-target support for `net8.0` and `net10.0` + +## Current Scope and Limitations + +AngleSharp.Wasm currently provides a practical subset of the WebAssembly JS API. + +- Bridge methods are synchronous from the caller perspective. +- Promise-based namespace operations are not currently exposed. +- `validate(...)` and streaming APIs are not yet implemented. +- Compile options such as builtins / imported string constants are not yet implemented. +- Non-function exports are currently represented as descriptors (`name`, `kind`) rather than full `Memory` / `Table` / `Global` / `Tag` objects. + +See [Spec Coverage Matrix](docs/general/02-Spec-Coverage.md) for a section-by-section status overview. ## Participating @@ -33,8 +114,6 @@ Participation in the project is highly welcome. For this project the same rules If you have any question, concern, or spot an issue then please report it before opening a pull request. An initial discussion is appreciated regardless of the nature of the problem. -Live discussions can take place in our [Gitter chat](https://gitter.im/AngleSharp/AngleSharp), which supports using GitHub accounts. - This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community. For more information see the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/code-of-conduct). @@ -45,12 +124,4 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) ## License -The MIT License (MIT) - -Copyright (c) 2019 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.Wasm is released using the MIT license. For more information see the [license file](./LICENSE). diff --git a/build.cake b/build.cake deleted file mode 100644 index c6a5d61..0000000 --- a/build.cake +++ /dev/null @@ -1,11 +0,0 @@ -var target = Argument("target", "Default"); -var projectName = "AngleSharp.Wasm"; -var solutionName = "AngleSharp.Wasm"; -var frameworks = new Dictionary -{ - { "netstandard2.0", "netstandard2.0" }, -}; - -#load tools/anglesharp.cake - -RunTarget(target); diff --git a/build.cmd b/build.cmd new file mode 100755 index 0000000..b08cc59 --- /dev/null +++ b/build.cmd @@ -0,0 +1,7 @@ +:; set -eo pipefail +:; SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) +:; ${SCRIPT_DIR}/build.sh "$@" +:; exit $? + +@ECHO OFF +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 index 6f27bfd..61c6628 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,81 +1,68 @@ +[CmdletBinding()] Param( - [string]$Script = "build.cake", - [string]$Target = "Default", - [ValidateSet("Release", "Debug")] - [string]$Configuration = "Release", - [ValidateSet("Quiet", "Minimal", "Normal", "Verbose", "Diagnostic")] - [string]$Verbosity = "Verbose", - [switch]$Experimental, - [switch]$WhatIf, - [switch]$Mono, - [switch]$SkipToolPackageRestore, [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] - [string[]]$ScriptArgs + [string[]]$BuildArguments ) -$PSScriptRoot = split-path -parent $MyInvocation.MyCommand.Definition; -$UseDryRun = ""; -$UseMono = ""; -$TOOLS_DIR = Join-Path $PSScriptRoot "tools" -$NUGET_EXE = Join-Path $TOOLS_DIR "nuget.exe" -$NUGET_OLD_EXE = Join-Path $TOOLS_DIR "nuget_old.exe" -$CAKE_EXE = Join-Path $TOOLS_DIR "Cake/Cake.exe" -$NUGET_URL = "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -$NUGET_OLD_URL = "https://dist.nuget.org/win-x86-commandline/v3.5.0/nuget.exe" +Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)" -# Should we use experimental build of Roslyn? -$UseExperimental = ""; -if ($Experimental.IsPresent) { - $UseExperimental = "--experimental" -} +Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 } +$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent -# Is this a dry run? -if ($WhatIf.IsPresent) { - $UseDryRun = "--dryrun" -} +########################################################################### +# CONFIGURATION +########################################################################### -# Should we use mono? -if ($Mono.IsPresent) { - $UseMono = "--mono" -} +$TempDirectory = "$PSScriptRoot\.fallout\temp" -# Try download NuGet.exe if do not exist. -if (!(Test-Path $NUGET_EXE)) { - (New-Object System.Net.WebClient).DownloadFile($NUGET_URL, $NUGET_EXE) -} +$DotNetGlobalFile = "$PSScriptRoot\global.json" +$DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" +$DotNetChannel = "STS" -# Try download NuGet.exe if do not exist. -if (!(Test-Path $NUGET_OLD_URL)) { - (New-Object System.Net.WebClient).DownloadFile($NUGET_OLD_URL, $NUGET_OLD_EXE) -} +$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 +$env:DOTNET_NOLOGO = 1 -# Make sure NuGet (latest) exists where we expect it. -if (!(Test-Path $NUGET_EXE)) { - Throw "Could not find nuget.exe" +########################################################################### +# EXECUTION +########################################################################### + +function ExecSafe([scriptblock] $cmd) { + & $cmd + if ($LASTEXITCODE) { exit $LASTEXITCODE } } -# Make sure NuGet (v3.5.0) exists where we expect it. -if (!(Test-Path $NUGET_OLD_EXE)) { - Throw "Could not find nuget_old.exe" +# 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 } +else { + # Download install script + $DotNetInstallFile = "$TempDirectory\dotnet-install.ps1" + New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + (New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile) -# Restore tools from NuGet? -if (-Not $SkipToolPackageRestore.IsPresent) -{ - Push-Location - Set-Location $TOOLS_DIR - Invoke-Expression "$NUGET_EXE install -ExcludeVersion" - Pop-Location - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE + # If global.json exists, load expected version + if (Test-Path $DotNetGlobalFile) { + $DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json) + if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) { + $DotNetVersion = $DotNetGlobal.sdk.version + } } -} -# Make sure that Cake has been installed. -if (!(Test-Path $CAKE_EXE)) { - Throw "Could not find Cake.exe" + # Install by channel or version + $DotNetDirectory = "$TempDirectory\dotnet-win" + if (!(Test-Path variable:DotNetVersion)) { + ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath } + } else { + ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } + } + $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" + $env:PATH = "$DotNetDirectory;$env:PATH" } -# Start Cake -Invoke-Expression "$CAKE_EXE `"$Script`" --target=`"$Target`" --configuration=`"$Configuration`" --verbosity=`"$Verbosity`" $UseMono $UseDryRun $UseExperimental $ScriptArgs" -exit $LASTEXITCODE \ No newline at end of file +Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" + +ExecSafe { & $env:DOTNET_EXE tool restore } +ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments } diff --git a/build.sh b/build.sh index c62ce5c..fc76bf8 100755 --- a/build.sh +++ b/build.sh @@ -1,93 +1,61 @@ #!/usr/bin/env bash -############################################################### -# This is the Cake bootstrapper script that is responsible for -# downloading Cake and all specified tools from NuGet. -############################################################### -# Define directories. -SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) -TOOLS_DIR=$SCRIPT_DIR/tools -NUGET_EXE=$TOOLS_DIR/nuget.exe -NUGET_OLD_EXE=$TOOLS_DIR/nuget_old.exe -CAKE_EXE=$TOOLS_DIR/Cake/Cake.exe +bash --version 2>&1 | head -n 1 -# Define default arguments. -SCRIPT="build.cake" -TARGET="Default" -CONFIGURATION="Release" -VERBOSITY="verbose" -DRYRUN= -SHOW_VERSION=false -SCRIPT_ARGUMENTS=() +set -eo pipefail +SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) -# Parse arguments. -for i in "$@"; do - case $1 in - -s|--script) SCRIPT="$2"; shift ;; - -t|--target) TARGET="$2"; shift ;; - -c|--configuration) CONFIGURATION="$2"; shift ;; - -v|--verbosity) VERBOSITY="$2"; shift ;; - -d|--dryrun) DRYRUN="--dryrun" ;; - --version) SHOW_VERSION=true ;; - --) shift; SCRIPT_ARGUMENTS+=("$@"); break ;; - *) SCRIPT_ARGUMENTS+=("$1") ;; - esac - shift -done +########################################################################### +# CONFIGURATION +########################################################################### -# Make sure the tools folder exist. -if [ ! -d $TOOLS_DIR ]; then - mkdir $TOOLS_DIR -fi +TEMP_DIRECTORY="$SCRIPT_DIR/.fallout/temp" -# Make sure that packages.config exist. -if [ ! -f $TOOLS_DIR/packages.config ]; then - echo "Downloading packages.config..." - curl -Lsfo $TOOLS_DIR/packages.config http://cakebuild.net/bootstrapper/packages - if [ $? -ne 0 ]; then - echo "An error occured while downloading packages.config." - exit 1 - fi -fi +DOTNET_GLOBAL_FILE="$SCRIPT_DIR/global.json" +DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" +DOTNET_CHANNEL="STS" -# Download NuGet (v3.5.0) if it does not exist. -if [ ! -f $NUGET_OLD_EXE ]; then - echo "Downloading NuGet..." - curl -Lsfo $NUGET_OLD_EXE https://dist.nuget.org/win-x86-commandline/v3.5.0/nuget.exe - if [ $? -ne 0 ]; then - echo "An error occured while downloading nuget.exe." - exit 1 - fi -fi +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_NOLOGO=1 + +########################################################################### +# EXECUTION +########################################################################### -# Download NuGet (latest) if it does not exist. -if [ ! -f $NUGET_EXE ]; then - echo "Downloading NuGet..." - curl -Lsfo $NUGET_EXE https://dist.nuget.org/win-x86-commandline/latest/nuget.exe - if [ $? -ne 0 ]; then - echo "An error occured while downloading nuget.exe." - exit 1 +function FirstJsonValue { + perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" +} + +# 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 + # Download install script + DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh" + mkdir -p "$TEMP_DIRECTORY" + curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" + chmod +x "$DOTNET_INSTALL_FILE" + + # If global.json exists, load expected version + if [[ -f "$DOTNET_GLOBAL_FILE" ]]; then + DOTNET_VERSION=$(FirstJsonValue "version" "$(cat "$DOTNET_GLOBAL_FILE")") + if [[ "$DOTNET_VERSION" == "" ]]; then + unset DOTNET_VERSION + fi fi -fi -# Restore tools from NuGet. -pushd $TOOLS_DIR >/dev/null -mono $NUGET_EXE install -ExcludeVersion -if [ $? -ne 0 ]; then - echo "Could not restore NuGet packages." - exit 1 + # Install by channel or version + DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix" + if [[ -z ${DOTNET_VERSION+x} ]]; then + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path + 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 -popd >/dev/null -# Make sure that Cake has been installed. -if [ ! -f $CAKE_EXE ]; then - echo "Could not find Cake.exe at '$CAKE_EXE'." - exit 1 -fi +echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" -# Start Cake -if $SHOW_VERSION; then - exec mono $CAKE_EXE --version -else - exec mono $CAKE_EXE $SCRIPT --verbosity=$VERBOSITY --configuration=$CONFIGURATION --target=$TARGET $DRYRUN "${SCRIPT_ARGUMENTS[@]}" -fi \ No newline at end of file +"$DOTNET_EXE" tool restore +exec "$DOTNET_EXE" fallout "$@" diff --git a/build/.editorconfig b/build/.editorconfig new file mode 100644 index 0000000..31e43dc --- /dev/null +++ b/build/.editorconfig @@ -0,0 +1,11 @@ +[*.cs] +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning +dotnet_style_require_accessibility_modifiers = never:warning + +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_accessors = true:warning diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 0000000..7d9ef65 --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,302 @@ +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 Octokit; +using Octokit.Internal; +using Serilog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using static Fallout.Common.Tools.DotNet.DotNetTasks; +using Project = Fallout.Solutions.Project; + +class Build : FalloutBuild +{ + public static int Main () => Execute(x => x.RunUnitTests); + + [Fallout.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; + + [Fallout.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] + readonly AbsolutePath ReleaseNotesFilePath = RootDirectory / "CHANGELOG.md"; + + [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] + readonly string AngleSharpVersion; + + [Solution] + readonly Solution Solution; + + string TargetProjectName => "AngleSharp.Wasm"; + + string TargetLibName => $"{TargetProjectName}"; + + AbsolutePath SourceDirectory => RootDirectory / "src"; + + AbsolutePath ResultDirectory => RootDirectory / "bin" / Version; + + AbsolutePath NugetDirectory => ResultDirectory / "nuget"; + + GitHubActions GitHubActions => GitHubActions.Instance; + + Project TargetProject { get; set; } + + // Note: The built-in ChangeLogTasks (inherited from NUKE) look buggy. So using the Cake source code. + IReadOnlyList ChangeLog { get; set; } + + ReleaseNotes LatestReleaseNotes { get; set; } + + SemVersion SemVersion { get; set; } + + string Version { get; set; } + + IReadOnlyCollection TargetFrameworks { get; set; } + + protected override void OnBuildInitialized() + { + var parser = new ReleaseNotesParser(); + + Log.Debug("Reading ChangeLog {FilePath}...", ReleaseNotesFilePath); + ChangeLog = parser.Parse(File.ReadAllText(ReleaseNotesFilePath)); + ChangeLog.NotNull("ChangeLog / ReleaseNotes could not be read!"); + + LatestReleaseNotes = ChangeLog.First(); + LatestReleaseNotes.NotNull("LatestVersion could not be read!"); + + Log.Debug("Using LastestVersion from ChangeLog: {LatestVersion}", LatestReleaseNotes.Version); + SemVersion = LatestReleaseNotes.SemVersion; + Version = LatestReleaseNotes.Version.ToString(); + + if (GitHubActions != null) + { + Log.Debug("Add Version Postfix if under CI - GithubAction(s)..."); + + var buildNumber = GitHubActions.RunNumber; + + if (ScheduledTargets.Contains(Default)) + { + Version = $"{Version}-ci.{buildNumber}"; + } + else if (ScheduledTargets.Contains(PrePublish)) + { + Version = $"{Version}-beta.{buildNumber}"; + } + } + + Log.Information("Building version: {Version}", Version); + + TargetProject = Solution.GetProject(TargetLibName); + TargetProject.NotNull("TargetProject could not be loaded!"); + + TargetFrameworks = TargetProject.GetTargetFrameworks(); + TargetFrameworks.NotNull("No TargetFramework(s) found to build for!"); + + Log.Information("Target Framework(s): {Frameworks}", TargetFrameworks); + } + + Target Clean => _ => _ + .Before(Restore) + .Executes(() => + { + SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(x => x.DeleteDirectory()); + }); + + Target Restore => _ => _ + .Executes(() => + { + 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 => + { + var settings = s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .SetVersion(Version) + .SetContinuousIntegrationBuild(IsServerBuild) + .EnableNoRestore(); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); + }); + + Target RunUnitTests => _ => _ + .DependsOn(Compile) + .Executes(() => + { + DotNetTest(s => + { + var settings = s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .SetProperty("Version", Version) + .EnableNoRestore() + .EnableNoBuild(); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); + }); + + // 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(() => + { + DotNetPack(s => + { + var settings = s + .SetProject(TargetProject) + .SetConfiguration(Configuration) + .SetVersion(Version) + .SetOutputDirectory(NugetDirectory) + .SetContinuousIntegrationBuild(IsServerBuild) + .EnableNoRestore() + .EnableNoBuild(); + + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } + + return settings; + }); + }); + + Target PublishPackage => _ => _ + .DependsOn(CreatePackage) + .DependsOn(RunUnitTests) + .Executes(() => + { + var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); + + + if (apiKey.IsNullOrEmpty()) + { + 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")) + { + DotNetNuGetPush(s => s + .SetTargetPath(nupkg) + .SetSource("https://api.nuget.org/v3/index.json") + .SetApiKey(apiKey)); + } + }); + + Target PublishPreRelease => _ => _ + .DependsOn(PublishPackage) + .Executes(() => + { + string gitHubToken; + + if (GitHubActions != null) + { + gitHubToken = GitHubActions.Token; + } + else + { + gitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + } + + if (gitHubToken.IsNullOrEmpty()) + { + throw new BuildAbortedException("Could not resolve GitHub token."); + } + + var credentials = new Credentials(gitHubToken); + + GitHubTasks.GitHubClient = new GitHubClient( + new ProductHeaderValue(nameof(FalloutBuild)), + new InMemoryCredentialStore(credentials)); + + GitHubTasks.GitHubClient.Repository.Release + .Create("AngleSharp", TargetProjectName, new NewRelease(Version) + { + Name = Version, + Body = String.Join(Environment.NewLine, LatestReleaseNotes.Notes), + Prerelease = true, + TargetCommitish = "devel", + }); + }); + + Target PublishRelease => _ => _ + .DependsOn(PublishPackage) + .Executes(() => + { + string gitHubToken; + + if (GitHubActions != null) + { + gitHubToken = GitHubActions.Token; + } + else + { + gitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + } + + if (gitHubToken.IsNullOrEmpty()) + { + throw new BuildAbortedException("Could not resolve GitHub token."); + } + + var credentials = new Credentials(gitHubToken); + + GitHubTasks.GitHubClient = new GitHubClient( + new ProductHeaderValue(nameof(FalloutBuild)), + new InMemoryCredentialStore(credentials)); + + GitHubTasks.GitHubClient.Repository.Release + .Create("AngleSharp", TargetProjectName, new NewRelease(Version) + { + Name = Version, + Body = String.Join(Environment.NewLine, LatestReleaseNotes.Notes), + Prerelease = false, + TargetCommitish = "main", + }); + }); + + Target Package => _ => _ + .DependsOn(RunUnitTests) + .DependsOn(CreatePackage); + + Target Default => _ => _ + .DependsOn(Package); + + Target Publish => _ => _ + .DependsOn(PublishRelease); + + Target PrePublish => _ => _ + .DependsOn(PublishPreRelease); +} diff --git a/build/Configuration.cs b/build/Configuration.cs new file mode 100644 index 0000000..75c63ea --- /dev/null +++ b/build/Configuration.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel; +using System.Linq; +using Fallout.Common.Tooling; + +[TypeConverter(typeof(TypeConverter))] +public class Configuration : Enumeration +{ + public static Configuration Debug = new Configuration { Value = nameof(Debug) }; + public static Configuration Release = new Configuration { Value = nameof(Release) }; + + public static implicit operator string(Configuration configuration) + { + return configuration.Value; + } +} diff --git a/build/Directory.Build.props b/build/Directory.Build.props new file mode 100644 index 0000000..e147d63 --- /dev/null +++ b/build/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/Directory.Build.targets b/build/Directory.Build.targets new file mode 100644 index 0000000..2532609 --- /dev/null +++ b/build/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/ReleaseNotes.cs b/build/ReleaseNotes.cs new file mode 100644 index 0000000..01dcdad --- /dev/null +++ b/build/ReleaseNotes.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// Represent release notes. +/// +/// +/// Original from Cake build tool source: +/// https://github.com/cake-build/cake/blob/9828d7b246d332054896e52ba56983822feb3f05/src/Cake.Common/ReleaseNotes.cs +/// +public sealed class ReleaseNotes +{ + private readonly List _notes; + + /// + /// Gets the version. + /// + /// The version. + public SemVersion SemVersion { get; } + + /// + /// Gets the version. + /// + /// The version. + public Version Version { get; } + + /// + /// Gets the release notes. + /// + /// The release notes. + public IReadOnlyList Notes => _notes; + + /// + /// Gets the raw text of the line that was extracted from. + /// + /// The raw text of the Version line. + public string RawVersionLine { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The semantic version. + /// The notes. + /// The raw text of the version line. + public ReleaseNotes(SemVersion semVersion, IEnumerable notes, string rawVersionLine) + : this( + semVersion?.AssemblyVersion ?? throw new ArgumentNullException(nameof(semVersion)), + semVersion, + notes, + rawVersionLine) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The version. + /// The notes. + /// The raw text of the version line. + public ReleaseNotes(Version version, IEnumerable notes, string rawVersionLine) + : this( + version ?? throw new ArgumentNullException(nameof(version)), + new SemVersion(version.Major, version.Minor, version.Build), + notes, + rawVersionLine) + { + } + + private ReleaseNotes(Version version, SemVersion semVersion, IEnumerable notes, string rawVersionLine) + { + Version = version ?? throw new ArgumentNullException(nameof(version)); + SemVersion = semVersion ?? throw new ArgumentNullException(nameof(semVersion)); + RawVersionLine = rawVersionLine; + _notes = new List(notes ?? Enumerable.Empty()); + } +} \ No newline at end of file diff --git a/build/ReleaseNotesParser.cs b/build/ReleaseNotesParser.cs new file mode 100644 index 0000000..88ed244 --- /dev/null +++ b/build/ReleaseNotesParser.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Build.Exceptions; + +/// +/// The release notes parser. +/// +/// +/// Original from Cake build tool source: +/// https://github.com/cake-build/cake/blob/9828d7b246d332054896e52ba56983822feb3f05/src/Cake.Common/ReleaseNotesParser.cs +/// +public sealed class ReleaseNotesParser +{ + /// + /// Parses all release notes. + /// + /// The content. + /// All release notes. + public IReadOnlyList Parse(string content) + { + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + var lines = content + .Replace("\r\n", "\n") + .Replace('\r', '\n') + .Split('\n'); + if (lines.Length > 0) + { + var line = lines[0].Trim(); + + if (line.StartsWith("#", StringComparison.OrdinalIgnoreCase)) + { + return ParseComplexFormat(lines); + } + + if (line.StartsWith("*", StringComparison.OrdinalIgnoreCase)) + { + return ParseSimpleFormat(lines); + } + } + + throw new BuildAbortedException("Unknown release notes format."); + } + + private IReadOnlyList ParseComplexFormat(string[] lines) + { + var lineIndex = 0; + var result = new List(); + + while (true) + { + if (lineIndex >= lines.Length) + { + break; + } + + // Create release notes. + var semVer = SemVersion.Zero; + var version = SemVersion.TryParse(lines[lineIndex], out semVer); + if (!version) + { + throw new BuildAbortedException("Could not parse version from release notes header."); + } + + var rawVersionLine = lines[lineIndex]; + + // Increase the line index. + lineIndex++; + + // Parse content. + var notes = new List(); + while (true) + { + // Sanity checks. + if (lineIndex >= lines.Length) + { + break; + } + + if (lines[lineIndex].StartsWith("#", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + // Get the current line. + var line = (lines[lineIndex] ?? string.Empty).Trim('*').Trim(); + if (!string.IsNullOrWhiteSpace(line)) + { + notes.Add(line); + } + + lineIndex++; + } + + result.Add(new ReleaseNotes(semVer, notes, rawVersionLine)); + } + + return result.OrderByDescending(x => x.SemVersion).ToArray(); + } + + private IReadOnlyList ParseSimpleFormat(string[] lines) + { + var lineIndex = 0; + var result = new List(); + + while (true) + { + if (lineIndex >= lines.Length) + { + break; + } + + // Trim the current line. + var line = (lines[lineIndex] ?? string.Empty).Trim('*', ' '); + if (string.IsNullOrWhiteSpace(line)) + { + lineIndex++; + continue; + } + + // Parse header. + var semVer = SemVersion.Zero; + var version = SemVersion.TryParse(lines[lineIndex], out semVer); + if (!version) + { + throw new BuildAbortedException("Could not parse version from release notes header."); + } + + // Parse the description. + line = line.Substring(semVer.ToString().Length).Trim('-', ' '); + + // Add the release notes to the result. + result.Add(new ReleaseNotes(semVer, new[] { line }, line)); + + lineIndex++; + } + + return result.OrderByDescending(x => x.SemVersion).ToArray(); + } +} \ No newline at end of file diff --git a/build/SemVersion.cs b/build/SemVersion.cs new file mode 100644 index 0000000..4a9a715 --- /dev/null +++ b/build/SemVersion.cs @@ -0,0 +1,378 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +/// +/// Class for representing semantic versions. +/// +/// +/// Original from Cake build tool source: +/// https://github.com/cake-build/cake/blob/9828d7b246d332054896e52ba56983822feb3f05/src/Cake.Common/SemanticVersion.cs +/// +public class SemVersion : IComparable, IComparable, IEquatable +{ + /// + /// Gets the default version of a SemanticVersion. + /// + public static SemVersion Zero { get; } = new SemVersion(0, 0, 0, null, null, "0.0.0"); + + /// + /// Regex property for parsing a semantic version number. + /// + public static readonly Regex SemVerRegex = + new Regex( + @"(?0|(?:[1-9]\d*))(?:\.(?0|(?:[1-9]\d*))(?:\.(?0|(?:[1-9]\d*)))?(?:\-(?[0-9A-Z\.-]+))?(?:\+(?[0-9A-Z\.-]+))?)?", + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase); + + /// + /// Gets the major number of the version. + /// + public int Major { get; } + + /// + /// Gets the minor number of the version. + /// + public int Minor { get; } + + /// + /// Gets the patch number of the version. + /// + public int Patch { get; } + + /// + /// Gets the prerelease of the version. + /// + public string PreRelease { get; } + + /// + /// Gets the meta of the version. + /// + public string Meta { get; } + + /// + /// Gets a value indicating whether semantic version is a prerelease or not. + /// + public bool IsPreRelease { get; } + + /// + /// Gets a value indicating whether semantic version has meta or not. + /// + public bool HasMeta { get; } + + /// + /// Gets the VersionString of the semantic version. + /// + public string VersionString { get; } + + /// + /// Gets the AssemblyVersion of the semantic version. + /// + public Version AssemblyVersion { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Major number. + /// Minor number. + /// Patch number. + /// Prerelease string. + /// Meta string. + public SemVersion(int major, int minor, int patch, string preRelease = null, string meta = null) : this(major, + minor, patch, preRelease, meta, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Major number. + /// Minor number. + /// Patch number. + /// Prerelease string. + /// Meta string. + /// The complete version number. + public SemVersion(int major, int minor, int patch, string preRelease, string meta, string versionString) + { + Major = major; + Minor = minor; + Patch = patch; + AssemblyVersion = new Version(major, minor, patch); + IsPreRelease = !string.IsNullOrEmpty(preRelease); + HasMeta = !string.IsNullOrEmpty(meta); + PreRelease = IsPreRelease ? preRelease : null; + Meta = HasMeta ? meta : null; + + if (!string.IsNullOrEmpty(versionString)) + { + VersionString = versionString; + } + else + { + var sb = new StringBuilder(); + sb.AppendFormat(CultureInfo.InvariantCulture, "{0}.{1}.{2}", Major, Minor, Patch); + + if (IsPreRelease) + { + sb.AppendFormat(CultureInfo.InvariantCulture, "-{0}", PreRelease); + } + + if (HasMeta) + { + sb.AppendFormat(CultureInfo.InvariantCulture, "+{0}", Meta); + } + + VersionString = sb.ToString(); + } + } + + /// + /// Method which tries to parse a semantic version string. + /// + /// the version that should be parsed. + /// the out parameter the parsed version should be stored in. + /// Returns a boolean indicating if the parse was successful. + public static bool TryParse(string version, + out SemVersion semVersion) + { + semVersion = Zero; + + if (string.IsNullOrEmpty(version)) + { + return false; + } + + var match = SemVerRegex.Match(version); + if (!match.Success) + { + return false; + } + + if (!int.TryParse( + match.Groups["Major"].Value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var major) || + !int.TryParse( + match.Groups["Minor"].Value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var minor) || + !int.TryParse( + match.Groups["Patch"].Value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var patch)) + { + return false; + } + + semVersion = new SemVersion( + major, + minor, + patch, + match.Groups["PreRelease"]?.Value, + match.Groups["Meta"]?.Value, + version); + + return true; + } + + /// + /// Checks if two SemVersion objects are equal. + /// + /// the other SemVersion want to test equality to. + /// A boolean indicating whether the objecst we're equal or not. + public bool Equals(SemVersion other) + { + return other is object + && Major == other.Major + && Minor == other.Minor + && Patch == other.Patch + && string.Equals(PreRelease, other.PreRelease, StringComparison.OrdinalIgnoreCase) + && string.Equals(Meta, other.Meta, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Compares to SemVersion objects to and another. + /// + /// The SemVersion object we compare with. + /// Return 0 if the objects are identical, 1 if the version is newer and -1 if the version is older. + public int CompareTo(SemVersion other) + { + if (other is null) + { + return 1; + } + + if (Equals(other)) + { + return 0; + } + + if (Major > other.Major) + { + return 1; + } + + if (Major < other.Major) + { + return -1; + } + + if (Minor > other.Minor) + { + return 1; + } + + if (Minor < other.Minor) + { + return -1; + } + + if (Patch > other.Patch) + { + return 1; + } + + if (Patch < other.Patch) + { + return -1; + } + + if (IsPreRelease != other.IsPreRelease) + { + return other.IsPreRelease ? 1 : -1; + } + + switch (StringComparer.InvariantCultureIgnoreCase.Compare(PreRelease, other.PreRelease)) + { + case 1: + return 1; + + case -1: + return -1; + + default: + { + return (string.IsNullOrEmpty(Meta) != string.IsNullOrEmpty(other.Meta)) + ? string.IsNullOrEmpty(Meta) ? 1 : -1 + : StringComparer.InvariantCultureIgnoreCase.Compare(Meta, other.Meta); + } + } + } + + /// + /// Compares to SemVersion objects to and another. + /// + /// The object we compare with. + /// Return 0 if the objects are identical, 1 if the version is newer and -1 if the version is older. + public int CompareTo(object obj) + { + return (obj is SemVersion semVersion) + ? CompareTo(semVersion) + : -1; + } + + /// + /// Equals-method for the SemVersion class. + /// + /// the other SemVersion want to test equality to. + /// A boolean indicating whether the objecst we're equal or not. + public override bool Equals(object obj) + { + return (obj is SemVersion semVersion) + && Equals(semVersion); + } + + /// + /// Method for getting the hashcode of the SemVersion object. + /// + /// The hashcode of the SemVersion object. + public override int GetHashCode() + { + unchecked + { + var hashCode = Major; + hashCode = (hashCode * 397) ^ Minor; + hashCode = (hashCode * 397) ^ Patch; + hashCode = (hashCode * 397) ^ + (PreRelease != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(PreRelease) : 0); + hashCode = (hashCode * 397) ^ (Meta != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Meta) : 0); + return hashCode; + } + } + + /// + /// Returns the string representation of an SemVersion object. + /// + /// The string representation of the object. + public override string ToString() + { + int[] verParts = { Major, Minor, Patch }; + string ver = string.Join(".", verParts); + return $"{ver}{(IsPreRelease ? "-" : string.Empty)}{PreRelease}{Meta}"; + } + + /// + /// The greater than-operator for the SemVersion class. + /// + /// first SemVersion. + /// second. SemVersion. + /// A value indicating if the operand1 was greater than operand2. + public static bool operator >(SemVersion operand1, SemVersion operand2) + => operand1 is { } && operand1.CompareTo(operand2) == 1; + + /// + /// The less than-operator for the SemVersion class. + /// + /// first SemVersion. + /// second. SemVersion. + /// A value indicating if the operand1 was less than operand2. + public static bool operator <(SemVersion operand1, SemVersion operand2) + => operand1 is { } + ? operand1.CompareTo(operand2) == -1 + : operand2 is { }; + + /// + /// The greater than or equal to-operator for the SemVersion class. + /// + /// first SemVersion. + /// second. SemVersion. + /// A value indicating if the operand1 was greater than or equal to operand2. + public static bool operator >=(SemVersion operand1, SemVersion operand2) + => operand1 is { } + ? operand1.CompareTo(operand2) >= 0 + : operand2 is null; + + /// + /// The lesser than or equal to-operator for the SemVersion class. + /// + /// first SemVersion. + /// second. SemVersion. + /// A value indicating if the operand1 was lesser than or equal to operand2. + public static bool operator <=(SemVersion operand1, SemVersion operand2) + => operand1 is null || operand1.CompareTo(operand2) <= 0; + + /// + /// The equal to-operator for the SemVersion class. + /// + /// first SemVersion. + /// second. SemVersion. + /// A value indicating if the operand1 was equal to operand2. + public static bool operator ==(SemVersion operand1, SemVersion operand2) + => operand1?.Equals(operand2) ?? operand2 is null; + + /// + /// The not equal to-operator for the SemVersion class. + /// + /// first SemVersion. + /// second. SemVersion. + /// A value indicating if the operand1 was not equal to operand2. + public static bool operator !=(SemVersion operand1, SemVersion operand2) + => !(operand1?.Equals(operand2) ?? operand2 is null); +} \ No newline at end of file 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/docs/README.md b/docs/README.md index e1efa12..2a42b61 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,4 +2,24 @@ We have more detailed information regarding the following subjects: +- [Getting Started](general/01-Basics.md) +- [Spec Coverage Matrix](general/02-Spec-Coverage.md) - [API Documentation](tutorials/01-API.md) +- [Examples](tutorials/02-Examples.md) +- [Frequently Asked Questions](tutorials/03-Questions.md) + +## Status Snapshot + +AngleSharp.Wasm currently provides: + +- Runtime registration via `WithWasm()` +- Optional host import registration via `WithWasmImports(...)` +- WebAssembly DOM surface for JS integration through discovered DOM attributes +- Module metadata APIs (`exports`, `imports`, `customSections`) +- Instance export access (`exports`) and invocation helpers + +For details about supported behavior and known limitations, see: + +- [Getting Started](general/01-Basics.md) +- [Spec Coverage Matrix](general/02-Spec-Coverage.md) +- [Frequently Asked Questions](tutorials/03-Questions.md) diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 2a7051b..ced8a1e 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -6,9 +6,7 @@ section: "AngleSharp.Wasm" ## Requirements -AngleSharp.Wasm comes currently in two flavors: on Windows for .NET 4.6 and in general targetting .NET Standard 2.0 platforms. - -Most of the features of the library do not require .NET 4.6, which means you could create your own fork and modify it to work with previous versions of the .NET-Framework. +AngleSharp.Wasm currently targets modern .NET runtimes and is available for .NET 8 and .NET 10. You need to have AngleSharp installed already. This could be done via NuGet: @@ -16,6 +14,8 @@ You need to have AngleSharp installed already. This could be done via NuGet: Install-Package AngleSharp ``` +If you want JavaScript-side access to `window.WebAssembly`, you also need a scripting setup (for example, AngleSharp.Js) that discovers exported DOM members from loaded assemblies. + ## Getting AngleSharp.Wasm over NuGet The simplest way of integrating AngleSharp.Wasm to your project is by using NuGet. You can install AngleSharp.Wasm by opening the package manager console (PM) and typing in the following statement: @@ -38,3 +38,51 @@ var config = Configuration.Default ``` This will register everything related for running WebAssembly. + +## Optional: Register Host Imports + +If your Wasm module imports host functions, register import providers: + +```cs +var config = Configuration.Default + .WithWasm() + .WithWasmImports(context => + { + return new[] + { + new WasmImportFunction( + moduleName: "host", + functionName: "answer", + parameterTypes: Array.Empty(), + resultTypes: new[] { WasmValueType.Int32 }, + callback: _ => 41), + }; + }); +``` + +## What Works Today + +- `WithWasm()` registers a default Wasmtime-backed runtime factory. +- `WithWasmImports(...)` can provide host imports during instantiation. +- The DOM surface exposes `WebAssembly.compile(...)` and `WebAssembly.instantiate(...)`. +- `Module` metadata APIs are available: + - `exports()` + - `imports()` + - `customSections(sectionName)` +- `Instance` supports: + - `exports` object access by name + - `invoke(exportName, ...args)` helper +- Export descriptors expose spec-style fields (`name`, `kind`). + +## Current Limitations + +AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS API. + +- Synchronous bridge calls are used (`compile` / `instantiate` are not Promise-based APIs). +- `WebAssembly.validate(...)`, streaming APIs, and compile options are not implemented. +- `Instance.exports` function members are wrapper objects requiring `.invoke(...)`. +- Non-function exports are currently represented as descriptors (`name`, `kind`), not full `Memory` / `Table` / `Global` / `Tag` objects. +- `customSections(...)` returns payload bytes (`byte[]`) mapped from custom sections. +- Runtime invocation/import marshaling is currently focused on numeric value kinds (`i32`, `i64`, `f32`, `f64`). + +These limitations are intentional for the current release scope and can be evolved incrementally. diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md new file mode 100644 index 0000000..a327c20 --- /dev/null +++ b/docs/general/02-Spec-Coverage.md @@ -0,0 +1,60 @@ +--- +title: "Spec Coverage Matrix" +section: "AngleSharp.Wasm" +--- +# Spec Coverage Matrix + +This page maps the current AngleSharp.Wasm implementation to the WebAssembly JavaScript API specification. + +Status values: + +- Implemented: Available and intended for normal use. +- Partial: Available in a reduced or adapted form. +- Not yet: Not currently implemented. + +## Coverage Table + +| Spec Area | Status | Notes | +| --- | --- | --- | +| WebAssembly namespace: `compile(bytes)` | Partial | Exposed as synchronous bridge method `WebAssembly.compile(byte[])`. | +| WebAssembly namespace: `instantiate(...)` | Partial | Exposed as synchronous bridge method `WebAssembly.instantiate(WasmJsModule)`. | +| WebAssembly namespace: `validate(bytes)` | Not yet | Not currently exposed. | +| WebAssembly namespace: streaming APIs | Not yet | No `instantiateStreaming` / `compileStreaming`. | +| WebAssembly namespace: compile options | Not yet | No `builtins` / `importedStringConstants` options support yet. | +| `Module.exports(module)` | Partial | Available as instance method `module.exports()` returning descriptor objects. | +| `Module.imports(module)` | Partial | Available as instance method `module.imports()` returning descriptor objects. | +| `Module.customSections(module, name)` | Partial | Available as instance method `module.customSections(name)` returning `byte[][]`. | +| `Instance.exports` | Partial | Available as `instance.exports`, with function wrappers and descriptor values for non-function exports. | +| Exported function invocation | Implemented | Supported via `instance.invoke(...)` and `WasmJsExportedFunction.invoke(...)`. | +| Host import functions | Implemented | Supported via `WithWasmImports(...)` and `WasmImportFunction`. | +| Memory object API (`Memory`) | Not yet | No full JS API `Memory` object projection yet. | +| Table object API (`Table`) | Not yet | No full JS API `Table` object projection yet. | +| Global object API (`Global`) | Not yet | No full JS API `Global` object projection yet. | +| Tag object API (`Tag`) | Not yet | No full JS API `Tag` object projection yet. | +| Exception object API (`Exception`) | Not yet | No full JS API exception projection yet. | +| Error constructors (`CompileError`, `LinkError`, `RuntimeError`) | Not yet | No dedicated namespace error constructor projection yet. | +| JS String builtins set | Not yet | No compile-option builtin-set wiring yet. | + +## Runtime and Type Support + +| Area | Status | Notes | +| --- | --- | --- | +| Runtime backend | Implemented | Uses Wasmtime through the default `WithWasm()` registration. | +| Invocation numeric value types | Implemented | `i32`, `i64`, `f32`, `f64` are supported for import/export invocation paths. | +| Extended/reference value kinds | Not yet | Rich reference-type projections are not yet exposed through JS API object wrappers. | + +## Test-Backed Behavior + +The current implementation is validated by runtime and bridge tests in the repository, including: + +- compile and instantiate flows +- export invocation +- import descriptor extraction +- export descriptor extraction +- custom section lookup behavior +- host import wiring + +## Interpretation Notes + +This matrix reflects API shape and behavior in AngleSharp.Wasm, not strict one-to-one Web IDL signatures. +Where the specification defines static namespace or Promise-based operations, AngleSharp.Wasm may expose equivalent functionality through synchronous bridge methods. diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 23f207b..afaeb2d 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -4,4 +4,104 @@ section: "AngleSharp.Wasm" --- # API Documentation -(tbd) +This page summarizes the currently available public API surface. + +## Configuration Extensions + +Extension methods are available on `IConfiguration` via `AngleSharp.Wasm`: + +### `WithWasm()` + +Registers the default Wasmtime-backed runtime factory. + +```cs +var config = Configuration.Default.WithWasm(); +``` + +### `WithWasm(Func)` + +Registers a custom runtime factory creator. + +### `WithWasmImports(IWasmImportProvider)` + +Registers a host import provider. + +### `WithWasmImports(Func>)` + +Registers host imports via delegate. + +## DOM-Facing API (`AngleSharp.Wasm.Dom`) + +When used with a scripting integration that discovers DOM attributes, the following surface is exposed under `WebAssembly`. + +### `WebAssembly.compile(byte[] moduleBytes)` + +Compiles bytes and returns a `WasmJsModule`. + +### `WebAssembly.instantiate(WasmJsModule module)` + +Instantiates a compiled module and returns a `WasmJsInstance`. + +## `WasmJsModule` + +### `exports()` + +Returns `WasmModuleExportDescriptor[]` with: + +- `name` +- `kind` (`function`, `table`, `memory`, `global`, `tag`) + +### `imports()` + +Returns `WasmModuleImportDescriptor[]` with: + +- `module` +- `name` +- `kind` + +### `customSections(string sectionName)` + +Returns all matching custom section payloads as `byte[][]`. + +## `WasmJsInstance` + +### `exports` (getter) + +Returns a `WasmJsExports` object. + +### `invoke(string exportName, params object?[] arguments)` + +Invokes an exported function by name. + +## `WasmJsExports` + +### `this[string name]` + +Returns an export entry by name: + +- `WasmJsExportedFunction` for function exports +- `WasmJsExportValue` for non-function exports +- `null` if no export is found + +### `keys()` + +Returns all export names. + +## `WasmJsExportedFunction` + +### `invoke(params object?[] arguments)` + +Invokes the wrapped exported function. + +## `WasmJsExportValue` + +Descriptor type for non-function exports. + +- `name` +- `kind` + +## Notes on Scope + +This API is intentionally focused on a practical subset of the full WebAssembly JS API spec. See [Questions](03-Questions.md) for limitations and expected behavior. + +For a section-by-section status overview, see [Spec Coverage Matrix](../general/02-Spec-Coverage.md). diff --git a/docs/tutorials/02-Examples.md b/docs/tutorials/02-Examples.md index 73ea720..e422330 100644 --- a/docs/tutorials/02-Examples.md +++ b/docs/tutorials/02-Examples.md @@ -6,6 +6,100 @@ section: "AngleSharp.Wasm" This is a (growing) list of examples for every-day usage of AngleSharp.Wasm. -## Some Example +## Basic Setup -(tbd) +```cs +var config = Configuration.Default.WithWasm(); +using var context = BrowsingContext.New(config); +using var document = await context.OpenNewAsync(); +``` + +## Compile and Instantiate + +```cs +var module = WebAssembly.Compile(context.Current!, wasmBytes); +var instance = WebAssembly.Instantiate(context.Current!, module); +``` + +## Invoke an Exported Function + +```cs +var result = instance.Invoke("answer"); +``` + +You can also invoke through the export wrapper: + +```cs +var exported = (WasmJsExportedFunction?)instance.Exports["answer"]; +var result = exported?.Invoke(); +``` + +## Invoke an Exported Function with Arguments + +```cs +var add = (WasmJsExportedFunction?)instance.Exports["add"]; +var result = add?.Invoke(20, 22); // 42 +``` + +## Read Module Export and Import Metadata + +```cs +foreach (var exportEntry in module.Exports()) +{ + Console.WriteLine($"Export: {exportEntry.Name} ({exportEntry.Kind})"); +} + +foreach (var importEntry in module.Imports()) +{ + Console.WriteLine($"Import: {importEntry.Module}.{importEntry.Name} ({importEntry.Kind})"); +} +``` + +## Read Custom Sections + +```cs +var sections = module.CustomSections("meta"); + +foreach (var payload in sections) +{ + Console.WriteLine($"Section payload length: {payload.Length}"); +} +``` + +## Register Host Imports + +```cs +var config = Configuration.Default + .WithWasm() + .WithWasmImports(_ => + { + return new[] + { + new WasmImportFunction( + "host", + "answer", + Array.Empty(), + new[] { WasmValueType.Int32 }, + _ => 41), + }; + }); +``` + +With a module importing `host.answer`, an export such as `answer_plus_one` can now be invoked as expected. + +## Check for Missing Exports + +```cs +var maybe = instance.Exports["does_not_exist"]; + +if (maybe is null) +{ + Console.WriteLine("Export not found."); +} +``` + +## List Available Export Names + +```cs +var keys = instance.Exports.Keys(); +``` diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index afb3499..58b785a 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -4,6 +4,69 @@ section: "AngleSharp.Wasm" --- # Frequently Asked Questions -## What to ask? +For a compact section-by-section status view, see [Spec Coverage Matrix](../general/02-Spec-Coverage.md). -(tbd) +## Which .NET versions are supported? + +AngleSharp.Wasm currently targets .NET 8 and .NET 10. + +## Is the full WebAssembly JS API specification implemented? + +Not yet. The current implementation covers a useful subset with emphasis on: + +- Compiling and instantiating modules +- Reading module metadata (`exports`, `imports`, `customSections`) +- Invoking exported functions +- Registering host imports + +## Is `window.WebAssembly` available? + +Yes, when used with a scripting integration that discovers DOM-annotated members from registered assemblies. + +## Are APIs synchronous or asynchronous? + +The current bridge methods are synchronous from the caller perspective. + +## Are `Memory`, `Table`, `Global`, and `Tag` exposed as full JS API objects? + +Not yet. Non-function exports currently appear as `WasmJsExportValue` descriptors (`name`, `kind`). + +## How do I invoke exported functions? + +Either call: + +- `instance.Invoke("exportName", args...)` + +or use the export wrapper: + +- `((WasmJsExportedFunction)instance.Exports["exportName"]).Invoke(args...)` + +## What does `Module.customSections(name)` return? + +It returns all matching custom section payloads as `byte[][]`. + +## How are host imports provided? + +Use `WithWasmImports(...)` with either: + +- an `IWasmImportProvider`, or +- a delegate returning `IEnumerable`. + +## Which value kinds are currently supported for invocation/import marshaling? + +Current numeric value kinds are supported: + +- `i32` +- `i64` +- `f32` +- `f64` + +## What are known limitations today? + +- No Promise-based `compile` / `instantiate` bridge methods. +- No `validate(...)` method yet. +- No streaming APIs. +- No compile options support (`builtins`, `importedStringConstants`). +- Non-function exports are descriptors instead of full JS API objects. + +These limits are expected at this stage and can be expanded in future versions. diff --git a/src/AngleSharp.Wasm.Docs/package.json b/src/AngleSharp.Wasm.Docs/package.json index ba35ea7..088253a 100644 --- a/src/AngleSharp.Wasm.Docs/package.json +++ b/src/AngleSharp.Wasm.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/wasm", - "version": "0.16.0", + "version": "1.0.0", "preview": true, "description": "The doclet for the AngleSharp.Wasm documentation.", "keywords": [ diff --git a/src/AngleSharp.Wasm.Tests/AngleSharp.Wasm.Tests.csproj b/src/AngleSharp.Wasm.Tests/AngleSharp.Wasm.Tests.csproj index 1097a28..f201bb3 100644 --- a/src/AngleSharp.Wasm.Tests/AngleSharp.Wasm.Tests.csproj +++ b/src/AngleSharp.Wasm.Tests/AngleSharp.Wasm.Tests.csproj @@ -1,24 +1,21 @@  - netcoreapp2.1 - true - Key.snk + net8.0;net10.0 + false false AngleSharp.Wasm.Tests true - - netstandard2.0 - + - - - - - + + + + + \ No newline at end of file diff --git a/src/AngleSharp.Wasm.Tests/Key.snk b/src/AngleSharp.Wasm.Tests/Key.snk deleted file mode 100644 index d43ce41..0000000 Binary files a/src/AngleSharp.Wasm.Tests/Key.snk and /dev/null differ diff --git a/src/AngleSharp.Wasm.Tests/Properties/AssemblyInfo.cs b/src/AngleSharp.Wasm.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 13d4a3a..0000000 --- a/src/AngleSharp.Wasm.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2019.")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: ComVisible(false)] diff --git a/src/AngleSharp.Wasm.Tests/WasmConfigurationTests.cs b/src/AngleSharp.Wasm.Tests/WasmConfigurationTests.cs new file mode 100644 index 0000000..7179991 --- /dev/null +++ b/src/AngleSharp.Wasm.Tests/WasmConfigurationTests.cs @@ -0,0 +1,15 @@ +namespace AngleSharp.Wasm.Tests; + +using NUnit.Framework; + +[TestFixture] +public sealed class WasmConfigurationTests +{ + [Test] + public void WithWasmRegistersFactory() + { + var config = Configuration.Default.WithWasm(); + + Assert.That(config.Has(), Is.True); + } +} diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs new file mode 100644 index 0000000..9fd0d33 --- /dev/null +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -0,0 +1,249 @@ +namespace AngleSharp.Wasm.Tests; + +using AngleSharp.Wasm.Dom; +using NUnit.Framework; +using System; +using System.Linq; +using System.Threading.Tasks; + +[TestFixture] +public sealed class WasmJsBridgeTests +{ + // (module (func (export "answer") (result i32) i32.const 42)) + private static readonly byte[] AnswerModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x0A, 0x01, 0x06, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, + 0x0A, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2A, 0x0B, + }; + + // (module + // (import "host" "answer" (func $answer (result i32))) + // (func (export "answer_plus_one") (result i32) call $answer i32.const 1 i32.add)) + private static readonly byte[] HostImportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, + 0x02, 0x0F, 0x01, 0x04, 0x68, 0x6F, 0x73, 0x74, 0x06, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x13, 0x01, 0x0F, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x5F, 0x70, 0x6C, 0x75, 0x73, 0x5F, 0x6F, 0x6E, 0x65, 0x00, 0x01, + 0x0A, 0x09, 0x01, 0x07, 0x00, 0x10, 0x00, 0x41, 0x01, 0x6A, 0x0B, + }; + + // (module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add)) + private static readonly byte[] AddModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x07, 0x01, 0x60, 0x02, 0x7F, 0x7F, 0x01, 0x7F, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, + 0x0A, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6A, 0x0B, + }; + + // (module (memory (export "mem") 1)) + private static readonly byte[] MemoryExportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x05, 0x03, 0x01, 0x00, 0x01, + 0x07, 0x07, 0x01, 0x03, 0x6D, 0x65, 0x6D, 0x02, 0x00, + }; + + // AnswerModule + two custom sections named "meta" + private static readonly byte[] AnswerModuleWithCustomSections = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x0A, 0x01, 0x06, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, + 0x0A, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2A, 0x0B, + 0x00, 0x08, 0x04, 0x6D, 0x65, 0x74, 0x61, 0x01, 0x02, 0x03, + 0x00, 0x06, 0x04, 0x6D, 0x65, 0x74, 0x61, 0xAA, + }; + + [Test] + public async Task BridgeCanCompileInstantiateAndInvokeViaInstanceExportsFunction() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, AnswerModule); + var moduleExports = module.Exports(); + var instance = WebAssembly.Instantiate(context.Current!, module); + + var result = instance.Invoke("answer"); + var answerExport = instance.Exports["answer"] as WasmJsExportedFunction; + var missingExport = instance.Exports["missing"]; + var exportKeys = instance.Exports.Keys(); + + Assert.That(result, Is.EqualTo(42)); + Assert.That(moduleExports, Has.Length.EqualTo(1)); + Assert.That(moduleExports[0].Name, Is.EqualTo("answer")); + Assert.That(moduleExports[0].Kind, Is.EqualTo("function")); + Assert.That(answerExport, Is.Not.Null); + Assert.That(answerExport!.Invoke(), Is.EqualTo(42)); + Assert.That(missingExport, Is.Null); + Assert.That(exportKeys, Is.EquivalentTo(new[] { "answer" })); + } + + [Test] + public async Task ModuleImportsExposeSpecDescriptorFields() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, HostImportModule); + var imports = module.Imports(); + + Assert.That(imports, Has.Length.EqualTo(1)); + Assert.That(imports[0].Module, Is.EqualTo("host")); + Assert.That(imports[0].Name, Is.EqualTo("answer")); + Assert.That(imports[0].Kind, Is.EqualTo("function")); + } + + [Test] + public async Task ModuleCustomSectionsFilterByNameAndReturnAllMatches() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, AnswerModuleWithCustomSections); + var sections = module.CustomSections("meta"); + var noSections = module.CustomSections("missing"); + + Assert.That(sections, Has.Length.EqualTo(2)); + Assert.That(sections[0], Is.EqualTo(new byte[] { 0x01, 0x02, 0x03 })); + Assert.That(sections[1], Is.EqualTo(new byte[] { 0xAA })); + Assert.That(noSections, Is.Empty); + } + + [Test] + public async Task InstanceExportsExposeNonFunctionKindsAsValueDescriptors() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, MemoryExportModule); + var moduleExports = module.Exports(); + var instance = WebAssembly.Instantiate(context.Current!, module); + + var memExport = instance.Exports["mem"] as WasmJsExportValue; + + Assert.That(moduleExports, Has.Length.EqualTo(1)); + Assert.That(moduleExports[0].Name, Is.EqualTo("mem")); + Assert.That(moduleExports[0].Kind, Is.EqualTo("memory")); + Assert.That(memExport, Is.Not.Null); + Assert.That(memExport!.Name, Is.EqualTo("mem")); + Assert.That(memExport.Kind, Is.EqualTo("memory")); + Assert.That(instance.Exports.Keys().Single(), Is.EqualTo("mem")); + } + + [Test] + public async Task ExportedFunctionWrapperSupportsArguments() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, AddModule); + var instance = WebAssembly.Instantiate(context.Current!, module); + var addExport = instance.Exports["add"] as WasmJsExportedFunction; + + Assert.That(addExport, Is.Not.Null); + Assert.That(addExport!.Invoke(20, 22), Is.EqualTo(42)); + } + + [Test] + public async Task BridgeCompileThrowsForNullBytes() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + Assert.That(() => WebAssembly.Compile(context.Current!, null!), Throws.ArgumentNullException); + } + + [Test] + public async Task BridgeInstantiateThrowsForNullModule() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + Assert.That(() => WebAssembly.Instantiate(context.Current!, null!), Throws.ArgumentNullException); + } + + [Test] + public async Task CustomSectionsThrowsForNullName() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, AnswerModuleWithCustomSections); + + Assert.That(() => module.CustomSections(null!), Throws.ArgumentNullException); + } + + [Test] + public async Task CustomSectionsLookupIsCaseSensitive() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, AnswerModuleWithCustomSections); + + Assert.That(module.CustomSections("meta"), Has.Length.EqualTo(2)); + Assert.That(module.CustomSections("Meta"), Is.Empty); + } + + [Test] + public async Task BridgeInstantiateUsesConfiguredImportProviders() + { + var config = Configuration.Default + .WithWasm() + .WithWasmImports(_ => + { + return new[] + { + new WasmImportFunction( + "host", + "answer", + Array.Empty(), + new[] { WasmValueType.Int32 }, + _ => 41), + }; + }); + + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, HostImportModule); + var instance = WebAssembly.Instantiate(context.Current!, module); + var export = instance.Exports["answer_plus_one"] as WasmJsExportedFunction; + + Assert.That(export, Is.Not.Null); + Assert.That(export!.Invoke(), Is.EqualTo(42)); + } + + [Test] + public async Task BridgeCompileWithoutWasmRegistrationThrows() + { + var config = Configuration.Default; + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + Assert.That(() => WebAssembly.Compile(context.Current!, AnswerModule), Throws.InvalidOperationException); + } +} diff --git a/src/AngleSharp.Wasm.Tests/WasmtimeRuntimeTests.cs b/src/AngleSharp.Wasm.Tests/WasmtimeRuntimeTests.cs new file mode 100644 index 0000000..fefe890 --- /dev/null +++ b/src/AngleSharp.Wasm.Tests/WasmtimeRuntimeTests.cs @@ -0,0 +1,91 @@ +namespace AngleSharp.Wasm.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; + +[TestFixture] +public sealed class WasmtimeRuntimeTests +{ + // (module (func (export "answer") (result i32) i32.const 42)) + private static readonly byte[] MinimalModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x0A, 0x01, 0x06, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, + 0x0A, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2A, 0x0B, + }; + + // (module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add)) + private static readonly byte[] AddModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x07, 0x01, 0x60, 0x02, 0x7F, 0x7F, 0x01, 0x7F, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, + 0x0A, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6A, 0x0B, + }; + + // (module + // (import "host" "answer" (func $answer (result i32))) + // (func (export "answer_plus_one") (result i32) call $answer i32.const 1 i32.add)) + private static readonly byte[] HostImportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, + 0x02, 0x0F, 0x01, 0x04, 0x68, 0x6F, 0x73, 0x74, 0x06, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x13, 0x01, 0x0F, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x5F, 0x70, 0x6C, 0x75, 0x73, 0x5F, 0x6F, 0x6E, 0x65, 0x00, 0x01, + 0x0A, 0x09, 0x01, 0x07, 0x00, 0x10, 0x00, 0x41, 0x01, 0x6A, 0x0B, + }; + + [Test] + public async Task CanCompileInstantiateAndInvokeExport() + { + using var runtime = new WasmtimeWasmRuntime(); + using var module = await runtime.CompileAsync(MinimalModule).ConfigureAwait(false); + using var instance = await runtime.InstantiateAsync(module).ConfigureAwait(false); + + var result = await instance.InvokeAsync("answer").ConfigureAwait(false); + + Assert.That(Convert.ToInt32(result), Is.EqualTo(42)); + } + + [Test] + public async Task CanInvokeExportWithArguments() + { + using var runtime = new WasmtimeWasmRuntime(); + using var module = await runtime.CompileAsync(AddModule).ConfigureAwait(false); + using var instance = await runtime.InstantiateAsync(module).ConfigureAwait(false); + + var result = await instance.InvokeAsync("add", new object?[] { 20, 22 }).ConfigureAwait(false); + + Assert.That(Convert.ToInt32(result), Is.EqualTo(42)); + } + + [Test] + public async Task CanInstantiateWithHostImport() + { + using var runtime = new WasmtimeWasmRuntime(); + using var module = await runtime.CompileAsync(HostImportModule).ConfigureAwait(false); + + var imports = new[] + { + new WasmImportFunction( + "host", + "answer", + Array.Empty(), + new[] { WasmValueType.Int32 }, + _ => 41), + }; + + using var instance = await runtime.InstantiateAsync(module, imports).ConfigureAwait(false); + var result = await instance.InvokeAsync("answer_plus_one").ConfigureAwait(false); + + Assert.That(Convert.ToInt32(result), Is.EqualTo(42)); + } +} diff --git a/src/AngleSharp.Wasm.nuspec b/src/AngleSharp.Wasm.nuspec deleted file mode 100644 index cd3b10f..0000000 --- a/src/AngleSharp.Wasm.nuspec +++ /dev/null @@ -1,20 +0,0 @@ - - - - AngleSharp.Wasm - $version$ - AngleSharp - Florian Rappl - MIT - https://anglesharp.github.io - logo.png - false - Adds a way to run WebAssembly to AngleSharp. - https://github.com/AngleSharp/AngleSharp.Wasm/blob/master/CHANGELOG.md - Copyright 2016-2021, AngleSharp - html html5 css css3 dom requester http https wasm web assembly - - - - - diff --git a/src/AngleSharp.Wasm.sln b/src/AngleSharp.Wasm.sln index 75053ab..7650c4d 100644 --- a/src/AngleSharp.Wasm.sln +++ b/src/AngleSharp.Wasm.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Wasm", "AngleSha EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Wasm.Tests", "AngleSharp.Wasm.Tests\AngleSharp.Wasm.Tests.csproj", "{18B0B97B-8795-4DC2-A1E7-8070255BE718}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "_build", "..\build\_build.csproj", "{B82798E2-2766-4C1E-860C-136F4B3B1185}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/AngleSharp.Wasm/AngleSharp.Wasm.csproj b/src/AngleSharp.Wasm/AngleSharp.Wasm.csproj index 2546778..8bd909b 100644 --- a/src/AngleSharp.Wasm/AngleSharp.Wasm.csproj +++ b/src/AngleSharp.Wasm/AngleSharp.Wasm.csproj @@ -2,14 +2,45 @@ AngleSharp.Wasm AngleSharp.Wasm - netstandard2.0 - true - Key.snk + net8.0;net10.0 true + + https://anglesharp.github.io + MIT + logo.png + README.md + html;html5;css;css3;dom;requester;http;https;wasm;web;assembly;anglesharp;angle + https://github.com/AngleSharp/AngleSharp.Wasm/blob/main/CHANGELOG.md + https://github.com/AngleSharp/AngleSharp.Wasm + git + true + true + true + snupkg + 1.5.0 + + + + + + + + + + + + + + + + - + + + <_Parameter1>false + diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExportValue.cs b/src/AngleSharp.Wasm/Dom/WasmJsExportValue.cs new file mode 100644 index 0000000..07902bc --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsExportValue.cs @@ -0,0 +1,30 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; + +/// +/// JavaScript-visible non-function export entry. +/// +[DomName("ExportedValue")] +public sealed class WasmJsExportValue +{ + internal WasmJsExportValue(string name, string kind) + { + Name = name; + Kind = kind; + } + + /// + /// Gets the export name. + /// + [DomName("name")] + [DomAccessor(Accessors.Getter)] + public string Name { get; } + + /// + /// Gets the export kind. + /// + [DomName("kind")] + [DomAccessor(Accessors.Getter)] + public string Kind { get; } +} diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs b/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs new file mode 100644 index 0000000..fd7c200 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs @@ -0,0 +1,27 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; + +/// +/// JavaScript-visible exported function wrapper. +/// +[DomName("ExportedFunction")] +public sealed class WasmJsExportedFunction +{ + private readonly WasmJsInstance _owner; + private readonly string _name; + + internal WasmJsExportedFunction(WasmJsInstance owner, string name) + { + _owner = owner; + _name = name; + } + + /// + /// Invokes the export function. + /// + /// The function arguments. + /// The invocation result. + [DomName("invoke")] + public object? Invoke(params object?[] arguments) => _owner.Invoke(_name, arguments); +} diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs new file mode 100644 index 0000000..462b81c --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs @@ -0,0 +1,40 @@ +namespace AngleSharp.Wasm.Dom; + +using System.Collections.Generic; +using System.Linq; +using AngleSharp.Attributes; + +/// +/// JavaScript-visible exports object. +/// +[DomName("Exports")] +public sealed class WasmJsExports +{ + private readonly IReadOnlyDictionary _entries; + + internal WasmJsExports(WasmJsInstance owner, IReadOnlyList exportsMetadata) + { + _entries = exportsMetadata + .ToDictionary( + m => m.Name, + m => m.Kind == "function" + ? (object)new WasmJsExportedFunction(owner, m.Name) + : new WasmJsExportValue(m.Name, m.Kind)); + } + + /// + /// Gets the export entry by name. + /// + /// The export name. + /// The export entry, if present. + [DomName("item")] + [DomAccessor(Accessors.Getter)] + public object? this[string name] => _entries.TryGetValue(name, out var value) ? value : null; + + /// + /// Gets the available export names. + /// + /// The export names. + [DomName("keys")] + public string[] Keys() => _entries.Keys.ToArray(); +} diff --git a/src/AngleSharp.Wasm/Dom/WasmJsInstance.cs b/src/AngleSharp.Wasm/Dom/WasmJsInstance.cs new file mode 100644 index 0000000..44a76e5 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsInstance.cs @@ -0,0 +1,39 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using System.Collections.Generic; + +/// +/// JavaScript-visible instance handle. +/// +[DomName("Instance")] +public sealed class WasmJsInstance +{ + private readonly WasmJsExports _exports; + + internal WasmJsInstance(IWasmInstance instance, IReadOnlyList exportsMetadata) + { + Instance = instance; + _exports = new WasmJsExports(this, exportsMetadata); + } + + internal IWasmInstance Instance { get; } + + /// + /// Gets the module exports object. + /// + [DomName("exports")] + [DomAccessor(Accessors.Getter)] + public WasmJsExports Exports => _exports; + + /// + /// Invokes an exported function. + /// + /// The exported function name. + /// The invocation arguments. + /// The invocation result. + [DomName("invoke")] + public object? Invoke(string exportName, params object?[] arguments) => + Instance.InvokeAsync(exportName, arguments).AsTask().GetAwaiter().GetResult(); +} + diff --git a/src/AngleSharp.Wasm/Dom/WasmJsModule.cs b/src/AngleSharp.Wasm/Dom/WasmJsModule.cs new file mode 100644 index 0000000..31054fa --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsModule.cs @@ -0,0 +1,61 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// JavaScript-visible module handle. +/// +[DomName("Module")] +public sealed class WasmJsModule +{ + private readonly WasmModuleMetadata _metadata; + + internal WasmJsModule(IWasmRuntime runtime, IWasmCompiledModule module, byte[] moduleBytes) + { + Runtime = runtime; + Module = module; + _metadata = WasmModuleMetadata.Parse(moduleBytes); + } + + internal IWasmRuntime Runtime { get; } + + internal IWasmCompiledModule Module { get; } + + internal IReadOnlyList ExportsMetadata => _metadata.Exports; + + /// + /// Gets this module's exported names and kinds. + /// + /// The export descriptors. + [DomName("exports")] + public WasmModuleExportDescriptor[] Exports() => _metadata.Exports.ToArray(); + + /// + /// Gets this module's imported names and kinds. + /// + /// The import descriptors. + [DomName("imports")] + public WasmModuleImportDescriptor[] Imports() => _metadata.Imports.ToArray(); + + /// + /// Gets the custom sections with the specified name. + /// + /// The custom section name. + /// The custom section payloads. + [DomName("customSections")] + public byte[][] CustomSections(string sectionName) + { + if (sectionName is null) + { + throw new ArgumentNullException(nameof(sectionName)); + } + + return _metadata.CustomSections + .Where(m => String.Equals(m.Name, sectionName, StringComparison.Ordinal)) + .Select(m => m.Payload) + .ToArray(); + } +} diff --git a/src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs b/src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs new file mode 100644 index 0000000..efc0c58 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs @@ -0,0 +1,316 @@ +namespace AngleSharp.Wasm.Dom; + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; + +internal sealed class WasmModuleMetadata +{ + private WasmModuleMetadata( + IReadOnlyList imports, + IReadOnlyList exports, + IReadOnlyList customSections) + { + Imports = imports; + Exports = exports; + CustomSections = customSections; + } + + public IReadOnlyList Imports { get; } + + public IReadOnlyList Exports { get; } + + public IReadOnlyList CustomSections { get; } + + public static WasmModuleMetadata Parse(byte[] bytes) + { + if (bytes is null) + { + throw new ArgumentNullException(nameof(bytes)); + } + + if (bytes.Length < 8) + { + throw new ArgumentException("The input is too short to be a valid WebAssembly module.", nameof(bytes)); + } + + if (bytes[0] != 0x00 || bytes[1] != 0x61 || bytes[2] != 0x73 || bytes[3] != 0x6D) + { + throw new ArgumentException("Missing WebAssembly magic header.", nameof(bytes)); + } + + var imports = new List(); + var exports = new List(); + var customSections = new List(); + + var offset = 8; + + while (offset < bytes.Length) + { + var sectionId = bytes[offset++]; + var sectionSize = (int)ReadVarUInt32(bytes, ref offset); + var sectionEnd = checked(offset + sectionSize); + + if (sectionEnd > bytes.Length) + { + throw new ArgumentException("Invalid WebAssembly section size.", nameof(bytes)); + } + + switch (sectionId) + { + case 0: + ParseCustomSection(bytes, ref offset, sectionEnd, customSections); + break; + case 2: + ParseImportSection(bytes, ref offset, sectionEnd, imports); + break; + case 7: + ParseExportSection(bytes, ref offset, sectionEnd, exports); + break; + default: + offset = sectionEnd; + break; + } + } + + return new WasmModuleMetadata(imports, exports, customSections); + } + + private static void ParseImportSection(byte[] bytes, ref int offset, int sectionEnd, List imports) + { + var count = ReadVarUInt32(bytes, ref offset); + + for (uint i = 0; i < count; i++) + { + var moduleName = ReadName(bytes, ref offset); + var name = ReadName(bytes, ref offset); + EnsureRemaining(bytes, offset, sectionEnd, 1); + + var kind = bytes[offset++]; + imports.Add(new WasmModuleImportDescriptor(moduleName, name, MapKind(kind))); + SkipImportType(bytes, ref offset, sectionEnd, kind); + } + + offset = sectionEnd; + } + + private static void ParseExportSection(byte[] bytes, ref int offset, int sectionEnd, List exports) + { + var count = ReadVarUInt32(bytes, ref offset); + + for (uint i = 0; i < count; i++) + { + var name = ReadName(bytes, ref offset); + EnsureRemaining(bytes, offset, sectionEnd, 1); + var kind = bytes[offset++]; + _ = ReadVarUInt32(bytes, ref offset); + exports.Add(new WasmModuleExportDescriptor(name, MapKind(kind))); + } + + offset = sectionEnd; + } + + private static void ParseCustomSection(byte[] bytes, ref int offset, int sectionEnd, List sections) + { + var name = ReadName(bytes, ref offset); + var remaining = sectionEnd - offset; + + if (remaining < 0) + { + throw new ArgumentException("Invalid custom section layout.", nameof(bytes)); + } + + var payload = ArrayPool.Shared.Rent(remaining); + + try + { + Array.Copy(bytes, offset, payload, 0, remaining); + sections.Add(new WasmCustomSectionDescriptor(name, payload.AsSpan(0, remaining).ToArray())); + } + finally + { + ArrayPool.Shared.Return(payload); + } + + offset = sectionEnd; + } + + private static void SkipImportType(byte[] bytes, ref int offset, int sectionEnd, byte kind) + { + switch (kind) + { + case 0x00: + _ = ReadVarUInt32(bytes, ref offset); + break; + case 0x01: + _ = ReadByte(bytes, ref offset, sectionEnd); + SkipLimits(bytes, ref offset, sectionEnd); + break; + case 0x02: + SkipLimits(bytes, ref offset, sectionEnd); + break; + case 0x03: + _ = ReadByte(bytes, ref offset, sectionEnd); + _ = ReadByte(bytes, ref offset, sectionEnd); + break; + case 0x04: + _ = ReadByte(bytes, ref offset, sectionEnd); + _ = ReadVarUInt32(bytes, ref offset); + break; + default: + throw new ArgumentException($"Unsupported WebAssembly import kind '{kind}'.", nameof(bytes)); + } + } + + private static void SkipLimits(byte[] bytes, ref int offset, int sectionEnd) + { + var flags = ReadByte(bytes, ref offset, sectionEnd); + _ = ReadVarUInt32(bytes, ref offset); + + if ((flags & 0x01) != 0) + { + _ = ReadVarUInt32(bytes, ref offset); + } + } + + private static string ReadName(byte[] bytes, ref int offset) + { + var length = (int)ReadVarUInt32(bytes, ref offset); + + if (length < 0 || offset + length > bytes.Length) + { + throw new ArgumentException("Invalid name length in WebAssembly binary.", nameof(bytes)); + } + + var name = Encoding.UTF8.GetString(bytes, offset, length); + offset += length; + return name; + } + + private static byte ReadByte(byte[] bytes, ref int offset, int sectionEnd) + { + EnsureRemaining(bytes, offset, sectionEnd, 1); + return bytes[offset++]; + } + + private static void EnsureRemaining(byte[] bytes, int offset, int sectionEnd, int needed) + { + if (offset + needed > sectionEnd || offset + needed > bytes.Length) + { + throw new ArgumentException("Unexpected end of WebAssembly section.", nameof(bytes)); + } + } + + private static uint ReadVarUInt32(byte[] bytes, ref int offset) + { + uint result = 0; + var shift = 0; + + while (true) + { + if (offset >= bytes.Length) + { + throw new ArgumentException("Unexpected end while reading LEB128 value.", nameof(bytes)); + } + + var current = bytes[offset++]; + result |= (uint)(current & 0x7F) << shift; + + if ((current & 0x80) == 0) + { + return result; + } + + shift += 7; + + if (shift >= 35) + { + throw new ArgumentException("Invalid LEB128 value in WebAssembly binary.", nameof(bytes)); + } + } + } + + private static string MapKind(byte kind) => kind switch + { + 0x00 => "function", + 0x01 => "table", + 0x02 => "memory", + 0x03 => "global", + 0x04 => "tag", + _ => "unknown", + }; +} + +/// +/// Represents a module export descriptor. +/// +public sealed class WasmModuleExportDescriptor +{ + internal WasmModuleExportDescriptor(string name, string kind) + { + Name = name; + Kind = kind; + } + + /// + /// Gets the exported field name. + /// + [AngleSharp.Attributes.DomName("name")] + [AngleSharp.Attributes.DomAccessor(AngleSharp.Attributes.Accessors.Getter)] + public string Name { get; } + + /// + /// Gets the exported field kind. + /// + [AngleSharp.Attributes.DomName("kind")] + [AngleSharp.Attributes.DomAccessor(AngleSharp.Attributes.Accessors.Getter)] + public string Kind { get; } +} + +/// +/// Represents a module import descriptor. +/// +public sealed class WasmModuleImportDescriptor +{ + internal WasmModuleImportDescriptor(string module, string name, string kind) + { + Module = module; + Name = name; + Kind = kind; + } + + /// + /// Gets the import module name. + /// + [AngleSharp.Attributes.DomName("module")] + [AngleSharp.Attributes.DomAccessor(AngleSharp.Attributes.Accessors.Getter)] + public string Module { get; } + + /// + /// Gets the import field name. + /// + [AngleSharp.Attributes.DomName("name")] + [AngleSharp.Attributes.DomAccessor(AngleSharp.Attributes.Accessors.Getter)] + public string Name { get; } + + /// + /// Gets the import kind. + /// + [AngleSharp.Attributes.DomName("kind")] + [AngleSharp.Attributes.DomAccessor(AngleSharp.Attributes.Accessors.Getter)] + public string Kind { get; } +} + +internal sealed class WasmCustomSectionDescriptor +{ + internal WasmCustomSectionDescriptor(string name, byte[] payload) + { + Name = name; + Payload = payload; + } + + public string Name { get; } + + public byte[] Payload { get; } +} diff --git a/src/AngleSharp.Wasm/Dom/WebAssembly.cs b/src/AngleSharp.Wasm/Dom/WebAssembly.cs new file mode 100644 index 0000000..d0fec65 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WebAssembly.cs @@ -0,0 +1,72 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +/// +/// Minimal JavaScript bridge object exposed as WebAssembly in compatible JS engines. +/// +[DomName("WebAssembly")] +[DomNoInterfaceObject] +[DomExposed("Window")] +public static class WebAssembly +{ + private static readonly ConditionalWeakTable runtimeCache = new (); + + /// + /// Compiles a module and returns a bridge module handle. + /// + /// The host. + /// The module bytes. + /// The module handle. + [DomName("compile")] + public static WasmJsModule Compile(this IWindow window, byte[] moduleBytes) + { + if (moduleBytes is null) + { + throw new ArgumentNullException(nameof(moduleBytes)); + } + + var runtime = GetOrCreateRuntime(window); + var module = runtime.CompileAsync(moduleBytes).AsTask().GetAwaiter().GetResult(); + return new WasmJsModule(runtime, module, moduleBytes); + } + + /// + /// Instantiates a module handle and returns a bridge instance. + /// + /// The host. + /// The compiled module handle. + /// The instance handle. + [DomName("instantiate")] + public static WasmJsInstance Instantiate(this IWindow window, WasmJsModule module) + { + if (module is null) + { + throw new ArgumentNullException(nameof(module)); + } + + var imports = GetImports(window); + var instance = module.Runtime.InstantiateAsync(module.Module, imports).AsTask().GetAwaiter().GetResult(); + return new WasmJsInstance(instance, module.ExportsMetadata); + } + + private static IWasmRuntime GetOrCreateRuntime(IWindow window) => + runtimeCache.GetValue(window.Document?.Context!, static context => + { + var factory = context.GetService() + ?? throw new InvalidOperationException("No IWasmRuntimeFactory has been registered."); + return factory.Create(context); + }); + + private static IReadOnlyList GetImports(IWindow window) => + window.Document.Context + .GetServices() + .SelectMany(provider => provider.GetImports(window.Document.Context)) + .ToArray(); +} + diff --git a/src/AngleSharp.Wasm/IWasmCompiledModule.cs b/src/AngleSharp.Wasm/IWasmCompiledModule.cs new file mode 100644 index 0000000..659e73b --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmCompiledModule.cs @@ -0,0 +1,10 @@ +namespace AngleSharp.Wasm; + +using System; + +/// +/// Represents a compiled WebAssembly module. +/// +public interface IWasmCompiledModule : IDisposable +{ +} diff --git a/src/AngleSharp.Wasm/IWasmImportProvider.cs b/src/AngleSharp.Wasm/IWasmImportProvider.cs new file mode 100644 index 0000000..d14c541 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmImportProvider.cs @@ -0,0 +1,16 @@ +namespace AngleSharp.Wasm; + +using System.Collections.Generic; + +/// +/// Provides host imports to use for WebAssembly instantiation. +/// +public interface IWasmImportProvider +{ + /// + /// Gets the imports available in the current browsing context. + /// + /// The browsing context. + /// The available import functions. + IEnumerable GetImports(IBrowsingContext context); +} diff --git a/src/AngleSharp.Wasm/IWasmInstance.cs b/src/AngleSharp.Wasm/IWasmInstance.cs new file mode 100644 index 0000000..559b808 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmInstance.cs @@ -0,0 +1,20 @@ +namespace AngleSharp.Wasm; + +using System; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Represents an instantiated WebAssembly module. +/// +public interface IWasmInstance : IDisposable +{ + /// + /// Invokes an exported function by name. + /// + /// The export name. + /// The arguments to pass. + /// The cancellation token. + /// The invocation result, if any. + ValueTask InvokeAsync(string exportName, object?[]? arguments = null, CancellationToken cancellationToken = default); +} diff --git a/src/AngleSharp.Wasm/IWasmRuntime.cs b/src/AngleSharp.Wasm/IWasmRuntime.cs new file mode 100644 index 0000000..9ab05e2 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmRuntime.cs @@ -0,0 +1,32 @@ +namespace AngleSharp.Wasm; + +using System.Collections.Generic; +using System; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Represents a runtime capable of compiling and instantiating WebAssembly modules. +/// +public interface IWasmRuntime : IDisposable +{ + /// + /// Compiles a WebAssembly binary module. + /// + /// The raw WebAssembly bytes. + /// The cancellation token. + /// The compiled module. + ValueTask CompileAsync(ReadOnlyMemory moduleBytes, CancellationToken cancellationToken = default); + + /// + /// Instantiates a previously compiled WebAssembly module. + /// + /// The compiled module to instantiate. + /// The host imports to expose during instantiation. + /// The cancellation token. + /// The created module instance. + ValueTask InstantiateAsync( + IWasmCompiledModule compiledModule, + IEnumerable? imports = null, + CancellationToken cancellationToken = default); +} diff --git a/src/AngleSharp.Wasm/IWasmRuntimeFactory.cs b/src/AngleSharp.Wasm/IWasmRuntimeFactory.cs new file mode 100644 index 0000000..29ddb30 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmRuntimeFactory.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Wasm; + +/// +/// Creates runtime instances for a browsing context. +/// +public interface IWasmRuntimeFactory +{ + /// + /// Creates a new runtime instance. + /// + /// The active browsing context. + /// The runtime instance. + IWasmRuntime Create(IBrowsingContext context); +} diff --git a/src/AngleSharp.Wasm/Key.snk b/src/AngleSharp.Wasm/Key.snk deleted file mode 100644 index d43ce41..0000000 Binary files a/src/AngleSharp.Wasm/Key.snk and /dev/null differ diff --git a/src/AngleSharp.Wasm/Properties/AssemblyInfo.cs b/src/AngleSharp.Wasm/Properties/AssemblyInfo.cs deleted file mode 100644 index 75078da..0000000 --- a/src/AngleSharp.Wasm/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.Wasm.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001adf274fa2b375134e8e4558d606f1a0f96f5cd0c6b99970f7cce9887477209d7c29f814e2508d8bd2526e99e8cd273bd1158a3984f1ea74830ec5329a77c6ff201a15edeb8b36ab046abd1bce211fe8dbb076d7d806f46b15bfda44def04ead0669971e96c5f666c9eda677f28824fff7aa90d32929ed91d529a7a41699893")] diff --git a/src/AngleSharp.Wasm/WasmConfigurationExtensions.cs b/src/AngleSharp.Wasm/WasmConfigurationExtensions.cs new file mode 100644 index 0000000..525d498 --- /dev/null +++ b/src/AngleSharp.Wasm/WasmConfigurationExtensions.cs @@ -0,0 +1,79 @@ +namespace AngleSharp; + +using AngleSharp.Wasm; +using System.Collections.Generic; +using System; + +/// +/// Additional extensions to register a WebAssembly runtime. +/// +public static class WasmConfigurationExtensions +{ + /// + /// Registers the default Wasmtime-backed WebAssembly runtime factory. + /// + /// The configuration. + /// The updated configuration. + public static IConfiguration WithWasm(this IConfiguration configuration) => + configuration.WithWasm(context => new WasmtimeWasmRuntimeFactory()); + + /// + /// Registers a custom WebAssembly runtime factory. + /// + /// The configuration. + /// The factory creator delegate. + /// The updated configuration. + public static IConfiguration WithWasm(this IConfiguration configuration, Func factory) + { + if (factory is null) + { + throw new ArgumentNullException(nameof(factory)); + } + + return configuration.WithOnly(factory); + } + + /// + /// Registers a provider for host import functions. + /// + /// The configuration. + /// The import provider. + /// The updated configuration. + public static IConfiguration WithWasmImports(this IConfiguration configuration, IWasmImportProvider provider) + { + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + + return configuration.With(provider); + } + + /// + /// Registers a delegate-based provider for host import functions. + /// + /// The configuration. + /// The imports delegate. + /// The updated configuration. + public static IConfiguration WithWasmImports(this IConfiguration configuration, Func> provider) + { + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + + return configuration.With(context => new DelegateWasmImportProvider(provider)); + } + + private sealed class DelegateWasmImportProvider : IWasmImportProvider + { + private readonly Func> _provider; + + public DelegateWasmImportProvider(Func> provider) + { + _provider = provider; + } + + public IEnumerable GetImports(IBrowsingContext context) => _provider(context); + } +} diff --git a/src/AngleSharp.Wasm/WasmImportFunction.cs b/src/AngleSharp.Wasm/WasmImportFunction.cs new file mode 100644 index 0000000..d3a4630 --- /dev/null +++ b/src/AngleSharp.Wasm/WasmImportFunction.cs @@ -0,0 +1,68 @@ +namespace AngleSharp.Wasm; + +using System; +using System.Collections.Generic; + +/// +/// Delegate executed for a host import function call. +/// +/// The import invocation arguments. +/// The return value or array of return values. +public delegate object? WasmImportCallback(IReadOnlyList arguments); + +/// +/// Describes a host import function exposed to a WebAssembly module. +/// +public sealed class WasmImportFunction +{ + /// + /// Creates a new import function descriptor. + /// + /// The import module name. + /// The import function name. + /// The parameter types expected by the import. + /// The result types returned by the import. + /// The callback to execute. + public WasmImportFunction( + string moduleName, + string functionName, + IReadOnlyList parameterTypes, + IReadOnlyList resultTypes, + WasmImportCallback callback) + { + ModuleName = !string.IsNullOrWhiteSpace(moduleName) + ? moduleName + : throw new ArgumentException("Module name must be provided.", nameof(moduleName)); + FunctionName = !string.IsNullOrWhiteSpace(functionName) + ? functionName + : throw new ArgumentException("Function name must be provided.", nameof(functionName)); + ParameterTypes = parameterTypes ?? throw new ArgumentNullException(nameof(parameterTypes)); + ResultTypes = resultTypes ?? throw new ArgumentNullException(nameof(resultTypes)); + Callback = callback ?? throw new ArgumentNullException(nameof(callback)); + } + + /// + /// Gets the import module name. + /// + public string ModuleName { get; } + + /// + /// Gets the import function name. + /// + public string FunctionName { get; } + + /// + /// Gets the expected parameter types. + /// + public IReadOnlyList ParameterTypes { get; } + + /// + /// Gets the expected result types. + /// + public IReadOnlyList ResultTypes { get; } + + /// + /// Gets the callback that implements the host import. + /// + public WasmImportCallback Callback { get; } +} diff --git a/src/AngleSharp.Wasm/WasmValueType.cs b/src/AngleSharp.Wasm/WasmValueType.cs new file mode 100644 index 0000000..1d8279e --- /dev/null +++ b/src/AngleSharp.Wasm/WasmValueType.cs @@ -0,0 +1,27 @@ +namespace AngleSharp.Wasm; + +/// +/// Represents the primitive value types shared across supported WebAssembly runtimes. +/// +public enum WasmValueType +{ + /// + /// 32-bit integer. + /// + Int32, + + /// + /// 64-bit integer. + /// + Int64, + + /// + /// 32-bit floating-point number. + /// + Float32, + + /// + /// 64-bit floating-point number. + /// + Float64, +} diff --git a/src/AngleSharp.Wasm/WasmtimeCompiledModule.cs b/src/AngleSharp.Wasm/WasmtimeCompiledModule.cs new file mode 100644 index 0000000..3ffdd4d --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeCompiledModule.cs @@ -0,0 +1,34 @@ +namespace AngleSharp.Wasm; + +using System; +using Wasmtime; + +/// +/// Wraps a compiled Wasmtime module. +/// +public sealed class WasmtimeCompiledModule : IWasmCompiledModule +{ + private bool _disposed; + + internal WasmtimeCompiledModule(Engine engine, Module module) + { + Engine = engine; + Module = module; + } + + internal Engine Engine { get; } + + internal Module Module { get; } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + Module.Dispose(); + } +} diff --git a/src/AngleSharp.Wasm/WasmtimeInstance.cs b/src/AngleSharp.Wasm/WasmtimeInstance.cs new file mode 100644 index 0000000..72f8524 --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeInstance.cs @@ -0,0 +1,92 @@ +namespace AngleSharp.Wasm; + +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Wasmtime; + +/// +/// Wraps a Wasmtime instance and store. +/// +public sealed class WasmtimeInstance : IWasmInstance +{ + private readonly Store _store; + private readonly Instance _instance; + private bool _disposed; + + internal WasmtimeInstance(Store store, Instance instance) + { + _store = store; + _instance = instance; + } + + /// + public ValueTask InvokeAsync(string exportName, object?[]? arguments = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(exportName)) + { + throw new ArgumentException("Export name must be provided.", nameof(exportName)); + } + + var function = _instance.GetFunction(exportName); + + if (function is null) + { + throw new InvalidOperationException($"The export '{exportName}' is not a function."); + } + + var parameters = function.Parameters; + var invocationArguments = arguments ?? Array.Empty(); + + if (invocationArguments.Length != parameters.Count) + { + throw new ArgumentException($"Export '{exportName}' expects {parameters.Count} argument(s), but {invocationArguments.Length} were provided.", nameof(arguments)); + } + + if (invocationArguments.Length == 0) + { + return ValueTask.FromResult(function.Invoke()); + } + + var boxedArguments = new ValueBox[invocationArguments.Length]; + + for (var i = 0; i < boxedArguments.Length; i++) + { + boxedArguments[i] = parameters[i] switch + { + ValueKind.Int32 => (ValueBox)Convert.ToInt32(invocationArguments[i], CultureInfo.InvariantCulture), + ValueKind.Int64 => (ValueBox)Convert.ToInt64(invocationArguments[i], CultureInfo.InvariantCulture), + ValueKind.Float32 => (ValueBox)Convert.ToSingle(invocationArguments[i], CultureInfo.InvariantCulture), + ValueKind.Float64 => (ValueBox)Convert.ToDouble(invocationArguments[i], CultureInfo.InvariantCulture), + _ => throw new NotSupportedException($"Unsupported parameter type '{parameters[i]}' on export '{exportName}'."), + }; + } + + var result = function.Invoke(boxedArguments); + return ValueTask.FromResult(result); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _store.Dispose(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(WasmtimeInstance)); + } + } +} diff --git a/src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs b/src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs new file mode 100644 index 0000000..2e55b88 --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs @@ -0,0 +1,165 @@ +namespace AngleSharp.Wasm; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Wasmtime; + +/// +/// Wasmtime-backed implementation. +/// +public sealed class WasmtimeWasmRuntime : IWasmRuntime +{ + private readonly Engine _engine; + private bool _disposed; + + /// + /// Creates a new runtime. + /// + public WasmtimeWasmRuntime() + { + _engine = new Engine(); + } + + /// + public ValueTask CompileAsync(ReadOnlyMemory moduleBytes, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + var module = Module.FromBytes(_engine, "anglesharp-wasm", moduleBytes.ToArray()); + return ValueTask.FromResult(new WasmtimeCompiledModule(_engine, module)); + } + + /// + public ValueTask InstantiateAsync( + IWasmCompiledModule compiledModule, + IEnumerable? imports = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (compiledModule is not WasmtimeCompiledModule wasmtimeModule) + { + throw new ArgumentException("Compiled module must be created by WasmtimeWasmRuntime.", nameof(compiledModule)); + } + + var linker = new Linker(wasmtimeModule.Engine); + var store = new Store(wasmtimeModule.Engine); + + if (imports is not null) + { + foreach (var import in imports) + { + BindImport(linker, import); + } + } + + var instance = linker.Instantiate(store, wasmtimeModule.Module); + + return ValueTask.FromResult(new WasmtimeInstance(store, instance)); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _engine.Dispose(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(WasmtimeWasmRuntime)); + } + } + + private static void BindImport(Linker linker, WasmImportFunction import) + { + var parameterKinds = import.ParameterTypes.Select(Map).ToArray(); + var resultKinds = import.ResultTypes.Select(Map).ToArray(); + + linker.DefineFunction( + import.ModuleName, + import.FunctionName, + (Caller _, ReadOnlySpan arguments, Span results) => + { + var callbackArguments = ConvertArguments(arguments, import.ParameterTypes); + var callbackResult = import.Callback(callbackArguments); + FillResults(results, import.ResultTypes, callbackResult); + }, + parameterKinds, + resultKinds); + } + + private static ValueKind Map(WasmValueType type) => type switch + { + WasmValueType.Int32 => ValueKind.Int32, + WasmValueType.Int64 => ValueKind.Int64, + WasmValueType.Float32 => ValueKind.Float32, + WasmValueType.Float64 => ValueKind.Float64, + _ => throw new NotSupportedException($"Unsupported WasmValueType '{type}'."), + }; + + private static object?[] ConvertArguments(ReadOnlySpan arguments, IReadOnlyList types) + { + var converted = new object?[arguments.Length]; + + for (var i = 0; i < arguments.Length; i++) + { + converted[i] = types[i] switch + { + WasmValueType.Int32 => arguments[i].AsInt32(), + WasmValueType.Int64 => arguments[i].AsInt64(), + WasmValueType.Float32 => arguments[i].AsSingle(), + WasmValueType.Float64 => arguments[i].AsDouble(), + _ => throw new NotSupportedException($"Unsupported argument type '{types[i]}'."), + }; + } + + return converted; + } + + private static void FillResults(Span results, IReadOnlyList types, object? callbackResult) + { + if (results.Length == 0) + { + return; + } + + if (results.Length == 1) + { + results[0] = ToValueBox(callbackResult, types[0]); + return; + } + + if (callbackResult is not object?[] values || values.Length != results.Length) + { + throw new InvalidOperationException("Import callback must return object[] with the same length as result types for multi-value results."); + } + + for (var i = 0; i < results.Length; i++) + { + results[i] = ToValueBox(values[i], types[i]); + } + } + + private static ValueBox ToValueBox(object? value, WasmValueType type) => type switch + { + WasmValueType.Int32 => (ValueBox)Convert.ToInt32(value, CultureInfo.InvariantCulture), + WasmValueType.Int64 => (ValueBox)Convert.ToInt64(value, CultureInfo.InvariantCulture), + WasmValueType.Float32 => (ValueBox)Convert.ToSingle(value, CultureInfo.InvariantCulture), + WasmValueType.Float64 => (ValueBox)Convert.ToDouble(value, CultureInfo.InvariantCulture), + _ => throw new NotSupportedException($"Unsupported result type '{type}'."), + }; +} diff --git a/src/AngleSharp.Wasm/WasmtimeWasmRuntimeFactory.cs b/src/AngleSharp.Wasm/WasmtimeWasmRuntimeFactory.cs new file mode 100644 index 0000000..1c56bdc --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeWasmRuntimeFactory.cs @@ -0,0 +1,20 @@ +namespace AngleSharp.Wasm; + +using System; + +/// +/// Creates Wasmtime-backed runtime instances. +/// +public sealed class WasmtimeWasmRuntimeFactory : IWasmRuntimeFactory +{ + /// + public IWasmRuntime Create(IBrowsingContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new WasmtimeWasmRuntime(); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f376b21..b1fa48a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,6 +2,10 @@ Adds a way to run WebAssembly to AngleSharp. AngleSharp.Wasm - 0.13.0 + 1.0.0 + enable + latest + true + $(MSBuildThisFileDirectory)\Key.snk \ No newline at end of file diff --git a/tools/anglesharp.cake b/tools/anglesharp.cake deleted file mode 100644 index bfcc3bd..0000000 --- a/tools/anglesharp.cake +++ /dev/null @@ -1,199 +0,0 @@ -#addin "Cake.FileHelpers" -#addin "Octokit" -using Octokit; - -var configuration = Argument("configuration", "Release"); -var isRunningOnUnix = IsRunningOnUnix(); -var isRunningOnWindows = IsRunningOnWindows(); -var isRunningOnGitHubActions = BuildSystem.GitHubActions.IsRunningOnGitHubActions; -var releaseNotes = ParseReleaseNotes("./CHANGELOG.md"); -var version = releaseNotes.Version.ToString(); -var buildDir = Directory($"./src/{projectName}/bin") + Directory(configuration); -var buildResultDir = Directory("./bin") + Directory(version); -var nugetRoot = buildResultDir + Directory("nuget"); - -if (isRunningOnGitHubActions) -{ - var buildNumber = BuildSystem.GitHubActions.Environment.Workflow.RunNumber; - - if (target == "Default") - { - version = $"{version}-ci-{buildNumber}"; - } - else if (target == "PrePublish") - { - version = $"{version}-alpha-{buildNumber}"; - } -} - -if (!isRunningOnWindows) -{ - frameworks.Remove("net46"); - frameworks.Remove("net461"); - frameworks.Remove("net472"); -} - -// Initialization -// ---------------------------------------- - -Setup(_ => -{ - Information($"Building version {version} of {projectName}."); - Information("For the publish target the following environment variables need to be set:"); - Information("- NUGET_API_KEY"); - Information("- GITHUB_API_TOKEN"); -}); - -// Tasks -// ---------------------------------------- - -Task("Clean") - .Does(() => - { - CleanDirectories(new DirectoryPath[] { buildDir, buildResultDir, nugetRoot }); - }); - -Task("Restore-Packages") - .IsDependentOn("Clean") - .Does(() => - { - NuGetRestore($"./src/{solutionName}.sln", new NuGetRestoreSettings - { - ToolPath = "tools/nuget.exe", - }); - }); - -Task("Build") - .IsDependentOn("Restore-Packages") - .Does(() => - { - ReplaceRegexInFiles("./src/Directory.Build.props", "(?<=)(.+?)(?=)", version); - DotNetCoreBuild($"./src/{solutionName}.sln", new DotNetCoreBuildSettings - { - Configuration = configuration, - }); - }); - -Task("Run-Unit-Tests") - .IsDependentOn("Build") - .Does(() => - { - var settings = new DotNetCoreTestSettings - { - Configuration = configuration, - }; - - if (isRunningOnGitHubActions) - { - settings.Loggers.Add("GitHubActions"); - } - - DotNetCoreTest($"./src/{solutionName}.Tests/", settings); - }); - -Task("Copy-Files") - .IsDependentOn("Build") - .Does(() => - { - foreach (var item in frameworks) - { - var targetDir = nugetRoot + Directory("lib") + Directory(item.Key); - CreateDirectory(targetDir); - CopyFiles(new FilePath[] - { - buildDir + Directory(item.Value) + File($"{projectName}.dll"), - buildDir + Directory(item.Value) + File($"{projectName}.xml"), - }, targetDir); - } - - CopyFiles(new FilePath[] { - $"src/{projectName}.nuspec", - "logo.png" - }, nugetRoot); - }); - -Task("Create-Package") - .IsDependentOn("Copy-Files") - .Does(() => - { - var nugetExe = GetFiles("./tools/**/nuget.exe").FirstOrDefault() - ?? throw new InvalidOperationException("Could not find nuget.exe."); - - var nuspec = nugetRoot + File($"{projectName}.nuspec"); - - NuGetPack(nuspec, new NuGetPackSettings - { - Version = version, - OutputDirectory = nugetRoot, - Symbols = false, - Properties = new Dictionary - { - { "Configuration", configuration }, - }, - }); - }); - -Task("Publish-Package") - .IsDependentOn("Create-Package") - .IsDependentOn("Run-Unit-Tests") - .Does(() => - { - var apiKey = EnvironmentVariable("NUGET_API_KEY"); - - if (String.IsNullOrEmpty(apiKey)) - { - throw new InvalidOperationException("Could not resolve the NuGet API key."); - } - - foreach (var nupkg in GetFiles(nugetRoot.Path.FullPath + "/*.nupkg")) - { - NuGetPush(nupkg, new NuGetPushSettings - { - Source = "https://nuget.org/api/v2/package", - ApiKey = apiKey, - }); - } - }); - -Task("Publish-Release") - .IsDependentOn("Publish-Package") - .IsDependentOn("Run-Unit-Tests") - .Does(() => - { - var githubToken = EnvironmentVariable("GITHUB_TOKEN"); - - if (String.IsNullOrEmpty(githubToken)) - { - throw new InvalidOperationException("Could not resolve GitHub token."); - } - - var github = new GitHubClient(new ProductHeaderValue("AngleSharpCakeBuild")) - { - Credentials = new Credentials(githubToken), - }; - - var newRelease = github.Repository.Release; - newRelease.Create("AngleSharp", projectName, new NewRelease("v" + version) - { - Name = version, - Body = String.Join(Environment.NewLine, releaseNotes.Notes), - Prerelease = false, - TargetCommitish = "main", - }).Wait(); - }); - -// Targets -// ---------------------------------------- - -Task("Package") - .IsDependentOn("Run-Unit-Tests") - .IsDependentOn("Create-Package"); - -Task("Default") - .IsDependentOn("Package"); - -Task("Publish") - .IsDependentOn("Publish-Release"); - -Task("PrePublish") - .IsDependentOn("Publish-Package"); diff --git a/tools/packages.config b/tools/packages.config deleted file mode 100644 index e714645..0000000 --- a/tools/packages.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file