Repository: denoland/deno_core
Pull request #805 (2024-07-04 08:49:41)

op2 object wraps
Object wrap for Cppgc-backed objects

`#[op2]` will generate the glue code declarations for `impl` blocks to create JS objects in Rust using the op2 infra.

```rust
deno_core::extension!(
   // ...
  objects = [MyObject],
)
```

Currently supported bindings:
 - constructor
 - methods
 - static methods

 Planned support:
 - getters
 - setters

Future:
- webidl validation
 
## Example:

```rust
// dompoint.rs
pub struct DOMPoint {
  x: f64,
  y: f64,
  z: f64,
  w: f64,
}

impl GarbageCollected for DOMPoint {}

#[op2]
impl DOMPoint {
  #[constructor]
  #[cppgc]
  fn new(
    x: Option<f64>,
    y: Option<f64>,
    z: Option<f64>,
    w: Option<f64>,
  ) -> DOMPoint {
    DOMPoint {
      x: x.unwrap_or(0.0),
      y: y.unwrap_or(0.0),
      z: z.unwrap_or(0.0),
      w: w.unwrap_or(0.0),
    }
  }

  #[static_method]
  #[cppgc]
  fn from_point(
    scope: &mut v8::HandleScope,
    other: v8::Local<v8::Object>,
  ) -> Result<DOMPoint, AnyError> {
    fn get(
      scope: &mut v8::HandleScope,
      other: v8::Local<v8::Object>,
      key: &str,
    ) -> Option<f64> {
      let key = v8::String::new(scope, key).unwrap();
      other
        .get(scope, key.into())
        .map(|x| x.to_number(scope).unwrap().value())
    }

    Ok(DOMPoint {
      x: get(scope, other, "x").unwrap_or(0.0),
      y: get(scope, other, "y").unwrap_or(0.0),
      z: get(scope, other, "z").unwrap_or(0.0),
      w: get(scope, other, "w").unwrap_or(0.0),
    })
  }

  #[fast]
  fn x(&self) -> f64 {
    self.x
  }
}
```

```js
import { DOMPoint } from "ext:core/ops";

const p = new DOMPoint(200, 300);
p.x(); // 200.0
```

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/denoland/deno_core/pull/805?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) Report
Attention: Patch coverage is `95.63636%` with `12 lines` in your changes missing coverage. Please review.
> Project coverage is 81.89%. Comparing base [(`0c7f83e`)](https://app.codecov.io/gh/denoland/deno_core/commit/0c7f83ea8daabe8f541fc93ae97529dc446f5f24?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) to head [(`19e91c6`)](https://app.codecov.io/gh/denoland/deno_core/commit/19e91c6a0688e9e18e4de5a76f6dd7f8ded75e89?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland).
> Report is 50 commits behind head on main.

| [Files](https://app.codecov.io/gh/denoland/deno_core/pull/805?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) | Patch % | Lines |
|---|---|---|
| [testing/checkin/runner/ops.rs](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&filepath=testing%2Fcheckin%2Frunner%2Fops.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland#diff-dGVzdGluZy9jaGVja2luL3J1bm5lci9vcHMucnM=) | 82.22% | [8 Missing :warning: ](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) |
| [core/extensions.rs](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&filepath=core%2Fextensions.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland#diff-Y29yZS9leHRlbnNpb25zLnJz) | 87.50% | [1 Missing :warning: ](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) |
| [ops/op2/config.rs](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&filepath=ops%2Fop2%2Fconfig.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland#diff-b3BzL29wMi9jb25maWcucnM=) | 90.90% | [1 Missing :warning: ](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) |
| [ops/op2/dispatch\_slow.rs](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&filepath=ops%2Fop2%2Fdispatch_slow.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland#diff-b3BzL29wMi9kaXNwYXRjaF9zbG93LnJz) | 85.71% | [1 Missing :warning: ](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) |
| [ops/op2/object\_wrap.rs](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&filepath=ops%2Fop2%2Fobject_wrap.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland#diff-b3BzL29wMi9vYmplY3Rfd3JhcC5ycw==) | 98.33% | [1 Missing :warning: ](https://app.codecov.io/gh/denoland/deno_core/pull/805?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #805      +/-   ##
==========================================
+ Coverage   81.43%   81.89%   +0.45%     
==========================================
  Files          97       99       +2     
  Lines       23877    24565     +688     
==========================================
+ Hits        19445    20117     +672     
- Misses       4432     4448      +16     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/denoland/deno_core/pull/805?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=denoland).

ry: Have you done any performance benchmarks?
littledivy: ```
$ target/release/dcore bench.js
JS   x(): 87ms
Rust x(): 285ms
JS   create: 904ms
Rust create: 1706ms
```

<details><summary>Benchmark</summary>

```js
const { DOMPoint } = Deno.core.ops;

function bench(name, fn, iterations = 1e7) {
  var start = Date.now();
  for (var i = 0; i < iterations; i++) {
    fn();
  }
  var end = Date.now();
  console.log(name + ': ' + (end - start) + 'ms');
}

class DOMPointJS {
  #x;
  #y;
  #z;
  #w;

  constructor(x, y, z, w) {
    this.#x = x || 0;
    this.#y = y || 0;
    this.#z = z || 0;
    this.#w = w || 1;
  }

  x() {
    return this.#x;
  }
}

const p = new DOMPointJS(100, 200);
function getXJS() {
   return p.x()
}

const p2 = new DOMPoint(100, 200);
function getXRust() {
   return p2.x();
}

bench('JS   x()', getXJS);
bench('Rust x()', getXRust);

function createPointJS() {
  return new DOMPointJS(100, 200);
}

function createPointRust() {
   return new DOMPoint(100, 200);
}

bench('JS   create', createPointJS);
bench('Rust create', createPointRust);
```

</details>

So...not great atm, working on it. `Object::wrap` and `Object::unwrap` can take Isolate instead of HandleScopes so we can avoid creating and dropping scope in most cases - which is 30% of the overhead.
littledivy: Update benchmarks:

```
JS   x(): 84ms
Rust x(): 191ms
JS   create: 953ms
Rust create: 1810ms
```

Patch:
diff --git a/core/cppgc.rs b/core/cppgc.rs
index 4327e3f..af7a05d 100644
--- a/core/cppgc.rs
+++ b/core/cppgc.rs
@@ -36,12 +36,38 @@ pub fn make_cppgc_object<'a, T: GarbageCollected + 'static>(
   t: T,
 ) -> v8::Local<'a, v8::Object> {
   let state = JsRuntime::state_from(scope);
-  let templ =
-    v8::Local::new(scope, state.cppgc_template.borrow().as_ref().unwrap());
-  let func = templ.get_function(scope).unwrap();
-  let obj = func.new_instance(scope, &[]).unwrap();
+  let opstate = state.op_state.borrow();
+
+  // To initialize object wraps correctly, we store the function
+  // template in OpState with `T`'s TypeId as the key when binding
+  // because it'll be pretty annoying to propogate `T` generic everywhere.
+  //
+  // Here we try to retrive a function template for `T`, falling back to
+  // the default cppgc template.
+  let id = TypeId::of::<T>();
+  let obj = if let Some(templ) =
+    opstate.try_borrow_untyped::<v8::Global<v8::FunctionTemplate>>(id)
+  {
+    let templ = v8::Local::new(scope, templ);
+    let inst = templ.instance_template(scope);
+    inst.new_instance(scope).unwrap()
+  } else {
+    let templ =
+      v8::Local::new(scope, state.cppgc_template.borrow().as_ref().unwrap());
+    let func = templ.get_function(scope).unwrap();
+    func.new_instance(scope, &[]).unwrap()
+  };
+
+  wrap_object(scope, obj, t)
+}
 
-  let heap = scope.get_cpp_heap().unwrap();
+// Wrap an API object (eg: `args.This()`)
+pub fn wrap_object<'a, T: GarbageCollected + 'static>(
+  isolate: &mut v8::Isolate,
+  obj: v8::Local<'a, v8::Object>,
+  t: T,
+) -> v8::Local<'a, v8::Object> {
+  let heap = isolate.get_cpp_heap().unwrap();
 
   let member = unsafe {
     v8::cppgc::make_garbage_collected(
@@ -54,9 +80,8 @@ pub fn make_cppgc_object<'a, T: GarbageCollected + 'static>(
   };
 
   unsafe {
-    v8::Object::wrap::<CPPGC_TAG, CppGcObject<T>>(scope, obj, &member);
+    v8::Object::wrap::<CPPGC_TAG, CppGcObject<T>>(isolate, obj, &member);
   }
-
   obj
 }
 
@@ -94,7 +119,6 @@ pub fn try_unwrap_cppgc_object<'sc, T: GarbageCollected + 'static>(
   let Ok(obj): Result<v8::Local<v8::Object>, _> = val.try_into() else {
     return None;
   };
-
   if !obj.is_api_wrapper() {
     return None;
   }
diff --git a/core/extension_set.rs b/core/extension_set.rs
index 5bbef18..1eabccf 100644
--- a/core/extension_set.rs
+++ b/core/extension_set.rs
@@ -11,6 +11,7 @@ use crate::extensions::GlobalTemplateMiddlewareFn;
 use crate::extensions::OpMiddlewareFn;
 use crate::modules::ModuleName;
 use crate::ops::OpCtx;
+use crate::ops::OpMethodCtx;
 use crate::runtime::ExtensionTranspiler;
 use crate::runtime::JsRuntimeState;
 use crate::runtime::OpDriverImpl;
@@ -22,6 +23,7 @@ use crate::OpDecl;
 use crate::OpMetricsFactoryFn;
 use crate::OpState;
 use crate::SourceMapData;
+use crate::_ops::OpMethodDecl;
 
 /// Contribute to the `OpState` from each extension.
 pub fn setup_op_state(op_state: &mut OpState, extensions: &mut [Extension]) {
@@ -37,7 +39,7 @@ pub fn setup_op_state(op_state: &mut OpState, extensions: &mut [Extension]) {
 pub fn init_ops(
   deno_core_ops: &'static [OpDecl],
   extensions: &mut [Extension],
-) -> Vec<OpDecl> {
+) -> (Vec<OpDecl>, Vec<OpMethodDecl>) {
   // In debug build verify there that inter-Extension dependencies
   // are setup correctly.
   #[cfg(debug_assertions)]
@@ -49,6 +51,12 @@ pub fn init_ops(
     .fold(0, |ext_ops_count, count| count + ext_ops_count);
   let mut ops = Vec::with_capacity(no_of_ops + deno_core_ops.len());
 
+  let no_of_methods = extensions
+    .iter()
+    .map(|e| e.method_op_count())
+    .fold(0, |ext_ops_count, count| count + ext_ops_count);
+  let mut op_methods = Vec::with_capacity(no_of_methods);
+
   // Collect all middlewares - deno_core extension must not have a middleware!
   let middlewares: Vec<Box<OpMiddlewareFn>> = extensions
     .iter_mut()
@@ -76,13 +84,18 @@ pub fn init_ops(
         ..macroware(*ext_op)
       });
     }
+
+    let ext_method_ops = ext.init_method_ops();
+    for ext_op in ext_method_ops {
+      op_methods.push(*ext_op);
+    }
   }
 
   // In debug build verify there are no duplicate ops.
   #[cfg(debug_assertions)]
   check_no_duplicate_op_names(&ops);
 
-  ops
+  (ops, op_methods)
 }
 
 /// This functions panics if any of the extensions is missing its dependencies.
@@ -119,25 +132,28 @@ fn check_no_duplicate_op_names(ops: &[OpDecl]) {
   }
 }
 
+#[allow(clippy::too_many_arguments)]
 pub fn create_op_ctxs(
   op_decls: Vec<OpDecl>,
+  op_method_decls: Vec<OpMethodDecl>,
   op_metrics_factory_fn: Option<OpMetricsFactoryFn>,
   op_driver: Rc<OpDriverImpl>,
   op_state: Rc<RefCell<OpState>>,
   runtime_state: Rc<JsRuntimeState>,
   get_error_class_fn: GetErrorClassFn,
   enable_stack_trace_arg_in_ops: bool,
-) -> Box<[OpCtx]> {
+) -> (Box<[OpCtx]>, Box<[OpMethodCtx]>) {
   let op_count = op_decls.len();
   let mut op_ctxs = Vec::with_capacity(op_count);
+  let mut op_method_ctxs = Vec::with_capacity(op_method_decls.len());
 
   let runtime_state_ptr = runtime_state.as_ref() as *const _;
-  for (index, decl) in op_decls.into_iter().enumerate() {
+  let create_ctx = |index, decl| {
     let metrics_fn = op_metrics_factory_fn
       .as_ref()
       .and_then(|f| (f)(index as _, op_count, &decl));
 
-    let op_ctx = OpCtx::new(
+    OpCtx::new(
       index as _,
       std::ptr::null_mut(),
       op_driver.clone(),
@@ -147,12 +163,37 @@ pub fn create_op_ctxs(
       get_error_class_fn,
       metrics_fn,
       enable_stack_trace_arg_in_ops,
-    );
+    )
+  };
 
-    op_ctxs.push(op_ctx);
+  for (index, decl) in op_decls.into_iter().enumerate() {
+    op_ctxs.push(create_ctx(index, decl));
+  }
+
+  for (index, mut decl) in op_method_decls.into_iter().enumerate() {
+    decl.constructor.name = decl.name.0;
+    decl.constructor.name_fast = decl.name.1;
+
+    op_method_ctxs.push(OpMethodCtx {
+      id: (decl.id)(),
+      constructor: create_ctx(index, decl.constructor),
+      methods: decl
+        .methods
+        .iter()
+        .map(|method_decl| create_ctx(index, *method_decl))
+        .collect(),
+      static_methods: decl
+        .static_methods
+        .iter()
+        .map(|method_decl| create_ctx(index, *method_decl))
+        .collect(),
+    });
   }
 
-  op_ctxs.into_boxed_slice()
+  (
+    op_ctxs.into_boxed_slice(),
+    op_method_ctxs.into_boxed_slice(),
+  )
 }
 
 pub fn get_middlewares_and_external_refs(
diff --git a/core/extensions.rs b/core/extensions.rs
index 2454185..a282d3f 100644
--- a/core/extensions.rs
+++ b/core/extensions.rs
@@ -176,10 +176,22 @@ const NOOP_FN: CFunction = CFunction::new(
   &CFunctionInfo::new(Type::Void.scalar(), &[], Int64Representation::Number),
 );
 
+// Declaration for object wrappers.
+#[derive(Clone, Copy)]
+pub struct OpMethodDecl {
+  // TypeId::of::<T>() is unstable-nightly in const context so
+  // we store the fn pointer instead.
+  pub id: fn() -> std::any::TypeId,
+  pub name: (&'static str, FastStaticString),
+  pub constructor: OpDecl,
+  pub methods: &'static [OpDecl],
+  pub static_methods: &'static [OpDecl],
+}
+
 #[derive(Clone, Copy)]
 pub struct OpDecl {
   pub name: &'static str,
-  pub(crate) name_fast: FastStaticString,
+  pub name_fast: FastStaticString,
   pub is_async: bool,
   pub is_reentrant: bool,
   pub arg_count: u8,
@@ -384,6 +396,7 @@ macro_rules! extension {
     $(, bounds = [ $( $bound:path : $bound_type:ident ),+ ] )?
     $(, ops_fn = $ops_symbol:ident $( < $ops_param:ident > )? )?
     $(, ops = [ $( $(#[$m:meta])* $( $op:ident )::+ $( < $( $op_param:ident ),* > )?  ),+ $(,)? ] )?
+    $(, objects = [  $( $object:ident )::+ ] )?
     $(, esm_entry_point = $esm_entry_point:expr )?
     $(, esm = [ $($esm:tt)* ] )?
     $(, lazy_loaded_esm = [ $($lazy_loaded_esm:tt)* ] )?
@@ -447,6 +460,9 @@ macro_rules! extension {
             $( #[ $m ] )*
             $( $op )::+ $( :: < $($op_param),* > )? ()
           }),+)?]),
+          objects: ::std::borrow::Cow::Borrowed(&[
+            $( $( $object )::+::DECL, )*
+          ]),
           external_references: ::std::borrow::Cow::Borrowed(&[ $( $external_reference ),* ]),
           global_template_middleware: ::std::option::Option::None,
           global_object_middleware: ::std::option::Option::None,
@@ -580,6 +596,7 @@ pub struct Extension {
   pub lazy_loaded_esm_files: Cow<'static, [ExtensionFileSource]>,
   pub esm_entry_point: Option<&'static str>,
   pub ops: Cow<'static, [OpDecl]>,
+  pub objects: Cow<'static, [OpMethodDecl]>,
   pub external_references: Cow<'static, [v8::ExternalReference<'static>]>,
   pub global_template_middleware: Option<GlobalTemplateMiddlewareFn>,
   pub global_object_middleware: Option<GlobalObjectMiddlewareFn>,
@@ -603,6 +620,7 @@ impl Extension {
       lazy_loaded_esm_files: Cow::Borrowed(&[]),
       esm_entry_point: None,
       ops: self.ops.clone(),
+      objects: self.objects.clone(),
       external_references: self.external_references.clone(),
       global_template_middleware: self.global_template_middleware,
       global_object_middleware: self.global_object_middleware,
@@ -621,6 +639,7 @@ impl Default for Extension {
       lazy_loaded_esm_files: Cow::Borrowed(&[]),
       esm_entry_point: None,
       ops: Cow::Borrowed(&[]),
+      objects: Cow::Borrowed(&[]),
       external_references: Cow::Borrowed(&[]),
       global_template_middleware: None,
       global_object_middleware: None,
@@ -675,6 +694,10 @@ impl Extension {
     self.ops.len()
   }
 
+  pub fn method_op_count(&self) -> usize {
+    self.objects.len()
+  }
+
   /// Called at JsRuntime startup to initialize ops in the isolate.
   pub fn init_ops(&mut self) -> &[OpDecl] {
     if !self.enabled {
@@ -685,6 +708,11 @@ impl Extension {
     self.ops.as_ref()
   }
 
+  /// Called at JsRuntime startup to initialize method ops in the isolate.
+  pub fn init_method_ops(&self) -> &[OpMethodDecl] {
+    self.objects.as_ref()
+  }
+
   /// Allows setting up the initial op-state of an isolate at startup.
   pub fn take_state(&mut self, state: &mut OpState) {
     if let Some(op_fn) = self.op_state_fn.take() {
diff --git a/core/gotham_state.rs b/core/gotham_state.rs
index fad5c3f..b7fb834 100644
--- a/core/gotham_state.rs
+++ b/core/gotham_state.rs
@@ -22,6 +22,11 @@ impl GothamState {
     self.data.insert(type_id, Box::new(t));
   }
 
+  // For internal use.
+  pub(crate) fn put_untyped<T: 'static>(&mut self, t: TypeId, v: T) {
+    self.data.insert(t, Box::new(v));
+  }
+
   /// Determines if the current value exists in `GothamState` storage.
   pub fn has<T: 'static>(&self) -> bool {
     let type_id = TypeId::of::<T>();
@@ -34,6 +39,11 @@ impl GothamState {
     self.data.get(&type_id).and_then(|b| b.downcast_ref())
   }
 
+  // For internal use.
+  pub(crate) fn try_borrow_untyped<T: 'static>(&self, t: TypeId) -> Option<&T> {
+    self.data.get(&t).and_then(|b| b.downcast_ref())
+  }
+
   /// Borrows a value from the `GothamState` storage.
   pub fn borrow<T: 'static>(&self) -> &T {
     self.try_borrow().unwrap_or_else(|| missing::<T>())
diff --git a/core/lib.rs b/core/lib.rs
index 92d959c..0f76a7a 100644
--- a/core/lib.rs
+++ b/core/lib.rs
@@ -177,6 +177,7 @@ pub mod _ops {
   pub use super::error_codes::get_error_code;
   pub use super::extensions::Op;
   pub use super::extensions::OpDecl;
+  pub use super::extensions::OpMethodDecl;
   #[cfg(debug_assertions)]
   pub use super::ops::reentrancy_check;
   pub use super::ops::OpCtx;
diff --git a/core/ops.rs b/core/ops.rs
index dccd14d..8aaf528 100644
--- a/core/ops.rs
+++ b/core/ops.rs
@@ -70,6 +70,18 @@ impl OpMetadata {
   }
 }
 
+/// Per-object contexts for members.
+pub struct OpMethodCtx {
+  /// TypeId of the wrapped type
+  pub id: std::any::TypeId,
+  /// Op context for the constructor
+  pub constructor: OpCtx,
+  /// Per-op context for the methods
+  pub methods: Vec<OpCtx>,
+  /// Per-op context for the static methods
+  pub static_methods: Vec<OpCtx>,
+}
+
 /// Per-op context.
 ///
 // Note: We don't worry too much about the size of this struct because it's allocated once per realm, and is
diff --git a/core/runtime/bindings.rs b/core/runtime/bindings.rs
index 2b16156..1351815 100644
--- a/core/runtime/bindings.rs
+++ b/core/runtime/bindings.rs
@@ -1,8 +1,10 @@
 // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
 use anyhow::Context;
+use std::cell::RefCell;
 use std::mem::MaybeUninit;
 use std::os::raw::c_void;
 use std::path::PathBuf;
+use std::rc::Rc;
 use url::Url;
 use v8::MapFnTo;
 
@@ -23,15 +25,18 @@ use crate::modules::synthetic_module_evaluation_steps;
 use crate::modules::ImportAttributesKind;
 use crate::modules::ModuleMap;
 use crate::ops::OpCtx;
+use crate::ops::OpMethodCtx;
 use crate::runtime::InitMode;
 use crate::runtime::JsRealm;
 use crate::FastStaticString;
 use crate::FastString;
 use crate::JsRuntime;
 use crate::ModuleType;
+use crate::OpState;
 
 pub(crate) fn create_external_references(
   ops: &[OpCtx],
+  op_method_ctxs: &[OpMethodCtx],
   additional_references: &[v8::ExternalReference],
   sources: &[v8::OneByteConst],
   ops_in_snapshot: usize,
@@ -44,6 +49,7 @@ pub(crate) fn create_external_references(
       + (ops.len() * 4)
       + additional_references.len()
       + sources.len()
+      + op_method_ctxs.len()
       + 18, // for callsite_fns
   );
 
@@ -83,6 +89,16 @@ pub(crate) fn create_external_references(
     });
   }
 
+  for ctx in op_method_ctxs {
+    references.extend_from_slice(&ctx.constructor.external_references());
+    for method in &ctx.methods {
+      references.extend_from_slice(&method.external_references());
+    }
+    for method in &ctx.static_methods {
+      references.extend_from_slice(&method.external_references());
+    }
+  }
+
   references.extend_from_slice(additional_references);
 
   for ctx in &ops[..ops_in_snapshot] {
@@ -327,8 +343,10 @@ pub(crate) fn initialize_primordials_and_infra(
 /// Set up JavaScript bindings for ops.
 pub(crate) fn initialize_deno_core_ops_bindings<'s>(
   scope: &mut v8::HandleScope<'s>,
+  op_state: Rc<RefCell<OpState>>,
   context: v8::Local<'s, v8::Context>,
   op_ctxs: &[OpCtx],
+  op_method_ctxs: &[OpMethodCtx],
 ) {
   let global = context.global(scope);
 
@@ -363,15 +381,41 @@ pub(crate) fn initialize_deno_core_ops_bindings<'s>(
 
     deno_core_ops_obj.set(scope, key.into(), op_fn.into());
   }
+
+  for op_method_ctx in op_method_ctxs {
+    let tmpl = op_ctx_template(scope, &op_method_ctx.constructor);
+    let prototype = tmpl.prototype_template(scope);
+    let key = op_method_ctx.constructor.decl.name_fast.v8_string(scope);
+
+    for method in op_method_ctx.methods.iter() {
+      let op_fn = op_ctx_template(scope, method);
+      let method_key = method.decl.name_fast.v8_string(scope);
+      prototype.set(method_key.into(), op_fn.into());
+    }
+
+    for method in op_method_ctx.static_methods.iter() {
+      let op_fn = op_ctx_template(scope, method);
+      let method_key = method.decl.name_fast.v8_string(scope);
+      tmpl.set(method_key.into(), op_fn.into());
+    }
+
+    let op_fn = tmpl.get_function(scope).unwrap();
+    op_fn.set_name(key);
+    deno_core_ops_obj.set(scope, key.into(), op_fn.into());
+
+    let id = op_method_ctx.id;
+    op_state
+      .borrow_mut()
+      .put_untyped(id, v8::Global::new(scope, tmpl));
+  }
 }
 
-fn op_ctx_function<'s>(
+pub(crate) fn op_ctx_template<'s>(
   scope: &mut v8::HandleScope<'s>,
   op_ctx: &OpCtx,
-) -> v8::Local<'s, v8::Function> {
+) -> v8::Local<'s, v8::FunctionTemplate> {
   let op_ctx_ptr = op_ctx as *const OpCtx as *const c_void;
   let external = v8::External::new(scope, op_ctx_ptr as *mut c_void);
-  let v8name = op_ctx.decl.name_fast.v8_string(scope);
 
   let (slow_fn, fast_fn) = if op_ctx.metrics_enabled() {
     (
@@ -398,6 +442,15 @@ fn op_ctx_function<'s>(
     builder.build(scope)
   };
 
+  template
+}
+
+fn op_ctx_function<'s>(
+  scope: &mut v8::HandleScope<'s>,
+  op_ctx: &OpCtx,
+) -> v8::Local<'s, v8::Function> {
+  let v8name = op_ctx.decl.name_fast.v8_string(scope);
+  let template = op_ctx_template(scope, op_ctx);
   let v8fn = template.get_function(scope).unwrap();
   v8fn.set_name(v8name);
   v8fn
@@ -820,10 +873,11 @@ where
 /// to a JavaScript function that executes and op.
 pub fn create_exports_for_ops_virtual_module<'s>(
   op_ctxs: &[OpCtx],
+  op_method_ctxs: &[OpMethodCtx],
   scope: &mut v8::HandleScope<'s>,
   global: v8::Local<v8::Object>,
 ) -> Vec<(FastStaticString, v8::Local<'s, v8::Value>)> {
-  let mut exports = Vec::with_capacity(op_ctxs.len());
+  let mut exports = Vec::with_capacity(op_ctxs.len() + op_method_ctxs.len());
 
   let deno_obj = get(scope, global, DENO, "Deno");
   let deno_core_obj = get(scope, deno_obj, CORE, "Deno.core");
@@ -852,5 +906,25 @@ pub fn create_exports_for_ops_virtual_module<'s>(
     exports.push((op_ctx.decl.name_fast, op_fn.into()));
   }
 
+  for ctx in op_method_ctxs {
+    let tmpl = op_ctx_template(scope, &ctx.constructor);
+    let prototype = tmpl.prototype_template(scope);
+
+    for method in ctx.methods.iter() {
+      let op_fn = op_ctx_template(scope, method);
+      let method_key = method.decl.name_fast.v8_string(scope);
+      prototype.set(method_key.into(), op_fn.into());
+    }
+
+    for method in ctx.static_methods.iter() {
+      let op_fn = op_ctx_template(scope, method);
+      let method_key = method.decl.name_fast.v8_string(scope);
+      tmpl.set(method_key.into(), op_fn.into());
+    }
+
+    let op_fn = tmpl.get_function(scope).unwrap();
+    exports.push((ctx.constructor.decl.name_fast, op_fn.into()));
+  }
+
   exports
 }
diff --git a/core/runtime/jsrealm.rs b/core/runtime/jsrealm.rs
index 998c8f6..b4d3aa6 100644
--- a/core/runtime/jsrealm.rs
+++ b/core/runtime/jsrealm.rs
@@ -13,6 +13,7 @@ use crate::modules::ModuleMap;
 use crate::modules::ModuleName;
 use crate::ops::ExternalOpsTracker;
 use crate::ops::OpCtx;
+use crate::ops::OpMethodCtx;
 use crate::stats::RuntimeActivityTraces;
 use crate::tasks::V8TaskSpawnerFactory;
 use crate::web_timeout::WebTimers;
@@ -67,6 +68,7 @@ pub struct ContextState {
   // We don't explicitly re-read this prop but need the slice to live alongside
   // the context
   pub(crate) op_ctxs: Box<[OpCtx]>,
+  pub(crate) op_method_ctxs: Box<[OpMethodCtx]>,
   pub(crate) isolate: Option<*mut v8::Isolate>,
   pub(crate) exception_state: Rc<ExceptionState>,
   pub(crate) has_next_tick_scheduled: Cell<bool>,
@@ -80,6 +82,7 @@ impl ContextState {
     isolate_ptr: *mut v8::Isolate,
     get_error_class_fn: GetErrorClassFn,
     op_ctxs: Box<[OpCtx]>,
+    op_method_ctxs: Box<[OpMethodCtx]>,
     external_ops_tracker: ExternalOpsTracker,
   ) -> Self {
     Self {
@@ -92,6 +95,7 @@ impl ContextState {
       wasm_instantiate_fn: Default::default(),
       activity_traces: Default::default(),
       op_ctxs,
+      op_method_ctxs,
       pending_ops: op_driver,
       task_spawner_factory: Default::default(),
       timers: Default::default(),
diff --git a/core/runtime/jsruntime.rs b/core/runtime/jsruntime.rs
index aeccadb..3d3f918 100644
--- a/core/runtime/jsruntime.rs
+++ b/core/runtime/jsruntime.rs
@@ -863,15 +863,16 @@ impl JsRuntime {
 
     // ...now we're moving on to ops; set them up, create `OpCtx` for each op
     // and get ready to actually create V8 isolate...
-    let op_decls =
+    let (op_decls, op_method_decls) =
       extension_set::init_ops(crate::ops_builtin::BUILTIN_OPS, &mut extensions);
 
     let op_driver = Rc::new(OpDriverImpl::default());
     let op_metrics_factory_fn = options.op_metrics_factory_fn.take();
     let get_error_class_fn = options.get_error_class_fn.unwrap_or(&|_| "Error");
 
-    let mut op_ctxs = extension_set::create_op_ctxs(
+    let (mut op_ctxs, mut op_method_ctxs) = extension_set::create_op_ctxs(
       op_decls,
+      op_method_decls,
       op_metrics_factory_fn,
       op_driver.clone(),
       op_state.clone(),
@@ -920,6 +921,7 @@ impl JsRuntime {
     isolate_allocations.external_refs =
       Some(Box::new(bindings::create_external_references(
         &op_ctxs,
+        &op_method_ctxs,
         &additional_references,
         &isolate_allocations.externalized_sources,
         ops_in_snapshot,
@@ -954,6 +956,13 @@ impl JsRuntime {
     for op_ctx in op_ctxs.iter_mut() {
       op_ctx.isolate = isolate_ptr;
     }
+    for op_method_ctx in op_method_ctxs.iter_mut() {
+      op_method_ctx.constructor.isolate = isolate_ptr;
+      for op in &mut op_method_ctx.methods {
+        op.isolate = isolate_ptr;
+      }
+    }
+
     op_state.borrow_mut().put(isolate_ptr);
 
     // ...once ops and isolate are set up, we can create a `ContextState`...
@@ -962,6 +971,7 @@ impl JsRuntime {
       isolate_ptr,
       options.get_error_class_fn.unwrap_or(&|_| "Error"),
       op_ctxs,
+      op_method_ctxs,
       op_state.borrow().external_ops_tracker.clone(),
     ));
 
@@ -1028,8 +1038,10 @@ impl JsRuntime {
     if init_mode.needs_ops_bindings() {
       bindings::initialize_deno_core_ops_bindings(
         scope,
+        op_state,
         context,
         &context_state.op_ctxs,
+        &context_state.op_method_ctxs,
       );
     }
 
@@ -1243,6 +1255,7 @@ impl JsRuntime {
     let global = context_local.global(scope);
     let synthetic_module_exports = create_exports_for_ops_virtual_module(
       &context_state.op_ctxs,
+      &context_state.op_method_ctxs,
       scope,
       global,
     );
diff --git a/ops/op2/config.rs b/ops/op2/config.rs
index 8a52ecb..d3c4a01 100644
--- a/ops/op2/config.rs
+++ b/ops/op2/config.rs
@@ -24,6 +24,10 @@ pub(crate) struct MacroConfig {
   pub reentrant: bool,
   /// Marks an op as a method on a wrapped object.
   pub method: Option<String>,
+  /// Marks an op as a constructor
+  pub constructor: bool,
+  /// Marks an op as a static member
+  pub static_member: bool,
   /// Marks an op with no side effects.
   pub no_side_effects: bool,
 }
@@ -63,7 +67,15 @@ impl MacroConfig {
     }
 
     for flag in flags {
-      if flag == "fast" {
+      if flag == "method" {
+        // Doesn't need any special handling, its more of a marker.
+        continue;
+      }
+      if flag == "constructor" {
+        config.constructor = true;
+      } else if flag == "static_method" {
+        config.static_member = true;
+      } else if flag == "fast" {
         config.fast = true;
       } else if flag.starts_with("fast(") {
         let tokens =
@@ -158,6 +170,9 @@ impl MacroConfig {
         ( $($flags:tt $( ( $( $args:ty ),* ) )? ),+ ) => {
           Self::from_token_trees(flags, args)
         }
+        ( # [ $($flags:tt),+ ] ) => {
+            Self::from_flags(flags.into_iter().map(|flag| flag.to_string()))
+        }
       })
     })
     .map_err(|_| Op2Error::PatternMatchFailed("attribute", attr_string))??;
diff --git a/ops/op2/dispatch_fast.rs b/ops/op2/dispatch_fast.rs
index 0eea673..ccbe5fd 100644
--- a/ops/op2/dispatch_fast.rs
+++ b/ops/op2/dispatch_fast.rs
@@ -458,6 +458,17 @@ pub(crate) fn generate_dispatch_fast(
     quote!()
   };
 
+  let with_isolate = if generator_state.needs_fast_isolate
+    && !generator_state.needs_fast_scope
+  {
+    generator_state.needs_opctx = true;
+    gs_quote!(generator_state(opctx, scope) =>
+      (let mut #scope = unsafe { &mut *#opctx.isolate };)
+    )
+  } else {
+    quote!()
+  };
+
   let with_opctx = if generator_state.needs_opctx {
     generator_state.needs_fast_api_callback_options = true;
     gs_quote!(generator_state(opctx, fast_api_callback_options) => {
@@ -569,6 +580,7 @@ pub(crate) fn generate_dispatch_fast(
       #with_opctx
       #with_stack_trace
       #with_js_runtime_state
+      #with_isolate
       #with_self
       let #result = {
         #(#call_args)*
diff --git a/ops/op2/dispatch_slow.rs b/ops/op2/dispatch_slow.rs
index 9ffff1c..fbd96b5 100644
--- a/ops/op2/dispatch_slow.rs
+++ b/ops/op2/dispatch_slow.rs
@@ -864,6 +864,12 @@ pub fn return_value_infallible(
     ArgMarker::Number => {
       gs_quote!(generator_state(result) => (deno_core::_ops::RustToV8Marker::<deno_core::_ops::NumberMarker, _>::from(#result)))
     }
+    ArgMarker::Cppgc if generator_state.use_this_cppgc => {
+      generator_state.needs_isolate = true;
+      gs_quote!(generator_state(result, scope) => (
+           Some(deno_core::cppgc::wrap_object(&mut #scope, args.this(), #result))
+      ))
+    }
     ArgMarker::Cppgc => {
       let marker = quote!(deno_core::_ops::RustToV8Marker::<deno_core::_ops::CppGcMarker, _>::from);
       if ret_type.is_option() {
@@ -938,7 +944,12 @@ pub fn return_value_v8_value(
       quote!(deno_core::_ops::RustToV8Marker::<deno_core::_ops::NumberMarker, _>::from(#result))
     }
     ArgMarker::Cppgc => {
-      let marker = quote!(deno_core::_ops::RustToV8Marker::<deno_core::_ops::CppGcMarker, _>::from);
+      let marker = if !generator_state.use_this_cppgc {
+        quote!(deno_core::_ops::RustToV8Marker::<deno_core::_ops::CppGcMarker, _>::from)
+      } else {
+        quote!((|x| { deno_core::cppgc::wrap_object(#scope, args.this(), x) }))
+      };
+
       if ret_type.is_option() {
         quote!(#result.map(#marker))
       } else {
diff --git a/ops/op2/generator_state.rs b/ops/op2/generator_state.rs
index 58b599f..37a6d37 100644
--- a/ops/op2/generator_state.rs
+++ b/ops/op2/generator_state.rs
@@ -46,6 +46,8 @@ pub struct GeneratorState {
   pub needs_args: bool,
   pub needs_retval: bool,
   pub needs_scope: bool,
+  pub needs_fast_scope: bool,
+  pub needs_fast_isolate: bool,
   pub needs_isolate: bool,
   pub needs_opstate: bool,
   pub needs_opctx: bool,
@@ -53,6 +55,8 @@ pub struct GeneratorState {
   pub needs_js_runtime_state: bool,
   pub needs_fast_api_callback_options: bool,
   pub needs_self: bool,
+  /// Wrap the `this` with cppgc object
+  pub use_this_cppgc: bool,
 }
 
 /// Quotes a set of generator_state fields, along with variables captured from
diff --git a/ops/op2/mod.rs b/ops/op2/mod.rs
index 6671014..7e1be6e 100644
--- a/ops/op2/mod.rs
+++ b/ops/op2/mod.rs
@@ -32,6 +32,7 @@ pub mod dispatch_fast;
 pub mod dispatch_shared;
 pub mod dispatch_slow;
 pub mod generator_state;
+pub mod object_wrap;
 pub mod signature;
 pub mod signature_retval;
 
@@ -61,6 +62,8 @@ pub enum Op2Error {
   TooManyFastAlternatives,
   #[error("The flags for this attribute were not sorted alphabetically. They should be listed as '({0})'.")]
   ImproperlySortedAttribute(String),
+  #[error("Only one constructor is allowed per object")]
+  MultipleConstructors,
 }
 
 #[derive(Debug, Error)]
@@ -81,12 +84,16 @@ pub(crate) fn op2(
   attr: TokenStream,
   item: TokenStream,
 ) -> Result<TokenStream, Op2Error> {
-  let func = parse2::<ItemFn>(item)?;
+  let Ok(func) = parse2::<ItemFn>(item.clone()) else {
+    let impl_block = parse2::<syn::ItemImpl>(item)?;
+    return object_wrap::generate_impl_ops(impl_block);
+  };
+
   let config = MacroConfig::from_tokens(attr)?;
   generate_op2(config, func)
 }
 
-fn generate_op2(
+pub(crate) fn generate_op2(
   config: MacroConfig,
   func: ItemFn,
 ) -> Result<TokenStream, Op2Error> {
@@ -180,6 +187,8 @@ fn generate_op2(
     moves: vec![],
     needs_retval: false,
     needs_scope: false,
+    needs_fast_isolate: false,
+    needs_fast_scope: false,
     needs_isolate: false,
     needs_opctx: false,
     needs_opstate: false,
@@ -187,6 +196,7 @@ fn generate_op2(
     needs_js_runtime_state: false,
     needs_fast_api_callback_options: false,
     needs_self: config.method.is_some(),
+    use_this_cppgc: config.constructor,
   };
 
   let mut slow_generator_state = base_generator_state.clone();
diff --git a/ops/op2/object_wrap.rs b/ops/op2/object_wrap.rs
new file mode 100644
index 0000000..77266d0
--- /dev/null
+++ b/ops/op2/object_wrap.rs
@@ -0,0 +1,135 @@
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+
+use proc_macro2::TokenStream;
+use quote::quote;
+use quote::ToTokens;
+use syn::ImplItem;
+use syn::ItemFn;
+use syn::ItemImpl;
+
+use crate::op2::generate_op2;
+use crate::op2::MacroConfig;
+use crate::op2::Op2Error;
+
+// Object wrap for Cppgc-backed objects
+//
+// This module generates the glue code declarations
+// for `impl` blocks to create JS objects in Rust
+// using the op2 infra.
+//
+// ```rust
+// #[op]
+// impl MyObject {
+//    #[constructor] // <-- first attribute defines binding type
+//    #[cppgc]       // <-- attributes for op2
+//    fn new() -> MyObject {
+//      MyObject::new()
+//    }
+//
+//    #[static_method]
+//    #[cppgc]
+//    fn static_method() -> MyObject {
+//      // ...
+//    }
+//
+//    #[method]
+//    #[smi]
+//    fn x(&self) -> i32 {
+//      // ...
+//    }
+// }
+//
+// The generated OpMethodDecl that can be passed to
+// `deno_core::extension!` macro to register the object
+//
+// ```rust
+// deno_core::extension!(
+//   ...,
+//   objects = [MyObject],
+// )
+// ```
+//
+// ```js
+// import { MyObject } from "ext:core/ops";
+// ```
+//
+// Currently supported bindings:
+// - constructor
+// - methods
+// - static methods
+//
+// Planned support:
+// - getters
+// - setters
+//
+pub(crate) fn generate_impl_ops(
+  item: ItemImpl,
+) -> Result<TokenStream, Op2Error> {
+  let mut tokens = TokenStream::new();
+
+  let self_ty = &item.self_ty;
+  let self_ty_ident = self_ty.to_token_stream().to_string();
+
+  // State
+  let mut constructor = None;
+  let mut methods = Vec::new();
+  let mut static_methods = Vec::new();
+
+  for item in item.items {
+    if let ImplItem::Fn(mut method) = item {
+      /* First attribute idents, for all functions in block */
+      let attrs = method.attrs.swap_remove(0);
+
+      let ident = method.sig.ident.clone();
+      let func = ItemFn {
+        attrs: method.attrs,
+        vis: method.vis,
+        sig: method.sig,
+        block: Box::new(method.block),
+      };
+
+      let mut config = MacroConfig::from_tokens(quote! {
+        #attrs
+      })?;
+      if config.constructor {
+        if constructor.is_some() {
+          return Err(Op2Error::MultipleConstructors);
+        }
+
+        constructor = Some(ident);
+      } else if config.static_member {
+        static_methods.push(ident);
+      } else {
+        methods.push(ident);
+        config.method = Some(self_ty_ident.clone());
+      }
+
+      let op = generate_op2(config, func);
+      tokens.extend(op);
+    }
+  }
+
+  let res = quote! {
+      impl #self_ty {
+        pub const DECL: deno_core::_ops::OpMethodDecl = deno_core::_ops::OpMethodDecl {
+          methods: &[
+            #(
+              #self_ty::#methods(),
+            )*
+          ],
+          static_methods: &[
+            #(
+              #self_ty::#static_methods(),
+            )*
+          ],
+          constructor: #self_ty::#constructor(),
+          name: ::deno_core::__op_name_fast!(#self_ty),
+          id: || ::std::any::TypeId::of::<#self_ty>()
+        };
+
+        #tokens
+      }
+  };
+
+  Ok(res)
+}


Test patch:
diff --git a/testing/checkin/runner/extensions.rs b/testing/checkin/runner/extensions.rs
index d9b9700..129277a 100644
--- a/testing/checkin/runner/extensions.rs
+++ b/testing/checkin/runner/extensions.rs
@@ -49,12 +49,16 @@ deno_core::extension!(
     ops_worker::op_worker_await_close,
     ops_worker::op_worker_terminate,
   ],
+  objects = [
+    ops::DOMPoint
+  ],
   esm_entry_point = "ext:checkin_runtime/__init.js",
   esm = [
     dir "checkin/runtime",
     "__init.js",
     "checkin:async" = "async.ts",
     "checkin:console" = "console.ts",
+    "checkin:object" = "object.ts",
     "checkin:error" = "error.ts",
     "checkin:timers" = "timers.ts",
     "checkin:worker" = "worker.ts",
diff --git a/testing/checkin/runner/ops.rs b/testing/checkin/runner/ops.rs
index 24eb3fe..03d0866 100644
--- a/testing/checkin/runner/ops.rs
+++ b/testing/checkin/runner/ops.rs
@@ -2,14 +2,15 @@
 use std::cell::RefCell;
 use std::rc::Rc;
 
+use deno_core::error::AnyError;
 use deno_core::op2;
 use deno_core::stats::RuntimeActivityDiff;
 use deno_core::stats::RuntimeActivitySnapshot;
 use deno_core::stats::RuntimeActivityStats;
 use deno_core::stats::RuntimeActivityStatsFactory;
 use deno_core::stats::RuntimeActivityStatsFilter;
+use deno_core::v8;
 use deno_core::GarbageCollected;
-use deno_core::OpDecl;
 use deno_core::OpState;
 
 use super::extensions::SomeType;
@@ -69,37 +70,76 @@ pub fn op_stats_delete(
   test_data.take::<RuntimeActivityStats>(name);
 }
 
-pub struct Stateful {
-  name: String,
+pub struct DOMPoint {
+  pub x: f64,
+  pub y: f64,
+  pub z: f64,
+  pub w: f64,
 }
 
-impl GarbageCollected for Stateful {}
+impl GarbageCollected for DOMPoint {}
 
-impl Stateful {
-  #[op2(method(Stateful))]
-  #[string]
-  fn get_name(&self) -> String {
-    self.name.clone()
+#[op2]
+impl DOMPoint {
+  #[constructor]
+  #[cppgc]
+  fn new(
+    x: Option<f64>,
+    y: Option<f64>,
+    z: Option<f64>,
+    w: Option<f64>,
+  ) -> DOMPoint {
+    DOMPoint {
+      x: x.unwrap_or(0.0),
+      y: y.unwrap_or(0.0),
+      z: z.unwrap_or(0.0),
+      w: w.unwrap_or(0.0),
+    }
   }
 
-  #[op2(fast, method(Stateful))]
-  #[smi]
-  fn len(&self) -> u32 {
-    self.name.len() as u32
+  #[static_method]
+  #[cppgc]
+  fn from_point(
+    scope: &mut v8::HandleScope,
+    other: v8::Local<v8::Object>,
+  ) -> Result<DOMPoint, AnyError> {
+    fn get(
+      scope: &mut v8::HandleScope,
+      other: v8::Local<v8::Object>,
+      key: &str,
+    ) -> Option<f64> {
+      let key = v8::String::new(scope, key).unwrap();
+      other
+        .get(scope, key.into())
+        .map(|x| x.to_number(scope).unwrap().value())
+    }
+
+    Ok(DOMPoint {
+      x: get(scope, other, "x").unwrap_or(0.0),
+      y: get(scope, other, "y").unwrap_or(0.0),
+      z: get(scope, other, "z").unwrap_or(0.0),
+      w: get(scope, other, "w").unwrap_or(0.0),
+    })
   }
 
-  #[op2(async, method(Stateful))]
-  async fn delay(&self, #[smi] millis: u32) {
-    tokio::time::sleep(std::time::Duration::from_millis(millis as u64)).await;
-    println!("name: {}", self.name);
+  #[fast]
+  fn x(&self) -> f64 {
+    self.x
+  }
+  #[fast]
+  fn y(&self) -> f64 {
+    self.y
+  }
+  #[fast]
+  fn w(&self) -> f64 {
+    self.w
+  }
+  #[fast]
+  fn z(&self) -> f64 {
+    self.z
   }
 }
 
-// Make sure this compiles, we'll use it when we add registration.
-#[allow(dead_code)]
-const STATEFUL_DECL: [OpDecl; 3] =
-  [Stateful::get_name(), Stateful::len(), Stateful::delay()];
-
 #[op2(fast)]
 pub fn op_nop_generic<T: SomeType + 'static>(state: &mut OpState) {
   state.take::<T>();
diff --git a/testing/checkin/runtime/__init.js b/testing/checkin/runtime/__init.js
index 9b4ac1e..73ad976 100644
--- a/testing/checkin/runtime/__init.js
+++ b/testing/checkin/runtime/__init.js
@@ -5,9 +5,11 @@ import * as error from "checkin:error";
 import * as timers from "checkin:timers";
 import * as worker from "checkin:worker";
 import * as throw_ from "checkin:throw";
+import * as object from "checkin:object";
 async;
 error;
 throw_;
+object;
 
 globalThis.console = console.console;
 globalThis.setTimeout = timers.setTimeout;
diff --git a/testing/checkin/runtime/object.ts b/testing/checkin/runtime/object.ts
new file mode 100644
index 0000000..8ffc560
--- /dev/null
+++ b/testing/checkin/runtime/object.ts
@@ -0,0 +1,5 @@
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+
+import { DOMPoint } from "ext:core/ops";
+
+export { DOMPoint };
diff --git a/testing/ops.d.ts b/testing/ops.d.ts
index c89a661..cc45158 100644
--- a/testing/ops.d.ts
+++ b/testing/ops.d.ts
@@ -19,3 +19,11 @@ export function op_worker_recv(...any: any[]): any;
 export function op_worker_send(...any: any[]): any;
 export function op_worker_spawn(...any: any[]): any;
 export function op_worker_terminate(...any: any[]): any;
+
+export class DOMPoint {
+  constructor(x?: number, y?: number, z?: number, w?: number);
+  static from_point(
+    other: { x?: number; y?: number; z?: number; w?: number },
+  ): DOMPoint;
+  x(): number;
+}
diff --git a/testing/tsconfig.json b/testing/tsconfig.json
index 2929441..5266444 100644
--- a/testing/tsconfig.json
+++ b/testing/tsconfig.json
@@ -11,7 +11,8 @@
       "checkin:testing": ["checkin/runtime/testing.ts"],
       "checkin:throw": ["checkin/runtime/throw.ts"],
       "checkin:timers": ["checkin/runtime/timers.ts"],
-      "checkin:worker": ["checkin/runtime/worker.ts"]
+      "checkin:worker": ["checkin/runtime/worker.ts"],
+      "checkin:object": ["checkin/runtime/object.ts"]
     },
     "lib": ["ESNext", "DOM", "ES2023.Array"],
     "module": "ESNext",
diff --git a/testing/unit/resource_test.ts b/testing/unit/resource_test.ts
index 5b52a7d..700a927 100644
--- a/testing/unit/resource_test.ts
+++ b/testing/unit/resource_test.ts
@@ -1,5 +1,6 @@
 // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
 import { assert, assertArrayEquals, assertEquals, test } from "checkin:testing";
+import { DOMPoint } from "checkin:object";
 
 const {
   op_pipe_create,
@@ -62,3 +63,23 @@ test(async function testCppgcAsync() {
   const resource = await op_async_make_cppgc_resource();
   assertEquals(await op_async_get_cppgc_resource(resource), 42);
 });
+
+test(function testDomPoint() {
+  const p1 = new DOMPoint(100, 100);
+  const p2 = new DOMPoint();
+  const p3 = DOMPoint.from_point({ x: 200 });
+  const p4 = DOMPoint.from_point({ x: 0, y: 100, z: 99.9, w: 100 });
+  assertEquals(p1.x(), 100);
+  assertEquals(p2.x(), 0);
+  assertEquals(p3.x(), 200);
+  assertEquals(p4.x(), 0);
+
+  let caught;
+  try {
+    // @ts-expect-error bad arg test
+    new DOMPoint("bad");
+  } catch (e) {
+    caught = e;
+  }
+  assert(caught);
+});