···23232424# TODO: Allow making those optional
2525rolldown = { package = "brk_rolldown", version = "0.8.0" }
2626+rolldown_common = { package = "brk_rolldown_common", version = "0.8.0" }
2627serde = { workspace = true }
2728serde_json = "1.0"
2829serde_yaml = "0.9.34"
+1-2
crates/maudit/src/assets/image_cache.rs
···347347 ..Default::default()
348348 };
349349350350- // Create cache with build options
351351- let cache = ImageCache::with_cache_dir(&build_options.assets_cache_dir());
350350+ let cache = ImageCache::with_cache_dir(build_options.assets_cache_dir());
352351353352 // Verify it uses the configured directory
354353 assert_eq!(cache.get_cache_dir(), custom_cache.join("assets"));
+69-10
crates/maudit/src/build.rs
···2626use log::{debug, info, trace, warn};
2727use pathdiff::diff_paths;
2828use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType};
2929+use rolldown_common::Output;
2930use rolldown_plugin_replace::ReplacePlugin;
3031use rustc_hash::{FxHashMap, FxHashSet};
3132···118119 BuildState::new()
119120 };
120121122122+ debug!(target: "build", "Loaded build state with {} asset mappings", build_state.asset_to_routes.len());
123123+ debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some());
124124+121125 // Determine if this is an incremental build
122126 let is_incremental = options.incremental && changed_files.is_some() && !build_state.asset_to_routes.is_empty();
123127···143147 };
144148145149 // Check if we should rebundle during incremental builds
146146- // Only rebundle if a changed file is in the bundler inputs
150150+ // Rebundle if a changed file is either:
151151+ // 1. A direct bundler input (entry point)
152152+ // 2. A transitive dependency tracked in asset_to_routes (JS/CSS/TS files)
147153 let should_rebundle = if is_incremental && !build_state.bundler_inputs.is_empty() {
148154 let changed = changed_files.unwrap();
149155 let should = changed.iter().any(|changed_file| {
150150- build_state.bundler_inputs.iter().any(|bundler_input| {
151151- // Check if the changed file matches any bundler input
152152- // Canonicalize both paths for comparison
156156+ // Check if it's a direct bundler input
157157+ let is_bundler_input = build_state.bundler_inputs.iter().any(|bundler_input| {
153158 if let (Ok(changed_canonical), Ok(bundler_canonical)) = (
154159 changed_file.canonicalize(),
155160 PathBuf::from(bundler_input).canonicalize()
···158163 } else {
159164 false
160165 }
161161- })
166166+ });
167167+168168+ if is_bundler_input {
169169+ return true;
170170+ }
171171+172172+ // Check if it's a transitive dependency (JS/CSS/TS file in asset_to_routes)
173173+ if let Some(ext) = changed_file.extension().and_then(|e| e.to_str()) {
174174+ let is_bundleable = matches!(ext.to_lowercase().as_str(), "js" | "ts" | "jsx" | "tsx" | "css");
175175+ if is_bundleable
176176+ && let Ok(canonical) = changed_file.canonicalize() {
177177+ return build_state.asset_to_routes.contains_key(&canonical);
178178+ }
179179+ }
180180+181181+ false
162182 });
163183164184 if should {
165165- info!(target: "build", "Rebundling needed: changed file matches bundler input");
185185+ info!(target: "build", "Rebundling needed: changed file affects bundled assets");
166186 } else {
167167- info!(target: "build", "Skipping bundler: no changed files match bundler inputs");
187187+ info!(target: "build", "Skipping bundler: no changed files affect bundled assets");
168188 }
169189170190 should
···190210 };
191211192212 // Create the image cache early so it can be shared across routes
193193- let image_cache = ImageCache::with_cache_dir(&options.assets_cache_dir());
213213+ let image_cache = ImageCache::with_cache_dir(options.assets_cache_dir());
194214 let _ = fs::create_dir_all(image_cache.get_cache_dir());
195215196216 // Create route_assets_options with the image cache
···702722 ],
703723 )?;
704724705705- let _result = bundler.write().await?;
725725+ let result = bundler.write().await?;
706726707707- // TODO: Add outputted chunks to build_metadata
727727+ // Track transitive dependencies from bundler output
728728+ // For each chunk, map all its modules to the routes that use the entry point
729729+ if options.incremental {
730730+ for output in &result.assets {
731731+ if let Output::Chunk(chunk) = output {
732732+ // Get the entry point for this chunk
733733+ if let Some(facade_module_id) = &chunk.facade_module_id {
734734+ // Try to find routes using this entry point
735735+ let entry_path = PathBuf::from(facade_module_id.as_str());
736736+ let canonical_entry = entry_path.canonicalize().ok();
737737+738738+ // Look up routes for this entry point
739739+ let routes = canonical_entry
740740+ .as_ref()
741741+ .and_then(|p| build_state.asset_to_routes.get(p))
742742+ .cloned();
743743+744744+ if let Some(routes) = routes {
745745+ // Register all modules in this chunk as dependencies for those routes
746746+ let mut transitive_count = 0;
747747+ for module_id in &chunk.module_ids {
748748+ let module_path = PathBuf::from(module_id.as_str());
749749+ if let Ok(canonical_module) = module_path.canonicalize() {
750750+ // Skip the entry point itself (already tracked)
751751+ if Some(&canonical_module) != canonical_entry.as_ref() {
752752+ for route in &routes {
753753+ build_state.track_asset(canonical_module.clone(), route.clone());
754754+ }
755755+ transitive_count += 1;
756756+ }
757757+ }
758758+ }
759759+ if transitive_count > 0 {
760760+ debug!(target: "build", "Tracked {} transitive dependencies for {}", transitive_count, facade_module_id);
761761+ }
762762+ }
763763+ }
764764+ }
765765+ }
766766+ }
708767 }
709768710769 info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), §ion_format_options)).bold());
+3
e2e/fixtures/incremental-build/src/assets/main.js
···11// Main script
22+import { greet } from './utils.js';
33+24console.log('Main script loaded');
55+console.log(greet('World'));
···99 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
1010 let _image = ctx.assets.add_image("src/assets/team.png");
1111 let _script = ctx.assets.add_script("src/assets/about.js");
1212+ // Shared style with index page (for testing shared assets)
1313+ let _style = ctx.assets.add_style("src/assets/styles.css");
12141315 // Generate a unique build ID - uses nanoseconds for uniqueness
1416 let build_id = SystemTime::now()
+242-43
e2e/tests/incremental-build.spec.ts
···11import { expect } from "@playwright/test";
22import { createTestWithFixture } from "./test-utils";
33-import { readFileSync, writeFileSync } from "node:fs";
33+import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
44import { resolve, dirname } from "node:path";
55import { fileURLToPath } from "node:url";
66···8282 return match ? parseInt(match[1], 10) : -1;
8383}
84848585+/**
8686+ * Helper to set up incremental build state
8787+ */
8888+async function setupIncrementalState(
8989+ devServer: any,
9090+ triggerChange: (suffix: string) => Promise<string[]>
9191+): Promise<void> {
9292+ // First change triggers a full build (no previous state)
9393+ await triggerChange("init");
9494+ await new Promise(resolve => setTimeout(resolve, 500));
9595+9696+ // Second change should be incremental (state now exists)
9797+ const logs = await triggerChange("setup");
9898+ expect(isIncrementalBuild(logs)).toBe(true);
9999+ await new Promise(resolve => setTimeout(resolve, 500));
100100+}
101101+102102+/**
103103+ * Record build IDs for all pages
104104+ */
105105+function recordBuildIds(htmlPaths: Record<string, string>): Record<string, string | null> {
106106+ const ids: Record<string, string | null> = {};
107107+ for (const [name, path] of Object.entries(htmlPaths)) {
108108+ ids[name] = getBuildId(path);
109109+ }
110110+ return ids;
111111+}
112112+85113test.describe("Incremental Build", () => {
86114 test.setTimeout(180000);
8711588116 const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build");
8911790118 // Asset paths
9191- const blogStylesPath = resolve(fixturePath, "src", "assets", "blog.css");
119119+ const assets = {
120120+ blogCss: resolve(fixturePath, "src", "assets", "blog.css"),
121121+ utilsJs: resolve(fixturePath, "src", "assets", "utils.js"),
122122+ mainJs: resolve(fixturePath, "src", "assets", "main.js"),
123123+ aboutJs: resolve(fixturePath, "src", "assets", "about.js"),
124124+ stylesCss: resolve(fixturePath, "src", "assets", "styles.css"),
125125+ logoPng: resolve(fixturePath, "src", "assets", "logo.png"),
126126+ teamPng: resolve(fixturePath, "src", "assets", "team.png"),
127127+ };
9212893129 // Output HTML paths
94130 const htmlPaths = {
···98134 };
99135100136 // Original content storage
101101- let originalBlogStyles: string;
137137+ const originals: Record<string, string | Buffer> = {};
102138103139 test.beforeAll(async () => {
104104- originalBlogStyles = readFileSync(blogStylesPath, "utf-8");
105105- // Ensure file is in original state
106106- writeFileSync(blogStylesPath, originalBlogStyles, "utf-8");
140140+ // Store original content for all assets we might modify
141141+ originals.blogCss = readFileSync(assets.blogCss, "utf-8");
142142+ originals.utilsJs = readFileSync(assets.utilsJs, "utf-8");
143143+ originals.mainJs = readFileSync(assets.mainJs, "utf-8");
144144+ originals.aboutJs = readFileSync(assets.aboutJs, "utf-8");
145145+ originals.stylesCss = readFileSync(assets.stylesCss, "utf-8");
146146+ originals.logoPng = readFileSync(assets.logoPng); // binary
147147+ originals.teamPng = readFileSync(assets.teamPng); // binary
107148 });
108149109150 test.afterAll(async () => {
110110- // Restore original content
111111- writeFileSync(blogStylesPath, originalBlogStyles, "utf-8");
151151+ // Restore all original content
152152+ writeFileSync(assets.blogCss, originals.blogCss);
153153+ writeFileSync(assets.utilsJs, originals.utilsJs);
154154+ writeFileSync(assets.mainJs, originals.mainJs);
155155+ writeFileSync(assets.aboutJs, originals.aboutJs);
156156+ writeFileSync(assets.stylesCss, originals.stylesCss);
157157+ writeFileSync(assets.logoPng, originals.logoPng);
158158+ writeFileSync(assets.teamPng, originals.teamPng);
159159+ });
160160+161161+ // ============================================================
162162+ // TEST 1: Direct CSS dependency (blog.css → /blog only)
163163+ // ============================================================
164164+ test("CSS file change rebuilds only routes using it", async ({ devServer }) => {
165165+ let testCounter = 0;
166166+167167+ async function triggerChange(suffix: string) {
168168+ testCounter++;
169169+ devServer.clearLogs();
170170+ writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`);
171171+ return await waitForBuildComplete(devServer, 30000);
172172+ }
173173+174174+ await setupIncrementalState(devServer, triggerChange);
175175+176176+ // Record build IDs before
177177+ const before = recordBuildIds(htmlPaths);
178178+ expect(before.index).not.toBeNull();
179179+ expect(before.about).not.toBeNull();
180180+ expect(before.blog).not.toBeNull();
181181+182182+ await new Promise(resolve => setTimeout(resolve, 500));
183183+184184+ // Trigger the change
185185+ const logs = await triggerChange("final");
186186+187187+ // Verify incremental build with 1 route
188188+ expect(isIncrementalBuild(logs)).toBe(true);
189189+ expect(getAffectedRouteCount(logs)).toBe(1);
190190+191191+ // Verify only blog was rebuilt
192192+ const after = recordBuildIds(htmlPaths);
193193+ expect(after.index).toBe(before.index);
194194+ expect(after.about).toBe(before.about);
195195+ expect(after.blog).not.toBe(before.blog);
196196+ });
197197+198198+ // ============================================================
199199+ // TEST 2: Transitive JS dependency (utils.js → main.js → /)
200200+ // ============================================================
201201+ test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => {
202202+ let testCounter = 0;
203203+204204+ async function triggerChange(suffix: string) {
205205+ testCounter++;
206206+ devServer.clearLogs();
207207+ writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`);
208208+ return await waitForBuildComplete(devServer, 30000);
209209+ }
210210+211211+ await setupIncrementalState(devServer, triggerChange);
212212+213213+ const before = recordBuildIds(htmlPaths);
214214+ expect(before.index).not.toBeNull();
215215+216216+ await new Promise(resolve => setTimeout(resolve, 500));
217217+218218+ const logs = await triggerChange("final");
219219+220220+ // Verify incremental build with 1 route
221221+ expect(isIncrementalBuild(logs)).toBe(true);
222222+ expect(getAffectedRouteCount(logs)).toBe(1);
223223+224224+ // Only index should be rebuilt (uses main.js which imports utils.js)
225225+ const after = recordBuildIds(htmlPaths);
226226+ expect(after.about).toBe(before.about);
227227+ expect(after.blog).toBe(before.blog);
228228+ expect(after.index).not.toBe(before.index);
112229 });
113230114114- test("incremental builds only rebuild affected routes", async ({ devServer }) => {
231231+ // ============================================================
232232+ // TEST 3: Direct JS entry point change (about.js → /about)
233233+ // ============================================================
234234+ test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => {
115235 let testCounter = 0;
116236117237 async function triggerChange(suffix: string) {
118238 testCounter++;
119239 devServer.clearLogs();
120120- writeFileSync(blogStylesPath, originalBlogStyles + `\n/* test-${testCounter}-${suffix} */`, "utf-8");
240240+ writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`);
121241 return await waitForBuildComplete(devServer, 30000);
122242 }
123243124124- // ========================================
125125- // SETUP: Establish incremental build state
126126- // ========================================
127127- // First change triggers a full build (no previous state)
128128- await triggerChange("init");
244244+ await setupIncrementalState(devServer, triggerChange);
245245+246246+ const before = recordBuildIds(htmlPaths);
247247+ expect(before.about).not.toBeNull();
248248+129249 await new Promise(resolve => setTimeout(resolve, 500));
130250131131- // Second change should be incremental (state now exists)
132132- let logs = await triggerChange("setup");
251251+ const logs = await triggerChange("final");
252252+253253+ // Verify incremental build with 1 route
133254 expect(isIncrementalBuild(logs)).toBe(true);
255255+ expect(getAffectedRouteCount(logs)).toBe(1);
256256+257257+ // Only about should be rebuilt
258258+ const after = recordBuildIds(htmlPaths);
259259+ expect(after.index).toBe(before.index);
260260+ expect(after.blog).toBe(before.blog);
261261+ expect(after.about).not.toBe(before.about);
262262+ });
263263+264264+ // ============================================================
265265+ // TEST 4: Shared asset change (styles.css → / AND /about)
266266+ // ============================================================
267267+ test("shared asset change rebuilds all routes using it", async ({ devServer }) => {
268268+ let testCounter = 0;
269269+270270+ async function triggerChange(suffix: string) {
271271+ testCounter++;
272272+ devServer.clearLogs();
273273+ writeFileSync(assets.stylesCss, originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`);
274274+ return await waitForBuildComplete(devServer, 30000);
275275+ }
276276+277277+ await setupIncrementalState(devServer, triggerChange);
278278+279279+ const before = recordBuildIds(htmlPaths);
280280+ expect(before.index).not.toBeNull();
281281+ expect(before.about).not.toBeNull();
282282+134283 await new Promise(resolve => setTimeout(resolve, 500));
284284+285285+ const logs = await triggerChange("final");
286286+287287+ // Verify incremental build with 2 routes (/ and /about both use styles.css)
288288+ expect(isIncrementalBuild(logs)).toBe(true);
289289+ expect(getAffectedRouteCount(logs)).toBe(2);
290290+291291+ // Index and about should be rebuilt, blog should not
292292+ const after = recordBuildIds(htmlPaths);
293293+ expect(after.blog).toBe(before.blog);
294294+ expect(after.index).not.toBe(before.index);
295295+ expect(after.about).not.toBe(before.about);
296296+ });
135297136136- // ========================================
137137- // TEST: CSS file change (blog.css → only /blog)
138138- // ========================================
139139- // Record build IDs before
140140- const beforeIndex = getBuildId(htmlPaths.index);
141141- const beforeAbout = getBuildId(htmlPaths.about);
142142- const beforeBlog = getBuildId(htmlPaths.blog);
298298+ // ============================================================
299299+ // TEST 5: Image change (logo.png → /)
300300+ // ============================================================
301301+ test("image change rebuilds only routes using it", async ({ devServer }) => {
302302+ let testCounter = 0;
143303144144- expect(beforeIndex).not.toBeNull();
145145- expect(beforeAbout).not.toBeNull();
146146- expect(beforeBlog).not.toBeNull();
304304+ async function triggerChange(suffix: string) {
305305+ testCounter++;
306306+ devServer.clearLogs();
307307+ // For images, we append bytes to change the file
308308+ // This simulates modifying an image file
309309+ const modified = Buffer.concat([
310310+ originals.logoPng as Buffer,
311311+ Buffer.from(`<!-- test-${testCounter}-${suffix} -->`)
312312+ ]);
313313+ writeFileSync(assets.logoPng, modified);
314314+ return await waitForBuildComplete(devServer, 30000);
315315+ }
147316148148- // Wait a bit more to ensure clean slate
317317+ await setupIncrementalState(devServer, triggerChange);
318318+319319+ const before = recordBuildIds(htmlPaths);
320320+ expect(before.index).not.toBeNull();
321321+149322 await new Promise(resolve => setTimeout(resolve, 500));
150323151151- // Trigger the change
152152- logs = await triggerChange("final");
324324+ const logs = await triggerChange("final");
153325154154- // Verify it was an incremental build
326326+ // Verify incremental build with 1 route
155327 expect(isIncrementalBuild(logs)).toBe(true);
328328+ expect(getAffectedRouteCount(logs)).toBe(1);
156329157157- // Verify exactly 1 route was rebuilt (from logs)
158158- const routeCount = getAffectedRouteCount(logs);
159159- expect(routeCount).toBe(1);
330330+ // Only index should be rebuilt (uses logo.png)
331331+ const after = recordBuildIds(htmlPaths);
332332+ expect(after.about).toBe(before.about);
333333+ expect(after.blog).toBe(before.blog);
334334+ expect(after.index).not.toBe(before.index);
335335+ });
336336+337337+ // ============================================================
338338+ // TEST 6: Multiple files changed simultaneously
339339+ // ============================================================
340340+ test("multiple file changes rebuild union of affected routes", async ({ devServer }) => {
341341+ let testCounter = 0;
342342+343343+ async function triggerChange(suffix: string) {
344344+ testCounter++;
345345+ devServer.clearLogs();
346346+ // Change both blog.css (affects /blog) and about.js (affects /about)
347347+ writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`);
348348+ writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`);
349349+ return await waitForBuildComplete(devServer, 30000);
350350+ }
160351161161- // Verify build IDs: only blog should have changed
162162- const afterIndex = getBuildId(htmlPaths.index);
163163- const afterAbout = getBuildId(htmlPaths.about);
164164- const afterBlog = getBuildId(htmlPaths.blog);
352352+ await setupIncrementalState(devServer, triggerChange);
353353+354354+ const before = recordBuildIds(htmlPaths);
355355+ expect(before.about).not.toBeNull();
356356+ expect(before.blog).not.toBeNull();
357357+358358+ await new Promise(resolve => setTimeout(resolve, 500));
359359+360360+ const logs = await triggerChange("final");
165361166166- // Index and about should NOT have been rebuilt
167167- expect(afterIndex).toBe(beforeIndex);
168168- expect(afterAbout).toBe(beforeAbout);
362362+ // Verify incremental build with 2 routes (/about and /blog)
363363+ expect(isIncrementalBuild(logs)).toBe(true);
364364+ expect(getAffectedRouteCount(logs)).toBe(2);
169365170170- // Blog SHOULD have been rebuilt
171171- expect(afterBlog).not.toBe(beforeBlog);
366366+ // About and blog should be rebuilt, index should not
367367+ const after = recordBuildIds(htmlPaths);
368368+ expect(after.index).toBe(before.index);
369369+ expect(after.about).not.toBe(before.about);
370370+ expect(after.blog).not.toBe(before.blog);
172371 });
173372});
+37-3
e2e/tests/test-utils.ts
···170170// Key format: "workerIndex-fixtureName"
171171const workerServers = new Map<string, DevServer>();
172172173173+// Track used ports to avoid collisions
174174+const usedPorts = new Set<number>();
175175+176176+/**
177177+ * Generate a deterministic port offset based on fixture name.
178178+ * This ensures each fixture gets a unique port range, avoiding collisions
179179+ * when multiple fixtures run on the same worker.
180180+ */
181181+function getFixturePortOffset(fixtureName: string): number {
182182+ // Simple hash function to get a number from the fixture name
183183+ let hash = 0;
184184+ for (let i = 0; i < fixtureName.length; i++) {
185185+ const char = fixtureName.charCodeAt(i);
186186+ hash = ((hash << 5) - hash) + char;
187187+ hash = hash & hash; // Convert to 32bit integer
188188+ }
189189+ // Use modulo to keep the offset reasonable (0-99)
190190+ return Math.abs(hash) % 100;
191191+}
192192+193193+/**
194194+ * Find an available port starting from the preferred port.
195195+ */
196196+function findAvailablePort(preferredPort: number): number {
197197+ let port = preferredPort;
198198+ while (usedPorts.has(port)) {
199199+ port++;
200200+ }
201201+ usedPorts.add(port);
202202+ return port;
203203+}
204204+173205/**
174206 * Create a test instance with a devServer fixture for a specific fixture.
175207 * This allows each test file to use a different fixture while sharing the same pattern.
176208 *
177209 * @param fixtureName - Name of the fixture directory under e2e/fixtures/
178178- * @param basePort - Starting port number (default: 1864). Each worker gets basePort + workerIndex
210210+ * @param basePort - Starting port number (default: 1864). Each fixture gets a unique port based on its name.
179211 *
180212 * @example
181213 * ```ts
···198230 let server = workerServers.get(serverKey);
199231200232 if (!server) {
201201- // Assign unique port based on worker index
202202- const port = basePort + workerIndex;
233233+ // Calculate port based on fixture name hash + worker index to avoid collisions
234234+ const fixtureOffset = getFixturePortOffset(fixtureName);
235235+ const preferredPort = basePort + (workerIndex * 100) + fixtureOffset;
236236+ const port = findAvailablePort(preferredPort);
203237204238 server = await startDevServer({
205239 fixture: fixtureName,