wirejs-web-worker 1.0.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/README.md ADDED
@@ -0,0 +1,154 @@
1
+ ## ⚠️ Experimental Alpha ⚠️
2
+
3
+ An experimental utility for type safe Web Workers.
4
+
5
+ ```
6
+ npm i wirejs-web-worker
7
+ ```
8
+
9
+ For example, create code you want to run as a worker in a `my-worker` sub-package:
10
+
11
+ ```
12
+ src/
13
+ my-app-code.ts
14
+
15
+ my-worker/
16
+ src/index.ts <-- here
17
+ package.json
18
+
19
+ package.json
20
+ ```
21
+
22
+ ### `my-worker/src/index.ts`
23
+
24
+ Let's just count up to *some number* and report on progress every 50ms.
25
+
26
+ ```typescript
27
+ import { SingleWorker } from 'wirejs-web-worker';
28
+
29
+ export const worker = SingleWorker({
30
+ async count(
31
+ upTo: number,
32
+ options?: { tick?: (pct: number) => void }
33
+ ) {
34
+ let lastUpdate = new Date();
35
+ options?.tick?.(0);
36
+ let c = 0;
37
+ for (let i = 0; i < upTo; i++) {
38
+ let current = new Date();
39
+ if (current.getTime() - lastUpdate.getTime() > 50) {
40
+ options?.tick?.(i / upTo);
41
+ lastUpdate = current;
42
+ }
43
+ c++;
44
+ }
45
+ options?.tick?.(1);
46
+ return c;
47
+ }
48
+ });
49
+ ```
50
+
51
+ ### `src/my-app-code.ts`
52
+
53
+ The page will just need to import `worker` from the `my-worker` sub-package and call `worker.count()`.
54
+
55
+ ```typescript
56
+ import { html, hydrate, text } from 'wirejs-dom/v2';
57
+ import { Main } from '../layouts/main.js';
58
+ import { worker } from 'my-worker';
59
+
60
+ async function App() {
61
+ return html`<div id='app'>
62
+ <h4>Web Worker Demo</h4>
63
+ <div>${text('status', '...')}</div>
64
+ <div>Web Worker output: ${text('output', '...')}</div>
65
+ </div>`.onadd(async self => {
66
+ console.log('starting web worker');
67
+ self.data.output = (await worker.count(256_000_000, {
68
+ tick: pct => self.data.status = `${Math.floor(pct * 100)} % complete.`
69
+ })).toString();
70
+ });
71
+ }
72
+
73
+ export async function generate() {
74
+ return Main({
75
+ pageTitle: 'Welcome!',
76
+ content: await App()
77
+ })
78
+ }
79
+
80
+ hydrate('app', App as any);
81
+ ```
82
+
83
+ In this example, we pass a function to the worker (the `tick` callback). An adapter is injected by `wirejs-web-worker` *during the build* that takes care of creating the necessary lookup tables, message passing, and garbage cleanup jobs on both ends of the pipe.
84
+
85
+ (The overarching framework for this example yet another experimental project. 😅)
86
+
87
+ ### `my-worker/package.json`
88
+
89
+ To make this work, the worker sub-package must built using the `wirejs-web-worker` build script, and the sub-package exports must be explicitly set:
90
+
91
+ ```json
92
+ {
93
+ "name": "my-worker",
94
+ "private": true,
95
+ "type": "module",
96
+ "exports": {
97
+ "types": "./src/index.ts",
98
+ "default": "./dist/index.js"
99
+ },
100
+ "scripts": {
101
+ "prebuild": "wirejs-web-worker-build",
102
+ "start": "wirejs-scripts watch src npm run prebuild"
103
+ }
104
+ }
105
+ ```
106
+
107
+ This example builds during the `prebuild` stage to ensure it is ready for the main package's `build` step &mdash; assuming we're using vanilla workspaces and don't have luxury of dependency-aware build sequencing.
108
+
109
+ ### `package.json`
110
+
111
+ An example top-level `package.json` using vanilla workspaces:
112
+
113
+ ```json
114
+ {
115
+ "name": "sample-app",
116
+ "version": "1.0.0",
117
+ "private": true,
118
+ "type": "module",
119
+ "workspaces": [
120
+ "src",
121
+ "web-worker"
122
+ ],
123
+ "dependencies": {
124
+ "wirejs-dom": "^1.0.41",
125
+ "wirejs-web-worker": "^1.0.0"
126
+ },
127
+ "devDependencies": {
128
+ "wirejs-scripts": "^3.0.101",
129
+ "typescript": "^5.7.3"
130
+ },
131
+ "scripts": {
132
+ "prebuild": "npm run prebuild --workspaces --if-present",
133
+ "prestart": "npm run prestart --workspaces --if-present",
134
+ "start": "wirejs-scripts ws-run-parallel start",
135
+ "build": "npm run build --workspaces --if-present"
136
+ }
137
+ }
138
+ ```
139
+
140
+ In this example, `build` and `prebuild` are run across all workspaces. `src` is treated as a sub-package that contains its own `build` steps. `start` runs all workspace `start` scripts in parallel. All of this ensure the web worker is built and ready when the `src` code needs it.
141
+
142
+ Your `package.json` will probably look different, especially if you're using a build management tool that builds in a dependency-aware manner.
143
+
144
+ Either way, the end result is magic. 🪄
145
+
146
+ ## ⚠️ Known Limitations ⚠️
147
+
148
+ 1. Only a single export is supported per worker package.
149
+ 1. Worker pools are not yet supported.
150
+ 1. Type-validation on workers is limited.
151
+ 1. Behavior is undefined when exporting non-worker from a worker package.
152
+ 1. No built-in mechanism for killing a worker.
153
+
154
+ In addition to these known limitations, this code has only been lightly tested and is still experimental.
package/build.js ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import { build } from 'esbuild';
4
+ import { randomUUID } from 'crypto';
5
+ import { cwd } from 'process';
6
+
7
+ const clientContainer = /* typescript */ `
8
+ import { proxyWorker } from 'wirejs-web-worker';
9
+ const workerBundle = "__WORKER_CODE__";
10
+ export const worker = proxyWorker(workerBundle);
11
+ `;
12
+
13
+ async function main() {
14
+ const PREBUILD_NAME = `./dist/client.${randomUUID()}.ts`;
15
+
16
+ const serverBuild = await build({
17
+ entryPoints: ['./src/index.ts'],
18
+ format: 'iife',
19
+ bundle: true,
20
+ write: false
21
+ });
22
+
23
+ const clientSource = clientContainer
24
+ .replace(
25
+ '"__WORKER_CODE__"',
26
+ JSON.stringify(serverBuild.outputFiles[0].text)
27
+ )
28
+ ;
29
+
30
+ await fs.promises.mkdir(`${cwd()}/dist`, { recursive: true });
31
+ await fs.promises.writeFile(PREBUILD_NAME, clientSource);
32
+
33
+ await build({
34
+ entryPoints: [PREBUILD_NAME],
35
+ format: 'esm',
36
+ bundle: true,
37
+ outfile: './dist/index.js'
38
+ });
39
+
40
+ await fs.promises.unlink(PREBUILD_NAME);
41
+ }
42
+
43
+ main();
@@ -0,0 +1,3 @@
1
+ export declare function messageHandler(send: typeof postMessage, callables: any): (message: any) => Promise<void>;
2
+ export declare function proxyWorker(workerBundle: string): any;
3
+ export declare function SingleWorker<const T extends Record<string, (...args: any) => Promise<any> | void>>(callables: T): T;
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ function isRequest(o) {
2
+ return (typeof o === 'object'
3
+ && o.__type === 'request');
4
+ }
5
+ ;
6
+ function isCallbackRequest(o) {
7
+ return (typeof o === 'object'
8
+ && o.__type === 'callbackRequest');
9
+ }
10
+ ;
11
+ function isCallbackDescriptor(o) {
12
+ return (typeof o === 'object'
13
+ && o.__type === 'callback');
14
+ }
15
+ ;
16
+ function isResponse(o) {
17
+ return (typeof o === 'object'
18
+ && o.__type === 'response');
19
+ }
20
+ ;
21
+ function isGarbageCollection(o) {
22
+ return (typeof o === 'object'
23
+ && o.__type === 'garbage');
24
+ }
25
+ ;
26
+ /**
27
+ * Callbacks the local context has exposed to the remote context.
28
+ *
29
+ * When the remote context detects that it no longer needs one of these,
30
+ * it will send a message letting this side of the wire know to remove the
31
+ * callback so it can be garbage collected. (Assuming nothing else is also
32
+ * using it!)
33
+ */
34
+ const callbackFunctions = new Map();
35
+ /**
36
+ * Callbacks the remote context has exposed to the local context.
37
+ *
38
+ * The functions these descriptor ID's map to are ingested directly by the
39
+ * concerned code in the local scope. Hence, when local code no longer needs
40
+ * the callback, the referenced function can be garbage collected. We can
41
+ * then detect which descriptors are no longer needed and inform the other
42
+ * side of the wire that it can delete the functions.
43
+ */
44
+ const callbackDescriptors = new Map();
45
+ /**
46
+ * Requests the local context has made to the remote context.
47
+ */
48
+ const requests = new Map();
49
+ /**
50
+ * Creates a callable function for a callback that lives on the other
51
+ * side of the wire.
52
+ *
53
+ * @param callback
54
+ * @returns
55
+ */
56
+ function createCallbackFunction(callback) {
57
+ const f = (...args) => {
58
+ return new Promise((resolve, reject) => {
59
+ const requestId = crypto.randomUUID();
60
+ requests.set(requestId, { resolve, reject });
61
+ postMessage({
62
+ __type: 'callbackRequest',
63
+ requestId,
64
+ args: dehydrateCallbacks(args),
65
+ target: callback.callbackId,
66
+ });
67
+ });
68
+ };
69
+ callbackDescriptors.set(callback.callbackId, new WeakRef(f));
70
+ return f;
71
+ }
72
+ /**
73
+ * Creates a callback descriptor to send over the wire to the other side,
74
+ * indicating to the other side that there is an invocable function waiting
75
+ * for it here.
76
+ *
77
+ * @param f
78
+ */
79
+ function createCallbackDescriptor(f) {
80
+ const callbackId = crypto.randomUUID();
81
+ callbackFunctions.set(callbackId, f);
82
+ return {
83
+ __type: 'callback',
84
+ callbackId
85
+ };
86
+ }
87
+ /**
88
+ * Copies the object, replacing callbacks descriptors with callback functions
89
+ * that know how to invoke them over the wire.
90
+ *
91
+ * @param o
92
+ * @returns
93
+ */
94
+ function hydrateCallbacks(o) {
95
+ if (isCallbackDescriptor(o)) {
96
+ return createCallbackFunction(o);
97
+ }
98
+ else if (!o) {
99
+ return o;
100
+ }
101
+ else if (Array.isArray(o)) {
102
+ const copied = [];
103
+ for (let i = 0; i < o.length; i++) {
104
+ copied[i] = hydrateCallbacks(o[i]);
105
+ }
106
+ return copied;
107
+ }
108
+ else if (typeof o === 'object') {
109
+ const copied = {};
110
+ for (const [k, v] of Object.entries(o)) {
111
+ copied[k] = hydrateCallbacks(v);
112
+ }
113
+ return copied;
114
+ }
115
+ return o;
116
+ }
117
+ /**
118
+ * Copies the object, replacing callbacks with descriptors.
119
+ *
120
+ * @param o
121
+ */
122
+ function dehydrateCallbacks(o) {
123
+ if (typeof o === 'function') {
124
+ return createCallbackDescriptor(o);
125
+ }
126
+ else if (!o) {
127
+ return o;
128
+ }
129
+ else if (Array.isArray(o)) {
130
+ const copied = [];
131
+ for (let i = 0; i < o.length; i++) {
132
+ copied[i] = dehydrateCallbacks(o[i]);
133
+ }
134
+ return copied;
135
+ }
136
+ else if (typeof o === 'object') {
137
+ const copied = {};
138
+ for (const [k, v] of Object.entries(o)) {
139
+ copied[k] = dehydrateCallbacks(v);
140
+ }
141
+ return copied;
142
+ }
143
+ return o;
144
+ }
145
+ let garbageCollectionLoop = undefined;
146
+ function ensureGarbageCollectionIsRunning(send) {
147
+ garbageCollectionLoop = garbageCollectionLoop ?? setInterval(() => {
148
+ for (const callbackId of Object.keys(callbackDescriptors)) {
149
+ const ref = callbackDescriptors.get(callbackId);
150
+ if (!ref.deref) {
151
+ send({ __type: 'garbage', callbackId });
152
+ }
153
+ }
154
+ }, 1000);
155
+ }
156
+ async function handleRequest(f, requestId, args, respond) {
157
+ try {
158
+ const result = dehydrateCallbacks(await f(...hydrateCallbacks(args || [])));
159
+ respond({ __type: 'response', requestId, result });
160
+ }
161
+ catch (error) {
162
+ respond({ __type: 'response', requestId, error });
163
+ }
164
+ }
165
+ ;
166
+ export function messageHandler(send, callables) {
167
+ ensureGarbageCollectionIsRunning(send);
168
+ return async (message) => {
169
+ const { data } = message;
170
+ if (isRequest(data)) {
171
+ handleRequest(callables[data.method], data.requestId, data.args, send);
172
+ }
173
+ else if (isCallbackRequest(data)) {
174
+ handleRequest(callbackFunctions.get(data.target), data.requestId, data.args, send);
175
+ }
176
+ else if (isResponse(data)) {
177
+ const request = requests.get(data.requestId);
178
+ if (!request)
179
+ return;
180
+ const hydratedResult = hydrateCallbacks(data.result);
181
+ requests.delete(data.requestId);
182
+ data.error ? request.reject(data.error) : request.resolve(hydratedResult);
183
+ }
184
+ else if (isGarbageCollection(data)) {
185
+ callbackFunctions.delete(data.callbackId);
186
+ }
187
+ };
188
+ }
189
+ ;
190
+ function buildWorker(workerBundle) {
191
+ const worker = new Worker(URL.createObjectURL(new Blob([workerBundle], { type: 'text/javascript' })));
192
+ worker.onmessage = messageHandler((m) => worker?.postMessage(m), {});
193
+ return worker;
194
+ }
195
+ export function proxyWorker(workerBundle) {
196
+ let worker;
197
+ return new Proxy({}, {
198
+ get(_target, method) {
199
+ worker = worker ?? buildWorker(workerBundle);
200
+ return (...args) => {
201
+ return new Promise((resolve, reject) => {
202
+ const requestId = crypto.randomUUID();
203
+ requests.set(requestId, { resolve, reject });
204
+ worker.postMessage({
205
+ __type: 'request',
206
+ method,
207
+ requestId,
208
+ args: dehydrateCallbacks(args)
209
+ });
210
+ });
211
+ };
212
+ }
213
+ });
214
+ }
215
+ ;
216
+ export function SingleWorker(callables) {
217
+ self.onmessage = messageHandler(postMessage, callables);
218
+ return callables;
219
+ }
220
+ ;
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "wirejs-web-worker",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "exports": {
6
+ "types": "./dist/index.d.ts",
7
+ "default": "./dist/index.js"
8
+ },
9
+ "bin": {
10
+ "wirejs-web-worker-build": "./build.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "*"
17
+ },
18
+ "files": [
19
+ "package.json",
20
+ "dist/*",
21
+ "build.js",
22
+ "README.md"
23
+ ]
24
+ }