Skip to content

compilePackages: ambient/computed require(expr) support (createRequire-backed) — two-tier plan #5389

Description

@proggeramlug

Tracks #5373. A bare/computed require(expr) in a compilePackages-compiled module throws ReferenceError: require is not defined because no ambient require binding is emitted. Only a literal require('pkg') is rewritten to an import at compile time; a non-literal callee falls through the HIR ident-read path to js_global_get_or_throw_unresolved("require").

This issue scopes the fix into two tiers. Tier 1 closes #5373 as filed. Tier 2 extends coverage to const-foldable computed specifiers and is explicitly bounded by the AOT model.

Background

Three independent layers rewrite literal require/createRequire to imports at compile time, so the runtime closure is never consulted for them:

  • HIR try_require_literalcrates/perry-hir/src/lower/expr_call/intrinsics.rs:40-111 (bails on a non-literal arg, returns Ok(None)).
  • var-decl destructuring — crates/perry-hir/src/destructuring/var_decl_sources.rs.
  • source-text createRequire transform — crates/perry/src/commands/compile/collect_modules/create_require_transform.rs.

The runtime require closure that createRequire(...) produces (crates/perry-runtime/src/module_require.rs, require_thunk) only supports Node builtins — any package/relative specifier throws ERR_PERRY_UNSUPPORTED_CREATE_REQUIRE ("supports built-in modules only"). So "createRequire works" in #5373 is true only because the repro's computed specifier resolves to the builtin node:os.

No ambient global require binding exists: require is absent from is_known_global_identifier_name (crates/perry-hir/src/lower/lower_expr.rs:57-96), so a bare require(expr) lowers to the throwing js_global_get_or_throw_unresolved("require") (lower_expr.rs:666-689).

The hard architectural boundary

A require(expr) in an ahead-of-time native binary can only resolve to a module that was discovered and linked at compile time — a runtime-computed string cannot pull in code that isn't in the executable. Perry already enforces exactly this for dynamic import(expr): a specifier the const-folder/globber can't reduce to a finite literal set is rejected (strict mode: compile error; default mode: a deferred throw at the call site — "dynamic import() of a runtime-computed path cannot run in an ahead-of-time compiled binary"; see crates/perry/src/commands/compile/collect_modules.rs:698-841 and crates/perry-codegen/src/expr/dyn_extern_i18n.rs:116-369).

Therefore "dynamic require" can never mean "resolve arbitrary runtime strings." Both tiers respect this boundary.

Tier 1 — ambient require backed by createRequire (closes #5373)

Stop emitting ReferenceError. In the HIR ident-read fall-through (crates/perry-hir/src/lower/lower_expr.rs:666-689), add an arm so a bare unshadowed require lowers to a real createRequire-backed binding instead of js_global_get_or_throw_unresolved("require").

Effects:

Scope guard: gate to external/compilePackages modules (ctx.is_external_module). In user source, the bare-require compile-time error is deliberate ("Closes #668", points users at static imports) and must not regress into a silent runtime path.

Tests: crates/perry/tests/create_require_package.rs, crates/perry/tests/module_import_forms.rs:526-586 (currently asserts the "unsupported interop" diagnostic).

Tier 2 — const-foldable / globbable computed require (follow-on)

Extend coverage from builtins to statically-resolvable package/relative computed specifiers — e.g. require(\./plugins/${name}`), require(cond ? 'a' : 'b'). Recommended approach reuses the proven dynamic-import()` pipeline:

  • const-fold/glob the arg to a finite specifier set at compile time (machinery exists: collect_modules.rs:698-841, expand_dynamic_import_glob, 64-target cap),
  • add each target to the module graph as a dynamic edge (per_module_dyn_import_targets),
  • emit a js_string_equals dispatch chain → <prefix>__init() + load @__perry_ns_<prefix>,
  • synchronous + CJS namespace shape (require returns the value directly, not a promise; the one real divergence from import() — needs care around module.exports vs ESM namespace),
  • genuinely-computed specifiers fall through to require_thunk → builtin-or-throw (unchanged graceful behavior).

Out of scope (by design): genuinely runtime-computed package strings (e.g. ['n','o',…].join('') resolving to a package, network/dynamic specifiers). Same boundary as dynamic import().

Rejected alternative: a net-new runtime spec→{init_fn, ns_global} registry looked up in require_thunk. It resolves the same set of modules as the approach above (the AOT limit is unchanged), at the cost of net-new infra in artifacts.rs/compile.rs/module_require.rs. Only revisit if require.cache identity semantics force a single canonical namespace store.

Plan

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions