Skip to content

Write new JS-API explainer and reference - #686

Open
eqrion wants to merge 7 commits into
WebAssembly:mainfrom
eqrion:web
Open

eqrion wants to merge 7 commits into
WebAssembly:mainfrom
eqrion:web

Conversation

@eqrion

@eqrion eqrion commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

I'm presenting on Mozilla's prototype implementation which is based off of this, so I wanted to have it publicly accessible somewhere. It needs a couple more iterations before it's ready for detailed review. High-level feedback is welcome though.

@ValorZard

Copy link
Copy Markdown

I have a few dumb questions

  1. What parts of the Web APIs DON'T fall under WebIDL?
  2. When interacting with javascript types and functions through webidl, are those objects still garbage collected, or does the wasm component take full ownership of them. If I'm writing a rust WASM component for the web, and I get access to a Button, is that button garbage collected? And if so, how? Can I "free" that button in the Rust component and accidentally remove it from the browser somehow?

@eqrion

eqrion commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

@ValorZard

Apologies for the delay, I was rewriting most of the explainer for a new approach. I'm less convinced now that specifying this in terms of WebIDL is the right approach and so I'm switching back to this being a JS-API.

I believe we can get good performance for web API calls by designing value conversions such that JS script cannot intercept them when a web API is imported directly. The new approach attempts this, and it's not as bad as I thought it would be.

It's not quite ready for review but is closer now.

I have answers to your questions, but they're in terms of the discarded draft so I'm not sure it's relevant anymore.

@eqrion eqrion changed the title Initial design sketch for a web embedding to replace the JS-API Initial design sketch for an revised JS-API Aug 21, 2026
@ValorZard

Copy link
Copy Markdown

@eqrion so, something that i've run into as a Rust WebAssembly enjoyer is that even though on paper Rust compiled to webassembly should be faster than javascript, in practice they're about the same speed, and in fact Rust can be slower compared to javascript when it comes to using apis like WebGPU or WebGL2.
For example (and don't quote me on this), I think the Bevy game engine's web renderer is slower than three.js because three.js is natively written and optimized for javascript.

will this new api fix this?

@eqrion eqrion changed the title Initial design sketch for an revised JS-API Initial design sketch for a revised JS-API Aug 24, 2026
@eqrion

eqrion commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@ValorZard Performance is going to be very workload dependent. The biggest thing this could provide is to lower the overhead for calling from Rust into a web API. But it's still possible to have bottlenecks in your rust code that make it slower than JS. JS engines are marvelous things and are not an easy target to beat consistently.

@eqrion eqrion changed the title Initial design sketch for a revised JS-API Write new JS-API explainer and reference Sep 17, 2026
@eqrion
eqrion requested a review from lukewagner September 17, 2026 16:02
@eqrion

eqrion commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

I think this is ready for review now. Tagging Luke for review.

cc @bvisness @guybedford @vados-cosmonic. Feel free to look at this if you're interested, but no worries if not.

@lukewagner lukewagner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work; exciting to see this all in detail! There's a lot and so I didn't get to read all of it and only skimmed some parts, but here's a first pass of comments/questions.

instance.exports.greet(); // TypeError
```

Passing too few arguments is a `TypeError`. Extra arguments are ignored.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to allow trailing optional parameters to be omitted?

Comment on lines +89 to +90
| `list<u8>` | `Uint8Array` |
| `list<T>`, `list<T, N>`, `tuple<T, U>` | Array |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we want the other fixed-width integers to also turn into their respective typed arrays? And perhaps also the list<T, N> variants too?

import { run } from "./component.wasm";

// calls the default export of `https://esm.unpkg.com/slugify@1.6.6`
run();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since run() has no params/results, maybe convert it to use a core start function and have the first demo just use <script type="module" src="./component.wasm"> (mentioning that this .html+.wasm is a complete web app without any JS code). And then maybe as a second example add an export with params/results and show what it looks like to import and call from JS like you're doing here. And then to complete the ESM high-level picture, maybe show a component that imports some name without an external-id and use an import-map to map it to a URL, and then mention that this is one way that non-browser APIs like WASI can be polyfilled on the Web.


### Importing from the JS global

The example above still needs someone to write `{ element: Element }`. A component can skip that and take its imports straight from the global object by importing `wasm:js/global`:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty cool that you only need one fixed JS built-in and the rest is done with instance type projects; great idea!


### Greeter: exporting a function

Let's start with a component that imports nothing:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically this is the type of a component (it'd fail validation as a standalone .wat, but would pass if you wrapped it with (component (type ...))). Since the convention is used (effectively, since the types are mostly all we care about) throughout the explainer, it might be good to clarify that here or in the introductory text above.

1. Perform `DefinePropertyOrThrow`(|target|, `JSName`(|e|), PropertyDescriptor { [[Value]]: |func|, [[Writable]]: **true**, [[Enumerable]]: **false**, [[Configurable]]: **true** }).
1. Return |constructor|.

A method named `constructor` and a static named `prototype` are rejected because they would unexpectedly change JS class semantics.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Along the same lines, I think perhaps arguments, caller, name and length might conflict with predefined class names. I wonder if, rather than throwing a type-error (which might actually be a problem for some of these), these methods are silently omitted but can still be found via their full original plainname on the exports object (just like interfacename exports), and thus these methods/statics/constructors are really just syntactic sugar.


#### Conversions for guest resources

The *current lender list* is a per-call spec state. `invoke a component function` establishes it for a JS-to-component call. Each instance lowered as a `borrow` during that call has its [[LendCount]] incremented and is appended to the list, which protects it from being dropped while lent. When the call returns, every [[LendCount]] in the list is decremented.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The *current lender list* is a per-call spec state. `invoke a component function` establishes it for a JS-to-component call. Each instance lowered as a `borrow` during that call has its [[LendCount]] incremented and is appended to the list, which protects it from being dropped while lent. When the call returns, every [[LendCount]] in the list is decremented.
The *current lender list* is a per-call spec state. `invoke a component function` establishes it for a JS-to-component call. Each guest resource instance lowered as a `borrow` during that call has its [[LendCount]] incremented and is appended to the list, which protects it from being dropped while lent. When the call returns, every [[LendCount]] in the list is decremented.


Converting the same JS value to a host resource type yields fresh handle indices. There is no canonicalization of indices.

### Guest resource types (i.e. exported)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, Guest resource types can be imported via the JS API by other components, in which case they are treated as Host resource types. It might be worth adding a short section mentioning this... somewhere.

To `brand check` given a JS value |jsValue| and an Object |constructor|:
1. If |constructor| is a WebIDL [interface object](https://webidl.spec.whatwg.org/#dfn-interface-object):
1. Return **true** if and only if |jsValue| is a platform object that [implements](https://webidl.spec.whatwg.org/#implements) the interface |constructor| is the interface object of.
1. If |constructor| has a [[ConstructorFunc]] internal slot (i.e. it is a [guest resource class](#guest-resource-classes)):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this apply not just to exported guest resource types but also imported host types that happen to be implemented by a previous component's exported resource type? If so, might be useful to mention that there are two ways this case applies.

1. Let |hostType| be |instance|.[[HostResourceTypes]][|abstractTypeKey|].
1. Assert: |rep| is a host resource value whose [[Type]] is |hostType|.
1. Return |rep|.[[JSValue]].
- `ToComponentValue(jsValue, own<R> | borrow<R>)`:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the host value is really a Guest resource instance (returned from some other component's export), it seems like ownership needs to be transferred (in the form of emptying the [[Rep]] of the Guest resource instance) so that there is not a double drop.

@vados-cosmonic vados-cosmonic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great start, took a pass at the explainer and reference pages and they look good so far, mostly nits/organizational (and emphasizing easy readability of the explainer) and some questions about conversions and WebIDL layout

@@ -0,0 +1,306 @@
# WebAssembly Components JS-API Explainer

This explainer describes how WebAssembly Components (hereafter 'components') can be used from JS.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to be more explicit about what we mean when we say "from JS"? It will likely be a while until we have full implementation support for all places people run JS.

I'm generally thinking of something like "can be used on runtimes that support the Component Model JS-API" ?

Comment on lines +11 to +14
### Greeter: exporting a function

Let's start with a component that imports nothing:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
### Greeter: exporting a function
Let's start with a component that imports nothing:
### Components that export
Let's start with a `greeter` component that imports nothing, but exports functionality:

The gist of this suggestion is that I think the "greeter" example is kind of a side-effect of doing the explanation of export-only components here, so figure that should take precedence as the title

BTW, we might also want to start with/use the WIT for the component actually rather that the WAT

Comment on lines +22 to +23

```js

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make things painfully clear, I think right now WebAssembly.instantiate does not support WAT as input, so we might want to have a transition sentence here that notes that the WAT is converted to a WebAssembly's binary. That said, if Luke's note about being clear the WAT is a fragment is taken up then this sentence could look different/be unnecessary of course.

Also, if we switch to WIT instead of WAT for the initial description, we could lead with something like "Given a WebAssembly binary that implements the WIT above", etc.

instance.exports.greet("world"); // "hello, world"
```

`exports` holds one property per export and `greet` is an ordinary JS function. Component names are kebab-case and JS names are [camelCase](./JS-Reference.md#names), so an export named `greet-loudly` would be `greetLoudly`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`exports` holds one property per export and `greet` is an ordinary JS function. Component names are kebab-case and JS names are [camelCase](./JS-Reference.md#names), so an export named `greet-loudly` would be `greetLoudly`.
The `exports` property of an instance holds one property per export and `greet` is an ordinary JS function.
Component export names are kebab-case and JS names are [camelCase](./JS-Reference.md#names), so an export named `greet-loudly` would be `greetLoudly`.

```

Passing too few arguments is a `TypeError`. Extra arguments are ignored.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should add some explanation around interfaces as exports, since they introduce a kind of nesting here. instance.exports.<iface> versus instance.exports.<fn>

Comment on lines +132 to +135
| *Plain* | `^(?<name><label>)$` | `query-selector` |
| *Property* | `^\[(?<accessor>get\|set)\](?<name><label>)$` | `[get]inner-HTML` |
| *Constructor* | `^\[constructor\](?<resource><label>)$` | `[constructor]element` |
| *Member* | `^\[(?<scope>method\|static)\](?:\[(?<accessor>get\|set)\])?(?<resource><label>)\.(?<name><label>)$` | `[method][set]element.inner-HTML` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a note after this about component model uniqueness here?

Unrelated to this section for example [method]foo.bar and [static]foo.bar actually would be an issue I think -- might be worth mentioning that the names are subject to those upstream rules.


While walking, the algorithm recognizes the pattern of a resource type import accompanied by *Constructor* and *Member* function imports naming it. A resource type import is read first and looks for a constructor (see [resource types](#resource-types)). Those function imports then read from the constructor and its prototype directly. This allows the common case of importing a class to be satisfied by just passing the constructor.

Passing an exported component definition to a component import via the JS-API/ESM-integration is treated as if the import were a JS value. There is no "direct linking" that bypasses going through JS semantics. This is different from core wasm, where exported functions are linked directly when imported and have stricter type checks. This is intentional to prevent the implementation detail of how a JS function was implemented from leaking. Components can still be nested and directly linked inside a single component binary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it be ever possible to change this in the future? i.e. should we have something like "there is not currently" any direct linking?


The component JS-API can provide builtins to imports just as the core JS-API does.

Builtin imports are opt-in via the `builtins` field of `WebAssemblyCompileOptions` when used in the JS-API. [ESM-integration](#webassembly-esm-integration) enables all builtins by default.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I don't think this matches the WebIDL right? We should probably have a component-specific compile options object if I'm reading this right


| Specifier | Resolves to |
|---|---|
| `wasm:js/global` | [the global object](#the-global-object) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do wonder if this should actually be wasm:browser/wasm:dom or something (or maybe split out into multiple packages/interfaces), given that some browser JS globals are not JS globals, and what not, but that's a much wider discussion!

|---|---|---|
| bare type, function, value | `import v from "JSSpecifier(decl)"` | the [default export](https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html#prod-ImportedDefaultBinding) |
| instance | `import { a, b } from "JSSpecifier(decl)"` | one [named import](https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html#prod-NamedImports) per export of the instance type whose name is an `interfacename` or matches *Plain*, named `JSName` of that export |
| core module, component | `import source M from "JSSpecifier(decl)"` | the module source, as a `Module` or `Component` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we link to the source phase https://github.com/tc39/proposal-source-phase-imports proposal here?

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.

4 participants