Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,4 @@ terrabuild-debug.md

!**/results/terrabuild-debug.*
.pnpm-store
.vite
10 changes: 5 additions & 5 deletions src/Terrabuild/Terrabuild.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<ToolCommandName>terrabuild</ToolCommandName>
<AssemblyName>terrabuild</AssemblyName>
<IsPackable>true</IsPackable>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
<Compile Include="Contracts/Api.fs" />
Expand Down Expand Up @@ -41,6 +42,7 @@
<Compile Include="Storages/AzureBlobStorage.fs" />
<Compile Include="Storages/Factory.fs" />
<Compile Include="CLI.fs" />
<Compile Include="Web/EmbeddedUiFileProvider.fs" />
<Compile Include="Web/GraphServer.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
Expand Down Expand Up @@ -77,11 +79,9 @@
</ItemGroup>

<ItemGroup>
<Content Include="..\Terrabuild.UI\dist\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<TargetPath>ui\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
</Content>
<EmbeddedResource Include="..\Terrabuild.UI\dist\**\*">
<LogicalName>ui\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>

<PropertyGroup>
Expand Down
76 changes: 76 additions & 0 deletions src/Terrabuild/Web/EmbeddedUiFileProvider.fs
Original file line number Diff line number Diff line change
@@ -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<string, string>) key value =
if map.ContainsKey(key) |> not then
map.Add(key, value)

let resourceMap =
let map = Dictionary<string, string>(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
50 changes: 43 additions & 7 deletions src/Terrabuild/Web/GraphServer.fs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,22 @@ let start (graphArgs: ParseResults<GraphArgs>) =
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()
Expand All @@ -274,7 +289,23 @@ let start (graphArgs: ParseResults<GraphArgs>) =
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,
Expand All @@ -291,7 +322,10 @@ let start (graphArgs: ParseResults<GraphArgs>) =
|> 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<HttpContext, Task<IResult>>(fun _ ->
Expand Down Expand Up @@ -484,14 +518,16 @@ let start (graphArgs: ParseResults<GraphArgs>) =

app.MapFallback(Func<HttpContext, Task>(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

Expand Down