diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 615e43ca96..e834d72cfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ concurrency: permissions: contents: read + actions: read security-events: write jobs: @@ -103,6 +104,11 @@ jobs: if: ${{ needs.changes.outputs.apphost == 'true' }} uses: ./.github/workflows/apphost-build.yml + hybrid-api-poc: + needs: changes + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'hybrid-api-poc') }} + uses: ./.github/workflows/hybrid-api-docs-poc.yml + release-sync-tests: runs-on: ubuntu-latest steps: @@ -115,7 +121,7 @@ jobs: bash .github/scripts/test-merge-main-into-release.sh ci-gate: - needs: [changes, frontend-build, apphost-build, release-sync-tests] + needs: [changes, frontend-build, apphost-build, release-sync-tests, hybrid-api-poc] if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest steps: @@ -128,6 +134,8 @@ jobs: FRONTEND_RESULT: ${{ needs['frontend-build'].result }} APPHOST_RESULT: ${{ needs['apphost-build'].result }} RELEASE_SYNC_RESULT: ${{ needs['release-sync-tests'].result }} + HYBRID_REQUESTED: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'hybrid-api-poc') }} + HYBRID_RESULT: ${{ needs['hybrid-api-poc'].result }} run: | echo "changes result: $CHANGES_RESULT" echo "frontend changed: $FRONTEND_CHANGED" @@ -135,6 +143,10 @@ jobs: echo "apphost changed: $APPHOST_CHANGED" echo "apphost-build result: $APPHOST_RESULT" echo "release-sync-tests result: $RELEASE_SYNC_RESULT" + if [[ "$HYBRID_REQUESTED" == "true" && "$HYBRID_RESULT" != "success" ]]; then + echo "The opted-in hybrid API POC checks must succeed." + exit 1 + fi if [[ "$RELEASE_SYNC_RESULT" != "success" ]]; then echo "The release sync tests must succeed." diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index 2abb8ad48b..5ea0348a39 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -1,6 +1,20 @@ name: Frontend Build on: + workflow_dispatch: + inputs: + node_version: + description: Node.js version + default: "24.x" + type: string + hybrid_api_poc: + description: Run the opt-in static/hybrid POC comparison + default: false + type: boolean + hybrid_artifact_run_id: + description: Recheck hybrid container packaging against an existing Linux artifact + default: "" + type: string workflow_call: inputs: node_version: @@ -18,6 +32,16 @@ on: required: false default: false type: boolean + hybrid_api_poc: + description: Also verify the opt-in hybrid API documentation POC + required: false + default: false + type: boolean + hybrid_artifact_run_id: + description: Recheck hybrid container packaging against an existing Linux artifact + required: false + default: "" + type: string secrets: ASPIRE_BOT_APP_ID: required: false @@ -26,9 +50,17 @@ on: permissions: contents: read + actions: read jobs: + hybrid-api-poc: + if: ${{ inputs.hybrid_api_poc }} + uses: ./.github/workflows/hybrid-api-docs-poc.yml + with: + artifact_run_id: ${{ inputs.hybrid_artifact_run_id }} + build: + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.hybrid_api_poc }} name: Frontend Build runs-on: ubuntu-latest defaults: @@ -247,6 +279,7 @@ jobs: ls -la dist validation: + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.hybrid_api_poc }} name: Frontend validation runs-on: ubuntu-latest defaults: @@ -422,7 +455,7 @@ jobs: frontend-gate: name: Frontend gate if: ${{ always() }} - needs: [build, validation, e2e, report] + needs: [build, validation, e2e, report, hybrid-api-poc] runs-on: ubuntu-latest steps: - name: Require all frontend checks @@ -431,7 +464,17 @@ jobs: VALIDATION: ${{ needs.validation.result }} E2E: ${{ needs.e2e.result }} REPORT: ${{ needs.report.result }} + HYBRID_REQUESTED: ${{ inputs.hybrid_api_poc }} + HYBRID_RESULT: ${{ needs['hybrid-api-poc'].result }} + POC_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.hybrid_api_poc }} run: | + if [[ "$HYBRID_REQUESTED" == "true" && "$HYBRID_RESULT" != "success" ]]; then + echo "::error::The opted-in hybrid POC must succeed." + exit 1 + fi + if [[ "$POC_ONLY" == "true" ]]; then + exit 0 + fi for result in "$BUILD" "$VALIDATION" "$E2E" "$REPORT"; do if [[ "$result" != "success" ]]; then echo "::error::Every frontend job must succeed (build=$BUILD validation=$VALIDATION e2e=$E2E report=$REPORT)." diff --git a/.github/workflows/hybrid-api-docs-poc.yml b/.github/workflows/hybrid-api-docs-poc.yml new file mode 100644 index 0000000000..d0924eb7c8 --- /dev/null +++ b/.github/workflows/hybrid-api-docs-poc.yml @@ -0,0 +1,171 @@ +name: Hybrid API docs POC + +on: + workflow_call: + inputs: + artifact_run_id: + description: Recheck container packaging using an existing Linux hybrid artifact + required: false + default: '' + type: string + workflow_dispatch: + inputs: + artifact_run_id: + description: Recheck container packaging using an existing Linux hybrid artifact + required: false + default: '' + type: string + +permissions: + contents: read + actions: read + +jobs: + windows-artifact: + if: ${{ inputs.artifact_run_id == '' }} + runs-on: windows-latest + defaults: + run: + working-directory: src/frontend + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + cache: pnpm + cache-dependency-path: src/frontend/pnpm-lock.yaml + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + - name: Build Windows hybrid artifact + env: + ASTRO_TELEMETRY_DISABLED: '1' + NODE_OPTIONS: --max-old-space-size=8192 + ASPIRE_HYBRID_API_DOCS: '1' + run: pnpm build:production + - name: Verify Windows StaticHost contracts + env: + ASPIRE_HYBRID_API_DOCS: '1' + run: dotnet test ../../tests/StaticHost.Tests/StaticHost.Tests.csproj --filter 'FullyQualifiedName~HybridApiDocs' --nologo + - name: Upload Windows POC artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: hybrid-api-docs-poc-windows + path: src/frontend/dist-hybrid + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + verify: + runs-on: ubuntu-latest + services: + redis: + image: redis:8.2-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + defaults: + run: + working-directory: src/frontend + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + cache: pnpm + cache-dependency-path: src/frontend/pnpm-lock.yaml + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + - name: Download existing Linux hybrid artifact for packaging verification + if: ${{ inputs.artifact_run_id != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: hybrid-api-docs-poc + path: src/frontend/dist-hybrid + github-token: ${{ github.token }} + run-id: ${{ inputs.artifact_run_id }} + - name: Verify POC unit contracts + env: + ASTRO_TELEMETRY_DISABLED: '1' + run: | + pnpm exec astro sync + pnpm exec eslint . --max-warnings 0 + pnpm exec vitest run --config vitest.config.ts tests/unit/hybrid-api-docs.vitest.test.ts tests/unit/hybrid-native-cache.vitest.test.ts tests/unit/api-markdown.vitest.test.ts tests/unit/ts-api-route-catalog.vitest.test.ts tests/unit/build-performance.vitest.test.ts + dotnet test ../../tests/StaticHost.Tests/StaticHost.Tests.csproj --filter 'FullyQualifiedName~HybridApiDocs' --nologo + dotnet test ../../tests/Aspire.Dev.AppHost.Tests/Aspire.Dev.AppHost.Tests.csproj --filter 'FullyQualifiedName~HybridApiDocs' --nologo + - name: Build static baseline + if: ${{ inputs.artifact_run_id == '' }} + env: + ASTRO_TELEMETRY_DISABLED: '1' + NODE_OPTIONS: --max-old-space-size=8192 + BUILD_TIMING: '1' + BUILD_TIMING_OUT: ${{ runner.temp }}/hybrid-poc/static-timing.jsonl + run: | + mkdir -p "$RUNNER_TEMP/hybrid-poc" + /usr/bin/time -v -o "$RUNNER_TEMP/hybrid-poc/static-resources.txt" pnpm build:production + - name: Build opted-in hybrid artifact + if: ${{ inputs.artifact_run_id == '' }} + env: + ASTRO_TELEMETRY_DISABLED: '1' + NODE_OPTIONS: --max-old-space-size=8192 + ASPIRE_HYBRID_API_DOCS: '1' + BUILD_TIMING: '1' + BUILD_TIMING_OUT: ${{ runner.temp }}/hybrid-poc/hybrid-timing.jsonl + run: /usr/bin/time -v -o "$RUNNER_TEMP/hybrid-poc/hybrid-resources.txt" pnpm build:production + - name: Verify output and non-API parity + if: ${{ inputs.artifact_run_id == '' }} + run: | + node scripts/verify-hybrid-output.mjs + rm -r dist + - name: Build independent StaticHost and Astro images + env: + ASPIRE_HYBRID_API_DOCS: '1' + run: | + dotnet publish ../statichost/StaticHost/StaticHost.csproj -c Release --os linux --arch x64 \ + -t:PublishContainer -p:ContainerRepository=hybrid-poc-host -p:ContainerImageTag=test --nologo + docker build -f Dockerfile.hybrid -t hybrid-poc-renderer:test . + - name: Verify rendering, caching and worker outages + env: + ConnectionStrings__cache: localhost:6379,abortConnect=false + HYBRID_TEST_BROWSER: '1' + HYBRID_TEST_CONTAINERS: '1' + run: node scripts/test-hybrid-host.mjs + - name: Upload private POC artifact + if: ${{ always() && inputs.artifact_run_id == '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: hybrid-api-docs-poc + path: src/frontend/dist-hybrid + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 + - name: Upload POC measurements + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: hybrid-api-docs-results + path: | + src/frontend/hybrid-output-results.json + src/frontend/hybrid-host-results.json + src/frontend/hybrid-host-processes.log + ${{ runner.temp }}/hybrid-poc + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index 370280c0c3..7783a04eac 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ # build output dist/ +dist-hybrid/ +hybrid-output-results.json +hybrid-host-results.json playwright-report/ blob-report/ test-results/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcc1ba8175..47d8fee4ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,82 @@ We welcome translation contributions! Please see our [Translation guide](https:/ └───📂 public # Static assets served as-is ``` +## Hybrid API documentation POC + +The normal build remains fully static. Opting in with `ASPIRE_HYBRID_API_DOCS=1` +at **build time** leaves non-API documentation and assets static, but renders all +C# and TypeScript API HTML and Markdown on demand through the existing StaticHost. +This includes both API search pages. The convenience command `build:hybrid` sets +the flag only for its child build; `build:hybrid:skip-search` also skips Pagefind. +Setting a runtime flag alone cannot convert an already-static artifact. +Use `build:hybrid`, not the static-only `build:statichost` command, for this mode. + +Run full-site comparisons in CI, not on your development machine. From the +**Frontend Build** workflow, select the POC option, or use: + +```powershell +gh workflow run frontend-build.yml --repo microsoft/aspire.dev --ref YOUR_BRANCH -f hybrid_api_poc=true +``` + +A PR carrying the `hybrid-api-poc` label also runs the POC checks when CI is +triggered by opening, reopening, or pushing to the PR. The comparison builds both +modes from the same commit and reports output counts, build resource usage, and +cold/warm rendering measurements in `hybrid-api-docs-results`. +The manual POC selection runs its focused checks and comparison without also +duplicating the ordinary frontend build and all viewport test shards. + +Download the completed `hybrid-api-docs-poc` artifact into the frontend directory: + +```powershell +gh run download RUN_ID --repo microsoft/aspire.dev --name hybrid-api-docs-poc-windows --dir .\src\frontend\dist-hybrid +$env:ASPIRE_HYBRID_API_DOCS = '1' +aspire start --non-interactive +``` + +Restore frontend dependencies with the repository's existing pnpm setup first. +The downloaded directory contains `client`, `server`, and `hybrid-api-docs.json`. +Use `hybrid-api-docs-poc` on Linux or `hybrid-api-docs-poc-windows` on Windows. +The Node adapter's build paths are platform-specific; a Linux artifact cannot +run on Windows. The manifest checks the platform before starting the renderer. +The manifest and server stay outside the public static root. Browse the +StaticHost endpoint in the Aspire dashboard. The AppHost runs two private Node +processes, `api-docs` and `api-docs-standby`, from the same artifact. Dependencies +must already be restored; startup does not install packages or rebuild the site. +Missing or incompatible artifacts cause an actionable startup error instead of +starting a development server or rebuilding the site. + +Astro's native `memoryCache()` is the only response cache: 128 LRU entries with a +30-minute lifetime, not a 128 MiB byte budget. It does not guarantee cold-miss +coalescing. StaticHost forwards every request, buffers up to 64 MiB, and sends +on-demand output with `Cache-Control: no-store`. Negotiated Markdown shares the +native `.md` entry; its private/Vary headers are applied during forwarding. +Authorization requests bypass caching; preference cookies are not forwarded. + +Workers verify a real API render before becoming ready. StaticHost retries a +failed GET against the matching-build standby, with 10-second attempts within a +30-second budget. `/healthz/api-docs` reports renderer readiness; `/healthz` +reports host liveness. If both workers fail, APIs return gateway errors while +static pages remain available. Restarting Node clears its cache; restarting +StaticHost does not. Restart the whole pair when replacing the artifact. + +Existing Redis/live-status behavior and the static Azure deployment are +unchanged. Hybrid publishing models a .NET main container and two private Astro +sidecars in the same App Service, without shared volumes. Build the renderer +image with `src/frontend/Dockerfile.hybrid`, push it to the App Service registry, +and supply its immutable image reference as `Parameters__api_docs_image` to the +Aspire deployment. Both images must use the same Linux build artifact. No Azure +resources are provisioned by the POC CI. Runtime images are restricted to bundled +local files and dimensions up to 4096 pixels. + +Set `HybridApiDocsArtifactDirectory` to select an artifact outside `dist-hybrid`, +for example when keeping Windows local and Linux deployment artifacts separate. + +Unset the flag to return to the ordinary Vite/local workflow: + +```powershell +Remove-Item Env:ASPIRE_HYBRID_API_DOCS +``` + ## Environment badge Set `PUBLIC_ENVIRONMENT_BADGE` to display a small text badge beside the header logo diff --git a/src/apphost/Aspire.Dev.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.AppHost/AppHost.cs index 6471a4b806..cd6d699423 100644 --- a/src/apphost/Aspire.Dev.AppHost/AppHost.cs +++ b/src/apphost/Aspire.Dev.AppHost/AppHost.cs @@ -1,13 +1,15 @@ var builder = DistributedApplication.CreateBuilder(args); - +var hybridApiDocs = HybridApiDocsExtensions.IsHybridApiDocsEnabled( + builder.Configuration["ASPIRE_HYBRID_API_DOCS"]); // For deployment: We want to pick AppService as the environment to publish to. -builder.AddAzureAppServiceEnvironment("production") +var production = builder.AddAzureAppServiceEnvironment("production") .WithDashboard(false); var cache = builder.AddAzureManagedRedis("cache") .RunAsContainer(); var staticHostWebsite = builder.AddProject("aspiredev") + .WithComputeEnvironment(production) .WithReference(cache) .WithExternalHttpEndpoints() .PublishAsAzureAppServiceWebsite((infra, website) => @@ -21,6 +23,10 @@ }); var frontDoor = builder.AddAzureFrontDoor(staticHostWebsite); +if (hybridApiDocs) +{ + staticHostWebsite.WithHybridApiDocs(builder, Path.GetFullPath("../../frontend", builder.AppHostDirectory)); +} if (builder.ExecutionContext.IsRunMode) { @@ -30,11 +36,14 @@ // The live-status client calls same-origin /api/live[/stream]; inject StaticHost's // origin so the Vite dev server can proxy those to the API (see astro.config.mjs). // Without this, /api/live 404s against the Vite origin under `aspire run`. - builder.AddViteApp("frontend", "../../frontend") + if (!hybridApiDocs) + { + builder.AddViteApp("frontend", "../../frontend") .WithPnpm() .WithEnvironment("ASPIRE_STATICHOST_URL", staticHostWebsite.GetEndpoint("https")) .WithUrlForEndpoint("http", static url => url.DisplayText = "aspire.dev (Local)") .WithExternalHttpEndpoints(); + } } else { diff --git a/src/apphost/Aspire.Dev.AppHost/HybridApiDocsExtensions.cs b/src/apphost/Aspire.Dev.AppHost/HybridApiDocsExtensions.cs new file mode 100644 index 0000000000..e6fe787c1f --- /dev/null +++ b/src/apphost/Aspire.Dev.AppHost/HybridApiDocsExtensions.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Azure.Provisioning.AppService; + +internal static class HybridApiDocsExtensions +{ + public static bool IsHybridApiDocsEnabled(string? value) => value switch + { + null or "0" => false, + "1" => true, + _ => throw new InvalidOperationException("ASPIRE_HYBRID_API_DOCS must be unset, 0, or 1."), + }; + + public static void WithHybridApiDocs(this IResourceBuilder website, + IDistributedApplicationBuilder builder, string frontendDirectory) + { + var artifactDirectory = builder.Configuration["HybridApiDocsArtifactDirectory"] + ?? Path.Combine(frontendDirectory, "dist-hybrid"); + var manifest = Path.GetFullPath(Path.Combine(artifactDirectory, "hybrid-api-docs.json")); + if (!File.Exists(manifest)) + throw new InvalidOperationException($"Build or download the opted-in hybrid CI artifact: {manifest}"); + using var json = JsonDocument.Parse(File.ReadAllText(manifest)); + if (json.RootElement.GetProperty("mode").GetString() != "hybrid-api-docs") + throw new InvalidOperationException("The selected artifact is not a hybrid API documentation build."); + var platform = builder.ExecutionContext.IsRunMode + ? OperatingSystem.IsWindows() ? "win32" : OperatingSystem.IsMacOS() ? "darwin" : "linux" + : "linux"; + if (json.RootElement.GetProperty("platform").GetString() != platform) + throw new InvalidOperationException($"The hybrid artifact must target {platform} for this execution mode."); + + if (builder.ExecutionContext.IsPublishMode) + { + var image = builder.AddParameter("api-docs-image"); + website.WithEnvironment("HybridApiDocs__ArtifactPath", "/app/hybrid/hybrid-api-docs.json"); + for (var index = 0; index < 2; index++) + { + var endpoint = $"http://127.0.0.1:{4323 + index}"; + website.WithEnvironment($"HybridApiDocs__RendererUrls__{index}", endpoint); + } + website.PublishAsAzureAppServiceWebsite((infra, site) => + { + site.SiteConfig.IsAlwaysOn = true; + // Renderer readiness only gates warmup; a renderer outage must not make the instance unhealthy. + site.SiteConfig.HealthCheckPath = "/healthz"; + site.SiteConfig.AppSettings.Add(new AppServiceNameValuePair + { + Name = "WEBSITE_HEALTHCHECK_MAXPINGFAILURES", Value = "2", + }); + site.SiteConfig.AppSettings.Add(new AppServiceNameValuePair + { + Name = "WEBSITE_WARMUP_PATH", Value = "/healthz/api-docs", + }); + site.SiteConfig.AppSettings.Add(new AppServiceNameValuePair + { + Name = "WEBSITE_WARMUP_STATUSES", Value = "200", + }); + var main = infra.GetProvisionableResources().OfType() + .Single(container => ReferenceEquals(container.Parent, site) && container.IsMain.Value); + for (var index = 0; index < 2; index++) + { + // A sidecar environment variable's value names an app setting; it is not a literal. + var portSetting = $"API_DOCS_PORT_{index}"; + site.SiteConfig.AppSettings.Add(new AppServiceNameValuePair + { + Name = portSetting, Value = $"{4323 + index}", + }); + var sidecar = new SiteContainer($"apiDocs{index}") + { + Parent = site, + Name = index == 0 ? "api-docs" : "api-docs-standby", + Image = image.Resource.AsProvisioningParameter(infra), + IsMain = false, + TargetPort = $"{4323 + index}", + AuthType = main.AuthType, + UserManagedIdentityClientId = main.UserManagedIdentityClientId, + InheritAppSettingsAndConnectionStrings = false, + }; + sidecar.EnvironmentVariables.Add(new WebAppEnvironmentVariable { Name = "PORT", Value = portSetting }); + infra.Add(sidecar); + } + }); + return; + } + + for (var index = 0; index < 2; index++) + { + var worker = builder.AddNodeApp(index == 0 ? "api-docs" : "api-docs-standby", frontendDirectory, "scripts/serve-api-docs.mjs") + .WithPnpm(install: false) + .WithHttpEndpoint(env: "PORT") + .WithEnvironment("HOST", "127.0.0.1") + .WithEnvironment("NODE_OPTIONS", "--max-old-space-size=1024") + .WithEnvironment("HYBRID_API_DOCS_MANIFEST", manifest) + .WithHttpHealthCheck("/__hybrid-api-docs/health"); + website.WithEnvironment($"HybridApiDocs__RendererUrls__{index}", worker.GetEndpoint("http")); + } + website.WithEnvironment("HybridApiDocs__ArtifactPath", manifest) + .WithHttpHealthCheck("/healthz/api-docs"); + } +} diff --git a/src/frontend/Dockerfile.hybrid b/src/frontend/Dockerfile.hybrid new file mode 100644 index 0000000000..8d6b5369cd --- /dev/null +++ b/src/frontend/Dockerfile.hybrid @@ -0,0 +1,25 @@ +FROM node:24-bookworm-slim AS dependencies +WORKDIR /app +RUN npm install --global pnpm@11.9.0 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches ./patches +RUN pnpm install --frozen-lockfile --prod + +FROM node:24-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production \ + NODE_OPTIONS=--max-old-space-size=1024 \ + HOST=127.0.0.1 \ + PORT=4323 \ + HYBRID_API_DOCS_MANIFEST=/app/hybrid-api-docs.json +COPY --from=dependencies --chown=node:node /app/node_modules ./node_modules +COPY --chown=node:node package.json tsconfig.json ./ +COPY --chown=node:node dist-hybrid/server ./server +COPY --chown=node:node dist-hybrid/renderer/client ./client +COPY --chown=node:node dist-hybrid/renderer/.starlight-icons ./.starlight-icons +COPY --chown=node:node dist-hybrid/hybrid-api-docs.json ./ +COPY --chown=node:node scripts/serve-api-docs.mjs ./scripts/serve-api-docs.mjs +USER node +HEALTHCHECK --interval=10s --timeout=3s --start-period=60s \ + CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/__hybrid-api-docs/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "scripts/serve-api-docs.mjs"] diff --git a/src/frontend/Dockerfile.hybrid.dockerignore b/src/frontend/Dockerfile.hybrid.dockerignore new file mode 100644 index 0000000000..5f45f17d6a --- /dev/null +++ b/src/frontend/Dockerfile.hybrid.dockerignore @@ -0,0 +1,18 @@ +** +!Dockerfile.hybrid +!package.json +!tsconfig.json +!pnpm-lock.yaml +!pnpm-workspace.yaml +!patches/ +!patches/** +!scripts/ +scripts/** +!scripts/serve-api-docs.mjs +!dist-hybrid/ +dist-hybrid/** +!dist-hybrid/hybrid-api-docs.json +!dist-hybrid/server/ +!dist-hybrid/server/** +!dist-hybrid/renderer/ +!dist-hybrid/renderer/** diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index 024f727f90..de3d94d9f0 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -25,7 +25,10 @@ import buildTiming from './config/build-timing.mjs'; import { contributorCacheIntegration } from './src/utils/contributors.ts'; import UnoCSS from 'unocss/astro'; import Icons from 'starlight-plugin-icons'; +import node from '@astrojs/node'; +import { isHybridApiDocs, hybridOutputDirectory, hybridApiDocsIntegration, hybridApiCacheConfig } from './config/hybrid-api-docs.mjs'; +const hybridApiDocs = isHybridApiDocs(); const modeArgIndex = process.argv.indexOf('--mode'); const isSkipSearchBuild = modeArgIndex >= 0 && process.argv[modeArgIndex + 1] === 'skip-search'; const outDir = process.env.ASTRO_OUT_DIR; @@ -55,12 +58,20 @@ const buildConcurrency = Number(process.env.ASPIRE_BUILD_CONCURRENCY) || 4; // https://astro.build/config export default defineConfig({ - cacheDir: './node_modules/.astro', - ...(outDir ? { outDir } : {}), + cacheDir: hybridApiDocs ? './node_modules/.astro-hybrid' : './node_modules/.astro', + ...(hybridApiDocs + ? { + outDir: hybridOutputDirectory(process.cwd(), outDir), + adapter: node({ mode: 'middleware' }), + session: false, + ...hybridApiCacheConfig(), + } + : outDir ? { outDir } : {}), vite: { define: { // Resolve filesystem-backed redirects before prerender modules are bundled. __ASPIRE_REDIRECT_PATHS__: JSON.stringify(Object.keys(redirects)), + __ASPIRE_HYBRID_API_DOCS__: JSON.stringify(hybridApiDocs), }, ...(staticHostUrl ? { @@ -80,6 +91,7 @@ export default defineConfig({ prefetch: true, site: 'https://aspire.dev', trailingSlash: 'always', + ...(hybridApiDocs ? { security: { allowedDomains: [{ hostname: 'aspire.dev', protocol: 'https' }] } } : {}), markdown: { processor: unified({ remarkPlugins: [remarkTypeScriptFirstAppHostTabs, remarkAspireVersionPlaceholders, remarkMermaid], @@ -157,6 +169,8 @@ export default defineConfig({ errorOnFallbackPages: false, exclude: [ '/i18n/', '/reference/api', '/reference/api/**', + // These remain real StaticHost redirects but the Node adapter emits no static redirect file. + ...(hybridApiDocs ? Object.keys(redirects).flatMap(path => [path, `${path}#*`]) : []), // Custom Astro destinations checked by the Dev Hub browser tests. '/hub/', '/hub/glossary/ats/', '/get-started/glossary/#polyglot', ], @@ -238,6 +252,7 @@ export default defineConfig({ }), ...(isBuildTimingEnabled ? [buildTiming()] : []), aspireVersionPlaceholdersIntegration(), + ...(hybridApiDocs ? [hybridApiDocsIntegration(redirects)] : []), ], build: { concurrency: buildConcurrency, diff --git a/src/frontend/config/hybrid-api-docs.mjs b/src/frontend/config/hybrid-api-docs.mjs new file mode 100644 index 0000000000..5855b7ea3c --- /dev/null +++ b/src/frontend/config/hybrid-api-docs.mjs @@ -0,0 +1,127 @@ +// @ts-check +import { createHash } from 'node:crypto'; +import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { memoryCache } from 'astro/config'; +import { buildApiRouteCatalog } from '../src/utils/api-route-catalog.ts'; + +export function isHybridApiDocs(value = process.env.ASPIRE_HYBRID_API_DOCS) { + if (value === undefined || value === '0') return false; + if (value === '1') return true; + throw new Error('ASPIRE_HYBRID_API_DOCS must be unset, 0, or 1.'); +} + +/** @returns {Pick} */ +export function hybridApiCacheConfig() { + return { + cache: { provider: memoryCache({ max: 128, query: { exclude: [] } }) }, + }; +} + +/** + * API pages rendered on demand. The two landing pages stay prerendered: they are the heaviest + * and most visited pages, and rendering them on demand made them an easy way to exhaust a worker. + * @param {string} component + */ +export function isApiRouteComponent(component) { + const path = component.replace(/\\/g, '/'); + return /(?:^|\/)src\/pages\/reference\/api\//.test(path) + && !/(?:^|\/)src\/pages\/reference\/api\/(?:csharp|typescript)(?:\/index\.astro|\.md\.ts)$/.test(path); +} + +/** @param {string} root @param {string | undefined} requested */ +export function hybridOutputDirectory(root, requested) { + const directory = resolve(root, requested || 'dist-hybrid'); + const expected = resolve(root, 'dist-hybrid'); + const matches = process.platform === 'win32' + ? directory.toLowerCase() === expected.toLowerCase() : directory === expected; + if (!matches) { + throw new Error('Hybrid output must use dist-hybrid, outside the normal dist/, public/ and wwwroot/ roots.'); + } + return directory; +} + +/** @template T @param {string} directory @returns {Promise} */ +async function readDocuments(directory) { + const files = (await readdir(directory)).filter((name) => name.endsWith('.json')).sort(); + return Promise.all(files.map(async (name) => JSON.parse(await readFile(resolve(directory, name), 'utf8')))); +} + +/** @param {string} directory @param {import('node:crypto').Hash} hash */ +async function hashDirectory(directory, hash, prefix = '') { + const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const path = resolve(directory, entry.name); + const name = `${prefix}${entry.name}`; + if (entry.isDirectory()) await hashDirectory(path, hash, `${name}/`); + else if (entry.isFile()) hash.update(name).update(await readFile(path)); + } +} + +/** + * @param {Record} redirects + * @returns {import('astro').AstroIntegration} + */ +export function hybridApiDocsIntegration(redirects) { + let root = ''; + let out = ''; + let site = ''; + return { + name: 'aspire-hybrid-api-docs', + hooks: { + 'astro:route:setup': ({ route }) => { + if (isApiRouteComponent(route.component)) route.prerender = false; + }, + 'astro:config:done': ({ config }) => { + root = fileURLToPath(config.root); + out = fileURLToPath(config.outDir); + if (!config.site) throw new Error('Hybrid API documentation requires a configured site URL.'); + site = config.site.replace(/\/$/, ''); + }, + 'astro:build:done': async () => { + /** @type {[import('../src/utils/packages').PackageApiDocument[], import('../src/utils/ts-modules').TsApiDocument[]]} */ + const [packages, modules] = await Promise.all([ + readDocuments(resolve(root, 'src/data/pkgs')), + readDocuments(resolve(root, 'src/data/ts-modules')), + ]); + const routes = buildApiRouteCatalog(packages, modules); + const redirectEntries = Object.entries(redirects).map(([path, value]) => ({ + path, destination: typeof value === 'string' ? value : value.destination, + status: typeof value === 'string' ? 301 : value.status, + })); + await cp(resolve(root, '../statichost/StaticHost/wwwroot/scalar'), + resolve(out, 'client/scalar'), { recursive: true }); + const rendererClient = resolve(out, 'renderer/client'); + await mkdir(resolve(rendererClient, '_astro'), { recursive: true }); + for (const file of await readdir(resolve(out, 'client/_astro'))) { + if (['.svg', '.png', '.jpg', '.jpeg', '.webp', '.avif', '.gif'].includes(extname(file).toLowerCase())) { + await cp(resolve(out, 'client/_astro', file), resolve(rendererClient, '_astro', file)); + } + } + await cp(resolve(out, 'client/404.html'), resolve(rendererClient, '404.html')); + await cp(resolve(root, '.starlight-icons'), resolve(out, 'renderer/.starlight-icons'), { recursive: true }); + await mkdir(resolve(out, 'server/src/data/twoslash'), { recursive: true }); + await cp(resolve(root, 'src/data/twoslash/aspire.d.ts'), resolve(out, 'server/src/data/twoslash/aspire.d.ts')); + const hash = createHash('sha256'); + hash.update(JSON.stringify({ routes, redirects: redirectEntries, site, platform: process.platform })); + await hashDirectory(resolve(out, 'client'), hash, 'client/'); + await hashDirectory(resolve(out, 'server'), hash, 'server/'); + const manifest = { + schemaVersion: 1, mode: 'hybrid-api-docs', buildId: hash.digest('hex'), + platform: process.platform, + client: 'client', server: 'server/entry.mjs', site, + routes, redirects: redirectEntries, + }; + await writeFile(resolve(out, 'hybrid-api-docs.json'), JSON.stringify(manifest)); + const urls = routes.filter(({ html }) => html).map(({ html }) => `${site}${html}`).join(''); + await writeFile(resolve(out, 'client/sitemap-api.xml'), + `${urls}`); + const sitemap = resolve(out, 'client/sitemap-index.xml'); + const content = await readFile(sitemap, 'utf8'); + await writeFile(sitemap, content.replace('', + `${site}/sitemap-api.xml`)); + }, + }, + }; +} diff --git a/src/frontend/eslint.config.mjs b/src/frontend/eslint.config.mjs index 1034ca018c..fdbaf9d140 100644 --- a/src/frontend/eslint.config.mjs +++ b/src/frontend/eslint.config.mjs @@ -16,6 +16,7 @@ export default [ '**/*.test-d.ts', '**/examples/', '**/dist/', + '**/dist-hybrid/', '**/build/', '**/examples/', ], diff --git a/src/frontend/frontend.esproj b/src/frontend/frontend.esproj index c72d5c4501..44d8d33277 100644 --- a/src/frontend/frontend.esproj +++ b/src/frontend/frontend.esproj @@ -1,10 +1,12 @@  $(MSBuildProjectDirectory)/dist + $(MSBuildProjectDirectory)/dist-hybrid/client pnpm run build false false $(MSBuildProjectDirectory)/obj/$(Configuration) + $(MSBuildProjectDirectory)/obj/$(Configuration)/hybrid \ No newline at end of file diff --git a/src/frontend/package.json b/src/frontend/package.json index fc2fe3f489..741eea3788 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -26,6 +26,9 @@ "build": "pnpm git-env && pnpm compute-skill-digests && astro build", "build:skip-search": "pnpm git-env && pnpm compute-skill-digests && astro build --mode skip-search", "build:production": "pnpm git-env && pnpm compute-skill-digests && astro build --mode production", + "build:hybrid": "node ./scripts/build-hybrid.mjs", + "build:hybrid:skip-search": "node ./scripts/build-hybrid.mjs --skip-search", + "serve:hybrid": "node ./scripts/serve-api-docs.mjs", "build:statichost": "node ./scripts/build-static-host.mjs", "build:statichost:skip-search": "node ./scripts/build-static-host.mjs --skip-search", "preview": "astro preview", @@ -69,6 +72,7 @@ "@astro-community/astro-embed-youtube": "^0.5.10", "@astrojs/markdown-remark": "7.2.1", "@astrojs/mdx": "^7.0.3", + "@astrojs/node": "11.1.6", "@astrojs/rss": "^4.0.19", "@astrojs/starlight": "^0.41.3", "@catppuccin/starlight": "^2.1.0", @@ -83,9 +87,14 @@ "asciinema-player": "^3.17.0", "astro": "^7.2.8", "astro-expressive-code": "^0.44.1", + "expressive-code-twoslash": "^0.6.1", + "hast-util-select": "^6.0.4", "hast-util-to-html": "^9.0.5", + "https-proxy-agent": "^9.1.0", "mdast-util-to-hast": "^13.2.1", "mermaid": "^11.16.1", + "node-fetch": "^3.3.2", + "rehype-parse": "^9.0.1", "remark-directive": "^4.0.0", "satori": "^0.28.1", "satteri": "^0.9.5", @@ -98,7 +107,10 @@ "starlight-page-actions": "^0.7.0", "starlight-plugin-icons": "^1.1.6", "starlight-sidebar-topics": "^0.8.0", - "tippy.js": "6.3.7" + "tippy.js": "6.3.7", + "tsx": "^4.23.1", + "typescript": "^6.0.3", + "unified": "^11.0.5" }, "devDependencies": { "@axe-core/playwright": "^4.12.1", @@ -115,22 +127,14 @@ "astro-vtbot": "^3.0.0", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", - "expressive-code-twoslash": "^0.6.1", "globals": "^17.7.0", - "hast-util-select": "^6.0.4", "http-proxy-agent": "^9.1.0", - "https-proxy-agent": "^9.1.0", - "node-fetch": "^3.3.2", "prettier": "^3.9.5", "prettier-plugin-astro": "^0.14.1", - "rehype-parse": "^9.0.1", "rehype-remark": "^10.0.1", "remark-gfm": "^4.0.1", "remark-stringify": "^11.0.0", - "tsx": "^4.23.1", - "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", - "unified": "^11.0.5", "unist-util-remove": "^4.0.0", "unocss": "66.7.5", "vitest": "^4.1.11" diff --git a/src/frontend/playwright.config.mjs b/src/frontend/playwright.config.mjs index f0d57d03f6..aa1a06d0af 100644 --- a/src/frontend/playwright.config.mjs +++ b/src/frontend/playwright.config.mjs @@ -6,6 +6,8 @@ const e2eBaseUrl = `http://127.0.0.1:${e2ePort}`; export default defineConfig({ testDir: './tests/e2e', + // Needs the real StaticHost; it runs through playwright.hybrid.config.mjs. + testIgnore: 'hybrid-api-docs.spec.ts', fullyParallel: true, forbidOnly: isCI, retries: isCI ? 2 : 0, diff --git a/src/frontend/playwright.hybrid.config.mjs b/src/frontend/playwright.hybrid.config.mjs new file mode 100644 index 0000000000..c505341d10 --- /dev/null +++ b/src/frontend/playwright.hybrid.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: 'hybrid-api-docs.spec.ts', + workers: 1, + use: { baseURL: process.env.HYBRID_TEST_URL || 'http://127.0.0.1:14324' }, +}); diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index 2c9e27be80..ef6809ae5c 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -62,16 +62,19 @@ importers: version: 7.2.1 '@astrojs/mdx': specifier: ^7.0.3 - version: 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@astrojs/node': + specifier: 11.1.6 + version: 11.1.6(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@astrojs/rss': specifier: ^4.0.19 version: 4.0.19 '@astrojs/starlight': specifier: ^0.41.3 - version: 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + version: 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) '@catppuccin/starlight': specifier: ^2.1.0 - version: 2.1.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 2.1.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@expressive-code/plugin-collapsible-sections': specifier: ^0.44.1 version: 0.44.1 @@ -92,7 +95,7 @@ importers: version: 0.1.1 '@lunariajs/starlight': specifier: ^0.1.1 - version: 0.1.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.1.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@mdx-js/mdx': specifier: ^3.1.1 version: 3.1.1 @@ -101,19 +104,34 @@ importers: version: 3.17.0 astro: specifier: ^7.2.8 - version: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + version: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) astro-expressive-code: specifier: ^0.44.1 - version: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + expressive-code-twoslash: + specifier: ^0.6.1 + version: 0.6.1(@expressive-code/core@0.44.1)(eslint@10.7.0(jiti@2.7.0))(expressive-code@0.44.1)(typescript@6.0.3) + hast-util-select: + specifier: ^6.0.4 + version: 6.0.4 hast-util-to-html: specifier: ^9.0.5 version: 9.0.5 + https-proxy-agent: + specifier: ^9.1.0 + version: 9.1.0 mdast-util-to-hast: specifier: ^13.2.1 version: 13.2.1 mermaid: specifier: ^11.16.1 version: 11.16.1 + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 + rehype-parse: + specifier: ^9.0.1 + version: 9.0.1 remark-directive: specifier: ^4.0.0 version: 4.0.0 @@ -128,31 +146,40 @@ importers: version: 0.35.4(@types/node@24.13.3) starlight-github-alerts: specifier: ^0.4.0 - version: 0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) + version: 0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) starlight-image-zoom: specifier: ^0.15.0 - version: 0.15.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) + version: 0.15.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) starlight-kbd: specifier: ^0.4.0 - version: 0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) + version: 0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) starlight-links-validator: specifier: ^0.25.2 - version: 0.25.2(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.25.2(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) starlight-llms-txt: specifier: ^0.11.0 - version: 0.11.0(@astrojs/markdown-satteri@0.3.4)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.11.0(@astrojs/markdown-satteri@0.3.4)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) starlight-page-actions: specifier: ^0.7.0 - version: 0.7.0(patch_hash=584b28601fea4de666f3eb91e3425afd11f8da7e4d8aba649c15ec5be5b5d54c)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.7.0(patch_hash=584b28601fea4de666f3eb91e3425afd11f8da7e4d8aba649c15ec5be5b5d54c)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) starlight-plugin-icons: specifier: ^1.1.6 - version: 1.1.6(patch_hash=ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)(unocss@66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(zod@4.4.3) + version: 1.1.6(patch_hash=ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)(unocss@66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(zod@4.4.3) starlight-sidebar-topics: specifier: ^0.8.0 - version: 0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) + version: 0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)) tippy.js: specifier: 6.3.7 version: 6.3.7 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + unified: + specifier: ^11.0.5 + version: 11.0.5 devDependencies: '@axe-core/playwright': specifier: ^4.12.1 @@ -186,7 +213,7 @@ importers: version: 66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) astro-embed: specifier: ^0.13.1 - version: 0.13.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + version: 0.13.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) astro-vtbot: specifier: ^3.0.0 version: 3.0.0 @@ -196,33 +223,18 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@10.7.0(jiti@2.7.0)) - expressive-code-twoslash: - specifier: ^0.6.1 - version: 0.6.1(@expressive-code/core@0.44.1)(eslint@10.7.0(jiti@2.7.0))(expressive-code@0.44.1)(typescript@6.0.3) globals: specifier: ^17.7.0 version: 17.7.0 - hast-util-select: - specifier: ^6.0.4 - version: 6.0.4 http-proxy-agent: specifier: ^9.1.0 version: 9.1.0 - https-proxy-agent: - specifier: ^9.1.0 - version: 9.1.0 - node-fetch: - specifier: ^3.3.2 - version: 3.3.2 prettier: specifier: ^3.9.5 version: 3.9.5 prettier-plugin-astro: specifier: ^0.14.1 version: 0.14.1 - rehype-parse: - specifier: ^9.0.1 - version: 9.0.1 rehype-remark: specifier: ^10.0.1 version: 10.0.1 @@ -232,18 +244,9 @@ importers: remark-stringify: specifier: ^11.0.0 version: 11.0.0 - tsx: - specifier: ^4.23.1 - version: 4.23.1 - typescript: - specifier: ^6.0.3 - version: 6.0.3 typescript-eslint: specifier: ^8.65.0 version: 8.65.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) - unified: - specifier: ^11.0.5 - version: 11.0.5 unist-util-remove: specifier: ^4.0.0 version: 4.0.0 @@ -384,6 +387,11 @@ packages: '@astrojs/markdown-satteri': optional: true + '@astrojs/node@11.1.6': + resolution: {integrity: sha512-jNZ10/vAT+HuXkX0E/tITsIYCSn3BmAvsooVAN1OjagG+FEzelpUWToy1bA3x6Irmu0VaknWRfReY+1twd35sQ==} + peerDependencies: + astro: ^7.2.1 + '@astrojs/prism@4.0.2': resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} engines: {node: '>=22.12.0'} @@ -621,9 +629,6 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.11.3': - resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -1099,13 +1104,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.3': - resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -2368,6 +2366,10 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2416,10 +2418,17 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex-xs@2.0.1: resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} engines: {node: '>=10.0.0'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2543,6 +2552,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -2653,6 +2666,10 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2785,6 +2802,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@9.1.0: resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} engines: {node: '>= 20'} @@ -2820,6 +2841,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3229,6 +3253,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3297,6 +3329,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -3499,6 +3535,10 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -3651,6 +3691,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.5.6: resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} engines: {node: '>=10'} @@ -3661,6 +3705,12 @@ packages: resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} engines: {node: '>=10'} + server-destroy@1.0.1: + resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.4: resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} @@ -3788,6 +3838,10 @@ packages: peerDependencies: '@astrojs/starlight': '>=0.38.0' + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -3864,6 +3918,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -4301,7 +4359,7 @@ snapshots: dependencies: '@astro-community/astro-embed-utils': 0.2.0 - '@astro-community/astro-embed-integration@0.12.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)': + '@astro-community/astro-embed-integration@0.12.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)': dependencies: '@astro-community/astro-embed-bluesky': 0.2.3(typescript@6.0.3) '@astro-community/astro-embed-gist': 0.1.0 @@ -4311,8 +4369,8 @@ snapshots: '@astro-community/astro-embed-vimeo': 0.3.12 '@astro-community/astro-embed-youtube': 0.5.10 '@types/unist': 3.0.3 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - astro-auto-import: 0.5.2(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro-auto-import: 0.5.2(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) unist-util-select: 5.1.0 transitivePeerDependencies: - typescript @@ -4359,12 +4417,7 @@ snapshots: '@astrojs/compiler-binding-linux-x64-musl@0.4.0': optional: true - '@astrojs/compiler-binding-wasm32-wasi@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': - dependencies: - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@astrojs/compiler-binding-wasm32-wasi@0.4.0': optional: true '@astrojs/compiler-binding-win32-arm64-msvc@0.4.0': @@ -4373,7 +4426,7 @@ snapshots: '@astrojs/compiler-binding-win32-x64-msvc@0.4.0': optional: true - '@astrojs/compiler-binding@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-binding@0.4.0': optionalDependencies: '@astrojs/compiler-binding-darwin-arm64': 0.4.0 '@astrojs/compiler-binding-darwin-x64': 0.4.0 @@ -4381,19 +4434,13 @@ snapshots: '@astrojs/compiler-binding-linux-arm64-musl': 0.4.0 '@astrojs/compiler-binding-linux-x64-gnu': 0.4.0 '@astrojs/compiler-binding-linux-x64-musl': 0.4.0 - '@astrojs/compiler-binding-wasm32-wasi': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-wasm32-wasi': 0.4.0 '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.0 '@astrojs/compiler-binding-win32-x64-msvc': 0.4.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - '@astrojs/compiler-rs@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-rs@0.4.0': dependencies: - '@astrojs/compiler-binding': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@astrojs/compiler-binding': 0.4.0 '@astrojs/compiler@2.13.1': {} @@ -4456,13 +4503,13 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + '@astrojs/mdx@7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.1 '@astrojs/markdown-remark': 7.2.1 '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) es-module-lexer: 2.3.1 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -4478,6 +4525,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@astrojs/node@11.1.6(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + send: 1.2.1 + server-destroy: 1.0.1 + transitivePeerDependencies: + - supports-color + '@astrojs/prism@4.0.2': dependencies: prismjs: 1.30.0 @@ -4494,17 +4550,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.5.4 - '@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)': + '@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)': dependencies: '@astrojs/markdown-satteri': 0.3.4 - '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - astro-expressive-code: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro-expressive-code: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) bcp-47: 2.1.1 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -4639,7 +4695,6 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@bruits/satteri-wasm32-wasi@0.9.5': @@ -4665,10 +4720,10 @@ snapshots: dependencies: fontkitten: 1.0.3 - '@catppuccin/starlight@2.1.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + '@catppuccin/starlight@2.1.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) '@chevrotain/types@11.1.2': {} @@ -4730,11 +4785,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.3': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -5020,8 +5070,6 @@ snapshots: optional: true '@img/sharp-wasm32@0.35.4': - dependencies: - '@emnapi/runtime': 1.11.3 optional: true '@img/sharp-webcontainers-wasm32@0.35.4': @@ -5082,11 +5130,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@lunariajs/starlight@0.1.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + '@lunariajs/starlight@0.1.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) '@lunariajs/core': 0.1.1 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -5131,27 +5179,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.3 - '@tybys/wasm-util': 0.10.3 - optional: true - '@nodable/entities@3.0.0': {} '@nodelib/fs.scandir@2.1.5': @@ -5220,7 +5247,6 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@oxc-parser/binding-win32-arm64-msvc@0.131.0': @@ -5315,7 +5341,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -6039,29 +6065,29 @@ snapshots: astring@1.9.0: {} - astro-auto-import@0.5.2(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + astro-auto-import@0.5.2(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: acorn: 8.17.0 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - astro-embed@0.13.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3): + astro-embed@0.13.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3): dependencies: '@astro-community/astro-embed-baseline-status': 0.2.2 '@astro-community/astro-embed-bluesky': 0.2.3(typescript@6.0.3) '@astro-community/astro-embed-gist': 0.1.0 - '@astro-community/astro-embed-integration': 0.12.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astro-community/astro-embed-integration': 0.12.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) '@astro-community/astro-embed-link-preview': 0.3.1 '@astro-community/astro-embed-mastodon': 0.1.1 '@astro-community/astro-embed-twitter': 0.5.11 '@astro-community/astro-embed-vimeo': 0.3.12 '@astro-community/astro-embed-youtube': 0.5.10 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: - typescript - astro-expressive-code@0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + astro-expressive-code@0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) rehype-expressive-code: 0.44.1 url-extras: 0.1.0 @@ -6073,9 +6099,9 @@ snapshots: '@vtbag/turn-signal': 1.3.1 '@vtbag/utensil-drawer': 1.2.16 - astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): + astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: - '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-rs': 0.4.0 '@astrojs/internal-helpers': 0.11.0 '@astrojs/markdown-satteri': 0.4.1 '@astrojs/telemetry': 3.3.3 @@ -6140,8 +6166,6 @@ snapshots: - '@azure/storage-blob' - '@capacitor/preferences' - '@deno/kv' - - '@emnapi/core' - - '@emnapi/runtime' - '@netlify/blobs' - '@planetscale/database' - '@types/node' @@ -6525,6 +6549,8 @@ snapshots: dependencies: robust-predicates: 3.0.3 + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -6567,8 +6593,12 @@ snapshots: duplexer@0.1.2: {} + ee-first@1.1.1: {} + emoji-regex-xs@2.0.1: {} + encodeurl@2.0.0: {} + entities@4.5.0: {} entities@6.0.1: {} @@ -6739,6 +6769,8 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@5.0.4: {} expect-type@1.4.0: {} @@ -6863,6 +6895,8 @@ snapshots: dependencies: fetch-blob: 3.2.0 + fresh@2.0.0: {} + fsevents@2.3.2: optional: true @@ -7132,6 +7166,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@9.1.0: dependencies: agent-base: 9.0.0 @@ -7166,6 +7208,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -7836,6 +7880,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.9 @@ -7893,6 +7943,10 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -8105,6 +8159,8 @@ snapshots: radix3@1.1.2: {} + range-parser@1.3.0: {} + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -8391,12 +8447,32 @@ snapshots: semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + seroval-plugins@1.5.6(seroval@1.5.6): dependencies: seroval: 1.5.6 seroval@1.5.6: {} + server-destroy@1.0.1: {} + + setprototypeof@1.2.0: {} + sharp@0.35.4(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 @@ -8509,15 +8585,15 @@ snapshots: stackback@0.0.2: {} - starlight-github-alerts@0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): + starlight-github-alerts@0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) satteri: 0.9.5 unist-util-visit: 5.1.0 - starlight-image-zoom@0.15.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): + starlight-image-zoom@0.15.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) mdast-util-mdx-jsx: 3.2.0 rehype-raw: 7.0.0 unist-util-visit: 5.1.0 @@ -8525,16 +8601,16 @@ snapshots: transitivePeerDependencies: - supports-color - starlight-kbd@0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): + starlight-kbd@0.4.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) - starlight-links-validator@0.25.2(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + starlight-links-validator@0.25.2(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@astrojs/markdown-satteri': 0.3.4 - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) '@types/picomatch': 4.0.3 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) github-slugger: 2.0.0 hast-util-from-html: 2.0.3 is-absolute-url: 5.0.0 @@ -8548,13 +8624,13 @@ snapshots: transitivePeerDependencies: - supports-color - starlight-llms-txt@0.11.0(@astrojs/markdown-satteri@0.3.4)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + starlight-llms-txt@0.11.0(@astrojs/markdown-satteri@0.3.4)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: - '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) '@types/hast': 3.0.5 '@types/micromatch': 4.0.10 - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) github-slugger: 2.0.0 hast-util-select: 6.0.4 micromatch: 4.0.8 @@ -8568,20 +8644,20 @@ snapshots: - '@astrojs/markdown-satteri' - supports-color - starlight-page-actions@0.7.0(patch_hash=584b28601fea4de666f3eb91e3425afd11f8da7e4d8aba649c15ec5be5b5d54c)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + starlight-page-actions@0.7.0(patch_hash=584b28601fea4de666f3eb91e3425afd11f8da7e4d8aba649c15ec5be5b5d54c)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) tidymd: 0.1.0 vite-plugin-static-copy: 4.1.1(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) vite-plugin-virtual: 0.5.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) transitivePeerDependencies: - vite - starlight-plugin-icons@1.1.6(patch_hash=ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)(unocss@66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(zod@4.4.3): + starlight-plugin-icons@1.1.6(patch_hash=ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)(unocss@66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(zod@4.4.3): dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) - astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) glob: 11.1.0 hast-util-select: 6.0.4 hast-util-to-string: 3.0.1 @@ -8592,11 +8668,13 @@ snapshots: unocss: 66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) zod: 4.4.3 - starlight-sidebar-topics@0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): + starlight-sidebar-topics@0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)): dependencies: - '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) + '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3) picomatch: 4.0.4 + statuses@2.0.2: {} + std-env@4.2.0: {} stream-replace-string@2.0.0: {} @@ -8673,6 +8751,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + totalist@3.0.1: {} trim-lines@3.0.1: {} diff --git a/src/frontend/scripts/build-hybrid.mjs b/src/frontend/scripts/build-hybrid.mjs new file mode 100644 index 0000000000..f1ffff9a63 --- /dev/null +++ b/src/frontend/scripts/build-hybrid.mjs @@ -0,0 +1,9 @@ +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const cwd = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const env = { ...process.env, ASPIRE_HYBRID_API_DOCS: '1' }; +execFileSync('pnpm', [process.argv.includes('--skip-search') ? 'build:skip-search' : 'build'], { + cwd, env, stdio: 'inherit', shell: process.platform === 'win32', +}); diff --git a/src/frontend/scripts/serve-api-docs.mjs b/src/frontend/scripts/serve-api-docs.mjs new file mode 100644 index 0000000000..ab0bff6c1f --- /dev/null +++ b/src/frontend/scripts/serve-api-docs.mjs @@ -0,0 +1,90 @@ +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve, relative, isAbsolute } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const frontend = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifestPath = resolve(process.env.HYBRID_API_DOCS_MANIFEST || resolve(frontend, 'dist-hybrid/hybrid-api-docs.json')); +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +if (manifest.schemaVersion !== 1 || manifest.mode !== 'hybrid-api-docs' || !/^[a-f0-9]{64}$/.test(manifest.buildId)) { + throw new Error('Expected a compatible prebuilt hybrid-api-docs.json artifact.'); +} +if (manifest.platform !== process.platform) { + throw new Error(`This Astro adapter artifact targets ${manifest.platform}; download the ${process.platform} POC artifact instead.`); +} +const root = dirname(manifestPath); +const entry = resolve(root, manifest.server); +const entryRelative = relative(root, entry); +if (isAbsolute(entryRelative) || entryRelative.startsWith('..')) throw new Error('Renderer entrypoint escapes its artifact.'); +const { handler } = await import(pathToFileURL(entry).href); +const paths = new Set(manifest.routes.flatMap(({ html, markdown }) => html ? [html, markdown] : [markdown])); +let requests = 0; +let cacheHits = 0; +let cacheMisses = 0; +let ready = false; +const started = process.memoryUsage(); +const server = http.createServer((req, res) => { + res.setHeader('X-Aspire-Build-Id', manifest.buildId); + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/__hybrid-api-docs/health') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.statusCode = ready ? 200 : 503; + res.end(JSON.stringify({ ready, buildId: manifest.buildId, requests, cacheHits, cacheMisses, started, memory: process.memoryUsage() })); + return; + } + if (!['GET', 'HEAD'].includes(req.method)) { + res.writeHead(405, { Allow: 'GET, HEAD' }); + res.end(); + return; + } + if (!paths.has(url.pathname) && !['/_image', '/_image/'].includes(url.pathname)) { + res.writeHead(404); + res.end('API route not found'); + return; + } + requests++; + res.once('finish', () => { + if (res.getHeader('X-Astro-Cache') === 'HIT') cacheHits++; + if (res.getHeader('X-Astro-Cache') === 'MISS') cacheMisses++; + }); + handler(req, res, () => { + res.writeHead(404); + res.end('API route not found'); + }).catch((error) => { + console.error(`[hybrid-api-docs] Rendering ${url.pathname} failed`, error); + if (res.headersSent) res.destroy(error); + else { + res.writeHead(500, { 'Cache-Control': 'no-store' }); + res.end('API rendering failed'); + } + }); +}); +server.on('error', (error) => { console.error(error); process.exitCode = 1; }); +server.listen(Number(process.env.PORT || 4323), process.env.HOST || '127.0.0.1', async () => { + try { + const path = manifest.routes.find(({ html }) => html?.split('/').length >= 7)?.html; + if (!path) throw new Error('The artifact has no API detail route for rendering readiness.'); + const site = new URL(manifest.site); + const address = server.address(); + const response = await fetch(`http://127.0.0.1:${address.port}${path}`, { + headers: { + Host: site.host, 'X-Forwarded-Host': site.host, 'X-Forwarded-Proto': site.protocol.slice(0, -1), + 'X-Aspire-Cache-Bypass': '1', + }, + signal: AbortSignal.timeout(30000), + }); + const body = await response.text(); + if (!response.ok || !body.includes(' { server.close(); server.closeAllConnections(); }); +} diff --git a/src/frontend/scripts/test-hybrid-host.mjs b/src/frontend/scripts/test-hybrid-host.mjs new file mode 100644 index 0000000000..d88a98b8da --- /dev/null +++ b/src/frontend/scripts/test-hybrid-host.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import { execFile, execFileSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifestPath = resolve(root, 'dist-hybrid/hybrid-api-docs.json'); +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +const children = []; +const containerMode = process.env.HYBRID_TEST_CONTAINERS === '1'; +const hostContainer = `hybrid-host-${process.pid}`; +const workerContainer = `hybrid-renderer-${process.pid}`; +const standbyContainer = `hybrid-standby-${process.pid}`; +const execute = promisify(execFile); +let output = ''; + +function launch(command, args, env) { + const child = spawn(command, args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' && command === 'pnpm' }); + children.push(child); + child.on('error', (error) => { output += `${command}: ${error.message}\n`; }); + for (const stream of [child.stdout, child.stderr]) stream.on('data', (data) => { output += data.toString(); }); + return child; +} + +async function stopWorker(child, name) { + const stopped = once(child, 'exit'); + if (containerMode) execFileSync('docker', ['stop', '--time', '2', name]); + else child.kill(); + await stopped; + if (containerMode) execFileSync('docker', ['rm', name]); +} + +async function request(url, init = {}) { + const container = { '14323': workerContainer, '14325': standbyContainer }[new URL(url).port]; + if (!containerMode || !container) return fetch(url, init); + const { stdout } = await execute('docker', ['exec', container, 'node', '--input-type=module', '-e', + `const response = await fetch(${JSON.stringify(url)}, { + headers: ${JSON.stringify(init.headers ?? {})}, signal: AbortSignal.timeout(2000) + }); + console.log(JSON.stringify({ + body: await response.text(), status: response.status, headers: [...response.headers] + }));`], { timeout: 10000, maxBuffer: 64 * 1024 * 1024 }); + const response = JSON.parse(stdout); + return new Response(response.body, { status: response.status, headers: response.headers }); +} + +async function ready(url, child) { + for (let attempt = 0; attempt < 120; attempt++) { + try { + const response = await request(url, { signal: AbortSignal.timeout(2000) }); + if (response.ok) return; + } catch (error) { + if (child.exitCode !== null) throw new Error(`POC process exited: ${output}`, { cause: error }); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`POC endpoint did not become ready: ${url}\n${output}`); +} + +const workerUrl = 'http://127.0.0.1:14323'; +const standbyUrl = 'http://127.0.0.1:14325'; +const hostUrl = 'http://127.0.0.1:14324'; +const measures = {}; +try { + let host; + if (containerMode) { + host = launch('docker', ['run', '--name', hostContainer, + '--add-host', 'host.docker.internal:host-gateway', + '-p', '14324:8080', + '-e', 'ASPNETCORE_ENVIRONMENT=Production', + '-e', 'HybridApiDocs__ArtifactPath=/app/hybrid/hybrid-api-docs.json', + '-e', `HybridApiDocs__RendererUrls__0=${workerUrl}`, + '-e', `HybridApiDocs__RendererUrls__1=${standbyUrl}`, + '-e', 'ConnectionStrings__cache=host.docker.internal:6379,abortConnect=false', + 'hybrid-poc-host:test'], process.env); + await ready(`${hostUrl}/healthz`, host); + } + function workerProcess(name, port) { + return containerMode + ? launch('docker', ['run', '--name', name, '--network', `container:${hostContainer}`, + '-e', `PORT=${port}`, 'hybrid-poc-renderer:test'], process.env) + : launch(process.execPath, ['scripts/serve-api-docs.mjs'], { + ...process.env, PORT: String(port), HYBRID_API_DOCS_MANIFEST: manifestPath, + }); + } + const worker = workerProcess(workerContainer, 14323); + const standby = workerProcess(standbyContainer, 14325); + await ready(`${workerUrl}/__hybrid-api-docs/health`, worker); + await ready(`${standbyUrl}/__hybrid-api-docs/health`, standby); + if (!containerMode) host = launch('dotnet', [resolve(root, '../statichost/StaticHost/bin/Release/net10.0/StaticHost.dll')], { + ...process.env, + ASPNETCORE_URLS: hostUrl, + ASPNETCORE_ENVIRONMENT: 'Production', + HybridApiDocs__ArtifactPath: manifestPath, + HybridApiDocs__RendererUrls__0: workerUrl, + HybridApiDocs__RendererUrls__1: standbyUrl, + Live__Twitch__ClientId: '', + Live__YouTube__ApiKey: '', + }); + await ready(`${hostUrl}/healthz`, host); + await ready(`${hostUrl}/healthz/api-docs`, host); + if (containerMode) { + const containers = JSON.parse(execFileSync('docker', ['inspect', hostContainer, workerContainer, standbyContainer], { encoding: 'utf8' })); + assert.ok(containers.every(container => container.Mounts.length === 0), 'No checkout or shared volume may supply runtime files.'); + assert.deepEqual(Object.keys(containers[0].HostConfig.PortBindings), ['8080/tcp']); + assert.ok(containers.slice(1).every(container => + [`container:${hostContainer}`, `container:${containers[0].Id}`].includes(container.HostConfig.NetworkMode))); + } + const statsOf = async (url) => (await request(`${url}/__hybrid-api-docs/health`)).json(); + // Mirrors HybridApiDocsEndpointPool.PreferredIndex: pages are routed to a stable worker. + const ownerOf = (path) => { + let hash = 2166136261; + for (const byte of Buffer.from(path)) hash = Math.imul(hash ^ byte, 16777619) >>> 0; + return hash % 2; + }; + const stats = async () => { + const all = await Promise.all([workerUrl, standbyUrl].map(statsOf)); + const sum = (key) => all.reduce((total, worker) => total + worker[key], 0); + return { requests: sum('requests'), cacheHits: sum('cacheHits'), cacheMisses: sum('cacheMisses'), workers: all }; + }; + async function page(path, init = {}) { + const start = performance.now(); + const response = await fetch(hostUrl + path, { ...init, signal: AbortSignal.timeout(60000) }); + const text = await response.text(); + assert.equal(response.status, 200, `${path}: ${response.status}\n${text.slice(0, 500)}\n${output}`); + measures[path] ??= []; + measures[path].push(Math.round((performance.now() - start) * 100) / 100); + return { response, text }; + } + const initial = await stats(); + const home = await page('/'); + assert.ok(!home.text.includes('Aspire Static Host'), 'The placeholder wwwroot home must not shadow the hybrid homepage.'); + await page('/docs/'); + assert.equal((await stats()).requests, initial.requests); + // Every kind of file the static build emits (recordings, Pagefind indexes, WASM, ...) must be served. + const clientRoot = resolve(dirname(manifestPath), manifest.client); + const samples = new Map(); + for (const entry of await readdir(clientRoot, { recursive: true, withFileTypes: true })) { + const extension = entry.name.includes('.') ? entry.name.slice(entry.name.lastIndexOf('.')) : ''; + if (!entry.isFile() || !extension || extension === '.md' || samples.has(extension)) continue; + const relative = resolve(entry.parentPath, entry.name).slice(clientRoot.length).replaceAll('\\', '/'); + samples.set(extension, relative.startsWith('/') ? relative : `/${relative}`); + } + for (const [extension, path] of samples) { + const response = await fetch(hostUrl + path.split('/').map(encodeURIComponent).join('/')); + assert.equal(response.status, 200, `Client files ending in ${extension} must be served: ${path}`); + await response.arrayBuffer(); + } + assert.ok(['.cast', '.pagefind', '.pf_meta'].every((extension) => samples.has(extension)), 'The sample set must cover the file types that need explicit serving.'); + const packageRoute = manifest.routes.find(({ html }) => html?.startsWith('/reference/api/csharp/') && html.split('/').length === 6); + assert.ok(packageRoute); + const csharp = await page(packageRoute.html); + assert.equal(csharp.response.headers.get('X-Astro-Cache'), 'MISS'); + assert.equal(csharp.response.headers.get('Cache-Control'), 'no-cache'); + const etag = csharp.response.headers.get('ETag'); + assert.ok(etag?.startsWith('W/')); + const cold = await stats(); + assert.equal(cold.cacheMisses, initial.cacheMisses + 1); + const csharpHit = await page(packageRoute.html); + assert.equal(csharpHit.text, csharp.text); + assert.equal(csharpHit.response.headers.get('X-Astro-Cache'), 'HIT'); + assert.equal((await stats()).requests, cold.requests + 1, 'StaticHost must forward cache hits to Astro.'); + assert.equal((await stats()).cacheMisses, cold.cacheMisses); + const directHit = await request(`${[workerUrl, standbyUrl][ownerOf(packageRoute.html)]}${packageRoute.html}`, { + headers: { Host: 'aspire.dev', 'X-Forwarded-Host': 'aspire.dev', 'X-Forwarded-Proto': 'https' }, + }); + assert.equal(directHit.headers.get('X-Astro-Cache'), 'HIT', 'The same response must be cached in Astro itself.'); + assert.equal(await directHit.text(), csharp.text); + const beforeRevalidation = await stats(); + const revalidated = await fetch(hostUrl + packageRoute.html, { headers: { 'If-None-Match': etag } }); + assert.equal(revalidated.status, 304); + assert.equal((await stats()).requests, beforeRevalidation.requests, 'A matching validator must not reach the renderer.'); + const queried = await page(`${packageRoute.html}?cachebust=1`); + assert.equal(queried.response.headers.get('X-Astro-Cache'), 'HIT', 'Query strings must not multiply renders.'); + const credentialRequest = await page(packageRoute.html, { headers: { Authorization: 'Bearer poc-test' } }); + assert.equal(credentialRequest.response.headers.get('X-Astro-Cache'), 'HIT', 'Credentials must not bypass the native cache.'); + assert.equal((await stats()).cacheMisses, cold.cacheMisses); + const typescript = await page('/reference/api/typescript/'); + assert.equal(typescript.response.headers.get('X-Astro-Cache'), null, 'The landing pages are static files.'); + assert.ok(typescript.text.includes('data-api-search-index-url="/reference/api-search/typescript.json"')); + assert.ok(Buffer.byteLength(typescript.text) < 2_000_000, 'The search index must not be embedded in the page.'); + const searchIndex = await page('/reference/api-search/typescript.json'); + assert.equal(searchIndex.response.headers.get('X-Astro-Cache'), null, 'The index is served as a static file.'); + assert.ok(Buffer.byteLength(searchIndex.text) >= 13_000_000); + assert.ok(JSON.parse(searchIndex.text).length > 30_000); + const stampede = manifest.routes.find(({ html }) => html?.startsWith('/reference/api/typescript/') && html.split('/').length === 6); + assert.ok(stampede); + const beforeStampede = await stats(); + const stampedePages = await Promise.all(Array.from({ length: 12 }, () => page(stampede.html))); + assert.ok(stampedePages.every(({ text }) => text === stampedePages[0].text)); + assert.ok((await stats()).cacheMisses - beforeStampede.cacheMisses <= 3, 'Concurrent requests for a cold page must share a render.'); + for (const language of ['csharp', 'typescript']) { + const route = manifest.routes.find(({ html }) => html?.startsWith(`/reference/api/${language}/`) && html.split('/').length >= 7); + assert.ok(route); + const detail = await page(route.html); + assert.equal(detail.response.headers.get('X-Astro-Cache'), 'MISS'); + const before = await stats(); + const pages = []; + for (let visit = 0; visit < 20; visit++) pages.push(await page(route.html)); + assert.ok(pages.every(({ text, response }) => text === detail.text && response.headers.get('X-Astro-Cache') === 'HIT')); + assert.equal((await stats()).requests, before.requests + 20); + assert.equal((await stats()).cacheMisses, before.cacheMisses); + const direct = await page(route.markdown); + assert.equal(direct.response.headers.get('X-Astro-Cache'), 'MISS'); + const negotiated = await page(route.html, { headers: { Accept: 'text/markdown' } }); + assert.equal(negotiated.response.headers.get('X-Astro-Cache'), 'HIT'); + assert.equal(negotiated.text, direct.text); + assert.ok(negotiated.response.headers.get('cache-control').includes('private')); + assert.ok(pages[0].response.headers.get('link').includes(route.markdown)); + } + const unknown = await fetch(`${hostUrl}/reference/api/not-a-page/`); + assert.equal(unknown.status, 404); + assert.ok((await unknown.text()).includes(' html && !measures[html] && ownerOf(html) === 0); + assert.ok(ownedByPrimary); + const failover = await page(ownedByPrimary.html); + assert.equal(failover.response.headers.get('X-Astro-Cache'), 'MISS', 'The standby owns its own native cache.'); + assert.equal((await page(ownedByPrimary.html)).response.headers.get('X-Astro-Cache'), 'HIT'); + assert.equal((await fetch(`${hostUrl}/healthz/api-docs`)).status, 200); + await stopWorker(standby, standbyContainer); + const previouslyCached = await fetch(hostUrl + ownedByPrimary.html); + assert.equal(previouslyCached.status, 502, 'No ASP.NET cache may hide loss of every renderer.'); + assert.ok((await previouslyCached.text()).includes('Try again'), 'An outage must explain itself and offer a retry.'); + assert.equal((await fetch(`${hostUrl}/healthz/api-docs`)).status, 503); + assert.equal((await fetch(`${hostUrl}/healthz`)).status, 200, 'Static pages must stay healthy without renderers.'); + await page('/docs/'); + const unwarmed = manifest.routes.find(({ html }) => html && !measures[html]); + assert.ok(unwarmed); + const failure = await fetch(hostUrl + unwarmed.html); + assert.equal(failure.status, 502); + const recovered = workerProcess(workerContainer, 14323); + await ready(`${workerUrl}/__hybrid-api-docs/health`, recovered); + await ready(`${hostUrl}/healthz/api-docs`, host); + assert.equal((await page(ownedByPrimary.html)).response.headers.get('X-Astro-Cache'), 'MISS'); + assert.equal((await page(ownedByPrimary.html)).response.headers.get('X-Astro-Cache'), 'HIT'); + const results = { success: true, separateContainers: containerMode, cacheOwner: 'astro-memory', primaryOutageFailover: true, rendererRecovery: true, buildId: manifest.buildId, measures, initial, final }; + await writeFile(resolve(root, 'hybrid-host-results.json'), JSON.stringify(results, null, 2)); + console.log(JSON.stringify(results, null, 2)); +} finally { + await writeFile(resolve(root, 'hybrid-host-processes.log'), output); + if (containerMode) { + for (const name of [workerContainer, standbyContainer, hostContainer]) { + const running = execFileSync('docker', ['ps', '-aq', '--filter', `name=^/${name}$`], { encoding: 'utf8' }).trim(); + if (running) execFileSync('docker', ['rm', '--force', name]); + } + } + for (const child of children) if (child.exitCode === null) child.kill(); +} diff --git a/src/frontend/scripts/verify-hybrid-output.mjs b/src/frontend/scripts/verify-hybrid-output.mjs new file mode 100644 index 0000000000..9251635fca --- /dev/null +++ b/src/frontend/scripts/verify-hybrid-output.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { measureOutput } from './measure-build-output.mjs'; + +export async function verifyHybridOutput(staticRoot, hybridRoot) { + const manifest = JSON.parse(await readFile(resolve(hybridRoot, 'hybrid-api-docs.json'), 'utf8')); + assert.equal(manifest.mode, 'hybrid-api-docs'); + assert.equal(manifest.schemaVersion, 1); + const [baseline, hybrid, server] = await Promise.all([ + measureOutput(staticRoot), measureOutput(resolve(hybridRoot, manifest.client)), + measureOutput(resolve(hybridRoot, 'server')), + ]); + const landing = /^reference\/api\/(?:csharp|typescript)(?:\/index\.html|\.md)$/; + const api = (path) => path.startsWith('reference/api/') && /\.(html|md)$/.test(path) && !landing.test(path); + assert.ok(baseline.files.some(({ path }) => api(path)), 'The baseline must include static API output.'); + assert.deepEqual(hybrid.files.filter(({ path }) => api(path)), [], 'API HTML/Markdown was prerendered.'); + assert.ok(server.files.some(({ path }) => path === 'entry.mjs'), 'Missing private renderer entrypoint.'); + const redirectFiles = new Set(manifest.redirects.map(({ path }) => `${path.replace(/^\/|\/$/g, '')}/index.html`)); + const clientFiles = new Set(hybrid.files.map(({ path }) => path)); + for (const redirect of manifest.redirects) { + const destination = redirect.destination.replace(/^\/|\/$/g, ''); + assert.ok(clientFiles.has(`${destination}/index.html`) || clientFiles.has(`${destination}.html`) + || manifest.routes.some(({ html }) => html === redirect.destination) + || manifest.redirects.some(({ path }) => path === redirect.destination), + `Configured redirect has no destination: ${redirect.path} -> ${redirect.destination}`); + } + const nonApi = (files) => files.filter(({ path }) => + /\.(html|md)$/.test(path) && !api(path) && !redirectFiles.has(path)).map(({ path }) => path); + assert.deepEqual(nonApi(hybrid.files), nonApi(baseline.files), 'Non-API page inventory changed.'); + for (const page of ['csharp/index.html', 'typescript/index.html', 'csharp.md', 'typescript.md']) { + assert.ok(clientFiles.has(`reference/api/${page}`), `The API landing pages must stay prerendered: ${page}`); + } + const searchIndex = (files) => files.map(({ path }) => path) + .filter((path) => path.startsWith('reference/api-search/') && path.endsWith('.json')); + assert.ok(searchIndex(hybrid.files).includes('reference/api-search/typescript.json'), 'Missing API search index.'); + assert.deepEqual(searchIndex(hybrid.files), searchIndex(baseline.files), 'API search indexes must stay prerendered.'); + for (const file of ['index.html', '404.html', 'llms.txt', 'sitemap-index.xml', 'sitemap-api.xml']) { + assert.ok(hybrid.files.some(({ path }) => path === file), `Missing ${file}`); + } + assert.ok(!hybrid.files.some(({ path }) => path === 'hybrid-api-docs.json' || path.startsWith('server/'))); + const sitemap = await readFile(resolve(hybridRoot, manifest.client, 'sitemap-api.xml'), 'utf8'); + for (const { html } of manifest.routes) { + if (html) assert.ok(sitemap.includes(`${manifest.site}${html}`), `Missing sitemap URL ${html}`); + } + return { + buildId: manifest.buildId, + baseline: { bytes: baseline.totalBytes, files: baseline.files.length }, + hybridClient: { bytes: hybrid.totalBytes, files: hybrid.files.length }, + hybridServer: { bytes: server.totalBytes, files: server.files.length }, + staticApiFilesAvoided: baseline.files.filter(({ path }) => api(path)).length, + nonApiPages: nonApi(hybrid.files).length, + apiRoutes: manifest.routes.length, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const result = await verifyHybridOutput(resolve(process.argv[2] || 'dist'), resolve(process.argv[3] || 'dist-hybrid')); + await writeFile(resolve('hybrid-output-results.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); +} diff --git a/src/frontend/src/components/api-reference/search-lifecycle.ts b/src/frontend/src/components/api-reference/search-lifecycle.ts index a0a0856c39..5ac7573d3c 100644 --- a/src/frontend/src/components/api-reference/search-lifecycle.ts +++ b/src/frontend/src/components/api-reference/search-lifecycle.ts @@ -48,3 +48,43 @@ export function readApiSearchIndex(root: HTMLElement): T[] { } return JSON.parse(indexJson) as T[]; } + +/** The index could not be downloaded or understood, so trying again may succeed. */ +export class ApiSearchIndexLoadError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ApiSearchIndexLoadError'; + } +} + +/** + * Large indexes are served as a separate JSON file named by `data-api-search-index-url`. + * Small ones are still embedded in the page and read synchronously. + */ +export async function loadApiSearchIndex(root: HTMLElement, signal: AbortSignal): Promise { + const url = root.dataset.apiSearchIndexUrl; + if (!url) return readApiSearchIndex(root); + + let response: Response; + try { + response = await fetch(url, { signal, headers: { Accept: 'application/json' } }); + } catch (error) { + if (signal.aborted) throw error; + throw new ApiSearchIndexLoadError('API search: the search index could not be downloaded', { cause: error }); + } + if (!response.ok) { + throw new ApiSearchIndexLoadError(`API search: the search index request failed (HTTP ${response.status})`); + } + + let index: unknown; + try { + index = await response.json(); + } catch (error) { + if (signal.aborted) throw error; + throw new ApiSearchIndexLoadError('API search: the search index is not valid JSON', { cause: error }); + } + if (!Array.isArray(index)) { + throw new ApiSearchIndexLoadError('API search: the search index is not a list'); + } + return index as T[]; +} diff --git a/src/frontend/src/middleware.ts b/src/frontend/src/middleware.ts index 8883c4c8de..a355e81504 100644 --- a/src/frontend/src/middleware.ts +++ b/src/frontend/src/middleware.ts @@ -1,5 +1,8 @@ import { defineMiddleware } from 'astro:middleware'; import { stripApiReferenceLocale } from './utils/api-reference-routes'; +import { withHybridApiCache } from './utils/hybrid-api-cache'; + +declare const __ASPIRE_HYBRID_API_DOCS__: boolean; const nestedApiMarkdownPattern = /^\/reference\/(?:api\/(?:csharp|typescript)|samples)\/.+\.md$/; @@ -28,10 +31,14 @@ export const onRequest = defineMiddleware((context, next) => { return redirect(`${canonicalApiPath}${search}`); } - if (nestedApiMarkdownPattern.test(pathname) || cliConfigSchemaPattern.test(pathname)) { + const hybridApiMarkdown = __ASPIRE_HYBRID_API_DOCS__ && pathname.startsWith('/reference/api/'); + if ((!hybridApiMarkdown && nestedApiMarkdownPattern.test(pathname)) || cliConfigSchemaPattern.test(pathname)) { return redirect(`${pathname}/${search}`); } } - return next(); + const pathname = context.url?.pathname ?? new URL(context.request.url).pathname; + return __ASPIRE_HYBRID_API_DOCS__ && !context.isPrerendered && + (pathname.startsWith('/reference/api/') || pathname === '/_image' || pathname === '/_image/') + ? withHybridApiCache(context, next) : next(); }); \ No newline at end of file diff --git a/src/frontend/src/pages/reference/api-search/csharp.json.ts b/src/frontend/src/pages/reference/api-search/csharp.json.ts new file mode 100644 index 0000000000..1e8999c53b --- /dev/null +++ b/src/frontend/src/pages/reference/api-search/csharp.json.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro'; + +import { apiSearchIndexResponse, buildCSharpApiSearchIndex } from '@utils/api-search-index'; +import { getPackages } from '@utils/packages'; + +export const prerender = true; + +export const GET: APIRoute = async () => { + const packages = (await getPackages()) + .map((entry) => entry.data) + .sort((a, b) => a.package.name.localeCompare(b.package.name)); + return apiSearchIndexResponse(buildCSharpApiSearchIndex(packages).index); +}; diff --git a/src/frontend/src/pages/reference/api-search/csharp/[package].json.ts b/src/frontend/src/pages/reference/api-search/csharp/[package].json.ts new file mode 100644 index 0000000000..41e65e45b3 --- /dev/null +++ b/src/frontend/src/pages/reference/api-search/csharp/[package].json.ts @@ -0,0 +1,24 @@ +import type { APIRoute } from 'astro'; + +import { apiSearchIndexResponse, buildCSharpPackageSearchIndex } from '@utils/api-search-index'; +import type { PackageApiDocument } from '@utils/packages'; +import { getPackages, packageSlug } from '@utils/packages'; + +export const prerender = true; + +type RouteProps = { + pkg: PackageApiDocument; +}; + +export async function getStaticPaths() { + const packages = await getPackages(); + return packages.map((entry) => ({ + params: { package: packageSlug(entry.data.package.name) }, + props: { pkg: entry.data } satisfies RouteProps, + })); +} + +export const GET: APIRoute = ({ props }) => { + const { pkg } = props as RouteProps; + return apiSearchIndexResponse(buildCSharpPackageSearchIndex(pkg.types.filter((type) => type.name)).index); +}; diff --git a/src/frontend/src/pages/reference/api-search/typescript.json.ts b/src/frontend/src/pages/reference/api-search/typescript.json.ts new file mode 100644 index 0000000000..99440f55b7 --- /dev/null +++ b/src/frontend/src/pages/reference/api-search/typescript.json.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from 'astro'; + +import { apiSearchIndexResponse } from '@utils/api-search-index'; +import { buildTsApiSearchIndex } from '@utils/ts-api-search'; +import { getTsModules } from '@utils/ts-modules'; + +export const prerender = true; + +export const GET: APIRoute = async () => { + const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + const modules = (await getTsModules()) + .map((entry) => entry.data) + .sort((a, b) => a.package.name.localeCompare(b.package.name)); + return apiSearchIndexResponse(buildTsApiSearchIndex(modules, base)); +}; diff --git a/src/frontend/src/pages/reference/api-search/typescript/[module].json.ts b/src/frontend/src/pages/reference/api-search/typescript/[module].json.ts new file mode 100644 index 0000000000..2034cb156c --- /dev/null +++ b/src/frontend/src/pages/reference/api-search/typescript/[module].json.ts @@ -0,0 +1,25 @@ +import type { APIRoute } from 'astro'; + +import { apiSearchIndexResponse, buildTsModuleSearchIndex } from '@utils/api-search-index'; +import type { TsApiDocument } from '@utils/ts-modules'; +import { getTsModules, tsModuleSlug } from '@utils/ts-modules'; + +export const prerender = true; + +type RouteProps = { + pkg: TsApiDocument; +}; + +export async function getStaticPaths() { + const modules = await getTsModules(); + return modules.map((entry) => ({ + params: { module: tsModuleSlug(entry.data.package.name) }, + props: { pkg: entry.data } satisfies RouteProps, + })); +} + +export const GET: APIRoute = ({ props }) => { + const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + const { pkg } = props as RouteProps; + return apiSearchIndexResponse(buildTsModuleSearchIndex(pkg, base).index); +}; diff --git a/src/frontend/src/pages/reference/api/_search-presentation.ts b/src/frontend/src/pages/reference/api/_search-presentation.ts index 8c0d37c97e..f759563619 100644 --- a/src/frontend/src/pages/reference/api/_search-presentation.ts +++ b/src/frontend/src/pages/reference/api/_search-presentation.ts @@ -1,28 +1,87 @@ import { createSearchEmptyState, searchRecoveryAction } from '@components/search/search-empty-state'; +import { ApiSearchIndexLoadError } from '@components/api-reference/search-lifecycle'; -/** A corrupt/unavailable embedded index is not a valid zero-result response. */ -export function withApiSearchFallback(mount: (root: HTMLElement, signal: AbortSignal) => void) { +const LOADING_TEXT = 'Loading search index…'; +const LOADING_DELAY_MS = 150; + +/** + * A corrupt or unavailable index is not a valid zero-result response. Downloaded + * indexes can be retried in place; anything else needs a page reload. A slow + * download shows a loading message only after a short delay, to avoid flashing. + */ +export function withApiSearchFallback(mount: (root: HTMLElement, signal: AbortSignal) => unknown) { return (root: HTMLElement, signal: AbortSignal) => { - const controller = new AbortController(); - signal.addEventListener('abort', () => controller.abort(), { once: true }); - try { - mount(root, controller.signal); - } catch (error) { - controller.abort(); - console.error('API search unavailable', error); + const statusElements = [...root.querySelectorAll('[id$="-search-status"], [id$="-search-count"]')]; + const originalText = new Map(statusElements.map(element => [element, element.textContent])); + const disabled: (HTMLInputElement | HTMLButtonElement)[] = []; + + const showLoading = (attemptSignal: AbortSignal) => { + const timer = setTimeout(() => { + root.dataset.apiSearchState = 'loading'; + statusElements.forEach(element => { element.textContent = LOADING_TEXT; }); + }, LOADING_DELAY_MS); + attemptSignal.addEventListener('abort', () => clearTimeout(timer), { once: true }); + return () => { + clearTimeout(timer); + delete root.dataset.apiSearchState; + statusElements.forEach(element => { + if (element.textContent === LOADING_TEXT) element.textContent = originalText.get(element) ?? ''; + }); + }; + }; + + const retry = (results: HTMLElement) => { + results.replaceChildren(); + results.style.display = 'none'; + statusElements.forEach(element => { element.textContent = originalText.get(element) ?? ''; }); + disabled.splice(0).forEach(element => { element.disabled = false; }); + start(); + }; + + const showUnavailable = (retryable: boolean) => { const results = root.querySelector('[id$="-search-results"]'); if (!results) return; results.style.display = ''; results.replaceChildren(createSearchEmptyState({ title: 'Search unavailable', - hint: 'Reload this page to try again. You can still browse the API reference below.', - action: { label: 'Reload page', run: () => { if (!signal.aborted) window.location.reload(); } }, + hint: retryable + ? 'The search index could not be loaded. You can still browse the API reference below.' + : 'Reload this page to try again. You can still browse the API reference below.', + action: retryable + ? { label: 'Try again', run: () => { if (!signal.aborted) retry(results); } } + : { label: 'Reload page', run: () => { if (!signal.aborted) window.location.reload(); } }, })); - root.querySelectorAll('[id$="-search-status"], [id$="-search-count"]') - .forEach(element => { element.textContent = 'Search unavailable'; }); + statusElements.forEach(element => { element.textContent = 'Search unavailable'; }); root.querySelectorAll('.inpage-search input, .inpage-search button') - .forEach(element => { if (!results.contains(element)) element.disabled = true; }); + .forEach(element => { + if (results.contains(element)) return; + element.disabled = true; + disabled.push(element); + }); + }; + + function start() { + const controller = new AbortController(); + signal.addEventListener('abort', () => controller.abort(), { once: true }); + const stopLoading = showLoading(controller.signal); + const fail = (error: unknown) => { + stopLoading(); + controller.abort(); + if (signal.aborted) return; + console.error('API search unavailable', error); + showUnavailable(error instanceof ApiSearchIndexLoadError); + }; + try { + const pending = mount(root, controller.signal); + if (typeof (pending as PromiseLike | undefined)?.then === 'function') { + (pending as PromiseLike).then(stopLoading, fail); + } else stopLoading(); + } catch (error) { + fail(error); + } } + + start(); }; } diff --git a/src/frontend/src/pages/reference/api/csharp/[package].md.ts b/src/frontend/src/pages/reference/api/csharp/[package].md.ts index 1aa3b064e4..be29c6c0ee 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package].md.ts +++ b/src/frontend/src/pages/reference/api/csharp/[package].md.ts @@ -4,6 +4,7 @@ import { markdownResponse } from '@utils/api-markdown-shared'; import { renderCSharpPackageMarkdown } from '@utils/csharp-api-markdown'; import type { PackageApiDocument } from '@utils/packages'; import { getPackages, packageSlug } from '@utils/packages'; +import { resolveCSharpApiRoute } from '@utils/api-route-data'; export const prerender = true; @@ -27,8 +28,9 @@ export async function getStaticPaths(): Promise { })); } -export const GET: APIRoute = ({ props }) => { +export const GET: APIRoute = async ({ props, url }) => { const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const routeProps = props as RouteProps; + const routeProps = (props.pkg ? props : await resolveCSharpApiRoute(url.pathname)) as RouteProps | undefined; + if (!routeProps) return new Response('API package not found', { status: 404 }); return markdownResponse(renderCSharpPackageMarkdown(routeProps.pkg, base)); }; diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/[type].md.ts b/src/frontend/src/pages/reference/api/csharp/[package]/[type].md.ts index b6b78253a4..38c553c428 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/[type].md.ts +++ b/src/frontend/src/pages/reference/api/csharp/[package]/[type].md.ts @@ -4,6 +4,7 @@ import { markdownResponse } from '@utils/api-markdown-shared'; import { renderCSharpTypeMarkdown } from '@utils/csharp-api-markdown'; import type { PackageApiDocument, PackageType } from '@utils/packages'; import { genericArity, getPackages, packageSlug, slugify } from '@utils/packages'; +import { resolveCSharpApiRoute } from '@utils/api-route-data'; export const prerender = true; @@ -49,9 +50,10 @@ export async function getStaticPaths(): Promise { return paths; } -export const GET: APIRoute = ({ props }) => { +export const GET: APIRoute = async ({ props, url }) => { const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const routeProps = props as RouteProps; + const routeProps = (props.pkg ? props : await resolveCSharpApiRoute(url.pathname)) as RouteProps | undefined; + if (!routeProps?.type) return new Response('API type not found', { status: 404 }); return markdownResponse( renderCSharpTypeMarkdown(routeProps.pkg, routeProps.type, routeProps.allTypes, base) diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro index 882a899aa8..cc65022d60 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro +++ b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro @@ -9,6 +9,7 @@ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; import { getApiReferenceSidebar } from '@utils/api-sidebar'; import Breadcrumb from '@components/Breadcrumb.astro'; import MemberCard from '@components/api-reference/MemberCard.astro'; +import { resolveCSharpApiRoute } from '@utils/api-route-data'; import DocContent from '@components/api-reference/DocContent.astro'; import { slugify, @@ -65,7 +66,9 @@ export async function getStaticPaths() { return paths; } -const { pkg, type, allTypes, kind } = Astro.props; +const routeProps = Astro.props.pkg ? Astro.props : await resolveCSharpApiRoute(Astro.url.pathname); +if (!routeProps?.type || !routeProps.kind) return new Response('API member group not found', { status: 404 }); +const { pkg, type, allTypes, kind } = routeProps; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); const apiSidebar = await getApiReferenceSidebar({ packageName: pkg.package.name }); diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].md.ts b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].md.ts index 58044dcb67..42bfb41dc5 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].md.ts +++ b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].md.ts @@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'; import { markdownResponse } from '@utils/api-markdown-shared'; import { renderCSharpMemberKindMarkdown } from '@utils/csharp-api-markdown'; +import { resolveCSharpApiRoute } from '@utils/api-route-data'; import type { PackageApiDocument, PackageMember, PackageType } from '@utils/packages'; import { genericArity, @@ -67,9 +68,10 @@ export async function getStaticPaths(): Promise { return paths; } -export const GET: APIRoute = ({ props }) => { +export const GET: APIRoute = async ({ props, url }) => { const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const routeProps = props as RouteProps; + const routeProps = (props.pkg ? props : await resolveCSharpApiRoute(url.pathname)) as RouteProps | undefined; + if (!routeProps?.type || !routeProps.memberKind) return new Response('API member group not found', { status: 404 }); return markdownResponse( renderCSharpMemberKindMarkdown( diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro index 0f0c8d0951..18756d5162 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro +++ b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro @@ -16,6 +16,7 @@ import AnchorHeading from '@components/api-reference/AnchorHeading.astro'; import InpageSearch from '@components/api-reference/InpageSearch.astro'; import PolyglotAttributePanel from '@components/api-reference/PolyglotAttributePanel.astro'; import { findAttribute, hasAttribute, resolveDiagnosticSlug } from '@utils/api-attributes'; +import { resolveCSharpApiRoute } from '@utils/api-route-data'; import { slugify, genericArity, @@ -54,7 +55,9 @@ export async function getStaticPaths() { return paths; } -const { pkg, type, allTypes } = Astro.props; +const routeProps = Astro.props.pkg ? Astro.props : await resolveCSharpApiRoute(Astro.url.pathname); +if (!routeProps?.type) return new Response('API type not found', { status: 404 }); +const { pkg, type, allTypes } = routeProps; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); const apiSidebar = await getApiReferenceSidebar({ packageName: pkg.package.name }); diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/index.astro b/src/frontend/src/pages/reference/api/csharp/[package]/index.astro index fe2d5c85e7..8af2488000 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/index.astro +++ b/src/frontend/src/pages/reference/api/csharp/[package]/index.astro @@ -7,7 +7,8 @@ * and collapsible namespace sections. */ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; -import { genericArity, typeDisplayName, groupTypesByNamespace, typeHref, nugetHref, packageSlug, slugify, getPackages, resolveMemberAnchors } from '@utils/packages'; +import { genericArity, typeDisplayName, groupTypesByNamespace, typeHref, nugetHref, packageSlug, getPackages } from '@utils/packages'; +import { apiSearchIndexUrl, buildCSharpPackageSearchIndex } from '@utils/api-search-index'; import { getApiReferenceSidebar } from '@utils/api-sidebar'; import InpageSearch from '@components/api-reference/InpageSearch.astro'; import Expand from '@components/Expand.astro'; @@ -15,6 +16,7 @@ import Breadcrumb from '@components/Breadcrumb.astro'; import DocContent from '@components/api-reference/DocContent.astro'; import NuGetButton from '@components/api-reference/NuGetButton.astro'; import { hasAttribute } from '@utils/api-attributes'; +import { resolveCSharpApiRoute } from '@utils/api-route-data'; export async function getStaticPaths() { const packages = await getPackages(); @@ -24,7 +26,9 @@ export async function getStaticPaths() { })); } -const { pkg } = Astro.props; +const routeProps = Astro.props.pkg ? Astro.props : await resolveCSharpApiRoute(Astro.url.pathname); +if (!routeProps) return new Response('API package not found', { status: 404 }); +const { pkg } = routeProps; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); const validTypes = pkg.types.filter((t: any) => t.name); // skip types with empty names const isOfficial = pkg.package.name.startsWith('Aspire.'); @@ -39,76 +43,9 @@ const headings = [...namespaceGroups.keys()].map((ns) => ({ /* Build package-scoped search index */ -interface PkgIndexEntry { - /** Short name */ - n: string; - /** Fully-qualified name */ - f: string; - /** Namespace */ - ns: string; - /** Kind (class, interface, enum, method, property, ...) */ - k: string; - /** Summary text (first ~160 chars) */ - s: string; - /** Parent type name (for members only) */ - t?: string; - /** Precomputed anchor slug (when it differs from the name-derived slug) */ - a?: string; -} - -function extractSummary(summary: any): string { - if (!summary || !Array.isArray(summary)) return ''; - return summary - .map((node: any) => { - if (node.kind === 'text') return node.text; - if (node.kind === 'cref') return node.value?.split('.').pop() ?? ''; - if (node.kind === 'paramref' || node.kind === 'typeparamref') return node.value ?? ''; - if (node.kind === 'langword') return node.value ?? ''; - return ''; - }) - .join('') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 160); -} - -const pkgIndex: PkgIndexEntry[] = []; -let memberCount = 0; - -for (const type of validTypes) { - const typeSlug = slugify(type.name, genericArity(type)); - pkgIndex.push({ - n: type.name, - f: type.fullName ?? type.name, - ns: type.namespace ?? '', - k: type.kind ?? 'class', - s: extractSummary(type.docs?.summary), - ...(typeSlug !== type.name.toLowerCase() ? { g: typeSlug } : {}), - }); - - const members: any[] = type.members ?? []; - const memberAnchors = resolveMemberAnchors(members); - for (const [memberIndex, member] of members.entries()) { - memberCount++; - const memberName = member.name === '.ctor' ? `${type.name} (constructor)` : member.name; - const memberFull = `${type.fullName ?? type.name}.${member.name === '.ctor' ? '.ctor' : member.name}`; - const slug = memberAnchors[memberIndex].exact; - const defaultSlug = memberName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); - pkgIndex.push({ - n: memberName, - f: memberFull, - ns: type.namespace ?? '', - k: member.kind ?? 'method', - s: extractSummary(member.docs?.summary), - t: type.name, - ...(slug !== defaultSlug ? { a: slug } : {}), - ...(typeSlug !== type.name.toLowerCase() ? { ts: typeSlug } : {}), - }); - } -} +const { index: pkgIndex, memberCount } = buildCSharpPackageSearchIndex(validTypes); const allKinds = [...new Set(pkgIndex.map(e => e.k))].sort(); -const pkgIndexJson = JSON.stringify(pkgIndex); const pkgName = pkg.package.name; function getCommonSourceDirectory(types: any[]): string | null { @@ -210,8 +147,7 @@ const packageSourceHref = pkg.package.sourceRepository && pkg.package.sourceComm -
- +

API reference temporarily unavailable

+

This page could not be loaded right now. Try again in a moment.

+ Try againBack to the docs
+ """); + + public static async Task WriteUnavailableAsync(HttpContext context, int statusCode) + { + var response = context.Response; + response.StatusCode = statusCode; + response.Headers.CacheControl = "no-store"; + response.Headers.RetryAfter = "5"; + response.ContentType = "text/html; charset=utf-8"; + response.ContentLength = Unavailable.Length; + if (!HttpMethods.IsHead(context.Request.Method)) await response.Body.WriteAsync(Unavailable, context.RequestAborted); + } +} diff --git a/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsExtensions.cs b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsExtensions.cs new file mode 100644 index 0000000000..f73575346c --- /dev/null +++ b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsExtensions.cs @@ -0,0 +1,80 @@ +using System.Net; +using Microsoft.AspNetCore.ResponseCompression; + +namespace StaticHost.HybridApiDocs; + +internal static class HybridApiDocsExtensions +{ + public static void AddHybridApiDocs(this IServiceCollection services, + HybridApiDocsArtifact artifact, string? rendererUrl) + => services.AddHybridApiDocs(artifact, [rendererUrl ?? ""]); + + public static void AddHybridApiDocs(this IServiceCollection services, + HybridApiDocsArtifact artifact, IEnumerable rendererUrls) + { + var endpoints = rendererUrls.Select(value => + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var endpoint) || + endpoint.Scheme is not ("http" or "https") || + endpoint.UserInfo.Length > 0 || endpoint.Query.Length > 0 || endpoint.Fragment.Length > 0) + throw new InvalidOperationException("HybridApiDocs:RendererUrls must specify private Astro worker endpoints."); + return endpoint; + }).Distinct().ToArray(); + if (endpoints.Length == 0) + throw new InvalidOperationException("At least one private Astro worker endpoint is required."); + // The hybrid client is served from plain files and renderer responses are forwarded + // uncompressed, so neither has the build-time precompression of static web assets. + services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.MimeTypes = ResponseCompressionDefaults.MimeTypes + .Union(["text/javascript", "text/markdown", "image/svg+xml"]); + }); + services.AddSingleton(artifact); + services.AddSingleton(new HybridApiDocsEndpointPool(endpoints)); + services.AddSingleton(); + services.AddHttpClient(client => + { + client.Timeout = Timeout.InfiniteTimeSpan; + client.MaxResponseContentBufferSize = HybridApiDocsRenderer.MaximumBodySize; + }).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + UseProxy = false, UseCookies = false, AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(5), + }); + } + + public static void UseHybridApiDocsRouting(this IApplicationBuilder app) + => app.UseMiddleware(); + + /// + /// Serves the prebuilt client as plain files. Unlike static web assets, UseStaticFiles refuses + /// extensions without a MIME mapping, which would drop Pagefind's index files and the terminal recordings. + /// + public static StaticFileOptions HybridStaticFileOptions() => new() + { + ServeUnknownFileTypes = true, + DefaultContentType = "application/octet-stream", + OnPrepareResponse = context => + { + context.Context.Response.Headers.CacheControl = + context.Context.Request.Path.StartsWithSegments("/_astro") + ? "public, max-age=31536000, immutable" : "no-cache"; + }, + }; + + public static void MapHybridApiDocs(this IEndpointRouteBuilder app) + { + app.MapMethods("/reference/api/{**path}", ["GET", "HEAD"], + (HttpContext context, HybridApiDocsRenderer renderer) => renderer.RenderAsync(context)); + app.MapMethods("/_image", ["GET", "HEAD"], + (HttpContext context, HybridApiDocsRenderer renderer) => renderer.RenderAsync(context)); + app.MapGet("/healthz/api-docs", async (HttpContext context, HybridApiDocsRenderer renderer) => + { + context.Response.Headers.CacheControl = "no-store"; + return Results.StatusCode(await renderer.IsReadyAsync(context.RequestAborted) + ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable); + }); + } +} diff --git a/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsFlights.cs b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsFlights.cs new file mode 100644 index 0000000000..702ecb7833 --- /dev/null +++ b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsFlights.cs @@ -0,0 +1,21 @@ +using System.Collections.Concurrent; + +namespace StaticHost.HybridApiDocs; + +internal sealed record HybridApiDocsResponse( + int Status, KeyValuePair[] Headers, byte[] Body, bool Unavailable, bool Cacheable) +{ + public string? CacheStatus => Headers.FirstOrDefault(header => + header.Key.Equals("X-Astro-Cache", StringComparison.OrdinalIgnoreCase)).Value?.FirstOrDefault(); + + public static HybridApiDocsResponse Failure(int status) => new(status, [], [], true, false); +} + +/// +/// The renderer client is transient, so identical in-flight renders are tracked here. One render per +/// URL is shared by every waiting request; a stampede on a cold page costs a single render. +/// +internal sealed class HybridApiDocsFlights +{ + public ConcurrentDictionary>> Requests { get; } = new(); +} diff --git a/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsRenderer.cs b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsRenderer.cs new file mode 100644 index 0000000000..157560af36 --- /dev/null +++ b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsRenderer.cs @@ -0,0 +1,198 @@ +using Microsoft.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; + +namespace StaticHost.HybridApiDocs; + +internal sealed class HybridApiDocsRenderer(HttpClient client, HybridApiDocsArtifact artifact, HybridApiDocsEndpointPool endpoints, + HybridApiDocsFlights flights, ILogger logger) +{ + internal const long MaximumBodySize = 64 * 1024 * 1024; + private static readonly HashSet ExcludedHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Connection", "Keep-Alive", "Transfer-Encoding", "Content-Length", "TE", "Trailer", + "Upgrade", "Proxy-Authenticate", "Proxy-Authorization", "X-Aspire-Build-Id", + }; + + public async Task RenderAsync(HttpContext context) + { + var selection = context.Features.Get(); + if (selection is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + context.Response.Headers.CacheControl = "no-store"; + await NotFoundPage.WriteAsync(context); + return; + } + + // Pages never vary by query string, so it is not forwarded and cannot multiply renders. + var image = selection.Representation == "image"; + var upstream = image ? selection.RendererPath + context.Request.QueryString : selection.RendererPath; + + // A page is immutable for a build, so a matching validator is answered without the renderer. + var etag = selection.Representation is "html" or "markdown" ? ETagFor(selection.RendererPath) : null; + if (etag is not null && IsNotModified(context.Request, etag)) + { + context.Response.StatusCode = StatusCodes.Status304NotModified; + context.Response.Headers.ETag = etag; + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers["X-Aspire-Build-Id"] = artifact.Manifest.BuildId; + return; + } + + HybridApiDocsResponse result; + try + { + var flight = flights.Requests.GetOrAdd(upstream, key => new(() => FetchSharedAsync(key))); + result = await flight.Value.WaitAsync(context.RequestAborted); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + context.Abort(); + return; + } + + if (result.Unavailable) + { + await HybridApiDocsErrorPages.WriteUnavailableAsync(context, result.Status); + return; + } + + var response = context.Response; + response.StatusCode = result.Status; + foreach (var (key, values) in result.Headers) response.Headers[key] = values; + var vary = response.Headers.Vary.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(value => !value.Equals("X-Aspire-Cache-Bypass", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (vary.Length == 0) response.Headers.Remove("Vary"); + else response.Headers.Vary = string.Join(", ", vary); + if (selection.Representation == "markdown-negotiated") + { + response.Headers.CacheControl = "private, no-store"; + response.Headers.Vary = "Accept"; + } + else if (result.Cacheable && etag is not null) + { + response.Headers.CacheControl = "no-cache"; + response.Headers.ETag = etag; + } + else + { + response.Headers.CacheControl = IsPrivate(result) ? "private, no-store" : "no-store"; + } + response.Headers["X-Aspire-Build-Id"] = artifact.Manifest.BuildId; + response.ContentLength = result.Body.Length; + if (!HttpMethods.IsHead(context.Request.Method)) + await response.Body.WriteAsync(result.Body, context.RequestAborted); + logger.LogInformation("Forwarded hybrid API {Path} ({Bytes} bytes, Astro cache {CacheStatus}) from build {BuildId}", + selection.RendererPath, result.Body.Length, result.CacheStatus ?? "bypass", artifact.Manifest.BuildId); + } + + public async Task IsReadyAsync(CancellationToken cancellationToken) + { + foreach (var candidate in endpoints.Candidates("")) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + try + { + using var response = await client.GetAsync( + new Uri(candidate.Address, "/__hybrid-api-docs/health"), HttpCompletionOption.ResponseHeadersRead, timeout.Token); + if (response.IsSuccessStatusCode && MatchesBuild(response)) return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) when (error is HttpRequestException or IOException or OperationCanceledException) + { + logger.LogWarning(error, "Hybrid API readiness probe failed for {Endpoint}", candidate.Address); + } + } + return false; + } + + private async Task FetchSharedAsync(string upstream) + { + // Yield so the in-flight entry is always registered before it can be removed. + await Task.Yield(); + try + { + return await FetchAsync(upstream); + } + finally + { + flights.Requests.TryRemove(upstream, out _); + } + } + + private async Task FetchAsync(string upstream) + { + // The shared render outlives any single caller, so only this deadline cancels it. + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var candidates = endpoints.Candidates(upstream).ToArray(); + var timedOut = false; + for (var index = 0; index < candidates.Length; index++) + { + using var attempt = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); + attempt.CancelAfter(TimeSpan.FromSeconds(10)); + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(candidates[index].Address, upstream)); + request.Headers.Host = artifact.Site.Authority; + request.Headers.TryAddWithoutValidation("X-Forwarded-Host", artifact.Site.Authority); + request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", artifact.Site.Scheme); + try + { + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, attempt.Token); + if (!MatchesBuild(response)) + throw new HttpRequestException("The renderer and StaticHost have different build identities."); + if ((int)response.StatusCode >= 500 && index < candidates.Length - 1) + { + logger.LogWarning("Renderer {Endpoint} returned {Status}; trying another replica", + candidates[index].Address, (int)response.StatusCode); + continue; + } + await response.Content.LoadIntoBufferAsync(MaximumBodySize, attempt.Token); + var body = await response.Content.ReadAsByteArrayAsync(attempt.Token); + if ((int)response.StatusCode < 500) endpoints.MarkHealthy(candidates[index].Index); + + var excluded = new HashSet(ExcludedHeaders, StringComparer.OrdinalIgnoreCase); + foreach (var value in response.Headers.Connection) excluded.Add(value); + var headers = response.Headers.Concat(response.Content.Headers) + .Where(header => !excluded.Contains(header.Key)) + .Select(header => KeyValuePair.Create(header.Key, header.Value.ToArray())).ToArray(); + var status = (int)response.StatusCode; + var privateOrCookie = response.Headers.CacheControl?.Private == true || response.Headers.Contains("Set-Cookie"); + return new HybridApiDocsResponse(status, headers, body, false, status is >= 200 and < 300 && !privateOrCookie); + } + catch (Exception error) when (error is HttpRequestException or IOException or OperationCanceledException) + { + timedOut = error is OperationCanceledException; + endpoints.MarkFailed(candidates[index].Index); + logger.LogWarning(error, "Hybrid API renderer {Endpoint} failed for {Path}", candidates[index].Address, upstream); + if (timeout.IsCancellationRequested) break; + } + } + logger.LogError("All hybrid API renderers failed for {Path}", upstream); + return HybridApiDocsResponse.Failure(timedOut ? StatusCodes.Status504GatewayTimeout : StatusCodes.Status502BadGateway); + } + + private static bool IsPrivate(HybridApiDocsResponse result) => result.Headers.Any(header => + header.Key.Equals("Cache-Control", StringComparison.OrdinalIgnoreCase) + && header.Value.Any(value => value.Contains("private", StringComparison.OrdinalIgnoreCase))); + + private string ETagFor(string rendererPath) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{artifact.Manifest.BuildId}\n{rendererPath}")); + return $"W/\"{Convert.ToHexStringLower(hash.AsSpan(0, 12))}\""; + } + + private static bool IsNotModified(HttpRequest request, string etag) + { + var current = EntityTagHeaderValue.Parse(etag); + return request.GetTypedHeaders().IfNoneMatch + .Any(candidate => candidate == EntityTagHeaderValue.Any || candidate.Compare(current, useStrongComparison: false)); + } + + private bool MatchesBuild(HttpResponseMessage response) + => response.Headers.TryGetValues("X-Aspire-Build-Id", out var ids) + && ids.SequenceEqual([artifact.Manifest.BuildId]); +} diff --git a/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsRoutingMiddleware.cs b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsRoutingMiddleware.cs new file mode 100644 index 0000000000..386dc61980 --- /dev/null +++ b/src/statichost/StaticHost/HybridApiDocs/HybridApiDocsRoutingMiddleware.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Http.Extensions; + +namespace StaticHost.HybridApiDocs; + +internal sealed record HybridApiDocsRequest(string RendererPath, string Representation, string? MarkdownPeer); + +internal sealed class HybridApiDocsRoutingMiddleware(RequestDelegate next, HybridApiDocsArtifact artifact) +{ + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? "/"; + if (artifact.TryCanonical(path, out var canonical) && canonical != path) + { + context.Response.Redirect(UriHelper.BuildRelative( + context.Request.PathBase, new PathString(canonical), context.Request.QueryString), + permanent: true, preserveMethod: true); + return; + } + if (artifact.TryRedirect(path, out var redirect)) + { + context.Response.StatusCode = redirect!.Status; + context.Response.Headers.Location = redirect.Destination + context.Request.QueryString; + return; + } + var image = path is "/_image" or "/_image/"; + if (!image && !context.Request.Path.StartsWithSegments("/reference/api")) + { + await next(context); + return; + } + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + context.Response.Headers.Allow = "GET, HEAD"; + return; + } + if (image) + { + var query = context.Request.Query; + if (query["href"].Count != 1 || !artifact.IsLocalImage(query["href"].ToString()) || + new[] { "w", "h" }.Any(key => query.TryGetValue(key, out var values) && + (values.Count != 1 || !int.TryParse(values.ToString(), out var size) || size is < 1 or > 4096))) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.Headers.CacheControl = "no-store"; + return; + } + context.Features.Set(new HybridApiDocsRequest(path, "image", null)); + await next(context); + return; + } + // Prerendered API pages (the landings) are plain static files; anything else is a 404. + if (!artifact.TryRoute(path, out var route)) + { + await next(context); + return; + } + var isMarkdown = path.EndsWith(".md", StringComparison.Ordinal); + var negotiation = AcceptHeaderParser.Negotiate(context.Request.Headers.Accept); + var negotiated = !isMarkdown && negotiation.PrefersMarkdown; + if (!isMarkdown && !negotiated && !negotiation.AcceptsHtml) + { + context.Response.StatusCode = StatusCodes.Status406NotAcceptable; + return; + } + context.Features.Set(new HybridApiDocsRequest( + negotiated ? route!.Markdown : path, + negotiated ? "markdown-negotiated" : isMarkdown ? "markdown" : "html", + isMarkdown || negotiated ? null : route!.Markdown)); + await next(context); + } +} diff --git a/src/statichost/StaticHost/NotFoundPage.cs b/src/statichost/StaticHost/NotFoundPage.cs new file mode 100644 index 0000000000..8eebac86ef --- /dev/null +++ b/src/statichost/StaticHost/NotFoundPage.cs @@ -0,0 +1,23 @@ +namespace StaticHost; + +internal static class NotFoundPage +{ + /// Serves the site's 404 page for a response that is already a 404 with no body. + public static async Task WriteAsync(HttpContext context) + { + var response = context.Response; + if (response is not { StatusCode: 404, HasStarted: false }) return; + + var env = context.RequestServices.GetRequiredService(); + var notFoundPath = Path.Combine(env.WebRootPath ?? string.Empty, "404.html"); + if (!File.Exists(notFoundPath)) return; + + // Ensure the 404 page itself is not cached aggressively so new deployments update quickly + response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + response.Headers.Pragma = "no-cache"; + response.Headers.Expires = "0"; + response.ContentType = "text/html; charset=utf-8"; + + await response.SendFileAsync(notFoundPath); + } +} diff --git a/src/statichost/StaticHost/Program.cs b/src/statichost/StaticHost/Program.cs index 9463687b5e..819f0bfac5 100644 --- a/src/statichost/StaticHost/Program.cs +++ b/src/statichost/StaticHost/Program.cs @@ -1,6 +1,22 @@ using Scalar.AspNetCore; - -var builder = WebApplication.CreateBuilder(args); +using StaticHost.HybridApiDocs; +using Microsoft.Extensions.FileProviders; + +var hybridArtifactPath = new ConfigurationBuilder() + .AddEnvironmentVariables().AddCommandLine(args).Build()["HybridApiDocs:ArtifactPath"]; +var hybridArtifact = hybridArtifactPath is null ? null : HybridApiDocsArtifact.Load(hybridArtifactPath); +#if HYBRID_API_DOCS_BUILD +if (hybridArtifact is null) + throw new InvalidOperationException("A hybrid StaticHost build requires HybridApiDocs:ArtifactPath and its private renderer endpoint."); +#else +if (hybridArtifact is not null) + throw new InvalidOperationException("Build StaticHost with ASPIRE_HYBRID_API_DOCS=1 to select the hybrid client's static-asset manifest."); +#endif +var builder = WebApplication.CreateBuilder(new WebApplicationOptions +{ + Args = args, + WebRootPath = hybridArtifact?.ClientDirectory, +}); builder.AddServiceDefaults(); builder.AddForwardedClientIp(); @@ -13,6 +29,13 @@ builder.AddLiveStatus(); +if (hybridArtifact is not null) +{ + var rendererUrls = builder.Configuration.GetSection("HybridApiDocs:RendererUrls").Get() + ?? [builder.Configuration["HybridApiDocs:RendererUrl"] ?? ""]; + builder.Services.AddHybridApiDocs(hybridArtifact, rendererUrls); +} + builder.Services.AddHsts(options => { options.Preload = true; @@ -26,6 +49,12 @@ } await using var app = builder.Build(); +if (hybridArtifact is not null) +{ + var clientFiles = new PhysicalFileProvider(hybridArtifact.ClientDirectory); + app.Environment.WebRootFileProvider = clientFiles; + app.Lifetime.ApplicationStopped.Register(clientFiles.Dispose); +} // Restore the Front Door client IP before telemetry or any middleware reads it. app.UseForwardedClientIp(); @@ -39,6 +68,11 @@ app.UseHttpsRedirection(); // Resolve request casing before any middleware rewrites or matches the static path. +if (hybridArtifact is not null) +{ + app.UseResponseCompression(); + app.UseHybridApiDocsRouting(); +} app.UseCanonicalPathRedirects(); // Agent-readiness middlewares MUST run before UseDefaultFiles + UseRouting: @@ -50,31 +84,15 @@ app.UseAgentReadiness(); app.UseDefaultFiles(); +if (hybridArtifact is not null) +{ + app.UseStaticFiles(HybridApiDocsExtensions.HybridStaticFileOptions()); +} // add routing after default files, so the default file middleware can modify the path first app.UseRouting(); -app.UseStatusCodePages(async context => -{ - var response = context.HttpContext.Response; - if (response is { StatusCode: 404, HasStarted: false }) - { - var env = context.HttpContext.RequestServices.GetRequiredService(); - - var notFoundPath = Path.Combine(env.WebRootPath ?? string.Empty, "404.html"); - - if (File.Exists(notFoundPath)) - { - // Ensure the 404 page itself is not cached aggressively so new deployments update quickly - response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; - response.Headers.Pragma = "no-cache"; - response.Headers.Expires = "0"; - response.ContentType = "text/html; charset=utf-8"; - - await response.SendFileAsync(notFoundPath); - } - } -}); +app.UseStatusCodePages(context => NotFoundPage.WriteAsync(context.HttpContext)); // Ensure text/plain responses include UTF-8 charset to fix encoding issues with .txt files app.Use(async (context, next) => @@ -130,5 +148,6 @@ }); app.MapStaticAssets(); +if (hybridArtifact is not null) app.MapHybridApiDocs(); await app.RunAsync(); diff --git a/src/statichost/StaticHost/StaticHost.csproj b/src/statichost/StaticHost/StaticHost.csproj index f6e5fdfb8e..bc0f621996 100644 --- a/src/statichost/StaticHost/StaticHost.csproj +++ b/src/statichost/StaticHost/StaticHost.csproj @@ -7,6 +7,9 @@ false true noble-chiseled + obj/$(Configuration)/hybrid/ + $(DefineConstants);HYBRID_API_DOCS_BUILD + $(MSBuildProjectDirectory)/../../frontend/dist-hybrid false @@ -36,6 +39,15 @@ + + + - + diff --git a/tests/Aspire.Dev.AppHost.Tests/HybridApiDocsConfigurationTests.cs b/tests/Aspire.Dev.AppHost.Tests/HybridApiDocsConfigurationTests.cs new file mode 100644 index 0000000000..c38d1d72a9 --- /dev/null +++ b/tests/Aspire.Dev.AppHost.Tests/HybridApiDocsConfigurationTests.cs @@ -0,0 +1,147 @@ +using Aspire.Hosting; +using Aspire.Hosting.Azure; +using Azure.Provisioning.AppService; + +namespace Aspire.Dev.AppHost.Tests; + +public sealed class HybridApiDocsConfigurationTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("0", false)] + [InlineData("1", true)] + public void BuildModeRequiresExplicitOptIn(string? value, bool expected) + => Assert.Equal(expected, HybridApiDocsExtensions.IsHybridApiDocsEnabled(value)); + + [Fact] + public void InvalidBuildModeFailsClearly() + => Assert.Throws(() => HybridApiDocsExtensions.IsHybridApiDocsEnabled("true")); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LocalGraphUsesTwoPrivateWorkersAndEndpointReferences(bool externalArtifact) + { + var directory = Path.Combine(Path.GetTempPath(), $"aspire-hybrid-graph-{Guid.NewGuid():N}"); + var artifactDirectory = Path.Combine(directory, externalArtifact ? "artifact-windows" : "dist-hybrid"); + Directory.CreateDirectory(artifactDirectory); + try + { + File.WriteAllText(Path.Combine(artifactDirectory, "hybrid-api-docs.json"), + $$"""{"mode":"hybrid-api-docs","platform":"{{(OperatingSystem.IsWindows() ? "win32" : "linux")}}"}"""); + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + DisableDashboard = true, + }); + if (externalArtifact) builder.Configuration["HybridApiDocsArtifactDirectory"] = artifactDirectory; + var website = builder.AddProject("aspiredev"); + website.WithHybridApiDocs(builder, directory); + await using var app = builder.Build(); + var workers = builder.Resources.OfType().ToArray(); + Assert.Equal(2, workers.Length); + Assert.Equal(["api-docs", "api-docs-standby"], workers.Select(worker => worker.Name).ToArray()); + foreach (var worker in workers) + Assert.All(worker.Annotations.OfType(), + endpoint => Assert.False(endpoint.IsExternal)); + + var environment = new Dictionary(); + var context = new Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext( + builder.ExecutionContext, website.Resource, environment, CancellationToken.None); + foreach (var annotation in website.Resource.Annotations + .OfType()) + { + await annotation.Callback(context); + } + Assert.IsType(environment["HybridApiDocs__RendererUrls__0"]); + Assert.IsType(environment["HybridApiDocs__RendererUrls__1"]); + Assert.Equal(Path.Combine(artifactDirectory, "hybrid-api-docs.json"), + environment["HybridApiDocs__ArtifactPath"]); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task PublishModelKeepsBothRenderersInsideTheSameWebsite() + { + var directory = Path.Combine(Path.GetTempPath(), $"aspire-hybrid-sidecars-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path.Combine(directory, "dist-hybrid")); + try + { + File.WriteAllText(Path.Combine(directory, "dist-hybrid", "hybrid-api-docs.json"), + """{"mode":"hybrid-api-docs","platform":"linux"}"""); + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + Args = ["--publisher", "manifest"], DisableDashboard = true, + }); + var website = builder.AddProject("aspiredev") + .PublishAsAzureAppServiceWebsite((infra, site) => site.IsEndToEndEncryptionEnabled = true); + website.WithHybridApiDocs(builder, directory); + Assert.Empty(builder.Resources.OfType()); + var image = Assert.Single(builder.Resources.OfType()); + Assert.Equal("api-docs-image", image.Name); + + var infrastructure = builder.AddAzureInfrastructure("hybrid-test", infra => + { + var site = new WebSite("website") { Name = "hybrid-test", SiteConfig = new SiteConfigProperties() }; + infra.Add(site); + infra.Add(new SiteContainer("mainContainer") + { + Parent = site, Name = "main", IsMain = true, Image = "example/main:build", + AuthType = SiteContainerAuthType.UserAssigned, + UserManagedIdentityClientId = "00000000-0000-0000-0000-000000000000", + }); + foreach (var annotation in website.Resource.Annotations.OfType()) + annotation.Configure(infra, site); + + var containers = infra.GetProvisionableResources().OfType().ToArray(); + Assert.Equal(3, containers.Length); + var sidecars = containers.Where(container => !container.IsMain.Value).ToArray(); + Assert.Equal(2, sidecars.Length); + Assert.All(sidecars, container => + { + Assert.Same(site, container.Parent); + Assert.False(container.InheritAppSettingsAndConnectionStrings.Value); + Assert.Empty(container.VolumeMounts); + Assert.Equal(SiteContainerAuthType.UserAssigned, container.AuthType.Value); + Assert.Equal("00000000-0000-0000-0000-000000000000", container.UserManagedIdentityClientId.Value); + var port = Assert.IsType(Assert.Single(container.EnvironmentVariables).Value); + Assert.Equal("PORT", port.Name.Value); + // The value names an app setting (App Service does not treat it as a literal), so that + // setting must exist and carry this sidecar's own port. + Assert.Contains(site.SiteConfig.AppSettings, setting => + setting.Value is { } pair && + pair.Name.Value == port.Value.Value && pair.Value.Value == container.TargetPort.Value); + }); + Assert.Equal(new string?[] { "4323", "4324" }, sidecars.Select(container => container.TargetPort.Value)); + Assert.Equal(2, sidecars.Select(container => + Assert.IsType(Assert.Single(container.EnvironmentVariables).Value).Value.Value).Distinct().Count()); + Assert.Equal("/healthz", site.SiteConfig.HealthCheckPath.Value); + Assert.True(site.SiteConfig.IsAlwaysOn.Value); + // The plan's instance count and scaling stay as configured on the existing production plan. + Assert.True(site.IsEndToEndEncryptionEnabled.Value); + Assert.Contains(site.SiteConfig.AppSettings, setting => + setting.Value is { } pair && + pair.Name.Value == "WEBSITE_HEALTHCHECK_MAXPINGFAILURES" && pair.Value.Value == "2"); + Assert.Contains(site.SiteConfig.AppSettings, setting => + setting.Value is { } pair && + pair.Name.Value == "WEBSITE_WARMUP_PATH" && pair.Value.Value == "/healthz/api-docs"); + Assert.Contains(site.SiteConfig.AppSettings, setting => + setting.Value is { } pair && + pair.Name.Value == "WEBSITE_WARMUP_STATUSES" && pair.Value.Value == "200"); + }); + await using var app = builder.Build(); + var bicep = infrastructure.Resource.GetBicepTemplateString(); + Assert.Contains("isMain: false", bicep, StringComparison.Ordinal); + Assert.DoesNotContain("numberOfWorkers", bicep, StringComparison.Ordinal); + Assert.Contains("healthCheckPath: '/healthz'", bicep, StringComparison.Ordinal); + Assert.Contains("endToEndEncryptionEnabled: true", bicep, StringComparison.Ordinal); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/tests/StaticHost.Tests/HybridApiDocsTests.cs b/tests/StaticHost.Tests/HybridApiDocsTests.cs new file mode 100644 index 0000000000..fb82dbb3ad --- /dev/null +++ b/tests/StaticHost.Tests/HybridApiDocsTests.cs @@ -0,0 +1,550 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StaticHost.AgentReadiness; +using StaticHost.HybridApiDocs; + +namespace StaticHost.Tests; + +public sealed class HybridApiDocsTests +{ + [Fact] + public async Task AlwaysForwardsSequentialRequestsEvenWhenAstroReportsCacheHits() + { + await using var host = await HybridHost.StartAsync(); + for (var index = 0; index < 3; index++) + Assert.Equal("html:demo", await host.Client.GetStringAsync("/reference/api/demo/")); + Assert.Equal(3, host.Renderer.Calls["/reference/api/demo/"]); + using var response = await host.Client.GetAsync("/reference/api/demo/"); + Assert.Equal("HIT", Assert.Single(response.Headers.GetValues("X-Astro-Cache"))); + Assert.True(response.Headers.CacheControl!.NoCache); + Assert.False(response.Headers.CacheControl.NoStore); + Assert.True(response.Headers.ETag!.IsWeak); + Assert.False(response.Headers.Contains("Vary")); + } + + [Fact] + public async Task ConcurrentRequestsForOneColdPageShareASingleRender() + { + await using var host = await HybridHost.StartAsync(); + host.Renderer.Gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + var requests = Enumerable.Range(0, 20).Select(_ => host.Client.GetStringAsync("/reference/api/demo/")).ToArray(); + await Task.Delay(200); + host.Renderer.Gate.SetResult(); + Assert.All(await Task.WhenAll(requests), body => Assert.Equal("html:demo", body)); + Assert.Equal(1, host.Renderer.Calls["/reference/api/demo/"]); + Assert.Equal("html:demo", await host.Client.GetStringAsync("/reference/api/demo/")); + Assert.Equal(2, host.Renderer.Calls["/reference/api/demo/"]); + } + + [Fact] + public async Task AbandonedRequestsDoNotCancelTheRenderOthersAreWaitingFor() + { + await using var host = await HybridHost.StartAsync(); + host.Renderer.Gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + using var abandon = new CancellationTokenSource(); + var abandoned = host.Client.GetAsync("/reference/api/demo/", abandon.Token); + var waiting = host.Client.GetStringAsync("/reference/api/demo/"); + await Task.Delay(200); + abandon.Cancel(); + await Assert.ThrowsAnyAsync(() => abandoned); + host.Renderer.Gate.SetResult(); + Assert.Equal("html:demo", await waiting); + Assert.Equal(1, host.Renderer.Calls["/reference/api/demo/"]); + } + + [Fact] + public async Task AnswersMatchingValidatorsWithoutCallingTheRenderer() + { + await using var host = await HybridHost.StartAsync(); + using var first = await host.Client.GetAsync("/reference/api/demo/"); + var etag = first.Headers.ETag!; + async Task Revalidate(string path, string tag) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation("If-None-Match", tag); + return await host.Client.SendAsync(request); + } + + using var notModified = await Revalidate("/reference/api/demo/", $"\"other\", {etag}"); + Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode); + Assert.Equal(etag, notModified.Headers.ETag); + Assert.Empty(await notModified.Content.ReadAsByteArrayAsync()); + using var any = await Revalidate("/reference/api/demo/", "*"); + Assert.Equal(HttpStatusCode.NotModified, any.StatusCode); + Assert.Equal(1, host.Renderer.Calls["/reference/api/demo/"]); + + using var changed = await Revalidate("/reference/api/demo/", "W/\"stale\""); + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + Assert.Equal(2, host.Renderer.Calls["/reference/api/demo/"]); + using var markdown = await Revalidate("/reference/api/demo.md", etag.ToString()); + Assert.Equal(HttpStatusCode.OK, markdown.StatusCode); + Assert.NotEqual(etag, markdown.Headers.ETag); + } + + [Fact] + public async Task OnlyCacheablePagesGetValidators() + { + await using var host = await HybridHost.StartAsync(); + foreach (var page in new[] { "private", "cookie", "failure" }) + { + using var response = await host.Client.GetAsync($"/reference/api/{page}/"); + Assert.Null(response.Headers.ETag); + Assert.True(response.Headers.CacheControl!.NoStore); + } + using var request = new HttpRequestMessage(HttpMethod.Get, "/reference/api/demo/"); + request.Headers.Accept.ParseAdd("text/markdown"); + using var negotiated = await host.Client.SendAsync(request); + Assert.Null(negotiated.Headers.ETag); + } + + [Fact] + public async Task QueryStringsAreNotForwardedForPages() + { + await using var host = await HybridHost.StartAsync(); + foreach (var path in new[] { "/reference/api/demo/?a=1", "/reference/api/demo/?a=2&q=redis", "/reference/api/demo.md?x=1" }) + await host.Client.GetStringAsync(path); + Assert.Equal(3, host.Renderer.Requests.Count); + Assert.DoesNotContain(host.Renderer.Requests, request => request.Contains('?')); + Assert.Equal(2, host.Renderer.Calls["/reference/api/demo/"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task KeepsMarkdownAndHtmlHeadersIsolatedInEitherOrder(bool negotiatedFirst) + { + await using var host = await HybridHost.StartAsync(); + async Task CheckNegotiated() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/reference/api/demo/"); + request.Headers.Accept.ParseAdd("text/markdown"); + using var response = await host.Client.SendAsync(request); + Assert.Equal("markdown:demo", await response.Content.ReadAsStringAsync()); + Assert.True(response.Headers.CacheControl!.Private); + Assert.Contains("Accept", response.Headers.Vary); + } + if (negotiatedFirst) await CheckNegotiated(); + using var markdown = await host.Client.GetAsync("/reference/api/demo.md"); + Assert.Equal("markdown:demo", await markdown.Content.ReadAsStringAsync()); + Assert.False(markdown.Headers.CacheControl!.Private); + using var html = await host.Client.GetAsync("/reference/api/demo/"); + Assert.Equal("html:demo", await html.Content.ReadAsStringAsync()); + Assert.Contains("text/html", html.Content.Headers.ContentType!.ToString()); + Assert.Contains("/reference/api/demo.md", string.Join("", html.Headers.GetValues("Link"))); + if (!negotiatedFirst) await CheckNegotiated(); + await CheckNegotiated(); + Assert.Equal("html:demo", await host.Client.GetStringAsync("/reference/api/demo/")); + } + + [Fact] + public async Task HeadDoesNotPoisonGetAndUsesTheCompleteContentLength() + { + await using var host = await HybridHost.StartAsync(); + using var head = await host.Client.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/reference/api/demo/")); + Assert.Empty(await head.Content.ReadAsByteArrayAsync()); + Assert.Equal("html:demo".Length, head.Content.Headers.ContentLength); + Assert.Equal("html:demo", await host.Client.GetStringAsync("/reference/api/demo/")); + } + + [Theory] + [InlineData("failure", HttpStatusCode.ServiceUnavailable)] + [InlineData("broken", HttpStatusCode.BadGateway)] + [InlineData("mismatch", HttpStatusCode.BadGateway)] + [InlineData("oversize", HttpStatusCode.BadGateway)] + public async Task DoesNotCacheRendererErrors(string page, HttpStatusCode expected) + { + await using var host = await HybridHost.StartAsync(); + for (var index = 0; index < 2; index++) + { + using var response = await host.Client.GetAsync($"/reference/api/{page}/"); + Assert.Equal(expected, response.StatusCode); + } + Assert.Equal(2, host.Renderer.Calls[$"/reference/api/{page}/"]); + } + + [Theory] + [InlineData("private")] + [InlineData("cookie")] + public async Task DoesNotCachePrivateOrCookieSettingResponses(string page) + { + await using var host = await HybridHost.StartAsync(); + await host.Client.GetStringAsync($"/reference/api/{page}/"); + await host.Client.GetStringAsync($"/reference/api/{page}/"); + Assert.Equal(2, host.Renderer.Calls[$"/reference/api/{page}/"]); + } + + [Fact] + public async Task AuthorizationHeadersDoNotBypassTheNativeCache() + { + await using var host = await HybridHost.StartAsync(); + host.Client.DefaultRequestHeaders.Authorization = new("Bearer", "test"); + using var response = await host.Client.GetAsync("/reference/api/demo/"); + Assert.NotNull(response.Headers.ETag); + Assert.False(host.Renderer.SawCacheBypass); + } + + [Fact] + public async Task ClientPreferenceCookiesDoNotDisablePublicCachingOrReachTheWorker() + { + await using var host = await HybridHost.StartAsync(); + host.Client.DefaultRequestHeaders.Add("Cookie", "preference=value"); + await host.Client.GetStringAsync("/reference/api/demo/"); + Assert.False(host.Renderer.SawCacheBypass); + Assert.False(host.Renderer.SawCookie); + } + + [Fact] + public async Task RendererTimeoutsAreExplicitAndNotCached() + { + await using var host = await HybridHost.StartAsync(); + host.Renderer.Timeout = true; + for (var index = 0; index < 2; index++) + { + using var response = await host.Client.GetAsync("/reference/api/demo/"); + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + } + Assert.Equal(2, host.Renderer.Calls["/reference/api/demo/"]); + } + + [Fact] + public async Task BuffersLargeResponsesButRequiresWorkerEvenForPreviouslyVisitedApis() + { + await using var host = await HybridHost.StartAsync(); + using var large = await host.Client.GetAsync("/reference/api/large/"); + var body = await large.Content.ReadAsByteArrayAsync(); + Assert.True(body.Length >= 13_621_124); + Assert.Equal(body.Length, large.Content.Headers.ContentLength); + Assert.Empty(large.Headers.TransferEncoding); + await host.Client.GetStringAsync("/reference/api/demo/"); + host.Renderer.Offline = true; + Assert.Equal("static guide", await host.Client.GetStringAsync("/guide/")); + using var previouslyVisited = await host.Client.GetAsync("/reference/api/demo/"); + Assert.Equal(HttpStatusCode.BadGateway, previouslyVisited.StatusCode); + using var cold = await host.Client.GetAsync("/reference/api/cold/"); + Assert.Equal(HttpStatusCode.BadGateway, cold.StatusCode); + } + + [Theory] + [InlineData("/reference/api/large/", "br")] + [InlineData("/reference/api/large/", "gzip")] + [InlineData("/reference/api/demo.md", "br")] + [InlineData("/guide/", "br")] + public async Task CompressesForwardedAndStaticResponsesWhenTheClientAllowsIt(string path, string encoding) + { + await using var host = await HybridHost.StartAsync(); + using var plain = await host.Client.GetAsync(path); + var expected = await plain.Content.ReadAsByteArrayAsync(); + Assert.Empty(plain.Content.Headers.ContentEncoding); + + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.AcceptEncoding.ParseAdd(encoding); + using var response = await host.Client.SendAsync(request); + var compressed = await response.Content.ReadAsByteArrayAsync(); + Assert.Equal(encoding, Assert.Single(response.Content.Headers.ContentEncoding)); + Assert.Contains("Accept-Encoding", response.Headers.Vary); + if (path.EndsWith("/large/")) Assert.True(compressed.Length < 1_000_000); + + await using var input = new MemoryStream(compressed); + await using Stream decoder = encoding == "br" + ? new System.IO.Compression.BrotliStream(input, System.IO.Compression.CompressionMode.Decompress) + : new System.IO.Compression.GZipStream(input, System.IO.Compression.CompressionMode.Decompress); + using var decoded = new MemoryStream(); + await decoder.CopyToAsync(decoded); + Assert.Equal(expected, decoded.ToArray()); + } + + [Fact] + public async Task SpreadsPagesAcrossReplicasAndKeepsEachPageOnTheSameOne() + { + await using var host = await HybridHost.StartAsync(["http://primary/", "http://standby/"]); + foreach (var page in HybridHost.SpreadPages) + for (var visit = 0; visit < 3; visit++) + Assert.Equal($"html:{page}", await host.Client.GetStringAsync($"/reference/api/{page}/")); + var primaryPages = HybridHost.SpreadPages.Count(page => HybridHost.PreferredReplica(page) == 0); + Assert.InRange(primaryPages, 1, HybridHost.SpreadPages.Length - 1); + Assert.Equal(primaryPages * 3, host.Renderer.EndpointCalls["primary"]); + Assert.Equal((HybridHost.SpreadPages.Length - primaryPages) * 3, host.Renderer.EndpointCalls["standby"]); + } + + [Fact] + public async Task FailsOverAndSkipsAFailedReplicaDuringItsCooldown() + { + await using var host = await HybridHost.StartAsync(["http://primary/", "http://standby/"]); + host.Renderer.OfflineHosts.Add("primary"); + var page = HybridHost.PageFor(0); + Assert.Equal($"html:{page}", await host.Client.GetStringAsync($"/reference/api/{page}/")); + Assert.Equal(1, host.Renderer.EndpointCalls["primary"]); + Assert.Equal(1, host.Renderer.EndpointCalls["standby"]); + Assert.Equal($"html:{page}", await host.Client.GetStringAsync($"/reference/api/{page}/")); + Assert.Equal(1, host.Renderer.EndpointCalls["primary"]); + Assert.Equal(2, host.Renderer.EndpointCalls["standby"]); + } + + [Fact] + public async Task ReadinessRequiresAnAvailableMatchingBuild() + { + await using var host = await HybridHost.StartAsync(["http://primary/", "http://standby/"]); + host.Renderer.OfflineHosts.Add("primary"); + using var ready = await host.Client.GetAsync("/healthz/api-docs"); + Assert.Equal(HttpStatusCode.OK, ready.StatusCode); + host.Renderer.MismatchedHosts.Add("standby"); + using var mismatch = await host.Client.GetAsync("/healthz/api-docs"); + Assert.Equal(HttpStatusCode.ServiceUnavailable, mismatch.StatusCode); + host.Renderer.Offline = true; + using var down = await host.Client.GetAsync("/healthz/api-docs"); + Assert.Equal(HttpStatusCode.ServiceUnavailable, down.StatusCode); + Assert.Equal("static guide", await host.Client.GetStringAsync("/guide/")); + } + + [Fact] + public async Task RejectsMismatchedReplicasButStillUsesThemAsALastResort() + { + await using var host = await HybridHost.StartAsync(["http://primary/", "http://standby/"]); + var page = HybridHost.PageFor(0); + host.Renderer.MismatchedHosts.Add("primary"); + Assert.Equal($"html:{page}", await host.Client.GetStringAsync($"/reference/api/{page}/")); + Assert.Equal(1, host.Renderer.EndpointCalls["standby"]); + host.Renderer.MismatchedHosts.Clear(); + host.Renderer.OfflineHosts.Add("standby"); + Assert.Equal($"html:{page}", await host.Client.GetStringAsync($"/reference/api/{page}/")); + Assert.Equal(2, host.Renderer.EndpointCalls["primary"]); + } + + [Fact] + public async Task RetriesServerErrorsAgainstAnotherReplicaBeforeSendingTheResponse() + { + await using var host = await HybridHost.StartAsync(["http://primary/", "http://standby/"]); + host.Renderer.ErrorHosts.Add("primary"); + var page = HybridHost.PageFor(0); + Assert.Equal($"html:{page}", await host.Client.GetStringAsync($"/reference/api/{page}/")); + Assert.Equal(1, host.Renderer.EndpointCalls["primary"]); + Assert.Equal(1, host.Renderer.EndpointCalls["standby"]); + } + + [Theory] + [InlineData("/casts/demo.cast", "application/octet-stream")] + [InlineData("/pagefind/pagefind.en_abc.pf_meta", "application/octet-stream")] + [InlineData("/pagefind/wasm.en.pagefind", "application/octet-stream")] + [InlineData("/_astro/logo.svg", "image/svg+xml")] + public async Task ServesEveryClientFileIncludingTypesWithoutAMimeMapping(string path, string contentType) + { + await using var host = await HybridHost.StartAsync(); + using var response = await host.Client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(contentType, response.Content.Headers.ContentType!.MediaType); + Assert.Equal(path.StartsWith("/_astro") ? "public, max-age=31536000, immutable" : "no-cache", + response.Headers.CacheControl!.ToString()); + Assert.Empty(host.Renderer.Calls); + } + + [Fact] + public async Task PrerenderedApiPagesAreServedAsStaticFilesWithoutTheRenderer() + { + await using var host = await HybridHost.StartAsync(); + Assert.Equal("static landing", await host.Client.GetStringAsync("/reference/api/landing/")); + Assert.Empty(host.Renderer.Calls); + } + + [Fact] + public async Task UnknownApiPagesGetTheSiteNotFoundPage() + { + await using var host = await HybridHost.StartAsync(); + using var missing = await host.Client.GetAsync("/reference/api/not-a-page/"); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + Assert.Equal("site not found", await missing.Content.ReadAsStringAsync()); + Assert.Contains("text/html", missing.Content.Headers.ContentType!.ToString()); + Assert.True(missing.Headers.CacheControl!.NoStore); + Assert.Empty(host.Renderer.Calls); + } + + [Theory] + [InlineData(false, HttpStatusCode.BadGateway)] + [InlineData(true, HttpStatusCode.GatewayTimeout)] + public async Task UnavailableRenderersGetARetryPage(bool timeout, HttpStatusCode expected) + { + await using var host = await HybridHost.StartAsync(); + host.Renderer.Offline = !timeout; + host.Renderer.Timeout = timeout; + using var response = await host.Client.GetAsync("/reference/api/demo/"); + Assert.Equal(expected, response.StatusCode); + Assert.Contains("Try again", await response.Content.ReadAsStringAsync()); + Assert.Contains("text/html", response.Content.Headers.ContentType!.ToString()); + Assert.True(response.Headers.CacheControl!.NoStore); + Assert.Equal("5", Assert.Single(response.Headers.GetValues("Retry-After"))); + + using var head = await host.Client.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/reference/api/demo/")); + Assert.Equal(expected, head.StatusCode); + Assert.Empty(await head.Content.ReadAsByteArrayAsync()); + } + + [Fact] + public async Task CanonicalizesAndRedirectsWithoutInvokingWorker() + { + await using var host = await HybridHost.StartAsync(); + using var canonical = await host.Client.GetAsync("/REFERENCE/API/DEMO"); + Assert.Equal(HttpStatusCode.PermanentRedirect, canonical.StatusCode); + Assert.Equal("/reference/api/demo/", canonical.Headers.Location!.OriginalString); + using var markdown = await host.Client.GetAsync("/reference/api/demo.md/"); + Assert.Equal(HttpStatusCode.PermanentRedirect, markdown.StatusCode); + Assert.Equal("/reference/api/demo.md", markdown.Headers.Location!.OriginalString); + using var redirect = await host.Client.GetAsync("/old-guide/?a=1"); + Assert.Equal(HttpStatusCode.Found, redirect.StatusCode); + Assert.Equal("/guide/?a=1", redirect.Headers.Location!.OriginalString); + using var missing = await host.Client.GetAsync("/reference/api/not-a-page/"); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + using var method = await host.Client.PostAsync("/reference/api/demo/", null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, method.StatusCode); + Assert.Empty(host.Renderer.Calls); + } + + [Fact] + public async Task AllowsOnlyBundledImagesAndBoundsTransformDimensions() + { + await using var host = await HybridHost.StartAsync(); + using var allowed = await host.Client.GetAsync("/_image?href=%2F_astro%2Flogo.svg&w=48&h=48"); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + using var remote = await host.Client.GetAsync("/_image?href=https%3A%2F%2Fexample.com%2Fimage.svg"); + Assert.Equal(HttpStatusCode.BadRequest, remote.StatusCode); + using var oversized = await host.Client.GetAsync("/_image?href=%2F_astro%2Flogo.svg&w=999999"); + Assert.Equal(HttpStatusCode.BadRequest, oversized.StatusCode); + Assert.Equal(1, host.Renderer.Calls["/_image"]); + Assert.Contains(host.Renderer.Requests, request => request.StartsWith("/_image?href=", StringComparison.Ordinal)); + } + + [Fact] + public void RejectsMissingOrEscapingArtifacts() + { + using var root = new TempWebRoot(); + Assert.Throws(() => HybridApiDocsArtifact.Load(Path.Combine(root.Path, "missing.json"))); + root.WriteFile("hybrid.json", JsonSerializer.Serialize(new HybridApiManifest( + 1, "hybrid-api-docs", new string('a', 64), "../escape", "server/entry.mjs", + "https://aspire.dev", [], [], OperatingSystem.IsWindows() ? "win32" : "linux"), new JsonSerializerOptions(JsonSerializerDefaults.Web))); + Assert.Throws(() => HybridApiDocsArtifact.Load(Path.Combine(root.Path, "hybrid.json"))); + } +} + +internal sealed class HybridHost : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly TempWebRoot _root; + public HttpClient Client { get; } + public TestApiRenderer Renderer { get; } + + // Generic pages with a stable preferred replica, so routing assertions do not depend on a hash value. + internal static readonly string[] SpreadPages = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"]; + internal static int PreferredReplica(string page) => HybridApiDocsEndpointPool.PreferredIndex($"/reference/api/{page}/", 2); + internal static string PageFor(int replica) => SpreadPages.First(page => PreferredReplica(page) == replica); + + private HybridHost(WebApplication app, TempWebRoot root, TestApiRenderer renderer) + { + _app = app; + _root = root; + Renderer = renderer; + Client = app.GetTestClient(); + } + + public static async Task StartAsync(string[]? endpoints = null) + { + var root = new TempWebRoot(); + root.WriteFile("client/guide/index.html", "static guide"); + root.WriteFile("client/404.html", "site not found"); + root.WriteFile("client/reference/api/landing/index.html", "static landing"); + root.WriteFile("client/casts/demo.cast", "{\"version\":2}"); + root.WriteFile("client/pagefind/pagefind.en_abc.pf_meta", "meta"); + root.WriteFile("client/pagefind/wasm.en.pagefind", "wasm"); + root.WriteFile("client/_astro/logo.svg", ""); + var pages = new[] { "demo", "private", "cookie", "failure", "broken", "mismatch", "oversize", "large", "cold" } + .Concat(SpreadPages).ToArray(); + root.WriteFile("hybrid.json", JsonSerializer.Serialize(new HybridApiManifest( + 1, "hybrid-api-docs", new string('a', 64), "client", "server/entry.mjs", "https://aspire.dev", + pages.Select(page => new HybridApiRoute($"/reference/api/{page}/", $"/reference/api/{page}.md")).ToArray(), + [new("/old-guide/", "/guide/", 302)], OperatingSystem.IsWindows() ? "win32" : "linux"), + new JsonSerializerOptions(JsonSerializerDefaults.Web))); + var artifact = HybridApiDocsArtifact.Load(Path.Combine(root.Path, "hybrid.json")); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { WebRootPath = artifact.ClientDirectory }); + builder.Logging.ClearProviders(); + builder.WebHost.UseTestServer(); + builder.Services.AddHybridApiDocs(artifact, endpoints ?? ["http://renderer/"]); + var renderer = new TestApiRenderer(); + builder.Services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => renderer); + var app = builder.Build(); + app.UseResponseCompression(); + app.UseHybridApiDocsRouting(); + app.UseAgentReadiness(); + app.UseDefaultFiles(); + app.UseStaticFiles(HybridApiDocsExtensions.HybridStaticFileOptions()); + app.UseRouting(); + app.MapHybridApiDocs(); + await app.StartAsync(); + return new HybridHost(app, root, renderer); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.DisposeAsync(); + _root.Dispose(); + } +} + +internal sealed class TestApiRenderer : HttpMessageHandler +{ + public ConcurrentDictionary Calls { get; } = new(); + public ConcurrentQueue Requests { get; } = new(); + public ConcurrentDictionary EndpointCalls { get; } = new(); + public HashSet OfflineHosts { get; } = []; + public HashSet MismatchedHosts { get; } = []; + public HashSet ErrorHosts { get; } = []; + public bool Offline { get; set; } + public bool Timeout { get; set; } + public TaskCompletionSource? Gate { get; set; } + public bool SawCacheBypass { get; private set; } + public bool SawCookie { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var endpoint = request.RequestUri.Host; + EndpointCalls.AddOrUpdate(endpoint, 1, (_, count) => count + 1); + SawCacheBypass = request.Headers.TryGetValues("X-Aspire-Cache-Bypass", out var bypass) + && bypass.SequenceEqual(["1"]); + SawCookie = request.Headers.Contains("Cookie"); + Calls.AddOrUpdate(path, 1, (_, count) => count + 1); + if (request.RequestUri.AbsolutePath != "/__hybrid-api-docs/health") Requests.Enqueue(request.RequestUri.PathAndQuery); + if (Offline || OfflineHosts.Contains(endpoint)) throw new HttpRequestException("Renderer unavailable."); + if (Timeout) throw new OperationCanceledException("Renderer timed out."); + if (Gate is not null) await Gate.Task.WaitAsync(cancellationToken); + await Task.Delay(30, cancellationToken); + var page = path.Split('/').Last(segment => segment.Length > 0).Replace(".md", ""); + var response = new HttpResponseMessage(page == "failure" || ErrorHosts.Contains(endpoint) + ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK); + response.Headers.Add("X-Aspire-Build-Id", new string(page == "mismatch" || MismatchedHosts.Contains(endpoint) ? 'b' : 'a', 64)); + response.Headers.Add("X-Astro-Cache", "HIT"); + response.Headers.Vary.Add("X-Aspire-Cache-Bypass"); + response.Content = page switch + { + "broken" => new BrokenContent(), + "oversize" => new ByteArrayContent(new byte[HybridApiDocsRenderer.MaximumBodySize + 1]), + "large" => new ByteArrayContent(new byte[13_621_124]), + _ => new StringContent((path.EndsWith(".md") ? "markdown:" : "html:") + page), + }; + response.Content.Headers.ContentType = new(path.EndsWith(".md") ? "text/markdown" : "text/html"); + if (page == "cookie") response.Headers.Add("Set-Cookie", "test=value"); + response.Headers.CacheControl = page == "private" + ? new() { Private = true, NoStore = true } : new() { Public = true }; + return response; + } + + private sealed class BrokenContent : HttpContent + { + protected override bool TryComputeLength(out long length) { length = 0; return false; } + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => throw new IOException("Interrupted renderer body."); + } +}