From 3b37cb3994a91c03fd1df3e30f34565a5d6a92fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 21 Aug 2026 09:58:41 +0200 Subject: [PATCH] feat(compile): package generated asset modules --- changelog.d/8517-opencode-assets.md | 5 + crates/perry/src/commands/compile.rs | 2 + .../src/commands/compile/asset_manifest.rs | 198 ++++++++++ .../src/commands/compile/asset_modules.rs | 363 ++++++++++++++++++ .../perry/src/commands/compile/build_cache.rs | 6 + .../src/commands/compile/collect_modules.rs | 50 ++- .../compile/collect_modules/import_helpers.rs | 9 + .../commands/compile/collect_modules/tests.rs | 43 +++ crates/perry/src/commands/compile/resolve.rs | 3 + .../src/commands/compile/run_pipeline.rs | 35 +- crates/perry/src/commands/compile/types.rs | 26 ++ crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + docs/src/cli/flags.md | 27 ++ 14 files changed, 749 insertions(+), 20 deletions(-) create mode 100644 changelog.d/8517-opencode-assets.md create mode 100644 crates/perry/src/commands/compile/asset_manifest.rs create mode 100644 crates/perry/src/commands/compile/asset_modules.rs diff --git a/changelog.d/8517-opencode-assets.md b/changelog.d/8517-opencode-assets.md new file mode 100644 index 0000000000..fdf3ef363a --- /dev/null +++ b/changelog.d/8517-opencode-assets.md @@ -0,0 +1,5 @@ +### Added + +- Added deterministic generated asset modules, checkout-stable file-loader + handles, and an `assets.json` provenance report for source-first + OpenCode-style builds. diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index e478e0f519..023ba16f03 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -13,6 +13,8 @@ pub(crate) mod android_target; mod app_metadata; mod apple_codesign; mod apple_info_plist; +mod asset_manifest; +mod asset_modules; mod audit_manifest; mod bootstrap; mod build_cache; diff --git a/crates/perry/src/commands/compile/asset_manifest.rs b/crates/perry/src/commands/compile/asset_manifest.rs new file mode 100644 index 0000000000..23bf5173c1 --- /dev/null +++ b/crates/perry/src/commands/compile/asset_manifest.rs @@ -0,0 +1,198 @@ +//! Deterministic provenance report for source-graph assets. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use super::{is_recognized_text_asset, CompilationContext}; +use crate::OutputFormat; + +#[derive(Serialize)] +struct AssetManifest { + version: u32, + generated_modules: Vec, + assets: Vec, +} + +#[derive(Serialize)] +struct GeneratedModuleRecord { + specifier: String, + source_directory: String, + packaged_files: usize, +} + +#[derive(Clone, Serialize)] +struct AssetRecord { + kind: &'static str, + source: String, + packaged_path: String, + size: u64, + sha256: String, + #[serde(skip_serializing_if = "Option::is_none")] + generated_module: Option, +} + +pub(super) fn write( + ctx: &CompilationContext, + embedded_assets: &[(String, PathBuf)], + format: OutputFormat, +) -> std::io::Result<()> { + let mut records: BTreeMap<(String, String), AssetRecord> = BTreeMap::new(); + + for path in ctx.native_modules.keys() { + if ctx.file_loader_asset_paths.contains(path) { + continue; + } + let kind = if path.extension().and_then(|ext| ext.to_str()) == Some("json") { + Some("json") + } else if is_recognized_text_asset(path) { + Some("text") + } else { + None + }; + if let Some(kind) = kind { + insert_record( + &mut records, + record_for( + ctx, + path, + kind, + format!("module:{}", source_origin(path, &ctx.cache_root)), + None, + )?, + ); + } + } + + for (packaged_name, path) in embedded_assets { + let kind = if path.extension().and_then(|ext| ext.to_str()) == Some("wasm") { + "wasm" + } else if ctx.file_loader_asset_paths.contains(path) { + "file" + } else { + "embedded" + }; + insert_record( + &mut records, + record_for( + ctx, + path, + kind, + format!("$perryfs/{packaged_name}"), + generated_owner(ctx, path), + )?, + ); + } + + let generated_modules = ctx + .generated_asset_modules + .iter() + .map(|(specifier, generated)| GeneratedModuleRecord { + specifier: specifier.clone(), + source_directory: source_origin(&generated.asset_root, &ctx.cache_root), + packaged_files: generated.assets.len(), + }) + .collect(); + let manifest = AssetManifest { + version: 1, + generated_modules, + assets: records.into_values().collect(), + }; + fs::create_dir_all(&ctx.cache_dir)?; + let path = ctx.cache_dir.join("assets.json"); + let mut json = serde_json::to_string_pretty(&manifest).map_err(std::io::Error::other)?; + json.push('\n'); + fs::write(&path, json)?; + if matches!(format, OutputFormat::Text) { + println!("Asset manifest: {}", path.display()); + } + Ok(()) +} + +fn insert_record(records: &mut BTreeMap<(String, String), AssetRecord>, record: AssetRecord) { + records.insert( + (record.packaged_path.clone(), record.source.clone()), + record, + ); +} + +fn record_for( + ctx: &CompilationContext, + path: &Path, + kind: &'static str, + packaged_path: String, + generated_module: Option, +) -> std::io::Result { + let bytes = fs::read(path)?; + Ok(AssetRecord { + kind, + source: source_origin(path, &ctx.cache_root), + packaged_path, + size: bytes.len() as u64, + sha256: Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + generated_module, + }) +} + +fn generated_owner(ctx: &CompilationContext, path: &Path) -> Option { + ctx.generated_asset_modules + .iter() + .find_map(|(specifier, generated)| { + path.starts_with(&generated.asset_root) + .then(|| specifier.clone()) + }) +} + +/// Return a slash-normalized path relative to the project/package root, +/// retaining `..` components for monorepo sibling assets. +fn source_origin(path: &Path, root: &Path) -> String { + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + relative_path(&root, &path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn relative_path(from: &Path, to: &Path) -> Option { + let from: Vec> = from.components().collect(); + let to: Vec> = to.components().collect(); + if from.first() != to.first() { + return None; + } + let common = from + .iter() + .zip(&to) + .take_while(|(left, right)| left == right) + .count(); + let mut result = PathBuf::new(); + for _ in common..from.len() { + result.push(".."); + } + for component in &to[common..] { + result.push(component.as_os_str()); + } + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_origins_are_checkout_independent() { + assert_eq!( + relative_path( + Path::new("/checkout/pkg"), + Path::new("/checkout/app/dist/a.js") + ), + Some(PathBuf::from("../app/dist/a.js")) + ); + } +} diff --git a/crates/perry/src/commands/compile/asset_modules.rs b/crates/perry/src/commands/compile/asset_modules.rs new file mode 100644 index 0000000000..eb3308296b --- /dev/null +++ b/crates/perry/src/commands/compile/asset_modules.rs @@ -0,0 +1,363 @@ +//! Deterministic virtual modules for directories of packaged files. +//! +//! Bun's build API lets applications inject a generated module whose default +//! export maps logical file names to `type: "file"` imports. Source-first AOT +//! compilation has no bundler injection phase, so `--asset-module` reproduces +//! that narrow operation before module collection and stores the generated +//! source in Perry's cache. + +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use sha2::{Digest, Sha256}; + +use super::{CompilationContext, GeneratedAssetModule}; +use crate::OutputFormat; + +pub(super) fn generate( + specs: &[String], + ctx: &mut CompilationContext, + format: OutputFormat, +) -> Result<()> { + for spec in specs { + let (module_specifier, directory) = spec.split_once('=').ok_or_else(|| { + anyhow!( + "invalid --asset-module `{spec}`; expected =" + ) + })?; + let module_specifier = module_specifier.trim(); + let directory = directory.trim(); + if module_specifier.is_empty() || directory.is_empty() { + bail!( + "invalid --asset-module `{spec}`; both the module specifier and asset directory are required" + ); + } + if Path::new(module_specifier) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + bail!( + "invalid --asset-module specifier `{module_specifier}`; use a bare or nested module name without `.` or `..` path components" + ); + } + if ctx.generated_asset_modules.contains_key(module_specifier) { + bail!("duplicate --asset-module specifier `{module_specifier}`"); + } + + let requested_root = Path::new(directory); + let requested_root = if requested_root.is_absolute() { + requested_root.to_path_buf() + } else { + ctx.cache_root.join(requested_root) + }; + let asset_root = requested_root.canonicalize().with_context(|| { + format!( + "asset directory for generated module `{module_specifier}` was not found: {}\n \ + Run the upstream asset build/preparation command first, then retry Perry compile.", + requested_root.display() + ) + })?; + if !asset_root.is_dir() { + bail!( + "asset-module source for `{module_specifier}` is not a directory: {}", + asset_root.display() + ); + } + + let mut assets: Vec<(String, PathBuf)> = walkdir::WalkDir::new(&asset_root) + .follow_links(false) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) + .filter_map(|entry| { + let path = entry.into_path(); + let relative = path.strip_prefix(&asset_root).ok()?; + let logical = slash_path(relative); + (!logical.ends_with(".map")).then_some((logical, path)) + }) + .collect(); + assets.sort_by(|a, b| a.0.cmp(&b.0)); + if assets.is_empty() { + bail!( + "asset directory for generated module `{module_specifier}` contains no packageable files: {}\n \ + Run the upstream asset build/preparation command first, then retry Perry compile.", + asset_root.display() + ); + } + + let generated_dir = ctx.cache_dir.join("generated-asset-modules"); + fs::create_dir_all(&generated_dir).with_context(|| { + format!( + "failed to create generated asset-module cache directory {}", + generated_dir.display() + ) + })?; + let module_hash = short_hash(module_specifier); + let generated_path = generated_dir.join(format!("{module_hash}.gen.ts")); + let logical_root = ctx + .cache_root + .canonicalize() + .unwrap_or_else(|_| ctx.cache_root.clone()); + let logical_path = logical_root.join(module_specifier); + let logical_dir = logical_path.parent().unwrap_or(&ctx.cache_root); + + let mut source = + String::from("// Generated deterministically by Perry --asset-module. Do not edit.\n"); + for (index, (logical, path)) in assets.iter().enumerate() { + let canonical = path + .canonicalize() + .with_context(|| format!("failed to resolve packaged asset {}", path.display()))?; + let import_path = relative_path(logical_dir, &canonical).ok_or_else(|| { + anyhow!( + "cannot express asset {} relative to generated module `{module_specifier}`", + canonical.display() + ) + })?; + let mut import_path = slash_path(&import_path); + if !import_path.starts_with('.') { + import_path.insert_str(0, "./"); + } + source.push_str(&format!( + "import file_{index} from {} with {{ type: \"file\" }};\n", + serde_json::to_string(&import_path)? + )); + let identity = format!("{module_specifier}\0{logical}"); + let packaged_name = format!( + "__perry_imports/{}/{filename}", + short_hash(&identity), + filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("asset.bin") + ); + ctx.file_loader_asset_names.insert(canonical, packaged_name); + } + source.push_str("export default {\n"); + for (index, (logical, _)) in assets.iter().enumerate() { + source.push_str(&format!( + " {}: file_{index},\n", + serde_json::to_string(logical)? + )); + } + source.push_str("};\n"); + fs::write(&generated_path, source).with_context(|| { + format!( + "failed to write generated asset module {}", + generated_path.display() + ) + })?; + let source_path = generated_path.canonicalize().with_context(|| { + format!( + "failed to resolve generated asset module {}", + generated_path.display() + ) + })?; + + if matches!(format, OutputFormat::Text) { + println!( + " Asset module: {module_specifier} ({} files from {})", + assets.len(), + asset_root.display() + ); + } + ctx.generated_asset_modules.insert( + module_specifier.to_string(), + GeneratedAssetModule { + source_path, + logical_path, + asset_root, + assets, + }, + ); + } + Ok(()) +} + +fn slash_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn relative_path(from: &Path, to: &Path) -> Option { + let from: Vec> = from.components().collect(); + let to: Vec> = to.components().collect(); + if from.first() != to.first() { + return None; + } + let common = from + .iter() + .zip(&to) + .take_while(|(left, right)| left == right) + .count(); + let mut result = PathBuf::new(); + for _ in common..from.len() { + result.push(".."); + } + for component in &to[common..] { + result.push(component.as_os_str()); + } + Some(result) +} + +fn short_hash(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + use crate::commands::progress::VerboseProgress; + + #[test] + fn generation_is_sorted_excludes_sourcemaps_and_uses_stable_handles() { + let project = tempfile::tempdir().unwrap(); + let assets = project.path().join("dist"); + fs::create_dir_all(assets.join("nested")).unwrap(); + fs::write(assets.join("z.js"), "z").unwrap(); + fs::write(assets.join("nested/a.css"), "a").unwrap(); + fs::write(assets.join("z.js.map"), "map").unwrap(); + + let mut ctx = CompilationContext::new(project.path().to_path_buf()); + ctx.cache_root = project.path().to_path_buf(); + ctx.cache_dir = project.path().join("cache"); + generate( + &["web-ui.gen.ts=dist".to_string()], + &mut ctx, + OutputFormat::Json, + ) + .unwrap(); + + let generated = &ctx.generated_asset_modules["web-ui.gen.ts"]; + assert_eq!( + generated + .assets + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + ["nested/a.css", "z.js"] + ); + let source = fs::read_to_string(&generated.source_path).unwrap(); + assert!(source.find("nested/a.css").unwrap() < source.find("z.js").unwrap()); + assert!(!source.contains("z.js.map")); + assert!(!source.contains(project.path().to_string_lossy().as_ref())); + + let first_names = ctx.file_loader_asset_names.clone(); + let mut second = CompilationContext::new(project.path().to_path_buf()); + second.cache_root = project.path().to_path_buf(); + second.cache_dir = project.path().join("cache-2"); + generate( + &["web-ui.gen.ts=dist".to_string()], + &mut second, + OutputFormat::Json, + ) + .unwrap(); + assert_eq!(first_names, second.file_loader_asset_names); + + let other_project = tempfile::tempdir().unwrap(); + let other_assets = other_project.path().join("dist"); + fs::create_dir_all(other_assets.join("nested")).unwrap(); + fs::write(other_assets.join("z.js"), "z").unwrap(); + fs::write(other_assets.join("nested/a.css"), "a").unwrap(); + fs::write(other_assets.join("z.js.map"), "map").unwrap(); + let mut relocated = CompilationContext::new(other_project.path().to_path_buf()); + relocated.cache_root = other_project.path().to_path_buf(); + relocated.cache_dir = other_project.path().join("cache"); + generate( + &["web-ui.gen.ts=dist".to_string()], + &mut relocated, + OutputFormat::Json, + ) + .unwrap(); + let relocated_source = + fs::read_to_string(&relocated.generated_asset_modules["web-ui.gen.ts"].source_path) + .unwrap(); + assert_eq!(source, relocated_source); + let mut first_handles: Vec<_> = first_names.into_values().collect(); + let mut relocated_handles: Vec<_> = + relocated.file_loader_asset_names.into_values().collect(); + first_handles.sort(); + relocated_handles.sort(); + assert_eq!(first_handles, relocated_handles); + } + + #[test] + fn missing_directory_has_preparation_remediation() { + let project = tempfile::tempdir().unwrap(); + let mut ctx = CompilationContext::new(project.path().to_path_buf()); + ctx.cache_root = project.path().to_path_buf(); + let error = generate( + &["web-ui.gen.ts=missing-dist".to_string()], + &mut ctx, + OutputFormat::Json, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("was not found")); + assert!(error.contains("upstream asset build/preparation command")); + } + + #[test] + fn generated_bare_module_collects_file_loader_assets_and_writes_manifest() { + let project = tempfile::tempdir().unwrap(); + let assets = project.path().join("dist"); + fs::create_dir_all(&assets).unwrap(); + fs::write(assets.join("index.html"), "

Perry

").unwrap(); + let entry = project.path().join("entry.ts"); + fs::write( + &entry, + r#" +export async function ui() { + return import("opencode-web-ui.gen.ts"); +} +"#, + ) + .unwrap(); + + let mut ctx = CompilationContext::new(project.path().to_path_buf()); + ctx.cache_root = project.path().to_path_buf(); + ctx.cache_dir = project.path().join("cache"); + ctx.entry_canonical = Some(entry.canonicalize().unwrap()); + generate( + &["opencode-web-ui.gen.ts=dist".to_string()], + &mut ctx, + OutputFormat::Json, + ) + .unwrap(); + + let mut visited = HashSet::new(); + let mut next_class_id: perry_hir::ClassId = 1; + let progress = VerboseProgress::new(OutputFormat::Json, 0); + super::super::collect_modules( + &entry, + &mut ctx, + &mut visited, + OutputFormat::Json, + None, + &mut next_class_id, + false, + &progress, + None, + ) + .unwrap(); + + assert_eq!(ctx.file_loader_asset_paths.len(), 1); + assert_eq!(ctx.embedded_assets.len(), 1); + let html = assets.join("index.html").canonicalize().unwrap(); + let html_hir = format!("{:?}", ctx.native_modules.get(&html).unwrap()); + assert!(html_hir.contains("$perryfs/__perry_imports/")); + assert!(!html_hir.contains("

Perry

")); + let embedded = ctx.embedded_assets.clone(); + super::super::asset_manifest::write(&ctx, &embedded, OutputFormat::Json).unwrap(); + let manifest = fs::read_to_string(ctx.cache_dir.join("assets.json")).unwrap(); + assert!(manifest.contains("opencode-web-ui.gen.ts")); + assert!(manifest.contains("index.html")); + assert!(manifest.contains("$perryfs/__perry_imports/")); + assert!(!manifest.contains(project.path().to_string_lossy().as_ref())); + } +} diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 810416ae39..f185a15653 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -689,6 +689,12 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if args.bundle_extensions.is_some() { return Err("bundle-extensions".to_string()); } + // Asset modules are generated from directory contents before collection. + // Keep the build-level cache conservative until its manifest fingerprints + // those directories; object/link caches still apply within the build. + if !args.asset_module.is_empty() { + return Err("asset-module".to_string()); + } if args.enable_wasm_runtime { return Err("wasm-runtime".to_string()); } diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 4e07034d55..f3813990a4 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -120,8 +120,12 @@ fn file_loader_import_sources(module: &swc_ecma_ast::Module) -> HashSet } /// Produce a stable virtual asset name without leaking an absolute source path. -fn imported_file_asset_name(path: &Path) -> String { - let normalized = path.to_string_lossy().replace('\\', "/"); +fn imported_file_asset_name(path: &Path, project_root: &Path) -> String { + // Hash the source identity relative to the package root whenever possible. + // Hashing the canonical absolute path made otherwise identical builds in + // two checkout directories expose different `$perryfs` handles. + let identity = path.strip_prefix(project_root).unwrap_or(path); + let normalized = identity.to_string_lossy().replace('\\', "/"); let mut hash = 0xcbf29ce484222325_u64; for byte in normalized.as_bytes() { hash ^= u64::from(*byte); @@ -134,6 +138,13 @@ fn imported_file_asset_name(path: &Path) -> String { format!("__perry_imports/{hash:016x}/{filename}") } +fn looks_like_generated_module(specifier: &str) -> bool { + let filename = specifier.rsplit('/').next().unwrap_or(specifier); + filename.contains(".gen.") + || filename.contains(".generated.") + || filename.starts_with("generated-") +} + #[allow(clippy::too_many_arguments)] fn collect_module_one( entry_path: &PathBuf, @@ -330,7 +341,11 @@ fn collect_module_one( } else if is_wasm { let bytes = fs::read(&canonical) .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))?; - let asset_name = imported_file_asset_name(&canonical); + let asset_name = ctx + .file_loader_asset_names + .get(&canonical) + .cloned() + .unwrap_or_else(|| imported_file_asset_name(&canonical, &ctx.cache_root)); if !ctx .embedded_assets .iter() @@ -349,7 +364,12 @@ fn collect_module_one( }; // JSON module import: turn the data file into a native ESM module whose // default export is the parsed value. - let raw_source = if is_json { + let raw_source = if imported_file_asset.is_some() || is_wasm { + // The explicit file loader (and the wasm adapter above) already + // synthesized executable TypeScript. Extension-based JSON/text + // loaders must not reinterpret that generated source as asset bytes. + raw_source + } else if is_json { synthesize_json_module(&raw_source, &canonical)? } else if is_text_asset { // #5223: text-asset import. The file's contents are exposed verbatim as @@ -1226,7 +1246,11 @@ fn collect_module_one( let resolved_path = resolved.canonical_path; let source_path = resolved.source_path; if uses_file_loader { - let name = imported_file_asset_name(&resolved_path); + let name = ctx + .file_loader_asset_names + .get(&resolved_path) + .cloned() + .unwrap_or_else(|| imported_file_asset_name(&resolved_path, &ctx.cache_root)); ctx.file_loader_asset_paths.insert(resolved_path.clone()); if !ctx .embedded_assets @@ -1488,6 +1512,22 @@ fn collect_module_one( } } else { // Could not resolve - might be a Node.js builtin or missing module + // Generated inputs must fail at collection time. Continuing with + // an empty binding makes a stale/missing preparation step look + // like a runtime application bug (OpenCode's injected + // `opencode-web-ui.gen.ts` was the motivating case). + if looks_like_generated_module(&import.source) { + return Err(anyhow::anyhow!( + "Could not resolve generated module `{source}` imported by {filename} ({path}).\n\ + Perry will not compile with a missing or stale generated input. Run the upstream preparation command first.\n\ + If this module is a directory-to-file map, retry with:\n \ + --asset-module '{source}='\n\ + For other generators, declare the command in package.json under `perry.codegen` and ensure it writes this module.", + source = import.source, + filename = filename, + path = canonical.display(), + )); + } // Issue #629: hard-error on namespace imports (`import * as X from ...`) // for unresolved modules. Pre-fix the codegen catch-all produced a // typeof-"object" empty-namespace stub; property access cleanly read diff --git a/crates/perry/src/commands/compile/collect_modules/import_helpers.rs b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs index b2e2990ca1..a76b88369e 100644 --- a/crates/perry/src/commands/compile/collect_modules/import_helpers.rs +++ b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs @@ -131,6 +131,15 @@ pub(super) fn cached_resolve_import_with_lexical_base( canonical_importer_path: &Path, ctx: &mut CompilationContext, ) -> Option { + // Virtual asset modules live in Perry's cache, but their deterministic + // source is authored as though the module existed at the package root. + // Resolve relative file-loader edges from that logical path rather than + // from `/generated-asset-modules/`. + if let Some(logical_path) = ctx.generated_asset_modules.values().find_map(|generated| { + (generated.source_path == canonical_importer_path).then(|| generated.logical_path.clone()) + }) { + return cached_resolve_import_from_base(import_source, &logical_path, ctx); + } // Module collection keys and reads use canonical paths, but source text // relative specifiers are written against the importer path the user // compiled. On platforms where /tmp is a symlink, resolving imports from diff --git a/crates/perry/src/commands/compile/collect_modules/tests.rs b/crates/perry/src/commands/compile/collect_modules/tests.rs index 7c5c71d160..00b12c5582 100644 --- a/crates/perry/src/commands/compile/collect_modules/tests.rs +++ b/crates/perry/src/commands/compile/collect_modules/tests.rs @@ -590,6 +590,49 @@ console.log(tone); assert!(ctx.native_modules.contains_key(&canonical_asset)); } +#[test] +fn missing_generated_module_fails_with_preparation_remediation() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let entry = root.join("entry.ts"); + std::fs::write( + &entry, + r#" +export async function load() { + return import("opencode-web-ui.gen.ts"); +} +"#, + ) + .expect("write entry"); + + let mut ctx = CompilationContext::new(root.to_path_buf()); + ctx.entry_canonical = Some(entry.canonicalize().unwrap()); + let mut visited = HashSet::new(); + let mut next_class_id: perry_hir::ClassId = 1; + let progress = VerboseProgress::new(OutputFormat::Json, 0); + let error = collect_modules( + &entry, + &mut ctx, + &mut visited, + OutputFormat::Json, + None, + &mut next_class_id, + false, + &progress, + None, + ) + .unwrap_err() + .to_string(); + + assert!( + error.contains("Could not resolve generated module"), + "{error}" + ); + assert!(error.contains("upstream preparation command"), "{error}"); + assert!(error.contains("--asset-module"), "{error}"); + assert!(error.contains("perry.codegen"), "{error}"); +} + #[test] fn wildcard_preflight_skips_node_native_addon_package() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index fcdc50590a..6712f2437c 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -1822,6 +1822,9 @@ pub(super) fn cached_resolve_import( importer_path: &Path, ctx: &mut CompilationContext, ) -> Option<(PathBuf, ModuleKind)> { + if let Some(generated) = ctx.generated_asset_modules.get(import_source) { + return Some((generated.source_path.clone(), ModuleKind::NativeCompiled)); + } let importer_dir = importer_path .parent() .unwrap_or(importer_path) diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 3d48f0c155..7dc9ea7089 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -542,6 +542,11 @@ pub fn run_with_parse_cache( let skip_codegen = args.no_codegen || codegen_steps::skip_from_env(); codegen_steps::run_codegen_steps(&ctx, skip_codegen, format)?; + // Reproduce bundler-injected file-map modules (for example OpenCode's + // `opencode-web-ui.gen.ts`) after upstream preparation has populated the + // asset directory and before the import graph is resolved. + asset_modules::generate(&args.asset_module, &mut ctx, format)?; + // #1681 (Phase 3 of #1677): self-hosted build-time `precompile(...)`. // If this is the capture subprocess, enter capture mode; otherwise, when // the entry uses `precompile(`, compile+run it via Perry itself (no node, @@ -5711,6 +5716,21 @@ pub fn run_with_parse_cache( } } + // Resolve every explicit and graph-discovered asset before the no-link + // return so diagnostics and the provenance manifest are available for + // compile-only workflows too. + let mut embedded_assets = embed::resolve_embedded_assets(&args.embed, &ctx.cache_root)?; + for (name, path) in &ctx.embedded_assets { + if !embedded_assets + .iter() + .any(|(existing_name, _)| existing_name == name) + { + embedded_assets.push((name.clone(), path.clone())); + } + } + embedded_assets.sort_by(|a, b| a.0.cmp(&b.0)); + asset_manifest::write(&ctx, &embedded_assets, format)?; + if args.no_link { let codegen_cache_stats = if object_cache.is_enabled() { Some(( @@ -5741,21 +5761,6 @@ pub fn run_with_parse_cache( // config are package/project-root-relative, so use the same walked-up root // as package.json, perry.toml, and the on-disk caches. Otherwise an entry // at `src/main.ts` makes `--embed ./dist/**` silently search `src/dist`. - let mut embedded_assets = embed::resolve_embedded_assets(&args.embed, &ctx.cache_root)?; - // `{ type: "file" }` imports are discovered while walking the module graph - // and already carry their virtual registry names. Merge those automatic - // assets with explicit `--embed`/config matches before generating the one - // registration object. A path can be named explicitly and imported; keep - // both names because user code may address either virtual path. - for (name, path) in &ctx.embedded_assets { - if !embedded_assets - .iter() - .any(|(existing_name, _)| existing_name == name) - { - embedded_assets.push((name.clone(), path.clone())); - } - } - embedded_assets.sort_by(|a, b| a.0.cmp(&b.0)); if !embedded_assets.is_empty() { if let Some(obj) = embed::generate_embedded_asset_object(&embedded_assets, &object_output_dir)? diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 2468c4d45d..e8c6de047e 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -163,6 +163,13 @@ pub struct CompileArgs { #[arg(long)] pub embed: Vec, + /// Generate a deterministic TypeScript module that maps asset-relative + /// names to Bun-compatible `{ type: "file" }` imports. The value is + /// `=`; paths are relative to the + /// nearest package.json/perry.toml project root. Repeatable. + #[arg(long, value_name = "SPECIFIER=DIR")] + pub asset_module: Vec, + /// Enable type checking via tsgo (Microsoft's native TypeScript checker). /// Resolves cross-file types, interfaces, and generics for better optimization. /// Requires: npm install -g @typescript/native-preview @@ -554,6 +561,17 @@ pub struct CodegenStep { pub command: String, } +/// One virtual generated module produced by `--asset-module` before graph +/// collection. The source lives in Perry's cache; its imported assets retain +/// their original source paths and stable packaged handles. +#[derive(Debug, Clone)] +pub struct GeneratedAssetModule { + pub source_path: PathBuf, + pub logical_path: PathBuf, + pub asset_root: PathBuf, + pub assets: Vec<(String, PathBuf)>, +} + /// Compilation context tracking all modules pub struct CompilationContext { /// Native TypeScript modules to compile @@ -639,6 +657,12 @@ pub struct CompilationContext { /// automatic wasm imports also register bytes there but must still lower /// their executable adapter on a later metadata pass. pub file_loader_asset_paths: HashSet, + /// Stable `$perryfs` names chosen for generated asset-module members. + /// These are keyed by canonical source path so the ordinary file-loader + /// collection path can reuse them without knowing how the import arose. + pub file_loader_asset_names: HashMap, + /// Bare module specifier -> generated source and provenance. + pub generated_asset_modules: BTreeMap, /// #1681 (Phase 3 of #1677): true when this is the build-time capture /// stage (the `current_exe` subprocess), so `precompile(EXPR)` sites /// emit their build-time value instead of substituting. Re-installed on @@ -1126,6 +1150,8 @@ impl CompilationContext { aot_discovered_modules: HashSet::new(), embedded_assets: Vec::new(), file_loader_asset_paths: HashSet::new(), + file_loader_asset_names: HashMap::new(), + generated_asset_modules: BTreeMap::new(), precompile_capture: false, precompile_results: HashMap::new(), fast_math: false, diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index e2da75f66e..8c76453ba5 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -295,6 +295,7 @@ fn build_once( output_type: "executable".to_string(), bundle_extensions: None, embed: Vec::new(), + asset_module: Vec::new(), type_check: false, minify: false, features: None, diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index dcfe5d7a53..8043074d55 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -207,6 +207,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> output_type: "executable".to_string(), bundle_extensions: None, embed: Vec::new(), + asset_module: Vec::new(), type_check: args.type_check, minify: target.as_deref() == Some("web"), features: None, diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 6aa353a073..0ee22336f2 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -62,6 +62,7 @@ executable so it runs with no external files on disk (#5731). | Flag | Description | |------|-------------| | `--embed ` | Embed a file, directory, or `*`/`**` glob (relative to the project root). Repeatable. Merged with `perry.embed` (package.json) and `[compile] embed` (perry.toml). | +| `--asset-module ` | Generate a virtual module whose default export maps every file below `dir` to a stable `$perryfs` handle. Repeatable; source maps are excluded. | ```bash vite build @@ -95,6 +96,32 @@ and bind the default import to its `$perryfs` path: import sound from "./sound.mp3" with { type: "file" }; ``` +Some build pipelines inject a generated module rather than writing it into the +source checkout. Reproduce that file-map step with `--asset-module`. Perry +sorts the directory walk, preserves each `{ type: "file" }` edge, and keeps the +generated source in its cache rather than modifying the checkout. + +For the pinned OpenCode source graph, build the upstream web UI first, then +compile from `packages/opencode`'s package root: + +```bash +bun run --cwd packages/app build +perry compile packages/opencode/src/index.ts \ + --asset-module 'opencode-web-ui.gen.ts=../app/dist' \ + -o opencode +``` + +The first command is the upstream preparation step; Perry reproduces only the +deterministic `opencode-web-ui.gen.ts` file map. A missing/empty `dist` or a +missing generated import fails with a remediation message instead of becoming +an unresolved-import warning. Run the preparation command for every source +revision so the generated inputs cannot be stale. + +Every compile also writes `/assets.json`, a deterministic report of +text, JSON, file-loader, WASM, and explicitly embedded assets. It records the +packaged handle, project-relative source origin, byte size, SHA-256 digest, and +the generating asset module where applicable. + > **Note** > `node:fs` consults the embedded registry *before* disk, and a bare > embed-relative key matches too — so `readFileSync("dist/index.html")` returns