diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2bf16a1..34e39e9 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -5,7 +5,7 @@ language: en-US early_access: false reviews: - profile: assertive + profile: thorough request_changes_workflow: false high_level_summary: true poem: false diff --git a/Dockerfile b/Dockerfile index aeb8a6a..e30b37e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,8 @@ FROM node:22 AS ui WORKDIR /ui COPY ["src/Plugins/Solutions/DevTools/UI/package.json", "src/Plugins/Solutions/DevTools/UI/package-lock.json", "./"] RUN npm ci -COPY ["src/Plugins/Solutions/DevTools/UI/template.html", "src/Plugins/Solutions/DevTools/UI/build.mjs", "./"] +COPY ["src/Plugins/Solutions/DevTools/UI/template.html", "src/Plugins/Solutions/DevTools/UI/vite.config.ts", "src/Plugins/Solutions/DevTools/UI/svelte.config.js", "./"] +COPY ["src/Plugins/Solutions/DevTools/UI/scripts/", "./scripts/"] COPY ["src/Plugins/Solutions/DevTools/UI/src/", "./src/"] RUN npm run build diff --git a/Docs/ADR/020-devtools-plugin.md b/Docs/ADR/020-devtools-plugin.md index 733f934..b2f2d59 100644 --- a/Docs/ADR/020-devtools-plugin.md +++ b/Docs/ADR/020-devtools-plugin.md @@ -43,6 +43,75 @@ Both developer tools move into single `DevTools` plugin (`src/Plugins/Solutions/ - A gRPC proxy process (eg. maintained third party gRPC UI binary) for streaming and health support additional deployment process and transport hop the extra support was deemed unnecessary for an internal developer tool. - Serving Swagger from the plugin while re-emitting document generation there would duplicate `AddSwaggerGen` configuration generation stays in the host, serving stays in the plugin. +## HTTP API Contract + +The DevTools plugin exposes two JSON API endpoints consumed by the TypeScript frontend: + +### `/api/services` — GrpcCatalogResponse + +Returns the discovered gRPC services for the catalog: + +```csharp +public sealed class GrpcCatalogResponse +{ + public required string Target { get; init; } + public required IEnumerable Services { get; init; } +} + +public sealed class GrpcServiceInfo +{ + public required string Name { get; init; } // Short service name + public required string FullName { get; init; } // Fully qualified name + public string? Description { get; init; } // Nullable proto doc comment (null = absent) + public required string Package { get; init; } + public required string FileName { get; init; } + public bool IsPlugin { get; init; } // Default: false (backward-compatible for host services) + public required IEnumerable Methods { get; init; } +} +``` + +- `Description`: Nullable string from proto documentation. Absent (`null`) when proto has no comment. +- `IsPlugin`: Defaults to `false` for host services, enabling backward compatibility with non-plugin assemblies. + +### `/api/invoke` — GrpcInvocationRequest → GrpcInvocationResult + +Executes a unary gRPC method: + +```csharp +public sealed class GrpcInvocationRequest +{ + public required string Service { get; init; } + public required string Method { get; init; } + public required string RequestJson { get; init; } + public IReadOnlyDictionary Headers { get; init; } = new Dictionary(); +} + +public sealed class GrpcInvocationResult +{ + public required bool Success { get; init; } + public required string StatusName { get; init; } + public required int StatusCode { get; init; } + public string? Detail { get; init; } + public string? ResponseJson { get; init; } + public string? ResponseBase64 { get; init; } // Success-only base64-encoded protobuf payload; null on failure + public double ElapsedMs { get; init; } + public IReadOnlyDictionary? Trailers { get; init; } +} +``` + +- `ResponseBase64`: Only populated on successful invocations (`Success == true`). Contains raw protobuf bytes encoded as base64. +- **Payload size limit**: The base64 payload is limited to `int.MaxValue - 1` bytes (≈2 GB) due to protobuf size constraints. +- `ResponseJson`: Present only on success, containing JSON-formatted response via protobuf JSON formatter. +- Failed invocations return `Success = false` with `Detail` and `Trailers` populated for debugging. + +### JSON Serialization + +All DTOs are serialized using `System.Text.Json` with `JsonSerializerDefaults.Web` for consistent casing: + +- PascalCase property names in JSON (e.g., `"ResponseBase64"`, `"IsPlugin"`). +- Nullable fields are omitted when null (default ASP.NET Core JSON behavior). +- Boolean fields with `false` values are included (not omitted). + ## Consequences The host's `AppMiddlewareConfiguration` is smaller and no longer references Swashbuckle. The `DevTools` solution owns compiler friendly tooling code (catalog, invoker, middleware, options, and UI assets). Swagger UI availability depends on plugin deployment and environment gating rather than host build configuration. The gRPC UI only supports unary methods streaming methods are reported as unsupported rather than approximated. Configuration is centralized in `DevToolsOptions`, resolved with environment variables (`GRPC_UI_TARGET`, `DEV_CERT_PORT_GRPC`) for local development against the HTTPS gRPC endpoint. diff --git a/Docs/ADR/026-plugin-authentication-and-authorization-hooks.md b/Docs/ADR/026-plugin-authentication-and-authorization-hooks.md index 2558dfe..c0989d7 100644 --- a/Docs/ADR/026-plugin-authentication-and-authorization-hooks.md +++ b/Docs/ADR/026-plugin-authentication-and-authorization-hooks.md @@ -1,4 +1,4 @@ -[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./025-plugin-options-openapi-and-marten-integrations.md) | [Next]() +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./025-plugin-options-openapi-and-marten-integrations.md) | [Next](./027-devtools-ui-typescript.md) # [ADR-026] Configure Plugin Authentication And Authorization Through Host Security Infrastructure @@ -46,4 +46,4 @@ Plugin schemes and policies can protect plugin endpoints through normal ASP.NET - [ADR-023](./023-plugin-application-pipeline-hooks.md) - plugin endpoints and middleware pipeline - [Issue #12](https://github.com/AuthKits/AuthKit.Server/issues/12) - authentication and authorization hooks -[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./025-plugin-options-openapi-and-marten-integrations.md) | [Next]() \ No newline at end of file +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./025-plugin-options-openapi-and-marten-integrations.md) | [Next](./027-devtools-ui-typescript.md) \ No newline at end of file diff --git a/Docs/ADR/README.md b/Docs/ADR/README.md index 7397621..0b6170e 100644 --- a/Docs/ADR/README.md +++ b/Docs/ADR/README.md @@ -77,6 +77,7 @@ The table below shows the architecture areas and their current scope. | [ADR-024](./024-plugin-lifecycle-and-hosted-services.md) | Bridge Plugin Lifecycle Hooks To The Standard .NET Host Lifecycle | Plugins | accepted | 2026-09-12 | | [ADR-025](./025-plugin-options-openapi-and-marten-integrations.md) | Keep Plugin Options, OpenAPI, And Marten Integrations Explicit | Plugins | accepted | 2026-09-12 | | [ADR-026](./026-plugin-authentication-and-authorization-hooks.md) | Configure Plugin Authentication And Authorization Through Host Security Infrastructure | Plugins | accepted | 2026-09-12 | +| [ADR-027](./027-devtools-ui-typescript.md) | Compile The DevTools UI From Modular TypeScript Into A Single Embedded Resource | Plugins | accepted | 2026-09-16 | ## Relationships Between Areas diff --git a/docker-compose.yml b/docker-compose.yml index 5d7fb5e..050698c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,14 +9,15 @@ services: - "5000:5000" # REST HTTPS - "5001:5001" # gRPC HTTPS depends_on: - - authdev-db + authdev-db: + condition: service_healthy volumes: - ./certs:/root/certs env_file: - .env environment: - ASPNETCORE_ENVIRONMENT=Development - - ConnectionStrings__DefaultConnection=Host=auth-db;Port=5432;Database=AuthDev;Username=postgres;Password=postgres + - ConnectionStrings__DefaultConnection=Host=authdev-db;Port=5432;Database=AuthDev;Username=postgres;Password=postgres healthcheck: test: ["CMD", "curl", "-k", "-f", "https://localhost:5000/health"] interval: 30s @@ -36,6 +37,11 @@ services: - "5434:5432" volumes: - AuthDev-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d AuthDev"] + interval: 5s + timeout: 5s + retries: 10 networks: - db_net diff --git a/src/Host/Host.csproj b/src/Host/Host.csproj index 8117283..a1396de 100644 --- a/src/Host/Host.csproj +++ b/src/Host/Host.csproj @@ -30,6 +30,11 @@ + + + PreserveNewest + + diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs index 0d4f29c..7ca8187 100644 --- a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs @@ -1,7 +1,9 @@ using System.Reflection; using System.Runtime.Loader; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; using Google.Protobuf.Reflection; using Microsoft.Extensions.Logging; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; namespace DevTools.Catalog; @@ -81,10 +83,11 @@ private static (IReadOnlyList Services, IReadOnlyDictionary + private static GrpcServiceInfo BuildServiceInfo(ServiceDescriptor descriptor, + IReadOnlyDictionary comments, Assembly assembly) => new() { Name = descriptor.Name, FullName = descriptor.FullName, + Description = comments.TryGetValue(descriptor.Name, out var summary) ? summary : null, Package = descriptor.File.Package, FileName = descriptor.File.Name, + IsPlugin = IsPluginAssembly(assembly), Methods = descriptor.Methods - .Select(BuildMethodInfo) + .Select(method => BuildMethodInfo(method, comments)) .ToArray() }; - private static GrpcMethodInfo BuildMethodInfo(MethodDescriptor method) => + private static bool IsPluginAssembly(Assembly assembly) + { + var assemblyName = assembly.GetName().Name ?? string.Empty; + if (string.Equals(assemblyName, "Host", StringComparison.OrdinalIgnoreCase) || + assembly == Assembly.GetEntryAssembly()) + { + return false; + } + + if (assembly.GetCustomAttribute() is not null) + { + return true; + } + + var types = SafeGetTypes(assembly); + if (types.Any(t => typeof(IAuthKitPlugin).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)) + { + return true; + } + + if (assemblyName.StartsWith("System.", StringComparison.OrdinalIgnoreCase) || + assemblyName.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) || + assemblyName.StartsWith("Google.", StringComparison.OrdinalIgnoreCase) || + string.Equals(assemblyName, "Core", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return false; + } + + private static GrpcMethodInfo BuildMethodInfo(MethodDescriptor method, + IReadOnlyDictionary comments) => new() { Name = method.Name, FullName = $"{method.Service.FullName}/{method.Name}", + Description = comments.TryGetValue($"{method.Service.Name}.{method.Name}", out var summary) + ? summary + : null, IsClientStreaming = method.IsClientStreaming, IsServerStreaming = method.IsServerStreaming, - Request = BuildMessageSchema(method.InputType, depth: 0), - Response = BuildMessageSchema(method.OutputType, depth: 0) + Request = BuildMessageSchema(method.InputType, depth: 0, comments), + Response = BuildMessageSchema(method.OutputType, depth: 0, comments) }; - private static GrpcMessageSchema BuildMessageSchema(MessageDescriptor message, int depth) => + private static GrpcMessageSchema BuildMessageSchema(MessageDescriptor message, int depth, + IReadOnlyDictionary comments) => new() { Name = message.Name, FullName = message.FullName, + Description = comments.TryGetValue(message.Name, out var summary) ? summary : null, Fields = depth >= MaxMessageDepth ? [] : message.Fields.InFieldNumberOrder() - .Select(field => BuildFieldSchema(field, depth)) + .Select(field => BuildFieldSchema(field, depth, comments)) .ToArray() }; - private static GrpcFieldSchema BuildFieldSchema(FieldDescriptor field, int depth) + private static GrpcFieldSchema BuildFieldSchema(FieldDescriptor field, int depth, + IReadOnlyDictionary comments) { var schema = new GrpcFieldSchema { + Number = field.FieldNumber, Name = field.Name, + Description = comments.TryGetValue($"{field.ContainingType.Name}.{field.Name}", out var summary) + ? summary + : null, FieldType = field.FieldType.ToString(), IsRepeated = field.IsRepeated, IsMap = field.IsMap @@ -164,13 +212,13 @@ private static GrpcFieldSchema BuildFieldSchema(FieldDescriptor field, int depth schema.MapKeyType = keyField.FieldType.ToString(); if (valueField.FieldType == FieldType.Message) - schema.MapValue = BuildMessageSchema(valueField.MessageType, depth + 1); + schema.MapValue = BuildMessageSchema(valueField.MessageType, depth + 1, comments); else schema.MapValueType = valueField.FieldType.ToString(); } if (field is { FieldType: FieldType.Message, IsMap: false }) - schema.Message = BuildMessageSchema(field.MessageType, depth + 1); + schema.Message = BuildMessageSchema(field.MessageType, depth + 1, comments); if (field.FieldType == FieldType.Enum) { @@ -180,4 +228,26 @@ private static GrpcFieldSchema BuildFieldSchema(FieldDescriptor field, int depth return schema; } -} \ No newline at end of file + + private static IReadOnlyDictionary CommentsFor(FileDescriptor file, + Assembly assembly, ILogger logger) + { + var roots = assembly.Location is { Length: > 0 } location + ? new[] { Path.GetDirectoryName(location) } + : null; + + var path = ProtoDocComments.TryResolvePath(file.Name, roots); + if (path is null) + return new Dictionary(StringComparer.Ordinal); + + try + { + return ProtoDocComments.Parse(path); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to parse proto comments from {Path}.", path); + return new Dictionary(StringComparer.Ordinal); + } + } +} diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs index 115e8c8..e6f8222 100644 --- a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs @@ -12,11 +12,21 @@ namespace DevTools.Catalog; /// public sealed class GrpcFieldSchema { + /// + /// Gets the field number from the source proto. + /// + public int Number { get; set; } + /// /// Gets the name of the field. /// public required string Name { get; set; } + /// + /// Gets the leading doc comment of the field from the source proto. + /// + public string? Description { get; set; } + /// /// Gets the protobuf field type (e.g. String, Int32, Message). /// @@ -78,6 +88,11 @@ public sealed class GrpcMessageSchema /// public required string Name { get; init; } + /// + /// Gets the lead doc comment of the message from the source proto. + /// + public string? Description { get; init; } + /// /// Gets the fully qualified name of the message. /// @@ -104,6 +119,11 @@ public sealed class GrpcMethodInfo /// public required string FullName { get; init; } + /// + /// Gets the leading doc comment of the method from the source proto. + /// + public string? Description { get; init; } + /// /// Gets a value indicating whether the method is a client-streaming method. /// @@ -140,6 +160,13 @@ public sealed class GrpcServiceInfo /// public required string FullName { get; init; } + /// + /// Gets the leading doc comment of the service from the source proto, + /// or null if no proto comment exists. This field is absent for + /// proto packages without documentation comments. + /// + public string? Description { get; init; } + /// /// Gets the protobuf package the service belongs to. /// @@ -150,6 +177,15 @@ public sealed class GrpcServiceInfo /// public required string FileName { get; init; } + /// + /// Gets a value indicating whether the service originates from a plugin. + /// + /// Defaults to false for assemblies that are not plugins, providing + /// backward-compatible behavior for host services. + /// + /// + public bool IsPlugin { get; init; } + /// /// Gets the methods exposed by the service. /// @@ -170,4 +206,4 @@ public sealed class GrpcCatalogResponse /// Gets the discovered gRPC services. /// public required IEnumerable Services { get; init; } -} \ No newline at end of file +} diff --git a/src/Plugins/Solutions/DevTools/Catalog/ProtoDocComments.cs b/src/Plugins/Solutions/DevTools/Catalog/ProtoDocComments.cs new file mode 100644 index 0000000..483ce1e --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Catalog/ProtoDocComments.cs @@ -0,0 +1,187 @@ +using System.Text; + +namespace DevTools.Catalog; + +/// +/// Extracts leading // doc comments from a .proto source file +/// so the UI can show method and field descriptions like Swagger does. +/// +/// +/// The generated descriptors do not carry source comments (protoc is invoked +/// without source info), so the catalog resolves comments by reading the +/// original file that was copied next to the host binaries at build time. +/// Only // line comments directly above a declaration are collected; +/// block comments and summary banners are ignored. +/// +public static class ProtoDocComments +{ + /// + /// Resolves the on-disk path of a .proto file for the given + /// descriptor file name (e.g. protos/jwks.proto). + /// + /// The descriptor file name relative to the proto root. + /// + /// Additional directories to search (e.g. plugin output folders) before + /// falling back to the app base directory. + /// + /// The absolute path when the file exists, otherwise null. + public static string? TryResolvePath(string fileName, IEnumerable? additionalRoots = null) + { + var roots = new List(additionalRoots ?? []) + { + AppContext.BaseDirectory, + Directory.GetCurrentDirectory() + }; + + foreach (var root in roots) + { + foreach (var candidate in PotentialPaths(root, fileName)) + { + if (File.Exists(candidate)) + return candidate; + } + } + + return null; + } + + private static IEnumerable PotentialPaths(string root, string fileName) + { + var normalized = Normalize(fileName); + if (Path.IsPathRooted(normalized)) + yield return normalized; + + yield return Path.Combine(root, "Grpc", normalized); + yield return Path.Combine(root, "protos", normalized); + yield return Path.Combine(root, normalized); + } + + private static string Normalize(string fileName) => + fileName.Replace('\\', '/') + .TrimStart('/') + .Replace('/', Path.DirectorySeparatorChar); + + /// + /// Parses the .proto file and returns leading comments keyed by + /// member: service.Name, service.rpc, message and + /// message.field. + /// + /// The absolute path of the proto file. + /// A map of member key to comment text. + public static IReadOnlyDictionary Parse(string path) + { + var comments = new Dictionary(StringComparer.Ordinal); + var pending = new List(); + var serviceName = ""; + var messageName = ""; + + foreach (var rawLine in File.ReadLines(path)) + { + var line = rawLine.Trim(); + + if (line.StartsWith("//", StringComparison.Ordinal)) + { + pending.Add(line[2..].Trim()); + continue; + } + + if (line.Length == 0) + { + pending.Clear(); + continue; + } + + if (line.StartsWith("rpc ", StringComparison.Ordinal) && serviceName.Length > 0) + { + var rpc = Ident(line, 4); + if (rpc is not null && pending.Count > 0) + comments[$"{serviceName}.{rpc}"] = Join(pending); + pending.Clear(); + continue; + } + + if (line.StartsWith("service ", StringComparison.Ordinal)) + { + var service = Ident(line, 8); + if (service is not null) + { + if (pending.Count > 0) + comments[service] = Join(pending); + serviceName = service; + } + pending.Clear(); + continue; + } + + if (line.StartsWith("message ", StringComparison.Ordinal)) + { + var message = Ident(line, 8); + if (message is not null) + { + if (pending.Count > 0) + comments[message] = Join(pending); + messageName = message; + } + pending.Clear(); + continue; + } + + if (messageName.Length > 0 && IsFieldDeclaration(line)) + { + var field = FieldIdent(line); + if (field is not null && pending.Count > 0) + comments[$"{messageName}.{field}"] = Join(pending); + pending.Clear(); + continue; + } + + pending.Clear(); + } + + return comments; + } + + private static bool IsFieldDeclaration(string line) + { + if (!line.EndsWith(';')) + return false; + if (line.StartsWith("option ", StringComparison.Ordinal) || line.StartsWith("reserved ", StringComparison.Ordinal) || line.StartsWith("map ", StringComparison.Ordinal)) + return false; + return line.IndexOf(' ') > 0 && line.IndexOf('=') > 0; + } + + private static string? Ident(string line, int start) + { + var part = line[start..]; + var builder = new StringBuilder(); + foreach (var ch in part) + { + if (!char.IsLetterOrDigit(ch) && ch != '_') + break; + builder.Append(ch); + } + return builder.Length > 0 ? builder.ToString() : null; + } + + /// + /// Extracts the field name from a declaration like + /// repeated string x5c = 7; or map<string, string> details = 5;. + /// + private static string? FieldIdent(string line) + { + var equal = line.IndexOf('=', StringComparison.Ordinal); + if (equal <= 0) + return null; + + var start = equal - 1; + while (start >= 0 && (char.IsLetterOrDigit(line[start]) || line[start] == '_')) + start--; + start++; + return start < equal ? line[start..equal].Trim() : null; + } + + private static string Join(IReadOnlyList lines) => + string.Join(' ', lines.Select(TrimAsterisk)).Trim(); + + private static string TrimAsterisk(string value) => value.Trim().TrimStart('*', ' ').Trim(); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Properties/AssemblyInfo.cs b/src/Plugins/Solutions/DevTools/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..5f77718 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DevTools.Tests")] \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs index 8b4d76f..76dc25b 100644 --- a/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs +++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs @@ -1,3 +1,5 @@ +using System.Net; + using Google.Protobuf; using Google.Protobuf.Reflection; using Grpc.Core; @@ -70,12 +72,14 @@ public async Task InvokeAsync( var response = await InvokeUnaryAsync(method, request.Headers, request.RequestJson, cancellationToken); stopwatch.Stop(); + var responseBytes = response.ToByteArray(); return new GrpcInvocationResult { Success = true, StatusName = nameof(StatusCode.OK), StatusCode = (int)StatusCode.OK, ResponseJson = JsonFormatter.Default.Format(response), + ResponseBase64 = Convert.ToBase64String(responseBytes), ElapsedMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2) }; } @@ -106,6 +110,32 @@ public async Task InvokeAsync( ElapsedMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2) }; } + catch (InvalidProtocolBufferException ex) + { + stopwatch.Stop(); + return new GrpcInvocationResult + { + Success = false, + StatusName = nameof(StatusCode.InvalidArgument), + StatusCode = (int)StatusCode.InvalidArgument, + Detail = $"Request JSON is invalid: {ex.Message}", + ElapsedMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2) + }; + } + catch (Exception ex) + { + stopwatch.Stop(); + logger.LogWarning(ex, "Unexpected failure invoking {Method}.", method.FullName); + + return new GrpcInvocationResult + { + Success = false, + StatusName = nameof(StatusCode.Internal), + StatusCode = (int)StatusCode.Internal, + Detail = ex.Message, + ElapsedMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2) + }; + } } /// @@ -158,15 +188,46 @@ private async Task InvokeUnaryAsync( /// private static GrpcChannel CreateChannel(IOptions options, ILogger logger) { + var target = options.Value.ResolveGrpcTarget(); + var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }; - var target = options.Value.ResolveGrpcTarget(); + // The host falls back to plain HTTP/2 (h2c) when no dev certificate is + // present. gRPC over an unencrypted connection requires this switch. + // H2C is only enabled for loopback targets to prevent sending caller-supplied + // credentials (including bearer tokens) in plaintext to remote servers. + if (IsLoopbackTarget(target) && target.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + AppContext.SetSwitch( + "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", + true); + } + logger.LogInformation("gRPC UI channel configured for target {Target}.", target); return GrpcChannel.ForAddress(target, new GrpcChannelOptions { HttpHandler = handler }); } -} \ No newline at end of file + + /// + /// Determines whether the target URL points to a loopback address. + /// + /// The target URL. + /// true if the target is a loopback address; otherwise, false. + internal static bool IsLoopbackTarget(string target) + { + if (!Uri.TryCreate(target, UriKind.Absolute, out var uri)) + return false; + + var host = uri.Host; + // Check for localhost first (doesn't parse as IP address) + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + // Then check if it's a loopback IP address + return IPAddress.TryParse(host, out var ip) && IPAddress.IsLoopback(ip); + } +} diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs index 9f25fae..fa9536e 100644 --- a/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs +++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs @@ -5,8 +5,9 @@ namespace DevTools.Runtime; /// /// /// A successful invocation carries the serialized response in -/// . Failures carry the gRPC status identifier and -/// code, a human-readable detail, and any response trailers. +/// and raw protobuf bytes in . +/// Failures carry the gRPC status identifier and code, a human-readable detail, +/// and any response trailers. /// public sealed class GrpcInvocationResult { @@ -36,6 +37,17 @@ public sealed class GrpcInvocationResult /// public string? ResponseJson { get; init; } + /// + /// Gets the raw binary protobuf response encoded as base64 string. + /// + /// This field is only populated on successful invocations; for failed + /// invocations it remains null. The base64-encoded payload is + /// limited to int.MaxValue - 1 bytes (approximately 2 GB) due + /// to protobuf size constraints. + /// + /// + public string? ResponseBase64 { get; init; } + /// /// Gets the invocation duration in milliseconds. /// @@ -45,4 +57,4 @@ public sealed class GrpcInvocationResult /// Gets the response trailers returned with the call. /// public IReadOnlyDictionary? Trailers { get; init; } -} \ No newline at end of file +} diff --git a/src/Plugins/Solutions/DevTools/UI/build.mjs b/src/Plugins/Solutions/DevTools/UI/build.mjs deleted file mode 100644 index ab61c42..0000000 --- a/src/Plugins/Solutions/DevTools/UI/build.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { build, context } from "esbuild"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -/** Marker replaced with the compiled JS bundle inside the HTML template. */ -const BUNDLE_MARKER = "/*__BUNDLE__*/"; - -const root = dirname(fileURLToPath(import.meta.url)); -const watch = process.argv.includes("--watch"); - -const options = { - entryPoints: [join(root, "src", "app.ts")], - bundle: true, - format: "iife", - minify: true, - target: ["es2020"], - outfile: join(root, "dist", "bundle.js"), -}; - -async function inlineBundle() { - const [template, bundle] = await Promise.all([ - readFile(join(root, "template.html"), "utf8"), - readFile(join(root, "dist", "bundle.js"), "utf8"), - ]); - const html = template.replace(BUNDLE_MARKER, () => bundle); - await writeFile(join(root, "dist", "ui.html"), html); -} - -if (watch) { - const ctx = await context(options); - await ctx.watch(); - await inlineBundle(); - console.log("Watching for changes…"); -} else { - await build(options); - await inlineBundle(); - console.log("TypeScript UI written to dist/ui.html"); -} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/package-lock.json b/src/Plugins/Solutions/DevTools/UI/package-lock.json index a501ad4..1d1bf4c 100644 --- a/src/Plugins/Solutions/DevTools/UI/package-lock.json +++ b/src/Plugins/Solutions/DevTools/UI/package-lock.json @@ -8,50 +8,102 @@ "name": "@authkit/devtools-ui", "version": "1.0.0", "devDependencies": { - "esbuild": "^0.28.2", - "typescript": "^7.0.2" + "@humanspeak/svelte-motion": "^1.2.1", + "@sveltejs/vite-plugin-svelte": "^7.3.0", + "@tailwindcss/vite": "^4.3.3", + "svelte": "^5.57.0", + "svelte-check": "^4.7.6", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + "vite": "^8.3.0", + "vite-plugin-singlefile": "^2.3.3" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@humanspeak/svelte-motion": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanspeak/svelte-motion/-/svelte-motion-1.2.1.tgz", + "integrity": "sha512-dnYL9ih+bJ0myIcBZSYHSKGMnibuoPJrKcMrGGPa3XOgjOACUUrw/MYyIv2q4ufNErQE259Yg+PZjd7ux7shvg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "acorn": "^8.18.0", + "motion": "^13.2.0", + "motion-dom": "^13.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/humanspeak" + }, + "peerDependencies": { + "svelte": "^5.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", @@ -60,15 +112,15 @@ "android" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -77,13 +129,13 @@ "android" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -94,13 +146,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -111,15 +163,15 @@ "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", @@ -128,166 +180,184 @@ "freebsd" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ - "arm" + "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ - "ia32" + "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ - "loong64" + "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ - "mips64el" + "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ - "ppc64" + "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ - "s390x" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -295,581 +365,1658 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">= 18.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, "engines": { - "node": ">=18" + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "openbsd" + "android" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "openharmony" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "sunos" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ - "ia32" + "arm" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "MPL-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "musl" + ], + "license": "MPL-2.0", "optional": true, "os": [ - "aix" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "musl" + ], + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], + "node_modules/@tailwindcss/node/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ - "loong64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ - "mips64el" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ - "riscv64" + "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ - "s390x" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], "cpu": [ - "arm64" + "wasm32" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, "engines": { - "node": ">=16.20.0" + "node": ">=14.0.0" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "openbsd" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "sunos" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "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/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "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/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/framer-motion": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.0.tgz", + "integrity": "sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-dom": "^13.3.0", + "motion-utils": "^13.3.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "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/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/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/motion": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-13.4.0.tgz", + "integrity": "sha512-aiNSA29N+vgV5p5cJSB5cFzx8KTuuxB99z8zGtxsaqlwKtBK6sq8YgPRpkVQLkbJMOcLDU0q/ShdT95qpBy/0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "framer-motion": "^13.4.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.3.0.tgz", + "integrity": "sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-utils": "^13.3.0" + } + }, + "node_modules/motion-utils": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz", + "integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "dev": true, - "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "bin": { - "esbuild": "bin/esbuild" + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, + "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/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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "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" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" } } } diff --git a/src/Plugins/Solutions/DevTools/UI/package.json b/src/Plugins/Solutions/DevTools/UI/package.json index e2dc1e1..bdbef9c 100644 --- a/src/Plugins/Solutions/DevTools/UI/package.json +++ b/src/Plugins/Solutions/DevTools/UI/package.json @@ -5,12 +5,20 @@ "type": "module", "description": "TypeScript UI for the AuthKit gRPC DevTools plugin.", "scripts": { - "typecheck": "tsc --noEmit", - "build": "node build.mjs", - "watch": "node build.mjs --watch" + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "build": "vite build && node scripts/finalize-ui.mjs", + "watch": "vite build --watch", + "dev": "vite" }, "devDependencies": { - "esbuild": "^0.28.2", - "typescript": "^7.0.2" + "@humanspeak/svelte-motion": "^1.2.1", + "@sveltejs/vite-plugin-svelte": "^7.3.0", + "@tailwindcss/vite": "^4.3.3", + "svelte": "^5.57.0", + "svelte-check": "^4.7.6", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + "vite": "^8.3.0", + "vite-plugin-singlefile": "^2.3.3" } -} \ No newline at end of file +} diff --git a/src/Plugins/Solutions/DevTools/UI/scripts/finalize-ui.mjs b/src/Plugins/Solutions/DevTools/UI/scripts/finalize-ui.mjs new file mode 100644 index 0000000..993c3ca --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/scripts/finalize-ui.mjs @@ -0,0 +1,9 @@ +import { rename } from "node:fs/promises"; +import { join } from "node:path"; + +const dist = join(import.meta.dirname, "..", "dist"); + +await rename( + join(dist, "template.html"), + join(dist, "ui.html"), +); diff --git a/src/Plugins/Solutions/DevTools/UI/src/App.svelte b/src/Plugins/Solutions/DevTools/UI/src/App.svelte new file mode 100644 index 0000000..6389fe7 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/App.svelte @@ -0,0 +1,54 @@ + + + +
+ + + + + + + + \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/api.ts b/src/Plugins/Solutions/DevTools/UI/src/api.ts deleted file mode 100644 index e544d6e..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/api.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { GrpcCatalogResponse, InvocationResult } from "./types"; - -export interface InvokeGrpcRequest -{ - service: string; - method: string; - requestJson: string; - headers: Record; -} - -export interface GrpcApi -{ - fetchServices(): Promise; - invoke(request: InvokeGrpcRequest): Promise; -} - -export class HttpGrpcApi implements GrpcApi -{ - constructor(private readonly base: string) {} - - async fetchServices(): Promise - { - const response = await fetch(`${this.base}/api/services`); - - if (!response.ok) { - throw new Error( - `Failed to load catalog: HTTP ${response.status}`, - ); - } - - return (await response.json()) as GrpcCatalogResponse; - } - - async invoke(request: InvokeGrpcRequest): Promise - { - const response = await fetch(`${this.base}/api/invoke`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - throw new Error( - `Failed to invoke gRPC method: HTTP ${response.status}`, - ); - } - - return (await response.json()) as InvocationResult; - } -} diff --git a/src/Plugins/Solutions/DevTools/UI/src/app.css b/src/Plugins/Solutions/DevTools/UI/src/app.css new file mode 100644 index 0000000..792cb57 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/app.css @@ -0,0 +1,260 @@ +@import "tailwindcss"; + +@theme { + --color-bg: #0f0c07; + --color-panel: #18130d; + --color-panel2: #201a12; + --color-border: #32291c; + --color-text: #e8e0d2; + --color-muted: #a89a86; + --color-accent: #d99a5b; + --color-accent2: #e8b87c; + --color-accentsoft: #ffd9a6; + --color-green: #7fc47f; + --color-red: #e0786f; + --color-amber: #d9a441; + --font-sans: "Inter", -apple-system, "Segoe UI", Roboto, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, + monospace; +} + +@layer base { + html { + box-sizing: border-box; + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + + body { + margin: 0; + background-color: var(--color-bg); + color: var(--color-text); + font: + 14px/1.5 var(--font-sans); + -webkit-font-smoothing: antialiased; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 8px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + html[data-theme="light"] { + --color-bg: #faf6ef; + --color-panel: #fffdf8; + --color-panel2: #f1eadd; + --color-border: #e3d9c8; + --color-text: #332d25; + --color-muted: #6f6453; + --color-accent: #a66b3d; + --color-accent2: #8a552f; + --color-accentsoft: #f3e7d7; + --color-green: #3f7d4e; + --color-red: #a3403a; + --color-amber: #9a6b1f; + } + + html[data-theme="light"] .tok-amber { + color: #9a6b1f; + } +} + +@layer components { + .tok-key { + color: #f0e8da; + } + .tok-string { + color: #a6d18f; + } + .tok-ph { + color: #c9a86d; + } + .tok-number { + color: #d9a441; + } + .tok-bool { + color: #c9a86d; + } + .tok-keyword { + color: #e0786f; + } + + .grpc-card { + border: 1px solid var(--color-border); + background-color: var(--color-panel); + border-radius: 12px; + } + + .grpc-input { + width: 100%; + border: 1px solid var(--color-border); + background-color: var(--color-bg); + color: var(--color-text); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + outline: none; + } + + .grpc-input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 25%, transparent); + } + + .grpc-mono { + font-family: var(--font-mono); + } + + .grpc-brand-shimmer { + background-image: linear-gradient( + 100deg, + var(--color-accent) 10%, + var(--color-accentsoft) 40%, + var(--color-accent) 70% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: grpc-brand-shimmer 5s linear infinite; + } + + @keyframes grpc-brand-shimmer { + to { + background-position: -200% center; + } + } + + .grpc-tab-indicator { + animation: grpc-indicator-slide 0.3s ease-out; + } + + @keyframes grpc-indicator-slide { + from { + opacity: 0; + transform: scaleX(0.4); + } + to { + opacity: 1; + transform: scaleX(1); + } + } + + .grpc-glow-pulse { + animation: grpc-glow-pulse 3s ease-in-out infinite; + } + + @keyframes grpc-glow-pulse { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 0%, transparent); } + 50% { box-shadow: 0 0 18px 4px color-mix(in srgb, var(--color-accent) 18%, transparent); } + } + + .grpc-gradient-border { + position: relative; + border: 1px solid transparent; + background-clip: padding-box; + background-origin: border-box; + } + + .grpc-gradient-border::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + background: linear-gradient(135deg, var(--color-accent), transparent 40%, transparent 60%, var(--color-accent2)); + z-index: -1; + opacity: 0.55; + transition: opacity 0.3s ease; + } + + .grpc-gradient-border:hover::before { + opacity: 0.85; + } +} + +@layer base { + html[data-theme="light"] .tok-key { + color: #3f3729; + } + html[data-theme="light"] .tok-ph { + color: #6f6453; + } + html[data-theme="light"] .tok-number { + color: #9a6b1f; + } + html[data-theme="light"] .tok-bool { + color: #6f6453; + } + html[data-theme="light"] .tok-string { + color: #2f6b3f; + } + html[data-theme="light"] .tok-keyword { + color: #a3403a; + } + +/* Light theme overrides for MetadataTable and other components using opacity utilities */ + html[data-theme="light"] [class*="bg-panel2\/50"], + html[data-theme="light"] [class*="bg-panel2\/70"], + html[data-theme="light"] [class*="bg-panel2\/30"] { + --tw-bg-opacity: 1; + background-color: #f1eadd; + } + + /* Light theme: make button backgrounds consistent */ + html[data-theme="light"] .bg-panel { + background-color: #fffdf8; + } + html[data-theme="light"] .text-muted { + color: #6f6453; + } + html[data-theme="light"] .text-text { + color: #332d25; + } + html[data-theme="light"] .border-border { + border-color: #e3d9c8; + } + html[data-theme="light"] .bg-bg { + background-color: #faf6ef; + } + + /* Dark card fallback - use for elements that should be dark only in dark mode */ + html:not([data-theme="light"]) .bg-dark-card, + html[data-theme="dark"] .bg-dark-card { + background-color: #17110a; + } + + /* Light theme: dark-card becomes light */ + html[data-theme="light"] .bg-dark-card { + background-color: #f1eadd; + } + + /* Modal dark background fallback */ + html:not([data-theme="light"]) .bg-modal-dark, + html[data-theme="dark"] .bg-modal-dark { + background-color: #0b0804; + } + + /* Light theme: modal-dark becomes light */ + html[data-theme="light"] .bg-modal-dark { + background-color: #f1eadd; + } + + /* Card header background - light overlay becomes solid light in light theme */ + html[data-theme="light"] .bg-panel2\/50 { + --tw-bg-opacity: 1; + background-color: #fffdf8; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/app.ts b/src/Plugins/Solutions/DevTools/UI/src/app.ts deleted file mode 100644 index 5d65c6a..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/app.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { HttpGrpcApi, type GrpcApi } from "./api"; -import { createInvokeAction } from "./controller"; -import { el, getById } from "./dom"; -import { highlightSelectedMethod, renderMethodDetail, renderServiceList } from "./render"; -import { errorMessage } from "./schema"; -import { InMemoryDevToolsStore, type DevToolsStore } from "./store"; -import { loadPersistedHeaders } from "./storage"; -import type { GrpcCatalogResponse } from "./types"; - -declare global { - interface Window { - __GRPC_UI_BASE__?: string; - } -} - -const base = window.__GRPC_UI_BASE__ ?? ""; -const api: GrpcApi = new HttpGrpcApi(base); - -function bootstrap(api: GrpcApi, store: DevToolsStore): void -{ - const servicesContainer = getById("services"); - const detailContainer = getById("detail"); - const target = getById("target"); - - function onSelect(serviceIndex: number, methodIndex: number): void - { - store.select(serviceIndex, methodIndex); - highlightSelectedMethod(store.state.selected); - - const service = store.state.services[serviceIndex]; - const method = service.methods[methodIndex]; - renderMethodDetail( - detailContainer, - method, - loadPersistedHeaders(), - createInvokeAction(api, service, method), - ); - } - - async function onCatalog(catalog: GrpcCatalogResponse): Promise - { - store.setServices(catalog.services ?? []); - target.textContent = catalog.target ?? ""; - renderServiceList(servicesContainer, store.state.services, store.state.selected, onSelect); - } - - void (async () => { - try { - await onCatalog(await api.fetchServices()); - } catch (err: unknown) { - servicesContainer.replaceChildren(); - const message = el("div", "empty", `Failed to load catalog: ${errorMessage(err)}`); - message.style.padding = "14px"; - servicesContainer.append(message); - } - })(); -} - -bootstrap(api, new InMemoryDevToolsStore()); \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/layout/Header.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/layout/Header.svelte new file mode 100644 index 0000000..e3105a1 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/layout/Header.svelte @@ -0,0 +1,129 @@ + + + + + + +
+ gRPC UI + Explore. Invoke. Debug. +
+
+ + + + + {#if $connectionStatus === "connecting"} + + + + + Connecting… + {:else if $connectionStatus === "error"} + + + + + Disconnected + {:else} + + + + Connected + {/if} + + + + +
+ + + + {#if $theme === "dark"} + + {:else} + + {/if} + + +
+
+
\ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/layout/ServiceTree.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/layout/ServiceTree.svelte new file mode 100644 index 0000000..cf4d12b --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/layout/ServiceTree.svelte @@ -0,0 +1,339 @@ + + +{#if inService.length === 0} +
+ No services match “{search}”. +
+{:else} + {#if host.length > 0} +
+ + + {#if isHostOpen} + {#each host as service (serviceId(service))} + {@const id = serviceId(service)} + {@const open = !!expanded[id]} + {@const active = $selectedServiceName === id} + +
+
toggleService(service)} + role="button" + tabindex="0" + onkeydown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleService(service); + } + }} + > + + + + + + + + + + + + + {service.package}.{service.name} + + + ({service.methods.length}) + +
+ + {#if open} +
    + {#each service.methods as method (method.name)} + {@const selected = + $selectedServiceName === id && + $selectedMethodName === method.name} +
  • + +
  • + {/each} +
+ {/if} +
+ {/each} + {/if} +
+ {/if} + + {#if plugin.length > 0} +
+ + + {#if isPluginsOpen} + {#each plugin as service (serviceId(service))} + {@const id = serviceId(service)} + {@const open = !!expanded[id]} + {@const active = $selectedServiceName === id} + +
+
toggleService(service)} + role="button" + tabindex="0" + onkeydown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleService(service); + } + }} + > + + + + + + + + + + + + {service.package}.{service.name} + + + ({service.methods.length}) + +
+ + {#if open} +
    + {#each service.methods as method (method.name)} + {@const selected = + $selectedServiceName === id && + $selectedMethodName === method.name} +
  • + +
  • + {/each} +
+ {/if} +
+ {/each} + {/if} +
+ {/if} +{/if} diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/layout/Sidebar.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/layout/Sidebar.svelte new file mode 100644 index 0000000..aa68c4e --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/layout/Sidebar.svelte @@ -0,0 +1,100 @@ + + + + +
+ + + + + + + ⌘K + +
+
+ + + + Services ({$services.length}) + + {#if search} + applySearch("clear")} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + Clear + + {/if} + + + + + +
diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/method/ActionBar.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/method/ActionBar.svelte new file mode 100644 index 0000000..db0e37c --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/method/ActionBar.svelte @@ -0,0 +1,61 @@ + + +
+
+ + + +
+ +
diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/method/JsonEditor.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/method/JsonEditor.svelte new file mode 100644 index 0000000..6f2373f --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/method/JsonEditor.svelte @@ -0,0 +1,69 @@ + + +
+ + +
+ + +
+
+ + \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/method/MetadataTable.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/method/MetadataTable.svelte new file mode 100644 index 0000000..9647285 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/method/MetadataTable.svelte @@ -0,0 +1,231 @@ + + +
+ +
+ +
+
+ + + + + + Request metadata +
+ +
+ + + + + + {requestMetadata.length} header{requestMetadata.length === 1 ? "" : "s"} + + + + + + session storage + +
+
+ + +
+

+ Type gRPC metadata as Key: Value, one header per line. + Persisted across method switches. +

+ +
+ + + {#if requestMetadata.length > 0} +
+ Parsed preview + +
+
+ + + + + + + + + + {#each requestMetadata as [key, value], i} + + + + + + {/each} + +
#KeyValue
{i + 1}{key}{value === "" ? '""' : value}
+
+ {/if} +
+ + +
+ +
+
+ + + + + + Response metadata +
+ +
+ + + + + + {$response === null ? "—" : `${responseMetadata.length} header${responseMetadata.length === 1 ? "" : "s"}`} + +
+
+ + {#if $response === null} +
+
+ + + + + Invoke a method to inspect the metadata returned by the server. +
+
+ {:else} +
+ Received headers + +
+
+ + + + + + + + + + + {#each responseMetadata as [key, value], i} + + + + + + + {/each} + +
#KeyValueCopy
{i + 1}{key}{value === "" ? '""' : value} + +
+
+ {/if} +
+
diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodDetail.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodDetail.svelte new file mode 100644 index 0000000..046b2b7 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodDetail.svelte @@ -0,0 +1,322 @@ + + +{#if $selectedService === null || $selectedMethodName === null} + + + + + + + + + Pick an RPC method from the sidebar to start testing. + + +{:else} + {@const service = $selectedService} + {@const method = service.methods.find((m) => m.name === $selectedMethodName)} + {#if method} + + + + + + + + + + + {#if $activeTab === "request"} + {#if method.requestFields !== undefined && method.requestFields.length === 0} + + + + + + + +

+ This method takes an empty request (google.protobuf.Empty) — nothing to configure. +

+
+ + + + + {#if statusMessage !== null} + + {statusMessage} + + {/if} + +
+ {:else} + + + + Request JSON + + + {friendlyBytes(payloadSizeBytes)} + + + + ($requestJson = next)} + /> + + + + + + {#if statusMessage !== null} + + {statusMessage} + + {/if} + + + {/if} + {:else if $activeTab === "response"} + + ($activeTab = "request")} + {isInvoking} + /> + + {:else if $activeTab === "metadata"} + + + + {:else if $activeTab === "proto"} + + + + {/if} +
+
+ {/if} +{/if} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodHeader.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodHeader.svelte new file mode 100644 index 0000000..633a593 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodHeader.svelte @@ -0,0 +1,74 @@ + + +
+ +
+
+ + + + + +

{method.name}

+ + RPC + +
+ +
+ + + + + {service.package}.{service.name}/{method.name} + +
+ + {#if method.description} +

+ {method.description} +

+ {/if} +
+ + +
+ +
+ + + + + + +
+
Request
+
+ {method.fullRequestPath} +
+
+
+ + +
+ + + + + + +
+
Response
+
+ {method.fullResponsePath} +
+
+
+
+
diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodTabs.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodTabs.svelte new file mode 100644 index 0000000..34588b0 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/method/MethodTabs.svelte @@ -0,0 +1,30 @@ + + +
+ {#each tabs as tab (tab.id)} + {@const active = $activeTab === tab.id} + + {/each} +
\ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/method/ResponsePanel.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/method/ResponsePanel.svelte new file mode 100644 index 0000000..35aae04 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/method/ResponsePanel.svelte @@ -0,0 +1,270 @@ + + +{#if $response === null} +
+
+ + + + +
+ +
+

No response yet

+

+ Run an invocation to execute this RPC method and inspect the server response here. +

+
+ + {#if onInvoke} +
+ + + {#if onSwitchToRequest} + + {/if} +
+ {/if} +
+{:else} + {@const result = $response} +
+ +
+
+ {#if result.status === "OK"} + + + + + + {:else} + + + + + + {/if} + + Response + + + {result.status === "OK" ? (result.statusName ?? "OK") : (result.statusName ?? "ERROR")} + +
+ +
+
+ + + + + {result.durationMs} ms +
+ +
+ + + + + {result.contentType} +
+
+
+ + +
+

Message

+
+ + +
+
+ {#each messageLines as line} +
{line.number}
+ {/each} +
+
+ {#each messageLines as line} +
{@html line.html || " "}
+ {/each} +
+
+
+
+ + +
+
+

Raw response

+
+ + + + +
+
+ +
+ + +
+
+ {#each rawLines as line} +
{line.number}
+ {/each} +
+
+ {#each rawLines as line} +
{line.raw || " "}
+ {/each} +
+
+
+
+ + +
+

Response trailers

+
+ + + {#each trailersList as [key, value], i} + + + + + {/each} + +
+ {key} + + {value === "" ? '""' : value} +
+
+
+
+{/if} diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/navigation/MethodsColumn.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/navigation/MethodsColumn.svelte new file mode 100644 index 0000000..546e34c --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/navigation/MethodsColumn.svelte @@ -0,0 +1,189 @@ + + +{#if $selectedService} + {@const service = $selectedService} + + + +
+
+ + + + + + + + + {service.name} + +

+ {service.package} +

+
+
+
+ + + (isProtoOpen = true)} + initial={{ opacity: 0, y: 20, scale: 0.9 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + transition={{ duration: 0.5, delay: 0.8, type: "spring", stiffness: 200 }} + whileHover={{ + scale: 1.03, + y: -3, + backgroundColor: "rgba(166, 107, 61, 0.15)", + borderColor: "rgba(166, 107, 61, 0.4)", + boxShadow: "0 8px 25px rgba(166, 107, 61, 0.15)" + }} + whileTap={{ scale: 0.97, y: 0 }} + > + + + + + + + Proto Schema + + → + + + +
+ + + + + Methods + + +
+ {#each service.methods as method, index (method.name)} + {@const isSelected = $selectedMethodName === method.name} + selectMethod(method.name)} + initial={{ opacity: 0, x: -30, scale: 0.9 }} + animate={{ opacity: 1, x: 0, scale: 1 }} + transition={{ duration: 0.3, delay: 1.0 + (index * 0.1), type: "spring", stiffness: 200 }} + whileHover={{ scale: 1.05, x: 5 }} + whileTap={{ scale: 0.95, x: 0 }} + > +
+ + RPC + + {method.name} +
+ + + +
+ {/each} +
+
+
+ + + (isProtoOpen = false)} + /> +{/if} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoModal.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoModal.svelte new file mode 100644 index 0000000..8840636 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoModal.svelte @@ -0,0 +1,244 @@ + + + + +{#if isOpen} + + + + + + + + +
+ + + + + + +
+ + {service.name}.proto + +

+ package {service.package}; +

+
+
+ +
+ + copy(protoCode, "proto")} + initial={{ opacity: 0, x: 20 }} + animate={{ opacity: 1, x: 0 }} + transition={{ duration: 0.3, delay: 0.2 }} + whileHover={{ scale: 1.05, y: -2 }} + whileTap={{ scale: 0.95 }} + > + {#if copied !== null} + + + + Copied! + {:else} + + + + + Copy Proto + {/if} + + + + + + + + +
+
+ + + +
+ {#each lines as line, index} + + {line.number} + + {/each} +
+
+ {#each lines as line, index} + + {@html line.html || " "} + + {/each} +
+
+ + + +
+ + + + + {lines.length} lines + + + + + + + {service.methods.length} methods + +
+
+ ESC + to close +
+
+
+
+{/if} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoView.svelte b/src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoView.svelte new file mode 100644 index 0000000..57db5d6 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoView.svelte @@ -0,0 +1,228 @@ + + +
+ +
+ +
+
+
+ </> + Request Proto +
+ + +
+ +
+
{@html highlightProto(requestProto)}
+
+
+ + +
+
+
+ </> + Response Proto +
+ + +
+ +
+
{@html highlightProto(responseProto)}
+
+
+
+ + +
+ +
+
+
+ {`{ }`} + Request (JSON) +
+ + +
+ +
+
+ {#each requestJsonLines as line} +
{line.number}
+ {/each} +
+
+ {#each requestJsonLines as line} +
{@html line.html || " "}
+ {/each} +
+
+
+ + +
+
+
+ {`{ }`} + Response (JSON) +
+ + +
+ +
+
+ {#each responseJsonLines as line} +
{line.number}
+ {/each} +
+
+ {#each responseJsonLines as line} +
{@html line.html || " "}
+ {/each} +
+
+
+
+ + +
+
+ + + + + + + + Parsed (Proto) + +
+ +
+ +
+
Request message
+
+
{@html formatProtoStruct(requestJsonData)}
+
+
+ + +
+
Response message
+
+
{@html formatProtoStruct(responseJsonData)}
+
+
+
+
+
diff --git a/src/Plugins/Solutions/DevTools/UI/src/controller.ts b/src/Plugins/Solutions/DevTools/UI/src/controller.ts deleted file mode 100644 index 8897da9..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/controller.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { GrpcApi, InvokeGrpcRequest } from "./api"; -import { - errorMessage, - formatInvocationOutput, - parseHeaders, -} from "./schema"; -import { persistHeaders } from "./storage"; -import type { MethodDetail } from "./render"; -import type { GrpcMethodInfo, GrpcServiceInfo } from "./types"; - -export function createInvokeAction( - api: GrpcApi, - service: GrpcServiceInfo, - method: GrpcMethodInfo, -): (detail: MethodDetail) => void -{ - return (detail) => - { - void invoke(api, service, method, detail); - }; -} - -async function invoke( - api: GrpcApi, - service: GrpcServiceInfo, - method: GrpcMethodInfo, - detail: MethodDetail, -): Promise -{ - const headersText = detail.headers.value; - persistHeaders(headersText); - - const request: InvokeGrpcRequest = { - service: service.fullName || `${service.package}.${service.name}`, - method: method.name, - requestJson: detail.request.value, - headers: parseHeaders(headersText), - }; - - detail.setStatus("idle", "Invoking…"); - detail.writeResponse(""); - - try - { - const result = await api.invoke(request); - const success = result.success && result.statusCode === 0; - - detail.setStatus( - success ? "ok" : "err", - `${result.statusName} · ${result.elapsedMs} ms`, - ); - detail.writeResponse(formatInvocationOutput(result)); - } catch (err: unknown) { - detail.setStatus("err", "Request failed"); - detail.writeResponse(errorMessage(err)); - } -} diff --git a/src/Plugins/Solutions/DevTools/UI/src/dom.ts b/src/Plugins/Solutions/DevTools/UI/src/dom.ts deleted file mode 100644 index 5ae1f51..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/dom.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function getById(id: string): T -{ - const node = document.getElementById(id); - if (node === null) throw new Error(`Element #${id} not found.`); - return node as T; -} - -export function el( - tag: K, - className?: string, - text?: string, -): HTMLElementTagNameMap[K] -{ - const node = document.createElement(tag); - if (className !== undefined) node.className = className; - if (text !== undefined) node.textContent = text; - return node; -} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/api.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/api.ts new file mode 100644 index 0000000..9c225e9 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/api.ts @@ -0,0 +1,107 @@ +/** + * gRPC API client for DevTools UI. + * Provides HTTP-based API calls via DevTools middleware. + * Refactored following SOLID principles. + */ + +import type { GrpcApi, GrpcMethod, GrpcService, InvokeRequest, InvokeResult } from "../types"; +import { formatError } from "../format/format"; +import { BrowserHttpClient, type HttpClient } from "./httpClient"; +import { + normalizeService, + normalizeResult, + type WireInvocationResult +} from "./normalizer"; +import { mapService } from "./mapper"; +import { DefaultResponseBuilder, type ResponseBuilder } from "./responseBuilder"; +import { DefaultRequestBuilder, type RequestBuilder } from "./requestBuilder"; + +/** Public API. */ + +/** HTTP based gRPC API client implemented via DevTools middleware. */ +export class HttpGrpcApi implements GrpcApi { + private readonly httpClient: HttpClient; + private readonly responseBuilder: ResponseBuilder; + private readonly requestBuilder: RequestBuilder; + + constructor( + httpClient?: HttpClient, + responseBuilder?: ResponseBuilder, + requestBuilder?: RequestBuilder + ) { + this.httpClient = httpClient ?? new BrowserHttpClient(); + this.responseBuilder = responseBuilder ?? new DefaultResponseBuilder(); + this.requestBuilder = requestBuilder ?? new DefaultRequestBuilder(); + } + + async fetchServices(): Promise { + const raw = await this.httpClient.get("/api/services") as Record; + const target = this.pickString(raw, "Target", "target"); + const servicesRaw = this.pick(raw, "Services", "services"); + return Array.isArray(servicesRaw) + ? servicesRaw.map((s) => mapService(normalizeService(s), target ?? "unknown")) + : []; + } + + async invoke(request: InvokeRequest): Promise { + const started = performance.now(); + try { + const raw = await this.httpClient.post("/api/invoke", { + Service: request.service, + Method: request.method, + RequestJson: request.requestJson, + Headers: request.headers, + }) as Record; + + const result = normalizeResult(raw); + const durationMs = Math.round(result.ElapsedMs ?? performance.now() - started); + return result.Success + ? this.responseBuilder.buildSuccess(result, durationMs) + : this.responseBuilder.buildError(result, durationMs); + } catch (err: unknown) { + return this.buildErrorResult(err, started); + } + } + + private buildErrorResult(err: unknown, started: number): InvokeResult { + return { + status: "ERROR", + statusName: "Unavailable", + statusCode: 14, + durationMs: Math.round(performance.now() - started), + contentType: "application/grpc", + body: { error: formatError(err) }, + trailers: { + "grpc-status": "14", + "grpc-message": formatError(err), + "content-type": "application/grpc", + }, + metadata: {}, + }; + } + + private pick(value: Record, ...keys: string[]): T | undefined { + for (const key of keys) { + const found = value[key]; + if (found !== undefined) return found as T; + } + return undefined; + } + + private pickString(value: Record, ...keys: string[]): string | undefined { + const result = this.pick(value, ...keys); + return result !== undefined && result !== null ? String(result) : undefined; + } +} +/** Request builders. */ + +const defaultRequestBuilder = new DefaultRequestBuilder(); + +/** Default request JSON with recursion guard. */ +export function defaultRequestJson(method: GrpcMethod): string { + return defaultRequestBuilder.buildDefaultRequestJson(method); +} + +export function defaultResponseJson(method: GrpcMethod): Record { + return defaultRequestBuilder.buildDefaultResponseJson(method); +} diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/httpClient.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/httpClient.ts new file mode 100644 index 0000000..4cf0d2f --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/httpClient.ts @@ -0,0 +1,45 @@ +/** + * HTTP client for making API requests. + * Handles fetch operations and base path resolution. + */ + +export interface HttpClient { + get(url: string): Promise; + post(url: string, body: unknown): Promise; +} + +export class BrowserHttpClient implements HttpClient { + private readonly base: string; + + constructor(basePath: string = "/grpc-ui") { + this.base = this.resolveBasePath(basePath); + } + + async get(url: string): Promise { + const response = await fetch(`${this.base}${url}`); + if (!response.ok) { + throw new Error(`GET request failed: ${response.status} ${response.statusText}`); + } + return response.json(); + } + + async post(url: string, body: unknown): Promise { + const response = await fetch(`${this.base}${url}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`POST request failed: ${response.status} ${response.statusText}`); + } + return response.json(); + } + + private resolveBasePath(defaultPath: string): string { + if (typeof window === "undefined") return defaultPath; + const injected = (window as { __GRPC_UI_BASE__?: string }).__GRPC_UI_BASE__; + return injected !== undefined && !injected.includes("{{") && injected !== "" + ? injected + : defaultPath; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/invoke.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/invoke.ts new file mode 100644 index 0000000..c3bc10d --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/invoke.ts @@ -0,0 +1,35 @@ +import { parseHeaders } from "../format/format"; +import { defaultRequestJson } from "./api"; +import type { GrpcMethod, GrpcService, InvokeRequest } from "../types"; + +export interface SelectedMethod { + service: string; + methodName: string; +} + +export function buildInvokeRequest( + method: SelectedMethod, + requestJson: string, + requestHeaders: string, +): InvokeRequest { + return { + service: method.service, + method: method.methodName, + requestJson, + headers: parseHeaders(requestHeaders), + }; +} + +export interface MethodReset { + requestJson: string; +} + +export function initialMethodState( + service: GrpcService | null, + methodName: string | null, +): MethodReset | null { + if (service === null || methodName === null) return null; + const method = service.methods.find((m) => m.name === methodName); + if (method === undefined) return null; + return { requestJson: defaultRequestJson(method) }; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts new file mode 100644 index 0000000..3cd82ae --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts @@ -0,0 +1,67 @@ +/** + * Mapper for converting wire format to domain format. + * Transforms normalized wire data into application domain models. + */ + +import type { GrpcMethod, GrpcService, ProtoField } from "../types"; +import type { WireField, WireMessage, WireMethod, WireService } from "./normalizer"; + +function optionalString(value: unknown): string | undefined { + return value !== undefined && value !== null ? String(value) : undefined; +} + +export function mapService(wire: WireService, serviceName: string): GrpcService { + return { + name: wire.Name, + package: wire.Package, + description: optionalString(wire.Description), + isPlugin: wire.IsPlugin ?? false, + methods: wire.Methods.map((m) => mapMethod(m, wire)), + }; +} + +export function mapMethod(wire: WireMethod, service: WireService): GrpcMethod { + return { + name: wire.Name, + fullName: wire.FullName, + description: optionalString(wire.Description), + kind: resolveKind(wire.IsClientStreaming, wire.IsServerStreaming), + requestType: wire.Request?.Name ?? "Request", + responseType: wire.Response?.Name ?? "Response", + fullRequestPath: wire.Request?.FullName ?? `${service.Package}.${service.Name}.Request`, + fullResponsePath: wire.Response?.FullName ?? `${service.Package}.${service.Name}.Response`, + requestFields: wire.Request ? mapMessage(wire.Request).fields : undefined, + responseFields: wire.Response ? mapMessage(wire.Response).fields : undefined, + }; +} + +export function mapMessage(wire: WireMessage): { name: string; fullName: string; description?: string; fields: ProtoField[] } { + return { + name: wire.Name, + fullName: wire.FullName, + description: optionalString(wire.Description), + fields: wire.Fields.map(mapField), + }; +} + +export function mapField(wire: WireField): ProtoField { + return { + number: wire.Number, + name: wire.Name, + description: optionalString(wire.Description), + fieldType: wire.FieldType, + repeated: wire.IsRepeated, + map: wire.IsMap, + mapKeyType: wire.MapKeyType, + mapValueType: wire.MapValueType, + enumValues: wire.EnumValues, + message: wire.Message ? mapMessage(wire.Message) as any : undefined, + }; +} + +function resolveKind(isClientStreaming: boolean, isServerStreaming: boolean): "unary" | "server streaming" | "client streaming" | "bidi streaming" { + if (isClientStreaming && isServerStreaming) return "bidi streaming"; + if (isClientStreaming) return "client streaming"; + if (isServerStreaming) return "server streaming"; + return "unary"; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts new file mode 100644 index 0000000..2e2aa98 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts @@ -0,0 +1,142 @@ +/** + * Normalizer for wire format data. + * Converts various JSON field name formats to consistent wire format. + */ + +export interface WireService { + Name: string; + FullName: string; + Description?: string; + Package: string; + FileName: string; + IsPlugin?: boolean; + Methods: WireMethod[]; +} + +export interface WireMethod { + Name: string; + FullName: string; + Description?: string; + IsClientStreaming: boolean; + IsServerStreaming: boolean; + Request?: WireMessage; + Response?: WireMessage; +} + +export interface WireMessage { + Name: string; + FullName: string; + Description?: string; + Fields: WireField[]; +} + +export interface WireField { + Number: number; + Name: string; + Description?: string; + FieldType: string; + IsRepeated: boolean; + IsMap: boolean; + MapKeyType?: string; + MapValueType?: string; + EnumType?: string; + EnumValues?: string[]; + Message?: WireMessage; + MapValue?: WireMessage; +} + +export interface WireInvocationResult { + Success: boolean; + StatusName: string; + StatusCode: number; + Detail?: string; + ResponseJson?: string; + ResponseBase64?: string; + ElapsedMs: number; + Trailers?: Record; +} + +function pick(value: Record, ...keys: string[]): T | undefined { + for (const key of keys) { + const found = value[key]; + if (found !== undefined) return found as T; + } + return undefined; +} + +function optionalString(value: unknown): string | undefined { + return value !== undefined && value !== null ? String(value) : undefined; +} + +export function normalizeService(wire: unknown): WireService { + const record = (wire ?? {}) as Record; + return { + Name: String(pick(record, "Name", "name") ?? ""), + FullName: String(pick(record, "FullName", "fullName") ?? ""), + Description: optionalString(pick(record, "Description", "description")), + Package: String(pick(record, "Package", "package") ?? ""), + FileName: String(pick(record, "FileName", "fileName") ?? ""), + IsPlugin: Boolean(pick(record, "IsPlugin", "isPlugin")), + Methods: Array.isArray(pick(record, "Methods", "methods")) + ? ((pick(record, "Methods", "methods") as unknown[]).map(normalizeMethod)) + : [], + }; +} + +export function normalizeMethod(wire: unknown): WireMethod { + const record = (wire ?? {}) as Record; + return { + Name: String(pick(record, "Name", "name") ?? ""), + FullName: String(pick(record, "FullName", "fullName") ?? ""), + Description: optionalString(pick(record, "Description", "description")), + IsClientStreaming: Boolean(pick(record, "IsClientStreaming", "isClientStreaming")), + IsServerStreaming: Boolean(pick(record, "IsServerStreaming", "isServerStreaming")), + Request: normalizeMessage(pick(record, "Request", "request")), + Response: normalizeMessage(pick(record, "Response", "response")), + }; +} + +export function normalizeMessage(wire: unknown): WireMessage { + const record = (wire ?? {}) as Record; + return { + Name: String(pick(record, "Name", "name") ?? ""), + FullName: String(pick(record, "FullName", "fullName") ?? ""), + Description: optionalString(pick(record, "Description", "description")), + Fields: Array.isArray(pick(record, "Fields", "fields")) + ? (pick(record, "Fields", "fields") as unknown[]).map(normalizeField) + : [], + }; +} + +export function normalizeField(wire: unknown): WireField { + const record = (wire ?? {}) as Record; + return { + Number: Number(pick(record, "Number", "number") ?? 0), + Name: String(pick(record, "Name", "name") ?? ""), + Description: optionalString(pick(record, "Description", "description")), + FieldType: String(pick(record, "FieldType", "fieldType") ?? ""), + IsRepeated: Boolean(pick(record, "IsRepeated", "isRepeated")), + IsMap: Boolean(pick(record, "IsMap", "isMap")), + MapKeyType: optionalString(pick(record, "MapKeyType", "mapKeyType")), + MapValueType: optionalString(pick(record, "MapValueType", "mapValueType")), + EnumType: optionalString(pick(record, "EnumType", "enumType")), + EnumValues: Array.isArray(pick(record, "EnumValues", "enumValues")) + ? (pick(record, "EnumValues", "enumValues") as unknown[]).map(String) + : undefined, + Message: pick(record, "Message", "message") != null ? normalizeMessage(pick(record, "Message", "message")) : undefined, + MapValue: pick(record, "MapValue", "mapValue") != null ? normalizeMessage(pick(record, "MapValue", "mapValue")) : undefined, + }; +} + +export function normalizeResult(wire: Record): WireInvocationResult { + return { + Success: Boolean(pick(wire, "Success", "success")), + StatusName: String(pick(wire, "StatusName", "statusName") ?? ""), + StatusCode: Number(pick(wire, "StatusCode", "statusCode") ?? 0), + Detail: optionalString(pick(wire, "Detail", "detail")), + ResponseJson: optionalString(pick(wire, "ResponseJson", "responseJson")), + ResponseBase64: optionalString(pick(wire, "ResponseBase64", "responseBase64")), + ElapsedMs: Number(pick(wire, "ElapsedMs", "elapsedMs") ?? 0), + Trailers: pick(wire, "Trailers", "trailers") as Record | undefined, + }; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/requestBuilder.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/requestBuilder.ts new file mode 100644 index 0000000..f01e105 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/requestBuilder.ts @@ -0,0 +1,54 @@ +/** + * Request builder for creating default request JSON. + * Handles construction of default values for gRPC method requests. + */ + +import type { GrpcMethod, ProtoField } from "../types"; + +export interface RequestBuilder { + buildDefaultRequestJson(method: GrpcMethod): string; + buildDefaultResponseJson(method: GrpcMethod): Record; +} + +export class DefaultRequestBuilder implements RequestBuilder { + buildDefaultRequestJson(method: GrpcMethod): string { + if (method.requestFields !== undefined && method.requestFields.length > 0) { + return JSON.stringify(this.buildMessageValue(method.requestFields), null, 2); + } + return "{}"; + } + + buildDefaultResponseJson(method: GrpcMethod): Record { + const noun = method.responseType.split(".").pop() ?? "response"; + return { [this.camelCase(noun)]: "OK", message: `Hello from ${method.name}!` }; + } + + private buildMessageValue(fields: readonly ProtoField[], depth = 0): Record { + const message: Record = {}; + for (const field of fields) { + message[field.name] = this.buildFieldValue(field, depth); + } + return message; + } + + private buildFieldValue(field: ProtoField, depth = 0): unknown { + if (field.map) return {}; + if (field.repeated) return []; + if (field.message) { + if (depth >= 8) return null; + return this.buildMessageValue(field.message.fields, depth + 1); + } + if (field.enumValues && field.enumValues.length > 0) return field.enumValues[0]; + const type = field.fieldType.toLowerCase(); + if (type === "bool" || type === "boolean") return false; + if (type.includes("int") || type.includes("float") || type.includes("double") || + type.includes("uint") || type.includes("fixed") || type.includes("sint") || + type.includes("sfixed") || type.includes("long") || type.includes("decimal")) return 0; + if (type === "bytes" || type.includes("character")) return ""; + return ""; + } + + private camelCase(value: string): string { + return value.replace(/^[A-Z]/, (m) => m.toLowerCase()); + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/responseBuilder.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/responseBuilder.ts new file mode 100644 index 0000000..f166640 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/responseBuilder.ts @@ -0,0 +1,52 @@ +/** + * Response builder for creating InvokeResult objects. + * Handles construction of success and error responses from wire format. + */ + +import type { InvokeResult } from "../types"; +import type { WireInvocationResult } from "./normalizer"; + +export interface ResponseBuilder { + buildSuccess(wire: WireInvocationResult, durationMs: number): InvokeResult; + buildError(wire: WireInvocationResult, durationMs: number): InvokeResult; +} + +export class DefaultResponseBuilder implements ResponseBuilder { + buildSuccess(wire: WireInvocationResult, durationMs: number): InvokeResult { + return { + status: "OK", + statusName: wire.StatusName, + statusCode: wire.StatusCode, + durationMs, + contentType: "application/grpc", + body: wire.ResponseJson ? JSON.parse(wire.ResponseJson) : null, + rawBase64: wire.ResponseBase64, + trailers: { + "grpc-status": String(wire.StatusCode), + "grpc-message": "", + "content-type": "application/grpc", + ...(wire.Trailers ?? {}), + }, + metadata: wire.Trailers ?? {}, + }; + } + + buildError(wire: WireInvocationResult, durationMs: number): InvokeResult { + return { + status: "ERROR", + statusName: wire.StatusName, + statusCode: wire.StatusCode, + durationMs, + contentType: "application/grpc", + body: { error: wire.Detail ?? wire.StatusName ?? "Unknown gRPC error." }, + rawBase64: wire.ResponseBase64, + trailers: { + "grpc-status": String(wire.StatusCode), + "grpc-message": wire.Detail ?? wire.StatusName, + "content-type": "application/grpc", + ...(wire.Trailers ?? {}), + }, + metadata: wire.Trailers ?? {}, + }; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/clipboard.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/clipboard.ts new file mode 100644 index 0000000..1d388a0 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/clipboard.ts @@ -0,0 +1,7 @@ +/** + * Copy text to the system clipboard. Throws on failure so callers can decide + * how to surface (or ignore) the error. + */ +export async function copyText(text: string): Promise { + await navigator.clipboard.writeText(text); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/useClipboard.svelte.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/useClipboard.svelte.ts new file mode 100644 index 0000000..77967ae --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/useClipboard.svelte.ts @@ -0,0 +1,29 @@ +import { copyText } from "./clipboard"; + +/** + * Shared clipboard state for components that show a transient "Copied" badge. + * Returns the currently copied section id and a `copy` helper that reports + * success by setting that id for 1.5 seconds. + */ +export function useClipboard() { + let copiedSection = $state(null); + + async function copy(text: string, section: string): Promise { + try { + await copyText(text); + copiedSection = section; + setTimeout(() => { + if (copiedSection === section) copiedSection = null; + }, 1500); + } catch { + // ignore clipboard errors selection still works manually + } + } + + return { + get copiedSection() { + return copiedSection; + }, + copy, + }; +} diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/byteCalculator.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/byteCalculator.ts new file mode 100644 index 0000000..1837aa9 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/byteCalculator.ts @@ -0,0 +1,21 @@ +/** + * Byte calculator for text size calculations. + * Handles byte size calculation and readable formatting. + */ + +export interface ByteCalculator { + calculateByteSize(text: string): number; + formatFriendlyBytes(bytes: number): string; +} + +export class TextByteCalculator implements ByteCalculator { + calculateByteSize(text: string): number { + return new TextEncoder().encode(text).length; + } + + formatFriendlyBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + } +} diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/format.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/format.ts new file mode 100644 index 0000000..065c22c --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/format.ts @@ -0,0 +1,61 @@ +import type { GrpcService } from "../types"; +import { GrpcHeaderParser } from "./headerParser"; +import { DefaultJsonFormatter } from "./jsonFormatter"; +import { TextByteCalculator } from "./byteCalculator"; +import { DefaultTextUtils } from "./textUtils"; +import { DefaultProtoGenerator } from "./protoGenerator"; + +// Create default instances for backward compatibility +const headerParser = new GrpcHeaderParser(); +const jsonFormatter = new DefaultJsonFormatter(); +const byteCalculator = new TextByteCalculator(); +const textUtils = new DefaultTextUtils(); +const protoGenerator = new DefaultProtoGenerator(); + +// Backward compatible function exports +export function parseHeaders(text: string): Record { + return headerParser.parse(text); +} + +export function prettyPrint(text: string): string { + return jsonFormatter.prettyPrint(text); +} + +export function formatError(error: unknown): string { + return jsonFormatter.formatError(error); +} + +export function byteSize(text: string): number { + return byteCalculator.calculateByteSize(text); +} + +export function lineNumbers(text: string): string { + return textUtils.generateLineNumbers(text); +} + +/** + * Scales the code block height to the request content (line count) so the + * Request JSON block grows and shrinks with the request instead of keeping a + * fixed height and scrolling internally. Clamped to readable range. + */ +export function editorHeight( + text: string, + options: { min?: number; max?: number } = {}, +): string { + return textUtils.calculateEditorHeight(text, options); +} + +export function friendlyBytes(bytes: number): string { + return byteCalculator.formatFriendlyBytes(bytes); +} + +export function protoPreview(service: GrpcService): string { + return protoGenerator.generatePreview(service); +} + +// Export classes for dependency injection +export { GrpcHeaderParser, type HeaderParser } from "./headerParser"; +export { DefaultJsonFormatter, type JsonFormatter } from "./jsonFormatter"; +export { TextByteCalculator, type ByteCalculator } from "./byteCalculator"; +export { DefaultTextUtils, type TextUtils } from "./textUtils"; +export { DefaultProtoGenerator, type ProtoGenerator } from "./protoGenerator"; diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/headerParser.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/headerParser.ts new file mode 100644 index 0000000..b3eb687 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/headerParser.ts @@ -0,0 +1,19 @@ +/** + * Header parser for gRPC metadata. + * Handles parsing of key-value header strings. + */ + +export interface HeaderParser { + parse(text: string): Record; +} + +export class GrpcHeaderParser implements HeaderParser { + parse(text: string): Record { + const headers: Record = {}; + for (const line of text.split(/\r?\n/)) { + const match = /^\s*([^:]+):\s*(.*)\s*$/.exec(line); + if (match !== null) headers[match[1].trim()] = match[2].trim(); + } + return headers; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonFormatter.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonFormatter.ts new file mode 100644 index 0000000..a0cad29 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonFormatter.ts @@ -0,0 +1,23 @@ +/** + * JSON formatter and error formatter. + * Handles JSON pretty-printing and error message formatting. + */ + +export interface JsonFormatter { + prettyPrint(text: string): string; + formatError(error: unknown): string; +} + +export class DefaultJsonFormatter implements JsonFormatter { + prettyPrint(text: string): string { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + + formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonHighlight.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonHighlight.ts new file mode 100644 index 0000000..0ee757f --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonHighlight.ts @@ -0,0 +1,116 @@ +const TOKEN_RE = + /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false)\b|\bnull\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; + +export function highlightJson(input: string): string { + return input.replace( + TOKEN_RE, + (match, stringValue: string | undefined, colon: string | undefined, ...rest: unknown[]) => { + if (stringValue !== undefined) { + const isKey = colon !== undefined; + if (isKey) { + return `${escapeHtml(stringValue)}${colon ?? ""}`; + } + const tokenClass = /^<[^>]*>$/.test(stripQuotes(stringValue)) + ? "tok-ph" + : "tok-string"; + return `${escapeHtml(stringValue)}${colon ?? ""}`; + } + const literal = rest[0] as string | undefined; + if (literal === "true" || literal === "false") { + return `${literal}`; + } + if (rest[1] !== undefined) return `null`; + return `${match}`; + }, + ); +} + +const PROTO_TOKEN_RE = + /("(?:\\.|[^"\\])*")|(\/\/.*$)|\b(syntax|package|message|service|rpc|returns|import|option|repeated|enum|map)\b|\b(string|int32|int64|uint32|uint64|sint32|sint64|fixed32|fixed64|sfixed32|sfixed64|bool|bytes|float|double)\b/gm; + +export function highlightProto(input: string): string { + const escaped = escapeHtml(input); + return escaped.replace( + PROTO_TOKEN_RE, + (match, strVal, commentVal, keywordVal, typeVal) => { + if (strVal !== undefined) { + return `${strVal}`; + } + if (commentVal !== undefined) { + return `${commentVal}`; + } + if (keywordVal !== undefined) { + return `${keywordVal}`; + } + if (typeVal !== undefined) { + return `${typeVal}`; + } + return match; + }, + ); +} + +function stripQuotes(value: string): string { + return value.length >= 2 ? value.slice(1, -1) : value; +} + +export function toHighlightedJson(value: unknown): string { + return highlightJson(typeof value === "string" ? value : JSON.stringify(value, null, 2)); +} + +export function escapeHtml(input: string): string { + return input.replace(/&/g, "&").replace(//g, ">"); +} + +export interface CodeLine { + number: number; + html: string; + raw: string; +} + +export function formatJsonLines(value: unknown): CodeLine[] { + const json = typeof value === "string" ? value : JSON.stringify(value, null, 2); + const highlighted = highlightJson(json); + const lines = highlighted.split("\n"); + const rawLines = json.split("\n"); + return lines.map((html, i) => ({ + number: i + 1, + html, + raw: rawLines[i] ?? "", + })); +} + +export function formatRawLines(value: string): CodeLine[] { + const lines = value.split("\n"); + return lines.map((line, i) => ({ + number: i + 1, + html: escapeHtml(line), + raw: line, + })); +} + +export function formatProtoStruct(val: unknown, depth = 0): string { + if (val === null || val === undefined) return 'null'; + if (typeof val === "string") return `"${escapeHtml(val)}"`; + if (typeof val === "number") return `${val}`; + if (typeof val === "boolean") return `${val}`; + if (Array.isArray(val)) { + if (val.length === 0) return "[]"; + const indent = " ".repeat(depth + 1); + const closeIndent = " ".repeat(depth); + const items = val.map((v) => `${indent}${formatProtoStruct(v, depth + 1)}`).join(",\n"); + return `[\n${items}\n${closeIndent}]`; + } + if (typeof val === "object") { + const keys = Object.keys(val as object); + if (keys.length === 0) return "{\n}"; + const indent = " ".repeat(depth + 1); + const closeIndent = " ".repeat(depth); + const lines = keys.map((k) => { + const v = (val as Record)[k]; + return `${indent}${escapeHtml(k)}: ${formatProtoStruct(v, depth + 1)}`; + }).join(",\n"); + return `{\n${lines}\n${closeIndent}}`; + } + return escapeHtml(String(val)); +} diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts new file mode 100644 index 0000000..2eea9da --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts @@ -0,0 +1,148 @@ +/** + * Proto generator for creating .proto file content. + * Handles generation of proto definitions from gRPC service metadata. + */ + +import type { GrpcService, ProtoField, ProtoMessage } from "../types"; + +export interface ProtoGenerator { + generatePreview(service: GrpcService): string; +} + +export class DefaultProtoGenerator implements ProtoGenerator { + private readonly SCALAR_MAP: Record = { + String: "string", + Int32: "int32", + Int64: "int64", + UInt32: "uint32", + UInt64: "uint64", + SInt32: "sint32", + SInt64: "sint64", + Fixed32: "fixed32", + Fixed64: "fixed64", + SFixed32: "sfixed32", + SFixed64: "sfixed64", + Bool: "bool", + Float: "float", + Double: "double", + Bytes: "bytes", + }; + + private readonly WELL_KNOWN_IMPORTS: Record = { + "google.protobuf.Empty": "google/protobuf/empty.proto", + "google.protobuf.Timestamp": "google/protobuf/timestamp.proto", + "google.protobuf.Duration": "google/protobuf/duration.proto", + "google.protobuf.Any": "google/protobuf/any.proto", + "google.protobuf.Value": "google/protobuf/struct.proto", + "google.protobuf.Struct": "google/protobuf/struct.proto", + }; + + generatePreview(service: GrpcService): string { + const imports = new Set(); + const messages: ProtoMessage[] = []; + const seen = new Set(); + + const visitMessage = (message: ProtoMessage | undefined, depth: number): void => { + if (message === undefined) return; + const fullName = message.fullName; + if (this.WELL_KNOWN_IMPORTS[fullName] !== undefined) { + imports.add(this.WELL_KNOWN_IMPORTS[fullName]); + return; + } + if (fullName === "" || seen.has(fullName)) return; + seen.add(fullName); + messages.push(message); + for (const field of message.fields) visitMessage(field.message, depth + 1); + }; + + for (const method of service.methods) { + if (method.requestFields !== undefined) { + visitMessage( + { name: method.requestType, fullName: method.fullRequestPath, fields: method.requestFields }, + 0 + ); + } + if (method.responseFields !== undefined) { + visitMessage( + { name: method.responseType, fullName: method.fullResponsePath, fields: method.responseFields }, + 0 + ); + } + } + + const lines: string[] = []; + lines.push('syntax = "proto3";'); + lines.push(`option csharp_namespace = "Host.Grpc";`); + lines.push(`package ${service.package};`); + if (imports.size > 0) { + lines.push(""); + for (const importPath of [...imports].sort()) { + lines.push(`import "${importPath}";`); + } + } + lines.push(""); + if (service.description !== undefined && service.description !== "") { + lines.push(`// ${service.description}`); + } + lines.push(`service ${service.name}\n{`); + for (const method of service.methods) { + const requestType = this.protoTypeName(method.fullRequestPath); + const responseType = this.protoTypeName(method.fullResponsePath); + if (method.description !== undefined && method.description !== "") { + lines.push(` // ${method.description}`); + } + lines.push(` rpc ${method.name} (${requestType}) returns (${responseType});`); + } + lines.push("}"); + if (messages.length > 0) { + lines.push(""); + for (const message of messages) { + lines.push(""); + lines.push(this.renderMessage(message)); + } + } + return lines.join("\n"); + } + + private protoTypeName(fullName: string): string { + return this.WELL_KNOWN_IMPORTS[fullName] !== undefined + ? fullName + : fullName.split(".").pop() ?? fullName; + } + + private fieldProtoType(field: ProtoField): string { + if (field.message !== undefined) return this.protoTypeName(field.message.fullName); + if (field.fieldType.toLowerCase() === "enum") return field.fieldType; + return this.SCALAR_MAP[field.fieldType] ?? field.fieldType.toLowerCase(); + } + + private renderField(field: ProtoField, number: number): string { + let type = this.fieldProtoType(field); + if (field.map) { + const keyType = field.mapKeyType ?? "string"; + const valueType = field.mapValueType ?? this.fieldProtoType(field); + type = `map<${keyType}, ${valueType}>`; + } else if (field.repeated) { + type = `repeated ${type}`; + } + const lines: string[] = []; + if (field.description !== undefined && field.description !== "") { + lines.push(` // ${field.description}`); + } + lines.push(` ${type} ${field.name} = ${number};`); + return lines.join("\n"); + } + + private renderMessage(message: ProtoMessage): string { + const lines: string[] = []; + if (message.description !== undefined && message.description !== "") { + lines.push(`// ${message.description}`); + } + lines.push(`message ${message.name} {`); + message.fields.forEach((field) => { + lines.push(this.renderField(field, field.number)); + }); + lines.push("}"); + return lines.join("\n"); + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/raw.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/raw.ts new file mode 100644 index 0000000..ece3a94 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/raw.ts @@ -0,0 +1,45 @@ +import { formatRawLines, type CodeLine } from "./jsonHighlight"; + +export type RawFormat = "base64" | "hex" | "raw"; + +/** + * Render the raw wire body (base64 from the server, or synthesized from the + * decoded JSON) as plain text in the requested format. + */ +export function formatRaw( + body: unknown, + rawBase64: string | undefined, + format: RawFormat, +): string { + if (format === "base64") return rawBase64 ?? bodyToBase64(body); + if (format === "hex") return toHex(rawBase64 ?? bodyToBase64(body)); + return JSON.stringify(body); +} + +export function rawCodeLines( + body: unknown, + rawBase64: string | undefined, + format: RawFormat, +): CodeLine[] { + return formatRawLines(formatRaw(body, rawBase64, format)); +} + +function bodyToBase64(body: unknown): string { + return btoa(JSON.stringify(body)); +} + +function toHex(base64: string): string { + try { + const bin = atob(base64); + const hex = Array.from(bin) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(" "); + const chunks: string[] = []; + for (let i = 0; i < hex.length; i += 48) { + chunks.push(hex.slice(i, i + 48).trim()); + } + return chunks.join("\n"); + } catch { + return "00 00 00"; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/textUtils.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/textUtils.ts new file mode 100644 index 0000000..d17e053 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/textUtils.ts @@ -0,0 +1,27 @@ +/** + * Text utilities for editor display. + * Handles line number generation and editor height calculation. + */ + +export interface TextUtils { + generateLineNumbers(text: string): string; + calculateEditorHeight(text: string, options?: { min?: number; max?: number }): string; +} + +export class DefaultTextUtils implements TextUtils { + generateLineNumbers(text: string): string { + return text + .split("\n") + .map((_, index) => index + 1) + .join("\n"); + } + + calculateEditorHeight( + text: string, + options: { min?: number; max?: number } = {}, + ): string { + const { min = 4, max = 24 } = options; + const rows = Math.max(min, Math.min(max, text.split("\n").length)); + return `${rows * 20 + 24}px`; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/metadata/metadata.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/metadata/metadata.ts new file mode 100644 index 0000000..c5fb82e --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/metadata/metadata.ts @@ -0,0 +1,67 @@ +import { parseHeaders } from "../format/format"; +import type { MetadataEntry, MetadataFallbacks } from "./types"; + +/** + * gRPC standard keys surfaced first so the row order is stable regardless of + * the wire shape. + */ +const PREFERRED_ORDER = [ + "content-type", + "grpc-encoding", + "grpc-status", + "grpc-message", +] as const; + +/** + * Merge response `metadata` and `trailers` into a single ordered list. gRPC + * standard keys are surfaced first (with safe fallbacks); no values are + * hardcoded except the fallbacks passed by the caller. + */ +export function parseMetadata( + metadata: Record | undefined, + trailers: Record | undefined, + fallbacks: MetadataFallbacks = {}, +): MetadataEntry[] { + const received: Record = { + ...(metadata ?? {}), + ...(trailers ?? {}), + }; + + if (received["grpc-status"] === undefined) { + received["grpc-status"] = String(fallbacks.statusCode ?? (fallbacks.status === "OK" ? 0 : 1)); + } + if (received["grpc-message"] === undefined) { + received["grpc-message"] = fallbacks.statusName ?? ""; + } + if (received["content-type"] === undefined) { + received["content-type"] = fallbacks.contentType ?? "application/grpc"; + } + + const ordered: MetadataEntry[] = []; + const seen = new Set(); + for (const key of PREFERRED_ORDER) { + if (received[key] !== undefined) { + ordered.push([key, received[key]]); + seen.add(key); + } + } + for (const [key, value] of Object.entries(received)) { + if (!seen.has(key)) { + ordered.push([key, value]); + seen.add(key); + } + } + return ordered; +} + +/** + * Tap the typed request headers (in `Key: Value` form, one per line) into a + * metadata entry list. + */ +export function parseRequestMetadata(value: string): MetadataEntry[] { + return Object.entries(parseHeaders(value)); +} + +export function formatMetadata(entries: MetadataEntry[]): string { + return entries.map(([key, value]) => `${key}: ${value}`).join("\n"); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/metadata/types.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/metadata/types.ts new file mode 100644 index 0000000..b4ff16f --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/metadata/types.ts @@ -0,0 +1,12 @@ +export type MetadataEntry = [string, string]; + +/** + * Fallback values used to guarantee the gRPC status columns always render, + * even when the invocation result omits them (e.g. transport-level errors). + */ +export interface MetadataFallbacks { + status?: string; + statusName?: string; + statusCode?: number; + contentType?: string; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/navigation/navigation.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/navigation/navigation.ts new file mode 100644 index 0000000..c6131ee --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/navigation/navigation.ts @@ -0,0 +1,44 @@ +import type { GrpcService } from "../types"; + +export function serviceId(service: GrpcService): string { + return service.package + "." + service.name; +} + +export function matchesSearch(service: GrpcService, search: string): boolean { + const query = search.trim(); + if (query === "") return true; + return serviceId(service).toLowerCase().includes(query.toLowerCase()); +} + +export function filterServices( + services: readonly GrpcService[], + search: string, +): GrpcService[] { + return services.filter((service) => matchesSearch(service, search)); +} + +export function hostServices(services: readonly GrpcService[]): GrpcService[] { + return services.filter((service) => !service.isPlugin); +} + +export function pluginServices( + services: readonly GrpcService[], +): GrpcService[] { + return services.filter((service) => !!service.isPlugin); +} + +/** Expand every service (used when search query is active). */ +export function expandAllServices( + services: readonly GrpcService[], +): Record { + const expanded: Record = {}; + for (const service of services) { + expanded[serviceId(service)] = true; + } + return expanded; +} + +/** True for the global "focus the service search box" shortcut (⌘K / Ctrl+K). */ +export function isSearchShortcut(event: KeyboardEvent): boolean { + return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts new file mode 100644 index 0000000..4a0ef21 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts @@ -0,0 +1,83 @@ +import type { GrpcMethod, GrpcService, ProtoField } from "../types"; +import { highlightProto, type CodeLine } from "../format/jsonHighlight"; + +const TYPE_MAP: Record = { + string: "string", + int32: "int32", + int64: "int64", + uint32: "uint32", + uint64: "uint64", + sint32: "sint32", + sint64: "sint64", + fixed32: "fixed32", + fixed64: "fixed64", + sfixed32: "sfixed32", + sfixed64: "sfixed64", + bool: "bool", + boolean: "bool", + float: "float", + double: "double", + bytes: "bytes", +}; + +export function formatFieldType(field: ProtoField): string { + if (field.message) return field.message.name || "Message"; + const low = field.fieldType.toLowerCase(); + return ( + TYPE_MAP[low] ?? + (field.fieldType.startsWith("google.protobuf.") + ? field.fieldType.split(".").pop() ?? field.fieldType + : field.fieldType.toLowerCase()) + ); +} + +function renderFieldLines(fields?: ProtoField[]): string { + if (!fields || fields.length === 0) return ""; + return fields + .map((f) => { + let typeName = formatFieldType(f); + if (f.repeated) typeName = `repeated ${typeName}`; + if (f.map) { + typeName = `map<${f.mapKeyType ?? "string"}, ${f.mapValueType ?? typeName}>`; + } + return ` ${typeName} ${f.name} = ${f.number};`; + }) + .join("\n"); +} + +export function requestProtoCode(service: GrpcService, method: GrpcMethod): string { + const pkg = service.package || "authkit.example"; + const svcName = service.name || "ExampleHello"; + const reqType = method.requestType.split(".").pop() || "Request"; + const resType = method.responseType.split(".").pop() || "Response"; + const fields = renderFieldLines(method.requestFields); + const body = fields ? `\n${fields}\n` : "\n"; + return `syntax = "proto3";\n\npackage ${pkg};\n\nmessage ${reqType} {${body}}\n\nservice ${svcName} {\n rpc ${method.name}(${reqType}) returns (${resType});\n}`; +} + +export function responseProtoCode(service: GrpcService, method: GrpcMethod): string { + const pkg = service.package || "authkit.example"; + const resType = method.responseType.split(".").pop() || "Response"; + const fields = renderFieldLines(method.responseFields); + const body = fields ? `\n${fields}\n` : "\n"; + return `syntax = "proto3";\n\npackage ${pkg};\n\nmessage ${resType} {${body}}`; +} + +export function parseJsonSafe(text: string): unknown { + try { + if (text && text.trim() !== "") { + return JSON.parse(text); + } + } catch { + // fall back to {} + } + return {}; +} + +export function protoCodeLines(proto: string): CodeLine[] { + return proto.split("\n").map((line, index) => ({ + number: index + 1, + html: highlightProto(line), + raw: line, + })); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/stores.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/stores.ts new file mode 100644 index 0000000..a84cf07 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/stores.ts @@ -0,0 +1,76 @@ +import { derived, writable } from "svelte/store"; +import { HttpGrpcApi } from "./api/api"; +import type { GrpcApi, GrpcService, InvokeResult } from "./types"; + +export const connectionStatus = writable<"connecting" | "connected" | "error">( + "connecting", +); + +const isDev = import.meta.env.DEV === true; + +export const api: GrpcApi = isDev ? new HttpGrpcApi() : new HttpGrpcApi(); + +export const services = writable([]); +export const selectedServiceName = writable(null); +export const selectedMethodName = writable(null); +export const requestJson = writable(""); +export const response = writable(null); +export const connectedTarget = writable("http://localhost:5000"); +export const activeTab = writable<"request" | "response" | "metadata" | "proto">( + "request", +); + +/** + * The raw gRPC metadata (request headers) typed by the user, in `Key: Value` + * form (one per line). Persisted to `sessionStorage` under + * `grpc-ui-headers` so it survives method switches within the same session. + */ +const REQUEST_HEADERS_STORAGE_KEY = "grpc-ui-headers"; +const DEFAULT_REQUEST_HEADERS = "authorization: Bearer "; + +function loadRequestHeaders(): string { + if (typeof window === "undefined") return DEFAULT_REQUEST_HEADERS; + return sessionStorage.getItem(REQUEST_HEADERS_STORAGE_KEY) ?? DEFAULT_REQUEST_HEADERS; +} + +export const requestHeaders = writable(loadRequestHeaders()); +requestHeaders.subscribe((value) => { + if (typeof window !== "undefined") { + sessionStorage.setItem(REQUEST_HEADERS_STORAGE_KEY, value); + } +}); + +export const selectedService = derived( + [services, selectedServiceName], + ([all, name]) => all.find((service) => service.package + "." + service.name === name) ?? null, +); + +export type SelectedMethod = { + service: string; + methodName: string; +} | null; + +export const selectedMethod = derived( + [selectedService, selectedMethodName], + ([service, methodName]) => + service === null || methodName === null + ? null + : { service: service.package + "." + service.name, methodName }, +); + +export const theme = writable<"dark" | "light">("dark"); + +// Persist theme preference to localStorage +theme.subscribe((value) => { + if (typeof window !== "undefined") { + localStorage.setItem("grpc-ui-theme", value); + } +}); + +// Load theme from localStorage on init +if (typeof window !== "undefined") { + const saved = localStorage.getItem("grpc-ui-theme"); + if (saved === "light" || saved === "dark") { + theme.set(saved); + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts new file mode 100644 index 0000000..42bb8cd --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts @@ -0,0 +1,76 @@ +export type JSONValue = + | null + | boolean + | number + | string + | JSONValue[] + | { [key: string]: JSONValue }; + +export interface ProtoField { + number: number; + name: string; + description?: string; + fieldType: string; + repeated: boolean; + map: boolean; + mapKeyType?: string; + mapValueType?: string; + enumValues?: string[]; + message?: ProtoMessage; +} + +export interface ProtoMessage { + name: string; + fullName: string; + description?: string; + fields: ProtoField[]; +} + +export interface GrpcMethod { + name: string; + fullName?: string; + kind?: RpcKind; + requestType: string; + responseType: string; + fullRequestPath: string; + fullResponsePath: string; + description?: string; + requestFields?: ProtoField[]; + responseFields?: ProtoField[]; +} + +export interface GrpcService { + name: string; + package: string; + description?: string; + isPlugin?: boolean; + methods: GrpcMethod[]; +} + +export type RpcKind = "unary" | "server streaming" | "client streaming" | "bidi streaming"; + +export type InvokeStatus = "OK" | "ERROR"; + +export interface InvokeResult { + status: InvokeStatus; + statusName?: string; + statusCode?: number; + durationMs: number; + contentType: string; + body: unknown; + rawBase64?: string; + trailers?: Record; + metadata: Record; +} + +export interface InvokeRequest { + service: string; + method: string; + requestJson: string; + headers: Record; +} + +export interface GrpcApi { + fetchServices(): Promise; + invoke(request: InvokeRequest): Promise; +} diff --git a/src/Plugins/Solutions/DevTools/UI/src/main.ts b/src/Plugins/Solutions/DevTools/UI/src/main.ts new file mode 100644 index 0000000..d108bbe --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/main.ts @@ -0,0 +1,8 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./app.css"; + +const target = document.getElementById("app"); +if (target === null) throw new Error("Element #app not found."); + +mount(App, { target }); \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/render.ts b/src/Plugins/Solutions/DevTools/UI/src/render.ts deleted file mode 100644 index 7c8af7d..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/render.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describeStreaming, fieldTypeText, jsonFromSchema, prettyPrint, streamingKind } from "./schema"; -import { el } from "./dom"; -import type { SelectedIndex } from "./store"; -import type { - GrpcFieldSchema, - GrpcMessageSchema, - GrpcMethodInfo, - GrpcServiceInfo, -} from "./types"; - -export interface MethodDetail -{ - readonly request: HTMLTextAreaElement; - readonly headers: HTMLTextAreaElement; - setStatus(state: "idle" | "ok" | "err", text: string): void; - writeResponse(text: string): void; -} - -export function renderServiceList( - container: HTMLElement, - services: readonly GrpcServiceInfo[], - selected: SelectedIndex | null, - onSelect: (serviceIndex: number, methodIndex: number) => void, -): void -{ - container.replaceChildren(); - - if (services.length === 0) - { - container.append(emptyMessage("No gRPC services found.")); - return; - } - - for (const [serviceIndex, svc] of services.entries()) - { - const details = el("details", "service"); - if (serviceIndex === 0) details.open = true; - - const summary = el("summary"); - summary.append(svc.package || svc.fileName); - const count = el("span", "muted", `(${svc.methods?.length ?? 0})`); - count.style.cssText = "font-weight:400;font-size:12px;"; - summary.append(count); - details.append(summary); - - const list = el("ul", "methods"); - for (const [methodIndex, method] of (svc.methods ?? []).entries()) - { - const isSelected = selected?.[0] === serviceIndex && selected?.[1] === methodIndex; - const item = el("li", isSelected ? "active" : undefined); - item.dataset.service = String(serviceIndex); - item.dataset.method = String(methodIndex); - - const kind = streamingKind(method); - const pill = el("span", `pill ${kind}`, method.name); - pill.title = `${kind} streaming`; - item.append(pill); - item.addEventListener("click", () => onSelect(serviceIndex, methodIndex)); - - list.append(item); - } - details.append(list); - container.append(details); - } -} - -export function highlightSelectedMethod(selected: SelectedIndex | null): void -{ - for (const item of document.querySelectorAll(".methods li")) { - const isActive = selected !== null - && item.dataset.service === String(selected[0]) - && item.dataset.method === String(selected[1]); - item.classList.toggle("active", isActive); - } -} - -export function renderMethodDetail( - container: HTMLElement, - method: GrpcMethodInfo, - initialHeaders: string, - onInvoke: (detail: MethodDetail) => void, -): void -{ - container.replaceChildren(); - const streaming = method.isClientStreaming || method.isServerStreaming; - - const methodPill = el("span", `pill ${method.isClientStreaming ? "cli" : "uni"}`, describeStreaming(method)); - container.append(card(heading(method.fullName, [methodPill]), [renderSchema("Request", method.request)])); - container.append(card(heading("Response"), [renderSchema("Message", method.response)])); - - const headers = el("textarea"); - headers.placeholder = "Authorization: Bearer \nX-Custom: value"; - headers.value = initialHeaders; - container.append(card(heading("Metadata headers"), [headers])); - - const request = el("textarea"); - request.spellcheck = false; - request.value = prettyPrint(jsonFromSchema(method.request)); - - const defaultsButton = el("button", "secondary", "Fill defaults"); - const formatButton = el("button", "secondary", "Format JSON"); - const invokeButton = el("button", undefined, "Invoke"); - const status = el("span", "status"); - status.hidden = true; - const response = el("pre"); - response.hidden = true; - - const group = el("div", "row"); - group.append(defaultsButton, formatButton, invokeButton, status); - container.append(card(heading("Request JSON"), [request, group, response])); - - if (streaming) - { - invokeButton.disabled = true; - invokeButton.title = "Streaming is not supported"; - } - - defaultsButton.addEventListener("click", () => { - request.value = prettyPrint(jsonFromSchema(method.request)); - }); - formatButton.addEventListener("click", () => { - request.value = prettyPrint(request.value); - }); - - const detail: MethodDetail = { - request, - headers, - setStatus(state, text) { - status.classList.remove("ok", "err"); - if (state === "ok" || state === "err") status.classList.add(state); - status.hidden = false; - status.textContent = text; - }, - writeResponse(text) { - response.hidden = false; - response.textContent = text; - }, - }; - - invokeButton.addEventListener("click", () => onInvoke(detail)); -} - -function emptyMessage(text: string): HTMLDivElement -{ - const message = el("div", "empty", text); - message.style.padding = "14px"; - return message; -} - -function heading(text: string, additions: Node[] = []): HTMLHeadingElement -{ - const h = el("h2"); - h.append(text, ...additions); - return h; -} - -function card(h: HTMLHeadingElement, bodyNodes: (Node | string)[]): HTMLDivElement -{ - const card = el("div", "card"); - const body = el("div", "body"); - body.append(...bodyNodes); - card.append(h, body); - return card; -} - -function renderField(field: GrpcFieldSchema, depth: number, into: HTMLElement): void -{ - const row = el("div", "row-line"); - const indent = el("span", "indent", "\u00A0".repeat(depth * 4)); - row.append(indent, `${field.name}: `, el("span", "type", fieldTypeText(field))); - - if (field.isRepeated && !field.isMap) - { - row.append(" ", el("span", "rep", "[repeated]")); - } - if (field.enumType) - { - row.append(" ", el("span", "enum", `${field.enumType} (${(field.enumValues ?? []).join("|")})`)); - } - into.append(row); - - if (field.message && depth < 3) { - for (const nested of field.message.fields ?? []) renderField(nested, depth + 1, into); - } -} - -function renderSchema(title: string, msg: GrpcMessageSchema | null | undefined): HTMLDivElement -{ - const wrap = el("div"); - if (!msg) - { - wrap.append(el("div", "empty", "—")); - return wrap; - } - - const fields = msg.fields ?? []; - const schema = el("div", "schema"); - if (fields.length === 0) { - schema.append(el("div", "empty", "(empty)")); - } - else - { - for (const field of fields) renderField(field, 0, schema); - } - - wrap.append(el("h3", undefined, `${title} — ${msg.fullName}`), schema); - return wrap; -} diff --git a/src/Plugins/Solutions/DevTools/UI/src/schema.ts b/src/Plugins/Solutions/DevTools/UI/src/schema.ts deleted file mode 100644 index 74cebca..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/schema.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { - GrpcFieldSchema, - GrpcMessageSchema, - GrpcMethodInfo, - InvocationResult, -} from "./types"; - -export type StreamingKind = "uni" | "cli" | "srv" | "bidi"; - -export function streamingKind(method: GrpcMethodInfo): StreamingKind -{ - if (method.isClientStreaming && method.isServerStreaming) return "bidi"; - if (method.isClientStreaming) return "cli"; - if (method.isServerStreaming) return "srv"; - return "uni"; -} - -export function describeStreaming(method: GrpcMethodInfo): string -{ - if (method.isClientStreaming && method.isServerStreaming) return "Bidirectional stream"; - if (method.isClientStreaming) return "Client streaming"; - if (method.isServerStreaming) return "Server streaming"; - return "Unary"; -} - -export function fieldTypeText(field: GrpcFieldSchema): string -{ - if (field.isMap) - { - const value = field.mapValue?.fullName ?? field.mapValueType ?? "?"; - return `map<${field.mapKeyType ?? "?"}, ${value}>`; - } - if (field.message && field.message.fullName !== field.message.name) return field.message.fullName; - return field.fieldType; -} - -export function jsonFromSchema(msg: GrpcMessageSchema, depth = 0): Record -{ - const out: Record = {}; - const seen = depth > 4; - for (const field of msg.fields ?? []) - { - if (seen) { out[field.name] = null; continue; } - if (field.isMap) { out[field.name] = {}; continue; } - if (field.isRepeated) { out[field.name] = []; continue; } - if (field.message) { out[field.name] = jsonFromSchema(field.message, depth + 1); continue; } - if (field.fieldType === "Enum") { out[field.name] = field.enumValues?.[0] ?? "UNKNOWN"; continue; } - switch (field.fieldType) - { - case "String": case "Bytes": out[field.name] = ""; break; - case "Bool": out[field.name] = false; break; - case "Double": case "Float": out[field.name] = 0.0; break; - case "Int32": case "Int64": case "SInt32": case "SInt64": - case "UInt32": case "UInt64": case "Fixed32": case "Fixed64": - case "SFixed32": case "SFixed64": out[field.name] = 0; break; - default: out[field.name] = null; - } - } - return out; -} - -export function parseHeaders(text: string): Record -{ - const headers: Record = {}; - for (const line of text.split("\n")) - { - const separator = line.indexOf(":"); - if (separator <= 0) continue; - const key = line.slice(0, separator).trim(); - const value = line.slice(separator + 1).trim(); - if (key) headers[key] = value; - } - return headers; -} - -export function prettyPrint(text: unknown): string -{ - if (typeof text === "string") - { - try { - return JSON.stringify(JSON.parse(text), null, 2); - } - catch { return text; } - } - if (text && typeof text === "object") return JSON.stringify(text, null, 2); - return String(text ?? ""); -} - -export function formatInvocationOutput(body: InvocationResult): string -{ - const parts: string[] = []; - if (body.detail) parts.push(`Error: ${body.detail}`); - - if (body.responseJson) - { - try { parts.push(JSON.stringify(JSON.parse(body.responseJson), null, 2)); } - catch { parts.push(body.responseJson); } - } - - if (body.trailers && Object.keys(body.trailers).length > 0) - { - parts.push("Trailers:\n" + Object.entries(body.trailers) - .map(([key, value]) => `${key}: ${value}`).join("\n")); - } - - return parts.length > 0 ? parts.join("\n\n") : "(no response body)"; -} - -export function errorMessage(err: unknown): string -{ - return err instanceof Error ? err.message : String(err); -} diff --git a/src/Plugins/Solutions/DevTools/UI/src/storage.ts b/src/Plugins/Solutions/DevTools/UI/src/storage.ts deleted file mode 100644 index 5090b35..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/storage.ts +++ /dev/null @@ -1,11 +0,0 @@ -const HEADERS_KEY = "grpc-ui-headers"; - -export function loadPersistedHeaders(): string -{ - return sessionStorage.getItem(HEADERS_KEY) ?? "Authorization: Bearer "; -} - -export function persistHeaders(text: string): void -{ - sessionStorage.setItem(HEADERS_KEY, text); -} diff --git a/src/Plugins/Solutions/DevTools/UI/src/store.ts b/src/Plugins/Solutions/DevTools/UI/src/store.ts deleted file mode 100644 index 8b04a21..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/store.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { GrpcServiceInfo } from "./types"; - -export type SelectedIndex = readonly [service: number, method: number]; - -export interface DevToolsState -{ - readonly services: readonly GrpcServiceInfo[]; - readonly selected: SelectedIndex | null; -} - -export interface DevToolsStore -{ - readonly state: DevToolsState; - setServices(services: readonly GrpcServiceInfo[]): void; - select(serviceIndex: number, methodIndex: number): void; -} - -export class InMemoryDevToolsStore implements DevToolsStore -{ - private current: DevToolsState = { services: [], selected: null }; - - get state(): DevToolsState { - return this.current; - } - - setServices(services: readonly GrpcServiceInfo[]): void { - this.current = { ...this.current, services }; - } - - select(serviceIndex: number, methodIndex: number): void { - this.current = { ...this.current, selected: [serviceIndex, methodIndex] as const }; - } -} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/src/types.ts b/src/Plugins/Solutions/DevTools/UI/src/types.ts deleted file mode 100644 index ded66f2..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** Schema of protobuf scalar or composite field, mirroring GrpcFieldSchema. */ -export interface GrpcFieldSchema { - name: string; - fieldType: string; - isRepeated: boolean; - isMap: boolean; - mapKeyType?: string | null; - mapValueType?: string | null; - mapValue?: GrpcMessageSchema | null; - message?: GrpcMessageSchema | null; - enumType?: string | null; - enumValues?: string[] | null; -} - -/** Protobuf message layout, mirroring GrpcMessageSchema. */ -export interface GrpcMessageSchema { - name: string; - fullName: string; - fields: GrpcFieldSchema[]; -} - -/** Descriptor of single RPC method, mirroring GrpcMethodInfo. */ -export interface GrpcMethodInfo { - name: string; - fullName: string; - isClientStreaming: boolean; - isServerStreaming: boolean; - request: GrpcMessageSchema; - response: GrpcMessageSchema; -} - -/** Descriptor of gRPC service together with its methods, mirroring GrpcServiceInfo. */ -export interface GrpcServiceInfo { - name: string; - fullName: string; - package: string; - fileName: string; - methods: GrpcMethodInfo[]; -} - -/** Payload returned by the catalog API endpoint. */ -export interface GrpcCatalogResponse { - target: string; - services: GrpcServiceInfo[]; -} - -/** Result of aRPC invocation, mirroring GrpcInvocationResult. */ -export interface InvocationResult { - success: boolean; - statusName: string; - statusCode: number; - detail?: string | null; - responseJson?: string | null; - elapsedMs: number; - trailers?: Record | null; -} diff --git a/src/Plugins/Solutions/DevTools/UI/src/vite-env.d.ts b/src/Plugins/Solutions/DevTools/UI/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/svelte.config.js b/src/Plugins/Solutions/DevTools/UI/svelte.config.js new file mode 100644 index 0000000..2913655 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/template.html b/src/Plugins/Solutions/DevTools/UI/template.html index 6ceaff2..6a03c90 100644 --- a/src/Plugins/Solutions/DevTools/UI/template.html +++ b/src/Plugins/Solutions/DevTools/UI/template.html @@ -4,101 +4,12 @@ gRPC UI AuthKit - -
-

gRPC UI

- AuthKit plugin - scanning… -
-
- -
-
Select method to inspect its schema and execute a request.
-
-
+
- + - + \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/UI/vite.config.ts b/src/Plugins/Solutions/DevTools/UI/vite.config.ts new file mode 100644 index 0000000..e4c99bd --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/vite.config.ts @@ -0,0 +1,17 @@ +import { join } from "node:path"; +import tailwindcss from "@tailwindcss/vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + root: import.meta.dirname, + build: { + outDir: "dist", + emptyOutDir: true, + rollupOptions: { + input: join(import.meta.dirname, "template.html"), + }, + }, + plugins: [svelte(), tailwindcss(), viteSingleFile()], +}); \ No newline at end of file diff --git a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj index 4d2a662..1127392 100644 --- a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj +++ b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj @@ -20,6 +20,11 @@ + + + PreserveNewest + + diff --git a/tests/DevTools/Catalog/ProtoDocCommentsTests.cs b/tests/DevTools/Catalog/ProtoDocCommentsTests.cs new file mode 100644 index 0000000..3dc524b --- /dev/null +++ b/tests/DevTools/Catalog/ProtoDocCommentsTests.cs @@ -0,0 +1,202 @@ +using System.IO; +using DevTools.Catalog; +using Xunit; + +namespace DevTools.Catalog.Tests; + +/// +/// Tests for ProtoDocComments parser. +/// +public class ProtoDocCommentsTests +{ + [Fact] + public void Parse_IncludesMapFieldDescription() + { + // Arrange: proto file with a documented map field + var protoContent = @"syntax = ""proto3""; + +package test.v1; + +// This is a test service. +service TestService { + // Gets user by ID. + rpc GetUser(GetUserRequest) returns (GetUserResponse); +} + +// Request message for GetUser. +message GetUserRequest { + // The user ID to look up. + string user_id = 1; + + // Map field with documentation. + // This map stores additional metadata. + map metadata = 2; +} + +message GetUserResponse { + string name = 1; + string email = 2; +} +"; + var tempPath = Path.GetTempFileName(); + File.WriteAllText(tempPath, protoContent); + + try + { + // Act + var comments = ProtoDocComments.Parse(tempPath); + + // Assert - parser uses format: service, service.method, message, message.field + Assert.Contains("TestService", comments); + Assert.Equal("This is a test service.", comments["TestService"]); + + // RPC methods use service.method format + Assert.Contains("TestService.GetUser", comments); + Assert.Equal("Gets user by ID.", comments["TestService.GetUser"]); + + Assert.Contains("GetUserRequest", comments); + Assert.Equal("Request message for GetUser.", comments["GetUserRequest"]); + + // Regular field - key is messageName.fieldName + var userIdEntry = comments.FirstOrDefault(kvp => kvp.Key.Contains("user_id")); + Assert.NotEqual(default, userIdEntry); + Assert.Equal("The user ID to look up.", userIdEntry.Value); + + // Critical assertion: map field description should be included + var metadataEntry = comments.FirstOrDefault(kvp => kvp.Key.EndsWith(".metadata")); + Assert.NotEqual(default, metadataEntry); + Assert.Equal("Map field with documentation. This map stores additional metadata.", metadataEntry.Value); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public void Parse_ExcludesOptionDeclarations() + { + // Arrange: proto file with option declarations + var protoContent = @"syntax = ""proto3""; + +package test.v1; + +// Option with documentation should not be treated as a field. +option go_package = ""test.v1""; +option deprecated = true; + +message TestMessage { + // A regular field. + string name = 1; +} +"; + var tempPath = Path.GetTempFileName(); + File.WriteAllText(tempPath, protoContent); + + try + { + // Act + var comments = ProtoDocComments.Parse(tempPath); + + // Assert: option declarations should not appear as keys + Assert.DoesNotContain("option", comments.Keys); + Assert.DoesNotContain("go_package", comments.Keys); + Assert.DoesNotContain("deprecated", comments.Keys); + + // Regular field should still be included + var nameEntry = comments.FirstOrDefault(kvp => kvp.Key.Contains("name")); + Assert.NotEqual(default, nameEntry); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public void Parse_ExcludesReservedDeclarations() + { + // Arrange: proto file with reserved declarations + var protoContent = @"syntax = ""proto3""; + +package test.v1; + +message TestMessage { + reserved 1, 2, 3; + reserved ""foo"", ""bar""; + + // A regular field. + string name = 4; +} +"; + var tempPath = Path.GetTempFileName(); + File.WriteAllText(tempPath, protoContent); + + try + { + // Act + var comments = ProtoDocComments.Parse(tempPath); + + // Assert: reserved declarations should not appear as keys + Assert.DoesNotContain("reserved", comments.Keys); + + // Regular field should be included + var nameEntry = comments.FirstOrDefault(kvp => kvp.Key.Contains("name")); + Assert.NotEqual(default, nameEntry); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public void Parse_HandlesComplexMapField() + { + // Arrange: proto file with typed map fields + var protoContent = @"syntax = ""proto3""; + +package complex.v1; + +message ComplexMessage { + // Simple string map. + map simple_map = 1; + + // Nested message map. + map nested_map = 2; + + // Repeated field for comparison. + repeated string items = 3; +} + +message InnerMessage { + string value = 1; +} +"; + var tempPath = Path.GetTempFileName(); + File.WriteAllText(tempPath, protoContent); + + try + { + // Act + var comments = ProtoDocComments.Parse(tempPath); + + // Assert: all fields should be included with their comments + var simpleMapEntry = comments.FirstOrDefault(kvp => kvp.Key.Contains("simple_map")); + Assert.NotEqual(default, simpleMapEntry); + Assert.Equal("Simple string map.", simpleMapEntry.Value); + + var nestedMapEntry = comments.FirstOrDefault(kvp => kvp.Key.Contains("nested_map")); + Assert.NotEqual(default, nestedMapEntry); + Assert.Equal("Nested message map.", nestedMapEntry.Value); + + var itemsEntry = comments.FirstOrDefault(kvp => kvp.Key.Contains("items")); + Assert.NotEqual(default, itemsEntry); + Assert.Equal("Repeated field for comparison.", itemsEntry.Value); + } + finally + { + File.Delete(tempPath); + } + } +} \ No newline at end of file diff --git a/tests/DevTools/DevTools.Tests.csproj b/tests/DevTools/DevTools.Tests.csproj new file mode 100644 index 0000000..dfe4f92 --- /dev/null +++ b/tests/DevTools/DevTools.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/tests/DevTools/Runtime/GrpcDynamicInvokerTests.cs b/tests/DevTools/Runtime/GrpcDynamicInvokerTests.cs new file mode 100644 index 0000000..cccf7f4 --- /dev/null +++ b/tests/DevTools/Runtime/GrpcDynamicInvokerTests.cs @@ -0,0 +1,73 @@ +using DevTools.Runtime; +using Xunit; + +namespace DevTools.Runtime.Tests; + +/// +/// Tests for security and channel configuration. +/// +public class GrpcDynamicInvokerTests +{ + [Theory] + [InlineData("http://localhost:5001")] + [InlineData("http://127.0.0.1:5001")] + [InlineData("http://[::1]:5001")] + [InlineData("https://localhost:5001")] + [InlineData("https://127.0.0.1")] + public void IsLoopbackTarget_ReturnsTrueForLoopbackAddresses(string target) + { + // Act + var result = GrpcDynamicInvoker.IsLoopbackTarget(target); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("http://example.com")] + [InlineData("http://192.168.1.1")] + [InlineData("http://10.0.0.1")] + [InlineData("http://172.16.0.1")] + [InlineData("https://example.com")] + [InlineData("https://192.168.1.1")] + [InlineData("http://10.10.10.10:8080")] + [InlineData("http://api.remote-server.com")] + public void IsLoopbackTarget_ReturnsFalseForNonLoopbackAddresses(string target) + { + // Act + var result = GrpcDynamicInvoker.IsLoopbackTarget(target); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsLoopbackTarget_ReturnsFalseForInvalidUri() + { + // Act + var result = GrpcDynamicInvoker.IsLoopbackTarget("not-a-valid-uri"); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsLoopbackTarget_ReturnsFalseForEmptyString() + { + // Act + var result = GrpcDynamicInvoker.IsLoopbackTarget(string.Empty); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsLoopbackTarget_ReturnsFalseForNull() + { + // Act + var result = GrpcDynamicInvoker.IsLoopbackTarget(null!); + + // Assert + Assert.False(result); + } +} \ No newline at end of file