Perry currently does not support dynamic import(). Most real-world usage passes a statically analyzable argument (string literal, const-propagated variable, template with resolvable interpolations, ternary over a finite set). We can support this subset with a pure compile-time rewrite — no runtime compiler, no dlopen. Truly dynamic paths remain unsupported and become a compile error (or opt into perry-jsruntime).
Goals
const mod = await import('./foo.ts') works.
const mod = await import(condition ? './a.ts' : './b.ts') works (both branches compiled, runtime dispatch).
const mod = await import(\./locales/${lang}.ts`)works whenlang`'s value set is resolvable (const array, union of literals).
import(userInput) → clear compile error at that site, with a --allow-quickjs-eval opt-in fallback.
Non-goals
- Shipping SWC/LLVM inside the produced binary.
eval() on arbitrary source — separate issue; route through perry-jsruntime (QuickJS).
- Runtime plugin loading via
.dylib/.so — also separate; would require a versioned runtime ABI.
Proposed design
1. HIR pass: resolve_dynamic_imports
New pass in perry-transform run after lowering, before codegen. For each Expr::DynamicImport { path, .. } site:
- Constant-fold
path to a ResolvedPath:
StringLit(\"./foo.ts\") → {\"./foo.ts\"}
LocalGet(x) where x has a single SSA def to a string literal → that literal
TemplateLit whose interpolations all resolve to finite literal sets → cartesian product
Conditional { then: A, else: B } → resolve(A) ∪ resolve(B)
- Anything else →
Unresolved
- If
Resolved({paths}): rewrite the site to a synthesized dispatch (see §3) and register every path in the compiler's module graph just like a static import.
- If
Unresolved: emit a diagnostic pointing at the call site; unless --allow-quickjs-eval, this is an error.
The pass reuses substitute_locals machinery — worth noting the v0.5.103/v0.5.104 lesson here: the walker must cover every wrapper expression that can contain the path. A catch-all _ => {} will silently miss cases (e.g. import((0, './foo.ts')), import(x satisfies string)).
2. Deferred module init
Perry currently runs all module top-level code eagerly at binary start (topo-sorted in perry/src/commands/compile.rs). import() must not evaluate its target until the returned Promise resolves.
Split modules into two buckets:
- Eager: reached only via static
import. Init unchanged.
- Deferred: reached via any resolved
import(). Emit top-level body as __perry_init_<module>() guarded by a per-module AtomicBool initialized flag. Static-import-plus-dynamic-import modules stay eager (the static edge already forces early init).
Deferred init must be thread-safe — another goroutine could import() the same module concurrently. Once-style guard; second caller waits.
3. Namespace objects
import('./x') resolves to a module namespace object, not its default export. Per resolved target, the compiler synthesizes a __perry_ns_<module> constant — a JSValue pointing to an object populated with every export of that module. PropertyGet on it should hit the existing object PIC; shape is fixed at compile time, so we can pre-bake the shape descriptor.
The dispatch emitted at the call site:
if resolved set size == 1:
__perry_init_foo(); return Promise.resolve(__perry_ns_foo)
else:
switch on path string:
case \"./a.ts\": __perry_init_a(); return Promise.resolve(__perry_ns_a)
case \"./b.ts\": __perry_init_b(); return Promise.resolve(__perry_ns_b)
default: return Promise.reject(new TypeError(\"unknown dynamic import path: \" + path))
Promise is already-resolved, so await import(...) stays on the microtask queue — no event-loop wakeup needed (cf. v0.5.96 js_wait_for_event).
4. Error mode for unresolved paths
Compile error message should be actionable:
error: dynamic import() path could not be resolved at compile time
--> app.ts:42:18
|
42 | const m = await import(userConfig.moduleName);
| ^^^^^^^^^^^^^^^^^^^^^ this value is only known at runtime
|
= help: Perry AOT-compiles all modules; dynamic paths must be resolvable during build.
= help: If you need runtime-variable paths, enumerate them in a ternary or pass --allow-quickjs-eval
to route through the JS interpreter (slower, no native codegen for the loaded module).
Tradeoffs
- Deferred init is a behavioral change even for code that doesn't use
import() — any module referenced dynamically is no longer guaranteed to run at startup. Worth a note in the docs, and probably a --explain-init-order debug flag.
- ABI stability of namespace objects: these are compiler-owned and not exposed across
.dylib boundaries, so the object-PIC coupling is fine. If we later add plugin loading, namespaces would need to become ABI-stable.
- Covers ~95% of real-world usage (lazy route modules, feature-flagged imports, platform splits, locale bundles). Codebases that compute paths from env vars or config hit the error and refactor to a ternary or registry object — acceptable for an AOT compiler.
Out-of-scope follow-ups
eval() via perry-jsruntime — separate issue.
- Runtime plugin
.dylib loading with versioned runtime ABI — separate issue.
- Tree-shaking unused branches of a resolved set (if only
./a.ts is reachable at runtime, don't link ./b.ts).
Perry currently does not support dynamic
import(). Most real-world usage passes a statically analyzable argument (string literal, const-propagated variable, template with resolvable interpolations, ternary over a finite set). We can support this subset with a pure compile-time rewrite — no runtime compiler, nodlopen. Truly dynamic paths remain unsupported and become a compile error (or opt intoperry-jsruntime).Goals
const mod = await import('./foo.ts')works.const mod = await import(condition ? './a.ts' : './b.ts')works (both branches compiled, runtime dispatch).const mod = await import(\./locales/${lang}.ts`)works whenlang`'s value set is resolvable (const array, union of literals).import(userInput)→ clear compile error at that site, with a--allow-quickjs-evalopt-in fallback.Non-goals
eval()on arbitrary source — separate issue; route throughperry-jsruntime(QuickJS)..dylib/.so— also separate; would require a versioned runtime ABI.Proposed design
1. HIR pass:
resolve_dynamic_importsNew pass in
perry-transformrun after lowering, before codegen. For eachExpr::DynamicImport { path, .. }site:pathto aResolvedPath:StringLit(\"./foo.ts\")→{\"./foo.ts\"}LocalGet(x)wherexhas a single SSA def to a string literal → that literalTemplateLitwhose interpolations all resolve to finite literal sets → cartesian productConditional { then: A, else: B }→resolve(A) ∪ resolve(B)UnresolvedResolved({paths}): rewrite the site to a synthesized dispatch (see §3) and register every path in the compiler's module graph just like a static import.Unresolved: emit a diagnostic pointing at the call site; unless--allow-quickjs-eval, this is an error.The pass reuses
substitute_localsmachinery — worth noting the v0.5.103/v0.5.104 lesson here: the walker must cover every wrapper expression that can contain the path. A catch-all_ => {}will silently miss cases (e.g.import((0, './foo.ts')),import(x satisfies string)).2. Deferred module init
Perry currently runs all module top-level code eagerly at binary start (topo-sorted in
perry/src/commands/compile.rs).import()must not evaluate its target until the returned Promise resolves.Split modules into two buckets:
import. Init unchanged.import(). Emit top-level body as__perry_init_<module>()guarded by a per-moduleAtomicBoolinitialized flag. Static-import-plus-dynamic-import modules stay eager (the static edge already forces early init).Deferred init must be thread-safe — another goroutine could
import()the same module concurrently.Once-style guard; second caller waits.3. Namespace objects
import('./x')resolves to a module namespace object, not its default export. Per resolved target, the compiler synthesizes a__perry_ns_<module>constant — a JSValue pointing to an object populated with every export of that module. PropertyGet on it should hit the existing object PIC; shape is fixed at compile time, so we can pre-bake the shape descriptor.The dispatch emitted at the call site:
Promise is already-resolved, so
await import(...)stays on the microtask queue — no event-loop wakeup needed (cf. v0.5.96js_wait_for_event).4. Error mode for unresolved paths
Compile error message should be actionable:
Tradeoffs
import()— any module referenced dynamically is no longer guaranteed to run at startup. Worth a note in the docs, and probably a--explain-init-orderdebug flag..dylibboundaries, so the object-PIC coupling is fine. If we later add plugin loading, namespaces would need to become ABI-stable.Out-of-scope follow-ups
eval()viaperry-jsruntime— separate issue..dylibloading with versioned runtime ABI — separate issue../a.tsis reachable at runtime, don't link./b.ts).