Skip to content

Commit f5cedc3

Browse files
Emit style modules via importmap + dataURI instead of <style type="module"> (#325)
* feat(handler): emit CSS modules via `<script type="importmap">` data URIs Replace the per-component `<style type="module" specifier="X">CSS</style>` emission with `<script type="importmap">{"imports":{"X":"data:text/css,…"}}</script>`. Why --- The `<style type="module">` shape is a non-standard CSS-module-style-tag prototype that has not shipped in all host browsers. Import Maps + `data:text/css,…` URIs achieve the same end - the browser resolves a CSS module by specifier - using only standards- track features available in Chrome 133+. The shape change is transparent to downstream consumers: the framework's client-side adoption code resolves CSS modules by specifier the same way in both forms, and `shadowrootadoptedstylesheets` continues to work unchanged. What changed ------------ * New private method `emit_css_module_importmap(specifier, css, ctx)` in `crates/webui-handler/src/lib.rs`. Percent-encodes only the bytes that would actually mis-parse in a data URI (`%` `#` `"` whitespace control chars, non-ASCII) - keeps human-readable CSS readable in DevTools' importmap view. * Builds the JSON body with `serde_json::json!` so the specifier and data URI are always correctly JSON-escaped. * Both emission call sites - `emit_css_module` (per-component first render) and the `body_end` reachable-but-unrendered loop — route through the new helper. * CSP nonce is applied identically to the legacy `<style type="module">` path and to the bootstrap `<script>` emit, so the existing nonce tests stay green (assertions updated to the new emission shape). * Tests updated: positive-shape asserts now check for `<script type="importmap"…>` and the embedded data URI; negative- shape asserts in non-module strategies additionally guard against accidental importmap emission. Tests ----- `cargo test -p microsoft-webui-handler --lib` - 285/285 pass. `cargo test --workspace --lib` - all crates green. * Initial commit
1 parent c0bd525 commit f5cedc3

19 files changed

Lines changed: 460 additions & 195 deletions

File tree

DESIGN.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ without a server round-trip.
319319

320320
**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):
321321
- `state`: (added by caller) route-scoped application data — the router applies it to components via `setState()`
322-
- `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
322+
- `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
323323
- `templates`: client template script/markup payloads the client doesn't already have (filtered by inventory bitmask). Format depends on the active parser plugin
324324
- `inventory`: updated hex bitmask of loaded templates
325325
- `chain`: matched route chain array — each entry has `component`, `path`, optional `params`, `exact`, `allowedQuery`, `keepAlive`, `pendingComponent`, `errorComponent`, and `invalidates`
@@ -499,7 +499,8 @@ pub struct RenderOptions<'a> {
499499
/// The URL path to match routes against (e.g., `"/contacts/42"`).
500500
pub request_path: &'a str,
501501
/// Optional CSP nonce reflected into the `<meta name="webui-nonce">`
502-
/// tag and onto every emitted inline `<script>` / `<style type="module">`.
502+
/// tag and onto every SSR-emitted inline `<script>` tag (bootstrap
503+
/// scripts and CSS-module importmaps - see [CssStrategy::Module](#css-strategy)).
503504
pub nonce: Option<&'a str>,
504505
/// Optional HTML emitted at the structural `head_end` boundary —
505506
/// see [Per-Render HTML Injection](#per-render-html-injection).
@@ -945,16 +946,16 @@ pub enum CssStrategy {
945946
Link,
946947
/// Embed CSS content inline in `<style>` tags within the shadow DOM template.
947948
Style,
948-
/// Emit a `<style type="module" specifier="component">` definition once per
949-
/// page and reference it via `shadowrootadoptedstylesheets` on each shadow
950-
/// root `<template>`.
949+
/// Register each component's CSS module via a `<script type="importmap">`
950+
/// data-URI definition (one per component, deduped) and reference it via
951+
/// `shadowrootadoptedstylesheets` on each shadow root `<template>`.
951952
Module,
952953
}
953954
```
954955

955956
- **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.
956957
- **Style**: Embeds the full CSS content in `<style>` tags inside the shadow DOM template. Used when all files are needed in-memory.
957-
- **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.
958+
- **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.
958959

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

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
//! Shared helpers for emitting CSS module definitions as `<script type="importmap">`
5+
//! tags with `data:text/css,…` URIs.
6+
//!
7+
//! Both the SSR inline emission path (`lib.rs::emit_css_module_importmap`) and
8+
//! the SPA partial-navigation path (`route_handler.rs::collect_component_assets`)
9+
//! use this helper to produce a single canonical wire shape, so the client only
10+
//! needs to understand one format.
11+
12+
use std::fmt::Write as _;
13+
14+
use serde_json::Value;
15+
16+
/// Build a complete `<script type="importmap">` tag string that registers a
17+
/// single CSS module under `specifier` via a `data:text/css,…` URI.
18+
///
19+
/// If `nonce` is `Some`, a `nonce="…"` attribute is inserted between `type`
20+
/// and `>` so strict CSP `script-src 'nonce-…'` policies allow the inline
21+
/// script. CSS bytes are percent-encoded so they survive the `data:` URI
22+
/// parser; the importmap JSON is produced via `serde_json` so the specifier
23+
/// and URI value are correctly JSON-escaped.
24+
///
25+
/// Requires browser support for Multiple Import Maps (Chrome 133+); the
26+
/// browser merges each emitted importmap into the document-level resolution
27+
/// table.
28+
pub(crate) fn build_importmap_tag(specifier: &str, css: &str, nonce: Option<&str>) -> String {
29+
let data_uri = build_data_uri(css);
30+
let body = build_importmap_json(specifier, data_uri);
31+
32+
// `<script type="importmap"></script>` is 33 chars; `nonce=""` adds 8 +
33+
// the value. A few extra bytes avoid a reallocation when the body is
34+
// small.
35+
let cap = 40 + body.len() + nonce.map_or(0, |n| n.len() + 9);
36+
let mut out = String::with_capacity(cap);
37+
out.push_str("<script type=\"importmap\"");
38+
if let Some(n) = nonce {
39+
out.push_str(" nonce=\"");
40+
out.push_str(n);
41+
out.push('"');
42+
}
43+
out.push('>');
44+
out.push_str(&body);
45+
out.push_str("</script>");
46+
out
47+
}
48+
49+
fn build_data_uri(css: &str) -> String {
50+
let mut out = String::with_capacity("data:text/css,".len() + css.len());
51+
out.push_str("data:text/css,");
52+
percent_encode_css_into(css, &mut out);
53+
out
54+
}
55+
56+
fn build_importmap_json(specifier: &str, data_uri: String) -> String {
57+
let mut imports = serde_json::Map::with_capacity(1);
58+
imports.insert(specifier.to_owned(), Value::String(data_uri));
59+
let mut root = serde_json::Map::with_capacity(1);
60+
root.insert("imports".into(), Value::Object(imports));
61+
Value::Object(root).to_string()
62+
}
63+
64+
// Percent-encode bytes that would mis-parse in a `data:` URI or break out
65+
// of the surrounding `<script type="importmap">` raw-text element:
66+
// `%` (escape), `#` (fragment delimiter), `"`, `<` / `>` (HTML script-data
67+
// terminator + attribute parser), whitespace, and non-ASCII / control bytes.
68+
fn percent_encode_css_into(css: &str, out: &mut String) {
69+
for b in css.bytes() {
70+
let needs_encoding = matches!(
71+
b,
72+
b'%' | b'#' | b'"' | b'<' | b'>' | b' ' | b'\t' | b'\n' | b'\r'
73+
) || !(0x20..0x80).contains(&b);
74+
if needs_encoding {
75+
let _ = write!(out, "%{:02X}", b);
76+
} else {
77+
out.push(char::from(b));
78+
}
79+
}
80+
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
86+
#[test]
87+
fn build_importmap_tag_basic() {
88+
let tag = build_importmap_tag("my-comp", "span{color:blue;}", None);
89+
assert_eq!(
90+
tag,
91+
r#"<script type="importmap">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>"#
92+
);
93+
}
94+
95+
#[test]
96+
fn build_importmap_tag_with_nonce() {
97+
let tag = build_importmap_tag("dash-page", "h1{font-size:2rem}", Some("test-nonce-123"));
98+
assert_eq!(
99+
tag,
100+
r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
101+
);
102+
}
103+
104+
#[test]
105+
fn percent_encoder_escapes_url_unsafe_bytes() {
106+
let mut out = String::new();
107+
// %, #, ", <, >, whitespace must all be percent-escaped. Printable
108+
// ASCII outside that set (including `{`, `}`, `\`) is preserved
109+
// verbatim; JSON-level escaping of `\` and `"` is handled by
110+
// serde_json when the URI is embedded inside the importmap object.
111+
percent_encode_css_into(".a{content:\"\\E000 #x %y\";}", &mut out);
112+
assert_eq!(out, r#".a{content:%22\E000%20%23x%20%25y%22;}"#);
113+
}
114+
115+
#[test]
116+
fn percent_encoder_escapes_non_ascii_bytes() {
117+
let mut out = String::new();
118+
percent_encode_css_into(".a::before{content:\"\"}", &mut out);
119+
// ★ is U+2605, UTF-8 bytes E2 98 85.
120+
assert_eq!(out, ".a::before{content:%22%E2%98%85%22}");
121+
}
122+
123+
#[test]
124+
fn empty_css_produces_empty_data_uri() {
125+
let tag = build_importmap_tag("empty", "", None);
126+
assert!(tag.contains(r#""empty":"data:text/css,""#));
127+
}
128+
129+
#[test]
130+
fn json_layer_escapes_backslash_in_css() {
131+
// CSS escapes like `\E000` survive the percent encoder (the bytes
132+
// are printable ASCII) but must still be JSON-escaped so the
133+
// resulting importmap parses. `serde_json` produces `\\E000`.
134+
let tag = build_importmap_tag("ic", "a::before{content:\"\\E000\"}", None);
135+
assert!(
136+
tag.contains(r#""data:text/css,a::before{content:%22\\E000%22}""#),
137+
"backslash inside the data URI must be JSON-escaped (got: {tag})"
138+
);
139+
}
140+
141+
#[test]
142+
fn css_with_script_close_tag_cannot_break_out_of_importmap_script() {
143+
// Regression guard: `<` and `>` must be percent-encoded so CSS
144+
// content containing `</script>` (or any tag-like sequence) cannot
145+
// terminate the surrounding `<script type="importmap">` element.
146+
// The HTML parser tokenizes script bodies in raw-text mode and
147+
// will treat any literal `</script>` as the end tag regardless of
148+
// JSON quoting.
149+
let malicious = r#".a::before{content:"</script><script>alert(1)</script>";}"#;
150+
let tag = build_importmap_tag("evil", malicious, None);
151+
152+
// Exactly one `</script>` (the real closing tag) and one `<script`
153+
// (the real opening tag) may appear; the encoded payload must not
154+
// contribute any extra tag-like sequences.
155+
assert_eq!(
156+
tag.matches("</script>").count(),
157+
1,
158+
"only the legitimate closing tag may appear: {tag}"
159+
);
160+
assert_eq!(
161+
tag.matches("<script").count(),
162+
1,
163+
"only the legitimate opening tag may appear: {tag}"
164+
);
165+
166+
// The body (between the opening `>` and the real closing tag)
167+
// must not contain any raw `<` or `>`.
168+
let body_start = tag.find('>').expect("opening tag must terminate") + 1;
169+
let body_end = tag.rfind("</script>").expect("closing tag must exist");
170+
let body = &tag[body_start..body_end];
171+
assert!(
172+
!body.contains('<') && !body.contains('>'),
173+
"no raw `<` or `>` may appear inside the importmap body: {body}"
174+
);
175+
}
176+
}

0 commit comments

Comments
 (0)