ES Modules in Script: File-Based Resolution, Caching, and Cross-Module Calls
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.tscl
export function add(a: number, b: number): number {
return a + b;
}
export const PI = 3.14159;
// main.tscl
import { add, PI } from './math';
console.log(add(2, 3)); // 5
console.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 path
import { utils } from '../lib/utils'; // Parent directory
import { 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.rs
fn 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.tscl
import { x } from './math';
// → /project/math.tscl
import { y } from '../lib/utils';
// → /lib/utils.tscl
import { 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.rs
OpCode::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.rs
pub 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.tscl
export 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.rs
fn 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.rs
fn 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.tscl
export function add(a: number, b: number): number {
return a + b;
}
// calculator.tscl
import { 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.rs
OpCode::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.rs
pub 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.tscl
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
// calculator.tscl
import { 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.tscl
import { 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.tscl
cat > math.tscl << 'EOF'
export function add(a: number, b: number): number {
return a + b;
}
EOF
# Create main.tscl
cat > main.tscl << 'EOF'
import { add } from './math';
console.log(add(2, 3));
EOF
# Run it
./target/release/script main.tscl
Learn more: