@novasamatech/host-worker-sandbox 0.6.8
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 +33 -0
- package/dist/globals/AbortController.d.ts +2 -0
- package/dist/globals/AbortController.js +17 -0
- package/dist/globals/TextDecoder.d.ts +2 -0
- package/dist/globals/TextDecoder.js +50 -0
- package/dist/globals/TextEncoder.d.ts +2 -0
- package/dist/globals/TextEncoder.js +28 -0
- package/dist/globals/console.d.ts +3 -0
- package/dist/globals/console.js +13 -0
- package/dist/globals/crypto.d.ts +2 -0
- package/dist/globals/crypto.js +25 -0
- package/dist/globals/timers.d.ts +4 -0
- package/dist/globals/timers.js +125 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/sandbox.d.ts +9 -0
- package/dist/sandbox.js +286 -0
- package/dist/sandbox.spec.d.ts +1 -0
- package/dist/sandbox.spec.js +165 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @novasamatech/host-worker-sandbox
|
|
2
|
+
|
|
3
|
+
QuickJS-based sandbox for running product "worker" code in an isolated VM, wired to Triangle Host API via a byte-oriented `Provider`.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
`createSandbox()` returns a `Sandbox` with `container` (a [`Container`](https://github.com/paritytech/triangle-js-sdks/tree/main/packages/host-container) from `@novasamatech/host-container`) and `provider`. Register `handle*` callbacks on `sandbox.container` so worker code that talks to the Host API receives real responses.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createSandbox } from '@novasamatech/host-worker-sandbox';
|
|
11
|
+
|
|
12
|
+
const hostStorage = new Map<string, Uint8Array>();
|
|
13
|
+
|
|
14
|
+
const sandbox = await createSandbox('worker.example-product.dot');
|
|
15
|
+
|
|
16
|
+
// Container bindings: implement the host side of the Host API (storage, features, chain, …).
|
|
17
|
+
const unbindStorageRead = sandbox.container.handleLocalStorageRead((key, { ok }) =>
|
|
18
|
+
ok(hostStorage.get(key) ?? new Uint8Array()),
|
|
19
|
+
);
|
|
20
|
+
const unbindFeatureSupported = sandbox.container.handleFeatureSupported((_params, { ok }) => ok(false));
|
|
21
|
+
|
|
22
|
+
const workerSource = `
|
|
23
|
+
// Worker module: __HOST_API_PORT__, TextEncoder, etc. are available in the VM.
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
await sandbox.run(workerSource);
|
|
27
|
+
|
|
28
|
+
// Later on...
|
|
29
|
+
|
|
30
|
+
unbindStorageRead();
|
|
31
|
+
unbindFeatureSupported();
|
|
32
|
+
sandbox.dispose();
|
|
33
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function injectAbortController(vm) {
|
|
2
|
+
const abortControllerCtor = vm.newConstructorFunction('AbortController', () => {
|
|
3
|
+
const signal = vm.newObject();
|
|
4
|
+
const addEventListener = vm.newFunction('addEventListener', () => vm.undefined);
|
|
5
|
+
const removeEventListener = vm.newFunction('removeEventListener', () => vm.undefined);
|
|
6
|
+
vm.setProp(signal, 'addEventListener', addEventListener);
|
|
7
|
+
vm.setProp(signal, 'removeEventListener', removeEventListener);
|
|
8
|
+
const instance = vm.newObject();
|
|
9
|
+
vm.setProp(instance, 'signal', signal);
|
|
10
|
+
signal.dispose();
|
|
11
|
+
addEventListener.dispose();
|
|
12
|
+
removeEventListener.dispose();
|
|
13
|
+
return instance;
|
|
14
|
+
});
|
|
15
|
+
vm.setProp(vm.global, 'AbortController', abortControllerCtor);
|
|
16
|
+
abortControllerCtor.dispose();
|
|
17
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function injectTextDecoder(vm) {
|
|
2
|
+
const textDecoderCtor = vm.newConstructorFunction('TextDecoder', (...args) => {
|
|
3
|
+
const proto = vm.newObject();
|
|
4
|
+
const decodeFn = vm.newFunction('decode', function (...args) {
|
|
5
|
+
const dataHandle = args[0];
|
|
6
|
+
if (dataHandle === undefined || vm.typeof(dataHandle) === 'undefined') {
|
|
7
|
+
return vm.newString('');
|
|
8
|
+
}
|
|
9
|
+
const encodingHandle = vm.getProp(this, 'encoding');
|
|
10
|
+
const rawEncoding = vm.dump(encodingHandle);
|
|
11
|
+
encodingHandle.dispose();
|
|
12
|
+
const encoding = typeof rawEncoding === 'string' ? rawEncoding : 'utf-8';
|
|
13
|
+
// Support TypedArray (has .buffer + .byteOffset) and raw ArrayBuffer
|
|
14
|
+
const bufferPropHandle = vm.getProp(dataHandle, 'buffer');
|
|
15
|
+
let bytes;
|
|
16
|
+
if (vm.typeof(bufferPropHandle) !== 'undefined') {
|
|
17
|
+
// TypedArray path: extract slice from underlying buffer
|
|
18
|
+
const byteOffsetHandle = vm.getProp(dataHandle, 'byteOffset');
|
|
19
|
+
const byteLengthHandle = vm.getProp(dataHandle, 'byteLength');
|
|
20
|
+
const byteOffset = Number(vm.dump(byteOffsetHandle));
|
|
21
|
+
const byteLength = Number(vm.dump(byteLengthHandle));
|
|
22
|
+
byteOffsetHandle.dispose();
|
|
23
|
+
byteLengthHandle.dispose();
|
|
24
|
+
const lifetime = vm.getArrayBuffer(bufferPropHandle);
|
|
25
|
+
bytes = lifetime.value.slice(byteOffset, byteOffset + byteLength);
|
|
26
|
+
lifetime.dispose();
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// Raw ArrayBuffer path
|
|
30
|
+
const lifetime = vm.getArrayBuffer(dataHandle);
|
|
31
|
+
bytes = lifetime.value.slice(0);
|
|
32
|
+
lifetime.dispose();
|
|
33
|
+
}
|
|
34
|
+
bufferPropHandle.dispose();
|
|
35
|
+
return vm.newString(new TextDecoder(encoding).decode(bytes));
|
|
36
|
+
});
|
|
37
|
+
vm.setProp(proto, 'decode', decodeFn);
|
|
38
|
+
const instance = vm.newObject(proto);
|
|
39
|
+
const raw = args[0] !== undefined ? vm.dump(args[0]) : undefined;
|
|
40
|
+
const encoding = typeof raw === 'string' ? raw : 'utf-8';
|
|
41
|
+
const encHandle = vm.newString(encoding);
|
|
42
|
+
vm.setProp(instance, 'encoding', encHandle);
|
|
43
|
+
encHandle.dispose();
|
|
44
|
+
proto.dispose();
|
|
45
|
+
decodeFn.dispose();
|
|
46
|
+
return instance;
|
|
47
|
+
});
|
|
48
|
+
vm.setProp(vm.global, 'TextDecoder', textDecoderCtor);
|
|
49
|
+
textDecoderCtor.dispose();
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function injectTextEncoder(vm, toUint8ArrayFn) {
|
|
2
|
+
const textEncoderCtor = vm.newConstructorFunction('TextEncoder', () => {
|
|
3
|
+
const proto = vm.newObject();
|
|
4
|
+
const encodeFn = vm.newFunction('encode', strHandle => {
|
|
5
|
+
const raw = vm.dump(strHandle);
|
|
6
|
+
const str = typeof raw === 'string' ? raw : '';
|
|
7
|
+
const hostBytes = new TextEncoder().encode(str);
|
|
8
|
+
const buf = hostBytes.byteOffset === 0 && hostBytes.byteLength === hostBytes.buffer.byteLength
|
|
9
|
+
? hostBytes.buffer
|
|
10
|
+
: hostBytes.buffer.slice(hostBytes.byteOffset, hostBytes.byteOffset + hostBytes.byteLength);
|
|
11
|
+
const bufHandle = vm.newArrayBuffer(buf);
|
|
12
|
+
const uint8Result = vm.callFunction(toUint8ArrayFn, vm.undefined, bufHandle);
|
|
13
|
+
bufHandle.dispose();
|
|
14
|
+
if (uint8Result.error) {
|
|
15
|
+
uint8Result.error.dispose();
|
|
16
|
+
return vm.undefined;
|
|
17
|
+
}
|
|
18
|
+
return uint8Result.value;
|
|
19
|
+
});
|
|
20
|
+
vm.setProp(proto, 'encode', encodeFn);
|
|
21
|
+
const instance = vm.newObject(proto);
|
|
22
|
+
proto.dispose();
|
|
23
|
+
encodeFn.dispose();
|
|
24
|
+
return instance;
|
|
25
|
+
});
|
|
26
|
+
vm.setProp(vm.global, 'TextEncoder', textEncoderCtor);
|
|
27
|
+
textEncoderCtor.dispose();
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function injectConsole(vm, logger) {
|
|
2
|
+
const consoleObj = vm.newObject();
|
|
3
|
+
for (const method of ['log', 'info', 'warn', 'error']) {
|
|
4
|
+
const fn = vm.newFunction(method, (...args) => {
|
|
5
|
+
logger[method](...args.map(h => vm.dump(h)));
|
|
6
|
+
return vm.undefined;
|
|
7
|
+
});
|
|
8
|
+
vm.setProp(consoleObj, method, fn);
|
|
9
|
+
fn.dispose();
|
|
10
|
+
}
|
|
11
|
+
vm.setProp(vm.global, 'console', consoleObj);
|
|
12
|
+
consoleObj.dispose();
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function injectCrypto(vm, toUint8ArrayFn) {
|
|
2
|
+
const cryptoInstance = vm.newObject();
|
|
3
|
+
const getRandomValues = vm.newFunction('getRandomValues', arg => {
|
|
4
|
+
const bufferPropHandle = vm.getProp(arg, 'buffer');
|
|
5
|
+
const buffer = vm.getArrayBuffer(bufferPropHandle);
|
|
6
|
+
bufferPropHandle.dispose();
|
|
7
|
+
const bytes = crypto.getRandomValues(buffer.value);
|
|
8
|
+
buffer.dispose();
|
|
9
|
+
const buf = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
10
|
+
? bytes.buffer
|
|
11
|
+
: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
12
|
+
const bufHandle = vm.newArrayBuffer(buf);
|
|
13
|
+
const uint8Result = vm.callFunction(toUint8ArrayFn, vm.undefined, bufHandle);
|
|
14
|
+
bufHandle.dispose();
|
|
15
|
+
if (uint8Result.error) {
|
|
16
|
+
uint8Result.error.dispose();
|
|
17
|
+
return vm.undefined;
|
|
18
|
+
}
|
|
19
|
+
return uint8Result.value;
|
|
20
|
+
});
|
|
21
|
+
vm.setProp(cryptoInstance, 'getRandomValues', getRandomValues);
|
|
22
|
+
vm.setProp(vm.global, 'crypto', cryptoInstance);
|
|
23
|
+
cryptoInstance.dispose();
|
|
24
|
+
getRandomValues.dispose();
|
|
25
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { QuickJSContext } from 'quickjs-emscripten';
|
|
2
|
+
export declare function injectTimeouts(vm: QuickJSContext): VoidFunction;
|
|
3
|
+
export declare function injectIntervals(vm: QuickJSContext): VoidFunction;
|
|
4
|
+
export declare function injectQueueMicrotask(vm: QuickJSContext): VoidFunction;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export function injectTimeouts(vm) {
|
|
2
|
+
const refs = new Map();
|
|
3
|
+
let disposed = false;
|
|
4
|
+
const setTimeoutHandler = vm.newFunction('setTimeout', (funcHandle, timeoutHandle) => {
|
|
5
|
+
const ttl = vm.getNumber(timeoutHandle);
|
|
6
|
+
const ref = funcHandle.dup();
|
|
7
|
+
const timeout = setTimeout(() => {
|
|
8
|
+
if (disposed)
|
|
9
|
+
return;
|
|
10
|
+
refs.delete(key);
|
|
11
|
+
const result = vm.callFunction(ref, vm.global);
|
|
12
|
+
if (result.error) {
|
|
13
|
+
result.error.dispose();
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
result.value.dispose();
|
|
17
|
+
}
|
|
18
|
+
ref.dispose();
|
|
19
|
+
vm.runtime.executePendingJobs(-1);
|
|
20
|
+
}, ttl);
|
|
21
|
+
const key = typeof timeout === 'number' ? timeout : Math.round(Math.random() * 10000);
|
|
22
|
+
refs.set(key, ref);
|
|
23
|
+
return vm.newNumber(key);
|
|
24
|
+
});
|
|
25
|
+
vm.setProp(vm.global, 'setTimeout', setTimeoutHandler);
|
|
26
|
+
setTimeoutHandler.dispose();
|
|
27
|
+
const clearTimeoutHandler = vm.newFunction('clearTimeout', timeoutHandle => {
|
|
28
|
+
const key = vm.getNumber(timeoutHandle);
|
|
29
|
+
const ref = refs.get(key);
|
|
30
|
+
if (ref) {
|
|
31
|
+
clearTimeout(key);
|
|
32
|
+
ref.dispose();
|
|
33
|
+
refs.delete(key);
|
|
34
|
+
vm.runtime.executePendingJobs(-1);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
vm.setProp(vm.global, 'clearTimeout', clearTimeoutHandler);
|
|
38
|
+
clearTimeoutHandler.dispose();
|
|
39
|
+
return () => {
|
|
40
|
+
disposed = true;
|
|
41
|
+
for (const [key, ref] of refs) {
|
|
42
|
+
clearTimeout(key);
|
|
43
|
+
ref.dispose();
|
|
44
|
+
}
|
|
45
|
+
refs.clear();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function injectIntervals(vm) {
|
|
49
|
+
const refs = new Map();
|
|
50
|
+
let disposed = false;
|
|
51
|
+
const setIntervalHandler = vm.newFunction('setInterval', (funcHandle, timeoutHandle) => {
|
|
52
|
+
const ttl = vm.getNumber(timeoutHandle);
|
|
53
|
+
const ref = funcHandle.dup();
|
|
54
|
+
const interval = setInterval(() => {
|
|
55
|
+
if (disposed)
|
|
56
|
+
return;
|
|
57
|
+
const result = vm.callFunction(ref, vm.global);
|
|
58
|
+
if (result.error) {
|
|
59
|
+
result.error.dispose();
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
result.value.dispose();
|
|
63
|
+
}
|
|
64
|
+
vm.runtime.executePendingJobs(-1);
|
|
65
|
+
}, ttl);
|
|
66
|
+
const key = typeof interval === 'number' ? interval : Math.round(Math.random() * 10000);
|
|
67
|
+
refs.set(key, ref);
|
|
68
|
+
return vm.newNumber(key);
|
|
69
|
+
});
|
|
70
|
+
vm.setProp(vm.global, 'setInterval', setIntervalHandler);
|
|
71
|
+
setIntervalHandler.dispose();
|
|
72
|
+
const clearIntervalHandler = vm.newFunction('clearInterval', timeoutHandle => {
|
|
73
|
+
const key = vm.getNumber(timeoutHandle);
|
|
74
|
+
const ref = refs.get(key);
|
|
75
|
+
if (ref) {
|
|
76
|
+
clearInterval(key);
|
|
77
|
+
ref.dispose();
|
|
78
|
+
refs.delete(key);
|
|
79
|
+
vm.runtime.executePendingJobs(-1);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
vm.setProp(vm.global, 'clearInterval', clearIntervalHandler);
|
|
83
|
+
clearIntervalHandler.dispose();
|
|
84
|
+
return () => {
|
|
85
|
+
disposed = true;
|
|
86
|
+
for (const [key, ref] of refs) {
|
|
87
|
+
clearInterval(key);
|
|
88
|
+
ref.dispose();
|
|
89
|
+
}
|
|
90
|
+
refs.clear();
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function injectQueueMicrotask(vm) {
|
|
94
|
+
const pendingRefs = new Set();
|
|
95
|
+
let disposed = false;
|
|
96
|
+
const queueMicrotaskHandler = vm.newFunction('queueMicrotask', funcHandle => {
|
|
97
|
+
const ref = funcHandle.dup();
|
|
98
|
+
pendingRefs.add(ref);
|
|
99
|
+
queueMicrotask(() => {
|
|
100
|
+
pendingRefs.delete(ref);
|
|
101
|
+
if (disposed) {
|
|
102
|
+
ref.dispose();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const result = vm.callFunction(ref, vm.global);
|
|
106
|
+
ref.dispose();
|
|
107
|
+
if (result.error) {
|
|
108
|
+
result.error.dispose();
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
result.value.dispose();
|
|
112
|
+
}
|
|
113
|
+
vm.runtime.executePendingJobs(-1);
|
|
114
|
+
});
|
|
115
|
+
return vm.undefined;
|
|
116
|
+
});
|
|
117
|
+
vm.setProp(vm.global, 'queueMicrotask', queueMicrotaskHandler);
|
|
118
|
+
queueMicrotaskHandler.dispose();
|
|
119
|
+
return () => {
|
|
120
|
+
disposed = true;
|
|
121
|
+
for (const ref of pendingRefs)
|
|
122
|
+
ref.dispose();
|
|
123
|
+
pendingRefs.clear();
|
|
124
|
+
};
|
|
125
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createSandbox } from './sandbox.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Provider } from '@novasamatech/host-api';
|
|
2
|
+
import type { Container } from '@novasamatech/host-container';
|
|
3
|
+
export type Sandbox = {
|
|
4
|
+
container: Container;
|
|
5
|
+
provider: Provider;
|
|
6
|
+
run: (code: string | Uint8Array, product?: string) => Promise<void>;
|
|
7
|
+
dispose: VoidFunction;
|
|
8
|
+
};
|
|
9
|
+
export declare function createSandbox(productId: string): Promise<Sandbox>;
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createDefaultLogger } from '@novasamatech/host-api';
|
|
2
|
+
import { createContainer } from '@novasamatech/host-container';
|
|
3
|
+
import { getQuickJS } from 'quickjs-emscripten';
|
|
4
|
+
import { injectAbortController } from './globals/AbortController.js';
|
|
5
|
+
import { injectTextDecoder } from './globals/TextDecoder.js';
|
|
6
|
+
import { injectTextEncoder } from './globals/TextEncoder.js';
|
|
7
|
+
import { injectConsole } from './globals/console.js';
|
|
8
|
+
import { injectCrypto } from './globals/crypto.js';
|
|
9
|
+
import { injectIntervals, injectQueueMicrotask, injectTimeouts } from './globals/timers.js';
|
|
10
|
+
function makeOnMessageDescriptor(state, vm) {
|
|
11
|
+
// Regular function expressions have implicit `this: any`, which satisfies
|
|
12
|
+
// `(this: QuickJSHandle) => QuickJSHandle` without needing an `as` cast.
|
|
13
|
+
return {
|
|
14
|
+
configurable: false,
|
|
15
|
+
enumerable: true,
|
|
16
|
+
get() {
|
|
17
|
+
return state.handle?.dup() ?? vm.null;
|
|
18
|
+
},
|
|
19
|
+
set(handlerHandle) {
|
|
20
|
+
state.handle?.dispose();
|
|
21
|
+
const value = vm.dump(handlerHandle);
|
|
22
|
+
state.handle = value == null ? null : handlerHandle.dup();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
class SandboxPort {
|
|
27
|
+
vm;
|
|
28
|
+
onMessageState = { handle: null };
|
|
29
|
+
messageListeners = [];
|
|
30
|
+
subscribers = new Set();
|
|
31
|
+
toUint8ArrayFn;
|
|
32
|
+
disposed = false;
|
|
33
|
+
provider;
|
|
34
|
+
constructor(productId, vm, toUint8ArrayFn) {
|
|
35
|
+
this.vm = vm;
|
|
36
|
+
this.toUint8ArrayFn = toUint8ArrayFn;
|
|
37
|
+
this.provider = this.makeProvider(productId);
|
|
38
|
+
}
|
|
39
|
+
buildHandle() {
|
|
40
|
+
const { vm } = this;
|
|
41
|
+
const port = vm.newObject();
|
|
42
|
+
// sandbox → host: extract Uint8Array bytes from the QuickJS handle and notify subscribers
|
|
43
|
+
const postMessageFn = vm.newFunction('postMessage', dataHandle => {
|
|
44
|
+
try {
|
|
45
|
+
const bufferHandle = vm.getProp(dataHandle, 'buffer');
|
|
46
|
+
const byteOffsetHandle = vm.getProp(dataHandle, 'byteOffset');
|
|
47
|
+
const byteLengthHandle = vm.getProp(dataHandle, 'byteLength');
|
|
48
|
+
const lifetime = vm.getArrayBuffer(bufferHandle);
|
|
49
|
+
// Number() converts `unknown` to number without an `as` cast
|
|
50
|
+
const byteOffset = Number(vm.dump(byteOffsetHandle));
|
|
51
|
+
const byteLength = Number(vm.dump(byteLengthHandle));
|
|
52
|
+
bufferHandle.dispose();
|
|
53
|
+
byteOffsetHandle.dispose();
|
|
54
|
+
byteLengthHandle.dispose();
|
|
55
|
+
// .slice() copies out of WASM memory before the lifetime is freed
|
|
56
|
+
const bytes = lifetime.value.slice(byteOffset, byteOffset + byteLength);
|
|
57
|
+
lifetime.dispose();
|
|
58
|
+
for (const subscriber of this.subscribers) {
|
|
59
|
+
subscriber(bytes);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
console.error('[Sandbox] port.postMessage: failed to extract bytes', e);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
vm.setProp(port, 'postMessage', postMessageFn);
|
|
67
|
+
postMessageFn.dispose();
|
|
68
|
+
// host → sandbox via addEventListener('message', handler)
|
|
69
|
+
const addEventListenerFn = vm.newFunction('addEventListener', (typeHandle, handlerHandle) => {
|
|
70
|
+
if (vm.getString(typeHandle) === 'message') {
|
|
71
|
+
this.messageListeners.push(handlerHandle.dup());
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
vm.setProp(port, 'addEventListener', addEventListenerFn);
|
|
75
|
+
addEventListenerFn.dispose();
|
|
76
|
+
// start() — no-op, exists for API compatibility
|
|
77
|
+
const startFn = vm.newFunction('start', () => vm.undefined);
|
|
78
|
+
vm.setProp(port, 'start', startFn);
|
|
79
|
+
startFn.dispose();
|
|
80
|
+
// close() — frees all stored handles
|
|
81
|
+
const closeFn = vm.newFunction('close', () => {
|
|
82
|
+
this.disposeHandles();
|
|
83
|
+
});
|
|
84
|
+
vm.setProp(port, 'close', closeFn);
|
|
85
|
+
closeFn.dispose();
|
|
86
|
+
// onmessage getter/setter via defineProp.
|
|
87
|
+
// State is accessed through a plain object ref captured in function expressions.
|
|
88
|
+
const { onMessageState } = this;
|
|
89
|
+
vm.defineProp(port, 'onmessage', makeOnMessageDescriptor(onMessageState, vm));
|
|
90
|
+
return port;
|
|
91
|
+
}
|
|
92
|
+
// Returns a Provider implementation backed by this port's QuickJS transport
|
|
93
|
+
makeProvider(productId) {
|
|
94
|
+
return {
|
|
95
|
+
logger: createDefaultLogger(productId),
|
|
96
|
+
isCorrectEnvironment: () => true,
|
|
97
|
+
postMessage: message => {
|
|
98
|
+
this.deliver(message);
|
|
99
|
+
},
|
|
100
|
+
subscribe: callback => {
|
|
101
|
+
this.subscribers.add(callback);
|
|
102
|
+
return () => {
|
|
103
|
+
this.subscribers.delete(callback);
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
dispose: () => undefined,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// Delivers raw bytes from the host into the sandbox as a MessageEvent with Uint8Array data
|
|
110
|
+
deliver(bytes) {
|
|
111
|
+
if (this.disposed)
|
|
112
|
+
return;
|
|
113
|
+
const { vm } = this;
|
|
114
|
+
// Ensure the ArrayBuffer exactly covers the bytes (handle slice views)
|
|
115
|
+
const buffer = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
116
|
+
? bytes.buffer
|
|
117
|
+
: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
118
|
+
const bufferHandle = vm.newArrayBuffer(buffer);
|
|
119
|
+
const uint8Result = vm.callFunction(this.toUint8ArrayFn, vm.undefined, bufferHandle);
|
|
120
|
+
bufferHandle.dispose();
|
|
121
|
+
if (uint8Result.error) {
|
|
122
|
+
console.error('[Sandbox] port: failed to create Uint8Array', vm.dump(uint8Result.error));
|
|
123
|
+
uint8Result.error.dispose();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const event = vm.newObject();
|
|
127
|
+
vm.setProp(event, 'data', uint8Result.value);
|
|
128
|
+
uint8Result.value.dispose();
|
|
129
|
+
const handlers = [];
|
|
130
|
+
if (this.onMessageState.handle)
|
|
131
|
+
handlers.push(this.onMessageState.handle);
|
|
132
|
+
handlers.push(...this.messageListeners);
|
|
133
|
+
for (const handler of handlers) {
|
|
134
|
+
const result = vm.callFunction(handler, vm.undefined, event);
|
|
135
|
+
if (result.error) {
|
|
136
|
+
console.error('[Sandbox] port.onmessage error:', vm.dump(result.error));
|
|
137
|
+
result.error.dispose();
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
result.value.dispose();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
event.dispose();
|
|
144
|
+
const jobResult = vm.runtime.executePendingJobs(-1);
|
|
145
|
+
if (jobResult.error) {
|
|
146
|
+
console.error('[Sandbox] job error after port message:', vm.dump(jobResult.error));
|
|
147
|
+
jobResult.error.dispose();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
disposeHandles() {
|
|
151
|
+
this.onMessageState.handle?.dispose();
|
|
152
|
+
this.onMessageState.handle = null;
|
|
153
|
+
for (const h of this.messageListeners)
|
|
154
|
+
h.dispose();
|
|
155
|
+
this.messageListeners.length = 0;
|
|
156
|
+
}
|
|
157
|
+
dispose() {
|
|
158
|
+
this.disposed = true;
|
|
159
|
+
this.disposeHandles();
|
|
160
|
+
this.subscribers.clear();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
class QuickJsSandbox {
|
|
164
|
+
vm;
|
|
165
|
+
port;
|
|
166
|
+
toUint8ArrayFn;
|
|
167
|
+
disposeTimers;
|
|
168
|
+
disposed = false;
|
|
169
|
+
productId;
|
|
170
|
+
container;
|
|
171
|
+
provider;
|
|
172
|
+
constructor(productId, vm) {
|
|
173
|
+
this.productId = productId;
|
|
174
|
+
this.vm = vm;
|
|
175
|
+
const helperResult = vm.evalCode('(buf) => new Uint8Array(buf)');
|
|
176
|
+
if (helperResult.error) {
|
|
177
|
+
const msg = vm.dump(helperResult.error);
|
|
178
|
+
helperResult.error.dispose();
|
|
179
|
+
throw new Error(`Sandbox setup error: ${JSON.stringify(msg)}`);
|
|
180
|
+
}
|
|
181
|
+
this.toUint8ArrayFn = helperResult.value;
|
|
182
|
+
this.port = new SandboxPort(productId, vm, this.toUint8ArrayFn);
|
|
183
|
+
this.provider = this.port.provider;
|
|
184
|
+
this.container = createContainer(this.provider);
|
|
185
|
+
this.disposeTimers = this.injectGlobals();
|
|
186
|
+
}
|
|
187
|
+
injectGlobals() {
|
|
188
|
+
const { vm } = this;
|
|
189
|
+
const portHandle = this.port.buildHandle();
|
|
190
|
+
vm.setProp(vm.global, '__HOST_WEBVIEW_MARK__', vm.true);
|
|
191
|
+
vm.setProp(vm.global, '__HOST_API_PORT__', portHandle);
|
|
192
|
+
portHandle.dispose();
|
|
193
|
+
vm.setProp(vm.global, 'top', vm.global);
|
|
194
|
+
vm.setProp(vm.global, 'window', vm.global);
|
|
195
|
+
injectConsole(vm, this.provider.logger);
|
|
196
|
+
injectTextEncoder(vm, this.toUint8ArrayFn);
|
|
197
|
+
injectTextDecoder(vm);
|
|
198
|
+
injectCrypto(vm, this.toUint8ArrayFn);
|
|
199
|
+
injectAbortController(vm);
|
|
200
|
+
const disposeQueueMicrotask = injectQueueMicrotask(vm);
|
|
201
|
+
const disposeIntervals = injectIntervals(vm);
|
|
202
|
+
const disposeTimeouts = injectTimeouts(vm);
|
|
203
|
+
return () => {
|
|
204
|
+
disposeTimeouts();
|
|
205
|
+
disposeIntervals();
|
|
206
|
+
disposeQueueMicrotask();
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async run(code) {
|
|
210
|
+
const { vm } = this;
|
|
211
|
+
const str = typeof code === 'string' ? code : new TextDecoder().decode(code);
|
|
212
|
+
const result = vm.evalCode(str, `${this.productId ?? 'unknown_product'}/worker.js`, {
|
|
213
|
+
type: 'module',
|
|
214
|
+
strict: true,
|
|
215
|
+
});
|
|
216
|
+
if (result.error) {
|
|
217
|
+
const message = vm.dump(result.error);
|
|
218
|
+
result.error.dispose();
|
|
219
|
+
throw new Error(`Sandbox error: ${JSON.stringify(message)}`);
|
|
220
|
+
}
|
|
221
|
+
// If the evaluated code returned a Promise (e.g. an async IIFE), attach a
|
|
222
|
+
// .then and .catch handler before flushing microtasks so that rejections are surfaced
|
|
223
|
+
// as thrown errors rather than silently swallowed.
|
|
224
|
+
let response = undefined;
|
|
225
|
+
const thenHandle = vm.getProp(result.value, 'then');
|
|
226
|
+
if (vm.typeof(thenHandle) === 'function') {
|
|
227
|
+
response = new Promise((resolve, reject) => {
|
|
228
|
+
const thenFn = vm.newFunction('__then', () => {
|
|
229
|
+
resolve();
|
|
230
|
+
});
|
|
231
|
+
const thenMethod = vm.getProp(result.value, 'then');
|
|
232
|
+
const thenChained = vm.callFunction(thenMethod, result.value, thenFn);
|
|
233
|
+
thenMethod.dispose();
|
|
234
|
+
thenFn.dispose();
|
|
235
|
+
if (thenChained.error) {
|
|
236
|
+
thenChained.error.dispose();
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
thenChained.value.dispose();
|
|
240
|
+
}
|
|
241
|
+
const catchFn = vm.newFunction('__catch', errorHandle => {
|
|
242
|
+
reject(new Error(`Sandbox error: ${JSON.stringify(vm.dump(errorHandle))}`));
|
|
243
|
+
});
|
|
244
|
+
const catchMethod = vm.getProp(result.value, 'catch');
|
|
245
|
+
const cacheChained = vm.callFunction(catchMethod, result.value, catchFn);
|
|
246
|
+
catchMethod.dispose();
|
|
247
|
+
catchFn.dispose();
|
|
248
|
+
if (cacheChained.error) {
|
|
249
|
+
cacheChained.error.dispose();
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
cacheChained.value.dispose();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
thenHandle.dispose();
|
|
257
|
+
result.value.dispose();
|
|
258
|
+
this.flushJobs();
|
|
259
|
+
if (response) {
|
|
260
|
+
await response;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
flushJobs() {
|
|
264
|
+
const result = this.vm.runtime.executePendingJobs(-1);
|
|
265
|
+
if (result.error) {
|
|
266
|
+
console.error('[Sandbox] job error:', this.vm.dump(result.error));
|
|
267
|
+
result.error.dispose();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
dispose() {
|
|
271
|
+
if (this.disposed)
|
|
272
|
+
return;
|
|
273
|
+
this.disposed = true;
|
|
274
|
+
const { vm } = this;
|
|
275
|
+
this.disposeTimers();
|
|
276
|
+
this.port.dispose();
|
|
277
|
+
this.toUint8ArrayFn.dispose();
|
|
278
|
+
vm.runtime.executePendingJobs(-1);
|
|
279
|
+
vm.dispose();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
export async function createSandbox(productId) {
|
|
283
|
+
const QuickJS = await getQuickJS();
|
|
284
|
+
const vm = QuickJS.newContext();
|
|
285
|
+
return new QuickJsSandbox(productId, vm);
|
|
286
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSandbox } from './sandbox.js';
|
|
3
|
+
describe('createSandbox', () => {
|
|
4
|
+
describe('initialization', () => {
|
|
5
|
+
it('should resolve to a sandbox with a container', async () => {
|
|
6
|
+
const sandbox = await createSandbox('test');
|
|
7
|
+
expect(sandbox.container).toBeDefined();
|
|
8
|
+
sandbox.dispose();
|
|
9
|
+
});
|
|
10
|
+
it('should inject window global with __HOST_WEBVIEW_MARK__', async () => {
|
|
11
|
+
const sandbox = await createSandbox('test');
|
|
12
|
+
await expect(sandbox.run('if (!window.__HOST_WEBVIEW_MARK__) throw new Error("missing")')).resolves.toBeUndefined();
|
|
13
|
+
sandbox.dispose();
|
|
14
|
+
});
|
|
15
|
+
it('should inject port global', async () => {
|
|
16
|
+
const sandbox = await createSandbox('test');
|
|
17
|
+
await expect(sandbox.run('if (typeof __HOST_API_PORT__ === "undefined") throw new Error("missing")')).resolves.toBeUndefined();
|
|
18
|
+
sandbox.dispose();
|
|
19
|
+
});
|
|
20
|
+
it('should inject TextEncoder', async () => {
|
|
21
|
+
const sandbox = await createSandbox('test');
|
|
22
|
+
await expect(sandbox.run('new TextEncoder()')).resolves.toBeUndefined();
|
|
23
|
+
sandbox.dispose();
|
|
24
|
+
});
|
|
25
|
+
it('should inject TextDecoder', async () => {
|
|
26
|
+
const sandbox = await createSandbox('test');
|
|
27
|
+
await expect(sandbox.run('new TextDecoder()')).resolves.toBeUndefined();
|
|
28
|
+
sandbox.dispose();
|
|
29
|
+
});
|
|
30
|
+
it('should inject intervals', async () => {
|
|
31
|
+
const sandbox = await createSandbox('test');
|
|
32
|
+
await expect(sandbox.run(`
|
|
33
|
+
const interval = setInterval(() => {}, 1000);
|
|
34
|
+
clearInterval(interval);
|
|
35
|
+
`)).resolves.toBeUndefined();
|
|
36
|
+
sandbox.dispose();
|
|
37
|
+
});
|
|
38
|
+
it('should inject timeouts', async () => {
|
|
39
|
+
const sandbox = await createSandbox('test');
|
|
40
|
+
await expect(sandbox.run(`
|
|
41
|
+
const timeout = setTimeout(() => {}, 1000);
|
|
42
|
+
clearTimeout(timeout);
|
|
43
|
+
`)).resolves.toBeUndefined();
|
|
44
|
+
sandbox.dispose();
|
|
45
|
+
});
|
|
46
|
+
it('should inject queueMicrotask', async () => {
|
|
47
|
+
const sandbox = await createSandbox('test');
|
|
48
|
+
await expect(sandbox.run(`
|
|
49
|
+
queueMicrotask(() => {});
|
|
50
|
+
`)).resolves.toBeUndefined();
|
|
51
|
+
sandbox.dispose();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('run', () => {
|
|
55
|
+
it('should evaluate code without throwing', async () => {
|
|
56
|
+
const sandbox = await createSandbox('test');
|
|
57
|
+
await expect(sandbox.run('const x = 1 + 2')).resolves.toBeUndefined();
|
|
58
|
+
sandbox.dispose();
|
|
59
|
+
});
|
|
60
|
+
it('should throw on runtime error', async () => {
|
|
61
|
+
const sandbox = await createSandbox('test');
|
|
62
|
+
await expect(sandbox.run('throw new Error("boom")')).rejects.toThrow('Sandbox error');
|
|
63
|
+
sandbox.dispose();
|
|
64
|
+
});
|
|
65
|
+
it('should throw on syntax error', async () => {
|
|
66
|
+
const sandbox = await createSandbox('test');
|
|
67
|
+
await expect(sandbox.run('const = invalid;;')).rejects.toThrow('Sandbox error');
|
|
68
|
+
sandbox.dispose();
|
|
69
|
+
});
|
|
70
|
+
it('should run async IIFE without throwing', async () => {
|
|
71
|
+
const sandbox = await createSandbox('test');
|
|
72
|
+
await expect(sandbox.run('(async () => { await Promise.resolve(); })()')).resolves.toBeUndefined();
|
|
73
|
+
sandbox.dispose();
|
|
74
|
+
});
|
|
75
|
+
it('should deliver data posted inside an async IIFE', async () => {
|
|
76
|
+
const received = [];
|
|
77
|
+
const sandbox = await createSandbox('test');
|
|
78
|
+
sandbox.provider.subscribe(bytes => received.push(...bytes.subarray(0, 1)));
|
|
79
|
+
await sandbox.run('(async () => { await Promise.resolve(); __HOST_API_PORT__.postMessage(new Uint8Array([7])); })()');
|
|
80
|
+
expect(received).toEqual([7]);
|
|
81
|
+
sandbox.dispose();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('port messaging', () => {
|
|
85
|
+
it('should call provider subscriber when sandbox calls port.postMessage', async () => {
|
|
86
|
+
const sandbox = await createSandbox('test');
|
|
87
|
+
const received = [];
|
|
88
|
+
sandbox.provider.subscribe(bytes => received.push(bytes));
|
|
89
|
+
await sandbox.run('__HOST_API_PORT__.postMessage(new Uint8Array([1, 2, 3]))');
|
|
90
|
+
expect(received).toHaveLength(1);
|
|
91
|
+
expect(received[0]).toEqual(new Uint8Array([1, 2, 3]));
|
|
92
|
+
sandbox.dispose();
|
|
93
|
+
});
|
|
94
|
+
it('should deliver bytes to port.onmessage when provider.postMessage is called', async () => {
|
|
95
|
+
const received = [];
|
|
96
|
+
const sandbox = await createSandbox('test');
|
|
97
|
+
sandbox.provider.subscribe(bytes => received.push(...bytes.subarray(0, 1)));
|
|
98
|
+
await sandbox.run('__HOST_API_PORT__.onmessage = event => { __HOST_API_PORT__.postMessage(new Uint8Array([event.data[0]])); }');
|
|
99
|
+
sandbox.provider.postMessage(new Uint8Array([42]));
|
|
100
|
+
expect(received).toEqual([42]);
|
|
101
|
+
sandbox.dispose();
|
|
102
|
+
});
|
|
103
|
+
it('should deliver bytes to port.addEventListener message handlers', async () => {
|
|
104
|
+
const received = [];
|
|
105
|
+
const sandbox = await createSandbox('test');
|
|
106
|
+
sandbox.provider.subscribe(bytes => received.push(...bytes.subarray(0, 1)));
|
|
107
|
+
await sandbox.run(`__HOST_API_PORT__.addEventListener('message', event => { __HOST_API_PORT__.postMessage(new Uint8Array([event.data[0]])); })`);
|
|
108
|
+
sandbox.provider.postMessage(new Uint8Array([99]));
|
|
109
|
+
expect(received).toEqual([99]);
|
|
110
|
+
sandbox.dispose();
|
|
111
|
+
});
|
|
112
|
+
it('should deliver to both onmessage and addEventListener handlers', async () => {
|
|
113
|
+
const received = [];
|
|
114
|
+
const sandbox = await createSandbox('test');
|
|
115
|
+
sandbox.provider.subscribe(bytes => received.push(...bytes.subarray(0, 1)));
|
|
116
|
+
await sandbox.run(`
|
|
117
|
+
__HOST_API_PORT__.onmessage = event => { __HOST_API_PORT__.postMessage(new Uint8Array([event.data[0]])); };
|
|
118
|
+
__HOST_API_PORT__.addEventListener('message', event => { __HOST_API_PORT__.postMessage(new Uint8Array([event.data[0] + 10])); });
|
|
119
|
+
`);
|
|
120
|
+
sandbox.provider.postMessage(new Uint8Array([5]));
|
|
121
|
+
// onmessage fires first, then addEventListener handlers
|
|
122
|
+
expect(received).toEqual([5, 15]);
|
|
123
|
+
sandbox.dispose();
|
|
124
|
+
});
|
|
125
|
+
it('should clear the subscriber when the returned unsubscribe is called', async () => {
|
|
126
|
+
const sandbox = await createSandbox('test');
|
|
127
|
+
const received = [];
|
|
128
|
+
const unsubscribe = sandbox.provider.subscribe(bytes => received.push(bytes));
|
|
129
|
+
unsubscribe();
|
|
130
|
+
await sandbox.run('__HOST_API_PORT__.postMessage(new Uint8Array([1]))');
|
|
131
|
+
expect(received).toHaveLength(0);
|
|
132
|
+
sandbox.dispose();
|
|
133
|
+
});
|
|
134
|
+
it('should read and set port.onmessage via the getter', async () => {
|
|
135
|
+
const sandbox = await createSandbox('test');
|
|
136
|
+
await sandbox.run(`
|
|
137
|
+
if (__HOST_API_PORT__.onmessage !== null) throw new Error('expected null');
|
|
138
|
+
__HOST_API_PORT__.onmessage = () => {};
|
|
139
|
+
if (typeof __HOST_API_PORT__.onmessage !== 'function') throw new Error('expected function');
|
|
140
|
+
`);
|
|
141
|
+
await expect(sandbox.run('')).resolves.toBeUndefined();
|
|
142
|
+
sandbox.dispose();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('dispose', () => {
|
|
146
|
+
it('should not throw when disposing with active subscriptions and handlers', async () => {
|
|
147
|
+
const sandbox = await createSandbox('test');
|
|
148
|
+
await sandbox.run(`
|
|
149
|
+
__HOST_API_PORT__.onmessage = () => {};
|
|
150
|
+
__HOST_API_PORT__.addEventListener('message', () => {});
|
|
151
|
+
`);
|
|
152
|
+
expect(() => sandbox.dispose()).not.toThrow();
|
|
153
|
+
});
|
|
154
|
+
it('should stop delivering messages after dispose', async () => {
|
|
155
|
+
const received = [];
|
|
156
|
+
const sandbox = await createSandbox('test');
|
|
157
|
+
sandbox.provider.subscribe(bytes => received.push(...bytes.subarray(0, 1)));
|
|
158
|
+
await sandbox.run('__HOST_API_PORT__.onmessage = event => { __HOST_API_PORT__.postMessage(new Uint8Array([event.data[0]])); }');
|
|
159
|
+
sandbox.dispose();
|
|
160
|
+
// provider.postMessage after dispose must not throw even though the vm is gone
|
|
161
|
+
expect(() => sandbox.provider.postMessage(new Uint8Array([1]))).not.toThrow();
|
|
162
|
+
expect(received).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@novasamatech/host-worker-sandbox",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.6.8",
|
|
5
|
+
"description": "QuickJS-based sandbox for running product worker code with Triangle Host API.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/paritytech/triangle-js-sdks.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"polkadot"
|
|
13
|
+
],
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
"./package.json": "./package.json",
|
|
17
|
+
".": {
|
|
18
|
+
"#/source": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@novasamatech/host-api": "0.6.8",
|
|
29
|
+
"@novasamatech/host-container": "0.6.8",
|
|
30
|
+
"quickjs-emscripten": "0.32.0"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|