Skip to content

feat(compile): #348 Phase A — native CommonJS support for compilePackages#359

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-issue-348-ink-compile
Apr 30, 2026
Merged

feat(compile): #348 Phase A — native CommonJS support for compilePackages#359
proggeramlug merged 1 commit into
mainfrom
worktree-issue-348-ink-compile

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Closes the React-class blocker surfaced in the #348 scoping pass: any package shipping CJS (the dominant shape on npm — React 18, react-dom, react-reconciler, scheduler, lots of older libs) hit Undefined symbols: _perry_fn_<pkg>_<file>__<name> at link time because Perry's native compilePackages pipeline only handled ESM. module.exports = require('./cjs/<file>') lowered module / require to bare-identifier-zero, so every named export silently vanished.

Approach

Source-level CJS-to-ESM wrap before SWC parses, modeled on the V8-fallback wrap at perry-jsruntime/src/modules.rs:481.

New crates/perry/src/commands/compile/cjs_wrap.rs (~210 LOC + 7 unit tests):

  • is_commonjs heuristic — matches module.exports / exports. / require( without import .
  • wrap_commonjs — hoists each require('X') as import _req_N from 'X', wraps the body in an IIFE that defines module = { exports: {} } plus a synchronous require(specifier) dispatching to the _req_N bindings, returns module.exports as _cjs, then emits export default _cjs plus export const X = _cjs.X for each detected named export.
  • Two named-export sources unioned: exports.X = ... patterns in the file itself (the existing jsruntime regex), AND for trivial re-export wrappers (module.exports = require('./X')) the patterns of the recursively-required target file. Without the second source, react/index.js (whose only meaningful statements are two conditional module.exports = require(...) calls inside a process.env.NODE_ENV check) produces zero named exports of its own and the link still fails.

Wired in one place: collect_modules.rs:114 after read_to_string, gated on is_in_compiled_pkg so user TypeScript and ESM-shaped packages skip the wrap untouched.

Verified

End-to-end on a react-only sandbox:

import { createContext, useState } from "react";
const Ctx = createContext(null);
console.log(typeof createContext, typeof useState, typeof Ctx);

compiles to a 2.2 MB single-file native binary, runs to exit 0, prints function function function. Matches Node on lines 1–2; line 3 returns function instead of Node's object — separate cross-module CJS-call return-value typeof bug, follow-up.

End-to-end ink compile flips 46/67 modules from JS-runtime fallback to native (was 0/67 — every react.* / ink/build/* file now compiles natively). The remaining 21 modules still routed to V8 fallback are ink's transitive deps not declared in compilePackages (chalk / scheduler / react-reconciler / yoga-layout / etc) — separate follow-up.

Out of scope (separate sub-issues under #348)

  • Dynamic require(someVar) — throws at IIFE runtime, unrepresentable as static ESM.
  • process.env.NODE_ENV static branch evaluation — both branches' requires hoist as imports, harmless modulo compile time.
  • Cross-module CJS-call return-value typeof bug.
  • Ink's transitive deps (process import resolution, dynamic import(), yoga, react-reconciler).
  • TUI gap: readline + tty.setRawMode + raw-mode stdin reader #347 — TUI primitives (raw stdin / readline / setRawMode).

Test plan

  • cargo build --release -p perry clean
  • Unit tests in cjs_wrap.rs (detection heuristics, regex extraction, end-to-end wrap shape)
  • react-only sandbox compiles + runs + matches Node on lines 1–2
  • ink sandbox: 46/67 native (was 0/67)
  • Worktree rebased onto current main (v0.5.432) before bump
  • CLAUDE.md "Recent Changes" entry + workspace version bump 0.5.432 → 0.5.433

Issue: #348

…ages (v0.5.433)

Closes the React-class blocker surfaced in the #348 scoping pass: any
package shipping CJS (the dominant shape on npm — React 18, react-dom,
react-reconciler, scheduler, lots of older libs) hit
`Undefined symbols: _perry_fn_<pkg>_<file>__<name>` at link time
because Perry's native `compilePackages` pipeline only handles ESM,
and `module.exports = require('./cjs/<file>')` lowered `module` /
`require` to bare-identifier-zero — every named export silently
vanished.

Implementation: source-level CJS-to-ESM wrap before SWC parses, modeled
on the V8-fallback wrap at perry-jsruntime/src/modules.rs:481. New
crates/perry/src/commands/compile/cjs_wrap.rs:

  - is_commonjs heuristic (matches module.exports / exports. / require(
    without import).
  - wrap_commonjs hoists each `require('X')` as `import _req_N from 'X'`,
    wraps the body in an IIFE that defines module = { exports: {} } plus
    a synchronous require(specifier) dispatching to the _req_N bindings,
    returns module.exports as _cjs, then emits `export default _cjs`
    plus `export const X = _cjs.X` for each detected named export.
  - Two named-export sources unioned: `exports.X = ...` patterns in the
    file itself (the existing jsruntime regex), AND for "trivial
    re-export wrappers" (`module.exports = require('./X')`) the patterns
    of the recursively-required target file. Without the second source,
    react/index.js (whose only meaningful statements are two conditional
    `module.exports = require(...)` calls inside a process.env.NODE_ENV
    check) produces zero named exports of its own and the link still
    fails.

Wired in one place: collect_modules.rs:114 after read_to_string, gated
on is_in_compiled_pkg so user TypeScript and ESM-shaped packages skip
the wrap untouched.

Verified end-to-end on a react-only sandbox:

    import { createContext, useState } from "react";
    const Ctx = createContext(null);
    console.log(typeof createContext, typeof useState, typeof Ctx);

compiles to a 2.2 MB single-file native binary, runs to exit 0, prints
`function function function`. Matches Node on lines 1-2; line 3
returns `function` instead of Node's `object` — separate
cross-module CJS-call return-value typeof bug, follow-up under #348
Phase B.

End-to-end ink compile flips 46/67 modules from JS-runtime fallback to
native (was 0/67 — every react.*/ink/build/* file now compiles
natively). The remaining 21 modules still routed to V8 fallback are
ink's transitive deps not declared in compilePackages
(chalk / scheduler / react-reconciler / yoga-layout / etc) — Phase B.

Out of scope (separate sub-issues under #348):
  - dynamic `require(someVar)` — throws at IIFE runtime, unrepresentable
    as static ESM
  - process.env.NODE_ENV static evaluation — both branches' requires
    hoist as imports, harmless modulo compile time
  - cross-module CJS-call typeof bug
  - ink's transitive deps (process import resolution, dynamic import(),
    yoga, react-reconciler)

7 unit tests in cjs_wrap.rs covering: detection on each shape, named-
export extraction skipping __esModule, require-specifier dedup, end-to-
end wrap round-trip with default + named exports + require import
hoisting.

New direct dep `regex = "1"` in crates/perry/Cargo.toml (already in
the transitive surface via perry-jsruntime).
@proggeramlug proggeramlug merged commit 3609a48 into main Apr 30, 2026
4 of 8 checks passed
@proggeramlug proggeramlug deleted the worktree-issue-348-ink-compile branch April 30, 2026 16:52
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