diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58e369fb..46e8e3d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,9 @@ jobs: run: node e2e/plugin-close-hooks.mjs - name: relative imports claimed by a resolveId filter go to the plugins first run: node e2e/plugin-resolve-relative.mjs + - name: Vite config adoption and lazy warmup + if: matrix.mode == 'unbundled' + run: node e2e/vite-config-adoption.mjs - name: server.fs allow list, strict:false, workspace-root default run: node e2e/server-fs-allow.mjs - name: optimized deps are versioned and served immutable with an ETag diff --git a/crates/oj_config/src/lib.rs b/crates/oj_config/src/lib.rs index 1581ac97..095e46db 100644 --- a/crates/oj_config/src/lib.rs +++ b/crates/oj_config/src/lib.rs @@ -863,9 +863,38 @@ pub fn optimize_deps_force(config: &OjConfig) -> bool { /// (Vite <=7): opaque bundler options forwarded to oj's dep bundling. pub fn optimize_deps_bundler_options(config: &OjConfig) -> Option { let od = config.optimize_deps.as_ref()?; - od.rolldown_options - .clone() - .or_else(|| od.esbuild_options.clone()) + let Some(options) = od.rolldown_options.as_ref() else { + return od.esbuild_options.clone(); + }; + // The optimizer uses esbuild. Translate the nested Rolldown transform and + // resolver options instead of dropping them at the sidecar boundary. + let mut out = options.as_object()?.clone(); + if let Some(transform) = options.get("transform").and_then(|v| v.as_object()) { + for key in ["define", "target", "keepNames", "drop"] { + if let Some(value) = transform.get(key) { + out.insert(key.to_string(), value.clone()); + } + } + // rolldown's transform.jsx is often an object (the oxc shape); esbuild's + // `jsx` is a string enum and rejects anything else, failing the whole + // optimizer run. Only the string form can cross. + if let Some(jsx) = transform.get("jsx").filter(|v| v.is_string()) { + out.insert("jsx".to_string(), jsx.clone()); + } + } + if let Some(resolve) = options.get("resolve").and_then(|v| v.as_object()) { + for (from, to) in [ + ("conditionNames", "conditions"), + ("mainFields", "mainFields"), + ("extensions", "resolveExtensions"), + ("alias", "alias"), + ] { + if let Some(value) = resolve.get(from) { + out.insert(to.to_string(), value.clone()); + } + } + } + Some(serde_json::Value::Object(out)) } /// `server.warmup.clientFiles` / `server.warmup.ssrFiles`: modules to compile @@ -1467,6 +1496,27 @@ mod tests { ); } + #[test] + fn rolldown_transform_jsx_crosses_only_as_a_string() { + // esbuild's `jsx` is a string enum; the oxc object shape would fail + // the whole optimizer run, so it must be dropped at translation. + let obj: OjConfig = serde_json::from_str( + r#"{"optimizeDeps":{"rolldownOptions":{"transform":{"jsx":{"runtime":"automatic"},"target":"es2020"}}}}"#, + ) + .unwrap(); + let out = optimize_deps_bundler_options(&obj).unwrap(); + assert!(out.get("jsx").is_none(), "object jsx must not cross: {out}"); + assert_eq!(out.get("target").unwrap(), "es2020"); + let string: OjConfig = serde_json::from_str( + r#"{"optimizeDeps":{"rolldownOptions":{"transform":{"jsx":"preserve"}}}}"#, + ) + .unwrap(); + assert_eq!( + optimize_deps_bundler_options(&string).unwrap().get("jsx").unwrap(), + "preserve" + ); + } + #[test] fn evaluates_ts_config_with_types_and_define_config() { let cfg = eval_config_in( diff --git a/crates/oj_config/src/schema.rs b/crates/oj_config/src/schema.rs index 4a3af46d..51818454 100644 --- a/crates/oj_config/src/schema.rs +++ b/crates/oj_config/src/schema.rs @@ -132,6 +132,7 @@ pub struct PreprocessorEntry { #[derive(Debug, Default, Clone, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct OptimizeDepsConfig { + pub no_discovery: Option, pub include: Option>, pub exclude: Option>, pub entries: Option>, diff --git a/crates/oj_server/src/assets/vite-extract.mjs b/crates/oj_server/src/assets/vite-extract.mjs index e855be8a..83e0857f 100644 --- a/crates/oj_server/src/assets/vite-extract.mjs +++ b/crates/oj_server/src/assets/vite-extract.mjs @@ -916,7 +916,7 @@ function extractProxy(proxy) { return Object.keys(out).length ? out : null; } -function extractOptimizeDeps(od) { +function extractOptimizeDeps(od, rawOd) { if (!od || typeof od !== "object") return null; const strArr = (v) => typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "string") : undefined; @@ -930,6 +930,16 @@ function extractOptimizeDeps(od) { if (ent) out.entries = ent; if (interop) out.needsInterop = interop; if (typeof od.force === "boolean") out.force = od.force; + // The RESOLVED config always carries noDiscovery (false by default), and + // adopting the default would force-enable full dep auto-discovery for every + // app; only a value the user wrote (visible in the raw config) crosses. A + // config()-hook-set value is missed by this gate, the safe direction. + if (typeof od.noDiscovery === "boolean" && typeof rawOd?.noDiscovery === "boolean") { + out.noDiscovery = od.noDiscovery; + } + for (const key of ["esbuildOptions", "rolldownOptions"]) { + if (od[key] && typeof od[key] === "object") out[key] = markFunctions(od[key]); + } return Object.keys(out).length ? out : null; } @@ -1262,7 +1272,22 @@ function extractServerFlags(s, legacy, appType) { if (typeof s.hmr.overlay === "boolean") h.overlay = s.hmr.overlay; if (Object.keys(h).length) out.hmr = h; } - if (s.fs && typeof s.fs === "object" && typeof s.fs.strict === "boolean") out.fsStrict = s.fs.strict; + if (s.fs && typeof s.fs === "object") { + if (typeof s.fs.strict === "boolean") out.fsStrict = s.fs.strict; + if (Array.isArray(s.fs.deny)) out.fsDeny = s.fs.deny.filter((x) => typeof x === "string"); + } + if (s.warmup && typeof s.warmup === "object") { + const warmup = {}; + for (const key of ["clientFiles", "ssrFiles"]) { + const files = Array.isArray(s.warmup[key]) + ? s.warmup[key].filter((x) => typeof x === "string") + : []; + if (files.length) warmup[key] = files; + } + // The resolved default is empty arrays on every config; only user lists + // cross (mirrors the hmr handling above). + if (Object.keys(warmup).length) out.warmup = warmup; + } // server.watch.ignored: string globs only (RegExp/functions cannot cross the bridge). if (s.watch && typeof s.watch === "object" && s.watch.ignored != null) { const raw = Array.isArray(s.watch.ignored) ? s.watch.ignored : [s.watch.ignored]; @@ -1364,10 +1389,16 @@ function warnUnsupported(c) { const rest = Object.keys(c.esbuild).filter((k) => !JSX_ESBUILD_KEYS.includes(k)); if (rest.length) warn(`esbuild options ${rest.join(", ")} are not applied (jsx* are)`); } - if (c.optimizeDeps?.esbuildOptions || c.optimizeDeps?.rollupOptions) { - warn("optimizeDeps.esbuildOptions/rollupOptions are not applied; include/exclude/entries are"); - } if (c.worker) warn("worker config is not applied"); + if (Array.isArray(c.optimizeDeps?.esbuildOptions?.plugins) && c.optimizeDeps.esbuildOptions.plugins.length) { + warn("optimizeDeps.esbuildOptions.plugins are not applied (scalar esbuild options are)"); + } + if (Array.isArray(c.optimizeDeps?.rolldownOptions?.plugins) && c.optimizeDeps.rolldownOptions.plugins.length) { + warn("optimizeDeps.rolldownOptions.plugins are not applied (transform/resolve options are)"); + } + if (c.optimizeDeps?.rollupOptions) { + warn("optimizeDeps.rollupOptions is not applied (use rolldownOptions or esbuildOptions)"); + } if (typeof c.build?.assetsInlineLimit === "function") { warn("build.assetsInlineLimit is a function and cannot be applied; the 4096 byte default is used"); } @@ -1499,7 +1530,7 @@ try { dedupe: Array.isArray(c.resolve?.dedupe) ? c.resolve.dedupe.filter((x) => typeof x === "string") : null, - optimizeDeps: extractOptimizeDeps(c.optimizeDeps), + optimizeDeps: extractOptimizeDeps(c.optimizeDeps, raw?.optimizeDeps), build: extractBuild(c.build), oxc: extractOxc(c.oxc), esbuild: extractEsbuild(c.esbuild), diff --git a/crates/oj_server/src/lib.rs b/crates/oj_server/src/lib.rs index a22e090b..9850a98c 100644 --- a/crates/oj_server/src/lib.rs +++ b/crates/oj_server/src/lib.rs @@ -1310,6 +1310,7 @@ impl DevServer { &root, env!("CARGO_PKG_VERSION"), optimize::OptimizeInput { + no_discovery: config.optimize_deps.as_ref().and_then(|o| o.no_discovery), include, exclude, entries, @@ -1349,6 +1350,28 @@ impl DevServer { }); } spawn_watcher(Arc::clone(&state)); + let (client_files, ssr_files) = oj_config::server_warmup_files(&config); + if !client_files.is_empty() || !ssr_files.is_empty() { + let state = Arc::clone(&state); + tokio::spawn(async move { + for file in warmup_paths(&state.root, &client_files) { + let url = url_of(&state.root, &file); + if let Err(error) = ensure_module(&state, &file, &url).await { + eprintln!("oj: warmup {url}: {error}"); + } + } + // SSR dev compiles are not cached anywhere (each /@ssr-module + // request re-transforms), so per-file warmup requests would be + // thrown away. The durable warm-up is the SSR plugin host + // itself: spawning it (and priming its hook plan) here moves + // the multi-second sidecar boot off the first real request. + if !ssr_files.is_empty() { + if let Some(host) = ssr_plugin_host(&state).await { + let _ = host.build_hook_plan().await; + } + } + }); + } if self.lazy { // Lazy mode (Vite's default): no eager graph crawl. Modules are // compiled on demand as the browser requests them, so the first @@ -1454,6 +1477,40 @@ impl DevServer { } } +fn warmup_paths(root: &Path, patterns: &[String]) -> Vec { + // Patterns are root-relative (Vite's warmup semantics). Exclusions match + // against the ROOT-RELATIVE path of each walked file, so a './' spelled in + // either side (or a root containing glob metacharacters) can never make a + // positive and a negative pattern disagree about the same file. + let normalize = |p: &str| p.trim_start_matches("./").to_string(); + let mut files = std::collections::BTreeSet::new(); + let mut excluded = Vec::new(); + for pattern in patterns { + let (negative, pattern) = pattern + .strip_prefix('!') + .map(|p| (true, p)) + .unwrap_or((false, pattern.as_str())); + let pattern = normalize(pattern); + if negative { + if let Ok(pattern) = glob::Pattern::new(&pattern) { + excluded.push(pattern); + } + } else { + let walk = format!("{}/{}", glob::Pattern::escape(&root.to_string_lossy()), pattern); + if let Ok(matches) = glob::glob(&walk) { + files.extend(matches.flatten().filter(|file| file.is_file())); + } + } + } + files + .into_iter() + .filter(|file| { + let rel = file.strip_prefix(root).unwrap_or(file); + !excluded.iter().any(|p| p.matches_path(rel)) + }) + .collect() +} + /// Vite's dev server close runs the plugin container's `buildEnd` then /// `closeBundle` (pluginContainer.close), so plugins that hold resources or /// write summaries on shutdown get to. oj has no graceful drain (HMR sockets @@ -5403,6 +5460,7 @@ fn compile_fs_deny(user: &[String]) -> Vec<(glob::Pattern, bool)> { .iter() .map(|s| s.to_string()) .chain(user.iter().cloned()) + .flat_map(|p| expand_braces(&p)) .filter_map(|p| { let base_only = !p.contains('/'); glob::Pattern::new(&p).ok().map(|pat| (pat, base_only)) @@ -5410,6 +5468,23 @@ fn compile_fs_deny(user: &[String]) -> Vec<(glob::Pattern, bool)> { .collect() } +/// Expands `{a,b}` groups the way picomatch/Vite treat them; the `glob` crate +/// has no brace support, so `*.{key,pem}` (Vite's own default deny shape) +/// would otherwise compile to a literal that matches nothing. +fn expand_braces(pattern: &str) -> Vec { + let Some(open) = pattern.find('{') else { + return vec![pattern.to_string()]; + }; + let Some(close) = pattern[open..].find('}').map(|i| open + i) else { + return vec![pattern.to_string()]; + }; + let (head, rest) = (&pattern[..open], &pattern[close + 1..]); + pattern[open + 1..close] + .split(',') + .flat_map(|alt| expand_braces(&format!("{head}{alt}{rest}"))) + .collect() +} + fn path_is_denied(file: &Path, root: &Path, deny: &[(glob::Pattern, bool)]) -> bool { if deny.is_empty() { return false; @@ -8791,6 +8866,40 @@ export default [{{ assert!(path_is_denied(&root.join("id_rsa.KEY"), root, &deny)); } + #[test] + fn fs_deny_expands_brace_groups() { + // Vite's own default deny list is brace-form (*.{crt,pem,key,...}); + // the glob crate has no brace support, so without expansion those + // patterns match nothing and denied files get served. + let root = Path::new("/proj"); + let deny = compile_fs_deny(&["*.{key,p12,pfx}".to_string(), "secrets/{a,b}/**".to_string()]); + assert!(path_is_denied(&root.join("server.key"), root, &deny)); + assert!(path_is_denied(&root.join("bundle.p12"), root, &deny)); + assert!(path_is_denied(&root.join("cert.pfx"), root, &deny)); + assert!(path_is_denied(&root.join("secrets/a/token"), root, &deny)); + assert!(path_is_denied(&root.join("secrets/b/token"), root, &deny)); + assert!(!path_is_denied(&root.join("secrets/c/token"), root, &deny)); + assert!(!path_is_denied(&root.join("server.kee"), root, &deny)); + } + + #[test] + fn warmup_paths_matches_exclusions_root_relative() { + // A './'-spelled exclusion and a bare positive pattern must agree + // about the same file, and a root containing glob metacharacters must + // not break the walk (the root is escaped, patterns are relative). + let dir = std::env::temp_dir().join(format!("oj-warmup-[x]-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("src/generated")).unwrap(); + std::fs::write(dir.join("src/a.js"), "a").unwrap(); + std::fs::write(dir.join("src/generated/b.js"), "b").unwrap(); + let picked = warmup_paths( + &dir, + &["src/**/*.js".to_string(), "!./src/generated/*.js".to_string()], + ); + assert_eq!(picked.len(), 1, "exclusion must apply: {picked:?}"); + assert!(picked[0].ends_with("src/a.js")); + } + #[test] fn bare_specifier_classification_routes_plugin_virtuals_to_the_fallback() { // Plugin virtuals (`virtual:pwa-register`, \0-prefixed ids) count as diff --git a/crates/oj_server/src/optimize.rs b/crates/oj_server/src/optimize.rs index 6457ab0c..f3106c4b 100644 --- a/crates/oj_server/src/optimize.rs +++ b/crates/oj_server/src/optimize.rs @@ -104,8 +104,18 @@ impl OptimizedDeps { /// Dependency-optimizer inputs derived from the resolved config /// (`optimizeDeps.include/exclude/entries`, `resolve.dedupe`, `resolve.alias`). +/// `optimizeDeps.noDiscovery` when the user set it, else the OJ_OPTIMIZE_SCAN +/// opt-in. One function so the optimizer run and its cache key can never +/// disagree about which mode a prebundle was built in. +fn effective_auto_discover(no_discovery: Option) -> bool { + no_discovery.map(|disabled| !disabled).unwrap_or_else(|| { + std::env::var("OJ_OPTIMIZE_SCAN").is_ok_and(|v| !v.is_empty() && v != "0") + }) +} + #[derive(Default, Clone)] pub struct OptimizeInput { + pub no_discovery: Option, pub include: Vec, pub exclude: Vec, pub entries: Vec, @@ -225,6 +235,10 @@ fn lockfile_hash(root: &Path, version: &str, input: &OptimizeInput) -> String { hasher.update(b"="); hasher.update(replacement.as_bytes()); } + // The cache key must cover the EFFECTIVE decision: OJ_OPTIMIZE_SCAN is a + // fallback input to it, and hashing the raw Option let an env toggle serve + // the other mode's stale prebundle. + hasher.update(format!("\0discovery:{}", effective_auto_discover(input.no_discovery)).as_bytes()); if let Some(opts) = &input.bundler_options { hasher.update(b"\0o"); hasher.update(opts.to_string().as_bytes()); @@ -330,8 +344,7 @@ async fn run_optimizer( // it) is opt-in via OJ_OPTIMIZE_SCAN=1: it can break apps with UMD/CommonJS // interop quirks, so by default oj pre-bundles only the explicit // optimizeDeps.include list and serves the rest through wrap_cjs. - let auto_discover = std::env::var("OJ_OPTIMIZE_SCAN") - .is_ok_and(|v| !v.is_empty() && v != "0"); + let auto_discover = effective_auto_discover(input.no_discovery); // The config travels as a JSON argument into the engine call (the old // subprocess packed it into one argv string, an OS argv-length hazard on // big include/alias lists) and the metadata comes back as the call's diff --git a/crates/oj_server/src/plugins.rs b/crates/oj_server/src/plugins.rs index a4e0f73a..afdbfd48 100644 --- a/crates/oj_server/src/plugins.rs +++ b/crates/oj_server/src/plugins.rs @@ -763,14 +763,10 @@ fn merge_vite_values(config: &mut oj_config::OjConfig, v: ViteValues) { if sc.host.is_none() { sc.host = v.host; } - if sc.fs.is_none() { - if v.fs_allow.is_some() || v.fs_strict.is_some() { - sc.fs = Some(oj_config::FsConfig { - allow: v.fs_allow, - strict: v.fs_strict, - deny: None, - }); - } + if v.fs_allow.is_some() || v.fs_strict.is_some() { + let fs = sc.fs.get_or_insert_with(Default::default); + fs.allow = fs.allow.take().or(v.fs_allow); + fs.strict = fs.strict.or(v.fs_strict); } if sc.headers.is_none() { if let Some(vheaders) = v.headers { @@ -825,10 +821,16 @@ fn merge_vite_values(config: &mut oj_config::OjConfig, v: ViteValues) { } } if let Some(od) = v.optimize_deps { - if config.optimize_deps.is_none() { - if let Ok(parsed) = serde_json::from_value::(od) { - config.optimize_deps = Some(parsed); - } + if let Ok(parsed) = serde_json::from_value::(od) { + let o = config.optimize_deps.get_or_insert_with(Default::default); + o.include = o.include.take().or(parsed.include); + o.exclude = o.exclude.take().or(parsed.exclude); + o.entries = o.entries.take().or(parsed.entries); + o.needs_interop = o.needs_interop.take().or(parsed.needs_interop); + o.force = o.force.or(parsed.force); + o.no_discovery = o.no_discovery.or(parsed.no_discovery); + o.esbuild_options = o.esbuild_options.take().or(parsed.esbuild_options); + o.rolldown_options = o.rolldown_options.take().or(parsed.rolldown_options); } } if let Some(vb) = v.build.as_ref().and_then(|b| b.as_object()) { @@ -1057,6 +1059,23 @@ fn merge_vite_values(config: &mut oj_config::OjConfig, v: ViteValues) { .get("watch") .and_then(|w| serde_json::from_value::(w.clone()).ok()); } + if let Some(w) = sf + .get("warmup") + .and_then(|w| serde_json::from_value::(w.clone()).ok()) + { + let warmup = sc.warmup.get_or_insert_with(Default::default); + warmup.client_files = warmup.client_files.take().or(w.client_files); + warmup.ssr_files = warmup.ssr_files.take().or(w.ssr_files); + } + if let Some(deny) = sf + .get("fsDeny") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + { + sc.fs + .get_or_insert_with(Default::default) + .deny + .get_or_insert(deny); + } if let Some(strict) = sf.get("fsStrict").and_then(|b| b.as_bool()) { let fs = sc.fs.get_or_insert_with(Default::default); if fs.strict.is_none() { @@ -4317,6 +4336,40 @@ export default [{ assert_eq!(config.public_dir, Some("my-public".into())); } + #[test] + fn merge_fills_partial_server_and_optimizer_configuration() { + let mut config: oj_config::OjConfig = serde_json::from_value(serde_json::json!({ + "server": { "fs": { "strict": false }, "warmup": { "clientFiles": ["own.ts"] } }, + "optimizeDeps": { "exclude": ["own-dep"] } + })) + .unwrap(); + merge_vite_values( + &mut config, + parse_vite_values(&serde_json::json!({ + "fsAllow": ["../shared"], "fsStrict": true, + "serverFlags": { "fsDeny": ["**/*.private"], "warmup": { + "clientFiles": ["other.ts"], "ssrFiles": ["server.ts"] } }, + "optimizeDeps": { "include": ["dep"], "exclude": ["other-dep"], "noDiscovery": true, + "rolldownOptions": { "transform": { "define": { "FLAG": "true" }, "target": "es2015" } } } + })), + ); + let fs = config.server.as_ref().unwrap().fs.as_ref().unwrap(); + assert_eq!(fs.strict, Some(false)); + assert_eq!(fs.allow.as_ref().unwrap(), &["../shared"]); + assert_eq!(fs.deny.as_ref().unwrap(), &["**/*.private"]); + assert_eq!( + oj_config::server_warmup_files(&config), + (vec!["own.ts".into()], vec!["server.ts".into()]) + ); + let od = config.optimize_deps.as_ref().unwrap(); + assert_eq!(od.exclude.as_ref().unwrap(), &["own-dep"]); + assert_eq!(od.include.as_ref().unwrap(), &["dep"]); + assert_eq!(od.no_discovery, Some(true)); + let opts = oj_config::optimize_deps_bundler_options(&config).unwrap(); + assert_eq!(opts["define"]["FLAG"], "true"); + assert_eq!(opts["target"], "es2015"); + } + #[test] fn merge_adopts_server_fs_strict() { // `server.fs.strict: false` in a vite config reaches oj's FsConfig (Vite diff --git a/e2e/unit/harness.mjs b/e2e/unit/harness.mjs index 960e528c..7f6a63d9 100644 --- a/e2e/unit/harness.mjs +++ b/e2e/unit/harness.mjs @@ -78,7 +78,9 @@ export function tmpProject({ prefix = "oj-fx-", pkgJson = { name: "fx" }, linkEs fs.writeFileSync(p, content); }, cleanup() { - fs.rmSync(root, { recursive: true, force: true }); + // A just-killed child can still flush writes into the tree while rm + // walks it (ENOTEMPTY); maxRetries makes node retry those races. + fs.rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }, }; } diff --git a/e2e/unit/vite-extract-main-run.test.mjs b/e2e/unit/vite-extract-main-run.test.mjs index eb97c147..70a76200 100644 --- a/e2e/unit/vite-extract-main-run.test.mjs +++ b/e2e/unit/vite-extract-main-run.test.mjs @@ -15,7 +15,7 @@ import fs from "node:fs"; import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { asset, repo } from "./harness.mjs"; +import { asset, repo, tmpProject } from "./harness.mjs"; // extract() wraps process.stdout/stderr.write and console (its output // capture), so the wrapper saves raw write fds first. writeSync keeps the @@ -391,6 +391,23 @@ test("real vite: hooks see isSsrBuild false while the file's build.ssr is preser } }); +// resolveConfig fills defaults the user never wrote (optimizeDeps.noDiscovery: +// false, empty server.warmup arrays): adopting them as explicit settings would +// force-enable dep auto-discovery for every app and ship empty warmup blobs. +// Only values visible in the RAW user config may cross. +test("real vite: resolved-config defaults are not adopted as user settings", { skip: skipNoVite }, () => { + const base = realViteDir("oj-vite-extract-defaults-"); + try { + writeFileSync(join(base, "vite.config.mjs"), "export default { base: '/app/' };\n"); + const out = JSON.parse(runExtractor(base, "vite.config.mjs")); + assert.equal(out.__ok, true); + assert.equal(out.optimizeDeps?.noDiscovery, undefined, "Vite's default noDiscovery:false must not cross"); + assert.equal(out.serverFlags?.warmup, undefined, "Vite's default empty warmup must not cross"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + // resolveConfig can throw AFTER the plugin config hooks ran (a throwing // configResolved): the sentinel's partial verdict must survive the throw, and // the hooks must NOT re-run out of band on the same instances (double side @@ -761,3 +778,29 @@ test("real vite: a config-module NODE_ENV assignment survives into resolveConfig rmSync(base, { recursive: true, force: true }); } }); + + +test("Vite server and optimizer settings survive actual config loading", () => { + const base = mkdtempSync(join(tmpdir(), "oj-vite-extract-adopt-")); + try { + copyFileSync(asset("vite-extract.mjs"), join(base, "vite-extract.mjs")); + writeFileSync(join(base, "package.json"), JSON.stringify({ name: "fx", type: "module" })); + writeFileSync(join(base, "vite.config.mjs"), `export default { + server: { fs: { allow: ["../shared"], deny: ["**/*.private"] }, + warmup: { clientFiles: ["./src/*.tsx"], ssrFiles: ["./server.ts"] } }, + optimizeDeps: { noDiscovery: true, rolldownOptions: { + transform: { define: { __FEATURE__: '"enabled"' }, target: "es2015" }, + resolve: { conditionNames: ["custom"] } + } } + }`); + const config = JSON.parse(runExtractor(base, "vite.config.mjs")); + assert.equal(config.__ok, true); + assert.deepEqual(config.serverFlags.fsDeny, ["**/*.private"]); + assert.deepEqual(config.serverFlags.warmup, { clientFiles: ["./src/*.tsx"], ssrFiles: ["./server.ts"] }); + assert.equal(config.optimizeDeps.noDiscovery, true); + assert.equal(config.optimizeDeps.rolldownOptions.transform.target, "es2015"); + assert.deepEqual(config.optimizeDeps.rolldownOptions.resolve.conditionNames, ["custom"]); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/e2e/unit/vite-extract.test.mjs b/e2e/unit/vite-extract.test.mjs index 3e8d652c..a5d4aca7 100644 --- a/e2e/unit/vite-extract.test.mjs +++ b/e2e/unit/vite-extract.test.mjs @@ -14,7 +14,7 @@ test("optimizeDeps carries needsInterop and force alongside the lists", () => { force: true, esbuildOptions: { target: "es2020" }, }); - assert.deepEqual(out, { include: ["a", "pkg/*"], exclude: ["b"], needsInterop: ["cjs-ish"], force: true }); + assert.deepEqual(out, { include: ["a", "pkg/*"], exclude: ["b"], needsInterop: ["cjs-ish"], force: true, esbuildOptions: { target: "es2020" } }); assert.equal(extractOptimizeDeps({ force: "yes" }), null, "non-boolean force is ignored"); assert.equal(extractOptimizeDeps(undefined), null); }); diff --git a/e2e/vite-config-adoption.mjs b/e2e/vite-config-adoption.mjs new file mode 100644 index 00000000..d483fcf6 --- /dev/null +++ b/e2e/vite-config-adoption.mjs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { once } from 'node:events'; +import { repo, tmpProject } from './unit/harness.mjs'; + +const fx = tmpProject({ prefix: 'oj-config-adoption-', linkEsbuild: true }); +let child; +let log = ''; +const waitFor = async (predicate) => { + for (let i = 0; i < 200; i++) { + if (await predicate()) return; + if (child.exitCode !== null) throw new Error(log); + await new Promise(r => setTimeout(r, 50)); + } + throw new Error(`timed out\n${log}`); +}; +try { + fx.write('index.html', ''); + fx.write('src/warm.js', 'export const warmed = 1;'); + fx.write('src/skip.js', 'export const skipped = 1;'); + fx.write('secret.private', 'private-content'); + fx.write('vite.config.mjs', ` + import { appendFileSync } from 'node:fs'; + export default { + server: { fs: { deny: ['**/*.private'] }, warmup: { clientFiles: ['./src/*.js', '!./src/skip.js'] } }, + plugins: [{ name: 'warmup-probe', transform(code, id) { + if (id.endsWith('/warm.js') || id.endsWith('/skip.js')) appendFileSync(${JSON.stringify(join(fx.root, 'transformed'))}, id + '\\n'); + return null; + } }] + }; + `); + child = spawn(join(repo, 'target/debug/oj'), ['dev', fx.root, '--port', '15391', '--lazy'], { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout.on('data', d => { log += d; }); + child.stderr.on('data', d => { log += d; }); + await waitFor(async () => { try { return (await fetch('http://127.0.0.1:15391/')).ok; } catch { return false; } }); + assert.equal((await fetch('http://127.0.0.1:15391/secret.private')).status, 403); + await waitFor(() => existsSync(join(fx.root, 'transformed'))); + const transformed = readFileSync(join(fx.root, 'transformed'), 'utf8'); + assert.match(transformed, /warm\.js/); + assert.doesNotMatch(transformed, /skip\.js/); + console.log('Vite config: deny rules and lazy warmup honored'); +} finally { + if (child && child.exitCode === null) { const exited = once(child, 'exit'); child.kill('SIGTERM'); await exited; } + fx.cleanup(); +}