From ff432a9d9b9448c2dd1ed03d6c1b9b7feae2b5a0 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sat, 8 Aug 2026 15:33:24 +0300 Subject: [PATCH 01/11] Move the build orchestrator to the Fallout stable channel (10.4.0) Fallout v10.4.0 is the first stable-channel release; the previously pinned 11.0.18 belongs to the edge channel. The stable CLI ships as Fallout.GlobalTool (the fallout command is unchanged), and Fallout.Common 10.4.0 resolves the patched System.Security.Cryptography.Xml 10.0.10 on its own, so the manual transitive pin is no longer needed (Fallout-build/Fallout#618). Co-Authored-By: Claude Fable 5 --- .config/dotnet-tools.json | 4 ++-- build/_build.csproj | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6a8c8a1..a8483d3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,8 +2,8 @@ "version": 1, "isRoot": true, "tools": { - "fallout.cli": { - "version": "11.0.18", + "fallout.globaltool": { + "version": "10.4.0", "commands": [ "fallout" ] diff --git a/build/_build.csproj b/build/_build.csproj index 647ac26..11c89b0 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -11,9 +11,7 @@ - - - + From bae260e64d4f31798df28fd6e06f175c257e03b2 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 11:17:48 +0200 Subject: [PATCH 02/11] Added agents files --- AGENTS.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 8 ++++++ 2 files changed, 88 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a9feabf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AngleSharp.Wasm + +## Purpose + +AngleSharp.Wasm extends AngleSharp with WebAssembly execution and a DOM/JavaScript-facing WebAssembly surface. The repository produces the `AngleSharp.Wasm` NuGet package. + +## Repository Layout + +- `src/AngleSharp.Wasm/`: library implementation. +- `src/AngleSharp.Wasm/Dom/`: DOM-annotated WebAssembly bridge types exposed to scripting integrations. +- `src/AngleSharp.Wasm.Tests/`: NUnit tests for configuration, runtime behavior, and the DOM bridge. +- `src/AngleSharp.Wasm.Docs/`: TypeScript documentation site configuration and entry point. +- `docs/`: Markdown documentation, including the API, examples, and WebAssembly specification coverage. +- `nuke/`: NUKE build project and build configuration. +- `build.sh`, `build.ps1`, `build.cmd`: platform-specific build entry points. + +## Development Environment + +- The solution is `src/AngleSharp.Wasm.sln`. +- The library and test projects target `net8.0` and `net10.0`. +- The library references AngleSharp 1.x and Wasmtime 44.0.0. +- The default runtime is `WasmtimeWasmRuntime`, registered through `Configuration.WithWasm()`. +- `global.json`, when present, controls the SDK version used by the build wrapper. + +## Common Commands + +Run from the repository root: + +```sh +./build.sh +``` + +The build wrapper restores local .NET tools and delegates to NUKE. On Windows, use `build.ps1` or `build.cmd`. + +For focused local checks, use the .NET CLI against the solution or test project: + +```sh +dotnet build src/AngleSharp.Wasm.sln +dotnet test src/AngleSharp.Wasm.Tests/AngleSharp.Wasm.Tests.csproj +``` + +When changing the DOM bridge or runtime behavior, run the relevant NUnit tests and then the full test project for both target frameworks when practical. + +## Architecture and Conventions + +- Keep runtime abstractions in `src/AngleSharp.Wasm/` independent of the concrete Wasmtime implementation where possible. +- `IWasmRuntime`, `IWasmCompiledModule`, `IWasmInstance`, and `IWasmRuntimeFactory` define the runtime boundary. +- Host imports are supplied through `IWasmImportProvider`, `WasmImportFunction`, and the `WithWasmImports(...)` configuration extensions. +- The default runtime is Wasmtime-specific; do not make Wasmtime types part of the public abstraction unless the feature requires it. +- DOM-facing members use AngleSharp DOM annotations such as `[DomName]`, `[DomExposed]`, `[DomAccessor]`, and `[DomNoInterfaceObject]`. +- Preserve the existing synchronous bridge style unless deliberately adding a Promise/streaming API. +- Use existing AngleSharp and Wasmtime APIs before introducing new abstractions. +- Keep changes focused and preserve public APIs unless the task explicitly requires a breaking change. +- Use ASCII for new source and documentation unless the surrounding file already requires another character set. + +## Current DOM/WebAssembly Surface + +The package exposes a minimal `window.WebAssembly` bridge through the types in `src/AngleSharp.Wasm/Dom/`: + +- `WebAssembly.compile(byte[])` creates a `WasmJsModule`. +- `WebAssembly.instantiate(WasmJsModule)` creates a `WasmJsInstance`. +- Module metadata includes imports, exports, and custom sections. +- Instance exports support name lookup and function invocation through `WasmJsExportedFunction`. +- Non-function exports are currently descriptor objects rather than full `Memory`, `Table`, `Global`, `Tag`, or `Exception` projections. + +The documented gaps include `WebAssembly.validate(...)`, streaming APIs, compile options, full non-function export objects, and dedicated WebAssembly error constructors. Consult `docs/general/02-Spec-Coverage.md` before extending the DOM surface. + +## Testing Guidance + +Add or update focused NUnit tests in `src/AngleSharp.Wasm.Tests/` for every behavior change. DOM bridge tests should configure a browsing context with `Configuration.Default.WithWasm()`. Runtime tests should cover both successful behavior and invalid input or disposal paths where applicable. + +For new WebAssembly binary fixtures, keep the fixture small and document its module shape near the byte array. Prefer testing the public bridge and runtime contracts rather than implementation details of Wasmtime. + +## Documentation + +Update the relevant Markdown documentation and the specification coverage matrix when adding or changing public WebAssembly functionality. Keep API names aligned with the WebAssembly JavaScript API where the package intentionally provides a compatible projection, and document any synchronous or .NET-specific adaptation. + +## Change Hygiene + +Do not commit generated output from `bin/`, `obj/`, or NUKE build folders unless the repository explicitly requires it. Do not alter unrelated user changes. Do not commit changes unless explicitly requested. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f02784 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +The guidance is shared with every AI agent working here, so it lives in AGENTS.md and is +imported below. Record new guidance there rather than in this file. + +@AGENTS.md From 5b18abc905aa1517c7cfefdfa124952d4e313928 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 13:23:31 +0200 Subject: [PATCH 03/11] Added memory projection #6 --- CHANGELOG.md | 6 ++ docs/general/02-Spec-Coverage.md | 5 +- src/AngleSharp.Wasm.Docs/package.json | 2 +- .../WasmJsBridgeTests.cs | 35 +++++++- src/AngleSharp.Wasm/Dom/WasmJsExports.cs | 25 +++++- src/AngleSharp.Wasm/Dom/WasmJsMemory.cs | 86 +++++++++++++++++++ src/AngleSharp.Wasm/IWasmMemory.cs | 35 ++++++++ src/AngleSharp.Wasm/IWasmMemoryProvider.cs | 14 +++ src/AngleSharp.Wasm/WasmtimeInstance.cs | 16 +++- src/AngleSharp.Wasm/WasmtimeMemory.cs | 24 ++++++ src/Directory.Build.props | 2 +- 11 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 src/AngleSharp.Wasm/Dom/WasmJsMemory.cs create mode 100644 src/AngleSharp.Wasm/IWasmMemory.cs create mode 100644 src/AngleSharp.Wasm/IWasmMemoryProvider.cs create mode 100644 src/AngleSharp.Wasm/WasmtimeMemory.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e0d4b..ed0fe90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.1.0 + +Released on Friday, August 21 2026. + +- Added correct `WebAssembly.Memory` object projection (#6) + # 1.0.0 Released on Friday, July 31 2026. diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index a327c20..8d0e908 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -24,10 +24,10 @@ Status values: | `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. | +| `Instance.exports` | Partial | Available as `instance.exports`, with function and memory wrappers plus descriptor values for other export kinds. | | 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. | +| Memory object API (`Memory`) | Partial | Exported memories expose `buffer`, `read(offset, count)`, `write(offset, bytes)`, and `grow(delta)`. `buffer` is a current `byte[]` snapshot rather than a live JavaScript `ArrayBuffer`; memory construction and imports are not yet exposed. | | 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. | @@ -49,6 +49,7 @@ The current implementation is validated by runtime and bridge tests in the repos - compile and instantiate flows - export invocation +- exported memory read, write, and growth - import descriptor extraction - export descriptor extraction - custom section lookup behavior diff --git a/src/AngleSharp.Wasm.Docs/package.json b/src/AngleSharp.Wasm.Docs/package.json index 088253a..3defbe1 100644 --- a/src/AngleSharp.Wasm.Docs/package.json +++ b/src/AngleSharp.Wasm.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/wasm", - "version": "1.0.0", + "version": "1.1.0", "preview": true, "description": "The doclet for the AngleSharp.Wasm documentation.", "keywords": [ diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index 9fd0d33..42d8db5 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -127,7 +127,7 @@ public async Task ModuleCustomSectionsFilterByNameAndReturnAllMatches() } [Test] - public async Task InstanceExportsExposeNonFunctionKindsAsValueDescriptors() + public async Task InstanceExportsExposeMemoryAsLiveMemoryObject() { var config = Configuration.Default.WithWasm(); using var context = BrowsingContext.New(config); @@ -137,17 +137,44 @@ public async Task InstanceExportsExposeNonFunctionKindsAsValueDescriptors() var moduleExports = module.Exports(); var instance = WebAssembly.Instantiate(context.Current!, module); - var memExport = instance.Exports["mem"] as WasmJsExportValue; + var memExport = instance.Exports["mem"] as WasmJsMemory; 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(memExport!.Buffer, Has.Length.EqualTo(65536)); + + memExport.Write(42, new byte[] { 1, 2, 3 }); + + Assert.That(memExport.Read(42, 3), Is.EqualTo(new byte[] { 1, 2, 3 })); + Assert.That(memExport.Buffer[43], Is.EqualTo(2)); + Assert.That(memExport.Grow(1), Is.EqualTo(1)); + Assert.That(memExport.Buffer, Has.Length.EqualTo(2 * 65536)); Assert.That(instance.Exports.Keys().Single(), Is.EqualTo("mem")); } + [Test] + public async Task MemoryProjectionValidatesArgumentsAndBounds() + { + 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 instance = WebAssembly.Instantiate(context.Current!, module); + var memory = instance.Exports["mem"] as WasmJsMemory; + + Assert.That(memory, Is.Not.Null); + Assert.That(() => memory!.Read(-1, 1), Throws.TypeOf()); + Assert.That(() => memory!.Read(0, -1), Throws.TypeOf()); + Assert.That(() => memory!.Read(65536, 1), Throws.TypeOf()); + Assert.That(() => memory!.Write(-1, Array.Empty()), Throws.TypeOf()); + Assert.That(() => memory!.Write(0, null!), Throws.ArgumentNullException); + Assert.That(() => memory!.Write(65536, new byte[] { 1 }), Throws.TypeOf()); + Assert.That(() => memory!.Grow(-1), Throws.TypeOf()); + } + [Test] public async Task ExportedFunctionWrapperSupportsArguments() { diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs index 462b81c..54e3e68 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs @@ -1,5 +1,6 @@ namespace AngleSharp.Wasm.Dom; +using System; using System.Collections.Generic; using System.Linq; using AngleSharp.Attributes; @@ -17,9 +18,27 @@ internal WasmJsExports(WasmJsInstance owner, IReadOnlyList m.Name, - m => m.Kind == "function" - ? (object)new WasmJsExportedFunction(owner, m.Name) - : new WasmJsExportValue(m.Name, m.Kind)); + m => CreateExport(owner, m)); + } + + private static object CreateExport(WasmJsInstance owner, WasmModuleExportDescriptor metadata) => + metadata.Kind switch + { + "function" => new WasmJsExportedFunction(owner, metadata.Name), + "memory" => CreateMemory(owner, metadata.Name), + _ => new WasmJsExportValue(metadata.Name, metadata.Kind), + }; + + private static WasmJsMemory CreateMemory(WasmJsInstance owner, string name) + { + if (owner.Instance is not IWasmMemoryProvider provider) + { + throw new NotSupportedException("The configured WebAssembly runtime does not support memory exports."); + } + + var memory = provider.GetMemory(name) + ?? throw new InvalidOperationException($"The export '{name}' is not a memory."); + return new WasmJsMemory(memory); } /// diff --git a/src/AngleSharp.Wasm/Dom/WasmJsMemory.cs b/src/AngleSharp.Wasm/Dom/WasmJsMemory.cs new file mode 100644 index 0000000..03cf2f8 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsMemory.cs @@ -0,0 +1,86 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using System; + +/// +/// JavaScript-visible WebAssembly linear memory. +/// +[DomName("Memory")] +public sealed class WasmJsMemory +{ + private readonly IWasmMemory _memory; + + internal WasmJsMemory(IWasmMemory memory) + { + _memory = memory; + } + + /// + /// Gets a snapshot of the current memory contents. + /// + [DomName("buffer")] + [DomAccessor(Accessors.Getter)] + public byte[] Buffer => Read(0, checked((int)_memory.Length)); + + /// + /// Reads bytes from memory. + /// + /// The byte offset in memory. + /// The number of bytes to read. + /// A copy of the requested bytes. + [DomName("read")] + public byte[] Read(long offset, int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + EnsureRange(offset, count); + var bytes = new byte[count]; + _memory.Read(offset, bytes); + return bytes; + } + + /// + /// Writes bytes into memory. + /// + /// The byte offset in memory. + /// The bytes to write. + [DomName("write")] + public void Write(long offset, byte[] bytes) + { + if (bytes is null) + { + throw new ArgumentNullException(nameof(bytes)); + } + + EnsureRange(offset, bytes.Length); + _memory.Write(offset, bytes); + } + + /// + /// Grows the memory by a number of WebAssembly pages. + /// + /// The number of pages to add. + /// The previous size in pages. + [DomName("grow")] + public long Grow(long delta) + { + if (delta < 0) + { + throw new ArgumentOutOfRangeException(nameof(delta)); + } + + return _memory.Grow(delta); + } + + private void EnsureRange(long offset, int count) + { + if (offset < 0 || offset > _memory.Length - count) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmMemory.cs b/src/AngleSharp.Wasm/IWasmMemory.cs new file mode 100644 index 0000000..67f2321 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmMemory.cs @@ -0,0 +1,35 @@ +namespace AngleSharp.Wasm; + +using System; + +/// +/// Represents a WebAssembly linear memory. +/// +public interface IWasmMemory +{ + /// + /// Gets the current memory length in bytes. + /// + long Length { get; } + + /// + /// Copies bytes from memory into a destination span. + /// + /// The byte offset in memory. + /// The destination span. + void Read(long offset, Span destination); + + /// + /// Copies bytes from a source span into memory. + /// + /// The byte offset in memory. + /// The source span. + void Write(long offset, ReadOnlySpan source); + + /// + /// Grows the memory by a number of WebAssembly pages. + /// + /// The number of pages to add. + /// The previous size in pages. + long Grow(long delta); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmMemoryProvider.cs b/src/AngleSharp.Wasm/IWasmMemoryProvider.cs new file mode 100644 index 0000000..9510ba6 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmMemoryProvider.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Wasm; + +/// +/// Provides access to exported WebAssembly memories. +/// +public interface IWasmMemoryProvider +{ + /// + /// Gets an exported memory by name. + /// + /// The export name. + /// The exported memory, if present. + IWasmMemory? GetMemory(string exportName); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/WasmtimeInstance.cs b/src/AngleSharp.Wasm/WasmtimeInstance.cs index 72f8524..c011ba8 100644 --- a/src/AngleSharp.Wasm/WasmtimeInstance.cs +++ b/src/AngleSharp.Wasm/WasmtimeInstance.cs @@ -9,7 +9,7 @@ namespace AngleSharp.Wasm; /// /// Wraps a Wasmtime instance and store. /// -public sealed class WasmtimeInstance : IWasmInstance +public sealed class WasmtimeInstance : IWasmInstance, IWasmMemoryProvider { private readonly Store _store; private readonly Instance _instance; @@ -21,6 +21,20 @@ internal WasmtimeInstance(Store store, Instance instance) _instance = instance; } + /// + public IWasmMemory? GetMemory(string exportName) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(exportName)) + { + throw new ArgumentException("Export name must be provided.", nameof(exportName)); + } + + var memory = _instance.GetMemory(exportName); + return memory is null ? null : new WasmtimeMemory(memory); + } + /// public ValueTask InvokeAsync(string exportName, object?[]? arguments = null, CancellationToken cancellationToken = default) { diff --git a/src/AngleSharp.Wasm/WasmtimeMemory.cs b/src/AngleSharp.Wasm/WasmtimeMemory.cs new file mode 100644 index 0000000..5d40882 --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeMemory.cs @@ -0,0 +1,24 @@ +namespace AngleSharp.Wasm; + +using System; +using Wasmtime; + +internal sealed class WasmtimeMemory : IWasmMemory +{ + private readonly Memory _memory; + + public WasmtimeMemory(Memory memory) + { + _memory = memory; + } + + public long Length => _memory.GetLength(); + + public void Read(long offset, Span destination) => + _memory.GetSpan(offset, destination.Length).CopyTo(destination); + + public void Write(long offset, ReadOnlySpan source) => + source.CopyTo(_memory.GetSpan(offset, source.Length)); + + public long Grow(long delta) => _memory.Grow(delta); +} \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b1fa48a..bc7ce44 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Adds a way to run WebAssembly to AngleSharp. AngleSharp.Wasm - 1.0.0 + 1.1.0 enable latest true From 7129dbd511b1cdef08be8cce1ae6cb89c9667012 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 13:42:02 +0200 Subject: [PATCH 04/11] Implemented Table object #7 --- CHANGELOG.md | 3 +- README.md | 5 +- docs/general/01-Basics.md | 2 +- docs/general/02-Spec-Coverage.md | 7 +- docs/tutorials/01-API.md | 12 ++- docs/tutorials/03-Questions.md | 6 +- .../WasmJsBridgeTests.cs | 83 +++++++++++++++++++ .../Dom/WasmJsExportedFunction.cs | 15 +++- src/AngleSharp.Wasm/Dom/WasmJsExports.cs | 27 +++++- src/AngleSharp.Wasm/Dom/WasmJsTable.cs | 72 ++++++++++++++++ src/AngleSharp.Wasm/IWasmFunction.cs | 14 ++++ src/AngleSharp.Wasm/IWasmFunctionProvider.cs | 14 ++++ src/AngleSharp.Wasm/IWasmTable.cs | 30 +++++++ src/AngleSharp.Wasm/IWasmTableProvider.cs | 14 ++++ src/AngleSharp.Wasm/WasmtimeFunction.cs | 47 +++++++++++ src/AngleSharp.Wasm/WasmtimeInstance.cs | 30 ++++++- src/AngleSharp.Wasm/WasmtimeTable.cs | 38 +++++++++ 17 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 src/AngleSharp.Wasm/Dom/WasmJsTable.cs create mode 100644 src/AngleSharp.Wasm/IWasmFunction.cs create mode 100644 src/AngleSharp.Wasm/IWasmFunctionProvider.cs create mode 100644 src/AngleSharp.Wasm/IWasmTable.cs create mode 100644 src/AngleSharp.Wasm/IWasmTableProvider.cs create mode 100644 src/AngleSharp.Wasm/WasmtimeFunction.cs create mode 100644 src/AngleSharp.Wasm/WasmtimeTable.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0fe90..4840c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ Released on Friday, August 21 2026. -- Added correct `WebAssembly.Memory` object projection (#6) +- Added `WebAssembly.Table` object projection (#7) +- Added `WebAssembly.Memory` object projection (#6) # 1.0.0 diff --git a/README.md b/README.md index 11982fd..8e90236 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,9 @@ The current package targets `net8.0` and `net10.0`. - Export invocation helpers - `instance.Invoke(...)` - `WasmJsExportedFunction.Invoke(...)` +- Exported memory and table access + - memory `buffer`, `read`, `write`, and `grow` + - table `length`, `get`, `set`, and `grow` - Multi-target support for `net8.0` and `net10.0` ## Current Scope and Limitations @@ -104,7 +107,7 @@ AngleSharp.Wasm currently provides a practical subset of the WebAssembly JS API. - 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. +- Exported memories and tables have usable object projections; other non-function exports remain descriptors (`name`, `kind`). See [Spec Coverage Matrix](docs/general/02-Spec-Coverage.md) for a section-by-section status overview. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index ced8a1e..945b3fd 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -81,7 +81,7 @@ AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS AP - 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. +- Exported memories and tables have usable object projections; other non-function exports remain descriptors (`name`, `kind`). - `customSections(...)` returns payload bytes (`byte[]`) mapped from custom sections. - Runtime invocation/import marshaling is currently focused on numeric value kinds (`i32`, `i64`, `f32`, `f64`). diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 8d0e908..3393a69 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -24,11 +24,11 @@ Status values: | `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 and memory wrappers plus descriptor values for other export kinds. | +| `Instance.exports` | Partial | Available as `instance.exports`, with function, memory, and table wrappers plus descriptor values for other export kinds. | | Exported function invocation | Implemented | Supported via `instance.invoke(...)` and `WasmJsExportedFunction.invoke(...)`. | | Host import functions | Implemented | Supported via `WithWasmImports(...)` and `WasmImportFunction`. | | Memory object API (`Memory`) | Partial | Exported memories expose `buffer`, `read(offset, count)`, `write(offset, bytes)`, and `grow(delta)`. `buffer` is a current `byte[]` snapshot rather than a live JavaScript `ArrayBuffer`; memory construction and imports are not yet exposed. | -| Table object API (`Table`) | Not yet | No full JS API `Table` object projection yet. | +| Table object API (`Table`) | Partial | Exported `funcref` and `externref` tables expose `length`, `get(index)`, `set(index, value)`, and `grow(delta, value)`. Table construction and imports are not yet exposed. | | 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. | @@ -41,7 +41,7 @@ Status values: | --- | --- | --- | | 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. | +| Extended/reference value kinds | Partial | Table projections support `funcref` and `externref`; broader reference-type invocation marshaling is not yet exposed. | ## Test-Backed Behavior @@ -50,6 +50,7 @@ The current implementation is validated by runtime and bridge tests in the repos - compile and instantiate flows - export invocation - exported memory read, write, and growth +- exported table access, mutation, and growth - import descriptor extraction - export descriptor extraction - custom section lookup behavior diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index afaeb2d..1ba16c0 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -80,7 +80,9 @@ Invokes an exported function by name. Returns an export entry by name: - `WasmJsExportedFunction` for function exports -- `WasmJsExportValue` for non-function exports +- `WasmJsMemory` for memory exports +- `WasmJsTable` for table exports +- `WasmJsExportValue` for other non-function exports - `null` if no export is found ### `keys()` @@ -93,6 +95,14 @@ Returns all export names. Invokes the wrapped exported function. +## `WasmJsMemory` + +Provides `buffer`, `read(offset, count)`, `write(offset, bytes)`, and `grow(delta)`. + +## `WasmJsTable` + +Provides `length`, `get(index)`, `set(index, value)`, and `grow(delta, value)`. Function references returned by `get(...)` expose `invoke(...)`. + ## `WasmJsExportValue` Descriptor type for non-function exports. diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index 58b785a..f516f50 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -27,9 +27,9 @@ Yes, when used with a scripting integration that discovers DOM-annotated members The current bridge methods are synchronous from the caller perspective. -## Are `Memory`, `Table`, `Global`, and `Tag` exposed as full JS API objects? +## Are `Memory`, `Table`, `Global`, and `Tag` exposed as JS API objects? -Not yet. Non-function exports currently appear as `WasmJsExportValue` descriptors (`name`, `kind`). +Exported memories and tables have usable object projections. Globals and tags currently appear as `WasmJsExportValue` descriptors (`name`, `kind`). Memory and table construction and imports are not yet exposed. ## How do I invoke exported functions? @@ -67,6 +67,6 @@ Current numeric value kinds are supported: - No `validate(...)` method yet. - No streaming APIs. - No compile options support (`builtins`, `importedStringConstants`). -- Non-function exports are descriptors instead of full JS API objects. +- Globals, tags, and other non-function exports remain 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.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index 42d8db5..5dad5be 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -54,6 +54,33 @@ public sealed class WasmJsBridgeTests 0x07, 0x07, 0x01, 0x03, 0x6D, 0x65, 0x6D, 0x02, 0x00, }; + // (module + // (func $answer (export "answer") (result i32) i32.const 42) + // (table (export "functions") 1 3 funcref) + // (elem (i32.const 0) $answer)) + private static readonly byte[] TableExportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, + 0x03, 0x02, 0x01, 0x00, + 0x04, 0x05, 0x01, 0x70, 0x01, 0x01, 0x03, + 0x07, 0x16, 0x02, + 0x09, 0x66, 0x75, 0x6E, 0x63, 0x74, 0x69, 0x6F, 0x6E, 0x73, 0x01, 0x00, + 0x06, 0x61, 0x6E, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, + 0x09, 0x07, 0x01, 0x00, 0x41, 0x00, 0x0B, 0x01, 0x00, + 0x0A, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2A, 0x0B, + }; + + // (module (table (export "objects") 1 3 externref)) + private static readonly byte[] ExternalReferenceTableExportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x04, 0x05, 0x01, 0x6F, 0x01, 0x01, 0x03, + 0x07, 0x0B, 0x01, 0x07, 0x6F, 0x62, 0x6A, 0x65, 0x63, 0x74, 0x73, 0x01, 0x00, + }; + // AnswerModule + two custom sections named "meta" private static readonly byte[] AnswerModuleWithCustomSections = { @@ -175,6 +202,62 @@ public async Task MemoryProjectionValidatesArgumentsAndBounds() Assert.That(() => memory!.Grow(-1), Throws.TypeOf()); } + [Test] + public async Task InstanceExportsExposeTableAsLiveTableObject() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, TableExportModule); + var instance = WebAssembly.Instantiate(context.Current!, module); + var table = instance.Exports["functions"] as WasmJsTable; + var exportedFunction = instance.Exports["answer"] as WasmJsExportedFunction; + + Assert.That(table, Is.Not.Null); + Assert.That(table!.Length, Is.EqualTo(1)); + + var function = table.Get(0) as WasmJsExportedFunction; + + Assert.That(function, Is.Not.Null); + Assert.That(function!.Invoke(), Is.EqualTo(42)); + + table.Set(0, null); + + Assert.That(table.Get(0), Is.Null); + + table.Set(0, exportedFunction); + + Assert.That(((WasmJsExportedFunction)table.Get(0)!).Invoke(), Is.EqualTo(42)); + Assert.That(table.Grow(1, function), Is.EqualTo(1)); + Assert.That(table.Length, Is.EqualTo(2)); + Assert.That(((WasmJsExportedFunction)table.Get(1)!).Invoke(), Is.EqualTo(42)); + Assert.That(() => table.Get(2), Throws.TypeOf()); + Assert.That(() => table.Set(2, null), Throws.TypeOf()); + } + + [Test] + public async Task TableProjectionSupportsExternalReferences() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, ExternalReferenceTableExportModule); + var instance = WebAssembly.Instantiate(context.Current!, module); + var table = instance.Exports["objects"] as WasmJsTable; + var value = new object(); + + Assert.That(table, Is.Not.Null); + Assert.That(table!.Get(0), Is.Null); + + table.Set(0, value); + + Assert.That(table.Get(0), Is.SameAs(value)); + Assert.That(table.Grow(1, value), Is.EqualTo(1)); + Assert.That(table.Get(1), Is.SameAs(value)); + } + [Test] public async Task ExportedFunctionWrapperSupportsArguments() { diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs b/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs index fd7c200..d9879cf 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs @@ -8,8 +8,9 @@ namespace AngleSharp.Wasm.Dom; [DomName("ExportedFunction")] public sealed class WasmJsExportedFunction { - private readonly WasmJsInstance _owner; - private readonly string _name; + private readonly WasmJsInstance? _owner; + private readonly string? _name; + private readonly IWasmFunction? _function; internal WasmJsExportedFunction(WasmJsInstance owner, string name) { @@ -17,11 +18,19 @@ internal WasmJsExportedFunction(WasmJsInstance owner, string name) _name = name; } + internal WasmJsExportedFunction(IWasmFunction function) + { + _function = function; + } + + internal IWasmFunction? Function => _function; + /// /// Invokes the export function. /// /// The function arguments. /// The invocation result. [DomName("invoke")] - public object? Invoke(params object?[] arguments) => _owner.Invoke(_name, arguments); + public object? Invoke(params object?[] arguments) => + _function is not null ? _function.Invoke(arguments) : _owner!.Invoke(_name!, arguments); } diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs index 54e3e68..7ad1c76 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs @@ -24,11 +24,36 @@ internal WasmJsExports(WasmJsInstance owner, IReadOnlyList metadata.Kind switch { - "function" => new WasmJsExportedFunction(owner, metadata.Name), + "function" => CreateFunction(owner, metadata.Name), + "table" => CreateTable(owner, metadata.Name), "memory" => CreateMemory(owner, metadata.Name), _ => new WasmJsExportValue(metadata.Name, metadata.Kind), }; + private static WasmJsExportedFunction CreateFunction(WasmJsInstance owner, string name) + { + if (owner.Instance is IWasmFunctionProvider provider) + { + var function = provider.GetFunction(name) + ?? throw new InvalidOperationException($"The export '{name}' is not a function."); + return new WasmJsExportedFunction(function); + } + + return new WasmJsExportedFunction(owner, name); + } + + private static WasmJsTable CreateTable(WasmJsInstance owner, string name) + { + if (owner.Instance is not IWasmTableProvider provider) + { + throw new NotSupportedException("The configured WebAssembly runtime does not support table exports."); + } + + var table = provider.GetTable(name) + ?? throw new InvalidOperationException($"The export '{name}' is not a table."); + return new WasmJsTable(table); + } + private static WasmJsMemory CreateMemory(WasmJsInstance owner, string name) { if (owner.Instance is not IWasmMemoryProvider provider) diff --git a/src/AngleSharp.Wasm/Dom/WasmJsTable.cs b/src/AngleSharp.Wasm/Dom/WasmJsTable.cs new file mode 100644 index 0000000..559278f --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsTable.cs @@ -0,0 +1,72 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using System; + +/// +/// JavaScript-visible WebAssembly table. +/// +[DomName("Table")] +public sealed class WasmJsTable +{ + private readonly IWasmTable _table; + + internal WasmJsTable(IWasmTable table) + { + _table = table; + } + + /// + /// Gets the current number of elements. + /// + [DomName("length")] + [DomAccessor(Accessors.Getter)] + public ulong Length => _table.Length; + + /// + /// Gets an element by index. + /// + /// The element index. + /// The table element. + [DomName("get")] + public object? Get(uint index) + { + EnsureIndex(index); + return Wrap(_table.Get(index)); + } + + /// + /// Sets an element by index. + /// + /// The element index. + /// The new element value. + [DomName("set")] + public void Set(uint index, object? value) + { + EnsureIndex(index); + _table.Set(index, Unwrap(value)); + } + + /// + /// Grows the table and initializes the new elements. + /// + /// The number of elements to add. + /// The initial value for new elements. + /// The previous table length. + [DomName("grow")] + public ulong Grow(uint delta, object? value = null) => _table.Grow(delta, Unwrap(value)); + + private void EnsureIndex(uint index) + { + if (index >= _table.Length) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + private static object? Wrap(object? value) => + value is IWasmFunction function ? new WasmJsExportedFunction(function) : value; + + private static object? Unwrap(object? value) => + value is WasmJsExportedFunction { Function: not null } function ? function.Function : value; +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmFunction.cs b/src/AngleSharp.Wasm/IWasmFunction.cs new file mode 100644 index 0000000..d4f7803 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmFunction.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Wasm; + +/// +/// Represents a WebAssembly function reference. +/// +public interface IWasmFunction +{ + /// + /// Invokes the function. + /// + /// The arguments to pass. + /// The invocation result, if any. + object? Invoke(object?[]? arguments = null); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmFunctionProvider.cs b/src/AngleSharp.Wasm/IWasmFunctionProvider.cs new file mode 100644 index 0000000..7849930 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmFunctionProvider.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Wasm; + +/// +/// Provides access to exported WebAssembly functions. +/// +public interface IWasmFunctionProvider +{ + /// + /// Gets an exported function by name. + /// + /// The export name. + /// The exported function, if present. + IWasmFunction? GetFunction(string exportName); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmTable.cs b/src/AngleSharp.Wasm/IWasmTable.cs new file mode 100644 index 0000000..0963c96 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmTable.cs @@ -0,0 +1,30 @@ +namespace AngleSharp.Wasm; + +/// +/// Represents a WebAssembly table. +/// +public interface IWasmTable +{ + /// + /// Gets the current number of elements. + /// + ulong Length { get; } + + /// + /// Gets an element by index. + /// + object? Get(uint index); + + /// + /// Sets an element by index. + /// + void Set(uint index, object? value); + + /// + /// Grows the table and initializes the new elements. + /// + /// The number of elements to add. + /// The initial value for new elements. + /// The previous table length. + ulong Grow(uint delta, object? value); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmTableProvider.cs b/src/AngleSharp.Wasm/IWasmTableProvider.cs new file mode 100644 index 0000000..25af519 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmTableProvider.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Wasm; + +/// +/// Provides access to exported WebAssembly tables. +/// +public interface IWasmTableProvider +{ + /// + /// Gets an exported table by name. + /// + /// The export name. + /// The exported table, if present. + IWasmTable? GetTable(string exportName); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/WasmtimeFunction.cs b/src/AngleSharp.Wasm/WasmtimeFunction.cs new file mode 100644 index 0000000..9a668d7 --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeFunction.cs @@ -0,0 +1,47 @@ +namespace AngleSharp.Wasm; + +using System; +using System.Globalization; +using Wasmtime; + +internal sealed class WasmtimeFunction : IWasmFunction +{ + internal WasmtimeFunction(Function function) + { + Function = function; + } + + internal Function Function { get; } + + public object? Invoke(object?[]? arguments = null) + { + var parameters = Function.Parameters; + var invocationArguments = arguments ?? Array.Empty(); + + if (invocationArguments.Length != parameters.Count) + { + throw new ArgumentException($"The function expects {parameters.Count} argument(s), but {invocationArguments.Length} were provided.", nameof(arguments)); + } + + if (invocationArguments.Length == 0) + { + return 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]}'."), + }; + } + + return Function.Invoke(boxedArguments); + } +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/WasmtimeInstance.cs b/src/AngleSharp.Wasm/WasmtimeInstance.cs index c011ba8..11eadf4 100644 --- a/src/AngleSharp.Wasm/WasmtimeInstance.cs +++ b/src/AngleSharp.Wasm/WasmtimeInstance.cs @@ -9,7 +9,7 @@ namespace AngleSharp.Wasm; /// /// Wraps a Wasmtime instance and store. /// -public sealed class WasmtimeInstance : IWasmInstance, IWasmMemoryProvider +public sealed class WasmtimeInstance : IWasmInstance, IWasmFunctionProvider, IWasmMemoryProvider, IWasmTableProvider { private readonly Store _store; private readonly Instance _instance; @@ -21,6 +21,20 @@ internal WasmtimeInstance(Store store, Instance instance) _instance = instance; } + /// + public IWasmFunction? GetFunction(string exportName) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(exportName)) + { + throw new ArgumentException("Export name must be provided.", nameof(exportName)); + } + + var function = _instance.GetFunction(exportName); + return function is null ? null : new WasmtimeFunction(function); + } + /// public IWasmMemory? GetMemory(string exportName) { @@ -35,6 +49,20 @@ internal WasmtimeInstance(Store store, Instance instance) return memory is null ? null : new WasmtimeMemory(memory); } + /// + public IWasmTable? GetTable(string exportName) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(exportName)) + { + throw new ArgumentException("Export name must be provided.", nameof(exportName)); + } + + var table = _instance.GetTable(exportName); + return table is null ? null : new WasmtimeTable(table); + } + /// public ValueTask InvokeAsync(string exportName, object?[]? arguments = null, CancellationToken cancellationToken = default) { diff --git a/src/AngleSharp.Wasm/WasmtimeTable.cs b/src/AngleSharp.Wasm/WasmtimeTable.cs new file mode 100644 index 0000000..d7e6024 --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeTable.cs @@ -0,0 +1,38 @@ +namespace AngleSharp.Wasm; + +using System; +using Wasmtime; + +internal sealed class WasmtimeTable : IWasmTable +{ + private readonly Table _table; + + internal WasmtimeTable(Table table) + { + _table = table; + } + + public ulong Length => _table.GetSize(); + + public object? Get(uint index) => Wrap(_table.GetElement(index)); + + public void Set(uint index, object? value) => _table.SetElement(index, Unwrap(value)); + + public ulong Grow(uint delta, object? value) => _table.Grow(delta, Unwrap(value)); + + private static object? Wrap(object? value) => value switch + { + Function { IsNull: true } => null, + Function function => new WasmtimeFunction(function), + _ => value, + }; + + private object? Unwrap(object? value) => value switch + { + null when _table.Kind == TableKind.FuncRef => Function.Null, + null => null, + WasmtimeFunction function => function.Function, + IWasmFunction => throw new ArgumentException("The function reference belongs to a different WebAssembly runtime.", nameof(value)), + _ => value, + }; +} \ No newline at end of file From f4de250bfb805ca59491b75e9663bd1b6d747b3d Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 13:59:54 +0200 Subject: [PATCH 05/11] Added Globals #8 --- CHANGELOG.md | 1 + README.md | 5 +- docs/general/01-Basics.md | 2 +- docs/general/02-Spec-Coverage.md | 5 +- docs/tutorials/01-API.md | 5 ++ docs/tutorials/03-Questions.md | 4 +- .../WasmJsBridgeTests.cs | 79 +++++++++++++++++++ src/AngleSharp.Wasm/Dom/WasmJsExports.cs | 13 +++ src/AngleSharp.Wasm/Dom/WasmJsGlobal.cs | 41 ++++++++++ src/AngleSharp.Wasm/IWasmGlobal.cs | 23 ++++++ src/AngleSharp.Wasm/IWasmGlobalProvider.cs | 14 ++++ src/AngleSharp.Wasm/WasmtimeGlobal.cs | 54 +++++++++++++ src/AngleSharp.Wasm/WasmtimeInstance.cs | 16 +++- 13 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 src/AngleSharp.Wasm/Dom/WasmJsGlobal.cs create mode 100644 src/AngleSharp.Wasm/IWasmGlobal.cs create mode 100644 src/AngleSharp.Wasm/IWasmGlobalProvider.cs create mode 100644 src/AngleSharp.Wasm/WasmtimeGlobal.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4840c37..1d6bb10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Friday, August 21 2026. +- Added `WebAssembly.Globals` object projection (#8) - Added `WebAssembly.Table` object projection (#7) - Added `WebAssembly.Memory` object projection (#6) diff --git a/README.md b/README.md index 8e90236..04a475d 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,10 @@ The current package targets `net8.0` and `net10.0`. - Export invocation helpers - `instance.Invoke(...)` - `WasmJsExportedFunction.Invoke(...)` -- Exported memory and table access +- Exported memory, table, and global access - memory `buffer`, `read`, `write`, and `grow` - table `length`, `get`, `set`, and `grow` + - global `value` and `valueOf` - Multi-target support for `net8.0` and `net10.0` ## Current Scope and Limitations @@ -107,7 +108,7 @@ AngleSharp.Wasm currently provides a practical subset of the WebAssembly JS API. - 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. -- Exported memories and tables have usable object projections; other non-function exports remain descriptors (`name`, `kind`). +- Exported memories, tables, and globals have usable object projections; other non-function exports remain descriptors (`name`, `kind`). See [Spec Coverage Matrix](docs/general/02-Spec-Coverage.md) for a section-by-section status overview. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 945b3fd..badfb98 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -81,7 +81,7 @@ AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS AP - 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(...)`. -- Exported memories and tables have usable object projections; other non-function exports remain descriptors (`name`, `kind`). +- Exported memories, tables, and globals have usable object projections; other non-function exports remain descriptors (`name`, `kind`). - `customSections(...)` returns payload bytes (`byte[]`) mapped from custom sections. - Runtime invocation/import marshaling is currently focused on numeric value kinds (`i32`, `i64`, `f32`, `f64`). diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 3393a69..4adcd42 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -24,12 +24,12 @@ Status values: | `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, memory, and table wrappers plus descriptor values for other export kinds. | +| `Instance.exports` | Partial | Available as `instance.exports`, with function, memory, table, and global wrappers plus descriptor values for other export kinds. | | Exported function invocation | Implemented | Supported via `instance.invoke(...)` and `WasmJsExportedFunction.invoke(...)`. | | Host import functions | Implemented | Supported via `WithWasmImports(...)` and `WasmImportFunction`. | | Memory object API (`Memory`) | Partial | Exported memories expose `buffer`, `read(offset, count)`, `write(offset, bytes)`, and `grow(delta)`. `buffer` is a current `byte[]` snapshot rather than a live JavaScript `ArrayBuffer`; memory construction and imports are not yet exposed. | | Table object API (`Table`) | Partial | Exported `funcref` and `externref` tables expose `length`, `get(index)`, `set(index, value)`, and `grow(delta, value)`. Table construction and imports are not yet exposed. | -| Global object API (`Global`) | Not yet | No full JS API `Global` object projection yet. | +| Global object API (`Global`) | Partial | Exported numeric and reference globals expose a mutable or immutable `value` accessor and `valueOf()`. Global construction and imports are not yet exposed. | | 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. | @@ -51,6 +51,7 @@ The current implementation is validated by runtime and bridge tests in the repos - export invocation - exported memory read, write, and growth - exported table access, mutation, and growth +- exported global access and mutation - import descriptor extraction - export descriptor extraction - custom section lookup behavior diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 1ba16c0..2982412 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -82,6 +82,7 @@ Returns an export entry by name: - `WasmJsExportedFunction` for function exports - `WasmJsMemory` for memory exports - `WasmJsTable` for table exports +- `WasmJsGlobal` for global exports - `WasmJsExportValue` for other non-function exports - `null` if no export is found @@ -103,6 +104,10 @@ Provides `buffer`, `read(offset, count)`, `write(offset, bytes)`, and `grow(delt Provides `length`, `get(index)`, `set(index, value)`, and `grow(delta, value)`. Function references returned by `get(...)` expose `invoke(...)`. +## `WasmJsGlobal` + +Provides a `value` accessor and `valueOf()`. Assigning to an immutable global throws `InvalidOperationException`. + ## `WasmJsExportValue` Descriptor type for non-function exports. diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index f516f50..07d7243 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -29,7 +29,7 @@ The current bridge methods are synchronous from the caller perspective. ## Are `Memory`, `Table`, `Global`, and `Tag` exposed as JS API objects? -Exported memories and tables have usable object projections. Globals and tags currently appear as `WasmJsExportValue` descriptors (`name`, `kind`). Memory and table construction and imports are not yet exposed. +Exported memories, tables, and globals have usable object projections. Tags currently appear as `WasmJsExportValue` descriptors (`name`, `kind`). Memory, table, and global construction and imports are not yet exposed. ## How do I invoke exported functions? @@ -67,6 +67,6 @@ Current numeric value kinds are supported: - No `validate(...)` method yet. - No streaming APIs. - No compile options support (`builtins`, `importedStringConstants`). -- Globals, tags, and other non-function exports remain descriptors instead of full JS API objects. +- Tags and other non-function exports remain 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.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index 5dad5be..1c257de 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -81,6 +81,35 @@ public sealed class WasmJsBridgeTests 0x07, 0x0B, 0x01, 0x07, 0x6F, 0x62, 0x6A, 0x65, 0x63, 0x74, 0x73, 0x01, 0x00, }; + // (module + // (global $counter (export "counter") (mut i32) (i32.const 42)) + // (global (export "constant") f64 (f64.const 1.5)) + // (func (export "read_counter") (result i32) global.get $counter)) + private static readonly byte[] GlobalExportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, + 0x03, 0x02, 0x01, 0x00, + 0x06, 0x12, 0x02, + 0x7F, 0x01, 0x41, 0x2A, 0x0B, + 0x7C, 0x00, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0x3F, 0x0B, + 0x07, 0x25, 0x03, + 0x07, 0x63, 0x6F, 0x75, 0x6E, 0x74, 0x65, 0x72, 0x03, 0x00, + 0x08, 0x63, 0x6F, 0x6E, 0x73, 0x74, 0x61, 0x6E, 0x74, 0x03, 0x01, + 0x0C, 0x72, 0x65, 0x61, 0x64, 0x5F, 0x63, 0x6F, 0x75, 0x6E, 0x74, 0x65, 0x72, 0x00, 0x00, + 0x0A, 0x06, 0x01, 0x04, 0x00, 0x23, 0x00, 0x0B, + }; + + // (module (global (export "object") (mut externref) (ref.null extern))) + private static readonly byte[] ExternalReferenceGlobalExportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x06, 0x06, 0x01, 0x6F, 0x01, 0xD0, 0x6F, 0x0B, + 0x07, 0x0A, 0x01, 0x06, 0x6F, 0x62, 0x6A, 0x65, 0x63, 0x74, 0x03, 0x00, + }; + // AnswerModule + two custom sections named "meta" private static readonly byte[] AnswerModuleWithCustomSections = { @@ -258,6 +287,56 @@ public async Task TableProjectionSupportsExternalReferences() Assert.That(table.Get(1), Is.SameAs(value)); } + [Test] + public async Task InstanceExportsExposeGlobalAsLiveGlobalObject() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, GlobalExportModule); + var instance = WebAssembly.Instantiate(context.Current!, module); + var counter = instance.Exports["counter"] as WasmJsGlobal; + var constant = instance.Exports["constant"] as WasmJsGlobal; + + Assert.That(counter, Is.Not.Null); + Assert.That(counter!.Value, Is.EqualTo(42)); + Assert.That(counter.ValueOf(), Is.EqualTo(42)); + Assert.That(constant, Is.Not.Null); + Assert.That(constant!.Value, Is.EqualTo(1.5)); + + counter.Value = 7.0; + + Assert.That(counter.Value, Is.EqualTo(7)); + Assert.That(instance.Invoke("read_counter"), Is.EqualTo(7)); + Assert.That(() => constant.Value = 2.0, Throws.InvalidOperationException); + Assert.That(constant.Value, Is.EqualTo(1.5)); + } + + [Test] + public async Task GlobalProjectionSupportsExternalReferences() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var module = WebAssembly.Compile(context.Current!, ExternalReferenceGlobalExportModule); + var instance = WebAssembly.Instantiate(context.Current!, module); + var global = instance.Exports["object"] as WasmJsGlobal; + var value = new object(); + + Assert.That(global, Is.Not.Null); + Assert.That(global!.Value, Is.Null); + + global.Value = value; + + Assert.That(global.Value, Is.SameAs(value)); + + global.Value = null; + + Assert.That(global.Value, Is.Null); + } + [Test] public async Task ExportedFunctionWrapperSupportsArguments() { diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs index 7ad1c76..a75fab8 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs @@ -27,6 +27,7 @@ private static object CreateExport(WasmJsInstance owner, WasmModuleExportDescrip "function" => CreateFunction(owner, metadata.Name), "table" => CreateTable(owner, metadata.Name), "memory" => CreateMemory(owner, metadata.Name), + "global" => CreateGlobal(owner, metadata.Name), _ => new WasmJsExportValue(metadata.Name, metadata.Kind), }; @@ -66,6 +67,18 @@ private static WasmJsMemory CreateMemory(WasmJsInstance owner, string name) return new WasmJsMemory(memory); } + private static WasmJsGlobal CreateGlobal(WasmJsInstance owner, string name) + { + if (owner.Instance is not IWasmGlobalProvider provider) + { + throw new NotSupportedException("The configured WebAssembly runtime does not support global exports."); + } + + var global = provider.GetGlobal(name) + ?? throw new InvalidOperationException($"The export '{name}' is not a global."); + return new WasmJsGlobal(global); + } + /// /// Gets the export entry by name. /// diff --git a/src/AngleSharp.Wasm/Dom/WasmJsGlobal.cs b/src/AngleSharp.Wasm/Dom/WasmJsGlobal.cs new file mode 100644 index 0000000..592f0ce --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsGlobal.cs @@ -0,0 +1,41 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; + +/// +/// JavaScript-visible WebAssembly global. +/// +[DomName("Global")] +public sealed class WasmJsGlobal +{ + private readonly IWasmGlobal _global; + + internal WasmJsGlobal(IWasmGlobal global) + { + _global = global; + } + + /// + /// Gets or sets the global value. + /// + [DomName("value")] + [DomAccessor(Accessors.Getter | Accessors.Setter)] + public object? Value + { + get => Wrap(_global.GetValue()); + set => _global.SetValue(Unwrap(value)); + } + + /// + /// Gets the primitive global value. + /// + /// The current value. + [DomName("valueOf")] + public object? ValueOf() => Value; + + private static object? Wrap(object? value) => + value is IWasmFunction function ? new WasmJsExportedFunction(function) : value; + + private static object? Unwrap(object? value) => + value is WasmJsExportedFunction { Function: not null } function ? function.Function : value; +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmGlobal.cs b/src/AngleSharp.Wasm/IWasmGlobal.cs new file mode 100644 index 0000000..ca6818c --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmGlobal.cs @@ -0,0 +1,23 @@ +namespace AngleSharp.Wasm; + +/// +/// Represents a WebAssembly global. +/// +public interface IWasmGlobal +{ + /// + /// Gets a value indicating whether the global is mutable. + /// + bool IsMutable { get; } + + /// + /// Gets the current value. + /// + object? GetValue(); + + /// + /// Sets the current value. + /// + /// The new value. + void SetValue(object? value); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/IWasmGlobalProvider.cs b/src/AngleSharp.Wasm/IWasmGlobalProvider.cs new file mode 100644 index 0000000..4f1baeb --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmGlobalProvider.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Wasm; + +/// +/// Provides access to exported WebAssembly globals. +/// +public interface IWasmGlobalProvider +{ + /// + /// Gets an exported global by name. + /// + /// The export name. + /// The exported global, if present. + IWasmGlobal? GetGlobal(string exportName); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/WasmtimeGlobal.cs b/src/AngleSharp.Wasm/WasmtimeGlobal.cs new file mode 100644 index 0000000..1cd373e --- /dev/null +++ b/src/AngleSharp.Wasm/WasmtimeGlobal.cs @@ -0,0 +1,54 @@ +namespace AngleSharp.Wasm; + +using System; +using System.Globalization; +using Wasmtime; + +internal sealed class WasmtimeGlobal : IWasmGlobal +{ + private readonly Global _global; + + internal WasmtimeGlobal(Global global) + { + _global = global; + } + + public bool IsMutable => _global.Mutability == Mutability.Mutable; + + public object? GetValue() => Wrap(_global.GetValue()); + + public void SetValue(object? value) + { + if (!IsMutable) + { + throw new InvalidOperationException("The WebAssembly global is immutable."); + } + + _global.SetValue(Unwrap(value)); + } + + private static object? Wrap(object? value) => value switch + { + Function { IsNull: true } => null, + Function function => new WasmtimeFunction(function), + _ => value, + }; + + private object? Unwrap(object? value) => _global.Kind switch + { + ValueKind.Int32 => Convert.ToInt32(value, CultureInfo.InvariantCulture), + ValueKind.Int64 => Convert.ToInt64(value, CultureInfo.InvariantCulture), + ValueKind.Float32 => Convert.ToSingle(value, CultureInfo.InvariantCulture), + ValueKind.Float64 => Convert.ToDouble(value, CultureInfo.InvariantCulture), + ValueKind.FuncRef => UnwrapFunction(value), + _ => value, + }; + + private static object UnwrapFunction(object? value) => value switch + { + null => Function.Null, + WasmtimeFunction function => function.Function, + IWasmFunction => throw new ArgumentException("The function reference belongs to a different WebAssembly runtime.", nameof(value)), + _ => value, + }; +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/WasmtimeInstance.cs b/src/AngleSharp.Wasm/WasmtimeInstance.cs index 11eadf4..3ded52a 100644 --- a/src/AngleSharp.Wasm/WasmtimeInstance.cs +++ b/src/AngleSharp.Wasm/WasmtimeInstance.cs @@ -9,7 +9,7 @@ namespace AngleSharp.Wasm; /// /// Wraps a Wasmtime instance and store. /// -public sealed class WasmtimeInstance : IWasmInstance, IWasmFunctionProvider, IWasmMemoryProvider, IWasmTableProvider +public sealed class WasmtimeInstance : IWasmInstance, IWasmFunctionProvider, IWasmGlobalProvider, IWasmMemoryProvider, IWasmTableProvider { private readonly Store _store; private readonly Instance _instance; @@ -35,6 +35,20 @@ internal WasmtimeInstance(Store store, Instance instance) return function is null ? null : new WasmtimeFunction(function); } + /// + public IWasmGlobal? GetGlobal(string exportName) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(exportName)) + { + throw new ArgumentException("Export name must be provided.", nameof(exportName)); + } + + var global = _instance.GetGlobal(exportName); + return global is null ? null : new WasmtimeGlobal(global); + } + /// public IWasmMemory? GetMemory(string exportName) { From df2ee1b9ef834f005793183755df47c14cc47ada Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 15:15:33 +0200 Subject: [PATCH 06/11] Implemented #9 --- CHANGELOG.md | 3 +- README.md | 6 +- docs/general/01-Basics.md | 3 +- docs/general/02-Spec-Coverage.md | 5 +- docs/tutorials/01-API.md | 5 + docs/tutorials/03-Questions.md | 6 +- .../WasmJsBridgeTests.cs | 99 +++++++++++++ src/AngleSharp.Wasm/Dom/WasmJsExports.cs | 20 ++- src/AngleSharp.Wasm/Dom/WasmJsTag.cs | 45 ++++++ src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs | 140 +++++++++++++++++- 10 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 src/AngleSharp.Wasm/Dom/WasmJsTag.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6bb10..2f054a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ Released on Friday, August 21 2026. -- Added `WebAssembly.Globals` object projection (#8) +- Added `WebAssembly.Tag` object projection (#9) +- Added `WebAssembly.Global` object projection (#8) - Added `WebAssembly.Table` object projection (#7) - Added `WebAssembly.Memory` object projection (#6) diff --git a/README.md b/README.md index 04a475d..2575e2b 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,11 @@ The current package targets `net8.0` and `net10.0`. - Export invocation helpers - `instance.Invoke(...)` - `WasmJsExportedFunction.Invoke(...)` -- Exported memory, table, and global access +- Exported memory, table, global, and tag access - memory `buffer`, `read`, `write`, and `grow` - table `length`, `get`, `set`, and `grow` - global `value` and `valueOf` + - tag `type().parameters` - Multi-target support for `net8.0` and `net10.0` ## Current Scope and Limitations @@ -108,7 +109,8 @@ AngleSharp.Wasm currently provides a practical subset of the WebAssembly JS API. - 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. -- Exported memories, tables, and globals have usable object projections; other non-function exports remain descriptors (`name`, `kind`). +- Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). +- Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API, so Tag exception operations are not yet available. See [Spec Coverage Matrix](docs/general/02-Spec-Coverage.md) for a section-by-section status overview. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index badfb98..a98bd05 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -81,7 +81,8 @@ AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS AP - 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(...)`. -- Exported memories, tables, and globals have usable object projections; other non-function exports remain descriptors (`name`, `kind`). +- Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). +- Tag projections expose parsed type metadata, but the default Wasmtime 44 backend does not expose native Tag handles or exception operations. - `customSections(...)` returns payload bytes (`byte[]`) mapped from custom sections. - Runtime invocation/import marshaling is currently focused on numeric value kinds (`i32`, `i64`, `f32`, `f64`). diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 4adcd42..54c81cf 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -24,13 +24,13 @@ Status values: | `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, memory, table, and global wrappers plus descriptor values for other export kinds. | +| `Instance.exports` | Partial | Available as `instance.exports`, with function, memory, table, global, and tag wrappers plus descriptor values for other export kinds. | | Exported function invocation | Implemented | Supported via `instance.invoke(...)` and `WasmJsExportedFunction.invoke(...)`. | | Host import functions | Implemented | Supported via `WithWasmImports(...)` and `WasmImportFunction`. | | Memory object API (`Memory`) | Partial | Exported memories expose `buffer`, `read(offset, count)`, `write(offset, bytes)`, and `grow(delta)`. `buffer` is a current `byte[]` snapshot rather than a live JavaScript `ArrayBuffer`; memory construction and imports are not yet exposed. | | Table object API (`Table`) | Partial | Exported `funcref` and `externref` tables expose `length`, `get(index)`, `set(index, value)`, and `grow(delta, value)`. Table construction and imports are not yet exposed. | | Global object API (`Global`) | Partial | Exported numeric and reference globals expose a mutable or immutable `value` accessor and `valueOf()`. Global construction and imports are not yet exposed. | -| Tag object API (`Tag`) | Not yet | No full JS API `Tag` object projection yet. | +| Tag object API (`Tag`) | Partial | Tag exports expose `type().parameters`, including re-exported imported tags. Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API. | | 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. | @@ -52,6 +52,7 @@ The current implementation is validated by runtime and bridge tests in the repos - exported memory read, write, and growth - exported table access, mutation, and growth - exported global access and mutation +- exported and re-exported tag type metadata - import descriptor extraction - export descriptor extraction - custom section lookup behavior diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 2982412..9c62122 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -83,6 +83,7 @@ Returns an export entry by name: - `WasmJsMemory` for memory exports - `WasmJsTable` for table exports - `WasmJsGlobal` for global exports +- `WasmJsTag` for tag exports - `WasmJsExportValue` for other non-function exports - `null` if no export is found @@ -108,6 +109,10 @@ Provides `length`, `get(index)`, `set(index, value)`, and `grow(delta, value)`. Provides a `value` accessor and `valueOf()`. Assigning to an immutable global throws `InvalidOperationException`. +## `WasmJsTag` + +Provides `type()`, whose descriptor exposes the tag's `parameters` as WebAssembly value type names. Native exception operations are not available through the default Wasmtime 44 backend. + ## `WasmJsExportValue` Descriptor type for non-function exports. diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index 07d7243..2d2ba12 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -29,7 +29,9 @@ The current bridge methods are synchronous from the caller perspective. ## Are `Memory`, `Table`, `Global`, and `Tag` exposed as JS API objects? -Exported memories, tables, and globals have usable object projections. Tags currently appear as `WasmJsExportValue` descriptors (`name`, `kind`). Memory, table, and global construction and imports are not yet exposed. +Exported memories, tables, and globals have usable object projections. Tag projections expose `type().parameters`, including metadata for re-exported imported tags. Memory, table, and global construction and imports are not yet exposed. + +Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API, so Tag exception operations are not yet available through the default backend. ## How do I invoke exported functions? @@ -67,6 +69,6 @@ Current numeric value kinds are supported: - No `validate(...)` method yet. - No streaming APIs. - No compile options support (`builtins`, `importedStringConstants`). -- Tags and other non-function exports remain descriptors instead of full JS API objects. +- Tag exception operations and full `WebAssembly.Exception` interoperability are not yet available. These limits are expected at this stage and can be expanded in future versions. diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index 1c257de..dab5080 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -3,7 +3,9 @@ namespace AngleSharp.Wasm.Tests; using AngleSharp.Wasm.Dom; using NUnit.Framework; using System; +using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading.Tasks; [TestFixture] @@ -110,6 +112,30 @@ public sealed class WasmJsBridgeTests 0x07, 0x0A, 0x01, 0x06, 0x6F, 0x62, 0x6A, 0x65, 0x63, 0x74, 0x03, 0x00, }; + // (module (tag (export "error") (export "alias") (param i32 f64))) + private static readonly byte[] TagExportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x06, 0x01, 0x60, 0x02, 0x7F, 0x7C, 0x00, + 0x0D, 0x03, 0x01, 0x00, 0x00, + 0x07, 0x11, 0x02, + 0x05, 0x65, 0x72, 0x72, 0x6F, 0x72, 0x04, 0x00, + 0x05, 0x61, 0x6C, 0x69, 0x61, 0x73, 0x04, 0x00, + }; + + // (module + // (import "host" "tag" (tag (param i64))) + // (export "tag" (tag 0))) + private static readonly byte[] ImportedTagExportModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x01, 0x7E, 0x00, + 0x02, 0x0D, 0x01, 0x04, 0x68, 0x6F, 0x73, 0x74, 0x03, 0x74, 0x61, 0x67, 0x04, 0x00, 0x00, + 0x07, 0x07, 0x01, 0x03, 0x74, 0x61, 0x67, 0x04, 0x00, + }; + // AnswerModule + two custom sections named "meta" private static readonly byte[] AnswerModuleWithCustomSections = { @@ -337,6 +363,33 @@ public async Task GlobalProjectionSupportsExternalReferences() Assert.That(global.Value, Is.Null); } + [Test] + public void InstanceExportsExposeTagTypeMetadata() + { + using var instance = CreateMetadataOnlyInstance(TagExportModule); + var tag = instance.Exports["error"] as WasmJsTag; + var alias = instance.Exports["alias"] as WasmJsTag; + + Assert.That(tag, Is.Not.Null); + Assert.That(tag!.Type().Parameters, Is.EqualTo(new[] { "i32", "f64" })); + Assert.That(alias, Is.SameAs(tag)); + + var parameters = tag.Type().Parameters; + parameters[0] = "changed"; + + Assert.That(tag.Type().Parameters, Is.EqualTo(new[] { "i32", "f64" })); + } + + [Test] + public void ReExportedImportedTagRetainsTypeMetadata() + { + using var instance = CreateMetadataOnlyInstance(ImportedTagExportModule); + var tag = instance.Exports["tag"] as WasmJsTag; + + Assert.That(tag, Is.Not.Null); + Assert.That(tag!.Type().Parameters, Is.EqualTo(new[] { "i64" })); + } + [Test] public async Task ExportedFunctionWrapperSupportsArguments() { @@ -435,4 +488,50 @@ public async Task BridgeCompileWithoutWasmRegistrationThrows() Assert.That(() => WebAssembly.Compile(context.Current!, AnswerModule), Throws.InvalidOperationException); } + + private static MetadataOnlyInstanceScope CreateMetadataOnlyInstance(byte[] moduleBytes) + { + var metadataType = typeof(WasmJsModule).Assembly.GetType("AngleSharp.Wasm.Dom.WasmModuleMetadata")!; + var parse = metadataType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static)!; + var metadata = parse.Invoke(null, new object[] { moduleBytes })!; + var exports = (IReadOnlyList)metadataType + .GetProperty("Exports", BindingFlags.Public | BindingFlags.Instance)! + .GetValue(metadata)!; + var runtimeInstance = new MetadataOnlyInstance(); + var instance = (WasmJsInstance)Activator.CreateInstance( + typeof(WasmJsInstance), + BindingFlags.NonPublic | BindingFlags.Instance, + null, + new object[] { runtimeInstance, exports }, + null)!; + return new MetadataOnlyInstanceScope(runtimeInstance, instance); + } + + private sealed class MetadataOnlyInstanceScope : IDisposable + { + private readonly MetadataOnlyInstance _runtimeInstance; + + public MetadataOnlyInstanceScope(MetadataOnlyInstance runtimeInstance, WasmJsInstance instance) + { + _runtimeInstance = runtimeInstance; + Exports = instance.Exports; + } + + public WasmJsExports Exports { get; } + + public void Dispose() => _runtimeInstance.Dispose(); + } + + private sealed class MetadataOnlyInstance : IWasmInstance + { + public ValueTask InvokeAsync( + string exportName, + object?[]? arguments = null, + System.Threading.CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public void Dispose() + { + } + } } diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs index a75fab8..b16232c 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsExports.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs @@ -15,22 +15,38 @@ public sealed class WasmJsExports internal WasmJsExports(WasmJsInstance owner, IReadOnlyList exportsMetadata) { + var tags = new Dictionary(); _entries = exportsMetadata .ToDictionary( m => m.Name, - m => CreateExport(owner, m)); + m => CreateExport(owner, m, tags)); } - private static object CreateExport(WasmJsInstance owner, WasmModuleExportDescriptor metadata) => + private static object CreateExport( + WasmJsInstance owner, + WasmModuleExportDescriptor metadata, + IDictionary tags) => metadata.Kind switch { "function" => CreateFunction(owner, metadata.Name), "table" => CreateTable(owner, metadata.Name), "memory" => CreateMemory(owner, metadata.Name), "global" => CreateGlobal(owner, metadata.Name), + "tag" => CreateTag(metadata, tags), _ => new WasmJsExportValue(metadata.Name, metadata.Kind), }; + private static WasmJsTag CreateTag(WasmModuleExportDescriptor metadata, IDictionary tags) + { + if (!tags.TryGetValue(metadata.Index, out var tag)) + { + tag = new WasmJsTag(metadata.TagParameters ?? Array.Empty()); + tags.Add(metadata.Index, tag); + } + + return tag; + } + private static WasmJsExportedFunction CreateFunction(WasmJsInstance owner, string name) { if (owner.Instance is IWasmFunctionProvider provider) diff --git a/src/AngleSharp.Wasm/Dom/WasmJsTag.cs b/src/AngleSharp.Wasm/Dom/WasmJsTag.cs new file mode 100644 index 0000000..6d70d2b --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsTag.cs @@ -0,0 +1,45 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; + +/// +/// JavaScript-visible WebAssembly exception tag. +/// +[DomName("Tag")] +public sealed class WasmJsTag +{ + private readonly WasmJsTagType _type; + + internal WasmJsTag(string[] parameters) + { + _type = new WasmJsTagType(parameters); + } + + /// + /// Gets the tag type descriptor. + /// + /// The tag type. + [DomName("type")] + public WasmJsTagType Type() => _type; +} + +/// +/// Describes a WebAssembly exception tag type. +/// +[DomName("TagType")] +public sealed class WasmJsTagType +{ + private readonly string[] _parameters; + + internal WasmJsTagType(string[] parameters) + { + _parameters = (string[])parameters.Clone(); + } + + /// + /// Gets the tag parameter value types. + /// + [DomName("parameters")] + [DomAccessor(Accessors.Getter)] + public string[] Parameters => (string[])_parameters.Clone(); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs b/src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs index efc0c58..1c2c75c 100644 --- a/src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs +++ b/src/AngleSharp.Wasm/Dom/WasmModuleMetadata.cs @@ -43,6 +43,8 @@ public static WasmModuleMetadata Parse(byte[] bytes) var imports = new List(); var exports = new List(); var customSections = new List(); + var functionTypes = new List(); + var tagTypes = new List(); var offset = 8; @@ -62,11 +64,17 @@ public static WasmModuleMetadata Parse(byte[] bytes) case 0: ParseCustomSection(bytes, ref offset, sectionEnd, customSections); break; + case 1: + ParseTypeSection(bytes, ref offset, sectionEnd, functionTypes); + break; case 2: - ParseImportSection(bytes, ref offset, sectionEnd, imports); + ParseImportSection(bytes, ref offset, sectionEnd, imports, tagTypes); break; case 7: - ParseExportSection(bytes, ref offset, sectionEnd, exports); + ParseExportSection(bytes, ref offset, sectionEnd, exports, functionTypes, tagTypes); + break; + case 13: + ParseTagSection(bytes, ref offset, sectionEnd, tagTypes); break; default: offset = sectionEnd; @@ -77,7 +85,55 @@ public static WasmModuleMetadata Parse(byte[] bytes) return new WasmModuleMetadata(imports, exports, customSections); } - private static void ParseImportSection(byte[] bytes, ref int offset, int sectionEnd, List imports) + private static void ParseTypeSection(byte[] bytes, ref int offset, int sectionEnd, List functionTypes) + { + var parsedTypes = new List(); + + try + { + var count = ReadVarUInt32(bytes, ref offset); + + for (uint i = 0; i < count; i++) + { + if (ReadByte(bytes, ref offset, sectionEnd) != 0x60) + { + throw new ArgumentException("Unsupported WebAssembly type definition.", nameof(bytes)); + } + + var parameterCount = ReadVarUInt32(bytes, ref offset); + var parameters = new string[parameterCount]; + + for (var parameterIndex = 0; parameterIndex < parameters.Length; parameterIndex++) + { + parameters[parameterIndex] = ReadValueType(bytes, ref offset, sectionEnd); + } + + var resultCount = ReadVarUInt32(bytes, ref offset); + + for (uint resultIndex = 0; resultIndex < resultCount; resultIndex++) + { + _ = ReadValueType(bytes, ref offset, sectionEnd); + } + + parsedTypes.Add(parameters); + } + } + catch (ArgumentException) + { + offset = sectionEnd; + return; + } + + functionTypes.AddRange(parsedTypes); + offset = sectionEnd; + } + + private static void ParseImportSection( + byte[] bytes, + ref int offset, + int sectionEnd, + List imports, + List tagTypes) { var count = ReadVarUInt32(bytes, ref offset); @@ -89,13 +145,28 @@ private static void ParseImportSection(byte[] bytes, ref int offset, int section var kind = bytes[offset++]; imports.Add(new WasmModuleImportDescriptor(moduleName, name, MapKind(kind))); - SkipImportType(bytes, ref offset, sectionEnd, kind); + + if (kind == 0x04) + { + _ = ReadByte(bytes, ref offset, sectionEnd); + tagTypes.Add(ReadVarUInt32(bytes, ref offset)); + } + else + { + SkipImportType(bytes, ref offset, sectionEnd, kind); + } } offset = sectionEnd; } - private static void ParseExportSection(byte[] bytes, ref int offset, int sectionEnd, List exports) + private static void ParseExportSection( + byte[] bytes, + ref int offset, + int sectionEnd, + List exports, + IReadOnlyList functionTypes, + IReadOnlyList tagTypes) { var count = ReadVarUInt32(bytes, ref offset); @@ -104,13 +175,41 @@ private static void ParseExportSection(byte[] bytes, ref int offset, int section 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))); + var index = ReadVarUInt32(bytes, ref offset); + var tagParameters = kind == 0x04 ? GetTagParameters(bytes, index, functionTypes, tagTypes) : null; + exports.Add(new WasmModuleExportDescriptor(name, MapKind(kind), index, tagParameters)); + } + + offset = sectionEnd; + } + + private static void ParseTagSection(byte[] bytes, ref int offset, int sectionEnd, List tagTypes) + { + var count = ReadVarUInt32(bytes, ref offset); + + for (uint i = 0; i < count; i++) + { + _ = ReadByte(bytes, ref offset, sectionEnd); + tagTypes.Add(ReadVarUInt32(bytes, ref offset)); } offset = sectionEnd; } + private static string[] GetTagParameters( + byte[] bytes, + uint tagIndex, + IReadOnlyList functionTypes, + IReadOnlyList tagTypes) + { + if (tagIndex >= tagTypes.Count || tagTypes[(int)tagIndex] >= functionTypes.Count) + { + throw new ArgumentException("Invalid WebAssembly tag type index.", nameof(bytes)); + } + + return functionTypes[(int)tagTypes[(int)tagIndex]]; + } + private static void ParseCustomSection(byte[] bytes, ref int offset, int sectionEnd, List sections) { var name = ReadName(bytes, ref offset); @@ -174,6 +273,25 @@ private static void SkipLimits(byte[] bytes, ref int offset, int sectionEnd) } } + private static string ReadValueType(byte[] bytes, ref int offset, int sectionEnd) => + ReadByte(bytes, ref offset, sectionEnd) switch + { + 0x7F => "i32", + 0x7E => "i64", + 0x7D => "f32", + 0x7C => "f64", + 0x7B => "v128", + 0x70 => "funcref", + 0x6F => "externref", + 0x6E => "anyref", + 0x6D => "eqref", + 0x6C => "i31ref", + 0x6B => "structref", + 0x6A => "arrayref", + 0x69 => "exnref", + _ => throw new ArgumentException("Unsupported WebAssembly value type.", nameof(bytes)), + }; + private static string ReadName(byte[] bytes, ref int offset) { var length = (int)ReadVarUInt32(bytes, ref offset); @@ -247,12 +365,18 @@ private static uint ReadVarUInt32(byte[] bytes, ref int offset) /// public sealed class WasmModuleExportDescriptor { - internal WasmModuleExportDescriptor(string name, string kind) + internal WasmModuleExportDescriptor(string name, string kind, uint index, string[]? tagParameters) { Name = name; Kind = kind; + Index = index; + TagParameters = tagParameters; } + internal uint Index { get; } + + internal string[]? TagParameters { get; } + /// /// Gets the exported field name. /// From 4f4af166b8ee56935ea2a3f2a35ed376a849cc30 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 15:16:48 +0200 Subject: [PATCH 07/11] Implemented #10 --- CHANGELOG.md | 1 + README.md | 5 +- docs/general/01-Basics.md | 2 +- docs/general/02-Spec-Coverage.md | 5 +- docs/tutorials/01-API.md | 6 +- docs/tutorials/03-Questions.md | 6 +- .../WasmJsBridgeTests.cs | 35 ++++++ src/AngleSharp.Wasm/Dom/WasmJsException.cs | 101 ++++++++++++++++++ src/AngleSharp.Wasm/Dom/WasmJsTag.cs | 29 ++++- 9 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 src/AngleSharp.Wasm/Dom/WasmJsException.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f054a5..6c8d810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Friday, August 21 2026. +- Added `WebAssembly.Exception` object projection (#10) - Added `WebAssembly.Tag` object projection (#9) - Added `WebAssembly.Global` object projection (#8) - Added `WebAssembly.Table` object projection (#7) diff --git a/README.md b/README.md index 2575e2b..227038e 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,12 @@ The current package targets `net8.0` and `net10.0`. - Export invocation helpers - `instance.Invoke(...)` - `WasmJsExportedFunction.Invoke(...)` -- Exported memory, table, global, and tag access +- WebAssembly object projections - memory `buffer`, `read`, `write`, and `grow` - table `length`, `get`, `set`, and `grow` - global `value` and `valueOf` - tag `type().parameters` + - exception `is(tag)` and `getArg(tag, index)` - Multi-target support for `net8.0` and `net10.0` ## Current Scope and Limitations @@ -110,7 +111,7 @@ AngleSharp.Wasm currently provides a practical subset of the WebAssembly JS API. - `validate(...)` and streaming APIs are not yet implemented. - Compile options such as builtins / imported string constants are not yet implemented. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). -- Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API, so Tag exception operations are not yet available. +- Host-created tags and exceptions are supported. Wasmtime 44 does not expose native Tag or Exception handles or enable exception modules through its .NET API, so runtime-thrown Wasm exceptions are not yet projected. See [Spec Coverage Matrix](docs/general/02-Spec-Coverage.md) for a section-by-section status overview. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index a98bd05..aeebf5a 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -82,7 +82,7 @@ AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS AP - `WebAssembly.validate(...)`, streaming APIs, and compile options are not implemented. - `Instance.exports` function members are wrapper objects requiring `.invoke(...)`. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). -- Tag projections expose parsed type metadata, but the default Wasmtime 44 backend does not expose native Tag handles or exception operations. +- Host-created exceptions support tag identity and typed payload inspection. The default Wasmtime 44 backend does not expose native Tag or Exception handles, so runtime-thrown Wasm exceptions are not yet projected. - `customSections(...)` returns payload bytes (`byte[]`) mapped from custom sections. - Runtime invocation/import marshaling is currently focused on numeric value kinds (`i32`, `i64`, `f32`, `f64`). diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 54c81cf..168b45c 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -30,8 +30,8 @@ Status values: | Memory object API (`Memory`) | Partial | Exported memories expose `buffer`, `read(offset, count)`, `write(offset, bytes)`, and `grow(delta)`. `buffer` is a current `byte[]` snapshot rather than a live JavaScript `ArrayBuffer`; memory construction and imports are not yet exposed. | | Table object API (`Table`) | Partial | Exported `funcref` and `externref` tables expose `length`, `get(index)`, `set(index, value)`, and `grow(delta, value)`. Table construction and imports are not yet exposed. | | Global object API (`Global`) | Partial | Exported numeric and reference globals expose a mutable or immutable `value` accessor and `valueOf()`. Global construction and imports are not yet exposed. | -| Tag object API (`Tag`) | Partial | Tag exports expose `type().parameters`, including re-exported imported tags. Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API. | -| Exception object API (`Exception`) | Not yet | No full JS API exception projection yet. | +| Tag object API (`Tag`) | Partial | Host-created and exported tags expose `type().parameters`, including re-exported imported tags. Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API. | +| Exception object API (`Exception`) | Partial | Host-created exceptions validate typed payloads and expose `is(tag)` and `getArg(tag, index)`. Runtime-thrown Wasm exceptions cannot be projected through Wasmtime 44. | | 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. | @@ -53,6 +53,7 @@ The current implementation is validated by runtime and bridge tests in the repos - exported table access, mutation, and growth - exported global access and mutation - exported and re-exported tag type metadata +- host-created exception payload validation and inspection - import descriptor extraction - export descriptor extraction - custom section lookup behavior diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 9c62122..1906888 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -111,7 +111,11 @@ Provides a `value` accessor and `valueOf()`. Assigning to an immutable global th ## `WasmJsTag` -Provides `type()`, whose descriptor exposes the tag's `parameters` as WebAssembly value type names. Native exception operations are not available through the default Wasmtime 44 backend. +Can be constructed with an ordered array of WebAssembly value type names. Provides `type()`, whose descriptor exposes the tag's `parameters`. + +## `WasmJsException` + +Construct with a `WasmJsTag` and matching payload array. Provides `is(tag)` and `getArg(tag, index)` and can be thrown and caught as a .NET exception. Runtime-thrown Wasm exceptions are not available through the default Wasmtime 44 backend. ## `WasmJsExportValue` diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index 2d2ba12..cd313e6 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -29,9 +29,9 @@ The current bridge methods are synchronous from the caller perspective. ## Are `Memory`, `Table`, `Global`, and `Tag` exposed as JS API objects? -Exported memories, tables, and globals have usable object projections. Tag projections expose `type().parameters`, including metadata for re-exported imported tags. Memory, table, and global construction and imports are not yet exposed. +Exported memories, tables, and globals have usable object projections. Host-created and exported tags expose `type().parameters`, including metadata for re-exported imported tags. Host-created exceptions support `is(tag)` and `getArg(tag, index)`. Memory, table, and global construction and imports are not yet exposed. -Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API, so Tag exception operations are not yet available through the default backend. +Wasmtime 44 does not expose native Tag or Exception handles or enable exception modules through its .NET API, so runtime-thrown Wasm exceptions are not yet projected through the default backend. ## How do I invoke exported functions? @@ -69,6 +69,6 @@ Current numeric value kinds are supported: - No `validate(...)` method yet. - No streaming APIs. - No compile options support (`builtins`, `importedStringConstants`). -- Tag exception operations and full `WebAssembly.Exception` interoperability are not yet available. +- Runtime-thrown Wasm exception interoperability is not yet available. These limits are expected at this stage and can be expanded in future versions. diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index dab5080..4627177 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -390,6 +390,41 @@ public void ReExportedImportedTagRetainsTypeMetadata() Assert.That(tag!.Type().Parameters, Is.EqualTo(new[] { "i64" })); } + [Test] + public void ExceptionProjectionValidatesAndExposesTypedPayload() + { + var tag = new WasmJsTag(new[] { "i32", "f64" }); + var otherTag = new WasmJsTag(new[] { "i32", "f64" }); + var payload = new object?[] { 42.0, 1.5 }; + var exception = new WasmJsException(tag, payload); + + payload[0] = 0; + + Assert.That(exception.Message, Is.EqualTo("wasm exception")); + Assert.That(exception.Is(tag), Is.True); + Assert.That(exception.Is(otherTag), Is.False); + Assert.That(exception.GetArg(tag, 0), Is.EqualTo(42)); + Assert.That(exception.GetArg(tag, 1), Is.EqualTo(1.5)); + Assert.That(() => exception.GetArg(otherTag, 0), Throws.ArgumentException); + Assert.That(() => exception.GetArg(tag, -1), Throws.TypeOf()); + Assert.That(() => exception.GetArg(tag, 2), Throws.TypeOf()); + Assert.That(() => new WasmJsException(tag, new object?[] { 42 }), Throws.ArgumentException); + Assert.That(() => new WasmJsException(tag, new object?[] { "invalid", 1.5 }), Throws.ArgumentException); + Assert.That(() => new WasmJsTag(new[] { "invalid" }), Throws.ArgumentException); + } + + [Test] + public void ExceptionProjectionCanBeThrownAndCaught() + { + var tag = new WasmJsTag(new[] { "i64" }); + var exception = new WasmJsException(tag, new object?[] { 42L }); + + var caught = Assert.Throws(() => throw exception); + + Assert.That(caught, Is.SameAs(exception)); + Assert.That(caught!.GetArg(tag, 0), Is.EqualTo(42L)); + } + [Test] public async Task ExportedFunctionWrapperSupportsArguments() { diff --git a/src/AngleSharp.Wasm/Dom/WasmJsException.cs b/src/AngleSharp.Wasm/Dom/WasmJsException.cs new file mode 100644 index 0000000..93c6865 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsException.cs @@ -0,0 +1,101 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using System; +using System.Globalization; + +/// +/// JavaScript-visible WebAssembly exception. +/// +[DomName("Exception")] +public sealed class WasmJsException : Exception +{ + private readonly WasmJsTag _tag; + private readonly object?[] _payload; + + /// + /// Creates a WebAssembly exception with a typed payload. + /// + /// The exception tag. + /// The exception payload. + public WasmJsException(WasmJsTag tag, object?[] payload) + : base("wasm exception") + { + _tag = tag ?? throw new ArgumentNullException(nameof(tag)); + + if (payload is null) + { + throw new ArgumentNullException(nameof(payload)); + } + + var parameterTypes = tag.Parameters; + + if (payload.Length != parameterTypes.Length) + { + throw new ArgumentException( + $"The payload contains {payload.Length} value(s), but the tag expects {parameterTypes.Length}.", + nameof(payload)); + } + + _payload = new object?[payload.Length]; + + for (var i = 0; i < payload.Length; i++) + { + _payload[i] = ConvertValue(payload[i], parameterTypes[i], i); + } + } + + /// + /// Determines whether this exception was created with the specified tag. + /// + /// The tag to compare by identity. + /// True if the tag matches; otherwise, false. + [DomName("is")] + public bool Is(WasmJsTag tag) => ReferenceEquals(_tag, tag); + + /// + /// Gets a payload argument after verifying the tag identity. + /// + /// The exception tag. + /// The payload index. + /// The payload value. + [DomName("getArg")] + public object? GetArg(WasmJsTag tag, int index) + { + if (!Is(tag)) + { + throw new ArgumentException("The tag does not match this WebAssembly exception.", nameof(tag)); + } + + if ((uint)index >= (uint)_payload.Length) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + return _payload[index]; + } + + private static object? ConvertValue(object? value, string type, int index) + { + try + { + return type switch + { + "i32" => Convert.ToInt32(value, CultureInfo.InvariantCulture), + "i64" => Convert.ToInt64(value, CultureInfo.InvariantCulture), + "f32" => Convert.ToSingle(value, CultureInfo.InvariantCulture), + "f64" => Convert.ToDouble(value, CultureInfo.InvariantCulture), + "funcref" when value is null or WasmJsExportedFunction => value, + "externref" or "anyref" or "eqref" or "i31ref" or "structref" or "arrayref" or "exnref" => value, + _ => throw new NotSupportedException($"Unsupported WebAssembly exception payload type '{type}'."), + }; + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + throw new ArgumentException( + $"Payload value at index {index} is not compatible with WebAssembly type '{type}'.", + nameof(value), + exception); + } + } +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/Dom/WasmJsTag.cs b/src/AngleSharp.Wasm/Dom/WasmJsTag.cs index 6d70d2b..fc87701 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsTag.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsTag.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Wasm.Dom; using AngleSharp.Attributes; +using System; /// /// JavaScript-visible WebAssembly exception tag. @@ -10,17 +11,43 @@ public sealed class WasmJsTag { private readonly WasmJsTagType _type; - internal WasmJsTag(string[] parameters) + /// + /// Creates a WebAssembly exception tag. + /// + /// The ordered payload value types. + public WasmJsTag(string[] parameters) { + if (parameters is null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + for (var i = 0; i < parameters.Length; i++) + { + if (!IsSupportedParameter(parameters[i])) + { + throw new ArgumentException( + $"Unsupported WebAssembly tag parameter type '{parameters[i]}'.", + nameof(parameters)); + } + } + _type = new WasmJsTagType(parameters); } + internal string[] Parameters => _type.Parameters; + /// /// Gets the tag type descriptor. /// /// The tag type. [DomName("type")] public WasmJsTagType Type() => _type; + + private static bool IsSupportedParameter(string? parameter) => parameter is + "i32" or "i64" or "f32" or "f64" or "v128" or + "funcref" or "externref" or "anyref" or "eqref" or + "i31ref" or "structref" or "arrayref" or "exnref"; } /// From de40e71d538c703f7eb5a9192373289e40892ebf Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 15:40:59 +0200 Subject: [PATCH 08/11] Added validate method #11 --- CHANGELOG.md | 1 + README.md | 3 +- docs/general/01-Basics.md | 2 +- docs/general/02-Spec-Coverage.md | 3 +- docs/tutorials/01-API.md | 4 + docs/tutorials/03-Questions.md | 1 - .../WasmJsBridgeTests.cs | 83 +++++++++++++++++++ .../WasmtimeRuntimeTests.cs | 9 ++ src/AngleSharp.Wasm/Dom/WebAssembly.cs | 36 ++++++++ src/AngleSharp.Wasm/IWasmValidator.cs | 16 ++++ src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs | 9 +- 11 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 src/AngleSharp.Wasm/IWasmValidator.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8d810..64897dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Friday, August 21 2026. +- Added `WebAssembly.validate` method (#11) - Added `WebAssembly.Exception` object projection (#10) - Added `WebAssembly.Tag` object projection (#9) - Added `WebAssembly.Global` object projection (#8) diff --git a/README.md b/README.md index 227038e..c3b5429 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ The current package targets `net8.0` and `net10.0`. - `exports()` - `imports()` - `customSections(name)` +- Module validation via `validate(bytes)` - Instance export access - `exports` lookup by export name - export key enumeration @@ -108,7 +109,7 @@ 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. +- Streaming APIs are not yet implemented. - Compile options such as builtins / imported string constants are not yet implemented. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). - Host-created tags and exceptions are supported. Wasmtime 44 does not expose native Tag or Exception handles or enable exception modules through its .NET API, so runtime-thrown Wasm exceptions are not yet projected. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index aeebf5a..0958925 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -79,7 +79,7 @@ var config = Configuration.Default 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. +- Streaming APIs and compile options are not implemented. - `Instance.exports` function members are wrapper objects requiring `.invoke(...)`. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). - Host-created exceptions support tag identity and typed payload inspection. The default Wasmtime 44 backend does not expose native Tag or Exception handles, so runtime-thrown Wasm exceptions are not yet projected. diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 168b45c..250fd2d 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -18,7 +18,7 @@ Status values: | --- | --- | --- | | 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: `validate(bytes)` | Implemented | Exposed as synchronous `WebAssembly.validate(byte[])`, using native Wasmtime validation or compile-and-dispose fallback for custom runtimes. | | 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. | @@ -48,6 +48,7 @@ Status values: The current implementation is validated by runtime and bridge tests in the repository, including: - compile and instantiate flows +- module validation - export invocation - exported memory read, write, and growth - exported table access, mutation, and growth diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 1906888..8cad59a 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -38,6 +38,10 @@ When used with a scripting integration that discovers DOM attributes, the follow Compiles bytes and returns a `WasmJsModule`. +### `WebAssembly.validate(byte[] moduleBytes)` + +Returns whether the bytes form a valid WebAssembly module without instantiating it. + ### `WebAssembly.instantiate(WasmJsModule module)` Instantiates a compiled module and returns a `WasmJsInstance`. diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index cd313e6..819c2e7 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -66,7 +66,6 @@ Current numeric value kinds are supported: ## 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`). - Runtime-thrown Wasm exception interoperability is not yet available. diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index 4627177..f3835fc 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -175,6 +175,38 @@ public async Task BridgeCanCompileInstantiateAndInvokeViaInstanceExportsFunction Assert.That(exportKeys, Is.EquivalentTo(new[] { "answer" })); } + [Test] + public async Task BridgeValidateReturnsModuleValidity() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + var invalidModule = (byte[])AnswerModule.Clone(); + invalidModule[^1] = 0xFF; + + Assert.That(WebAssembly.Validate(context.Current!, AnswerModule), Is.True); + Assert.That(WebAssembly.Validate(context.Current!, invalidModule), Is.False); + Assert.That(WebAssembly.Validate(context.Current!, Array.Empty()), Is.False); + Assert.That(() => WebAssembly.Validate(context.Current!, null!), Throws.ArgumentNullException); + + var module = WebAssembly.Compile(context.Current!, AnswerModule); + + Assert.That(module, Is.Not.Null); + } + + [Test] + public async Task BridgeValidateFallsBackToCompileForCustomRuntime() + { + var runtime = new ValidationFallbackRuntime(); + var config = Configuration.Default.WithWasm(_ => new ValidationFallbackRuntimeFactory(runtime)); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + Assert.That(WebAssembly.Validate(context.Current!, new byte[] { 1 }), Is.True); + Assert.That(runtime.LastModuleDisposed, Is.True); + Assert.That(WebAssembly.Validate(context.Current!, Array.Empty()), Is.False); + } + [Test] public async Task ModuleImportsExposeSpecDescriptorFields() { @@ -569,4 +601,55 @@ public void Dispose() { } } + + private sealed class ValidationFallbackRuntimeFactory : IWasmRuntimeFactory + { + private readonly IWasmRuntime _runtime; + + public ValidationFallbackRuntimeFactory(IWasmRuntime runtime) + { + _runtime = runtime; + } + + public IWasmRuntime Create(IBrowsingContext context) => _runtime; + } + + private sealed class ValidationFallbackRuntime : IWasmRuntime + { + public bool LastModuleDisposed { get; private set; } + + public ValueTask CompileAsync( + ReadOnlyMemory moduleBytes, + System.Threading.CancellationToken cancellationToken = default) + { + if (moduleBytes.IsEmpty) + { + throw new FormatException("Invalid module."); + } + + return ValueTask.FromResult(new ValidationFallbackModule(this)); + } + + public ValueTask InstantiateAsync( + IWasmCompiledModule compiledModule, + IEnumerable? imports = null, + System.Threading.CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public void Dispose() + { + } + + private sealed class ValidationFallbackModule : IWasmCompiledModule + { + private readonly ValidationFallbackRuntime _runtime; + + public ValidationFallbackModule(ValidationFallbackRuntime runtime) + { + _runtime = runtime; + } + + public void Dispose() => _runtime.LastModuleDisposed = true; + } + } } diff --git a/src/AngleSharp.Wasm.Tests/WasmtimeRuntimeTests.cs b/src/AngleSharp.Wasm.Tests/WasmtimeRuntimeTests.cs index fefe890..c9e77b0 100644 --- a/src/AngleSharp.Wasm.Tests/WasmtimeRuntimeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmtimeRuntimeTests.cs @@ -55,6 +55,15 @@ public async Task CanCompileInstantiateAndInvokeExport() Assert.That(Convert.ToInt32(result), Is.EqualTo(42)); } + [Test] + public void CanValidateModuleWithoutCompiling() + { + using var runtime = new WasmtimeWasmRuntime(); + + Assert.That(runtime.Validate(MinimalModule), Is.True); + Assert.That(runtime.Validate(Array.Empty()), Is.False); + } + [Test] public async Task CanInvokeExportWithArguments() { diff --git a/src/AngleSharp.Wasm/Dom/WebAssembly.cs b/src/AngleSharp.Wasm/Dom/WebAssembly.cs index d0fec65..2fc967e 100644 --- a/src/AngleSharp.Wasm/Dom/WebAssembly.cs +++ b/src/AngleSharp.Wasm/Dom/WebAssembly.cs @@ -17,6 +17,42 @@ public static class WebAssembly { private static readonly ConditionalWeakTable runtimeCache = new (); + /// + /// Determines whether bytes form a valid WebAssembly module. + /// + /// The host. + /// The module bytes. + /// True if the module is valid; otherwise, false. + [DomName("validate")] + public static bool Validate(this IWindow window, byte[] moduleBytes) + { + if (moduleBytes is null) + { + throw new ArgumentNullException(nameof(moduleBytes)); + } + + var runtime = GetOrCreateRuntime(window); + + if (runtime is IWasmValidator validator) + { + return validator.Validate(moduleBytes); + } + + try + { + using var module = runtime.CompileAsync(moduleBytes).AsTask().GetAwaiter().GetResult(); + return true; + } + catch (Exception exception) when (exception is ObjectDisposedException or OperationCanceledException) + { + throw; + } + catch (Exception) + { + return false; + } + } + /// /// Compiles a module and returns a bridge module handle. /// diff --git a/src/AngleSharp.Wasm/IWasmValidator.cs b/src/AngleSharp.Wasm/IWasmValidator.cs new file mode 100644 index 0000000..e648a7d --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmValidator.cs @@ -0,0 +1,16 @@ +namespace AngleSharp.Wasm; + +using System; + +/// +/// Validates WebAssembly binary modules without compiling them. +/// +public interface IWasmValidator +{ + /// + /// Determines whether the supplied bytes form a valid WebAssembly module. + /// + /// The raw WebAssembly bytes. + /// True if the module is valid; otherwise, false. + bool Validate(ReadOnlyMemory moduleBytes); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs b/src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs index 2e55b88..51dd0da 100644 --- a/src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs +++ b/src/AngleSharp.Wasm/WasmtimeWasmRuntime.cs @@ -11,7 +11,7 @@ namespace AngleSharp.Wasm; /// /// Wasmtime-backed implementation. /// -public sealed class WasmtimeWasmRuntime : IWasmRuntime +public sealed class WasmtimeWasmRuntime : IWasmRuntime, IWasmValidator { private readonly Engine _engine; private bool _disposed; @@ -24,6 +24,13 @@ public WasmtimeWasmRuntime() _engine = new Engine(); } + /// + public bool Validate(ReadOnlyMemory moduleBytes) + { + ThrowIfDisposed(); + return Module.Validate(_engine, moduleBytes.Span) is null; + } + /// public ValueTask CompileAsync(ReadOnlyMemory moduleBytes, CancellationToken cancellationToken = default) { From f6fa97230688f467a807dbffa5d8765d973cfcd8 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 22:15:21 +0200 Subject: [PATCH 09/11] Added streaming APIs #12 --- CHANGELOG.md | 1 + README.md | 3 +- docs/general/01-Basics.md | 2 +- docs/general/02-Spec-Coverage.md | 3 +- docs/tutorials/01-API.md | 8 +++ docs/tutorials/03-Questions.md | 2 +- .../WasmJsBridgeTests.cs | 47 ++++++++++++++++ .../Dom/WasmJsInstantiationResult.cs | 30 ++++++++++ src/AngleSharp.Wasm/Dom/WebAssembly.cs | 56 +++++++++++++++++++ 9 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 src/AngleSharp.Wasm/Dom/WasmJsInstantiationResult.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 64897dd..4ee22a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Friday, August 21 2026. +- Added `WebAssembly.compileStreaming` and `WebAssembly.instantiateStreaming` methods (#12) - Added `WebAssembly.validate` method (#11) - Added `WebAssembly.Exception` object projection (#10) - Added `WebAssembly.Tag` object projection (#9) diff --git a/README.md b/README.md index c3b5429..face4d4 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ The current package targets `net8.0` and `net10.0`. - `imports()` - `customSections(name)` - Module validation via `validate(bytes)` +- Response-based compilation and instantiation via `compileStreaming(response)` and `instantiateStreaming(response)` - Instance export access - `exports` lookup by export name - export key enumeration @@ -109,7 +110,7 @@ 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. -- Streaming APIs are not yet implemented. +- Streaming APIs synchronously buffer AngleSharp response streams because module metadata requires the complete binary. - Compile options such as builtins / imported string constants are not yet implemented. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). - Host-created tags and exceptions are supported. Wasmtime 44 does not expose native Tag or Exception handles or enable exception modules through its .NET API, so runtime-thrown Wasm exceptions are not yet projected. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 0958925..0ba81b3 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -79,7 +79,7 @@ var config = Configuration.Default AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS API. - Synchronous bridge calls are used (`compile` / `instantiate` are not Promise-based APIs). -- Streaming APIs and compile options are not implemented. +- Streaming APIs synchronously buffer AngleSharp response streams; compile options are not implemented. - `Instance.exports` function members are wrapper objects requiring `.invoke(...)`. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). - Host-created exceptions support tag identity and typed payload inspection. The default Wasmtime 44 backend does not expose native Tag or Exception handles, so runtime-thrown Wasm exceptions are not yet projected. diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 250fd2d..69045cb 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -19,7 +19,7 @@ Status values: | 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)` | Implemented | Exposed as synchronous `WebAssembly.validate(byte[])`, using native Wasmtime validation or compile-and-dispose fallback for custom runtimes. | -| WebAssembly namespace: streaming APIs | Not yet | No `instantiateStreaming` / `compileStreaming`. | +| WebAssembly namespace: streaming APIs | Partial | `compileStreaming(IResponse)` and `instantiateStreaming(IResponse)` synchronously consume successful `application/wasm` AngleSharp responses. Response bodies are buffered for metadata extraction. | | 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. | @@ -49,6 +49,7 @@ The current implementation is validated by runtime and bridge tests in the repos - compile and instantiate flows - module validation +- response-based compilation and instantiation - export invocation - exported memory read, write, and growth - exported table access, mutation, and growth diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 8cad59a..b1c7ea3 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -42,6 +42,14 @@ Compiles bytes and returns a `WasmJsModule`. Returns whether the bytes form a valid WebAssembly module without instantiating it. +### `WebAssembly.compileStreaming(IResponse source)` + +Synchronously consumes a successful `application/wasm` response and returns a compiled `WasmJsModule`. + +### `WebAssembly.instantiateStreaming(IResponse source)` + +Synchronously consumes a successful `application/wasm` response and returns a `WasmJsInstantiationResult` containing `module` and `instance`. + ### `WebAssembly.instantiate(WasmJsModule module)` Instantiates a compiled module and returns a `WasmJsInstance`. diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index 819c2e7..d1d97b3 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -66,7 +66,7 @@ Current numeric value kinds are supported: ## What are known limitations today? - No Promise-based `compile` / `instantiate` bridge methods. -- No streaming APIs. +- Streaming APIs buffer response bodies and return synchronously rather than returning promises. - No compile options support (`builtins`, `importedStringConstants`). - Runtime-thrown Wasm exception interoperability is not yet available. diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index f3835fc..45595bc 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -1,10 +1,13 @@ namespace AngleSharp.Wasm.Tests; using AngleSharp.Wasm.Dom; +using AngleSharp.Io; using NUnit.Framework; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net; using System.Reflection; using System.Threading.Tasks; @@ -207,6 +210,41 @@ public async Task BridgeValidateFallsBackToCompileForCustomRuntime() Assert.That(WebAssembly.Validate(context.Current!, Array.Empty()), Is.False); } + [Test] + public async Task BridgeCanCompileAndInstantiateStreamingResponse() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + using var compileResponse = CreateWasmResponse(AnswerModule, contentType: "Application/Wasm; charset=binary"); + + var module = WebAssembly.CompileStreaming(context.Current!, compileResponse); + + Assert.That(module.Exports().Single().Name, Is.EqualTo("answer")); + + using var instantiateResponse = CreateWasmResponse(AnswerModule); + var result = WebAssembly.InstantiateStreaming(context.Current!, instantiateResponse); + var function = result.Instance.Exports["answer"] as WasmJsExportedFunction; + + Assert.That(result.Module.Exports().Single().Name, Is.EqualTo("answer")); + Assert.That(function, Is.Not.Null); + Assert.That(function!.Invoke(), Is.EqualTo(42)); + } + + [Test] + public async Task StreamingMethodsValidateResponseStatusAndContentType() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + using var badStatus = CreateWasmResponse(AnswerModule, HttpStatusCode.NotFound); + using var badContentType = CreateWasmResponse(AnswerModule, contentType: "application/octet-stream"); + + Assert.That(() => WebAssembly.CompileStreaming(context.Current!, null!), Throws.ArgumentNullException); + Assert.That(() => WebAssembly.CompileStreaming(context.Current!, badStatus), Throws.InvalidOperationException); + Assert.That(() => WebAssembly.CompileStreaming(context.Current!, badContentType), Throws.InvalidOperationException); + } + [Test] public async Task ModuleImportsExposeSpecDescriptorFields() { @@ -574,6 +612,15 @@ private static MetadataOnlyInstanceScope CreateMetadataOnlyInstance(byte[] modul return new MetadataOnlyInstanceScope(runtimeInstance, instance); } + private static IResponse CreateWasmResponse( + byte[] moduleBytes, + HttpStatusCode statusCode = HttpStatusCode.OK, + string contentType = "application/wasm") => + VirtualResponse.Create(response => response + .Status(statusCode) + .Header("Content-Type", contentType) + .Content(new MemoryStream(moduleBytes, writable: false), shouldDispose: true)); + private sealed class MetadataOnlyInstanceScope : IDisposable { private readonly MetadataOnlyInstance _runtimeInstance; diff --git a/src/AngleSharp.Wasm/Dom/WasmJsInstantiationResult.cs b/src/AngleSharp.Wasm/Dom/WasmJsInstantiationResult.cs new file mode 100644 index 0000000..8ef0d4b --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsInstantiationResult.cs @@ -0,0 +1,30 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; + +/// +/// Contains a compiled module and its instantiated instance. +/// +[DomName("WebAssemblyInstantiatedSource")] +public sealed class WasmJsInstantiationResult +{ + internal WasmJsInstantiationResult(WasmJsModule module, WasmJsInstance instance) + { + Module = module; + Instance = instance; + } + + /// + /// Gets the compiled module. + /// + [DomName("module")] + [DomAccessor(Accessors.Getter)] + public WasmJsModule Module { get; } + + /// + /// Gets the instantiated module instance. + /// + [DomName("instance")] + [DomAccessor(Accessors.Getter)] + public WasmJsInstance Instance { get; } +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/Dom/WebAssembly.cs b/src/AngleSharp.Wasm/Dom/WebAssembly.cs index 2fc967e..439c46c 100644 --- a/src/AngleSharp.Wasm/Dom/WebAssembly.cs +++ b/src/AngleSharp.Wasm/Dom/WebAssembly.cs @@ -2,9 +2,12 @@ namespace AngleSharp.Wasm.Dom; using AngleSharp.Attributes; using AngleSharp.Dom; +using AngleSharp.Io; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net; using System.Runtime.CompilerServices; /// @@ -53,6 +56,30 @@ public static bool Validate(this IWindow window, byte[] moduleBytes) } } + /// + /// Compiles a WebAssembly module from an HTTP response body. + /// + /// The host. + /// The response containing the module. + /// The compiled module. + [DomName("compileStreaming")] + public static WasmJsModule CompileStreaming(this IWindow window, IResponse source) => + Compile(window, ReadStreamingSource(source)); + + /// + /// Compiles and instantiates a WebAssembly module from an HTTP response body. + /// + /// The host. + /// The response containing the module. + /// The compiled module and instance. + [DomName("instantiateStreaming")] + public static WasmJsInstantiationResult InstantiateStreaming(this IWindow window, IResponse source) + { + var module = CompileStreaming(window, source); + var instance = Instantiate(window, module); + return new WasmJsInstantiationResult(module, instance); + } + /// /// Compiles a module and returns a bridge module handle. /// @@ -104,5 +131,34 @@ private static IReadOnlyList GetImports(IWindow window) => .GetServices() .SelectMany(provider => provider.GetImports(window.Document.Context)) .ToArray(); + + private static byte[] ReadStreamingSource(IResponse source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source.StatusCode < HttpStatusCode.OK || source.StatusCode >= HttpStatusCode.MultipleChoices) + { + throw new InvalidOperationException($"The WebAssembly response has unsuccessful status code {(int)source.StatusCode}."); + } + + var contentType = source.Headers + .FirstOrDefault(header => String.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) + .Value; + + if (contentType is null || !String.Equals( + contentType.Split(';', 2)[0].Trim(), + "application/wasm", + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The WebAssembly response must use the 'application/wasm' content type."); + } + + using var buffer = new MemoryStream(); + source.Content.CopyTo(buffer); + return buffer.ToArray(); + } } From 69323d695267ca88fcaec8281578359179b1c5f4 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 22:33:36 +0200 Subject: [PATCH 10/11] Added compile options #13 --- CHANGELOG.md | 1 + README.md | 3 +- docs/general/01-Basics.md | 2 +- docs/general/02-Spec-Coverage.md | 5 +- docs/tutorials/01-API.md | 15 ++++ docs/tutorials/03-Questions.md | 2 +- .../WasmJsBridgeTests.cs | 80 ++++++++++++++++- src/AngleSharp.Wasm/Dom/WebAssembly.cs | 86 ++++++++++++++++++- .../IWasmCompileOptionsCompiler.cs | 23 +++++ src/AngleSharp.Wasm/WasmCompileOptions.cs | 55 ++++++++++++ 10 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 src/AngleSharp.Wasm/IWasmCompileOptionsCompiler.cs create mode 100644 src/AngleSharp.Wasm/WasmCompileOptions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee22a6..6fc2275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Friday, August 21 2026. +- Updated with compile options support (#13) - Added `WebAssembly.compileStreaming` and `WebAssembly.instantiateStreaming` methods (#12) - Added `WebAssembly.validate` method (#11) - Added `WebAssembly.Exception` object projection (#10) diff --git a/README.md b/README.md index face4d4..ad947ca 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The current package targets `net8.0` and `net10.0`. - `customSections(name)` - Module validation via `validate(bytes)` - Response-based compilation and instantiation via `compileStreaming(response)` and `instantiateStreaming(response)` +- Compile option routing for `builtins` and `importedStringConstants` - Instance export access - `exports` lookup by export name - export key enumeration @@ -111,7 +112,7 @@ 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. - Streaming APIs synchronously buffer AngleSharp response streams because module metadata requires the complete binary. -- Compile options such as builtins / imported string constants are not yet implemented. +- Compile options are validated and forwarded to capable runtimes. Wasmtime 44 does not expose JavaScript string builtins or imported string constants, so requesting either option with the default backend throws `NotSupportedException`. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). - Host-created tags and exceptions are supported. Wasmtime 44 does not expose native Tag or Exception handles or enable exception modules through its .NET API, so runtime-thrown Wasm exceptions are not yet projected. diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index 0ba81b3..e3c6101 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -79,7 +79,7 @@ var config = Configuration.Default AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS API. - Synchronous bridge calls are used (`compile` / `instantiate` are not Promise-based APIs). -- Streaming APIs synchronously buffer AngleSharp response streams; compile options are not implemented. +- Streaming APIs synchronously buffer AngleSharp response streams. Compile options are forwarded to capable runtimes, but Wasmtime 44 does not support JavaScript string builtins or imported string constants. - `Instance.exports` function members are wrapper objects requiring `.invoke(...)`. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). - Host-created exceptions support tag identity and typed payload inspection. The default Wasmtime 44 backend does not expose native Tag or Exception handles, so runtime-thrown Wasm exceptions are not yet projected. diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 69045cb..3b4fdee 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -20,7 +20,7 @@ Status values: | WebAssembly namespace: `instantiate(...)` | Partial | Exposed as synchronous bridge method `WebAssembly.instantiate(WasmJsModule)`. | | WebAssembly namespace: `validate(bytes)` | Implemented | Exposed as synchronous `WebAssembly.validate(byte[])`, using native Wasmtime validation or compile-and-dispose fallback for custom runtimes. | | WebAssembly namespace: streaming APIs | Partial | `compileStreaming(IResponse)` and `instantiateStreaming(IResponse)` synchronously consume successful `application/wasm` AngleSharp responses. Response bodies are buffered for metadata extraction. | -| WebAssembly namespace: compile options | Not yet | No `builtins` / `importedStringConstants` options support yet. | +| WebAssembly namespace: compile options | Partial | `WasmCompileOptions` validates and routes `builtins` and `importedStringConstants` through compile, byte instantiate, and streaming entry points. Wasmtime 44 cannot execute these options. | | `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[][]`. | @@ -33,7 +33,7 @@ Status values: | Tag object API (`Tag`) | Partial | Host-created and exported tags expose `type().parameters`, including re-exported imported tags. Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API. | | Exception object API (`Exception`) | Partial | Host-created exceptions validate typed payloads and expose `is(tag)` and `getArg(tag, index)`. Runtime-thrown Wasm exceptions cannot be projected through Wasmtime 44. | | 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. | +| JS String builtins set | Partial | The `js-string` builtin set is recognized and forwarded to capable custom runtimes; Wasmtime 44 has no corresponding API. | ## Runtime and Type Support @@ -50,6 +50,7 @@ The current implementation is validated by runtime and bridge tests in the repos - compile and instantiate flows - module validation - response-based compilation and instantiation +- compile option validation and runtime forwarding - export invocation - exported memory read, write, and growth - exported table access, mutation, and growth diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index b1c7ea3..77e4d0c 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -38,6 +38,8 @@ When used with a scripting integration that discovers DOM attributes, the follow Compiles bytes and returns a `WasmJsModule`. +An overload accepts `WasmCompileOptions`. + ### `WebAssembly.validate(byte[] moduleBytes)` Returns whether the bytes form a valid WebAssembly module without instantiating it. @@ -46,14 +48,27 @@ Returns whether the bytes form a valid WebAssembly module without instantiating Synchronously consumes a successful `application/wasm` response and returns a compiled `WasmJsModule`. +An overload accepts `WasmCompileOptions`. + ### `WebAssembly.instantiateStreaming(IResponse source)` Synchronously consumes a successful `application/wasm` response and returns a `WasmJsInstantiationResult` containing `module` and `instance`. +An overload accepts `WasmCompileOptions`. + ### `WebAssembly.instantiate(WasmJsModule module)` Instantiates a compiled module and returns a `WasmJsInstance`. +Byte-array overloads compile and instantiate in one operation and return `WasmJsInstantiationResult`; compile options can be supplied. + +## `WasmCompileOptions` + +- `builtins`: requested builtin sets; currently the standard `js-string` value is recognized. +- `importedStringConstants`: module namespace for imported string constants. + +Options are forwarded to runtimes implementing `IWasmCompileOptionsCompiler`. Wasmtime 44 does not expose either feature and rejects non-empty options. + ## `WasmJsModule` ### `exports()` diff --git a/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index d1d97b3..7e63161 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -67,7 +67,7 @@ Current numeric value kinds are supported: - No Promise-based `compile` / `instantiate` bridge methods. - Streaming APIs buffer response bodies and return synchronously rather than returning promises. -- No compile options support (`builtins`, `importedStringConstants`). +- Wasmtime 44 cannot execute the `builtins` or `importedStringConstants` compile options; capable custom runtimes can implement them through `IWasmCompileOptionsCompiler`. - Runtime-thrown Wasm exception interoperability is not yet available. These limits are expected at this stage and can be expanded in future versions. diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index 45595bc..48b74c2 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -245,6 +245,55 @@ public async Task StreamingMethodsValidateResponseStatusAndContentType() Assert.That(() => WebAssembly.CompileStreaming(context.Current!, badContentType), Throws.InvalidOperationException); } + [Test] + public async Task CompileOptionsAreValidatedAcrossCompileEntryPoints() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + var emptyOptions = new WasmCompileOptions(); + var builtinOptions = new WasmCompileOptions { Builtins = new[] { "js-string" } }; + var stringOptions = new WasmCompileOptions { ImportedStringConstants = "strings" }; + var invalidOptions = new WasmCompileOptions { Builtins = new[] { "invalid" } }; + + Assert.That(WebAssembly.Compile(context.Current!, AnswerModule, emptyOptions), Is.Not.Null); + Assert.That(() => WebAssembly.Compile(context.Current!, AnswerModule, builtinOptions), Throws.TypeOf()); + Assert.That(() => WebAssembly.Compile(context.Current!, AnswerModule, stringOptions), Throws.TypeOf()); + Assert.That(() => WebAssembly.Compile(context.Current!, AnswerModule, invalidOptions), Throws.ArgumentException); + + using var response = CreateWasmResponse(AnswerModule); + Assert.That(() => WebAssembly.CompileStreaming(context.Current!, response, builtinOptions), Throws.TypeOf()); + } + + [Test] + public async Task CompileOptionsAreForwardedToCapableRuntime() + { + using var runtime = new CompileOptionsRuntime(); + var config = Configuration.Default.WithWasm(_ => new ValidationFallbackRuntimeFactory(runtime)); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + var builtins = new[] { "js-string" }; + var options = new WasmCompileOptions + { + Builtins = builtins, + ImportedStringConstants = "strings", + }; + + var result = WebAssembly.Instantiate(context.Current!, AnswerModule, options); + builtins[0] = "changed"; + + Assert.That(runtime.LastOptions, Is.Not.Null); + Assert.That(runtime.LastOptions!.Builtins, Is.EqualTo(new[] { "js-string" })); + Assert.That(runtime.LastOptions.ImportedStringConstants, Is.EqualTo("strings")); + Assert.That(result.Instance.Invoke("answer"), Is.EqualTo(42)); + + builtins[0] = "js-string"; + using var response = CreateWasmResponse(AnswerModule); + var streamingResult = WebAssembly.InstantiateStreaming(context.Current!, response, options); + + Assert.That(streamingResult.Instance.Invoke("answer"), Is.EqualTo(42)); + } + [Test] public async Task ModuleImportsExposeSpecDescriptorFields() { @@ -527,7 +576,7 @@ public async Task BridgeInstantiateThrowsForNullModule() using var context = BrowsingContext.New(config); using var document = await context.OpenNewAsync(); - Assert.That(() => WebAssembly.Instantiate(context.Current!, null!), Throws.ArgumentNullException); + Assert.That(() => WebAssembly.Instantiate(context.Current!, (WasmJsModule)null!), Throws.ArgumentNullException); } [Test] @@ -699,4 +748,33 @@ public ValidationFallbackModule(ValidationFallbackRuntime runtime) public void Dispose() => _runtime.LastModuleDisposed = true; } } + + private sealed class CompileOptionsRuntime : IWasmRuntime, IWasmCompileOptionsCompiler + { + private readonly WasmtimeWasmRuntime _runtime = new (); + + public WasmCompileOptions? LastOptions { get; private set; } + + public ValueTask CompileAsync( + ReadOnlyMemory moduleBytes, + System.Threading.CancellationToken cancellationToken = default) => + _runtime.CompileAsync(moduleBytes, cancellationToken); + + public ValueTask CompileAsync( + ReadOnlyMemory moduleBytes, + WasmCompileOptions options, + System.Threading.CancellationToken cancellationToken = default) + { + LastOptions = options; + return _runtime.CompileAsync(moduleBytes, cancellationToken); + } + + public ValueTask InstantiateAsync( + IWasmCompiledModule compiledModule, + IEnumerable? imports = null, + System.Threading.CancellationToken cancellationToken = default) => + _runtime.InstantiateAsync(compiledModule, imports, cancellationToken); + + public void Dispose() => _runtime.Dispose(); + } } diff --git a/src/AngleSharp.Wasm/Dom/WebAssembly.cs b/src/AngleSharp.Wasm/Dom/WebAssembly.cs index 439c46c..3626773 100644 --- a/src/AngleSharp.Wasm/Dom/WebAssembly.cs +++ b/src/AngleSharp.Wasm/Dom/WebAssembly.cs @@ -64,7 +64,18 @@ public static bool Validate(this IWindow window, byte[] moduleBytes) /// The compiled module. [DomName("compileStreaming")] public static WasmJsModule CompileStreaming(this IWindow window, IResponse source) => - Compile(window, ReadStreamingSource(source)); + CompileStreaming(window, source, null); + + /// + /// Compiles a WebAssembly module from an HTTP response body with compile options. + /// + /// The host. + /// The response containing the module. + /// The compile options. + /// The compiled module. + [DomName("compileStreaming")] + public static WasmJsModule CompileStreaming(this IWindow window, IResponse source, WasmCompileOptions? options) => + Compile(window, ReadStreamingSource(source), options); /// /// Compiles and instantiates a WebAssembly module from an HTTP response body. @@ -74,8 +85,50 @@ public static WasmJsModule CompileStreaming(this IWindow window, IResponse sourc /// The compiled module and instance. [DomName("instantiateStreaming")] public static WasmJsInstantiationResult InstantiateStreaming(this IWindow window, IResponse source) + => InstantiateStreaming(window, source, null); + + /// + /// Compiles and instantiates a WebAssembly module from an HTTP response body with compile options. + /// + /// The host. + /// The response containing the module. + /// The compile options. + /// The compiled module and instance. + [DomName("instantiateStreaming")] + public static WasmJsInstantiationResult InstantiateStreaming( + this IWindow window, + IResponse source, + WasmCompileOptions? options) + { + var module = CompileStreaming(window, source, options); + var instance = Instantiate(window, module); + return new WasmJsInstantiationResult(module, instance); + } + + /// + /// Compiles and instantiates module bytes. + /// + /// The host. + /// The module bytes. + /// The compiled module and instance. + [DomName("instantiate")] + public static WasmJsInstantiationResult Instantiate(this IWindow window, byte[] moduleBytes) => + Instantiate(window, moduleBytes, null); + + /// + /// Compiles and instantiates module bytes with compile options. + /// + /// The host. + /// The module bytes. + /// The compile options. + /// The compiled module and instance. + [DomName("instantiate")] + public static WasmJsInstantiationResult Instantiate( + this IWindow window, + byte[] moduleBytes, + WasmCompileOptions? options) { - var module = CompileStreaming(window, source); + var module = Compile(window, moduleBytes, options); var instance = Instantiate(window, module); return new WasmJsInstantiationResult(module, instance); } @@ -88,6 +141,17 @@ public static WasmJsInstantiationResult InstantiateStreaming(this IWindow window /// The module handle. [DomName("compile")] public static WasmJsModule Compile(this IWindow window, byte[] moduleBytes) + => Compile(window, moduleBytes, null); + + /// + /// Compiles a module with compile options and returns a bridge module handle. + /// + /// The host. + /// The module bytes. + /// The compile options. + /// The module handle. + [DomName("compile")] + public static WasmJsModule Compile(this IWindow window, byte[] moduleBytes, WasmCompileOptions? options) { if (moduleBytes is null) { @@ -95,7 +159,23 @@ public static WasmJsModule Compile(this IWindow window, byte[] moduleBytes) } var runtime = GetOrCreateRuntime(window); - var module = runtime.CompileAsync(moduleBytes).AsTask().GetAwaiter().GetResult(); + var compileOptions = options?.Snapshot(); + IWasmCompiledModule module; + + if (compileOptions is not null && runtime is IWasmCompileOptionsCompiler compiler) + { + module = compiler.CompileAsync(moduleBytes, compileOptions).AsTask().GetAwaiter().GetResult(); + } + else + { + if (compileOptions?.HasFeatures == true) + { + throw new NotSupportedException("The configured WebAssembly runtime does not support compile options."); + } + + module = runtime.CompileAsync(moduleBytes).AsTask().GetAwaiter().GetResult(); + } + return new WasmJsModule(runtime, module, moduleBytes); } diff --git a/src/AngleSharp.Wasm/IWasmCompileOptionsCompiler.cs b/src/AngleSharp.Wasm/IWasmCompileOptionsCompiler.cs new file mode 100644 index 0000000..5b8e540 --- /dev/null +++ b/src/AngleSharp.Wasm/IWasmCompileOptionsCompiler.cs @@ -0,0 +1,23 @@ +namespace AngleSharp.Wasm; + +using System; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Compiles WebAssembly modules with compile options. +/// +public interface IWasmCompileOptionsCompiler +{ + /// + /// Compiles a WebAssembly binary module with the supplied options. + /// + /// The raw WebAssembly bytes. + /// The compile options. + /// The cancellation token. + /// The compiled module. + ValueTask CompileAsync( + ReadOnlyMemory moduleBytes, + WasmCompileOptions options, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/WasmCompileOptions.cs b/src/AngleSharp.Wasm/WasmCompileOptions.cs new file mode 100644 index 0000000..d31896f --- /dev/null +++ b/src/AngleSharp.Wasm/WasmCompileOptions.cs @@ -0,0 +1,55 @@ +namespace AngleSharp.Wasm; + +using AngleSharp.Attributes; +using System; +using System.Collections.Generic; + +/// +/// Configures optional WebAssembly compilation features. +/// +public sealed class WasmCompileOptions +{ + /// + /// Gets or sets the requested JavaScript builtin sets. + /// + [DomName("builtins")] + [DomAccessor(Accessors.Getter | Accessors.Setter)] + public IReadOnlyList Builtins { get; set; } = Array.Empty(); + + /// + /// Gets or sets the module namespace for imported string constants. + /// + [DomName("importedStringConstants")] + [DomAccessor(Accessors.Getter | Accessors.Setter)] + public string? ImportedStringConstants { get; set; } + + internal WasmCompileOptions Snapshot() + { + if (Builtins is null) + { + throw new ArgumentNullException(nameof(Builtins)); + } + + var builtins = new string[Builtins.Count]; + + for (var i = 0; i < builtins.Length; i++) + { + var builtin = Builtins[i]; + + if (!String.Equals(builtin, "js-string", StringComparison.Ordinal)) + { + throw new ArgumentException($"Unsupported WebAssembly builtin set '{builtin}'.", nameof(Builtins)); + } + + builtins[i] = builtin; + } + + return new WasmCompileOptions + { + Builtins = builtins, + ImportedStringConstants = ImportedStringConstants, + }; + } + + internal bool HasFeatures => Builtins.Count > 0 || ImportedStringConstants is not null; +} \ No newline at end of file From 2b8dfd600996a63b3c5563207f5f91351a8781f0 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 17 Aug 2026 22:46:35 +0200 Subject: [PATCH 11/11] Added error classes #14 --- CHANGELOG.md | 1 + README.md | 1 + docs/general/01-Basics.md | 1 + docs/general/02-Spec-Coverage.md | 3 +- docs/tutorials/01-API.md | 4 + .../WasmJsBridgeTests.cs | 52 +++++++++ src/AngleSharp.Wasm/Dom/WasmJsErrors.cs | 109 ++++++++++++++++++ .../Dom/WasmJsExportedFunction.cs | 14 ++- src/AngleSharp.Wasm/Dom/WasmJsInstance.cs | 14 ++- src/AngleSharp.Wasm/Dom/WebAssembly.cs | 48 +++++--- 10 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 src/AngleSharp.Wasm/Dom/WasmJsErrors.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc2275..ffd4982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on Friday, August 21 2026. - Updated with compile options support (#13) +- Added `WebAssembly.CompileError`, `WebAssembly.LinkError`, and `WebAssembly.RuntimeError` projections (#14) - Added `WebAssembly.compileStreaming` and `WebAssembly.instantiateStreaming` methods (#12) - Added `WebAssembly.validate` method (#11) - Added `WebAssembly.Exception` object projection (#10) diff --git a/README.md b/README.md index ad947ca..9008090 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ The current package targets `net8.0` and `net10.0`. - global `value` and `valueOf` - tag `type().parameters` - exception `is(tag)` and `getArg(tag, index)` + - `CompileError`, `LinkError`, and `RuntimeError` - Multi-target support for `net8.0` and `net10.0` ## Current Scope and Limitations diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index e3c6101..ee7b144 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -81,6 +81,7 @@ AngleSharp.Wasm currently implements a pragmatic subset of the WebAssembly JS AP - Synchronous bridge calls are used (`compile` / `instantiate` are not Promise-based APIs). - Streaming APIs synchronously buffer AngleSharp response streams. Compile options are forwarded to capable runtimes, but Wasmtime 44 does not support JavaScript string builtins or imported string constants. - `Instance.exports` function members are wrapper objects requiring `.invoke(...)`. +- Compile, link, and execution failures are projected as `CompileError`, `LinkError`, and `RuntimeError` objects. - Exported memories, tables, globals, and tags have object projections; other non-function exports remain descriptors (`name`, `kind`). - Host-created exceptions support tag identity and typed payload inspection. The default Wasmtime 44 backend does not expose native Tag or Exception handles, so runtime-thrown Wasm exceptions are not yet projected. - `customSections(...)` returns payload bytes (`byte[]`) mapped from custom sections. diff --git a/docs/general/02-Spec-Coverage.md b/docs/general/02-Spec-Coverage.md index 3b4fdee..77e2af3 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -32,7 +32,7 @@ Status values: | Global object API (`Global`) | Partial | Exported numeric and reference globals expose a mutable or immutable `value` accessor and `valueOf()`. Global construction and imports are not yet exposed. | | Tag object API (`Tag`) | Partial | Host-created and exported tags expose `type().parameters`, including re-exported imported tags. Wasmtime 44 does not expose native Tag handles or enable exception modules through its .NET API. | | Exception object API (`Exception`) | Partial | Host-created exceptions validate typed payloads and expose `is(tag)` and `getArg(tag, index)`. Runtime-thrown Wasm exceptions cannot be projected through Wasmtime 44. | -| Error constructors (`CompileError`, `LinkError`, `RuntimeError`) | Not yet | No dedicated namespace error constructor projection yet. | +| Error constructors (`CompileError`, `LinkError`, `RuntimeError`) | Implemented | Public DOM-named constructors are available, and compile, instantiate/link, and execution failures are translated at bridge boundaries. | | JS String builtins set | Partial | The `js-string` builtin set is recognized and forwarded to capable custom runtimes; Wasmtime 44 has no corresponding API. | ## Runtime and Type Support @@ -57,6 +57,7 @@ The current implementation is validated by runtime and bridge tests in the repos - exported global access and mutation - exported and re-exported tag type metadata - host-created exception payload validation and inspection +- compile, link, and runtime error translation - import descriptor extraction - export descriptor extraction - custom section lookup behavior diff --git a/docs/tutorials/01-API.md b/docs/tutorials/01-API.md index 77e4d0c..84ff770 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -144,6 +144,10 @@ Can be constructed with an ordered array of WebAssembly value type names. Provid Construct with a `WasmJsTag` and matching payload array. Provides `is(tag)` and `getArg(tag, index)` and can be thrown and caught as a .NET exception. Runtime-thrown Wasm exceptions are not available through the default Wasmtime 44 backend. +## Error Constructors + +`WasmJsCompileError`, `WasmJsLinkError`, and `WasmJsRuntimeError` expose the standard `CompileError`, `LinkError`, and `RuntimeError` DOM names. Each can be constructed with an optional message. Bridge compilation, linking, and execution failures are translated automatically. + ## `WasmJsExportValue` Descriptor type for non-function exports. diff --git a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs index 48b74c2..e82b93e 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -139,6 +139,17 @@ public sealed class WasmJsBridgeTests 0x07, 0x07, 0x01, 0x03, 0x74, 0x61, 0x67, 0x04, 0x00, }; + // (module (func (export "fail") unreachable)) + private static readonly byte[] TrapModule = + { + 0x00, 0x61, 0x73, 0x6D, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x08, 0x01, 0x04, 0x66, 0x61, 0x69, 0x6C, 0x00, 0x00, + 0x0A, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0B, + }; + // AnswerModule + two custom sections named "meta" private static readonly byte[] AnswerModuleWithCustomSections = { @@ -178,6 +189,47 @@ public async Task BridgeCanCompileInstantiateAndInvokeViaInstanceExportsFunction Assert.That(exportKeys, Is.EquivalentTo(new[] { "answer" })); } + [Test] + public async Task BridgeTranslatesCompileLinkAndRuntimeFailures() + { + var config = Configuration.Default.WithWasm(); + using var context = BrowsingContext.New(config); + using var document = await context.OpenNewAsync(); + + var compileError = Assert.Throws(() => + WebAssembly.Compile(context.Current!, new byte[] { 0, 1, 2, 3 })); + + Assert.That(compileError!.Name, Is.EqualTo("CompileError")); + Assert.That(compileError.InnerException, Is.Not.Null); + + var importModule = WebAssembly.Compile(context.Current!, HostImportModule); + var linkError = Assert.Throws(() => + WebAssembly.Instantiate(context.Current!, importModule)); + + Assert.That(linkError!.Name, Is.EqualTo("LinkError")); + Assert.That(linkError.InnerException, Is.Not.Null); + + var trapModule = WebAssembly.Compile(context.Current!, TrapModule); + var trapInstance = WebAssembly.Instantiate(context.Current!, trapModule); + var runtimeError = Assert.Throws(() => trapInstance.Invoke("fail")); + var fail = (WasmJsExportedFunction)trapInstance.Exports["fail"]!; + + Assert.That(runtimeError!.Name, Is.EqualTo("RuntimeError")); + Assert.That(runtimeError.InnerException, Is.Not.Null); + Assert.That(() => fail.Invoke(), Throws.TypeOf()); + } + + [Test] + public void ErrorConstructorsPreserveMessages() + { + Assert.That(new WasmJsCompileError().Message, Is.Empty); + Assert.That(new WasmJsLinkError().Message, Is.Empty); + Assert.That(new WasmJsRuntimeError().Message, Is.Empty); + Assert.That(new WasmJsCompileError("compile").Message, Is.EqualTo("compile")); + Assert.That(new WasmJsLinkError("link").Message, Is.EqualTo("link")); + Assert.That(new WasmJsRuntimeError("runtime").Message, Is.EqualTo("runtime")); + } + [Test] public async Task BridgeValidateReturnsModuleValidity() { diff --git a/src/AngleSharp.Wasm/Dom/WasmJsErrors.cs b/src/AngleSharp.Wasm/Dom/WasmJsErrors.cs new file mode 100644 index 0000000..ea0de89 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsErrors.cs @@ -0,0 +1,109 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using System; + +/// +/// Represents a WebAssembly compilation failure. +/// +[DomName("CompileError")] +public sealed class WasmJsCompileError : Exception +{ + /// + /// Gets the standard error name. + /// + [DomName("name")] + [DomAccessor(Accessors.Getter)] + public string Name => "CompileError"; + + /// + /// Creates an empty compilation error. + /// + public WasmJsCompileError() + : base(String.Empty) + { + } + + /// + /// Creates a compilation error with a message. + /// + public WasmJsCompileError(string? message) + : base(message) + { + } + + internal WasmJsCompileError(string? message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Represents a WebAssembly instantiation or linking failure. +/// +[DomName("LinkError")] +public sealed class WasmJsLinkError : Exception +{ + /// + /// Gets the standard error name. + /// + [DomName("name")] + [DomAccessor(Accessors.Getter)] + public string Name => "LinkError"; + + /// + /// Creates an empty linking error. + /// + public WasmJsLinkError() + : base(String.Empty) + { + } + + /// + /// Creates a linking error with a message. + /// + public WasmJsLinkError(string? message) + : base(message) + { + } + + internal WasmJsLinkError(string? message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Represents a WebAssembly execution failure. +/// +[DomName("RuntimeError")] +public sealed class WasmJsRuntimeError : Exception +{ + /// + /// Gets the standard error name. + /// + [DomName("name")] + [DomAccessor(Accessors.Getter)] + public string Name => "RuntimeError"; + + /// + /// Creates an empty runtime error. + /// + public WasmJsRuntimeError() + : base(String.Empty) + { + } + + /// + /// Creates a runtime error with a message. + /// + public WasmJsRuntimeError(string? message) + : base(message) + { + } + + internal WasmJsRuntimeError(string? message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs b/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs index d9879cf..4c9d4aa 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Wasm.Dom; using AngleSharp.Attributes; +using System; /// /// JavaScript-visible exported function wrapper. @@ -31,6 +32,15 @@ internal WasmJsExportedFunction(IWasmFunction function) /// The function arguments. /// The invocation result. [DomName("invoke")] - public object? Invoke(params object?[] arguments) => - _function is not null ? _function.Invoke(arguments) : _owner!.Invoke(_name!, arguments); + public object? Invoke(params object?[] arguments) + { + try + { + return _function is not null ? _function.Invoke(arguments) : _owner!.Invoke(_name!, arguments); + } + catch (Exception exception) when (WebAssembly.ShouldTranslateOperationFailure(exception)) + { + throw new WasmJsRuntimeError(exception.Message, exception); + } + } } diff --git a/src/AngleSharp.Wasm/Dom/WasmJsInstance.cs b/src/AngleSharp.Wasm/Dom/WasmJsInstance.cs index 44a76e5..e5e3938 100644 --- a/src/AngleSharp.Wasm/Dom/WasmJsInstance.cs +++ b/src/AngleSharp.Wasm/Dom/WasmJsInstance.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Wasm.Dom; using AngleSharp.Attributes; +using System; using System.Collections.Generic; /// @@ -33,7 +34,16 @@ internal WasmJsInstance(IWasmInstance instance, IReadOnlyListThe invocation arguments. /// The invocation result. [DomName("invoke")] - public object? Invoke(string exportName, params object?[] arguments) => - Instance.InvokeAsync(exportName, arguments).AsTask().GetAwaiter().GetResult(); + public object? Invoke(string exportName, params object?[] arguments) + { + try + { + return Instance.InvokeAsync(exportName, arguments).AsTask().GetAwaiter().GetResult(); + } + catch (Exception exception) when (WebAssembly.ShouldTranslateOperationFailure(exception)) + { + throw new WasmJsRuntimeError(exception.Message, exception); + } + } } diff --git a/src/AngleSharp.Wasm/Dom/WebAssembly.cs b/src/AngleSharp.Wasm/Dom/WebAssembly.cs index 3626773..32407ef 100644 --- a/src/AngleSharp.Wasm/Dom/WebAssembly.cs +++ b/src/AngleSharp.Wasm/Dom/WebAssembly.cs @@ -162,21 +162,28 @@ public static WasmJsModule Compile(this IWindow window, byte[] moduleBytes, Wasm var compileOptions = options?.Snapshot(); IWasmCompiledModule module; - if (compileOptions is not null && runtime is IWasmCompileOptionsCompiler compiler) - { - module = compiler.CompileAsync(moduleBytes, compileOptions).AsTask().GetAwaiter().GetResult(); - } - else + try { - if (compileOptions?.HasFeatures == true) + if (compileOptions is not null && runtime is IWasmCompileOptionsCompiler compiler) + { + module = compiler.CompileAsync(moduleBytes, compileOptions).AsTask().GetAwaiter().GetResult(); + } + else { - throw new NotSupportedException("The configured WebAssembly runtime does not support compile options."); + if (compileOptions?.HasFeatures == true) + { + throw new NotSupportedException("The configured WebAssembly runtime does not support compile options."); + } + + module = runtime.CompileAsync(moduleBytes).AsTask().GetAwaiter().GetResult(); } - module = runtime.CompileAsync(moduleBytes).AsTask().GetAwaiter().GetResult(); + return new WasmJsModule(runtime, module, moduleBytes); + } + catch (Exception exception) when (ShouldTranslateCompileFailure(exception)) + { + throw new WasmJsCompileError(exception.Message, exception); } - - return new WasmJsModule(runtime, module, moduleBytes); } /// @@ -193,9 +200,16 @@ public static WasmJsInstance Instantiate(this IWindow window, WasmJsModule modul 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); + try + { + var imports = GetImports(window); + var instance = module.Runtime.InstantiateAsync(module.Module, imports).AsTask().GetAwaiter().GetResult(); + return new WasmJsInstance(instance, module.ExportsMetadata); + } + catch (Exception exception) when (ShouldTranslateOperationFailure(exception)) + { + throw new WasmJsLinkError(exception.Message, exception); + } } private static IWasmRuntime GetOrCreateRuntime(IWindow window) => @@ -240,5 +254,13 @@ private static byte[] ReadStreamingSource(IResponse source) source.Content.CopyTo(buffer); return buffer.ToArray(); } + + internal static bool ShouldTranslateOperationFailure(Exception exception) => exception is not + (ArgumentException or NotSupportedException or ObjectDisposedException or OperationCanceledException or + WasmJsCompileError or WasmJsLinkError or WasmJsRuntimeError); + + private static bool ShouldTranslateCompileFailure(Exception exception) => exception is not + (NotSupportedException or ObjectDisposedException or OperationCanceledException or + WasmJsCompileError or WasmJsLinkError or WasmJsRuntimeError); }