├── .github └── workflows │ ├── continuous-integration-workflow.yml │ └── publish-workflow.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── deno ├── MessageChannel.ts ├── MessageChannel_test.ts ├── MessageTarget.ts ├── StructureClone.ts ├── StructureClone_test.ts └── index.ts ├── gulpfile.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── DenoWorker.spec.ts ├── DenoWorker.ts ├── MessageChannel.spec.ts ├── MessageChannel.ts ├── MessageTarget.ts ├── StructureClone.spec.ts ├── StructureClone.ts ├── Utils.ts ├── index.ts └── test │ ├── deno.json │ ├── echo.js │ ├── env.js │ ├── fail.js │ ├── fetch.js │ ├── import_map.json │ ├── infinite.js │ ├── memory.js │ ├── ping.js │ └── unresolved_promise.js └── tsconfig.json /.github/workflows/continuous-integration-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-12, windows-latest] 11 | node-version: [12.x, 20.x] 12 | deno-version: [1.40.x] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Use Deno Version ${{ matrix.deno-version }} 21 | uses: denolib/setup-deno@master 22 | with: 23 | deno-version: ${{ matrix.deno-version }} 24 | - name: npm install 25 | run: npm ci 26 | env: 27 | CI: true 28 | - name: npm test 29 | run: npx jest --detectOpenHandles --forceExit --no-cache 30 | env: 31 | CI: true 32 | - name: deno test 33 | run: | 34 | cd deno 35 | deno test 36 | build: 37 | name: Build 38 | strategy: 39 | matrix: 40 | os: [ubuntu-latest, macos-12, windows-latest] 41 | node-version: [12.x, 20.x] 42 | runs-on: ${{ matrix.os }} 43 | steps: 44 | - uses: actions/checkout@v1 45 | - name: Use Node.js ${{ matrix.node-version }} 46 | uses: actions/setup-node@v1 47 | with: 48 | node-version: ${{ matrix.node-version }} 49 | - name: npm install and build 50 | run: | 51 | npm ci 52 | npm run build 53 | env: 54 | CI: true 55 | -------------------------------------------------------------------------------- /.github/workflows/publish-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Use Node.js 12.x 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 12.x 17 | registry-url: https://registry.npmjs.org/ 18 | - name: Use Deno Version ${{ matrix.deno-version }} 19 | uses: denolib/setup-deno@master 20 | with: 21 | deno-version: 1.40.x 22 | - name: npm install and test 23 | run: | 24 | npm ci 25 | npx jest --no-cache --ci 26 | env: 27 | CI: true 28 | - name: deno test 29 | run: | 30 | cd deno 31 | deno test 32 | - name: build 33 | run: npm run build 34 | env: 35 | CI: true 36 | - name: publish 37 | run: npm publish --access public 38 | env: 39 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 40 | - name: build docs 41 | run: npm run build:docs 42 | - name: publish docs 43 | uses: JamesIves/github-pages-deploy-action@4.1.7 44 | with: 45 | token: ${{ secrets.GITHUB_TOKEN }} 46 | branch: gh-pages # The branch the action should deploy to. 47 | folder: docs # The folder the action should deploy. 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | docs/ 4 | tmp/ 5 | src/test/deno.lock -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /docs 3 | public 4 | dist 5 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "trailingComma": "es5", 4 | "tabWidth": 4, 5 | "semi": true, 6 | "singleQuote": true, 7 | "overrides": [ 8 | { 9 | "files": "*.vue", 10 | "options": { 11 | "printWidth": 100 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.13.0 4 | 5 | ### Date: 4/28/2025 6 | 7 | ### Changes: 8 | 9 | - Added `denoConfig`, `denoNoNPM`, and `denoExtraFlags` options to DenoWorker. 10 | - `denoNoNPM` defaults to `true` to prevent Deno from trying to load or interact with other files/configurations. 11 | - `denoNoNPM` needs to be set to `true` (or `denoConfig` not be specified) for DenoWorker to be able to load the bootstrap script from `node_modules` (see [#54 for more info](https://github.com/casual-simulation/node-deno-vm/issues/54)). 12 | 13 | ## v0.12.0 14 | 15 | ### Date: 4/12/2024 16 | 17 | ### Changes: 18 | 19 | - Added `unsafelyIgnoreCertificateErrors` option to DenoWorker to let users skip 20 | SSL verification. 21 | 22 | ## v0.11.0 23 | 24 | ### Date: 4/01/2024 25 | 26 | ### Changes: 27 | 28 | - Added `location` option to DenoWorker to let users customize location.href and scoping for caches 29 | 30 | ## v0.10.4 31 | 32 | ### Date: 2/08/2024 33 | 34 | ### Changes: 35 | 36 | - Updated `removeEventListener` to also remove `exit` events. 37 | 38 | ## v0.10.3 39 | 40 | ### Date: 2/02/2024 41 | 42 | ### Changes: 43 | 44 | - Make the minimum Node version 12 45 | - It's now possible to specify an object for `denoUnstable`, which can let you enable more fine-grained unstable flags. 46 | 47 | ```ts 48 | new DenoWorker(echoScript, { 49 | denoUnstable: { 50 | temporal: true, 51 | broadcastChannel: true, 52 | }, 53 | }); 54 | ``` 55 | 56 | ## v0.10.2 57 | 58 | ### Date: 1/29/2024 59 | 60 | ### Changes: 61 | 62 | - Added the `denyNet` option to DenoWorker. 63 | - This matches the `--deny-net` option in the Deno CLI: https://docs.deno.com/runtime/manual/basics/permissions#permissions-list 64 | - Thanks to [@andreterron](https://github.com/andreterron) for contributing this! ([#41](https://github.com/casual-simulation/node-deno-vm/pull/41)) 65 | 66 | ## v0.10.1 67 | 68 | ### Date: 12/21/2023 69 | 70 | ### Changes: 71 | 72 | - Update base64 imports to support Deno std 0.210.0. 73 | - Thanks to [@andreterron](https://github.com/andreterron) for contributing this! ([#40](https://github.com/casual-simulation/node-deno-vm/pull/40)) 74 | 75 | ## v0.10.0 76 | 77 | ### Date: 11/20/2023 78 | 79 | ### Changes: 80 | 81 | - Added the ability to close the websocket connection to the Deno subprocess with `.closeSocket()`. 82 | - Thanks to [@andreterron](https://github.com/andreterron) for contributing this! ([#39](https://github.com/casual-simulation/node-deno-vm/pull/39)) 83 | 84 | ## v0.9.1 85 | 86 | ### Date: 10/11/2023 87 | 88 | ### Changes: 89 | 90 | - Fixed an issue where `DenoWorker` would throw an error when the child Deno process runs out of memory. 91 | - Thanks to [@tmcw](https://github.com/tmcw) for contributing this! ([#34](https://github.com/casual-simulation/node-deno-vm/pull/34)) 92 | 93 | ## v0.9.0 94 | 95 | ### Date: 9/15/2023 96 | 97 | ### Changes: 98 | 99 | - Added the `spawnOptions` configuration option. 100 | - Useful for customizing how Node spawns the Deno child process. 101 | - Thanks to [@andreterron](https://github.com/andreterron) for contributing this! ([#31](https://github.com/casual-simulation/node-deno-vm/pull/31)) 102 | 103 | ## v0.8.4 104 | 105 | ### Date: 1/24/2023 106 | 107 | ### Changes: 108 | 109 | - Added the `exit` event. 110 | - This event is triggered on `DenoWorker` instances when the Deno child process exits. 111 | - Available via the `onexit` property or by using `worker.addEventListener("exit", listener)`. 112 | 113 | ## v0.8.3 114 | 115 | ### Date: 12/15/2021 116 | 117 | ### Changes: 118 | 119 | - Added the `denoNoCheck` option to `DenoWorker` for the `--no-check` flag. 120 | - Thanks to [@derekwheel](https://github.com/derekwheel) for contributing this! ([#13](https://github.com/casual-simulation/node-deno-vm/pull/13)) 121 | 122 | ## v0.8.2 123 | 124 | ### Date: 12/13/2021 125 | 126 | ### Changes: 127 | 128 | - Added the `denoV8Flags`, `denoImportMapPath`, `denoCachedOnly`, and `denoLockFilePath` options to `DenoWorker` for the `--v8-flags`, `--import-map`, `--cached-only`, and `--lock` flags. 129 | - Thanks to [@derekwheel](https://github.com/derekwheel) for contributing this! ([#12](https://github.com/casual-simulation/node-deno-vm/pull/12)) 130 | 131 | ## v0.8.1 132 | 133 | ### Date: 8/12/2021 134 | 135 | ### Changes: 136 | 137 | - Updated to support Deno 1.12. 138 | - Deno 1.12 added the `MessageChannel` and `MessagePort` APIs which caused `MessagePort` instances to be untransferrable. 139 | - Added the `denoUnstable` option to `DenoWorker` to enable unstable Deno features. 140 | 141 | ## v0.8.0 142 | 143 | ### Date: 9/17/2020 144 | 145 | ### Changes: 146 | 147 | - Updated to support Deno 1.4. 148 | - Deno 1.4 changed their WebSocket API and so we no longer need the polyfill. 149 | 150 | ## v0.7.4 151 | 152 | ### Date: 9/10/2020 153 | 154 | ### Changes: 155 | 156 | - Fixed to force the Deno subprocess to close when terminating the worker. 157 | - Forcing the process to be killed seems to be the most reasonable in the case that we're treating these like headless browser tabs. 158 | - When we try to gracefully kill the process, Deno might ignore it if it has things like infinite loops or open handles. 159 | - On Linux/Unix, this means sending a `SIGKILL` signal to the Deno subprocess. 160 | - On Windows, this means using `taskkill` with the `/T` and `/F` options. 161 | 162 | ## v0.7.3 163 | 164 | ### Date: 8/28/2020 165 | 166 | ### Changes: 167 | 168 | - Fixed to use the global `Object.hasOwnProperty()` function instead of relying on objects to have it themselves. 169 | 170 | ## v0.7.1 171 | 172 | ### Date: 7/27/2020 173 | 174 | ### Changes: 175 | 176 | - Fixed to log stdout and stderr in UTF-8. 177 | 178 | ## v0.7.0 179 | 180 | ### Date: 7/27/2020 181 | 182 | ### Changes: 183 | 184 | - Added the ability to get the stdout and stderr streams from the worker and choose whether to automatically log them to the console. 185 | - Added a global WebSocket polyfill since Deno doesn't implement the [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket). 186 | 187 | ## v0.7.0-alpha.1 188 | 189 | ### Date: 7/27/2020 190 | 191 | ### Changes: 192 | 193 | - Fixed the WebSocket implementation to allow setting `binaryType`. 194 | 195 | ## v0.7.0-alpha.0 196 | 197 | ### Date: 7/27/2020 198 | 199 | ### Changes: 200 | 201 | - Added a global WebSocket polyfill since Deno doesn't implement the [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket). 202 | 203 | ## v0.6.2 204 | 205 | ### Date: 7/23/2020 206 | 207 | ### Changes: 208 | 209 | - Exported MessageChannel and MessagePort. 210 | - Added `polyfillMessageChannel()` to polyfill the MessageChannel and MessagePort objects on the global object. 211 | 212 | ## v0.6.1 213 | 214 | ### Date: 7/22/2020 215 | 216 | ### Changes: 217 | 218 | - Fixed an issue where permissions were being passed incorrectly. 219 | 220 | ## v0.6.0 221 | 222 | ### Date: 7/22/2020 223 | 224 | ### Changes: 225 | 226 | - Added the ability to transfer `MessagePort` instances between the host and worker. 227 | 228 | ## v0.5.0 229 | 230 | ### Date: 7/21/2020 231 | 232 | ### Changes: 233 | 234 | - Added the `DenoWorker` class. 235 | - It is a Web Worker-like API that gives you the ability to run arbitrary scripts inside Deno. 236 | - Supports the structure-clone algorithm for Maps, Sets, BigInts, ArrayBuffers, Errors, and circular object references. 237 | - Requires that Deno be installed on the system. 238 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Casual Simulation 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # deno-vm 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/casual-simulation/node-deno-vm/Continuous%20Integration)](https://github.com/casual-simulation/node-deno-vm/actions?query=workflow%3A%22Continuous+Integration%22) [![npm](https://img.shields.io/npm/v/deno-vm)](https://www.npmjs.com/package/deno-vm) 4 | 5 | A VM module for Node.js that utilizes the secure environment provided by Deno. 6 | 7 | [API Documentation](https://docs.casualsimulation.com/node-deno-vm/) 8 | 9 | ## Features 10 | 11 | - Secure out-of-process VM environment provided by [Deno](https://deno.land). 12 | - Web Worker-like API 13 | - Supports Windows, MacOS, and Linux. 14 | - Tunable permissions (via Deno's permissions). 15 | - Supports passing ArrayBuffer, TypedArray, Maps, Sets, Dates, RegExp, Errors, MessagePorts, and circular objects. 16 | 17 | ## Installation 18 | 19 | Note that [Deno](https://deno.land/) needs to be installed and available on the PATH. 20 | 21 | ``` 22 | npm install deno-vm 23 | ``` 24 | 25 | ## Usage 26 | 27 | ```typescript 28 | import { DenoWorker } from 'deno-vm'; 29 | 30 | const script = ` 31 | self.onmessage = (e) => { 32 | self.postMessage(e.data * 2); 33 | }; 34 | `; 35 | 36 | const worker = new DenoWorker(script); 37 | 38 | worker.onmessage = (e) => { 39 | console.log('Number: ' + e.data); 40 | }; 41 | 42 | worker.postMessage(2); 43 | // Number: 4 44 | ``` 45 | 46 | ## Dependencies 47 | 48 | **deno-vm** depends on the following packages: 49 | 50 | - [`ws`](https://github.com/websockets/ws) - Used for interprocess communication between the Node.js process and Deno. Stopgap solution until Deno gets [IPC support](https://github.com/denoland/deno/issues/2585). 51 | - [`base64-js`](https://github.com/beatgammit/base64-js) - Used to serialize/deserialize binary data into JSON. 52 | 53 | ## License 54 | 55 | ``` 56 | MIT License 57 | 58 | Copyright (c) 2020 Casual Simulation, Inc. 59 | 60 | Permission is hereby granted, free of charge, to any person obtaining a copy 61 | of this software and associated documentation files (the "Software"), to deal 62 | in the Software without restriction, including without limitation the rights 63 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 64 | copies of the Software, and to permit persons to whom the Software is 65 | furnished to do so, subject to the following conditions: 66 | 67 | The above copyright notice and this permission notice shall be included in all 68 | copies or substantial portions of the Software. 69 | 70 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 71 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 72 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 73 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 74 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 75 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 76 | SOFTWARE. 77 | ``` 78 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest major version will be supported with security updates. 6 | 7 | Currently, this is `v0.8.x`. 8 | 9 | | Version | Supported | 10 | | ------- | ------------------ | 11 | | v0.8.x | :white_check_mark: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | High severity vulnerabilities can be reported via email to [devops@casualsimulation.com](mailto:devops@casualsimulation.com). 16 | 17 | Medium to low severity vulnerabilities can be reported via [an issue](https://github.com/casual-simulation/node-deno-vm/issues). 18 | 19 | Note that any vulnerability that allows the Deno sandbox to compromise the Node.js process would be considered high severity. 20 | Misconfiguration foot-guns or other similar issues would be considered medium to low severity. 21 | 22 | We will try to triage and mitigate issues to the best of our ability and will cooperate with reporters to find a good solution. 23 | Of course, high quality vulnerability reports are more likely to be fixed quickly. 24 | 25 | ## Other Notes 26 | 27 | Additionally, we will publish security advisories for all high severity issues once a fix is available but advisories for medium to low severity issues will be on a case-by-case basis. 28 | 29 | Finally, we are likely to prefer breaking API changes in cases where non-breaking fixes are non-trivial. 30 | -------------------------------------------------------------------------------- /deno/MessageChannel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessagePortInterface, 3 | Transferrable, 4 | MessageEvent, 5 | OnMessageListener, 6 | } from './MessageTarget.ts'; 7 | 8 | // Global Channel ID counter. 9 | let channelIDCounter = 0; 10 | 11 | export function resetChannelIDCounter() { 12 | channelIDCounter = 0; 13 | } 14 | 15 | /** 16 | * Defines a class that implements the Channel Messaging API for the worker. 17 | */ 18 | export class MessageChannel { 19 | port1: MessagePort; 20 | port2: MessagePort; 21 | 22 | constructor(channel?: number | string) { 23 | const id = 24 | typeof channel !== 'undefined' 25 | ? channel 26 | : (channelIDCounter++).toString(); 27 | this.port1 = new MessagePort(id); 28 | this.port2 = new MessagePort(id); 29 | MessagePort.link(this.port1, this.port2); 30 | } 31 | } 32 | 33 | /** 34 | * Defines a class that allows messages sent from one port to be recieved at the other port. 35 | */ 36 | export class MessagePort implements MessagePortInterface { 37 | /** 38 | * Whether this message port has been transferred. 39 | */ 40 | private _transferred: boolean; 41 | 42 | /** 43 | * The function that should be called to send a message to the remote. 44 | */ 45 | private _sendMessage: (data: any, transfer?: Transferrable[]) => void; 46 | 47 | /** 48 | * The ID of this message port's channel. 49 | */ 50 | private _channelId: number | string; 51 | 52 | /** 53 | * The "message" listeners. 54 | */ 55 | private _listeners: OnMessageListener[]; 56 | 57 | /** 58 | * The other message port. 59 | */ 60 | private _other: MessagePort | null; 61 | 62 | get channelID() { 63 | return this._channelId; 64 | } 65 | 66 | get transferred() { 67 | return this._transferred; 68 | } 69 | 70 | constructor(channelID: number | string) { 71 | this._other = null; 72 | this._transferred = false; 73 | this._channelId = channelID; 74 | this._listeners = []; 75 | this._sendMessage = () => {}; 76 | this.onmessage = () => {}; 77 | } 78 | 79 | addEventListener(type: 'message', listener: OnMessageListener): void { 80 | if (type === 'message') { 81 | this._listeners.push(listener); 82 | } 83 | } 84 | 85 | removeEventListener(type: 'message', listener: OnMessageListener): void { 86 | if (type === 'message') { 87 | const index = this._listeners.indexOf(listener); 88 | if (index >= 0) { 89 | this._listeners.splice(index, 1); 90 | } 91 | } 92 | } 93 | 94 | postMessage(data: any, transferrable?: Transferrable[]) { 95 | if (this.transferred) { 96 | this._sendMessage(data, transferrable); 97 | } else { 98 | if (this._other) { 99 | this._other._recieveMessage(data); 100 | } 101 | } 102 | } 103 | 104 | start() {} 105 | 106 | close() {} 107 | 108 | /** 109 | * Represents an event handler for the "message" event, that is a function to be called when a message is recieved from the worker. 110 | */ 111 | onmessage: (e: MessageEvent) => void; 112 | 113 | transfer( 114 | sendMessage: (data: any, transfer?: Transferrable[]) => void 115 | ): (data: any) => void { 116 | if (this.transferred) { 117 | throw new Error('Already transferred'); 118 | } 119 | if (!this._other) { 120 | throw new Error('Must be linked to another message port.'); 121 | } 122 | 123 | this._transferred = true; 124 | this._other._transferred = true; 125 | this._other._sendMessage = sendMessage; 126 | return this._other._recieveMessage.bind(this._other); 127 | } 128 | 129 | private _recieveMessage(data: any) { 130 | const event = { 131 | data, 132 | } as MessageEvent; 133 | if (this.onmessage) { 134 | this.onmessage(event); 135 | } 136 | for (let onmessage of this._listeners) { 137 | onmessage(event); 138 | } 139 | } 140 | 141 | /** 142 | * Links the two message ports. 143 | * @param port1 The first port. 144 | * @param port2 The second port. 145 | */ 146 | static link(port1: MessagePort, port2: MessagePort) { 147 | port1._other = port2; 148 | port2._other = port1; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /deno/MessageChannel_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assert, 4 | assertThrows, 5 | } from 'https://deno.land/std/testing/asserts.ts'; 6 | import { MessageChannel } from './MessageChannel.ts'; 7 | import { MessageEvent } from './MessageTarget.ts'; 8 | 9 | Deno.test( 10 | 'MessageChannel messages sent on port1 should end up on port2', 11 | () => { 12 | const channel = new MessageChannel(); 13 | 14 | let event: MessageEvent | null = null; 15 | channel.port2.onmessage = (e) => { 16 | event = e; 17 | }; 18 | 19 | channel.port1.postMessage({ 20 | hello: 'world', 21 | }); 22 | 23 | assertEquals(event, { 24 | data: { 25 | hello: 'world', 26 | }, 27 | }); 28 | } 29 | ); 30 | 31 | Deno.test( 32 | 'MessageChannel messages sent on port2 should end up on port1', 33 | () => { 34 | const channel = new MessageChannel(); 35 | 36 | let event: MessageEvent | null = null; 37 | channel.port1.onmessage = (e) => { 38 | event = e; 39 | }; 40 | 41 | channel.port2.postMessage({ 42 | hello: 'world', 43 | }); 44 | 45 | assertEquals(event, { 46 | data: { 47 | hello: 'world', 48 | }, 49 | }); 50 | } 51 | ); 52 | 53 | Deno.test('MessageChannel should create ports with a string channel ID', () => { 54 | const channel = new MessageChannel(); 55 | assertEquals(typeof channel.port1.channelID, 'string'); 56 | }); 57 | 58 | Deno.test( 59 | 'MessageChannel should be able to transfer() a MessagePort to take control of the serialization', 60 | () => { 61 | const channel = new MessageChannel(); 62 | 63 | let sent = [] as any[]; 64 | const recieveMessage = channel.port1.transfer((data, list) => { 65 | sent.push([data, list]); 66 | }); 67 | 68 | channel.port2.postMessage({ 69 | hello: 'world', 70 | }); 71 | 72 | assertEquals(sent, [ 73 | [ 74 | { 75 | hello: 'world', 76 | }, 77 | undefined, 78 | ], 79 | ]); 80 | 81 | let event: MessageEvent | null = null; 82 | channel.port2.onmessage = (e) => { 83 | event = e; 84 | }; 85 | 86 | recieveMessage({ 87 | wow: true, 88 | }); 89 | 90 | assertEquals(event, { 91 | data: { wow: true }, 92 | }); 93 | } 94 | ); 95 | -------------------------------------------------------------------------------- /deno/MessageTarget.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The possible transferrable data types. 3 | */ 4 | export type Transferrable = 5 | | ArrayBuffer 6 | | Uint8Array 7 | | Uint16Array 8 | | Uint32Array 9 | | Int8Array 10 | | Int16Array 11 | | Int32Array 12 | | MessagePortInterface; 13 | 14 | /** 15 | * Defines an interface for objects that are message ports. 16 | */ 17 | export interface MessagePortInterface extends MessageTarget { 18 | start(): void; 19 | close(): void; 20 | } 21 | 22 | /** 23 | * Defines an interface for objects that are able to send and recieve message events. 24 | */ 25 | export interface MessageTarget { 26 | postMessage(data: any, transferrable?: Transferrable[]): void; 27 | onmessage(e: MessageEvent): void; 28 | 29 | /** 30 | * Adds the given listener for the "message" event. 31 | * @param type The type of the event. (Always "message") 32 | * @param listener The listener to add for the event. 33 | */ 34 | addEventListener(type: 'message', listener: OnMessageListener): void; 35 | 36 | /** 37 | * Removes the given listener for the "message" event. 38 | * @param type The type of the event. (Always "message") 39 | * @param listener The listener to add for the event. 40 | */ 41 | removeEventListener(type: 'message', listener: OnMessageListener): void; 42 | } 43 | 44 | export interface OnMessageListener { 45 | (event: MessageEvent): void; 46 | } 47 | 48 | export class MessageEvent extends Event { 49 | data: any; 50 | 51 | constructor(type: string, dict: EventInit & { data: any }) { 52 | super(type, dict); 53 | this.data = dict.data; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /deno/StructureClone.ts: -------------------------------------------------------------------------------- 1 | import { 2 | encodeBase64, 3 | decodeBase64, 4 | } from 'https://deno.land/std/encoding/base64.ts'; 5 | import { Transferrable } from './MessageTarget.ts'; 6 | import { MessagePort, MessageChannel } from './MessageChannel.ts'; 7 | 8 | const HAS_CIRCULAR_REF_OR_TRANSFERRABLE = Symbol('hasCircularRef'); 9 | 10 | /** 11 | * Serializes the given value into a new object that is flat and contains no circular references. 12 | * 13 | * The returned object contains a root which is the entry point to the data structure and optionally 14 | * contains a refs property which is a flat map of references. 15 | * 16 | * If the refs property is defined, then the data structure was circular. 17 | * 18 | * @param value The value to serialize. 19 | * @param transferrable The transferrable list. 20 | */ 21 | export function serializeStructure( 22 | value: unknown, 23 | transferrable?: Transferrable[] 24 | ): Structure | StructureWithRefs { 25 | if ( 26 | (typeof value !== 'object' && typeof value !== 'bigint') || 27 | value === null 28 | ) { 29 | return { 30 | root: value, 31 | }; 32 | } else { 33 | let map = new Map(); 34 | const result = _serializeObject(value, map); 35 | 36 | if ((map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] === true) { 37 | let refs = {} as any; 38 | for (let [key, ref] of map) { 39 | refs[ref.id] = ref.obj; 40 | } 41 | return { 42 | root: result, 43 | refs: refs, 44 | }; 45 | } 46 | return { 47 | root: value, 48 | }; 49 | } 50 | } 51 | 52 | export function deserializeStructure( 53 | value: Structure | StructureWithRefs 54 | ): DeserializedStructure { 55 | if ('refs' in value) { 56 | let map = new Map(); 57 | let transferred = [] as Transferrable[]; 58 | const result = _deserializeRef(value, value.root[0], map, transferred); 59 | return { 60 | data: result, 61 | transferred: transferred, 62 | }; 63 | } else { 64 | return { 65 | data: value.root, 66 | transferred: [], 67 | }; 68 | } 69 | } 70 | 71 | function _serializeObject(value: unknown, map: Map) { 72 | if (typeof value !== 'object' && typeof value !== 'bigint') { 73 | return value; 74 | } 75 | const ref = map.get(value); 76 | if (ref) { 77 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 78 | return [ref.id]; 79 | } 80 | let id = '$' + map.size; 81 | 82 | if ( 83 | value instanceof Uint8Array || 84 | value instanceof Uint16Array || 85 | value instanceof Uint32Array || 86 | value instanceof Int8Array || 87 | value instanceof Int16Array || 88 | value instanceof Int32Array 89 | ) { 90 | let ref = { 91 | root: encodeBase64( 92 | new Uint8Array(value.buffer, value.byteOffset, value.byteLength) 93 | ), 94 | type: value.constructor.name, 95 | } as Ref; 96 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 97 | map.set(value, { 98 | id, 99 | obj: ref, 100 | }); 101 | return [id]; 102 | } else if (value instanceof ArrayBuffer) { 103 | let ref = { 104 | root: encodeBase64(new Uint8Array(value)), 105 | type: value.constructor.name, 106 | } as Ref; 107 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 108 | map.set(value, { 109 | id, 110 | obj: ref, 111 | }); 112 | return [id]; 113 | } else if (typeof value === 'bigint') { 114 | const root = value.toString(); 115 | const ref = { 116 | root, 117 | type: 'BigInt', 118 | } as const; 119 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 120 | map.set(value, { 121 | id, 122 | obj: ref, 123 | }); 124 | return [id]; 125 | } else if (Array.isArray(value)) { 126 | let root = [] as any[]; 127 | let ref = { 128 | root, 129 | } as Ref; 130 | map.set(value, { 131 | id, 132 | obj: ref, 133 | }); 134 | for (let prop of value) { 135 | root.push(_serializeObject(prop, map)); 136 | } 137 | return [id]; 138 | } else if (value instanceof Date) { 139 | const obj = { 140 | root: value.toISOString(), 141 | type: 'Date', 142 | } as const; 143 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 144 | map.set(value, { 145 | id, 146 | obj, 147 | }); 148 | return [id]; 149 | } else if (value instanceof RegExp) { 150 | const obj = { 151 | root: { 152 | source: value.source, 153 | flags: value.flags, 154 | }, 155 | type: 'RegExp', 156 | } as const; 157 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 158 | map.set(value, { 159 | id, 160 | obj, 161 | }); 162 | return [id]; 163 | } else if (value instanceof Map) { 164 | let root = [] as any[]; 165 | let obj = { 166 | root, 167 | type: 'Map', 168 | } as Ref; 169 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 170 | map.set(value, { 171 | id, 172 | obj, 173 | }); 174 | for (let prop of value) { 175 | root.push(_serializeObject(prop, map)); 176 | } 177 | return [id]; 178 | } else if (value instanceof Set) { 179 | let root = [] as any[]; 180 | let obj = { 181 | root, 182 | type: 'Set', 183 | } as Ref; 184 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 185 | map.set(value, { 186 | id, 187 | obj, 188 | }); 189 | for (let prop of value) { 190 | root.push(_serializeObject(prop, map)); 191 | } 192 | return [id]; 193 | } else if (value instanceof Error) { 194 | let obj = { 195 | root: { 196 | name: value.name, 197 | message: value.message, 198 | stack: value.stack, 199 | }, 200 | type: 'Error', 201 | } as const; 202 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 203 | map.set(value, { 204 | id, 205 | obj, 206 | }); 207 | return [id]; 208 | } else if (value instanceof MessagePort) { 209 | if (!value.transferred) { 210 | throw new Error( 211 | 'Port must be transferred before serialization. Did you forget to add it to the transfer list?' 212 | ); 213 | } 214 | let obj = { 215 | root: { 216 | channel: value.channelID, 217 | }, 218 | type: 'MessagePort', 219 | } as const; 220 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 221 | map.set(value, { 222 | id, 223 | obj, 224 | }); 225 | return [id]; 226 | } else if (value instanceof Object) { 227 | let root = {} as any; 228 | let ref = { 229 | root, 230 | } as Ref; 231 | map.set(value, { 232 | id, 233 | obj: ref, 234 | }); 235 | for (let prop in value) { 236 | if (Object.hasOwnProperty.call(value, prop)) { 237 | root[prop] = _serializeObject((value)[prop], map); 238 | } 239 | } 240 | return [id]; 241 | } 242 | } 243 | 244 | function _deserializeRef( 245 | structure: StructureWithRefs, 246 | ref: string, 247 | map: Map, 248 | transfered: Transferrable[] 249 | ): any { 250 | if (map.has(ref)) { 251 | return map.get(ref); 252 | } 253 | 254 | const refData = structure.refs[ref]; 255 | if (!refData) { 256 | throw new Error('Missing reference'); 257 | } 258 | 259 | if ('type' in refData && !!refData.type) { 260 | const types = [ 261 | 'ArrayBuffer', 262 | 'Uint8Array', 263 | 'Uint16Array', 264 | 'Uint32Array', 265 | 'Int8Array', 266 | 'Int16Array', 267 | 'Int32Array', 268 | ]; 269 | if (types.indexOf(refData.type) >= 0) { 270 | const bytes = new Uint8Array(decodeBase64(refData.root)); 271 | const final = 272 | refData.type == 'Uint8Array' 273 | ? bytes 274 | : refData.type === 'ArrayBuffer' 275 | ? bytes.buffer.slice( 276 | bytes.byteOffset, 277 | bytes.byteOffset + bytes.byteLength 278 | ) 279 | : refData.type === 'Int8Array' 280 | ? new Int8Array( 281 | bytes.buffer, 282 | bytes.byteOffset, 283 | bytes.byteLength / Int8Array.BYTES_PER_ELEMENT 284 | ) 285 | : refData.type == 'Int16Array' 286 | ? new Int16Array( 287 | bytes.buffer, 288 | bytes.byteOffset, 289 | bytes.byteLength / Int16Array.BYTES_PER_ELEMENT 290 | ) 291 | : refData.type == 'Int32Array' 292 | ? new Int32Array( 293 | bytes.buffer, 294 | bytes.byteOffset, 295 | bytes.byteLength / Int32Array.BYTES_PER_ELEMENT 296 | ) 297 | : refData.type == 'Uint16Array' 298 | ? new Uint16Array( 299 | bytes.buffer, 300 | bytes.byteOffset, 301 | bytes.byteLength / Uint16Array.BYTES_PER_ELEMENT 302 | ) 303 | : refData.type == 'Uint32Array' 304 | ? new Uint32Array( 305 | bytes.buffer, 306 | bytes.byteOffset, 307 | bytes.byteLength / Uint32Array.BYTES_PER_ELEMENT 308 | ) 309 | : null; 310 | map.set(ref, final); 311 | return final; 312 | } else if (refData.type === 'BigInt') { 313 | const final = BigInt(refData.root); 314 | map.set(ref, final); 315 | return final; 316 | } else if (refData.type === 'Date') { 317 | const final = new Date(refData.root); 318 | map.set(ref, final); 319 | return final; 320 | } else if (refData.type === 'RegExp') { 321 | const final = new RegExp(refData.root.source, refData.root.flags); 322 | map.set(ref, final); 323 | return final; 324 | } else if (refData.type === 'Map') { 325 | let final = new Map(); 326 | map.set(ref, final); 327 | for (let value of refData.root) { 328 | const [key, val] = _deserializeRef( 329 | structure, 330 | value[0], 331 | map, 332 | transfered 333 | ); 334 | final.set(key, val); 335 | } 336 | return final; 337 | } else if (refData.type === 'Set') { 338 | let final = new Set(); 339 | map.set(ref, final); 340 | for (let value of refData.root) { 341 | const val = Array.isArray(value) 342 | ? _deserializeRef(structure, value[0], map, transfered) 343 | : value; 344 | final.add(val); 345 | } 346 | return final; 347 | } else if (refData.type === 'Error') { 348 | let proto = Error.prototype; 349 | if (refData.root.name === 'EvalError') { 350 | proto = EvalError.prototype; 351 | } else if (refData.root.name === 'RangeError') { 352 | proto = RangeError.prototype; 353 | } else if (refData.root.name === 'ReferenceError') { 354 | proto = ReferenceError.prototype; 355 | } else if (refData.root.name === 'SyntaxError') { 356 | proto = SyntaxError.prototype; 357 | } else if (refData.root.name === 'TypeError') { 358 | proto = TypeError.prototype; 359 | } else if (refData.root.name === 'URIError') { 360 | proto = URIError.prototype; 361 | } 362 | let final = Object.create(proto); 363 | if (typeof refData.root.message !== 'undefined') { 364 | Object.defineProperty(final, 'message', { 365 | value: refData.root.message, 366 | writable: true, 367 | enumerable: false, 368 | configurable: true, 369 | }); 370 | } 371 | if (typeof refData.root.stack !== 'undefined') { 372 | Object.defineProperty(final, 'stack', { 373 | value: refData.root.stack, 374 | writable: true, 375 | enumerable: false, 376 | configurable: true, 377 | }); 378 | } 379 | return final; 380 | } else if (refData.type === 'MessagePort') { 381 | let final = new MessageChannel(refData.root.channel); 382 | map.set(ref, final.port1); 383 | transfered.push(final.port2); 384 | return final.port1; 385 | } 386 | } else if (Array.isArray(refData.root)) { 387 | let arr = [] as any[]; 388 | map.set(ref, arr); 389 | for (let value of refData.root) { 390 | arr.push( 391 | Array.isArray(value) 392 | ? _deserializeRef(structure, value[0], map, transfered) 393 | : value 394 | ); 395 | } 396 | return arr; 397 | } else if (typeof refData.root === 'object') { 398 | let obj = {} as any; 399 | map.set(ref, obj); 400 | for (let prop in refData.root) { 401 | if (Object.hasOwnProperty.call(refData.root, prop)) { 402 | const value = refData.root[prop]; 403 | obj[prop] = Array.isArray(value) 404 | ? _deserializeRef(structure, value[0], map, transfered) 405 | : value; 406 | } 407 | } 408 | return obj; 409 | } 410 | 411 | map.set(ref, refData.root); 412 | return refData.root; 413 | } 414 | 415 | export interface Structure { 416 | root: any; 417 | channel?: number | string; 418 | } 419 | 420 | /** 421 | * Defines an interface for a structure that was deserialized. 422 | */ 423 | export interface DeserializedStructure { 424 | /** 425 | * The data in the structure. 426 | */ 427 | data: any; 428 | 429 | /** 430 | * The list of values that were transferred and require extra processing to be fully transferred. 431 | */ 432 | transferred: Transferrable[]; 433 | } 434 | 435 | export interface StructureWithRefs { 436 | root: any; 437 | channel?: number | string; 438 | refs: { 439 | [key: string]: Ref; 440 | }; 441 | } 442 | 443 | interface MapRef { 444 | id: string; 445 | obj: Ref; 446 | } 447 | 448 | export interface Ref { 449 | root: any; 450 | type?: 451 | | 'ArrayBuffer' 452 | | 'Uint8Array' 453 | | 'Uint16Array' 454 | | 'Uint32Array' 455 | | 'Int8Array' 456 | | 'Int16Array' 457 | | 'Int32Array' 458 | | 'BigInt' 459 | | 'Date' 460 | | 'RegExp' 461 | | 'Map' 462 | | 'Set' 463 | | 'Error' 464 | | 'MessagePort'; 465 | } 466 | -------------------------------------------------------------------------------- /deno/StructureClone_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assert, 4 | assertThrows, 5 | } from 'https://deno.land/std/testing/asserts.ts'; 6 | import { serializeStructure, deserializeStructure } from './StructureClone.ts'; 7 | import { 8 | MessagePort, 9 | MessageChannel, 10 | resetChannelIDCounter, 11 | } from './MessageChannel.ts'; 12 | 13 | const primitives = [[true], [false], [0], [1], ['string'], [undefined], [null]]; 14 | 15 | const arrayTypes = [ 16 | ['Uint8Array'] as const, 17 | ['Uint16Array'] as const, 18 | ['Uint32Array'] as const, 19 | ['Int8Array'] as const, 20 | ['Int16Array'] as const, 21 | ['Int32Array'] as const, 22 | ]; 23 | 24 | const errorCases = [ 25 | [Error], 26 | [EvalError], 27 | [RangeError], 28 | [ReferenceError], 29 | [SyntaxError], 30 | [TypeError], 31 | [URIError], 32 | ]; 33 | 34 | Deno.test('serializeStructure() should return an object with root', () => { 35 | for (let [value] of primitives) { 36 | assertEquals(serializeStructure(value), { 37 | root: value, 38 | }); 39 | } 40 | }); 41 | 42 | Deno.test( 43 | 'serializeStructure() should serialize non-circular objects normally', 44 | () => { 45 | let obj1 = { 46 | name: 'obj1', 47 | obj2: { 48 | name: 'obj2', 49 | obj3: { 50 | name: 'obj3', 51 | }, 52 | }, 53 | }; 54 | 55 | assertEquals(serializeStructure(obj1), { 56 | root: { 57 | name: 'obj1', 58 | obj2: { 59 | name: 'obj2', 60 | obj3: { 61 | name: 'obj3', 62 | }, 63 | }, 64 | }, 65 | }); 66 | } 67 | ); 68 | 69 | Deno.test( 70 | 'serializeStructure() should add circular references to the refs map', 71 | () => { 72 | let obj3 = { 73 | name: 'obj3', 74 | } as any; 75 | let obj2 = { 76 | name: 'obj2', 77 | obj3: obj3, 78 | } as any; 79 | let obj1 = { 80 | name: 'obj1', 81 | obj2: obj2, 82 | } as any; 83 | 84 | obj3.obj1 = obj1; 85 | 86 | assertEquals(serializeStructure(obj1), { 87 | root: ['$0'], 88 | refs: { 89 | $0: { 90 | root: { 91 | name: 'obj1', 92 | obj2: ['$1'], 93 | }, 94 | }, 95 | $1: { 96 | root: { 97 | name: 'obj2', 98 | obj3: ['$2'], 99 | }, 100 | }, 101 | $2: { 102 | root: { 103 | name: 'obj3', 104 | obj1: ['$0'], 105 | }, 106 | }, 107 | }, 108 | }); 109 | } 110 | ); 111 | 112 | Deno.test('serializeStructure() should handle simple arrays', () => { 113 | assertEquals(serializeStructure(['abc', 'def', 123, true]), { 114 | root: ['abc', 'def', 123, true], 115 | }); 116 | }); 117 | 118 | Deno.test('serializeStructure() should handle arrays with objects', () => { 119 | assertEquals( 120 | serializeStructure(['abc', 'def', 123, true, { message: 'Hello' }]), 121 | { 122 | root: ['abc', 'def', 123, true, { message: 'Hello' }], 123 | } 124 | ); 125 | }); 126 | 127 | Deno.test('serializeStructure() should handle circular arrays', () => { 128 | let arr3 = ['arr3'] as any[]; 129 | let arr2 = ['arr2', arr3]; 130 | let arr1 = ['arr1', arr2]; 131 | arr3.push(arr1); 132 | 133 | assertEquals(serializeStructure(arr1), { 134 | root: ['$0'], 135 | refs: { 136 | $0: { 137 | root: ['arr1', ['$1']], 138 | }, 139 | $1: { 140 | root: ['arr2', ['$2']], 141 | }, 142 | $2: { 143 | root: ['arr3', ['$0']], 144 | }, 145 | }, 146 | }); 147 | }); 148 | 149 | Deno.test( 150 | 'serializeStructure() should map transferrables as special references', 151 | () => { 152 | let buffer = new ArrayBuffer(64); 153 | let obj1 = { 154 | name: 'obj1', 155 | buffer: buffer, 156 | }; 157 | 158 | assertEquals(serializeStructure(obj1, [buffer]), { 159 | root: ['$0'], 160 | refs: { 161 | $0: { 162 | root: { 163 | name: 'obj1', 164 | buffer: ['$1'], 165 | }, 166 | }, 167 | $1: { 168 | root: 169 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 170 | type: 'ArrayBuffer', 171 | }, 172 | }, 173 | }); 174 | } 175 | ); 176 | 177 | Deno.test( 178 | 'serializeStructure() should map typed arrays as special references', 179 | () => { 180 | for (let [type] of arrayTypes) { 181 | let buffer = new ArrayBuffer(64); 182 | let array = new (globalThis)[type](buffer); 183 | let obj1 = { 184 | name: 'obj1', 185 | array: array, 186 | }; 187 | 188 | assertEquals(serializeStructure(obj1, [buffer]), { 189 | root: ['$0'], 190 | refs: { 191 | $0: { 192 | root: { 193 | name: 'obj1', 194 | array: ['$1'], 195 | }, 196 | }, 197 | $1: { 198 | root: 199 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 200 | type: type, 201 | }, 202 | }, 203 | }); 204 | } 205 | } 206 | ); 207 | 208 | Deno.test('serializeStructure() should support BigInt objects', () => { 209 | assertEquals(serializeStructure(BigInt(989898434684646)), { 210 | root: ['$0'], 211 | refs: { 212 | $0: { 213 | root: '989898434684646', 214 | type: 'BigInt', 215 | }, 216 | }, 217 | }); 218 | }); 219 | 220 | Deno.test('serializeStructure() should support Date objects', () => { 221 | assertEquals(serializeStructure(new Date('2020-07-21T00:00:00.000Z')), { 222 | root: ['$0'], 223 | refs: { 224 | $0: { 225 | root: '2020-07-21T00:00:00.000Z', 226 | type: 'Date', 227 | }, 228 | }, 229 | }); 230 | }); 231 | 232 | Deno.test('serializeStructure() should support RegExp objects', () => { 233 | assertEquals(serializeStructure(new RegExp('^abc$', 'gi')), { 234 | root: ['$0'], 235 | refs: { 236 | $0: { 237 | root: { 238 | source: '^abc$', 239 | flags: 'gi', 240 | }, 241 | type: 'RegExp', 242 | }, 243 | }, 244 | }); 245 | }); 246 | 247 | Deno.test('serializeStructure() should support Map objects', () => { 248 | assertEquals( 249 | serializeStructure( 250 | new Map([ 251 | ['key', 'value'], 252 | [{ name: 'bob' }, 99], 253 | ]) 254 | ), 255 | { 256 | root: ['$0'], 257 | refs: { 258 | $0: { 259 | root: [['$1'], ['$2']], 260 | type: 'Map', 261 | }, 262 | $1: { 263 | root: ['key', 'value'], 264 | }, 265 | $2: { 266 | root: [['$3'], 99], 267 | }, 268 | $3: { 269 | root: { name: 'bob' }, 270 | }, 271 | }, 272 | } 273 | ); 274 | }); 275 | 276 | Deno.test('serializeStructure() should support Set objects', () => { 277 | assertEquals( 278 | serializeStructure( 279 | new Set(['abc', 'def', 99, { name: 'bob' }]) 280 | ), 281 | { 282 | root: ['$0'], 283 | refs: { 284 | $0: { 285 | root: ['abc', 'def', 99, ['$1']], 286 | type: 'Set', 287 | }, 288 | $1: { 289 | root: { name: 'bob' }, 290 | }, 291 | }, 292 | } 293 | ); 294 | }); 295 | 296 | Deno.test('serializeStructure() should support Error objects', () => { 297 | for (let [type] of errorCases as any) { 298 | const err = new type('abc'); 299 | assertEquals(serializeStructure(err), { 300 | root: ['$0'], 301 | refs: { 302 | $0: { 303 | root: { 304 | name: err.name, 305 | message: 'abc', 306 | stack: err.stack, 307 | }, 308 | type: 'Error', 309 | }, 310 | }, 311 | }); 312 | } 313 | }); 314 | 315 | Deno.test( 316 | 'serializeStructure() should require MessagePort objects to be transferred', 317 | () => { 318 | const port = new MessagePort(99); 319 | 320 | assertThrows( 321 | () => { 322 | serializeStructure(port, [port]); 323 | }, 324 | Error, 325 | 'Port must be transferred before serialization. Did you forget to add it to the transfer list?' 326 | ); 327 | } 328 | ); 329 | 330 | Deno.test( 331 | 'serializeStructure() should require deeply nested MessagePort objects to be transferred', 332 | () => { 333 | const port = new MessagePort(99); 334 | 335 | assertThrows( 336 | () => { 337 | serializeStructure( 338 | { 339 | value: { 340 | value2: { 341 | port, 342 | }, 343 | }, 344 | }, 345 | [port] 346 | ); 347 | }, 348 | Error, 349 | 'Port must be transferred before serialization. Did you forget to add it to the transfer list?' 350 | ); 351 | } 352 | ); 353 | 354 | Deno.test('serializeStructure() should support MessagePort objects', () => { 355 | const port1 = new MessagePort(99); 356 | const port2 = new MessagePort(99); 357 | MessagePort.link(port1, port2); 358 | port1.transfer(() => {}); 359 | 360 | assertEquals(serializeStructure(port1, [port1]), { 361 | root: ['$0'], 362 | refs: { 363 | $0: { 364 | root: { 365 | channel: 99, 366 | }, 367 | type: 'MessagePort', 368 | }, 369 | }, 370 | }); 371 | }); 372 | 373 | Deno.test( 374 | 'serializeStructure() should support deeply nested MessagePort objects', 375 | () => { 376 | const port1 = new MessagePort(99); 377 | const port2 = new MessagePort(99); 378 | MessagePort.link(port1, port2); 379 | port1.transfer(() => {}); 380 | 381 | assertEquals( 382 | serializeStructure( 383 | { 384 | a: { 385 | b: { 386 | port1, 387 | }, 388 | }, 389 | }, 390 | [port1] 391 | ), 392 | { 393 | root: ['$0'], 394 | refs: { 395 | $0: { 396 | root: { 397 | a: ['$1'], 398 | }, 399 | }, 400 | $1: { 401 | root: { 402 | b: ['$2'], 403 | }, 404 | }, 405 | $2: { 406 | root: { 407 | port1: ['$3'], 408 | }, 409 | }, 410 | $3: { 411 | root: { 412 | channel: 99, 413 | }, 414 | type: 'MessagePort', 415 | }, 416 | }, 417 | } 418 | ); 419 | } 420 | ); 421 | 422 | Deno.test( 423 | 'serializeStructure() should support MessagePort objects in arrays', 424 | () => { 425 | resetChannelIDCounter(); 426 | const channel1 = new MessageChannel(); 427 | const channel2 = new MessageChannel(); 428 | const channel3 = new MessageChannel(); 429 | 430 | channel1.port1.transfer(() => {}); 431 | channel2.port1.transfer(() => {}); 432 | channel3.port1.transfer(() => {}); 433 | 434 | assertEquals( 435 | serializeStructure( 436 | { 437 | arr: [channel1.port1, channel2.port1, channel3.port1], 438 | }, 439 | [channel1.port1, channel2.port1, channel3.port1] 440 | ), 441 | { 442 | root: ['$0'], 443 | refs: { 444 | $0: { 445 | root: { 446 | arr: ['$1'], 447 | }, 448 | }, 449 | $1: { 450 | root: [['$2'], ['$3'], ['$4']], 451 | }, 452 | $2: { 453 | root: { 454 | channel: '0', 455 | }, 456 | type: 'MessagePort', 457 | }, 458 | $3: { 459 | root: { 460 | channel: '1', 461 | }, 462 | type: 'MessagePort', 463 | }, 464 | $4: { 465 | root: { 466 | channel: '2', 467 | }, 468 | type: 'MessagePort', 469 | }, 470 | }, 471 | } 472 | ); 473 | } 474 | ); 475 | 476 | Deno.test( 477 | 'serializeStructure() should not error when given an object without hasOwnProperty', 478 | () => { 479 | let obj = { 480 | myProp: 'abc', 481 | hasOwnProperty: null, 482 | }; 483 | assertEquals(serializeStructure(obj), { 484 | root: { 485 | hasOwnProperty: null, 486 | myProp: 'abc', 487 | }, 488 | }); 489 | } 490 | ); 491 | 492 | Deno.test( 493 | 'deserializeStructure() should return the root value for primitives', 494 | () => { 495 | for (let [value] of primitives) { 496 | assertEquals( 497 | deserializeStructure({ 498 | root: value, 499 | }), 500 | { 501 | data: value, 502 | transferred: [], 503 | } 504 | ); 505 | } 506 | } 507 | ); 508 | 509 | Deno.test('deserializeStructure() should deserialize circular objects', () => { 510 | let obj3 = { 511 | name: 'obj3', 512 | } as any; 513 | let obj2 = { 514 | name: 'obj2', 515 | obj3: obj3, 516 | } as any; 517 | let obj1 = { 518 | name: 'obj1', 519 | obj2: obj2, 520 | } as any; 521 | 522 | obj3.obj1 = obj1; 523 | 524 | const deserialized = deserializeStructure({ 525 | root: ['$0'], 526 | refs: { 527 | $0: { 528 | root: { 529 | name: 'obj1', 530 | obj2: ['$1'], 531 | }, 532 | }, 533 | $1: { 534 | root: { 535 | name: 'obj2', 536 | obj3: ['$2'], 537 | }, 538 | }, 539 | $2: { 540 | root: { 541 | name: 'obj3', 542 | obj1: ['$0'], 543 | }, 544 | }, 545 | }, 546 | }); 547 | const result = deserialized.data; 548 | 549 | // Can't use assertEquals because it doesn't handle the circular reference for some reason. 550 | assert(typeof result === 'object'); 551 | assertEquals(result.name, 'obj1'); 552 | assert(typeof result.obj2 === 'object'); 553 | assertEquals(result.obj2.name, 'obj2'); 554 | assert(typeof result.obj2.obj3 === 'object'); 555 | assertEquals(result.obj2.obj3.name, 'obj3'); 556 | assert(result.obj2.obj3.obj1 === result); 557 | }); 558 | 559 | Deno.test( 560 | 'deserializeStructure() should deserialize arrays with objects', 561 | () => { 562 | assertEquals( 563 | deserializeStructure({ 564 | root: ['abc', 'def', 123, true, { message: 'Hello' }], 565 | }), 566 | { 567 | data: ['abc', 'def', 123, true, { message: 'Hello' }], 568 | transferred: [], 569 | } 570 | ); 571 | } 572 | ); 573 | 574 | Deno.test( 575 | 'deserializeStructure() should deserialize arrays with objects', 576 | () => { 577 | assertEquals( 578 | deserializeStructure({ 579 | root: ['abc', 'def', 123, true, { message: 'Hello' }], 580 | }), 581 | { 582 | data: ['abc', 'def', 123, true, { message: 'Hello' }], 583 | transferred: [], 584 | } 585 | ); 586 | } 587 | ); 588 | 589 | Deno.test('deserializeStructure() should deserialize circular arrays', () => { 590 | let arr3 = ['arr3'] as any[]; 591 | let arr2 = ['arr2', arr3]; 592 | let arr1 = ['arr1', arr2]; 593 | arr3.push(arr1); 594 | 595 | const deserialized = deserializeStructure({ 596 | root: ['$0'], 597 | refs: { 598 | $0: { 599 | root: ['arr1', ['$1']], 600 | }, 601 | $1: { 602 | root: ['arr2', ['$2']], 603 | }, 604 | $2: { 605 | root: ['arr3', ['$0']], 606 | }, 607 | }, 608 | }); 609 | const result = deserialized.data; 610 | 611 | // Can't use assertEquals because it doesn't handle the circular reference for some reason. 612 | assert(typeof result === 'object'); 613 | assertEquals(result[0], 'arr1'); 614 | assert(typeof result[1] === 'object'); 615 | assertEquals(result[1][0], 'arr2'); 616 | assert(typeof result[1][1] === 'object'); 617 | assertEquals(result[1][1][0], 'arr3'); 618 | assert(result[1][1][1] === result); 619 | }); 620 | 621 | Deno.test( 622 | 'deserializeStructure() should map transferrables as special references', 623 | () => { 624 | let buffer = new ArrayBuffer(64); 625 | let obj1 = { 626 | name: 'obj1', 627 | buffer: buffer, 628 | }; 629 | 630 | assertEquals( 631 | deserializeStructure({ 632 | root: ['$0'], 633 | refs: { 634 | $0: { 635 | root: { 636 | name: 'obj1', 637 | buffer: ['$1'], 638 | }, 639 | }, 640 | $1: { 641 | root: 642 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 643 | type: 'ArrayBuffer', 644 | }, 645 | }, 646 | }), 647 | { 648 | data: obj1, 649 | transferred: [], 650 | } 651 | ); 652 | } 653 | ); 654 | 655 | Deno.test( 656 | 'deserializeStructure() should map typed arrays as special references', 657 | () => { 658 | for (let [type] of arrayTypes) { 659 | let buffer = new ArrayBuffer(64); 660 | let array = new (globalThis)[type](buffer); 661 | let obj1 = { 662 | name: 'obj1', 663 | array: array, 664 | }; 665 | 666 | assertEquals( 667 | deserializeStructure({ 668 | root: ['$0'], 669 | refs: { 670 | $0: { 671 | root: { 672 | name: 'obj1', 673 | array: ['$1'], 674 | }, 675 | }, 676 | $1: { 677 | root: 678 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 679 | type: type as any, 680 | }, 681 | }, 682 | }), 683 | { 684 | data: obj1, 685 | transferred: [], 686 | } 687 | ); 688 | } 689 | } 690 | ); 691 | 692 | Deno.test('deserializeStructure() should support BigInt objects', () => { 693 | assertEquals( 694 | deserializeStructure({ 695 | root: ['$0'], 696 | refs: { 697 | $0: { 698 | root: '989898434684646', 699 | type: 'BigInt', 700 | }, 701 | }, 702 | }), 703 | { 704 | data: BigInt(989898434684646), 705 | transferred: [], 706 | } 707 | ); 708 | }); 709 | 710 | Deno.test('deserializeStructure() should support Date objects', () => { 711 | assertEquals( 712 | deserializeStructure({ 713 | root: ['$0'], 714 | refs: { 715 | $0: { 716 | root: '2020-07-21T00:00:00.000Z', 717 | type: 'Date', 718 | }, 719 | }, 720 | }), 721 | { 722 | data: new Date('2020-07-21T00:00:00.000Z'), 723 | transferred: [], 724 | } 725 | ); 726 | }); 727 | 728 | Deno.test('deserializeStructure() should support RegExp objects', () => { 729 | assertEquals( 730 | deserializeStructure({ 731 | root: ['$0'], 732 | refs: { 733 | $0: { 734 | root: { 735 | source: '^abc$', 736 | flags: 'gi', 737 | }, 738 | type: 'RegExp', 739 | }, 740 | }, 741 | }), 742 | { 743 | data: new RegExp('^abc$', 'gi'), 744 | transferred: [], 745 | } 746 | ); 747 | }); 748 | 749 | Deno.test('deserializeStructure() should support Map objects', () => { 750 | assertEquals( 751 | deserializeStructure({ 752 | root: ['$0'], 753 | refs: { 754 | $0: { 755 | root: [['$1'], ['$2']], 756 | type: 'Map', 757 | }, 758 | $1: { 759 | root: ['key', 'value'], 760 | }, 761 | $2: { 762 | root: [['$3'], 99], 763 | }, 764 | $3: { 765 | root: { name: 'bob' }, 766 | }, 767 | }, 768 | }), 769 | { 770 | data: new Map([ 771 | ['key', 'value'], 772 | [{ name: 'bob' }, 99], 773 | ]), 774 | transferred: [], 775 | } 776 | ); 777 | }); 778 | 779 | Deno.test('deserializeStructure() should support Set objects', () => { 780 | assertEquals( 781 | deserializeStructure({ 782 | root: ['$0'], 783 | refs: { 784 | $0: { 785 | root: ['abc', 'def', 99, ['$1']], 786 | type: 'Set', 787 | }, 788 | $1: { 789 | root: { name: 'bob' }, 790 | }, 791 | }, 792 | }), 793 | { 794 | data: new Set(['abc', 'def', 99, { name: 'bob' }]), 795 | transferred: [], 796 | } 797 | ); 798 | }); 799 | 800 | Deno.test('deserializeStructure() should support Error objects', () => { 801 | for (let [type] of errorCases as any) { 802 | const err = new type('abc'); 803 | assertEquals( 804 | deserializeStructure({ 805 | root: ['$0'], 806 | refs: { 807 | $0: { 808 | root: { 809 | name: err.name, 810 | message: 'abc', 811 | stack: err.stack, 812 | }, 813 | type: 'Error', 814 | }, 815 | }, 816 | }), 817 | { 818 | data: err, 819 | transferred: [], 820 | } 821 | ); 822 | } 823 | }); 824 | 825 | Deno.test('deserializeStructure() should support MessagePort objects', () => { 826 | const port1 = new MessagePort(99); 827 | 828 | const { data, transferred } = deserializeStructure({ 829 | root: ['$0'], 830 | refs: { 831 | $0: { 832 | root: { 833 | channel: 99, 834 | }, 835 | type: 'MessagePort', 836 | }, 837 | }, 838 | }); 839 | 840 | assert(typeof data === 'object'); 841 | assert(data instanceof MessagePort); 842 | assert(Object.getPrototypeOf(data) === Object.getPrototypeOf(port1)); 843 | assert(data.channelID === 99); 844 | assert(transferred[0] !== data); 845 | }); 846 | 847 | Deno.test( 848 | 'deserializeStructure() should have a different port for the transferred from in the data', 849 | () => { 850 | const deserialized = deserializeStructure({ 851 | root: ['$0'], 852 | refs: { 853 | $0: { 854 | root: { 855 | channel: 99, 856 | }, 857 | type: 'MessagePort', 858 | }, 859 | }, 860 | }); 861 | 862 | assert(deserialized.data !== deserialized.transferred[0]); 863 | } 864 | ); 865 | 866 | Deno.test( 867 | 'deserializeStructure() should not error when given an object without hasOwnProperty', 868 | () => { 869 | const deserialized = deserializeStructure({ 870 | root: ['$0'], 871 | refs: { 872 | $0: { 873 | root: { 874 | hasOwnProperty: null, 875 | myProp: 'abc', 876 | }, 877 | }, 878 | }, 879 | }); 880 | assertEquals(deserialized, { 881 | data: { 882 | hasOwnProperty: null, 883 | myProp: 'abc', 884 | }, 885 | transferred: [], 886 | }); 887 | } 888 | ); 889 | -------------------------------------------------------------------------------- /deno/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | serializeStructure, 3 | deserializeStructure, 4 | Structure, 5 | StructureWithRefs, 6 | } from './StructureClone.ts'; 7 | import { MessageEvent, Transferrable } from './MessageTarget.ts'; 8 | import { 9 | MessagePort as MessagePortShim, 10 | MessageChannel as MessageChannelShim, 11 | } from './MessageChannel.ts'; 12 | 13 | const address = Deno.args[0]; 14 | const scriptType = Deno.args[1]; 15 | const script = Deno.args[2]; 16 | 17 | let ports = new Map(); 18 | 19 | init(); 20 | 21 | async function init() { 22 | const socket = new WebSocket(address); 23 | 24 | let onMessage = patchGlobalThis((json) => socket.send(json)); 25 | 26 | socket.onmessage = (message) => { 27 | onMessage(message.data); 28 | }; 29 | socket.onerror = (err) => { 30 | console.error(err); 31 | if (socket.readyState !== WebSocket.CLOSED) { 32 | socket.close(); 33 | } 34 | }; 35 | socket.onopen = () => { 36 | sendMessage( 37 | { 38 | type: 'init', 39 | }, 40 | socket 41 | ); 42 | 43 | if (scriptType === 'script') { 44 | Function(script)(); 45 | } else if (scriptType === 'import') { 46 | import(script); 47 | } else { 48 | throw new Error('Unsupported scrypt type: ' + scriptType); 49 | } 50 | }; 51 | } 52 | 53 | async function sendMessage(message: any, socket: WebSocket) { 54 | if (socket.readyState !== WebSocket.OPEN) { 55 | return; 56 | } 57 | const structured = serializeStructure(message); 58 | const json = JSON.stringify(structured); 59 | return socket.send(json); 60 | } 61 | 62 | function patchGlobalThis(send: (json: string) => void) { 63 | (globalThis).postMessage = (data: any, transfer?: Transferrable[]) => 64 | postMessage(null, data, transfer); 65 | 66 | if (typeof (globalThis).MessageChannel !== 'undefined') { 67 | (globalThis).BuiltinMessageChannel = (( 68 | globalThis 69 | )).MessageChannel; 70 | } 71 | if (typeof (globalThis).MessagePort !== 'undefined') { 72 | (globalThis).BuiltinMessagePort = (globalThis).MessagePort; 73 | } 74 | (globalThis).MessageChannel = MessageChannelShim; 75 | (globalThis).MessagePort = MessagePortShim; 76 | 77 | return function onmessage(message: string) { 78 | if (typeof message === 'string') { 79 | const structuredData = JSON.parse(message) as 80 | | Structure 81 | | StructureWithRefs; 82 | const channel = structuredData.channel; 83 | const deserialized = deserializeStructure(structuredData); 84 | const data = deserialized.data; 85 | 86 | if (deserialized.transferred) { 87 | handleTransfers(deserialized.transferred); 88 | } 89 | 90 | if (typeof channel === 'number' || typeof channel === 'string') { 91 | const portData = ports.get(channel); 92 | if (portData) { 93 | portData.recieveData(data); 94 | } else { 95 | console.log('No Port!'); 96 | } 97 | } else { 98 | const event = new MessageEvent('message', { 99 | data, 100 | }); 101 | 102 | if (typeof (globalThis).onmessage === 'function') { 103 | (globalThis).onmessage(event); 104 | } 105 | globalThis.dispatchEvent(event); 106 | } 107 | } 108 | }; 109 | 110 | function postMessage( 111 | channel: number | string | null, 112 | data: any, 113 | transfer?: Transferrable[] 114 | ): void { 115 | if (transfer) { 116 | handleTransfers(transfer); 117 | } 118 | const structuredData = serializeStructure(data, transfer); 119 | if (typeof channel === 'number' || typeof channel === 'string') { 120 | structuredData.channel = channel; 121 | } 122 | const json = JSON.stringify(structuredData); 123 | send(json); 124 | } 125 | 126 | function handleTransfers(transfer?: Transferrable[]) { 127 | if (transfer) { 128 | for (let t of transfer) { 129 | if (t instanceof MessagePortShim) { 130 | const channel = t.channelID; 131 | ports.set(t.channelID, { 132 | port: t, 133 | recieveData: t.transfer((data, list) => { 134 | postMessage(channel, data, list); 135 | }), 136 | }); 137 | } 138 | } 139 | } 140 | } 141 | } 142 | 143 | interface MessagePortData { 144 | port: MessagePortShim; 145 | recieveData: (data: any) => void; 146 | } 147 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const del = require('del'); 3 | const childProcess = require('child_process'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | let folders = [`${__dirname}/dist`, `${__dirname}/docs`]; 8 | 9 | gulp.task('clean', function () { 10 | return del(folders); 11 | }); 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | moduleFileExtensions: ['ts', 'tsx', 'js'], 4 | testPathIgnorePatterns: [ 5 | '/node_modules/', 6 | '/deno/', 7 | '/temp/', 8 | '/lib/', 9 | '/dist/', 10 | ], 11 | watchPathIgnorePatterns: ['/node_modules/'], 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deno-vm", 3 | "version": "0.13.0", 4 | "description": "A VM module that provides a secure runtime environment via Deno.", 5 | "main": "./dist/cjs/index.js", 6 | "types": "./dist/typings/index.d.ts", 7 | "module": "./dist/esm/index.js", 8 | "scripts": { 9 | "build": "npm run clean && rollup -c", 10 | "watch": "npm run clean && rollup -cw", 11 | "test": "jest", 12 | "test:watch": "jest --watchAll", 13 | "clean": "gulp clean", 14 | "build:docs": "typedoc --out ./docs ./src" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/casual-simulation/node-deno-vm.git" 19 | }, 20 | "keywords": [ 21 | "vm", 22 | "sandbox", 23 | "jail", 24 | "worker", 25 | "web worker", 26 | "deno" 27 | ], 28 | "author": "Kallyn Gowdy ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/casual-simulation/node-deno-vm/issues" 32 | }, 33 | "homepage": "https://github.com/casual-simulation/node-deno-vm#readme", 34 | "husky": { 35 | "hooks": { 36 | "pre-commit": "pretty-quick --staged" 37 | } 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^26.0.5", 41 | "@types/node": "^14.0.23", 42 | "@types/ws": "^7.2.6", 43 | "@types/base64-js": "1.2.5", 44 | "husky": "^4.2.5", 45 | "prettier": "^2.0.5", 46 | "pretty-quick": "^2.0.1", 47 | "jest": "^26.1.0", 48 | "rollup": "^2.22.1", 49 | "rollup-plugin-typescript2": "^0.27.1", 50 | "rollup-watch": "^4.3.1", 51 | "ts-jest": "^26.1.3", 52 | "typescript": "^3.9.7", 53 | "tslib": "2.0.0", 54 | "gulp": "^4.0.0", 55 | "del": "^3.0.0", 56 | "typedoc": "^0.17.8", 57 | "ps-list": "7.2.0" 58 | }, 59 | "dependencies": { 60 | "ws": "^7.5.9", 61 | "base64-js": "1.5.1" 62 | }, 63 | "files": [ 64 | "dist/*", 65 | "src/*", 66 | "deno/*", 67 | "LICENSE", 68 | "README.md" 69 | ], 70 | "engines": { 71 | "node": ">= 12" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | 3 | export default [ 4 | { 5 | input: './src/index.ts', 6 | output: [ 7 | { 8 | dir: './dist/cjs', 9 | format: 'cjs', 10 | sourcemap: true, 11 | }, 12 | { 13 | dir: './dist/esm', 14 | format: 'es', 15 | sourcemap: true, 16 | }, 17 | ], 18 | plugins: [ 19 | typescript({ 20 | useTsconfigDeclarationDir: true, 21 | }), 22 | ], 23 | external: [ 24 | 'http', 25 | 'ws', 26 | 'child_process', 27 | 'base64-js', 28 | 'path', 29 | 'process', 30 | ], 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/DenoWorker.spec.ts: -------------------------------------------------------------------------------- 1 | import { DenoWorker } from './DenoWorker'; 2 | import { readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs'; 3 | import { randomBytes } from 'crypto'; 4 | import path from 'path'; 5 | import { URL } from 'url'; 6 | import { MessageChannel, MessagePort } from './MessageChannel'; 7 | import psList from 'ps-list'; 8 | import child_process from 'child_process'; 9 | 10 | console.log = jest.fn(); 11 | jest.setTimeout(10000); 12 | 13 | describe('DenoWorker', () => { 14 | let worker: DenoWorker; 15 | const echoFile = path.resolve(__dirname, './test/echo.js'); 16 | const echoScript = readFileSync(echoFile, { encoding: 'utf-8' }); 17 | const pingFile = path.resolve(__dirname, './test/ping.js'); 18 | const pingScript = readFileSync(pingFile, { encoding: 'utf-8' }); 19 | const infiniteFile = path.resolve(__dirname, './test/infinite.js'); 20 | const infiniteScript = readFileSync(infiniteFile, { encoding: 'utf-8' }); 21 | const fetchFile = path.resolve(__dirname, './test/fetch.js'); 22 | const fetchScript = readFileSync(fetchFile, { encoding: 'utf-8' }); 23 | const failFile = path.resolve(__dirname, './test/fail.js'); 24 | const failScript = readFileSync(failFile, { encoding: 'utf-8' }); 25 | const envFile = path.resolve(__dirname, './test/env.js'); 26 | const envScript = readFileSync(envFile, { encoding: 'utf-8' }); 27 | const memoryCrashFile = path.resolve(__dirname, './test/memory.js'); 28 | const unresolvedPromiseFile = path.resolve( 29 | __dirname, 30 | './test/unresolved_promise.js' 31 | ); 32 | const unresolvedPromiseScript = readFileSync(unresolvedPromiseFile, { 33 | encoding: 'utf-8', 34 | }); 35 | 36 | afterEach(() => { 37 | if (worker) { 38 | worker.terminate(); 39 | } 40 | }); 41 | 42 | describe('scripts', () => { 43 | it('should be able to run the given script', async () => { 44 | worker = new DenoWorker(echoScript); 45 | 46 | let ret: any; 47 | let resolve: any; 48 | let promise = new Promise((res, rej) => { 49 | resolve = res; 50 | }); 51 | worker.onmessage = (e) => { 52 | ret = e.data; 53 | resolve(); 54 | }; 55 | 56 | worker.postMessage({ 57 | type: 'echo', 58 | message: 'Hello', 59 | }); 60 | 61 | await promise; 62 | 63 | expect(ret).toEqual({ 64 | type: 'echo', 65 | message: 'Hello', 66 | }); 67 | }); 68 | 69 | it('should be able to import the given script', async () => { 70 | const file = path.resolve(__dirname, './test/echo.js'); 71 | 72 | const url = new URL(`file://${file}`); 73 | worker = new DenoWorker(url, { 74 | permissions: { 75 | allowRead: [file], 76 | }, 77 | }); 78 | 79 | let ret: any; 80 | let resolve: any; 81 | let promise = new Promise((res, rej) => { 82 | resolve = res; 83 | }); 84 | worker.onmessage = (e) => { 85 | ret = e.data; 86 | resolve(); 87 | }; 88 | 89 | worker.postMessage({ 90 | type: 'echo', 91 | message: 'Hello', 92 | }); 93 | 94 | await promise; 95 | 96 | expect(ret).toEqual({ 97 | type: 'echo', 98 | message: 'Hello', 99 | }); 100 | }); 101 | 102 | it('should be able to specify additional network addresses to allow', async () => { 103 | worker = new DenoWorker(echoScript, { 104 | permissions: { 105 | allowNet: [`https://google.com`], 106 | }, 107 | }); 108 | 109 | let ret: any; 110 | let resolve: any; 111 | let promise = new Promise((res, rej) => { 112 | resolve = res; 113 | }); 114 | worker.onmessage = (e) => { 115 | ret = e.data; 116 | resolve(); 117 | }; 118 | 119 | worker.postMessage({ 120 | type: 'echo', 121 | message: 'Hello', 122 | }); 123 | 124 | await promise; 125 | 126 | expect(ret).toEqual({ 127 | type: 'echo', 128 | message: 'Hello', 129 | }); 130 | }); 131 | 132 | it('should be able to specify network addresses to block', async () => { 133 | const host = `example.com`; 134 | 135 | worker = new DenoWorker(fetchScript, { 136 | permissions: { 137 | allowNet: true, 138 | denyNet: [host], 139 | }, 140 | }); 141 | 142 | let ret: any; 143 | let resolve: any; 144 | let promise = new Promise((res, rej) => { 145 | resolve = res; 146 | }); 147 | worker.onmessage = (e) => { 148 | ret = e.data; 149 | resolve(); 150 | }; 151 | 152 | worker.postMessage({ 153 | type: 'fetch', 154 | url: `https://${host}`, 155 | }); 156 | 157 | await promise; 158 | 159 | expect(ret).toMatchObject({ 160 | type: 'error', 161 | }); 162 | }); 163 | 164 | it('should call onexit when the script fails', async () => { 165 | worker = new DenoWorker(failScript); 166 | 167 | let exitCode: number; 168 | let exitSignal: string; 169 | let resolve: any; 170 | let promise = new Promise((res, rej) => { 171 | resolve = res; 172 | }); 173 | worker.onexit = (code, status) => { 174 | exitCode = code; 175 | exitSignal = status; 176 | resolve(); 177 | }; 178 | 179 | let ret: any; 180 | worker.onmessage = (e) => { 181 | ret = e.data; 182 | }; 183 | 184 | await promise; 185 | 186 | expect(ret).toBeUndefined(); 187 | 188 | const isWindows = /^win/.test(process.platform); 189 | if (isWindows) { 190 | expect(exitCode).toBe(1); 191 | expect(exitSignal).toBe(null); 192 | } else { 193 | expect(exitCode).toBe(1); 194 | expect(exitSignal).toBe(null); 195 | } 196 | }); 197 | 198 | describe('denoUnstable', async () => { 199 | afterEach(() => { 200 | jest.clearAllMocks(); 201 | }); 202 | 203 | it('should not include the --unstable flag by default', async () => { 204 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 205 | 206 | worker = new DenoWorker(echoScript); 207 | 208 | let resolve: any; 209 | let promise = new Promise((res, rej) => { 210 | resolve = res; 211 | }); 212 | worker.onmessage = (e) => { 213 | resolve(); 214 | }; 215 | 216 | worker.postMessage({ 217 | type: 'echo', 218 | message: 'Hello', 219 | }); 220 | 221 | await promise; 222 | 223 | const call = spawnSpy.mock.calls[0]; 224 | const [_deno, args] = call; 225 | expect(args).not.toContain('--unstable'); 226 | }); 227 | 228 | it('should not include the --unstable flag by when denoUnstable is false', async () => { 229 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 230 | 231 | worker = new DenoWorker(echoScript, { denoUnstable: false }); 232 | 233 | let resolve: any; 234 | let promise = new Promise((res, rej) => { 235 | resolve = res; 236 | }); 237 | worker.onmessage = (e) => { 238 | resolve(); 239 | }; 240 | 241 | worker.postMessage({ 242 | type: 'echo', 243 | message: 'Hello', 244 | }); 245 | 246 | await promise; 247 | 248 | const call = spawnSpy.mock.calls[0]; 249 | const [_deno, args] = call; 250 | expect(args).not.toContain('--unstable'); 251 | }); 252 | 253 | it('should include the --unstable flag when denoUnstable is true', async () => { 254 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 255 | 256 | worker = new DenoWorker(echoScript, { denoUnstable: true }); 257 | 258 | let resolve: any; 259 | let promise = new Promise((res, rej) => { 260 | resolve = res; 261 | }); 262 | worker.onmessage = (e) => { 263 | resolve(); 264 | }; 265 | 266 | worker.postMessage({ 267 | type: 'echo', 268 | message: 'Hello', 269 | }); 270 | 271 | await promise; 272 | 273 | const call = spawnSpy.mock.calls[0]; 274 | const [_deno, args] = call; 275 | expect(args).toContain('--unstable'); 276 | }); 277 | 278 | it('should allow fine-grained unstable with an object parameter', async () => { 279 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 280 | 281 | worker = new DenoWorker(echoScript, { 282 | denoUnstable: { 283 | temporal: true, 284 | broadcastChannel: true, 285 | }, 286 | }); 287 | 288 | let resolve: any; 289 | let promise = new Promise((res, rej) => { 290 | resolve = res; 291 | }); 292 | worker.onmessage = (e) => { 293 | resolve(); 294 | }; 295 | 296 | worker.postMessage({ 297 | type: 'echo', 298 | message: 'Hello', 299 | }); 300 | 301 | await promise; 302 | 303 | const call = spawnSpy.mock.calls[0]; 304 | const [_deno, args] = call; 305 | expect(args).toContain('--unstable-temporal'); 306 | expect(args).toContain('--unstable-broadcast-channel'); 307 | }); 308 | }); 309 | 310 | describe('unsafelyIgnoreCertificateErrors', async () => { 311 | afterEach(() => { 312 | jest.clearAllMocks(); 313 | }); 314 | 315 | it('supports the --unsafely-ignore-certificate-errors flag', async () => { 316 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 317 | 318 | worker = new DenoWorker( 319 | `self.onmessage = (e) => { 320 | if (e.data.type === 'echo') { 321 | self.postMessage('hi'); 322 | } 323 | };`, 324 | { 325 | unsafelyIgnoreCertificateErrors: true, 326 | } 327 | ); 328 | 329 | let resolve: any; 330 | let promise = new Promise((res, rej) => { 331 | resolve = res; 332 | }); 333 | worker.onmessage = (e) => { 334 | resolve(e); 335 | }; 336 | 337 | worker.postMessage({ 338 | type: 'echo', 339 | }); 340 | 341 | expect(await promise).toEqual({ data: 'hi' }); 342 | 343 | const call = spawnSpy.mock.calls[0]; 344 | const [_deno, args] = call; 345 | expect(args).toContain(`--unsafely-ignore-certificate-errors`); 346 | }); 347 | }); 348 | 349 | describe('location', async () => { 350 | afterEach(() => { 351 | jest.clearAllMocks(); 352 | }); 353 | 354 | it('supports the --location flag to specify location.href', async () => { 355 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 356 | const LOCATION = 'https://xxx.com/'; 357 | 358 | worker = new DenoWorker( 359 | `self.onmessage = (e) => { 360 | if (e.data.type === 'echo') { 361 | self.postMessage(location.href); 362 | } 363 | };`, 364 | { 365 | location: LOCATION, 366 | } 367 | ); 368 | 369 | let resolve: any; 370 | let promise = new Promise((res, rej) => { 371 | resolve = res; 372 | }); 373 | worker.onmessage = (e) => { 374 | resolve(e); 375 | }; 376 | 377 | worker.postMessage({ 378 | type: 'echo', 379 | }); 380 | 381 | expect(await promise).toEqual({ data: LOCATION }); 382 | 383 | const call = spawnSpy.mock.calls[0]; 384 | const [_deno, args] = call; 385 | expect(args).toContain(`--location=${LOCATION}`); 386 | }); 387 | }); 388 | 389 | describe('denoCachedOnly', async () => { 390 | afterEach(() => { 391 | jest.clearAllMocks(); 392 | }); 393 | 394 | it('should not include the --cached-only flag by default', async () => { 395 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 396 | 397 | worker = new DenoWorker(echoScript); 398 | 399 | let resolve: any; 400 | let promise = new Promise((res, rej) => { 401 | resolve = res; 402 | }); 403 | worker.onmessage = (e) => { 404 | resolve(); 405 | }; 406 | 407 | worker.postMessage({ 408 | type: 'echo', 409 | message: 'Hello', 410 | }); 411 | 412 | await promise; 413 | 414 | const call = spawnSpy.mock.calls[0]; 415 | const [_deno, args] = call; 416 | expect(args).not.toContain('--cached-only'); 417 | }); 418 | 419 | it('should not include the --cached-only flag by when denoCachedOnly is false', async () => { 420 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 421 | 422 | worker = new DenoWorker(echoScript, { denoCachedOnly: false }); 423 | 424 | let resolve: any; 425 | let promise = new Promise((res, rej) => { 426 | resolve = res; 427 | }); 428 | worker.onmessage = (e) => { 429 | resolve(); 430 | }; 431 | 432 | worker.postMessage({ 433 | type: 'echo', 434 | message: 'Hello', 435 | }); 436 | 437 | await promise; 438 | 439 | const call = spawnSpy.mock.calls[0]; 440 | const [_deno, args] = call; 441 | expect(args).not.toContain('--cached-only'); 442 | }); 443 | 444 | it('should include the --cached-only flag when denoCachedOnly is true', async () => { 445 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 446 | 447 | worker = new DenoWorker(echoScript, { denoCachedOnly: true }); 448 | 449 | let resolve: any; 450 | let promise = new Promise((res, rej) => { 451 | resolve = res; 452 | }); 453 | worker.onmessage = (e) => { 454 | resolve(); 455 | }; 456 | 457 | worker.postMessage({ 458 | type: 'echo', 459 | message: 'Hello', 460 | }); 461 | 462 | await promise; 463 | 464 | const call = spawnSpy.mock.calls[0]; 465 | const [_deno, args] = call; 466 | expect(args).toContain('--cached-only'); 467 | }); 468 | }); 469 | 470 | describe('denoNoCheck', async () => { 471 | afterEach(() => { 472 | jest.clearAllMocks(); 473 | }); 474 | 475 | it('should not include the --no-check flag by default', async () => { 476 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 477 | 478 | worker = new DenoWorker(echoScript); 479 | 480 | let resolve: any; 481 | let promise = new Promise((res, rej) => { 482 | resolve = res; 483 | }); 484 | worker.onmessage = (e) => { 485 | resolve(); 486 | }; 487 | 488 | worker.postMessage({ 489 | type: 'echo', 490 | message: 'Hello', 491 | }); 492 | 493 | await promise; 494 | 495 | const call = spawnSpy.mock.calls[0]; 496 | const [_deno, args] = call; 497 | expect(args).not.toContain('--no-check'); 498 | }); 499 | 500 | it('should not include the --no-check flag by when denoNoCheck is false', async () => { 501 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 502 | 503 | worker = new DenoWorker(echoScript, { denoNoCheck: false }); 504 | 505 | let resolve: any; 506 | let promise = new Promise((res, rej) => { 507 | resolve = res; 508 | }); 509 | worker.onmessage = (e) => { 510 | resolve(); 511 | }; 512 | 513 | worker.postMessage({ 514 | type: 'echo', 515 | message: 'Hello', 516 | }); 517 | 518 | await promise; 519 | 520 | const call = spawnSpy.mock.calls[0]; 521 | const [_deno, args] = call; 522 | expect(args).not.toContain('--no-check'); 523 | }); 524 | 525 | it('should include the --no-check flag when denoNoCheck is true', async () => { 526 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 527 | 528 | worker = new DenoWorker(echoScript, { denoNoCheck: true }); 529 | 530 | let resolve: any; 531 | let promise = new Promise((res, rej) => { 532 | resolve = res; 533 | }); 534 | worker.onmessage = (e) => { 535 | resolve(); 536 | }; 537 | 538 | worker.postMessage({ 539 | type: 'echo', 540 | message: 'Hello', 541 | }); 542 | 543 | await promise; 544 | 545 | const call = spawnSpy.mock.calls[0]; 546 | const [_deno, args] = call; 547 | expect(args).toContain('--no-check'); 548 | }); 549 | }); 550 | 551 | describe('denoImportMapPath', async () => { 552 | afterEach(() => { 553 | jest.clearAllMocks(); 554 | }); 555 | 556 | it('should not include --import-map by default', async () => { 557 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 558 | 559 | worker = new DenoWorker(echoScript); 560 | 561 | let resolve: any; 562 | let promise = new Promise((res, rej) => { 563 | resolve = res; 564 | }); 565 | worker.onmessage = (e) => { 566 | resolve(); 567 | }; 568 | 569 | worker.postMessage({ 570 | type: 'echo', 571 | message: 'Hello', 572 | }); 573 | 574 | await promise; 575 | 576 | const call = spawnSpy.mock.calls[0]; 577 | const [_deno, args] = call; 578 | expect(args).not.toContain('--import-map'); 579 | }); 580 | 581 | it('should not set --import-map by when denoImportMapPath is empty', async () => { 582 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 583 | 584 | worker = new DenoWorker(echoScript, { denoImportMapPath: '' }); 585 | 586 | let resolve: any; 587 | let promise = new Promise((res, rej) => { 588 | resolve = res; 589 | }); 590 | worker.onmessage = (e) => { 591 | resolve(); 592 | }; 593 | 594 | worker.postMessage({ 595 | type: 'echo', 596 | message: 'Hello', 597 | }); 598 | 599 | await promise; 600 | 601 | const call = spawnSpy.mock.calls[0]; 602 | const [_deno, args] = call; 603 | expect(args).not.toContain('--import-map'); 604 | }); 605 | 606 | it('should set --import-map when denoImportMapPath is nonempty', async () => { 607 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 608 | const importMapPath = path.resolve( 609 | './src/test/import_map.json' 610 | ); 611 | worker = new DenoWorker(echoScript, { 612 | denoImportMapPath: importMapPath, 613 | }); 614 | 615 | let resolve: any; 616 | let promise = new Promise((res, rej) => { 617 | resolve = res; 618 | }); 619 | worker.onmessage = (e) => { 620 | resolve(); 621 | }; 622 | 623 | worker.postMessage({ 624 | type: 'echo', 625 | message: 'Hello', 626 | }); 627 | 628 | await promise; 629 | 630 | const call = spawnSpy.mock.calls[0]; 631 | const [_deno, args] = call; 632 | expect(args).toContain(`--import-map=${importMapPath}`); 633 | }); 634 | }); 635 | 636 | describe('denoLockFilePath', async () => { 637 | /* 638 | * generateLockFile() shells out to deno to create a lock file from index.ts`. This lock file is created 639 | * to be a temporary file (stored in the gitignored ./tmp) It returns the fully qualified path to the temp 640 | * file as a string. Cleanup is to handled by the test cleanup. 641 | */ 642 | function generateLockFile(): Promise { 643 | const tmpDirPath = path.resolve('./tmp'); 644 | if (!existsSync(tmpDirPath)) { 645 | mkdirSync(tmpDirPath); 646 | } 647 | const lockFileName = randomBytes(32).toString('hex'); 648 | const lockFilePath = path.join(tmpDirPath, lockFileName); 649 | lockFiles.add(lockFilePath); 650 | 651 | const denoIndexPath = path.resolve('./deno/index.ts'); 652 | const process = child_process.exec( 653 | `deno cache --lock=${lockFilePath} --lock-write ${denoIndexPath}` 654 | ); 655 | const promise = new Promise((res) => { 656 | process.on('exit', () => res(lockFilePath)); 657 | }); 658 | return promise; 659 | } 660 | 661 | let lockFiles: Set; 662 | beforeEach(() => { 663 | lockFiles = new Set(); 664 | }); 665 | 666 | afterEach(() => { 667 | lockFiles.forEach((file) => unlinkSync(file)); 668 | jest.clearAllMocks(); 669 | }); 670 | 671 | it('should not include --lock by default', async () => { 672 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 673 | 674 | worker = new DenoWorker(echoScript); 675 | 676 | let resolve: any; 677 | let promise = new Promise((res, rej) => { 678 | resolve = res; 679 | }); 680 | worker.onmessage = (e) => { 681 | resolve(); 682 | }; 683 | 684 | worker.postMessage({ 685 | type: 'echo', 686 | message: 'Hello', 687 | }); 688 | 689 | await promise; 690 | 691 | const call = spawnSpy.mock.calls[0]; 692 | const [_deno, args] = call; 693 | expect(args).not.toContain('--lock'); 694 | }); 695 | 696 | it('should not set --lock by when denoLockFilePath is empty', async () => { 697 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 698 | 699 | worker = new DenoWorker(echoScript, { denoLockFilePath: '' }); 700 | 701 | let resolve: any; 702 | let promise = new Promise((res, rej) => { 703 | resolve = res; 704 | }); 705 | worker.onmessage = (e) => { 706 | resolve(); 707 | }; 708 | 709 | worker.postMessage({ 710 | type: 'echo', 711 | message: 'Hello', 712 | }); 713 | 714 | await promise; 715 | 716 | const call = spawnSpy.mock.calls[0]; 717 | const [_deno, args] = call; 718 | expect(args).not.toContain('--lock'); 719 | }); 720 | 721 | it('should set --lock when denoLockFilePath is nonempty', async () => { 722 | const lockFilePath = await generateLockFile(); 723 | 724 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 725 | 726 | worker = new DenoWorker(echoScript, { 727 | denoLockFilePath: lockFilePath, 728 | }); 729 | 730 | let resolve: any; 731 | let promise = new Promise((res, rej) => { 732 | resolve = res; 733 | }); 734 | worker.onmessage = (e) => { 735 | resolve(); 736 | }; 737 | 738 | worker.postMessage({ 739 | type: 'echo', 740 | message: 'Hello', 741 | }); 742 | 743 | await promise; 744 | worker.terminate(); 745 | 746 | const call = spawnSpy.mock.calls[0]; 747 | const [_deno, args] = call; 748 | expect(args).toContain(`--lock=${lockFilePath}`); 749 | }); 750 | }); 751 | 752 | describe('denoConfig', async () => { 753 | afterEach(() => { 754 | jest.clearAllMocks(); 755 | }); 756 | 757 | it('should not include --config by default', async () => { 758 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 759 | 760 | worker = new DenoWorker(echoScript); 761 | 762 | let resolve: any; 763 | let promise = new Promise((res, rej) => { 764 | resolve = res; 765 | }); 766 | worker.onmessage = (e) => { 767 | resolve(); 768 | }; 769 | 770 | worker.postMessage({ 771 | type: 'echo', 772 | message: 'Hello', 773 | }); 774 | 775 | await promise; 776 | 777 | const call = spawnSpy.mock.calls[0]; 778 | const [_deno, args] = call; 779 | expect(args).not.toContain('--config'); 780 | 781 | // should pass --no-config when denoConfig is omitted 782 | expect(args).toContain('--no-config'); 783 | }); 784 | 785 | it('should not set --config by when denoConfig is empty', async () => { 786 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 787 | 788 | worker = new DenoWorker(echoScript, { denoConfig: '' }); 789 | 790 | let resolve: any; 791 | let promise = new Promise((res, rej) => { 792 | resolve = res; 793 | }); 794 | worker.onmessage = (e) => { 795 | resolve(); 796 | }; 797 | 798 | worker.postMessage({ 799 | type: 'echo', 800 | message: 'Hello', 801 | }); 802 | 803 | await promise; 804 | 805 | const call = spawnSpy.mock.calls[0]; 806 | const [_deno, args] = call; 807 | expect(args).not.toContain('--config'); 808 | 809 | // should pass --no-config when denoConfig is empty 810 | expect(args).toContain('--no-config'); 811 | }); 812 | 813 | it('should set --config when denoConfig is nonempty', async () => { 814 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 815 | const configPath = path.resolve('./src/test/deno.json'); 816 | worker = new DenoWorker(echoScript, { 817 | denoConfig: configPath, 818 | }); 819 | 820 | let resolve: any; 821 | let promise = new Promise((res, rej) => { 822 | resolve = res; 823 | }); 824 | worker.onmessage = (e) => { 825 | resolve(); 826 | }; 827 | 828 | worker.postMessage({ 829 | type: 'echo', 830 | message: 'Hello', 831 | }); 832 | 833 | await promise; 834 | 835 | const call = spawnSpy.mock.calls[0]; 836 | const [_deno, args] = call; 837 | expect(args).toContain(`--config=${configPath}`); 838 | }); 839 | }); 840 | 841 | describe('denoExtraFlags', async () => { 842 | afterEach(() => { 843 | jest.clearAllMocks(); 844 | }); 845 | 846 | it('should include the given extra flags', async () => { 847 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 848 | 849 | worker = new DenoWorker(echoScript, { 850 | denoExtraFlags: ['--unstable', '--inspect'], 851 | }); 852 | 853 | let resolve: any; 854 | let promise = new Promise((res, rej) => { 855 | resolve = res; 856 | }); 857 | worker.onmessage = (e) => { 858 | resolve(); 859 | }; 860 | 861 | worker.postMessage({ 862 | type: 'echo', 863 | message: 'Hello', 864 | }); 865 | 866 | await promise; 867 | 868 | const call = spawnSpy.mock.calls[0]; 869 | const [_deno, args] = call; 870 | expect(args).toContain('--unstable'); 871 | expect(args).toContain('--inspect'); 872 | }); 873 | }); 874 | 875 | describe('denoV8Flags', async () => { 876 | afterEach(() => { 877 | jest.clearAllMocks(); 878 | }); 879 | 880 | it('should not include --v8-flags by default', async () => { 881 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 882 | 883 | worker = new DenoWorker(echoScript); 884 | 885 | let resolve: any; 886 | let promise = new Promise((res, rej) => { 887 | resolve = res; 888 | }); 889 | worker.onmessage = (e) => { 890 | resolve(); 891 | }; 892 | 893 | worker.postMessage({ 894 | type: 'echo', 895 | message: 'Hello', 896 | }); 897 | 898 | await promise; 899 | 900 | const call = spawnSpy.mock.calls[0]; 901 | const [_deno, args] = call; 902 | expect(args).not.toContain('--v8-flags'); 903 | }); 904 | 905 | it('should not set --v8-flags by when denoV8Flags is empty', async () => { 906 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 907 | 908 | worker = new DenoWorker(echoScript, { denoV8Flags: [] }); 909 | 910 | let resolve: any; 911 | let promise = new Promise((res, rej) => { 912 | resolve = res; 913 | }); 914 | worker.onmessage = (e) => { 915 | resolve(); 916 | }; 917 | 918 | worker.postMessage({ 919 | type: 'echo', 920 | message: 'Hello', 921 | }); 922 | 923 | await promise; 924 | 925 | const call = spawnSpy.mock.calls[0]; 926 | const [_deno, args] = call; 927 | expect(args).not.toContain('--v8-flags'); 928 | }); 929 | 930 | it('should set --v8-flags denoV8Flags has a single flag set ', async () => { 931 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 932 | worker = new DenoWorker(echoScript, { 933 | denoV8Flags: ['--max-old-space-size=2048'], 934 | }); 935 | 936 | let resolve: any; 937 | let promise = new Promise((res, rej) => { 938 | resolve = res; 939 | }); 940 | worker.onmessage = (e) => { 941 | resolve(); 942 | }; 943 | 944 | worker.postMessage({ 945 | type: 'echo', 946 | message: 'Hello', 947 | }); 948 | 949 | await promise; 950 | 951 | const call = spawnSpy.mock.calls[0]; 952 | const [_deno, args] = call; 953 | expect(args).toContain(`--v8-flags=--max-old-space-size=2048`); 954 | }); 955 | 956 | it('should set --v8-flags when denoV8Flags has multiple flags set', async () => { 957 | const spawnSpy = jest.spyOn(child_process, 'spawn'); 958 | worker = new DenoWorker(echoScript, { 959 | denoV8Flags: [ 960 | '--max-old-space-size=2048', 961 | '--max-heap-size=2048', 962 | ], 963 | }); 964 | 965 | let resolve: any; 966 | let promise = new Promise((res, rej) => { 967 | resolve = res; 968 | }); 969 | worker.onmessage = (e) => { 970 | resolve(); 971 | }; 972 | 973 | worker.postMessage({ 974 | type: 'echo', 975 | message: 'Hello', 976 | }); 977 | 978 | await promise; 979 | 980 | const call = spawnSpy.mock.calls[0]; 981 | const [_deno, args] = call; 982 | expect(args).toContain( 983 | `--v8-flags=--max-old-space-size=2048,--max-heap-size=2048` 984 | ); 985 | }); 986 | }); 987 | 988 | describe('spawnOptions', async () => { 989 | it('should be able to pass spawn options', async () => { 990 | worker = new DenoWorker(envScript, { 991 | permissions: { 992 | allowEnv: true, 993 | }, 994 | spawnOptions: { 995 | env: { 996 | ...process.env, 997 | HELLO: 'WORLD', 998 | }, 999 | }, 1000 | }); 1001 | 1002 | let ret: any; 1003 | let resolve: any; 1004 | let promise = new Promise((res, rej) => { 1005 | resolve = res; 1006 | }); 1007 | worker.onmessage = (e) => { 1008 | ret = e.data; 1009 | resolve(); 1010 | }; 1011 | 1012 | worker.postMessage({ 1013 | type: 'env', 1014 | name: 'HELLO', 1015 | }); 1016 | 1017 | await promise; 1018 | 1019 | expect(ret).toEqual('WORLD'); 1020 | }); 1021 | }); 1022 | }); 1023 | 1024 | describe('data types', () => { 1025 | let ret: any; 1026 | let resolve: any; 1027 | let promise: Promise; 1028 | beforeEach(() => { 1029 | worker = new DenoWorker(echoScript); 1030 | promise = new Promise((res, rej) => { 1031 | resolve = res; 1032 | }); 1033 | 1034 | worker.onmessage = (e) => { 1035 | ret = e.data; 1036 | resolve(); 1037 | }; 1038 | }); 1039 | 1040 | it('should be able pass BigInt values', async () => { 1041 | worker.postMessage({ 1042 | type: 'echo', 1043 | num: BigInt(9007199254740991), 1044 | }); 1045 | 1046 | await promise; 1047 | 1048 | expect(ret).toEqual({ 1049 | type: 'echo', 1050 | num: BigInt(9007199254740991), 1051 | }); 1052 | }); 1053 | 1054 | it('should be able pass Date values', async () => { 1055 | worker.postMessage({ 1056 | type: 'echo', 1057 | time: new Date(2020, 7, 21, 7, 54, 32, 412), 1058 | }); 1059 | 1060 | await promise; 1061 | 1062 | expect(ret).toEqual({ 1063 | type: 'echo', 1064 | time: new Date(2020, 7, 21, 7, 54, 32, 412), 1065 | }); 1066 | }); 1067 | 1068 | it('should be able pass RegExp values', async () => { 1069 | worker.postMessage({ 1070 | type: 'echo', 1071 | regex: new RegExp('^hellosworld$'), 1072 | }); 1073 | 1074 | await promise; 1075 | 1076 | expect(ret).toEqual({ 1077 | type: 'echo', 1078 | regex: new RegExp('^hellosworld$'), 1079 | }); 1080 | }); 1081 | 1082 | it('should be able pass Map values', async () => { 1083 | worker.postMessage({ 1084 | type: 'echo', 1085 | map: new Map([ 1086 | ['key', 'value'], 1087 | ['key2', 'value2'], 1088 | ]), 1089 | }); 1090 | 1091 | await promise; 1092 | 1093 | expect(ret).toEqual({ 1094 | type: 'echo', 1095 | map: new Map([ 1096 | ['key', 'value'], 1097 | ['key2', 'value2'], 1098 | ]), 1099 | }); 1100 | }); 1101 | 1102 | it('should be able pass Set values', async () => { 1103 | worker.postMessage({ 1104 | type: 'echo', 1105 | set: new Set(['abc', 'def']), 1106 | }); 1107 | 1108 | await promise; 1109 | 1110 | expect(ret).toEqual({ 1111 | type: 'echo', 1112 | set: new Set(['abc', 'def']), 1113 | }); 1114 | }); 1115 | 1116 | it('should be able pass Error values', async () => { 1117 | worker.postMessage({ 1118 | type: 'echo', 1119 | error: new Error('my error'), 1120 | }); 1121 | 1122 | await promise; 1123 | 1124 | expect(ret).toEqual({ 1125 | type: 'echo', 1126 | error: new Error('my error'), 1127 | }); 1128 | }); 1129 | }); 1130 | 1131 | describe('transfer', () => { 1132 | beforeEach(() => { 1133 | worker = new DenoWorker(pingScript); 1134 | }); 1135 | 1136 | it('should be able to pass a MessagePort to the worker', async () => { 1137 | let resolve: any; 1138 | let promise = new Promise((res, rej) => { 1139 | resolve = res; 1140 | }); 1141 | 1142 | let channel = new MessageChannel(); 1143 | channel.port1.onmessage = (e) => { 1144 | resolve(e.data); 1145 | }; 1146 | worker.postMessage( 1147 | { 1148 | type: 'port', 1149 | port: channel.port2, 1150 | }, 1151 | [channel.port2] 1152 | ); 1153 | 1154 | channel.port1.postMessage('ping'); 1155 | 1156 | let ret = await promise; 1157 | 1158 | expect(ret).toEqual('pong'); 1159 | }); 1160 | 1161 | it('should be able to recieve a MessagePort from the worker', async () => { 1162 | let resolve: any; 1163 | let promise1 = new Promise((res, rej) => { 1164 | resolve = res; 1165 | }); 1166 | 1167 | worker.onmessage = (e) => { 1168 | resolve(e.data); 1169 | }; 1170 | 1171 | worker.postMessage({ 1172 | type: 'request_port', 1173 | }); 1174 | 1175 | let ret = await promise1; 1176 | 1177 | let promise2 = new Promise((res, rej) => { 1178 | resolve = res; 1179 | }); 1180 | 1181 | expect(ret.type).toEqual('port'); 1182 | expect(ret.port).toBeInstanceOf(MessagePort); 1183 | 1184 | // Ports from the worker should have String IDs 1185 | // so they don't interfere with the ones generated from the host. 1186 | expect(typeof ret.port.channelID).toBe('string'); 1187 | 1188 | ret.port.onmessage = (e: any) => { 1189 | resolve(e.data); 1190 | }; 1191 | ret.port.postMessage('ping'); 1192 | 1193 | let final = await promise2; 1194 | 1195 | expect(final).toEqual('pong'); 1196 | }); 1197 | }); 1198 | 1199 | describe('terminate()', () => { 1200 | it('should kill the deno process when terminated immediately', async () => { 1201 | let denoProcesses = await getDenoProcesses(); 1202 | expect(denoProcesses).toEqual([]); 1203 | 1204 | worker = new DenoWorker(echoScript, { 1205 | permissions: { 1206 | allowNet: [`https://google.com`], 1207 | }, 1208 | }); 1209 | worker.terminate(); 1210 | 1211 | denoProcesses = await getDenoProcesses(); 1212 | expect(denoProcesses).toEqual([]); 1213 | }); 1214 | 1215 | it('should kill the deno process when terminated after the initial connection', async () => { 1216 | let denoProcesses = await getDenoProcesses(); 1217 | expect(denoProcesses).toEqual([]); 1218 | 1219 | worker = new DenoWorker(echoScript, { 1220 | permissions: { 1221 | allowNet: [`https://google.com`], 1222 | }, 1223 | }); 1224 | 1225 | let ret: any; 1226 | let resolve: any; 1227 | let promise = new Promise((res, rej) => { 1228 | resolve = res; 1229 | }); 1230 | worker.onmessage = (e) => { 1231 | ret = e.data; 1232 | resolve(); 1233 | }; 1234 | 1235 | worker.postMessage({ 1236 | type: 'echo', 1237 | message: 'Hello', 1238 | }); 1239 | 1240 | await promise; 1241 | 1242 | worker.terminate(); 1243 | 1244 | denoProcesses = await getDenoProcesses(); 1245 | expect(denoProcesses).toEqual([]); 1246 | }); 1247 | 1248 | it('should kill the deno process when terminated while sending data', async () => { 1249 | let denoProcesses = await getDenoProcesses(); 1250 | expect(denoProcesses).toEqual([]); 1251 | 1252 | worker = new DenoWorker(echoScript, { 1253 | permissions: { 1254 | allowNet: [`https://google.com`], 1255 | }, 1256 | }); 1257 | 1258 | let ret: any; 1259 | let resolve: any; 1260 | let promise = new Promise((res, rej) => { 1261 | resolve = res; 1262 | }); 1263 | worker.onmessage = (e) => { 1264 | ret = e.data; 1265 | resolve(); 1266 | }; 1267 | 1268 | worker.terminate(); 1269 | 1270 | worker.postMessage({ 1271 | type: 'echo', 1272 | message: 'Hello', 1273 | }); 1274 | 1275 | denoProcesses = await getDenoProcesses(); 1276 | expect(denoProcesses).toEqual([]); 1277 | }); 1278 | 1279 | it('should kill the deno process when terminated while recieving data', async () => { 1280 | let denoProcesses = await getDenoProcesses(); 1281 | expect(denoProcesses).toEqual([]); 1282 | 1283 | worker = new DenoWorker(echoScript, { 1284 | permissions: { 1285 | allowNet: [`https://google.com`], 1286 | }, 1287 | }); 1288 | 1289 | let ret: any; 1290 | let resolve: any; 1291 | let promise = new Promise((res, rej) => { 1292 | resolve = res; 1293 | }); 1294 | worker.onmessage = (e) => { 1295 | ret = e.data; 1296 | console.log('Message'); 1297 | resolve(); 1298 | }; 1299 | 1300 | worker.postMessage({ 1301 | type: 'echo', 1302 | message: 'Hello', 1303 | }); 1304 | 1305 | await Promise.resolve(); 1306 | 1307 | worker.terminate(); 1308 | 1309 | denoProcesses = await getDenoProcesses(); 1310 | expect(denoProcesses).toEqual([]); 1311 | }); 1312 | 1313 | it('should kill the deno process when terminated while the script is in an infinite loop', async () => { 1314 | let denoProcesses = await getDenoProcesses(); 1315 | expect(denoProcesses).toEqual([]); 1316 | 1317 | worker = new DenoWorker(infiniteScript, { 1318 | permissions: { 1319 | allowNet: [`https://google.com`], 1320 | }, 1321 | }); 1322 | 1323 | let ret: any; 1324 | let resolve: any; 1325 | let promise = new Promise((res, rej) => { 1326 | resolve = res; 1327 | }); 1328 | worker.onmessage = (e) => { 1329 | ret = e.data; 1330 | resolve(); 1331 | }; 1332 | 1333 | await promise; 1334 | 1335 | worker.postMessage({ 1336 | type: 'echo', 1337 | message: 'Hello', 1338 | }); 1339 | 1340 | worker.terminate(); 1341 | 1342 | denoProcesses = await getDenoProcesses(); 1343 | expect(denoProcesses).toEqual([]); 1344 | }); 1345 | 1346 | it('should call onexit', async () => { 1347 | let denoProcesses = await getDenoProcesses(); 1348 | expect(denoProcesses).toEqual([]); 1349 | 1350 | worker = new DenoWorker(echoScript, { 1351 | permissions: { 1352 | allowNet: [`https://google.com`], 1353 | }, 1354 | }); 1355 | let exitCode: number; 1356 | let exitSignal: string; 1357 | worker.onexit = (code, signal) => { 1358 | exitCode = code; 1359 | exitSignal = signal; 1360 | }; 1361 | 1362 | let ret: any; 1363 | let resolve: any; 1364 | let promise = new Promise((res, rej) => { 1365 | resolve = res; 1366 | }); 1367 | worker.onmessage = (e) => { 1368 | ret = e.data; 1369 | resolve(); 1370 | }; 1371 | 1372 | worker.postMessage({ 1373 | type: 'echo', 1374 | message: 'Hello', 1375 | }); 1376 | 1377 | await promise; 1378 | 1379 | expect(exitCode).toBeUndefined(); 1380 | expect(exitSignal).toBeUndefined(); 1381 | 1382 | worker.terminate(); 1383 | 1384 | denoProcesses = await getDenoProcesses(); 1385 | expect(denoProcesses).toEqual([]); 1386 | 1387 | const isWindows = /^win/.test(process.platform); 1388 | if (isWindows) { 1389 | expect(exitCode).toBe(1); 1390 | expect(exitSignal).toBe(null); 1391 | } else { 1392 | expect(exitCode).toBe(null); 1393 | expect(exitSignal).toBe('SIGKILL'); 1394 | } 1395 | }); 1396 | 1397 | it('should gracefully handle Deno out-of-memory', async () => { 1398 | let denoProcesses = await getDenoProcesses(); 1399 | expect(denoProcesses).toEqual([]); 1400 | worker = new DenoWorker(memoryCrashFile, { 1401 | denoV8Flags: ['--max-heap-size=10'], 1402 | logStdout: true, 1403 | }); 1404 | worker.postMessage({}); 1405 | 1406 | const exitValues = await new Promise< 1407 | [number | null, string | null] 1408 | >((resolve) => { 1409 | worker.onexit = (...args) => resolve(args); 1410 | }); 1411 | 1412 | const isWindows = /^win/.test(process.platform); 1413 | 1414 | if (isWindows) { 1415 | expect(typeof exitValues[0]).toEqual('number'); 1416 | expect(exitValues[1]).toEqual(null); 1417 | } else { 1418 | expect(exitValues).toEqual([null, 'SIGTRAP']); 1419 | } 1420 | 1421 | worker.terminate(); 1422 | }); 1423 | }); 1424 | 1425 | describe('closeSocket()', () => { 1426 | it('should allow natural exit if closeSocket is called after message is received', async () => { 1427 | worker = new DenoWorker(unresolvedPromiseScript); 1428 | 1429 | let resolveExit: any; 1430 | let exit = new Promise((resolve) => { 1431 | resolveExit = resolve; 1432 | }); 1433 | worker.onexit = () => resolveExit(); 1434 | 1435 | await new Promise( 1436 | (resolve) => 1437 | (worker.onmessage = (e) => { 1438 | worker.closeSocket(); 1439 | resolve(); 1440 | }) 1441 | ); 1442 | 1443 | await exit; 1444 | }); 1445 | 1446 | it('should allow natural exit if closeSocket is called before socket is open', async () => { 1447 | worker = new DenoWorker(unresolvedPromiseScript); 1448 | 1449 | let resolveExit: any; 1450 | let exit = new Promise((resolve) => { 1451 | resolveExit = resolve; 1452 | }); 1453 | worker.onexit = () => resolveExit(); 1454 | 1455 | worker.closeSocket(); 1456 | 1457 | await exit; 1458 | }); 1459 | }); 1460 | }); 1461 | 1462 | async function getDenoProcesses() { 1463 | const list = await psList(); 1464 | const denoProcesses = list.filter( 1465 | (p) => /^deno/.test(p.name) && p.cmd !== 'deno lsp' 1466 | ); 1467 | return denoProcesses; 1468 | } 1469 | -------------------------------------------------------------------------------- /src/DenoWorker.ts: -------------------------------------------------------------------------------- 1 | import { createServer, Server } from 'http'; 2 | import WebSocket, { Server as WSServer } from 'ws'; 3 | import { resolve } from 'path'; 4 | import { ChildProcess, spawn, SpawnOptions } from 'child_process'; 5 | import { 6 | serializeStructure, 7 | deserializeStructure, 8 | Structure, 9 | } from './StructureClone'; 10 | import { URL } from 'url'; 11 | import process from 'process'; 12 | import { 13 | OnMessageListener, 14 | MessageEvent, 15 | Transferrable, 16 | OnExitListener, 17 | } from './MessageTarget'; 18 | import { MessagePort } from './MessageChannel'; 19 | import { Stream, Readable, Duplex } from 'stream'; 20 | import { forceKill } from './Utils'; 21 | 22 | const DEFAULT_DENO_BOOTSTRAP_SCRIPT_PATH = __dirname.endsWith('src') 23 | ? resolve(__dirname, '../deno/index.ts') 24 | : resolve(__dirname, '../../deno/index.ts'); 25 | 26 | export interface DenoWorkerOptions { 27 | /** 28 | * The path to the executable that should be use when spawning the subprocess. 29 | * Defaults to "deno". 30 | */ 31 | denoExecutable: string; 32 | 33 | /** 34 | * The path to the script that should be used to bootstrap the worker environment in Deno. 35 | * If specified, this script will be used instead of the default bootstrap script. 36 | * Only advanced users should set this. 37 | */ 38 | denoBootstrapScriptPath: string; 39 | 40 | /** 41 | * Whether to reload scripts. 42 | * If given a list of strings then only the specified URLs will be reloaded. 43 | * Defaults to false when NODE_ENV is set to "production" and true otherwise. 44 | */ 45 | reload: boolean | string[]; 46 | 47 | /** 48 | * Whether to log stdout from the worker. 49 | * Defaults to true. 50 | */ 51 | logStdout: boolean; 52 | 53 | /** 54 | * Whether to log stderr from the worker. 55 | * Defaults to true. 56 | */ 57 | logStderr: boolean; 58 | 59 | /** 60 | * Whether to use Deno's unstable features 61 | */ 62 | denoUnstable: 63 | | boolean 64 | | { 65 | /** 66 | * Enable unstable bare node builtins feature 67 | */ 68 | bareNodeBuiltins?: boolean; 69 | 70 | /** 71 | * Enable unstable 'bring your own node_modules' feature 72 | */ 73 | byonm?: boolean; 74 | 75 | /** 76 | * Enable unstable resolving of specifiers by extension probing, 77 | * .js to .ts, and directory probing. 78 | */ 79 | sloppyImports?: boolean; 80 | 81 | /** 82 | * Enable unstable `BroadcastChannel` API 83 | */ 84 | broadcastChannel?: boolean; 85 | 86 | /** 87 | * Enable unstable Deno.cron API 88 | */ 89 | cron?: boolean; 90 | 91 | /** 92 | * Enable unstable FFI APIs 93 | */ 94 | ffi?: boolean; 95 | 96 | /** 97 | * Enable unstable file system APIs 98 | */ 99 | fs?: boolean; 100 | 101 | /** 102 | * Enable unstable HTTP APIs 103 | */ 104 | http?: boolean; 105 | 106 | /** 107 | * Enable unstable Key-Value store APIs 108 | */ 109 | kv?: boolean; 110 | 111 | /** 112 | * Enable unstable net APIs 113 | */ 114 | net?: boolean; 115 | 116 | /** 117 | * Enable unstable Temporal API 118 | */ 119 | temporal?: boolean; 120 | 121 | /** 122 | * Enable unsafe __proto__ support. This is a security risk. 123 | */ 124 | unsafeProto?: boolean; 125 | 126 | /** 127 | * Enable unstable `WebGPU` API 128 | */ 129 | webgpu?: boolean; 130 | 131 | /** 132 | * Enable unstable Web Worker APIs 133 | */ 134 | workerOptions?: boolean; 135 | }; 136 | 137 | /** 138 | * V8 flags to be set when starting Deno 139 | */ 140 | denoV8Flags: string[]; 141 | 142 | /** 143 | * Path where deno can find an import map 144 | */ 145 | denoImportMapPath: string; 146 | 147 | /** 148 | * Path where deno can find a lock file 149 | */ 150 | denoLockFilePath: string; 151 | 152 | /** 153 | * Whether to disable fetching uncached dependencies 154 | */ 155 | denoCachedOnly: boolean; 156 | 157 | /** 158 | * Whether to disable typechecking when starting Deno 159 | */ 160 | denoNoCheck: boolean; 161 | 162 | /** 163 | * The path to the config file that Deno should use. 164 | */ 165 | denoConfig: string; 166 | 167 | /** 168 | * Whether to prevent Deno from loading packages from npm. 169 | * Defaults to true. 170 | */ 171 | denoNoNPM: boolean; 172 | 173 | /** 174 | * Extra flags that should be passed to Deno. 175 | * This may be useful for passing flags that are not supported by this library. 176 | */ 177 | denoExtraFlags: string[]; 178 | 179 | /** 180 | * Allow Deno to make requests to hosts with certificate 181 | * errors. 182 | */ 183 | unsafelyIgnoreCertificateErrors: boolean; 184 | 185 | /** 186 | * Specify the --location flag, which defines location.href. 187 | * This must be a valid URL if provided. 188 | */ 189 | location: string; 190 | 191 | /** 192 | * The permissions that the Deno worker should use. 193 | */ 194 | permissions: { 195 | /** 196 | * Whether to allow all permissions. 197 | * Defaults to false. 198 | */ 199 | allowAll?: boolean; 200 | 201 | /** 202 | * Whether to allow network connnections. 203 | * If given a list of strings then only the specified origins/paths are allowed. 204 | * Defaults to false. 205 | */ 206 | allowNet?: boolean | string[]; 207 | 208 | /** 209 | * Disable network access to provided IP addresses or hostnames. Any addresses 210 | * specified here will be denied access, even if they are specified in 211 | * `allowNet`. Note that deno-vm needs a network connection between the host 212 | * and the guest, so it's not possible to fully disable network access. 213 | */ 214 | denyNet?: string[]; 215 | 216 | /** 217 | * Whether to allow reading from the filesystem. 218 | * If given a list of strings then only the specified file paths are allowed. 219 | * Defaults to false. 220 | */ 221 | allowRead?: boolean | string[]; 222 | 223 | /** 224 | * Whether to allow writing to the filesystem. 225 | * If given a list of strings then only the specified file paths are allowed. 226 | * Defaults to false. 227 | */ 228 | allowWrite?: boolean | string[]; 229 | 230 | /** 231 | * Whether to allow reading environment variables. 232 | * Defaults to false. 233 | */ 234 | allowEnv?: boolean | string[]; 235 | 236 | /** 237 | * Whether to allow running Deno plugins. 238 | * Defaults to false. 239 | */ 240 | allowPlugin?: boolean; 241 | 242 | /** 243 | * Whether to allow running subprocesses. 244 | * Defaults to false. 245 | */ 246 | allowRun?: boolean | string[]; 247 | 248 | /** 249 | * Whether to allow high resolution time measurement. 250 | * Defaults to false. 251 | */ 252 | allowHrtime?: boolean; 253 | }; 254 | 255 | /** 256 | * Options used to spawn the Deno child process 257 | */ 258 | spawnOptions: SpawnOptions; 259 | } 260 | 261 | /** 262 | * The DenoWorker class is a WebWorker-like interface for interacting with Deno. 263 | * 264 | * Because Deno is an isolated environment, this worker gives you the ability to run untrusted JavaScript code without 265 | * potentially compromising your system. 266 | */ 267 | export class DenoWorker { 268 | private _httpServer: Server; 269 | private _server: WSServer; 270 | private _process: ChildProcess; 271 | private _socket: WebSocket; 272 | private _socketClosed: boolean; 273 | private _onmessageListeners: OnMessageListener[]; 274 | private _onexitListeners: OnExitListener[]; 275 | private _available: boolean; 276 | private _pendingMessages: string[]; 277 | private _options: DenoWorkerOptions; 278 | private _ports: Map; 279 | private _terminated: boolean; 280 | private _stdout: Readable; 281 | private _stderr: Readable; 282 | 283 | /** 284 | * Creates a new DenoWorker instance and injects the given script. 285 | * @param script The JavaScript that the worker should be started with. 286 | */ 287 | constructor(script: string | URL, options?: Partial) { 288 | this._onmessageListeners = []; 289 | this._onexitListeners = []; 290 | this._pendingMessages = []; 291 | this._available = false; 292 | this._socketClosed = false; 293 | this._stdout = new Readable(); 294 | this._stdout.setEncoding('utf-8'); 295 | this._stderr = new Readable(); 296 | this._stdout.setEncoding('utf-8'); 297 | this._stderr.setEncoding('utf-8'); 298 | this._options = Object.assign( 299 | { 300 | denoExecutable: 'deno', 301 | denoBootstrapScriptPath: DEFAULT_DENO_BOOTSTRAP_SCRIPT_PATH, 302 | reload: process.env.NODE_ENV !== 'production', 303 | logStdout: true, 304 | logStderr: true, 305 | denoUnstable: false, 306 | location: undefined, 307 | permissions: {}, 308 | denoV8Flags: [], 309 | denoImportMapPath: '', 310 | denoLockFilePath: '', 311 | denoCachedOnly: false, 312 | denoNoCheck: false, 313 | denoConfig: undefined, 314 | denoNoNPM: true, 315 | unsafelyIgnoreCertificateErrors: false, 316 | denoExtraFlags: [], 317 | spawnOptions: {}, 318 | }, 319 | options || {} 320 | ); 321 | 322 | this._ports = new Map(); 323 | this._httpServer = createServer(); 324 | this._server = new WSServer({ 325 | server: this._httpServer, 326 | }); 327 | this._server.on('connection', (socket) => { 328 | if (this._socket) { 329 | socket.close(); 330 | return; 331 | } 332 | if (this._socketClosed) { 333 | socket.close(); 334 | this._socket = null; 335 | return; 336 | } 337 | this._socket = socket; 338 | socket.on('message', (message) => { 339 | if (typeof message === 'string') { 340 | const structuredData = JSON.parse(message) as Structure; 341 | const channel = structuredData.channel; 342 | const deserialized = deserializeStructure(structuredData); 343 | const data = deserialized.data; 344 | 345 | if (deserialized.transferred) { 346 | this._handleTransferrables(deserialized.transferred); 347 | } 348 | 349 | if (!this._available && data && data.type === 'init') { 350 | this._available = true; 351 | let pendingMessages = this._pendingMessages; 352 | this._pendingMessages = []; 353 | for (let message of pendingMessages) { 354 | socket.send(message); 355 | } 356 | } else { 357 | if ( 358 | typeof channel === 'number' || 359 | typeof channel === 'string' 360 | ) { 361 | const portData = this._ports.get(channel); 362 | if (portData) { 363 | portData.recieveData(data); 364 | } 365 | } else { 366 | const event = { 367 | data, 368 | } as MessageEvent; 369 | if (this.onmessage) { 370 | this.onmessage(event); 371 | } 372 | for (let onmessage of this._onmessageListeners) { 373 | onmessage(event); 374 | } 375 | } 376 | } 377 | } 378 | }); 379 | 380 | socket.on('close', () => { 381 | this._available = false; 382 | this._socket = null; 383 | }); 384 | }); 385 | 386 | this._httpServer.listen({ host: '127.0.0.1', port: 0 }, () => { 387 | if (this._terminated) { 388 | this._httpServer.close(); 389 | return; 390 | } 391 | const addr = this._httpServer.address(); 392 | let connectAddress: string; 393 | let allowAddress: string; 394 | if (typeof addr === 'string') { 395 | connectAddress = addr; 396 | } else { 397 | connectAddress = `ws://${addr.address}:${addr.port}`; 398 | allowAddress = `${addr.address}:${addr.port}`; 399 | } 400 | 401 | let scriptArgs: string[]; 402 | 403 | if (typeof script === 'string') { 404 | scriptArgs = ['script', script]; 405 | } else { 406 | scriptArgs = ['import', script.href]; 407 | } 408 | 409 | let runArgs = [] as string[]; 410 | 411 | addOption(runArgs, '--reload', this._options.reload); 412 | if (this._options.denoUnstable === true) { 413 | runArgs.push('--unstable'); 414 | } else if (this._options.denoUnstable) { 415 | for (let [key] of Object.entries( 416 | this._options.denoUnstable 417 | ).filter(([_key, val]) => val)) { 418 | runArgs.push( 419 | `--unstable-${key.replace( 420 | /[A-Z]/g, 421 | (m) => '-' + m.toLowerCase() 422 | )}` 423 | ); 424 | } 425 | } 426 | addOption(runArgs, '--cached-only', this._options.denoCachedOnly); 427 | addOption(runArgs, '--no-check', this._options.denoNoCheck); 428 | if (!this._options.denoConfig) { 429 | addOption(runArgs, '--no-config', true); 430 | } else { 431 | addOption(runArgs, '--config', [this._options.denoConfig]); 432 | } 433 | addOption(runArgs, '--no-npm', this._options.denoNoNPM); 434 | addOption( 435 | runArgs, 436 | '--unsafely-ignore-certificate-errors', 437 | this._options.unsafelyIgnoreCertificateErrors 438 | ); 439 | if (this._options.location) { 440 | addOption(runArgs, '--location', [this._options.location]); 441 | } 442 | 443 | if (this._options.denoV8Flags.length > 0) { 444 | addOption(runArgs, '--v8-flags', this._options.denoV8Flags); 445 | } 446 | 447 | if (this._options.denoImportMapPath) { 448 | addOption(runArgs, '--import-map', [ 449 | this._options.denoImportMapPath, 450 | ]); 451 | } 452 | 453 | if (this._options.denoLockFilePath) { 454 | addOption(runArgs, '--lock', [this._options.denoLockFilePath]); 455 | } 456 | 457 | if (this._options.permissions) { 458 | addOption( 459 | runArgs, 460 | '--allow-all', 461 | this._options.permissions.allowAll 462 | ); 463 | if (!this._options.permissions.allowAll) { 464 | addOption( 465 | runArgs, 466 | '--allow-net', 467 | typeof this._options.permissions.allowNet === 'boolean' 468 | ? this._options.permissions.allowNet 469 | : this._options.permissions.allowNet 470 | ? [ 471 | ...this._options.permissions.allowNet, 472 | allowAddress, 473 | ] 474 | : [allowAddress] 475 | ); 476 | // Ensures the `allowAddress` isn't denied 477 | const deniedAddresses = this._options.permissions.denyNet?.filter( 478 | (address) => address !== allowAddress 479 | ); 480 | addOption( 481 | runArgs, 482 | '--deny-net', 483 | // Ensures an empty array isn't used 484 | deniedAddresses?.length ? deniedAddresses : false 485 | ); 486 | addOption( 487 | runArgs, 488 | '--allow-read', 489 | this._options.permissions.allowRead 490 | ); 491 | addOption( 492 | runArgs, 493 | '--allow-write', 494 | this._options.permissions.allowWrite 495 | ); 496 | addOption( 497 | runArgs, 498 | '--allow-env', 499 | this._options.permissions.allowEnv 500 | ); 501 | addOption( 502 | runArgs, 503 | '--allow-plugin', 504 | this._options.permissions.allowPlugin 505 | ); 506 | addOption( 507 | runArgs, 508 | '--allow-hrtime', 509 | this._options.permissions.allowHrtime 510 | ); 511 | } 512 | } 513 | 514 | if (this._options.denoExtraFlags.length > 0) { 515 | runArgs.push(...this._options.denoExtraFlags); 516 | } 517 | 518 | this._process = spawn( 519 | this._options.denoExecutable, 520 | [ 521 | 'run', 522 | ...runArgs, 523 | this._options.denoBootstrapScriptPath, 524 | connectAddress, 525 | ...scriptArgs, 526 | ], 527 | this._options.spawnOptions 528 | ); 529 | this._process.on('exit', (code: number, signal: string) => { 530 | this.terminate(); 531 | 532 | if (this.onexit) { 533 | this.onexit(code, signal); 534 | } 535 | for (let onexit of this._onexitListeners) { 536 | onexit(code, signal); 537 | } 538 | }); 539 | 540 | this._stdout = this._process.stdout; 541 | this._stderr = this._process.stderr; 542 | 543 | if (this._options.logStdout) { 544 | this.stdout.setEncoding('utf-8'); 545 | this.stdout.on('data', (data) => { 546 | console.log('[deno]', data); 547 | }); 548 | } 549 | if (this._options.logStderr) { 550 | this.stderr.setEncoding('utf-8'); 551 | this.stderr.on('data', (data) => { 552 | console.log('[deno]', data); 553 | }); 554 | } 555 | }); 556 | } 557 | 558 | get stdout() { 559 | return this._stdout; 560 | } 561 | 562 | get stderr() { 563 | return this._stderr; 564 | } 565 | 566 | /** 567 | * Represents an event handler for the "message" event, that is a function to be called when a message is recieved from the worker. 568 | */ 569 | onmessage: (e: MessageEvent) => void = null; 570 | 571 | /** 572 | * Represents an event handler for the "exit" event. That is, a function to be called when the Deno worker process is terminated. 573 | */ 574 | onexit: (code: number, signal: string) => void = null; 575 | 576 | /** 577 | * Sends a message to the worker. 578 | * @param data The data to be sent. Copied via the Structured Clone algorithm so circular references are supported in addition to typed arrays. 579 | * @param transfer Values that should be transferred. This should include any typed arrays that are referenced in the data. 580 | */ 581 | postMessage(data: any, transfer?: Transferrable[]): void { 582 | return this._postMessage(null, data, transfer); 583 | } 584 | 585 | /** 586 | * Closes the websocket, which may allow the process to exit natually. 587 | */ 588 | closeSocket() { 589 | this._socketClosed = true; 590 | if (this._socket) { 591 | this._socket.close(); 592 | this._socket = null; 593 | } 594 | } 595 | 596 | /** 597 | * Terminates the worker and cleans up unused resources. 598 | */ 599 | terminate() { 600 | this._terminated = true; 601 | this._socketClosed = true; 602 | if (this._process && this._process.exitCode === null) { 603 | // this._process.kill(); 604 | forceKill(this._process.pid); 605 | } 606 | this._process = null; 607 | if (this._httpServer) { 608 | this._httpServer.close(); 609 | } 610 | if (this._server) { 611 | this._server.close(); 612 | this._server = null; 613 | } 614 | this._socket = null; 615 | this._pendingMessages = null; 616 | } 617 | 618 | /** 619 | * Adds the given listener for the "message" event. 620 | * @param type The type of the event. (Always "message") 621 | * @param listener The listener to add for the event. 622 | */ 623 | addEventListener(type: 'message', listener: OnMessageListener): void; 624 | 625 | /** 626 | * Adds the given listener for the "exit" event. 627 | * @param type The type of the event. (Always "exit") 628 | * @param listener The listener to add for the event. 629 | */ 630 | addEventListener(type: 'exit', listener: OnExitListener): void; 631 | 632 | /** 633 | * Adds the given listener for the "message" or "exit" event. 634 | * @param type The type of the event. (Always either "message" or "exit") 635 | * @param listener The listener to add for the event. 636 | */ 637 | addEventListener( 638 | type: 'message' | 'exit', 639 | listener: OnMessageListener | OnExitListener 640 | ): void { 641 | if (type === 'message') { 642 | this._onmessageListeners.push(listener as OnMessageListener); 643 | } else if (type === 'exit') { 644 | this._onexitListeners.push(listener as OnExitListener); 645 | } 646 | } 647 | 648 | /** 649 | * Removes the given listener for the "message" event. 650 | * @param type The type of the event. (Always "message") 651 | * @param listener The listener to remove for the event. 652 | */ 653 | removeEventListener(type: 'message', listener: OnMessageListener): void; 654 | 655 | /** 656 | * Removes the given listener for the "exit" event. 657 | * @param type The type of the event. (Always "exit") 658 | * @param listener The listener to remove for the event. 659 | */ 660 | removeEventListener(type: 'exit', listener: OnExitListener): void; 661 | 662 | /** 663 | * Removes the given listener for the "message" or "exit" event. 664 | * @param type The type of the event. (Always either "message" or "exit") 665 | * @param listener The listener to remove for the event. 666 | */ 667 | removeEventListener( 668 | type: 'message' | 'exit', 669 | listener: OnMessageListener | OnExitListener 670 | ): void { 671 | if (type === 'message') { 672 | const index = this._onmessageListeners.indexOf( 673 | listener as OnMessageListener 674 | ); 675 | if (index >= 0) { 676 | this._onmessageListeners.splice(index, 1); 677 | } 678 | } 679 | if (type === 'exit') { 680 | const index = this._onexitListeners.indexOf( 681 | listener as OnExitListener 682 | ); 683 | if (index >= 0) { 684 | this._onexitListeners.splice(index, 1); 685 | } 686 | } 687 | } 688 | 689 | private _postMessage( 690 | channel: number | string | null, 691 | data: any, 692 | transfer?: Transferrable[] 693 | ) { 694 | if (this._terminated) { 695 | return; 696 | } 697 | this._handleTransferrables(transfer); 698 | const structuredData = serializeStructure(data, transfer); 699 | if (channel !== null) { 700 | structuredData.channel = channel; 701 | } 702 | const json = JSON.stringify(structuredData); 703 | if (!this._available) { 704 | this._pendingMessages.push(json); 705 | } else if (this._socket) { 706 | this._socket.send(json); 707 | } 708 | } 709 | 710 | private _handleTransferrables(transfer?: Transferrable[]) { 711 | if (transfer) { 712 | for (let t of transfer) { 713 | if (t instanceof MessagePort) { 714 | if (!t.transferred) { 715 | const channelID = t.channelID; 716 | this._ports.set(t.channelID, { 717 | port: t, 718 | recieveData: t.transfer((data, transfer) => { 719 | this._postMessage(channelID, data, transfer); 720 | }), 721 | }); 722 | } 723 | } 724 | } 725 | } 726 | } 727 | } 728 | 729 | function addOption(list: string[], name: string, option: boolean | string[]) { 730 | if (option === true) { 731 | list.push(`${name}`); 732 | } else if (Array.isArray(option)) { 733 | let values = option.join(','); 734 | list.push(`${name}=${values}`); 735 | } 736 | } 737 | 738 | interface MessagePortData { 739 | port: MessagePort; 740 | recieveData: (data: any) => void; 741 | } 742 | -------------------------------------------------------------------------------- /src/MessageChannel.spec.ts: -------------------------------------------------------------------------------- 1 | import { MessageChannel } from './MessageChannel'; 2 | import { MessageEvent } from './MessageTarget'; 3 | 4 | describe('MessageChannel', () => { 5 | it('messages sent on port1 should end up on port2', () => { 6 | const channel = new MessageChannel(); 7 | 8 | let event: MessageEvent; 9 | channel.port2.onmessage = (e) => { 10 | event = e; 11 | }; 12 | 13 | channel.port1.postMessage({ 14 | hello: 'world', 15 | }); 16 | 17 | expect(event).toEqual({ 18 | data: { 19 | hello: 'world', 20 | }, 21 | }); 22 | }); 23 | 24 | it('messages sent on port2 should end up on port1', () => { 25 | const channel = new MessageChannel(); 26 | 27 | let event: MessageEvent; 28 | channel.port1.onmessage = (e) => { 29 | event = e; 30 | }; 31 | 32 | channel.port2.postMessage({ 33 | hello: 'world', 34 | }); 35 | 36 | expect(event).toEqual({ 37 | data: { 38 | hello: 'world', 39 | }, 40 | }); 41 | }); 42 | 43 | it('should create message ports with a number channel ID', () => { 44 | const channel = new MessageChannel(); 45 | expect(typeof channel.port1.channelID).toBe('number'); 46 | }); 47 | 48 | it('should be able to transfer() a MessagePort to take control of the serialization', () => { 49 | const channel = new MessageChannel(); 50 | 51 | let sent = [] as any[]; 52 | const recieveMessage = channel.port1.transfer((data, list) => { 53 | sent.push([data, list]); 54 | }); 55 | 56 | channel.port2.postMessage({ 57 | hello: 'world', 58 | }); 59 | 60 | expect(sent).toEqual([ 61 | [ 62 | { 63 | hello: 'world', 64 | }, 65 | undefined, 66 | ], 67 | ]); 68 | 69 | let event: MessageEvent; 70 | channel.port2.onmessage = (e) => { 71 | event = e; 72 | }; 73 | 74 | recieveMessage({ 75 | wow: true, 76 | }); 77 | 78 | expect(event).toEqual({ 79 | data: { wow: true }, 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/MessageChannel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessagePortInterface, 3 | Transferrable, 4 | MessageEvent, 5 | OnMessageListener, 6 | } from './MessageTarget'; 7 | 8 | // Global Channel ID counter. 9 | let channelIDCounter = 0; 10 | 11 | /** 12 | * Defines a class that implements the Channel Messaging API for the worker. 13 | */ 14 | export class MessageChannel { 15 | port1: MessagePort; 16 | port2: MessagePort; 17 | 18 | constructor(channel?: number | string) { 19 | const id = 20 | typeof channel !== 'undefined' ? channel : channelIDCounter++; 21 | this.port1 = new MessagePort(id); 22 | this.port2 = new MessagePort(id); 23 | MessagePort.link(this.port1, this.port2); 24 | } 25 | } 26 | 27 | /** 28 | * Defines a class that allows messages sent from one port to be recieved at the other port. 29 | */ 30 | export class MessagePort implements MessagePortInterface { 31 | /** 32 | * Whether this message port has been transferred. 33 | */ 34 | private _transferred: boolean; 35 | 36 | /** 37 | * The function that should be called to send a message to the remote. 38 | */ 39 | private _sendMessage: (data: any, transfer: Transferrable[]) => void; 40 | 41 | /** 42 | * The ID of this message port's channel. 43 | */ 44 | private _channelId: number | string; 45 | 46 | /** 47 | * The "message" listeners. 48 | */ 49 | private _listeners: OnMessageListener[]; 50 | 51 | /** 52 | * The other message port. 53 | */ 54 | private _other: MessagePort; 55 | 56 | get channelID() { 57 | return this._channelId; 58 | } 59 | 60 | get transferred() { 61 | return this._transferred; 62 | } 63 | 64 | constructor(channelID: number | string) { 65 | this._transferred = false; 66 | this._channelId = channelID; 67 | this._listeners = []; 68 | } 69 | 70 | addEventListener(type: 'message', listener: OnMessageListener): void { 71 | if (type === 'message') { 72 | this._listeners.push(listener); 73 | } 74 | } 75 | 76 | removeEventListener(type: 'message', listener: OnMessageListener): void { 77 | if (type === 'message') { 78 | const index = this._listeners.indexOf(listener); 79 | if (index >= 0) { 80 | this._listeners.splice(index, 1); 81 | } 82 | } 83 | } 84 | 85 | postMessage(data: any, transferrable?: Transferrable[]) { 86 | if (this.transferred) { 87 | this._sendMessage(data, transferrable); 88 | } else { 89 | this._other._recieveMessage(data); 90 | } 91 | } 92 | 93 | start() {} 94 | 95 | close() {} 96 | 97 | /** 98 | * Represents an event handler for the "message" event, that is a function to be called when a message is recieved from the worker. 99 | */ 100 | onmessage: (e: MessageEvent) => void = null; 101 | 102 | transfer( 103 | sendMessage: (data: any, transfer: Transferrable[]) => void 104 | ): (data: any) => void { 105 | if (this.transferred) { 106 | throw new Error('Already transferred'); 107 | } 108 | 109 | this._transferred = true; 110 | this._other._transferred = true; 111 | this._other._sendMessage = sendMessage; 112 | return this._other._recieveMessage.bind(this._other); 113 | } 114 | 115 | private _recieveMessage(data: any) { 116 | const event = { 117 | data, 118 | } as MessageEvent; 119 | if (this.onmessage) { 120 | this.onmessage(event); 121 | } 122 | for (let onmessage of this._listeners) { 123 | onmessage(event); 124 | } 125 | } 126 | 127 | /** 128 | * Links the two message ports. 129 | * @param port1 The first port. 130 | * @param port2 The second port. 131 | */ 132 | static link(port1: MessagePort, port2: MessagePort) { 133 | port1._other = port2; 134 | port2._other = port1; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/MessageTarget.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The possible transferrable data types. 3 | */ 4 | export type Transferrable = 5 | | ArrayBuffer 6 | | Uint8Array 7 | | Uint16Array 8 | | Uint32Array 9 | | Int8Array 10 | | Int16Array 11 | | Int32Array 12 | | MessagePortInterface; 13 | 14 | /** 15 | * Defines an interface for objects that are message ports. 16 | */ 17 | export interface MessagePortInterface extends MessageTarget { 18 | start(): void; 19 | close(): void; 20 | } 21 | 22 | /** 23 | * Defines an interface for objects that are able to send and recieve message events. 24 | */ 25 | export interface MessageTarget { 26 | postMessage(data: any, transferrable?: Transferrable[]): void; 27 | onmessage(e: MessageEvent): void; 28 | 29 | /** 30 | * Adds the given listener for the "message" event. 31 | * @param type The type of the event. (Always "message") 32 | * @param listener The listener to add for the event. 33 | */ 34 | addEventListener(type: 'message', listener: OnMessageListener): void; 35 | 36 | /** 37 | * Removes the given listener for the "message" event. 38 | * @param type The type of the event. (Always "message") 39 | * @param listener The listener to add for the event. 40 | */ 41 | removeEventListener(type: 'message', listener: OnMessageListener): void; 42 | } 43 | 44 | export interface OnMessageListener { 45 | (event: MessageEvent): void; 46 | } 47 | 48 | export interface OnExitListener { 49 | (exitCode: number, signal: string): void; 50 | } 51 | 52 | export interface MessageEvent { 53 | data: any; 54 | } 55 | -------------------------------------------------------------------------------- /src/StructureClone.spec.ts: -------------------------------------------------------------------------------- 1 | import { serializeStructure, deserializeStructure } from './StructureClone'; 2 | import { MessagePort, MessageChannel } from './MessageChannel'; 3 | 4 | describe('StructureClone', () => { 5 | const primitives = [ 6 | [true], 7 | [false], 8 | [0], 9 | [1], 10 | ['string'], 11 | [undefined], 12 | [null], 13 | ]; 14 | const arrayTypes = [ 15 | ['Uint8Array'], 16 | ['Uint16Array'], 17 | ['Uint32Array'], 18 | ['Int8Array'], 19 | ['Int16Array'], 20 | ['Int32Array'], 21 | ]; 22 | const errorCases = [ 23 | ['Error', Error], 24 | ['EvalError', EvalError], 25 | ['RangeError', RangeError], 26 | ['ReferenceError', ReferenceError], 27 | ['SyntaxError', SyntaxError], 28 | ['TypeError', TypeError], 29 | ['URIError', URIError], 30 | ]; 31 | describe('serializeStructure()', () => { 32 | it.each(primitives)( 33 | 'should return an object with root set to %s', 34 | (value: any) => { 35 | expect(serializeStructure(value)).toEqual({ 36 | root: value, 37 | }); 38 | } 39 | ); 40 | 41 | it('should serialize non-circular objects normally', () => { 42 | let obj1 = { 43 | name: 'obj1', 44 | obj2: { 45 | name: 'obj2', 46 | obj3: { 47 | name: 'obj3', 48 | }, 49 | }, 50 | }; 51 | 52 | expect(serializeStructure(obj1)).toEqual({ 53 | root: { 54 | name: 'obj1', 55 | obj2: { 56 | name: 'obj2', 57 | obj3: { 58 | name: 'obj3', 59 | }, 60 | }, 61 | }, 62 | }); 63 | }); 64 | 65 | it('should add circular references to the refs map', () => { 66 | let obj3 = { 67 | name: 'obj3', 68 | } as any; 69 | let obj2 = { 70 | name: 'obj2', 71 | obj3: obj3, 72 | } as any; 73 | let obj1 = { 74 | name: 'obj1', 75 | obj2: obj2, 76 | } as any; 77 | 78 | obj3.obj1 = obj1; 79 | 80 | expect(serializeStructure(obj1)).toEqual({ 81 | root: ['$0'], 82 | refs: { 83 | $0: { 84 | root: { 85 | name: 'obj1', 86 | obj2: ['$1'], 87 | }, 88 | }, 89 | $1: { 90 | root: { 91 | name: 'obj2', 92 | obj3: ['$2'], 93 | }, 94 | }, 95 | $2: { 96 | root: { 97 | name: 'obj3', 98 | obj1: ['$0'], 99 | }, 100 | }, 101 | }, 102 | }); 103 | }); 104 | 105 | it('should handle simple arrays', () => { 106 | expect(serializeStructure(['abc', 'def', 123, true])).toEqual({ 107 | root: ['abc', 'def', 123, true], 108 | }); 109 | }); 110 | 111 | it('should handle arrays with objects', () => { 112 | expect( 113 | serializeStructure([ 114 | 'abc', 115 | 'def', 116 | 123, 117 | true, 118 | { message: 'Hello' }, 119 | ]) 120 | ).toEqual({ 121 | root: ['abc', 'def', 123, true, { message: 'Hello' }], 122 | }); 123 | }); 124 | 125 | it('should handle circular arrays', () => { 126 | let arr3 = ['arr3'] as any[]; 127 | let arr2 = ['arr2', arr3]; 128 | let arr1 = ['arr1', arr2]; 129 | arr3.push(arr1); 130 | 131 | expect(serializeStructure(arr1)).toEqual({ 132 | root: ['$0'], 133 | refs: { 134 | $0: { 135 | root: ['arr1', ['$1']], 136 | }, 137 | $1: { 138 | root: ['arr2', ['$2']], 139 | }, 140 | $2: { 141 | root: ['arr3', ['$0']], 142 | }, 143 | }, 144 | }); 145 | }); 146 | 147 | it('should map transferrables as special references', () => { 148 | let buffer = new ArrayBuffer(64); 149 | let obj1 = { 150 | name: 'obj1', 151 | buffer: buffer, 152 | }; 153 | 154 | expect(serializeStructure(obj1, [buffer])).toEqual({ 155 | root: ['$0'], 156 | refs: { 157 | $0: { 158 | root: { 159 | name: 'obj1', 160 | buffer: ['$1'], 161 | }, 162 | }, 163 | $1: { 164 | root: 165 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 166 | type: 'ArrayBuffer', 167 | }, 168 | }, 169 | }); 170 | }); 171 | 172 | it.each(arrayTypes)('should map %s as special references', (type) => { 173 | let buffer = new ArrayBuffer(64); 174 | let array = new (globalThis)[type](buffer); 175 | let obj1 = { 176 | name: 'obj1', 177 | array: array, 178 | }; 179 | 180 | expect(serializeStructure(obj1, [buffer])).toEqual({ 181 | root: ['$0'], 182 | refs: { 183 | $0: { 184 | root: { 185 | name: 'obj1', 186 | array: ['$1'], 187 | }, 188 | }, 189 | $1: { 190 | root: 191 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 192 | type: type, 193 | }, 194 | }, 195 | }); 196 | }); 197 | 198 | it('should support BigInt objects', () => { 199 | expect(serializeStructure(BigInt(989898434684646))).toEqual({ 200 | root: ['$0'], 201 | refs: { 202 | $0: { 203 | root: '989898434684646', 204 | type: 'BigInt', 205 | }, 206 | }, 207 | }); 208 | }); 209 | 210 | it('should support Date objects', () => { 211 | expect( 212 | serializeStructure(new Date('2020-07-21T00:00:00.000Z')) 213 | ).toEqual({ 214 | root: ['$0'], 215 | refs: { 216 | $0: { 217 | root: '2020-07-21T00:00:00.000Z', 218 | type: 'Date', 219 | }, 220 | }, 221 | }); 222 | }); 223 | 224 | it('should support RegExp objects', () => { 225 | expect(serializeStructure(new RegExp('^abc$', 'gi'))).toEqual({ 226 | root: ['$0'], 227 | refs: { 228 | $0: { 229 | root: { 230 | source: '^abc$', 231 | flags: 'gi', 232 | }, 233 | type: 'RegExp', 234 | }, 235 | }, 236 | }); 237 | }); 238 | 239 | it('should support Map objects', () => { 240 | expect( 241 | serializeStructure( 242 | new Map([ 243 | ['key', 'value'], 244 | [{ name: 'bob' }, 99], 245 | ]) 246 | ) 247 | ).toEqual({ 248 | root: ['$0'], 249 | refs: { 250 | $0: { 251 | root: [['$1'], ['$2']], 252 | type: 'Map', 253 | }, 254 | $1: { 255 | root: ['key', 'value'], 256 | }, 257 | $2: { 258 | root: [['$3'], 99], 259 | }, 260 | $3: { 261 | root: { name: 'bob' }, 262 | }, 263 | }, 264 | }); 265 | }); 266 | 267 | it('should support Set objects', () => { 268 | expect( 269 | serializeStructure( 270 | new Set(['abc', 'def', 99, { name: 'bob' }]) 271 | ) 272 | ).toEqual({ 273 | root: ['$0'], 274 | refs: { 275 | $0: { 276 | root: ['abc', 'def', 99, ['$1']], 277 | type: 'Set', 278 | }, 279 | $1: { 280 | root: { name: 'bob' }, 281 | }, 282 | }, 283 | }); 284 | }); 285 | 286 | it.each(errorCases)( 287 | 'should support %s objects', 288 | (desc: string, type: any) => { 289 | const err = new type('abc'); 290 | expect(serializeStructure(err)).toEqual({ 291 | root: ['$0'], 292 | refs: { 293 | $0: { 294 | root: { 295 | name: err.name, 296 | message: 'abc', 297 | stack: err.stack, 298 | }, 299 | type: 'Error', 300 | }, 301 | }, 302 | }); 303 | } 304 | ); 305 | 306 | it('should require MessagePort objects to be transferred', () => { 307 | const port = new MessagePort(99); 308 | 309 | expect(() => { 310 | serializeStructure(port, [port]); 311 | }).toThrow( 312 | new Error( 313 | 'Port must be transferred before serialization. Did you forget to add it to the transfer list?' 314 | ) 315 | ); 316 | }); 317 | 318 | it('should support MessagePort objects', () => { 319 | const port1 = new MessagePort(99); 320 | const port2 = new MessagePort(99); 321 | MessagePort.link(port1, port2); 322 | port1.transfer(() => {}); 323 | 324 | expect(serializeStructure(port1, [port1])).toEqual({ 325 | root: ['$0'], 326 | refs: { 327 | $0: { 328 | root: { 329 | channel: 99, 330 | }, 331 | type: 'MessagePort', 332 | }, 333 | }, 334 | }); 335 | }); 336 | 337 | it('should not error when given an object without hasOwnProperty', () => { 338 | let obj = { 339 | myProp: 'abc', 340 | hasOwnProperty: null as any, 341 | }; 342 | expect(serializeStructure(obj)).toEqual({ 343 | root: { 344 | hasOwnProperty: null, 345 | myProp: 'abc', 346 | }, 347 | }); 348 | }); 349 | }); 350 | 351 | describe('deserializeStructure', () => { 352 | it.each(primitives)( 353 | 'should return the root value for %s', 354 | (value: any) => { 355 | expect( 356 | deserializeStructure({ 357 | root: value, 358 | }) 359 | ).toEqual({ 360 | data: value, 361 | transferred: [], 362 | }); 363 | } 364 | ); 365 | 366 | it('should deserialize circular objects', () => { 367 | let obj3 = { 368 | name: 'obj3', 369 | } as any; 370 | let obj2 = { 371 | name: 'obj2', 372 | obj3: obj3, 373 | } as any; 374 | let obj1 = { 375 | name: 'obj1', 376 | obj2: obj2, 377 | } as any; 378 | 379 | obj3.obj1 = obj1; 380 | 381 | expect( 382 | deserializeStructure({ 383 | root: ['$0'], 384 | refs: { 385 | $0: { 386 | root: { 387 | name: 'obj1', 388 | obj2: ['$1'], 389 | }, 390 | }, 391 | $1: { 392 | root: { 393 | name: 'obj2', 394 | obj3: ['$2'], 395 | }, 396 | }, 397 | $2: { 398 | root: { 399 | name: 'obj3', 400 | obj1: ['$0'], 401 | }, 402 | }, 403 | }, 404 | }) 405 | ).toEqual({ 406 | data: obj1, 407 | transferred: [], 408 | }); 409 | }); 410 | 411 | it('should deserialize arrays with objects', () => { 412 | expect( 413 | deserializeStructure({ 414 | root: ['abc', 'def', 123, true, { message: 'Hello' }], 415 | }) 416 | ).toEqual({ 417 | data: ['abc', 'def', 123, true, { message: 'Hello' }], 418 | transferred: [], 419 | }); 420 | }); 421 | 422 | it('should deserialize circular arrays', () => { 423 | let arr3 = ['arr3'] as any[]; 424 | let arr2 = ['arr2', arr3]; 425 | let arr1 = ['arr1', arr2]; 426 | arr3.push(arr1); 427 | 428 | expect( 429 | deserializeStructure({ 430 | root: ['$0'], 431 | refs: { 432 | $0: { 433 | root: ['arr1', ['$1']], 434 | }, 435 | $1: { 436 | root: ['arr2', ['$2']], 437 | }, 438 | $2: { 439 | root: ['arr3', ['$0']], 440 | }, 441 | }, 442 | }) 443 | ).toEqual({ 444 | data: arr1, 445 | transferred: [], 446 | }); 447 | }); 448 | 449 | it('should map transferrables as special references', () => { 450 | let buffer = new ArrayBuffer(64); 451 | let obj1 = { 452 | name: 'obj1', 453 | buffer: buffer, 454 | }; 455 | 456 | expect( 457 | deserializeStructure({ 458 | root: ['$0'], 459 | refs: { 460 | $0: { 461 | root: { 462 | name: 'obj1', 463 | buffer: ['$1'], 464 | }, 465 | }, 466 | $1: { 467 | root: 468 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 469 | type: 'ArrayBuffer', 470 | }, 471 | }, 472 | }) 473 | ).toEqual({ 474 | data: obj1, 475 | transferred: [], 476 | }); 477 | }); 478 | 479 | it.each(arrayTypes)('should map %s as special references', (type) => { 480 | let buffer = new ArrayBuffer(64); 481 | let array = new (globalThis)[type](buffer); 482 | let obj1 = { 483 | name: 'obj1', 484 | array: array, 485 | }; 486 | 487 | expect( 488 | deserializeStructure({ 489 | root: ['$0'], 490 | refs: { 491 | $0: { 492 | root: { 493 | name: 'obj1', 494 | array: ['$1'], 495 | }, 496 | }, 497 | $1: { 498 | root: 499 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', 500 | type: type as any, 501 | }, 502 | }, 503 | }) 504 | ).toEqual({ 505 | data: obj1, 506 | transferred: [], 507 | }); 508 | }); 509 | 510 | it('should support BigInt objects', () => { 511 | expect( 512 | deserializeStructure({ 513 | root: ['$0'], 514 | refs: { 515 | $0: { 516 | root: '989898434684646', 517 | type: 'BigInt', 518 | }, 519 | }, 520 | }) 521 | ).toEqual({ 522 | data: BigInt(989898434684646), 523 | transferred: [], 524 | }); 525 | }); 526 | 527 | it('should support Date objects', () => { 528 | expect( 529 | deserializeStructure({ 530 | root: ['$0'], 531 | refs: { 532 | $0: { 533 | root: '2020-07-21T00:00:00.000Z', 534 | type: 'Date', 535 | }, 536 | }, 537 | }) 538 | ).toEqual({ 539 | data: new Date('2020-07-21T00:00:00.000Z'), 540 | transferred: [], 541 | }); 542 | }); 543 | 544 | it('should support RegExp objects', () => { 545 | expect( 546 | deserializeStructure({ 547 | root: ['$0'], 548 | refs: { 549 | $0: { 550 | root: { 551 | source: '^abc$', 552 | flags: 'gi', 553 | }, 554 | type: 'RegExp', 555 | }, 556 | }, 557 | }) 558 | ).toEqual({ 559 | data: new RegExp('^abc$', 'gi'), 560 | transferred: [], 561 | }); 562 | }); 563 | 564 | it('should support Map objects', () => { 565 | expect( 566 | deserializeStructure({ 567 | root: ['$0'], 568 | refs: { 569 | $0: { 570 | root: [['$1'], ['$2']], 571 | type: 'Map', 572 | }, 573 | $1: { 574 | root: ['key', 'value'], 575 | }, 576 | $2: { 577 | root: [['$3'], 99], 578 | }, 579 | $3: { 580 | root: { name: 'bob' }, 581 | }, 582 | }, 583 | }) 584 | ).toEqual({ 585 | data: new Map([ 586 | ['key', 'value'], 587 | [{ name: 'bob' }, 99], 588 | ]), 589 | transferred: [], 590 | }); 591 | }); 592 | 593 | it('should support Set objects', () => { 594 | expect( 595 | deserializeStructure({ 596 | root: ['$0'], 597 | refs: { 598 | $0: { 599 | root: ['abc', 'def', 99, ['$1']], 600 | type: 'Set', 601 | }, 602 | $1: { 603 | root: { name: 'bob' }, 604 | }, 605 | }, 606 | }) 607 | ).toEqual({ 608 | data: new Set(['abc', 'def', 99, { name: 'bob' }]), 609 | transferred: [], 610 | }); 611 | }); 612 | 613 | it.each(errorCases)( 614 | 'should support %s objects', 615 | (desc: string, type: any) => { 616 | const err = new type('abc'); 617 | expect( 618 | deserializeStructure({ 619 | root: ['$0'], 620 | refs: { 621 | $0: { 622 | root: { 623 | name: err.name, 624 | message: 'abc', 625 | stack: err.stack, 626 | }, 627 | type: 'Error', 628 | }, 629 | }, 630 | }) 631 | ).toEqual({ 632 | data: err, 633 | transferred: [], 634 | }); 635 | } 636 | ); 637 | 638 | it('should support MessagePort objects', () => { 639 | const channel = new MessageChannel(99); 640 | 641 | expect( 642 | deserializeStructure({ 643 | root: ['$0'], 644 | refs: { 645 | $0: { 646 | root: { 647 | channel: 99, 648 | }, 649 | type: 'MessagePort', 650 | }, 651 | }, 652 | }) 653 | ).toEqual({ 654 | data: channel.port1, 655 | transferred: [channel.port2], 656 | }); 657 | }); 658 | 659 | it('should have a different port for the transferred from in the data', () => { 660 | const port1 = new MessagePort(99); 661 | 662 | const deserialized = deserializeStructure({ 663 | root: ['$0'], 664 | refs: { 665 | $0: { 666 | root: { 667 | channel: 99, 668 | }, 669 | type: 'MessagePort', 670 | }, 671 | }, 672 | }); 673 | 674 | expect(deserialized.data).not.toBe(deserialized.transferred[0]); 675 | }); 676 | 677 | it('should not error when given an object without hasOwnProperty', () => { 678 | const deserialized = deserializeStructure({ 679 | root: ['$0'], 680 | refs: { 681 | $0: { 682 | root: { 683 | hasOwnProperty: null, 684 | myProp: 'abc', 685 | }, 686 | }, 687 | }, 688 | }); 689 | 690 | expect(deserialized).toEqual({ 691 | data: { 692 | hasOwnProperty: null, 693 | myProp: 'abc', 694 | }, 695 | transferred: [], 696 | }); 697 | }); 698 | }); 699 | }); 700 | -------------------------------------------------------------------------------- /src/StructureClone.ts: -------------------------------------------------------------------------------- 1 | import { fromByteArray, toByteArray } from 'base64-js'; 2 | import { Transferrable } from './MessageTarget'; 3 | import { MessagePort, MessageChannel } from './MessageChannel'; 4 | 5 | const HAS_CIRCULAR_REF_OR_TRANSFERRABLE = Symbol('hasCircularRef'); 6 | 7 | /** 8 | * Serializes the given value into a new object that is flat and contains no circular references. 9 | * 10 | * The returned object is JSON-safe and contains a root which is the entry point to the data structure and optionally 11 | * contains a refs property which is a flat map of references. 12 | * 13 | * If the refs property is defined, then the data structure was circular. 14 | * 15 | * @param value The value to serialize. 16 | * @param transferrable The transferrable list. 17 | */ 18 | export function serializeStructure( 19 | value: unknown, 20 | transferrable?: Transferrable[] 21 | ): Structure { 22 | if ( 23 | (typeof value !== 'object' && typeof value !== 'bigint') || 24 | value === null 25 | ) { 26 | return { 27 | root: value, 28 | }; 29 | } else { 30 | let map = new Map(); 31 | const result = _serializeObject(value, map); 32 | 33 | if ((map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] === true) { 34 | let refs = {} as any; 35 | for (let [key, ref] of map) { 36 | refs[ref.id] = ref.obj; 37 | } 38 | return { 39 | root: result, 40 | refs: refs, 41 | }; 42 | } 43 | return { 44 | root: value, 45 | }; 46 | } 47 | } 48 | 49 | /** 50 | * Deserializes the given structure into its original form. 51 | * @param value The structure to deserialize. 52 | */ 53 | export function deserializeStructure(value: Structure): DeserializedStructure { 54 | if ('refs' in value) { 55 | let map = new Map(); 56 | let list = [] as Transferrable[]; 57 | const result = _deserializeRef(value, value.root[0], map, list); 58 | return { 59 | data: result, 60 | transferred: list, 61 | }; 62 | } else { 63 | return { 64 | data: value.root, 65 | transferred: [], 66 | }; 67 | } 68 | } 69 | 70 | function _serializeObject(value: unknown, map: Map) { 71 | if (typeof value !== 'object' && typeof value !== 'bigint') { 72 | return value; 73 | } 74 | if (map.has(value)) { 75 | const ref = map.get(value); 76 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 77 | return [ref.id]; 78 | } 79 | let id = '$' + map.size; 80 | 81 | if ( 82 | value instanceof Uint8Array || 83 | value instanceof Uint16Array || 84 | value instanceof Uint32Array || 85 | value instanceof Int8Array || 86 | value instanceof Int16Array || 87 | value instanceof Int32Array || 88 | value instanceof ArrayBuffer 89 | ) { 90 | let ref = { 91 | root: fromByteArray( 92 | value instanceof ArrayBuffer 93 | ? new Uint8Array(value) 94 | : new Uint8Array( 95 | value.buffer, 96 | value.byteOffset, 97 | value.byteLength 98 | ) 99 | ), 100 | type: value.constructor.name, 101 | } as Ref; 102 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 103 | map.set(value, { 104 | id, 105 | obj: ref, 106 | }); 107 | return [id]; 108 | } else if (typeof value === 'bigint') { 109 | const root = value.toString(); 110 | const obj = { 111 | root, 112 | type: 'BigInt', 113 | } as const; 114 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 115 | map.set(value, { 116 | id, 117 | obj, 118 | }); 119 | return [id]; 120 | } else if (Array.isArray(value)) { 121 | let root = [] as any[]; 122 | let obj = { 123 | root, 124 | } as Ref; 125 | map.set(value, { 126 | id, 127 | obj, 128 | }); 129 | for (let prop of value) { 130 | root.push(_serializeObject(prop, map)); 131 | } 132 | return [id]; 133 | } else if (value instanceof Date) { 134 | const obj = { 135 | root: value.toISOString(), 136 | type: 'Date', 137 | } as const; 138 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 139 | map.set(value, { 140 | id, 141 | obj, 142 | }); 143 | return [id]; 144 | } else if (value instanceof RegExp) { 145 | const obj = { 146 | root: { 147 | source: value.source, 148 | flags: value.flags, 149 | }, 150 | type: 'RegExp', 151 | } as const; 152 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 153 | map.set(value, { 154 | id, 155 | obj, 156 | }); 157 | return [id]; 158 | } else if (value instanceof Map) { 159 | let root = [] as any[]; 160 | let obj = { 161 | root, 162 | type: 'Map', 163 | } as Ref; 164 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 165 | map.set(value, { 166 | id, 167 | obj, 168 | }); 169 | for (let prop of value) { 170 | root.push(_serializeObject(prop, map)); 171 | } 172 | return [id]; 173 | } else if (value instanceof Set) { 174 | let root = [] as any[]; 175 | let obj = { 176 | root, 177 | type: 'Set', 178 | } as Ref; 179 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 180 | map.set(value, { 181 | id, 182 | obj, 183 | }); 184 | for (let prop of value) { 185 | root.push(_serializeObject(prop, map)); 186 | } 187 | return [id]; 188 | } else if (value instanceof Error) { 189 | let obj = { 190 | root: { 191 | name: value.name, 192 | message: value.message, 193 | stack: value.stack, 194 | }, 195 | type: 'Error', 196 | } as const; 197 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 198 | map.set(value, { 199 | id, 200 | obj, 201 | }); 202 | return [id]; 203 | } else if (value instanceof MessagePort) { 204 | if (!value.transferred) { 205 | throw new Error( 206 | 'Port must be transferred before serialization. Did you forget to add it to the transfer list?' 207 | ); 208 | } 209 | let obj = { 210 | root: { 211 | channel: value.channelID, 212 | }, 213 | type: 'MessagePort', 214 | } as const; 215 | (map)[HAS_CIRCULAR_REF_OR_TRANSFERRABLE] = true; 216 | map.set(value, { 217 | id, 218 | obj, 219 | }); 220 | return [id]; 221 | } else if (value instanceof Object) { 222 | let root = {} as any; 223 | let ref = { 224 | root, 225 | } as Ref; 226 | map.set(value, { 227 | id, 228 | obj: ref, 229 | }); 230 | for (let prop in value) { 231 | if (Object.hasOwnProperty.call(value, prop)) { 232 | root[prop] = _serializeObject((value)[prop], map); 233 | } 234 | } 235 | return [id]; 236 | } 237 | } 238 | 239 | function _deserializeRef( 240 | structure: Structure, 241 | ref: string, 242 | map: Map, 243 | transfered: Transferrable[] 244 | ): any { 245 | if (map.has(ref)) { 246 | return map.get(ref); 247 | } 248 | 249 | const refData = structure.refs[ref]; 250 | 251 | if ('type' in refData) { 252 | const arrayTypes = [ 253 | 'ArrayBuffer', 254 | 'Uint8Array', 255 | 'Uint16Array', 256 | 'Uint32Array', 257 | 'Int8Array', 258 | 'Int16Array', 259 | 'Int32Array', 260 | ]; 261 | if (arrayTypes.indexOf(refData.type) >= 0) { 262 | const bytes = toByteArray(refData.root); 263 | const final = 264 | refData.type == 'Uint8Array' 265 | ? bytes 266 | : refData.type === 'ArrayBuffer' 267 | ? bytes.buffer.slice( 268 | bytes.byteOffset, 269 | bytes.byteOffset + bytes.byteLength 270 | ) 271 | : refData.type === 'Int8Array' 272 | ? new Int8Array( 273 | bytes.buffer, 274 | bytes.byteOffset, 275 | bytes.byteLength / Int8Array.BYTES_PER_ELEMENT 276 | ) 277 | : refData.type == 'Int16Array' 278 | ? new Int16Array( 279 | bytes.buffer, 280 | bytes.byteOffset, 281 | bytes.byteLength / Int16Array.BYTES_PER_ELEMENT 282 | ) 283 | : refData.type == 'Int32Array' 284 | ? new Int32Array( 285 | bytes.buffer, 286 | bytes.byteOffset, 287 | bytes.byteLength / Int32Array.BYTES_PER_ELEMENT 288 | ) 289 | : refData.type == 'Uint16Array' 290 | ? new Uint16Array( 291 | bytes.buffer, 292 | bytes.byteOffset, 293 | bytes.byteLength / Uint16Array.BYTES_PER_ELEMENT 294 | ) 295 | : refData.type == 'Uint32Array' 296 | ? new Uint32Array( 297 | bytes.buffer, 298 | bytes.byteOffset, 299 | bytes.byteLength / Uint32Array.BYTES_PER_ELEMENT 300 | ) 301 | : null; 302 | map.set(ref, final); 303 | return final; 304 | } else if (refData.type === 'BigInt') { 305 | const final = BigInt(refData.root); 306 | map.set(ref, final); 307 | return final; 308 | } else if (refData.type === 'Date') { 309 | const final = new Date(refData.root); 310 | map.set(ref, final); 311 | return final; 312 | } else if (refData.type === 'RegExp') { 313 | const final = new RegExp(refData.root.source, refData.root.flags); 314 | map.set(ref, final); 315 | return final; 316 | } else if (refData.type === 'Map') { 317 | let final = new Map(); 318 | map.set(ref, final); 319 | for (let value of refData.root) { 320 | const [key, val] = _deserializeRef( 321 | structure, 322 | value[0], 323 | map, 324 | transfered 325 | ); 326 | final.set(key, val); 327 | } 328 | return final; 329 | } else if (refData.type === 'Set') { 330 | let final = new Set(); 331 | map.set(ref, final); 332 | for (let value of refData.root) { 333 | const val = Array.isArray(value) 334 | ? _deserializeRef(structure, value[0], map, transfered) 335 | : value; 336 | final.add(val); 337 | } 338 | return final; 339 | } else if (refData.type === 'Error') { 340 | let proto = Error.prototype; 341 | if (refData.root.name === 'EvalError') { 342 | proto = EvalError.prototype; 343 | } else if (refData.root.name === 'RangeError') { 344 | proto = RangeError.prototype; 345 | } else if (refData.root.name === 'ReferenceError') { 346 | proto = ReferenceError.prototype; 347 | } else if (refData.root.name === 'SyntaxError') { 348 | proto = SyntaxError.prototype; 349 | } else if (refData.root.name === 'TypeError') { 350 | proto = TypeError.prototype; 351 | } else if (refData.root.name === 'URIError') { 352 | proto = URIError.prototype; 353 | } 354 | let final = Object.create(proto); 355 | if (typeof refData.root.message !== 'undefined') { 356 | Object.defineProperty(final, 'message', { 357 | value: refData.root.message, 358 | writable: true, 359 | enumerable: false, 360 | configurable: true, 361 | }); 362 | } 363 | if (typeof refData.root.stack !== 'undefined') { 364 | Object.defineProperty(final, 'stack', { 365 | value: refData.root.stack, 366 | writable: true, 367 | enumerable: false, 368 | configurable: true, 369 | }); 370 | } 371 | return final; 372 | } else if (refData.type === 'MessagePort') { 373 | const channel = new MessageChannel(refData.root.channel); 374 | map.set(ref, channel.port1); 375 | transfered.push(channel.port2); 376 | return channel.port1; 377 | } 378 | } else if (Array.isArray(refData.root)) { 379 | let arr = [] as any[]; 380 | map.set(ref, arr); 381 | for (let value of refData.root) { 382 | arr.push( 383 | Array.isArray(value) 384 | ? _deserializeRef(structure, value[0], map, transfered) 385 | : value 386 | ); 387 | } 388 | return arr; 389 | } else if (typeof refData.root === 'object') { 390 | let obj = {} as any; 391 | map.set(ref, obj); 392 | for (let prop in refData.root) { 393 | if (Object.hasOwnProperty.call(refData.root, prop)) { 394 | const value = refData.root[prop]; 395 | obj[prop] = Array.isArray(value) 396 | ? _deserializeRef(structure, value[0], map, transfered) 397 | : value; 398 | } 399 | } 400 | return obj; 401 | } 402 | 403 | map.set(ref, refData.root); 404 | return refData.root; 405 | } 406 | 407 | /** 408 | * Defines an interface for a serializable structure. 409 | * Usually created from a normal JavaScript object. 410 | */ 411 | export interface Structure { 412 | /** 413 | * The entry point into the structure. 414 | * Can be a reference to an object in the refs property. 415 | */ 416 | root: any; 417 | 418 | /** 419 | * The ID of the channel that serialized this structure. 420 | * If omitted, then the root channel sent this message. 421 | * Used to multiplex messages. 422 | */ 423 | channel?: number | string; 424 | 425 | /** 426 | * A map of reference IDs to objects. 427 | * Objects can additionally reference other objects. 428 | */ 429 | refs?: { 430 | [key: string]: Ref; 431 | }; 432 | } 433 | 434 | /** 435 | * Defines an interface for a structure that was deserialized. 436 | */ 437 | export interface DeserializedStructure { 438 | /** 439 | * The data in the structure. 440 | */ 441 | data: any; 442 | 443 | /** 444 | * The list of values that were transferred and require extra processing to be fully transferred. 445 | */ 446 | transferred: Transferrable[]; 447 | } 448 | 449 | interface MapRef { 450 | id: string; 451 | obj: Ref; 452 | } 453 | 454 | /** 455 | * Defines an interface for an object that has been serialized into a flat structure with references to other objects. 456 | */ 457 | export interface Ref { 458 | /** 459 | * The entry point for the object. 460 | * Can contain references to other objects. 461 | */ 462 | root: any; 463 | 464 | /** 465 | * The type of the reference. 466 | * If omitted, then the value is either an object or an array. 467 | * If specified, then the value should be converted into the given type on 468 | * deserialization. 469 | */ 470 | type?: 471 | | 'ArrayBuffer' 472 | | 'Uint8Array' 473 | | 'Uint16Array' 474 | | 'Uint32Array' 475 | | 'Int8Array' 476 | | 'Int16Array' 477 | | 'Int32Array' 478 | | 'BigInt' 479 | | 'Date' 480 | | 'RegExp' 481 | | 'Map' 482 | | 'Set' 483 | | 'Error' 484 | | 'MessagePort'; 485 | } 486 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import { MessageChannel, MessagePort } from './MessageChannel'; 2 | import { execSync } from 'child_process'; 3 | 4 | export function polyfillMessageChannel() { 5 | const anyGlobalThis = globalThis as any; 6 | if (typeof anyGlobalThis.MessageChannel === 'undefined') { 7 | anyGlobalThis.MessageChannel = MessageChannel; 8 | anyGlobalThis.MessagePort = MessagePort; 9 | } 10 | } 11 | 12 | /** 13 | * Forcefully kills the process with the given ID. 14 | * On Linux/Unix, this means sending the process the SIGKILL signal. 15 | * On Windows, this means using the taskkill executable to kill the process. 16 | * @param pid The ID of the process to kill. 17 | */ 18 | export function forceKill(pid: number) { 19 | const isWindows = /^win/.test(process.platform); 20 | if (isWindows) { 21 | return killWindows(pid); 22 | } else { 23 | return killUnix(pid); 24 | } 25 | } 26 | 27 | function killWindows(pid: number) { 28 | execSync(`taskkill /PID ${pid} /T /F`); 29 | } 30 | 31 | function killUnix(pid: number) { 32 | try { 33 | const signal = 'SIGKILL'; 34 | process.kill(pid, signal); 35 | } catch (e) { 36 | // Allow this call to fail with 37 | // ESRCH, which meant that the process 38 | // to be killed was already dead. 39 | // But re-throw on other codes. 40 | if (e.code !== 'ESRCH') { 41 | throw e; 42 | } 43 | } 44 | } 45 | 46 | export interface ExecResult { 47 | stdout: string; 48 | stdin: string; 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DenoWorker'; 2 | export * from './MessageTarget'; 3 | export * from './MessageChannel'; 4 | export * from './Utils'; 5 | -------------------------------------------------------------------------------- /src/test/deno.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/test/echo.js: -------------------------------------------------------------------------------- 1 | self.onmessage = (e) => { 2 | if (e.data.type === 'echo') { 3 | self.postMessage(e.data); 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /src/test/env.js: -------------------------------------------------------------------------------- 1 | self.onmessage = (e) => { 2 | if (e.data.type === 'env') { 3 | self.postMessage(Deno.env.get(e.data.name)); 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /src/test/fail.js: -------------------------------------------------------------------------------- 1 | throw new Error('script fail'); 2 | -------------------------------------------------------------------------------- /src/test/fetch.js: -------------------------------------------------------------------------------- 1 | self.onmessage = async (e) => { 2 | if (e.data.type === 'fetch') { 3 | // NOTE: Don't fetch within tests unless an error is expected 4 | await fetch(e.data.url).then( 5 | (r) => self.postMessage({ type: 'response', status: r.status }), 6 | (e) => self.postMessage({ type: 'error', error: e.message }) 7 | ); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/test/import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": {} 3 | } 4 | -------------------------------------------------------------------------------- /src/test/infinite.js: -------------------------------------------------------------------------------- 1 | self.postMessage('test'); 2 | 3 | self.onmessage = (e) => { 4 | while (true) { 5 | console.log('Running...'); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/test/memory.js: -------------------------------------------------------------------------------- 1 | self.onmessage = (event) => { 2 | let arrays = []; 3 | for (;;) { 4 | arrays.push(new Uint32Array(128)); 5 | console.log(arrays.length); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/test/ping.js: -------------------------------------------------------------------------------- 1 | self.onmessage = (event) => { 2 | if (event.data.type === 'port') { 3 | let port = event.data.port; 4 | port.onmessage = (e) => { 5 | if (e.data === 'ping') { 6 | port.postMessage('pong'); 7 | } 8 | }; 9 | } else if (event.data.type === 'request_port') { 10 | const channel = new MessageChannel(); 11 | channel.port1.onmessage = (e) => { 12 | if (e.data === 'ping') { 13 | channel.port1.postMessage('pong'); 14 | } 15 | }; 16 | 17 | self.postMessage( 18 | { 19 | type: 'port', 20 | port: channel.port2, 21 | }, 22 | [channel.port2] 23 | ); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/test/unresolved_promise.js: -------------------------------------------------------------------------------- 1 | new Promise(() => {}); 2 | self.postMessage({ type: 'ready' }); 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declarationDir": "./dist/typings", 5 | "declaration": true, 6 | "noImplicitAny": true, 7 | "module": "esNext", 8 | "esModuleInterop": true, 9 | "target": "es6", 10 | "allowJs": false, 11 | "moduleResolution": "node", 12 | "sourceMap": true, 13 | "lib": ["es2015", "es5"], 14 | "typeRoots": ["node_modules/@types", "./typings"] 15 | }, 16 | "exclude": ["node_modules", "deno/"] 17 | } 18 | --------------------------------------------------------------------------------