diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a904504fa..083738e2d 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -157,6 +157,12 @@
+
+
+
+
+
+
@@ -236,6 +242,7 @@
+
@@ -253,6 +260,7 @@
+
@@ -293,12 +301,13 @@
+
-
+
@@ -316,6 +325,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6c0a70586..446b46d40 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -23,6 +23,8 @@
+
+
@@ -139,4 +141,4 @@
-
+
\ No newline at end of file
diff --git a/examples/seaweedfs/README.md b/examples/seaweedfs/README.md
new file mode 100644
index 000000000..4ece3095a
--- /dev/null
+++ b/examples/seaweedfs/README.md
@@ -0,0 +1,54 @@
+# SeaweedFS Aspire Integration Example
+
+This example demonstrates how to integrate and consume a SeaweedFS cluster within a .NET Aspire distributed application. It showcases both the **S3-Compatible API** and the **Native Filer API** in a single cohesive setup.
+
+## Project Structure
+
+* **SeaweedFS.AppHost:** The Aspire orchestrator. It spins up the SeaweedFS Docker container, enables the S3 Gateway and Data Volumes, and injects the dynamic connection strings into the API.
+* **SeaweedFS.ServiceDefaults:** Standard Aspire telemetry, resilience, and health check configurations.
+* **SeaweedFS.ApiService:** A minimal API application that registers both the `IAmazonS3` client and the `SeaweedFSFilerClient` to interact with the storage cluster.
+
+## Running the Example
+
+1. Ensure you have [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Podman running on your machine.
+2. Open a terminal in this directory (`aspire/seaweedfs/`).
+3. Run the AppHost:
+
+```dotnetcli
+dotnet run --project SeaweedFS.AppHost
+
+```
+
+4. Open the **Aspire Dashboard** URL provided in the console output.
+5. Wait for both the `seaweedfs` container and the `apiservice` to show as **Healthy**.
+
+## Exploring the Endpoints
+
+The `SeaweedFS.ApiService` exposes endpoints to test both storage approaches. You can trigger them using Swagger (if enabled) or using tools like `curl` or Postman.
+
+### S3 Endpoints (AWS SDK Compatibility)
+
+These endpoints use the injected `IAmazonS3` client.
+
+* **Create a Bucket:**
+`POST /s3/buckets?bucketName=my-bucket`
+* **Upload a File:**
+`POST /s3/upload?bucketName=my-bucket&key=test.txt`
+*(Send raw text in the body)*
+* **Download a File:**
+`GET /s3/download?bucketName=my-bucket&key=test.txt`
+
+### Filer Endpoints (Native SeaweedFS API)
+
+These endpoints use the injected `SeaweedFSFilerClient` performing direct HTTP calls to the cluster.
+
+* **Upload a File to Root:**
+`POST /filer/upload?fileName=native-file.txt`
+*(Send raw text in the body)*
+* **List Directory Contents:**
+`GET /filer/list`
+*(Returns a JSON representation of the Filer's directory structure)*
+
+## Viewing Data in SeaweedFS
+
+Since `SeaweedFS.AppHost` maps a data volume using `.WithDataVolume()`, any file you upload using the endpoints above will be persisted in the Docker volume. If you stop the AppHost and run it again, your data will still be accessible.
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.ApiService/Program.cs b/examples/seaweedfs/SeaweedFS.ApiService/Program.cs
new file mode 100644
index 000000000..b4d5c02c7
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.ApiService/Program.cs
@@ -0,0 +1,119 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using CommunityToolkit.Aspire.SeaweedFS.Client;
+using Microsoft.AspNetCore.Mvc;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+// Add service defaults (OpenTelemetry, HealthChecks, etc.)
+builder.AddServiceDefaults();
+
+// Add essential web services
+builder.Services.AddProblemDetails();
+
+// =========================================================================
+// 🌊 REGISTER SEAWEEDFS CLIENTS
+// =========================================================================
+// Basic Setup (Endpoints and credentials are automatically injected by AppHost)
+builder.AddSeaweedFSS3Client("seaweedfs");
+builder.AddSeaweedFSFilerClient("seaweedfs");
+
+/* // -------------------------------------------------------------------------
+// ADVANCED SETUP EXAMPLES
+// Uncomment and use the blocks below to override client settings programmatically.
+// -------------------------------------------------------------------------
+
+builder.AddSeaweedFSS3Client("seaweedfs", settings =>
+{
+ // The Endpoint is injected automatically, but you can override protocol rules:
+ settings.UseSsl = false;
+ settings.ForcePathStyle = true; // Required by SeaweedFS architecture
+
+ // Deep configuration of the underlying AWS SDK:
+ settings.ConfigureS3Config = s3Config =>
+ {
+ s3Config.Timeout = TimeSpan.FromSeconds(30);
+ s3Config.MaxErrorRetry = 3;
+ };
+});
+
+builder.AddSeaweedFSFilerClient("seaweedfs", settings =>
+{
+ // Example of disabling health checks for the Filer specifically
+ settings.DisableHealthChecks = true;
+});
+*/
+
+WebApplication app = builder.Build();
+
+app.UseExceptionHandler();
+
+// ==========================================
+// 🌊 S3 ENDPOINTS (AWS Compatibility)
+// ==========================================
+RouteGroupBuilder s3Group = app.MapGroup("/s3").WithTags("S3 API");
+
+s3Group.MapPost("/buckets", async ([FromQuery] string bucketName, IAmazonS3 s3Client) =>
+{
+ await s3Client.PutBucketAsync(new PutBucketRequest { BucketName = bucketName });
+ return Results.Ok(new { Message = "Bucket successfully created via S3 API." });
+});
+
+s3Group.MapPost("/upload", async ([FromQuery] string bucketName, [FromQuery] string key, [FromBody] string content, IAmazonS3 s3Client) =>
+{
+ await s3Client.PutObjectAsync(new PutObjectRequest
+ {
+ BucketName = bucketName,
+ Key = key,
+ ContentBody = content
+ });
+ return Results.Ok(new { Message = "File successfully uploaded to bucket via S3 API." });
+});
+
+s3Group.MapGet("/download", async ([FromQuery] string bucketName, [FromQuery] string key, IAmazonS3 s3Client) =>
+{
+ GetObjectResponse response = await s3Client.GetObjectAsync(bucketName, key);
+ using StreamReader reader = new(response.ResponseStream);
+ string content = await reader.ReadToEndAsync();
+
+ return Results.Ok(new
+ {
+ Bucket = System.Text.Encodings.Web.HtmlEncoder.Default.Encode(bucketName),
+ Key = System.Text.Encodings.Web.HtmlEncoder.Default.Encode(key),
+ Content = content
+ });
+});
+
+// ==========================================
+// 📁 FILER ENDPOINTS (Native API)
+// ==========================================
+RouteGroupBuilder filerGroup = app.MapGroup("/filer").WithTags("Filer API");
+
+filerGroup.MapGet("/list", async (SeaweedFSFilerClient filerClient) =>
+{
+ HttpRequestMessage request = new(HttpMethod.Get, "/");
+ request.Headers.Add("Accept", "application/json");
+
+ HttpResponseMessage response = await filerClient.HttpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ string content = await response.Content.ReadAsStringAsync();
+ return Results.Content(content, "application/json");
+});
+
+filerGroup.MapPost("/upload", async ([FromQuery] string fileName, [FromBody] string content, SeaweedFSFilerClient filerClient) =>
+{
+ StringContent stringContent = new(content);
+
+ string safeFileName = Uri.EscapeDataString(fileName.TrimStart('/'));
+
+ // Uploads the file directly to the root of the Filer
+ HttpResponseMessage response = await filerClient.HttpClient.PutAsync($"/{safeFileName}", stringContent);
+ response.EnsureSuccessStatusCode();
+
+ return Results.Ok(new { Message = "File successfully uploaded via native Filer API." });
+});
+
+app.MapDefaultEndpoints();
+
+app.Run();
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.ApiService/Properties/launchSettings.json b/examples/seaweedfs/SeaweedFS.ApiService/Properties/launchSettings.json
new file mode 100644
index 000000000..9713d2083
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.ApiService/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "SeaweedFS.ApiService": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:4530;http://localhost:4531"
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.ApiService/SeaweedFS.ApiService.csproj b/examples/seaweedfs/SeaweedFS.ApiService/SeaweedFS.ApiService.csproj
new file mode 100644
index 000000000..9bc3cfa96
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.ApiService/SeaweedFS.ApiService.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/seaweedfs/SeaweedFS.ApiService/appsettings.json b/examples/seaweedfs/SeaweedFS.ApiService/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.ApiService/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/apphost.mts b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/apphost.mts
new file mode 100644
index 000000000..df0b815ce
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/apphost.mts
@@ -0,0 +1,18 @@
+import { createBuilder } from "./.aspire/modules/aspire.mjs";
+
+const builder = await createBuilder();
+
+// 1. S3-Compatible API Gateway
+const seaweedS3 = await builder.addSeaweedFS("seaweedfs-s3");
+await seaweedS3.withS3();
+await seaweedS3.withDataVolume();
+
+// 2. Native Filer API Only
+const seaweedFiler = await builder.addSeaweedFS("seaweedfs-filer");
+await seaweedFiler.withFiler();
+
+// Get connection strings to ensure the expressions are evaluated properly by ATS
+const _s3ConnectionString = await seaweedS3.connectionStringExpression();
+const _filerConnectionString = await seaweedFiler.connectionStringExpression();
+
+await builder.build().run();
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/aspire.config.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/aspire.config.json
new file mode 100644
index 000000000..418390b79
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/aspire.config.json
@@ -0,0 +1,17 @@
+{
+ "appHost": {
+ "path": "apphost.mts",
+ "language": "typescript/nodejs"
+ },
+ "sdk": {
+ "version": "13.4.0-preview.1.26275.15"
+ },
+ "profiles": {
+ "http": {
+ "applicationUrl": "http://localhost:15333"
+ }
+ },
+ "packages": {
+ "CommunityToolkit.Aspire.Hosting.SeaweedFS": "../../../src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj"
+ }
+}
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/eslint.config.mjs b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/eslint.config.mjs
new file mode 100644
index 000000000..159a14ac6
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/eslint.config.mjs
@@ -0,0 +1,17 @@
+// @ts-check
+
+import { defineConfig } from 'eslint/config';
+import tseslint from 'typescript-eslint';
+
+export default defineConfig({
+ files: ['apphost.mts'],
+ extends: [tseslint.configs.base],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ },
+ },
+ rules: {
+ '@typescript-eslint/no-floating-promises': ['error', { checkThenables: true }],
+ },
+});
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package-lock.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package-lock.json
new file mode 100644
index 000000000..b55ba1742
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package-lock.json
@@ -0,0 +1,2034 @@
+{
+ "name": "validationapphost",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "validationapphost",
+ "version": "1.0.0",
+ "dependencies": {
+ "vscode-jsonrpc": "^8.2.1"
+ },
+ "devDependencies": {
+ "@types/node": "^25",
+ "eslint": "^10",
+ "nodemon": "^3.1.14",
+ "tsx": "^4.22.4",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.60.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
+ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
+ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
+ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
+ "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.60.1",
+ "@typescript-eslint/type-utils": "8.60.1",
+ "@typescript-eslint/utils": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.60.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
+ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.60.1",
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/typescript-estree": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
+ "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.60.1",
+ "@typescript-eslint/types": "^8.60.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
+ "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
+ "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
+ "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/typescript-estree": "8.60.1",
+ "@typescript-eslint/utils": "8.60.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
+ "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
+ "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.60.1",
+ "@typescript-eslint/tsconfig-utils": "8.60.1",
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/visitor-keys": "8.60.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
+ "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.60.1",
+ "@typescript-eslint/types": "8.60.1",
+ "@typescript-eslint/typescript-estree": "8.60.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
+ "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.60.1",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
+ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.6.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.2",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.4",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.14",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+ "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^10.2.1",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.22.4",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.60.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz",
+ "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.60.1",
+ "@typescript-eslint/parser": "8.60.1",
+ "@typescript-eslint/typescript-estree": "8.60.1",
+ "@typescript-eslint/utils": "8.60.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
+ "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package.json
new file mode 100644
index 000000000..624958522
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "validationapphost",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "lint": "eslint apphost.mts",
+ "predev": "npm run lint",
+ "dev": "aspire run",
+ "prebuild": "npm run lint",
+ "build": "tsc",
+ "watch": "tsc --watch"
+ },
+ "dependencies": {
+ "vscode-jsonrpc": "^8.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "devDependencies": {
+ "@types/node": "^25",
+ "eslint": "^10",
+ "nodemon": "^3.1.14",
+ "tsx": "^4.22.4",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.60.1"
+ }
+}
diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.json
new file mode 100644
index 000000000..c34e4c0b3
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "rootDir": "."
+ },
+ "include": [
+ "apphost.mts",
+ ".aspire/modules/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.tsbuildinfo b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.tsbuildinfo
new file mode 100644
index 000000000..2d90f95ef
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./apphost.ts"],"errors":true,"version":"6.0.3"}
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.AppHost/AppHost.cs b/examples/seaweedfs/SeaweedFS.AppHost/AppHost.cs
new file mode 100644
index 000000000..400a4a7e6
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost/AppHost.cs
@@ -0,0 +1,37 @@
+IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
+
+// =========================================================================
+// 🌊 SEAWEEDFS CONFIGURATION EXAMPLES
+// =========================================================================
+// The integration provides flexible options to configure APIs, ports, and security.
+//
+// 1. STANDARD S3 SETUP (Default ports dynamically mapped by Aspire)
+// builder.AddSeaweedFS("seaweedfs").WithS3().WithDataVolume();
+//
+// 2. NATIVE FILER SETUP (No S3 overhead)
+// builder.AddSeaweedFS("seaweedfs").WithFiler().WithDataVolume();
+//
+// 3. ADVANCED SETUP (Custom ports, credentials, and persistence)
+// var accessKey = builder.AddParameter("s3-access-key", "my-custom-admin");
+// var secretKey = builder.AddParameter("s3-secret-key", "my-super-secret", secret: true);
+//
+// builder.AddSeaweedFS("seaweedfs")
+// .WithHostPort(9333) // Locks the Master API host port
+// .WithS3(s3Port: 8333) // Locks the S3 Gateway host port
+// .WithFiler(filerPort: 8888) // Locks the Filer API host port
+// .WithAccessKey(accessKey) // Applies custom S3 Access Key
+// .WithSecretKey(secretKey) // Applies custom S3 Secret Key
+// .WithDataVolume("my-seaweed-data"); // Uses a named docker volume
+// =========================================================================
+
+IResourceBuilder seaweedfs = builder.AddSeaweedFS("seaweedfs")
+ .WithS3(); // Use .WithS3() for AWS compatibility or .WithFiler() for native API
+ //.WithDataVolume(); // Uncomment to enable persistent storage across restarts
+
+// Adds the API and injects the SeaweedFS cluster connection string
+builder.AddProject("apiservice")
+ .WithReference(seaweedfs)
+ .WaitFor(seaweedfs)
+ .WithHttpHealthCheck("/health");
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.AppHost/SeaweedFS.AppHost.csproj b/examples/seaweedfs/SeaweedFS.AppHost/SeaweedFS.AppHost.csproj
new file mode 100644
index 000000000..4dfa8ff95
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost/SeaweedFS.AppHost.csproj
@@ -0,0 +1,13 @@
+
+
+
+ Exe
+ 3cd96cb6-5c49-48f1-8c89-c1f210bb205d
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/seaweedfs/SeaweedFS.AppHost/appsettings.json b/examples/seaweedfs/SeaweedFS.AppHost/appsettings.json
new file mode 100644
index 000000000..31c092aa4
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/examples/seaweedfs/SeaweedFS.ServiceDefaults/Extensions.cs b/examples/seaweedfs/SeaweedFS.ServiceDefaults/Extensions.cs
new file mode 100644
index 000000000..6eac296dd
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.ServiceDefaults/Extensions.cs
@@ -0,0 +1,126 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/examples/seaweedfs/SeaweedFS.ServiceDefaults/SeaweedFS.ServiceDefaults.csproj b/examples/seaweedfs/SeaweedFS.ServiceDefaults/SeaweedFS.ServiceDefaults.csproj
new file mode 100644
index 000000000..7a2fb7cf6
--- /dev/null
+++ b/examples/seaweedfs/SeaweedFS.ServiceDefaults/SeaweedFS.ServiceDefaults.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj
new file mode 100644
index 000000000..d113c6a0d
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj
@@ -0,0 +1,16 @@
+
+
+
+ seaweedfs hosting cloud storage s3
+ Provides extension methods and resource definitions for the Aspire AppHost to support running SeaweedFS containers with an S3-compatible API.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/README.md b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/README.md
new file mode 100644
index 000000000..66439bd39
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/README.md
@@ -0,0 +1,67 @@
+# CommunityToolkit.Aspire.Hosting.SeaweedFS library
+
+Provides extension methods and resource definitions for the Aspire AppHost to support running [SeaweedFS](https://github.com/seaweedfs/seaweedfs) containers with flexible API configurations (Native Master/Volume/Filer APIs or an S3-compatible Gateway).
+
+## Getting Started
+
+### Install the package
+
+In your AppHost project, install the package using the following command:
+
+```dotnetcli
+dotnet add package CommunityToolkit.Aspire.Hosting.SeaweedFS
+
+```
+
+### Example usage
+
+Then, in the *Program.cs* file of `AppHost`, add a SeaweedFS resource, opt-in to the desired APIs, and consume the connection in your dependent projects:
+
+```csharp
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Example 1: Standard usage enabling the S3-Compatible API Gateway
+// Note: Enabling S3 implicitly enables and exposes the Filer API underneath.
+var seaweedS3 = builder.AddSeaweedFS("seaweedfs")
+ .WithS3()
+ .WithDataVolume();
+
+// Example 2: Native usage enabling only the Filer API (No S3 API running)
+var seaweedNative = builder.AddSeaweedFS("seaweed-native")
+ .WithFiler()
+ .WithDataVolume();
+
+// Reference the SeaweedFS resource in a project.
+var apiService = builder.AddProject("apiservice")
+ .WithReference(seaweedS3);
+
+builder.Build().Run();
+
+```
+
+When you use `.WithReference()`, the hosting library dynamically builds the connection string based on your opt-ins. For instance, if you chained `.WithS3()`, the connection string will inject the S3 endpoint, credentials, and the explicit `FilerEndpoint` to be consumed by the Aspire SeaweedFS Client package automatically.
+
+## Configuring SeaweedFS
+
+SeaweedFS provides several modular extension methods to configure its container based on your needs:
+
+### API Activation (Opt-In)
+
+* `WithS3(int? s3Port)`: Enables the SeaweedFS S3-Compatible API Gateway (defaults to target port `8333`). This implicitly enables the Filer API since it is required by the S3 gateway.
+* `WithFiler(int? filerPort)`: Enables the SeaweedFS Native Filer API (defaults to target port `8888`) without starting the S3 gateway.
+* `WithHostPort(int? port)`: Configures the explicit host port for the core Master API (defaults to target port `9333`).
+
+### Credentials & Security
+
+* `WithAccessKey(IResourceBuilder accessKey)`: Configures a custom Access Key parameter for the S3 identity mapping wrapper.
+* `WithSecretKey(IResourceBuilder secretKey)`: Configures a custom Secret Key parameter for the S3 identity mapping wrapper.
+* `WithS3ConfigFile(string configFilePath)`: Overrides the dynamic configuration injection and enforces using a specific custom local `s3.json` configuration file mounted directly into the container.
+
+### Persistence
+
+* `WithDataBindMount(string source, bool isReadOnly)`: Binds a local host directory to the container's `/data` folder to persist your cluster data across restarts.
+* `WithDataVolume(string? name, bool isReadOnly)`: Uses a named Docker volume to persist SeaweedFS cluster data.
+
+## Feedback & contributing
+
+https://github.com/CommunityToolkit/Aspire
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSBuilderExtensions.cs
new file mode 100644
index 000000000..dfc7b2d6c
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSBuilderExtensions.cs
@@ -0,0 +1,284 @@
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.SeaweedFS;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+#pragma warning disable IDE0130
+namespace Aspire.Hosting;
+#pragma warning restore IDE0130
+
+///
+/// Provides extension methods for adding SeaweedFS resources to an .
+///
+public static class SeaweedFSBuilderExtensions
+{
+ private const int DefaultS3Port = 8333;
+ private const int DefaultMasterPort = 9333;
+ private const int DefaultFilerPort = 8888;
+ private const int DefaultVolumePort = 8080;
+
+ private const string AccessKeyEnvVarName = "S3_ACCESS_KEY";
+ private const string SecretKeyEnvVarName = "S3_SECRET_KEY";
+
+ ///
+ /// Adds a base SeaweedFS container (Master and Volume APIs) to the application model.
+ /// Chain with .WithS3() or .WithFiler() to enable additional APIs.
+ ///
+ /// The .
+ /// The name of the resource. This name will be used as the connection string name when referenced in a dependency.
+ /// A reference to the .
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder AddSeaweedFS(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ // Properly name the access key to avoid collisions and explicitly add it to the builder
+ // so it appears in the manifest generated by azd.
+ ParameterResource accessKeyParameter = new($"{name}-accessKey", _ => "admin");
+ builder.AddResource(accessKeyParameter);
+
+ ParameterResource secretKeyParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-secretKey");
+
+ SeaweedFSContainerResource resource = new(name, accessKeyParameter, secretKeyParameter);
+
+ IResourceBuilder builderWithResource = builder.AddResource(resource)
+ .WithImage(SeaweedFSContainerImageTags.Image, SeaweedFSContainerImageTags.Tag)
+ .WithImageRegistry(SeaweedFSContainerImageTags.Registry)
+ .WithHttpEndpoint(targetPort: DefaultMasterPort, name: SeaweedFSContainerResource.MasterEndpointName)
+ .WithHttpEndpoint(targetPort: DefaultVolumePort, name: SeaweedFSContainerResource.VolumeEndpointName);
+
+ // Configure strict, topology-aware readiness verification
+ string healthCheckKey = $"{name}_check";
+ builder.Services.AddHealthChecks()
+ .Add(new HealthCheckRegistration(
+ name: healthCheckKey,
+ factory: sp => new SeaweedFSHealthCheck(resource),
+ failureStatus: HealthStatus.Unhealthy,
+ tags: ["seaweedfs", "storage", "cluster"],
+ timeout: TimeSpan.FromSeconds(10)));
+
+ builderWithResource.WithHealthCheck(healthCheckKey);
+
+ // Core pipeline arguments map
+ builderWithResource.WithArgs(context =>
+ {
+ bool isFilerEnabled = builderWithResource.Resource.Annotations.OfType().Any();
+ bool isS3Enabled = builderWithResource.Resource.Annotations.OfType().Any();
+ SeaweedFSCustomS3ConfigAnnotation? customConfig = builderWithResource.Resource.Annotations.OfType().SingleOrDefault();
+
+ if (isS3Enabled && !isFilerEnabled)
+ {
+ isFilerEnabled = true;
+ }
+
+ string args = "weed server -dir=\"/data\"";
+ if (isFilerEnabled) args += " -filer";
+
+ if (customConfig is not null)
+ {
+ args += " -s3 -s3.config=\"/etc/seaweedfs/s3-custom.json\"";
+ context.Args.Clear();
+ context.Args.Add("server");
+ context.Args.Add("-dir=/data");
+ context.Args.Add("-s3");
+ context.Args.Add("-s3.config=/etc/seaweedfs/s3-custom.json");
+ context.Args.Add("-filer");
+ }
+ else if (isS3Enabled)
+ {
+ args += " -s3 -s3.config=\"/tmp/s3.json\"";
+
+ // Robust shell script escaping using 'EOF' Heredoc to avoid escaping breakages
+ string shellScript = "cat << EOF > /tmp/s3.json\n" +
+ "{\"identities\":[{\"name\":\"aspire-user\",\"credentials\":[{\"accessKey\":\"" + $"${AccessKeyEnvVarName}" + "\",\"secretKey\":\"" + $"${SecretKeyEnvVarName}" + "\"}],\"actions\":[\"Admin\",\"Read\",\"Write\",\"List\",\"Tagging\"]}]}\n" +
+ "EOF\n" +
+ $"exec {args}";
+
+ context.Args.Clear();
+ context.Args.Add("-c");
+ context.Args.Add(shellScript);
+ }
+ else
+ {
+ context.Args.Clear();
+ context.Args.Add("server");
+ context.Args.Add("-dir=/data");
+ if (isFilerEnabled) context.Args.Add("-filer");
+ }
+ });
+
+ return builderWithResource;
+ }
+
+ ///
+ /// Enables the SeaweedFS S3-Compatible API Gateway.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithS3(
+ this IResourceBuilder builder,
+ int? s3Port = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ // Implicitly expose the Filer endpoint since the S3 gateway relies on it natively.
+ // This ensures the endpoint is registered in the Aspire application model.
+ if (!builder.Resource.Annotations.OfType().Any())
+ {
+ builder.WithFiler();
+ }
+
+ builder.WithAnnotation(new SeaweedFSS3Annotation());
+ builder.WithHttpEndpoint(targetPort: DefaultS3Port, port: s3Port, name: SeaweedFSContainerResource.S3EndpointName);
+
+ // Ensure implicit initialization of entrypoint behavior based on the required shell deployment
+ builder.WithEntrypoint("sh");
+
+ builder.WithEnvironment(AccessKeyEnvVarName, builder.Resource.AccessKey);
+ builder.WithEnvironment(SecretKeyEnvVarName, builder.Resource.SecretKey);
+
+ return builder;
+ }
+
+ ///
+ /// Enables the SeaweedFS Native Filer API.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithFiler(
+ this IResourceBuilder builder,
+ int? filerPort = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.WithAnnotation(new SeaweedFSFilerAnnotation());
+ builder.WithHttpEndpoint(targetPort: DefaultFilerPort, port: filerPort, name: SeaweedFSContainerResource.FilerEndpointName);
+
+ if (!builder.Resource.Annotations.OfType().Any())
+ {
+ builder.WithEntrypoint("weed");
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Configures the Master API host port that the SeaweedFS resource is exposed on.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithHostPort(this IResourceBuilder builder, int? port)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithEndpoint(SeaweedFSContainerResource.MasterEndpointName, endpoint =>
+ {
+ endpoint.Port = port;
+ });
+ }
+
+ ///
+ /// Configures the Access Key that the SeaweedFS S3 resource uses.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithAccessKey(
+ this IResourceBuilder builder,
+ IResourceBuilder accessKey)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(accessKey);
+
+ builder.Resource.AccessKey = accessKey.Resource;
+ return builder;
+ }
+
+ ///
+ /// Configures the Secret Key that the SeaweedFS S3 resource uses.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithSecretKey(
+ this IResourceBuilder builder,
+ IResourceBuilder secretKey)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(secretKey);
+
+ builder.Resource.SecretKey = secretKey.Resource;
+ return builder;
+ }
+
+ ///
+ /// Configures the SeaweedFS S3 API to use a specific external security configuration file.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithS3ConfigFile(
+ this IResourceBuilder builder,
+ string configFilePath)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
+
+ // Prevent the container from enabling the -s3 arg without actually mapping the S3 port.
+ if (!builder.Resource.Annotations.OfType().Any())
+ {
+ throw new InvalidOperationException("You must enable the S3 Gateway using .WithS3() before configuring a custom S3 config file.");
+ }
+
+ string containerPath = "/etc/seaweedfs/s3-custom.json";
+
+ // Safely annotate the pipeline to resolve logic within 'WithArgs' rather than forcing raw overrides
+ builder.WithAnnotation(new SeaweedFSCustomS3ConfigAnnotation(configFilePath));
+ builder.WithEntrypoint("weed");
+
+ return builder.WithBindMount(configFilePath, containerPath, isReadOnly: true);
+ }
+
+ ///
+ /// Adds a bind mount for the SeaweedFS data folder.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithDataBindMount(
+ this IResourceBuilder builder,
+ string source,
+ bool isReadOnly = false)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(source);
+
+ return builder.WithBindMount(source, "/data", isReadOnly);
+ }
+
+ ///
+ /// Adds a named volume for the SeaweedFS data folder.
+ ///
+#pragma warning disable ASPIREATS001
+ [AspireExport]
+#pragma warning restore ASPIREATS001
+ public static IResourceBuilder WithDataVolume(
+ this IResourceBuilder builder,
+ string? name = null,
+ bool isReadOnly = false)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ string volumeName = name ?? VolumeNameGenerator.Generate(builder, "data");
+
+ return builder.WithVolume(volumeName, "/data", isReadOnly);
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerImageTags.cs
new file mode 100644
index 000000000..e7a6b622b
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerImageTags.cs
@@ -0,0 +1,13 @@
+namespace CommunityToolkit.Aspire.Hosting.SeaweedFS;
+
+internal static class SeaweedFSContainerImageTags
+{
+ /// docker.io
+ public const string Registry = "docker.io";
+
+ /// chrislusf/seaweedfs
+ public const string Image = "chrislusf/seaweedfs";
+
+ /// 4.27
+ public const string Tag = "4.27";
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerResource.cs
new file mode 100644
index 000000000..20237672f
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerResource.cs
@@ -0,0 +1,111 @@
+using Aspire.Hosting.ApplicationModel;
+
+#pragma warning disable IDE0130
+namespace Aspire.Hosting;
+#pragma warning restore IDE0130
+
+///
+/// A resource that represents a SeaweedFS container.
+///
+/// The resource name.
+/// The parameter containing the S3 access key.
+/// The parameter containing the S3 secret key.
+public sealed class SeaweedFSContainerResource(string name, ParameterResource accessKey, ParameterResource secretKey)
+ : ContainerResource(name), IResourceWithConnectionString
+{
+ internal const string S3EndpointName = "s3";
+ internal const string MasterEndpointName = "master";
+ internal const string FilerEndpointName = "filer";
+ internal const string VolumeEndpointName = "volume";
+
+ /// Gets the S3 Access Key.
+ public ParameterResource AccessKey { get; internal set; } = accessKey;
+
+ /// Gets the S3 Secret Key.
+ public ParameterResource SecretKey { get; internal set; } = secretKey;
+
+ /// Gets the primary endpoint for the SeaweedFS S3 API. Will throw if S3 is not enabled.
+ public EndpointReference PrimaryEndpoint => new(this, S3EndpointName);
+
+ /// Gets the endpoint for the SeaweedFS Master API.
+ public EndpointReference MasterEndpoint => new(this, MasterEndpointName);
+
+ /// Gets the endpoint for the SeaweedFS Filer API. Will throw if Filer is not enabled.
+ public EndpointReference FilerEndpoint => new(this, FilerEndpointName);
+
+ /// Gets the connection string expression for the SeaweedFS resource.
+ public ReferenceExpression ConnectionStringExpression => GetConnectionString();
+
+ ///
+ /// Gets the connection string asynchronously.
+ ///
+ /// A to observe while waiting for the task to complete.
+ /// A connection string for the SeaweedFS server.
+ public ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default)
+ {
+ if (this.TryGetLastAnnotation(out ConnectionStringRedirectAnnotation? connectionStringAnnotation))
+ {
+ return connectionStringAnnotation.Resource.GetConnectionStringAsync(cancellationToken);
+ }
+
+ return ConnectionStringExpression.GetValueAsync(cancellationToken);
+ }
+
+ private ReferenceExpression GetConnectionString()
+ {
+ ReferenceExpressionBuilder builder = new();
+
+ bool hasS3 = Annotations.OfType().Any();
+ bool hasFiler = Annotations.OfType().Any();
+
+ EndpointReference targetEndpoint = hasS3 ? PrimaryEndpoint : MasterEndpoint;
+
+ builder.Append($"Endpoint=http://{targetEndpoint.Property(EndpointProperty.Host)}:{targetEndpoint.Property(EndpointProperty.Port)}");
+
+ if (hasS3)
+ {
+ builder.Append($";AccessKey={AccessKey}");
+ builder.Append($";SecretKey={SecretKey}");
+ }
+
+ if (hasFiler)
+ {
+ builder.Append($";FilerEndpoint=http://{FilerEndpoint.Property(EndpointProperty.Host)}:{FilerEndpoint.Property(EndpointProperty.Port)}");
+ }
+
+ return builder.Build();
+ }
+
+ IEnumerable> IResourceWithConnectionString.GetConnectionProperties()
+ {
+ bool hasS3 = Annotations.OfType().Any();
+ bool hasFiler = Annotations.OfType().Any();
+
+ EndpointReference targetEndpoint = hasS3 ? PrimaryEndpoint : MasterEndpoint;
+
+ yield return new("Host", ReferenceExpression.Create($"{targetEndpoint.Property(EndpointProperty.Host)}"));
+ yield return new("Port", ReferenceExpression.Create($"{targetEndpoint.Property(EndpointProperty.Port)}"));
+
+ yield return new("MasterUrl", ReferenceExpression.Create($"http://{MasterEndpoint.Property(EndpointProperty.Host)}:{MasterEndpoint.Property(EndpointProperty.Port)}"));
+
+ if (hasS3)
+ {
+ yield return new("AccessKey", ReferenceExpression.Create($"{AccessKey}"));
+ yield return new("SecretKey", ReferenceExpression.Create($"{SecretKey}"));
+ yield return new("S3Url", ReferenceExpression.Create($"http://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}"));
+ }
+
+ if (hasFiler)
+ {
+ yield return new("FilerUrl", ReferenceExpression.Create($"http://{FilerEndpoint.Property(EndpointProperty.Host)}:{FilerEndpoint.Property(EndpointProperty.Port)}"));
+ }
+ }
+}
+
+// Internal annotations used safely during the resource compilation lifecycle
+internal sealed class SeaweedFSS3Annotation : IResourceAnnotation { }
+internal sealed class SeaweedFSFilerAnnotation : IResourceAnnotation { }
+internal sealed class SeaweedFSCustomS3ConfigAnnotation(string hostPath) : IResourceAnnotation
+{
+ public string HostPath { get; } = hostPath;
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSHealthCheck.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSHealthCheck.cs
new file mode 100644
index 000000000..59b9a2fa6
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSHealthCheck.cs
@@ -0,0 +1,69 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using System.Text.Json;
+
+namespace CommunityToolkit.Aspire.Hosting.SeaweedFS;
+
+///
+/// A smart readiness probe that ensures the SeaweedFS cluster is not marked as healthy
+/// until the Volume Server has fully mapped the data nodes to the Master Server.
+///
+internal sealed class SeaweedFSHealthCheck(SeaweedFSContainerResource resource) : IHealthCheck
+{
+ // Use a shared HttpClient to prevent socket exhaustion during periodic health checks.
+ // PooledConnectionLifetime prevents DNS caching issues in dynamic container environments.
+ private static readonly HttpClient s_httpClient = new(new SocketsHttpHandler
+ {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5)
+ })
+ {
+ Timeout = TimeSpan.FromSeconds(5)
+ };
+
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ EndpointReference masterEndpoint = resource.GetEndpoint(SeaweedFSContainerResource.MasterEndpointName);
+
+ // Evaluates the endpoint. If the container is still booting network interfaces, it will throw safely.
+ string dirStatusUrl = $"{masterEndpoint.Url}/dir/status";
+
+ // Explicitly request JSON to prevent the SeaweedFS Master API from
+ // returning HTML in edge cases or future versions, which would break the JSON parser.
+ using HttpRequestMessage request = new(HttpMethod.Get, dirStatusUrl);
+ request.Headers.Add("Accept", "application/json");
+
+ HttpResponseMessage response = await s_httpClient.SendAsync(request, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return HealthCheckResult.Unhealthy($"Master API is unreachable. HTTP {response.StatusCode}.");
+ }
+
+ string json = await response.Content.ReadAsStringAsync(cancellationToken);
+ using JsonDocument document = JsonDocument.Parse(json);
+ JsonElement root = document.RootElement;
+
+ // Validates the Eventual Consistency of the cluster topology
+ if (root.TryGetProperty("Topology", out JsonElement topology) && topology.TryGetProperty("Max", out JsonElement max))
+ {
+ if (max.GetInt64() == 0)
+ {
+ return HealthCheckResult.Unhealthy("Master is online, but waiting for Volume Server to map data nodes...");
+ }
+ }
+ else
+ {
+ return HealthCheckResult.Unhealthy("Invalid topology response from Master.");
+ }
+
+ return HealthCheckResult.Healthy("SeaweedFS Cluster is fully operational and data volumes are registered.");
+ }
+ catch (Exception ex)
+ {
+ return HealthCheckResult.Unhealthy("Failed to connect to SeaweedFS Master API.", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/CommunityToolkit.Aspire.SeaweedFS.Client.csproj b/src/CommunityToolkit.Aspire.SeaweedFS.Client/CommunityToolkit.Aspire.SeaweedFS.Client.csproj
new file mode 100644
index 000000000..e77aaf4ca
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/CommunityToolkit.Aspire.SeaweedFS.Client.csproj
@@ -0,0 +1,23 @@
+
+
+
+ seaweedfs client cloud storage s3
+ An Aspire client integration for SeaweedFS using the AWSSDK.S3 client.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/README.md b/src/CommunityToolkit.Aspire.SeaweedFS.Client/README.md
new file mode 100644
index 000000000..70e6f49a3
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/README.md
@@ -0,0 +1,130 @@
+# CommunityToolkit.Aspire.SeaweedFS.Client library
+
+Provides an Aspire client integration for SeaweedFS. It supports both the S3-compatible API using the standard `AWSSDK.S3` client, and the Native Filer API through a strongly-typed `HttpClient`.
+
+## Usage example
+
+In the *Program.cs* file of your project, you can register the SeaweedFS clients depending on the APIs you enabled in your AppHost.
+
+### Registering the S3 Client
+Call the `AddSeaweedFSS3Client` extension method to register an `IAmazonS3` client for use via the dependency injection container.
+
+```csharp
+builder.AddSeaweedFSS3Client("seaweedfs");
+
+```
+
+### Registering the Native Filer Client
+
+Call the `AddSeaweedFSFilerClient` extension method to register a `SeaweedFSFilerClient` (which wraps a configured `HttpClient` pointing directly to the Filer node).
+
+```csharp
+builder.AddSeaweedFSFilerClient("seaweedfs");
+
+```
+
+## Configuration
+
+The Aspire SeaweedFS Client integration provides multiple options to configure the server connection based on the requirements and conventions of your project.
+
+### Use a connection string
+
+When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling the builder methods. The connection string dynamically handles multiple endpoints (Master, S3, Filer) injected by the AppHost.
+
+```json
+{
+ "ConnectionStrings": {
+ "seaweedfs": "Endpoint=http://localhost:8333;FilerEndpoint=http://localhost:8888;AccessKey=admin;SecretKey=admin-secret;UseSsl=false"
+ }
+}
+
+```
+
+### Use configuration providers
+
+The client supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `SeaweedFSClientSettings` from configuration by using the `Aspire:SeaweedFS:Client` key.
+
+Example `appsettings.json` that configures both endpoints and enables SSL:
+
+```json
+{
+ "Aspire": {
+ "SeaweedFS": {
+ "Client": {
+ "Endpoint": "http://localhost:8333",
+ "FilerEndpoint": "http://localhost:8888",
+ "AccessKey": "admin",
+ "SecretKey": "admin-secret",
+ "ForcePathStyle": true,
+ "UseSsl": false,
+ "DisableHealthChecks": false
+ }
+ }
+ }
+}
+
+```
+
+> **Note:** `ForcePathStyle` defaults to `true` as it is strictly required by the SeaweedFS architecture to correctly route S3 bucket requests.
+
+### Use inline delegates
+
+You can also pass the `Action configureSettings` delegate to set up some or all the options inline. This is particularly useful for applying advanced S3 configurations:
+
+```csharp
+builder.AddSeaweedFSS3Client("seaweedfs", configureSettings: settings =>
+{
+ settings.AccessKey = "admin";
+ settings.SecretKey = "admin-secret";
+ settings.ConfigureS3Config = s3Config =>
+ {
+ s3Config.Timeout = TimeSpan.FromSeconds(30);
+ s3Config.MaxErrorRetry = 3;
+ };
+});
+
+```
+
+## Consuming the Clients
+
+Once registered, you can inject the clients into your services.
+
+**Using the S3 API:**
+
+```csharp
+using Amazon.S3;
+using Amazon.S3.Model;
+
+public class StorageService(IAmazonS3 s3Client)
+{
+ public async Task CreateBucketAsync(string bucketName)
+ {
+ await s3Client.PutBucketAsync(new PutBucketRequest { BucketName = bucketName });
+ }
+}
+
+```
+
+**Using the Native Filer API:**
+
+```csharp
+using CommunityToolkit.Aspire.SeaweedFS.Client;
+
+public class NativeFileService(SeaweedFSFilerClient filerClient)
+{
+ public async Task ListFilesAsync()
+ {
+ // Requires Accept header for JSON responses from Filer
+ var request = new HttpRequestMessage(HttpMethod.Get, "/");
+ request.Headers.Add("Accept", "application/json");
+
+ var response = await filerClient.HttpClient.SendAsync(request);
+ return await response.Content.ReadAsStringAsync();
+ }
+}
+
+```
+
+## Feedback & contributing
+
+https://github.com/CommunityToolkit/Aspire
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientBuilderExtensionMethods.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientBuilderExtensionMethods.cs
new file mode 100644
index 000000000..fc83714f2
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientBuilderExtensionMethods.cs
@@ -0,0 +1,162 @@
+using Amazon.Runtime;
+using Amazon.S3;
+using CommunityToolkit.Aspire.SeaweedFS.Client;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+#pragma warning disable IDE0130
+namespace Microsoft.Extensions.Hosting;
+#pragma warning restore IDE0130
+
+///
+/// Extension methods for connecting a SeaweedFS cluster to an Aspire application.
+///
+public static class SeaweedFSClientBuilderExtensionMethods
+{
+ private const string DefaultConfigSectionName = "Aspire:SeaweedFS:Client";
+
+ ///
+ /// Registers a client for interacting with the SeaweedFS S3 API.
+ ///
+ public static void AddSeaweedFSS3Client(
+ this IHostApplicationBuilder builder,
+ string connectionName,
+ Action? configureSettings = null,
+ string? configurationSectionName = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionName);
+
+ string sectionName = configurationSectionName ?? DefaultConfigSectionName;
+
+ SeaweedFSClientSettings settings = new();
+ builder.Configuration.GetSection(sectionName).Bind(settings);
+ builder.Configuration.GetSection($"{sectionName}:{connectionName}").Bind(settings);
+
+ if (builder.Configuration.GetConnectionString(connectionName) is string connectionString)
+ {
+ settings.ParseConnectionString(connectionString);
+ }
+
+ configureSettings?.Invoke(settings);
+
+ builder.Services.AddKeyedSingleton(connectionName, (sp, key) =>
+ {
+ if (settings.Endpoint is null)
+ {
+ throw new InvalidOperationException($"A valid absolute SeaweedFS endpoint URI must be provided. Ensure a valid connection string is registered for '{connectionName}'.");
+ }
+
+ // Dynamically override the endpoint scheme based on the explicit UseSsl property
+ UriBuilder uriBuilder = new(settings.Endpoint)
+ {
+ Scheme = settings.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp
+ };
+
+ // Remove default ports if they clash with the chosen scheme to ensure clean presigned URLs
+ if ((settings.UseSsl && uriBuilder.Port == 80) || (!settings.UseSsl && uriBuilder.Port == 443))
+ {
+ uriBuilder.Port = -1;
+ }
+
+ AmazonS3Config config = new()
+ {
+ ServiceURL = uriBuilder.Uri.GetLeftPart(UriPartial.Authority),
+ ForcePathStyle = settings.ForcePathStyle,
+ UseHttp = !settings.UseSsl
+ };
+
+ settings.ConfigureS3Config?.Invoke(config);
+
+ AWSCredentials credentials = (!string.IsNullOrWhiteSpace(settings.AccessKey) && !string.IsNullOrWhiteSpace(settings.SecretKey))
+ ? new BasicAWSCredentials(settings.AccessKey, settings.SecretKey)
+ : new AnonymousAWSCredentials();
+
+ return new AmazonS3Client(credentials, config);
+ });
+
+ if (connectionName.Equals("seaweedfs", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.Services.TryAddSingleton(sp => sp.GetRequiredKeyedService(connectionName));
+ }
+
+ if (!settings.DisableHealthChecks)
+ {
+ builder.Services.AddHealthChecks()
+ .Add(new HealthCheckRegistration(
+ name: $"seaweedfs_s3_{connectionName}",
+ factory: sp => new SeaweedFSS3HealthCheck(sp.GetRequiredKeyedService(connectionName)),
+ failureStatus: HealthStatus.Unhealthy,
+ tags: ["seaweedfs", "storage", "s3"],
+ timeout: TimeSpan.FromSeconds(10)));
+ }
+ }
+
+ ///
+ /// Registers a for interacting with the SeaweedFS Native Filer API.
+ ///
+ public static void AddSeaweedFSFilerClient(
+ this IHostApplicationBuilder builder,
+ string connectionName,
+ Action? configureSettings = null,
+ string? configurationSectionName = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionName);
+
+ string sectionName = configurationSectionName ?? DefaultConfigSectionName;
+
+ SeaweedFSClientSettings settings = new();
+ builder.Configuration.GetSection(sectionName).Bind(settings);
+ builder.Configuration.GetSection($"{sectionName}:{connectionName}").Bind(settings);
+
+ if (builder.Configuration.GetConnectionString(connectionName) is string connectionString)
+ {
+ settings.ParseConnectionString(connectionString);
+ }
+
+ configureSettings?.Invoke(settings);
+
+ // Fallback for FilerEndpoint or generic Endpoint
+ Uri baseAddress = settings.FilerEndpoint ?? settings.Endpoint ?? new Uri($"http://{connectionName}");
+
+ UriBuilder uriBuilder = new(baseAddress)
+ {
+ Scheme = settings.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp
+ };
+
+ if ((settings.UseSsl && uriBuilder.Port == 80) || (!settings.UseSsl && uriBuilder.Port == 443))
+ {
+ uriBuilder.Port = -1;
+ }
+
+ // 1. Register the Named HttpClient
+ builder.Services.AddHttpClient(connectionName, client =>
+ {
+ client.BaseAddress = uriBuilder.Uri;
+ });
+
+ // 2. Register the Keyed Service
+ builder.Services.AddKeyedTransient(connectionName, (sp, key) =>
+ {
+ IHttpClientFactory httpClientFactory = sp.GetRequiredService();
+ return new SeaweedFSFilerClient(httpClientFactory.CreateClient(connectionName));
+ });
+
+ // TryAddTransient ensures the FIRST registered Filer becomes the default resolvable instance.
+ builder.Services.TryAddTransient(sp => sp.GetRequiredKeyedService(connectionName));
+
+ if (!settings.DisableHealthChecks)
+ {
+ builder.Services.AddHealthChecks()
+ .Add(new HealthCheckRegistration(
+ name: $"seaweedfs_filer_{connectionName}",
+ factory: sp => new SeaweedFSFilerHealthCheck(sp.GetRequiredKeyedService(connectionName)),
+ failureStatus: HealthStatus.Unhealthy,
+ tags: ["seaweedfs", "storage", "filer"],
+ timeout: TimeSpan.FromSeconds(10)));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientSettings.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientSettings.cs
new file mode 100644
index 000000000..3ebb26b35
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientSettings.cs
@@ -0,0 +1,122 @@
+using Amazon.S3;
+using System.Data.Common;
+
+#pragma warning disable IDE0130
+namespace Microsoft.Extensions.Hosting;
+#pragma warning restore IDE0130
+
+///
+/// Provides the settings for configuring the SeaweedFS S3 client.
+///
+public sealed class SeaweedFSClientSettings
+{
+ private const string ConnectionStringEndpoint = "Endpoint";
+ private const string AccessKeyName = "AccessKey";
+ private const string SecretKeyName = "SecretKey";
+ private const string ConnectionStringFilerEndpoint = "FilerEndpoint";
+ private const string ConnectionStringFilerUrl = "FilerUrl";
+ private const string UseSslKey = "UseSsl";
+
+ ///
+ /// Gets or sets the endpoint URL of the SeaweedFS S3 API.
+ ///
+ public Uri? Endpoint { get; set; }
+
+ ///
+ /// Gets or sets the endpoint URL for the SeaweedFS Filer API.
+ ///
+ public Uri? FilerEndpoint { get; set; }
+
+ ///
+ /// Gets or sets the S3 Access Key.
+ ///
+ public string? AccessKey { get; set; }
+
+ ///
+ /// Gets or sets the S3 Secret Key.
+ ///
+ public string? SecretKey { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to force path style URLs for S3 objects.
+ /// Defaults to true, which is strictly required by the SeaweedFS architecture.
+ ///
+ public bool ForcePathStyle { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether to use SSL/TLS (HTTPS) for requests.
+ /// Defaults to false.
+ ///
+ public bool UseSsl { get; set; } = false;
+
+ ///
+ /// Gets or sets a boolean value that indicates whether the S3 health check is disabled or not.
+ ///
+ public bool DisableHealthChecks { get; set; } = false;
+
+ ///
+ /// Gets or sets an action to deeply configure the underlying AmazonS3Config (e.g., retries, telemetry, proxies).
+ ///
+ public Action? ConfigureS3Config { get; set; }
+
+ internal void ParseConnectionString(string? connectionString)
+ {
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ return;
+ }
+
+ // If the connection string is an absolute URI (e.g., "http://...", "https://..."), use it as the endpoint.
+ if (Uri.TryCreate(connectionString, UriKind.Absolute, out Uri? uri))
+ {
+ Endpoint = uri;
+ return;
+ }
+
+ try
+ {
+ DbConnectionStringBuilder connectionBuilder = new()
+ {
+ ConnectionString = connectionString
+ };
+
+ if (connectionBuilder.TryGetValue(ConnectionStringEndpoint, out object? endpoint) &&
+ Uri.TryCreate(endpoint.ToString()?.Trim(), UriKind.Absolute, out Uri? serviceUri))
+ {
+ Endpoint = serviceUri;
+ }
+
+ if (connectionBuilder.TryGetValue(UseSslKey, out object? useSslObj) &&
+ useSslObj is string useSslString &&
+ bool.TryParse(useSslString, out bool useSslParsed))
+ {
+ UseSsl = useSslParsed;
+ }
+
+ if (connectionBuilder.TryGetValue(ConnectionStringFilerEndpoint, out object? filerEndpoint) &&
+ Uri.TryCreate(filerEndpoint.ToString()?.Trim(), UriKind.Absolute, out Uri? filerServiceUri))
+ {
+ FilerEndpoint = filerServiceUri;
+ }
+ else if (connectionBuilder.TryGetValue(ConnectionStringFilerUrl, out object? filerUrl) &&
+ Uri.TryCreate(filerUrl.ToString()?.Trim(), UriKind.Absolute, out Uri? filerUrlUri))
+ {
+ FilerEndpoint = filerUrlUri;
+ }
+
+ if (connectionBuilder.TryGetValue(AccessKeyName, out object? accessKeyValue) && accessKeyValue is string accessKey)
+ {
+ AccessKey = accessKey;
+ }
+
+ if (connectionBuilder.TryGetValue(SecretKeyName, out object? secretKeyValue) && secretKeyValue is string secretKey)
+ {
+ SecretKey = secretKey;
+ }
+ }
+ catch (ArgumentException)
+ {
+ // Ignore badly formed connection strings gracefully.
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerClient.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerClient.cs
new file mode 100644
index 000000000..3de2098e3
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerClient.cs
@@ -0,0 +1,31 @@
+namespace CommunityToolkit.Aspire.SeaweedFS.Client;
+
+///
+/// A strongly-typed HTTP client for interacting with the SeaweedFS Native Filer API.
+///
+///
+/// The provides a direct interface to the SeaweedFS Filer node,
+/// allowing for file system operations such as listing, creating, and managing files/directories.
+///
+/// Usage example:
+///
+/// public class MyService(SeaweedFSFilerClient filerClient)
+/// {
+/// public async Task ListFilesAsync()
+/// {
+/// var response = await filerClient.HttpClient.GetAsync("/");
+/// // ...
+/// }
+/// }
+///
+///
+///
+/// The configured instance, typically managed by .
+public sealed class SeaweedFSFilerClient(HttpClient httpClient)
+{
+ ///
+ /// Gets the underlying configured for the SeaweedFS Filer API.
+ ///
+ /// The configured instance.
+ public HttpClient HttpClient { get; } = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerHealthCheck.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerHealthCheck.cs
new file mode 100644
index 000000000..a305b031d
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerHealthCheck.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace CommunityToolkit.Aspire.SeaweedFS.Client;
+
+///
+/// Represents a health check for the SeaweedFS Filer API.
+///
+internal sealed class SeaweedFSFilerHealthCheck(SeaweedFSFilerClient filerClient) : IHealthCheck
+{
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Send a lightweight GET request to the root of the Filer API.
+ // Using Accept: application/json ensures we get a small JSON directory listing rather than HTML payload.
+ using HttpRequestMessage request = new(HttpMethod.Get, "/");
+ request.Headers.Add("Accept", "application/json");
+
+ HttpResponseMessage response = await filerClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+
+ if (response.IsSuccessStatusCode)
+ {
+ return HealthCheckResult.Healthy();
+ }
+
+ return new HealthCheckResult(context.Registration.FailureStatus, $"SeaweedFS Filer API responded with HTTP {response.StatusCode}.");
+ }
+ catch (Exception ex)
+ {
+ return new HealthCheckResult(context.Registration.FailureStatus, "SeaweedFS Filer API health check failed.", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSS3HealthCheck.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSS3HealthCheck.cs
new file mode 100644
index 000000000..a184b441c
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSS3HealthCheck.cs
@@ -0,0 +1,24 @@
+using Amazon.S3;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace CommunityToolkit.Aspire.SeaweedFS.Client;
+
+///
+/// Represents a health check for the SeaweedFS S3 Gateway.
+///
+internal sealed class SeaweedFSS3HealthCheck(IAmazonS3 s3Client) : IHealthCheck
+{
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // A lightweight operation to verify S3 connectivity without mutating data.
+ await s3Client.ListBucketsAsync(cancellationToken);
+ return HealthCheckResult.Healthy();
+ }
+ catch (Exception ex)
+ {
+ return new HealthCheckResult(context.Registration.FailureStatus, "SeaweedFS S3 Gateway health check failed.", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/AppHostTests.cs
new file mode 100644
index 000000000..d6dadd5d3
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/AppHostTests.cs
@@ -0,0 +1,91 @@
+using CommunityToolkit.Aspire.Testing;
+using System.Net.Http.Json;
+using System.Text;
+
+namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests;
+
+[RequiresDocker]
+public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture>
+{
+ [Fact]
+ public async Task ResourceStartsAndApiCanPerformS3Operations()
+ {
+ const string apiResourceName = "apiservice";
+ const string seaweedResourceName = "seaweedfs";
+
+ CancellationTokenSource cts = new(TimeSpan.FromMinutes(3));
+
+ // Wait for both the database container and the API to become fully healthy
+ await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(seaweedResourceName, cts.Token).WaitAsync(cts.Token);
+ await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(apiResourceName, cts.Token).WaitAsync(cts.Token);
+
+ HttpClient httpClient = fixture.CreateHttpClient(apiResourceName);
+
+ // 1. Create a Bucket via the S3 API endpoint (With Retry for Eventual Consistency)
+ const string bucketName = "e2e-test-bucket";
+ HttpResponseMessage? createBucketResponse = null;
+
+ // Retry loop to handle the S3 Gateway topology mapping delay right after boot
+ for (int i = 0; i < 15; i++)
+ {
+ createBucketResponse = await httpClient.PostAsync($"/s3/buckets?bucketName={bucketName}", null, cts.Token);
+ if (createBucketResponse.IsSuccessStatusCode)
+ {
+ break;
+ }
+ await Task.Delay(1000, cts.Token); // Waits 1 second before trying again
+ }
+
+ Assert.NotNull(createBucketResponse);
+ Assert.Equal(HttpStatusCode.OK, createBucketResponse.StatusCode);
+
+ // 2. Upload a file via the S3 API endpoint
+ const string s3ObjectKey = "hello-s3.txt";
+ const string s3FileContent = "Integration test content via S3";
+ StringContent s3StringContent = new($"\"{s3FileContent}\"", Encoding.UTF8, "application/json");
+
+ HttpResponseMessage uploadS3Response = await httpClient.PostAsync($"/s3/upload?bucketName={bucketName}&key={s3ObjectKey}", s3StringContent, cts.Token);
+ Assert.Equal(HttpStatusCode.OK, uploadS3Response.StatusCode);
+
+ // 3. Download and verify the file via the S3 API endpoint
+ HttpResponseMessage downloadS3Response = await httpClient.GetAsync($"/s3/download?bucketName={bucketName}&key={s3ObjectKey}", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, downloadS3Response.StatusCode);
+
+ // Wait, parse and assert the custom anonymous object returned by the API
+ S3DownloadResult? s3Result = await downloadS3Response.Content.ReadFromJsonAsync(cancellationToken: cts.Token);
+ Assert.NotNull(s3Result);
+ Assert.Equal(s3FileContent, s3Result.Content);
+ }
+
+ [Fact]
+ public async Task ResourceStartsAndApiCanPerformFilerOperations()
+ {
+ const string apiResourceName = "apiservice";
+ const string seaweedResourceName = "seaweedfs";
+
+ CancellationTokenSource cts = new(TimeSpan.FromMinutes(3));
+
+ await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(seaweedResourceName, cts.Token).WaitAsync(cts.Token);
+ await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(apiResourceName, cts.Token).WaitAsync(cts.Token);
+
+ HttpClient httpClient = fixture.CreateHttpClient(apiResourceName);
+
+ // 1. Upload a file via the native Filer API endpoint
+ const string filerFileName = "hello-filer.txt";
+ const string filerFileContent = "Integration test content via Filer";
+ StringContent filerStringContent = new($"\"{filerFileContent}\"", Encoding.UTF8, "application/json");
+
+ HttpResponseMessage uploadFilerResponse = await httpClient.PostAsync($"/filer/upload?fileName={filerFileName}", filerStringContent, cts.Token);
+ Assert.Equal(HttpStatusCode.OK, uploadFilerResponse.StatusCode);
+
+ // 2. List the root directory of the Filer and ensure the file is there
+ HttpResponseMessage listFilerResponse = await httpClient.GetAsync("/filer/list", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, listFilerResponse.StatusCode);
+
+ string listResult = await listFilerResponse.Content.ReadAsStringAsync(cts.Token);
+ Assert.Contains(filerFileName, listResult);
+ }
+
+ // Helper record to parse the API response
+ private sealed record S3DownloadResult(string Bucket, string Key, string Content);
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests.csproj
new file mode 100644
index 000000000..cc28e9d58
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/ResourceCreationTests.cs
new file mode 100644
index 000000000..48272ed19
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/ResourceCreationTests.cs
@@ -0,0 +1,116 @@
+using Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests;
+
+public class ResourceCreationTests
+{
+ [Fact]
+ public void AddSeaweedFS_CreatesResourceWithDefaultEndpoints()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ builder.AddSeaweedFS("seaweed");
+
+ using DistributedApplication app = builder.Build();
+
+ DistributedApplicationModel appModel = app.Services.GetRequiredService();
+
+ SeaweedFSContainerResource resource = Assert.Single(appModel.Resources.OfType());
+
+ Assert.Equal("seaweed", resource.Name);
+
+ // Assert that only Master and Volume endpoints are created by default
+ EndpointAnnotation? masterEndpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.MasterEndpointName);
+ EndpointAnnotation? volumeEndpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.VolumeEndpointName);
+
+ Assert.NotNull(masterEndpoint);
+ Assert.Equal(9333, masterEndpoint.TargetPort);
+
+ Assert.NotNull(volumeEndpoint);
+ Assert.Equal(8080, volumeEndpoint.TargetPort);
+
+ // Assert that Opt-In endpoints are not present
+ Assert.Null(resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.FilerEndpointName));
+ Assert.Null(resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.S3EndpointName));
+ }
+
+ [Fact]
+ public void AddSeaweedFS_HasHealthCheck()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ builder.AddSeaweedFS("seaweed");
+
+ using DistributedApplication app = builder.Build();
+
+ DistributedApplicationModel appModel = app.Services.GetRequiredService();
+ SeaweedFSContainerResource? resource = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(resource);
+
+ bool result = resource.TryGetAnnotationsOfType(out IEnumerable? annotations);
+
+ Assert.True(result);
+ Assert.NotNull(annotations);
+ Assert.Single(annotations);
+ }
+
+ [Fact]
+ public void WithFiler_AddsFilerEndpointAndAnnotation()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ builder.AddSeaweedFS("seaweed").WithFiler();
+
+ using DistributedApplication app = builder.Build();
+
+ DistributedApplicationModel appModel = app.Services.GetRequiredService();
+ SeaweedFSContainerResource resource = appModel.Resources.OfType().Single();
+
+ EndpointAnnotation? filerEndpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.FilerEndpointName);
+
+ Assert.NotNull(filerEndpoint);
+ Assert.Equal(8888, filerEndpoint.TargetPort);
+
+ Assert.Contains(resource.Annotations, a => a is SeaweedFSFilerAnnotation);
+ }
+
+ [Fact]
+ public void WithS3_AddsS3EndpointAndAnnotation()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ builder.AddSeaweedFS("seaweed").WithS3();
+
+ using DistributedApplication app = builder.Build();
+
+ DistributedApplicationModel appModel = app.Services.GetRequiredService();
+ SeaweedFSContainerResource resource = appModel.Resources.OfType().Single();
+
+ EndpointAnnotation? s3Endpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.S3EndpointName);
+
+ Assert.NotNull(s3Endpoint);
+ Assert.Equal(8333, s3Endpoint.TargetPort);
+
+ Assert.Contains(resource.Annotations, a => a is SeaweedFSS3Annotation);
+ }
+
+ [Fact]
+ public void WithDataVolume_AddsVolumeAnnotation()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ builder.AddSeaweedFS("seaweed").WithDataVolume();
+
+ using DistributedApplication app = builder.Build();
+
+ DistributedApplicationModel appModel = app.Services.GetRequiredService();
+ SeaweedFSContainerResource resource = appModel.Resources.OfType().Single();
+
+ ContainerMountAnnotation? volumeAnnotation = resource.Annotations.OfType().SingleOrDefault(a => a.Target == "/data");
+
+ Assert.NotNull(volumeAnnotation);
+ Assert.Equal(ContainerMountType.Volume, volumeAnnotation.Type);
+ Assert.False(string.IsNullOrWhiteSpace(volumeAnnotation.Source));
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSFunctionalTests.cs
new file mode 100644
index 000000000..84283b21e
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSFunctionalTests.cs
@@ -0,0 +1,253 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using Aspire.Hosting;
+using Aspire.Hosting.Utils;
+using CommunityToolkit.Aspire.Testing;
+using System.Text;
+
+namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests;
+
+[RequiresDocker]
+public class SeaweedFSFunctionalTests(ITestOutputHelper testOutputHelper)
+{
+ [Fact]
+ public async Task S3Api_GetsCreatedAndUsable()
+ {
+ using IDistributedApplicationTestingBuilder builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ IResourceBuilder seaweedfs = builder.AddSeaweedFS("seaweedfs").WithS3();
+
+ await using DistributedApplication app = await builder.BuildAsync();
+ await app.StartAsync();
+
+ ResourceNotificationService rns = app.Services.GetRequiredService();
+ await rns.WaitForResourceHealthyAsync(seaweedfs.Resource.Name);
+
+ EndpointReference s3Endpoint = seaweedfs.GetEndpoint(SeaweedFSContainerResource.S3EndpointName);
+ string? accessKey = await seaweedfs.Resource.AccessKey.GetValueAsync(default);
+ string? secretKey = await seaweedfs.Resource.SecretKey.GetValueAsync(default);
+
+ Assert.NotNull(accessKey);
+ Assert.NotNull(secretKey);
+
+ AmazonS3Config s3Config = new()
+ {
+ ServiceURL = s3Endpoint.Url,
+ ForcePathStyle = true,
+ UseHttp = true,
+ Timeout = TimeSpan.FromSeconds(5),
+ MaxErrorRetry = 0
+ };
+
+ using AmazonS3Client s3Client = new(accessKey, secretKey, s3Config);
+
+ const string bucketName = "test-bucket";
+ const string objectKey = "test-file.txt";
+ const string fileContent = "Hello from SeaweedFS S3 API!";
+
+ await ExecuteWithRetryAsync(async () =>
+ {
+ PutBucketResponse putBucketResponse = await s3Client.PutBucketAsync(new PutBucketRequest
+ {
+ BucketName = bucketName
+ });
+ Assert.Equal(HttpStatusCode.OK, putBucketResponse.HttpStatusCode);
+
+ PutObjectResponse putObjectResponse = await s3Client.PutObjectAsync(new PutObjectRequest
+ {
+ BucketName = bucketName,
+ Key = objectKey,
+ ContentBody = fileContent
+ });
+ Assert.Equal(HttpStatusCode.OK, putObjectResponse.HttpStatusCode);
+ });
+
+ GetObjectResponse getObjectResponse = await s3Client.GetObjectAsync(bucketName, objectKey);
+ using StreamReader reader = new(getObjectResponse.ResponseStream);
+ string downloadedContent = await reader.ReadToEndAsync();
+
+ Assert.Equal(fileContent, downloadedContent);
+ }
+
+ [Fact]
+ public async Task FilerApi_GetsCreatedAndUsable()
+ {
+ using IDistributedApplicationTestingBuilder builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ IResourceBuilder seaweedfs = builder.AddSeaweedFS("seaweedfs").WithFiler();
+
+ await using DistributedApplication app = await builder.BuildAsync();
+ await app.StartAsync();
+
+ ResourceNotificationService rns = app.Services.GetRequiredService();
+ await rns.WaitForResourceHealthyAsync(seaweedfs.Resource.Name);
+
+ EndpointReference filerEndpoint = seaweedfs.GetEndpoint(SeaweedFSContainerResource.FilerEndpointName);
+
+ using HttpClient httpClient = new() { BaseAddress = new Uri(filerEndpoint.Url) };
+
+ const string fileName = "/my-native-file.txt";
+ const string fileContent = "Hello from SeaweedFS Native Filer API!";
+
+ await ExecuteWithRetryAsync(async () =>
+ {
+ using StringContent content = new(fileContent, Encoding.UTF8, "text/plain");
+ HttpResponseMessage response = await httpClient.PutAsync(fileName, content);
+ response.EnsureSuccessStatusCode();
+ });
+
+ string downloadedContent = await GetStringWithRetryAsync(httpClient, fileName);
+
+ Assert.Equal(fileContent, downloadedContent);
+ }
+
+ [Theory]
+ [InlineData(true)] // Test with Docker Volume
+ [InlineData(false)] // Test with Bind Mount
+ public async Task WithData_ShouldPersistStateBetweenUsages(bool useVolume)
+ {
+ string? volumeName = null;
+ string? bindMountPath = null;
+ const string fileName = "/persisted-file.txt";
+ const string fileContent = "This data should survive a restart.";
+
+ try
+ {
+ // --- First run: Write data ---
+ using (IDistributedApplicationTestingBuilder builder1 = TestDistributedApplicationBuilder.Create(testOutputHelper))
+ {
+ IResourceBuilder seaweed1 = builder1.AddSeaweedFS("seaweedfs").WithFiler();
+
+ if (useVolume)
+ {
+ volumeName = VolumeNameGenerator.Generate(seaweed1, nameof(WithData_ShouldPersistStateBetweenUsages));
+ DockerUtils.AttemptDeleteDockerVolume(volumeName, throwOnFailure: false);
+ seaweed1.WithDataVolume(volumeName);
+ }
+ else
+ {
+ bindMountPath = Directory.CreateTempSubdirectory().FullName;
+ seaweed1.WithDataBindMount(bindMountPath);
+ }
+
+ using DistributedApplication app1 = builder1.Build();
+ await app1.StartAsync();
+
+ ResourceNotificationService rns1 = app1.Services.GetRequiredService();
+ await rns1.WaitForResourceHealthyAsync(seaweed1.Resource.Name);
+
+ EndpointReference filerEndpoint1 = seaweed1.GetEndpoint(SeaweedFSContainerResource.FilerEndpointName);
+ using HttpClient httpClient1 = new() { BaseAddress = new Uri(filerEndpoint1.Url) };
+
+ await ExecuteWithRetryAsync(async () =>
+ {
+ using StringContent content = new(fileContent, Encoding.UTF8, "text/plain");
+ HttpResponseMessage response = await httpClient1.PutAsync(fileName, content);
+ response.EnsureSuccessStatusCode();
+ });
+
+ await app1.StopAsync(); // Stops the container and flushes the data safely
+ }
+
+ // --- Second run: Read data ---
+ using IDistributedApplicationTestingBuilder builder2 = TestDistributedApplicationBuilder.Create(testOutputHelper);
+ IResourceBuilder seaweed2 = builder2.AddSeaweedFS("seaweedfs").WithFiler();
+
+ if (useVolume)
+ {
+ seaweed2.WithDataVolume(volumeName);
+ }
+ else
+ {
+ seaweed2.WithDataBindMount(bindMountPath!);
+ }
+
+ using DistributedApplication app2 = builder2.Build();
+ await app2.StartAsync();
+
+ ResourceNotificationService rns2 = app2.Services.GetRequiredService();
+ await rns2.WaitForResourceHealthyAsync(seaweed2.Resource.Name);
+
+ EndpointReference filerEndpoint2 = seaweed2.GetEndpoint(SeaweedFSContainerResource.FilerEndpointName);
+ using HttpClient httpClient2 = new() { BaseAddress = new Uri(filerEndpoint2.Url) };
+
+ string downloadedContent = await GetStringWithRetryAsync(httpClient2, fileName);
+
+ Assert.Equal(fileContent, downloadedContent);
+
+ await app2.StopAsync();
+ }
+ finally
+ {
+ // Cleanup cloud-native engine resources
+ if (volumeName is not null)
+ {
+ DockerUtils.AttemptDeleteDockerVolume(volumeName);
+ }
+
+ if (bindMountPath is not null)
+ {
+ try
+ {
+ Directory.Delete(bindMountPath, recursive: true);
+ }
+ catch
+ {
+ // Ignore cleanup errors in pipeline tests
+ }
+ }
+ }
+ }
+
+ ///
+ /// Helper to retry actions, mitigating internal eventual consistency delays
+ /// in SeaweedFS cluster topology (Volume mapping to Master) just after boot.
+ ///
+ private static async Task ExecuteWithRetryAsync(Func action, int maxRetries = 15)
+ {
+ for (int i = 0; i < maxRetries; i++)
+ {
+ try
+ {
+ await action();
+ return;
+ }
+ catch (Exception)
+ {
+ if (i == maxRetries - 1)
+ {
+ throw;
+ }
+ await Task.Delay(1000); // Wait 1 second before retrying
+ }
+ }
+ }
+
+ ///
+ /// Helper to retry GET requests and extract the string content.
+ ///
+ private static async Task GetStringWithRetryAsync(HttpClient client, string url, int maxRetries = 15)
+ {
+ for (int i = 0; i < maxRetries; i++)
+ {
+ try
+ {
+ HttpResponseMessage response = await client.GetAsync(url);
+ if (response.IsSuccessStatusCode)
+ {
+ return await response.Content.ReadAsStringAsync();
+ }
+ }
+ catch (Exception)
+ {
+ if (i == maxRetries - 1)
+ {
+ throw;
+ }
+ }
+ await Task.Delay(1000);
+ }
+
+ throw new InvalidOperationException("Retry loop failed to fetch the string.");
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSPublicApiTests.cs
new file mode 100644
index 000000000..0ba53ad82
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSPublicApiTests.cs
@@ -0,0 +1,224 @@
+using Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests;
+
+public class SeaweedFSPublicApiTests
+{
+ [Fact]
+ public void AddSeaweedFS_ThrowsWhenBuilderIsNull()
+ {
+ IDistributedApplicationBuilder builder = null!;
+ const string name = "seaweedfs";
+
+ IResourceBuilder action() => builder.AddSeaweedFS(name);
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void AddSeaweedFS_ThrowsWhenNameIsNullOrEmpty(string? name)
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ IResourceBuilder action() => builder.AddSeaweedFS(name!);
+
+ ArgumentException exception = Assert.ThrowsAny((Func>)action);
+ Assert.Equal(nameof(name), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithS3_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ IResourceBuilder action() => builder.WithS3();
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithFiler_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ IResourceBuilder action() => builder.WithFiler();
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithHostPort_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ IResourceBuilder action() => builder.WithHostPort(9333);
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithAccessKey_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder();
+ IResourceBuilder parameter = builderApp.AddParameter("test");
+
+ IResourceBuilder action() => builder.WithAccessKey(parameter);
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithAccessKey_ThrowsWhenAccessKeyIsNull()
+ {
+ IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder();
+ IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs");
+
+ IResourceBuilder action() => builder.WithAccessKey(null!);
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal("accessKey", exception.ParamName);
+ }
+
+ [Fact]
+ public void WithSecretKey_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder();
+ IResourceBuilder parameter = builderApp.AddParameter("test");
+
+ IResourceBuilder action() => builder.WithSecretKey(parameter);
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithSecretKey_ThrowsWhenSecretKeyIsNull()
+ {
+ IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder();
+ IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs");
+
+ IResourceBuilder action() => builder.WithSecretKey(null!);
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal("secretKey", exception.ParamName);
+ }
+
+ [Fact]
+ public void WithS3ConfigFile_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ IResourceBuilder action() => builder.WithS3ConfigFile("config.json");
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void WithS3ConfigFile_ThrowsWhenConfigFilePathIsNullOrWhitespace(string? configFilePath)
+ {
+ IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder();
+ IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs");
+
+ IResourceBuilder action() => builder.WithS3ConfigFile(configFilePath!);
+
+ ArgumentException exception = Assert.ThrowsAny((Func>)action);
+ Assert.Equal(nameof(configFilePath), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithDataBindMount_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ IResourceBuilder action() => builder.WithDataBindMount("/data");
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithDataBindMount_ThrowsWhenSourceIsNull()
+ {
+ IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder();
+ IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs");
+
+ IResourceBuilder action() => builder.WithDataBindMount(null!);
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal("source", exception.ParamName);
+ }
+
+ [Fact]
+ public void WithDataVolume_ThrowsWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ IResourceBuilder action() => builder.WithDataVolume();
+
+ ArgumentNullException exception = Assert.Throws((Func>)action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void VerifySeaweedFSContainerResource_WithHostPort()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+ builder.AddSeaweedFS("seaweed")
+ .WithHostPort(1000);
+
+ SeaweedFSContainerResource resource = Assert.Single(builder.Resources.OfType());
+ EndpointAnnotation endpoint = Assert.Single(resource.Annotations.OfType(), x => x.Name == SeaweedFSContainerResource.MasterEndpointName);
+
+ Assert.Equal(1000, endpoint.Port);
+ }
+
+ [Fact]
+ public async Task VerifyConnectionString_PointsToMaster_WhenS3IsNotEnabled()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+ IResourceBuilder seaweed = builder.AddSeaweedFS("seaweed")
+ .WithEndpoint(SeaweedFSContainerResource.MasterEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 9333));
+
+ string? connectionString = await seaweed.Resource.GetConnectionStringAsync();
+
+ Assert.Equal("Endpoint=http://localhost:9333", connectionString);
+ }
+
+ [Fact]
+ public async Task VerifyConnectionString_PointsToS3AndIncludesCredentials_WhenS3IsEnabled()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+ IResourceBuilder accessKey = builder.AddParameter("access", "my-access");
+ IResourceBuilder secretKey = builder.AddParameter("secret", "my-secret");
+
+ IResourceBuilder seaweed = builder.AddSeaweedFS("seaweed")
+ .WithAccessKey(accessKey)
+ .WithSecretKey(secretKey)
+ .WithS3()
+ // Mock the S3 endpoint allocation
+ .WithEndpoint(SeaweedFSContainerResource.S3EndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8333))
+ // Mock the Filer endpoint allocation (implicitly added by WithS3) to prevent async evaluation hangs
+ .WithEndpoint(SeaweedFSContainerResource.FilerEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8888));
+
+ string? connectionString = await seaweed.Resource.GetConnectionStringAsync();
+
+ // Assert dynamically against the reference expression to evaluate parameters just like the real AppHost
+ // Now includes the FilerEndpoint appended dynamically by the Resource
+ string? expected = await ReferenceExpression.Create($"Endpoint=http://localhost:8333;AccessKey={accessKey.Resource};SecretKey={secretKey.Resource};FilerEndpoint=http://localhost:8888").GetValueAsync(CancellationToken.None);
+
+ Assert.Equal(expected, connectionString);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/TypeScriptAppHostTests.cs
new file mode 100644
index 000000000..595f3d763
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/TypeScriptAppHostTests.cs
@@ -0,0 +1,20 @@
+using CommunityToolkit.Aspire.Testing;
+
+namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests;
+
+[RequiresDocker]
+public class TypeScriptAppHostTests
+{
+ [Fact]
+ public async Task TypeScriptAppHostCompilesAndStarts()
+ {
+ // This will automatically generate the Node SDK, run the apphost.mts,
+ // and wait for the resources to report a healthy state.
+ await TypeScriptAppHostTest.Run(
+ appHostProject: "SeaweedFS.AppHost.TypeScript",
+ packageName: "CommunityToolkit.Aspire.Hosting.SeaweedFS",
+ exampleName: "seaweedfs",
+ waitForResources: ["seaweedfs-s3", "seaweedfs-filer"],
+ cancellationToken: TestContext.Current.CancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests.csproj b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests.csproj
new file mode 100644
index 000000000..5f35a6148
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/ConfigurationTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/ConfigurationTests.cs
new file mode 100644
index 000000000..3a82a5877
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/ConfigurationTests.cs
@@ -0,0 +1,110 @@
+using Microsoft.Extensions.Hosting;
+
+namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests;
+
+public class ConfigurationTests
+{
+ [Fact]
+ public void ParseConnectionString_DbConnectionStringWithoutKeys_SetsEndpointOnly()
+ {
+ var settings = new SeaweedFSClientSettings();
+ settings.ParseConnectionString("Endpoint=https://seaweedfs.local:8333");
+
+ Assert.NotNull(settings.Endpoint);
+ Assert.Equal("https://seaweedfs.local:8333/", settings.Endpoint.ToString());
+ Assert.Null(settings.AccessKey);
+ Assert.Null(settings.SecretKey);
+ }
+
+ [Fact]
+ public void ParseConnectionString_DbConnectionString_SetsFilerEndpoint()
+ {
+ var settings = new SeaweedFSClientSettings();
+ settings.ParseConnectionString("FilerEndpoint=http://localhost:8888;AccessKey=admin");
+
+ Assert.NotNull(settings.FilerEndpoint);
+ Assert.Equal("http://localhost:8888/", settings.FilerEndpoint.ToString());
+ }
+
+ [Fact]
+ public void ParseConnectionString_DbConnectionString_SetsFilerUrl()
+ {
+ // Tests the implicit fallback property injected by the Aspire AppHost (IResourceWithConnectionString)
+ var settings = new SeaweedFSClientSettings();
+ settings.ParseConnectionString("FilerUrl=http://seaweedfs:8888;SecretKey=secret");
+
+ Assert.NotNull(settings.FilerEndpoint);
+ Assert.Equal("http://seaweedfs:8888/", settings.FilerEndpoint.ToString());
+ }
+
+
+ [Fact]
+ public void DefaultValuesAreCorrect()
+ {
+ var settings = new SeaweedFSClientSettings();
+
+ Assert.Null(settings.Endpoint);
+ Assert.Null(settings.AccessKey);
+ Assert.Null(settings.SecretKey);
+
+ Assert.True(settings.ForcePathStyle);
+ Assert.False(settings.DisableHealthChecks);
+ Assert.False(settings.UseSsl);
+ }
+
+ [Fact]
+ public void ParseConnectionString_NullOrEmpty_DoesNotThrow()
+ {
+ var settings = new SeaweedFSClientSettings();
+
+ settings.ParseConnectionString(null);
+ Assert.Null(settings.Endpoint);
+
+ settings.ParseConnectionString("");
+ Assert.Null(settings.Endpoint);
+
+ settings.ParseConnectionString(" ");
+ Assert.Null(settings.Endpoint);
+ }
+
+ [Fact]
+ public void ParseConnectionString_AbsoluteUri_SetsEndpoint()
+ {
+ var settings = new SeaweedFSClientSettings();
+ settings.ParseConnectionString("http://localhost:8333");
+
+ Assert.NotNull(settings.Endpoint);
+ Assert.Equal("http://localhost:8333/", settings.Endpoint.ToString());
+ }
+
+ [Fact]
+ public void ParseConnectionString_DbConnectionString_SetsProperties()
+ {
+ var settings = new SeaweedFSClientSettings();
+ settings.ParseConnectionString("Endpoint=http://localhost:8333;AccessKey=admin;SecretKey=secret");
+
+ Assert.NotNull(settings.Endpoint);
+ Assert.Equal("http://localhost:8333/", settings.Endpoint.ToString());
+ Assert.Equal("admin", settings.AccessKey);
+ Assert.Equal("secret", settings.SecretKey);
+ }
+
+ [Fact]
+ public void ParseConnectionString_UseSslCaseInsensitive()
+ {
+ var settings = new SeaweedFSClientSettings();
+ settings.ParseConnectionString("Endpoint=http://localhost:8333;UseSsl=True;AccessKey=key;SecretKey=secret");
+
+ Assert.True(settings.UseSsl);
+ }
+
+ [Fact]
+ public void ParseConnectionString_ExplicitUseSslFalse()
+ {
+ var settings = new SeaweedFSClientSettings();
+ settings.ParseConnectionString("Endpoint=https://localhost:8333;UseSsl=false;AccessKey=key;SecretKey=secret");
+
+ Assert.NotNull(settings.Endpoint);
+ Assert.False(settings.UseSsl);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientConformanceTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientConformanceTests.cs
new file mode 100644
index 000000000..1ea2333e7
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientConformanceTests.cs
@@ -0,0 +1,99 @@
+using Amazon.S3;
+using Aspire.Components.ConformanceTests;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+
+namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests;
+
+///
+/// Conformance tests for the SeaweedFS S3 Client integration.
+/// Ensures the component adheres to the strict architectural guidelines of .NET Aspire.
+///
+public class SeaweedFSClientConformanceTests : ConformanceTests
+{
+ protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton;
+
+ protected override string ActivitySourceName => string.Empty;
+
+ protected override string[] RequiredLogCategories => [];
+
+ protected override bool CanConnectToServer => false;
+
+ protected override bool SupportsKeyedRegistrations => true;
+
+ protected override string ConfigurationSectionName => "Aspire:SeaweedFS:Client";
+
+ protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null)
+ {
+ string endpoint = "http://localhost:8333";
+ const string accessKey = "admin";
+ const string secretKey = "admin-secret";
+
+ string connString = $"Endpoint={endpoint};AccessKey={accessKey};SecretKey={secretKey};UseSsl=false";
+
+ configuration.AddInMemoryCollection(
+ [
+ new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "Endpoint"), endpoint),
+ new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "AccessKey"), accessKey),
+ new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "SecretKey"), secretKey),
+ new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "UseSsl"), "false"),
+ new KeyValuePair($"ConnectionStrings:{key ?? "seaweedfs"}", connString)
+ ]);
+ }
+
+ protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null)
+ {
+ if (key is null)
+ {
+ builder.AddSeaweedFSS3Client("seaweedfs", configure);
+ }
+ else
+ {
+ builder.AddSeaweedFSS3Client(key, configure);
+ }
+ }
+
+ protected override void SetHealthCheck(SeaweedFSClientSettings options, bool enabled)
+ {
+ options.DisableHealthChecks = !enabled;
+ }
+
+ protected override void SetMetrics(SeaweedFSClientSettings options, bool enabled)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected override void SetTracing(SeaweedFSClientSettings options, bool enabled)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected override void TriggerActivity(IAmazonS3 service)
+ {
+ using CancellationTokenSource source = new(TimeSpan.FromMilliseconds(100));
+ try
+ {
+ service.ListBucketsAsync(source.Token).Wait();
+ }
+ catch
+ {
+ }
+ }
+
+ protected override string ValidJsonConfig => """
+ {
+ "Aspire": {
+ "SeaweedFS": {
+ "Client": {
+ "Endpoint": "http://localhost:8333",
+ "AccessKey": "admin",
+ "SecretKey": "admin-secret",
+ "ForcePathStyle": true,
+ "UseSsl": false,
+ "DisableHealthChecks": false
+ }
+ }
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientTests.cs
new file mode 100644
index 000000000..6ff78fa2f
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientTests.cs
@@ -0,0 +1,150 @@
+using Amazon.S3;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+
+namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests;
+
+public class SeaweedFSClientTests
+{
+ [Fact]
+ public void AddSeaweedFSS3Client_ThrowsArgumentNullException_WhenBuilderIsNull()
+ {
+ IHostApplicationBuilder builder = null!;
+
+ void action() => builder.AddSeaweedFSS3Client("seaweedfs");
+
+ ArgumentNullException exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddSeaweedFSS3Client_ThrowsArgumentException_WhenConnectionNameIsNull()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+
+ void action() => builder.AddSeaweedFSS3Client(null!);
+
+ ArgumentNullException exception = Assert.Throws(action);
+ Assert.Equal("connectionName", exception.ParamName);
+ }
+
+ [Fact]
+ public void AddSeaweedFSS3Client_ThrowsInvalidOperationException_WhenEndpointIsMissing()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+
+ void action()
+ {
+ builder.AddSeaweedFSS3Client("seaweedfs");
+
+ using IHost host = builder.Build();
+ _ = host.Services.GetRequiredKeyedService("seaweedfs");
+ }
+
+ InvalidOperationException exception = Assert.Throws(action);
+ Assert.Contains("A valid absolute SeaweedFS endpoint URI must be provided", exception.Message);
+ }
+
+ [Fact]
+ public void AddSeaweedFSS3Client_RegistersKeyedAndStandardServices()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333" },
+ { "ConnectionStrings:cluster2", "Endpoint=http://localhost:9333" }
+ });
+
+ builder.AddSeaweedFSS3Client("seaweedfs");
+ builder.AddSeaweedFSS3Client("cluster2");
+
+ using IHost host = builder.Build();
+
+ IAmazonS3 client1 = host.Services.GetRequiredKeyedService("seaweedfs");
+ IAmazonS3 client2 = host.Services.GetRequiredKeyedService("cluster2");
+
+ Assert.NotNull(client1);
+ Assert.NotNull(client2);
+ Assert.NotSame(client1, client2);
+
+ IAmazonS3 defaultClient = host.Services.GetRequiredService();
+ Assert.NotNull(defaultClient);
+ Assert.Same(client1, defaultClient);
+ }
+
+ [Fact]
+ public void AddSeaweedFSS3Client_ConfiguresS3ClientCorrectly()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333" }
+ });
+
+ builder.AddSeaweedFSS3Client("seaweedfs");
+
+ using IHost host = builder.Build();
+ IAmazonS3 client = host.Services.GetRequiredKeyedService("seaweedfs");
+
+ AmazonS3Config s3Config = Assert.IsType(client.Config);
+
+ Assert.Equal("http://localhost:8333/", s3Config.ServiceURL);
+ Assert.True(s3Config.ForcePathStyle);
+ Assert.True(s3Config.UseHttp);
+ }
+
+ [Fact]
+ public void AddSeaweedFSS3Client_OverridesSchemeWithUseSsl()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ // Even if HTTP is used in the string, UseSsl=true should override it to HTTPS
+ { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333;UseSsl=true" }
+ });
+
+ builder.AddSeaweedFSS3Client("seaweedfs");
+
+ using IHost host = builder.Build();
+ IAmazonS3 client = host.Services.GetRequiredKeyedService("seaweedfs");
+
+ AmazonS3Config s3Config = Assert.IsType(client.Config);
+
+ Assert.Equal("https://localhost:8333/", s3Config.ServiceURL);
+ Assert.False(s3Config.UseHttp); // S3 uses HTTPS natively
+ }
+
+ [Fact]
+ public void AddSeaweedFSS3Client_InjectsAnonymousCredentials_WhenKeysAreMissing()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333" }
+ });
+
+ builder.AddSeaweedFSS3Client("seaweedfs");
+
+ using IHost host = builder.Build();
+ AmazonS3Client client = (AmazonS3Client)host.Services.GetRequiredService();
+
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public void AddSeaweedFSS3Client_AppliesProvidedCredentials_FromConnectionString()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333;AccessKey=my-admin;SecretKey=my-secret" }
+ });
+
+ builder.AddSeaweedFSS3Client("seaweedfs");
+
+ using IHost host = builder.Build();
+ AmazonS3Client client = (AmazonS3Client)host.Services.GetRequiredService();
+
+ Assert.NotNull(client);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSFilerClientTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSFilerClientTests.cs
new file mode 100644
index 000000000..0136b750c
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSFilerClientTests.cs
@@ -0,0 +1,93 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+
+namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests;
+
+public class SeaweedFSFilerClientTests
+{
+ [Fact]
+ public void Constructor_ThrowsArgumentNullException_WhenHttpClientIsNull()
+ {
+ // Validates the primary constructor runtime check
+ ArgumentNullException exception = Assert.Throws(() => new SeaweedFSFilerClient(null!));
+ Assert.Equal("httpClient", exception.ParamName);
+ }
+
+ [Fact]
+ public void AddSeaweedFSFilerClient_ThrowsArgumentNullException_WhenBuilderIsNull()
+ {
+ IHostApplicationBuilder builder = null!;
+
+ void action() => builder.AddSeaweedFSFilerClient("seaweedfs");
+
+ ArgumentNullException exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddSeaweedFSFilerClient_ThrowsArgumentException_WhenConnectionNameIsNull()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+
+ void action() => builder.AddSeaweedFSFilerClient(null!);
+
+ ArgumentNullException exception = Assert.Throws(action);
+ Assert.Equal("connectionName", exception.ParamName);
+ }
+
+ [Fact]
+ public void AddSeaweedFSFilerClient_RegistersHttpClientWithCorrectBaseAddress()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ { "ConnectionStrings:seaweedfs", "FilerEndpoint=http://localhost:8888" }
+ });
+
+ builder.AddSeaweedFSFilerClient("seaweedfs");
+
+ using IHost host = builder.Build();
+ SeaweedFSFilerClient client = host.Services.GetRequiredService();
+
+ Assert.NotNull(client);
+ Assert.NotNull(client.HttpClient);
+ Assert.Equal("http://localhost:8888/", client.HttpClient.BaseAddress?.ToString());
+ }
+
+ [Fact]
+ public void AddSeaweedFSFilerClient_FallbackToStandardEndpoint_WhenFilerEndpointIsMissing()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ // Simulates a legacy or simplified connection string where only 'Endpoint' is defined
+ { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:9999" }
+ });
+
+ builder.AddSeaweedFSFilerClient("seaweedfs");
+
+ using IHost host = builder.Build();
+ SeaweedFSFilerClient client = host.Services.GetRequiredService();
+
+ Assert.NotNull(client);
+ Assert.NotNull(client.HttpClient);
+ Assert.Equal("http://localhost:9999/", client.HttpClient.BaseAddress?.ToString());
+ }
+
+ [Fact]
+ public void AddSeaweedFSFilerClient_UsesServiceDiscovery_WhenNoEndpointIsConfigured()
+ {
+ HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
+
+ // No connection string or endpoint is added to the configuration.
+ // It must fallback seamlessly to the Aspire Service Discovery protocol.
+ builder.AddSeaweedFSFilerClient("my-filer-cluster");
+
+ using IHost host = builder.Build();
+ SeaweedFSFilerClient client = host.Services.GetRequiredService();
+
+ Assert.NotNull(client);
+ Assert.NotNull(client.HttpClient);
+ Assert.Equal("http://my-filer-cluster/", client.HttpClient.BaseAddress?.ToString());
+ }
+}
\ No newline at end of file