Setup
Workex is a solution to RPC in the JS world. You define interfaces in TypeScript, and Workex generates code so that calling a remote context uses the same interface you define.
Workex provides support for these bidirectional RPCs:
- Between
Window
andWorker
- Between 2
Window
s, for both same- and cross-origin- For example, the main window and iframes, or popouts
- WebSocket support might be added in the future
The library works as follows:
- You define the messaging protocol as TypeScript interfaces
- Run the
workex
CLI to generate code in your project - Use the generated code and the
@pistonite/workex
SDK library to have seamless RPC - Handshake, protocol agreement, error handling, same- and cross-origin… all handled internally
To get started, install the CLI tool with
cargo install --git https://github.com/Pistonite/workex
After installing, you can run workex --help
to see the available options.
See the tutorials for a full end-to-end walk-through
of running some basic RPC stuff.
TypeScript SDK
The code generated by the workex
CLI depends on the @pistonite/workex
TypeScript SDK.
You can install it with your favorite package manager. For example, for pnpm
:
pnpm i @pistonite/workex
The tutorials shows basic examples for using the SDK. You can also refer to the SDK reference when needed.
Note that:
- The SDK version and CLI version should have the same minor version (the major version will always be
0
)- i.e.
0.1.1
and0.1.0
are compatible with each other, but not0.1.1
and0.2.0
- i.e.
- The SDK is TypeScript-only, so a bundler is needed to consume it.
Tutorial
This chapter is a basic tutorial for setting up a project with workex
.
Be sure to first install the workex
tool before following
the tutorial.
Project Setup
We will use a bare-bone node project as the starting point.
If you want to follow along from scratch, create an empty directory.
Or, you can clone the repo and use packages/example-tutorial
directory,
which has all the code already there. (Code blocks on these tutorial pages actually pull
code directly from those files)
Project Structure
The project should have package.json
, tsconfig.json
and a src
directory
that is currently empty:
- src/
- package.json
- tsconfig.json
Run the following to make sure you have typescript
and @pistonite/workex
installed.
You can use any package manager - I am using pnpm
as an example
pnpm i -D typescript
pnpm i @pistonite/workex
If you want to build and serve the example after the walk-through, also
install serve
, and make sure you have bun
callable
from the command line. The easiest way to install both is:
pnpm i -D serve
pnpm i -g bun
Your package.json
should be similar to the following after installing those
dependencies:
{
"dependencies": {
"@pistonite/workex": "workspace:*"
},
"devDependencies": {
"serve": "^14.2.4",
"typescript": "^5.7.2"
}
}
The version of @pistonite/workex
in the example is workspace:*
because the example in the repo references the library in the workspace.
Yours should be the real version number
Now, create tsconfig.json
:
{
"compilerOptions": {
"noEmit": true,
"lib": ["esnext", "dom"],
"target": "esnext",
"useDefineForClassFields": true,
"moduleDetection": "force",
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"strict": true,
"skipLibCheck": true
},
"include": [ "src" ]
}
Most of the keys in tsconfig.json
are only there to have a reasonable base line
for typechecking. The only important configs are:
"lib"
: It should contain"dom"
or"webworker"
. Alternatively, you can setuptypes
to reference global types from other runtimes. The global scope must haveconsole
,setTimeout
,clearTimeout
,AbortController
, andURL
."allowImportingTsExtensions"
: This allows specifying.ts
extension inimport
statements, which is what the SDK library and generated code does. This slightly speeds up bundlers when resolving imports
Define Interfaces
The interfaces used for communication can be defined in one or more TypeScript
files. Here, we will create one src/Interfaces.ts
file that contains
2 interfaces AppSide
and WorkerSide
. These interfaces can have any names
that doesn’t start with _
(to avoid name conflicts in generated code)
Create src/Interfaces.ts
with the following content
// src/Interfaces.ts
import type { WxPromise } from "@pistonite/workex";
/**
* Functions implemented by the main thread callable from the worker
*/
export interface AppSide {
/**
* Get some data from the main thread for the worker to process
*/
getData(id: string): WxPromise<string>;
}
/**
* Functions implemented by the worker callable from the main thread
*/
export interface WorkerSide {
/**
* Initialize the worker
*/
initialize(): WxPromise<void>;
/**
* Process some data in the worker, and return the result
*/
process(data: string): WxPromise<string>;
}
When defining these interfaces, it’s helpful to treat the RPC calls like regular function calls, i.e. don’t think about one side calling the other side with inputs and the other side calling back with outputs. Instead, think about one side calling the other side with inputs as an async function call, and the other side returns the output through the function return.
Important rules to note:
- The interfaces must be declared with
export interface
. Other syntaxes are ignored even if they are technically the same in TypeScript, such asexport type
anddeclare
- The interfaces cannot contain constructor, getter, or setter signature; only regular functions
- The functions must return a
WxPromise
type. The import can be renamed, but type alias is not supported, as the CLI current doesn’t resolve types.
Some of these might be supported in the future, but as for now, these rules help simplify the parsing
There are some restrictions on syntax that can be used in the interfaces:
- Property signatures are not supported, only methods (change
foo: () => Bar
tofoo(): Bar
) - Interface type parameters and inheritance are not supported
- Method type parameters are not supported
- The generated files currently copy-paste the same
import
statements from the input files. When you have multiple interfaces in one file, it’s possible that some imports are unused in the output and may cause an error. A workaround is to split the input file into one interface per file.
These may be improved in the future
Documentation on the interfaces and functions are preserved in the output.
You can also use the Rust comment style (/// ...
), but in general, the JS Doc style
(/** ... */
) has better tooling support
Generate Code
Once you have the interfaces defined in the input file(s), it’s time to run the CLI to generate some code!
There are 2 flags that will be most commonly used:
-p/--protocol
: A string that identifies your package/library. This is used to distinguish between multiple libraries generated withworkex
operating on the same messaging channel. It can also contain some versioning scheme to identify version mismatch, if your protocol is meant to be implemented by others. (For example, your webapp support custom UI widgets through iframes)-l/--link
: This links 2 interfaces so that if one side of the connection implements one, the other side is assumed to implement the other. An interface can only be linked to one other interfaces. Unlinked interfaces are linked to a “stub” interface, i.e. the communication becomes one-direction.
In the example directory, run the following command, which
generates code that refers to the testapp
protocol and links our AppSide
and WorkerSide
workex src/Interfaces.ts -p testapp -l AppSide,WorkerSide
The protocol
string is also used as prefix for generated functions,
if it only contains lowercase alphabetic characters (a-z
). Otherwise,
you also need specify --prefix
flag to specify another prefix. Using
this flag is recommended if your protocol contains a version. For example
workex -p myproto-1.0.0 --prefix myproto
This should generate the src/interfaces/
directory. Note:
- You can use
--dir
to change the nameinterfaces
to something else, but you can’t change the output location otherwise. - If there are multiple input files, they must be in the same directory
The directory structure should now look something like:
- src/
- interfaces/
- AppSide.ts
- AppSide.bus.ts
- WorkerSide.ts
- WorkerSide.bus.ts
- .gitignore
- Interfaces.ts
- package.json
- tsconfig.json
The .gitignore
file is automatically generated to ignore everything in the interfaces
directory. You can turn it off with --no-gitignore
.
The generated files should be ignored from check tools like ESLint or Prettier.
See ESLint Documentation
or Prettier Documentation. The tool
doesn’t emit any disable directives because they might cause issues, for example
with ESLint’s --report-unused-directives
option.
If you don’t mean to git-ignore the output, you can also use a wrapper command to call the CLI then run prettier or other formatter to fix the output.
Using Generated Code
Finally, we will write the code for the main thread and the worker that talks to each other using the generated code.
For simplicity, we will put the code for the main thread
and the worker both in src
directory, as src/App.ts
and src/Worker.ts
. In real projects, these can be put anywhere
as long as they are able to import the generated code.
App Code
Create src/App.ts
, and paste in the code below.
See the comments in the code and the summary after for explanation.
// src/App.ts
import { wxWrapHandler, wxWorker } from "@pistonite/workex";
// this imports the generated code
import { testappWorkerSide } from "./interfaces/WorkerSide.bus.ts";
// this imports the original input interface
import type { AppSide } from "./Interfaces.ts";
// we will wrap the code in an async main function
// to avoid issues with top-level await in older browsers
// and be able to use early returns
const main = async () => {
console.log("App: start");
// define the handler on app side that responds
// to calls from the worker
const handler: AppSide = {
getData: wxWrapHandler((id: string) => {
if (id === "foo") {
return "bar";
}
return "";
})
}
console.log("App: creating worker");
// create the worker
// here we assume the bundled worker JS file will be served
// as /worker.js
const worker = new Worker("/worker.js");
console.log("App: connectiong to worker");
// because WorkerSide and AppSide are linked,
// testappWorkerSide takes in an AppSide handler
// and returns a WorkerSide interface
const result = await wxWorker(worker)({
workerApi: testappWorkerSide(handler)
});
// handle error in initialization of the connection
if (result.err) {
console.error("App got error:", result.err);
// note if the connection fails, the worker will be terminated
// automatically
return;
}
console.log("App: worker connected!");
// initialize the worker side
// the `workerApi` name is derived from the wxWorker call
// above, and is type-checked
const { connection, protocols: { workerApi }} = result.val;
// the returned connection handle can be used to control the connection.
// for example, register a callback to be called when the connection is closed
void connection.onClose(() => {
console.log("App: connection closed");
});
// the onClose() function returns a function that can be called
// to unregister the listener.
console.log("App: calling worker.initialize()");
// call the initialize() function defined on the WorkerSide
// interface
const ready = await workerApi.initialize();
// Any RPC call would return WxResult<T>, and you must
// handle the potential errors that happen during
// communication
if (ready.err) {
console.error("App got error:", ready.err);
// after connection is established, errors don't close
// the connection. you can manually close the connection
// with connection.close() or worker.terminate()
connection.close();
return;
}
console.log("App: calling worker.process()");
// do some work!
const output = await workerApi.process("hello foo");
console.log("App: got response from worker:", output);
}
// call our main function
void main();
The app code does the following things:
- Defines an implementation of
AppSide
, for the worker to call once the connection is established - Creates the worker, and connect to it with
wxWorker
function from the SDK library, together with the generated bind configtestappWorkerSide
- Call functions on the returned interface just like normal async function calls.
Note the curried syntax for wxWorker
. The wxWorker
function
actually returns a WxBusCreatorFn
that can be called with the
config to initialize the connection.
This way, wxWorker
can take optional arguments without
having to put them after the config object, which improves
readability. This pattern is used in other “connection creator”
functions too, such as wxWorkerGlobal
, wxWindowOwner
,
and wxPopup
Also note the usage of wxWrapHandler
. This is a very thin
wrapper that wraps the return value T
as a WxPromise<T>
.
The above handler is equivalent to:
const handler: AppSide = {
getData: async (id: string) => {
if (id === "foo") {
return { val: "bar" };
}
return { val: "" };
}
}
Worker Code
Create src/Worker.ts
, and paste in the code below.
See the comments in the code and the summary after for explanation.
// src/Worker.ts
import { wxMakePromise, wxWorkerGlobal, type WxPromise } from "@pistonite/workex";
// this imports the generated code
import { testappAppSide } from "./interfaces/AppSide.bus.ts";
// this imports the original input interface
import type { WorkerSide, AppSide } from "./Interfaces.ts";
// we will wrap the code in an async main function
// to avoid issues with top-level await in older browsers
// and be able to use early returns
const main = async () => {
console.log("Worker: start");
// create the binding up here since the handler
// needs to reference the app side, which may or may not be ready
// when the handler is called
const {
promise: appApiPromise,
resolve: resolveAppApi,
} = wxMakePromise<AppSide>();
// define the handler on worker side that responds
// to calls from the app
const handler: WorkerSide = {
initialize: async () => {
console.log("Worker: (fakely) initialized!");
return {};
},
process: async (input: string): WxPromise<string> => {
console.log("Worker: processing input:", input);
// wait for the binding to be set
const app = await appApiPromise;
// some example logic
const data = await app.getData("foo");
if (data.err) {
return data;
}
return { val: `${input} ${data.val}` };
}
};
// connect to the app side that created this worker
const result = await wxWorkerGlobal()({
// this function can take a second parameter,
// for a callback to be invoked when the binding is ready.
// If the app side calls us after the connection is established,
// but before the binding is set on our side, the handler
// will wait until the binding is set before continuing.
// thanks to the promise we created at the beginning
appApi: testappAppSide(handler, resolveAppApi)
});
// handle error in initialization of the connection
if (result.err) {
console.error(result.err);
return;
}
console.log("Worker: ready to be initialized!");
}
void main();
The worker code is very similar to the app code, with the following difference:
- Because the worker code needs to call back to the app in its handler,
it defines a promise using
wxMakePromise
and use it to block the handler until the bindings are fully setup. - It uses
wxWorkerGlobal
, which connects to the thread that created the worker ( in this case, the main thread)
If you want to avoid await appApiPromise
every function in your handler,
you can use this alternative:
// casting is ok since we don't call this until it's ready
let appApi: AppSide = undefined as AppSide;
const { promise, resolve } = wxMakePromise();
const handler = {
initialize: async () => {
await promise;
return {}
}
/* ... other functions ... */
}
/* ... setup the connection ... */
// assign the binding
({ appApi } = result.val);
// resolve the promise
resolve();
The catch is, the app side has to await workerApi.initialize()
before
calling other functions to guarantee it’s initialized.
Running the Example
The example we walked through isn’t some sample code to show you the syntax. It’s fully runnable!
First, we will run tsc
to do a sanity type-checking to make sure
the example conforms to the API contracts:
pnpm exec tsc
To bundle the TS code we wrote and the SDK library, we will use bun
,
which offers zero-config bundling for TypeScript:
bun build src/App.ts > dist/index.js
bun build src/Worker.ts > dist/worker.js
We will also create a HTML file that does nothing but loads index.js
:
<!-- dist/index.html -->
<DOCTYPE! html>
<html>
<head>
</head>
<body>
<script src="index.js"></script>
You need to open the console to see the example output
</body>
</html>
Now run pnpm exec serve
to serve the example locally, and open
it in your browser. You should see something like this in the console:
App: start
App: creating worker
App: connectiong to worker
Worker: start
App: worker connected!
App: calling worker.initialize()
Worker: ready to be initialized!
Worker: (fakely) initialized!
App: calling worker.process()
Worker: processing input: hello foo
App: got response from worker: {val: 'hello foo bar'}
Your output might have a different order - That’s the magic of multithreading! Now it’s time for you to go back to the source and see if the output makes sense to you.
You can also try refreshing the window a few times, and you might see the order of the logs change!
SDK Reference
This section is the main documentation of the SDK library. It details the behavior of the API sets, as well as some designs and underlying concepts used internally (and externally).
Some of these concepts are:
Also, the generated API reference is available here. This can be used as quick reference and supplement to this documentation.
The SDK reference is organized bottom-up, meaning it starts with the lowest level of concepts and browser primitives, and then build up to the high level APIs that you saw in the tutorials.
Error Handling
The SDK uses the result pattern, powered by another of my in-house TypeScript
library - pure
. This is a purely type-based
implementation, so it does not have any runtime overhead (other than having
to check error).
In the SDK, the 2 types used for error handling is WxError
and WxEc
which is a string type-union enum that stands for “Error Code”.
Each WxError
has a code: WxEc
that will tell you what the error is, and optionally
a message that might have more details about the error
const result = wxDoSomething();
if (result.err) {
if (result.err.code === "Timeout") {
// failed because of time out
}
// make this path diverge
return;
}
// TypeScript can now infer the type of result.val
console.log(result.val);
JS Messaging Channel
Messaging in JS refers to passing JS objects between different contexts.
Different contexts do not have access to each other directly, such as the main thread
and worker threads, or windows with different origins. Messaging in JS
is done by calling postMessage
on an object, typically a Worker
, WorkerGlobalScope
,
or a Window
in the Web. The message will be delivered as a MessageEvent
to event listeners listening for the "message"
event.
Worker
, WorkerGlobalScope
, and Window
has slightly different behavior
when it comes to how postMessage
works:
- Calling
postMessage
on a worker sends the message to the worker - Calling
postMessage
on aWorkerGlobalScope
sends the message to the context that created the worker, on the worker object- Note in this case,
globalThis.postMessage
sends the message to the creator of the worker
- Note in this case,
- Calling
postMessage
on aWindow
sends the message to thatWindow
.- Note in this case,
globalThis.postMessage
sends the message to the calling context itself
- Note in this case,
Therefore, messaging is only relevant when there are multiple JS contexts. These are the usual ways how multiple JS contexts are usually involved or created:
- A
Window
object has aparent
if it’s embedded in another document, for example as aniframe
. - A
Window
object has aopener
if it’s opened by callingwindow.open
. - A
Window
object can open otherWindow
s by callingwindow.open
, or embed other documents (which can be same- or cross-origin). - A
Window
object can spawnWorker
s. - A
Worker
can spawn otherWorker
s, but cannot openWindow
s.
This can be illustrated as a tree
Active and Passive Sides
This brings the lowest-level concept in Workex
- Active and Passive sides.
We call the context created by another context the active side,
and the context creating the other the passive side.
This distinction is important in 2 places:
- When initiating the communication
- When terminating the communication
The terminology is derived from the relationship of the 2 sides when initiating
the communication. Although major browsers have basically the same behavior,
the web standard actually does not specify when a new context created by an existing
context should start running. For example, when you call new Worker()
, the worker
JS code can start executing immediately in a different thread (since it’s a different
context, this is multithreaded JS), or, like how most browser implemented it,
does not start until the current JS task is done executing.
As a library, we do not want to rely on each browser implementing a specific behavior. Therefore, we will assume that the existing context does not know when the new context will start executing. This means to initiate the communication, the best way is to let the new context send the first message (referred to as the handshake message), and then the existing context will respond with a handshake message as well. The side that sends the first handshake message is the active side, and the other the passive side.
It’s also important when terminating the communication. Based on the diagram above, we can see the direction of the arrow as a “ownership” relation. For example, if a website creates a popup window, and the user closes the website, the popup window should probably close as well (note this is not the default behavior for popular browsers). We enforces this: the passive side is the owning side, and the active side is the owned side, and the differences are:
- When communication is to be terminated, the owner (passive) side will definitely be notified in order for any necessary clean-up to run. The owned (active) side may not be notified, as the context may simply get destroyed
- The owned (active) side will not close itself even though it’s technically possible. It will also “request” the owning side to close it, allowing the owner side to always be notified when the connection terminates, regardless of the implementation of the JS runtime.
An End of a Channel
The WxEnd
is the first layer of abstraction
over the JS messaging channels. It presents an established channel, meaning
when an WxEnd
object exists, the underlying channel it encapsulates
must already exchanged the handshake and ready to send messages to each other.
Every channel has 2 ends, shown by the illustration below
This means you should only create one channel in the SDK per connection. Having multiple channels for the same underlying connection is not supported. However, the SDK supports having multiple protocols on the same channel. See next chapters for details
An end does not map to a context: Any Window
or Worker
could
have multiple ends open, if they spawn multiple Window
or Worker
s. They
could also have an end open for their owner/creator, and one or more ends
open for any other Window
or Worker
they create.
At this level, there is no distinction between the active and passive side from
the API perspective: calling close()
on either end of the channel will close the connection.
The underlying implementation may depend on if the side is active or passive,
but this is encapsulated.
Finally, the end also owns managing the underlying resource, meaning
if the messaging is done to a Worker
or a popup Window
, the will also be
disposed of (terminated/closed). The only exception is embedded frames (iframe
s).
Closing the connection for an embbeded frame with its embedder does not
automatically remove the iframe
element from the DOM.
This behavior is cascading. Suppose the main window opens a popup, and the popup
opens another popup (both using workex
. When the main window is closed,
both popups will be closed.
Bidirectional Unicall System (BUS)
This is term completely made up by me to fit the BUS acronym and does not have significance in the industry!
With the WxEnd
abstraction, we have a uniform way of communicating
between different contexts, regardless of if it’s between Window
s,
Worker
s, or any combination or nested combination of them. The Bidirectional
Unicall System is the layer on top of WxEnd
that implements
the Remote Procedure Call, or RPC.
This layer turns messaging into async function call - the implementation is basically
// this is pseudo code and greatly simplified,
// not how the BUS is actually implemented
function callRPC() {
return new Promise(resolve => {
end.postMessage({
id: 1,
call: "foo",
args: ["bar"]
});
end.onmessage = ({data}) => {
if (data.id === 1) {
resolve(data.returnvalue);
}
};
});
}
In reality, the implementation is slightly more complicated to handle potential errors, timeouts, catching exceptions on the other end and send it back, and multiple inflight messages, and perhaps mostly importantly, muxing different protocols, which will be explained in the next chapter.
Protocols
Protocols exist to solve 2 problems:
- The
workex
CLI tool ensures every function call can be uniquely identified by assigning a numeric ID to each function. However, it’s possible that an application uses multiple sets of code generated byworkex
. We need an external method to tell these messages apart. - One great use case for this library is custom plugins loaded as frames, popups,
or workers. We need a way to version the generated code, so an update doesn’t
break the application because the 2 sides don’t have the same definition on
what function
42
is, for example.
The following illustration shows 2 protocols operating on the same bus, using the same underlying connection.
See the tutorial for what the command means
For ergnomics and to simplify the implementation, the BUS enforces that each protocol can only register one connection. The protocols are registered at BUS-creation time, by passing in an implementation of one of the ends and calling a “binding function” generated by the CLI tool. The BUS will then automatically return an implementation of the linked interface. Calling functions on that interface will invoke the implementation on the other side through RPC, but it all feels like regular async function call.
// `wxCreateBus` is an internal function, so this is just
// pseudo code to demonstrate the concept - error handling is also omitted
const barAImpl: BarA = {
/* ... implements the interface */
};
// wxCreateBus will handle all the handshake, protocol agreement, etc
const { fooA } = await wxCreateBus({
// the bind config functions are generated by CLI
fooA: bindBarA(barAImpl)
});
// use fooA, which implements FooA
To solve the second problem on the top, the BUS will send protocol query messages
and agree on the protocols and interface types before accepting any other message.
For example, if side A receives a bindBarA
config, then it knows the other side
must provide a FooA
implementation in the same protocol. If the other side agrees,
then the protocols are agreed, and RPCs can start.
When breaking changes to a library is made, the protocol can be changed (e.g.
my-lib-v1
to my-lib-v2
). This will cause outdated plugins to have a protocol
disagreement, and prevent further breakage.
// the error is returned by the creator function in real code
const result = await wxCreateBus({...});
if (result.err) {
if (result.err.code === "ProtocolDisagree") {
// tell author of the plugin
console.error("Hey! Please update the version of my-lib in your package.json!");
return;
}
}
Both sides will receive this error, so it’s also helpful to have a nice user-friendly UI in the main app that explains to the user why they can’t use the plugin.
Creator Functions
Finally, the creator functions are the entry points of this library. They encapsulate everything explained in the chapters before. They take in the config object to setup the BUS, establish the connection, agree on protocols, and return the linked interface implementation to call the other side.
There are five creator functions available, which can be divided into 2 groups:
- For workers:
- For windows:
Workers
wxWorker
and wxWorkerGlobal
are used for connecting to Worker
s.
The passive side calls wxWorker(worker)
, and the active side calls wxWorkerGlobal
to establish the connection using globalThis
.
See Active and Passive Sides for what active and passive means.
The tutorial has full examples for setting up connection for workers
Windows
Windows are slightly more complicated. The global Window
object
can both receive messages from its owner and the windows it owns.
The source
of the MessageEvent
tells where the message is sent.
This is encapsulated in the WxWindow
object,
which is a handle/wrapper around the global Window
. You don’t need
to use this object directly.
For popups, the wxPopup
creator function takes an URL and options, and
will open the popup window for you. For frames, the wxFrame
takes
in an HTMLIFrameElement
and only establish the connection (i.e. it doesn’t
put the element in the DOM for you).
From the other side, the wxWindowOwner
is used to connect to the owner side.
Since a window can’t both be an embedded document and a popup at the same time,
it will automatically detect what scenario it is.
What it does need to know is the origin
of the owner. This is because of
2 reasons:
- same-origin
Window
s run in the same context, and is considered trusted. In this case,postMessage
is quite expensive, and the SDK uses a simple implementation to directly call the handler on the other end. - For cross-origin
postMessage
, the sender must indicate the origin of the recipient. This is to prevent sending message to an untrusted context. For example, if the other window navigates to another website, you don’t want to send your information to scripts on unknown websites. The browser will typically block these messages.
An easy way to do this, is to use a URL param when opening the popup or frame.
// === on the main window (passive) side
const origin = window.location.origin;
const result = await wxPopup(`https://mywebsite.com/popup.html?ownerOrigin=${origin}`)({
/* your config object */
});
// === on the popup (active) side
const ownerOrigin = new URLSearchParams(window.location.search).get("ownerOrigin");
const result = await wxWindowOwner(ownerOrigin)({
/* your config object */
});
The multiwindow test app has examples for how to use these creator functions for windows