[CmdletBinding()] param( [Alias("h")] [switch]$Help, [string]$ApiHost = "127.0.0.1", [int]$ApiPort = 8082, [string]$WebHost = "0.0.0.0", [int]$WebPort = 3000, [string]$SpacetimeHost = "127.0.0.1", [int]$SpacetimePort = 3101, [string]$SpacetimeRootDir = "", [string]$Database = "", [string]$Log = "info,tower_http=info", [int]$SpacetimeStartupTimeoutSeconds = 60, [switch]$SkipSpacetime, [switch]$SkipPublish, [switch]$ClearDatabase ) $ErrorActionPreference = "Stop" function Write-Usage { @( 'Usage:', ' npm run dev:rust', ' .\scripts\dev-rust-stack.ps1 -ApiPort 8090 -SpacetimePort 3110', ' .\scripts\dev-rust-stack.ps1 -SkipSpacetime -SkipPublish', ' .\scripts\dev-rust-stack.ps1 -ClearDatabase', '', 'Notes:', ' 1. Start SpacetimeDB standalone, Rust api-server, and Vite web together.', ' 2. Publish server-rs/crates/spacetime-module by default, without clearing data.', ' 3. Only -ClearDatabase appends spacetime publish --clear-database.', ' 4. Web listens on 0.0.0.0:3000 by default; API listens on 127.0.0.1:8082.' ) -join [Environment]::NewLine } function Quote-ProcessArgument { param([string]$Value) if ($null -eq $Value) { return '""' } if ($Value -notmatch '[\s"]') { return $Value } return '"' + $Value.Replace('"', '\"') + '"' } function Join-ProcessArguments { param([string[]]$Arguments) return (($Arguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " ") } function Resolve-ClientHost { param([string]$HostName) if ($HostName -eq "0.0.0.0" -or $HostName -eq "::") { return "127.0.0.1" } return $HostName } function Read-LocalSpacetimeDatabase { param([string]$RepoRoot) $localConfigPath = Join-Path $RepoRoot "spacetime.local.json" if (-not (Test-Path $localConfigPath)) { return "" } try { $localConfig = Get-Content -Path $localConfigPath -Encoding UTF8 -Raw | ConvertFrom-Json $database = [string]$localConfig.database if (-not [string]::IsNullOrWhiteSpace($database)) { return $database.Trim() } } catch { Write-Host "[dev:rust] ignore invalid spacetime.local.json: $($_.Exception.Message)" } return "" } function Start-StackProcess { param( [string]$Name, [string]$FilePath, [string[]]$Arguments, [string]$WorkingDirectory, [hashtable]$Environment ) $argumentLine = Join-ProcessArguments -Arguments $Arguments Write-Host "[dev:rust] start ${Name}: $FilePath $argumentLine" $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $FilePath $startInfo.Arguments = $argumentLine $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.RedirectStandardOutput = $false $startInfo.RedirectStandardError = $false $startInfo.RedirectStandardInput = $false foreach ($entry in $Environment.GetEnumerator()) { $startInfo.EnvironmentVariables[$entry.Key] = [string]$entry.Value } $process = New-Object System.Diagnostics.Process $process.StartInfo = $startInfo if (-not $process.Start()) { throw "Failed to start process: $Name" } return [PSCustomObject]@{ Name = $Name Process = $process } } function Stop-StackProcesses { param([System.Collections.Generic.List[object]]$Processes) for ($index = $Processes.Count - 1; $index -ge 0; $index--) { $item = $Processes[$index] $process = $item.Process if ($null -ne $process -and -not $process.HasExited) { Write-Host "[dev:rust] stop $($item.Name) (pid=$($process.Id))" $taskkillCommand = Get-Command taskkill.exe -ErrorAction SilentlyContinue if ($null -ne $taskkillCommand) { & $taskkillCommand.Source /PID $process.Id /T /F *> $null } else { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } } } } function Wait-ForSpacetimeServer { param( [string]$CommandPath, [string]$Server, [int]$TimeoutSeconds, $ProcessItem ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { if ($null -ne $ProcessItem -and $ProcessItem.Process.HasExited) { throw "SpacetimeDB exited before readiness, exit code: $($ProcessItem.Process.ExitCode)" } & $CommandPath server ping $Server *> $null if ($LASTEXITCODE -eq 0) { return } Start-Sleep -Milliseconds 500 } throw "Timed out waiting for SpacetimeDB readiness: $Server" } if ($Help) { Write-Usage exit 0 } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent $scriptDir $serverRsDir = Join-Path $repoRoot "server-rs" $manifestPath = Join-Path $serverRsDir "Cargo.toml" $modulePath = Join-Path $serverRsDir "crates\spacetime-module" $viteCliPath = Join-Path $repoRoot "scripts\vite-cli.mjs" if ([string]::IsNullOrWhiteSpace($SpacetimeRootDir)) { $SpacetimeRootDir = Join-Path $serverRsDir ".spacetimedb\local" } if ([string]::IsNullOrWhiteSpace($Database)) { $Database = Read-LocalSpacetimeDatabase -RepoRoot $repoRoot } if ([string]::IsNullOrWhiteSpace($Database)) { $Database = "genarrative-dev" } if (-not (Test-Path $manifestPath)) { throw "Missing server-rs/Cargo.toml, cannot start Rust local stack." } if (-not (Test-Path (Join-Path $modulePath "Cargo.toml"))) { throw "Missing server-rs/crates/spacetime-module/Cargo.toml, cannot publish SpacetimeDB module." } if (-not (Test-Path $viteCliPath)) { throw "Missing scripts/vite-cli.mjs, cannot start web frontend." } $cargoCommand = Get-Command cargo -ErrorAction SilentlyContinue $nodeCommand = Get-Command node -ErrorAction SilentlyContinue $spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue if ($null -eq $cargoCommand) { throw "Missing cargo. Install Rust toolchain first." } if ($null -eq $nodeCommand) { throw "Missing node. Install Node.js or use the project bundled runtime first." } if (-not $SkipSpacetime -or -not $SkipPublish) { if ($null -eq $spacetimeCommand) { throw "Missing spacetime CLI. Install guide: https://spacetimedb.com/install" } } $spacetimeServer = "http://$SpacetimeHost`:$SpacetimePort" $apiTargetHost = Resolve-ClientHost -HostName $ApiHost $rustServerTarget = "http://$apiTargetHost`:$ApiPort" $stackProcesses = New-Object System.Collections.Generic.List[object] $exitCode = 0 Write-Host "[dev:rust] repo: $repoRoot" Write-Host "[dev:rust] web: http://127.0.0.1:$WebPort" Write-Host "[dev:rust] rust api: $rustServerTarget" Write-Host "[dev:rust] spacetime: $spacetimeServer" Write-Host "[dev:rust] database: $Database" try { $spacetimeProcessItem = $null if (-not $SkipSpacetime) { New-Item -ItemType Directory -Force -Path $SpacetimeRootDir | Out-Null $spacetimeProcessItem = Start-StackProcess ` -Name "spacetimedb" ` -FilePath $spacetimeCommand.Source ` -Arguments @( "start", "--edition", "standalone", "--listen-addr", "$SpacetimeHost`:$SpacetimePort" ) ` -WorkingDirectory $serverRsDir ` -Environment @{} $stackProcesses.Add($spacetimeProcessItem) } if (-not $SkipPublish) { Write-Host "[dev:rust] wait for SpacetimeDB readiness" Wait-ForSpacetimeServer ` -CommandPath $spacetimeCommand.Source ` -Server $spacetimeServer ` -TimeoutSeconds $SpacetimeStartupTimeoutSeconds ` -ProcessItem $spacetimeProcessItem $publishArgs = @( "publish", $Database, "--server", $spacetimeServer, "--module-path", $modulePath ) if ($ClearDatabase) { $publishArgs += "--clear-database" } $publishArgs += "--yes" Write-Host "[dev:rust] publish SpacetimeDB module: $Database" & $spacetimeCommand.Source @publishArgs if ($LASTEXITCODE -ne 0) { throw "spacetime publish failed, exit code: $LASTEXITCODE" } } $apiEnvironment = @{ GENARRATIVE_API_HOST = $ApiHost GENARRATIVE_API_PORT = "$ApiPort" GENARRATIVE_API_LOG = $Log GENARRATIVE_SPACETIME_SERVER_URL = $spacetimeServer GENARRATIVE_SPACETIME_DATABASE = $Database } $apiProcessItem = Start-StackProcess ` -Name "api-server" ` -FilePath $cargoCommand.Source ` -Arguments @("run", "-p", "api-server", "--manifest-path", $manifestPath) ` -WorkingDirectory $repoRoot ` -Environment $apiEnvironment $stackProcesses.Add($apiProcessItem) $webEnvironment = @{ GENARRATIVE_BACKEND_STACK = "rust" RUST_SERVER_TARGET = $rustServerTarget GENARRATIVE_RUNTIME_SERVER_TARGET = $rustServerTarget VITE_DEV_HOST = $WebHost } $webProcessItem = Start-StackProcess ` -Name "vite" ` -FilePath $nodeCommand.Source ` -Arguments @($viteCliPath, "--port=$WebPort", "--host=$WebHost") ` -WorkingDirectory $repoRoot ` -Environment $webEnvironment $stackProcesses.Add($webProcessItem) Write-Host "[dev:rust] local Rust stack is running. Press Ctrl+C to stop all child processes." while ($true) { foreach ($item in $stackProcesses) { if ($item.Process.HasExited) { $exitCode = $item.Process.ExitCode Write-Host "[dev:rust] $($item.Name) exited, code: $exitCode" throw "Child process exited, shutting down Rust local stack." } } Start-Sleep -Seconds 1 } } catch { if ($exitCode -eq 0) { $exitCode = 1 } Write-Host "[dev:rust] $($_.Exception.Message)" } finally { Stop-StackProcesses -Processes $stackProcesses } exit $exitCode