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: io::Error| 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         var __euv_base = (document.querySelector('base[href]') && document.baseURI) || location.href;\n         __wbg_init(new URL(__euv_wasm_url, __euv_base).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: io::Error| 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> =
220        read(&snippet_path)
221            .await
222            .map_err(|error: io::Error| EuvError::IoPath {
223                message: String::from("Failed to read wasm-pack snippet module"),
224                path: snippet_path.clone(),
225                error,
226            })?;
227    let raw: String = String::from_utf8(bytes).map_err(|error: FromUtf8Error| EuvError::Utf8 {
228        message: String::from("Snippet module is not valid UTF-8"),
229        error,
230    })?;
231    Ok(Some(strip_snippet_export(&raw)))
232}
233
234fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result<std::path::PathBuf, EuvError> {
235    let base: &str = spec.trim_start_matches('.').trim_start_matches('/');
236    let mut path: std::path::PathBuf = pkg_dir.to_path_buf();
237    for segment in base.split('/') {
238        if segment.is_empty() || segment == "." {
239            continue;
240        }
241        if segment == ".." {
242            return Err(EuvError::Message(String::from(
243                "Snippet import spec escapes pkg directory",
244            )));
245        }
246        path.push(segment);
247    }
248    Ok(path)
249}
250
251fn strip_snippet_export(raw: &str) -> String {
252    let mut out: String = String::with_capacity(raw.len());
253    for line in raw.lines() {
254        let trimmed: &str = line.trim();
255        if trimmed.starts_with("export ") {
256            if let Some(rest) = trimmed.strip_prefix("export ") {
257                out.push_str(rest);
258                out.push('\n');
259            }
260        } else {
261            out.push_str(line);
262            out.push('\n');
263        }
264    }
265    out
266}
267
268fn strip_module_imports(source: &str) -> String {
269    let mut out: String = String::with_capacity(source.len());
270    for line in source.lines() {
271        let trimmed: &str = line.trim();
272        if trimmed.starts_with("import ") {
273            continue;
274        }
275        if trimmed.contains("import.meta.url") {
276            continue;
277        }
278        out.push_str(line);
279        out.push('\n');
280    }
281    out
282}
283
284fn strip_trailing_module_exports(source: &str) -> String {
285    let mut out: String = String::with_capacity(source.len());
286    for line in source.lines() {
287        let trimmed: &str = line.trim();
288        if trimmed.starts_with("export ") && trimmed.contains('{') && trimmed.contains('}') {
289            let inner: &str = &trimmed["export ".len()..];
290            let stripped: &str = match inner.strip_prefix('{') {
291                Some(value) => value,
292                None => {
293                    out.push_str(line);
294                    out.push('\n');
295                    continue;
296                }
297            };
298            let close: usize = match stripped.rfind('}') {
299                Some(value) => value,
300                None => {
301                    out.push_str(line);
302                    out.push('\n');
303                    continue;
304                }
305            };
306            let names_block: &str = &stripped[..close];
307            let inlined: String = inline_export_aliases(names_block.trim());
308            if !inlined.is_empty() {
309                out.push_str(&inlined);
310                out.push('\n');
311            }
312            continue;
313        }
314        if trimmed.starts_with("export function ") {
315            let rest: &str = match trimmed.strip_prefix("export function ") {
316                Some(value) => value,
317                None => {
318                    out.push_str(line);
319                    out.push('\n');
320                    continue;
321                }
322            };
323            let name_end: usize = match rest.find(|c: char| !c.is_alphanumeric() && c != '_') {
324                Some(value) => value,
325                None => rest.len(),
326            };
327            let name: &str = &rest[..name_end];
328            if !name.is_empty() {
329                out.push_str("function ");
330                out.push_str(name);
331                out.push_str(&rest[name_end..]);
332                out.push('\n');
333                continue;
334            }
335        }
336        out.push_str(line);
337        out.push('\n');
338    }
339    out
340}
341
342/// Converts the body of an `export { a, b as c, d as default }` clause into
343/// the corresponding runtime side effects: each alias becomes
344/// `<name> = <original>;` so the local function stays reachable through the
345/// `as` name. Pure renames where original and exported name match are kept
346/// as a no-op to keep the diff readable.
347///
348/// Exports of `wasm` (the `instance.exports` binding set by `__wbg_load`)
349/// are silently dropped: the IIFE inlines the bridge inside a regular
350/// `<script>` tag (not a module), so there is no `wasm` binding in scope
351/// to expose — the `main` call in the IIFE reaches the wasm exports via
352/// the `main` symbol that the user-defined Rust `#[wasm_bindgen] pub fn main`
353/// exposes.
354fn inline_export_aliases(block: &str) -> String {
355    let mut out: String = String::new();
356    for entry in block.split(',') {
357        let entry: &str = entry.trim();
358        if entry.is_empty() {
359            continue;
360        }
361        if let Some((original, exported)) = entry.split_once(" as ") {
362            let original: &str = original.trim();
363            let exported: &str = exported.trim();
364            if original == exported || exported == "default" || original == "wasm" {
365                continue;
366            }
367            out.push_str(exported);
368            out.push_str(" = ");
369            out.push_str(original);
370            out.push_str(";\n");
371        }
372    }
373    out
374}
375
376fn serde_json_wasm_url(url: &str) -> String {
377    let mut out: String = String::with_capacity(url.len() + 2);
378    out.push('"');
379    for ch in url.chars() {
380        match ch {
381            '"' => out.push_str("\\\""),
382            '\\' => out.push_str("\\\\"),
383            '\n' => out.push_str("\\n"),
384            '\r' => out.push_str("\\r"),
385            '\t' => out.push_str("\\t"),
386            '\u{08}' => out.push_str("\\b"),
387            '\u{0C}' => out.push_str("\\f"),
388            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
389            c => out.push(c),
390        }
391    }
392    out.push('"');
393    out
394}