Skip to content

feat(jsruntime): #1021 — Perry-class V8 exposure + Reflect.metadata bridge — NestJS sweep#1117

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-agent-a9bd3f473788a529a
May 19, 2026
Merged

feat(jsruntime): #1021 — Perry-class V8 exposure + Reflect.metadata bridge — NestJS sweep#1117
proggeramlug merged 1 commit into
mainfrom
worktree-agent-a9bd3f473788a529a

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Summary

Closes the last NestJS surface in #1021. The smoke sweep flips from
body={"message":"Cannot GET /ping","error":"Not Found","statusCode":404}
(no route registered) → body=pong (handler runs).

What landed

The prior agent diagnosed this as a Reflect-metadata bridge gap, but the
metadata wiring was already in place — five layered V8-fallback gaps
prevented NestJS from even seeing the AppModule at scan time. All five
are required for the route to register; isolating any one would leave the
sweep at 404.

  1. V8 wrapper class prototype now carries methods. native_class_to_v8
    populates metatype.prototype from Perry's CLASS_VTABLE_REGISTRY so
    Object.getPrototypeOf(new metatype())[methodName] (NestJS's
    paths-explorer/MetadataScanner walk) resolves to a real function.
  2. new metatype() preserves prototype chain. native_class_constructor
    returns args.this() (whose [[Prototype]] V8 already populated)
    instead of overriding with a fresh empty object.
  3. Class names cross the boundary. Added a CLASS_NAMES registry +
    js_register_class_name codegen-emitted FFI, then surface the name
    through v8::Function::set_name — without this every module hashed to
    the same empty-string token under NestJS's ModuleTokenFactory.
  4. crypto.createHash().digest() returns real digests. Added
    op_perry_hash (sha1/sha256/sha384/sha512/md5) and rewrote the
    V8-side stub to mirror the working createHmac path.
  5. perf_hooks.performance.now is real. Replaced the default empty
    stub with a Date.now()-backed shape — without this NestJS's
    ModuleTokenFactory.create() threw Cannot read properties of undefined (reading 'now') during dynamic-module compile and bailed
    before any user modules were inserted.

Plus three sub-fixes to keep the metadata round-trip clean:

  • install_reflect_metadata_bridge is re-run after every module
    evaluation (reflect-metadata overwrites our wrappers; idempotent
    re-install survives that).
  • Perry closures crossing into V8 now have stable identity
    (NATIVE_CLOSURE_HANDLES cache + __perry_closure_ptr property), so
    descriptor.value and prototype['methodName'] resolve to the same
    V8 function — load-bearing for any WeakMap-keyed metadata store.
  • v8_to_native_metadata_target recognizes the new closure-ptr property
    and round-trips through Perry's POINTER_TAG | ptr identity.

Test plan

  • cargo test --release -p perry-codegen --test manifest_consistency — 4/4 pass
  • cargo build --release -p perry-runtime -p perry-stdlib -p perry-jsruntime -p perry
  • NestJS reproducer (/tmp/perry-compat-sweep/nestjs with the
    @Controller() + @Get('/ping') shape from the task brief) returns
    body=pong
  • test-files/test_reflect_metadata.ts (added in this PR) exercises
    defineMetadata/getMetadata/hasMetadata + the decorator-factory shape

Deferred

  • Method bodies that read this against a V8-allocated instance still
    dispatch with TAG_UNDEFINED as the receiver. The canonical NestJS
    controller-handler shape (the sweep target) doesn't use this so this
    matches expected behavior. Properly wiring instance-as-this would
    require allocating a real Perry ObjectHeader for the V8-wrapper
    instances (or routing through the handle table) — separate work item.
  • The class-ref INT32_TAG | class_id collision in the V8 bridge
    (existing pre-fix caveat in bridge.rs) is unchanged — out of scope
    for compat: async lowering blocks self-fetch (express/fastify/nestjs/hono) #1021.

No version bump or changelog change (external-contributor convention).

…ross-boundary wire

Flips the NestJS smoke from `body={"message":"Cannot GET /ping",...}` (404,
no route registered) to `body=pong` (the route is discovered and the
handler runs).

The previous attempt (#1067) wired the Reflect-metadata bridge JS but
NestJS still saw zero modules at scan time. End-to-end the route never
registered because four separate gaps stacked on top of each other:

1. **V8 class wrapper had no methods on the prototype.** `native_class_to_v8`
   returned a `v8::Function` whose `.prototype` was V8's default empty
   object, so `Object.getPrototypeOf(new metatype())[name]` (the path
   NestJS's `MetadataScanner.getAllMethodNames` and `paths-explorer`
   walk) resolved to `undefined`. Now `populate_native_class_v8_prototype`
   mirrors each entry in `CLASS_VTABLE_REGISTRY` onto the wrapper's
   `.prototype` as a `v8::Function` that re-dispatches through the
   vtable's `func_ptr` directly (NOT through `js_native_call_method` —
   that path walks `jsval.as_pointer()` on the receiver, which is junk
   for a V8 object instance and returned the class_id integer instead
   of the method body's return value).

2. **`new metatype()` discarded the prototype chain.** `native_class_constructor`
   used to `retval.set(v8::Object::new(scope))`, overriding the implicit
   `this` whose `[[Prototype]]` V8 already pointed at the populated
   wrapper prototype. Now we return `args.this()` for construct calls.

3. **Class names were lost on the boundary.** `metatype.name` came back
   `""`, which made NestJS's `ModuleTokenFactory` hash every module under
   the same empty-string token — `modules.size` was always 1 (only the
   internal core module won the slot). Added a per-`class_id` →
   `String` registry (`CLASS_NAMES`, populated by codegen via the new
   `js_register_class_name` FFI), and surface it through V8's
   `v8::Function::set_name` so the wrapper carries the user-visible
   class name.

4. **`crypto.createHash().digest()` returned `""`.** The V8-side stub
   threw away `update()` chunks and returned an empty string —
   `ModuleTokenFactory.hashString` hashed every module to the same
   `""`. Added `op_perry_hash` (sha1/sha256/sha384/sha512/md5 via
   `sha1`/`sha2`/`md-5` crates) and rewrote the stub to mirror the
   working `createHmac` path (chunked update → final concat → op
   → encode).

5. **`perf_hooks.performance.now` was undefined.** The default `_ =>
   "export default {}"` fallback applied; NestJS's
   `ModuleTokenFactory.create()` threw `Cannot read properties of
   undefined (reading 'now')` and the dynamic-module compile path
   bailed before any user modules were inserted. Added a real
   `perf_hooks` stub backed by `Date.now()` (good enough for the
   serialization-warning timer NestJS uses it for).

Sub-fixes shipped alongside, all required for the route to land:

- **`reflect-metadata` bridge re-asserted after every module evaluation**
  (`poll_pending_module_evaluations` in `interop.rs`). The npm
  `reflect-metadata` package (loaded transitively by `@nestjs/common`
  via `require("reflect-metadata")`) overwrites `Reflect.defineMetadata`
  /`getMetadata` etc. — without this, our wrappers from `js_runtime_init`
  are gone by the time the first decorator runs. The bridge JS is
  idempotent so re-running on each module evaluation is cheap.
- **Perry closure identity stabilized across V8 crossings**
  (`NATIVE_CLOSURE_HANDLES` cache + `__perry_closure_ptr` property).
  Decorators that key on `descriptor.value` (the method function) now
  see the same `v8::Function` instance as later
  `prototype['methodName']` reads — load-bearing for any
  `WeakMap`-based metadata store on the V8 side.
- **`v8_to_native_metadata_target` recognizes Perry-closure-wrapped V8
  functions** so `descriptor.value` round-trips to the same
  POINTER_TAG | ptr identity Perry uses internally — the precondition
  for the Perry-side `REFLECT_METADATA` store entry from a V8-side
  `Reflect.defineMetadata(...)` to be reachable from a Perry-side
  `Reflect.getMetadata(...)`.

Test: `test-files/test_reflect_metadata.ts` exercises
`defineMetadata`/`getMetadata`/`hasMetadata` + the decorator-factory
shape. NestJS sweep: `body={"message":"Cannot GET /ping",...}` (404)
→ `body=pong`.

Deferred follow-ups:
- Methods that read `this` against a V8-allocated instance still
  dispatch with `TAG_UNDEFINED` as the receiver — the canonical NestJS
  controller-handler shape doesn't use `this` so this matches expected
  behavior for the smoke. Properly wiring instance-as-`this` would
  require allocating a real Perry `ObjectHeader` for V8 wrapper
  instances (or routing through the handle table) — a separate
  follow-up.
- Class-ref id `INT32_TAG | class_id` collision in the bridge
  (existing pre-fix note in `bridge.rs`) is unchanged.

No version bump or changelog change (external-contributor convention).
@proggeramlug proggeramlug merged commit 693cd31 into main May 19, 2026
9 checks passed
@proggeramlug proggeramlug deleted the worktree-agent-a9bd3f473788a529a branch May 19, 2026 13:05
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