@ape-egg/vibe 2.1.19 → 2.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/compiler/bin/vibe-compile.js +12 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +182 -13
- package/compiler/src/compiler/watcher.rs +185 -0
- package/hot-module-refresh.js +384 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.21] - 2026-07-01
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **An orphaned `--watch` compiler held its lock forever, blocking every future watch on the same output** (`compiler/bin/vibe-compile.js`, `compiler/src/compiler/watcher.rs`, compiler 2.0.2) — the watch lock added below is only released on clean shutdown (Drop), but the compiler is a native child spawned by the `vibe-compile.js` wrapper, and killing the wrapper (Ctrl+C, a dev server's `child.kill()`, a process manager) left that child alive as an orphan still holding the lock — so the next `--watch` exited loudly naming a holder pid that was long gone. Two coordinated fixes close both escape routes: **(1)** the wrapper now forwards `SIGINT`/`SIGTERM`/`SIGHUP` and its own `exit` to the spawned child (`wireLifecycle`), so a graceful kill of the wrapper unwinds the watcher's Drop and frees the lock normally; **(2)** for the ungraceful case (`SIGKILL`, a crashed parent) where no signal is forwarded, the watcher polls its own parent pid on a background thread (`exit_when_orphaned`, unix-only) and, the moment it's reparented to init/a reaper — the unambiguous orphan signal — removes its lockfile and `process::exit`s, since `exit` skips Drop. Together they guarantee a dead owner never leaves a lock behind, complementing the stale-lock stealing the next watcher already does.
|
|
8
|
+
- **Concurrent compilers corrupted hyperspeed manifests → pages hydrated blank with no errors** (`compiler/src/compiler/compile.rs`, `compiler/src/compiler/watcher.rs`, compiler 2.0.1 → 2.0.2) — manifest generation re-read each compiled page **from disk** after writing it, so when two `vibe compile --watch` processes shared one output directory (a leaked dev-server session next to a live one), one watcher's manifest pass could read a page the other was mid-rewrite. html5ever parses the torn prefix into a well-formed shell, producing a *valid but content-less* manifest that doesn't match its HTML — hydration mounts nothing, `vibe-fouc` never releases, and the page renders blank with zero console errors (observed in Battle Brawlers: a random subset of pages broke on every save of a widely-used component). Three-layer fix: **(1)** the compiler keeps each page's compiled HTML in memory (`Compiler.compiled_html`) and both manifest passes (`generate_manifests`, `generate_manifests_for_files`) build from those exact bytes — disk is only a fallback for pages the running compiler never produced (no-clean leftovers); **(2)** compiled HTML, stamped HTML, and manifests are written atomically (same-directory pid-tagged temp file + `rename`) so no reader — dev server, browser, or another process — can ever observe a partial file; **(3)** `watch` takes a per-output-directory lockfile (OS temp dir, keyed by canonicalized output path, holding the owner pid): a second watcher on the same output now exits loudly naming the holder instead of silently double-compiling and racing, and a lock whose process is dead (Ctrl+C/SIGTERM never unwind) is stolen. Tests: `compile.rs` (`manifest_survives_output_corruption_between_compile_and_manifests`, `incremental_manifest_survives_output_corruption`, `atomic_write_replaces_content_without_leaving_tmp_files`), `watcher.rs` (`second_watch_lock_on_same_output_fails_while_held`, `stale_lock_from_dead_process_is_stolen`, `locks_on_different_outputs_do_not_conflict`).
|
|
9
|
+
|
|
10
|
+
## [2.1.20] - 2026-06-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`@ape-egg/vibe/hot-module-refresh` — transport-agnostic browser HMR client** (`hot-module-refresh.js` (new), exported from `package.json`) — the surgical-HMR "brain" that reconciles a code edit into the live DOM instead of reloading the page is extracted out of `vite-plugin-vibe` into a standalone flat module the vibe package now exports. It's a *soft dependency*: nothing in the runtime imports it and it imports nothing from the runtime, depending only on the public `window.$` surface (`reconcile` / `renderComponent` / `clearComponentCache`), so a plain static server can serve it as-is — no bundler, no Vite. A transport adapter wires its channel to the brain through a single seam, `setupHotModuleRefresh({ debug, subscribe })`, where `subscribe` delivers two callbacks: `componentUpdate(path)` — re-fetch a changed component template and, for each live instance, reconcile surgically when its scripts are unchanged or re-mount otherwise (runtime mode only, since compiled output inlines components into pages) — and `pageUpdate(payload)` — re-fetch the current page and reconcile the `[vibe]` root (mode-agnostic; raw and compiled pages reconcile the same way). This lets the Vite plugin and any other dev transport share one HMR implementation. Tests: `e2e-runtime/hot-module-refresh.html`, `tests/e2e/hot-module-refresh.spec.js`.
|
|
15
|
+
|
|
3
16
|
## [2.1.19] - 2026-06-25
|
|
4
17
|
|
|
5
18
|
### Fixed
|
|
@@ -35,6 +35,16 @@ const getPlatformBinary = () => {
|
|
|
35
35
|
return binary;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// The wrapper is what gets killed (Ctrl+C, a dev server's child.kill(), a
|
|
39
|
+
// process manager) — without forwarding, the compiler child survives as an
|
|
40
|
+
// orphan whose watch lock blocks every future `--watch` on the same output.
|
|
41
|
+
const wireLifecycle = (child) => {
|
|
42
|
+
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) =>
|
|
43
|
+
process.on(signal, () => child.kill(signal)),
|
|
44
|
+
);
|
|
45
|
+
process.on('exit', () => child.kill());
|
|
46
|
+
};
|
|
47
|
+
|
|
38
48
|
const run = () => {
|
|
39
49
|
const binary = getPlatformBinary();
|
|
40
50
|
const binaryPath = join(nativeDir, binary);
|
|
@@ -74,6 +84,7 @@ const run = () => {
|
|
|
74
84
|
cwd: srcDir,
|
|
75
85
|
stdio: 'inherit',
|
|
76
86
|
});
|
|
87
|
+
wireLifecycle(cargo);
|
|
77
88
|
|
|
78
89
|
cargo.on('error', (err) => {
|
|
79
90
|
if (err.code === 'ENOENT') {
|
|
@@ -95,6 +106,7 @@ const run = () => {
|
|
|
95
106
|
const child = spawn(binaryPath, userArgs, {
|
|
96
107
|
stdio: 'inherit',
|
|
97
108
|
});
|
|
109
|
+
wireLifecycle(child);
|
|
98
110
|
|
|
99
111
|
child.on('error', (err) => {
|
|
100
112
|
console.error('Failed to run vibe-compiler:', err.message);
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -39,6 +39,17 @@ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
|
|
|
39
39
|
.join("/")
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/// Write via a same-directory temp file + rename, so no reader — the dev
|
|
43
|
+
/// server, the browser, another compiler process — can ever observe a
|
|
44
|
+
/// partially-written file. Rename is atomic on POSIX; the temp name carries the
|
|
45
|
+
/// pid so two processes writing the same target never share a temp file.
|
|
46
|
+
pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
47
|
+
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
|
|
48
|
+
let tmp = path.with_file_name(format!(".{}.{}.vibe-tmp", file_name, std::process::id()));
|
|
49
|
+
fs::write(&tmp, contents)?;
|
|
50
|
+
fs::rename(&tmp, path)
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
// =============================================================================
|
|
43
54
|
// MIRROR_MODE: Copy asset files from source to output as-is, preserving
|
|
44
55
|
// directory structure. HTML files are compiled separately.
|
|
@@ -544,6 +555,11 @@ pub struct Compiler {
|
|
|
544
555
|
unique_components: HashSet<String>,
|
|
545
556
|
/// Cache for all components (both internal paths and external URLs)
|
|
546
557
|
component_cache: std::collections::HashMap<String, String>,
|
|
558
|
+
/// Compiled (pre-stamp) HTML of every page this compiler wrote, keyed by
|
|
559
|
+
/// output path. Manifest generation builds from these bytes — never from a
|
|
560
|
+
/// disk read-back — so a concurrent writer rewriting the output directory
|
|
561
|
+
/// (e.g. a second compiler) can't feed a torn read into a manifest.
|
|
562
|
+
compiled_html: HashMap<PathBuf, String>,
|
|
547
563
|
}
|
|
548
564
|
|
|
549
565
|
impl Compiler {
|
|
@@ -555,6 +571,7 @@ impl Compiler {
|
|
|
555
571
|
logger,
|
|
556
572
|
unique_components: HashSet::new(),
|
|
557
573
|
component_cache: HashMap::new(),
|
|
574
|
+
compiled_html: HashMap::new(),
|
|
558
575
|
}
|
|
559
576
|
}
|
|
560
577
|
|
|
@@ -898,6 +915,7 @@ impl Compiler {
|
|
|
898
915
|
// Resolve constant global-state keys once, shared read-only across pages.
|
|
899
916
|
let global_constants = self.compute_global_constants();
|
|
900
917
|
|
|
918
|
+
let compiled_html = &self.compiled_html;
|
|
901
919
|
let results: Vec<bool> = files
|
|
902
920
|
.par_iter()
|
|
903
921
|
.map(|file_path| {
|
|
@@ -911,13 +929,24 @@ impl Compiler {
|
|
|
911
929
|
return false;
|
|
912
930
|
}
|
|
913
931
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
932
|
+
// The HTML this compiler produced in memory is the source of
|
|
933
|
+
// truth; the on-disk file may have been rewritten by another
|
|
934
|
+
// process since we wrote it. Disk is only a fallback for pages
|
|
935
|
+
// this compiler never compiled (e.g. no-clean leftovers).
|
|
936
|
+
let disk_html;
|
|
937
|
+
let html: &str = match compiled_html.get(&output_path) {
|
|
938
|
+
Some(h) => h,
|
|
939
|
+
None => {
|
|
940
|
+
disk_html = match fs::read_to_string(&output_path) {
|
|
941
|
+
Ok(h) => h,
|
|
942
|
+
Err(_) => return false,
|
|
943
|
+
};
|
|
944
|
+
&disk_html
|
|
945
|
+
}
|
|
917
946
|
};
|
|
918
947
|
|
|
919
948
|
match Self::generate_file_manifest(
|
|
920
|
-
|
|
949
|
+
html,
|
|
921
950
|
&output_path,
|
|
922
951
|
&output_dir,
|
|
923
952
|
relative_path,
|
|
@@ -1009,7 +1038,7 @@ impl Compiler {
|
|
|
1009
1038
|
manifest_json
|
|
1010
1039
|
);
|
|
1011
1040
|
|
|
1012
|
-
|
|
1041
|
+
atomic_write(&manifest_path, &manifest_js)
|
|
1013
1042
|
.map_err(|e| format!("Failed to write manifest: {}", e))?;
|
|
1014
1043
|
|
|
1015
1044
|
// Stamp the compiled HTML with initial state values for FOUC prevention:
|
|
@@ -1023,7 +1052,7 @@ impl Compiler {
|
|
|
1023
1052
|
let stamped = stamper.stamp_html(html.to_string())
|
|
1024
1053
|
.map_err(|e| format!("Failed to stamp HTML: {}", e))?;
|
|
1025
1054
|
|
|
1026
|
-
|
|
1055
|
+
atomic_write(html_path, &stamped)
|
|
1027
1056
|
.map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
|
|
1028
1057
|
|
|
1029
1058
|
Ok(())
|
|
@@ -1108,6 +1137,7 @@ impl Compiler {
|
|
|
1108
1137
|
// pages (read-only; `&Map` is Sync so the parallel map can borrow it).
|
|
1109
1138
|
let global_constants = self.compute_global_constants();
|
|
1110
1139
|
|
|
1140
|
+
let compiled_html = &self.compiled_html;
|
|
1111
1141
|
let results: Vec<_> = html_files
|
|
1112
1142
|
.par_iter()
|
|
1113
1143
|
.map(|html_path| {
|
|
@@ -1116,14 +1146,24 @@ impl Compiler {
|
|
|
1116
1146
|
.to_str()
|
|
1117
1147
|
.unwrap();
|
|
1118
1148
|
|
|
1119
|
-
//
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1149
|
+
// Prefer the HTML this compiler produced in memory — the disk
|
|
1150
|
+
// copy may have been rewritten by another process since. Disk
|
|
1151
|
+
// is only a fallback for pages this compiler never compiled
|
|
1152
|
+
// (e.g. no-clean leftovers from an earlier run).
|
|
1153
|
+
let disk_html;
|
|
1154
|
+
let html: &str = match compiled_html.get(html_path.as_path()) {
|
|
1155
|
+
Some(h) => h,
|
|
1156
|
+
None => {
|
|
1157
|
+
disk_html = match fs::read_to_string(html_path) {
|
|
1158
|
+
Ok(h) => h,
|
|
1159
|
+
Err(_) => return (false, Some(relative_path.to_string())),
|
|
1160
|
+
};
|
|
1161
|
+
&disk_html
|
|
1162
|
+
}
|
|
1123
1163
|
};
|
|
1124
1164
|
|
|
1125
1165
|
// Try to generate manifest for this file (skip on error)
|
|
1126
|
-
match Self::generate_file_manifest(
|
|
1166
|
+
match Self::generate_file_manifest(html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants) {
|
|
1127
1167
|
Ok(()) => (true, None),
|
|
1128
1168
|
Err(e) => {
|
|
1129
1169
|
if verbose {
|
|
@@ -1320,7 +1360,7 @@ impl Compiler {
|
|
|
1320
1360
|
}
|
|
1321
1361
|
|
|
1322
1362
|
fn compile_html_file(
|
|
1323
|
-
&self,
|
|
1363
|
+
&mut self,
|
|
1324
1364
|
path: &Path,
|
|
1325
1365
|
parser: &HtmlParser,
|
|
1326
1366
|
relative_path: &str,
|
|
@@ -1361,11 +1401,14 @@ impl Compiler {
|
|
|
1361
1401
|
|
|
1362
1402
|
// Write to output
|
|
1363
1403
|
let output_path = self.get_output_path(path, relative_path)?;
|
|
1364
|
-
|
|
1404
|
+
atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
|
|
1365
1405
|
path: output_path.display().to_string(),
|
|
1366
1406
|
source: e,
|
|
1367
1407
|
})?;
|
|
1368
1408
|
|
|
1409
|
+
// Keep the exact bytes for this pass's manifest generation.
|
|
1410
|
+
self.compiled_html.insert(output_path, output);
|
|
1411
|
+
|
|
1369
1412
|
// Return component counts and src list
|
|
1370
1413
|
Ok((internal_count, external_count, component_srcs))
|
|
1371
1414
|
}
|
|
@@ -2258,4 +2301,130 @@ mod tests {
|
|
|
2258
2301
|
let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
|
|
2259
2302
|
assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
|
|
2260
2303
|
}
|
|
2304
|
+
|
|
2305
|
+
#[test]
|
|
2306
|
+
fn atomic_write_replaces_content_without_leaving_tmp_files() {
|
|
2307
|
+
let dir = std::env::temp_dir().join("vibe_atomic_write_test");
|
|
2308
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2309
|
+
fs::create_dir_all(&dir).unwrap();
|
|
2310
|
+
let target = dir.join("page.html");
|
|
2311
|
+
|
|
2312
|
+
atomic_write(&target, "first").unwrap();
|
|
2313
|
+
assert_eq!(fs::read_to_string(&target).unwrap(), "first");
|
|
2314
|
+
|
|
2315
|
+
// Overwriting an existing file goes through the same tmp+rename path.
|
|
2316
|
+
atomic_write(&target, "second, longer content").unwrap();
|
|
2317
|
+
assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
|
|
2318
|
+
|
|
2319
|
+
let leftovers: Vec<String> = fs::read_dir(&dir)
|
|
2320
|
+
.unwrap()
|
|
2321
|
+
.filter_map(|e| e.ok())
|
|
2322
|
+
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
2323
|
+
.filter(|n| n != "page.html")
|
|
2324
|
+
.collect();
|
|
2325
|
+
assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// A minimal on-disk project: source with one page carrying a distinctive
|
|
2329
|
+
// binding, empty components dir, output dir sibling. Returns (config, page
|
|
2330
|
+
// source path, compiled page output path, manifest path).
|
|
2331
|
+
fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
|
|
2332
|
+
let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
|
|
2333
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2334
|
+
let source = dir.join("src");
|
|
2335
|
+
let output = dir.join("out");
|
|
2336
|
+
fs::create_dir_all(source.join("components")).unwrap();
|
|
2337
|
+
fs::write(
|
|
2338
|
+
source.join("index.html"),
|
|
2339
|
+
"<!doctype html>\n<html><head><title>t</title></head>\n\
|
|
2340
|
+
<body vibe>\n<page-home><h1>@[uniqueMarker123]</h1></page-home>\n</body></html>\n",
|
|
2341
|
+
)
|
|
2342
|
+
.unwrap();
|
|
2343
|
+
|
|
2344
|
+
let config = Config {
|
|
2345
|
+
source: source.clone(),
|
|
2346
|
+
output: output.clone(),
|
|
2347
|
+
_source_str: String::new(),
|
|
2348
|
+
_output_str: String::new(),
|
|
2349
|
+
components: "components".to_string(),
|
|
2350
|
+
pages: "pages".to_string(),
|
|
2351
|
+
_assets: String::new(),
|
|
2352
|
+
root: None,
|
|
2353
|
+
minify: false,
|
|
2354
|
+
elements_as_is: false,
|
|
2355
|
+
source_maps: false,
|
|
2356
|
+
reserved_elements: Vec::new(),
|
|
2357
|
+
skip_files: Vec::new(),
|
|
2358
|
+
node_modules_as_is: false,
|
|
2359
|
+
components_as_is: false,
|
|
2360
|
+
runtime_as_is: false,
|
|
2361
|
+
iterations_as_is: false,
|
|
2362
|
+
no_clean: false,
|
|
2363
|
+
fouc_as_is: false,
|
|
2364
|
+
working_dir: dir.clone(),
|
|
2365
|
+
};
|
|
2366
|
+
|
|
2367
|
+
let page_src = source.join("index.html");
|
|
2368
|
+
let page_out = output.join("index.html");
|
|
2369
|
+
let manifest = output.join("vibe-hyperspeed").join("index.html.manifest.js");
|
|
2370
|
+
(config, page_src, page_out, manifest)
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// The watcher race: another writer truncates/rewrites a compiled page on
|
|
2374
|
+
// disk between our compile and our manifest pass. The manifest must be
|
|
2375
|
+
// built from the HTML this compiler just produced in memory — never from a
|
|
2376
|
+
// disk read-back — or a torn read yields a valid-but-empty manifest and the
|
|
2377
|
+
// page hydrates to a blank screen.
|
|
2378
|
+
#[test]
|
|
2379
|
+
fn manifest_survives_output_corruption_between_compile_and_manifests() {
|
|
2380
|
+
let (config, _page_src, page_out, manifest) =
|
|
2381
|
+
manifest_test_project("manifest_memory_full");
|
|
2382
|
+
|
|
2383
|
+
let mut compiler = Compiler::new(config, false);
|
|
2384
|
+
compiler.compile().expect("compile should succeed");
|
|
2385
|
+
assert!(
|
|
2386
|
+
fs::read_to_string(&page_out).unwrap().contains("uniqueMarker123"),
|
|
2387
|
+
"sanity: compiled page carries the binding"
|
|
2388
|
+
);
|
|
2389
|
+
|
|
2390
|
+
// Simulate the concurrent writer: the on-disk page is now a shell.
|
|
2391
|
+
fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
|
|
2392
|
+
|
|
2393
|
+
compiler.generate_manifests().expect("manifest generation should succeed");
|
|
2394
|
+
|
|
2395
|
+
let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
|
|
2396
|
+
assert!(
|
|
2397
|
+
manifest_js.contains("uniqueMarker123"),
|
|
2398
|
+
"manifest was built from the corrupted disk file instead of the in-memory compile output"
|
|
2399
|
+
);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// Same property on the incremental watch path (generate_manifests_for_files),
|
|
2403
|
+
// which is where the two-watcher race actually corrupted manifests.
|
|
2404
|
+
#[test]
|
|
2405
|
+
fn incremental_manifest_survives_output_corruption() {
|
|
2406
|
+
let (config, page_src, page_out, manifest) =
|
|
2407
|
+
manifest_test_project("manifest_memory_incremental");
|
|
2408
|
+
|
|
2409
|
+
let mut compiler = Compiler::new(config.clone(), false);
|
|
2410
|
+
let mut parser = HtmlParser::new(config.components_path());
|
|
2411
|
+
parser.load_elements().unwrap();
|
|
2412
|
+
|
|
2413
|
+
fs::create_dir_all(&config.output).unwrap();
|
|
2414
|
+
compiler
|
|
2415
|
+
.compile_specific_html_files(&[page_src.clone()], &parser)
|
|
2416
|
+
.expect("incremental compile should succeed");
|
|
2417
|
+
|
|
2418
|
+
fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
|
|
2419
|
+
|
|
2420
|
+
compiler
|
|
2421
|
+
.generate_manifests_for_files(&[page_src])
|
|
2422
|
+
.expect("incremental manifest generation should succeed");
|
|
2423
|
+
|
|
2424
|
+
let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
|
|
2425
|
+
assert!(
|
|
2426
|
+
manifest_js.contains("uniqueMarker123"),
|
|
2427
|
+
"incremental manifest was built from the corrupted disk file instead of the in-memory compile output"
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2261
2430
|
}
|
|
@@ -206,6 +206,114 @@ fn component_cache_key(component: &Path, canonical_source: &Path) -> Option<Stri
|
|
|
206
206
|
.map(|rel| format!("/{}", rel.replace('\\', "/")))
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/// Cross-process guard: exactly one `vibe compile --watch` per output
|
|
210
|
+
/// directory. Two concurrent watchers double-compile every save and race each
|
|
211
|
+
/// other's output writes — the loser reads a torn file back and emits a
|
|
212
|
+
/// valid-but-empty manifest, so the page hydrates blank with no errors. The
|
|
213
|
+
/// lock lives in the OS temp dir keyed by the output path, holds the owner's
|
|
214
|
+
/// pid, and a lock whose process is gone is stolen (a killed watcher never
|
|
215
|
+
/// unwinds, so Drop alone can't be trusted to clean up).
|
|
216
|
+
#[derive(Debug)]
|
|
217
|
+
pub struct WatchLock {
|
|
218
|
+
path: PathBuf,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
impl WatchLock {
|
|
222
|
+
/// Deterministic lock path for an output dir, stable whether or not the
|
|
223
|
+
/// output exists yet: canonicalize the output itself when possible, else
|
|
224
|
+
/// its (existing) parent — so a watcher that locked before the first
|
|
225
|
+
/// compile created the output still collides with one that locked after.
|
|
226
|
+
fn lock_path_for(output: &Path) -> PathBuf {
|
|
227
|
+
use std::collections::hash_map::DefaultHasher;
|
|
228
|
+
use std::hash::{Hash, Hasher};
|
|
229
|
+
|
|
230
|
+
let canonical = output.canonicalize().unwrap_or_else(|_| {
|
|
231
|
+
let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
|
232
|
+
let name = output.file_name().map(PathBuf::from).unwrap_or_default();
|
|
233
|
+
parent
|
|
234
|
+
.canonicalize()
|
|
235
|
+
.unwrap_or_else(|_| parent.to_path_buf())
|
|
236
|
+
.join(name)
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let mut hasher = DefaultHasher::new();
|
|
240
|
+
canonical.hash(&mut hasher);
|
|
241
|
+
std::env::temp_dir().join(format!("vibe-watch-{:016x}.lock", hasher.finish()))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub fn acquire(output: &Path) -> Result<Self, String> {
|
|
245
|
+
let path = Self::lock_path_for(output);
|
|
246
|
+
|
|
247
|
+
// Two attempts: the second runs only after a stale lock was removed.
|
|
248
|
+
for _ in 0..2 {
|
|
249
|
+
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
|
250
|
+
Ok(mut file) => {
|
|
251
|
+
use std::io::Write;
|
|
252
|
+
let _ = write!(file, "{}", std::process::id());
|
|
253
|
+
return Ok(Self { path });
|
|
254
|
+
}
|
|
255
|
+
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
256
|
+
let holder = std::fs::read_to_string(&path)
|
|
257
|
+
.ok()
|
|
258
|
+
.and_then(|s| s.trim().parse::<u32>().ok());
|
|
259
|
+
match holder {
|
|
260
|
+
Some(pid) if process_alive(pid) => {
|
|
261
|
+
return Err(format!(
|
|
262
|
+
"another `vibe compile --watch` (pid {}) is already watching this output directory. \
|
|
263
|
+
Concurrent watchers race each other's writes and corrupt manifests — stop the other one first. \
|
|
264
|
+
(lock: {})",
|
|
265
|
+
pid,
|
|
266
|
+
path.display()
|
|
267
|
+
));
|
|
268
|
+
}
|
|
269
|
+
// Dead owner or unreadable lock: stale, steal it.
|
|
270
|
+
_ => {
|
|
271
|
+
let _ = std::fs::remove_file(&path);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
Err(e) => {
|
|
276
|
+
return Err(format!(
|
|
277
|
+
"failed to create watch lock {}: {}",
|
|
278
|
+
path.display(),
|
|
279
|
+
e
|
|
280
|
+
));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
Err(format!(
|
|
286
|
+
"could not acquire watch lock {} — still held after stale-lock cleanup",
|
|
287
|
+
path.display()
|
|
288
|
+
))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
impl Drop for WatchLock {
|
|
293
|
+
fn drop(&mut self) {
|
|
294
|
+
let _ = std::fs::remove_file(&self.path);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#[cfg(unix)]
|
|
299
|
+
fn process_alive(pid: u32) -> bool {
|
|
300
|
+
std::process::Command::new("kill")
|
|
301
|
+
.arg("-0")
|
|
302
|
+
.arg(pid.to_string())
|
|
303
|
+
.stdout(std::process::Stdio::null())
|
|
304
|
+
.stderr(std::process::Stdio::null())
|
|
305
|
+
.status()
|
|
306
|
+
.map(|s| s.success())
|
|
307
|
+
.unwrap_or(false)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Without a portable liveness probe, treat an existing lock as live — failing
|
|
311
|
+
/// loudly (with the lock path in the message) beats silently racing.
|
|
312
|
+
#[cfg(not(unix))]
|
|
313
|
+
fn process_alive(_pid: u32) -> bool {
|
|
314
|
+
true
|
|
315
|
+
}
|
|
316
|
+
|
|
209
317
|
/// Check if a path should be blacklisted based on SKIP_FILES patterns
|
|
210
318
|
/// This checks both the filename and all path components relative to source root
|
|
211
319
|
fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
|
|
@@ -297,8 +405,34 @@ fn scan_directory(
|
|
|
297
405
|
Ok(())
|
|
298
406
|
}
|
|
299
407
|
|
|
408
|
+
/// A watcher whose spawning wrapper dies without unwinding (SIGKILL, crashed
|
|
409
|
+
/// dev server) is orphaned: it keeps watching and its lock blocks every future
|
|
410
|
+
/// `--watch` on the same output. Reparenting is the orphan signal — when the
|
|
411
|
+
/// parent pid changes (to init or a reaper), the owner is gone, so release the
|
|
412
|
+
/// lock and exit. `std::process::exit` skips Drop, hence the explicit remove.
|
|
413
|
+
#[cfg(unix)]
|
|
414
|
+
fn exit_when_orphaned(lock_path: PathBuf) {
|
|
415
|
+
let parent = std::os::unix::process::parent_id();
|
|
416
|
+
std::thread::spawn(move || loop {
|
|
417
|
+
std::thread::sleep(Duration::from_secs(2));
|
|
418
|
+
if std::os::unix::process::parent_id() != parent {
|
|
419
|
+
eprintln!("parent process exited — shutting down watcher");
|
|
420
|
+
let _ = std::fs::remove_file(&lock_path);
|
|
421
|
+
std::process::exit(0);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#[cfg(not(unix))]
|
|
427
|
+
fn exit_when_orphaned(_lock_path: PathBuf) {}
|
|
428
|
+
|
|
300
429
|
/// Start watching for file changes
|
|
301
430
|
pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
|
431
|
+
// Held for the watcher's whole lifetime; a second watcher on the same
|
|
432
|
+
// output exits loudly instead of silently racing this one.
|
|
433
|
+
let _watch_lock = WatchLock::acquire(&config.output)?;
|
|
434
|
+
exit_when_orphaned(_watch_lock.path.clone());
|
|
435
|
+
|
|
302
436
|
println!("{}", "Building dependency graph...".cyan());
|
|
303
437
|
let mut graph = build_dependency_graph(&config)?;
|
|
304
438
|
|
|
@@ -912,6 +1046,57 @@ mod tests {
|
|
|
912
1046
|
);
|
|
913
1047
|
}
|
|
914
1048
|
|
|
1049
|
+
fn lock_test_output(name: &str) -> PathBuf {
|
|
1050
|
+
let out = std::env::temp_dir().join(format!("vibe_watch_lock_{}_out", name));
|
|
1051
|
+
let _ = std::fs::remove_dir_all(&out);
|
|
1052
|
+
std::fs::create_dir_all(&out).unwrap();
|
|
1053
|
+
// A previous crashed test run may have left a lock behind.
|
|
1054
|
+
let _ = std::fs::remove_file(WatchLock::lock_path_for(&out));
|
|
1055
|
+
out
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Two concurrent watchers on one output dir double-compile every save and
|
|
1059
|
+
// race each other's writes (torn manifest reads → blank pages). The second
|
|
1060
|
+
// watcher must refuse to start while the first holds the lock.
|
|
1061
|
+
#[test]
|
|
1062
|
+
fn second_watch_lock_on_same_output_fails_while_held() {
|
|
1063
|
+
let out = lock_test_output("same");
|
|
1064
|
+
|
|
1065
|
+
let first = WatchLock::acquire(&out).expect("first lock acquires");
|
|
1066
|
+
let second = WatchLock::acquire(&out);
|
|
1067
|
+
let msg = second.expect_err("second watcher on the same output must fail loudly");
|
|
1068
|
+
assert!(
|
|
1069
|
+
msg.contains("vibe compile --watch"),
|
|
1070
|
+
"error should explain the conflict: {msg}"
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
drop(first);
|
|
1074
|
+
WatchLock::acquire(&out).expect("released lock can be re-acquired");
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// A watcher killed without unwinding (Ctrl+C, SIGTERM from dev tooling)
|
|
1078
|
+
// leaves its lock file behind; the pid inside is dead, so the next watcher
|
|
1079
|
+
// steals the lock instead of being locked out forever.
|
|
1080
|
+
#[test]
|
|
1081
|
+
fn stale_lock_from_dead_process_is_stolen() {
|
|
1082
|
+
let out = lock_test_output("stale");
|
|
1083
|
+
|
|
1084
|
+
// No live process can have this pid (pid_max is 99998 on macOS,
|
|
1085
|
+
// ≤ 4194304 on Linux).
|
|
1086
|
+
std::fs::write(WatchLock::lock_path_for(&out), "4294967295").unwrap();
|
|
1087
|
+
|
|
1088
|
+
WatchLock::acquire(&out).expect("stale lock from a dead process must be stolen");
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
#[test]
|
|
1092
|
+
fn locks_on_different_outputs_do_not_conflict() {
|
|
1093
|
+
let out_a = lock_test_output("indep_a");
|
|
1094
|
+
let out_b = lock_test_output("indep_b");
|
|
1095
|
+
|
|
1096
|
+
let _a = WatchLock::acquire(&out_a).expect("lock a");
|
|
1097
|
+
WatchLock::acquire(&out_b).expect("an unrelated output dir must not be blocked");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
915
1100
|
#[test]
|
|
916
1101
|
fn cache_key_is_source_relative_with_leading_slash() {
|
|
917
1102
|
let source = PathBuf::from("/proj/src");
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// @ape-egg/vibe/hot-module-refresh
|
|
2
|
+
//
|
|
3
|
+
// Transport-agnostic browser HMR client — the "brain" that reconciles a code
|
|
4
|
+
// edit into the live DOM instead of reloading the page. Extracted out of
|
|
5
|
+
// vite-plugin-vibe so both dev modes share one implementation.
|
|
6
|
+
//
|
|
7
|
+
// SOFT DEPENDENCY: nothing in Vibe's runtime imports this, it imports nothing
|
|
8
|
+
// from Vibe, and deleting it leaves a working framework. It depends solely on
|
|
9
|
+
// the runtime global `window.$` (reconcile / renderComponent /
|
|
10
|
+
// clearComponentCache), so it ships as a flat module a plain static server can
|
|
11
|
+
// serve as-is — no bundler, no Vite.
|
|
12
|
+
//
|
|
13
|
+
// A transport adapter wires its channel to the brain through one seam:
|
|
14
|
+
//
|
|
15
|
+
// setupHotModuleRefresh({ debug, subscribe })
|
|
16
|
+
// subscribe({ componentUpdate, pageUpdate })
|
|
17
|
+
//
|
|
18
|
+
// • componentUpdate(path) — a component template changed. Fetch the raw
|
|
19
|
+
// template; for every live instance, reconcile surgically when scripts
|
|
20
|
+
// are unchanged, else re-mount. Runtime-mode only — compiled output
|
|
21
|
+
// inlines components into pages, so its adapter never calls this.
|
|
22
|
+
// • pageUpdate(payload) — the current page changed. Fetch it and reconcile
|
|
23
|
+
// the [vibe] root. Mode-agnostic: a raw page (runtime) and a compiled
|
|
24
|
+
// page (compiled) reconcile the same way.
|
|
25
|
+
|
|
26
|
+
export const setupHotModuleRefresh = ({ debug = false, subscribe }) => {
|
|
27
|
+
const dbg = {
|
|
28
|
+
info: (...a) => { if (debug) console.info(...a); },
|
|
29
|
+
group: (...a) => { if (debug) console.group(...a); },
|
|
30
|
+
groupEnd: () => { if (debug) console.groupEnd(); },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Track which live <component> wrappers correspond to which source file.
|
|
34
|
+
// Vibe's component.js replaces <component src='X'> with a fresh <component>
|
|
35
|
+
// (no src). The spy below tags the new wrapper with _vibeSrc + the slot
|
|
36
|
+
// content, so on a component-update we can find every live instance and
|
|
37
|
+
// hand the slot back to Vibe verbatim during re-mount.
|
|
38
|
+
const liveBySrc = new Map();
|
|
39
|
+
|
|
40
|
+
const isComponentSrc = (n) =>
|
|
41
|
+
n?.nodeType === 1 && n.hasAttribute?.('src') &&
|
|
42
|
+
(n.nodeName === 'COMPONENT' || (n.nodeName === 'DIV' && n.classList?.contains?.('component')));
|
|
43
|
+
const isProcessedComponent = (n) =>
|
|
44
|
+
n?.nodeType === 1 && !n.hasAttribute?.('src') &&
|
|
45
|
+
(n.nodeName === 'COMPONENT' || (n.nodeName === 'DIV' && n.classList?.contains?.('component')));
|
|
46
|
+
|
|
47
|
+
// Snapshot slot innerHTML on a <component src> BEFORE vibe strips iteration/
|
|
48
|
+
// conditional templates from its subtree. vibe's renderAllIterations removes
|
|
49
|
+
// nodes between <!-- each -->/<!-- /each --> comments synchronously after
|
|
50
|
+
// hydrate; if we wait until the component is swapped out (spy's removedNodes
|
|
51
|
+
// path) and read innerHTML then, the templates are gone and HMR re-mounts
|
|
52
|
+
// receive an empty slot. setup runs before the page's boot script calls into
|
|
53
|
+
// vibe — the initial pass captures everything present at parse time.
|
|
54
|
+
const captureSlot = (el) => {
|
|
55
|
+
if (el._vibePluginSlot === undefined) el._vibePluginSlot = el.innerHTML;
|
|
56
|
+
};
|
|
57
|
+
document.querySelectorAll('component[src], div.component[src]').forEach(captureSlot);
|
|
58
|
+
|
|
59
|
+
// Mirrors vibe/runtime/cleanup.js#shouldCleanup: a subtree is 'done' once
|
|
60
|
+
// no <component[src]> remains pending and no literal @[...] sits in a
|
|
61
|
+
// text node. Used to release vibe-fouc once Vibe finishes processing the
|
|
62
|
+
// re-mounted component subtree.
|
|
63
|
+
const waitForVibeReady = (target, timeout = 2000) => new Promise((done) => {
|
|
64
|
+
const deadline = performance.now() + timeout;
|
|
65
|
+
const tick = () => {
|
|
66
|
+
if (!target.isConnected) return done();
|
|
67
|
+
if (!target.querySelector('component[src], div.component[src]')) {
|
|
68
|
+
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
|
|
69
|
+
let pending = false;
|
|
70
|
+
let n;
|
|
71
|
+
while ((n = walker.nextNode())) {
|
|
72
|
+
if (/@\[.+?\]/.test(n.textContent)) { pending = true; break; }
|
|
73
|
+
}
|
|
74
|
+
if (!pending) return done();
|
|
75
|
+
}
|
|
76
|
+
if (performance.now() > deadline) return done();
|
|
77
|
+
requestAnimationFrame(tick);
|
|
78
|
+
};
|
|
79
|
+
requestAnimationFrame(tick);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
new MutationObserver((mutations) => {
|
|
83
|
+
// First pass: capture slot content on any <component src> that gets
|
|
84
|
+
// added dynamically (conditional/iteration branches mounting nested
|
|
85
|
+
// components). Must happen before the removal/addition logic runs, so
|
|
86
|
+
// that if a subsequent mutation re-mounts or strips this element, the
|
|
87
|
+
// snapshot is already in place.
|
|
88
|
+
for (const { addedNodes } of mutations) {
|
|
89
|
+
for (const n of addedNodes) {
|
|
90
|
+
if (isComponentSrc(n)) captureSlot(n);
|
|
91
|
+
if (n?.nodeType === 1 && n.querySelectorAll) {
|
|
92
|
+
n.querySelectorAll('component[src], div.component[src]').forEach(captureSlot);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const { addedNodes, removedNodes } of mutations) {
|
|
97
|
+
let src = null;
|
|
98
|
+
let slotContent = '';
|
|
99
|
+
let hadFouc = false;
|
|
100
|
+
let props = null;
|
|
101
|
+
let scriptHash;
|
|
102
|
+
for (const n of removedNodes) {
|
|
103
|
+
if (isComponentSrc(n)) {
|
|
104
|
+
src = n.getAttribute('src');
|
|
105
|
+
// vibe's component.js deletes _vibeSlotContent synchronously after
|
|
106
|
+
// reading it, so on re-mount transitions the prop is already gone
|
|
107
|
+
// by the time we see the removal. _vibePluginSlot is our own
|
|
108
|
+
// mirror that vibe never touches — read it preferentially, fall
|
|
109
|
+
// back to vibe's prop (initial page load), then innerHTML (also
|
|
110
|
+
// initial, when source was authored inline).
|
|
111
|
+
slotContent = (
|
|
112
|
+
n._vibePluginSlot !== undefined ? n._vibePluginSlot :
|
|
113
|
+
n._vibeSlotContent !== undefined ? n._vibeSlotContent :
|
|
114
|
+
n.innerHTML
|
|
115
|
+
).trim();
|
|
116
|
+
hadFouc = n.hasAttribute('vibe-fouc');
|
|
117
|
+
// Script hash is set by remount() on the <component src> before it
|
|
118
|
+
// hits the DOM, OR carried from a previous wrapper via this same
|
|
119
|
+
// forward path. Either way, stash it so the processed wrapper can
|
|
120
|
+
// use it as baseline for future surgical reconciles.
|
|
121
|
+
scriptHash = n._vibeScriptHash;
|
|
122
|
+
// Capture all authored attrs so reconcile can detect prop changes
|
|
123
|
+
// on the callsite and re-mount when they differ. vibe-fouc is a
|
|
124
|
+
// transient HMR marker — in either attribute or class form — and
|
|
125
|
+
// must never end up in the authored set or every FOUC flip would
|
|
126
|
+
// falsely look like a prop change.
|
|
127
|
+
props = {};
|
|
128
|
+
for (const a of n.attributes) {
|
|
129
|
+
if (a.name === 'vibe-fouc') continue;
|
|
130
|
+
if (a.name === 'class') {
|
|
131
|
+
const kept = a.value.split(/\s+/).filter((t) => t && t !== 'vibe-fouc');
|
|
132
|
+
if (kept.length) props.class = kept.join(' ');
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
props[a.name] = a.value;
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!src) continue;
|
|
141
|
+
for (const n of addedNodes) {
|
|
142
|
+
if (isProcessedComponent(n)) {
|
|
143
|
+
n._vibeSrc = src;
|
|
144
|
+
n._vibeSlotContent = slotContent;
|
|
145
|
+
// Brain-owned mirror so we can recover slot content after vibe's
|
|
146
|
+
// processSingle deletes _vibeSlotContent on the next re-mount.
|
|
147
|
+
n._vibePluginSlot = slotContent;
|
|
148
|
+
n._vibeProps = props;
|
|
149
|
+
if (scriptHash !== undefined) n._vibeScriptHash = scriptHash;
|
|
150
|
+
if (!liveBySrc.has(src)) liveBySrc.set(src, new Set());
|
|
151
|
+
liveBySrc.get(src).add(n);
|
|
152
|
+
if (hadFouc) {
|
|
153
|
+
n.setAttribute('vibe-fouc', '');
|
|
154
|
+
waitForVibeReady(n).then(() => n.removeAttribute('vibe-fouc'));
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
161
|
+
|
|
162
|
+
// Cheap string hash (djb2). Used to detect <script type="module"> changes
|
|
163
|
+
// between HMR fetches — unchanged scripts mean registered component state
|
|
164
|
+
// is still valid, so we can reconcile in place instead of re-mounting.
|
|
165
|
+
const hashString = (s) => {
|
|
166
|
+
let h = 5381;
|
|
167
|
+
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) + s.charCodeAt(i)) | 0;
|
|
168
|
+
return h;
|
|
169
|
+
};
|
|
170
|
+
const hashScripts = (rawHtml) => {
|
|
171
|
+
const temp = document.createElement('div');
|
|
172
|
+
temp.innerHTML = rawHtml;
|
|
173
|
+
const scripts = temp.querySelectorAll('script[type="module"]');
|
|
174
|
+
return hashString([...scripts].map((s) => s.textContent || '').join(''));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Return the baseline script hash for a live wrapper. On initial mount,
|
|
178
|
+
// vibe's component.js stashes the raw fetched HTML on the wrapper as
|
|
179
|
+
// _vibeRawSource. We hash it lazily on first HMR check and cache the
|
|
180
|
+
// result. This makes the very first save after page load surgical
|
|
181
|
+
// (assuming the script body hasn't changed) rather than always falling
|
|
182
|
+
// back to re-mount.
|
|
183
|
+
const getBaselineScriptHash = (el) => {
|
|
184
|
+
if (el._vibeScriptHash !== undefined) return el._vibeScriptHash;
|
|
185
|
+
if (el._vibeRawSource) {
|
|
186
|
+
el._vibeScriptHash = hashScripts(el._vibeRawSource);
|
|
187
|
+
return el._vibeScriptHash;
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Collect data-vibe-component-id values from the live wrapper's subtree in
|
|
193
|
+
// DOM order. Script processing inside the component assigns ids in script
|
|
194
|
+
// order → DOM order (each script's sibling group follows it), so reusing
|
|
195
|
+
// ids in DOM order keeps state bindings aligned across HMR renders.
|
|
196
|
+
const collectIdsInOrder = (el) => {
|
|
197
|
+
const ids = [];
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
el.querySelectorAll('[data-vibe-component-id]').forEach((node) => {
|
|
200
|
+
const id = node.getAttribute('data-vibe-component-id');
|
|
201
|
+
if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
|
|
202
|
+
});
|
|
203
|
+
return ids;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// True when an element lives inside a vibe iteration region (between
|
|
207
|
+
// <!-- each ... --> and <!-- /each -->). Iterations materialize the
|
|
208
|
+
// template N times with pre-resolved props; iterate.js caches the template
|
|
209
|
+
// at mount time, so surgical changes to one instance would be clobbered on
|
|
210
|
+
// the next array mutation. Fall back to re-mount in that case.
|
|
211
|
+
const isInsideIteration = (el) => {
|
|
212
|
+
let cur = el;
|
|
213
|
+
while (cur && cur.parentNode) {
|
|
214
|
+
let depth = 0;
|
|
215
|
+
let sib = cur.previousSibling;
|
|
216
|
+
while (sib) {
|
|
217
|
+
if (sib.nodeType === 8) {
|
|
218
|
+
const t = sib.textContent.trim();
|
|
219
|
+
if (t === '/each') depth++;
|
|
220
|
+
else if (t.startsWith('each ')) {
|
|
221
|
+
if (depth === 0) return true;
|
|
222
|
+
depth--;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
sib = sib.previousSibling;
|
|
226
|
+
}
|
|
227
|
+
cur = cur.parentNode;
|
|
228
|
+
if (!cur || cur === document.body) return false;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Re-mount a single live instance by swapping its wrapper for a fresh
|
|
234
|
+
// <component src>. Vibe re-fetches, re-executes scripts, and re-inlines
|
|
235
|
+
// the template. Used for first-HMR (no baseline hash), script changes,
|
|
236
|
+
// iteration-nested instances, and as a failure fallback.
|
|
237
|
+
const remount = (el, path, scriptHash) => {
|
|
238
|
+
const oldComponentIds = collectIdsInOrder(el);
|
|
239
|
+
const fresh = document.createElement(el.tagName);
|
|
240
|
+
if (el.tagName === 'DIV') fresh.className = 'component';
|
|
241
|
+
const props = el._vibeProps || { src: path };
|
|
242
|
+
for (const [name, value] of Object.entries(props)) {
|
|
243
|
+
fresh.setAttribute(name, value);
|
|
244
|
+
}
|
|
245
|
+
fresh.setAttribute('vibe-fouc', '');
|
|
246
|
+
const slot = el._vibePluginSlot !== undefined
|
|
247
|
+
? el._vibePluginSlot
|
|
248
|
+
: (el._vibeSlotContent || '');
|
|
249
|
+
fresh._vibeSlotContent = slot;
|
|
250
|
+
fresh._vibePluginSlot = slot;
|
|
251
|
+
fresh._vibeScriptHash = scriptHash;
|
|
252
|
+
if (oldComponentIds.length) fresh._vibeReuseComponentIds = oldComponentIds;
|
|
253
|
+
// Transfer iteration-prop registry ownership from the soon-to-be-detached
|
|
254
|
+
// wrapper to the fresh one. The detach would otherwise trigger Vibe's
|
|
255
|
+
// releaseOrphanedIterationProps and free the registry slots that the
|
|
256
|
+
// copied prop attributes (e.g. node='@[window.__vibeIterProps._pN]') still
|
|
257
|
+
// reference, leaving every binding to render undefined after the HMR swap.
|
|
258
|
+
if (el._vibeIterPropIds) {
|
|
259
|
+
fresh._vibeIterPropIds = el._vibeIterPropIds;
|
|
260
|
+
fresh.setAttribute('data-vibe-iter-prop', '');
|
|
261
|
+
el._vibeIterPropIds = null;
|
|
262
|
+
el.removeAttribute('data-vibe-iter-prop');
|
|
263
|
+
}
|
|
264
|
+
el.replaceWith(fresh);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Component file changed. Strategy:
|
|
268
|
+
// 1. Fetch the raw template once per update; hash its <script type="module">
|
|
269
|
+
// contents. Script hash unchanged + not inside an iteration → surgical
|
|
270
|
+
// path: $.renderComponent produces the processed HTML (props + slot
|
|
271
|
+
// substituted, componentIds reused) and $.reconcile diffs it against
|
|
272
|
+
// the live wrapper's children. DOM identity, focus, and component
|
|
273
|
+
// state are preserved.
|
|
274
|
+
// 2. First HMR for any instance (no baseline hash stored), script changes,
|
|
275
|
+
// or iteration-scoped callsites → full re-mount (same path as before
|
|
276
|
+
// the surgical rewrite). vibe-fouc hides the subtree until ready.
|
|
277
|
+
//
|
|
278
|
+
// Props and slot content always come from the spy's _vibeProps /
|
|
279
|
+
// _vibePluginSlot snapshot.
|
|
280
|
+
let componentUpdateCount = 0;
|
|
281
|
+
const componentUpdate = async (path) => {
|
|
282
|
+
const n = ++componentUpdateCount;
|
|
283
|
+
// Drop the runtime's cached template for this file so any component
|
|
284
|
+
// mounted *after* this edit fetches the fresh version. Already-mounted
|
|
285
|
+
// instances are refreshed surgically below; this covers future mounts.
|
|
286
|
+
window.$?.clearComponentCache?.(path);
|
|
287
|
+
const instances = liveBySrc.get(path);
|
|
288
|
+
dbg.group('[vibe-hmr] component-update #' + n, path,
|
|
289
|
+
'— instances:', instances?.size || 0);
|
|
290
|
+
try {
|
|
291
|
+
if (!instances || !instances.size) {
|
|
292
|
+
dbg.info('[vibe-hmr] no live instances; nothing to do');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const rawUrl = path + (path.includes('?') ? '&' : '?') + '_t=' + Date.now();
|
|
296
|
+
const rawHtml = await fetch(rawUrl, { cache: 'no-store' }).then((r) => r.text());
|
|
297
|
+
const scriptHash = hashScripts(rawHtml);
|
|
298
|
+
const canSurgical = typeof window.$?.renderComponent === 'function'
|
|
299
|
+
&& typeof window.$?.reconcile === 'function';
|
|
300
|
+
|
|
301
|
+
let surgical = 0;
|
|
302
|
+
let remounted = 0;
|
|
303
|
+
for (const el of [...instances]) {
|
|
304
|
+
instances.delete(el);
|
|
305
|
+
if (!el.parentNode) continue;
|
|
306
|
+
|
|
307
|
+
const oldHash = getBaselineScriptHash(el);
|
|
308
|
+
const inIter = isInsideIteration(el);
|
|
309
|
+
|
|
310
|
+
if (canSurgical && oldHash !== undefined && oldHash === scriptHash && !inIter) {
|
|
311
|
+
try {
|
|
312
|
+
const componentIds = collectIdsInOrder(el);
|
|
313
|
+
const slot = el._vibePluginSlot !== undefined
|
|
314
|
+
? el._vibePluginSlot
|
|
315
|
+
: (el._vibeSlotContent || '');
|
|
316
|
+
const props = el._vibeProps || {};
|
|
317
|
+
const processedHtml = window.$.renderComponent(rawHtml, props, slot, { componentIds });
|
|
318
|
+
const summary = await window.$.reconcile(el, processedHtml);
|
|
319
|
+
el._vibeScriptHash = scriptHash;
|
|
320
|
+
el._vibeRawSource = rawHtml;
|
|
321
|
+
if (!liveBySrc.has(path)) liveBySrc.set(path, new Set());
|
|
322
|
+
liveBySrc.get(path).add(el);
|
|
323
|
+
surgical++;
|
|
324
|
+
dbg.info('[vibe-hmr] surgical reconcile:', summary);
|
|
325
|
+
continue;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.warn('[vibe-hmr] surgical failed, falling back to re-mount:', err);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
remount(el, path, scriptHash);
|
|
332
|
+
remounted++;
|
|
333
|
+
}
|
|
334
|
+
dbg.info('[vibe-hmr] surgical:', surgical, 'remounted:', remounted);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error('[vibe-hmr] component-update failed:', err);
|
|
337
|
+
} finally {
|
|
338
|
+
dbg.groupEnd();
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Page file changed — fetch new HTML and hand the [vibe] root + new inner
|
|
343
|
+
// content to $.reconcile. Vibe walks live vs. source and applies the
|
|
344
|
+
// minimal mutation; iteration / conditional / component regions are
|
|
345
|
+
// opaque (their interiors are state-driven). Page-level JS state on $
|
|
346
|
+
// is preserved (no full reload).
|
|
347
|
+
let pageUpdateCount = 0;
|
|
348
|
+
const pageUpdate = async (payload) => {
|
|
349
|
+
const n = ++pageUpdateCount;
|
|
350
|
+
dbg.group('[vibe-hmr] page-update #' + n, payload?.path || '(no path)');
|
|
351
|
+
try {
|
|
352
|
+
if (!window.$ || typeof window.$.reconcile !== 'function') {
|
|
353
|
+
console.warn('[vibe-hmr] $.reconcile not available — skipped');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const url = location.href + (location.href.includes('?') ? '&' : '?') + '_t=' + Date.now();
|
|
357
|
+
const t0 = performance.now();
|
|
358
|
+
const html = await fetch(url).then((r) => r.text());
|
|
359
|
+
dbg.info('[vibe-hmr] fetched', html.length, 'bytes in', (performance.now() - t0).toFixed(1) + 'ms');
|
|
360
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
361
|
+
const newRoot = doc.querySelector('[vibe]') || doc.body;
|
|
362
|
+
const liveRoot = document.querySelector('[vibe]') || document.body;
|
|
363
|
+
dbg.info('[vibe-hmr] target [vibe] root:', liveRoot, 'isConnected=', liveRoot.isConnected);
|
|
364
|
+
// Note: we no longer strip <script type='module'> from source. Stripping
|
|
365
|
+
// misaligned source vs. live and caused cascading replaces. Reconcile's
|
|
366
|
+
// tag-aligned walk now matches scripts at their position; updating a
|
|
367
|
+
// script's textContent doesn't re-execute it (a known limitation —
|
|
368
|
+
// editing inline page scripts requires a real reload to take effect).
|
|
369
|
+
const summary = await window.$.reconcile(liveRoot, newRoot.innerHTML);
|
|
370
|
+
dbg.info('[vibe-hmr] summary:', summary);
|
|
371
|
+
if (summary?.changes?.length) {
|
|
372
|
+
for (const c of summary.changes) dbg.info('[vibe-hmr] ·', c);
|
|
373
|
+
} else {
|
|
374
|
+
dbg.info('[vibe-hmr] no changes — source matches live');
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error('[vibe-hmr] page-update failed:', err);
|
|
378
|
+
} finally {
|
|
379
|
+
dbg.groupEnd();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
subscribe({ componentUpdate, pageUpdate });
|
|
384
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime-first reactivity with optional compiler",
|
|
6
6
|
"main": "index.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
".": "./index.js",
|
|
10
10
|
"./boot": "./boot.js",
|
|
11
11
|
"./component": "./component.js",
|
|
12
|
+
"./hot-module-refresh": "./hot-module-refresh.js",
|
|
12
13
|
"./runtime": "./runtime/index.js",
|
|
13
14
|
"./compiler": "./compiler/bin/vibe-compile.js"
|
|
14
15
|
},
|