Skip to content

feat(compile): compile user .js/.cjs natively; support require() (#668)#1711

Merged
proggeramlug merged 2 commits into
mainfrom
feat/require-js-native
May 24, 2026
Merged

feat(compile): compile user .js/.cjs natively; support require() (#668)#1711
proggeramlug merged 2 commits into
mainfrom
feat/require-js-native

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

What

User .js/.cjs files (entry or project sources outside node_modules) now compile through the native AOT pipeline instead of the removed V8/QuickJS interpreter, and their CommonJS (require(...) / module.exports) is rewritten to ESM by the existing cjs_wrap path before SWC parses. As a result require("literal") lowers to a static namespace import and flows through the normal resolution + init-order + codegen machinery — closing the #668 require() compile-time bail for user code, and letting Perry consume plain CommonJS .js.

Why

Two long-standing limits, both deliberate stubs rather than architectural blockers:

cjs_wrap already performs exactly the requireimport + module.exportsexport rewrite (trusted for compilePackages CommonJS like pino/react), so this reuses that proven path instead of new HIR lowering.

Change

Two gates in crates/perry/src/commands/compile/collect_modules.rs:

  • should_use_js_runtime: scope the .js→js-runtime classification to node_modules only, so user .js compiles natively. node_modules .js keeps the legacy (gated) behavior.
  • was_cjs_wrapped: extend the CommonJS→ESM rewrite to any user file (!is_in_node_modules), not just compilePackages targets.

Blast radius

  • node_modules .js behavior is unchanged (still gated on --enable-js-runtime).
  • Only affects user .js/.cjs outside node_modules, which previously hard-errored — so this strictly expands what compiles.
  • A user .ts written in pure CommonJS (require, no top-level import/export) is now also wrapped; mixed ESM+require files are untouched (is_commonjs returns false when top-level ESM is present).

Validation

Raw Node test/parallel CommonJS tests — require('../common') + require('assert')/require('path') — compile natively ("2 native, 0 JavaScript") and run, producing real assertion results. __filename resolves correctly. (Companion PR adds the #800 radar that exercises this corpus.)

Follow-ups: dynamic require(expr) and CJS-module module.exports consumed by other modules remain out of scope (the hard tail).

User .js/.cjs files (entry or project sources outside node_modules) now
feed the native AOT pipeline instead of the removed V8 interpreter, and
their CommonJS (require(...) / module.exports) is rewritten to ESM by the
existing cjs_wrap path before SWC parses. So require("literal") lowers to
a static namespace import and flows through normal resolution, init-order
and codegen — closing the #668 require() bail for user code.

Two gates in collect_modules.rs:
- should_use_js_runtime: scope the .js->js-runtime classification to
  node_modules, so user .js compiles natively.
- was_cjs_wrapped: extend the CommonJS->ESM rewrite to any user file,
  not just compilePackages targets.

Validated: raw Node test/parallel CommonJS tests (require('../common') +
require('assert')/require('path')) compile and run natively.
@proggeramlug proggeramlug force-pushed the feat/require-js-native branch from ee1f8d1 to 14cf2b6 Compare May 24, 2026 19:55
#1712)

A coverage radar that runs Node's own test/parallel corpus (per API in
supported-apis.txt) under both Perry and Node, bucketing divergence by
exit-code parity: pass / diff / runtime-fail / compile-fail / node-skip.

Complements the hand-authored test-parity/node-suite by exercising Node's
canonical tests — the same corpus Deno and Bun use for Node-compat. Relies
on the companion require()/.js native-compile support to run the raw
CommonJS tests; a Perry-compilable CommonJS shim is staged as common/ so
the API under test is real while the harness is neutralized.

- scripts/node_core_subset.py — the runner (vendor a Node subset, stage,
  compare, write report.json).
- test-compat/node-core/shim/ — CommonJS replacements for test/common.
- .gitignore — re-include node-core source (the /test-* rule swept it).

Advisory tooling, not wired into CI gating yet. First sweep surfaced real
gaps (path.join not a first-class value, path.basename/normalize diffs).
@proggeramlug proggeramlug merged commit fda7f99 into main May 24, 2026
9 checks passed
@proggeramlug proggeramlug deleted the feat/require-js-native branch May 24, 2026 20:09
proggeramlug added a commit that referenced this pull request May 25, 2026
…) (#1726)

Binding a user CommonJS module via `const c = require('./m')` returned
undefined under `perry compile` (side-effect require and builtin-module
binding already worked). Two layers, both follow-ups to #668/#1711:

1. cjs_wrap self-shadowing: the importer adopts the alias `c` as the
   hoisted import name (`import c from './m'`) and the synthetic require
   returns `c`, but the original `const c = require('./m')` body line was
   only blanked when hoisting classes. Without classes it survived,
   redeclaring `c` inside the IIFE so require() returned the shadowed,
   not-yet-initialized binding. Now blank adopted-alias body lines
   regardless of hoisted classes.

2. import-edge misclassification: a relative/absolute/file: `.js` import
   was tagged ModuleKind::Interpreted by extension, routing it to the
   removed (#1696) V8 bridge so the default/named export symbols never
   linked (_perry_fn___v8___..._default). Now node_modules-gated, mirroring
   collect_modules' should_use_js_runtime: user `.js` outside node_modules
   is NativeCompiled.

Adds a cjs_wrap regression unit test. Validated: `const c = require('./m')`
binds the module object; raw Node test/parallel cases using
`const common = require('../common')` now run.
proggeramlug added a commit that referenced this pull request May 25, 2026
…ember (#1723) (#1741)

The #503 dynamic-stdlib-dispatch guard refused every computed member access on
a stdlib namespace (`path[platform]`, `fs[k]`, …), including the legit
`ns[dynamicKey].staticMember` shape where the dynamic index merely selects a
SUB-namespace (`path.win32` / `path.posix`) and the member actually used is a
source-visible static name. That is NOT the obfuscation the guard targets:
`ns[runtimeVar]()` hides the called method behind a runtime string, whereas
here the method/property name is in plaintext and fully auditable, and the
dynamic index only picks among a namespace's tiny, known set of sub-namespaces.

Surfaced by the #800 node-core radar: Node's own test-path-glob.js does
`path[platform].matchesGlob(pathStr, glob)` and hit a hard `(#503)` compile
error. The require()-imported form (#1711 cjs_wrap → default import) and the
ESM `import * as` / default-import forms were all refused identically; the
discriminator in this fix is the ENCLOSING ACCESS SHAPE, not the binding
origin, so all three forms now behave the same.

Fix: a new `stdlib_ns_subnamespace_static_access` recognizes
`<stdlib-ns>[<nonLiteral>] . <staticName>` (static = Ident or literal-string
key). When matched, a one-shot context flag tells the nested `ns[dynamicKey]`
lowering to skip the refusal; the guard consumes the flag on read, so a dynamic
key INSIDE the index (`ns[fs[evil]].x`) and chained dynamic access
(`ns[d1][d2]`) stay refused. The flag is set in two places — at the top of
`lower_member` (before its early-return arms lower `member.obj`) for the
property-access form, and after args in `lower_call` for the dot method-call
form, whose receiver never routes through `lower_member` for the `.method`
part. Args are lowered before the call-path flag is set, so a dynamic stdlib
key in an argument is still refused.

Verified end-to-end: `path[v].sep` / `path[v].delimiter` for win32/posix now
compile and match `node --experimental-strip-types` byte-for-byte. All 12
existing #503 security tests still pass (terminal `ns[k]`, `ns[k]()`,
`ns[a][b]()`, node_modules self-annotation, etc. stay refused), plus 5 new
unit tests.

Dynamic sub-namespace METHOD dispatch (`path[v].matchesGlob(...)` returning the
right value at runtime) is a separate runtime-layer gap tracked in #1740; this
change is the guard fix only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant