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
216 changes: 179 additions & 37 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
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
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
29 changes: 24 additions & 5 deletions crates/oj_server/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1118,8 +1118,9 @@ fn watch_ignored_globs_match_relative_and_absolute_paths() {
}

#[test]
fn watch_feed_drops_ignored_paths_and_dropped_subscribers() {
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]| {
Expand All @@ -1132,17 +1133,35 @@ fn watch_feed_drops_ignored_paths_and_dropped_subscribers() {
drop(dropped);

feed.publish(
&ev(&["/app/shared/a.ts", "/app/src/generated/b.ts"]),
&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,
);
feed.publish(&ev(&["/app/src/generated/c.ts"]), &ignored, root);

let got = kept.try_recv().expect("the unignored path is delivered");
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(),
"an all-ignored event is not delivered"
"all-ignored, cache-dir and config-temp events are not delivered"
);
assert_eq!(
feed.0.lock().unwrap().len(),
Expand Down
Loading
Loading