···1515 // is spent in bundling, including the script in pages, etc. instead of that. It's still neat to see how much overhead prefetching adds,
1616 // but not really in this benchmark.
1717 strategy: PrefetchStrategy::None,
1818+ ..Default::default()
1819 },
1920 ..Default::default()
2021 },
+1
benchmarks/overhead/src/lib.rs
···1010 // This benchmark is really about testing Maudit's overhead, if we enable prefetching then a lot of time
1111 // is spent in bundling, including the script in pages, etc. instead of Maudit itself.
1212 strategy: PrefetchStrategy::None,
1313+ ..Default::default()
1314 },
1415 ..Default::default()
1516 },
+2
crates/maudit-cli/src/consts.rs
···11+/// Default port used by the development web server.
22+pub const PORT: u16 = 1864;
+44-10
crates/maudit-cli/src/dev.rs
···1212use server::WebSocketMessage;
1313use std::{fs, path::Path};
1414use tokio::{
1515+ signal,
1516 sync::{broadcast, mpsc::channel},
1617 task::JoinHandle,
1718};
···19202021use crate::dev::build::BuildManager;
21222222-pub async fn start_dev_env(cwd: &str, host: bool) -> Result<(), Box<dyn std::error::Error>> {
2323+pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> {
2324 let start_time = Instant::now();
2425 info!(name: "dev", "Preparing dev environment…");
2526···7475 start_time,
7576 sender_websocket.clone(),
7677 host,
7878+ port,
7779 None,
7880 build_manager.current_status(),
7981 )));
···147149 start_time,
148150 sender_websocket_watcher.clone(),
149151 host,
152152+ port,
150153 None,
151154 build_manager_watcher.current_status(),
152155 )));
···196199 }
197200 });
198201199199- // Wait for either the web server or the file watcher to finish
200200- if let Some(web_server) = web_server_thread {
201201- tokio::select! {
202202- _ = web_server => {},
203203- _ = file_watcher_task => {},
202202+ // Wait for either the web server, file watcher, or shutdown signal
203203+ tokio::select! {
204204+ _ = shutdown_signal() => {
205205+ info!(name: "dev", "Shutting down dev environment...");
204206 }
205205- } else {
206206- // No web server started yet, just wait for file watcher
207207- // If it started the web server, it'll also close itself if the web server ends
208208- file_watcher_task.await.unwrap();
207207+ _ = async {
208208+ if let Some(web_server) = web_server_thread {
209209+ tokio::select! {
210210+ _ = web_server => {},
211211+ _ = file_watcher_task => {},
212212+ }
213213+ } else {
214214+ // No web server started yet, just wait for file watcher
215215+ // If it started the web server, it'll also close itself if the web server ends
216216+ file_watcher_task.await.unwrap();
217217+ }
218218+ } => {}
209219 }
210220 Ok(())
211221}
···248258249259 true
250260}
261261+262262+async fn shutdown_signal() {
263263+ let ctrl_c = async {
264264+ signal::ctrl_c()
265265+ .await
266266+ .expect("failed to install Ctrl+C handler");
267267+ };
268268+269269+ #[cfg(unix)]
270270+ let terminate = async {
271271+ signal::unix::signal(signal::unix::SignalKind::terminate())
272272+ .expect("failed to install signal handler")
273273+ .recv()
274274+ .await;
275275+ };
276276+277277+ #[cfg(not(unix))]
278278+ let terminate = std::future::pending::<()>();
279279+280280+ tokio::select! {
281281+ _ = ctrl_c => {},
282282+ _ = terminate => {},
283283+ }
284284+}
+7-2
crates/maudit-cli/src/dev/server.rs
···2929use axum::extract::connect_info::ConnectInfo;
3030use futures::{SinkExt, stream::StreamExt};
31313232+use crate::consts::PORT;
3233use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start};
3334use axum::http::header;
3435use local_ip_address::local_ip;
···9495 start_time: Instant,
9596 tx: broadcast::Sender<WebSocketMessage>,
9697 host: bool,
9898+ port: Option<u16>,
9799 initial_error: Option<String>,
98100 current_status: Arc<RwLock<Option<PersistentStatus>>>,
99101) {
···131133 } else {
132134 IpAddr::from([127, 0, 0, 1])
133135 };
134134- let port = find_open_port(&addr, 1864).await;
136136+137137+ // Use provided port or default to the constant PORT
138138+ let starting_port = port.unwrap_or(PORT);
139139+140140+ let port = find_open_port(&addr, starting_port).await;
135141 let socket = TcpSocket::new_v4().unwrap();
136142 let _ = socket.set_reuseaddr(true);
137137- let _ = socket.set_reuseport(true);
138143139144 let socket_addr = SocketAddr::new(addr, port);
140145 socket.bind(socket_addr).unwrap();
+8-2
crates/maudit-cli/src/main.rs
···33mod init;
44mod preview;
5566+mod consts;
77+68mod logging;
79mod server_utils;
810···3335 Dev {
3436 #[clap(long)]
3537 host: bool,
3838+3939+ /// Port to run the dev server on
4040+ #[clap(long, short, default_value_t = crate::consts::PORT)]
4141+ port: u16,
3642 },
3743 /// Preview the project
3844 Preview {
···67736874 let _ = start_preview_web_server(PathBuf::from("dist"), *host).await;
6975 }
7070- Commands::Dev { host } => {
7676+ Commands::Dev { host, port } => {
7177 // TODO: cwd should be configurable, ex: --root <path>
7272- let _ = start_dev_env(".", *host).await;
7878+ let _ = start_dev_env(".", *host, Some(*port)).await;
7379 }
7480 }
7581}
+2-1
crates/maudit-cli/src/preview/server.rs
···1616 trace::{DefaultMakeSpan, TraceLayer},
1717};
18181919+use crate::consts::PORT;
1920use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start};
20212122pub async fn start_preview_web_server(dist_dir: PathBuf, host: bool) {
···4243 IpAddr::from([127, 0, 0, 1])
4344 };
44454545- let port = find_open_port(&addr, 1864).await;
4646+ let port = find_open_port(&addr, PORT).await;
4647 let socket = TcpSocket::new_v4().unwrap();
4748 let _ = socket.set_reuseaddr(true);
4849 let _ = socket.set_reuseport(true);
+4-5
crates/maudit-macros/src/lib.rs
···247247 }
248248 }
249249 LocaleKind::Prefix(prefix) => {
250250- if args.path.is_none() {
251251- // Emit compile error if prefix is used without base path
250250+ if let Some(base_path) = args.path.as_ref() {
252251 quote! {
253253- compile_error!("Cannot use locale prefix without a base route path")
252252+ (#locale_name.to_string(), format!("{}{}", #prefix, #base_path))
254253 }
255254 } else {
256256- let base_path = args.path.as_ref().unwrap();
255255+ // Emit compile error if prefix is used without base path
257256 quote! {
258258- (#locale_name.to_string(), format!("{}{}", #prefix, #base_path))
257257+ compile_error!("Cannot use locale prefix without a base route path")
259258 }
260259 }
261260 }
+74-2
crates/maudit/js/prefetch.ts
···2233interface PreloadConfig {
44 skipConnectionCheck?: boolean;
55+ /**
66+ * Enable prerendering using Speculation Rules API if supported.
77+ * Falls back to prefetch if not supported. (default: false)
88+ */
99+ prerender?: boolean;
1010+ /**
1111+ * Hint to the browser as to how eagerly it should prefetch/prerender.
1212+ * Only works when browser supports Speculation Rules API.
1313+ * (default: 'immediate')
1414+ *
1515+ * - 'immediate': Prefetch/prerender as soon as possible
1616+ * - 'eager': Prefetch/prerender eagerly but not immediately
1717+ * - 'moderate': Prefetch/prerender with moderate eagerness
1818+ * - 'conservative': Prefetch/prerender conservatively
1919+ */
2020+ eagerness?: "immediate" | "eager" | "moderate" | "conservative";
521}
622723export function prefetch(url: string, config?: PreloadConfig) {
···1430 }
15311632 const skipConnectionCheck = config?.skipConnectionCheck ?? false;
3333+ const shouldPrerender = config?.prerender ?? false;
3434+ const eagerness = config?.eagerness ?? "immediate";
17351836 if (!canPrefetchUrl(urlObj, skipConnectionCheck)) {
1937 return;
2038 }
21394040+ preloadedUrls.add(urlObj.href);
4141+4242+ // Calculate relative path once (pathname + search, no origin)
4343+ const path = urlObj.pathname + urlObj.search;
4444+4545+ // Use Speculation Rules API when supported
4646+ if (HTMLScriptElement.supports && HTMLScriptElement.supports("speculationrules")) {
4747+ appendSpeculationRules(path, eagerness, shouldPrerender);
4848+ return;
4949+ }
5050+5151+ // Fallback to link prefetch for other browsers
2252 const linkElement = document.createElement("link");
2353 const supportsPrefetch = linkElement.relList?.supports?.("prefetch");
24542555 if (supportsPrefetch) {
2656 linkElement.rel = "prefetch";
2727- linkElement.href = url;
5757+ linkElement.href = path;
2858 document.head.appendChild(linkElement);
2929- preloadedUrls.add(urlObj.href);
3059 }
3160}
3261···51805281 return false;
5382}
8383+8484+/**
8585+ * Appends a <script type="speculationrules"> tag to prefetch or prerender the URL.
8686+ *
8787+ * Note: Each URL needs its own script element - modifying an existing
8888+ * script won't trigger a new prerender/prefetch.
8989+ *
9090+ * @param path - The relative path (pathname + search) to prefetch/prerender
9191+ * @param eagerness - How eagerly the browser should prefetch/prerender
9292+ * @param prerender - Whether to include a prerender rule
9393+ */
9494+function appendSpeculationRules(
9595+ path: string,
9696+ eagerness: NonNullable<PreloadConfig["eagerness"]>,
9797+ prerender: boolean,
9898+) {
9999+ const script = document.createElement("script");
100100+ script.type = "speculationrules";
101101+102102+ // We always want the prefetch, even if prerendering as a fallback
103103+ const rules: any = {
104104+ prefetch: [
105105+ {
106106+ source: "list",
107107+ urls: [path],
108108+ eagerness,
109109+ },
110110+ ],
111111+ };
112112+113113+ if (prerender) {
114114+ rules.prerender = [
115115+ {
116116+ source: "list",
117117+ urls: [path],
118118+ eagerness,
119119+ },
120120+ ];
121121+ }
122122+123123+ script.textContent = JSON.stringify(rules);
124124+ document.head.appendChild(script);
125125+}
+20
crates/maudit/src/build/options.rs
···4141/// },
4242/// prefetch: PrefetchOptions {
4343/// strategy: PrefetchStrategy::Viewport,
4444+/// ..Default::default()
4445/// },
4546/// ..Default::default()
4647/// },
···8081 Viewport,
8182}
82838484+#[derive(Clone, Copy, PartialEq, Eq)]
8585+pub enum PrerenderEagerness {
8686+ /// Prerender as soon as possible
8787+ Immediate,
8888+ /// Prerender eagerly but not immediately
8989+ Eager,
9090+ /// Prerender with moderate eagerness
9191+ Moderate,
9292+ /// Prerender conservatively
9393+ Conservative,
9494+}
9595+8396#[derive(Clone)]
8497pub struct PrefetchOptions {
8598 /// The prefetch strategy to use
8699 pub strategy: PrefetchStrategy,
100100+ /// Enable prerendering using Speculation Rules API if supported.
101101+ pub prerender: bool,
102102+ /// Hint to the browser as to how eagerly it should prefetch/prerender.
103103+ /// Only works when prerender is enabled and browser supports Speculation Rules API.
104104+ pub eagerness: PrerenderEagerness,
87105}
8810689107impl Default for PrefetchOptions {
90108 fn default() -> Self {
91109 Self {
92110 strategy: PrefetchStrategy::Tap,
111111+ prerender: false,
112112+ eagerness: PrerenderEagerness::Immediate,
93113 }
94114 }
95115}
···11+# E2E Tests
22+33+End-to-end tests for Maudit using Playwright.
44+55+## Setup
66+77+```bash
88+cd e2e
99+pnpm install
1010+npx playwright install
1111+```
1212+1313+## Running Tests
1414+1515+The tests will automatically:
1616+1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`)
1717+2. Start the Maudit dev server on the test fixture site
1818+3. Run the tests
1919+2020+```bash
2121+# Run all tests
2222+pnpm test
2323+2424+# Run tests in UI mode
2525+pnpm test:ui
2626+2727+# Run tests in debug mode
2828+pnpm test:debug
2929+3030+# Run tests with browser visible
3131+pnpm test:headed
3232+3333+# Run tests only on Chromium (for Speculation Rules tests)
3434+pnpm test:chromium
3535+3636+# Show test report
3737+pnpm report
3838+```
3939+4040+## Test Structure
4141+4242+- `fixtures/test-site/` - Simple Maudit site used for testing
4343+- `tests/prefetch.spec.ts` - Tests for basic prefetch functionality
4444+- `tests/prerender.spec.ts` - Tests for Speculation Rules prerendering
4545+4646+## Features Tested
4747+4848+### Basic Prefetch
4949+- Creating link elements with `rel="prefetch"`
5050+- Preventing duplicate prefetches
5151+- Skipping current page prefetch
5252+- Blocking cross-origin prefetches
5353+5454+### Prerendering (Chromium only)
5555+- Creating `<script type="speculationrules">` elements
5656+- Different eagerness levels (immediate, eager, moderate, conservative)
5757+- Fallback to link prefetch on non-Chromium browsers
5858+- Multiple URL prerendering
5959+6060+## Notes
6161+6262+- Speculation Rules API tests only run on Chromium (Chrome/Edge 109+)
6363+- The test server runs on `http://127.0.0.1:3456`
6464+- Tests automatically skip unsupported features on different browsers
···11+import { defineConfig, devices } from "@playwright/test";
22+33+/**
44+ * See https://playwright.dev/docs/test-configuration.
55+ */
66+export default defineConfig({
77+ testDir: "./tests",
88+ /* Run tests in files in parallel */
99+ fullyParallel: true,
1010+ /* Fail the build on CI if you accidentally left test.only in the source code. */
1111+ forbidOnly: !!process.env.CI,
1212+ /* Retry on CI only */
1313+ retries: process.env.CI ? 2 : 0,
1414+ /* Opt out of parallel tests on CI. */
1515+ workers: process.env.CI ? 1 : undefined,
1616+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
1717+ reporter: "html",
1818+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
1919+ use: {
2020+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
2121+ trace: "on-first-retry",
2222+ },
2323+2424+ /* Configure projects for major browsers */
2525+ projects: [
2626+ {
2727+ name: "chromium",
2828+ use: { ...devices["Desktop Chrome"] },
2929+ },
3030+ ],
3131+});
+153
e2e/tests/prefetch.spec.ts
···11+import { test, expect } from "./test-utils";
22+import { prefetchScript } from "./utils";
33+44+test.describe("Prefetch", () => {
55+ test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
66+ page,
77+ browserName,
88+ devServer,
99+ }) => {
1010+ await page.goto(devServer.url);
1111+1212+ // Inject prefetch function
1313+ await page.addScriptTag({ content: prefetchScript });
1414+1515+ // Call prefetch
1616+ await page.evaluate(() => {
1717+ window.prefetch("/about/");
1818+ });
1919+2020+ if (browserName === "chromium") {
2121+ // Chromium: Should create a speculation rules script with prefetch
2222+ const speculationScript = page.locator('script[type="speculationrules"]').first();
2323+ const scriptContent = await speculationScript.textContent();
2424+ expect(scriptContent).toBeTruthy();
2525+ if (scriptContent) {
2626+ const rules = JSON.parse(scriptContent);
2727+ expect(rules.prefetch).toBeDefined();
2828+ expect(rules.prefetch[0].urls).toContain("/about/");
2929+ }
3030+ } else {
3131+ // Non-Chromium: If link prefetch is supported, assert link element; otherwise, ensure no speculation script
3232+ const supportsPrefetch = await page.evaluate(() => {
3333+ const link = document.createElement("link");
3434+ // Some browsers may not support relList.supports('prefetch')
3535+ return !!(link.relList && link.relList.supports && link.relList.supports("prefetch"));
3636+ });
3737+3838+ if (supportsPrefetch) {
3939+ const prefetchLink = page.locator('link[rel="prefetch"]').first();
4040+ await expect(prefetchLink).toHaveAttribute("href", "/about/");
4141+ } else {
4242+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
4343+ expect(speculationScripts.length).toBe(0);
4444+ }
4545+ }
4646+ });
4747+4848+ test("should not prefetch same URL twice", async ({ page, browserName, devServer }) => {
4949+ await page.goto(devServer.url);
5050+5151+ await page.addScriptTag({ content: prefetchScript });
5252+5353+ // Call prefetch twice
5454+ await page.evaluate(() => {
5555+ window.prefetch("/about/");
5656+ window.prefetch("/about/");
5757+ });
5858+5959+ if (browserName === "chromium") {
6060+ // Should only have one speculation rules script
6161+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
6262+ expect(speculationScripts.length).toBe(1);
6363+ const scriptContent = await speculationScripts[0].textContent();
6464+ if (scriptContent) {
6565+ const rules = JSON.parse(scriptContent);
6666+ expect(rules.prefetch).toBeDefined();
6767+ expect(rules.prefetch[0].urls).toContain("/about/");
6868+ }
6969+ } else {
7070+ // Non-Chromium: If link prefetch is supported, expect one link; otherwise, expect no speculation script
7171+ const supportsPrefetch = await page.evaluate(() => {
7272+ const link = document.createElement("link");
7373+ return !!(link.relList && link.relList.supports && link.relList.supports("prefetch"));
7474+ });
7575+7676+ if (supportsPrefetch) {
7777+ const prefetchLinks = await page.locator('link[rel="prefetch"]').all();
7878+ expect(prefetchLinks.length).toBe(1);
7979+ } else {
8080+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
8181+ expect(speculationScripts.length).toBe(0);
8282+ }
8383+ }
8484+ });
8585+8686+ test("should not prefetch current page", async ({ page, browserName, devServer }) => {
8787+ await page.goto(`${devServer.url}/about/`);
8888+8989+ await page.addScriptTag({ content: prefetchScript });
9090+9191+ // Try to prefetch current page
9292+ await page.evaluate(() => {
9393+ window.prefetch("/about/");
9494+ });
9595+9696+ if (browserName === "chromium") {
9797+ // Should not create any speculation rules script
9898+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
9999+ expect(speculationScripts.length).toBe(0);
100100+ } else {
101101+ // Should not create any link element
102102+ const prefetchLinks = await page.locator('link[rel="prefetch"]').all();
103103+ expect(prefetchLinks.length).toBe(0);
104104+ }
105105+ });
106106+107107+ test("should not prefetch cross-origin URLs", async ({ page, browserName, devServer }) => {
108108+ await page.goto(devServer.url);
109109+110110+ await page.addScriptTag({ content: prefetchScript });
111111+112112+ // Try to prefetch cross-origin URL
113113+ await page.evaluate(() => {
114114+ window.prefetch("https://example.com/about/");
115115+ });
116116+117117+ if (browserName === "chromium") {
118118+ // Should not create any speculation rules script
119119+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
120120+ expect(speculationScripts.length).toBe(0);
121121+ } else {
122122+ // Should not create any link element
123123+ const prefetchLinks = await page.locator('link[rel="prefetch"]').all();
124124+ expect(prefetchLinks.length).toBe(0);
125125+ }
126126+ });
127127+128128+ test("should use correct eagerness level without prerender", async ({
129129+ page,
130130+ browserName,
131131+ devServer,
132132+ }) => {
133133+ test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium");
134134+135135+ await page.goto(devServer.url);
136136+137137+ await page.addScriptTag({ content: prefetchScript });
138138+139139+ // Call prefetch with custom eagerness but no prerender
140140+ await page.evaluate(() => {
141141+ window.prefetch("/about/", { eagerness: "moderate" });
142142+ });
143143+144144+ const speculationScript = page.locator('script[type="speculationrules"]').first();
145145+ const scriptContent = await speculationScript.textContent();
146146+147147+ if (scriptContent) {
148148+ const rules = JSON.parse(scriptContent);
149149+ expect(rules.prefetch[0].eagerness).toBe("moderate");
150150+ expect(rules.prerender).toBeUndefined();
151151+ }
152152+ });
153153+});
+145
e2e/tests/prerender.spec.ts
···11+import { test, expect } from "./test-utils";
22+import { prefetchScript } from "./utils";
33+44+test.describe("Prefetch - Speculation Rules (Prerender)", () => {
55+ test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
66+ page,
77+ browserName,
88+ devServer,
99+ }) => {
1010+ await page.goto(devServer.url);
1111+1212+ await page.addScriptTag({ content: prefetchScript });
1313+1414+ // Call prefetch with prerender
1515+ await page.evaluate(() => {
1616+ window.prefetch("/about/", { prerender: true });
1717+ });
1818+1919+ if (browserName === "chromium") {
2020+ // Chromium: should create speculation rules script including prerender and prefetch
2121+ const speculationScript = page.locator('script[type="speculationrules"]').first();
2222+ const scriptContent = await speculationScript.textContent();
2323+ expect(scriptContent).toBeTruthy();
2424+2525+ if (scriptContent) {
2626+ const rules = JSON.parse(scriptContent);
2727+ expect(rules.prerender).toBeDefined();
2828+ expect(rules.prerender[0].urls).toContain("/about/");
2929+ expect(rules.prefetch).toBeDefined(); // Fallback
3030+ expect(rules.prefetch[0].urls).toContain("/about/");
3131+ }
3232+ } else {
3333+ // Non-Chromium: If link prefetch is supported, assert link element; otherwise, ensure no speculation script
3434+ const supportsPrefetch = await page.evaluate(() => {
3535+ const link = document.createElement("link");
3636+ return !!(link.relList && link.relList.supports && link.relList.supports("prefetch"));
3737+ });
3838+3939+ if (supportsPrefetch) {
4040+ const prefetchLink = page.locator('link[rel="prefetch"]').first();
4141+ await expect(prefetchLink).toHaveAttribute("href", "/about/");
4242+ } else {
4343+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
4444+ expect(speculationScripts.length).toBe(0);
4545+ }
4646+ }
4747+ });
4848+4949+ test("should use correct eagerness level", async ({ page, browserName, devServer }) => {
5050+ test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium");
5151+5252+ await page.goto(devServer.url);
5353+5454+ await page.addScriptTag({ content: prefetchScript });
5555+5656+ // Call prefetch with custom eagerness
5757+ await page.evaluate(() => {
5858+ window.prefetch("/about/", { prerender: true, eagerness: "conservative" });
5959+ });
6060+6161+ const speculationScript = page.locator('script[type="speculationrules"]').first();
6262+ const scriptContent = await speculationScript.textContent();
6363+6464+ if (scriptContent) {
6565+ const rules = JSON.parse(scriptContent);
6666+ expect(rules.prerender[0].eagerness).toBe("conservative");
6767+ expect(rules.prefetch[0].eagerness).toBe("conservative");
6868+ }
6969+ });
7070+7171+ test("should fallback to link prefetch when speculation rules not supported", async ({
7272+ page,
7373+ browserName,
7474+ devServer,
7575+ }) => {
7676+ // Run this test on Firefox/Safari where Speculation Rules is not supported
7777+ test.skip(browserName === "chromium", "Testing fallback behavior on non-Chromium browsers");
7878+7979+ await page.goto(devServer.url);
8080+8181+ await page.addScriptTag({ content: prefetchScript });
8282+8383+ // Call prefetch with prerender (should fallback to link)
8484+ await page.evaluate(() => {
8585+ window.prefetch("/about/", { prerender: true });
8686+ });
8787+8888+ // Check if browser supports link prefetch
8989+ const supportsPrefetch = await page.evaluate(() => {
9090+ const link = document.createElement("link");
9191+ return !!(link.relList && link.relList.supports && link.relList.supports("prefetch"));
9292+ });
9393+9494+ if (supportsPrefetch) {
9595+ // Should create link element instead
9696+ const prefetchLink = page.locator('link[rel="prefetch"]').first();
9797+ await expect(prefetchLink).toHaveAttribute("href", "/about/");
9898+ }
9999+100100+ // Should NOT create speculation rules script
101101+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
102102+ expect(speculationScripts.length).toBe(0);
103103+ });
104104+105105+ test("should not prerender same URL twice", async ({ page, browserName, devServer }) => {
106106+ test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium");
107107+108108+ await page.goto(devServer.url);
109109+110110+ await page.addScriptTag({ content: prefetchScript });
111111+112112+ // Call prefetch with prerender twice
113113+ await page.evaluate(() => {
114114+ window.prefetch("/about/", { prerender: true });
115115+ window.prefetch("/about/", { prerender: true });
116116+ });
117117+118118+ // Should only have one speculation rules script
119119+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
120120+ expect(speculationScripts.length).toBe(1);
121121+ });
122122+123123+ test("should create separate scripts for different URLs", async ({
124124+ page,
125125+ browserName,
126126+ devServer,
127127+ }) => {
128128+ test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium");
129129+130130+ await page.goto(devServer.url);
131131+132132+ await page.addScriptTag({ content: prefetchScript });
133133+134134+ // Prerender multiple URLs
135135+ await page.evaluate(() => {
136136+ window.prefetch("/about/", { prerender: true });
137137+ window.prefetch("/contact/", { prerender: true });
138138+ window.prefetch("/blog/", { prerender: true });
139139+ });
140140+141141+ // Should have three separate scripts (one per URL)
142142+ const speculationScripts = await page.locator('script[type="speculationrules"]').all();
143143+ expect(speculationScripts.length).toBe(3);
144144+ });
145145+});
+168
e2e/tests/test-utils.ts
···11+import { spawn, execFile, type ChildProcess } from "node:child_process";
22+import { join, resolve, dirname } from "node:path";
33+import { existsSync } from "node:fs";
44+import { fileURLToPath } from "node:url";
55+import { test as base } from "@playwright/test";
66+77+const __filename = fileURLToPath(import.meta.url);
88+const __dirname = dirname(__filename);
99+1010+export interface DevServerOptions {
1111+ /** Path to the fixture directory relative to e2e/fixtures/ */
1212+ fixture: string;
1313+ /** Port to run the server on (default: auto-find) */
1414+ port?: number;
1515+ /** Additional CLI flags to pass to maudit dev */
1616+ flags?: string[];
1717+}
1818+1919+export interface DevServer {
2020+ /** Base URL of the dev server */
2121+ url: string;
2222+ /** Port the server is running on */
2323+ port: number;
2424+ /** Stop the dev server */
2525+ stop: () => Promise<void>;
2626+}
2727+2828+/**
2929+ * Start a maudit dev server for testing.
3030+ */
3131+export async function startDevServer(options: DevServerOptions): Promise<DevServer> {
3232+ // Use __dirname (test file location) to reliably find paths
3333+ const e2eRoot = resolve(__dirname, "..");
3434+ const fixturePath = resolve(e2eRoot, "fixtures", options.fixture);
3535+ const flags = options.flags || [];
3636+ const command = resolve(e2eRoot, "..", "target", "debug", "maudit");
3737+3838+ // Verify the binary exists
3939+ if (!existsSync(command)) {
4040+ throw new Error(
4141+ `Maudit binary not found at: ${command}. Please build it with 'cargo build --bin maudit'`,
4242+ );
4343+ }
4444+4545+ // Build args array
4646+ const args = ["dev", ...flags];
4747+ if (options.port) {
4848+ args.push("--port", options.port.toString());
4949+ }
5050+5151+ // Start the dev server process
5252+ const childProcess = spawn(command, args, {
5353+ cwd: fixturePath,
5454+ stdio: ["ignore", "pipe", "pipe"],
5555+ });
5656+5757+ // Capture output to detect when server is ready
5858+ let serverReady = false;
5959+6060+ const outputPromise = new Promise<number>((resolve, reject) => {
6161+ const timeout = setTimeout(() => {
6262+ reject(new Error("Dev server did not start within 30 seconds"));
6363+ }, 30000);
6464+6565+ childProcess.stdout?.on("data", (data: Buffer) => {
6666+ const output = data.toString();
6767+6868+ // Look for "waiting for requests" to know server is ready
6969+ if (output.includes("waiting for requests")) {
7070+ serverReady = true;
7171+ clearTimeout(timeout);
7272+ // We already know the port from options, so just resolve with it
7373+ resolve(options.port || 1864);
7474+ }
7575+ });
7676+7777+ childProcess.stderr?.on("data", (data: Buffer) => {
7878+ // Only log errors, not all stderr output
7979+ const output = data.toString();
8080+ if (output.toLowerCase().includes("error")) {
8181+ console.error(`[maudit dev] ${output}`);
8282+ }
8383+ });
8484+8585+ childProcess.on("error", (error) => {
8686+ clearTimeout(timeout);
8787+ reject(new Error(`Failed to start dev server: ${error.message}`));
8888+ });
8989+9090+ childProcess.on("exit", (code) => {
9191+ if (!serverReady) {
9292+ clearTimeout(timeout);
9393+ reject(new Error(`Dev server exited with code ${code} before becoming ready`));
9494+ }
9595+ });
9696+ });
9797+9898+ const port = await outputPromise;
9999+100100+ return {
101101+ url: `http://127.0.0.1:${port}`,
102102+ port,
103103+ stop: async () => {
104104+ return new Promise((resolve) => {
105105+ childProcess.on("exit", () => resolve());
106106+ childProcess.kill("SIGTERM");
107107+108108+ // Force kill after 5 seconds if it doesn't stop gracefully
109109+ setTimeout(() => {
110110+ if (!childProcess.killed) {
111111+ childProcess.kill("SIGKILL");
112112+ }
113113+ }, 5000);
114114+ });
115115+ },
116116+ };
117117+}
118118+119119+/**
120120+ * Helper to manage multiple dev servers in tests.
121121+ * Automatically cleans up servers when tests finish.
122122+ */
123123+export class DevServerPool {
124124+ private servers: DevServer[] = [];
125125+126126+ async start(options: DevServerOptions): Promise<DevServer> {
127127+ const server = await startDevServer(options);
128128+ this.servers.push(server);
129129+ return server;
130130+ }
131131+132132+ async stopAll(): Promise<void> {
133133+ await Promise.all(this.servers.map((server) => server.stop()));
134134+ this.servers = [];
135135+ }
136136+}
137137+138138+// Worker-scoped server pool - one server per worker, shared across all tests in that worker
139139+const workerServers = new Map<number, DevServer>();
140140+141141+// Extend Playwright's test with a devServer fixture
142142+export const test = base.extend<{ devServer: DevServer }>({
143143+ devServer: async ({}, use, testInfo) => {
144144+ // Use worker index to get or create a server for this worker
145145+ const workerIndex = testInfo.workerIndex;
146146+147147+ let server = workerServers.get(workerIndex);
148148+149149+ if (!server) {
150150+ // Assign unique port based on worker index
151151+ const port = 1864 + workerIndex;
152152+153153+ server = await startDevServer({
154154+ fixture: "prefetch-prerender",
155155+ port,
156156+ });
157157+158158+ workerServers.set(workerIndex, server);
159159+ }
160160+161161+ await use(server);
162162+163163+ // Don't stop the server here - it stays alive for all tests in this worker
164164+ // Playwright will clean up when the worker exits
165165+ },
166166+});
167167+168168+export { expect } from "@playwright/test";
+25
e2e/tests/utils.ts
···11+import { readFileSync, readdirSync } from "node:fs";
22+import { join } from "node:path";
33+44+// Find the actual prefetch bundle file (hash changes on each build)
55+const distDir = join(process.cwd(), "../crates/maudit/js/dist");
66+const prefetchFile = readdirSync(distDir).find(
77+ (f) => f.startsWith("prefetch-") && f.endsWith(".js"),
88+);
99+if (!prefetchFile) throw new Error("Could not find prefetch bundle");
1010+1111+// Read the bundled prefetch script
1212+const prefetchBundled = readFileSync(join(distDir, prefetchFile), "utf-8");
1313+1414+// Extract the internal function name from export{X as prefetch}
1515+const exportMatch = prefetchBundled.match(/export\{(\w+) as prefetch\}/);
1616+if (!exportMatch) throw new Error("Could not parse prefetch export");
1717+const internalName = exportMatch[1];
1818+1919+// Remove export and expose on window
2020+export const prefetchScript = `
2121+ (function() {
2222+ ${prefetchBundled.replace(/export\{.*\};?$/, "")}
2323+ window.prefetch = ${internalName};
2424+ })();
2525+`;
···11111212## Configuration
13131414-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`.
1414+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.
15151616```rs
1717use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy};
···1919BuildOptions {
2020 prefetch: PrefetchOptions {
2121 strategy: PrefetchStrategy::Hover,
2222+ ..Default::default()
2223 },
2324 ..Default.default()
2425}
2526```
26272728To disable prefetching, set `strategy` to [`PrefetchStrategy::None`](https://docs.rs/maudit/latest/maudit/enum.PrefetchStrategy.html#variant.None).
2929+3030+## Using the speculation rules API
3131+3232+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.
3333+3434+### Prerendering
3535+3636+By enabling `PrefetchOptions.prerender`, Maudit will also prerender your prefetched pages using the Speculation Rules API.
3737+3838+```rs
3939+use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy};
4040+4141+BuildOptions {
4242+ prefetch: PrefetchOptions {
4343+ prerender: true,
4444+ ..Default::default()
4545+ },
4646+ ..Default.default()
4747+}
4848+```
4949+5050+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.
5151+5252+## Possible risks
5353+5454+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.
5555+5656+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).