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