From 5122419d4e9be8fdde8ec3c775aa978b4207ee93 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:08:32 +0000 Subject: [PATCH 01/18] fix: address DevTools UI review findings --- .coderabbit.yaml | 2 +- src/Plugins/Solutions/DevTools/UI/build.mjs | 17 ++++++++++++++--- src/Plugins/Solutions/DevTools/UI/src/api.ts | 6 ++++++ src/Plugins/Solutions/DevTools/UI/src/app.ts | 5 ++++- .../Solutions/DevTools/UI/src/controller.ts | 2 ++ src/Plugins/Solutions/DevTools/UI/src/dom.ts | 4 +++- src/Plugins/Solutions/DevTools/UI/src/render.ts | 12 ++++++++++++ src/Plugins/Solutions/DevTools/UI/src/schema.ts | 8 ++++++++ .../Solutions/DevTools/UI/src/storage.ts | 2 ++ src/Plugins/Solutions/DevTools/UI/src/store.ts | 7 ++++++- 10 files changed, 58 insertions(+), 7 deletions(-) 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/src/Plugins/Solutions/DevTools/UI/build.mjs b/src/Plugins/Solutions/DevTools/UI/build.mjs index ab61c42..0acf3fa 100644 --- a/src/Plugins/Solutions/DevTools/UI/build.mjs +++ b/src/Plugins/Solutions/DevTools/UI/build.mjs @@ -18,6 +18,7 @@ const options = { outfile: join(root, "dist", "bundle.js"), }; +/** Writes the HTML template with the latest compiled bundle inlined. */ async function inlineBundle() { const [template, bundle] = await Promise.all([ readFile(join(root, "template.html"), "utf8"), @@ -28,12 +29,22 @@ async function inlineBundle() { } if (watch) { - const ctx = await context(options); + const ctx = await context({ + ...options, + plugins: [{ + name: "inline-bundle", + /** Registers HTML inlining after each successful esbuild run. */ + setup(build) { + build.onEnd(async (result) => { + if (result.errors.length === 0) await inlineBundle(); + }); + }, + }], + }); 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/src/api.ts b/src/Plugins/Solutions/DevTools/UI/src/api.ts index e544d6e..a75723f 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/api.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/api.ts @@ -10,14 +10,19 @@ export interface InvokeGrpcRequest export interface GrpcApi { + /** Loads the services and methods exposed by the DevTools catalog. */ fetchServices(): Promise; + + /** Invokes a gRPC method through the DevTools HTTP endpoint. */ invoke(request: InvokeGrpcRequest): Promise; } export class HttpGrpcApi implements GrpcApi { + /** Creates an HTTP API client rooted at the supplied DevTools path. */ constructor(private readonly base: string) {} + /** Loads the services and methods exposed by the DevTools catalog. */ async fetchServices(): Promise { const response = await fetch(`${this.base}/api/services`); @@ -31,6 +36,7 @@ export class HttpGrpcApi implements GrpcApi return (await response.json()) as GrpcCatalogResponse; } + /** Invokes a gRPC method through the DevTools HTTP endpoint. */ async invoke(request: InvokeGrpcRequest): Promise { const response = await fetch(`${this.base}/api/invoke`, { diff --git a/src/Plugins/Solutions/DevTools/UI/src/app.ts b/src/Plugins/Solutions/DevTools/UI/src/app.ts index 5d65c6a..22d7ea0 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/app.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/app.ts @@ -16,12 +16,14 @@ declare global { const base = window.__GRPC_UI_BASE__ ?? ""; const api: GrpcApi = new HttpGrpcApi(base); +/** Connects the API and state store to the DevTools page. */ function bootstrap(api: GrpcApi, store: DevToolsStore): void { const servicesContainer = getById("services"); const detailContainer = getById("detail"); const target = getById("target"); + /** Selects a method and renders its invocation controls. */ function onSelect(serviceIndex: number, methodIndex: number): void { store.select(serviceIndex, methodIndex); @@ -37,6 +39,7 @@ function bootstrap(api: GrpcApi, store: DevToolsStore): void ); } + /** Stores and renders a fetched service catalog. */ async function onCatalog(catalog: GrpcCatalogResponse): Promise { store.setServices(catalog.services ?? []); @@ -56,4 +59,4 @@ function bootstrap(api: GrpcApi, store: DevToolsStore): void })(); } -bootstrap(api, new InMemoryDevToolsStore()); \ No newline at end of file +bootstrap(api, new InMemoryDevToolsStore()); diff --git a/src/Plugins/Solutions/DevTools/UI/src/controller.ts b/src/Plugins/Solutions/DevTools/UI/src/controller.ts index 8897da9..6eae764 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/controller.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/controller.ts @@ -8,6 +8,7 @@ import { persistHeaders } from "./storage"; import type { MethodDetail } from "./render"; import type { GrpcMethodInfo, GrpcServiceInfo } from "./types"; +/** Creates the UI action that invokes the selected gRPC method. */ export function createInvokeAction( api: GrpcApi, service: GrpcServiceInfo, @@ -20,6 +21,7 @@ export function createInvokeAction( }; } +/** Executes an invocation and writes its progress and result to the detail view. */ async function invoke( api: GrpcApi, service: GrpcServiceInfo, diff --git a/src/Plugins/Solutions/DevTools/UI/src/dom.ts b/src/Plugins/Solutions/DevTools/UI/src/dom.ts index 5ae1f51..ef2cc8b 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/dom.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/dom.ts @@ -1,3 +1,4 @@ +/** Returns a required page element by id. */ export function getById(id: string): T { const node = document.getElementById(id); @@ -5,6 +6,7 @@ export function getById(id: string): T return node as T; } +/** Creates an element with optional class and text content. */ export function el( tag: K, className?: string, @@ -15,4 +17,4 @@ export function el( 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/render.ts b/src/Plugins/Solutions/DevTools/UI/src/render.ts index 7c8af7d..e063bb7 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/render.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/render.ts @@ -12,10 +12,13 @@ export interface MethodDetail { readonly request: HTMLTextAreaElement; readonly headers: HTMLTextAreaElement; + /** Updates the visible invocation status. */ setStatus(state: "idle" | "ok" | "err", text: string): void; + /** Replaces the visible invocation response. */ writeResponse(text: string): void; } +/** Renders the service and method navigation list. */ export function renderServiceList( container: HTMLElement, services: readonly GrpcServiceInfo[], @@ -64,6 +67,7 @@ export function renderServiceList( } } +/** Applies the active style to the selected method. */ export function highlightSelectedMethod(selected: SelectedIndex | null): void { for (const item of document.querySelectorAll(".methods li")) { @@ -74,6 +78,7 @@ export function highlightSelectedMethod(selected: SelectedIndex | null): void } } +/** Renders schemas and invocation controls for a method. */ export function renderMethodDetail( container: HTMLElement, method: GrpcMethodInfo, @@ -125,12 +130,14 @@ export function renderMethodDetail( const detail: MethodDetail = { request, headers, + /** Updates the visible invocation status. */ setStatus(state, text) { status.classList.remove("ok", "err"); if (state === "ok" || state === "err") status.classList.add(state); status.hidden = false; status.textContent = text; }, + /** Replaces the visible invocation response. */ writeResponse(text) { response.hidden = false; response.textContent = text; @@ -140,6 +147,7 @@ export function renderMethodDetail( invokeButton.addEventListener("click", () => onInvoke(detail)); } +/** Creates a consistently styled empty-state message. */ function emptyMessage(text: string): HTMLDivElement { const message = el("div", "empty", text); @@ -147,6 +155,7 @@ function emptyMessage(text: string): HTMLDivElement return message; } +/** Creates a card heading with optional appended nodes. */ function heading(text: string, additions: Node[] = []): HTMLHeadingElement { const h = el("h2"); @@ -154,6 +163,7 @@ function heading(text: string, additions: Node[] = []): HTMLHeadingElement return h; } +/** Wraps content nodes in a standard card. */ function card(h: HTMLHeadingElement, bodyNodes: (Node | string)[]): HTMLDivElement { const card = el("div", "card"); @@ -163,6 +173,7 @@ function card(h: HTMLHeadingElement, bodyNodes: (Node | string)[]): HTMLDivEleme return card; } +/** Renders a protobuf field and its nested fields. */ function renderField(field: GrpcFieldSchema, depth: number, into: HTMLElement): void { const row = el("div", "row-line"); @@ -184,6 +195,7 @@ function renderField(field: GrpcFieldSchema, depth: number, into: HTMLElement): } } +/** Renders a protobuf message schema. */ function renderSchema(title: string, msg: GrpcMessageSchema | null | undefined): HTMLDivElement { const wrap = el("div"); diff --git a/src/Plugins/Solutions/DevTools/UI/src/schema.ts b/src/Plugins/Solutions/DevTools/UI/src/schema.ts index 74cebca..d3fd46a 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/schema.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/schema.ts @@ -7,6 +7,7 @@ import type { export type StreamingKind = "uni" | "cli" | "srv" | "bidi"; +/** Classifies a method by its client and server streaming flags. */ export function streamingKind(method: GrpcMethodInfo): StreamingKind { if (method.isClientStreaming && method.isServerStreaming) return "bidi"; @@ -15,6 +16,7 @@ export function streamingKind(method: GrpcMethodInfo): StreamingKind return "uni"; } +/** Returns a human-readable description of a method's streaming behavior. */ export function describeStreaming(method: GrpcMethodInfo): string { if (method.isClientStreaming && method.isServerStreaming) return "Bidirectional stream"; @@ -23,6 +25,7 @@ export function describeStreaming(method: GrpcMethodInfo): string return "Unary"; } +/** Formats a protobuf field's type for display. */ export function fieldTypeText(field: GrpcFieldSchema): string { if (field.isMap) @@ -34,6 +37,7 @@ export function fieldTypeText(field: GrpcFieldSchema): string return field.fieldType; } +/** Builds representative JSON values from a protobuf message schema. */ export function jsonFromSchema(msg: GrpcMessageSchema, depth = 0): Record { const out: Record = {}; @@ -59,6 +63,7 @@ export function jsonFromSchema(msg: GrpcMessageSchema, depth = 0): Record { const headers: Record = {}; @@ -73,6 +78,7 @@ export function parseHeaders(text: string): Record return headers; } +/** Pretty-prints JSON-compatible input and preserves invalid JSON text. */ export function prettyPrint(text: unknown): string { if (typeof text === "string") @@ -86,6 +92,7 @@ export function prettyPrint(text: unknown): string return String(text ?? ""); } +/** Formats an invocation response, error detail, and trailers for display. */ export function formatInvocationOutput(body: InvocationResult): string { const parts: string[] = []; @@ -106,6 +113,7 @@ export function formatInvocationOutput(body: InvocationResult): string return parts.length > 0 ? parts.join("\n\n") : "(no response body)"; } +/** Converts an unknown thrown value into a displayable message. */ 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 index 5090b35..30c20b1 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/storage.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/storage.ts @@ -1,10 +1,12 @@ const HEADERS_KEY = "grpc-ui-headers"; +/** Loads the session's saved metadata headers or the default authorization header. */ export function loadPersistedHeaders(): string { return sessionStorage.getItem(HEADERS_KEY) ?? "Authorization: Bearer "; } +/** Saves metadata headers for the current browser session. */ 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 index 8b04a21..66c27c6 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/store.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/store.ts @@ -11,7 +11,9 @@ export interface DevToolsState export interface DevToolsStore { readonly state: DevToolsState; + /** Replaces the current service catalog. */ setServices(services: readonly GrpcServiceInfo[]): void; + /** Records the selected service and method indexes. */ select(serviceIndex: number, methodIndex: number): void; } @@ -19,15 +21,18 @@ export class InMemoryDevToolsStore implements DevToolsStore { private current: DevToolsState = { services: [], selected: null }; + /** Returns the current immutable state snapshot. */ get state(): DevToolsState { return this.current; } + /** Replaces the current service catalog. */ setServices(services: readonly GrpcServiceInfo[]): void { this.current = { ...this.current, services }; } + /** Records the selected service and method indexes. */ select(serviceIndex: number, methodIndex: number): void { this.current = { ...this.current, selected: [serviceIndex, methodIndex] as const }; } -} \ No newline at end of file +} From d7c2e03937eabbe5ebfba0bd8f2209b29ec1f1c6 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 16 Sep 2026 02:16:19 +0200 Subject: [PATCH 02/18] refactor: migrate DevTools UI build to Vite --- Dockerfile | 2 +- src/Plugins/Solutions/DevTools/UI/build.mjs | 39 - .../Solutions/DevTools/UI/package-lock.json | 1006 ++++++++++++----- .../Solutions/DevTools/UI/package.json | 12 +- .../Solutions/DevTools/UI/template.html | 4 +- .../Solutions/DevTools/UI/vite.config.ts | 29 + 6 files changed, 755 insertions(+), 337 deletions(-) delete mode 100644 src/Plugins/Solutions/DevTools/UI/build.mjs create mode 100644 src/Plugins/Solutions/DevTools/UI/vite.config.ts diff --git a/Dockerfile b/Dockerfile index aeb8a6a..2872d36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ 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", "./"] COPY ["src/Plugins/Solutions/DevTools/UI/src/", "./src/"] RUN npm run build 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..1ff68df 100644 --- a/src/Plugins/Solutions/DevTools/UI/package-lock.json +++ b/src/Plugins/Solutions/DevTools/UI/package-lock.json @@ -8,31 +8,25 @@ "name": "@authkit/devtools-ui", "version": "1.0.0", "devDependencies": { - "esbuild": "^0.28.2", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "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/@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", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, - "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==", + "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": [ "arm" ], @@ -43,13 +37,13 @@ "android" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "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/@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": [ "arm64" ], @@ -60,30 +54,13 @@ "android" ], "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "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 +71,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,30 +88,13 @@ "darwin" ], "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "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-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": [ "x64" ], @@ -145,13 +105,13 @@ "freebsd" ], "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-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": [ "arm" ], @@ -162,217 +122,133 @@ "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-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": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "libc": [ + "glibc" ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "ia32" - ], - "dev": true, "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-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": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "libc": [ + "musl" ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "mips64el" - ], - "dev": true, "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-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": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "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-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": [ "s390x" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.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==", + "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": [ "x64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.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/@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": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "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/@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": [ "arm64" ], @@ -383,30 +259,13 @@ "openharmony" ], "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "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/@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": [ "arm64" ], @@ -417,15 +276,15 @@ "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "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/@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": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", @@ -434,25 +293,15 @@ "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", - "cpu": [ - "x64" - ], + "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", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", @@ -794,46 +643,525 @@ "node": ">=16.20.0" } }, - "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/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/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/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/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/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/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": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/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": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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/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": { - "esbuild": "bin/esbuild" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18" + "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/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/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/typescript": { @@ -870,6 +1198,106 @@ "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" } + }, + "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": { + "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 + } + } } } } diff --git a/src/Plugins/Solutions/DevTools/UI/package.json b/src/Plugins/Solutions/DevTools/UI/package.json index e2dc1e1..cc09559 100644 --- a/src/Plugins/Solutions/DevTools/UI/package.json +++ b/src/Plugins/Solutions/DevTools/UI/package.json @@ -6,11 +6,13 @@ "description": "TypeScript UI for the AuthKit gRPC DevTools plugin.", "scripts": { "typecheck": "tsc --noEmit", - "build": "node build.mjs", - "watch": "node build.mjs --watch" + "build": "vite build", + "watch": "vite build --watch", + "dev": "vite" }, "devDependencies": { - "esbuild": "^0.28.2", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vite": "^8.3.0", + "vite-plugin-singlefile": "^2.3.3" } -} \ 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..253356e 100644 --- a/src/Plugins/Solutions/DevTools/UI/template.html +++ b/src/Plugins/Solutions/DevTools/UI/template.html @@ -97,8 +97,6 @@

gRPC UI

- + 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..8e2cddd --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/vite.config.ts @@ -0,0 +1,29 @@ +import { rename } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { defineConfig, type Plugin } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +function outputAsUiHtml(): Plugin { + return { + name: "ui-html-output-name", + apply: "build", + async writeBundle() { + const out = resolve(import.meta.dirname, "dist"); + await rename(join(out, "template.html"), join(out, "ui.html")); + }, + }; +} + +export default defineConfig({ + root: resolve(import.meta.dirname), + build: { + outDir: "dist", + emptyOutDir: true, + rollupOptions: { + input: { + ui: resolve(import.meta.dirname, "template.html"), + }, + }, + }, + plugins: [viteSingleFile(), outputAsUiHtml()], +}); \ No newline at end of file From a50ab902fc6c021997d514fc84aeedc49be2ee8a Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:06:12 +0200 Subject: [PATCH 03/18] feat: expose proto comments, plugin metadata, and raw responses --- .../DevTools/Catalog/GrpcServiceCatalog.cs | 95 ++++++++++++++++--- .../DevTools/Catalog/GrpcServiceInfo.cs | 27 +++++- .../DevTools/Runtime/GrpcDynamicInvoker.cs | 42 +++++++- .../DevTools/Runtime/GrpcInvocationResult.cs | 12 ++- 4 files changed, 157 insertions(+), 19 deletions(-) diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs index 0d4f29c..284f7ed 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 true; + } + + 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 { 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 +211,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 +227,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..5665780 100644 --- a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs @@ -17,6 +17,11 @@ public sealed class GrpcFieldSchema /// 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 +83,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 +114,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 +155,11 @@ public sealed class GrpcServiceInfo /// public required string FullName { get; init; } + /// + /// Gets the leading doc comment of the service from the source proto. + /// + public string? Description { get; init; } + /// /// Gets the protobuf package the service belongs to. /// @@ -150,6 +170,11 @@ public sealed class GrpcServiceInfo /// public required string FileName { get; init; } + /// + /// Gets a value indicating whether the service originates from a plugin. + /// + public bool IsPlugin { get; init; } + /// /// Gets the methods exposed by the service. /// @@ -170,4 +195,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/Runtime/GrpcDynamicInvoker.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs index 8b4d76f..c5754e5 100644 --- a/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs +++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs @@ -70,12 +70,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 +108,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 +186,25 @@ private async Task InvokeUnaryAsync( /// private static GrpcChannel CreateChannel(IOptions options, ILogger logger) { + 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. + if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + AppContext.SetSwitch( + "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", + true); + } + var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }; - var target = options.Value.ResolveGrpcTarget(); logger.LogInformation("gRPC UI channel configured for target {Target}.", target); return GrpcChannel.ForAddress(target, new GrpcChannelOptions { HttpHandler = handler }); } -} \ No newline at end of file +} diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs index 9f25fae..7dfee0d 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,11 @@ public sealed class GrpcInvocationResult /// public string? ResponseJson { get; init; } + /// + /// Gets the raw binary protobuf response encoded as base64 string. + /// + public string? ResponseBase64 { get; init; } + /// /// Gets the invocation duration in milliseconds. /// @@ -45,4 +51,4 @@ public sealed class GrpcInvocationResult /// Gets the response trailers returned with the call. /// public IReadOnlyDictionary? Trailers { get; init; } -} \ No newline at end of file +} From ccc177b146f3d2d226b9b4cb35704711f6a6b22f Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:07:57 +0200 Subject: [PATCH 04/18] feat: set up Svelte 5 and Tailwind css for devtools --- .../DevTools/Catalog/ProtoDocComments.cs | 187 +++ .../Solutions/DevTools/UI/package-lock.json | 1059 ++++++++++++++--- .../Solutions/DevTools/UI/package.json | 10 +- .../Solutions/DevTools/UI/svelte.config.js | 5 + .../Solutions/DevTools/UI/vite.config.ts | 26 +- 5 files changed, 1096 insertions(+), 191 deletions(-) create mode 100644 src/Plugins/Solutions/DevTools/Catalog/ProtoDocComments.cs create mode 100644 src/Plugins/Solutions/DevTools/UI/svelte.config.js 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/UI/package-lock.json b/src/Plugins/Solutions/DevTools/UI/package-lock.json index 1ff68df..1d1bf4c 100644 --- a/src/Plugins/Solutions/DevTools/UI/package-lock.json +++ b/src/Plugins/Solutions/DevTools/UI/package-lock.json @@ -8,11 +8,86 @@ "name": "@authkit/devtools-ui", "version": "1.0.0", "devDependencies": { - "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/@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", + "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/@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", + "engines": { + "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/@oxc-project/types": { "version": "0.149.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", @@ -303,344 +378,651 @@ "dev": true, "license": "MIT" }, - "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/@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", + "engines": { + "node": ">= 18.0.0" + } + }, + "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", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "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", + "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": ">= 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/@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": [ - "ppc64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ - "aix" + "android" ], "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-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": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ "darwin" ], "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-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": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ "darwin" ], "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-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": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ "freebsd" ], "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-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": [ - "x64" + "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "linux" ], "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==", + "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": [ - "arm" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/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": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "musl" + ], + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/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": [ - "loong64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/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": [ - "mips64el" + "x64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "musl" + ], + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/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": [ - "ppc64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/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": [ - "riscv64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "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": ">= 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/@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": "MIT", + "optional": true, + "os": [ + "android" ], "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-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": [ - "s390x" + "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-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-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": [ "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-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-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": [ - "arm64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "netbsd" + "freebsd" ], "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-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": [ - "x64" + "arm" ], "dev": true, - "license": "Apache-2.0", + "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-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": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "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": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "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-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": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "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-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": [ - "sunos" + "linux" ], "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==", + "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": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "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": ">=14.0.0" + } + }, + "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": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" } }, - "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/@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": [ "win32" ], "engines": { - "node": ">=16.20.0" + "node": ">= 20" + } + }, + "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": { @@ -656,6 +1038,42 @@ "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", @@ -666,6 +1084,52 @@ "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", @@ -697,6 +1161,30 @@ "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", @@ -712,6 +1200,13 @@ "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", @@ -722,6 +1217,26 @@ "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", @@ -995,6 +1510,23 @@ "url": "https://opencollective.com/parcel" } }, + "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", @@ -1022,6 +1554,56 @@ "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, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -1041,6 +1623,20 @@ "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", @@ -1090,6 +1686,20 @@ "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", @@ -1124,6 +1734,19 @@ "@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", @@ -1134,6 +1757,89 @@ "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", @@ -1164,39 +1870,25 @@ "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" - }, - "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" + "node": ">=14.17" } }, "node_modules/vite": { @@ -1298,6 +1990,33 @@ "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 cc09559..dfdfaef 100644 --- a/src/Plugins/Solutions/DevTools/UI/package.json +++ b/src/Plugins/Solutions/DevTools/UI/package.json @@ -5,13 +5,19 @@ "type": "module", "description": "TypeScript UI for the AuthKit gRPC DevTools plugin.", "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", "build": "vite build", "watch": "vite build --watch", "dev": "vite" }, "devDependencies": { - "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" } 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/vite.config.ts b/src/Plugins/Solutions/DevTools/UI/vite.config.ts index 8e2cddd..e4c99bd 100644 --- a/src/Plugins/Solutions/DevTools/UI/vite.config.ts +++ b/src/Plugins/Solutions/DevTools/UI/vite.config.ts @@ -1,29 +1,17 @@ -import { rename } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { defineConfig, type Plugin } from "vite"; +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"; -function outputAsUiHtml(): Plugin { - return { - name: "ui-html-output-name", - apply: "build", - async writeBundle() { - const out = resolve(import.meta.dirname, "dist"); - await rename(join(out, "template.html"), join(out, "ui.html")); - }, - }; -} - export default defineConfig({ - root: resolve(import.meta.dirname), + root: import.meta.dirname, build: { outDir: "dist", emptyOutDir: true, rollupOptions: { - input: { - ui: resolve(import.meta.dirname, "template.html"), - }, + input: join(import.meta.dirname, "template.html"), }, }, - plugins: [viteSingleFile(), outputAsUiHtml()], + plugins: [svelte(), tailwindcss(), viteSingleFile()], }); \ No newline at end of file From 9edd472370cb9d090c2806c170de080d4810f31c Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:43:17 +0200 Subject: [PATCH 05/18] feat(devtools): add modular gRPC API client layer --- .../Solutions/DevTools/UI/src/lib/api/api.ts | 107 +++++++++++++ .../DevTools/UI/src/lib/api/httpClient.ts | 45 ++++++ .../DevTools/UI/src/lib/api/invoke.ts | 35 +++++ .../DevTools/UI/src/lib/api/mapper.ts | 66 +++++++++ .../DevTools/UI/src/lib/api/normalizer.ts | 140 ++++++++++++++++++ .../DevTools/UI/src/lib/api/requestBuilder.ts | 54 +++++++ .../UI/src/lib/api/responseBuilder.ts | 52 +++++++ 7 files changed, 499 insertions(+) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/api/api.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/api/httpClient.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/api/invoke.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/api/requestBuilder.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/api/responseBuilder.ts 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..425bb61 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts @@ -0,0 +1,66 @@ +/** + * 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 { + 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..b2aad22 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts @@ -0,0 +1,140 @@ +/** + * 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 { + 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 { + 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 From 0256efab10b7206662392ecd3a4ab2aad295faef Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:44:41 +0200 Subject: [PATCH 06/18] feat(devtools): add clipboard utilities --- .../UI/src/lib/clipboard/clipboard.ts | 7 +++++ .../src/lib/clipboard/useClipboard.svelte.ts | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/clipboard.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/clipboard/useClipboard.svelte.ts 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, + }; +} From 624c6e3fb9488f1dc6c3a9d0d4efa5d7ed4216f8 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:46:20 +0200 Subject: [PATCH 07/18] feat(devtools): introduce formatting utilities --- .../UI/src/lib/format/byteCalculator.ts | 21 +++ .../DevTools/UI/src/lib/format/format.ts | 61 ++++++++ .../UI/src/lib/format/headerParser.ts | 19 +++ .../UI/src/lib/format/jsonFormatter.ts | 23 +++ .../UI/src/lib/format/jsonHighlight.ts | 116 ++++++++++++++ .../UI/src/lib/format/protoGenerator.ts | 148 ++++++++++++++++++ .../DevTools/UI/src/lib/format/raw.ts | 45 ++++++ .../DevTools/UI/src/lib/format/textUtils.ts | 27 ++++ 8 files changed, 460 insertions(+) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/byteCalculator.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/format.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/headerParser.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonFormatter.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/jsonHighlight.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/raw.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/format/textUtils.ts 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..32d574d --- /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, index) => { + lines.push(this.renderField(field, index + 1)); + }); + 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 From cadc069a032bfc797f6c722cc9c1ac2407c8a12c Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:49:36 +0200 Subject: [PATCH 08/18] feat(devtools): add gRPC service and metadata utilities --- .../DevTools/UI/src/lib/metadata/metadata.ts | 67 +++++++++++++++ .../DevTools/UI/src/lib/metadata/types.ts | 12 +++ .../UI/src/lib/navigation/navigation.ts | 44 ++++++++++ .../DevTools/UI/src/lib/proto/protoView.ts | 83 +++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/metadata/metadata.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/metadata/types.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/navigation/navigation.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts 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..269b8b3 --- /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, i) => { + 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} = ${i + 1};`; + }) + .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 From 321dbd53c3bd0ded6b19fb728e41c6c7b78fc8d7 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:51:25 +0200 Subject: [PATCH 09/18] refactor(devtools): restructure UI architecture --- src/Plugins/Solutions/DevTools/UI/src/api.ts | 58 ----- .../Solutions/DevTools/UI/src/controller.ts | 59 ----- src/Plugins/Solutions/DevTools/UI/src/dom.ts | 20 -- .../Solutions/DevTools/UI/src/lib/stores.ts | 76 ++++++ .../Solutions/DevTools/UI/src/lib/types.ts | 75 ++++++ src/Plugins/Solutions/DevTools/UI/src/main.ts | 8 + .../Solutions/DevTools/UI/src/render.ts | 220 ------------------ .../Solutions/DevTools/UI/src/schema.ts | 120 ---------- .../Solutions/DevTools/UI/src/storage.ts | 13 -- .../Solutions/DevTools/UI/src/store.ts | 38 --- .../Solutions/DevTools/UI/src/types.ts | 56 ----- 11 files changed, 159 insertions(+), 584 deletions(-) delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/api.ts delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/controller.ts delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/dom.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/stores.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/lib/types.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/main.ts delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/render.ts delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/schema.ts delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/storage.ts delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/store.ts delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/types.ts 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 a75723f..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/api.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { GrpcCatalogResponse, InvocationResult } from "./types"; - -export interface InvokeGrpcRequest -{ - service: string; - method: string; - requestJson: string; - headers: Record; -} - -export interface GrpcApi -{ - /** Loads the services and methods exposed by the DevTools catalog. */ - fetchServices(): Promise; - - /** Invokes a gRPC method through the DevTools HTTP endpoint. */ - invoke(request: InvokeGrpcRequest): Promise; -} - -export class HttpGrpcApi implements GrpcApi -{ - /** Creates an HTTP API client rooted at the supplied DevTools path. */ - constructor(private readonly base: string) {} - - /** Loads the services and methods exposed by the DevTools catalog. */ - 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; - } - - /** Invokes a gRPC method through the DevTools HTTP endpoint. */ - 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/controller.ts b/src/Plugins/Solutions/DevTools/UI/src/controller.ts deleted file mode 100644 index 6eae764..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/controller.ts +++ /dev/null @@ -1,59 +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"; - -/** Creates the UI action that invokes the selected gRPC method. */ -export function createInvokeAction( - api: GrpcApi, - service: GrpcServiceInfo, - method: GrpcMethodInfo, -): (detail: MethodDetail) => void -{ - return (detail) => - { - void invoke(api, service, method, detail); - }; -} - -/** Executes an invocation and writes its progress and result to the detail view. */ -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 ef2cc8b..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/dom.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Returns a required page element by id. */ -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; -} - -/** Creates an element with optional class and text content. */ -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; -} 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..bcb962d --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts @@ -0,0 +1,75 @@ +export type JSONValue = + | null + | boolean + | number + | string + | JSONValue[] + | { [key: string]: JSONValue }; + +export interface ProtoField { + 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 e063bb7..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/render.ts +++ /dev/null @@ -1,220 +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; - /** Updates the visible invocation status. */ - setStatus(state: "idle" | "ok" | "err", text: string): void; - /** Replaces the visible invocation response. */ - writeResponse(text: string): void; -} - -/** Renders the service and method navigation list. */ -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); - } -} - -/** Applies the active style to the selected method. */ -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); - } -} - -/** Renders schemas and invocation controls for a method. */ -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, - /** Updates the visible invocation status. */ - setStatus(state, text) { - status.classList.remove("ok", "err"); - if (state === "ok" || state === "err") status.classList.add(state); - status.hidden = false; - status.textContent = text; - }, - /** Replaces the visible invocation response. */ - writeResponse(text) { - response.hidden = false; - response.textContent = text; - }, - }; - - invokeButton.addEventListener("click", () => onInvoke(detail)); -} - -/** Creates a consistently styled empty-state message. */ -function emptyMessage(text: string): HTMLDivElement -{ - const message = el("div", "empty", text); - message.style.padding = "14px"; - return message; -} - -/** Creates a card heading with optional appended nodes. */ -function heading(text: string, additions: Node[] = []): HTMLHeadingElement -{ - const h = el("h2"); - h.append(text, ...additions); - return h; -} - -/** Wraps content nodes in a standard card. */ -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; -} - -/** Renders a protobuf field and its nested fields. */ -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); - } -} - -/** Renders a protobuf message schema. */ -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 d3fd46a..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/schema.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { - GrpcFieldSchema, - GrpcMessageSchema, - GrpcMethodInfo, - InvocationResult, -} from "./types"; - -export type StreamingKind = "uni" | "cli" | "srv" | "bidi"; - -/** Classifies a method by its client and server streaming flags. */ -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"; -} - -/** Returns a human-readable description of a method's streaming behavior. */ -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"; -} - -/** Formats a protobuf field's type for display. */ -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; -} - -/** Builds representative JSON values from a protobuf message schema. */ -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; -} - -/** Parses newline-separated metadata headers into a key-value record. */ -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; -} - -/** Pretty-prints JSON-compatible input and preserves invalid JSON text. */ -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 ?? ""); -} - -/** Formats an invocation response, error detail, and trailers for display. */ -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)"; -} - -/** Converts an unknown thrown value into a displayable message. */ -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 30c20b1..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/storage.ts +++ /dev/null @@ -1,13 +0,0 @@ -const HEADERS_KEY = "grpc-ui-headers"; - -/** Loads the session's saved metadata headers or the default authorization header. */ -export function loadPersistedHeaders(): string -{ - return sessionStorage.getItem(HEADERS_KEY) ?? "Authorization: Bearer "; -} - -/** Saves metadata headers for the current browser session. */ -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 66c27c6..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/store.ts +++ /dev/null @@ -1,38 +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; - /** Replaces the current service catalog. */ - setServices(services: readonly GrpcServiceInfo[]): void; - /** Records the selected service and method indexes. */ - select(serviceIndex: number, methodIndex: number): void; -} - -export class InMemoryDevToolsStore implements DevToolsStore -{ - private current: DevToolsState = { services: [], selected: null }; - - /** Returns the current immutable state snapshot. */ - get state(): DevToolsState { - return this.current; - } - - /** Replaces the current service catalog. */ - setServices(services: readonly GrpcServiceInfo[]): void { - this.current = { ...this.current, services }; - } - - /** Records the selected service and method indexes. */ - select(serviceIndex: number, methodIndex: number): void { - this.current = { ...this.current, selected: [serviceIndex, methodIndex] as const }; - } -} 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; -} From ea7cba0a5cea11a3baf481b8ed2cab52e18614f8 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:53:30 +0200 Subject: [PATCH 10/18] feat(devtools): introduce Svelte UI and update infrastructure --- Dockerfile | 4 +- docker-compose.yml | 10 +- src/Host/Host.csproj | 5 + .../Solutions/DevTools/UI/src/App.svelte | 54 ++++ src/Plugins/Solutions/DevTools/UI/src/app.css | 260 ++++++++++++++++++ src/Plugins/Solutions/DevTools/UI/src/app.ts | 62 ----- .../Solutions/DevTools/UI/src/vite-env.d.ts | 1 + .../Solutions/DevTools/UI/template.html | 93 +------ .../ExamplePlugin/ExamplePlugin.csproj | 5 + 9 files changed, 338 insertions(+), 156 deletions(-) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/App.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/app.css delete mode 100644 src/Plugins/Solutions/DevTools/UI/src/app.ts create mode 100644 src/Plugins/Solutions/DevTools/UI/src/vite-env.d.ts diff --git a/Dockerfile b/Dockerfile index 2872d36..5aabb93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ 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/vite.config.ts", "./"] +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/src/", "./src/"] RUN npm run build @@ -48,7 +48,7 @@ RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens COPY src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json -COPY --from=ui /ui/dist/ui.html src/Plugins/Solutions/DevTools/UI/dist/ui.html +COPY --from=ui /ui/dist/template.html src/Plugins/Solutions/DevTools/UI/dist/ui.html RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools COPY src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json 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/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/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 22d7ea0..0000000 --- a/src/Plugins/Solutions/DevTools/UI/src/app.ts +++ /dev/null @@ -1,62 +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); - -/** Connects the API and state store to the DevTools page. */ -function bootstrap(api: GrpcApi, store: DevToolsStore): void -{ - const servicesContainer = getById("services"); - const detailContainer = getById("detail"); - const target = getById("target"); - - /** Selects a method and renders its invocation controls. */ - 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), - ); - } - - /** Stores and renders a fetched service catalog. */ - 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()); 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/template.html b/src/Plugins/Solutions/DevTools/UI/template.html index 253356e..6a03c90 100644 --- a/src/Plugins/Solutions/DevTools/UI/template.html +++ b/src/Plugins/Solutions/DevTools/UI/template.html @@ -4,99 +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/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 + + From b447b836f04e65129e857810778bac86918b2a5a Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:55:03 +0200 Subject: [PATCH 11/18] feat(devtools): introduce DevTools layout components --- .../UI/src/components/layout/Header.svelte | 129 +++++++ .../src/components/layout/ServiceTree.svelte | 339 ++++++++++++++++++ .../UI/src/components/layout/Sidebar.svelte | 100 ++++++ 3 files changed, 568 insertions(+) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/layout/Header.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/layout/ServiceTree.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/layout/Sidebar.svelte 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} + + + + + +
From 47d77c10a228fc5fd06f4ab96390b6be8b4991d7 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:56:30 +0200 Subject: [PATCH 12/18] feat(devtools): add proto viewer components --- .../navigation/MethodsColumn.svelte | 189 ++++++++++++++ .../UI/src/components/proto/ProtoModal.svelte | 244 ++++++++++++++++++ .../UI/src/components/proto/ProtoView.svelte | 228 ++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/navigation/MethodsColumn.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoModal.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/proto/ProtoView.svelte 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)}
+
+
+
+
+
From a68731922e0c8e1534d1c4a8fd9b136d8ac1c9a5 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 17 Sep 2026 23:57:12 +0200 Subject: [PATCH 13/18] feat(devtools): introduce gRPC method workspace --- .../UI/src/components/method/ActionBar.svelte | 61 ++++ .../src/components/method/JsonEditor.svelte | 69 ++++ .../components/method/MetadataTable.svelte | 231 +++++++++++++ .../src/components/method/MethodDetail.svelte | 322 ++++++++++++++++++ .../src/components/method/MethodHeader.svelte | 74 ++++ .../src/components/method/MethodTabs.svelte | 30 ++ .../components/method/ResponsePanel.svelte | 270 +++++++++++++++ 7 files changed, 1057 insertions(+) create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/method/ActionBar.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/method/JsonEditor.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/method/MetadataTable.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/method/MethodDetail.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/method/MethodHeader.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/method/MethodTabs.svelte create mode 100644 src/Plugins/Solutions/DevTools/UI/src/components/method/ResponsePanel.svelte 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} From a68eda8f3dbd824c5c4b80b93da271c6b1984e0d Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 18 Sep 2026 00:07:09 +0200 Subject: [PATCH 14/18] fix: Rename built UI template for DevTools --- src/Plugins/Solutions/DevTools/UI/package.json | 2 +- .../Solutions/DevTools/UI/scripts/finalize-ui.mjs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/Plugins/Solutions/DevTools/UI/scripts/finalize-ui.mjs diff --git a/src/Plugins/Solutions/DevTools/UI/package.json b/src/Plugins/Solutions/DevTools/UI/package.json index dfdfaef..bdbef9c 100644 --- a/src/Plugins/Solutions/DevTools/UI/package.json +++ b/src/Plugins/Solutions/DevTools/UI/package.json @@ -6,7 +6,7 @@ "description": "TypeScript UI for the AuthKit gRPC DevTools plugin.", "scripts": { "typecheck": "svelte-check --tsconfig ./tsconfig.json", - "build": "vite build", + "build": "vite build && node scripts/finalize-ui.mjs", "watch": "vite build --watch", "dev": "vite" }, 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"), +); From 98f5ad63985ca7e3e7f9b81e8b8d240807ca0741 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 18 Sep 2026 00:14:16 +0200 Subject: [PATCH 15/18] Fix: DevTools UI Docker build --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5aabb93..e30b37e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,7 @@ 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/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 @@ -48,7 +49,7 @@ RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens COPY src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json -COPY --from=ui /ui/dist/template.html src/Plugins/Solutions/DevTools/UI/dist/ui.html +COPY --from=ui /ui/dist/ui.html src/Plugins/Solutions/DevTools/UI/dist/ui.html RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools COPY src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json From 11eeb97f785c955c93f29b4e91476bdebe61ca6f Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 18 Sep 2026 00:38:38 +0200 Subject: [PATCH 16/18] fix: return false for unknown assemblies in IsPluginAssembly fallback The fallback return value in IsPluginAssembly was incorrectly returning true for assemblies that don't match any known plugin or host service criteria. This could cause unknown assemblies to be misclassified as plugins. Now returns false, treating unknown assemblies as host services, which is safer and more consistent with the deny-list approach used elsewhere. --- Docs/ADR/026-plugin-authentication-and-authorization-hooks.md | 4 ++-- Docs/ADR/README.md | 1 + src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs index 284f7ed..1a80f66 100644 --- a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs @@ -158,7 +158,7 @@ private static bool IsPluginAssembly(Assembly assembly) return false; } - return true; + return false; } private static GrpcMethodInfo BuildMethodInfo(MethodDescriptor method, From 634154699b74176030613353eb68f6d471f298a0 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 18 Sep 2026 00:39:22 +0200 Subject: [PATCH 17/18] docs: document HTTP DTO fields in DevTools API contract - Document Description field as nullable with absence behavior - Document IsPlugin with default false and backward-compatible behavior - Document ResponseBase64 as success-only with size limit (2GB max) --- src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs | 8 +++++++- .../Solutions/DevTools/Runtime/GrpcInvocationResult.cs | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs index 5665780..632c4ba 100644 --- a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs @@ -156,7 +156,9 @@ public sealed class GrpcServiceInfo public required string FullName { get; init; } /// - /// Gets the leading doc comment of the service from the source proto. + /// 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; } @@ -172,6 +174,10 @@ public sealed class GrpcServiceInfo /// /// 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; } diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs index 7dfee0d..fa9536e 100644 --- a/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs +++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs @@ -39,6 +39,12 @@ public sealed class GrpcInvocationResult /// /// 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; } From d9c72013d871373f535958fd7d6f5c92de25b6e5 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 18 Sep 2026 01:34:18 +0200 Subject: [PATCH 18/18] fix(devtools): harden gRPC channel and preserve proto field numbers --- Docs/ADR/020-devtools-plugin.md | 69 ++++++ .../DevTools/Catalog/GrpcServiceCatalog.cs | 1 + .../DevTools/Catalog/GrpcServiceInfo.cs | 5 + .../DevTools/Properties/AssemblyInfo.cs | 3 + .../DevTools/Runtime/GrpcDynamicInvoker.cs | 37 +++- .../DevTools/UI/src/lib/api/mapper.ts | 1 + .../DevTools/UI/src/lib/api/normalizer.ts | 2 + .../UI/src/lib/format/protoGenerator.ts | 4 +- .../DevTools/UI/src/lib/proto/protoView.ts | 4 +- .../Solutions/DevTools/UI/src/lib/types.ts | 1 + .../DevTools/Catalog/ProtoDocCommentsTests.cs | 202 ++++++++++++++++++ tests/DevTools/DevTools.Tests.csproj | 23 ++ .../Runtime/GrpcDynamicInvokerTests.cs | 73 +++++++ 13 files changed, 414 insertions(+), 11 deletions(-) create mode 100644 src/Plugins/Solutions/DevTools/Properties/AssemblyInfo.cs create mode 100644 tests/DevTools/Catalog/ProtoDocCommentsTests.cs create mode 100644 tests/DevTools/DevTools.Tests.csproj create mode 100644 tests/DevTools/Runtime/GrpcDynamicInvokerTests.cs 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/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs index 1a80f66..7ca8187 100644 --- a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs @@ -195,6 +195,7 @@ private static GrpcFieldSchema BuildFieldSchema(FieldDescriptor field, int depth { var schema = new GrpcFieldSchema { + Number = field.FieldNumber, Name = field.Name, Description = comments.TryGetValue($"{field.ContainingType.Name}.{field.Name}", out var summary) ? summary diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs index 632c4ba..e6f8222 100644 --- a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs @@ -12,6 +12,11 @@ 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. /// 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 c5754e5..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; @@ -188,23 +190,44 @@ private static GrpcChannel CreateChannel(IOptions options, ILog { var target = options.Value.ResolveGrpcTarget(); + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + // The host falls back to plain HTTP/2 (h2c) when no dev certificate is // present. gRPC over an unencrypted connection requires this switch. - if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + // 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); } - var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }; - logger.LogInformation("gRPC UI channel configured for target {Target}.", target); return GrpcChannel.ForAddress(target, new GrpcChannelOptions { HttpHandler = handler }); } + + /// + /// 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/UI/src/lib/api/mapper.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts index 425bb61..3cd82ae 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/mapper.ts @@ -46,6 +46,7 @@ export function mapMessage(wire: WireMessage): { name: string; fullName: string; export function mapField(wire: WireField): ProtoField { return { + number: wire.Number, name: wire.Name, description: optionalString(wire.Description), fieldType: wire.FieldType, diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts index b2aad22..2e2aa98 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/api/normalizer.ts @@ -31,6 +31,7 @@ export interface WireMessage { } export interface WireField { + Number: number; Name: string; Description?: string; FieldType: string; @@ -110,6 +111,7 @@ export function normalizeMessage(wire: unknown): WireMessage { 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") ?? ""), diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts index 32d574d..2eea9da 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/format/protoGenerator.ts @@ -139,8 +139,8 @@ export class DefaultProtoGenerator implements ProtoGenerator { lines.push(`// ${message.description}`); } lines.push(`message ${message.name} {`); - message.fields.forEach((field, index) => { - lines.push(this.renderField(field, index + 1)); + message.fields.forEach((field) => { + lines.push(this.renderField(field, field.number)); }); lines.push("}"); return lines.join("\n"); diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts index 269b8b3..4a0ef21 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/proto/protoView.ts @@ -34,13 +34,13 @@ export function formatFieldType(field: ProtoField): string { function renderFieldLines(fields?: ProtoField[]): string { if (!fields || fields.length === 0) return ""; return fields - .map((f, i) => { + .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} = ${i + 1};`; + return ` ${typeName} ${f.name} = ${f.number};`; }) .join("\n"); } diff --git a/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts b/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts index bcb962d..42bb8cd 100644 --- a/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts +++ b/src/Plugins/Solutions/DevTools/UI/src/lib/types.ts @@ -7,6 +7,7 @@ export type JSONValue = | { [key: string]: JSONValue }; export interface ProtoField { + number: number; name: string; description?: string; fieldType: string; 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