synapse-cc-0.3.2: src/SynapseCC/Pipeline.hs
-- | Pipeline orchestration - running the full toolchain
module SynapseCC.Pipeline
( runPipeline
, runBuildFromConfig
, generateIR
, generateCode
, formatSynapseError
) where
import Control.Exception (try, SomeException)
import Control.Monad (when, unless, forM_, forM)
import Data.Aeson (FromJSON, eitherDecodeStrict, eitherDecodeFileStrict)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO
import Data.Time.Clock (UTCTime, getCurrentTime, diffUTCTime)
import Data.Time.Format (formatTime, defaultTimeLocale)
import GHC.Generics (Generic)
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, listDirectory)
import System.Exit (ExitCode(..))
import System.FilePath ((</>), splitPath)
import Synapse.Monad (initEnv, runSynapseM, SynapseError(..))
import Synapse.IR.Builder (buildIR)
import qualified Synapse.Log as Log
import qualified Katip
import SynapseCC.Benchmark (timeStep, baselinePath, reportBenchmarks)
import SynapseCC.Config (buildConfigFromTarget)
import SynapseCC.Logging (logDebug, logInfo, logStep, logSuccess)
import SynapseCC.Types
import SynapseCC.Process
import SynapseCC.Discover
import qualified SynapseCC.Language as Language
import qualified SynapseCC.Cache as Cache
import SynapseCC.Cache (getCacheDir)
import qualified SynapseCC.Merge as Merge
import qualified SynapseCC.Lock as Lock
-- ============================================================================
-- Pipeline Orchestration
-- ============================================================================
-- | Run the complete pipeline
runPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath)
runPipeline config tools = do
let debug = optDebug (cfgOptions config)
-- Step 0: Check cache (unless --force is set)
cacheResult <- Cache.validateCache config tools
case cacheResult of
FullCacheHit -> do
logDebug debug "Full cache hit (versions match)"
let outputPath = optOutput (cfgOptions config)
-- If synapse.lock has an entry for this target, verify the live irHash
-- before skipping. This catches dynamic backends (e.g. fidget-spinner)
-- where the schema can change without a toolchain version bump.
lockResult <- Lock.readSynapseLock
case lockResult >>= Lock.lookupLockTarget outputPath of
Nothing ->
-- No lock entry — fall through to directory existence check
useCachedOutput debug outputPath
Just lt -> do
logDebug debug " Checking live irHash against synapse.lock..."
irCheckResult <- generateIR config tools
case irCheckResult of
Left _ -> do
-- Backend unreachable — use cached output gracefully (offline mode)
logDebug debug " Cannot reach backend — using cached output"
useCachedOutput debug outputPath
Right (IRPath irFile) -> do
irBytes <- BS.readFile irFile
-- Use the IR's own irHash field (content-stable, excludes timestamps).
let liveHash = case eitherDecodeStrict irBytes of
Right (irData :: IRData) -> fromMaybe "" (irdIrHash irData)
Left _ -> Merge.computeFileHash (TE.decodeUtf8 irBytes)
if liveHash == Lock.ltIrHash lt
then do
logDebug debug $ " irHash unchanged (" <> T.take 8 liveHash <> "...) — using cache"
useCachedOutput debug outputPath
else do
logDebug debug $ " irHash changed: " <> T.take 8 (Lock.ltIrHash lt)
<> " → " <> T.take 8 liveHash
runFullPipeline config tools
where
useCachedOutput dbg outPath = do
dirExists <- doesDirectoryExist outPath
files <- if dirExists then listDirectory outPath else pure []
if dirExists && not (null files)
then do
logDebug dbg " Using cached output"
pure $ Right $ CompiledPath outPath
else do
logDebug dbg " Cache hit but output directory missing or empty — regenerating"
runFullPipeline config tools
CacheMiss reason -> do
logDebug debug $ "Cache miss: " <> T.pack (show reason)
logDebug debug " Regenerating..."
runFullPipeline config tools
PartialCacheHit valid invalid -> do
let totalPlugins = length valid + length invalid
logInfo $ T.pack (show (length invalid)) <> " of " <> T.pack (show totalPlugins)
<> " plugins changed — regenerating"
runFullPipeline config tools
-- | Minimal starter package.json written when none exists in the output directory.
-- Includes standard scripts for the generated client; users own the name/version.
-- Dependencies are NOT listed here — they are added via `pm add` by addDependencies.
-- | Marker field written into the starter package.json.
-- Presence of this field in the cwd package.json tells synapse-cc that IT
-- created the file (standalone mode), so scaffolding files (tsconfig, test/)
-- continue to be regenerated on subsequent runs. Users who remove this field
-- opt in to integration mode — synapse-cc will stop managing those files.
synapseCCMarker :: Text
synapseCCMarker = "\"_generatedBy\": \"synapse-cc\""
-- | Marker present in package.json files generated by hub-codegen.
-- Used alongside synapseCCMarker to detect generated (standalone) output directories.
generatedByMarker :: Text
generatedByMarker = "\"_generatedBy\""
-- | Generate tsconfig.json for the synapse-cc managed output directory.
-- Excludes test/ so bun:test imports don't cause tsc errors — bun handles
-- test files natively and doesn't need tsconfig to know about bun:test.
generateTsconfig :: TransportType -> Text
generateTsconfig transport =
let typeConfig = case transport of
BrowserTransport -> "\"lib\": [\"ES2022\", \"DOM\"]"
WsTransport -> "\"types\": [\"node\"]"
in T.unlines
[ "{"
, " \"compilerOptions\": {"
, " \"target\": \"ES2022\","
, " \"module\": \"ESNext\","
, " \"moduleResolution\": \"bundler\","
, " \"strict\": true,"
, " \"skipLibCheck\": true,"
, " \"noEmit\": true,"
, " " <> typeConfig
, " },"
, " \"include\": [\"*.ts\"]"
, "}"
]
-- | Generate tsconfig.json for integration mode (generated code inside a host project).
-- Enables project references (composite, declaration, declarationMap) so the host
-- project can reference the generated directory as a sub-project with its own settings.
generateIntegrationTsconfig :: Text
generateIntegrationTsconfig = T.unlines
[ "{"
, " \"compilerOptions\": {"
, " \"target\": \"ES2022\","
, " \"module\": \"ES2022\","
, " \"moduleResolution\": \"bundler\","
, " \"composite\": true,"
, " \"declaration\": true,"
, " \"declarationMap\": true,"
, " \"strict\": true,"
, " \"skipLibCheck\": true,"
, " \"esModuleInterop\": true,"
, " \"outDir\": \"../dist/generated\","
, " \"rootDir\": \".\""
, " },"
, " \"include\": [\"**/*.ts\"]"
, "}"
]
-- | Run the full pipeline without cache
runFullPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath)
runFullPipeline config tools = do
let debug = optDebug (cfgOptions config)
opts = cfgOptions config
Backend backendName = cfgBackend config
targetName = targetToText (cfgTarget config)
outputDir = optOutput opts
pipelineStart <- getCurrentTime
cacheDir <- getCacheDir opts
-- Step 1: Read schema from backend
logStep "Reading schema..."
(irResult, irMs) <- timeStep $ generateIR config tools
case irResult of
Left err -> pure $ Left err
Right irPath -> do
irBytes <- BS.readFile (unIRPath irPath)
let pluginCount = case eitherDecodeStrict irBytes of
Right (ir :: IRData) -> Map.size (irdPlugins ir)
Left _ -> 0
logSuccess $ "Schema ready (" <> T.pack (show pluginCount) <> " plugins)"
logDebug debug $ " IR at " <> T.pack (unIRPath irPath)
-- Step 2: Generate code
logStep "Generating code..."
(codeResult, codeMs) <- timeStep $ generateCode config tools irPath Nothing
case codeResult of
Left err -> pure $ Left err
Right out -> do
logSuccess $ "Code generated (" <> T.pack (show (Map.size (coFiles out))) <> " files)"
-- Detect standalone vs integration mode.
--
-- Check the OUTPUT directory first: if it already has a package.json with a
-- "_generatedBy" field, hub-codegen (or a prior synapse-cc run) wrote it
-- → standalone, regardless of what the CWD contains.
--
-- If outputDir has no package.json, fall back to checking the CWD: if the CWD
-- has a package.json without our marker, the user ran synapse-cc from inside a
-- host project → integration mode (Tauri, monorepo, etc.).
--
-- Integration mode strips test/ scaffolding, skips devDeps, and skips
-- build/test steps — the host project owns those concerns.
cwd <- getCurrentDirectory
let pmPath = GeneratedPath cwd
cwdPkgPath = cwd </> "package.json"
outPkgJsonPath = outputDir </> "package.json"
outPkgExists <- doesFileExist outPkgJsonPath
isIntegration <-
if outPkgExists
then do
-- outputDir has a package.json — if it has _generatedBy it's ours (standalone)
content <- TIO.readFile outPkgJsonPath
pure $ not (generatedByMarker `T.isInfixOf` content)
else do
-- outputDir has no package.json — check CWD for host project
cwdPkgExists <- doesFileExist cwdPkgPath
if not cwdPkgExists
then pure False
else do
cwdContent <- TIO.readFile cwdPkgPath
pure $ not (synapseCCMarker `T.isInfixOf` cwdContent)
when isIntegration $
logInfo " Integration mode — build and test steps skipped"
let scaffolding k _ = "test/" `T.isPrefixOf` k
-- Apply three-way merge: write safe files, skip user-modified ones.
-- tsconfig.json is always excluded: synapse-cc writes its own (see below).
-- package.json is excluded in integration mode (host project owns it);
-- in standalone mode it goes through the merge so script changes are applied.
-- In integration mode, also exclude test/* (host project owns those).
-- With --force, skip cached hashes so all files are written fresh.
cachedHashes <- if optForce opts then pure Map.empty else getCachedFileHashes config
let filesToMerge = (if isIntegration then Map.filterWithKey (fmap not . scaffolding) else id)
$ (if isIntegration then Map.delete "package.json" else id)
$ Map.delete "tsconfig.json"
$ coFiles out
mergeResult <- Merge.applyMerge filesToMerge (coFileHashes out) cachedHashes outputDir
-- Remove stale generated files (plugins removed from backend).
-- Only deletes files whose on-disk hash still matches the cached hash
-- (i.e. the user hasn't modified them). Empty directories are pruned.
deleted <- Merge.cleanRemovedFiles cachedHashes (coFileHashes out) outputDir
unless (null deleted) $ do
logInfo $ " 🗑 " <> T.pack (show (length deleted))
<> " stale file(s) removed (plugin removed from backend)"
when debug $ forM_ deleted $ \f ->
logDebug debug $ " - " <> f
-- Write ir.json to output dir as a reference artifact
irContent <- BS.readFile (unIRPath irPath)
BS.writeFile (outputDir </> "ir.json") irContent
-- Log smart-merged files (new content added, user modifications preserved)
let merged = Merge.mrMerged mergeResult
unless (null merged) $ do
logInfo $ " ✓ " <> T.pack (show (length merged))
<> " file(s) smart-merged (new content added, user modifications preserved)"
when debug $ forM_ merged $ \f ->
logDebug debug $ " - " <> f
-- Log skipped files (user modifications preserved)
let skipped = Merge.mrSkipped mergeResult
unless (null skipped) $ do
logInfo $ " ⚠ " <> T.pack (show (length skipped))
<> " file(s) skipped (user modifications preserved)"
when debug $ forM_ skipped $ \f ->
logDebug debug $ " - " <> f
let genPath = GeneratedPath outputDir
-- Write synapse-cc's tsconfig to the output dir.
-- Standalone mode: tight config (noEmit, transport-specific types).
-- Integration mode: project-references config (composite, declaration).
do logDebug debug " Writing tsconfig.json"
let tsconfig = if isIntegration
then generateIntegrationTsconfig
else generateTsconfig (optTransport opts)
TIO.writeFile (outputDir </> "tsconfig.json") tsconfig
-- Step 3: Install dependencies (if enabled)
-- Standalone: deps go into the generated dir (genPath) — it owns its own package.json.
-- Integration: deps go into the host project root (pmPath) since there's no generated package.json.
let depsToAdd = coDependencies out
devDepsToAdd = if isIntegration then Map.empty else coDevDependencies out
depPath = if isIntegration then pmPath else genPath
(installResult, installMs) <- if optInstallDeps opts
then do
(addResult, addMs) <- timeStep $ Language.addDependencies
depPath
depsToAdd
devDepsToAdd
debug
case addResult of
Left err -> pure (Left err, addMs)
Right added -> do
-- Run installDependencies if new packages were added OR node_modules is absent
nodeModulesExists <- doesDirectoryExist
(unGeneratedPath depPath </> "node_modules")
if not added && nodeModulesExists
then pure (Right (), addMs)
else do
when added $ logSuccess "Dependencies updated"
logStep "Installing dependencies..."
(result, ms) <- timeStep $ Language.installDependencies (cfgTarget config) depPath debug
case result of
Right () -> logSuccess "Dependencies installed"
Left _ -> pure ()
pure (result, addMs + ms)
else do
logDebug debug "Skipping dependency installation (--no-install)"
pure (Right (), 0)
case installResult of
Left err -> pure $ Left err
Right () -> do
-- Step 4: Build project (if enabled)
-- In integration mode tsconfig.json is not written to the output dir;
-- the host project's own build system handles compilation.
(buildResult, buildMs) <- if optBuild opts && not isIntegration
then do
logStep "Building..."
(result, ms) <- timeStep $ Language.buildProject (cfgTarget config) genPath debug
case result of
Right _ -> logSuccess "Build passed"
Left _ -> pure ()
pure (result, ms)
else do
if isIntegration
then logDebug debug "Skipping build (integration mode — compile via your project's build system)"
else logDebug debug "Skipping build (--no-build)"
pure (Right $ CompiledPath $ unGeneratedPath genPath, 0)
case buildResult of
Left err -> pure $ Left err
Right compiledPath -> do
-- Step 5: Run tests (if enabled)
-- In integration mode test/ is not written to the output dir.
(testResult, testMs) <- if optRunTests opts && not isIntegration
then do
logStep "Running tests..."
(result, ms) <- timeStep $ Language.runTests (cfgTarget config) genPath debug
case result of
Right () -> logSuccess "Tests passed"
Left _ -> pure ()
pure (result, ms)
else pure (Right (), 0)
case testResult of
Left err -> pure $ Left err
Right () -> do
-- Patch file hashes: replace hashes for smart-merged files
-- with their merged hashes so subsequent runs see them as SafeToUpdate
let patchedHashes = Map.union (Merge.mrMergedHashes mergeResult) (coFileHashes out)
patchedOut = out { coFileHashes = patchedHashes }
writeCache config tools irPath compiledPath patchedOut
-- Benchmarks
pipelineEnd <- getCurrentTime
let totalMs = round (pipelineEnd `diffUTCTime'` pipelineStart * 1000) :: Int
steps = Map.fromList $ filter (\(_, v) -> v > 0)
[ ("ir_generation", irMs)
, ("code_generation", codeMs)
, ("install_dependencies", installMs)
, ("build", buildMs)
, ("tests", testMs)
]
ts = T.pack $ formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" pipelineEnd
bPath = baselinePath cacheDir targetName backendName
when debug $ reportBenchmarks bPath backendName targetName ts steps totalMs
pure $ Right compiledPath
diffUTCTime' :: UTCTime -> UTCTime -> Double
diffUTCTime' t1 t0 = realToFrac (t1 `diffUTCTime` t0)
-- | Write cache manifests after successful generation
writeCache :: Config -> ToolLocations -> IRPath -> CompiledPath -> CodegenOutput -> IO ()
writeCache config tools irPath _compiledPath codegenOutput = do
let debug = optDebug (cfgOptions config)
synapseVer = toolSynapseVersion tools
hubCodegenVer = coHubCodegenVersion codegenOutput
logDebug debug "Writing cache manifests..."
-- Read IR to extract plugin hashes
irPluginCaches <- readIRPluginHashes irPath debug
-- Write IR cache manifest with plugin hashes
Cache.writeIRCacheManifest (cfgOptions config) (cfgBackend config) irPluginCaches synapseVer
-- Write code cache manifest using file hashes from CodegenOutput
let pluginCaches = Map.singleton "default" CodePluginCache
{ cpcIRHash = ""
, cpcFileHashes = coFileHashes codegenOutput
, cpcCachedAt = ""
}
Cache.writeCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config)
pluginCaches synapseVer hubCodegenVer
-- Write synapse.lock (project-level, committed to git)
irBytes <- BS.readFile (unIRPath irPath)
-- Use the IR's own irHash field (content-stable, no timestamps).
-- Fall back to hashing the raw bytes only if the field is absent.
let parsedIrHash = case eitherDecodeStrict irBytes of
Right (irData :: IRData) -> fromMaybe "" (irdIrHash irData)
Left _ -> ""
irHash = if T.null parsedIrHash
then Merge.computeFileHash (TE.decodeUtf8 irBytes)
else parsedIrHash
outputDir = optOutput (cfgOptions config)
transport = case optTransport (cfgOptions config) of
WsTransport -> "ws"
BrowserTransport -> "browser"
Backend bkName = cfgBackend config
lt = Lock.LockTarget
{ Lock.ltBackend = bkName
, Lock.ltIrHash = irHash
, Lock.ltTransport = transport
, Lock.ltFiles = coFileHashes codegenOutput
, Lock.ltSharedTransport = Nothing
}
lock <- fromMaybe Lock.emptySynapseLock <$> Lock.readSynapseLock
now <- getCurrentTime
Lock.writeSynapseLock $ lock
{ Lock.slTargets = Map.insert (T.pack outputDir) lt (Lock.slTargets lock)
, Lock.slUpdatedAt = T.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" now)
, Lock.slHubCodegen = hubCodegenVer
}
logDebug debug " Cache manifests written"
-- | Read the cached file hashes for three-way merge.
-- synapse.lock (project-level, committed to git) takes priority over ~/.cache.
getCachedFileHashes :: Config -> IO (Map.Map Text Text)
getCachedFileHashes config = do
let outputDir = optOutput (cfgOptions config)
lockResult <- Lock.readSynapseLock
case lockResult >>= Lock.lookupLockTarget outputDir of
Just lt -> pure (Lock.ltFiles lt)
Nothing -> do
result <- Cache.readCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config)
pure $ case result of
Left _ -> Map.empty
Right manifest -> maybe Map.empty cpcFileHashes $ Map.lookup "default" (ccmPlugins manifest)
-- | Read IR plugin hashes from ir.json (now at cache path)
readIRPluginHashes :: IRPath -> Bool -> IO (Map.Map Text IRPluginCache)
readIRPluginHashes (IRPath irFilePath) debug = do
exists <- doesFileExist irFilePath
if not exists
then do
logDebug debug " ir.json not found, skipping IR plugin hashes"
pure Map.empty
else do
result <- eitherDecodeFileStrict irFilePath
case result of
Left err -> do
logDebug debug $ " Failed to parse IR: " <> T.pack err
pure Map.empty
Right (irData :: IRData) -> do
let pluginHashes = fromMaybe Map.empty (irdPluginHashes irData)
pluginCaches = Map.mapWithKey (buildIRPluginCache pluginHashes) (irdPlugins irData)
logDebug debug $ " Extracted hashes for " <> T.pack (show (Map.size pluginCaches)) <> " plugins"
pure pluginCaches
-- | Build an IRPluginCache entry from plugin info and hashes
buildIRPluginCache :: Map.Map Text PluginHashInfo -> Text -> [Text] -> IRPluginCache
buildIRPluginCache hashMap pluginName _methods =
case Map.lookup pluginName hashMap of
Just hashes -> IRPluginCache
{ ipcIRHash = ""
, ipcSchemaHash = phiHash hashes
, ipcSelfHash = phiSelfHash hashes
, ipcChildrenHash = phiChildrenHash hashes
, ipcDependencies = []
, ipcCachedAt = ""
}
Nothing -> IRPluginCache
{ ipcIRHash = ""
, ipcSchemaHash = ""
, ipcSelfHash = ""
, ipcChildrenHash = ""
, ipcDependencies = []
, ipcCachedAt = ""
}
-- ============================================================================
-- Config-file build
-- ============================================================================
-- | Run a build pass for every target in a SynapseConfig.
-- CLI options override per-target transport/outputDir only when explicitly
-- different from their defaults; targets own those fields.
-- Providers (no sharedTransport) are built first, then consumers (with sharedTransport)
-- get shim files that re-export from the provider's output.
-- Returns one result per target (name, outcome).
runBuildFromConfig
:: SynapseConfig
-> Options -- ^ CLI flags (--debug, --force, --no-install, etc.)
-> ToolLocations
-> IO [(T.Text, Either SynapseCCError CompiledPath)]
runBuildFromConfig sc opts tools = do
let allTargets = Map.toList (scTargets sc)
-- Partition: providers first (no sharedTransport), then consumers
(providers, consumers) = foldr partitionTarget ([], []) allTargets
partitionTarget (name, tc) (ps, cs) =
case tcSharedTransport tc of
Nothing -> ((name, tc) : ps, cs)
Just _ -> (ps, (name, tc) : cs)
-- Build providers first
providerResults <- mapM buildTarget providers
-- Build consumers: run hub-codegen for plugins only, then write shim files
consumerResults <- forM consumers $ \(name, tc) -> do
let config = buildConfigFromTarget opts sc tc
logInfo $ "Building \"" <> name <> "\" → " <> T.pack (optOutput (cfgOptions config))
logDebug (optDebug opts) $ " target \"" <> name <> "\" uses sharedTransport"
-- Run normal pipeline for plugins (generate list should be ["plugins"])
result <- runPipeline config tools
-- Write re-export shim files and update lock
case (result, tcSharedTransport tc) of
(Right _, Just providerName) ->
case Map.lookup providerName (scTargets sc) of
Just providerTc -> do
let shimFiles = generateShimFiles (tcOutputDir tc) (tcOutputDir providerTc)
createDirectoryIfMissing True (tcOutputDir tc)
forM_ (Map.toList shimFiles) $ \(fileName, content) -> do
let filePath = tcOutputDir tc </> T.unpack fileName
TIO.writeFile filePath content
logDebug (optDebug opts) $ " Wrote shim: " <> T.pack filePath
-- Update lock file with shim file hashes
lock <- fromMaybe Lock.emptySynapseLock <$> Lock.readSynapseLock
let outputKey = T.pack (tcOutputDir tc)
case Map.lookup outputKey (Lock.slTargets lock) of
Just lt -> do
let shimHashes = Map.mapWithKey (\_ content -> Merge.computeFileHash content) shimFiles
updatedLt = lt
{ Lock.ltSharedTransport = Just providerName
, Lock.ltFiles = Map.union shimHashes (Lock.ltFiles lt)
}
Lock.writeSynapseLock $ lock
{ Lock.slTargets = Map.insert outputKey updatedLt (Lock.slTargets lock) }
Nothing -> pure ()
logSuccess $ "Shim files written for \"" <> name <> "\""
Nothing -> pure () -- Should not happen (validated in Config.hs)
_ -> pure ()
pure (name, result)
pure $ providerResults ++ consumerResults
where
buildTarget (name, tc) = do
let config = buildConfigFromTarget opts sc tc
logInfo $ "Building \"" <> name <> "\" → " <> T.pack (optOutput (cfgOptions config))
logDebug (optDebug opts) $ " target \"" <> name <> "\" is defined in synapse.config.json under .targets"
result <- if tcGenerate tc == ["transport"]
then runTransportOnlyPipeline config tools
else runPipeline config tools
pure (name, result)
-- | Generate re-export shim files for a child target that shares transport
-- with a parent target. Computes the relative path from child → parent
-- and returns a map of filename → shim content.
generateShimFiles :: FilePath -> FilePath -> Map.Map Text Text
generateShimFiles childDir parentDir =
let relPath = computeRelativePath childDir parentDir
shimContent file = "export * from '" <> relPath <> "/" <> file <> "';\n"
in Map.fromList
[ ("transport.ts", shimContent "transport")
, ("rpc.ts", shimContent "rpc")
, ("types.ts", shimContent "types")
]
-- | Compute the relative path from childDir to parentDir.
-- E.g., "generated/substrate" → "generated" yields ".."
-- E.g., "src/gen/sub" → "src/gen" yields ".."
-- E.g., "generated-sub" → "generated" yields "../generated"
computeRelativePath :: FilePath -> FilePath -> Text
computeRelativePath childDir parentDir =
let -- splitPath keeps trailing '/' on components; strip for comparison
normalize = map (filter (/= '/'))
childParts = normalize (splitPath childDir)
parentParts = normalize (splitPath parentDir)
-- Find common prefix length
commonLen = length $ takeWhile id $ zipWith (==) childParts parentParts
-- Steps up from child to common ancestor
stepsUp = length childParts - commonLen
-- Steps down from common ancestor to parent
stepsDown = drop commonLen parentParts
upParts = replicate stepsUp ".."
relParts = upParts ++ stepsDown
rel = T.intercalate "/" $ map T.pack relParts
in if T.null rel then "." else rel
-- ============================================================================
-- Transport-Only Pipeline (no backend connection required)
-- ============================================================================
-- | Run the transport-only pipeline.
-- transport.ts is a static template parameterised only by --transport mode,
-- so no backend connection or IR generation is needed.
runTransportOnlyPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath)
runTransportOnlyPipeline config tools = do
let opts = cfgOptions config
debug = optDebug opts
outputDir = optOutput opts
createDirectoryIfMissing True outputDir
logStep "Generating transport layer..."
codeResult <- generateTransportCode config tools
case codeResult of
Left err -> pure $ Left err
Right out -> do
logSuccess "Transport generated"
-- Three-way merge (transport.ts is static — merge handles unchanged correctly
-- even without a cache entry: if content matches what's on disk, skip write).
cachedHashes <- if optForce opts then pure Map.empty else getCachedFileHashes config
mergeResult <- Merge.applyMerge (coFiles out) (coFileHashes out) cachedHashes outputDir
let skipped = Merge.mrSkipped mergeResult
unless (null skipped) $
logInfo $ " ⚠ " <> T.pack (show (length skipped))
<> " file(s) skipped (user modifications preserved)"
logDebug debug " No backend, deps, build, or tests for transport-only target"
pure $ Right $ CompiledPath outputDir
-- | Call hub-codegen --generate transport (no IR argument needed).
generateTransportCode :: Config -> ToolLocations -> IO (Either SynapseCCError CodegenOutput)
generateTransportCode config tools = do
let debug = optDebug (cfgOptions config)
hubCodegenPath = toolPathToFilePath (toolHubCodegen tools)
opts = cfgOptions config
targetArg = T.unpack (targetToText (cfgTarget config))
args =
[ "--target", targetArg
, "--output-format", "json"
, "--generate", "transport"
, "--transport", case optTransport opts of
WsTransport -> "ws"
BrowserTransport -> "browser"
]
result <- runProcess hubCodegenPath args Nothing debug
case prExitCode result of
ExitSuccess ->
case eitherDecodeStrict (TE.encodeUtf8 (prStdout result)) of
Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0
Right out -> pure $ Right out
ExitFailure code ->
pure $ Left $ HubCodegenError (prStderr result) code
-- ============================================================================
-- IR Generation
-- ============================================================================
-- | Generate IR using plexus-synapse library (replaces subprocess call)
generateIR :: Config -> ToolLocations -> IO (Either SynapseCCError IRPath)
generateIR config _tools = do
let debug = optDebug (cfgOptions config)
Backend bkName = cfgBackend config
outputDir = optOutput (cfgOptions config)
opts = cfgOptions config
host = cfgHost config
port = read (T.unpack (cfgPort config)) :: Int
generatorInfo = ["synapse-cc:" <> synapseCCVersion]
-- Compute IR file path in the cache directory:
-- <cacheDir>/synapse/ir/<backend>/ir.json
cacheDir <- getCacheDir opts
let irDir = cacheDir </> "synapse" </> "ir" </> T.unpack bkName
irFile = irDir </> "ir.json"
-- Ensure output and IR cache directories exist
createDirectoryIfMissing True outputDir
createDirectoryIfMissing True irDir
-- Call plexus-synapse library directly (no subprocess).
-- Pass [] as path: the backend is encoded in the env; [] = walk from root.
logDebug debug $ " Connecting to " <> host <> ":" <> T.pack (show port)
logger <- Log.makeLogger Katip.ErrorS
env <- initEnv host port bkName logger
-- Wrap in try to catch hard exceptions from child-plugin fetch errors
rawResult <- try (runSynapseM env (buildIR generatorInfo []))
result <- case rawResult of
Left (ex :: SomeException) ->
let raw = T.pack (show ex)
msg = if "Connection refused" `T.isInfixOf` raw || "does not exist" `T.isInfixOf` raw
then "Cannot connect to backend — is it running?"
else T.takeWhile (/= '\n') raw
in pure $ Left msg
Right (Left synapseErr) -> pure $ Left $ formatSynapseError synapseErr
Right (Right ir) -> pure $ Right ir
case result of
Left msg -> do
logDebug debug $ " Synapse error: " <> msg
pure $ Left $ SynapseError msg 1
Right ir -> do
-- Encode IR to JSON and write to cache path
let irBytes = BL.toStrict (Aeson.encode ir)
BS.writeFile irFile irBytes
-- Validate by round-tripping (ensures we can parse what we wrote)
case eitherDecodeStrict irBytes of
Left parseErr -> pure $ Left $ InvalidIR $ T.pack parseErr
Right (_ :: IRData) -> pure $ Right $ IRPath irFile
-- | Format a SynapseError (from plexus-synapse) for display
formatSynapseError :: SynapseError -> Text
formatSynapseError err = case err of
NavError ne ->
let raw = T.pack (show ne)
in if "Connection refused" `T.isInfixOf` raw || "does not exist" `T.isInfixOf` raw
then "Cannot connect to backend — is it running?"
else "Connection error: " <> T.takeWhile (/= '\n') raw
TransportError t -> t
TransportErrorContext ctx ->
"Cannot connect to " <> T.pack (show ctx)
ParseError t -> "Parse error: " <> t
ValidationError t -> "Validation error: " <> t
BackendError bt _ -> "Backend error: " <> T.pack (show bt)
-- | IR structure for reading plugin hashes
-- We only need the fields relevant for caching
data IRData = IRData
{ irdVersion :: !Text
, irdIrHash :: !(Maybe Text) -- ^ Content-stable hash of schema (no timestamps)
, irdPlugins :: !(Map.Map Text [Text])
, irdPluginHashes :: !(Maybe (Map.Map Text PluginHashInfo))
} deriving stock (Show, Generic)
instance FromJSON IRData where
parseJSON = Aeson.withObject "IRData" $ \o -> IRData
<$> o Aeson..: "irVersion"
<*> o Aeson..:? "irHash"
<*> o Aeson..: "irPlugins"
<*> o Aeson..:? "irPluginHashes"
-- | Plugin hash information from IR
data PluginHashInfo = PluginHashInfo
{ phiHash :: !Text
, phiSelfHash :: !Text
, phiChildrenHash :: !Text
} deriving stock (Show, Generic)
deriving anyclass (FromJSON)
-- ============================================================================
-- Code Generation
-- ============================================================================
-- | Generate code using hub-codegen, returning parsed JSON output.
-- Pass @Just namespaces@ to restrict generation to specific plugin namespaces
-- (equivalent to @--generate plugins --plugins ns1,ns2@).
-- Pass @Nothing@ for a full generation pass.
generateCode :: Config -> ToolLocations -> IRPath -> Maybe [Text] -> IO (Either SynapseCCError CodegenOutput)
generateCode config tools irPath nsFilter = do
let debug = optDebug (cfgOptions config)
hubCodegenPath = toolPathToFilePath (toolHubCodegen tools)
opts = cfgOptions config
target = cfgTarget config
targetArg = T.unpack (targetToText target)
backendUrl = "ws://" <> T.unpack (cfgHost config) <> ":" <> T.unpack (cfgPort config)
-- nsFilter: incremental mode (--generate plugins --plugins ...)
filterArgs = case nsFilter of
Nothing -> []
Just nss -> ["--generate", "plugins", "--plugins", T.unpack (T.intercalate "," nss)]
-- cfgPlugins: full generation with plugin filtering (--plugins ... only, no --generate plugins)
pluginFilterArgs = case cfgPlugins config of
Just ps | null filterArgs -> ["--plugins", T.unpack (T.intercalate "," ps)]
_ -> []
args =
[ "--target", targetArg
, "--output-format", "json"
, "--transport", case optTransport opts of
WsTransport -> "ws"
BrowserTransport -> "browser"
, "--backend-url", backendUrl
]
++ filterArgs
++ pluginFilterArgs
++ [ unIRPath irPath ]
result <- runProcess hubCodegenPath args Nothing debug
case prExitCode result of
ExitSuccess -> do
let stdoutBytes = TE.encodeUtf8 (prStdout result)
case eitherDecodeStrict stdoutBytes of
Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0
Right out -> pure $ Right out
ExitFailure code ->
pure $ Left $ HubCodegenError (prStderr result) code