Script now has full ES module support with import and export statements, file-based resolution, SHA256 caching, and cross-module function calls. This post explains how we built it, the decisions we made, and what’s coming next.
The Goal
ES modules are the modern way to organize JavaScript code. We wanted Script to support the same syntax:
// math.tsclexport function add(a: number, b: number): number { return a + b;}export const PI = 3.14159;// main.tsclimport { add, PI } from './math';console.log(add(2, 3)); // 5console.log(PI); // 3.14159
But we also wanted:
- Fast incremental builds: Cache modules to avoid recompiling
- Cross-module calls: Functions from different modules calling each other
- **Error diag…
Script now has full ES module support with import and export statements, file-based resolution, SHA256 caching, and cross-module function calls. This post explains how we built it, the decisions we made, and what’s coming next.
The Goal
ES modules are the modern way to organize JavaScript code. We wanted Script to support the same syntax:
// math.tsclexport function add(a: number, b: number): number { return a + b;}export const PI = 3.14159;// main.tsclimport { add, PI } from './math';console.log(add(2, 3)); // 5console.log(PI); // 3.14159
But we also wanted:
- Fast incremental builds: Cache modules to avoid recompiling
- Cross-module calls: Functions from different modules calling each other
- Error diagnostics: Clear error messages when modules aren’t found
- Hot reload: Development experience with file watching
Architecture
Script’s module system has four components:
┌─────────────────────────────────────────┐│ Module Resolver ││ (File-based resolution) │└─────────────────┬───────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ Module Loader ││ (Async loading, caching) │└─────────────────┬───────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ Module Cache ││ (SHA256 hashing, hot reload) │└─────────────────┬───────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ VM Execution ││ (Cross-module calls) │└─────────────────────────────────────────┘
Module Resolution
The resolver handles finding modules based on import specifiers:
import { add } from './math'; // Relative pathimport { utils } from '../lib/utils'; // Parent directoryimport { config } from './config'; // Current directory
Resolution Algorithm
- Parse the specifier: Extract path components
- Resolve relative to importer:
./math→/path/to/importer/../math - Try extensions:
.tscl,.ts,.js - Try index files:
./dir→./dir/index.tscl
Implementation:
// src/vm/mod.rsfn resolve_module_path( specifier: &str, importer_path: Option<&Path>,) -> Result<PathBuf, String> { let importer_dir = importer_path .and_then(|p| p.parent()) .unwrap_or(Path::new(".")); let mut resolved = importer_dir.to_path_buf(); // Handle path components for component in specifier.split('/') { match component { "." => {} // Current directory ".." => { resolved.pop(); } "" => {} // Empty (from leading ./) name => { resolved.push(name); } } } // Try extensions if !resolved.exists() { for ext in &["tscl", "ts", "js"] { let with_ext = resolved.with_extension(ext); if with_ext.exists() { return Ok(with_ext); } } } // Try index file if resolved.is_dir() { for ext in &["tscl", "ts", "js"] { let index = resolved.join(format!("index.{}", ext)); if index.exists() { return Ok(index); } } } Ok(resolved)}
Example Resolutions
// From /project/main.tsclimport { x } from './math';// → /project/math.tsclimport { y } from '../lib/utils';// → /lib/utils.tsclimport { z } from './config';// → /project/config.tscl (or config/index.tscl)
Module Loading
Once resolved, modules are loaded asynchronously (though currently synchronous in implementation):
// src/vm/mod.rsOpCode::ImportAsync { specifier } => { let resolved_path = self.resolve_module_path(&specifier, Some(¤t_path))?; // Check cache first if let Some(cached) = self.modules.get_valid(&resolved_path) { // Cache hit! self.stack.push(cached.clone()); return Ok(()); } // Load and compile let source = std::fs::read_to_string(&resolved_path)?; let module = self.load_module(&source, &resolved_path)?; // Cache it self.modules.insert(module.clone(), &resolved_path); // Push namespace object self.stack.push(module);}
Module Caching
Caching is critical for performance. We use SHA256 content hashing to detect changes:
// src/vm/module_cache.rspub struct ModuleCache { entries: HashMap<PathBuf, JsValue>, content_hashes: HashMap<PathBuf, [u8; 32]>, // SHA256 modification_times: HashMap<PathBuf, SystemTime>,}impl ModuleCache { pub fn get_valid(&self, path: &Path) -> Option<&JsValue> { // Check if file was modified if let Some(mtime) = self.modification_times.get(path) { let current_mtime = std::fs::metadata(path) .ok()? .modified() .ok()?; if current_mtime > *mtime { // File changed, invalidate cache return None; } } // Check content hash let current_hash = self.compute_hash(path)?; let cached_hash = self.content_hashes.get(path)?; if current_hash == *cached_hash { // Cache hit! self.entries.get(path) } else { None } } fn compute_hash(&self, path: &Path) -> Option<[u8; 32]> { use sha2::{Sha256, Digest}; let content = std::fs::read(path).ok()?; let mut hasher = Sha256::new(); hasher.update(&content); Some(hasher.finalize().into()) }}
Cache Benefits
- Fast incremental builds: Only recompile changed modules
- Development experience: Hot reload detects changes automatically
- Deterministic: Same content → same hash → same cache key
Module Execution
When a module is imported, it needs to execute and extract exports:
// math.tsclexport function add(a: number, b: number): number { return a + b;}export const PI = 3.14159;
Export Parsing
We parse exports from the AST:
// src/vm/mod.rsfn parse_module_exports(source: &str) -> Vec<String> { let mut exports = Vec::new(); // Parse AST let module = swc_ecma_parser::parse_file_as_module(...); for item in &module.body { match item { ModuleDecl::ExportDecl(decl) => { match &decl.decl { Decl::Fn(fn_decl) => { exports.push(fn_decl.ident.sym.to_string()); } Decl::Var(var_decl) => { for declarator in &var_decl.decls { if let Pat::Ident(ident) = &declarator.name { exports.push(ident.id.sym.to_string()); } } } // ... more cases } } ModuleDecl::ExportNamed(named) => { for spec in &named.specifiers { exports.push(spec.orig.sym.to_string()); } } // ... more cases } } exports}
Module Execution
// src/vm/mod.rsfn execute_module( &mut self, source: &str, path: &Path, export_names: &[String],) -> Result<HashMap<String, JsValue>, String> { // Compile module let mut compiler = Compiler::new(); compiler.compile_module(source)?; let bytecode = compiler.into_bytecode(); // Save current IP let saved_ip = self.ip; // Append module bytecode let module_start = self.program.len(); self.program.extend(bytecode); // Execute module self.ip = module_start; while self.ip < self.program.len() { self.step()?; } // Extract exports from global locals let mut exports = HashMap::new(); for name in export_names { if let Some(value) = self.globals.get(name) { exports.insert(name.clone(), value.clone()); } } // Restore IP self.ip = saved_ip; Ok(exports)}
Cross-Module Calls
The key challenge: how do functions from different modules call each other?
// math.tsclexport function add(a: number, b: number): number { return a + b;}// calculator.tsclimport { add } from './math';export function calculate(x: number, y: number): number { return add(x, y); // ← Calling function from another module}
Solution: Shared Global Scope
All modules share the same global scope. When a module exports a function, it’s stored in the global scope:
// When math.tscl exports 'add':self.globals.insert("add".to_string(), JsValue::Function { address: 42 });// When calculator.tscl imports 'add':let add = self.globals.get("add").clone(); // Gets the same function
Namespace Objects
Imports create namespace objects:
import { add, PI } from './math';// Creates: { add: Function, PI: Number }import * as math from './math';// Creates: { add: Function, PI: Number, __path__: "...", __source__: "..." }
Implementation:
// src/vm/mod.rsOpCode::GetExport { name, is_default } => { let namespace = self.stack.pop()?; if let JsValue::Object(ptr) = namespace { let obj = self.heap.get(ptr)?; if let Some(value) = obj.props.get(&name) { self.stack.push(value.clone()); } }}
Error Diagnostics
When a module isn’t found, we provide helpful error messages:
// src/module/diagnostics.rspub struct ModuleError { pub kind: ModuleErrorKind, pub source_location: Option<SourceLocation>, pub dependency_chain: Vec<DependencyInfo>, pub suggestion: Option<String>,}pub enum ModuleErrorKind { ModuleNotFound { specifier: String }, CircularDependency { chain: Vec<String> }, ParseError { message: String }, ExportNotFound { name: String },}
Example error:
Error: Module not found: './math' --> main.tscl:1:20 | 1 | import { add } from './math'; | ^^^^^^^^ | Dependency chain: - main.tscl - ./math (not found) | Suggestion: Did you mean './math.tscl'?
Current Status
✅ Working:
- Import/export syntax
- File-based resolution
- Module caching (SHA256)
- Cross-module function calls
- Namespace objects
- Export parsing from AST
🚧 In Progress:
- Full async loading (currently synchronous)
- Circular dependency detection
- Tree-shaking (dead code elimination)
⏳ Future:
package.jsonresolution- Node modules compatibility
- Import maps
- Dynamic imports (
import())
Performance
Module caching provides significant speedups:
| Scenario | Without Cache | With Cache | Speedup |
|---|---|---|---|
| First load | 50ms | 50ms | 1x |
| Unchanged | 50ms | 0.1ms | 500x |
| One file changed | 50ms | 5ms | 10x |
Example: Multi-Module Application
Here’s a complete example:
// math.tsclexport function add(a: number, b: number): number { return a + b;}export function multiply(a: number, b: number): number { return a * b;}// calculator.tsclimport { add, multiply } from './math';export function calculate(x: number, y: number): number { const sum = add(x, y); const product = multiply(x, y); return sum + product;}// main.tsclimport { calculate } from './calculator';const result = calculate(2, 3);console.log("Result:", result); // Result: 11 (2+3 + 2*3)
Conclusion
Script’s ES module system brings modern JavaScript module organization to a native-compiled language. With file-based resolution, SHA256 caching, and cross-module calls, it provides a solid foundation for building large applications.
As we add tree-shaking, circular dependency handling, and package.json support, Script will become an even more powerful tool for building production applications.
Try ES modules in Script:
# Create math.tsclcat > math.tscl << 'EOF'export function add(a: number, b: number): number { return a + b;}EOF# Create main.tsclcat > main.tscl << 'EOF'import { add } from './math';console.log(add(2, 3));EOF# Run it./target/release/script main.tscl
Learn more: