Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 53 additions & 3 deletions crates/oj_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> {
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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions crates/oj_config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ pub struct PreprocessorEntry {
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OptimizeDepsConfig {
pub no_discovery: Option<bool>,
pub include: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
pub entries: Option<Vec<String>>,
Expand Down
43 changes: 37 additions & 6 deletions crates/oj_server/src/assets/vite-extract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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),
Expand Down
109 changes: 109 additions & 0 deletions crates/oj_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1454,6 +1477,40 @@ impl DevServer {
}
}

fn warmup_paths(root: &Path, patterns: &[String]) -> Vec<PathBuf> {
// 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
Expand Down Expand Up @@ -5403,13 +5460,31 @@ 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))
})
.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<String> {
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;
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions crates/oj_server/src/optimize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> 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<bool>,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub entries: Vec<String>,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading