@jcubic/mitty 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +298 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.global.js +410 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +385 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakub T. Jankiewicz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/jcubic/mitty/blob/master/.github/logo-dark.svg?raw=true" />
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="https://github.com/jcubic/mitty/blob/master/.github/logo-light.svg?raw=true" />
|
|
5
|
+
<img alt="Mitty Logo" src="https://github.com/jcubic/mitty/blob/master/.github/logo-light.svg?raw=true" height="500"/>
|
|
6
|
+
</picture>
|
|
7
|
+
</h1>
|
|
8
|
+
|
|
9
|
+
<div align="center">
|
|
10
|
+
|
|
11
|
+
[](https://www.npmjs.com/package/@jcubic/mitty)
|
|
12
|
+
[](https://github.com/jcubic/mitty)
|
|
13
|
+
[](https://github.com/jcubic/mitty/actions/workflows/ci.yml)
|
|
14
|
+
[](https://coveralls.io/github/jcubic/mitty)
|
|
15
|
+
[](https://github.com/jcubic/mitty/blob/master/LICENSE)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
Use objects that only exist on the main thread — DOM nodes, jQuery objects, class
|
|
20
|
+
instances with methods — from inside a Web Worker.
|
|
21
|
+
|
|
22
|
+
A worker has no DOM, and `postMessage` cannot carry a DOM node, a jQuery object, or
|
|
23
|
+
anything else that isn't structured-cloneable. Mitty leaves the real object on the main
|
|
24
|
+
thread and puts a proxy in the worker. The proxy records property accesses and calls
|
|
25
|
+
without touching the channel; the whole chain is replayed on the main thread when you
|
|
26
|
+
await it.
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
// inside a worker — no DOM here
|
|
30
|
+
const $ = require('$');
|
|
31
|
+
await $('#list').find('li').first().text(); // one message, not four
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install @jcubic/mitty
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from a CDN, for a `<script>` tag or `importScripts()` — the build exposes a `Mitty`
|
|
41
|
+
global:
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<script src="https://cdn.jsdelivr.net/npm/@jcubic/mitty"></script>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
### Main thread
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { Host } from '@jcubic/mitty';
|
|
53
|
+
|
|
54
|
+
const modules = {
|
|
55
|
+
$: () => jQuery,
|
|
56
|
+
term: () => $('.terminal').terminal(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const channel = new BroadcastChannel('my-app');
|
|
60
|
+
|
|
61
|
+
const host = new Host({
|
|
62
|
+
channel,
|
|
63
|
+
resolve(name) {
|
|
64
|
+
if (Object.hasOwn(modules, name)) {
|
|
65
|
+
return modules[name]();
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
},
|
|
69
|
+
serialize(value) {
|
|
70
|
+
// a jQuery object wraps DOM nodes and cannot cross the channel —
|
|
71
|
+
// hand out a handle the worker can call methods on instead
|
|
72
|
+
if (value instanceof $.fn.init) {
|
|
73
|
+
return this.remote(value);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const worker = new Worker('./worker.js');
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Only the names your `resolve()` answers are reachable. Everything else on the page stays
|
|
83
|
+
invisible to the worker.
|
|
84
|
+
|
|
85
|
+
### Worker
|
|
86
|
+
|
|
87
|
+
With an `import` statement, in a worker started with `{ type: 'module' }`:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { connect } from '@jcubic/mitty';
|
|
91
|
+
|
|
92
|
+
const { require } = connect(new BroadcastChannel('my-app'));
|
|
93
|
+
|
|
94
|
+
const $ = require('$');
|
|
95
|
+
await $('.terminal').terminal().echo('Hello from a worker');
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or with `importScripts()`, in a classic worker:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
importScripts('https://cdn.jsdelivr.net/npm/@jcubic/mitty');
|
|
102
|
+
|
|
103
|
+
const { require } = Mitty.connect(new BroadcastChannel('my-app'));
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## One channel per worker
|
|
107
|
+
|
|
108
|
+
Both ends take a channel rather than creating one, so you decide what the two sides talk
|
|
109
|
+
over and how many conversations there are. Request ids are per connection and start at 1,
|
|
110
|
+
so two workers sharing a channel name will see each other's replies and resolve the wrong
|
|
111
|
+
calls. Give every worker its own channel:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
let seq = 0;
|
|
115
|
+
|
|
116
|
+
function spawn(url) {
|
|
117
|
+
const name = `my-app-${++seq}`;
|
|
118
|
+
const host = new Host({ channel: new BroadcastChannel(name), resolve, serialize });
|
|
119
|
+
const worker = new Worker(url);
|
|
120
|
+
worker.postMessage({ channel: name }); // tell the worker which one to join
|
|
121
|
+
return { host, worker };
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A channel is never closed by this library — whoever created it owns it.
|
|
126
|
+
|
|
127
|
+
## How a chain becomes one message
|
|
128
|
+
|
|
129
|
+
`$('#list').find('li').first().text()` does not talk to the main thread four times. Each
|
|
130
|
+
step appends to a list of operations held in the worker:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
[
|
|
134
|
+
{ type: 'call', args: ['#list'] },
|
|
135
|
+
{ type: 'get', key: 'find' },
|
|
136
|
+
{ type: 'call', args: ['li'] },
|
|
137
|
+
// ...
|
|
138
|
+
];
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Nothing is sent until the chain is awaited — that is the only point at which the proxy
|
|
142
|
+
becomes a real thenable. The host then walks the whole list against the resolved module
|
|
143
|
+
and sends back the final value.
|
|
144
|
+
|
|
145
|
+
A consequence worth knowing: a proxy you have not awaited is not a promise, and a bare
|
|
146
|
+
handle is not one either. Both are inert until you chain something onto them and await.
|
|
147
|
+
|
|
148
|
+
## Handles and memory
|
|
149
|
+
|
|
150
|
+
Anything `serialize()` turns into a handle with `this.remote(value)` is kept alive on the
|
|
151
|
+
main thread until it is released. There is no automatic collection — a handle is a plain
|
|
152
|
+
integer on the wire, and the host cannot see when the worker drops its proxy.
|
|
153
|
+
|
|
154
|
+
Release explicitly when you are done:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const list = await $('#list');
|
|
158
|
+
// ... use it ...
|
|
159
|
+
release(list);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
If you would rather tie it to garbage collection, a `FinalizationRegistry` can do it —
|
|
163
|
+
but the callback must not capture the proxy, or the proxy will never become unreachable
|
|
164
|
+
and the callback will never run. Register the numeric id instead, which is what
|
|
165
|
+
`handle()` is for:
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
const client = connect(channel);
|
|
169
|
+
const registry = new FinalizationRegistry(id => client.release(id));
|
|
170
|
+
|
|
171
|
+
function tracked(remote) {
|
|
172
|
+
registry.register(remote, client.handle(remote));
|
|
173
|
+
return remote;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const list = tracked(await $('#list'));
|
|
177
|
+
// when `list` becomes unreachable, the host is told to drop it
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Collection timing is not guaranteed, so this is a safety net rather than a replacement
|
|
181
|
+
for releasing things you know you are finished with.
|
|
182
|
+
|
|
183
|
+
## Callbacks
|
|
184
|
+
|
|
185
|
+
A function passed from the worker stays in the worker. The host receives a stub that
|
|
186
|
+
returns a promise; calling it runs the real function back in the worker:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
// worker
|
|
190
|
+
const result = await require('util').map([1, 2, 3], n => n * 2);
|
|
191
|
+
// -> [2, 4, 6], with the doubling done in the worker
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Arguments are trimmed to the callback's declared arity, because callers like jQuery pass
|
|
195
|
+
extras (event objects, indexes) that usually cannot be serialized. Declare the parameters
|
|
196
|
+
you actually want.
|
|
197
|
+
|
|
198
|
+
## Errors
|
|
199
|
+
|
|
200
|
+
Errors cross the channel with their `name`, `message` and the host's `stack`, and arrive
|
|
201
|
+
as real `Error` instances:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
try {
|
|
205
|
+
await $('#list').nosuchmethod();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
error instanceof Error; // true
|
|
208
|
+
error.message; // "mitty: $().nosuchmethod is not a function"
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## API
|
|
213
|
+
|
|
214
|
+
### `new Host(options)`
|
|
215
|
+
|
|
216
|
+
| option | type | description |
|
|
217
|
+
| ------------- | -------------------- | -------------------------------------------------------------------------------------------- |
|
|
218
|
+
| `channel` | `Channel` | Transport to listen on. Required. Never closed by mitty. |
|
|
219
|
+
| `resolve` | `(name) => unknown` | Turns a `require()` name into a value. Return `null`/`undefined` for unknown. May be async. |
|
|
220
|
+
| `serialize` | `(value) => unknown` | Called for every outgoing value. Return `this.remote(value)` for anything JSON cannot carry. |
|
|
221
|
+
| `unserialize` | `(value) => unknown` | Called for every incoming value. |
|
|
222
|
+
|
|
223
|
+
`serialize` and `unserialize` are called with the host as `this`.
|
|
224
|
+
|
|
225
|
+
- **`host.remote(value)`** — register `value` and return a handle marker. Call this from
|
|
226
|
+
`serialize()`.
|
|
227
|
+
- **`host.release(handle)`** — drop a handle, by marker or by id. Returns `false` if it
|
|
228
|
+
was already gone.
|
|
229
|
+
- **`host.close()`** — stop listening and forget every handle.
|
|
230
|
+
|
|
231
|
+
### `connect(channel)`
|
|
232
|
+
|
|
233
|
+
Returns a client:
|
|
234
|
+
|
|
235
|
+
- **`require(name)`** — a proxy rooted at whatever the host's `resolve()` returns.
|
|
236
|
+
- **`handle(remote)`** — the numeric id behind a handle proxy.
|
|
237
|
+
- **`release(remote)`** — tell the host to drop it, by proxy or by id.
|
|
238
|
+
- **`close()`** — stop listening. The channel stays open.
|
|
239
|
+
|
|
240
|
+
### `Channel`
|
|
241
|
+
|
|
242
|
+
Any object with these three members works — a `BroadcastChannel`, a `MessagePort`
|
|
243
|
+
wrapper, or a stub in tests:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
interface Channel {
|
|
247
|
+
postMessage(message: string): void;
|
|
248
|
+
addEventListener(type: 'message', listener: (event: { data: string }) => void): void;
|
|
249
|
+
removeEventListener(type: 'message', listener: (event: { data: string }) => void): void;
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Messages on the wire are JSON strings, so a channel only has to carry text.
|
|
254
|
+
|
|
255
|
+
## Limitations
|
|
256
|
+
|
|
257
|
+
- **Values must survive JSON.** Anything else needs a handle via `serialize()`. Circular
|
|
258
|
+
structures fail the call that would return them.
|
|
259
|
+
- **`{ type, data }` is reserved.** Functions, handles and errors travel as objects of
|
|
260
|
+
that exact shape. An application object that looks identical would be mistaken for one.
|
|
261
|
+
- **Functions returned from the host are dropped**, as they would be by `JSON.stringify`.
|
|
262
|
+
Expose them through a handle instead.
|
|
263
|
+
- **Handles are not garbage collected** — see [Handles and memory](#handles-and-memory).
|
|
264
|
+
|
|
265
|
+
## Example
|
|
266
|
+
|
|
267
|
+
A runnable page that drives jQuery from a worker, in both the `importScripts` and
|
|
268
|
+
`import` styles, lives in
|
|
269
|
+
[`example/`](https://github.com/jcubic/mitty/tree/master/example):
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
npm install
|
|
273
|
+
npm run build
|
|
274
|
+
npx serve .
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
then open `/example/`.
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
npm test # unit tests
|
|
283
|
+
npm run coverage # tests with coverage
|
|
284
|
+
npm run lint # eslint
|
|
285
|
+
npm run build # ESM + IIFE + .d.ts into dist/
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Origin
|
|
289
|
+
|
|
290
|
+
The first idea for the mechanism was created for [Hacking Cafe](https://hacking.cafe), a Unix-like environment in the browser. It was then extracted into an NPM library. The name and logo were based on a fictional "Life" magazine worker named [Waleter Mitty](https://en.wikipedia.org/wiki/Walter_Mitty) from the movie ["The Secret Life of Walter Mitty"](<https://en.wikipedia.org/wiki/The_Secret_Life_of_Walter_Mitty_(2013_film)>).
|
|
291
|
+
|
|
292
|
+
A similar RPC mechanism was created in [Wayne library](https://github.com/jcubic/wayne).
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
Copyright (c) 2026 [Jakub T. Jankiewicz](https://jakub.jankiewicz.org/)
|
|
297
|
+
|
|
298
|
+
Released under the MIT License. See [LICENSE](https://github.com/jcubic/mitty/blob/master/LICENSE) for details.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
interface ChannelEvent {
|
|
2
|
+
data: string;
|
|
3
|
+
}
|
|
4
|
+
type ChannelListener = (event: ChannelEvent) => void;
|
|
5
|
+
interface Channel {
|
|
6
|
+
postMessage(message: string): void;
|
|
7
|
+
addEventListener(type: 'message', listener: ChannelListener): void;
|
|
8
|
+
removeEventListener(type: 'message', listener: ChannelListener): void;
|
|
9
|
+
}
|
|
10
|
+
interface FunctionMarker {
|
|
11
|
+
type: 'function';
|
|
12
|
+
data: [id: number, length: number];
|
|
13
|
+
}
|
|
14
|
+
interface ObjectMarker {
|
|
15
|
+
type: 'object';
|
|
16
|
+
data: [id: number];
|
|
17
|
+
}
|
|
18
|
+
interface ErrorMarker {
|
|
19
|
+
type: 'error';
|
|
20
|
+
data: [name: string, message: string, stack: string | null];
|
|
21
|
+
}
|
|
22
|
+
type Marker = FunctionMarker | ObjectMarker | ErrorMarker;
|
|
23
|
+
type Op = {
|
|
24
|
+
type: 'get';
|
|
25
|
+
key: string;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'call';
|
|
28
|
+
args: unknown[];
|
|
29
|
+
};
|
|
30
|
+
interface Remote {
|
|
31
|
+
(...args: any[]): Remote;
|
|
32
|
+
then<R1 = any, R2 = never>(onfulfilled?: ((value: any) => R1 | PromiseLike<R1>) | null, onrejected?: ((reason: any) => R2 | PromiseLike<R2>) | null): Promise<R1 | R2>;
|
|
33
|
+
[key: string]: any;
|
|
34
|
+
}
|
|
35
|
+
interface Client {
|
|
36
|
+
require(name: string): Remote;
|
|
37
|
+
handle(remote: unknown): number;
|
|
38
|
+
release(remote: unknown | number): void;
|
|
39
|
+
close(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface HostOptions {
|
|
43
|
+
channel: Channel;
|
|
44
|
+
resolve(name: string): unknown;
|
|
45
|
+
serialize?(this: Host, value: unknown): unknown;
|
|
46
|
+
unserialize?(this: Host, value: unknown): unknown;
|
|
47
|
+
}
|
|
48
|
+
declare class Host {
|
|
49
|
+
private _options;
|
|
50
|
+
private _channel;
|
|
51
|
+
private _listener;
|
|
52
|
+
private _objects;
|
|
53
|
+
private _object_id;
|
|
54
|
+
private _pending;
|
|
55
|
+
private _call_id;
|
|
56
|
+
constructor(options: HostOptions);
|
|
57
|
+
remote(value: unknown): ObjectMarker;
|
|
58
|
+
release(handle: ObjectMarker | number): boolean;
|
|
59
|
+
close(): void;
|
|
60
|
+
private _post;
|
|
61
|
+
private _serialize;
|
|
62
|
+
private _unserialize;
|
|
63
|
+
private _callback;
|
|
64
|
+
private _on_message;
|
|
65
|
+
private _invoke;
|
|
66
|
+
private _root;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
declare function connect(channel: Channel): Client;
|
|
70
|
+
|
|
71
|
+
export { type Channel, type ChannelEvent, type ChannelListener, type Client, type ErrorMarker, type FunctionMarker, Host, type HostOptions, type Marker, type ObjectMarker, type Op, type Remote, connect };
|