open System open System.IO open System.Text.Json open System.Threading open System.Threading.Tasks open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.StaticFiles open Microsoft.Extensions.FileProviders open Microsoft.Extensions.Hosting open SomhairlesDream.Server open SomhairlesDream.Shared let builder = WebApplication.CreateBuilder(Environment.GetCommandLineArgs() |> Array.skip 1) let publicRoot = Path.Combine(AppContext.BaseDirectory, "public") builder.Environment.WebRootPath <- publicRoot builder.Environment.WebRootFileProvider <- new PhysicalFileProvider(publicRoot) let app = builder.Build() let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) let jsonOptions = JsonSerializerOptions(JsonSerializerDefaults.Web) let environmentOrDefault name fallback = match Environment.GetEnvironmentVariable(name) with | value when not (String.IsNullOrWhiteSpace(value)) -> value | _ -> fallback let artifactRoot = environmentOrDefault "SOMHAIRLES_ARTIFACT_ROOT" (Path.Combine(Environment.CurrentDirectory, ".artifacts")) let stageRoot = environmentOrDefault "SOMHAIRLES_STAGE_ROOT" (Path.Combine(Environment.CurrentDirectory, "stage")) let python = environmentOrDefault "SOMHAIRLES_BPYTHON" "python3" let artifactClock () = DateTimeOffset.UtcNow let coordinator = ArtifactRunCoordinator( artifactRoot, (fun () -> SomhairlesDream.Modeling.BlenderProcessBridge(python, stageRoot, TimeSpan.FromSeconds 120.) :> SomhairlesDream.Modeling.IArtifactBridge), artifactClock, TimeSpan.FromMinutes 2. ) let json value = Results.Json(value, jsonOptions) let events (context: HttpContext) : Task = (task { context.Response.ContentType <- "text/event-stream" context.Response.Headers.CacheControl <- "no-cache" let reader = store.Subscribe() try while not context.RequestAborted.IsCancellationRequested do let! snapshot = reader.ReadAsync(context.RequestAborted).AsTask() let payload = JsonSerializer.Serialize(snapshot, jsonOptions) do! context.Response.WriteAsync($"data: {payload}\n\n", context.RequestAborted) do! context.Response.Body.FlushAsync(context.RequestAborted) with | :? OperationCanceledException -> () } :> Task) app.UseDefaultFiles() |> ignore // Serve geometry and decoder binaries with correct MIME types: ASP.NET's // default map has no entry for .glb/.wasm, so those requests would 404. let staticFileOptions = let provider = FileExtensionContentTypeProvider() provider.Mappings.[".glb"] <- "model/gltf-binary" provider.Mappings.[".gltf"] <- "model/gltf+json" provider.Mappings.[".wasm"] <- "application/wasm" StaticFileOptions(ContentTypeProvider = provider) app.UseStaticFiles(staticFileOptions) |> ignore app.MapGet("/health", Func(fun () -> Results.Ok({| status = "ok" |}))) |> ignore ArtifactRunApi.register app { Coordinator = coordinator Clock = artifactClock JsonOptions = jsonOptions } let observeRuns = Task.Run( Func(fun () -> task { try while not app.Lifetime.ApplicationStopping.IsCancellationRequested do coordinator.ObserveAll() |> ignore do! Task.Delay(TimeSpan.FromSeconds 1., app.Lifetime.ApplicationStopping) with | :? OperationCanceledException -> () } :> Task) ) app.MapGet( "/api/state", Func(fun () -> store.Observe(DateTimeOffset.UtcNow) |> json) ) |> ignore app.MapPost( "/api/runs/heartbeat", Func(fun () -> store.Heartbeat(DateTimeOffset.UtcNow) |> json) ) |> ignore app.MapGet("/api/events", Func(events)) |> ignore app.Run()