>();
- logger.LogError(
- exception.Error,
- "Unhandled exception processing request {Path}. Error: {Error}\nStack Trace: {StackTrace}\nInner Exception: {InnerException}",
- context.Request.Path,
- exception.Error.Message,
- exception.Error.StackTrace,
- exception.Error.InnerException?.ToString() ?? "None"
- );
- logger.LogError(
- "Request Details - Method: {Method}, Path: {Path}, QueryString: {QueryString}",
- context.Request.Method,
- context.Request.Path,
- context.Request.QueryString
- );
-
- context.Response.StatusCode = 500;
- context.Response.ContentType = "text/html";
- await context.Response.WriteAsync(@"
-
- Error
-
- Internal Server Error
- An error occurred while processing your request.
- Please check the application logs for more details.
-
- ");
- }
- }
- catch (Exception handlerEx)
- {
- var logger = context.RequestServices.GetRequiredService>();
- logger.LogCritical(
- handlerEx,
- "Error handler failed to process exception. Handler Error: {Error}\nStack Trace: {StackTrace}",
- handlerEx.Message,
- handlerEx.StackTrace
- );
- context.Response.StatusCode = 500;
- context.Response.ContentType = "text/plain";
- await context.Response.WriteAsync("A critical error occurred.");
- }
- });
- })
- .UseLiveReload()
+ .UseLiveReloadWithManualScriptInjection(_webApplication.Lifetime)
+ .UseDeveloperExceptionPage(new DeveloperExceptionPageOptions())
.UseStaticFiles(
new StaticFileOptions
{
@@ -174,18 +120,15 @@ await context.Response.WriteAsync(@"
private async Task ServeApiFile(ReloadableGeneratorState holder, string slug, Cancel ctx)
{
-#if DEBUG
- // only reload when actually debugging
- if (System.Diagnostics.Debugger.IsAttached)
- await holder.ReloadApiReferences(ctx);
-#endif
+ var x = LiveReloadConfiguration.Current.LiveReloadScriptUrl;
+
var path = Path.Combine(holder.ApiPath.FullName, slug.Trim('/'), "index.html");
var info = _writeFileSystem.FileInfo.New(path);
if (info.Exists)
{
//TODO STREAM
var contents = await _writeFileSystem.File.ReadAllTextAsync(info.FullName, ctx);
- return Results.Content(contents, "text/html");
+ return LiveReloadHtml(contents, Encoding.UTF8, 200);
}
return Results.NotFound();
@@ -193,18 +136,18 @@ private async Task ServeApiFile(ReloadableGeneratorState holder, string
private static async Task ServeDocumentationFile(ReloadableGeneratorState holder, string slug, Cancel ctx)
{
+ if (slug == ".well-known/appspecific/com.chrome.devtools.json")
+ return Results.NotFound();
+
var generator = holder.Generator;
const string navPartialSuffix = ".nav.html";
// Check if the original request is asking for LLM-rendered markdown
var requestLlmMarkdown = slug.EndsWith(".md");
- var originalSlug = slug;
// If requesting .md output, remove the .md extension to find the source file
if (requestLlmMarkdown)
- {
slug = slug[..^3]; // Remove ".md" extension
- }
if (slug.EndsWith(navPartialSuffix))
{
@@ -243,12 +186,9 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta
var llmRendered = await generator.RenderLlmMarkdown(markdown, ctx);
return Results.Content(llmRendered, "text/markdown; charset=utf-8");
}
- else
- {
- // Regular HTML rendering
- var rendered = await generator.RenderLayout(markdown, ctx);
- return Results.Content(rendered.Html, "text/html");
- }
+ // Regular HTML rendering
+ var rendered = await generator.RenderLayout(markdown, ctx);
+ return LiveReloadHtml(rendered.Html);
case ImageFile image:
return Results.File(image.SourceFile.FullName, image.MimeType);
@@ -267,6 +207,19 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta
}
}
+ private static IResult LiveReloadHtml(string content, Encoding? encoding = null, int? statusCode = null)
+ {
+ if (LiveReloadConfiguration.Current.LiveReloadEnabled)
+ {
+ //var script = WebsocketScriptInjectionHelper.GetWebSocketClientJavaScript(context, true);
+ //var html = $"";
+ var html = "\n" + $@"";
+ content += html;
+ }
+
+ return Results.Content(content, "text/html", encoding, statusCode);
+ }
+
private static async Task ProxyChatRequest(HttpContext context, CancellationToken ctx)
{
try
diff --git a/src/tooling/docs-builder/Http/LiveReload.cs b/src/tooling/docs-builder/Http/LiveReload.cs
index d71e62c0b..c0b165f4b 100644
--- a/src/tooling/docs-builder/Http/LiveReload.cs
+++ b/src/tooling/docs-builder/Http/LiveReload.cs
@@ -3,9 +3,13 @@
// See the LICENSE file in the project root for more information
using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
// ReSharper disable once CheckNamespace
#pragma warning disable IDE0130
@@ -19,8 +23,7 @@ namespace Westwind.AspNetCore.LiveReload;
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresDynamicCode", Justification = "Manually verified")]
public static class LiveReloadMiddlewareExtensions
{
- public static IServiceCollection AddAotLiveReload(this IServiceCollection services,
- Action configAction)
+ public static IServiceCollection AddAotLiveReload(this IServiceCollection services, Action configAction)
{
var provider = services.BuildServiceProvider();
@@ -57,4 +60,60 @@ public static IServiceCollection AddAotLiveReload(this IServiceCollection servic
return services;
}
+
+ public static IApplicationBuilder UseLiveReloadWithManualScriptInjection(this IApplicationBuilder builder, IHostApplicationLifetime webApplicationLifetime)
+ {
+ var config = LiveReloadConfiguration.Current;
+
+ if (config.LiveReloadEnabled)
+ {
+ var webSocketOptions = new WebSocketOptions
+ {
+ KeepAliveInterval = TimeSpan.FromSeconds(300)
+ };
+ _ = builder.UseWebSockets(webSocketOptions);
+
+ _ = builder
+ .Use((context, next) =>
+ {
+ var middleWare = new NoInjectLiveReloadMiddleware(next, webApplicationLifetime);
+ return middleWare.InvokeAsync(context);
+ });
+
+ // always refresh when the server restarts...
+ _ = LiveReloadMiddleware.RefreshWebSocketRequest();
+ }
+
+ return builder;
+ }
+}
+
+
+///
+public class NoInjectLiveReloadMiddleware(RequestDelegate next, IHostApplicationLifetime lifeTime) : LiveReloadMiddleware(next, lifeTime)
+{
+ private readonly MethodInfo _handleWebSocketRequest =
+ typeof(LiveReloadMiddleware).GetMethod("HandleWebSocketRequest", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.InvokeMethod)!;
+
+ private readonly RequestDelegate _next = next;
+
+ public new async Task InvokeAsync(HttpContext context)
+ {
+ var config = LiveReloadConfiguration.Current;
+ if (!config.LiveReloadEnabled)
+ {
+ await _next(context);
+ return;
+ }
+
+ if (await HandleServeLiveReloadScript(context))
+ return;
+
+ // See if we have a WebSocket request. True means we handled
+ var invoked = await (Task)_handleWebSocketRequest.Invoke(this, [context])!;
+ if (invoked)
+ return;
+
+ await _next(context);
+ }
}
diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs
index 689d4290c..30a0af437 100644
--- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs
+++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs
@@ -1,15 +1,14 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information
+
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using Westwind.AspNetCore.LiveReload;
namespace Documentation.Builder.Http;
-public sealed class ReloadGeneratorService(
- ReloadableGeneratorState reloadableGenerator,
- ILogger logger) : IHostedService,
- IDisposable
+public sealed class ReloadGeneratorService(ReloadableGeneratorState reloadableGenerator, ILogger logger) : IHostedService, IDisposable
{
private FileSystemWatcher? _watcher;
private ReloadableGeneratorState ReloadableGenerator { get; } = reloadableGenerator;
@@ -22,15 +21,17 @@ public async Task StartAsync(Cancel cancellationToken)
{
await ReloadableGenerator.ReloadAsync(cancellationToken);
- var watcher = new FileSystemWatcher(ReloadableGenerator.Generator.DocumentationSet.SourceDirectory.FullName)
+ var directory = ReloadableGenerator.Generator.DocumentationSet.SourceDirectory.FullName;
+ Logger.LogInformation("Start file watch on: {Directory}", directory);
+ var watcher = new FileSystemWatcher(directory)
{
NotifyFilter = NotifyFilters.Attributes
- | NotifyFilters.CreationTime
- | NotifyFilters.DirectoryName
- | NotifyFilters.FileName
- | NotifyFilters.LastWrite
- | NotifyFilters.Security
- | NotifyFilters.Size
+ | NotifyFilters.CreationTime
+ | NotifyFilters.DirectoryName
+ | NotifyFilters.FileName
+ | NotifyFilters.LastWrite
+ | NotifyFilters.Security
+ | NotifyFilters.Size
};
watcher.Changed += OnChanged;
@@ -49,10 +50,11 @@ public async Task StartAsync(Cancel cancellationToken)
private void Reload() =>
_ = _debouncer.ExecuteAsync(async ctx =>
{
- Logger.LogInformation("Reload due to changes!");
await ReloadableGenerator.ReloadAsync(ctx);
Logger.LogInformation("Reload complete!");
- }, default);
+
+ _ = LiveReloadMiddleware.RefreshWebSocketRequest();
+ }, Cancel.None);
public Task StopAsync(Cancel cancellationToken)
{
@@ -65,26 +67,27 @@ private void OnChanged(object sender, FileSystemEventArgs e)
if (e.ChangeType != WatcherChangeTypes.Changed)
return;
+ Logger.LogInformation("Changed: {FullPath}", e.FullPath);
+
if (e.FullPath.EndsWith("docset.yml"))
Reload();
if (e.FullPath.EndsWith(".md"))
Reload();
- Logger.LogInformation("Changed: {FullPath}", e.FullPath);
}
private void OnCreated(object sender, FileSystemEventArgs e)
{
+ Logger.LogInformation("Created: {FullPath}", e.FullPath);
if (e.FullPath.EndsWith(".md"))
Reload();
- Logger.LogInformation("Created: {FullPath}", e.FullPath);
}
private void OnDeleted(object sender, FileSystemEventArgs e)
{
+ Logger.LogInformation("Deleted: {FullPath}", e.FullPath);
if (e.FullPath.EndsWith(".md"))
Reload();
- Logger.LogInformation("Deleted: {FullPath}", e.FullPath);
}
private void OnRenamed(object sender, RenamedEventArgs e)
@@ -144,5 +147,4 @@ public async Task ExecuteAsync(Func innerAction, Cancel cancellati
public void Dispose() => _semaphore.Dispose();
}
-
}
diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs
index ea9858d3a..bd9e1c542 100644
--- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs
@@ -35,9 +35,9 @@ public async Task Reads()
};
var context = new BuildContext(collector, new FileSystem(), versionsConfig);
- context.Configuration.OpenApiSpecification.Should().NotBeNull();
+ context.Configuration.OpenApiSpecifications.Should().NotBeNull().And.NotBeEmpty();
- var x = await OpenApiReader.Create(context.Configuration.OpenApiSpecification);
+ var x = await OpenApiReader.Create(context.Configuration.OpenApiSpecifications.First().Value);
x.Should().NotBeNull();
x.BaseUri.Should().NotBeNull();
@@ -63,11 +63,12 @@ public async Task Navigation()
var collector = new DiagnosticsCollector([]);
var context = new BuildContext(collector, new FileSystem(), versionsConfig);
var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance);
- context.Configuration.OpenApiSpecification.Should().NotBeNull();
+ context.Configuration.OpenApiSpecifications.Should().NotBeNull().And.NotBeEmpty();
- var openApiDocument = await OpenApiReader.Create(context.Configuration.OpenApiSpecification);
+ var (urlPathPrefix, fi) = context.Configuration.OpenApiSpecifications.First();
+ var openApiDocument = await OpenApiReader.Create(fi);
openApiDocument.Should().NotBeNull();
- var navigation = generator.CreateNavigation(openApiDocument);
+ var navigation = generator.CreateNavigation(urlPathPrefix, openApiDocument);
navigation.Should().NotBeNull();
}