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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ jobs:
run: node e2e/scan-starvation.mjs
- name: thousands of plugin fs.watch handles register and deliver under a write storm
run: node e2e/fs-watch-many-watchers.mjs
- name: top-level files and dirs created after boot are watched (new shared/ tree shape)
run: node e2e/watch-new-root-dir.mjs
- name: mid-boot SIGTERM kills plugin children; next boot reaps a SIGKILLed server's orphans
run: node e2e/orphan-reap.mjs
- name: worker build forms (?worker, inline, url, new URL) and worker-only chunks skip document helpers
Expand Down Expand Up @@ -256,6 +258,8 @@ jobs:
run: node e2e/ssr-concurrent.mjs
- name: start dev holds the page reload behind the editor HMR gate until the flush
run: node e2e/start-hmr-gate.mjs
- name: start dev skips rebundle and reload for changes outside every served graph
run: node e2e/start-rebundle-gate.mjs

# i need to fix this or automate
build:
Expand Down
292 changes: 230 additions & 62 deletions crates/oj/src/start_dev.rs

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions crates/oj_server/src/assets/plugin-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2229,6 +2229,7 @@ async function invalidateEnvironments(environments, watcher, changes) {
unmatchedChangeLogged.add(file);
process.stderr.write(`${OJ} plugin host: change to ${file} matched no module in any runner environment\n`);
}
return matched.size;
}

// Vite's prepareError: the payload hot.send({type:"error"}) carries.
Expand Down Expand Up @@ -3219,13 +3220,18 @@ async function ensureConfigureServerMiddleware() {
// Answer only once the invalidation is done, so the Rust side's POST
// completing means a next request cannot be served from stale modules.
// Serialized: the early (pre-settle) and settled sends for one edit
// batch must not interleave their module-graph walks.
invalidateQueue = invalidateQueue
// batch must not interleave their module-graph walks. The reply says
// how many changes matched a runner-backed graph (null: the walk
// threw, the caller must assume a hit): the Rust side reloads the
// browser only when something actually served changed.
const run = invalidateQueue
.then(() => invalidateEnvironments(server.environments, fileWatcher, changes))
.catch(() => {});
await invalidateQueue;
res.statusCode = 204;
res.end();
.catch(() => null);
invalidateQueue = run;
const matchedCount = await run;
res.statusCode = 200;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ matched: matchedCount === null ? null : matchedCount || 0 }));
});
return;
}
Expand Down
19 changes: 18 additions & 1 deletion crates/oj_server/src/assets/start/bundle-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ async function main() {
visitedIds.add(id);
return null;
},
// The complete input set, from the bundler itself: getModuleIds() covers
// every module the build touched, including the kinds the transform hook
// and the output walk miss (css imports among them). closure.json must
// not under-report: the dev server skips rebundles for paths outside it.
buildEnd() {
try {
for (const id of this.getModuleIds()) visitedIds.add(id);
} catch {}
},
};

const serverFnClient = {
Expand Down Expand Up @@ -210,7 +219,15 @@ async function main() {
for (const id of Object.keys(o.modules ?? {})) closure.add(id);
}
const closureFiles = [...closure]
.map((id) => String(id).split("?")[0])
// An asset module's id is virtual (\0oj-css:/abs/x.css and friends), but
// its FILE is an input: raw/inline embed its bytes, css lands in
// css-urls.json. The closure must carry the file, or an edit to it never
// rebundles.
.map((id) => {
const s = String(id);
return s.startsWith("\0oj-") ? s.slice(s.indexOf(":") + 1) : s;
})
.map((id) => id.split("?")[0])
.filter((p) => !p.startsWith("\0"))
.map((p) => (isAbsolute(p) ? p : resolve(APP, p)))
.filter((p) => !p.startsWith(HERE) && !/(^|\/)routeTree\.gen\.[jt]sx?$/.test(p))
Expand Down
12 changes: 12 additions & 0 deletions crates/oj_server/src/assets/vite-extract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,13 @@ function extractResolve(r) {
if (typeof r.preserveSymlinks === "boolean") out.preserveSymlinks = r.preserveSymlinks;
return Object.keys(out).length ? out : null;
}
// `server.watch.ignored` is anymatch-shaped: a string, RegExp, function, or an
// array mixing them. Keep the string globs, in an array.
function extractWatchIgnored(ignored) {
const list = Array.isArray(ignored) ? ignored : ignored != null ? [ignored] : [];
const globs = list.filter((x) => typeof x === "string");
return globs.length ? globs : null;
}
function extractServerFlags(s, legacy, appType) {
const out = {};
if (appType === "spa" || appType === "mpa" || appType === "custom") out.appType = appType;
Expand Down Expand Up @@ -1533,6 +1540,11 @@ export async function extract(input) {
hmr: c.server?.hmr === false ? false : null,
fsAllow: Array.isArray(c.server?.fs?.allow) ? c.server.fs.allow.filter((x) => typeof x === "string") : null,
fsStrict: typeof c.server?.fs?.strict === "boolean" ? c.server.fs.strict : null,
// The RESOLVED `server.watch.ignored`: plugin config hooks add entries
// (vite-plugin-cloudflare ignores `**/.wrangler/**`) that exist nowhere
// in the user's own config. String globs only; RegExp and function
// matchers cannot cross the bridge (same rule as oj's WatchConfig).
watchIgnored: extractWatchIgnored(c.server?.watch?.ignored),
define: c.define && typeof c.define === "object" ? c.define : null,
alias: extractAlias(c.resolve?.alias),
headers: stringMap(c.server?.headers),
Expand Down
5 changes: 5 additions & 0 deletions crates/oj_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ struct ServerState {
/// Into the watcher thread's inbox (`WatchMsg`): directories of files
/// served from outside the root (`ensure_watched_file`).
watch_tx: std::sync::mpsc::Sender<WatchMsg>,
watch_feed: WatchFeed,
plugins_use_module_parsed: bool,
plugins_have_transform: bool,
plugins_have_load: bool,
Expand Down Expand Up @@ -632,6 +633,7 @@ pub struct BuiltApp {
pub hmr_gate: Option<HmrGateHandle>,
/// The SSR resolve/load pipeline, for the in-process SSR module runner.
pub ssr: SsrBridge,
pub watch_feed: WatchFeed,
}

pub async fn bind_dev_listener(
Expand Down Expand Up @@ -1574,6 +1576,7 @@ impl DevServer {
ssr_plugin_config: payloads.ssr,
plugin_watched: Arc::new(Mutex::new(std::collections::HashSet::new())),
watch_tx,
watch_feed: WatchFeed::default(),
plugins_use_module_parsed: caps.use_module_parsed,
plugins_have_transform: caps.have_transform,
plugins_have_load: caps.have_load,
Expand Down Expand Up @@ -1656,6 +1659,7 @@ impl DevServer {
let ssr = SsrBridge {
state: Arc::clone(&state),
};
let watch_feed = state.watch_feed.clone();
let router = build_router(state, &server_cfg, &hmr.ws_path);

Ok(BuiltApp {
Expand All @@ -1672,6 +1676,7 @@ impl DevServer {
open,
hmr_gate,
ssr,
watch_feed,
})
}
}
Expand Down
20 changes: 17 additions & 3 deletions crates/oj_server/src/plugin_mw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,34 @@ pub(crate) fn stream_reqwest_response(resp: reqwest::Response) -> Response {
}

// Tell the plugin middleware server that files changed so it can invalidate
// module graphs and send HMR; type is "update" | "create" | "delete". Fire-and-forget.
pub async fn notify_plugin_mw_invalidate(port: u16, changes: &[(String, &'static str)]) {
// module graphs and send HMR; type is "update" | "create" | "delete".
// Returns whether a runner-backed graph matched one of the changes; a host
// that cannot tell (an error reply, an unreachable host, a null count) reads
// as true, so the caller's reload decision only ever errs toward reloading.
pub async fn notify_plugin_mw_invalidate(port: u16, changes: &[(String, &'static str)]) -> bool {
let client = plugin_mw_client();
let changes: Vec<serde_json::Value> = changes
.iter()
.map(|(path, kind)| serde_json::json!({ "path": path, "type": kind }))
.collect();
let body = serde_json::json!({ "changes": changes }).to_string();
let _ = client
let resp = client
.post(format!("http://127.0.0.1:{port}/__oj_invalidate"))
.header(header::CONTENT_TYPE, "application/json")
.body(body)
.send()
.await;
let Ok(resp) = resp else { return true };
let Ok(body) = resp.bytes().await else {
return true;
};
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) else {
return true;
};
match parsed.get("matched") {
Some(serde_json::Value::Number(n)) => n.as_u64().map(|n| n > 0).unwrap_or(true),
_ => true,
}
}

pub(crate) fn plugin_mw_client() -> &'static reqwest::Client {
Expand Down
22 changes: 22 additions & 0 deletions crates/oj_server/src/plugins/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ pub struct ViteValues {
pub hmr_disabled: bool,
pub fs_allow: Option<Vec<String>>,
pub fs_strict: Option<bool>,
/// `server.watch.ignored` string globs from the RESOLVED config: plugin
/// `config` hooks add their own (vite-plugin-cloudflare ignores
/// `**/.wrangler/**`, whose miniflare state writes would otherwise feed
/// every request back into the watcher), and only the resolved list
/// carries them.
pub watch_ignored: Option<Vec<String>>,
pub define: Option<serde_json::Map<String, serde_json::Value>>,
pub alias: Option<serde_json::Map<String, serde_json::Value>>,
pub headers: Option<serde_json::Map<String, serde_json::Value>>,
Expand Down Expand Up @@ -552,6 +558,7 @@ pub(crate) fn parse_vite_values(json: &serde_json::Value) -> ViteValues {
hmr_disabled: json.get("hmr").and_then(|v| v.as_bool()) == Some(false),
fs_allow: json.get("fsAllow").and_then(string_list),
fs_strict: json.get("fsStrict").and_then(|v| v.as_bool()),
watch_ignored: json.get("watchIgnored").and_then(string_list),
define: json.get("define").and_then(|v| v.as_object()).cloned(),
alias: json.get("alias").and_then(|v| v.as_object()).cloned(),
headers: json.get("headers").and_then(|v| v.as_object()).cloned(),
Expand Down Expand Up @@ -660,6 +667,21 @@ pub(crate) fn merge_vite_values(config: &mut oj_config::OjConfig, v: ViteValues)
fs_strict: v.fs_strict,
},
);
// Unioned rather than filled-if-unset: Vite's mergeConfig concatenates
// `server.watch.ignored` arrays, and a user list in oj's own config must
// not displace the plugin-added ignores (`**/.wrangler/**` et al).
if let Some(vignored) = v.watch_ignored {
if !vignored.is_empty() {
let sc = config.server.get_or_insert_with(Default::default);
let watch = sc.watch.get_or_insert_with(Default::default);
let list = watch.ignored.get_or_insert_with(Default::default);
for glob in vignored {
if !list.contains(&glob) {
list.push(glob);
}
}
}
}
if let Some(valias) = v.alias {
if !valias.is_empty() {
let rc = config.resolve.get_or_insert_with(Default::default);
Expand Down
52 changes: 52 additions & 0 deletions crates/oj_server/src/plugins/vite_values_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,7 @@ fn merge_adopts_only_unset_fields() {
hmr_disabled: false,
fs_allow: None,
fs_strict: None,
watch_ignored: None,
define: None,
alias: None,
headers: None,
Expand Down Expand Up @@ -1225,6 +1226,7 @@ fn merge_never_overrides_config() {
hmr_disabled: false,
fs_allow: None,
fs_strict: None,
watch_ignored: None,
define: None,
alias: None,
headers: None,
Expand Down Expand Up @@ -1632,3 +1634,53 @@ fn extraction_stderr_lines_print_once_per_process() {
);
assert_eq!(unseen_extraction_lines(""), "");
}

#[test]
fn parse_reads_watch_ignored() {
let v =
parse_vite_values(&serde_json::json!({ "watchIgnored": ["**/.wrangler/**", ".dev.vars"] }));
assert_eq!(
v.watch_ignored,
Some(vec!["**/.wrangler/**".to_string(), ".dev.vars".to_string()])
);
let none = parse_vite_values(&serde_json::json!({ "watchIgnored": null }));
assert!(none.watch_ignored.is_none());
}

#[test]
fn merge_unions_watch_ignored() {
// Unlike the fill-if-unset fields, ignores union (Vite's mergeConfig
// concatenates them): a user list must not displace the plugin-added
// `**/.wrangler/**`, whose loss feeds miniflare state writes back into
// the watcher.
let mut config = oj_config::OjConfig::default();
let sc = config.server.get_or_insert_with(Default::default);
sc.watch = Some(oj_config::WatchConfig {
ignored: Some(vec!["**/generated/**".to_string(), ".dev.vars".to_string()]),
});
let v = ViteValues {
watch_ignored: Some(vec!["**/.wrangler/**".to_string(), ".dev.vars".to_string()]),
..Default::default()
};
merge_vite_values(&mut config, v);
assert_eq!(
config.server.unwrap().watch.unwrap().ignored,
Some(vec![
"**/generated/**".to_string(),
".dev.vars".to_string(),
"**/.wrangler/**".to_string(),
]),
"vite entries append without duplicating"
);

let mut unset = oj_config::OjConfig::default();
let v = ViteValues {
watch_ignored: Some(vec!["**/.wrangler/**".to_string()]),
..Default::default()
};
merge_vite_values(&mut unset, v);
assert_eq!(
unset.server.unwrap().watch.unwrap().ignored,
Some(vec!["**/.wrangler/**".to_string()])
);
}
8 changes: 8 additions & 0 deletions crates/oj_server/src/ssr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ impl SsrBridge {
self.state.engine_registry.clone()
}

/// Whether the generic pipeline ever served this file as a module (its
/// module graph; a Start worker script fetched by URL lands here while
/// staying outside both the client bundle and the Start engine's graph).
pub fn graph_knows_file(&self, file: &Path) -> bool {
let url = crate::rewrite::url_of(&self.state.root, file);
self.state.graph.lock().unwrap().contains(Path::new(&url))
}

pub async fn resolve(&self, importer: &str, spec: &str) -> Result<SsrResolution, String> {
ssr_resolve_inner(&self.state, importer, spec).await
}
Expand Down
53 changes: 53 additions & 0 deletions crates/oj_server/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,59 @@ fn watch_ignored_globs_match_relative_and_absolute_paths() {
));
}

#[test]
fn watch_feed_drops_excluded_paths_and_dropped_subscribers() {
let root = Path::new("/app");
let cache = Path::new("/app/relocated-cache");
let ignored = watch_ignored_patterns(root, &["**/generated/**".to_string()]);
let feed = WatchFeed::default();
let ev = |paths: &[&str]| {
let mut ev = notify::Event::new(notify::EventKind::Any);
ev.paths = paths.iter().map(PathBuf::from).collect();
ev
};
let kept = feed.subscribe();
let dropped = feed.subscribe();
drop(dropped);

feed.publish(
&ev(&[
"/app/shared/a.ts",
"/app/src/generated/b.ts",
"/app/relocated-cache/v1/start/chunk.js",
]),
&ignored,
root,
cache,
);
feed.publish(&ev(&["/app/src/generated/c.ts"]), &ignored, root, cache);
feed.publish(
&ev(&["/app/relocated-cache/v1/mod.json"]),
&ignored,
root,
cache,
);

feed.publish(
&ev(&["/app/vite.config.ts.timestamp-1791586920568-4c4081376eef.mjs"]),
&ignored,
root,
cache,
);

let got = kept.try_recv().expect("the unexcluded path is delivered");
assert_eq!(got.paths, vec![PathBuf::from("/app/shared/a.ts")]);
assert!(
kept.try_recv().is_err(),
"all-ignored, cache-dir and config-temp events are not delivered"
);
assert_eq!(
feed.0.lock().unwrap().len(),
1,
"the dropped receiver is pruned"
);
}

#[test]
fn html_fallback_rewrites_like_vite() {
assert_eq!(
Expand Down
Loading
Loading