Skip to main content

euv_cli/build/
inline.rs

1use std::path::Path;
2
3use tokio::fs::{read, read_to_string};
4
5use super::*;
6
7/// Reads the wasm-bindgen JS bridge produced by `wasm-pack build --target web`
8/// and returns a synchronous IIFE script that inlines the bridge, inlines the
9/// 2 snippet helper modules, fetches the wasm module from `wasm_url`, and
10/// calls `main()` once the instance is ready.
11///
12/// The returned script is a drop-in replacement for the old
13/// `<script type="module">import init, { main } from '__IMPORT_PATH__'; ...</script>`
14/// block. Because the script is plain JavaScript (not a module) and is inlined
15/// directly into the HTML, the browser incurs **zero** extra HTTP roundtrips
16/// to fetch the JS bridge file and skips the ES module graph parser.
17///
18/// The bridge file's own top-level `import` statements (tiny snippet helpers
19/// from `./snippets/<crate>-<hash>/inline{0,1}.js`) are stripped and the
20/// corresponding snippet bodies are inlined at the top of the bridge so the
21/// result is self-contained. Namespace imports (`import * as X from ...`)
22/// are rewritten to `var X = globalThis;` so the bridge can call `X.foo(...)`
23/// as it did in the original module — the hoisted function declarations from
24/// the snippets are already in the IIFE scope.
25pub(crate) async fn build_inline_bridge(
26    pkg_dir: &Path,
27    js_name: &str,
28    wasm_url: &str,
29) -> Result<String, EuvError> {
30    let js_path: std::path::PathBuf = pkg_dir.join(js_name);
31    let bridge_source: String =
32        read_to_string(&js_path)
33            .await
34            .map_err(|error: IoError| EuvError::IoPath {
35                message: String::from("Failed to read wasm-bindgen JS bridge"),
36                path: js_path.clone(),
37                error,
38            })?;
39    let snippet_bodies: String = collect_snippet_bodies(&bridge_source, pkg_dir).await?;
40    let stripped: String = strip_module_imports(&bridge_source);
41    let js_only: String = strip_trailing_module_exports(&stripped);
42    let wasm_url_json: String = serde_json_wasm_url(wasm_url);
43    Ok(format!(
44        "(function() {{\n\
45         {snippet_bodies}\n\
46         {js_only}\n\
47         var __euv_wasm_url = {wasm_url_json};\n\
48         if (typeof __wbg_init === 'function') {{\n\
49         __wbg_init(new URL(__euv_wasm_url, location.href).toString()).then(function() {{\n\
50         if (typeof main === 'function') main();\n\
51         }}).catch(function(e) {{ console.error('[euv] inline bridge init failed:', e); }});\n\
52         }} else if (typeof initSync === 'function') {{\n\
53         initSync();\n\
54         if (typeof main === 'function') main();\n\
55         }} else {{\n\
56         console.error('[euv] inline bridge: no __wbg_init or initSync exported');\n\
57         }}\n\
58         }})();\n"
59    ))
60}
61
62/// Returns the inline `<script>` body to use when JS bridge inlining is
63/// disabled (either by env var or by absence of the `pkg/<name>.js` file).
64/// This restores the classic `<script type="module">` import so the browser
65/// fetches the JS bridge as a normal module — useful as a fallback during
66/// wasm-pack output transitions.
67pub(crate) fn build_module_fallback_bridge(import_path: &str) -> String {
68    format!(
69        "    import init, {{ main }} from '{import_path}';\n    \
70         await init();\n    \
71         main();\n"
72    )
73}
74
75/// Returns `true` when the inline bridge should be skipped.
76///
77/// Honors the `EUV_NO_INLINE_BRIDGE` env var (any non-empty value disables
78/// inlining). Other future opt-out hooks (CLI flag) can short-circuit here
79/// without touching the templates.
80pub(crate) fn inline_bridge_disabled() -> bool {
81    matches!(std::env::var(EUV_NO_INLINE_BRIDGE_ENV), Ok(value) if !value.is_empty())
82}
83
84/// A namespace import: `import * as <alias> from "<spec>"`.
85struct NamespaceImport {
86    alias: String,
87    spec: String,
88}
89
90async fn collect_snippet_bodies(bridge_source: &str, pkg_dir: &Path) -> Result<String, EuvError> {
91    let mut bodies: String = String::new();
92    let mut namespace_imports: Vec<NamespaceImport> = Vec::new();
93    for line in bridge_source.lines() {
94        let trimmed: &str = line.trim();
95        if !trimmed.starts_with("import ") {
96            continue;
97        }
98        let rest: &str = match trimmed.strip_prefix("import ") {
99            Some(value) => value,
100            None => continue,
101        };
102        let spec: &str = match extract_import_spec(rest) {
103            Some(value) => value,
104            None => continue,
105        };
106        if is_namespace_import(rest)
107            && let Some(alias) = extract_namespace_alias(rest)
108        {
109            namespace_imports.push(NamespaceImport {
110                alias: alias.to_string(),
111                spec: spec.to_string(),
112            });
113        }
114        let body: Option<String> = read_snippet_module(pkg_dir, spec).await?;
115        if let Some(body) = body {
116            if !bodies.is_empty() {
117                bodies.push('\n');
118            }
119            bodies.push_str(&body);
120        }
121    }
122    for ns in &namespace_imports {
123        let snippet_path: std::path::PathBuf = resolve_snippet_path(pkg_dir, &ns.spec)?;
124        let exports: Vec<String> = if snippet_path.exists() {
125            let raw: String = read_to_string(&snippet_path)
126                .await
127                .map_err(|error: IoError| EuvError::IoPath {
128                    message: String::from(
129                        "Failed to read wasm-pack snippet module for namespace export scan",
130                    ),
131                    path: snippet_path.clone(),
132                    error,
133                })?;
134            extract_exported_function_names(&raw)
135        } else {
136            Vec::new()
137        };
138        if !bodies.is_empty() {
139            bodies.push('\n');
140        }
141        bodies.push_str(&format!("var {} = {{", ns.alias));
142        for (i, name) in exports.iter().enumerate() {
143            if i > 0 {
144                bodies.push(',');
145            }
146            bodies.push_str(name);
147            bodies.push(':');
148            bodies.push_str(name);
149        }
150        bodies.push_str("};\n");
151    }
152    Ok(bodies)
153}
154
155/// Scans a snippet module's source for `export function NAME(`
156/// declarations and returns the list of exported names. The IIFE relies on
157/// function declarations being hoisted, so the names referenced in the
158/// generated namespace object always resolve to the same hoisted bindings
159/// that wasm-bindgen's `__wbg_get_imports` expects.
160pub fn extract_exported_function_names(source: &str) -> Vec<String> {
161    let mut names: Vec<String> = Vec::new();
162    for line in source.lines() {
163        let trimmed: &str = line.trim();
164        if let Some(rest) = trimmed.strip_prefix("export function ") {
165            let name_end: usize =
166                match rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') {
167                    Some(value) => value,
168                    None => rest.len(),
169                };
170            let name: &str = &rest[..name_end];
171            if !name.is_empty() {
172                names.push(name.to_string());
173            }
174        }
175    }
176    names
177}
178
179pub fn is_namespace_import(rest: &str) -> bool {
180    rest.trim_start().starts_with('*')
181}
182
183pub fn extract_namespace_alias(rest: &str) -> Option<&str> {
184    let rest: &str = rest.trim_end_matches(';').trim();
185    if !rest.starts_with('*') {
186        return None;
187    }
188    let rest: &str = rest.strip_prefix('*')?.trim();
189    let rest: &str = rest.strip_prefix("as")?.trim();
190    let end: usize = match rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') {
191        Some(value) => value,
192        None => rest.len(),
193    };
194    let alias: &str = &rest[..end];
195    if alias.is_empty() { None } else { Some(alias) }
196}
197
198pub fn extract_import_spec(rest: &str) -> Option<&str> {
199    let rest: &str = rest.trim_end_matches(';').trim();
200    let from_idx: usize = rest.find(" from ")?;
201    let spec_part: &str = rest[from_idx + " from ".len()..].trim();
202    let spec: &str = spec_part
203        .trim_start_matches('\'')
204        .trim_start_matches('"')
205        .trim_end_matches('\'')
206        .trim_end_matches('"');
207    if spec.starts_with("./") || spec.starts_with("../") {
208        Some(spec)
209    } else {
210        None
211    }
212}
213
214async fn read_snippet_module(pkg_dir: &Path, spec: &str) -> Result<Option<String>, EuvError> {
215    let snippet_path: std::path::PathBuf = resolve_snippet_path(pkg_dir, spec)?;
216    if !snippet_path.exists() {
217        return Ok(None);
218    }
219    let bytes: Vec<u8> = read(&snippet_path)
220        .await
221        .map_err(|error: IoError| EuvError::IoPath {
222            message: String::from("Failed to read wasm-pack snippet module"),
223            path: snippet_path.clone(),
224            error,
225        })?;
226    let raw: String = String::from_utf8(bytes).map_err(|error: FromUtf8Error| EuvError::Utf8 {
227        message: String::from("Snippet module is not valid UTF-8"),
228        error,
229    })?;
230    Ok(Some(strip_snippet_export(&raw)))
231}
232
233fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result<std::path::PathBuf, EuvError> {
234    let base: &str = spec.trim_start_matches('.').trim_start_matches('/');
235    let mut path: std::path::PathBuf = pkg_dir.to_path_buf();
236    for segment in base.split('/') {
237        if segment.is_empty() || segment == "." {
238            continue;
239        }
240        if segment == ".." {
241            return Err(EuvError::Message(String::from(
242                "Snippet import spec escapes pkg directory",
243            )));
244        }
245        path.push(segment);
246    }
247    Ok(path)
248}
249
250fn strip_snippet_export(raw: &str) -> String {
251    let mut out: String = String::with_capacity(raw.len());
252    for line in raw.lines() {
253        let trimmed: &str = line.trim();
254        if trimmed.starts_with("export ") {
255            if let Some(rest) = trimmed.strip_prefix("export ") {
256                out.push_str(rest);
257                out.push('\n');
258            }
259        } else {
260            out.push_str(line);
261            out.push('\n');
262        }
263    }
264    out
265}
266
267fn strip_module_imports(source: &str) -> String {
268    let mut out: String = String::with_capacity(source.len());
269    for line in source.lines() {
270        let trimmed: &str = line.trim();
271        if trimmed.starts_with("import ") {
272            continue;
273        }
274        if trimmed.contains("import.meta.url") {
275            continue;
276        }
277        out.push_str(line);
278        out.push('\n');
279    }
280    out
281}
282
283fn strip_trailing_module_exports(source: &str) -> String {
284    let mut out: String = String::with_capacity(source.len());
285    for line in source.lines() {
286        let trimmed: &str = line.trim();
287        if trimmed.starts_with("export ") && trimmed.contains('{') && trimmed.contains('}') {
288            let inner: &str = &trimmed["export ".len()..];
289            let stripped: &str = match inner.strip_prefix('{') {
290                Some(value) => value,
291                None => {
292                    out.push_str(line);
293                    out.push('\n');
294                    continue;
295                }
296            };
297            let close: usize = match stripped.rfind('}') {
298                Some(value) => value,
299                None => {
300                    out.push_str(line);
301                    out.push('\n');
302                    continue;
303                }
304            };
305            let names_block: &str = &stripped[..close];
306            let inlined: String = inline_export_aliases(names_block.trim());
307            if !inlined.is_empty() {
308                out.push_str(&inlined);
309                out.push('\n');
310            }
311            continue;
312        }
313        if trimmed.starts_with("export function ") {
314            let rest: &str = match trimmed.strip_prefix("export function ") {
315                Some(value) => value,
316                None => {
317                    out.push_str(line);
318                    out.push('\n');
319                    continue;
320                }
321            };
322            let name_end: usize = match rest.find(|c: char| !c.is_alphanumeric() && c != '_') {
323                Some(value) => value,
324                None => rest.len(),
325            };
326            let name: &str = &rest[..name_end];
327            if !name.is_empty() {
328                out.push_str("function ");
329                out.push_str(name);
330                out.push_str(&rest[name_end..]);
331                out.push('\n');
332                continue;
333            }
334        }
335        out.push_str(line);
336        out.push('\n');
337    }
338    out
339}
340
341/// Converts the body of an `export { a, b as c, d as default }` clause into
342/// the corresponding runtime side effects: each alias becomes
343/// `<name> = <original>;` so the local function stays reachable through the
344/// `as` name. Pure renames where original and exported name match are kept
345/// as a no-op to keep the diff readable.
346///
347/// Exports of `wasm` (the `instance.exports` binding set by `__wbg_load`)
348/// are silently dropped: the IIFE inlines the bridge inside a regular
349/// `<script>` tag (not a module), so there is no `wasm` binding in scope
350/// to expose — the `main` call in the IIFE reaches the wasm exports via
351/// the `main` symbol that the user-defined Rust `#[wasm_bindgen] pub fn main`
352/// exposes.
353fn inline_export_aliases(block: &str) -> String {
354    let mut out: String = String::new();
355    for entry in block.split(',') {
356        let entry: &str = entry.trim();
357        if entry.is_empty() {
358            continue;
359        }
360        if let Some((original, exported)) = entry.split_once(" as ") {
361            let original: &str = original.trim();
362            let exported: &str = exported.trim();
363            if original == exported || exported == "default" || original == "wasm" {
364                continue;
365            }
366            out.push_str(exported);
367            out.push_str(" = ");
368            out.push_str(original);
369            out.push_str(";\n");
370        }
371    }
372    out
373}
374
375fn serde_json_wasm_url(url: &str) -> String {
376    let mut out: String = String::with_capacity(url.len() + 2);
377    out.push('"');
378    for ch in url.chars() {
379        match ch {
380            '"' => out.push_str("\\\""),
381            '\\' => out.push_str("\\\\"),
382            '\n' => out.push_str("\\n"),
383            '\r' => out.push_str("\\r"),
384            '\t' => out.push_str("\\t"),
385            '\u{08}' => out.push_str("\\b"),
386            '\u{0C}' => out.push_str("\\f"),
387            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
388            c => out.push(c),
389        }
390    }
391    out.push('"');
392    out
393}