Skip to content

Add a stackpress dev fast edit loop and fast-build defaults in app templates #18

Open

Description

@cblanquera

Problem

Developing a Stackpress application today means running separate native and WASM builds by hand, often for both hydrate and CSR, and reloading the browser manually. Every edit pays for full rebuilds of outputs the edit did not affect. Framework-library settings cannot fix this alone: Cargo reads [profile.*] only from the consuming workspace root, so each application's own manifest decides its build defaults (Cargo profiles).

Requested behavior

Add a stackpress dev command to the application tooling that gives a fast edit loop for a selected app:

  • watch the app's sources and rebuild only the affected native and/or WASM outputs;
  • build only the render mode being developed (for example hydrate), not both hydrate and CSR on every edit;
  • reload the browser after a successful rebuild, and update CSS without a Rust compile;
  • start the dev server only through the normal managed path (no hidden listeners), and show build errors in the terminal and the page.

Ship matching defaults in the application templates, so new apps get fast development builds:

  • workspace [profile.dev] settings (for example reduced debug info and incremental builds);
  • an optional, documented compiler cache;
  • a test command that prefers cargo nextest where installed.

Prior art

cargo-leptos already provides server/client watch builds, browser reload and CSS hot reload. Its documented support centres on SSR plus hydration. Stackpress would need integration work for:

  • CSR-only applications;
  • custom hosting (the Stackpress server and gateway, mounted base paths);
  • generated admin and asset manifests.

Decide whether to wrap cargo-leptos, reuse parts of it, or implement a smaller watcher.

Scope and boundaries

  • Development only; production builds stay explicit and reproducible.
  • No change to plugin selection rules: plugins remain chosen at compile time.
  • Keep SSR, hydrate and CSR builds separate. Never unify mutually exclusive render features.
  • No dependency or toolchain upgrades as part of this issue.

Acceptance criteria

  • Design records the watcher choice (cargo-leptos integration vs custom), supported app shapes (SSR+hydrate, CSR, custom host) and limits.
  • Editing a Rust file in a sample app rebuilds only affected outputs; a CSS-only edit applies without a Rust compile.
  • Browser reloads after a successful rebuild; failures are shown without killing the session.
  • Application templates include the development profile defaults and document optional sccache and cargo nextest use.
  • Measured before/after edit-loop times for a sample app are recorded.

Related: #14 (plugin entry points), #17 (stackpress-leptos).

Activity

  1. cblanquera commented on Oct 29, 2026

    @cblanquera
    MemberAuthor

    Implementation recommendation

    Grounded in the current tree. Each example ships its own Python build script (for example examples/ui-preferences/build.py). Every run does a native build plus both hydrate and CSR WASM builds and wasm-bindgen. stackpress-cli is an application-owned event CLI (emit ...) and should stay that way. The recommendation below keeps the dev loop out of every runtime graph.

    1. A separate, development-only tool

    Add tools/dev (package stackpress-dev, binary stackpress-dev, also runnable as cargo stackpress-dev). It is never a dependency of an app, library or browser build, the same rule as codegen and generator tools.

    stackpress-dev [--mode hydrate|csr] [--mount /base] [--port N] [--no-reload] [--test]
    

    2. Declare the app once, in its own manifest

    Read the app shape from the app workspace's own Cargo.toml, so nothing is guessed from paths:

    # Application workspace Cargo.toml (shipped by the app template)
    [workspace.metadata.stackpress.dev]
    server  = { package = "my-app-web", bin = "my-app-web" }       # native host
    client  = { package = "my-app-client", out-name = "app" }      # WASM crate
    mode    = "hydrate"                    # default render mode for the loop
    bootstrap = "client/bootstrap.js"      # copied as start.js, as today
    assets  = "dist"                       # where bindgen output and CSS land
    styles  = ["styles/app.css"]           # app-owned CSS files (hot-swappable)
    mount   = ""                           # or "/demo/v1"

    3. Rebuild only the affected outputs

    Use cargo metadata once at start-up (and again when a Cargo.toml changes). It maps every changed file to a workspace package, then to the outputs that depend on that package:

    /// Which outputs one batch of file changes invalidates.
    #[derive(Default)]
    struct Affected { server: bool, client: bool, css: Vec<PathBuf>, manifest: bool }
    
    fn classify(changes: &[PathBuf], graph: &PackageGraph, cfg: &DevConfig) -> Affected {
        let mut out = Affected::default();
        for path in changes {
            // App-owned stylesheets never need a Rust compile.
            if cfg.styles.iter().any(|s| path.ends_with(s)) { out.css.push(path.clone()); continue; }
            if path.file_name() == Some("Cargo.toml".as_ref()) { out.manifest = true; continue; }
            // Owning package = longest package root that contains the path.
            let Some(pkg) = graph.owner_of(path) else { continue };
            out.server |= graph.depends_on(&cfg.server.package, pkg);
            out.client |= graph.depends_on(&cfg.client.package, pkg);
        }
        out
    }

    Notes:

    • Build one render mode per loop (--features hydrate or csr). CI and release keep building every mode separately.
    • Skip wasm-opt in dev.
    • Take artifact paths from Cargo rather than guessing target layouts: run cargo build --message-format=json-render-diagnostics and read the compiler-artifact message for the client's .wasm file before running wasm-bindgen.
    • Library CSS embedded in Rust (for example CORE_STYLES) still needs a compile; only app-owned stylesheet files hot-swap. Document that limit.

    4. Watch, debounce, and cancel stale builds

    Use notify with a short debounce. When a new change arrives mid-build, kill the running build's process group and start over, so the loop never finishes a build that is already stale:

    let (tx, rx) = std::sync::mpsc::channel();
    let mut debouncer = notify_debouncer_full::new_debouncer(Duration::from_millis(150), None, tx)?;
    debouncer.watch(&cfg.root, RecursiveMode::Recursive)?;
    
    let mut running: Option<BuildJob> = None;
    for batch in rx {
        let changes = relevant_paths(batch?, &cfg);        // ignores target/, dist/, .git/
        let affected = classify(&changes, &graph, &cfg);
        if let Some(job) = running.take() { job.cancel(); } // kill + reap the stale build
        running = Some(BuildJob::spawn(affected, &cfg, reload.clone()));
    }

    5. Reload through a small dev proxy, so apps need no dev code

    Run the app server as a child on an internal port. stackpress-dev listens on the user-facing port, started through the project's normal managed-listener rules. It forwards requests and, in dev only, injects one script tag into HTML responses. It also serves a Server-Sent Events channel. Release builds, app code and CSP stay untouched: the proxy adds the injected script's hash or nonce to the dev response's CSP header.

    // Proxy response hook (dev only): add the reload client to HTML documents.
    if is_html(&response) {
        body = inject_before_body_end(body, r#"<script src="/__stackpress/dev.js"></script>"#);
    }
    // GET /__stackpress/events -> text/event-stream of {"type":"reload"} or {"type":"css","href":...}
    // /__stackpress/dev.js: tiny reload client served by the dev proxy.
    const events = new EventSource('/__stackpress/events');
    events.onmessage = ({ data }) => {
      const msg = JSON.parse(data);
      if (msg.type === 'css') {
        // Swap only the changed stylesheet; no page reload, no Rust compile.
        for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
          if (new URL(link.href).pathname === msg.href) link.href = `${msg.href}?v=${Date.now()}`;
        }
      } else if (msg.type === 'reload') {
        location.reload();
      } else if (msg.type === 'error') {
        console.error(msg.text); // also render an overlay with the compiler message
      }
    };

    After a native rebuild, restart the server child. The proxy holds incoming requests until the new child accepts connections, so the browser never sees a refused connection. Build errors go to the terminal and the overlay, and the previous good server stays up.

    6. Template defaults (Cargo reads profiles only from the app's workspace root)

    # App template: Cargo.toml
    [profile.dev]
    debug = "line-tables-only"        # keeps backtrace line numbers, smaller and faster links
    
    [profile.dev.package."*"]
    debug = false                     # no debug info for dependencies
    # App template: .cargo/config.toml (commented, opt-in)
    # [build]
    # rustc-wrapper = "sccache"       # shared compiler cache; install separately
    # App template: .config/nextest.toml
    [profile.default]
    slow-timeout = { period = "30s", terminate-after = 4 }

    Document cargo nextest run for tests and cargo test --doc for doctests. stackpress-dev --test can run cargo nextest run -p <affected packages> after each successful rebuild.

    7. cargo-leptos: borrow ideas, don't wrap (for now)

    cargo-leptos proves the loop: paired server and client watch builds, live reload and CSS hot reload. It is built around one SSR+hydrate lib/bin layout and its own server integrations. Stackpress needs:

    • CSR-only apps;
    • its own server and gateway, with mounted base paths;
    • checked asset manifests;
    • generated admin and examples.

    A small purpose-built tool fits better. Keep the manifest format close enough that a later engine = "cargo-leptos" option stays possible for plain SSR+hydrate apps.

    8. Suggested milestones

    1. M1: tools/dev with the manifest, affected-output mapping, a single-mode client build, a native rebuild with server restart, and terminal errors. Prove it on examples/ui-preferences.
    2. M2: the dev proxy with live reload, CSS swap, error overlay and request hold during restart. Prove mounted base paths and the strict-CSP pages.
    3. M3: the template defaults ([profile.dev], opt-in sccache, nextest.toml), --test, and measured before/after edit-loop times on two examples, plus docs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions