···15 // is spent in bundling, including the script in pages, etc. instead of that. It's still neat to see how much overhead prefetching adds,
16 // but not really in this benchmark.
17 strategy: PrefetchStrategy::None,
018 },
19 ..Default::default()
20 },
···15 // is spent in bundling, including the script in pages, etc. instead of that. It's still neat to see how much overhead prefetching adds,
16 // but not really in this benchmark.
17 strategy: PrefetchStrategy::None,
18+ ..Default::default()
19 },
20 ..Default::default()
21 },
+1
benchmarks/overhead/src/lib.rs
···10 // This benchmark is really about testing Maudit's overhead, if we enable prefetching then a lot of time
11 // is spent in bundling, including the script in pages, etc. instead of Maudit itself.
12 strategy: PrefetchStrategy::None,
013 },
14 ..Default::default()
15 },
···10 // This benchmark is really about testing Maudit's overhead, if we enable prefetching then a lot of time
11 // is spent in bundling, including the script in pages, etc. instead of Maudit itself.
12 strategy: PrefetchStrategy::None,
13+ ..Default::default()
14 },
15 ..Default::default()
16 },
+2
crates/maudit-cli/src/consts.rs
···00
···1+/// Default port used by the development web server.
2+pub const PORT: u16 = 1864;
+44-10
crates/maudit-cli/src/dev.rs
···12use server::WebSocketMessage;
13use std::{fs, path::Path};
14use tokio::{
015 sync::{broadcast, mpsc::channel},
16 task::JoinHandle,
17};
···1920use crate::dev::build::BuildManager;
2122-pub async fn start_dev_env(cwd: &str, host: bool) -> Result<(), Box<dyn std::error::Error>> {
23 let start_time = Instant::now();
24 info!(name: "dev", "Preparing dev environment…");
25···74 start_time,
75 sender_websocket.clone(),
76 host,
077 None,
78 build_manager.current_status(),
79 )));
···147 start_time,
148 sender_websocket_watcher.clone(),
149 host,
0150 None,
151 build_manager_watcher.current_status(),
152 )));
···196 }
197 });
198199- // Wait for either the web server or the file watcher to finish
200- if let Some(web_server) = web_server_thread {
201- tokio::select! {
202- _ = web_server => {},
203- _ = file_watcher_task => {},
204 }
205- } else {
206- // No web server started yet, just wait for file watcher
207- // If it started the web server, it'll also close itself if the web server ends
208- file_watcher_task.await.unwrap();
00000000209 }
210 Ok(())
211}
···248249 true
250}
000000000000000000000000
···12use server::WebSocketMessage;
13use std::{fs, path::Path};
14use tokio::{
15+ signal,
16 sync::{broadcast, mpsc::channel},
17 task::JoinHandle,
18};
···2021use crate::dev::build::BuildManager;
2223+pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> {
24 let start_time = Instant::now();
25 info!(name: "dev", "Preparing dev environment…");
26···75 start_time,
76 sender_websocket.clone(),
77 host,
78+ port,
79 None,
80 build_manager.current_status(),
81 )));
···149 start_time,
150 sender_websocket_watcher.clone(),
151 host,
152+ port,
153 None,
154 build_manager_watcher.current_status(),
155 )));
···199 }
200 });
201202+ // Wait for either the web server, file watcher, or shutdown signal
203+ tokio::select! {
204+ _ = shutdown_signal() => {
205+ info!(name: "dev", "Shutting down dev environment...");
0206 }
207+ _ = async {
208+ if let Some(web_server) = web_server_thread {
209+ tokio::select! {
210+ _ = web_server => {},
211+ _ = file_watcher_task => {},
212+ }
213+ } else {
214+ // No web server started yet, just wait for file watcher
215+ // If it started the web server, it'll also close itself if the web server ends
216+ file_watcher_task.await.unwrap();
217+ }
218+ } => {}
219 }
220 Ok(())
221}
···258259 true
260}
261+262+async fn shutdown_signal() {
263+ let ctrl_c = async {
264+ signal::ctrl_c()
265+ .await
266+ .expect("failed to install Ctrl+C handler");
267+ };
268+269+ #[cfg(unix)]
270+ let terminate = async {
271+ signal::unix::signal(signal::unix::SignalKind::terminate())
272+ .expect("failed to install signal handler")
273+ .recv()
274+ .await;
275+ };
276+277+ #[cfg(not(unix))]
278+ let terminate = std::future::pending::<()>();
279+280+ tokio::select! {
281+ _ = ctrl_c => {},
282+ _ = terminate => {},
283+ }
284+}
···1+# E2E Tests
2+3+End-to-end tests for Maudit using Playwright.
4+5+## Setup
6+7+```bash
8+cd e2e
9+pnpm install
10+npx playwright install
11+```
12+13+## Running Tests
14+15+The tests will automatically:
16+1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`)
17+2. Start the Maudit dev server on the test fixture site
18+3. Run the tests
19+20+```bash
21+# Run all tests
22+pnpm test
23+24+# Run tests in UI mode
25+pnpm test:ui
26+27+# Run tests in debug mode
28+pnpm test:debug
29+30+# Run tests with browser visible
31+pnpm test:headed
32+33+# Run tests only on Chromium (for Speculation Rules tests)
34+pnpm test:chromium
35+36+# Show test report
37+pnpm report
38+```
39+40+## Test Structure
41+42+- `fixtures/test-site/` - Simple Maudit site used for testing
43+- `tests/prefetch.spec.ts` - Tests for basic prefetch functionality
44+- `tests/prerender.spec.ts` - Tests for Speculation Rules prerendering
45+46+## Features Tested
47+48+### Basic Prefetch
49+- Creating link elements with `rel="prefetch"`
50+- Preventing duplicate prefetches
51+- Skipping current page prefetch
52+- Blocking cross-origin prefetches
53+54+### Prerendering (Chromium only)
55+- Creating `<script type="speculationrules">` elements
56+- Different eagerness levels (immediate, eager, moderate, conservative)
57+- Fallback to link prefetch on non-Chromium browsers
58+- Multiple URL prerendering
59+60+## Notes
61+62+- Speculation Rules API tests only run on Chromium (Chrome/Edge 109+)
63+- The test server runs on `http://127.0.0.1:3456`
64+- Tests automatically skip unsupported features on different browsers
···1+import { spawn, execFile, type ChildProcess } from "node:child_process";
2+import { join, resolve, dirname } from "node:path";
3+import { existsSync } from "node:fs";
4+import { fileURLToPath } from "node:url";
5+import { test as base } from "@playwright/test";
6+7+const __filename = fileURLToPath(import.meta.url);
8+const __dirname = dirname(__filename);
9+10+export interface DevServerOptions {
11+ /** Path to the fixture directory relative to e2e/fixtures/ */
12+ fixture: string;
13+ /** Port to run the server on (default: auto-find) */
14+ port?: number;
15+ /** Additional CLI flags to pass to maudit dev */
16+ flags?: string[];
17+}
18+19+export interface DevServer {
20+ /** Base URL of the dev server */
21+ url: string;
22+ /** Port the server is running on */
23+ port: number;
24+ /** Stop the dev server */
25+ stop: () => Promise<void>;
26+}
27+28+/**
29+ * Start a maudit dev server for testing.
30+ */
31+export async function startDevServer(options: DevServerOptions): Promise<DevServer> {
32+ // Use __dirname (test file location) to reliably find paths
33+ const e2eRoot = resolve(__dirname, "..");
34+ const fixturePath = resolve(e2eRoot, "fixtures", options.fixture);
35+ const flags = options.flags || [];
36+ const command = resolve(e2eRoot, "..", "target", "debug", "maudit");
37+38+ // Verify the binary exists
39+ if (!existsSync(command)) {
40+ throw new Error(
41+ `Maudit binary not found at: ${command}. Please build it with 'cargo build --bin maudit'`,
42+ );
43+ }
44+45+ // Build args array
46+ const args = ["dev", ...flags];
47+ if (options.port) {
48+ args.push("--port", options.port.toString());
49+ }
50+51+ // Start the dev server process
52+ const childProcess = spawn(command, args, {
53+ cwd: fixturePath,
54+ stdio: ["ignore", "pipe", "pipe"],
55+ });
56+57+ // Capture output to detect when server is ready
58+ let serverReady = false;
59+60+ const outputPromise = new Promise<number>((resolve, reject) => {
61+ const timeout = setTimeout(() => {
62+ reject(new Error("Dev server did not start within 30 seconds"));
63+ }, 30000);
64+65+ childProcess.stdout?.on("data", (data: Buffer) => {
66+ const output = data.toString();
67+68+ // Look for "waiting for requests" to know server is ready
69+ if (output.includes("waiting for requests")) {
70+ serverReady = true;
71+ clearTimeout(timeout);
72+ // We already know the port from options, so just resolve with it
73+ resolve(options.port || 1864);
74+ }
75+ });
76+77+ childProcess.stderr?.on("data", (data: Buffer) => {
78+ // Only log errors, not all stderr output
79+ const output = data.toString();
80+ if (output.toLowerCase().includes("error")) {
81+ console.error(`[maudit dev] ${output}`);
82+ }
83+ });
84+85+ childProcess.on("error", (error) => {
86+ clearTimeout(timeout);
87+ reject(new Error(`Failed to start dev server: ${error.message}`));
88+ });
89+90+ childProcess.on("exit", (code) => {
91+ if (!serverReady) {
92+ clearTimeout(timeout);
93+ reject(new Error(`Dev server exited with code ${code} before becoming ready`));
94+ }
95+ });
96+ });
97+98+ const port = await outputPromise;
99+100+ return {
101+ url: `http://127.0.0.1:${port}`,
102+ port,
103+ stop: async () => {
104+ return new Promise((resolve) => {
105+ childProcess.on("exit", () => resolve());
106+ childProcess.kill("SIGTERM");
107+108+ // Force kill after 5 seconds if it doesn't stop gracefully
109+ setTimeout(() => {
110+ if (!childProcess.killed) {
111+ childProcess.kill("SIGKILL");
112+ }
113+ }, 5000);
114+ });
115+ },
116+ };
117+}
118+119+/**
120+ * Helper to manage multiple dev servers in tests.
121+ * Automatically cleans up servers when tests finish.
122+ */
123+export class DevServerPool {
124+ private servers: DevServer[] = [];
125+126+ async start(options: DevServerOptions): Promise<DevServer> {
127+ const server = await startDevServer(options);
128+ this.servers.push(server);
129+ return server;
130+ }
131+132+ async stopAll(): Promise<void> {
133+ await Promise.all(this.servers.map((server) => server.stop()));
134+ this.servers = [];
135+ }
136+}
137+138+// Worker-scoped server pool - one server per worker, shared across all tests in that worker
139+const workerServers = new Map<number, DevServer>();
140+141+// Extend Playwright's test with a devServer fixture
142+export const test = base.extend<{ devServer: DevServer }>({
143+ devServer: async ({}, use, testInfo) => {
144+ // Use worker index to get or create a server for this worker
145+ const workerIndex = testInfo.workerIndex;
146+147+ let server = workerServers.get(workerIndex);
148+149+ if (!server) {
150+ // Assign unique port based on worker index
151+ const port = 1864 + workerIndex;
152+153+ server = await startDevServer({
154+ fixture: "prefetch-prerender",
155+ port,
156+ });
157+158+ workerServers.set(workerIndex, server);
159+ }
160+161+ await use(server);
162+163+ // Don't stop the server here - it stays alive for all tests in this worker
164+ // Playwright will clean up when the worker exits
165+ },
166+});
167+168+export { expect } from "@playwright/test";
+25
e2e/tests/utils.ts
···0000000000000000000000000
···1+import { readFileSync, readdirSync } from "node:fs";
2+import { join } from "node:path";
3+4+// Find the actual prefetch bundle file (hash changes on each build)
5+const distDir = join(process.cwd(), "../crates/maudit/js/dist");
6+const prefetchFile = readdirSync(distDir).find(
7+ (f) => f.startsWith("prefetch-") && f.endsWith(".js"),
8+);
9+if (!prefetchFile) throw new Error("Could not find prefetch bundle");
10+11+// Read the bundled prefetch script
12+const prefetchBundled = readFileSync(join(distDir, prefetchFile), "utf-8");
13+14+// Extract the internal function name from export{X as prefetch}
15+const exportMatch = prefetchBundled.match(/export\{(\w+) as prefetch\}/);
16+if (!exportMatch) throw new Error("Could not parse prefetch export");
17+const internalName = exportMatch[1];
18+19+// Remove export and expose on window
20+export const prefetchScript = `
21+ (function() {
22+ ${prefetchBundled.replace(/export\{.*\};?$/, "")}
23+ window.prefetch = ${internalName};
24+ })();
25+`;
···1112## Configuration
1314-Prefetching can be configured using the `prefetch` property of [`BuildOptions`](https://docs.rs/maudit/latest/maudit/struct.BuildOptions.html) which takes a [`PrefetchOptions`](https://docs.rs/maudit/latest/maudit/struct.PrefetchOptions.html) struct. Currently, the only option is `strategy`.
1516```rs
17use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy};
···19BuildOptions {
20 prefetch: PrefetchOptions {
21 strategy: PrefetchStrategy::Hover,
022 },
23 ..Default.default()
24}
25```
2627To disable prefetching, set `strategy` to [`PrefetchStrategy::None`](https://docs.rs/maudit/latest/maudit/enum.PrefetchStrategy.html#variant.None).
0000000000000000000000000000
···1112## Configuration
1314+Prefetching can be configured using the `prefetch` property of [`BuildOptions`](https://docs.rs/maudit/latest/maudit/struct.BuildOptions.html) which takes a [`PrefetchOptions`](https://docs.rs/maudit/latest/maudit/struct.PrefetchOptions.html) struct.
1516```rs
17use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy};
···19BuildOptions {
20 prefetch: PrefetchOptions {
21 strategy: PrefetchStrategy::Hover,
22+ ..Default::default()
23 },
24 ..Default.default()
25}
26```
2728To disable prefetching, set `strategy` to [`PrefetchStrategy::None`](https://docs.rs/maudit/latest/maudit/enum.PrefetchStrategy.html#variant.None).
29+30+## Using the speculation rules API
31+32+Maudit will automatically uses the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API) to prefetch instead of `<link rel="prefetch">` tags when supported by the browser.
33+34+### Prerendering
35+36+By enabling `PrefetchOptions.prerender`, Maudit will also prerender your prefetched pages using the Speculation Rules API.
37+38+```rs
39+use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy};
40+41+BuildOptions {
42+ prefetch: PrefetchOptions {
43+ prerender: true,
44+ ..Default::default()
45+ },
46+ ..Default.default()
47+}
48+```
49+50+Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views.
51+52+## Possible risks
53+54+Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening.
55+56+Additionally, the performance improvements provided by prefetching will, in the vast majority of cases, trump any possible resource wastage (of which the potential is low in the first place).