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/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/CHANGELOG.md b/CHANGELOG.md index 79e0d4b..ffd4982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# 1.1.0 + +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) +- Added `WebAssembly.Tag` object projection (#9) +- Added `WebAssembly.Global` object projection (#8) +- Added `WebAssembly.Table` object projection (#7) +- Added `WebAssembly.Memory` object projection (#6) + # 1.0.0 Released on Friday, July 31 2026. 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 diff --git a/README.md b/README.md index 11982fd..9008090 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,22 @@ The current package targets `net8.0` and `net10.0`. - `exports()` - `imports()` - `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 - Export invocation helpers - `instance.Invoke(...)` - `WasmJsExportedFunction.Invoke(...)` +- 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)` + - `CompileError`, `LinkError`, and `RuntimeError` - Multi-target support for `net8.0` and `net10.0` ## Current Scope and Limitations @@ -102,9 +112,10 @@ AngleSharp.Wasm currently provides a practical subset of the WebAssembly JS API. - Bridge methods are synchronous from the caller perspective. - Promise-based namespace operations are not currently exposed. -- `validate(...)` and streaming APIs are not yet implemented. -- Compile options such as builtins / imported string constants are not yet implemented. -- Non-function exports are currently represented as descriptors (`name`, `kind`) rather than full `Memory` / `Table` / `Global` / `Tag` objects. +- Streaming APIs synchronously buffer AngleSharp response streams because module metadata requires the complete binary. +- 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. See [Spec Coverage Matrix](docs/general/02-Spec-Coverage.md) for a section-by-section status overview. 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 @@ - - - + diff --git a/docs/general/01-Basics.md b/docs/general/01-Basics.md index ced8a1e..ee7b144 100644 --- a/docs/general/01-Basics.md +++ b/docs/general/01-Basics.md @@ -79,9 +79,11 @@ 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 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(...)`. -- Non-function exports are currently represented as descriptors (`name`, `kind`), not full `Memory` / `Table` / `Global` / `Tag` objects. +- 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. - 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 a327c20..77e2af3 100644 --- a/docs/general/02-Spec-Coverage.md +++ b/docs/general/02-Spec-Coverage.md @@ -18,22 +18,22 @@ 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: streaming APIs | Not yet | No `instantiateStreaming` / `compileStreaming`. | -| WebAssembly namespace: compile options | Not yet | No `builtins` / `importedStringConstants` options support yet. | +| 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 | 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[][]`. | -| `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, 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`) | Not yet | No full JS API `Memory` object projection yet. | -| Table object API (`Table`) | Not yet | No full JS API `Table` object projection yet. | -| Global object API (`Global`) | Not yet | No full JS API `Global` object projection yet. | -| Tag object API (`Tag`) | Not yet | No full JS API `Tag` object projection yet. | -| Exception object API (`Exception`) | Not yet | No full JS API exception projection yet. | -| Error constructors (`CompileError`, `LinkError`, `RuntimeError`) | Not yet | No dedicated namespace error constructor projection yet. | -| JS String builtins set | Not yet | No compile-option builtin-set wiring yet. | +| 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 | 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`) | 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 @@ -41,14 +41,23 @@ 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 The current implementation is validated by runtime and bridge tests in the repository, including: - 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 +- 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 afaeb2d..84ff770 100644 --- a/docs/tutorials/01-API.md +++ b/docs/tutorials/01-API.md @@ -38,10 +38,37 @@ 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. + +### `WebAssembly.compileStreaming(IResponse source)` + +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()` @@ -80,7 +107,11 @@ 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 +- `WasmJsGlobal` for global exports +- `WasmJsTag` for tag exports +- `WasmJsExportValue` for other non-function exports - `null` if no export is found ### `keys()` @@ -93,6 +124,30 @@ 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(...)`. + +## `WasmJsGlobal` + +Provides a `value` accessor and `valueOf()`. Assigning to an immutable global throws `InvalidOperationException`. + +## `WasmJsTag` + +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. + +## 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/docs/tutorials/03-Questions.md b/docs/tutorials/03-Questions.md index 58b785a..7e63161 100644 --- a/docs/tutorials/03-Questions.md +++ b/docs/tutorials/03-Questions.md @@ -27,9 +27,11 @@ 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, 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 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? @@ -64,9 +66,8 @@ 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`). -- Non-function exports are descriptors instead of full JS API objects. +- Streaming APIs buffer response bodies and return synchronously rather than returning promises. +- 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.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..e82b93e 100644 --- a/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs +++ b/src/AngleSharp.Wasm.Tests/WasmJsBridgeTests.cs @@ -1,9 +1,14 @@ 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; [TestFixture] @@ -54,6 +59,97 @@ 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, + }; + + // (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, + }; + + // (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, + }; + + // (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 = { @@ -93,6 +189,163 @@ 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() + { + 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 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 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() { @@ -127,7 +380,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 +390,212 @@ 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 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 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 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 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() { @@ -180,7 +628,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] @@ -246,4 +694,139 @@ 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 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; + + 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() + { + } + } + + 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; + } + } + + 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.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/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/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/WasmJsExportedFunction.cs b/src/AngleSharp.Wasm/Dom/WasmJsExportedFunction.cs index fd7c200..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. @@ -8,8 +9,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 +19,28 @@ 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) + { + 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/WasmJsExports.cs b/src/AngleSharp.Wasm/Dom/WasmJsExports.cs index 462b81c..b16232c 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; @@ -14,12 +15,84 @@ public sealed class WasmJsExports internal WasmJsExports(WasmJsInstance owner, IReadOnlyList exportsMetadata) { + var tags = new Dictionary(); _entries = exportsMetadata .ToDictionary( m => m.Name, - m => m.Kind == "function" - ? (object)new WasmJsExportedFunction(owner, m.Name) - : new WasmJsExportValue(m.Name, m.Kind)); + m => CreateExport(owner, m, tags)); + } + + 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) + { + 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) + { + 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); + } + + 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); } /// 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/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/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/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/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/Dom/WasmJsTag.cs b/src/AngleSharp.Wasm/Dom/WasmJsTag.cs new file mode 100644 index 0000000..fc87701 --- /dev/null +++ b/src/AngleSharp.Wasm/Dom/WasmJsTag.cs @@ -0,0 +1,72 @@ +namespace AngleSharp.Wasm.Dom; + +using AngleSharp.Attributes; +using System; + +/// +/// JavaScript-visible WebAssembly exception tag. +/// +[DomName("Tag")] +public sealed class WasmJsTag +{ + private readonly WasmJsTagType _type; + + /// + /// 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"; +} + +/// +/// 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. /// diff --git a/src/AngleSharp.Wasm/Dom/WebAssembly.cs b/src/AngleSharp.Wasm/Dom/WebAssembly.cs index d0fec65..32407ef 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; /// @@ -17,6 +20,119 @@ 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 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) => + 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. + /// + /// The host. + /// The response containing the module. + /// 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 = Compile(window, moduleBytes, options); + var instance = Instantiate(window, module); + return new WasmJsInstantiationResult(module, instance); + } + /// /// Compiles a module and returns a bridge module handle. /// @@ -25,6 +141,17 @@ public static class WebAssembly /// 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) { @@ -32,8 +159,31 @@ public static WasmJsModule Compile(this IWindow window, byte[] moduleBytes) } var runtime = GetOrCreateRuntime(window); - var module = runtime.CompileAsync(moduleBytes).AsTask().GetAwaiter().GetResult(); - return new WasmJsModule(runtime, module, moduleBytes); + var compileOptions = options?.Snapshot(); + IWasmCompiledModule module; + + try + { + 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); + } + catch (Exception exception) when (ShouldTranslateCompileFailure(exception)) + { + throw new WasmJsCompileError(exception.Message, exception); + } } /// @@ -50,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) => @@ -68,5 +225,42 @@ 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(); + } + + 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); } 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/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/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/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/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/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/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 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/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 72f8524..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 +public sealed class WasmtimeInstance : IWasmInstance, IWasmFunctionProvider, IWasmGlobalProvider, IWasmMemoryProvider, IWasmTableProvider { private readonly Store _store; private readonly Instance _instance; @@ -21,6 +21,62 @@ 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 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) + { + 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 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/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/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 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) { 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