From abcbf26a2af286e704f47f6fba384e5c79ec2af4 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Wed, 14 Jan 2026 23:42:20 +0100 Subject: [PATCH 1/4] serve from resources --- .gitignore | 1 + src/Terrabuild/Terrabuild.fsproj | 4 +++ src/Terrabuild/Web/GraphServer.fs | 48 +++++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 6 deletions(-) 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..6dba4b5c 100644 --- a/src/Terrabuild/Terrabuild.fsproj +++ b/src/Terrabuild/Terrabuild.fsproj @@ -5,6 +5,7 @@ terrabuild terrabuild true + true @@ -82,6 +83,9 @@ PreserveNewest ui\%(RecursiveDir)%(Filename)%(Extension) + + ui\%(RecursiveDir)%(Filename)%(Extension) + diff --git a/src/Terrabuild/Web/GraphServer.fs b/src/Terrabuild/Web/GraphServer.fs index 0e5ae244..204b557e 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,26 @@ let start (graphArgs: ParseResults) = let buildState = createBuildState() let workspaceLock = obj() - let fileProvider = new PhysicalFileProvider(uiRoot) + let embeddedProvider = + try + let assembly = System.Reflection.Assembly.GetExecutingAssembly() + let provider = new ManifestEmbeddedFileProvider(assembly, "ui") + if provider.GetFileInfo("index.html").Exists then + Some (provider :> IFileProvider) + else + None + with :? InvalidOperationException -> + 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, @@ -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 From 76880b0a1cef6ab60effecf6fcdd1b3cb00df560 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Thu, 15 Jan 2026 20:20:03 +0100 Subject: [PATCH 2/4] add embedded content --- src/Terrabuild/Terrabuild.fsproj | 6 +- src/Terrabuild/Web/EmbeddedUiFileProvider.fs | 70 ++++++++++++++++++++ src/Terrabuild/Web/GraphServer.fs | 18 ++--- 3 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 src/Terrabuild/Web/EmbeddedUiFileProvider.fs diff --git a/src/Terrabuild/Terrabuild.fsproj b/src/Terrabuild/Terrabuild.fsproj index 6dba4b5c..f753534f 100644 --- a/src/Terrabuild/Terrabuild.fsproj +++ b/src/Terrabuild/Terrabuild.fsproj @@ -42,6 +42,7 @@ + @@ -78,11 +79,6 @@ - - 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..e5bcc5de --- /dev/null +++ b/src/Terrabuild/Web/EmbeddedUiFileProvider.fs @@ -0,0 +1,70 @@ +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 + use stream = assembly.GetManifestResourceStream(resourceName) + if isNull stream then -1L else stream.Length + with _ -> -1L + member _.PhysicalPath = null + member _.Name = name + member _.LastModified = DateTimeOffset.MinValue + member _.IsDirectory = false + member _.CreateReadStream() = + match assembly.GetManifestResourceStream(resourceName) with + | null -> raise (FileNotFoundException($"Embedded resource not found: {resourceName}")) + | 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 -> + EmbeddedUiFileInfo(resourceName, Path.GetFileName(key), 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 204b557e..5464ed73 100644 --- a/src/Terrabuild/Web/GraphServer.fs +++ b/src/Terrabuild/Web/GraphServer.fs @@ -290,14 +290,11 @@ let start (graphArgs: ParseResults) = let workspaceLock = obj() let embeddedProvider = - try - let assembly = System.Reflection.Assembly.GetExecutingAssembly() - let provider = new ManifestEmbeddedFileProvider(assembly, "ui") - if provider.GetFileInfo("index.html").Exists then - Some (provider :> IFileProvider) - else - None - with :? InvalidOperationException -> + 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 = @@ -325,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 _ -> From c852c2ffb231014f68bf39055060dc4cefd747ff Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Thu, 15 Jan 2026 20:22:49 +0100 Subject: [PATCH 3/4] fix reported date --- src/Terrabuild/Web/EmbeddedUiFileProvider.fs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Terrabuild/Web/EmbeddedUiFileProvider.fs b/src/Terrabuild/Web/EmbeddedUiFileProvider.fs index e5bcc5de..ced17657 100644 --- a/src/Terrabuild/Web/EmbeddedUiFileProvider.fs +++ b/src/Terrabuild/Web/EmbeddedUiFileProvider.fs @@ -17,7 +17,9 @@ type private EmbeddedUiFileInfo(resourceName: string, name: string, assembly: As with _ -> -1L member _.PhysicalPath = null member _.Name = name - member _.LastModified = DateTimeOffset.MinValue + member _.LastModified = + // Avoid DateTimeOffset.MinValue which fails ToFileTime in StaticFileMiddleware. + DateTimeOffset.FromUnixTimeSeconds(0) member _.IsDirectory = false member _.CreateReadStream() = match assembly.GetManifestResourceStream(resourceName) with From f22bb2b729ee86824467b04b2497d3e437c4ce16 Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Thu, 15 Jan 2026 20:24:12 +0100 Subject: [PATCH 4/4] fix nullability warnings --- src/Terrabuild/Web/EmbeddedUiFileProvider.fs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Terrabuild/Web/EmbeddedUiFileProvider.fs b/src/Terrabuild/Web/EmbeddedUiFileProvider.fs index ced17657..e3f4d02a 100644 --- a/src/Terrabuild/Web/EmbeddedUiFileProvider.fs +++ b/src/Terrabuild/Web/EmbeddedUiFileProvider.fs @@ -12,8 +12,11 @@ type private EmbeddedUiFileInfo(resourceName: string, name: string, assembly: As member _.Exists = true member _.Length = try - use stream = assembly.GetManifestResourceStream(resourceName) - if isNull stream then -1L else stream.Length + match assembly.GetManifestResourceStream(resourceName) |> Option.ofObj with + | None -> -1L + | Some stream -> + use stream = stream + stream.Length with _ -> -1L member _.PhysicalPath = null member _.Name = name @@ -22,9 +25,9 @@ type private EmbeddedUiFileInfo(resourceName: string, name: string, assembly: As DateTimeOffset.FromUnixTimeSeconds(0) member _.IsDirectory = false member _.CreateReadStream() = - match assembly.GetManifestResourceStream(resourceName) with - | null -> raise (FileNotFoundException($"Embedded resource not found: {resourceName}")) - | stream -> stream + 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) = @@ -62,7 +65,8 @@ type Provider(assembly: Assembly) = let key = normalizePath subpath match resourceMap.TryGetValue(key) with | true, resourceName -> - EmbeddedUiFileInfo(resourceName, Path.GetFileName(key), assembly) :> IFileInfo + let fileName = Path.GetFileName(key) |> Option.ofObj |> Option.defaultValue key + EmbeddedUiFileInfo(resourceName, fileName, assembly) :> IFileInfo | _ -> NotFoundFileInfo(subpath) :> IFileInfo member _.GetDirectoryContents(_subpath) =