Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ without a server round-trip.

**Partial response:** `render_partial()` returns `{ templateStyles, templates, inventory, path, chain, cacheTags, cacheControl }`. The caller adds application state to the response (e.g. as a top-level `state` field for non-streaming, or as an NDJSON Chunk 2 for streaming):
- `state`: (added by caller) route-scoped application data — the router applies it to components via `setState()`
- `templateStyles`: module CSS definition tags (`<style type="module" specifier="...">`) for newly shipped components. Empty array for Link/Style modes. The client appends these to `<head>` before evaluating template scripts so adopted stylesheets are available
- `templateStyles`: CSS module definition tags (`<script type="importmap">{"imports":{"...":"data:text/css,..."}}</script>` strings - see [CssStrategy::Module](#css-strategy)) for newly shipped components. Empty array for Link/Style modes. The client appends these to `<head>` before evaluating template scripts so adopted stylesheets are available
- `templates`: client template script/markup payloads the client doesn't already have (filtered by inventory bitmask). Format depends on the active parser plugin
- `inventory`: updated hex bitmask of loaded templates
- `chain`: matched route chain array — each entry has `component`, `path`, optional `params`, `exact`, `allowedQuery`, `keepAlive`, `pendingComponent`, `errorComponent`, and `invalidates`
Expand Down Expand Up @@ -499,7 +499,8 @@ pub struct RenderOptions<'a> {
/// The URL path to match routes against (e.g., `"/contacts/42"`).
pub request_path: &'a str,
/// Optional CSP nonce reflected into the `<meta name="webui-nonce">`
/// tag and onto every emitted inline `<script>` / `<style type="module">`.
/// tag and onto every SSR-emitted inline `<script>` tag (bootstrap
/// scripts and CSS-module importmaps - see [CssStrategy::Module](#css-strategy)).
pub nonce: Option<&'a str>,
/// Optional HTML emitted at the structural `head_end` boundary —
/// see [Per-Render HTML Injection](#per-render-html-injection).
Expand Down Expand Up @@ -945,16 +946,16 @@ pub enum CssStrategy {
Link,
/// Embed CSS content inline in `<style>` tags within the shadow DOM template.
Style,
/// Emit a `<style type="module" specifier="component">` definition once per
/// page and reference it via `shadowrootadoptedstylesheets` on each shadow
/// root `<template>`.
/// Register each component's CSS module via a `<script type="importmap">`
/// data-URI definition (one per component, deduped) and reference it via
/// `shadowrootadoptedstylesheets` on each shadow root `<template>`.
Module,
}
```

- **Link** (default): Emits `<link>` tags referencing external `.css` files only for components whose discovery/registration data included CSS. Used by the CLI for production builds where CSS files are served separately. Output filenames are configurable with a naming template (`[name]`, `[hash]`, `[ext]`), defaulting to `[name].[ext]`. `[hash]` is SHA-256 truncated to 8 hex chars. An optional public base prefix can be applied so protocol `css_href` values point to CDN URLs. The resolved href is used consistently for handler-emitted head links and parser/plugin-generated component template stylesheet links.
- **Style**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.
- **Module**: Uses the [Declarative CSS Module Scripts](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOM/explainer.md) proposal. During SSR, emits a `<style type="module" specifier="component-name">` definition in each component's light DOM on first render (e.g., `<my-comp><style type="module" ...>CSS</style><template ...>`) and adds `shadowrootadoptedstylesheets="component-name"` to each shadow root `<template>`. Components inside false `<if>` blocks or empty `<for>` loops that were not rendered during SSR get their module style definitions emitted at `body_end`, so client-side activation can adopt them. When a CSP nonce is configured (via `RenderOptions::with_nonce` / `webui_handler_set_nonce`), the SSR-emitted `<style type="module">` tags include `nonce="VALUE"` (in `type`, `nonce`, `specifier` order) so strict `style-src 'nonce-...'` policies allow them, matching the existing nonce treatment of inline `<script>` tags. The browser registers the CSS module globally and shares a single `CSSStyleSheet` across all shadow roots that adopt it. No external CSS files are produced. During SPA partial navigation, module style definitions for newly needed components are sent in the `templateStyles` array; the router appends them to `<head>` before executing template scripts. WebUI Framework compiled metadata carries the adopted stylesheet specifier (`sa`) so client-created components can adopt the registered stylesheet on their shadow root.
- **Module**: Registers each component's CSS as a CSS Module via an [Import Map](https://html.spec.whatwg.org/multipage/webappapis.html#import-maps) entry whose value is a `data:text/css,...` URI. During SSR, the handler emits a `<script type="importmap">{"imports":{"component-name":"data:text/css,..."}}</script>` in each component's light DOM on first render (e.g., `<my-comp><script type="importmap">...</script><template ...>`) and adds `shadowrootadoptedstylesheets="component-name"` to each shadow root `<template>`. Components inside false `<if>` blocks or empty `<for>` loops that were not rendered during SSR get their importmap definitions emitted at `body_end`, so client-side activation can adopt them. CSS bytes are percent-encoded as needed to survive the `data:` URI parser (`%`, `#`, `"`, whitespace, and non-ASCII / control bytes); the importmap JSON object is built via `serde_json` so the specifier and URI value are correctly JSON-escaped. **Requires browser support for [Multiple Import Maps](https://github.com/WICG/import-maps/blob/main/proposals/multiple-import-maps.md) (Chrome 133+)** so each component's importmap can be emitted independently and merged into the document-level resolution table by the browser. When a CSP nonce is configured (via `RenderOptions::with_nonce` / `webui_handler_set_nonce`), the SSR-emitted `<script type="importmap">` tags include `nonce="VALUE"` (in `type`, `nonce` order) so strict `script-src 'nonce-...'` policies allow them, matching the existing nonce treatment of inline `<script>` tags. The browser registers the CSS module globally and shares a single `CSSStyleSheet` across all shadow roots that adopt it. No external CSS files are produced. During SPA partial navigation, definitions for newly needed components are sent in the `templateStyles` array as `<script type="importmap">{"imports":{...}}</script>` strings (without a `nonce` attribute - the router materializes each tag client-side and applies the per-request nonce when appending to `<head>` before executing template scripts). WebUI Framework compiled metadata carries the adopted stylesheet specifier (`sa`) so client-created components can adopt the registered stylesheet on their shadow root.

Set at construction time with `HtmlParser::with_options(ParserOptions::try_new(...))`.

Expand Down
176 changes: 176 additions & 0 deletions crates/webui-handler/src/css_module.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

//! Shared helpers for emitting CSS module definitions as `<script type="importmap">`
//! tags with `data:text/css,…` URIs.
//!
//! Both the SSR inline emission path (`lib.rs::emit_css_module_importmap`) and
//! the SPA partial-navigation path (`route_handler.rs::collect_component_assets`)
//! use this helper to produce a single canonical wire shape, so the client only
//! needs to understand one format.

use std::fmt::Write as _;

use serde_json::Value;

/// Build a complete `<script type="importmap">` tag string that registers a
/// single CSS module under `specifier` via a `data:text/css,…` URI.
///
/// If `nonce` is `Some`, a `nonce="…"` attribute is inserted between `type`
/// and `>` so strict CSP `script-src 'nonce-…'` policies allow the inline
/// script. CSS bytes are percent-encoded so they survive the `data:` URI
/// parser; the importmap JSON is produced via `serde_json` so the specifier
/// and URI value are correctly JSON-escaped.
///
/// Requires browser support for Multiple Import Maps (Chrome 133+); the
/// browser merges each emitted importmap into the document-level resolution
/// table.
pub(crate) fn build_importmap_tag(specifier: &str, css: &str, nonce: Option<&str>) -> String {
let data_uri = build_data_uri(css);
let body = build_importmap_json(specifier, data_uri);

// `<script type="importmap"></script>` is 33 chars; `nonce=""` adds 8 +
// the value. A few extra bytes avoid a reallocation when the body is
// small.
let cap = 40 + body.len() + nonce.map_or(0, |n| n.len() + 9);
let mut out = String::with_capacity(cap);
out.push_str("<script type=\"importmap\"");
if let Some(n) = nonce {
out.push_str(" nonce=\"");
out.push_str(n);
out.push('"');
}
out.push('>');
out.push_str(&body);
out.push_str("</script>");
out
}

fn build_data_uri(css: &str) -> String {
let mut out = String::with_capacity("data:text/css,".len() + css.len());
out.push_str("data:text/css,");
percent_encode_css_into(css, &mut out);
out
}

fn build_importmap_json(specifier: &str, data_uri: String) -> String {
let mut imports = serde_json::Map::with_capacity(1);
imports.insert(specifier.to_owned(), Value::String(data_uri));
let mut root = serde_json::Map::with_capacity(1);
root.insert("imports".into(), Value::Object(imports));
Value::Object(root).to_string()
}

// Percent-encode bytes that would mis-parse in a `data:` URI or break out
// of the surrounding `<script type="importmap">` raw-text element:
// `%` (escape), `#` (fragment delimiter), `"`, `<` / `>` (HTML script-data
// terminator + attribute parser), whitespace, and non-ASCII / control bytes.
fn percent_encode_css_into(css: &str, out: &mut String) {
for b in css.bytes() {
let needs_encoding = matches!(
b,
b'%' | b'#' | b'"' | b'<' | b'>' | b' ' | b'\t' | b'\n' | b'\r'
) || !(0x20..0x80).contains(&b);
if needs_encoding {
let _ = write!(out, "%{:02X}", b);
} else {
out.push(char::from(b));
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn build_importmap_tag_basic() {
let tag = build_importmap_tag("my-comp", "span{color:blue;}", None);
assert_eq!(
tag,
r#"<script type="importmap">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>"#
);
}

#[test]
fn build_importmap_tag_with_nonce() {
let tag = build_importmap_tag("dash-page", "h1{font-size:2rem}", Some("test-nonce-123"));
assert_eq!(
tag,
r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
);
}

#[test]
fn percent_encoder_escapes_url_unsafe_bytes() {
let mut out = String::new();
// %, #, ", <, >, whitespace must all be percent-escaped. Printable
// ASCII outside that set (including `{`, `}`, `\`) is preserved
// verbatim; JSON-level escaping of `\` and `"` is handled by
// serde_json when the URI is embedded inside the importmap object.
percent_encode_css_into(".a{content:\"\\E000 #x %y\";}", &mut out);
assert_eq!(out, r#".a{content:%22\E000%20%23x%20%25y%22;}"#);
}

#[test]
fn percent_encoder_escapes_non_ascii_bytes() {
let mut out = String::new();
percent_encode_css_into(".a::before{content:\"★\"}", &mut out);
// ★ is U+2605, UTF-8 bytes E2 98 85.
assert_eq!(out, ".a::before{content:%22%E2%98%85%22}");
}

#[test]
fn empty_css_produces_empty_data_uri() {
let tag = build_importmap_tag("empty", "", None);
assert!(tag.contains(r#""empty":"data:text/css,""#));
}

#[test]
fn json_layer_escapes_backslash_in_css() {
// CSS escapes like `\E000` survive the percent encoder (the bytes
// are printable ASCII) but must still be JSON-escaped so the
// resulting importmap parses. `serde_json` produces `\\E000`.
let tag = build_importmap_tag("ic", "a::before{content:\"\\E000\"}", None);
assert!(
tag.contains(r#""data:text/css,a::before{content:%22\\E000%22}""#),
"backslash inside the data URI must be JSON-escaped (got: {tag})"
);
}

#[test]
fn css_with_script_close_tag_cannot_break_out_of_importmap_script() {
// Regression guard: `<` and `>` must be percent-encoded so CSS
// content containing `</script>` (or any tag-like sequence) cannot
// terminate the surrounding `<script type="importmap">` element.
// The HTML parser tokenizes script bodies in raw-text mode and
// will treat any literal `</script>` as the end tag regardless of
// JSON quoting.
let malicious = r#".a::before{content:"</script><script>alert(1)</script>";}"#;
let tag = build_importmap_tag("evil", malicious, None);

// Exactly one `</script>` (the real closing tag) and one `<script`
// (the real opening tag) may appear; the encoded payload must not
// contribute any extra tag-like sequences.
assert_eq!(
tag.matches("</script>").count(),
1,
"only the legitimate closing tag may appear: {tag}"
);
assert_eq!(
tag.matches("<script").count(),
1,
"only the legitimate opening tag may appear: {tag}"
);

// The body (between the opening `>` and the real closing tag)
// must not contain any raw `<` or `>`.
let body_start = tag.find('>').expect("opening tag must terminate") + 1;
let body_end = tag.rfind("</script>").expect("closing tag must exist");
let body = &tag[body_start..body_end];
assert!(
!body.contains('<') && !body.contains('>'),
"no raw `<` or `>` may appear inside the importmap body: {body}"
);
}
}
Loading
Loading