Skip to content

Dynamic imports: compile-time resolution for statically analyzable import() calls #100

@proggeramlug

Description

@proggeramlug

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:

  1. 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
  2. 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.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions