From 7a9033ac67d08a00792418affd3141a4adb03c9f Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Fri, 16 Jan 2026 23:03:53 +0100 Subject: [PATCH 1/6] feat: cloudflare workers support --- .github/workflows/ci.yaml | 9 + AspNetCore.sln | 30 ++ Directory.Build.props | 14 + nuget.config | 9 + sandbox/WasmApp/Program.cs | 60 ++++ sandbox/WasmApp/WasmApp.csproj | 12 + .../Sdk/Sdk.props | 105 ++++++ .../Sdk/Sdk.targets | 287 +++++++++++++++ .../Sdk/WasmExports.cs.template | 24 ++ .../Sdk/index.js | 224 ++++++++++++ .../Sdk/wrangler.toml | 9 + .../Zapto.AspNetCore.CloudFlare.SDK.csproj | 52 +++ src/Directory.Build.props | 12 +- src/Zapto.AspNetCore.Wasm/GlobalUsings.cs | 16 + .../HostBuilderWasmExtensions.cs | 37 ++ .../Internal/RequestContext.cs | 18 + .../Internal/ResponseContext.cs | 15 + .../Internal/WasmHttpConnectionFeature.cs | 15 + .../Internal/WasmHttpRequestFeature.cs | 17 + .../WasmHttpRequestIdentifierFeature.cs | 9 + .../Internal/WasmHttpResponseBodyFeature.cs | 41 +++ .../Internal/WasmHttpResponseFeature.cs | 28 ++ .../Internal/WasmJsonContext.cs | 10 + .../Internal/WasmRequestContext.cs | 118 +++++++ src/Zapto.AspNetCore.Wasm/WasmInterop.cs | 87 +++++ src/Zapto.AspNetCore.Wasm/WasmServer.cs | 147 ++++++++ .../WasmServerOptions.cs | 17 + .../WebHostBuilderWasmExtensions.cs | 68 ++++ .../Zapto.AspNetCore.Wasm.csproj | 23 ++ src/Zapto.AspNetCore.Wasm/index.js | 109 ++++++ tests/Zapto.AspNetCore.Wasm.Tests/Assembly.cs | 1 + .../WasmEndpointTests.cs | 72 ++++ .../WranglerFixture.cs | 330 ++++++++++++++++++ .../Zapto.AspNetCore.Wasm.Tests.csproj | 39 +++ .../xunit.runner.json | 4 + 35 files changed, 2058 insertions(+), 10 deletions(-) create mode 100644 Directory.Build.props create mode 100644 nuget.config create mode 100644 sandbox/WasmApp/Program.cs create mode 100644 sandbox/WasmApp/WasmApp.csproj create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj create mode 100644 src/Zapto.AspNetCore.Wasm/GlobalUsings.cs create mode 100644 src/Zapto.AspNetCore.Wasm/HostBuilderWasmExtensions.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/RequestContext.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/ResponseContext.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/WasmHttpConnectionFeature.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestFeature.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestIdentifierFeature.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseBodyFeature.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseFeature.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/WasmJsonContext.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Internal/WasmRequestContext.cs create mode 100644 src/Zapto.AspNetCore.Wasm/WasmInterop.cs create mode 100644 src/Zapto.AspNetCore.Wasm/WasmServer.cs create mode 100644 src/Zapto.AspNetCore.Wasm/WasmServerOptions.cs create mode 100644 src/Zapto.AspNetCore.Wasm/WebHostBuilderWasmExtensions.cs create mode 100644 src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj create mode 100644 src/Zapto.AspNetCore.Wasm/index.js create mode 100644 tests/Zapto.AspNetCore.Wasm.Tests/Assembly.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj create mode 100644 tests/Zapto.AspNetCore.Wasm.Tests/xunit.runner.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e1698c5..2bd8058 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,6 +14,7 @@ jobs: dotnet-version: | 8.0.x 9.0.x + 10.0.x - name: Install dependencies run: dotnet restore - name: Locate MSBuild with vswhere @@ -36,6 +37,14 @@ jobs: & $env:MSBUILD AspNetCore.sln /p:Configuration=Debug /p:Platform="Any CPU" - name: Test Zapto.AspNetCore.NetFx run: dotnet test --no-restore --no-build --verbosity normal tests/Zapto.AspNetCore.NetFx.Tests/Zapto.AspNetCore.NetFx.Tests.csproj + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install wrangler + run: npm install -g wrangler + - name: Test Zapto.AspNetCore.Wasm + run: dotnet test --verbosity normal tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj - name: Publish uses: GerardSmit/publish-nuget@v4.0.2 if: github.ref == 'refs/heads/main' diff --git a/AspNetCore.sln b/AspNetCore.sln index 9f10d9f..2a17dc2 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -14,6 +14,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.NetFx.Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Polyfill", "src\Zapto.AspNetCore.Polyfill\Zapto.AspNetCore.Polyfill.csproj", "{507A2467-52E9-4181-8414-426AC45E01B3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm", "src\Zapto.AspNetCore.Wasm\Zapto.AspNetCore.Wasm.csproj", "{A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SDK", "SDK", "{E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.CloudFlare.SDK", "sdk\Zapto.AspNetCore.CloudFlare.SDK\Zapto.AspNetCore.CloudFlare.SDK.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmApp", "sandbox\WasmApp\WasmApp.csproj", "{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.Tests", "tests\Zapto.AspNetCore.Wasm.Tests\Zapto.AspNetCore.Wasm.Tests.csproj", "{D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,6 +34,10 @@ Global {77FA9DA3-FB50-46EC-B9B8-905121594EA1} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} {305C7374-1A54-4409-B527-968D49B258DF} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} {507A2467-52E9-4181-8414-426AC45E01B3} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F} = {E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B} + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -42,5 +56,21 @@ Global {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {507A2467-52E9-4181-8414-426AC45E01B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {507A2467-52E9-4181-8414-426AC45E01B3}.Release|Any CPU.Build.0 = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..8162474 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + 1.0.0-alpha.2 + 1.0.0-alpha.2 + Zapto + https://github.com/zapto-dev/AspNetCore + Copyright © 2025 Zapto + zapto, aspnetcore + https://github.com/zapto-dev/AspNetCore + https://github.com/zapto-dev/AspNetCore/blob/main/LICENSE + + + diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..8f6583e --- /dev/null +++ b/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/sandbox/WasmApp/Program.cs b/sandbox/WasmApp/Program.cs new file mode 100644 index 0000000..b00d8f0 --- /dev/null +++ b/sandbox/WasmApp/Program.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + +builder.Services.AddRouting(); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default); +}); + +builder.WebHost.UseWasmServer(options => +{ + options.IncludeExceptionDetails = true; +}); + +var app = builder.Build(); + +app.Use(async (context, next) => +{ + try + { + await next(); + } + catch (Exception ex) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync(ex.ToString()); + } +}); + +app.UseRouting(); + +app.MapGet("/", () => "Hello from ASP.NET Core on Cloudflare Workers!"); + +app.MapGet("/api/time", () => +{ + return Results.Json(new TimeResponse(DateTime.UtcNow.ToString("O")), AppJsonContext.Default.TimeResponse); +}); + +app.MapGet("/api/greet/{name}", (string name) => $"Hello, {name}!"); + +app.MapPost("/api/echo", async context => +{ + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(context.RequestAborted); + await context.Response.WriteAsync($"Echo: {body}"); +}); + +await app.StartAsync(); + +internal record TimeResponse([property: JsonPropertyName("time")] string Time); + +[JsonSerializable(typeof(TimeResponse))] +internal partial class AppJsonContext : JsonSerializerContext; diff --git a/sandbox/WasmApp/WasmApp.csproj b/sandbox/WasmApp/WasmApp.csproj new file mode 100644 index 0000000..353a75d --- /dev/null +++ b/sandbox/WasmApp/WasmApp.csproj @@ -0,0 +1,12 @@ + + + + + + + true + + + + + diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props new file mode 100644 index 0000000..d4e7c5c --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props @@ -0,0 +1,105 @@ + + + + + net10.0 + + + Exe + + + true + + + + + + <_WranglerNameIsDefault Condition="'$(WranglerName)' == ''">true + $(AssemblyName) + + index.js + + 2026-01-01 + + nodejs_compat + + 8787 + + http + + + + false + true + false + $(EmccFlags) -O3 + + + true + true + Shared + + + true + true + + + browser-wasm + + + wasm + AnyCPU + + + true + true + true + + + true + true + + + full + true + <_AggressiveAttributeTrimming>true + false + false + false + false + false + false + <_EnableConsumingManagedCodeFromNativeHosting>false + false + false + false + true + true + false + false + false + false + false + false + false + true + false + false + false + false + none + + + -s EXPORTED_RUNTIME_METHODS=cwrap -s ENVIRONMENT=webview -s EXPORT_ES6=1 -s ASSERTIONS=0 + + + enable + enable + + + + https://api.nuget.org/v3/index.json; + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json; + + + diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets new file mode 100644 index 0000000..4cfab4f --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets @@ -0,0 +1,287 @@ + + + + + 10.0.1 + $(NetCoreRoot)shared\Microsoft.AspNetCore.App\$(AspNetCoreAppRuntimeVersion)\ + + + + + + + + + + + + + + + + + + + + + + + + 3.1.56 + 10.0.2 + $(NetCoreRoot)packs\Microsoft.NET.Runtime.Emscripten.$(EmscriptenVersion).Sdk.win-x64\$(EmscriptenPackVersion)\tools\ + $(NetCoreRoot)packs\Microsoft.NET.Runtime.Emscripten.$(EmscriptenVersion).Node.win-x64\$(EmscriptenPackVersion)\tools\bin\ + + $(NetCoreRoot)packs\Microsoft.NET.Runtime.Emscripten.$(EmscriptenVersion).Cache.win-x64\$(EmscriptenPackVersion)\tools\emscripten\cache\ + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + PreserveNewest + + + + + Release + <_CloudflareBaseOutputPath Condition="'$(BaseOutputPath)' != ''">$(BaseOutputPath) + <_CloudflareBaseOutputPath Condition="'$(BaseOutputPath)' == ''">bin\ + <_PublishOutputPath Condition="'$(PublishDir)' != ''">$(PublishDir) + <_PublishOutputPath Condition="'$(PublishDir)' == ''">$(_CloudflareBaseOutputPath)$(CloudflarePublishConfiguration)\$(TargetFramework)\browser-wasm\publish\ + $([System.IO.Path]::GetFullPath('$(_PublishOutputPath)..\cloudflare\')) + $(MSBuildThisFileDirectory)wrangler.toml + $(MSBuildThisFileDirectory)index.js + $(BaseIntermediateOutputPath)generated\ + $(CloudflareIndexGeneratedDir)index.js + $(CloudflareIndexGeneratedDir)wrangler.toml + + + + + + + <_CloudflareIndexTemplate>$([System.IO.File]::ReadAllText('$(CloudflareIndexTemplatePath)')) + <_CloudflareIndexContent>$(_CloudflareIndexTemplate.Replace('__MAIN_ASSEMBLY__', '$(AssemblyName)')) + + + + + + + + + + + <_WranglerNameKebab Condition="'$(_WranglerNameIsDefault)' == 'true'">$(WranglerName.Replace('.', '-').Replace('_', '-').ToLowerInvariant()) + <_WranglerNameKebab Condition="'$(_WranglerNameIsDefault)' != 'true'">$(WranglerName) + + <_WranglerFlagsFormatted>"$(WranglerCompatibilityFlags.Replace(',', '", "').Trim())" + + + + <_CloudflareWranglerTemplate>$([System.IO.File]::ReadAllText('$(CloudflareWranglerTemplatePath)')) + <_CloudflareWranglerContent>$(_CloudflareWranglerTemplate.Replace('__WRANGLER_NAME__', '$(_WranglerNameKebab)')) + <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_MAIN__', '$(WranglerMain)')) + <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_COMPATIBILITY_DATE__', '$(WranglerCompatibilityDate)')) + <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_COMPATIBILITY_FLAGS__', '$(_WranglerFlagsFormatted)')) + <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_DEV_PORT__', '$(WranglerDevPort)')) + <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_DEV_PROTOCOL__', '$(WranglerDevProtocol)')) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + EnsureWranglerAvailable;EnsureCloudflareOutput;Publish;PrepareCloudflareOutput;$(RunDependsOn) + $(MSBuildProjectDirectory) + + + + powershell.exe + -NoProfile -Command "$ErrorActionPreference='Stop'; if (-not (Get-Command wrangler -ErrorAction SilentlyContinue)) { Write-Error 'Wrangler is not available on PATH. Install it with ''npm i -g wrangler''.'; exit 1 }; dotnet publish -c $(CloudflarePublishConfiguration) -v q -tl:false; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; dotnet msbuild /t:PrepareCloudflareOutput -p:Configuration=$(CloudflarePublishConfiguration) -p:RuntimeIdentifier=browser-wasm; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; wrangler dev --config '$(CloudflareOutputPath)wrangler.toml' --cwd '$(CloudflareOutputPath)' --local --ip 127.0.0.1 --port 8787" + + + + /bin/sh + -c "dotnet publish -c $(CloudflarePublishConfiguration) -v q -tl:false && dotnet msbuild /t:PrepareCloudflareOutput -p:Configuration=$(CloudflarePublishConfiguration) -p:RuntimeIdentifier=browser-wasm && command -v wrangler >/dev/null 2>&1 && wrangler dev --config '$(CloudflareOutputPath)wrangler.toml' --cwd '$(CloudflareOutputPath)' --local --ip 127.0.0.1 --port 8787 || { echo 'Wrangler is not available on PATH. Install it with \"npm i -g wrangler\".' 1>&2; exit 1; }" + + + + powershell.exe -NoProfile -Command "if (Get-Command wrangler -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" + /bin/sh -c "command -v wrangler" + + + + + + + + + + @(_WranglerPathLines) + + + + + + + + $(BaseIntermediateOutputPath)generated\ + $(WasmExportsGeneratedDir)WasmExports.g.cs + $(RootNamespace) + $(AssemblyName) + + + + + + + <_WasmExportsTemplate>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)WasmExports.cs.template')) + <_WasmExportsContent>$(_WasmExportsTemplate.Replace('$NAMESPACE$', '$(WasmExportsNamespace)')) + + + + + + + + + + + diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template new file mode 100644 index 0000000..c41d4c5 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template @@ -0,0 +1,24 @@ +// +// This file is generated by Zapto.AspNetCore.CloudFlare.SDK. +// Do not modify this file directly. +// + +using System; +using System.Runtime.InteropServices; +using Zapto.AspNetCore.Wasm; + +namespace $NAMESPACE$; + +/// +/// WASM exports that forward to the Zapto.AspNetCore.Wasm library. +/// These must be in the main assembly for ILC to export them. +/// +internal static class WasmExports +{ + [UnmanagedCallersOnly(EntryPoint = "BeginRequest")] + public static unsafe IntPtr BeginRequest(int contextLength, byte* contextPtr, int bodyLength, byte* bodyPtr) + => WasmInterop.ProcessRequest(contextLength, contextPtr, bodyLength, bodyPtr); + + [UnmanagedCallersOnly(EntryPoint = "EndRequest")] + public static void EndRequest(IntPtr ptr) => WasmInterop.FreeResponse(ptr); +} diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js new file mode 100644 index 0000000..cdcdf17 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js @@ -0,0 +1,224 @@ +import worker from './dotnet.native.wasm'; +import createDotnetRuntime from './dotnet.native.js'; +import { + initializeExports, + initializeReplacements, + configureRuntimeStartup, + configureEmscriptenStartup, + configureWorkerStartup, + passEmscriptenInternals, + setRuntimeGlobals, +} from './dotnet.runtime.js'; + +const mainAssemblyName = '__MAIN_ASSEMBLY__'; + +let cachedModule; + +function load() { + return cachedModule ??= (async () => { + let scriptDirectory = './'; + try { + if (typeof import.meta !== 'undefined' && import.meta.url) { + scriptDirectory = new URL('./', import.meta.url).toString(); + } + } catch { + scriptDirectory = './'; + } + + const moduleConfig = { + config: { + mainAssemblyName, + applicationArguments: [], + environmentVariables: {}, + }, + addRunDependency: () => {}, + removeRunDependency: () => {}, + monitorRunDependencies: () => {}, + }; + + const runtimeGlobals = { + mono: {}, + binding: {}, + internal: { + require: globalThis.require ?? (() => { + throw new Error('require is not supported in this environment'); + }), + }, + module: moduleConfig, + loaderHelpers: { + scriptDirectory, + locateFile: (path) => { + if (scriptDirectory.startsWith('http://') || scriptDirectory.startsWith('https://') || scriptDirectory.startsWith('file:')) { + return new URL(path, scriptDirectory).toString(); + } + return `${scriptDirectory}${path}`; + }, + fetch_like: (url, options) => fetch(url, options), + createPromiseController: (afterResolve, afterReject) => { + let promiseControl = null; + const promise = new Promise((resolve, reject) => { + promiseControl = { + isDone: false, + promise: null, + resolve: (value) => { + if (!promiseControl.isDone) { + promiseControl.isDone = true; + resolve(value); + afterResolve?.(); + } + }, + reject: (reason) => { + if (!promiseControl.isDone) { + promiseControl.isDone = true; + reject(reason); + afterReject?.(); + } + }, + }; + }); + promiseControl.promise = promise; + return { promise, promise_control: promiseControl }; + }, + }, + runtimeHelpers: {}, + api: {}, + }; + + setRuntimeGlobals(runtimeGlobals); + initializeExports(runtimeGlobals); + await configureRuntimeStartup(moduleConfig); + + runtimeGlobals.runtimeHelpers?.coreAssetsInMemory?.promise_control?.resolve?.(); + runtimeGlobals.runtimeHelpers?.allAssetsInMemory?.promise_control?.resolve?.(); + + const moduleFactory = (module) => { + const base = {}; + if (module && (typeof module === 'object' || typeof module === 'function')) { + for (const key of Object.keys(module)) { + if (key !== 'caller' && key !== 'callee' && key !== 'arguments') { + base[key] = module[key]; + } + } + } + + const merged = Object.assign(base, moduleConfig, { + locateFile: () => './dotnet.native.wasm', + instantiateWasm: (info, receiveInstance) => { + WebAssembly.instantiate(worker, info).then(instance => { + receiveInstance(instance, worker); + }).catch(err => { + console.error('WebAssembly.instantiate failed:', err); + }); + return []; + }, + addRunDependency: () => {}, + removeRunDependency: () => {}, + monitorRunDependencies: () => {}, + __dotnet_runtime: { + initializeReplacements, + configureEmscriptenStartup, + configureWorkerStartup, + passEmscriptenInternals, + }, + }); + + if (!merged.ready) { + merged.ready = Promise.resolve(merged); + } + return merged; + }; + + const runtime = await createDotnetRuntime(moduleFactory); + + // Expose Emscripten functions to the internal $e reference + Object.assign(runtimeGlobals.module, runtime); + + // Register JSExports + runtime._WasmApp__GeneratedInitializer__Register_?.(); + + // Call entry point directly (bypasses runMain which uses unavailable Emscripten helpers) + const callEntrypoint = runtime._System_Runtime_InteropServices_JavaScript_JavaScriptExports_CallEntrypoint; + if (typeof callEntrypoint === 'function') { + const frameSize = 5 * 32; + const sp = runtime.stackSave(); + const args = runtime.stackAlloc(frameSize); + runtime.HEAPU8.fill(0, args, args + frameSize); + callEntrypoint(args); + runtime.stackRestore(sp); + } + + const beginRequest = runtime.cwrap('BeginRequest', 'number', ['number', 'array', 'number', 'array']); + const endRequest = runtime.cwrap('EndRequest', null, ['number']); + const encoder = new TextEncoder(); + const decoder = new TextDecoder('utf-8'); + + const getInt32 = (ptr) => { + const memory = runtime['HEAP8']; + return ((memory[ptr + 3] & 0xFF) << 24) | + ((memory[ptr + 2] & 0xFF) << 16) | + ((memory[ptr + 1] & 0xFF) << 8) | + (memory[ptr] & 0xFF); + }; + + return (requestContext, requestBody) => { + const json = encoder.encode(JSON.stringify(requestContext)); + const ptr = beginRequest(json.length, json, requestBody.length, requestBody); + const memory = runtime['HEAP8']; + + let offset = ptr; + const responseBodyLength = getInt32(offset); + offset += 4; + const responseLength = getInt32(offset); + offset += 4; + + let body = null; + if (responseBodyLength > 0) { + const responseBodyCopy = new Uint8Array(responseBodyLength); + responseBodyCopy.set(memory.subarray(offset, offset + responseBodyLength)); + offset += responseBodyLength; + + let done = false; + body = new ReadableStream({ + start(controller) { + controller.enqueue(responseBodyCopy); + }, + pull(controller) { + if (!done) { + done = true; + controller.close(); + endRequest(ptr); + } + }, + cancel() { + endRequest(ptr); + } + }); + } else { + endRequest(ptr); + } + + const responseContext = JSON.parse(decoder.decode(memory.subarray(offset, offset + responseLength))); + return { body, response: responseContext }; + }; + })(); +} + +export default { + async fetch(request, env, ctx) { + const processRequest = await load(); + const arrayBuffer = await request.arrayBuffer(); + const requestBody = new Uint8Array(arrayBuffer); + const headers = {}; + + for (const [key, value] of request.headers) { + headers[key] = value; + } + + const { body, response } = processRequest( + { method: request.method, url: request.url, headers }, + requestBody + ); + + return new Response(body, response); + }, +}; \ No newline at end of file diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml new file mode 100644 index 0000000..adeeba3 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml @@ -0,0 +1,9 @@ +name = "__WRANGLER_NAME__" +main = "__WRANGLER_MAIN__" +compatibility_date = "__WRANGLER_COMPATIBILITY_DATE__" +compatibility_flags = [__WRANGLER_COMPATIBILITY_FLAGS__] + +# Development settings +[dev] +port = __WRANGLER_DEV_PORT__ +local_protocol = "__WRANGLER_DEV_PROTOCOL__" diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj new file mode 100644 index 0000000..6bb3c9c --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj @@ -0,0 +1,52 @@ + + + + netstandard2.0 + SDK package for hosting ASP.NET Core on Cloudflare Workers using WebAssembly LLVM + aspnetcore, webassembly, wasm, llvm, cloudflare, workers, sdk + true + MSBuildSdk + $(NoWarn);NU5128 + Zapto.AspNetCore.CloudFlare.SDK + $(BaseIntermediateOutputPath)Sdk\ + + + + + + + + + + + + + + + + + <_SdkTargetsTemplate>$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\Sdk\Sdk.targets')) + <_SdkTargetsContent>$(_SdkTargetsTemplate.Replace('__ZAPTO_WASM_VERSION__', '$(PackageVersion)')) + + + + + + + <_PackageFiles Include="$(GeneratedSdkDir)Sdk.targets" PackagePath="Sdk" /> + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 9ae51fa..bb91393 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,15 +1,7 @@ - - 1.0.0-alpha.2 - 1.0.0-alpha.2 - Zapto - https://github.com/zapto-dev/AspNetCore - Copyright © 2025 Zapto - zapto, aspnetcore - https://github.com/zapto-dev/AspNetCore - https://github.com/zapto-dev/AspNetCore/blob/main/LICENSE - + + 8.0.0 diff --git a/src/Zapto.AspNetCore.Wasm/GlobalUsings.cs b/src/Zapto.AspNetCore.Wasm/GlobalUsings.cs new file mode 100644 index 0000000..7e78320 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/GlobalUsings.cs @@ -0,0 +1,16 @@ +global using System.Buffers; +global using System.Buffers.Binary; +global using System.IO.Pipelines; +global using System.Runtime.CompilerServices; +global using System.Runtime.InteropServices; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using Microsoft.AspNetCore.Hosting.Server; +global using Microsoft.AspNetCore.Hosting.Server.Features; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Http.Features; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; diff --git a/src/Zapto.AspNetCore.Wasm/HostBuilderWasmExtensions.cs b/src/Zapto.AspNetCore.Wasm/HostBuilderWasmExtensions.cs new file mode 100644 index 0000000..2da0134 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/HostBuilderWasmExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Hosting; +using Zapto.AspNetCore.Wasm; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for configuring WebAssembly server on IHostBuilder. +/// +public static class HostBuilderWasmExtensions +{ + /// + /// Configures the host to use the WebAssembly server. + /// + /// The IHostBuilder to configure. + /// A reference to the IHostBuilder. + public static IHostBuilder UseWasmServer(this IHostBuilder hostBuilder) + { + return hostBuilder.ConfigureServices(services => + { + services.AddSingleton(); + }); + } + + /// + /// Configures the host to use the WebAssembly server with options. + /// + /// The IHostBuilder to configure. + /// A callback to configure WasmServerOptions. + /// A reference to the IHostBuilder. + public static IHostBuilder UseWasmServer(this IHostBuilder hostBuilder, Action options) + { + return hostBuilder.UseWasmServer().ConfigureServices(services => + { + services.Configure(options); + }); + } +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/RequestContext.cs b/src/Zapto.AspNetCore.Wasm/Internal/RequestContext.cs new file mode 100644 index 0000000..80c2082 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/RequestContext.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Represents the request context received from the JavaScript runtime. +/// +internal sealed class RequestContext +{ + [JsonPropertyName("url")] + public required string Url { get; set; } + + [JsonPropertyName("method")] + public required string Method { get; set; } + + [JsonPropertyName("headers")] + public Dictionary Headers { get; set; } = new(); +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/ResponseContext.cs b/src/Zapto.AspNetCore.Wasm/Internal/ResponseContext.cs new file mode 100644 index 0000000..46b4425 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/ResponseContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Represents the response context to send back to the JavaScript runtime. +/// +internal sealed class ResponseContext +{ + [JsonPropertyName("status")] + public int Status { get; set; } = 200; + + [JsonPropertyName("headers")] + public Dictionary Headers { get; set; } = new(); +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpConnectionFeature.cs b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpConnectionFeature.cs new file mode 100644 index 0000000..b375452 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpConnectionFeature.cs @@ -0,0 +1,15 @@ +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Implementation of IHttpConnectionFeature for WebAssembly. +/// +internal sealed class WasmHttpConnectionFeature : IHttpConnectionFeature +{ + private static readonly System.Net.IPAddress LocalAddress = System.Net.IPAddress.Parse("127.0.0.1"); + + public string ConnectionId { get; set; } = Guid.NewGuid().ToString("N"); + public System.Net.IPAddress? LocalIpAddress { get; set; } = LocalAddress; + public int LocalPort { get; set; } = 443; + public System.Net.IPAddress? RemoteIpAddress { get; set; } = LocalAddress; + public int RemotePort { get; set; } = 0; +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestFeature.cs b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestFeature.cs new file mode 100644 index 0000000..4d80bbb --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestFeature.cs @@ -0,0 +1,17 @@ +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Default implementation of IHttpRequestFeature for WebAssembly. +/// +internal sealed class WasmHttpRequestFeature : IHttpRequestFeature +{ + public string Protocol { get; set; } = "HTTP/1.1"; + public string Scheme { get; set; } = "https"; + public string Method { get; set; } = "GET"; + public string PathBase { get; set; } = string.Empty; + public string Path { get; set; } = "/"; + public string QueryString { get; set; } = string.Empty; + public string RawTarget { get; set; } = string.Empty; + public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); + public Stream Body { get; set; } = Stream.Null; +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestIdentifierFeature.cs b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestIdentifierFeature.cs new file mode 100644 index 0000000..0b7f715 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpRequestIdentifierFeature.cs @@ -0,0 +1,9 @@ +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Implementation of IHttpRequestIdentifierFeature for WebAssembly. +/// +internal sealed class WasmHttpRequestIdentifierFeature : IHttpRequestIdentifierFeature +{ + public string TraceIdentifier { get; set; } = Guid.NewGuid().ToString("N"); +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseBodyFeature.cs b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseBodyFeature.cs new file mode 100644 index 0000000..d83020b --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseBodyFeature.cs @@ -0,0 +1,41 @@ +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Implementation of IHttpResponseBodyFeature for WebAssembly. +/// +internal sealed class WasmHttpResponseBodyFeature : IHttpResponseBodyFeature +{ + private readonly WasmHttpResponseFeature _responseFeature; + private PipeWriter? _pipeWriter; + + public WasmHttpResponseBodyFeature(MemoryStream stream, WasmHttpResponseFeature responseFeature) + { + Stream = stream; + _responseFeature = responseFeature; + } + + public Stream Stream { get; } + + public PipeWriter Writer => _pipeWriter ??= PipeWriter.Create(Stream, new StreamPipeWriterOptions(leaveOpen: true)); + + public Task CompleteAsync() + { + return Task.CompletedTask; + } + + public void DisableBuffering() + { + // WASM doesn't support disabling buffering - everything is buffered + } + + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) + { + throw new NotSupportedException("SendFileAsync is not supported in WebAssembly."); + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + _responseFeature.MarkAsStarted(); + await Task.CompletedTask; + } +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseFeature.cs b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseFeature.cs new file mode 100644 index 0000000..94d7611 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/WasmHttpResponseFeature.cs @@ -0,0 +1,28 @@ +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Default implementation of IHttpResponseFeature for WebAssembly. +/// +internal sealed class WasmHttpResponseFeature : IHttpResponseFeature +{ + public int StatusCode { get; set; } = 200; + public string? ReasonPhrase { get; set; } + public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); + public Stream Body { get; set; } = Stream.Null; + public bool HasStarted { get; private set; } + + public void OnStarting(Func callback, object state) + { + // WASM doesn't support streaming, response is sent all at once + } + + public void OnCompleted(Func callback, object state) + { + // WASM doesn't support streaming, response is sent all at once + } + + internal void MarkAsStarted() + { + HasStarted = true; + } +} diff --git a/src/Zapto.AspNetCore.Wasm/Internal/WasmJsonContext.cs b/src/Zapto.AspNetCore.Wasm/Internal/WasmJsonContext.cs new file mode 100644 index 0000000..ea89135 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/WasmJsonContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// JSON serialization context for worker communication. +/// +[JsonSerializable(typeof(RequestContext))] +[JsonSerializable(typeof(ResponseContext))] +internal sealed partial class WasmJsonContext : JsonSerializerContext; diff --git a/src/Zapto.AspNetCore.Wasm/Internal/WasmRequestContext.cs b/src/Zapto.AspNetCore.Wasm/Internal/WasmRequestContext.cs new file mode 100644 index 0000000..d8b922d --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Internal/WasmRequestContext.cs @@ -0,0 +1,118 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Zapto.AspNetCore.Wasm.Internal; + +/// +/// Context for a single WebAssembly HTTP request. +/// +internal sealed class WasmRequestContext +{ + public WasmRequestContext( + RequestContext requestContext, + Stream requestBody, + MemoryStream responseBody) + { + RequestContext = requestContext; + RequestBody = requestBody; + ResponseBody = responseBody; + } + + public RequestContext RequestContext { get; } + public Stream RequestBody { get; } + public MemoryStream ResponseBody { get; } + + public FeatureCollection CreateFeatures(IServiceScopeFactory serviceScopeFactory) + { + var features = new FeatureCollection(); + + // Parse the URL + var uri = new Uri(RequestContext.Url, UriKind.RelativeOrAbsolute); + if (!uri.IsAbsoluteUri) + { + uri = new Uri("https://localhost" + RequestContext.Url); + } + + // Request feature + var requestFeature = new WasmHttpRequestFeature + { + Method = RequestContext.Method, + Scheme = uri.Scheme, + Path = uri.AbsolutePath, + QueryString = uri.Query, + RawTarget = uri.PathAndQuery, + Body = RequestBody, + Headers = new HeaderDictionary() + }; + + foreach (var header in RequestContext.Headers) + { + requestFeature.Headers[header.Key] = header.Value; + } + + // Response feature + var responseFeature = new WasmHttpResponseFeature + { + Body = ResponseBody, + StatusCode = 200, + Headers = new HeaderDictionary() + }; + + // Response body feature + var responseBodyFeature = new WasmHttpResponseBodyFeature(ResponseBody, responseFeature); + + // Connection feature + var connectionFeature = new WasmHttpConnectionFeature(); + + // Request identifier feature + var requestIdentifierFeature = new WasmHttpRequestIdentifierFeature(); + + // Add all features + features.Set(requestFeature); + features.Set(responseFeature); + features.Set(responseBodyFeature); + features.Set(connectionFeature); + features.Set(requestIdentifierFeature); + + // Add request services feature so minimal APIs can resolve services. + features.Set(new WasmServiceProvidersFeature(serviceScopeFactory)); + + return features; + } +} + +internal sealed class WasmServiceProvidersFeature(IServiceScopeFactory serviceScopeFactory) + : IServiceProvidersFeature, IDisposable +{ + private IServiceScope? _scope; + private IServiceProvider? _requestServices; + + public IServiceProvider RequestServices + { + get + { + if (_requestServices != null) + { + return _requestServices; + } + + _scope ??= serviceScopeFactory.CreateScope(); + _requestServices = _scope.ServiceProvider; + return _requestServices; + } + set + { + _scope?.Dispose(); + _scope = null; + _requestServices = value; + } + } + + public void Dispose() + { + _scope?.Dispose(); + _scope = null; + _requestServices = null; + } +} diff --git a/src/Zapto.AspNetCore.Wasm/WasmInterop.cs b/src/Zapto.AspNetCore.Wasm/WasmInterop.cs new file mode 100644 index 0000000..e04e4e2 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/WasmInterop.cs @@ -0,0 +1,87 @@ +using System.Web; +using Zapto.AspNetCore.Wasm.Internal; + +namespace Zapto.AspNetCore.Wasm; + +/// +/// Provides interop methods for WebAssembly LLVM runtime communication. +/// +public static class WasmInterop +{ + /// + /// Begins processing an HTTP request. Called from the main assembly's exported function. + /// + /// Length of the JSON context data. + /// Pointer to the JSON context data. + /// Length of the request body. + /// Pointer to the request body data. + /// Pointer to the response data. + public static unsafe IntPtr ProcessRequest(int contextLength, byte* contextPtr, int bodyLength, byte* bodyPtr) + { + var contextSpan = new Span(contextPtr, contextLength); + var requestContext = JsonSerializer.Deserialize(contextSpan, WasmJsonContext.Default.RequestContext)!; + + // Handle empty body - UnmanagedMemoryStream doesn't accept null pointers + Stream body = bodyLength > 0 && bodyPtr != null + ? new UnmanagedMemoryStream(bodyPtr, bodyLength) + : Stream.Null; + + try + { + return CreateResponse(requestContext, body).GetAwaiter().GetResult(); + } + finally + { + if (body != Stream.Null) + body.Dispose(); + } + } + + /// + /// Ends processing of an HTTP request and frees memory. Called from the main assembly's exported function. + /// + /// Pointer to the response data to free. + public static void FreeResponse(IntPtr ptr) + { + Marshal.FreeHGlobal(ptr); + } + + private static async ValueTask CreateResponse(RequestContext requestContext, Stream requestBody) + { + var server = WasmServer.Instance; + if (server == null) + { + throw new InvalidOperationException("Server has not been initialized."); + } + + var (responseStream, responseContext) = await server.HandleRequestAsync(requestContext, requestBody); + + return Alloc(responseStream, responseContext); + } + + private static unsafe IntPtr Alloc(MemoryStream stream, ResponseContext response) + { + stream.Flush(); + + var bodyLength = (uint)stream.Length; + JsonSerializer.Serialize(stream, response, WasmJsonContext.Default.ResponseContext); + stream.Flush(); + + var contextLength = (uint)stream.Length - bodyLength; + + stream.Position = 0; + + Span source = stream.TryGetBuffer(out var segment) ? segment : stream.ToArray(); + + var length = source.Length + 8; + var ptr = Marshal.AllocHGlobal(length); + var p = (byte*)ptr.ToPointer(); + var target = new Span(p, length); + + BinaryPrimitives.WriteUInt32LittleEndian(target, bodyLength); + BinaryPrimitives.WriteUInt32LittleEndian(target.Slice(4), contextLength); + source.CopyTo(target.Slice(8)); + + return ptr; + } +} diff --git a/src/Zapto.AspNetCore.Wasm/WasmServer.cs b/src/Zapto.AspNetCore.Wasm/WasmServer.cs new file mode 100644 index 0000000..6532b3f --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/WasmServer.cs @@ -0,0 +1,147 @@ +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Zapto.AspNetCore.Wasm.Internal; + +namespace Zapto.AspNetCore.Wasm; + +/// +/// ASP.NET Core IServer implementation for WebAssembly using LLVM. +/// +public sealed class WasmServer : IServer +{ + private readonly ILogger _logger; + private readonly WasmServerOptions _options; + private readonly IServiceScopeFactory _serviceScopeFactory; + private Func>? _processRequest; + + public WasmServer( + IOptions options, + ILogger logger, + IServiceScopeFactory serviceScopeFactory) + { + _options = options.Value; + _logger = logger; + _serviceScopeFactory = serviceScopeFactory; + + var serverAddressesFeature = new ServerAddressesFeature(); + Features = new FeatureCollection(); + Features.Set(serverAddressesFeature); + } + + /// + public IFeatureCollection Features { get; } + + /// + /// Stores the server instance for handling requests from the WASM interop layer. + /// + internal static WasmServer? Instance { get; private set; } + + /// + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull + { + // Create a strongly-typed request processor that captures the generic type + _processRequest = (requestContext, requestBody) => ProcessRequestAsync(application, requestContext, requestBody); + Instance = this; + + _logger.LogInformation("WebAssembly server started"); + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("WebAssembly server stopped"); + Instance = null; + _processRequest = null; + return Task.CompletedTask; + } + + /// + /// Processes a request from the WASM interop layer (public entry point). + /// + internal Task<(MemoryStream ResponseBody, ResponseContext ResponseContext)> HandleRequestAsync( + RequestContext requestContext, + Stream requestBody) + { + if (_processRequest == null) + { + throw new InvalidOperationException("Server has not been started."); + } + + return _processRequest(requestContext, requestBody); + } + + /// + /// Processes a request with the strongly-typed application context. + /// + private async Task<(MemoryStream ResponseBody, ResponseContext ResponseContext)> ProcessRequestAsync( + IHttpApplication application, + RequestContext requestContext, + Stream requestBody) + where TContext : notnull + { + var responseBody = new MemoryStream(); + var wasmContext = new WasmRequestContext(requestContext, requestBody, responseBody); + var features = wasmContext.CreateFeatures(_serviceScopeFactory); + + Exception? exception = null; + TContext? context = default; + var contextCreated = false; + + try + { + context = application.CreateContext(features); + contextCreated = true; + await application.ProcessRequestAsync(context); + } + catch (Exception ex) + { + exception = ex; + _logger.LogError(ex, "An error occurred processing the request"); + + var responseFeature = features.Get()!; + if (!responseFeature.HasStarted) + { + responseFeature.StatusCode = 500; + responseBody.SetLength(0); + responseFeature.Headers["Content-Type"] = "text/plain; charset=utf-8"; + var errorText = _options.IncludeExceptionDetails ? ex.ToString() : "Internal Server Error"; + var errorBytes = Encoding.UTF8.GetBytes(errorText); + await responseBody.WriteAsync(errorBytes); + } + } + finally + { + if (contextCreated) + { + application.DisposeContext(context!, exception); + } + + if (features.Get() is IDisposable disposable) + { + disposable.Dispose(); + } + } + + var httpResponseFeature = features.Get()!; + var responseContext = new ResponseContext + { + Status = httpResponseFeature.StatusCode + }; + + foreach (var header in httpResponseFeature.Headers) + { + responseContext.Headers[header.Key] = header.Value.ToString(); + } + + return (responseBody, responseContext); + } + + /// + public void Dispose() + { + Instance = null; + _processRequest = null; + } +} diff --git a/src/Zapto.AspNetCore.Wasm/WasmServerOptions.cs b/src/Zapto.AspNetCore.Wasm/WasmServerOptions.cs new file mode 100644 index 0000000..175467e --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/WasmServerOptions.cs @@ -0,0 +1,17 @@ +namespace Zapto.AspNetCore.Wasm; + +/// +/// Options for the WebAssembly server. +/// +public class WasmServerOptions +{ + /// + /// Gets or sets the default content type for responses when not specified. + /// + public string DefaultContentType { get; set; } = "text/plain"; + + /// + /// Gets or sets whether exception details should be returned in 500 responses. + /// + public bool IncludeExceptionDetails { get; set; } +} diff --git a/src/Zapto.AspNetCore.Wasm/WebHostBuilderWasmExtensions.cs b/src/Zapto.AspNetCore.Wasm/WebHostBuilderWasmExtensions.cs new file mode 100644 index 0000000..8fcc18c --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/WebHostBuilderWasmExtensions.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Zapto.AspNetCore.Wasm; + +namespace Microsoft.AspNetCore.Hosting; + +/// +/// Provides extension methods for configuring WebAssembly server on IWebHostBuilder. +/// +public static class WebHostBuilderWasmExtensions +{ + /// + /// Specifies WebAssembly as the server to be used by the web host. + /// + /// The IWebHostBuilder to configure. + /// A reference to the IWebHostBuilder. + public static IWebHostBuilder UseWasmServer(this IWebHostBuilder hostBuilder) + { + return hostBuilder.ConfigureServices(services => + { + services.AddSingleton(); + }); + } + + /// + /// Specifies WebAssembly as the server to be used by the web host. + /// + /// The IWebHostBuilder to configure. + /// A callback to configure WasmServerOptions. + /// A reference to the IWebHostBuilder. + public static IWebHostBuilder UseWasmServer(this IWebHostBuilder hostBuilder, Action options) + { + return hostBuilder.UseWasmServer().ConfigureServices(services => + { + services.Configure(options); + }); + } + + /// + /// Specifies WebAssembly as the server to be used by the web host. + /// + /// The ConfigureWebHostBuilder to configure. + /// A reference to the ConfigureWebHostBuilder. + public static ConfigureWebHostBuilder UseWasmServer(this ConfigureWebHostBuilder hostBuilder) + { + hostBuilder.ConfigureServices(services => + { + services.AddSingleton(); + }); + return hostBuilder; + } + + /// + /// Specifies WebAssembly as the server to be used by the web host. + /// + /// The ConfigureWebHostBuilder to configure. + /// A callback to configure WasmServerOptions. + /// A reference to the ConfigureWebHostBuilder. + public static ConfigureWebHostBuilder UseWasmServer(this ConfigureWebHostBuilder hostBuilder, Action options) + { + hostBuilder.UseWasmServer(); + hostBuilder.ConfigureServices(services => + { + services.Configure(options); + }); + return hostBuilder; + } +} diff --git a/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj b/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj new file mode 100644 index 0000000..81a3f3c --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + Zapto.AspNetCore.Wasm + ASP.NET Core server implementation for WebAssembly using LLVM + aspnetcore, webassembly, wasm, llvm, cloudflare, workers + + + + + + + + + PreserveNewest + + + + diff --git a/src/Zapto.AspNetCore.Wasm/index.js b/src/Zapto.AspNetCore.Wasm/index.js new file mode 100644 index 0000000..b95e1d0 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/index.js @@ -0,0 +1,109 @@ +import worker from './Project.wasm'; +import start from './Project.js'; + +let cachedModule; + +function load() { + return cachedModule ??= new Promise(resolve => { + const Module = {}; + + Module.locateFile = () => './Project.wasm'; + + Module.instantiateWasm = async (info, receiveInstance) => { + const result = await WebAssembly.instantiate(worker, info); + receiveInstance(result); + }; + + Module.onRuntimeInitialized = () => { + const initialize = Module.cwrap('Initialize', null, []); + const beginRequest = Module.cwrap('BeginRequest', 'number', ['number', 'array', 'number', 'array']); + const endRequest = Module.cwrap('EndRequest', null, ['number']); + + initialize(); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder('utf-8'); + + const getInt32 = (ptr) => { + const memory = Module['HEAP8']; + return ((memory[ptr + 3] & 0xFF) << 24) | + ((memory[ptr + 2] & 0xFF) << 16) | + ((memory[ptr + 1] & 0xFF) << 8) | + (memory[ptr] & 0xFF); + }; + + resolve((requestContext, requestBody) => { + const json = encoder.encode(JSON.stringify(requestContext)); + const ptr = beginRequest(json.length, json, requestBody.length, requestBody); + const memory = Module['HEAP8']; + + let offset = ptr; + + const responseBodyLength = getInt32(offset); + offset += 4; + + const responseLength = getInt32(offset); + offset += 4; + + let body = null; + + if (responseBodyLength > 0) { + // Copy the response body before creating the stream + const responseBodyCopy = new Uint8Array(responseBodyLength); + responseBodyCopy.set(memory.subarray(offset, offset + responseBodyLength)); + offset += responseBodyLength; + + let done = false; + body = new ReadableStream({ + start(controller) { + controller.enqueue(responseBodyCopy); + }, + pull(controller) { + if (!done) { + done = true; + controller.close(); + endRequest(ptr); + } + }, + cancel() { + endRequest(ptr); + } + }); + } else { + // No body, free memory immediately + endRequest(ptr); + } + + const responseContext = JSON.parse(decoder.decode(memory.subarray(offset, offset + responseLength))); + + return { body, response: responseContext }; + }); + }; + + start(Module); + }); +} + +export default { + async fetch(request, env, ctx) { + const processRequest = await load(); + const arrayBuffer = await request.arrayBuffer(); + const requestBody = new Uint8Array(arrayBuffer); + const headers = {}; + + for (const [key, value] of request.headers) { + headers[key] = value; + } + + const { body, response } = processRequest( + { + method: request.method, + url: request.url, + headers, + }, + requestBody + ); + + return new Response(body, response); + }, +}; diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/Assembly.cs b/tests/Zapto.AspNetCore.Wasm.Tests/Assembly.cs new file mode 100644 index 0000000..3d0f9d0 --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.Tests/Assembly.cs @@ -0,0 +1 @@ +[assembly: AssemblyFixture(typeof(Zapto.AspNetCore.Wasm.Tests.WranglerFixture))] diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs b/tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs new file mode 100644 index 0000000..ce9695c --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs @@ -0,0 +1,72 @@ +namespace Zapto.AspNetCore.Wasm.Tests; + +/// +/// Tests for the ASP.NET Core Cloudflare Workers endpoints. +/// +public class WasmEndpointTests(WranglerFixture fixture) +{ + [Fact] + public async Task Root_ReturnsHelloMessage() + { + var response = await fixture.Client.GetAsync("/", TestContext.Current.CancellationToken); + await EnsureSuccessAsync(response, TestContext.Current.CancellationToken); + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal("Hello from ASP.NET Core on Cloudflare Workers!", content); + } + + [Fact] + public async Task ApiTime_ReturnsTimeJson() + { + var response = await fixture.Client.GetAsync("/api/time", TestContext.Current.CancellationToken); + await EnsureSuccessAsync(response, TestContext.Current.CancellationToken); + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Contains("time", content); + Assert.Contains(":", content); // Time format includes colons + } + + [Fact] + public async Task ApiGreet_ReturnsPersonalizedGreeting() + { + var response = await fixture.Client.GetAsync("/api/greet/World", TestContext.Current.CancellationToken); + await EnsureSuccessAsync(response, TestContext.Current.CancellationToken); + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal("Hello, World!", content); + } + + [Fact] + public async Task ApiEcho_EchoesRequestBody() + { + var content = new StringContent("Test message"); + var response = await fixture.Client.PostAsync("/api/echo", content, TestContext.Current.CancellationToken); + await EnsureSuccessAsync(response, TestContext.Current.CancellationToken); + + var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal("Echo: Test message", responseContent); + } + + [Fact] + public async Task NotFound_Returns404() + { + var response = await fixture.Client.GetAsync("/nonexistent", TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Response status code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).\n{body}"); + } +} diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs b/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs new file mode 100644 index 0000000..1c43e0f --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs @@ -0,0 +1,330 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace Zapto.AspNetCore.Wasm.Tests; + +/// +/// Fixture that manages the Wrangler development server for testing +/// Cloudflare Workers locally. +/// +public class WranglerFixture : IAsyncLifetime +{ + private Process? _wranglerProcess; + private readonly List _processOutput = new(); + private string? _wranglerConfigPath; + private CancellationTokenSource? _lifetimeCts; + private Task? _timeoutTask; + private readonly TimeSpan _startupTimeout = TimeSpan.FromSeconds(60); + private readonly TimeSpan _maxRunTime = TimeSpan.FromMinutes(2); + + public string BaseAddress { get; private set; } = ""; + + public HttpClient Client { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + var projectPath = ProjectLocator.GetWasmAppPath(); + int port = 8787; + + await EnsureWranglerAvailableAsync(); + + var publishPath = Path.Combine(projectPath, "bin", "Release", "net10.0", "browser-wasm", "publish"); + if (!Directory.Exists(publishPath)) + { + publishPath = Path.Combine(projectPath, "Release", "net10.0", "browser-wasm", "publish"); + } + + if (!Directory.Exists(publishPath)) + { + throw new DirectoryNotFoundException($"Publish output not found. Expected: {publishPath}"); + } + + var cloudflarePath = Path.GetFullPath(Path.Combine(publishPath, "..", "cloudflare")); + Directory.CreateDirectory(cloudflarePath); + + CopyFileIfExists(Path.Combine(publishPath, "index.js"), cloudflarePath); + CopyFileIfExists(Path.Combine(publishPath, "dotnet.native.js"), cloudflarePath); + CopyFileIfExists(Path.Combine(publishPath, "dotnet.runtime.js"), cloudflarePath); + CopyFileIfExists(Path.Combine(publishPath, "dotnet.native.wasm"), cloudflarePath); + CopyFileIfExists(Path.Combine(publishPath, "Project.wasm"), cloudflarePath); + CopyFileIfExists(Path.Combine(publishPath, "Project.js"), cloudflarePath); + CopyFileIfExists(Path.Combine(projectPath, "Release", "net10.0", "browser-wasm", "Project.js"), cloudflarePath); + + // Start Wrangler dev server from the publish directory + // Use --show-interactive-dev-session=false to disable the interactive menu + // so we can kill the process normally without needing to send 'x' to stdin + + // Create a minimal wrangler.toml for testing (no build command to avoid rebuild delays) + _wranglerConfigPath = Path.Combine(cloudflarePath, "wrangler.toml"); + await File.WriteAllTextAsync(_wranglerConfigPath, $""" + name = "aspnetcore-worker-test" + main = "index.js" + compatibility_date = "2024-09-23" + compatibility_flags = ["nodejs_compat"] + + [dev] + port = {port} + local_protocol = "http" + """); + + var arguments = $"dev --config \"{_wranglerConfigPath}\" --local --ip 127.0.0.1 --port {port}"; + var startInfo = new ProcessStartInfo + { + WorkingDirectory = cloudflarePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + if (OperatingSystem.IsWindows()) + { + startInfo.FileName = "powershell.exe"; + startInfo.Arguments = $"-NoProfile -Command \"wrangler {arguments}\""; + } + else + { + startInfo.FileName = "wrangler"; + startInfo.Arguments = arguments; + } + + _wranglerProcess = new Process { StartInfo = startInfo }; + + _wranglerProcess.StartInfo.Environment["CI"] = "1"; + _wranglerProcess.StartInfo.Environment["WRANGLER_LOG"] = "debug"; + + _lifetimeCts = new CancellationTokenSource(_maxRunTime); + _timeoutTask = Task.Run(async () => + { + try + { + await Task.Delay(_maxRunTime, _lifetimeCts.Token); + } + catch (OperationCanceledException) + { + return; + } + + _processOutput.Add($"Timeout exceeded ({_maxRunTime.TotalSeconds}s). Terminating Wrangler."); + await KillWranglerAsync(); + }); + + _wranglerProcess.OutputDataReceived += (_, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + { + _processOutput.Add(e.Data); + } + }; + + _wranglerProcess.ErrorDataReceived += (_, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + { + _processOutput.Add(e.Data); + } + }; + + _wranglerProcess.Start(); + _wranglerProcess.BeginOutputReadLine(); + _wranglerProcess.BeginErrorReadLine(); + + BaseAddress = $"http://localhost:{port}/"; + Client = new HttpClient { BaseAddress = new Uri(BaseAddress), Timeout = TimeSpan.FromSeconds(10) }; + + // Wait until server is reachable + var startTime = Stopwatch.StartNew(); + while (startTime.Elapsed < _startupTimeout) + { + try + { + var response = await Client.GetAsync("/"); + if (response.IsSuccessStatusCode) + { + break; + } + } + catch + { + // Server not ready yet + } + + await Task.Delay(1000); + + if (_lifetimeCts.IsCancellationRequested) + { + throw new TimeoutException($"Wrangler timed out after {_maxRunTime.TotalSeconds}s. Output:\n{string.Join("\n", _processOutput)}"); + } + + if (_wranglerProcess.HasExited) + { + throw new Exception($"Wrangler process exited unexpectedly. Output:\n{string.Join("\n", _processOutput)}"); + } + } + + if (startTime.Elapsed >= _startupTimeout) + { + throw new TimeoutException($"Wrangler did not become ready within {_startupTimeout.TotalSeconds}s. Output:\n{string.Join("\n", _processOutput)}"); + } + } + + public async ValueTask DisposeAsync() + { + Client?.Dispose(); + + if (_lifetimeCts != null) + { + _lifetimeCts.Cancel(); + } + + await KillWranglerAsync(); + + if (_timeoutTask != null) + { + try + { + await _timeoutTask; + } + catch (OperationCanceledException) + { + // Ignore cancellation. + } + } + + // Give a moment for ports to be released + await Task.Delay(1000); + } + + private async Task KillWranglerAsync() + { + if (_wranglerProcess == null) + { + return; + } + + try + { + if (OperatingSystem.IsWindows()) + { + using var killProcess = Process.Start(new ProcessStartInfo + { + FileName = "taskkill.exe", + Arguments = $"/F /T /PID {_wranglerProcess.Id}", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + + if (killProcess != null) + { + await killProcess.WaitForExitAsync(); + } + } + else if (!_wranglerProcess.HasExited) + { + _wranglerProcess.Kill(entireProcessTree: true); + } + + await _wranglerProcess.WaitForExitAsync(); + } + catch + { + // Best-effort cleanup. + } + finally + { + _wranglerProcess.Dispose(); + _wranglerProcess = null; + } + } + + private static async Task EnsureWranglerAvailableAsync() + { + ProcessStartInfo startInfo; + + if (OperatingSystem.IsWindows()) + { + startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -Command \"if (Get-Command wrangler -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + else + { + startInfo = new ProcessStartInfo + { + FileName = "wrangler", + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + + try + { + using var process = Process.Start(startInfo); + if (process == null) + { + throw new InvalidOperationException("Wrangler is not available on PATH. Install it with 'npm i -g wrangler'."); + } + + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException("Wrangler is not available on PATH. Install it with 'npm i -g wrangler'."); + } + } + catch (Win32Exception) + { + throw new InvalidOperationException("Wrangler is not available on PATH. Install it with 'npm i -g wrangler'."); + } + } + + private static void CopyFileIfExists(string sourcePath, string destinationDirectory) + { + if (!File.Exists(sourcePath)) + { + return; + } + + var destinationPath = Path.Combine(destinationDirectory, Path.GetFileName(sourcePath)); + File.Copy(sourcePath, destinationPath, overwrite: true); + } +} + +public static class ProjectLocator +{ + public static string GetWasmAppPath() + { + // Find the solution root by looking for AspNetCore.sln + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AspNetCore.sln"))) + { + dir = dir.Parent; + } + + if (dir == null) + { + throw new DirectoryNotFoundException("Could not find solution root (AspNetCore.sln) in directory hierarchy."); + } + + // Go to 'sandbox/WasmApp' + var wasmAppDir = Path.Combine(dir.FullName, "sandbox", "WasmApp"); + + if (!Directory.Exists(wasmAppDir)) + { + throw new DirectoryNotFoundException($"Could not find 'WasmApp' folder at expected location: {wasmAppDir}"); + } + + return wasmAppDir; + } +} diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj b/tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj new file mode 100644 index 0000000..d85954d --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + preview + enable + enable + Exe + false + + + + + + + + + + + + + + + + + + + + + + + $([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '..', '..', 'sandbox', 'WasmApp', 'WasmApp.csproj')) + + + + + + + diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/xunit.runner.json b/tests/Zapto.AspNetCore.Wasm.Tests/xunit.runner.json new file mode 100644 index 0000000..6d157cb --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false +} From 47b2449bc17cd32e1be13a1ee8632e5bab435630 Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Fri, 16 Jan 2026 23:50:01 +0100 Subject: [PATCH 2/6] feat: WasmExport for raw exports --- AspNetCore.sln | 253 +++++++--- .../Sdk/Sdk.targets | 45 +- .../Sdk/WasmExports.cs.template | 24 - .../Zapto.AspNetCore.CloudFlare.SDK.csproj | 16 +- .../IsExternalInit.cs | 7 + .../WasmExportsGenerator.cs | 352 +++++++++++++ ...pto.AspNetCore.Wasm.SourceGenerator.csproj | 30 ++ .../WasmExportAttribute.cs | 25 + src/Zapto.AspNetCore.Wasm/WasmInterop.cs | 2 + .../ModuleInitializer.cs | 12 + .../Utils/MemoryAdditionalText.cs | 26 + .../WasmExportsGeneratorTests.cs | 468 ++++++++++++++++++ ...pNetCore.Wasm.SourceGenerator.Tests.csproj | 34 ++ .../xunit.runner.json | 4 + 14 files changed, 1167 insertions(+), 131 deletions(-) delete mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template create mode 100644 src/Zapto.AspNetCore.Wasm.SourceGenerator/IsExternalInit.cs create mode 100644 src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmExportsGenerator.cs create mode 100644 src/Zapto.AspNetCore.Wasm.SourceGenerator/Zapto.AspNetCore.Wasm.SourceGenerator.csproj create mode 100644 src/Zapto.AspNetCore.Wasm/WasmExportAttribute.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/ModuleInitializer.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Utils/MemoryAdditionalText.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests.csproj create mode 100644 tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/xunit.runner.json diff --git a/AspNetCore.sln b/AspNetCore.sln index 2a17dc2..93f8ca5 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1,76 +1,177 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNetCore", "AspNetCore", "{7F209FD2-1190-4721-9A08-9569AB4AA8D5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.NetFx", "src\Zapto.AspNetCore.NetFx\Zapto.AspNetCore.NetFx.csproj", "{C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sandbox", "Sandbox", "{DD6845EA-4012-4E0A-8B78-8BAE5FF6967B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebFormsApp", "sandbox\WebFormsApp\WebFormsApp.csproj", "{77FA9DA3-FB50-46EC-B9B8-905121594EA1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{B3DA0F06-4511-4791-BB12-A84655F02BFA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.NetFx.Tests", "tests\Zapto.AspNetCore.NetFx.Tests\Zapto.AspNetCore.NetFx.Tests.csproj", "{305C7374-1A54-4409-B527-968D49B258DF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Polyfill", "src\Zapto.AspNetCore.Polyfill\Zapto.AspNetCore.Polyfill.csproj", "{507A2467-52E9-4181-8414-426AC45E01B3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm", "src\Zapto.AspNetCore.Wasm\Zapto.AspNetCore.Wasm.csproj", "{A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SDK", "SDK", "{E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.CloudFlare.SDK", "sdk\Zapto.AspNetCore.CloudFlare.SDK\Zapto.AspNetCore.CloudFlare.SDK.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmApp", "sandbox\WasmApp\WasmApp.csproj", "{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.Tests", "tests\Zapto.AspNetCore.Wasm.Tests\Zapto.AspNetCore.Wasm.Tests.csproj", "{D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} - {77FA9DA3-FB50-46EC-B9B8-905121594EA1} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} - {305C7374-1A54-4409-B527-968D49B258DF} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} - {507A2467-52E9-4181-8414-426AC45E01B3} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} - {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} - {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F} = {E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B} - {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} - {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|Any CPU.Build.0 = Release|Any CPU - {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|Any CPU.Build.0 = Release|Any CPU - {305C7374-1A54-4409-B527-968D49B258DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {305C7374-1A54-4409-B527-968D49B258DF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {305C7374-1A54-4409-B527-968D49B258DF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {305C7374-1A54-4409-B527-968D49B258DF}.Release|Any CPU.Build.0 = Release|Any CPU - {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {507A2467-52E9-4181-8414-426AC45E01B3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {507A2467-52E9-4181-8414-426AC45E01B3}.Release|Any CPU.Build.0 = Release|Any CPU - {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|Any CPU.Build.0 = Release|Any CPU - {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU - {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU - {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNetCore", "AspNetCore", "{7F209FD2-1190-4721-9A08-9569AB4AA8D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.NetFx", "src\Zapto.AspNetCore.NetFx\Zapto.AspNetCore.NetFx.csproj", "{C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sandbox", "Sandbox", "{DD6845EA-4012-4E0A-8B78-8BAE5FF6967B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebFormsApp", "sandbox\WebFormsApp\WebFormsApp.csproj", "{77FA9DA3-FB50-46EC-B9B8-905121594EA1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{B3DA0F06-4511-4791-BB12-A84655F02BFA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.NetFx.Tests", "tests\Zapto.AspNetCore.NetFx.Tests\Zapto.AspNetCore.NetFx.Tests.csproj", "{305C7374-1A54-4409-B527-968D49B258DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Polyfill", "src\Zapto.AspNetCore.Polyfill\Zapto.AspNetCore.Polyfill.csproj", "{507A2467-52E9-4181-8414-426AC45E01B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm", "src\Zapto.AspNetCore.Wasm\Zapto.AspNetCore.Wasm.csproj", "{A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SDK", "SDK", "{E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.CloudFlare.SDK", "sdk\Zapto.AspNetCore.CloudFlare.SDK\Zapto.AspNetCore.CloudFlare.SDK.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmApp", "sandbox\WasmApp\WasmApp.csproj", "{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.Tests", "tests\Zapto.AspNetCore.Wasm.Tests\Zapto.AspNetCore.Wasm.Tests.csproj", "{D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.SourceGenerator", "src\Zapto.AspNetCore.Wasm.SourceGenerator\Zapto.AspNetCore.Wasm.SourceGenerator.csproj", "{7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.SourceGenerator.Tests", "tests\Zapto.AspNetCore.Wasm.SourceGenerator.Tests\Zapto.AspNetCore.Wasm.SourceGenerator.Tests.csproj", "{1333E8B4-070C-4C70-A9F2-070CAA66CDAD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|x64.Build.0 = Debug|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Debug|x86.Build.0 = Debug|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|Any CPU.Build.0 = Release|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|x64.ActiveCfg = Release|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|x64.Build.0 = Release|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|x86.ActiveCfg = Release|Any CPU + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4}.Release|x86.Build.0 = Release|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|x64.ActiveCfg = Debug|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|x64.Build.0 = Debug|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|x86.ActiveCfg = Debug|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Debug|x86.Build.0 = Debug|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|Any CPU.Build.0 = Release|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|x64.ActiveCfg = Release|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|x64.Build.0 = Release|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|x86.ActiveCfg = Release|Any CPU + {77FA9DA3-FB50-46EC-B9B8-905121594EA1}.Release|x86.Build.0 = Release|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Debug|x64.ActiveCfg = Debug|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Debug|x64.Build.0 = Debug|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Debug|x86.Build.0 = Debug|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Release|Any CPU.Build.0 = Release|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Release|x64.ActiveCfg = Release|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Release|x64.Build.0 = Release|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Release|x86.ActiveCfg = Release|Any CPU + {305C7374-1A54-4409-B527-968D49B258DF}.Release|x86.Build.0 = Release|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|x64.ActiveCfg = Debug|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|x64.Build.0 = Debug|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|x86.ActiveCfg = Debug|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Debug|x86.Build.0 = Debug|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Release|Any CPU.Build.0 = Release|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Release|x64.ActiveCfg = Release|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Release|x64.Build.0 = Release|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Release|x86.ActiveCfg = Release|Any CPU + {507A2467-52E9-4181-8414-426AC45E01B3}.Release|x86.Build.0 = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|x64.Build.0 = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Debug|x86.Build.0 = Debug|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|Any CPU.Build.0 = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|x64.ActiveCfg = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|x64.Build.0 = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|x86.ActiveCfg = Release|Any CPU + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A}.Release|x86.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Debug|x86.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F}.Release|x86.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x64.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x64.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Debug|x86.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x64.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x64.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x86.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}.Release|x86.Build.0 = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|x64.ActiveCfg = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|x64.Build.0 = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|x86.ActiveCfg = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Debug|x86.Build.0 = Debug|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|Any CPU.Build.0 = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|x64.ActiveCfg = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|x64.Build.0 = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|x86.ActiveCfg = Release|Any CPU + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A}.Release|x86.Build.0 = Release|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Debug|x64.ActiveCfg = Debug|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Debug|x64.Build.0 = Debug|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Debug|x86.ActiveCfg = Debug|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Debug|x86.Build.0 = Debug|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Release|Any CPU.Build.0 = Release|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Release|x64.ActiveCfg = Release|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Release|x64.Build.0 = Release|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Release|x86.ActiveCfg = Release|Any CPU + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F}.Release|x86.Build.0 = Release|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Debug|x64.ActiveCfg = Debug|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Debug|x64.Build.0 = Debug|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Debug|x86.ActiveCfg = Debug|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Debug|x86.Build.0 = Debug|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|Any CPU.Build.0 = Release|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|x64.ActiveCfg = Release|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|x64.Build.0 = Release|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|x86.ActiveCfg = Release|Any CPU + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C3CEF02D-43F9-4C28-BC1D-18ED3D1FC0E4} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {77FA9DA3-FB50-46EC-B9B8-905121594EA1} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} + {305C7374-1A54-4409-B527-968D49B258DF} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} + {507A2467-52E9-4181-8414-426AC45E01B3} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F} = {E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B} + {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} + {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {1333E8B4-070C-4C70-A9F2-070CAA66CDAD} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} + EndGlobalSection +EndGlobal diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets index 4cfab4f..17747a9 100644 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets @@ -251,37 +251,22 @@ - - $(BaseIntermediateOutputPath)generated\ - $(WasmExportsGeneratedDir)WasmExports.g.cs - $(RootNamespace) - $(AssemblyName) - - - - - - - <_WasmExportsTemplate>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)WasmExports.cs.template')) - <_WasmExportsContent>$(_WasmExportsTemplate.Replace('$NAMESPACE$', '$(WasmExportsNamespace)')) - - - - - - - - + + + + + + + + + diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template deleted file mode 100644 index c41d4c5..0000000 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/WasmExports.cs.template +++ /dev/null @@ -1,24 +0,0 @@ -// -// This file is generated by Zapto.AspNetCore.CloudFlare.SDK. -// Do not modify this file directly. -// - -using System; -using System.Runtime.InteropServices; -using Zapto.AspNetCore.Wasm; - -namespace $NAMESPACE$; - -/// -/// WASM exports that forward to the Zapto.AspNetCore.Wasm library. -/// These must be in the main assembly for ILC to export them. -/// -internal static class WasmExports -{ - [UnmanagedCallersOnly(EntryPoint = "BeginRequest")] - public static unsafe IntPtr BeginRequest(int contextLength, byte* contextPtr, int bodyLength, byte* bodyPtr) - => WasmInterop.ProcessRequest(contextLength, contextPtr, bodyLength, bodyPtr); - - [UnmanagedCallersOnly(EntryPoint = "EndRequest")] - public static void EndRequest(IntPtr ptr) => WasmInterop.FreeResponse(ptr); -} diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj index 6bb3c9c..ecb2657 100644 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj @@ -21,9 +21,23 @@ - + + + + + + + + + + <_PackageFiles Include="..\..\src\Zapto.AspNetCore.Wasm.SourceGenerator\bin\$(Configuration)\netstandard2.0\Zapto.AspNetCore.Wasm.SourceGenerator.dll" + PackagePath="analyzers/dotnet/cs" /> + + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Zapto.AspNetCore.Wasm/WasmExportAttribute.cs b/src/Zapto.AspNetCore.Wasm/WasmExportAttribute.cs new file mode 100644 index 0000000..387c6c7 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/WasmExportAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Zapto.AspNetCore.Wasm; + +/// +/// Marks a static method as a WASM export that should be forwarded from the main assembly. +/// The source generator will create a forwarding method with [UnmanagedCallersOnly] in the main assembly. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class WasmExportAttribute : Attribute +{ + /// + /// Creates a new instance of . + /// + /// The entry point name for the WASM export. + public WasmExportAttribute(string entryPoint) + { + EntryPoint = entryPoint; + } + + /// + /// Gets the entry point name for the WASM export. + /// + public string EntryPoint { get; } +} diff --git a/src/Zapto.AspNetCore.Wasm/WasmInterop.cs b/src/Zapto.AspNetCore.Wasm/WasmInterop.cs index e04e4e2..a3464a4 100644 --- a/src/Zapto.AspNetCore.Wasm/WasmInterop.cs +++ b/src/Zapto.AspNetCore.Wasm/WasmInterop.cs @@ -16,6 +16,7 @@ public static class WasmInterop /// Length of the request body. /// Pointer to the request body data. /// Pointer to the response data. + [WasmExport("BeginRequest")] public static unsafe IntPtr ProcessRequest(int contextLength, byte* contextPtr, int bodyLength, byte* bodyPtr) { var contextSpan = new Span(contextPtr, contextLength); @@ -41,6 +42,7 @@ public static unsafe IntPtr ProcessRequest(int contextLength, byte* contextPtr, /// Ends processing of an HTTP request and frees memory. Called from the main assembly's exported function. /// /// Pointer to the response data to free. + [WasmExport("EndRequest")] public static void FreeResponse(IntPtr ptr) { Marshal.FreeHGlobal(ptr); diff --git a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/ModuleInitializer.cs b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..952bbde --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/ModuleInitializer.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace Zapto.AspNetCore.Wasm.SourceGenerator.Tests; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + VerifySourceGenerators.Initialize(); + } +} diff --git a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Utils/MemoryAdditionalText.cs b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Utils/MemoryAdditionalText.cs new file mode 100644 index 0000000..8b3d8a8 --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Utils/MemoryAdditionalText.cs @@ -0,0 +1,26 @@ +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Zapto.AspNetCore.Wasm.SourceGenerator.Tests.Utils; + +/// +/// In-memory implementation of AdditionalText for testing. +/// +public sealed class MemoryAdditionalText : AdditionalText +{ + public MemoryAdditionalText(string path, string text) + { + Path = path; + Text = text; + } + + public override string Path { get; } + + public string Text { get; } + + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return SourceText.From(Text); + } +} diff --git a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs new file mode 100644 index 0000000..ed4c03c --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs @@ -0,0 +1,468 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Zapto.AspNetCore.Wasm.SourceGenerator.Tests; + +/// +/// Tests for the incremental source generator. +/// +public class WasmExportsGeneratorTests +{ + /// + /// The WasmExport attribute source code that must be included in test compilations. + /// + private const string WasmExportAttributeSource = """ + using System; + + namespace Zapto.AspNetCore.Wasm; + + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class WasmExportAttribute : Attribute + { + public WasmExportAttribute(string entryPoint) + { + EntryPoint = entryPoint; + } + + public string EntryPoint { get; } + } + """; + + [Fact] + public void Generator_IsIncremental() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + // Create a referenced library with WasmExport + var libraryCode = """ + using System; + using Zapto.AspNetCore.Wasm; + + namespace TestLibrary; + + public static class WasmInterop + { + [WasmExport("BeginRequest")] + public static unsafe IntPtr BeginRequest(int contextLength, byte* contextPtr) + => IntPtr.Zero; + + [WasmExport("EndRequest")] + public static void EndRequest(IntPtr ptr) { } + } + """; + + var attributeSyntaxTree = CSharpSyntaxTree.ParseText(WasmExportAttributeSource, parseOptions); + var librarySyntaxTree = CSharpSyntaxTree.ParseText(libraryCode, parseOptions); + var libraryCompilation = CSharpCompilation.Create( + "TestLibrary", + [attributeSyntaxTree, librarySyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + + // Create main assembly that references the library + var mainCode = """ + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + var libraryRef = libraryCompilation.ToMetadataReference(); + + var mainCompilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences().Add(libraryRef), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: true); + + // Act - run twice to test caching + driver = driver.RunGenerators(mainCompilation); + driver = driver.RunGenerators(mainCompilation); + + var runResult = driver.GetRunResult(); + var generatorResult = runResult.Results.Single(); + var outputs = generatorResult.TrackedSteps + .SelectMany(step => step.Value) + .SelectMany(step => step.Outputs) + .ToArray(); + + // Assert + Assert.NotEmpty(outputs); + Assert.All(outputs, output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason)); + } + + [Fact] + public void Generator_WithNoExports_GeneratesNothing() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + var mainCode = """ + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + + var compilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: false); + + // Act + driver = driver.RunGenerators(compilation); + + // Assert + var runResult = driver.GetRunResult(); + var generatorResult = runResult.Results.Single(); + Assert.Empty(generatorResult.GeneratedSources); + } + + [Fact] + public void Generator_WithExports_GeneratesForwardingMethods() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + var libraryCode = """ + using System; + using Zapto.AspNetCore.Wasm; + + namespace TestLibrary; + + public static class WasmInterop + { + [WasmExport("BeginRequest")] + public static unsafe IntPtr BeginRequest(int contextLength, byte* contextPtr, int bodyLength, byte* bodyPtr) + => IntPtr.Zero; + + [WasmExport("EndRequest")] + public static void EndRequest(IntPtr ptr) { } + } + """; + + var attributeSyntaxTree = CSharpSyntaxTree.ParseText(WasmExportAttributeSource, parseOptions); + var librarySyntaxTree = CSharpSyntaxTree.ParseText(libraryCode, parseOptions); + var libraryCompilation = CSharpCompilation.Create( + "TestLibrary", + [attributeSyntaxTree, librarySyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + + var mainCode = """ + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + var libraryRef = libraryCompilation.ToMetadataReference(); + + var mainCompilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences().Add(libraryRef), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: false); + + // Act + driver = driver.RunGenerators(mainCompilation); + + // Assert + var runResult = driver.GetRunResult(); + var generatorResult = runResult.Results.Single(); + + Assert.Single(generatorResult.GeneratedSources); + + var generatedSource = generatorResult.GeneratedSources[0]; + Assert.Equal("WasmExports.g.cs", generatedSource.HintName); + + var generatedCode = generatedSource.SourceText.ToString(); + + // Verify structure + Assert.Contains("namespace MainApp;", generatedCode); + Assert.Contains("internal static class WasmExports", generatedCode); + + // Verify BeginRequest export + Assert.Contains("[UnmanagedCallersOnly(EntryPoint = \"BeginRequest\")]", generatedCode); + Assert.Contains("BeginRequest(int contextLength, byte* contextPtr, int bodyLength, byte* bodyPtr)", generatedCode); + Assert.Contains("global::TestLibrary.WasmInterop.BeginRequest(contextLength, contextPtr, bodyLength, bodyPtr)", generatedCode); + + // Verify EndRequest export + Assert.Contains("[UnmanagedCallersOnly(EntryPoint = \"EndRequest\")]", generatedCode); + // IntPtr may be represented as nint or System.IntPtr depending on framework + Assert.True( + generatedCode.Contains("EndRequest(global::System.IntPtr ptr)") || + generatedCode.Contains("EndRequest(nint ptr)"), + $"Expected EndRequest parameter signature. Generated code:\n{generatedCode}"); + Assert.Contains("global::TestLibrary.WasmInterop.EndRequest(ptr)", generatedCode); + } + + [Fact] + public void Generator_WithMultipleLibraries_CombinesExports() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + // Create attribute assembly first + var attributeSyntaxTree = CSharpSyntaxTree.ParseText(WasmExportAttributeSource, parseOptions); + var attributeCompilation = CSharpCompilation.Create( + "Zapto.AspNetCore.Wasm", + [attributeSyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var attributeRef = attributeCompilation.ToMetadataReference(); + + var library1Code = """ + using System; + using Zapto.AspNetCore.Wasm; + + namespace Library1; + + public static class Exports + { + [WasmExport("Initialize")] + public static void Initialize() { } + } + """; + + var library2Code = """ + using System; + using Zapto.AspNetCore.Wasm; + + namespace Library2; + + public static class Exports + { + [WasmExport("Shutdown")] + public static void Shutdown() { } + } + """; + + var library1SyntaxTree = CSharpSyntaxTree.ParseText(library1Code, parseOptions); + var library2SyntaxTree = CSharpSyntaxTree.ParseText(library2Code, parseOptions); + + var library1Compilation = CSharpCompilation.Create( + "Library1", + [library1SyntaxTree], + GetReferences().Add(attributeRef), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var library2Compilation = CSharpCompilation.Create( + "Library2", + [library2SyntaxTree], + GetReferences().Add(attributeRef), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var mainCode = """ + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + + var mainCompilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences() + .Add(attributeRef) + .Add(library1Compilation.ToMetadataReference()) + .Add(library2Compilation.ToMetadataReference()), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: false); + + // Act + driver = driver.RunGenerators(mainCompilation); + + // Assert + var runResult = driver.GetRunResult(); + var generatorResult = runResult.Results.Single(); + + Assert.Single(generatorResult.GeneratedSources); + + var generatedCode = generatorResult.GeneratedSources[0].SourceText.ToString(); + + // Verify both exports are present + Assert.Contains("EntryPoint = \"Initialize\"", generatedCode); + Assert.Contains("EntryPoint = \"Shutdown\"", generatedCode); + } + + [Fact] + public void Generator_WithRefParameters_GeneratesCorrectSignatures() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + var libraryCode = """ + using System; + using Zapto.AspNetCore.Wasm; + + namespace TestLibrary; + + public static class WasmInterop + { + [WasmExport("ProcessWithRef")] + public static void ProcessWithRef(ref int value) { } + + [WasmExport("ProcessWithOut")] + public static void ProcessWithOut(out int value) { value = 0; } + } + """; + + var attributeSyntaxTree = CSharpSyntaxTree.ParseText(WasmExportAttributeSource, parseOptions); + var librarySyntaxTree = CSharpSyntaxTree.ParseText(libraryCode, parseOptions); + var libraryCompilation = CSharpCompilation.Create( + "TestLibrary", + [attributeSyntaxTree, librarySyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var mainCode = """ + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + var libraryRef = libraryCompilation.ToMetadataReference(); + + var mainCompilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences().Add(libraryRef), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: false); + + // Act + driver = driver.RunGenerators(mainCompilation); + + // Assert + var runResult = driver.GetRunResult(); + var generatedCode = runResult.Results.Single().GeneratedSources.Single().SourceText.ToString(); + + Assert.Contains("ProcessWithRef(ref int value)", generatedCode); + Assert.Contains("ProcessWithRef(ref value)", generatedCode); + Assert.Contains("ProcessWithOut(out int value)", generatedCode); + Assert.Contains("ProcessWithOut(out value)", generatedCode); + } + + [Fact] + public void Generator_IgnoresSystemAssemblies() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + // Just a simple main app - System assemblies should be ignored + var mainCode = """ + using System.Runtime.InteropServices; + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + + var mainCompilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: false); + + // Act + driver = driver.RunGenerators(mainCompilation); + + // Assert - No exports should be generated from System assemblies + var runResult = driver.GetRunResult(); + var generatorResult = runResult.Results.Single(); + Assert.Empty(generatorResult.GeneratedSources); + } + + [Fact] + public void Generator_UsesEntryPointAsMethodName() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + var libraryCode = """ + using System; + using Zapto.AspNetCore.Wasm; + + namespace TestLibrary; + + public static class WasmInterop + { + [WasmExport("custom_entry_point")] + public static void InternalMethodName() { } + } + """; + + var attributeSyntaxTree = CSharpSyntaxTree.ParseText(WasmExportAttributeSource, parseOptions); + var librarySyntaxTree = CSharpSyntaxTree.ParseText(libraryCode, parseOptions); + var libraryCompilation = CSharpCompilation.Create( + "TestLibrary", + [attributeSyntaxTree, librarySyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var mainCode = """ + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + + var mainCompilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences().Add(libraryCompilation.ToMetadataReference()), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: false); + + // Act + driver = driver.RunGenerators(mainCompilation); + + // Assert + var generatedCode = driver.GetRunResult().Results.Single().GeneratedSources.Single().SourceText.ToString(); + + Assert.Contains("EntryPoint = \"custom_entry_point\"", generatedCode); + Assert.Contains("public static void custom_entry_point()", generatedCode); + Assert.Contains("InternalMethodName()", generatedCode); + } + + private static ImmutableArray GetReferences() + { + var trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + var assemblies = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(trustedAssemblies)) + { + foreach (var path in trustedAssemblies.Split(Path.PathSeparator)) + { + assemblies.Add(path); + } + } + + return assemblies + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToImmutableArray(); + } + + private static GeneratorDriver CreateDriver(IIncrementalGenerator generator, CSharpParseOptions parseOptions, bool trackSteps) + { + var options = new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackSteps); + + return CSharpGeneratorDriver.Create( + generators: [generator.AsSourceGenerator()], + parseOptions: parseOptions, + driverOptions: options); + } +} diff --git a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests.csproj b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests.csproj new file mode 100644 index 0000000..fdceb97 --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + preview + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/xunit.runner.json b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/xunit.runner.json new file mode 100644 index 0000000..396ea10 --- /dev/null +++ b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "methodDisplay": "method" +} From e6fa732f7fa4931854dc0c327aefe5f625170e24 Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Sat, 17 Jan 2026 14:27:04 +0100 Subject: [PATCH 3/6] feat: WasmLibraryImport --- AspNetCore.sln | 17 ++ sandbox/WasmApp.Library/Example.wasmlib.js | 8 + sandbox/WasmApp.Library/NativeMethods.cs | 13 + .../WasmApp.Library/WasmApp.Library.csproj | 23 ++ sandbox/WasmApp/Program.cs | 22 +- sandbox/WasmApp/WasmApp.csproj | 4 + sandbox/WasmApp/test-wrangler.sh | 27 ++ .../Sdk/Sdk.targets | 43 +++ .../Sdk/index.js | 162 ++++++++++- .../ModuleInitializer.cs | 18 ++ .../WasmExportAttribute.cs | 28 ++ .../WasmLibraryImportAttribute.cs | 31 ++ .../WasmLibraryRuntime.cs | 92 ++++++ .../WasmLibraryRuntime.rd.xml | 11 + .../WasmSynchronizationContext.cs | 93 ++++++ .../Zapto.AspNetCore.Wasm.Interop.csproj | 15 + .../WasmExportsGenerator.cs | 21 +- .../WasmLibraryImportGenerator.cs | 271 ++++++++++++++++++ src/Zapto.AspNetCore.Wasm/WasmInterop.cs | 98 ++++++- .../Zapto.AspNetCore.Wasm.csproj | 4 + .../WasmExportsGeneratorTests.cs | 83 ++++++ .../WasmLibraryImportGeneratorTests.cs | 0 .../WasmEndpointTests.cs | 11 + .../WranglerFixture.cs | 1 - 24 files changed, 1051 insertions(+), 45 deletions(-) create mode 100644 sandbox/WasmApp.Library/Example.wasmlib.js create mode 100644 sandbox/WasmApp.Library/NativeMethods.cs create mode 100644 sandbox/WasmApp.Library/WasmApp.Library.csproj create mode 100644 sandbox/WasmApp/test-wrangler.sh create mode 100644 src/Zapto.AspNetCore.Wasm.Interop/ModuleInitializer.cs create mode 100644 src/Zapto.AspNetCore.Wasm.Interop/WasmExportAttribute.cs create mode 100644 src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryImportAttribute.cs create mode 100644 src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.cs create mode 100644 src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.rd.xml create mode 100644 src/Zapto.AspNetCore.Wasm.Interop/WasmSynchronizationContext.cs create mode 100644 src/Zapto.AspNetCore.Wasm.Interop/Zapto.AspNetCore.Wasm.Interop.csproj create mode 100644 src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmLibraryImportGenerator.cs create mode 100644 tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmLibraryImportGeneratorTests.cs diff --git a/AspNetCore.sln b/AspNetCore.sln index 93f8ca5..af1fb42 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -28,6 +28,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.Sourc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.SourceGenerator.Tests", "tests\Zapto.AspNetCore.Wasm.SourceGenerator.Tests\Zapto.AspNetCore.Wasm.SourceGenerator.Tests.csproj", "{1333E8B4-070C-4C70-A9F2-070CAA66CDAD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.Interop", "src\Zapto.AspNetCore.Wasm.Interop\Zapto.AspNetCore.Wasm.Interop.csproj", "{3827F320-31F0-4AE3-A048-8157C66DBEC8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -158,6 +162,18 @@ Global {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|x64.Build.0 = Release|Any CPU {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|x86.ActiveCfg = Release|Any CPU {1333E8B4-070C-4C70-A9F2-070CAA66CDAD}.Release|x86.Build.0 = Release|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Debug|x64.ActiveCfg = Debug|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Debug|x64.Build.0 = Debug|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Debug|x86.ActiveCfg = Debug|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Debug|x86.Build.0 = Debug|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|Any CPU.Build.0 = Release|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|x64.ActiveCfg = Release|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|x64.Build.0 = Release|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|x86.ActiveCfg = Release|Any CPU + {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -173,5 +189,6 @@ Global {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} {1333E8B4-070C-4C70-A9F2-070CAA66CDAD} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} + {3827F320-31F0-4AE3-A048-8157C66DBEC8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/sandbox/WasmApp.Library/Example.wasmlib.js b/sandbox/WasmApp.Library/Example.wasmlib.js new file mode 100644 index 0000000..b67b76d --- /dev/null +++ b/sandbox/WasmApp.Library/Example.wasmlib.js @@ -0,0 +1,8 @@ +/** + * Greets a person by name. + * @param {string} name - The name of the person to greet. + * @returns {Promise} A greeting message. + */ +export function greet(name) { + return Promise.resolve(`Greetings from Example, ${name}!`); +} diff --git a/sandbox/WasmApp.Library/NativeMethods.cs b/sandbox/WasmApp.Library/NativeMethods.cs new file mode 100644 index 0000000..f47bbae --- /dev/null +++ b/sandbox/WasmApp.Library/NativeMethods.cs @@ -0,0 +1,13 @@ +using Zapto.AspNetCore.Wasm.Interop; + +namespace WasmApp.Library; + +public static partial class NativeMethods +{ + /// + /// Calls the JavaScript greet function from the Example module. + /// WASM import: Example_greet(byte* namePtr, int nameLen, int callbackId) + /// + [WasmLibraryImport("greet", "Example")] + public static partial Task GreetAsync(string name); +} diff --git a/sandbox/WasmApp.Library/WasmApp.Library.csproj b/sandbox/WasmApp.Library/WasmApp.Library.csproj new file mode 100644 index 0000000..7c7705a --- /dev/null +++ b/sandbox/WasmApp.Library/WasmApp.Library.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + browser-wasm + enable + true + enable + + + + + + + + + + PreserveNewest + PreserveNewest + + + + diff --git a/sandbox/WasmApp/Program.cs b/sandbox/WasmApp/Program.cs index b00d8f0..a9d12bf 100644 --- a/sandbox/WasmApp/Program.cs +++ b/sandbox/WasmApp/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using WasmApp.Library; var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); @@ -13,27 +14,10 @@ options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default); }); -builder.WebHost.UseWasmServer(options => -{ - options.IncludeExceptionDetails = true; -}); +builder.WebHost.UseWasmServer(); var app = builder.Build(); -app.Use(async (context, next) => -{ - try - { - await next(); - } - catch (Exception ex) - { - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "text/plain"; - await context.Response.WriteAsync(ex.ToString()); - } -}); - app.UseRouting(); app.MapGet("/", () => "Hello from ASP.NET Core on Cloudflare Workers!"); @@ -45,6 +29,8 @@ app.MapGet("/api/greet/{name}", (string name) => $"Hello, {name}!"); +app.MapGet("/library/greet/{name}", async (string name) => await NativeMethods.GreetAsync(name)); + app.MapPost("/api/echo", async context => { using var reader = new StreamReader(context.Request.Body); diff --git a/sandbox/WasmApp/WasmApp.csproj b/sandbox/WasmApp/WasmApp.csproj index 353a75d..2bb4ad7 100644 --- a/sandbox/WasmApp/WasmApp.csproj +++ b/sandbox/WasmApp/WasmApp.csproj @@ -7,6 +7,10 @@ true + + + + diff --git a/sandbox/WasmApp/test-wrangler.sh b/sandbox/WasmApp/test-wrangler.sh new file mode 100644 index 0000000..f9fec75 --- /dev/null +++ b/sandbox/WasmApp/test-wrangler.sh @@ -0,0 +1,27 @@ +#!/bin/bash +cd /c/Sources/AspNetCore/bin/Release/net10.0/browser-wasm/cloudflare + +echo "Starting wrangler..." +npx wrangler dev --port 8792 --show-interactive-dev-session=false 2>&1 & +WRANGLER_PID=$! + +echo "Waiting for wrangler to start (PID: $WRANGLER_PID)..." +sleep 2 + +echo "" +echo "=== Testing root ===" +curl -v --max-time 20 http://127.0.0.1:8792/ 2>&1 +echo "" + +echo "" +echo "=== Testing library/greet ===" +curl -s --max-time 30 http://127.0.0.1:8792/library/greet/TestUser +CURL_EXIT=$? +echo "" +echo "curl exit code: $CURL_EXIT" + +echo "" +echo "=== Stopping wrangler ===" +sleep 2 +kill $WRANGLER_PID 2>/dev/null +echo "Done" diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets index 17747a9..f296347 100644 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets @@ -105,13 +105,45 @@ + + <_DiscoveredWasmLibraryScripts Include="$(PublishDir)*.wasmlib.js" /> + + + + + + + <_LibraryImportLines Include="import __STAR__ as _lib_$([System.String]::Copy('%(_DiscoveredWasmLibraryScripts.Filename)').Replace('.wasmlib', '').Replace('.', '_').Replace('-', '_')) from __QUOTE__./%(_DiscoveredWasmLibraryScripts.Filename)%(_DiscoveredWasmLibraryScripts.Extension)__QUOTE____SEMI__" + Condition="'@(_DiscoveredWasmLibraryScripts)' != ''" /> + <_LibraryModuleLines Include=" __QUOTE__$([System.String]::Copy('%(_DiscoveredWasmLibraryScripts.Filename)').Replace('.wasmlib', ''))__QUOTE__: _lib_$([System.String]::Copy('%(_DiscoveredWasmLibraryScripts.Filename)').Replace('.wasmlib', '').Replace('.', '_').Replace('-', '_'))," + Condition="'@(_DiscoveredWasmLibraryScripts)' != ''" /> + + + + <_LibraryImportStatements>@(_LibraryImportLines, '%0a') + <_LibraryImportStatements>$(_LibraryImportStatements.Replace('__STAR__', '*')) + <_LibraryImportStatements>$(_LibraryImportStatements.Replace('__QUOTE__', "'")) + <_LibraryImportStatements>$(_LibraryImportStatements.Replace('__SEMI__', ';')) + <_LibraryModuleRegistrations>@(_LibraryModuleLines, '%0a') + <_LibraryModuleRegistrations>$(_LibraryModuleRegistrations.Replace('__QUOTE__', "'")) + + <_CloudflareIndexTemplate>$([System.IO.File]::ReadAllText('$(CloudflareIndexTemplatePath)')) <_CloudflareIndexContent>$(_CloudflareIndexTemplate.Replace('__MAIN_ASSEMBLY__', '$(AssemblyName)')) + <_CloudflareIndexContent>$(_CloudflareIndexContent.Replace('// __LIBRARY_IMPORTS__', '$(_LibraryImportStatements)')) + <_CloudflareIndexContent>$(_CloudflareIndexContent.Replace(' // __LIBRARY_MODULES__', '$(_LibraryModuleRegistrations)')) @@ -184,6 +217,16 @@ SkipUnchangedFiles="true" Condition="Exists('$(PublishDir)dotnet.runtime.js')" /> + + + <_WasmLibScriptsToCopy Include="$(PublishDir)*.wasmlib.js" /> + + + + { + const arg0 = readString(arg0Ptr, arg0Len); + + try { + const result = func(arg0); + + if (result && typeof result.then === 'function') { + const pendingPromise = result + .then(value => { + completeCallback(callbackId, value == null ? null : String(value)); + }) + .catch(err => { + completeCallbackError(callbackId, err?.message || String(err)); + }) + .finally(() => { + pendingCallbacks.delete(callbackId); + }); + pendingCallbacks.set(callbackId, pendingPromise); + } else { + completeCallback(callbackId, result == null ? null : String(result)); + } + } catch (err) { + completeCallbackError(callbackId, err?.message || String(err)); + } + }; +} + +function createWasmImports() { + const imports = {}; + + for (const [moduleName, moduleExports] of Object.entries(libraryModules)) { + for (const [funcName, func] of Object.entries(moduleExports)) { + if (typeof func !== 'function') continue; + imports[`_${moduleName}_${funcName}`] = createImportFunction(moduleName, funcName, func); + } + } + + return imports; +} + let cachedModule; function load() { @@ -85,12 +172,14 @@ function load() { }; setRuntimeGlobals(runtimeGlobals); - initializeExports(runtimeGlobals); + const dotnetApi = initializeExports(runtimeGlobals); await configureRuntimeStartup(moduleConfig); runtimeGlobals.runtimeHelpers?.coreAssetsInMemory?.promise_control?.resolve?.(); runtimeGlobals.runtimeHelpers?.allAssetsInMemory?.promise_control?.resolve?.(); + const customImports = createWasmImports(); + const moduleFactory = (module) => { const base = {}; if (module && (typeof module === 'object' || typeof module === 'function')) { @@ -100,10 +189,24 @@ function load() { } } } - + const merged = Object.assign(base, moduleConfig, { locateFile: () => './dotnet.native.wasm', instantiateWasm: (info, receiveInstance) => { + const imports = info.a || info.env || {}; + for (const [key, fn] of Object.entries(imports)) { + if (typeof fn === 'function' && fn.stub) { + const fnString = fn.toString(); + for (const [importName, importFn] of Object.entries(customImports)) { + const funcName = importName.slice(1); + if (fnString.includes(funcName)) { + imports[key] = importFn; + break; + } + } + } + } + WebAssembly.instantiate(worker, info).then(instance => { receiveInstance(instance, worker); }).catch(err => { @@ -129,14 +232,31 @@ function load() { }; const runtime = await createDotnetRuntime(moduleFactory); + + wasmRuntime = runtime; - // Expose Emscripten functions to the internal $e reference Object.assign(runtimeGlobals.module, runtime); - // Register JSExports + runtimeGlobals.runtimeHelpers.mono_wasm_bindings_is_ready = true; + + const setModuleImports = dotnetApi?.setModuleImports || runtime?.setModuleImports; + if (setModuleImports) { + for (const [moduleName, moduleExports] of Object.entries(libraryModules)) { + setModuleImports(moduleName, moduleExports); + } + } + runtime._WasmApp__GeneratedInitializer__Register_?.(); - // Call entry point directly (bypasses runMain which uses unavailable Emscripten helpers) + const mono_wasm_assembly_load = runtime.cwrap?.('mono_wasm_assembly_load', 'number', ['string']) + || runtime._mono_wasm_assembly_load; + if (mono_wasm_assembly_load) { + try { + mono_wasm_assembly_load('System.Runtime.InteropServices.JavaScript'); + } catch { + } + } + const callEntrypoint = runtime._System_Runtime_InteropServices_JavaScript_JavaScriptExports_CallEntrypoint; if (typeof callEntrypoint === 'function') { const frameSize = 5 * 32; @@ -149,6 +269,9 @@ function load() { const beginRequest = runtime.cwrap('BeginRequest', 'number', ['number', 'array', 'number', 'array']); const endRequest = runtime.cwrap('EndRequest', null, ['number']); + const getRequestResult = runtime._GetRequestResult; + const isRequestComplete = runtime._IsRequestComplete; + const encoder = new TextEncoder(); const decoder = new TextDecoder('utf-8'); @@ -160,11 +283,8 @@ function load() { (memory[ptr] & 0xFF); }; - return (requestContext, requestBody) => { - const json = encoder.encode(JSON.stringify(requestContext)); - const ptr = beginRequest(json.length, json, requestBody.length, requestBody); + const parseResponse = (ptr) => { const memory = runtime['HEAP8']; - let offset = ptr; const responseBodyLength = getInt32(offset); offset += 4; @@ -200,6 +320,28 @@ function load() { const responseContext = JSON.parse(decoder.decode(memory.subarray(offset, offset + responseLength))); return { body, response: responseContext }; }; + + return async (requestContext, requestBody) => { + const json = encoder.encode(JSON.stringify(requestContext)); + let ptr = beginRequest(json.length, json, requestBody.length, requestBody); + + if (ptr === 0) { + while (pendingCallbacks.size > 0) { + const pendingPromises = Array.from(pendingCallbacks.values()); + await Promise.all(pendingPromises); + } + + if (isRequestComplete && isRequestComplete() !== 0) { + ptr = getRequestResult ? getRequestResult() : 0; + } + + if (ptr === 0) { + throw new Error('Request did not complete after all callbacks finished'); + } + } + + return parseResponse(ptr); + }; })(); } @@ -214,7 +356,7 @@ export default { headers[key] = value; } - const { body, response } = processRequest( + const { body, response } = await processRequest( { method: request.method, url: request.url, headers }, requestBody ); diff --git a/src/Zapto.AspNetCore.Wasm.Interop/ModuleInitializer.cs b/src/Zapto.AspNetCore.Wasm.Interop/ModuleInitializer.cs new file mode 100644 index 0000000..5a4fc07 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.Interop/ModuleInitializer.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace Zapto.AspNetCore.Wasm.Interop; + +/// +/// Module initializer to ensure WasmLibraryRuntime exports are preserved by the linker. +/// +internal static class ModuleInitializer +{ + /// + /// Forces the runtime type to be referenced, preventing tree-shaking of exports. + /// + [ModuleInitializer] + public static void Initialize() + { + _ = typeof(WasmLibraryRuntime); + } +} diff --git a/src/Zapto.AspNetCore.Wasm.Interop/WasmExportAttribute.cs b/src/Zapto.AspNetCore.Wasm.Interop/WasmExportAttribute.cs new file mode 100644 index 0000000..abe899a --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.Interop/WasmExportAttribute.cs @@ -0,0 +1,28 @@ +namespace Zapto.AspNetCore.Wasm.Interop; + +/// +/// Marks a static method as a WASM export that should be forwarded from the main assembly. +/// The source generator will create a forwarding method with [UnmanagedCallersOnly] in the main assembly. +/// +/// +/// This attribute is used to mark methods that need to be callable from JavaScript. +/// Since NativeAOT only exports [UnmanagedCallersOnly] methods from the main assembly, +/// the source generator will generate forwarding methods in the consuming assembly. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class WasmExportAttribute : Attribute +{ + /// + /// Creates a new instance of . + /// + /// The entry point name for the WASM export. + public WasmExportAttribute(string entryPoint) + { + EntryPoint = entryPoint; + } + + /// + /// Gets the entry point name for the WASM export. + /// + public string EntryPoint { get; } +} diff --git a/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryImportAttribute.cs b/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryImportAttribute.cs new file mode 100644 index 0000000..7eb6edc --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryImportAttribute.cs @@ -0,0 +1,31 @@ +namespace Zapto.AspNetCore.Wasm.Interop; + +/// +/// Marks a partial method as a WASM library import. +/// The source generator will generate the implementation that marshals +/// strings to/from WASM memory and handles async Task completion via callbacks. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class WasmLibraryImportAttribute : Attribute +{ + /// + /// The name of the JavaScript function to call. + /// + public string FunctionName { get; } + + /// + /// The module name prefix. The WASM import will be named "{ModuleName}_{FunctionName}". + /// + public string ModuleName { get; } + + /// + /// Creates a new WasmLibraryImportAttribute. + /// + /// The JavaScript function name. + /// The module name prefix (default: "lib"). + public WasmLibraryImportAttribute(string functionName, string moduleName = "lib") + { + FunctionName = functionName; + ModuleName = moduleName; + } +} diff --git a/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.cs b/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.cs new file mode 100644 index 0000000..830c88f --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace Zapto.AspNetCore.Wasm.Interop; + +/// +/// Represents the result of a WASM library callback. +/// +public readonly struct WasmCallbackResult +{ + public readonly nint Ptr; + public readonly int Length; + + public WasmCallbackResult(nint ptr, int length) + { + Ptr = ptr; + Length = length; + } +} + +/// +/// Runtime support for WASM library imports. +/// Handles callback registration for async operations. +/// +public static class WasmLibraryRuntime +{ + private static int _nextCallbackId = 1; + private static readonly ConcurrentDictionary> _pendingCallbacks = new(); + + /// + /// Gets the number of pending callbacks waiting for JavaScript completion. + /// + public static int PendingCallbackCount => _pendingCallbacks.Count; + + /// + /// Registers a callback and returns the callback ID. + /// This method also ensures the callback methods are preserved by the linker. + /// + [DynamicDependency(nameof(CompleteCallback), typeof(WasmLibraryRuntime))] + [DynamicDependency(nameof(CompleteCallbackError), typeof(WasmLibraryRuntime))] + public static int RegisterCallback(TaskCompletionSource tcs) + { + var id = Interlocked.Increment(ref _nextCallbackId); + _pendingCallbacks[id] = tcs; + return id; + } + + /// + /// Called from JavaScript when an async operation completes successfully. + /// This method completes the callback and immediately runs all pending continuations. + /// + /// The callback ID returned from RegisterCallback. + /// Pointer to the UTF-8 result string (0 if null). + /// Length of the result in bytes. + [WasmExport("WasmLibrary_CompleteCallback")] + public static unsafe void CompleteCallback(int callbackId, byte* resultPtr, int resultLen) + { + if (!_pendingCallbacks.TryRemove(callbackId, out var tcs)) + return; + + tcs.TrySetResult(new WasmCallbackResult((nint)resultPtr, resultLen)); + + WasmSynchronizationContext.Instance.PumpAll(); + } + + /// + /// Called from JavaScript when an async operation fails. + /// This method completes the callback with an error and immediately runs all pending continuations. + /// + /// The callback ID returned from RegisterCallback. + /// Pointer to the UTF-8 error message. + /// Length of the error message in bytes. + [WasmExport("WasmLibrary_CompleteCallbackError")] + public static unsafe void CompleteCallbackError(int callbackId, byte* errorPtr, int errorLen) + { + if (!_pendingCallbacks.TryRemove(callbackId, out var tcs)) + { + return; + } + + string errorMessage = "Unknown error"; + if (errorPtr != null && errorLen > 0) + { + errorMessage = System.Text.Encoding.UTF8.GetString(errorPtr, errorLen); + } + + tcs.TrySetException(new InvalidOperationException(errorMessage)); + + WasmSynchronizationContext.Instance.PumpAll(); + } +} diff --git a/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.rd.xml b/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.rd.xml new file mode 100644 index 0000000..1db2642 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.Interop/WasmLibraryRuntime.rd.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Zapto.AspNetCore.Wasm.Interop/WasmSynchronizationContext.cs b/src/Zapto.AspNetCore.Wasm.Interop/WasmSynchronizationContext.cs new file mode 100644 index 0000000..19dfe14 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.Interop/WasmSynchronizationContext.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; + +namespace Zapto.AspNetCore.Wasm.Interop; + +/// +/// A SynchronizationContext for single-threaded WASM that queues work items +/// and allows them to be pumped from JavaScript. +/// +public sealed class WasmSynchronizationContext : SynchronizationContext +{ + private static WasmSynchronizationContext? _current; + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _workQueue = new(); + + /// + /// Gets the current WASM synchronization context, creating one if necessary. + /// + public static WasmSynchronizationContext Instance => _current ??= new WasmSynchronizationContext(); + + /// + /// Installs this synchronization context as the current context. + /// + public static void Install() + { + SetSynchronizationContext(Instance); + } + + /// + /// Gets whether there is pending work in the queue. + /// + public bool HasPendingWork => !_workQueue.IsEmpty; + + /// + /// Processes all pending work items in the queue. + /// + /// The number of work items processed. + public int PumpAll() + { + int count = 0; + while (_workQueue.TryDequeue(out var item)) + { + try + { + item.Callback(item.State); + } + catch + { + // Swallow exceptions to prevent breaking the pump loop + // In production, you might want to log these + } + count++; + } + return count; + } + + /// + /// Processes a single pending work item. + /// + /// True if a work item was processed, false if the queue was empty. + public bool PumpOne() + { + if (_workQueue.TryDequeue(out var item)) + { + try + { + item.Callback(item.State); + } + catch + { + } + return true; + } + return false; + } + + /// + public override void Post(SendOrPostCallback d, object? state) + { + _workQueue.Enqueue((d, state)); + } + + /// + public override void Send(SendOrPostCallback d, object? state) + { + // In single-threaded WASM, we can execute synchronously + d(state); + } + + /// + public override SynchronizationContext CreateCopy() + { + return this; // Single instance for WASM + } +} diff --git a/src/Zapto.AspNetCore.Wasm.Interop/Zapto.AspNetCore.Wasm.Interop.csproj b/src/Zapto.AspNetCore.Wasm.Interop/Zapto.AspNetCore.Wasm.Interop.csproj new file mode 100644 index 0000000..2adb8c0 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.Interop/Zapto.AspNetCore.Wasm.Interop.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmExportsGenerator.cs b/src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmExportsGenerator.cs index 205bb8c..9607b77 100644 --- a/src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmExportsGenerator.cs +++ b/src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmExportsGenerator.cs @@ -17,19 +17,24 @@ namespace Zapto.AspNetCore.Wasm.SourceGenerator; [Generator(LanguageNames.CSharp)] public sealed class WasmExportsGenerator : IIncrementalGenerator { + /// + /// WasmExport attribute from the main Wasm assembly. + /// private const string WasmExportAttribute = "Zapto.AspNetCore.Wasm.WasmExportAttribute"; + /// + /// WasmExport attribute from the Interop assembly (for runtime exports). + /// + private const string WasmExportInteropAttribute = "Zapto.AspNetCore.Wasm.Interop.WasmExportAttribute"; + public void Initialize(IncrementalGeneratorInitializationContext context) { - // Get the assembly name for the namespace var assemblyNameProvider = context.CompilationProvider .Select(static (compilation, _) => GetRootNamespace(compilation)); - // Find all [UnmanagedCallersOnly] methods from referenced assemblies var exportsFromReferences = context.CompilationProvider .Select(static (compilation, ct) => GetExportsFromReferences(compilation, ct)); - // Combine and generate var combined = assemblyNameProvider.Combine(exportsFromReferences); context.RegisterSourceOutput(combined, static (spc, source) => @@ -119,13 +124,11 @@ private static void FindExportsInType( HashSet processedMethods, System.Threading.CancellationToken cancellationToken) { - // Process nested types foreach (var nestedType in typeSymbol.GetTypeMembers()) { FindExportsInType(nestedType, exports, processedMethods, cancellationToken); } - // Find methods with [WasmExport] foreach (var member in typeSymbol.GetMembers()) { cancellationToken.ThrowIfCancellationRequested(); @@ -141,21 +144,23 @@ private static void FindExportsInType( } var wasmExportAttr = methodSymbol.GetAttributes() - .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == WasmExportAttribute); + .FirstOrDefault(attr => + { + var attrName = attr.AttributeClass?.ToDisplayString(); + return attrName == WasmExportAttribute || attrName == WasmExportInteropAttribute; + }); if (wasmExportAttr == null) { continue; } - // Get the EntryPoint from the attribute constructor argument var entryPoint = GetEntryPointFromAttribute(wasmExportAttr); if (string.IsNullOrEmpty(entryPoint)) { entryPoint = methodSymbol.Name; } - // Create a unique key to avoid duplicates var key = $"{entryPoint}"; if (!processedMethods.Add(key)) { diff --git a/src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmLibraryImportGenerator.cs b/src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmLibraryImportGenerator.cs new file mode 100644 index 0000000..04ebac3 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.SourceGenerator/WasmLibraryImportGenerator.cs @@ -0,0 +1,271 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Zapto.AspNetCore.Wasm.SourceGenerator; + +/// +/// Incremental source generator that generates implementations for methods marked with [WasmLibraryImport]. +/// +[Generator(LanguageNames.CSharp)] +public sealed class WasmLibraryImportGenerator : IIncrementalGenerator +{ + private const string WasmLibraryImportAttribute = "Zapto.AspNetCore.Wasm.Interop.WasmLibraryImportAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all partial methods with [WasmLibraryImport] attribute + var methodsWithAttribute = context.SyntaxProvider + .ForAttributeWithMetadataName( + WasmLibraryImportAttribute, + predicate: static (node, _) => node is MethodDeclarationSyntax, + transform: static (context, ct) => GetMethodInfo(context, ct)) + .Where(static m => m is not null) + .Select(static (m, _) => m!.Value); + + // Collect all methods and generate + var collectedMethods = methodsWithAttribute.Collect(); + + context.RegisterSourceOutput(collectedMethods, GenerateSource); + } + + private static void GenerateSource(SourceProductionContext spc, ImmutableArray methods) + { + if (methods.IsEmpty) + { + return; + } + + var groupedByType = methods.GroupBy(m => (m.Namespace, m.TypeName)); + + foreach (var group in groupedByType) + { + var code = GenerateImplementation(group.Key.Namespace, group.Key.TypeName, group.ToArray()); + var fileName = $"{group.Key.Namespace}.{group.Key.TypeName}.WasmLibraryImports.g.cs"; + spc.AddSource(fileName, SourceText.From(code, Encoding.UTF8)); + } + } + + private static WasmLibraryImportInfo? GetMethodInfo(GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken ct) + { + if (context.TargetSymbol is not IMethodSymbol methodSymbol) + { + return null; + } + + if (!methodSymbol.IsPartialDefinition) + { + return null; + } + + var attributeData = context.Attributes.FirstOrDefault(); + if (attributeData is null) + { + return null; + } + + string? functionName = null; + string? moduleName = null; + + if (attributeData.ConstructorArguments.Length >= 1) + { + functionName = attributeData.ConstructorArguments[0].Value as string; + } + if (attributeData.ConstructorArguments.Length >= 2) + { + moduleName = attributeData.ConstructorArguments[1].Value as string; + } + + if (string.IsNullOrEmpty(functionName)) + { + return null; + } + + var containingType = methodSymbol.ContainingType; + var namespaceName = containingType.ContainingNamespace.ToDisplayString(); + var typeName = containingType.Name; + + var returnType = methodSymbol.ReturnType; + var isAsync = returnType.OriginalDefinition.ToDisplayString() == "System.Threading.Tasks.Task" + || returnType.ToDisplayString() == "System.Threading.Tasks.Task"; + + string? asyncResultType = null; + if (isAsync && returnType is INamedTypeSymbol namedType && namedType.TypeArguments.Length == 1) + { + asyncResultType = namedType.TypeArguments[0].ToDisplayString(); + } + + var parameters = methodSymbol.Parameters + .Select(p => new WasmLibraryImportParameterInfo(p.Name, p.Type.ToDisplayString())) + .ToImmutableArray(); + + return new WasmLibraryImportInfo( + namespaceName, + typeName, + methodSymbol.Name, + functionName!, + moduleName ?? "default", + returnType.ToDisplayString(), + isAsync, + asyncResultType, + parameters); + } + + private static string GenerateImplementation(string namespaceName, string typeName, WasmLibraryImportInfo[] methods) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using System.Runtime.InteropServices;"); + sb.AppendLine("using System.Text;"); + sb.AppendLine("using Zapto.AspNetCore.Wasm.Interop;"); + sb.AppendLine(); + sb.AppendLine($"namespace {namespaceName};"); + sb.AppendLine(); + sb.AppendLine($"partial class {typeName}"); + sb.AppendLine("{"); + + foreach (var method in methods) + { + GenerateMethodImplementation(sb, method); + } + + // Generate the DllImport declarations + sb.AppendLine(" // Native WASM imports"); + foreach (var method in methods) + { + GenerateDllImport(sb, method); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void GenerateDllImport(StringBuilder sb, WasmLibraryImportInfo method) + { + var entryPoint = $"{method.ModuleName}_{method.FunctionName}"; + + if (method.IsAsync) + { + // Async: (byte* arg0Ptr, int arg0Len, int callbackId) -> void + sb.AppendLine($" [DllImport(\"*\", EntryPoint = \"{entryPoint}\")]"); + sb.AppendLine($" private static extern unsafe void {method.MethodName}_Native(byte* arg0Ptr, int arg0Len, int callbackId);"); + } + else + { + // Sync: (byte* arg0Ptr, int arg0Len, int* resultLenPtr) -> byte* + sb.AppendLine($" [DllImport(\"*\", EntryPoint = \"{entryPoint}\")]"); + sb.AppendLine($" private static extern unsafe byte* {method.MethodName}_Native(byte* arg0Ptr, int arg0Len, int* resultLenPtr);"); + } + sb.AppendLine(); + } + + private static void GenerateMethodImplementation(StringBuilder sb, WasmLibraryImportInfo method) + { + // Build parameter list + var paramList = string.Join(", ", method.Parameters.Select(p => $"{p.Type} {p.Name}")); + var firstParam = method.Parameters.Length > 0 ? method.Parameters[0].Name : null; + + sb.AppendLine($" public static partial {method.ReturnType} {method.MethodName}({paramList})"); + sb.AppendLine(" {"); + + if (method.IsAsync) + { + GenerateAsyncMethodBody(sb, method, firstParam); + } + else + { + GenerateSyncMethodBody(sb, method, firstParam); + } + + sb.AppendLine(" }"); + sb.AppendLine(); + } + + private static void GenerateSyncMethodBody(StringBuilder sb, WasmLibraryImportInfo method, string? firstParam) + { + sb.AppendLine(" unsafe"); + sb.AppendLine(" {"); + + if (firstParam != null) + { + sb.AppendLine($" var argBytes = Encoding.UTF8.GetBytes({firstParam} ?? \"\");"); + sb.AppendLine(" int resultLen = 0;"); + sb.AppendLine(" fixed (byte* argPtr = argBytes)"); + sb.AppendLine(" {"); + sb.AppendLine($" var resultPtr = {method.MethodName}_Native(argPtr, argBytes.Length, &resultLen);"); + sb.AppendLine(" if (resultPtr == null || resultLen <= 0)"); + sb.AppendLine(" {"); + sb.AppendLine(" return null;"); + sb.AppendLine(" }"); + sb.AppendLine(" return Encoding.UTF8.GetString(resultPtr, resultLen);"); + sb.AppendLine(" }"); + } + else + { + sb.AppendLine(" int resultLen = 0;"); + sb.AppendLine($" var resultPtr = {method.MethodName}_Native(null, 0, &resultLen);"); + sb.AppendLine(" if (resultPtr == null || resultLen <= 0)"); + sb.AppendLine(" {"); + sb.AppendLine(" return null;"); + sb.AppendLine(" }"); + sb.AppendLine(" return Encoding.UTF8.GetString(resultPtr, resultLen);"); + } + + sb.AppendLine(" }"); + } + + private static void GenerateAsyncMethodBody(StringBuilder sb, WasmLibraryImportInfo method, string? firstParam) + { + sb.AppendLine(" var tcs = new TaskCompletionSource();"); + sb.AppendLine(" var callbackId = WasmLibraryRuntime.RegisterCallback(tcs);"); + sb.AppendLine(); + sb.AppendLine(" unsafe"); + sb.AppendLine(" {"); + + if (firstParam != null) + { + sb.AppendLine($" var argBytes = Encoding.UTF8.GetBytes({firstParam} ?? \"\");"); + sb.AppendLine(" fixed (byte* argPtr = argBytes)"); + sb.AppendLine(" {"); + sb.AppendLine($" {method.MethodName}_Native(argPtr, argBytes.Length, callbackId);"); + sb.AppendLine(" }"); + } + else + { + sb.AppendLine($" {method.MethodName}_Native(null, 0, callbackId);"); + } + + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" return tcs.Task.ContinueWith(t =>"); + sb.AppendLine(" {"); + sb.AppendLine(" var result = t.Result;"); + sb.AppendLine(" if (result.Ptr == 0 || result.Length <= 0)"); + sb.AppendLine(" {"); + sb.AppendLine(" return null;"); + sb.AppendLine(" }"); + sb.AppendLine(" unsafe"); + sb.AppendLine(" {"); + sb.AppendLine(" return Encoding.UTF8.GetString((byte*)result.Ptr, result.Length);"); + sb.AppendLine(" }"); + sb.AppendLine(" }, TaskContinuationOptions.ExecuteSynchronously);"); + } +} + +internal readonly record struct WasmLibraryImportInfo( + string Namespace, + string TypeName, + string MethodName, + string FunctionName, + string ModuleName, + string ReturnType, + bool IsAsync, + string? AsyncResultType, + ImmutableArray Parameters); + +internal readonly record struct WasmLibraryImportParameterInfo(string Name, string Type); diff --git a/src/Zapto.AspNetCore.Wasm/WasmInterop.cs b/src/Zapto.AspNetCore.Wasm/WasmInterop.cs index a3464a4..bc4d20e 100644 --- a/src/Zapto.AspNetCore.Wasm/WasmInterop.cs +++ b/src/Zapto.AspNetCore.Wasm/WasmInterop.cs @@ -1,5 +1,6 @@ using System.Web; using Zapto.AspNetCore.Wasm.Internal; +using Zapto.AspNetCore.Wasm.Interop; namespace Zapto.AspNetCore.Wasm; @@ -8,34 +9,94 @@ namespace Zapto.AspNetCore.Wasm; /// public static class WasmInterop { + private static Task? _pendingRequestTask; + private static IntPtr _pendingRequestResult; + private static bool _requestCompleted; + /// /// Begins processing an HTTP request. Called from the main assembly's exported function. + /// Returns the response pointer immediately for sync requests, or 0 for async requests. + /// For async requests, JavaScript waits for callbacks to complete, which pump continuations + /// automatically, then calls GetRequestResult to get the response. /// /// Length of the JSON context data. /// Pointer to the JSON context data. /// Length of the request body. /// Pointer to the request body data. - /// Pointer to the response data. + /// Pointer to the response data, or 0 if request is still pending. [WasmExport("BeginRequest")] public static unsafe IntPtr ProcessRequest(int contextLength, byte* contextPtr, int bodyLength, byte* bodyPtr) { + WasmSynchronizationContext.Install(); + var contextSpan = new Span(contextPtr, contextLength); var requestContext = JsonSerializer.Deserialize(contextSpan, WasmJsonContext.Default.RequestContext)!; - // Handle empty body - UnmanagedMemoryStream doesn't accept null pointers Stream body = bodyLength > 0 && bodyPtr != null ? new UnmanagedMemoryStream(bodyPtr, bodyLength) : Stream.Null; - try + _requestCompleted = false; + _pendingRequestResult = IntPtr.Zero; + + _pendingRequestTask = CreateResponseAsync(requestContext, body); + + if (_pendingRequestTask.IsCompleted) { - return CreateResponse(requestContext, body).GetAwaiter().GetResult(); + _requestCompleted = true; + if (_pendingRequestTask.IsCompletedSuccessfully) + { + _pendingRequestResult = _pendingRequestTask.Result; + } + else + { + _pendingRequestResult = CreateErrorResponse(_pendingRequestTask.Exception?.InnerException?.Message ?? "Unknown error"); + } + + if (body != Stream.Null) + body.Dispose(); + + return _pendingRequestResult; } - finally + + _pendingRequestTask.ContinueWith(task => { + _requestCompleted = true; + if (task.IsCompletedSuccessfully) + { + _pendingRequestResult = task.Result; + } + else + { + _pendingRequestResult = CreateErrorResponse(task.Exception?.InnerException?.Message ?? "Unknown error"); + } + if (body != Stream.Null) body.Dispose(); - } + }, TaskContinuationOptions.ExecuteSynchronously); + + return IntPtr.Zero; + } + + /// + /// Gets the result of a completed async request. + /// Call this after HasPendingWork returns false. + /// + /// Pointer to the response data. + [WasmExport("GetRequestResult")] + public static IntPtr GetRequestResult() + { + return _pendingRequestResult; + } + + /// + /// Checks if the current request has completed. + /// + /// 1 if complete, 0 if still pending. + [WasmExport("IsRequestComplete")] + public static int IsRequestComplete() + { + return _requestCompleted ? 1 : 0; } /// @@ -45,10 +106,13 @@ public static unsafe IntPtr ProcessRequest(int contextLength, byte* contextPtr, [WasmExport("EndRequest")] public static void FreeResponse(IntPtr ptr) { - Marshal.FreeHGlobal(ptr); + if (ptr != IntPtr.Zero) + { + Marshal.FreeHGlobal(ptr); + } } - private static async ValueTask CreateResponse(RequestContext requestContext, Stream requestBody) + private static async Task CreateResponseAsync(RequestContext requestContext, Stream requestBody) { var server = WasmServer.Instance; if (server == null) @@ -61,6 +125,24 @@ private static async ValueTask CreateResponse(RequestContext requestCont return Alloc(responseStream, responseContext); } + private static unsafe IntPtr CreateErrorResponse(string message) + { + var responseContext = new ResponseContext + { + Status = 500, + Headers = new Dictionary + { + ["Content-Type"] = "text/plain; charset=utf-8" + } + }; + + var body = System.Text.Encoding.UTF8.GetBytes(message); + using var stream = new MemoryStream(); + stream.Write(body, 0, body.Length); + + return Alloc(stream, responseContext); + } + private static unsafe IntPtr Alloc(MemoryStream stream, ResponseContext response) { stream.Flush(); diff --git a/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj b/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj index 81a3f3c..727dd89 100644 --- a/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj +++ b/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj @@ -14,6 +14,10 @@ + + + + PreserveNewest diff --git a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs index ed4c03c..99a3f32 100644 --- a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs +++ b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmExportsGeneratorTests.cs @@ -438,6 +438,89 @@ public class Program { } Assert.Contains("InternalMethodName()", generatedCode); } + /// + /// The WasmExport attribute source code for the Interop namespace. + /// + private const string WasmExportInteropAttributeSource = """ + using System; + + namespace Zapto.AspNetCore.Wasm.Interop; + + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class WasmExportAttribute : Attribute + { + public WasmExportAttribute(string entryPoint) + { + EntryPoint = entryPoint; + } + + public string EntryPoint { get; } + } + """; + + [Fact] + public void Generator_SupportsInteropNamespaceAttribute() + { + // Arrange + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + // Create a runtime library with WasmExport from the Interop namespace + var runtimeCode = """ + using System; + using Zapto.AspNetCore.Wasm.Interop; + + namespace Zapto.AspNetCore.Wasm.Interop; + + public static class WasmLibraryRuntime + { + [WasmExport("WasmLibrary_CompleteCallback")] + public static unsafe void CompleteCallback(int callbackId, byte* resultPtr, int resultLen) { } + + [WasmExport("WasmLibrary_CompleteCallbackError")] + public static unsafe void CompleteCallbackError(int callbackId, byte* errorPtr, int errorLen) { } + } + """; + + var interopAttributeSyntaxTree = CSharpSyntaxTree.ParseText(WasmExportInteropAttributeSource, parseOptions); + var runtimeSyntaxTree = CSharpSyntaxTree.ParseText(runtimeCode, parseOptions); + var runtimeCompilation = CSharpCompilation.Create( + "Zapto.AspNetCore.Wasm.Interop", + [interopAttributeSyntaxTree, runtimeSyntaxTree], + GetReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + + // Create main assembly that references the runtime + var mainCode = """ + namespace MainApp; + public class Program { } + """; + + var mainSyntaxTree = CSharpSyntaxTree.ParseText(mainCode, parseOptions); + + var mainCompilation = CSharpCompilation.Create( + "MainApp", + [mainSyntaxTree], + GetReferences().Add(runtimeCompilation.ToMetadataReference()), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + + var driver = CreateDriver(new WasmExportsGenerator(), parseOptions, trackSteps: false); + + // Act + driver = driver.RunGenerators(mainCompilation); + + // Assert + var runResult = driver.GetRunResult(); + var generatorResult = runResult.Results.Single(); + + Assert.Single(generatorResult.GeneratedSources); + var generatedCode = generatorResult.GeneratedSources.Single().SourceText.ToString(); + + Assert.Contains("EntryPoint = \"WasmLibrary_CompleteCallback\"", generatedCode); + Assert.Contains("EntryPoint = \"WasmLibrary_CompleteCallbackError\"", generatedCode); + Assert.Contains("WasmLibrary_CompleteCallback(", generatedCode); + Assert.Contains("WasmLibrary_CompleteCallbackError(", generatedCode); + } + private static ImmutableArray GetReferences() { var trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; diff --git a/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmLibraryImportGeneratorTests.cs b/tests/Zapto.AspNetCore.Wasm.SourceGenerator.Tests/WasmLibraryImportGeneratorTests.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs b/tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs index ce9695c..34e4096 100644 --- a/tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs +++ b/tests/Zapto.AspNetCore.Wasm.Tests/WasmEndpointTests.cs @@ -59,6 +59,17 @@ public async Task NotFound_Returns404() Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task LibraryGreet_ReturnsGreetingFromJavaScriptLibrary() + { + var response = await fixture.Client.GetAsync("/library/greet/Alice", TestContext.Current.CancellationToken); + await EnsureSuccessAsync(response, TestContext.Current.CancellationToken); + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal("Greetings from Example, Alice!", content); + } + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (response.IsSuccessStatusCode) diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs b/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs index 1c43e0f..2433cc2 100644 --- a/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs +++ b/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs @@ -91,7 +91,6 @@ await File.WriteAllTextAsync(_wranglerConfigPath, $""" _wranglerProcess = new Process { StartInfo = startInfo }; _wranglerProcess.StartInfo.Environment["CI"] = "1"; - _wranglerProcess.StartInfo.Environment["WRANGLER_LOG"] = "debug"; _lifetimeCts = new CancellationTokenSource(_maxRunTime); _timeoutTask = Task.Run(async () => From 40a7237da0c2b5e6e253a320c43aee34e3dc702e Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Thu, 12 Feb 2026 20:01:03 +0100 Subject: [PATCH 4/6] fixes --- .github/workflows/ci.yaml | 3 + AspNetCore.sln | 58 +- Directory.Build.props | 2 +- sandbox/WasmApp/Program.cs | 6 +- sandbox/WasmApp/WasmApp.csproj | 2 + sandbox/WasmApp/test-wrangler-dotnet-run.sh | 25 + .../Configuration/WranglerBindings.cs | 655 ++++++++++++++++++ .../Configuration/WranglerConfig.cs | 273 ++++++++ .../Configuration/WranglerDevConfig.cs | 53 ++ .../GenerateWranglerConfig.cs | 570 +++++++++++++++ ...pto.AspNetCore.CloudFlare.SDK.Tasks.csproj | 29 + .../Sdk/Sdk.props | 28 +- .../Sdk/Sdk.targets | 108 ++- .../Sdk/context.js | 3 + .../Sdk/index.js | 27 +- .../Sdk/wrangler.toml | 9 - .../Zapto.AspNetCore.CloudFlare.SDK.csproj | 34 +- .../Caching/KeyValueDistributedCache.cs | 46 ++ .../CloudFlareKeyValue.wasmlib.js | 17 + .../NativeMethods.cs | 9 + .../Zapto.AspNetCore.CloudFlare.csproj | 27 + .../Http/Features/FeatureCollectionImpl.cs | 2 +- .../Zapto.AspNetCore.Wasm.Interop.csproj | 1 + ...ApplicationRunAsyncInterceptorGenerator.cs | 268 +++++++ ...pto.AspNetCore.Wasm.SourceGenerator.csproj | 4 +- .../WebApplicationWasmExtensions.cs | 53 ++ .../Zapto.AspNetCore.Wasm.csproj | 7 +- src/Zapto.AspNetCore.Wasm/index.js | 109 --- .../WranglerFixture.cs | 12 +- .../Zapto.AspNetCore.Wasm.Tests.csproj | 6 +- 30 files changed, 2265 insertions(+), 181 deletions(-) create mode 100644 sandbox/WasmApp/test-wrangler-dotnet-run.sh create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerBindings.cs create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerConfig.cs create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerDevConfig.cs create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/GenerateWranglerConfig.cs create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Zapto.AspNetCore.CloudFlare.SDK.Tasks.csproj create mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/context.js delete mode 100644 sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml create mode 100644 src/Zapto.AspNetCore.CloudFlare/Caching/KeyValueDistributedCache.cs create mode 100644 src/Zapto.AspNetCore.CloudFlare/CloudFlareKeyValue.wasmlib.js create mode 100644 src/Zapto.AspNetCore.CloudFlare/NativeMethods.cs create mode 100644 src/Zapto.AspNetCore.CloudFlare/Zapto.AspNetCore.CloudFlare.csproj create mode 100644 src/Zapto.AspNetCore.Wasm.SourceGenerator/WebApplicationRunAsyncInterceptorGenerator.cs create mode 100644 src/Zapto.AspNetCore.Wasm/WebApplicationWasmExtensions.cs delete mode 100644 src/Zapto.AspNetCore.Wasm/index.js diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2bd8058..b7906a2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,6 +52,9 @@ jobs: NUGET_KEY: ${{secrets.NUGET_API_KEY}} VERSION_FILE_PATH: src/Directory.Build.props PROJECT_FILE_PATH: | + src/Zapto.AspNetCore.Wasm.Interop/Zapto.AspNetCore.Wasm.Interop.csproj + src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj src/Zapto.AspNetCore.Polyfill/Zapto.AspNetCore.Polyfill.csproj src/Zapto.AspNetCore.NetFx/Zapto.AspNetCore.NetFx.csproj + sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj diff --git a/AspNetCore.sln b/AspNetCore.sln index af1fb42..88efd2a 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -28,10 +28,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.Sourc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.SourceGenerator.Tests", "tests\Zapto.AspNetCore.Wasm.SourceGenerator.Tests\Zapto.AspNetCore.Wasm.SourceGenerator.Tests.csproj", "{1333E8B4-070C-4C70-A9F2-070CAA66CDAD}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.Wasm.Interop", "src\Zapto.AspNetCore.Wasm.Interop\Zapto.AspNetCore.Wasm.Interop.csproj", "{3827F320-31F0-4AE3-A048-8157C66DBEC8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Wasm", "Wasm", "{36B2818B-95AB-47ED-96AC-563A115C16A8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CloudFlare", "CloudFlare", "{75661D3A-C7D4-4032-8E88-5949C22550EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.CloudFlare", "src\Zapto.AspNetCore.CloudFlare\Zapto.AspNetCore.CloudFlare.csproj", "{B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmApp.Library", "sandbox\WasmApp.Library\WasmApp.Library.csproj", "{3A4FB731-98D3-4006-A63C-5DA356FEF674}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zapto.AspNetCore.CloudFlare.SDK.Tasks", "sdk\Zapto.AspNetCore.CloudFlare.SDK.Tasks\Zapto.AspNetCore.CloudFlare.SDK.Tasks.csproj", "{20818784-C3EC-4554-95EB-B92CE9E58C7A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -174,6 +182,42 @@ Global {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|x64.Build.0 = Release|Any CPU {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|x86.ActiveCfg = Release|Any CPU {3827F320-31F0-4AE3-A048-8157C66DBEC8}.Release|x86.Build.0 = Release|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Debug|x64.ActiveCfg = Debug|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Debug|x64.Build.0 = Debug|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Debug|x86.ActiveCfg = Debug|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Debug|x86.Build.0 = Debug|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Release|Any CPU.Build.0 = Release|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Release|x64.ActiveCfg = Release|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Release|x64.Build.0 = Release|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Release|x86.ActiveCfg = Release|Any CPU + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414}.Release|x86.Build.0 = Release|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Debug|x64.Build.0 = Debug|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Debug|x86.Build.0 = Debug|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Release|Any CPU.Build.0 = Release|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Release|x64.ActiveCfg = Release|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Release|x64.Build.0 = Release|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Release|x86.ActiveCfg = Release|Any CPU + {3A4FB731-98D3-4006-A63C-5DA356FEF674}.Release|x86.Build.0 = Release|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Debug|x64.ActiveCfg = Debug|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Debug|x64.Build.0 = Debug|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Debug|x86.ActiveCfg = Debug|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Debug|x86.Build.0 = Debug|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Release|Any CPU.Build.0 = Release|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Release|x64.ActiveCfg = Release|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Release|x64.Build.0 = Release|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Release|x86.ActiveCfg = Release|Any CPU + {20818784-C3EC-4554-95EB-B92CE9E58C7A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -183,12 +227,16 @@ Global {77FA9DA3-FB50-46EC-B9B8-905121594EA1} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} {305C7374-1A54-4409-B527-968D49B258DF} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} {507A2467-52E9-4181-8414-426AC45E01B3} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} - {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {A8E7D4F1-2B3C-4D5E-9F6A-1B2C3D4E5F6A} = {36B2818B-95AB-47ED-96AC-563A115C16A8} {B1C2D3E4-F5A6-4B7C-8D9E-0A1B2C3D4E5F} = {E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B} {C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} {D3E4F5A6-B7C8-4D9E-0F1A-2B3C4D5E6F7A} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} - {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {7BEC1F6E-3E97-489C-A134-ECCEA93FC93F} = {36B2818B-95AB-47ED-96AC-563A115C16A8} {1333E8B4-070C-4C70-A9F2-070CAA66CDAD} = {B3DA0F06-4511-4791-BB12-A84655F02BFA} - {3827F320-31F0-4AE3-A048-8157C66DBEC8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {3827F320-31F0-4AE3-A048-8157C66DBEC8} = {36B2818B-95AB-47ED-96AC-563A115C16A8} + {36B2818B-95AB-47ED-96AC-563A115C16A8} = {7F209FD2-1190-4721-9A08-9569AB4AA8D5} + {B42E9FA8-1B6D-4CF0-BAFB-9CF8E86DF414} = {75661D3A-C7D4-4032-8E88-5949C22550EC} + {3A4FB731-98D3-4006-A63C-5DA356FEF674} = {DD6845EA-4012-4E0A-8B78-8BAE5FF6967B} + {20818784-C3EC-4554-95EB-B92CE9E58C7A} = {E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B} EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 8162474..0c9276f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ Copyright © 2025 Zapto zapto, aspnetcore https://github.com/zapto-dev/AspNetCore - https://github.com/zapto-dev/AspNetCore/blob/main/LICENSE + MIT diff --git a/sandbox/WasmApp/Program.cs b/sandbox/WasmApp/Program.cs index a9d12bf..efbe476 100644 --- a/sandbox/WasmApp/Program.cs +++ b/sandbox/WasmApp/Program.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using WasmApp.Library; -var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); +var builder = WebApplication.CreateBuilder(args); builder.Services.AddRouting(); @@ -14,8 +14,6 @@ options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default); }); -builder.WebHost.UseWasmServer(); - var app = builder.Build(); app.UseRouting(); @@ -38,7 +36,7 @@ await context.Response.WriteAsync($"Echo: {body}"); }); -await app.StartAsync(); +await app.RunAsync(); internal record TimeResponse([property: JsonPropertyName("time")] string Time); diff --git a/sandbox/WasmApp/WasmApp.csproj b/sandbox/WasmApp/WasmApp.csproj index 2bb4ad7..27cb29a 100644 --- a/sandbox/WasmApp/WasmApp.csproj +++ b/sandbox/WasmApp/WasmApp.csproj @@ -3,6 +3,8 @@ + net10.0-browser;net10.0 + true diff --git a/sandbox/WasmApp/test-wrangler-dotnet-run.sh b/sandbox/WasmApp/test-wrangler-dotnet-run.sh new file mode 100644 index 0000000..c2aef00 --- /dev/null +++ b/sandbox/WasmApp/test-wrangler-dotnet-run.sh @@ -0,0 +1,25 @@ +#!/bin/bash +echo "Starting wrangler..." +dotnet run 2>&1 & +WRANGLER_PID=$! + +echo "Waiting for wrangler to start (PID: $WRANGLER_PID)..." +sleep 5 + +echo "" +echo "=== Testing root ===" +curl -v --max-time 20 http://127.0.0.1:8787/ 2>&1 +echo "" + +echo "" +echo "=== Testing library/greet ===" +curl -s --max-time 30 http://127.0.0.1:8787/library/greet/TestUser +CURL_EXIT=$? +echo "" +echo "curl exit code: $CURL_EXIT" + +echo "" +echo "=== Stopping wrangler ===" +sleep 2 +kill $WRANGLER_PID 2>/dev/null +echo "Done" diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerBindings.cs b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerBindings.cs new file mode 100644 index 0000000..c197516 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerBindings.cs @@ -0,0 +1,655 @@ +namespace Zapto.AspNetCore.CloudFlare.SDK.Tasks; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +/// +/// Configures a custom build step to be run by Wrangler when building your Worker. +/// +public sealed class WranglerBuildConfig +{ + /// + /// The command used to build your Worker. + /// + [JsonPropertyName("command")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Command { get; set; } + + /// + /// The directory in which the command is executed. + /// + [JsonPropertyName("cwd")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cwd { get; set; } + + /// + /// The directory to watch for changes while using wrangler dev. + /// + [JsonPropertyName("watch_dir")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WatchDir { get; set; } +} + +/// +/// Represents a route for the Worker. +/// +public sealed class WranglerRoute +{ + /// + /// The route pattern. + /// + [JsonPropertyName("pattern")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Pattern { get; set; } + + /// + /// The zone ID for the route. + /// + [JsonPropertyName("zone_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ZoneId { get; set; } + + /// + /// The zone name for the route. + /// + [JsonPropertyName("zone_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ZoneName { get; set; } + + /// + /// Whether to use a custom domain. + /// + [JsonPropertyName("custom_domain")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? CustomDomain { get; set; } +} + +/// +/// Cron trigger definitions. +/// +public sealed class WranglerTriggers +{ + /// + /// Cron expressions for scheduled triggers. + /// + [JsonPropertyName("crons")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Crons { get; set; } +} + +/// +/// Runtime limits for the Worker. +/// +public sealed class WranglerLimits +{ + /// + /// Maximum CPU time in milliseconds. + /// + [JsonPropertyName("cpu_ms")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CpuMs { get; set; } +} + +/// +/// Smart placement configuration. +/// +public sealed class WranglerPlacement +{ + /// + /// Placement mode: "off", "smart", or "targeted". + /// + [JsonPropertyName("mode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Mode { get; set; } + + /// + /// Hint for placement (when mode is "smart"). + /// + [JsonPropertyName("hint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Hint { get; set; } +} + +/// +/// Static assets configuration. +/// +public sealed class WranglerAssets +{ + /// + /// The directory containing static assets. + /// + [JsonPropertyName("directory")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Directory { get; set; } + + /// + /// Binding name for the assets. + /// + [JsonPropertyName("binding")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Binding { get; set; } +} + +/// +/// KV Namespace binding configuration. +/// +public sealed class WranglerKvNamespace +{ + /// + /// The binding name used to refer to the KV Namespace. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The ID of the KV namespace. + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + /// + /// The ID of the KV namespace used during wrangler dev. + /// + [JsonPropertyName("preview_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PreviewId { get; set; } +} + +/// +/// R2 Bucket binding configuration. +/// +public sealed class WranglerR2Bucket +{ + /// + /// The binding name used to refer to the R2 bucket. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The name of this R2 bucket at the edge. + /// + [JsonPropertyName("bucket_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? BucketName { get; set; } + + /// + /// The preview name of this R2 bucket at the edge. + /// + [JsonPropertyName("preview_bucket_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PreviewBucketName { get; set; } + + /// + /// The jurisdiction that the bucket exists in. + /// + [JsonPropertyName("jurisdiction")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Jurisdiction { get; set; } +} + +/// +/// D1 Database binding configuration. +/// +public sealed class WranglerD1Database +{ + /// + /// The binding name used to refer to the D1 database. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The name of this D1 database. + /// + [JsonPropertyName("database_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DatabaseName { get; set; } + + /// + /// The UUID of this D1 database. + /// + [JsonPropertyName("database_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DatabaseId { get; set; } + + /// + /// The UUID of this D1 database for Wrangler Dev. + /// + [JsonPropertyName("preview_database_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PreviewDatabaseId { get; set; } + + /// + /// The name of the migrations table. + /// + [JsonPropertyName("migrations_table")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MigrationsTable { get; set; } + + /// + /// The path to the directory of migrations. + /// + [JsonPropertyName("migrations_dir")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MigrationsDir { get; set; } +} + +/// +/// Durable Objects configuration. +/// +public sealed class WranglerDurableObjects +{ + /// + /// List of Durable Object bindings. + /// + [JsonPropertyName("bindings")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Bindings { get; set; } +} + +/// +/// Durable Object binding configuration. +/// +public sealed class WranglerDurableObjectBinding +{ + /// + /// The binding name for the Durable Object. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The class name of the Durable Object. + /// + [JsonPropertyName("class_name")] + public string? ClassName { get; set; } + + /// + /// The script name where the Durable Object class is defined. + /// + [JsonPropertyName("script_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ScriptName { get; set; } + + /// + /// The environment where the Durable Object is defined. + /// + [JsonPropertyName("environment")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Environment { get; set; } +} + +/// +/// Service binding (Worker-to-Worker) configuration. +/// +public sealed class WranglerService +{ + /// + /// The binding name used to refer to the bound service. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The name of the service. + /// + [JsonPropertyName("service")] + public string? Service { get; set; } + + /// + /// The entrypoint (named export) of the service to bind to. + /// + [JsonPropertyName("entrypoint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Entrypoint { get; set; } +} + +/// +/// Queues configuration. +/// +public sealed class WranglerQueues +{ + /// + /// Producer bindings. + /// + [JsonPropertyName("producers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Producers { get; set; } + + /// + /// Consumer configuration. + /// + [JsonPropertyName("consumers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Consumers { get; set; } +} + +/// +/// Queue producer binding. +/// +public sealed class WranglerQueueProducer +{ + /// + /// The binding name used to refer to the Queue. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The name of this Queue. + /// + [JsonPropertyName("queue")] + public string? Queue { get; set; } + + /// + /// The number of seconds to wait before delivering a message. + /// + [JsonPropertyName("delivery_delay")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? DeliveryDelay { get; set; } +} + +/// +/// Queue consumer configuration. +/// +public sealed class WranglerQueueConsumer +{ + /// + /// The name of the queue from which this consumer should consume. + /// + [JsonPropertyName("queue")] + public string? Queue { get; set; } + + /// + /// The maximum number of messages per batch. + /// + [JsonPropertyName("max_batch_size")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxBatchSize { get; set; } + + /// + /// The maximum number of seconds to wait to fill a batch. + /// + [JsonPropertyName("max_batch_timeout")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxBatchTimeout { get; set; } + + /// + /// The maximum number of retries for each message. + /// + [JsonPropertyName("max_retries")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxRetries { get; set; } + + /// + /// The queue to send messages that failed to be consumed. + /// + [JsonPropertyName("dead_letter_queue")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DeadLetterQueue { get; set; } + + /// + /// The maximum number of concurrent consumer Worker invocations. + /// + [JsonPropertyName("max_concurrency")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxConcurrency { get; set; } +} + +/// +/// Analytics Engine Dataset binding. +/// +public sealed class WranglerAnalyticsEngineDataset +{ + /// + /// The binding name used to refer to the dataset. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The name of this dataset to write to. + /// + [JsonPropertyName("dataset")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Dataset { get; set; } +} + +/// +/// AI binding configuration. +/// +public sealed class WranglerAiBinding +{ + /// + /// The binding name. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } +} + +/// +/// Browser binding configuration. +/// +public sealed class WranglerBrowserBinding +{ + /// + /// The binding name. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } +} + +/// +/// Hyperdrive binding configuration. +/// +public sealed class WranglerHyperdrive +{ + /// + /// The binding name used to refer to the project. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The id of the database. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The local database connection string for wrangler dev. + /// + [JsonPropertyName("localConnectionString")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? LocalConnectionString { get; set; } +} + +/// +/// Vectorize index binding. +/// +public sealed class WranglerVectorize +{ + /// + /// The binding name used to refer to the Vectorize index. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The name of the index. + /// + [JsonPropertyName("index_name")] + public string? IndexName { get; set; } +} + +/// +/// mTLS certificate binding. +/// +public sealed class WranglerMtlsCertificate +{ + /// + /// The binding name used to refer to the certificate. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// The uuid of the uploaded mTLS certificate. + /// + [JsonPropertyName("certificate_id")] + public string? CertificateId { get; set; } +} + +/// +/// Tail consumer binding. +/// +public sealed class WranglerTailConsumer +{ + /// + /// The name of the tail Worker. + /// + [JsonPropertyName("service")] + public string? Service { get; set; } + + /// + /// The environment of the tail Worker. + /// + [JsonPropertyName("environment")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Environment { get; set; } +} + +/// +/// Send Email binding configuration. +/// +public sealed class WranglerSendEmailBinding +{ + /// + /// The binding name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// If this binding should be restricted to a specific verified address. + /// + [JsonPropertyName("destination_address")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DestinationAddress { get; set; } + + /// + /// If this binding should be restricted to a set of verified addresses. + /// + [JsonPropertyName("allowed_destination_addresses")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? AllowedDestinationAddresses { get; set; } +} + +/// +/// Pipeline binding configuration. +/// +public sealed class WranglerPipeline +{ + /// + /// The binding name used to refer to the bound service. + /// + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + /// + /// Name of the Pipeline to bind. + /// + [JsonPropertyName("pipeline")] + public string? Pipeline { get; set; } +} + +/// +/// Observability configuration. +/// +public sealed class WranglerObservability +{ + /// + /// Whether observability is enabled. + /// + [JsonPropertyName("enabled")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Enabled { get; set; } + + /// + /// The sampling rate for head-based sampling (0.0 to 1.0). + /// + [JsonPropertyName("head_sampling_rate")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? HeadSamplingRate { get; set; } +} + +/// +/// Environment-specific configuration overrides. +/// +public sealed class WranglerEnvironment +{ + /// + /// The name of the environment. + /// + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + + /// + /// Account ID for this environment. + /// + [JsonPropertyName("account_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AccountId { get; set; } + + /// + /// Compatibility date for this environment. + /// + [JsonPropertyName("compatibility_date")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CompatibilityDate { get; set; } + + /// + /// Compatibility flags for this environment. + /// + [JsonPropertyName("compatibility_flags")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CompatibilityFlags { get; set; } + + /// + /// Routes for this environment. + /// + [JsonPropertyName("routes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Routes { get; set; } + + /// + /// Environment variables for this environment. + /// + [JsonPropertyName("vars")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Vars { get; set; } + + /// + /// KV namespaces for this environment. + /// + [JsonPropertyName("kv_namespaces")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? KvNamespaces { get; set; } + + /// + /// R2 buckets for this environment. + /// + [JsonPropertyName("r2_buckets")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? R2Buckets { get; set; } + + /// + /// D1 databases for this environment. + /// + [JsonPropertyName("d1_databases")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? D1Databases { get; set; } +} diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerConfig.cs b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerConfig.cs new file mode 100644 index 0000000..4c39772 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerConfig.cs @@ -0,0 +1,273 @@ +namespace Zapto.AspNetCore.CloudFlare.SDK.Tasks; + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Represents the root wrangler configuration. +/// This corresponds to the wrangler.jsonc configuration file format. +/// +/// +/// Based on the Wrangler config schema. +/// See: https://developers.cloudflare.com/workers/wrangler/configuration/ +/// +public sealed class WranglerConfig +{ + /// + /// JSON Schema reference. + /// + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + /// + /// The name of your Worker. Alphanumeric + dashes only. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The entrypoint/path to the JavaScript file that will be executed. + /// + [JsonPropertyName("main")] + public string? Main { get; set; } + + /// + /// This is the ID of the account associated with your zone. + /// It can also be specified through the CLOUDFLARE_ACCOUNT_ID environment variable. + /// + [JsonPropertyName("account_id")] + public string? AccountId { get; set; } + + /// + /// A date in the form yyyy-mm-dd, which will be used to determine which + /// version of the Workers runtime is used. + /// + [JsonPropertyName("compatibility_date")] + public string? CompatibilityDate { get; set; } + + /// + /// A list of flags that enable features from upcoming features of the Workers runtime. + /// + [JsonPropertyName("compatibility_flags")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CompatibilityFlags { get; set; } + + /// + /// Whether we use <name>.<subdomain>.workers.dev to test and deploy your Worker. + /// + [JsonPropertyName("workers_dev")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? WorkersDev { get; set; } + + /// + /// Options to configure the development server. + /// + [JsonPropertyName("dev")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerDevConfig? Dev { get; set; } + + /// + /// Configures a custom build step to be run by Wrangler when building your Worker. + /// + [JsonPropertyName("build")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerBuildConfig? Build { get; set; } + + /// + /// Skip internal build steps and directly deploy script. + /// + [JsonPropertyName("no_bundle")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? NoBundle { get; set; } + + /// + /// Minify the script before uploading. + /// + [JsonPropertyName("minify")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Minify { get; set; } + + /// + /// A list of routes that your Worker should be published to. + /// + [JsonPropertyName("routes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Routes { get; set; } + + /// + /// Cron definitions to trigger a Worker's scheduled function. + /// + [JsonPropertyName("triggers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerTriggers? Triggers { get; set; } + + /// + /// Specify limits for runtime behavior. + /// + [JsonPropertyName("limits")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerLimits? Limits { get; set; } + + /// + /// Specify how the Worker should be located to minimize round-trip time. + /// + [JsonPropertyName("placement")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerPlacement? Placement { get; set; } + + /// + /// Specify the directory of static assets to deploy/serve. + /// + [JsonPropertyName("assets")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerAssets? Assets { get; set; } + + /// + /// A map of environment variables to set when deploying your Worker. + /// + [JsonPropertyName("vars")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Vars { get; set; } + + /// + /// A map of values to substitute when deploying your Worker. + /// + [JsonPropertyName("define")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Define { get; set; } + + /// + /// Workers KV Namespaces you want to access from inside your Worker. + /// + [JsonPropertyName("kv_namespaces")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? KvNamespaces { get; set; } + + /// + /// Specifies R2 buckets that are bound to this Worker environment. + /// + [JsonPropertyName("r2_buckets")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? R2Buckets { get; set; } + + /// + /// Specifies D1 databases that are bound to this Worker environment. + /// + [JsonPropertyName("d1_databases")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? D1Databases { get; set; } + + /// + /// A list of durable objects that your Worker should be bound to. + /// + [JsonPropertyName("durable_objects")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerDurableObjects? DurableObjects { get; set; } + + /// + /// Specifies service bindings (Worker-to-Worker) that are bound to this Worker. + /// + [JsonPropertyName("services")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Services { get; set; } + + /// + /// Specifies Queues that are bound to this Worker environment. + /// + [JsonPropertyName("queues")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerQueues? Queues { get; set; } + + /// + /// Specifies analytics engine datasets that are bound to this Worker. + /// + [JsonPropertyName("analytics_engine_datasets")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? AnalyticsEngineDatasets { get; set; } + + /// + /// Binding to the AI project. + /// + [JsonPropertyName("ai")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerAiBinding? Ai { get; set; } + + /// + /// A browser that will be usable from the Worker. + /// + [JsonPropertyName("browser")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerBrowserBinding? Browser { get; set; } + + /// + /// Specifies Hyperdrive configs that are bound to this Worker environment. + /// + [JsonPropertyName("hyperdrive")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Hyperdrive { get; set; } + + /// + /// Specifies Vectorize indexes that are bound to this Worker environment. + /// + [JsonPropertyName("vectorize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Vectorize { get; set; } + + /// + /// Specifies a list of mTLS certificates that are bound to this Worker. + /// + [JsonPropertyName("mtls_certificates")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? MtlsCertificates { get; set; } + + /// + /// Specifies a list of Tail Workers that are bound to this Worker. + /// + [JsonPropertyName("tail_consumers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? TailConsumers { get; set; } + + /// + /// Send bindings to send email from inside your Worker. + /// + [JsonPropertyName("send_email")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? SendEmail { get; set; } + + /// + /// Specifies list of Pipelines bound to this Worker environment. + /// + [JsonPropertyName("pipelines")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Pipelines { get; set; } + + /// + /// Specify the observability behavior of the Worker. + /// + [JsonPropertyName("observability")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WranglerObservability? Observability { get; set; } + + /// + /// Upload source maps when deploying this worker. + /// + [JsonPropertyName("upload_source_maps")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? UploadSourceMaps { get; set; } + + /// + /// Send Trace Events from this Worker to Workers Logpush. + /// + [JsonPropertyName("logpush")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Logpush { get; set; } + + /// + /// Environment overrides for different deployment environments. + /// + [JsonPropertyName("env")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Env { get; set; } +} diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerDevConfig.cs b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerDevConfig.cs new file mode 100644 index 0000000..7de9dc7 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Configuration/WranglerDevConfig.cs @@ -0,0 +1,53 @@ +namespace Zapto.AspNetCore.CloudFlare.SDK.Tasks; + +using System.Text.Json.Serialization; + +/// +/// Options to configure the development server. +/// +public sealed class WranglerDevConfig +{ + /// + /// IP address for the local dev server to listen on. + /// + [JsonPropertyName("ip")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Ip { get; set; } + + /// + /// Port for the local dev server to listen on. + /// + [JsonPropertyName("port")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Port { get; set; } + + /// + /// Port for the local dev server's inspector to listen on. + /// + [JsonPropertyName("inspector_port")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? InspectorPort { get; set; } + + /// + /// Protocol that local wrangler dev server listens to requests on. + /// Valid values: "http" or "https". + /// + [JsonPropertyName("local_protocol")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? LocalProtocol { get; set; } + + /// + /// Protocol that wrangler dev forwards requests on. + /// Valid values: "http" or "https". + /// + [JsonPropertyName("upstream_protocol")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? UpstreamProtocol { get; set; } + + /// + /// Host to forward requests to, defaults to the host of the first route. + /// + [JsonPropertyName("host")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Host { get; set; } +} diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/GenerateWranglerConfig.cs b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/GenerateWranglerConfig.cs new file mode 100644 index 0000000..22e8062 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/GenerateWranglerConfig.cs @@ -0,0 +1,570 @@ +namespace Zapto.AspNetCore.CloudFlare.SDK.Tasks; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +/// +/// MSBuild task that generates a wrangler.jsonc configuration file for Cloudflare Workers. +/// +public sealed class GenerateWranglerConfig : Task +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + /// + /// The output path for the generated wrangler.jsonc file. + /// + [Required] + public string OutputPath { get; set; } = string.Empty; + + /// + /// The name of the Worker. Alphanumeric + dashes only. + /// + [Required] + public string Name { get; set; } = string.Empty; + + /// + /// The entrypoint/path to the JavaScript file that will be executed. + /// + [Required] + public string Main { get; set; } = string.Empty; + + /// + /// A date in the form yyyy-mm-dd for the Workers runtime compatibility. + /// + [Required] + public string CompatibilityDate { get; set; } = string.Empty; + + /// + /// Comma-separated list of compatibility flags. + /// + public string? CompatibilityFlags { get; set; } + + /// + /// Port for the local dev server to listen on. + /// + public int DevPort { get; set; } + + /// + /// Protocol that local wrangler dev server listens to requests on. + /// + public string? DevProtocol { get; set; } + + /// + /// IP address for the local dev server to listen on. + /// + public string? DevIp { get; set; } + + /// + /// Account ID for Cloudflare. + /// + public string? AccountId { get; set; } + + /// + /// Whether to deploy to workers.dev subdomain. + /// + public bool? WorkersDev { get; set; } + + /// + /// Whether to upload source maps. + /// + public bool? UploadSourceMaps { get; set; } + + /// + /// Whether to enable logpush. + /// + public bool? Logpush { get; set; } + + /// + /// Whether to minify the script. + /// + public bool? Minify { get; set; } + + /// + /// Skip internal build steps and directly deploy script. + /// + public bool? NoBundle { get; set; } + + /// + /// KV namespaces as semicolon-separated bindings in format "binding=id" or "binding=id;preview_id=preview". + /// + public ITaskItem[]? KvNamespaces { get; set; } + + /// + /// R2 buckets as semicolon-separated bindings in format "binding=bucket_name". + /// + public ITaskItem[]? R2Buckets { get; set; } + + /// + /// D1 databases as semicolon-separated bindings in format "binding=database_name;database_id=id". + /// + public ITaskItem[]? D1Databases { get; set; } + + /// + /// Service bindings in format "binding=service_name". + /// + public ITaskItem[]? Services { get; set; } + + /// + /// Environment variables in format "key=value". + /// + public ITaskItem[]? Vars { get; set; } + + /// + /// Define substitutions in format "key=value". + /// + public ITaskItem[]? Define { get; set; } + + /// + /// Routes in format "pattern" or "pattern;zone_id=id". + /// + public ITaskItem[]? Routes { get; set; } + + /// + /// Cron triggers as comma-separated expressions. + /// + public string? CronTriggers { get; set; } + + /// + /// Smart placement mode: "off", "smart", or "targeted". + /// + public string? PlacementMode { get; set; } + + /// + /// CPU time limit in milliseconds. + /// + public int CpuMs { get; set; } + + /// + /// Optional path to a custom wrangler.jsonc file to merge with the generated config. + /// Properties from the custom config will override generated properties. + /// + public string? CustomConfigPath { get; set; } + + /// + public override bool Execute() + { + try + { + var config = BuildConfiguration(); + var generatedJson = JsonSerializer.Serialize(config, s_jsonOptions); + + // Merge with custom config if provided + string finalJson; + if (!string.IsNullOrWhiteSpace(CustomConfigPath) && File.Exists(CustomConfigPath)) + { + Log.LogMessage(MessageImportance.Normal, $"Merging custom wrangler config: {CustomConfigPath}"); + finalJson = MergeWithCustomConfig(generatedJson, CustomConfigPath!); + } + else + { + finalJson = generatedJson; + } + + var output = SerializeWithComments(finalJson); + + var directory = Path.GetDirectoryName(OutputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // Only write if content has changed + if (File.Exists(OutputPath)) + { + var existingContent = File.ReadAllText(OutputPath); + if (existingContent == output) + { + Log.LogMessage(MessageImportance.Low, "Wrangler config unchanged, skipping write."); + return true; + } + } + + File.WriteAllText(OutputPath, output, Encoding.UTF8); + Log.LogMessage(MessageImportance.Normal, $"Generated wrangler config: {OutputPath}"); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, showStackTrace: true); + return false; + } + } + + private WranglerConfig BuildConfiguration() + { + var config = new WranglerConfig + { + Schema = "https://unpkg.com/wrangler@latest/config-schema.json", + Name = Name, + Main = Main, + CompatibilityDate = CompatibilityDate, + AccountId = string.IsNullOrWhiteSpace(AccountId) ? null : AccountId, + WorkersDev = WorkersDev, + UploadSourceMaps = UploadSourceMaps, + Logpush = Logpush, + Minify = Minify, + NoBundle = NoBundle + }; + + // Parse compatibility flags + if (!string.IsNullOrWhiteSpace(CompatibilityFlags)) + { + config.CompatibilityFlags = ParseCommaSeparatedList(CompatibilityFlags!); + } + + // Dev settings + if (DevPort > 0 || !string.IsNullOrWhiteSpace(DevProtocol) || !string.IsNullOrWhiteSpace(DevIp)) + { + config.Dev = new WranglerDevConfig + { + Port = DevPort > 0 ? DevPort : (int?)null, + LocalProtocol = string.IsNullOrWhiteSpace(DevProtocol) ? null : DevProtocol, + Ip = string.IsNullOrWhiteSpace(DevIp) ? null : DevIp + }; + } + + // KV Namespaces + if (KvNamespaces is { Length: > 0 }) + { + config.KvNamespaces = new List(); + foreach (var item in KvNamespaces) + { + config.KvNamespaces.Add(new WranglerKvNamespace + { + Binding = item.ItemSpec, + Id = GetMetadataOrNull(item, "Id"), + PreviewId = GetMetadataOrNull(item, "PreviewId") + }); + } + } + + // R2 Buckets + if (R2Buckets is { Length: > 0 }) + { + config.R2Buckets = new List(); + foreach (var item in R2Buckets) + { + config.R2Buckets.Add(new WranglerR2Bucket + { + Binding = item.ItemSpec, + BucketName = GetMetadataOrNull(item, "BucketName"), + PreviewBucketName = GetMetadataOrNull(item, "PreviewBucketName"), + Jurisdiction = GetMetadataOrNull(item, "Jurisdiction") + }); + } + } + + // D1 Databases + if (D1Databases is { Length: > 0 }) + { + config.D1Databases = new List(); + foreach (var item in D1Databases) + { + config.D1Databases.Add(new WranglerD1Database + { + Binding = item.ItemSpec, + DatabaseName = GetMetadataOrNull(item, "DatabaseName"), + DatabaseId = GetMetadataOrNull(item, "DatabaseId"), + PreviewDatabaseId = GetMetadataOrNull(item, "PreviewDatabaseId"), + MigrationsTable = GetMetadataOrNull(item, "MigrationsTable"), + MigrationsDir = GetMetadataOrNull(item, "MigrationsDir") + }); + } + } + + // Services + if (Services is { Length: > 0 }) + { + config.Services = new List(); + foreach (var item in Services) + { + config.Services.Add(new WranglerService + { + Binding = item.ItemSpec, + Service = GetMetadataOrNull(item, "Service"), + Entrypoint = GetMetadataOrNull(item, "Entrypoint") + }); + } + } + + // Environment variables + if (Vars is { Length: > 0 }) + { + config.Vars = new Dictionary(); + foreach (var item in Vars) + { + var value = GetMetadataOrNull(item, "Value"); + if (value != null) + { + config.Vars[item.ItemSpec] = value; + } + } + } + + // Define substitutions + if (Define is { Length: > 0 }) + { + config.Define = new Dictionary(); + foreach (var item in Define) + { + var value = GetMetadataOrNull(item, "Value"); + if (value != null) + { + config.Define[item.ItemSpec] = value; + } + } + } + + // Routes + if (Routes is { Length: > 0 }) + { + config.Routes = new List(); + foreach (var item in Routes) + { + config.Routes.Add(new WranglerRoute + { + Pattern = item.ItemSpec, + ZoneId = GetMetadataOrNull(item, "ZoneId"), + ZoneName = GetMetadataOrNull(item, "ZoneName"), + CustomDomain = GetBoolMetadata(item, "CustomDomain") + }); + } + } + + // Cron triggers + if (!string.IsNullOrWhiteSpace(CronTriggers)) + { + config.Triggers = new WranglerTriggers + { + Crons = ParseCommaSeparatedList(CronTriggers!) + }; + } + + // Placement + if (!string.IsNullOrWhiteSpace(PlacementMode)) + { + config.Placement = new WranglerPlacement + { + Mode = PlacementMode + }; + } + + // Limits + if (CpuMs > 0) + { + config.Limits = new WranglerLimits + { + CpuMs = CpuMs + }; + } + + return config; + } + + private static string SerializeWithComments(string json) + { + // Add a header comment for JSONC + var sb = new StringBuilder(); + sb.AppendLine("// Wrangler configuration file"); + sb.AppendLine("// Generated by Zapto.AspNetCore.CloudFlare.SDK"); + sb.AppendLine("// See: https://developers.cloudflare.com/workers/wrangler/configuration/"); + sb.Append(json); + + return sb.ToString(); + } + + private static List ParseCommaSeparatedList(string value) + { + var result = new List(); + foreach (var item in value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = item.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + { + result.Add(trimmed); + } + } + return result; + } + + private static string? GetMetadataOrNull(ITaskItem item, string metadataName) + { + var value = item.GetMetadata(metadataName); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static bool? GetBoolMetadata(ITaskItem item, string metadataName) + { + var value = item.GetMetadata(metadataName); + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + return bool.TryParse(value, out var result) && result ? true : null; + } + + /// + /// Merges a custom wrangler.jsonc file with the generated configuration. + /// Custom config properties take precedence over generated ones. + /// Uses JsonDocument to preserve unknown properties not in WranglerConfig. + /// + private string MergeWithCustomConfig(string generatedJson, string customConfigPath) + { + try + { + var customJson = File.ReadAllText(customConfigPath); + + // Strip JSONC comments (single-line // and multi-line /* */) + customJson = StripJsonComments(customJson); + + // Parse both as JsonDocument to merge properties + using var generatedDoc = JsonDocument.Parse(generatedJson); + using var customDoc = JsonDocument.Parse(customJson); + + // Merge the two JSON objects (custom takes precedence) + return MergeJsonObjects(generatedDoc.RootElement, customDoc.RootElement); + } + catch (Exception ex) + { + Log.LogWarning($"Failed to merge custom wrangler config: {ex.Message}"); + return generatedJson; + } + } + + /// + /// Strips JSONC comments from JSON content. + /// + private static string StripJsonComments(string json) + { + var sb = new StringBuilder(); + var i = 0; + var inString = false; + + while (i < json.Length) + { + // Handle string literals + if (json[i] == '"' && (i == 0 || json[i - 1] != '\\')) + { + inString = !inString; + sb.Append(json[i]); + i++; + continue; + } + + if (!inString) + { + // Single-line comment + if (i + 1 < json.Length && json[i] == '/' && json[i + 1] == '/') + { + while (i < json.Length && json[i] != '\n' && json[i] != '\r') + { + i++; + } + continue; + } + + // Multi-line comment + if (i + 1 < json.Length && json[i] == '/' && json[i + 1] == '*') + { + i += 2; + while (i + 1 < json.Length && !(json[i] == '*' && json[i + 1] == '/')) + { + i++; + } + i += 2; // Skip */ + continue; + } + } + + sb.Append(json[i]); + i++; + } + + return sb.ToString(); + } + + /// + /// Merges two JSON objects, with the second object's properties taking precedence. + /// + private static string MergeJsonObjects(JsonElement baseElement, JsonElement overrideElement) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + { + MergeJsonElements(writer, baseElement, overrideElement); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void MergeJsonElements(Utf8JsonWriter writer, JsonElement baseElement, JsonElement overrideElement) + { + if (baseElement.ValueKind != JsonValueKind.Object || overrideElement.ValueKind != JsonValueKind.Object) + { + // If either is not an object, use override + overrideElement.WriteTo(writer); + return; + } + + writer.WriteStartObject(); + + // Collect all property names from both objects + var baseProperties = new Dictionary(); + foreach (var prop in baseElement.EnumerateObject()) + { + baseProperties[prop.Name] = prop.Value; + } + + var overrideProperties = new Dictionary(); + foreach (var prop in overrideElement.EnumerateObject()) + { + overrideProperties[prop.Name] = prop.Value; + } + + // Write base properties, merging with override where applicable + foreach (var kvp in baseProperties) + { + writer.WritePropertyName(kvp.Key); + + if (overrideProperties.TryGetValue(kvp.Key, out var overrideValue)) + { + // Both have this property - merge if both are objects, otherwise use override + if (kvp.Value.ValueKind == JsonValueKind.Object && overrideValue.ValueKind == JsonValueKind.Object) + { + MergeJsonElements(writer, kvp.Value, overrideValue); + } + else + { + overrideValue.WriteTo(writer); + } + + overrideProperties.Remove(kvp.Key); + } + else + { + // Only in base + kvp.Value.WriteTo(writer); + } + } + + // Write remaining override-only properties + foreach (var kvp in overrideProperties) + { + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + } +} diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Zapto.AspNetCore.CloudFlare.SDK.Tasks.csproj b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Zapto.AspNetCore.CloudFlare.SDK.Tasks.csproj new file mode 100644 index 0000000..c23ecb4 --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK.Tasks/Zapto.AspNetCore.CloudFlare.SDK.Tasks.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.0 + latest + enable + Zapto.AspNetCore.CloudFlare.SDK.Tasks + MSBuild tasks for generating Cloudflare Workers configuration + aspnetcore, cloudflare, workers, msbuild, tasks + + + true + true + false + + + $(NoWarn);NU1903 + + + + + + + + + + + + diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props index d4e7c5c..216aa52 100644 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.props @@ -1,8 +1,13 @@ - - net10.0 + + net10.0-browser + + + <_IsBrowserTfm Condition="'$(TargetPlatformIdentifier)' == 'browser'">true + <_IsBrowserTfm Condition="'$(_IsBrowserTfm)' == '' and $([System.String]::Copy('$(TargetFramework)').EndsWith('-browser'))">true + <_IsBrowserTfm Condition="'$(_IsBrowserTfm)' == ''">false Exe @@ -28,11 +33,12 @@ http - + false true false $(EmccFlags) -O3 + $(InterceptorsNamespaces);Zapto.AspNetCore.Wasm.Generated true @@ -90,8 +96,18 @@ none - -s EXPORTED_RUNTIME_METHODS=cwrap -s ENVIRONMENT=webview -s EXPORT_ES6=1 -s ASSERTIONS=0 + -s EXPORTED_RUNTIME_METHODS=cwrap -s ENVIRONMENT=webview -s EXPORT_ES6=1 -s ASSERTIONS=0 -s WARN_ON_UNDEFINED_SYMBOLS=0 -Wno-unused-command-line-argument -Wno-js-compiler + + CS8784 + $(NoWarn);CS8784 + + + CLOUDFLARE + $(DefineConstants);CLOUDFLARE + + + enable enable @@ -102,4 +118,8 @@ https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json; + + + + diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets index f296347..a8acbba 100644 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/Sdk.targets @@ -71,37 +71,65 @@ - + - + - + PreserveNewest PreserveNewest + + PreserveNewest + PreserveNewest + + + + <_WranglerTaskAssemblyPath Condition="'$(ZaptoWasmUseProjectReference)' == 'true'">$(MSBuildThisFileDirectory)..\..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\Zapto.AspNetCore.CloudFlare.SDK.Tasks.dll + <_WranglerTaskProjectPath Condition="'$(ZaptoWasmUseProjectReference)' == 'true'">$(MSBuildThisFileDirectory)..\..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\Zapto.AspNetCore.CloudFlare.SDK.Tasks.csproj + <_WranglerTaskAssemblyPath Condition="'$(ZaptoWasmUseProjectReference)' != 'true'">$(MSBuildThisFileDirectory)..\tasks\netstandard2.0\Zapto.AspNetCore.CloudFlare.SDK.Tasks.dll + + + + + + + + Release <_CloudflareBaseOutputPath Condition="'$(BaseOutputPath)' != ''">$(BaseOutputPath) <_CloudflareBaseOutputPath Condition="'$(BaseOutputPath)' == ''">bin\ <_PublishOutputPath Condition="'$(PublishDir)' != ''">$(PublishDir) <_PublishOutputPath Condition="'$(PublishDir)' == ''">$(_CloudflareBaseOutputPath)$(CloudflarePublishConfiguration)\$(TargetFramework)\browser-wasm\publish\ - $([System.IO.Path]::GetFullPath('$(_PublishOutputPath)..\cloudflare\')) - $(MSBuildThisFileDirectory)wrangler.toml + <_PublishOutputPathAbsolute Condition="!$([System.IO.Path]::IsPathRooted('$(_PublishOutputPath)'))">$(MSBuildProjectDirectory)\$(_PublishOutputPath) + <_PublishOutputPathAbsolute Condition="$([System.IO.Path]::IsPathRooted('$(_PublishOutputPath)'))">$(_PublishOutputPath) + $([System.IO.Path]::GetFullPath('$(_PublishOutputPathAbsolute)..\cloudflare\')) $(MSBuildThisFileDirectory)index.js $(BaseIntermediateOutputPath)generated\ $(CloudflareIndexGeneratedDir)index.js - $(CloudflareIndexGeneratedDir)wrangler.toml + $(CloudflareIndexGeneratedDir)wrangler.jsonc + $(MSBuildProjectDirectory)\wrangler.jsonc <_WranglerNameKebab Condition="'$(_WranglerNameIsDefault)' == 'true'">$(WranglerName.Replace('.', '-').Replace('_', '-').ToLowerInvariant()) <_WranglerNameKebab Condition="'$(_WranglerNameIsDefault)' != 'true'">$(WranglerName) - - <_WranglerFlagsFormatted>"$(WranglerCompatibilityFlags.Replace(',', '", "').Trim())" - - <_CloudflareWranglerTemplate>$([System.IO.File]::ReadAllText('$(CloudflareWranglerTemplatePath)')) - <_CloudflareWranglerContent>$(_CloudflareWranglerTemplate.Replace('__WRANGLER_NAME__', '$(_WranglerNameKebab)')) - <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_MAIN__', '$(WranglerMain)')) - <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_COMPATIBILITY_DATE__', '$(WranglerCompatibilityDate)')) - <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_COMPATIBILITY_FLAGS__', '$(_WranglerFlagsFormatted)')) - <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_DEV_PORT__', '$(WranglerDevPort)')) - <_CloudflareWranglerContent>$(_CloudflareWranglerContent.Replace('__WRANGLER_DEV_PROTOCOL__', '$(WranglerDevProtocol)')) - - - + + + + + + <_WasmFilesToCopy Include="$(PublishDir)*.wasm" /> + + + + @@ -253,7 +309,7 @@ Condition="'$(RuntimeIdentifier)' == 'browser-wasm'"> @@ -265,12 +321,12 @@ powershell.exe - -NoProfile -Command "$ErrorActionPreference='Stop'; if (-not (Get-Command wrangler -ErrorAction SilentlyContinue)) { Write-Error 'Wrangler is not available on PATH. Install it with ''npm i -g wrangler''.'; exit 1 }; dotnet publish -c $(CloudflarePublishConfiguration) -v q -tl:false; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; dotnet msbuild /t:PrepareCloudflareOutput -p:Configuration=$(CloudflarePublishConfiguration) -p:RuntimeIdentifier=browser-wasm; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; wrangler dev --config '$(CloudflareOutputPath)wrangler.toml' --cwd '$(CloudflareOutputPath)' --local --ip 127.0.0.1 --port 8787" + -NoProfile -Command "$ErrorActionPreference='Stop'; if (-not (Get-Command wrangler -ErrorAction SilentlyContinue)) { Write-Error 'Wrangler is not available on PATH. Install it with ''npm i -g wrangler''.'; exit 1 }; Write-Host '[Zapto.CloudFlare.SDK] Starting NativeAOT compilation...'; dotnet publish -f $(TargetFramework) -c $(CloudflarePublishConfiguration) -v q -tl:false; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host '[Zapto.CloudFlare.SDK] Preparing Cloudflare output...'; dotnet msbuild /t:PrepareCloudflareOutput -p:Configuration=$(CloudflarePublishConfiguration) -p:TargetFramework=$(TargetFramework) -p:RuntimeIdentifier=browser-wasm; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host '[Zapto.CloudFlare.SDK] Launching Wrangler dev server...'; wrangler dev --config '$(CloudflareOutputPath)wrangler.jsonc' --cwd '$(CloudflareOutputPath)' --local --ip 127.0.0.1 --port 8787" /bin/sh - -c "dotnet publish -c $(CloudflarePublishConfiguration) -v q -tl:false && dotnet msbuild /t:PrepareCloudflareOutput -p:Configuration=$(CloudflarePublishConfiguration) -p:RuntimeIdentifier=browser-wasm && command -v wrangler >/dev/null 2>&1 && wrangler dev --config '$(CloudflareOutputPath)wrangler.toml' --cwd '$(CloudflareOutputPath)' --local --ip 127.0.0.1 --port 8787 || { echo 'Wrangler is not available on PATH. Install it with \"npm i -g wrangler\".' 1>&2; exit 1; }" + -c "echo '[Zapto.CloudFlare.SDK] Starting NativeAOT compilation...' && dotnet publish -f $(TargetFramework) -c $(CloudflarePublishConfiguration) -v q -tl:false && echo '[Zapto.CloudFlare.SDK] Preparing Cloudflare output...' && dotnet msbuild /t:PrepareCloudflareOutput -p:Configuration=$(CloudflarePublishConfiguration) -p:TargetFramework=$(TargetFramework) -p:RuntimeIdentifier=browser-wasm && command -v wrangler >/dev/null 2>&1 && echo '[Zapto.CloudFlare.SDK] Launching Wrangler dev server...' && wrangler dev --config '$(CloudflareOutputPath)wrangler.jsonc' --cwd '$(CloudflareOutputPath)' --local --ip 127.0.0.1 --port 8787 || { echo 'Wrangler is not available on PATH. Install it with \"npm i -g wrangler\".' 1>&2; exit 1; }" diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/context.js b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/context.js new file mode 100644 index 0000000..f1269ab --- /dev/null +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/context.js @@ -0,0 +1,3 @@ +const context = {} + +export default context \ No newline at end of file diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js index a51997d..91ea247 100644 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/index.js @@ -1,4 +1,5 @@ import worker from './dotnet.native.wasm'; +import context from './context.js'; import createDotnetRuntime from './dotnet.native.js'; import { initializeExports, @@ -20,26 +21,27 @@ const libraryModules = { const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder('utf-8'); - -let wasmRuntime = null; - const pendingCallbacks = new Map(); +function createFeatureProbe(value) { + return async () => value; +} + function readString(ptr, length) { if (ptr === 0 || length <= 0) return null; - const bytes = wasmRuntime.HEAPU8.subarray(ptr, ptr + length); + const bytes = context.runtime.HEAPU8.subarray(ptr, ptr + length); return textDecoder.decode(bytes); } function allocateString(str) { const bytes = textEncoder.encode(str); - const ptr = wasmRuntime._malloc(bytes.length); - wasmRuntime.HEAPU8.set(bytes, ptr); + const ptr = context.runtime._malloc(bytes.length); + context.runtime.HEAPU8.set(bytes, ptr); return { ptr, length: bytes.length }; } function completeCallback(callbackId, resultStr) { - const fn = wasmRuntime._WasmLibrary_CompleteCallback; + const fn = context.runtime._WasmLibrary_CompleteCallback; if (!fn) return; if (resultStr == null) { @@ -51,7 +53,7 @@ function completeCallback(callbackId, resultStr) { } function completeCallbackError(callbackId, errorMessage) { - const fn = wasmRuntime._WasmLibrary_CompleteCallbackError; + const fn = context.runtime._WasmLibrary_CompleteCallbackError; if (!fn) return; const { ptr, length } = allocateString(errorMessage || 'Unknown error'); @@ -141,6 +143,9 @@ function load() { return `${scriptDirectory}${path}`; }, fetch_like: (url, options) => fetch(url, options), + simd: createFeatureProbe(true), + relaxedSimd: createFeatureProbe(true), + exceptions: createFeatureProbe(true), createPromiseController: (afterResolve, afterReject) => { let promiseControl = null; const promise = new Promise((resolve, reject) => { @@ -233,7 +238,7 @@ function load() { const runtime = await createDotnetRuntime(moduleFactory); - wasmRuntime = runtime; + context.runtime = runtime; Object.assign(runtimeGlobals.module, runtime); @@ -347,6 +352,10 @@ function load() { export default { async fetch(request, env, ctx) { + context.request = request; + context.env = env; + context.ctx = ctx; + const processRequest = await load(); const arrayBuffer = await request.arrayBuffer(); const requestBody = new Uint8Array(arrayBuffer); diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml deleted file mode 100644 index adeeba3..0000000 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Sdk/wrangler.toml +++ /dev/null @@ -1,9 +0,0 @@ -name = "__WRANGLER_NAME__" -main = "__WRANGLER_MAIN__" -compatibility_date = "__WRANGLER_COMPATIBILITY_DATE__" -compatibility_flags = [__WRANGLER_COMPATIBILITY_FLAGS__] - -# Development settings -[dev] -port = __WRANGLER_DEV_PORT__ -local_protocol = "__WRANGLER_DEV_PROTOCOL__" diff --git a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj index ecb2657..27073c2 100644 --- a/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj +++ b/sdk/Zapto.AspNetCore.CloudFlare.SDK/Zapto.AspNetCore.CloudFlare.SDK.csproj @@ -6,7 +6,8 @@ aspnetcore, webassembly, wasm, llvm, cloudflare, workers, sdk true MSBuildSdk - $(NoWarn);NU5128 + + $(NoWarn);NU5128;NU5100 Zapto.AspNetCore.CloudFlare.SDK $(BaseIntermediateOutputPath)Sdk\ @@ -20,7 +21,8 @@ - + + @@ -32,12 +34,40 @@ ReferenceOutputAssembly="false" /> + + + + + <_PackageFiles Include="..\..\src\Zapto.AspNetCore.Wasm.SourceGenerator\bin\$(Configuration)\netstandard2.0\Zapto.AspNetCore.Wasm.SourceGenerator.dll" PackagePath="analyzers/dotnet/cs" /> + + + + + <_PackageFiles Include="..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\Zapto.AspNetCore.CloudFlare.SDK.Tasks.dll" + PackagePath="tasks/netstandard2.0" /> + <_PackageFiles Include="..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Text.Json.dll" + PackagePath="tasks/netstandard2.0" /> + <_PackageFiles Include="..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Text.Encodings.Web.dll" + PackagePath="tasks/netstandard2.0" /> + <_PackageFiles Include="..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Buffers.dll" + PackagePath="tasks/netstandard2.0" + Condition="Exists('..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Buffers.dll')" /> + <_PackageFiles Include="..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Memory.dll" + PackagePath="tasks/netstandard2.0" + Condition="Exists('..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Memory.dll')" /> + <_PackageFiles Include="..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Runtime.CompilerServices.Unsafe.dll" + PackagePath="tasks/netstandard2.0" + Condition="Exists('..\Zapto.AspNetCore.CloudFlare.SDK.Tasks\bin\$(Configuration)\netstandard2.0\System.Runtime.CompilerServices.Unsafe.dll')" /> + + diff --git a/src/Zapto.AspNetCore.Wasm.SourceGenerator/WebApplicationRunAsyncInterceptorGenerator.cs b/src/Zapto.AspNetCore.Wasm.SourceGenerator/WebApplicationRunAsyncInterceptorGenerator.cs new file mode 100644 index 0000000..0ed4110 --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm.SourceGenerator/WebApplicationRunAsyncInterceptorGenerator.cs @@ -0,0 +1,268 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Zapto.AspNetCore.Wasm.SourceGenerator; + +[Generator(LanguageNames.CSharp)] +public sealed class WebApplicationRunAsyncInterceptorGenerator : IIncrementalGenerator +{ + private const string GeneratedNamespace = "Zapto.AspNetCore.Wasm.Generated"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var interceptedInvocations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is InvocationExpressionSyntax, + transform: static (syntaxContext, cancellationToken) => TryGetIntercept(syntaxContext, cancellationToken)) + .Where(static interceptor => interceptor.HasValue) + .Select(static (interceptor, _) => interceptor!.Value) + .Collect(); + + context.RegisterSourceOutput(interceptedInvocations, static (sourceProductionContext, interceptors) => + { + EmitInterceptors(sourceProductionContext, interceptors); + }); + } + + private static InterceptInfo? TryGetIntercept(GeneratorSyntaxContext syntaxContext, System.Threading.CancellationToken cancellationToken) + { + if (syntaxContext.Node is not InvocationExpressionSyntax invocation) + { + return null; + } + + var symbolInfo = syntaxContext.SemanticModel.GetSymbolInfo(invocation, cancellationToken); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return null; + } + + var targetType = GetInterceptTargetType(methodSymbol, invocation.ArgumentList.Arguments.Count); + if (targetType is null) + { + return null; + } + + var interceptableLocation = syntaxContext.SemanticModel.GetInterceptableLocation(invocation, cancellationToken); + if (interceptableLocation is null) + { + return null; + } + + return new InterceptInfo( + TargetType: targetType.Value, + AttributeSyntax: interceptableLocation.GetInterceptsLocationAttributeSyntax()); + } + + private static InterceptTargetType? GetInterceptTargetType(IMethodSymbol methodSymbol, int argumentCount) + { + var methodToCheck = methodSymbol.ReducedFrom ?? methodSymbol; + var containingTypeName = methodToCheck.ContainingType.ToDisplayString(); + + if (containingTypeName == "Microsoft.AspNetCore.Builder.WebApplication") + { + if (methodToCheck.Name == "RunAsync" && argumentCount == 0) + { + return InterceptTargetType.WebApplicationRunAsync; + } + + if (methodToCheck.Name == "CreateBuilder") + { + if (methodToCheck.Parameters.Length == 0) + { + return InterceptTargetType.CreateBuilderNoArgs; + } + + if (methodToCheck.Parameters.Length == 1) + { + var parameterType = methodToCheck.Parameters[0].Type.ToDisplayString(); + if (parameterType == "string[]") + { + return InterceptTargetType.CreateBuilderArgs; + } + + if (parameterType == "Microsoft.AspNetCore.Builder.WebApplicationOptions") + { + return InterceptTargetType.CreateBuilderOptions; + } + } + } + + if (methodToCheck.Name == "CreateSlimBuilder") + { + if (methodToCheck.Parameters.Length == 0) + { + return InterceptTargetType.CreateSlimBuilderNoArgs; + } + + if (methodToCheck.Parameters.Length == 1 + && methodToCheck.Parameters[0].Type.ToDisplayString() == "Microsoft.AspNetCore.Builder.WebApplicationOptions") + { + return InterceptTargetType.CreateSlimBuilderOptions; + } + } + + return null; + } + + if (containingTypeName == "Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions" + && methodToCheck.Name == "RunAsync" + && methodToCheck.Parameters.Length > 0 + && methodToCheck.Parameters[0].Type.ToDisplayString() == "Microsoft.Extensions.Hosting.IHost") + { + return InterceptTargetType.HostRunAsync; + } + + return null; + } + + private static void EmitInterceptors( + SourceProductionContext sourceProductionContext, + ImmutableArray interceptors) + { + if (interceptors.IsDefaultOrEmpty) + { + return; + } + + var attributesByTarget = new Dictionary>(); + + foreach (var interceptor in interceptors) + { + if (!attributesByTarget.TryGetValue(interceptor.TargetType, out var attributes)) + { + attributes = new HashSet(); + attributesByTarget[interceptor.TargetType] = attributes; + } + + attributes.Add(interceptor.AttributeSyntax); + } + + if (attributesByTarget.Count == 0) + { + return; + } + + var source = GenerateInterceptorSource(attributesByTarget); + sourceProductionContext.AddSource("WebApplicationRunAsyncInterceptors.g.cs", SourceText.From(source, Encoding.UTF8)); + } + + private static string GenerateInterceptorSource(Dictionary> attributesByTarget) + { + var builder = new StringBuilder(); + + builder.AppendLine("// "); + builder.AppendLine("// This file is generated by Zapto.AspNetCore.Wasm.SourceGenerator."); + builder.AppendLine("// Do not modify this file directly."); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("#if CLOUDFLARE"); + builder.AppendLine("namespace System.Runtime.CompilerServices"); + builder.AppendLine("{"); + builder.AppendLine(" [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true, Inherited = false)]"); + builder.AppendLine(" internal sealed class InterceptsLocationAttribute : global::System.Attribute"); + builder.AppendLine(" {"); + builder.AppendLine(" public InterceptsLocationAttribute(int version, string data)"); + builder.AppendLine(" {"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + builder.AppendLine(); + builder.AppendLine($"namespace {GeneratedNamespace}"); + builder.AppendLine("{"); + builder.AppendLine("internal static class WebApplicationRunAsyncInterceptors"); + builder.AppendLine("{"); + + if (attributesByTarget.TryGetValue(InterceptTargetType.WebApplicationRunAsync, out var webApplicationRunAsyncAttributes) + && webApplicationRunAsyncAttributes.Count > 0) + { + foreach (var attribute in webApplicationRunAsyncAttributes.OrderBy(static attribute => attribute)) + { + builder.AppendLine($" {attribute}"); + } + + builder.AppendLine(" internal static global::System.Threading.Tasks.Task InterceptRunAsync(this global::Microsoft.AspNetCore.Builder.WebApplication app, string? url)"); + builder.AppendLine(" => app.StartAsync();"); + builder.AppendLine(); + } + + if (attributesByTarget.TryGetValue(InterceptTargetType.HostRunAsync, out var hostRunAsyncAttributes) + && hostRunAsyncAttributes.Count > 0) + { + foreach (var attribute in hostRunAsyncAttributes.OrderBy(static attribute => attribute)) + { + builder.AppendLine($" {attribute}"); + } + + builder.AppendLine(" internal static global::System.Threading.Tasks.Task InterceptHostRunAsync(this global::Microsoft.Extensions.Hosting.IHost host, global::System.Threading.CancellationToken cancellationToken)"); + builder.AppendLine(" => host.StartAsync(cancellationToken);"); + builder.AppendLine(); + } + + AppendCreateBuilderIntercept(builder, attributesByTarget, InterceptTargetType.CreateBuilderNoArgs, + "InterceptCreateBuilder", "()", "global::Microsoft.AspNetCore.Builder.WebApplicationWasmExtensions.CreateWasmBuilder()"); + + AppendCreateBuilderIntercept(builder, attributesByTarget, InterceptTargetType.CreateBuilderArgs, + "InterceptCreateBuilderWithArgs", "(string[] args)", + "global::Microsoft.AspNetCore.Builder.WebApplicationWasmExtensions.CreateWasmBuilder(new global::Microsoft.AspNetCore.Builder.WebApplicationOptions { Args = args })"); + + AppendCreateBuilderIntercept(builder, attributesByTarget, InterceptTargetType.CreateBuilderOptions, + "InterceptCreateBuilderWithOptions", "(global::Microsoft.AspNetCore.Builder.WebApplicationOptions options)", + "global::Microsoft.AspNetCore.Builder.WebApplicationWasmExtensions.CreateWasmBuilder(options)"); + + AppendCreateBuilderIntercept(builder, attributesByTarget, InterceptTargetType.CreateSlimBuilderNoArgs, + "InterceptCreateSlimBuilder", "()", "global::Microsoft.AspNetCore.Builder.WebApplicationWasmExtensions.CreateWasmBuilder()"); + + AppendCreateBuilderIntercept(builder, attributesByTarget, InterceptTargetType.CreateSlimBuilderOptions, + "InterceptCreateSlimBuilderWithOptions", "(global::Microsoft.AspNetCore.Builder.WebApplicationOptions options)", + "global::Microsoft.AspNetCore.Builder.WebApplicationWasmExtensions.CreateWasmBuilder(options)"); + + builder.AppendLine("}"); + builder.AppendLine("}"); + builder.AppendLine("#endif"); + + return builder.ToString(); + } + + private static void AppendCreateBuilderIntercept( + StringBuilder builder, + Dictionary> attributesByTarget, + InterceptTargetType targetType, + string methodName, + string parameterList, + string expression) + { + if (!attributesByTarget.TryGetValue(targetType, out var attributes) || attributes.Count == 0) + { + return; + } + + foreach (var attribute in attributes.OrderBy(static attribute => attribute)) + { + builder.AppendLine($" {attribute}"); + } + + builder.AppendLine($" internal static global::Microsoft.AspNetCore.Builder.WebApplicationBuilder {methodName}{parameterList}"); + builder.AppendLine($" => {expression};"); + builder.AppendLine(); + } +} + +internal readonly record struct InterceptInfo(InterceptTargetType TargetType, string AttributeSyntax); + +internal enum InterceptTargetType +{ + WebApplicationRunAsync, + HostRunAsync, + CreateBuilderNoArgs, + CreateBuilderArgs, + CreateBuilderOptions, + CreateSlimBuilderNoArgs, + CreateSlimBuilderOptions +} diff --git a/src/Zapto.AspNetCore.Wasm.SourceGenerator/Zapto.AspNetCore.Wasm.SourceGenerator.csproj b/src/Zapto.AspNetCore.Wasm.SourceGenerator/Zapto.AspNetCore.Wasm.SourceGenerator.csproj index 03abb51..6c05254 100644 --- a/src/Zapto.AspNetCore.Wasm.SourceGenerator/Zapto.AspNetCore.Wasm.SourceGenerator.csproj +++ b/src/Zapto.AspNetCore.Wasm.SourceGenerator/Zapto.AspNetCore.Wasm.SourceGenerator.csproj @@ -16,11 +16,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Zapto.AspNetCore.Wasm/WebApplicationWasmExtensions.cs b/src/Zapto.AspNetCore.Wasm/WebApplicationWasmExtensions.cs new file mode 100644 index 0000000..328534f --- /dev/null +++ b/src/Zapto.AspNetCore.Wasm/WebApplicationWasmExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Provides C# extension members for creating a WebApplication builder configured for WASM hosting. +/// +public static class WebApplicationWasmExtensions +{ + extension(WebApplication) + { + /// + /// Creates a WebApplicationBuilder configured with WebAssembly server hosting. + /// + /// A configured WebApplicationBuilder. + public static WebApplicationBuilder CreateWasmBuilder() + { + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + builder.WebHost.UseWasmServer(); + return builder; + } + + /// + /// Creates a WebApplicationBuilder configured with WebAssembly server hosting. + /// + /// Application command-line arguments. + /// A configured WebApplicationBuilder. + public static WebApplicationBuilder CreateWasmBuilder(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + Args = args, + }); + builder.WebHost.UseWasmServer(); + return builder; + } + + /// + /// Creates a WebApplicationBuilder configured with WebAssembly server hosting. + /// + /// Web application options. + /// A configured WebApplicationBuilder. + public static WebApplicationBuilder CreateWasmBuilder(WebApplicationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var builder = WebApplication.CreateEmptyBuilder(options); + builder.WebHost.UseWasmServer(); + return builder; + } + } +} diff --git a/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj b/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj index 727dd89..bc7bb84 100644 --- a/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj +++ b/src/Zapto.AspNetCore.Wasm/Zapto.AspNetCore.Wasm.csproj @@ -5,6 +5,7 @@ enable enable true + $(NoWarn);ASP0012 Zapto.AspNetCore.Wasm ASP.NET Core server implementation for WebAssembly using LLVM aspnetcore, webassembly, wasm, llvm, cloudflare, workers @@ -18,10 +19,4 @@ - - - PreserveNewest - - - diff --git a/src/Zapto.AspNetCore.Wasm/index.js b/src/Zapto.AspNetCore.Wasm/index.js deleted file mode 100644 index b95e1d0..0000000 --- a/src/Zapto.AspNetCore.Wasm/index.js +++ /dev/null @@ -1,109 +0,0 @@ -import worker from './Project.wasm'; -import start from './Project.js'; - -let cachedModule; - -function load() { - return cachedModule ??= new Promise(resolve => { - const Module = {}; - - Module.locateFile = () => './Project.wasm'; - - Module.instantiateWasm = async (info, receiveInstance) => { - const result = await WebAssembly.instantiate(worker, info); - receiveInstance(result); - }; - - Module.onRuntimeInitialized = () => { - const initialize = Module.cwrap('Initialize', null, []); - const beginRequest = Module.cwrap('BeginRequest', 'number', ['number', 'array', 'number', 'array']); - const endRequest = Module.cwrap('EndRequest', null, ['number']); - - initialize(); - - const encoder = new TextEncoder(); - const decoder = new TextDecoder('utf-8'); - - const getInt32 = (ptr) => { - const memory = Module['HEAP8']; - return ((memory[ptr + 3] & 0xFF) << 24) | - ((memory[ptr + 2] & 0xFF) << 16) | - ((memory[ptr + 1] & 0xFF) << 8) | - (memory[ptr] & 0xFF); - }; - - resolve((requestContext, requestBody) => { - const json = encoder.encode(JSON.stringify(requestContext)); - const ptr = beginRequest(json.length, json, requestBody.length, requestBody); - const memory = Module['HEAP8']; - - let offset = ptr; - - const responseBodyLength = getInt32(offset); - offset += 4; - - const responseLength = getInt32(offset); - offset += 4; - - let body = null; - - if (responseBodyLength > 0) { - // Copy the response body before creating the stream - const responseBodyCopy = new Uint8Array(responseBodyLength); - responseBodyCopy.set(memory.subarray(offset, offset + responseBodyLength)); - offset += responseBodyLength; - - let done = false; - body = new ReadableStream({ - start(controller) { - controller.enqueue(responseBodyCopy); - }, - pull(controller) { - if (!done) { - done = true; - controller.close(); - endRequest(ptr); - } - }, - cancel() { - endRequest(ptr); - } - }); - } else { - // No body, free memory immediately - endRequest(ptr); - } - - const responseContext = JSON.parse(decoder.decode(memory.subarray(offset, offset + responseLength))); - - return { body, response: responseContext }; - }); - }; - - start(Module); - }); -} - -export default { - async fetch(request, env, ctx) { - const processRequest = await load(); - const arrayBuffer = await request.arrayBuffer(); - const requestBody = new Uint8Array(arrayBuffer); - const headers = {}; - - for (const [key, value] of request.headers) { - headers[key] = value; - } - - const { body, response } = processRequest( - { - method: request.method, - url: request.url, - headers, - }, - requestBody - ); - - return new Response(body, response); - }, -}; diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs b/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs index 2433cc2..ab20339 100644 --- a/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs +++ b/tests/Zapto.AspNetCore.Wasm.Tests/WranglerFixture.cs @@ -28,7 +28,17 @@ public async ValueTask InitializeAsync() await EnsureWranglerAvailableAsync(); - var publishPath = Path.Combine(projectPath, "bin", "Release", "net10.0", "browser-wasm", "publish"); + var publishPath = Path.Combine(projectPath, "bin", "Release", "net10.0-browser", "browser-wasm", "publish"); + if (!Directory.Exists(publishPath)) + { + publishPath = Path.Combine(projectPath, "Release", "net10.0-browser", "browser-wasm", "publish"); + } + + if (!Directory.Exists(publishPath)) + { + publishPath = Path.Combine(projectPath, "bin", "Release", "net10.0", "browser-wasm", "publish"); + } + if (!Directory.Exists(publishPath)) { publishPath = Path.Combine(projectPath, "Release", "net10.0", "browser-wasm", "publish"); diff --git a/tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj b/tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj index d85954d..d406bd2 100644 --- a/tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj +++ b/tests/Zapto.AspNetCore.Wasm.Tests/Zapto.AspNetCore.Wasm.Tests.csproj @@ -32,8 +32,10 @@ $([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '..', '..', 'sandbox', 'WasmApp', 'WasmApp.csproj')) - - + + From f8d3ee512c3b429823755eaaede426e876bb684e Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Thu, 12 Feb 2026 21:44:52 +0100 Subject: [PATCH 5/6] cleanup --- sandbox/WasmApp/test-wrangler-dotnet-run.sh | 25 ------------------- sandbox/WasmApp/test-wrangler.sh | 27 --------------------- 2 files changed, 52 deletions(-) delete mode 100644 sandbox/WasmApp/test-wrangler-dotnet-run.sh delete mode 100644 sandbox/WasmApp/test-wrangler.sh diff --git a/sandbox/WasmApp/test-wrangler-dotnet-run.sh b/sandbox/WasmApp/test-wrangler-dotnet-run.sh deleted file mode 100644 index c2aef00..0000000 --- a/sandbox/WasmApp/test-wrangler-dotnet-run.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -echo "Starting wrangler..." -dotnet run 2>&1 & -WRANGLER_PID=$! - -echo "Waiting for wrangler to start (PID: $WRANGLER_PID)..." -sleep 5 - -echo "" -echo "=== Testing root ===" -curl -v --max-time 20 http://127.0.0.1:8787/ 2>&1 -echo "" - -echo "" -echo "=== Testing library/greet ===" -curl -s --max-time 30 http://127.0.0.1:8787/library/greet/TestUser -CURL_EXIT=$? -echo "" -echo "curl exit code: $CURL_EXIT" - -echo "" -echo "=== Stopping wrangler ===" -sleep 2 -kill $WRANGLER_PID 2>/dev/null -echo "Done" diff --git a/sandbox/WasmApp/test-wrangler.sh b/sandbox/WasmApp/test-wrangler.sh deleted file mode 100644 index f9fec75..0000000 --- a/sandbox/WasmApp/test-wrangler.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -cd /c/Sources/AspNetCore/bin/Release/net10.0/browser-wasm/cloudflare - -echo "Starting wrangler..." -npx wrangler dev --port 8792 --show-interactive-dev-session=false 2>&1 & -WRANGLER_PID=$! - -echo "Waiting for wrangler to start (PID: $WRANGLER_PID)..." -sleep 2 - -echo "" -echo "=== Testing root ===" -curl -v --max-time 20 http://127.0.0.1:8792/ 2>&1 -echo "" - -echo "" -echo "=== Testing library/greet ===" -curl -s --max-time 30 http://127.0.0.1:8792/library/greet/TestUser -CURL_EXIT=$? -echo "" -echo "curl exit code: $CURL_EXIT" - -echo "" -echo "=== Stopping wrangler ===" -sleep 2 -kill $WRANGLER_PID 2>/dev/null -echo "Done" From a1f29c2b45061063d737d27745e6c9705e208e66 Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Thu, 12 Feb 2026 21:49:17 +0100 Subject: [PATCH 6/6] Install workload in CI --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b7906a2..e79a84e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,6 +15,8 @@ jobs: 8.0.x 9.0.x 10.0.x + - name: Installl WASM workloads + run: dotnet workload install wasm-tools --skip-manifest-update - name: Install dependencies run: dotnet restore - name: Locate MSBuild with vswhere