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
30 changes: 28 additions & 2 deletions bindings/csharp/FusionFramework/FusionApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,26 @@ internal void AddRawRoute(string method, string path, Func<object?> handler)
throw new InvalidOperationException($"Failed to register {method} {path}");
}

public void Listen(string? host = null, ushort port = 0)
public void Listen(string? host = null, ushort port = 0, bool? reload = null, IEnumerable<string>? watchDirs = null)
{
Mount();
var settings = SettingsStore.Current;
var settingsReload = Truthy(settings.Get("reload", false));
var shouldReload = Reloader.Resolve(reload, settingsReload);

if (shouldReload && !Reloader.IsChild)
{
Reloader.RunWithReloader(watchDirs);
return;
}

Mount();
host ??= settings.Host;
if (port == 0) port = settings.Port;
if (settings.Debug || shouldReload)
{
var mode = shouldReload ? " (reload)" : "";
Console.WriteLine($"fusion listening on http://{host}:{port}{mode}");
}

var code = Native.fusion_app_listen(_app, host, port);
// listen consumes the native app
Expand All @@ -156,6 +170,18 @@ public void Listen(string? host = null, ushort port = 0)
throw new InvalidOperationException("fusion_app_listen failed");
}

static bool Truthy(System.Text.Json.Nodes.JsonNode? node, bool fallback = false)
{
if (node is null) return fallback;
if (node is System.Text.Json.Nodes.JsonValue v)
{
if (v.TryGetValue<bool>(out var b)) return b;
if (v.TryGetValue<string>(out var s))
return s is "1" or "true" or "True" or "yes" or "on";
}
return fallback;
}

public void Dispose()
{
if (_disposed) return;
Expand Down
16 changes: 16 additions & 0 deletions bindings/csharp/FusionFramework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ foreach (var mw in MIDDLEWARE) app.Use(mw);
app.Listen();
```

### Auto-reload (development)

```csharp
// Restart the process when source files change
app.Listen(reload: true);

// Never reload (default) — same as omit / settings reload: false
app.Listen(reload: false);
```

Or in `fusion.dev.json`:

```json
{ "reload": true }
```

## Custom HTTP routes

Use method-level attributes alongside convention `get`/`post`/… handlers:
Expand Down
169 changes: 169 additions & 0 deletions bindings/csharp/FusionFramework/Reloader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Diagnostics;

namespace FusionFramework;

/// <summary>
/// Process-based auto-reload for development.
/// Parent watches files and restarts a child that runs the real server.
/// </summary>
public static class Reloader
{
public const string ChildEnvVar = "FUSION_RELOAD_CHILD";

static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
{
".git", ".hg", "node_modules", "target", ".venv", "venv",
"__pycache__", "bin", "obj", "dist", "build", ".idea", ".vs",
};

static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
".cs", ".json", ".html", ".tera", ".js", ".mjs", ".py",
};

public static bool IsChild =>
string.Equals(Environment.GetEnvironmentVariable(ChildEnvVar), "1", StringComparison.Ordinal);

public static bool Resolve(bool? reload, bool settingsReload) =>
reload ?? settingsReload;

public static void RunWithReloader(IEnumerable<string>? watchDirs = null)
{
if (IsChild)
throw new InvalidOperationException("RunWithReloader must not run inside the child process");

var roots = (watchDirs ?? new[] { Directory.GetCurrentDirectory() })
.Select(Path.GetFullPath)
.Distinct(StringComparer.Ordinal)
.ToList();

Console.WriteLine($"fusion: reload enabled (watching {string.Join(", ", roots)})");

Process? child = null;
var mtimes = Snapshot(Collect(roots));

void StopChild()
{
if (child is null || child.HasExited)
{
child = null;
return;
}
try
{
child.Kill(entireProcessTree: true);
child.WaitForExit(5000);
}
catch
{
/* best effort */
}
child = null;
}

Process StartChild()
{
var fileName = Environment.ProcessPath
?? throw new InvalidOperationException("Environment.ProcessPath is unavailable");
var start = new ProcessStartInfo
{
FileName = fileName,
UseShellExecute = false,
};
// Skip argv[0] (executable path); forward the rest.
foreach (var arg in Environment.GetCommandLineArgs().Skip(1))
start.ArgumentList.Add(arg);
start.Environment[ChildEnvVar] = "1";
return Process.Start(start)
?? throw new InvalidOperationException("failed to start reload child");
}

Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
StopChild();
Environment.Exit(0);
};

child = StartChild();
while (true)
{
Thread.Sleep(500);
if (child.HasExited)
{
Console.WriteLine($"fusion: child exited ({child.ExitCode}); restarting…");
Thread.Sleep(300);
child = StartChild();
mtimes = Snapshot(Collect(roots));
continue;
}

var files = Collect(roots);
var next = Snapshot(files);
string? changed = null;
foreach (var (file, mtime) in next)
{
if (!mtimes.TryGetValue(file, out var prev) || mtime > prev)
{
changed = file;
break;
}
}
if (changed is null)
{
foreach (var file in mtimes.Keys)
{
if (!next.ContainsKey(file))
{
changed = file;
break;
}
}
}
if (changed is null) continue;

var label = changed;
try { label = Path.GetRelativePath(Directory.GetCurrentDirectory(), changed); } catch { /* keep */ }
Console.WriteLine($"fusion: change detected ({label}); reloading…");
StopChild();
child = StartChild();
mtimes = Snapshot(Collect(roots));
}
}

static List<string> Collect(IEnumerable<string> roots)
{
var files = new List<string>();
foreach (var root in roots)
{
if (File.Exists(root))
{
if (Extensions.Contains(Path.GetExtension(root)))
files.Add(root);
continue;
}
if (!Directory.Exists(root)) continue;
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(root, file);
if (rel.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Any(p => SkipDirs.Contains(p)))
continue;
if (Extensions.Contains(Path.GetExtension(file)))
files.Add(file);
}
}
return files;
}

static Dictionary<string, long> Snapshot(IEnumerable<string> files)
{
var map = new Dictionary<string, long>(StringComparer.Ordinal);
foreach (var file in files)
{
try { map[file] = File.GetLastWriteTimeUtc(file).Ticks; }
catch { /* ignore */ }
}
return map;
}
}
21 changes: 21 additions & 0 deletions crates/fusion-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ impl Settings {
self.get_bool("debug").unwrap_or(false)
}

/// When true, host bindings should restart the process on source changes.
/// Default is ``false`` (no reload). Override with ``listen(reload=...)``.
pub fn reload(&self) -> bool {
self.get_bool("reload")
.or_else(|| self.get_bool("reload.enabled"))
.unwrap_or(false)
}

/// Directory for Tera templates (``templates.dir`` in settings).
pub fn templates_dir(&self) -> String {
self.get_str("templates.dir")
Expand Down Expand Up @@ -378,6 +386,19 @@ mod tests {
assert_eq!(settings.get_str("secret_key").as_deref(), Some("abc"));
}

#[test]
fn reload_defaults_false() {
let settings = Settings::new();
assert!(!settings.reload());
let mut settings = Settings::new();
settings.merge_map({
let mut m = Map::new();
m.insert("reload".into(), json!(true));
m
});
assert!(settings.reload());
}

fn tempfile_dir() -> PathBuf {
let dir = env::temp_dir().join(format!("fusion-settings-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
Expand Down
11 changes: 10 additions & 1 deletion crates/fusion-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,16 @@ export class FusionApp {
constructor(settings?: Partial<FusionSettings>)
use(middleware: FusionMiddleware): void
mount(): void
listen(host?: string, port?: number): Promise<void>
listen(
host?: string | {
host?: string
port?: number
reload?: boolean
watchDirs?: string[]
},
port?: number,
options?: { reload?: boolean; watchDirs?: string[] },
): Promise<void>
}

export type RouteOptions = {
Expand Down
Loading
Loading