diff --git a/.gitignore b/.gitignore index 8ec76fe2..bcc0c911 100644 --- a/.gitignore +++ b/.gitignore @@ -423,3 +423,4 @@ terrabuild-debug.md !**/results/terrabuild-debug.* .pnpm-store +.vite diff --git a/src/Terrabuild/Terrabuild.fsproj b/src/Terrabuild/Terrabuild.fsproj index a43eba9f..f753534f 100644 --- a/src/Terrabuild/Terrabuild.fsproj +++ b/src/Terrabuild/Terrabuild.fsproj @@ -5,6 +5,7 @@ terrabuild terrabuild true + true @@ -41,6 +42,7 @@ + @@ -77,11 +79,9 @@ - - PreserveNewest - PreserveNewest - ui\%(RecursiveDir)%(Filename)%(Extension) - + + ui\%(RecursiveDir)%(Filename)%(Extension) + diff --git a/src/Terrabuild/Web/EmbeddedUiFileProvider.fs b/src/Terrabuild/Web/EmbeddedUiFileProvider.fs new file mode 100644 index 00000000..e3f4d02a --- /dev/null +++ b/src/Terrabuild/Web/EmbeddedUiFileProvider.fs @@ -0,0 +1,76 @@ +module EmbeddedUiFileProvider + +open System +open System.IO +open System.Reflection +open System.Collections.Generic +open Microsoft.Extensions.FileProviders +open Microsoft.Extensions.Primitives + +type private EmbeddedUiFileInfo(resourceName: string, name: string, assembly: Assembly) = + interface IFileInfo with + member _.Exists = true + member _.Length = + try + match assembly.GetManifestResourceStream(resourceName) |> Option.ofObj with + | None -> -1L + | Some stream -> + use stream = stream + stream.Length + with _ -> -1L + member _.PhysicalPath = null + member _.Name = name + member _.LastModified = + // Avoid DateTimeOffset.MinValue which fails ToFileTime in StaticFileMiddleware. + DateTimeOffset.FromUnixTimeSeconds(0) + member _.IsDirectory = false + member _.CreateReadStream() = + match assembly.GetManifestResourceStream(resourceName) |> Option.ofObj with + | None -> raise (FileNotFoundException($"Embedded resource not found: {resourceName}")) + | Some stream -> stream + +type Provider(assembly: Assembly) = + let normalizePath (path: string) = + path.TrimStart('/').Replace('\\', '/') + + let addKey (map: Dictionary) key value = + if map.ContainsKey(key) |> not then + map.Add(key, value) + + let resourceMap = + let map = Dictionary(StringComparer.OrdinalIgnoreCase) + let names = assembly.GetManifestResourceNames() + for name in names do + let normalized = name.Replace('\\', '/') + if normalized.StartsWith("ui/") then + let rel = normalized.Substring(3) + addKey map rel name + elif normalized.StartsWith("ui.") then + let rel = normalized.Substring(3) + addKey map rel name + addKey map (rel.Replace('.', '/')) name + elif normalized.Contains(".ui.") then + let idx = normalized.IndexOf(".ui.", StringComparison.OrdinalIgnoreCase) + let rel = normalized.Substring(idx + 4) + addKey map rel name + addKey map (rel.Replace('.', '/')) name + elif normalized.Contains("/ui/") then + let idx = normalized.IndexOf("/ui/", StringComparison.OrdinalIgnoreCase) + let rel = normalized.Substring(idx + 4) + addKey map rel name + map + + interface IFileProvider with + member _.GetFileInfo(subpath) = + let key = normalizePath subpath + match resourceMap.TryGetValue(key) with + | true, resourceName -> + let fileName = Path.GetFileName(key) |> Option.ofObj |> Option.defaultValue key + EmbeddedUiFileInfo(resourceName, fileName, assembly) :> IFileInfo + | _ -> NotFoundFileInfo(subpath) :> IFileInfo + + member _.GetDirectoryContents(_subpath) = + NotFoundDirectoryContents.Singleton :> IDirectoryContents + + member _.Watch(_filter) = + NullChangeToken.Singleton diff --git a/src/Terrabuild/Web/GraphServer.fs b/src/Terrabuild/Web/GraphServer.fs index 0e5ae244..5464ed73 100644 --- a/src/Terrabuild/Web/GraphServer.fs +++ b/src/Terrabuild/Web/GraphServer.fs @@ -261,7 +261,22 @@ let start (graphArgs: ParseResults) = graphArgs.TryGetResult(CLI.GraphArgs.Workspace) |> resolveWorkspace let shouldOpenBrowser = graphArgs.Contains(GraphArgs.No_Open) |> not - let uiRoot = Path.Combine(AppContext.BaseDirectory, "ui") + let processDir = + System.Environment.ProcessPath + |> Option.ofObj + |> Option.bind (fun path -> Path.GetDirectoryName(path) |> Option.ofObj) + let defaultUiRoot = Path.Combine(AppContext.BaseDirectory, "ui") + let uiEnv = System.Environment.GetEnvironmentVariable("TB_UI_ROOT") |> Option.ofObj |> Option.defaultValue defaultUiRoot + let uiCandidates = + [ + uiEnv + defaultUiRoot + Path.Combine(AppContext.BaseDirectory, "..", "ui") + Path.Combine(AppContext.BaseDirectory, "..", "share", "terrabuild", "ui") + Path.Combine(AppContext.BaseDirectory, "..", "lib", "terrabuild", "ui") + ] + |> List.append (processDir |> Option.map (fun dir -> Path.Combine(dir, "ui")) |> Option.toList) + |> List.map (fun path -> Path.GetFullPath(path)) let port = graphArgs.TryGetResult(GraphArgs.Port) |> Option.defaultValue 5179 let url = $"http://127.0.0.1:{port}" let builder = WebApplication.CreateBuilder() @@ -274,7 +289,23 @@ let start (graphArgs: ParseResults) = let buildState = createBuildState() let workspaceLock = obj() - let fileProvider = new PhysicalFileProvider(uiRoot) + let embeddedProvider = + let assembly = System.Reflection.Assembly.GetExecutingAssembly() + let provider = EmbeddedUiFileProvider.Provider(assembly) :> IFileProvider + if provider.GetFileInfo("index.html").Exists then + Some provider + else + None + + let physicalProvider = + uiCandidates + |> List.tryFind Directory.Exists + |> Option.map (fun root -> new PhysicalFileProvider(root) :> IFileProvider) + + let fileProvider = + embeddedProvider + |> Option.orElse physicalProvider + |> Option.defaultValue (new Microsoft.Extensions.FileProviders.NullFileProvider() :> IFileProvider) let defaultFiles = DefaultFilesOptions( FileProvider = fileProvider, @@ -291,7 +322,10 @@ let start (graphArgs: ParseResults) = |> ignore) |> ignore - app.UseDefaultFiles(defaultFiles) |> ignore + match fileProvider with + | :? PhysicalFileProvider -> + app.UseDefaultFiles(defaultFiles) |> ignore + | _ -> () app.UseStaticFiles(StaticFileOptions(FileProvider = fileProvider, RequestPath = PathString.Empty)) |> ignore app.MapGet("/api/targets", Func>(fun _ -> @@ -484,14 +518,16 @@ let start (graphArgs: ParseResults) = app.MapFallback(Func(fun ctx -> task { - let indexFile = Path.Combine(uiRoot, "index.html") - if File.Exists(indexFile) then + let indexFile = fileProvider.GetFileInfo("index.html") + if indexFile.Exists then ctx.Response.ContentType <- "text/html" - let! html = File.ReadAllTextAsync(indexFile) + use stream = indexFile.CreateReadStream() + use reader = new StreamReader(stream, Encoding.UTF8) + let! html = reader.ReadToEndAsync() do! ctx.Response.WriteAsync(html) else ctx.Response.StatusCode <- 404 - do! ctx.Response.WriteAsync("UI assets not found. Build Terrabuild.UI first.") + do! ctx.Response.WriteAsync("UI assets not found. Set TB_UI_ROOT or build Terrabuild.UI.") })) |> ignore