├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── pull_request.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── logo.png ├── package.json ├── src ├── compile │ ├── build.js │ ├── detect-dependencies.js │ ├── index.js │ ├── install-dependencies.js │ ├── parse-dependency.js │ └── transform-dependencies.js ├── debug.js ├── index.js └── template │ ├── index.js │ └── serialize-error.js └── test ├── compile ├── detect-dependencies.js ├── parse-dependency.js └── transform-dependencies.js ├── error.js └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | max_line_length = 100 13 | indent_brace_style = 1TBS 14 | spaces_around_operators = true 15 | quote_type = auto 16 | 17 | [package.json] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: 'github-actions' 8 | directory: '/' 9 | schedule: 10 | # Check for updates to GitHub Actions every weekday 11 | interval: 'daily' 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | contributors: 10 | if: "${{ github.event.head_commit.message != 'build: contributors' }}" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | - name: Contributors 23 | run: | 24 | git config --global user.email ${{ secrets.GIT_EMAIL }} 25 | git config --global user.name ${{ secrets.GIT_USERNAME }} 26 | npm run contributors 27 | - name: Push changes 28 | run: | 29 | git push origin ${{ github.head_ref }} 30 | 31 | release: 32 | if: | 33 | !startsWith(github.event.head_commit.message, 'chore(release):') && 34 | !startsWith(github.event.head_commit.message, 'docs:') && 35 | !startsWith(github.event.head_commit.message, 'ci:') 36 | needs: [contributors] 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | with: 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | - name: Setup Node.js 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: lts/* 47 | - name: Setup PNPM 48 | uses: pnpm/action-setup@v4 49 | with: 50 | version: latest 51 | run_install: true 52 | - name: Test 53 | run: pnpm test 54 | - name: Report 55 | run: npx c8 report --reporter=text-lcov > coverage/lcov.info 56 | - name: Coverage 57 | uses: coverallsapp/github-action@main 58 | with: 59 | github-token: ${{ secrets.GITHUB_TOKEN }} 60 | - name: Release 61 | env: 62 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 63 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 64 | run: | 65 | git config --global user.email ${{ secrets.GIT_EMAIL }} 66 | git config --global user.name ${{ secrets.GIT_USERNAME }} 67 | git pull origin master 68 | pnpm run release 69 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: pull_request 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | test: 12 | if: github.ref != 'refs/heads/master' 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [lts/*, latest] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: Setup PNPM 27 | uses: pnpm/action-setup@v4 28 | with: 29 | version: latest 30 | run_install: true 31 | - name: Test 32 | run: pnpm test 33 | - name: Report 34 | run: npx c8 report --reporter=text-lcov > coverage/lcov.info 35 | - name: Coverage 36 | uses: coverallsapp/github-action@main 37 | with: 38 | github-token: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # npm 3 | ############################ 4 | node_modules 5 | npm-debug.log 6 | .node_history 7 | yarn.lock 8 | package-lock.json 9 | 10 | ############################ 11 | # tmp, editor & OS files 12 | ############################ 13 | .tmp 14 | *.swo 15 | *.swp 16 | *.swn 17 | *.swm 18 | .DS_Store 19 | *# 20 | *~ 21 | .idea 22 | *sublime* 23 | nbproject 24 | 25 | ############################ 26 | # Tests 27 | ############################ 28 | testApp 29 | coverage 30 | .nyc_output 31 | 32 | ############################ 33 | # Other 34 | ############################ 35 | .env 36 | .envrc 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | audit=false 2 | fund=false 3 | loglevel=error 4 | package-lock=false 5 | prefer-dedupe=true 6 | prefer-offline=false 7 | resolution-mode=highest 8 | save-prefix=~ 9 | save=false 10 | shamefully-hoist=true 11 | strict-peer-dependencies=false 12 | unsafe-perm=true 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### 0.1.32 (2025-06-09) 6 | 7 | ### 0.1.31 (2025-05-20) 8 | 9 | ### 0.1.30 (2025-04-29) 10 | 11 | ### [0.1.29](https://github.com/Kikobeats/isolated-function/compare/v0.1.28...v0.1.29) (2025-04-01) 12 | 13 | ### [0.1.28](https://github.com/Kikobeats/isolated-function/compare/v0.1.27...v0.1.28) (2025-03-13) 14 | 15 | ### 0.1.27 (2025-03-10) 16 | 17 | ### 0.1.26 (2024-10-28) 18 | 19 | ### 0.1.25 (2024-10-16) 20 | 21 | ### [0.1.24](https://github.com/Kikobeats/isolated-function/compare/v0.1.23...v0.1.24) (2024-09-23) 22 | 23 | ### 0.1.23 (2024-09-14) 24 | 25 | ### 0.1.22 (2024-09-14) 26 | 27 | ### 0.1.21 (2024-09-14) 28 | 29 | ### 0.1.20 (2024-09-14) 30 | 31 | ### 0.1.19 (2024-09-14) 32 | 33 | 34 | ### Features 35 | 36 | * detect builtin modules ([4c32e59](https://github.com/Kikobeats/isolated-function/commit/4c32e59c4acbb84aa119c482bbfaf57dc1a0c615)) 37 | 38 | ### 0.1.18 (2024-09-14) 39 | 40 | ### 0.1.17 (2024-09-13) 41 | 42 | ### 0.1.16 (2024-09-13) 43 | 44 | ### 0.1.15 (2024-09-13) 45 | 46 | ### 0.1.14 (2024-09-12) 47 | 48 | ### 0.1.13 (2024-09-12) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * preserver names ([d65993f](https://github.com/Kikobeats/isolated-function/commit/d65993f91a186e62a211cc8903f112d3849f8cbe)) 54 | 55 | ### 0.1.12 (2024-09-12) 56 | 57 | ### 0.1.11 (2024-09-12) 58 | 59 | ### 0.1.10 (2024-09-11) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * escape arguments ([927f52f](https://github.com/Kikobeats/isolated-function/commit/927f52fe326092c36ca2c06d105339acad35a7c5)) 65 | 66 | ### 0.1.9 (2024-09-11) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * serialize logs ([68a1358](https://github.com/Kikobeats/isolated-function/commit/68a135809c43db224120019f0e0b0074c6f8bda6)) 72 | 73 | ### 0.1.8 (2024-09-09) 74 | 75 | ### 0.1.7 (2024-09-09) 76 | 77 | ### 0.1.6 (2024-09-09) 78 | 79 | 80 | ### Features 81 | 82 | * add scoped packages support ([bda3eab](https://github.com/Kikobeats/isolated-function/commit/bda3eab471956152933e1094dff8795a4587f6ee)) 83 | 84 | ### 0.1.5 (2024-09-08) 85 | 86 | 87 | ### Features 88 | 89 | * add throwError ([016a8cb](https://github.com/Kikobeats/isolated-function/commit/016a8cbe7ab3d0b3fd91e0595b9ad205f6ecf720)) 90 | 91 | ### 0.1.4 (2024-09-07) 92 | 93 | ### 0.1.3 (2024-09-07) 94 | 95 | ### 0.1.2 (2024-09-02) 96 | 97 | ### 0.1.1 (2024-09-01) 98 | 99 | ## [0.1.0](https://github.com/Kikobeats/isolated-function/compare/v0.0.6...v0.1.0) (2024-09-01) 100 | 101 | ### 0.0.6 (2024-09-01) 102 | 103 | ### 0.0.5 (2024-09-01) 104 | 105 | ### 0.0.4 (2024-09-01) 106 | 107 | ### 0.0.3 (2024-08-31) 108 | 109 | ### 0.0.2 (2024-08-28) 110 | 111 | 112 | ### Bug Fixes 113 | 114 | * add missing dependency ([fca2b3e](https://github.com/Kikobeats/isolated-function/commit/fca2b3e926167594bcc6ad158b08d0fa5f5f9c0d)) 115 | 116 | ### 0.0.1 (2024-08-28) 117 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2024 microlink.io (microlink.io) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 5 |
6 |

isolated-function

7 | 10 | Coverage Status 14 | NPM Status 17 |

18 | 19 | - [Install](#install) 20 | - [Quickstart](#quickstart) 21 | - [Minimal privilege execution](#minimal-privilege-execution) 22 | - [Auto install dependencies](#auto-install-dependencies) 23 | - [Execution profiling](#execution-profiling) 24 | - [Resource limits](#resource-limits) 25 | - [Logging](#logging) 26 | - [Error handling](#error-handling) 27 | - [API](#api) 28 | - [isolatedFunction(code, \[options\])](#isolatedfunctioncode-options) 29 | - [code](#code) 30 | - [options](#options) 31 | - [memory](#memory) 32 | - [throwError](#throwerror) 33 | - [timeout](#timeout) 34 | - [tmpdir](#tmpdir) 35 | - [=\> (fn(\[...args\]), teardown())](#-fnargs-teardown) 36 | - [fn](#fn) 37 | - [teardown](#teardown) 38 | - [Environment Variables](#environment-variables) 39 | - [`ISOLATED_FUNCTIONS_MINIFY`](#isolated_functions_minify) 40 | - [`DEBUG`](#debug) 41 | - [License](#license) 42 | 43 | ## Install 44 | 45 | ```bash 46 | npm install isolated-function --save 47 | ``` 48 | 49 | ## Quickstart 50 | 51 | **isolated-function** is a modern solution for running untrusted code in Node.js. 52 | 53 | ```js 54 | const isolatedFunction = require('isolated-function') 55 | 56 | /* create an isolated-function, with resources limitation */ 57 | const [sum, teardown] = isolatedFunction((y, z) => y + z, { 58 | memory: 128, // in MB 59 | timeout: 10000 // in milliseconds 60 | }) 61 | 62 | /* interact with the isolated-function */ 63 | const { value, profiling } = await sum(3, 2) 64 | 65 | /* close resources associated with the isolated-function initialization */ 66 | await teardown() 67 | ``` 68 | 69 | ### Minimal privilege execution 70 | 71 | The hosted code runs in a separate process, with minimal privilege, using [Node.js permission model API](https://nodejs.org/api/permissions.html#permission-model). 72 | 73 | ```js 74 | const [fn, teardown] = isolatedFunction(() => { 75 | const fs = require('fs') 76 | fs.writeFileSync('/etc/passwd', 'foo') 77 | }) 78 | 79 | await fn() 80 | // => PermissionError: Access to 'FileSystemWrite' has been restricted. 81 | ``` 82 | 83 | If you exceed your limit, an error will occur. Any of the following interaction will throw an error: 84 | 85 | - Native modules 86 | - Child process 87 | - Worker Threads 88 | - Inspector protocol 89 | - File system access 90 | - WASI 91 | 92 | ### Auto install dependencies 93 | 94 | The hosted code is parsed for detecting `require`/`import` calls and install these dependencies: 95 | 96 | ```js 97 | const [isEmoji, teardown] = isolatedFunction(input => { 98 | /* this dependency only exists inside the isolated function */ 99 | const isEmoji = require('is-standard-emoji@1.0.0') // default is latest 100 | return isEmoji(input) 101 | }) 102 | 103 | await isEmoji('🙌') // => true 104 | await isEmoji('foo') // => false 105 | await teardown() 106 | ``` 107 | 108 | The dependencies, along with the hosted code, are bundled by [esbuild](https://esbuild.github.io/) into a single file that will be evaluated at runtime. 109 | 110 | ### Execution profiling 111 | 112 | Any hosted code execution will be run in their own separate process: 113 | 114 | ```js 115 | /** make a function to consume ~128MB */ 116 | const [fn, teardown] = isolatedFunction(() => { 117 | const storage = [] 118 | const oneMegabyte = 1024 * 1024 119 | while (storage.length < 78) { 120 | const array = new Uint8Array(oneMegabyte) 121 | for (let ii = 0; ii < oneMegabyte; ii += 4096) { 122 | array[ii] = 1 123 | } 124 | storage.push(array) 125 | } 126 | }) 127 | t.teardown(cleanup) 128 | 129 | const { value, profiling } = await fn() 130 | console.log(profiling) 131 | // { 132 | // memory: 128204800, 133 | // duration: 54.98325 134 | // } 135 | ``` 136 | 137 | Each execution has a profiling, which helps understand what happened. 138 | 139 | ### Resource limits 140 | 141 | You can limit a **isolated-function** by memory: 142 | 143 | ```js 144 | const [fn, teardown] = isolatedFunction(() => { 145 | const storage = [] 146 | const oneMegabyte = 1024 * 1024 147 | while (storage.length < 78) { 148 | const array = new Uint8Array(oneMegabyte) 149 | for (let ii = 0; ii < oneMegabyte; ii += 4096) { 150 | array[ii] = 1 151 | } 152 | storage.push(array) 153 | } 154 | }, { memory: 64 }) 155 | 156 | await fn() 157 | // => MemoryError: Out of memory 158 | ``` 159 | 160 | or by execution duration: 161 | 162 | ```js 163 | const [fn, teardown] = isolatedFunction(() => { 164 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) 165 | await delay(duration) 166 | return 'done' 167 | }, { timeout: 50 }) 168 | 169 | await fn(100) 170 | // => TimeoutError: Execution timed out 171 | ``` 172 | 173 | ### Logging 174 | 175 | The logs are collected into a `logging` object returned after the execution: 176 | 177 | ```js 178 | const [fn, teardown] = isolatedFunction(() => { 179 | console.log('console.log') 180 | console.info('console.info') 181 | console.debug('console.debug') 182 | console.warn('console.warn') 183 | console.error('console.error') 184 | return 'done' 185 | }) 186 | 187 | const { logging } await fn() 188 | 189 | console.log(logging) 190 | // { 191 | // log: ['console.log'], 192 | // info: ['console.info'], 193 | // debug: ['console.debug'], 194 | // warn: ['console.warn'], 195 | // error: ['console.error'] 196 | // } 197 | ``` 198 | 199 | ### Error handling 200 | 201 | Any error during **isolated-function** execution will be propagated: 202 | 203 | ```js 204 | const [fn, cleanup] = isolatedFunction(() => { 205 | throw new TypeError('oh no!') 206 | }) 207 | 208 | const result = await fn() 209 | // TypeError: oh no! 210 | ``` 211 | 212 | You can also return the error instead of throwing it with `{ throwError: false }`: 213 | 214 | ```js 215 | const [fn, cleanup] = isolatedFunction(() => { 216 | throw new TypeError('oh no!') 217 | }) 218 | 219 | const { isFullfiled, value } = await fn() 220 | 221 | if (!isFufilled) { 222 | console.error(value) 223 | // TypeError: oh no! 224 | } 225 | ``` 226 | 227 | ## API 228 | 229 | ### isolatedFunction(code, [options]) 230 | 231 | #### code 232 | 233 | _Required_
234 | Type: `function` 235 | 236 | The hosted function to run. 237 | 238 | #### options 239 | 240 | ##### memory 241 | 242 | Type: `number`
243 | Default: `Infinity` 244 | 245 | Set the function memory limit, in megabytes. 246 | 247 | ##### throwError 248 | 249 | Type: `boolean`
250 | Default: `false` 251 | 252 | When is `true`, it returns the error rather than throw it. 253 | 254 | The error will be accessible against `{ value: error, isFufilled: false }` object. 255 | 256 | Set the function memory limit, in megabytes. 257 | 258 | ##### timeout 259 | 260 | Type: `number`
261 | Default: `Infinity` 262 | 263 | Timeout after a specified amount of time, in milliseconds. 264 | 265 | ##### tmpdir 266 | 267 | Type: `function`
268 | 269 | It setup the temporal folder to be used for installing code dependencies. 270 | 271 | The default implementation is: 272 | 273 | ```js 274 | const tmpdir = async () => { 275 | const cwd = await fs.mkdtemp(path.join(require('os').tmpdir(), 'compile-')) 276 | await fs.mkdir(cwd, { recursive: true }) 277 | const cleanup = () => fs.rm(cwd, { recursive: true, force: true }) 278 | return { cwd, cleanup } 279 | } 280 | ``` 281 | 282 | ### => (fn([...args]), teardown()) 283 | 284 | #### fn 285 | 286 | Type: `function` 287 | 288 | The isolated function to execute. You can pass arguments over it. 289 | 290 | #### teardown 291 | 292 | Type: `function` 293 | 294 | A function to be called to release resources associated with the **isolated-function**. 295 | 296 | ## Environment Variables 297 | 298 | #### `ISOLATED_FUNCTIONS_MINIFY` 299 | 300 | Default: `true` 301 | 302 | When is `false`, it disabled minify the compiled code. 303 | 304 | #### `DEBUG` 305 | 306 | Pass `DEBUG=isolated-function` for enabling debug timing output. 307 | 308 | ## License 309 | 310 | **isolated-function** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/isolated-function/blob/master/LICENSE.md) License.
311 | Authored and maintained by Kiko Beats with help from [contributors](https://github.com/Kikobeats/isolated-function/contributors). 312 | 313 | > [kikobeats.com](https://kikobeats.com) · GitHub [@Kiko Beats](https://github.com/Kikobeats) · X [@Kikobeats](https://x.com/Kikobeats) 314 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kikobeats/isolated-function/19fe15c6af03d424035371e8c8e880a0de612d3a/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isolated-function", 3 | "description": "Runs untrusted code in a Node.js v8 sandbox.", 4 | "homepage": "https://github.com/Kikobeats/isolated-function", 5 | "version": "0.1.32", 6 | "main": "src/index.js", 7 | "exports": { 8 | ".": "./src/index.js" 9 | }, 10 | "author": { 11 | "email": "hello@microlink.io", 12 | "name": "microlink.io", 13 | "url": "https://microlink.io" 14 | }, 15 | "contributors": [ 16 | { 17 | "name": "Kiko Beats", 18 | "email": "josefrancisco.verdu@gmail.com" 19 | } 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/Kikobeats/isolated-function.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/Kikobeats/isolated-function/issues" 27 | }, 28 | "keywords": [ 29 | "isolated", 30 | "javascript", 31 | "js", 32 | "sandbox", 33 | "v8" 34 | ], 35 | "dependencies": { 36 | "@kikobeats/time-span": "~1.0.5", 37 | "acorn": "~8.15.0", 38 | "acorn-walk": "~8.3.4", 39 | "debug-logfmt": "~1.2.3", 40 | "ensure-error": "~3.0.1", 41 | "esbuild": "~0.25.1", 42 | "serialize-error": "8", 43 | "tinyspawn": "~1.5.0" 44 | }, 45 | "devDependencies": { 46 | "@commitlint/cli": "latest", 47 | "@commitlint/config-conventional": "latest", 48 | "@ksmithut/prettier-standard": "latest", 49 | "ava": "latest", 50 | "c8": "latest", 51 | "ci-publish": "latest", 52 | "finepack": "latest", 53 | "git-authors-cli": "latest", 54 | "github-generate-release": "latest", 55 | "nano-staged": "latest", 56 | "simple-git-hooks": "latest", 57 | "standard": "latest", 58 | "standard-version": "latest" 59 | }, 60 | "engines": { 61 | "node": ">= 20" 62 | }, 63 | "files": [ 64 | "src" 65 | ], 66 | "scripts": { 67 | "clean": "rm -rf node_modules", 68 | "contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true", 69 | "coverage": "c8 report --reporter=text-lcov > coverage/lcov.info", 70 | "lint": "standard", 71 | "postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)", 72 | "pretest": "npm run lint", 73 | "release": "standard-version -a", 74 | "release:github": "github-generate-release", 75 | "release:tags": "git push --follow-tags origin HEAD:master", 76 | "test": "c8 ava" 77 | }, 78 | "license": "MIT", 79 | "ava": { 80 | "workerThreads": false 81 | }, 82 | "commitlint": { 83 | "extends": [ 84 | "@commitlint/config-conventional" 85 | ], 86 | "rules": { 87 | "body-max-line-length": [ 88 | 0 89 | ] 90 | } 91 | }, 92 | "nano-staged": { 93 | "*.js": [ 94 | "prettier-standard", 95 | "standard --fix" 96 | ], 97 | "package.json": [ 98 | "finepack" 99 | ] 100 | }, 101 | "simple-git-hooks": { 102 | "commit-msg": "npx commitlint --edit", 103 | "pre-commit": "npx nano-staged" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/compile/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const esbuild = require('esbuild') 4 | 5 | const MINIFY = (() => { 6 | return process.env.ISOLATED_FUNCTIONS_MINIFY !== 'false' 7 | ? {} 8 | : { 9 | minifyWhitespace: true, 10 | minifyIdentifiers: false, 11 | minifySyntax: true 12 | } 13 | })() 14 | 15 | module.exports = ({ content, cwd }) => 16 | esbuild.build({ 17 | stdin: { 18 | contents: content, 19 | resolveDir: cwd, 20 | sourcefile: 'index.js' 21 | }, 22 | bundle: true, 23 | write: false, 24 | platform: 'node', 25 | legalComments: 'eof', 26 | target: 'es2023', 27 | ...MINIFY 28 | }) 29 | -------------------------------------------------------------------------------- /src/compile/detect-dependencies.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const walk = require('acorn-walk') 4 | const acorn = require('acorn') 5 | 6 | const parseDependency = require('./parse-dependency') 7 | 8 | // List of built-in Node.js modules 9 | // https://github.com/sindresorhus/builtin-modules/blob/main/builtin-modules.json 10 | const builtins = [ 11 | 'crypto', 12 | 'dgram', 13 | 'diagnostics_channel', 14 | 'dns', 15 | 'dns/promises', 16 | 'domain', 17 | 'events', 18 | 'fs', 19 | 'fs/promises', 20 | 'http', 21 | 'http2', 22 | 'https', 23 | 'inspector', 24 | 'inspector/promises', 25 | 'module', 26 | 'net', 27 | 'os', 28 | 'path', 29 | 'path/posix', 30 | 'path/win32', 31 | 'perf_hooks', 32 | 'process', 33 | 'punycode', 34 | 'querystring', 35 | 'readline', 36 | 'readline/promises', 37 | 'repl', 38 | 'stream', 39 | 'stream/consumers', 40 | 'stream/promises', 41 | 'stream/web', 42 | 'string_decoder', 43 | 'timers', 44 | 'timers/promises', 45 | 'tls', 46 | 'trace_events', 47 | 'tty', 48 | 'url', 49 | 'util', 50 | 'util/types', 51 | 'v8', 52 | 'vm', 53 | 'wasi', 54 | 'worker_threads', 55 | 'zlib' 56 | ] 57 | 58 | const isBuiltinModule = moduleName => { 59 | if (moduleName.startsWith('node:')) moduleName = moduleName.slice('node:'.length) 60 | return builtins.includes(moduleName) 61 | } 62 | 63 | module.exports = code => { 64 | const dependencies = new Set() 65 | 66 | // Parse the code into an AST 67 | const ast = acorn.parse(code, { ecmaVersion: 2023, sourceType: 'module' }) 68 | 69 | // Traverse the AST to find require and import statements 70 | walk.simple(ast, { 71 | CallExpression (node) { 72 | if ( 73 | node.callee.name === 'require' && 74 | node.arguments.length === 1 && 75 | node.arguments[0].type === 'Literal' 76 | ) { 77 | const dependency = node.arguments[0].value 78 | if (!isBuiltinModule(dependency)) dependencies.add(parseDependency(dependency)) 79 | } 80 | }, 81 | ImportDeclaration (node) { 82 | const source = node.source.value 83 | if (!isBuiltinModule(source)) dependencies.add(parseDependency(source)) 84 | } 85 | }) 86 | 87 | return Array.from(dependencies) 88 | } 89 | 90 | module.exports.parseDependency = parseDependency 91 | -------------------------------------------------------------------------------- /src/compile/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs/promises') 4 | const path = require('path') 5 | 6 | const transformDependencies = require('./transform-dependencies') 7 | const detectDependencies = require('./detect-dependencies') 8 | const installDependencies = require('./install-dependencies') 9 | const generateTemplate = require('../template') 10 | const { duration } = require('../debug') 11 | const build = require('./build') 12 | 13 | const tmpdirDefault = async () => { 14 | const cwd = await fs.mkdtemp(path.join(require('os').tmpdir(), 'compile-')) 15 | await fs.mkdir(cwd, { recursive: true }) 16 | const cleanup = () => fs.rm(cwd, { recursive: true, force: true }) 17 | return { cwd, cleanup } 18 | } 19 | 20 | module.exports = async (snippet, tmpdir = tmpdirDefault) => { 21 | const compiledTemplate = generateTemplate(snippet) 22 | const dependencies = detectDependencies(compiledTemplate) 23 | let content = transformDependencies(compiledTemplate) 24 | let cleanupPromise 25 | 26 | if (dependencies.length) { 27 | const { cwd, cleanup } = await duration('tmpdir', tmpdir) 28 | await duration('npm:install', () => installDependencies({ dependencies, cwd }), { 29 | dependencies 30 | }) 31 | const result = await duration('esbuild', () => build({ content, cwd })) 32 | content = result.outputFiles[0].text 33 | cleanupPromise = duration('tmpDir:cleanup', cleanup) 34 | } 35 | 36 | return { content, cleanupPromise } 37 | } 38 | 39 | module.exports.detectDependencies = detectDependencies 40 | module.exports.transformDependencies = transformDependencies 41 | -------------------------------------------------------------------------------- /src/compile/install-dependencies.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { execSync } = require('child_process') 4 | const { writeFile } = require('fs/promises') 5 | const $ = require('tinyspawn') 6 | const path = require('path') 7 | 8 | const install = (() => { 9 | try { 10 | execSync('which pnpm', { stdio: ['pipe', 'pipe', 'ignore'] }) 11 | .toString() 12 | .trim() 13 | return 'pnpm install --no-lockfile --silent' 14 | } catch { 15 | return 'npm install --no-package-lock --silent' 16 | } 17 | })() 18 | 19 | module.exports = async ({ dependencies, cwd }) => { 20 | await writeFile(path.join(cwd, 'package.json'), '{}') 21 | return $(`${install} ${dependencies.join(' ')}`, { cwd }) 22 | } 23 | -------------------------------------------------------------------------------- /src/compile/parse-dependency.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = dependency => { 4 | if (dependency.startsWith('@')) { 5 | // Handle scoped packages 6 | const slashIndex = dependency.indexOf('/') 7 | if (slashIndex !== -1) { 8 | const atVersionIndex = dependency.indexOf('@', slashIndex) 9 | if (atVersionIndex !== -1) { 10 | // Scoped package with version 11 | const packageName = dependency.substring(0, atVersionIndex) 12 | const version = dependency.substring(atVersionIndex + 1) 13 | return `${packageName}@${version}` 14 | } else { 15 | // Scoped package without explicit version 16 | return `${dependency}@latest` 17 | } 18 | } 19 | } else { 20 | // Non-scoped packages 21 | const atVersionIndex = dependency.indexOf('@') 22 | if (atVersionIndex !== -1) { 23 | // Non-scoped package with version 24 | const packageName = dependency.substring(0, atVersionIndex) 25 | const version = dependency.substring(atVersionIndex + 1) 26 | return `${packageName}@${version}` 27 | } 28 | } 29 | // Non-scoped package without explicit version 30 | return `${dependency}@latest` 31 | } 32 | -------------------------------------------------------------------------------- /src/compile/transform-dependencies.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const walk = require('acorn-walk') 4 | const acorn = require('acorn') 5 | 6 | module.exports = code => { 7 | const ast = acorn.parse(code, { ecmaVersion: 2023, sourceType: 'module' }) 8 | 9 | let newCode = '' 10 | let lastIndex = 0 11 | 12 | // Helper function to process and transform nodes 13 | const processNode = node => { 14 | if (node.type === 'Literal' && node.value.includes('@')) { 15 | // Check if it's a scoped module 16 | if (node.value.startsWith('@')) { 17 | // Handle scoped packages 18 | const slashIndex = node.value.indexOf('/') 19 | if (slashIndex !== -1) { 20 | const atVersionIndex = node.value.indexOf('@', slashIndex) 21 | const moduleName = 22 | atVersionIndex !== -1 ? node.value.substring(0, atVersionIndex) : node.value 23 | // Append code before this node 24 | newCode += code.substring(lastIndex, node.start) 25 | // Append transformed dependency 26 | newCode += `'${moduleName}'` 27 | } 28 | } else { 29 | // Handle non-scoped packages 30 | const [moduleName] = node.value.split('@') 31 | // Append code before this node 32 | newCode += code.substring(lastIndex, node.start) 33 | // Append transformed dependency 34 | newCode += `'${moduleName}'` 35 | } 36 | // Update lastIndex to end of current node 37 | lastIndex = node.end 38 | } 39 | } 40 | 41 | // Traverse the AST to find require and import declarations 42 | walk.simple(ast, { 43 | CallExpression (node) { 44 | if (node.callee.name === 'require' && node.arguments.length === 1) { 45 | processNode(node.arguments[0]) 46 | } 47 | }, 48 | ImportDeclaration (node) { 49 | processNode(node.source) 50 | } 51 | }) 52 | 53 | // Append remaining code after last modified dependency 54 | newCode += code.substring(lastIndex) 55 | 56 | return newCode 57 | } 58 | -------------------------------------------------------------------------------- /src/debug.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug-logfmt')('isolated-function') 4 | 5 | const duration = async (name, fn, props) => { 6 | const duration = debug.duration(name) 7 | 8 | return Promise.resolve(fn()) 9 | .then(result => { 10 | props ? duration(props) : duration() 11 | return result 12 | }) 13 | .catch(error => { 14 | props ? duration.error(props) : duration.duration.error() 15 | throw error 16 | }) 17 | } 18 | 19 | module.exports = { debug, duration } 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { deserializeError } = require('serialize-error') 4 | const timeSpan = require('@kikobeats/time-span')() 5 | const { Readable } = require('node:stream') 6 | const $ = require('tinyspawn') 7 | 8 | const compile = require('./compile') 9 | const { debug } = require('./debug') 10 | 11 | const createError = ({ name, message, ...props }) => { 12 | const error = new Error(message) 13 | error.name = name 14 | Object.assign(error, props) 15 | return error 16 | } 17 | 18 | const flags = ({ memory }) => { 19 | const flags = ['--disable-warning=ExperimentalWarning', '--experimental-permission'] 20 | if (memory) flags.push(`--max-old-space-size=${memory}`) 21 | return flags.join(' ') 22 | } 23 | 24 | module.exports = (snippet, { tmpdir, timeout, memory, throwError = true } = {}) => { 25 | if (!['function', 'string'].includes(typeof snippet)) throw new TypeError('Expected a function') 26 | const compilePromise = compile(snippet, tmpdir) 27 | 28 | const fn = async (...args) => { 29 | let duration 30 | try { 31 | const { content, cleanupPromise } = await compilePromise 32 | 33 | duration = timeSpan() 34 | const subprocess = $('node', ['-', JSON.stringify(args)], { 35 | env: { 36 | ...process.env, 37 | NODE_OPTIONS: flags({ memory }) 38 | }, 39 | timeout, 40 | killSignal: 'SIGKILL' 41 | }) 42 | Readable.from(content).pipe(subprocess.stdin) 43 | const [{ stdout }] = await Promise.all([subprocess, cleanupPromise]) 44 | const { isFulfilled, value, profiling, logging } = JSON.parse(stdout) 45 | profiling.duration = duration() 46 | debug('node', { 47 | duration: `${Math.round(profiling.duration / 100)}s`, 48 | memory: `${Math.round(profiling.memory / (1024 * 1024))}MiB` 49 | }) 50 | 51 | return isFulfilled 52 | ? { isFulfilled, value, profiling, logging } 53 | : throwError 54 | ? (() => { 55 | throw deserializeError(value) 56 | })() 57 | : { isFulfilled: false, value: deserializeError(value), profiling, logging } 58 | } catch (error) { 59 | if (error.signalCode === 'SIGTRAP') { 60 | throw createError({ 61 | name: 'MemoryError', 62 | message: 'Out of memory', 63 | profiling: { duration: duration() } 64 | }) 65 | } 66 | 67 | if (error.signalCode === 'SIGKILL') { 68 | throw createError({ 69 | name: 'TimeoutError', 70 | message: 'Execution timed out', 71 | profiling: { duration: duration() } 72 | }) 73 | } 74 | 75 | if (error.code === 'ERR_ACCESS_DENIED') { 76 | throw createError({ 77 | name: 'PermissionError', 78 | message: `Access to '${error.permission}' has been restricted`, 79 | profiling: { duration: duration() } 80 | }) 81 | } 82 | 83 | throw error 84 | } 85 | } 86 | 87 | const cleanup = async () => (await compilePromise).cleanup 88 | 89 | return [fn, cleanup] 90 | } 91 | -------------------------------------------------------------------------------- /src/template/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const SERIALIZE_ERROR = require('./serialize-error') 4 | 5 | module.exports = snippet => ` 6 | const args = JSON.parse(process.argv[2]) 7 | 8 | /* https://github.com/Kikobeats/null-prototype-object */ 9 | const logging = new (/* @__PURE__ */ (() => { let e = function(){}; return e.prototype = Object.create(null), Object.freeze(e.prototype), e })()); 10 | 11 | for (const method of ['log', 'info', 'debug', 'warn', 'error']) { 12 | console[method] = function (...args) { 13 | logging[method] === undefined ? logging[method] = [args] : logging[method].push(args) 14 | } 15 | } 16 | 17 | ;(async (send) => { 18 | process.stdout.write = function () {} 19 | let value 20 | let isFulfilled 21 | 22 | try { 23 | value = await (${snippet.toString()})(...args) 24 | isFulfilled = true 25 | } catch (error) { 26 | value = ${SERIALIZE_ERROR}(error) 27 | isFulfilled = false 28 | } finally { 29 | send(JSON.stringify({ 30 | isFulfilled, 31 | logging, 32 | value, 33 | profiling: { memory: process.memoryUsage().rss } 34 | })) 35 | } 36 | })(process.stdout.write.bind(process.stdout))` 37 | -------------------------------------------------------------------------------- /src/template/serialize-error.js: -------------------------------------------------------------------------------- 1 | // serialize-error@8.1.0 2 | module.exports = `(() => { 3 | 'use strict' 4 | 5 | const commonProperties = [ 6 | { property: 'name', enumerable: false }, 7 | { property: 'message', enumerable: false }, 8 | { property: 'stack', enumerable: false }, 9 | { property: 'code', enumerable: true } 10 | ] 11 | 12 | const isCalled = Symbol('.toJSON called') 13 | 14 | const toJSON = from => { 15 | from[isCalled] = true 16 | const json = from.toJSON() 17 | delete from[isCalled] 18 | return json 19 | } 20 | 21 | const destroyCircular = ({ 22 | from, 23 | seen, 24 | to_, 25 | forceEnumerable, 26 | maxDepth, 27 | depth 28 | }) => { 29 | const to = to_ || (Array.isArray(from) ? [] : {}) 30 | 31 | seen.push(from) 32 | 33 | if (depth >= maxDepth) { 34 | return to 35 | } 36 | 37 | if (typeof from.toJSON === 'function' && from[isCalled] !== true) { 38 | return toJSON(from) 39 | } 40 | 41 | for (const [key, value] of Object.entries(from)) { 42 | if (typeof Buffer === 'function' && Buffer.isBuffer(value)) { 43 | to[key] = '[object Buffer]' 44 | continue 45 | } 46 | 47 | if (typeof value === 'function') { 48 | continue 49 | } 50 | 51 | if (!value || typeof value !== 'object') { 52 | to[key] = value 53 | continue 54 | } 55 | 56 | if (!seen.includes(from[key])) { 57 | depth++ 58 | 59 | to[key] = destroyCircular({ 60 | from: from[key], 61 | seen: seen.slice(), 62 | forceEnumerable, 63 | maxDepth, 64 | depth 65 | }) 66 | continue 67 | } 68 | 69 | to[key] = '[Circular]' 70 | } 71 | 72 | for (const { property, enumerable } of commonProperties) { 73 | if (typeof from[property] === 'string') { 74 | Object.defineProperty(to, property, { 75 | value: from[property], 76 | enumerable: forceEnumerable ? true : enumerable, 77 | configurable: true, 78 | writable: true 79 | }) 80 | } 81 | } 82 | 83 | return to 84 | } 85 | 86 | return (value, options = {}) => { 87 | const { maxDepth = Number.POSITIVE_INFINITY } = options 88 | 89 | if (typeof value === 'object' && value !== null) { 90 | return destroyCircular({ 91 | from: value, 92 | seen: [], 93 | forceEnumerable: true, 94 | maxDepth, 95 | depth: 0 96 | }) 97 | } 98 | 99 | // People sometimes throw things besides Error objects… 100 | if (typeof value === 'function') { 101 | return \`[Function: \${value.name || 'anonymous'}]\` 102 | } 103 | 104 | return value 105 | } 106 | 107 | })()` 108 | -------------------------------------------------------------------------------- /test/compile/detect-dependencies.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | 5 | const { detectDependencies } = require('../../src/compile') 6 | 7 | test('detect requires', t => { 8 | const code = ` 9 | const isEmoji = require('is-standard-emoji@1.0.0'); 10 | const puppeteer = require('@cloudflare/puppeteer@1.2.3') 11 | const timeSpan = require('@kikobeats/timespan@latest') 12 | const isNumber = require('is-number@latest'); 13 | const isString = require('is-string'); 14 | ` 15 | t.deepEqual(detectDependencies(code), [ 16 | 'is-standard-emoji@1.0.0', 17 | '@cloudflare/puppeteer@1.2.3', 18 | '@kikobeats/timespan@latest', 19 | 'is-number@latest', 20 | 'is-string@latest' 21 | ]) 22 | }) 23 | 24 | test('detect imports', t => { 25 | const code = ` 26 | import puppeteer from '@cloudflare/puppeteer@1.2.3'; 27 | import isEmoji from 'is-standard-emoji@1.0.0'; 28 | import isNumber from 'is-number'; 29 | import isString from 'is-string'; 30 | ` 31 | 32 | t.deepEqual(detectDependencies(code), [ 33 | '@cloudflare/puppeteer@1.2.3', 34 | 'is-standard-emoji@1.0.0', 35 | 'is-number@latest', 36 | 'is-string@latest' 37 | ]) 38 | }) 39 | 40 | test('detect builtin modules', t => { 41 | { 42 | const code = ` 43 | const fs = require('node:fs'); 44 | const http = require('http'); 45 | const https = require('https'); 46 | const path = require('path'); 47 | const url = require('url'); 48 | const fake = require('node:fake'); 49 | ` 50 | 51 | t.deepEqual(detectDependencies(code), ['node:fake@latest']) 52 | } 53 | { 54 | const code = ` 55 | import fs from 'node:fs'; 56 | import http from 'http'; 57 | import https from 'https'; 58 | import path from 'path'; 59 | import url from 'url'; 60 | import fake from 'node:fake'; 61 | ` 62 | t.deepEqual(detectDependencies(code), ['node:fake@latest']) 63 | } 64 | }) 65 | -------------------------------------------------------------------------------- /test/compile/parse-dependency.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | 5 | const parseDependency = require('../../src/compile/parse-dependency') 6 | 7 | test('dependency with no version', t => { 8 | t.is(parseDependency('is-number'), 'is-number@latest') 9 | }) 10 | 11 | test('dependency with version', t => { 12 | t.is(parseDependency('is-number@1.2.3'), 'is-number@1.2.3') 13 | }) 14 | 15 | test('scoped dependency with no version', t => { 16 | t.is(parseDependency('@kikobeats/is-number'), '@kikobeats/is-number@latest') 17 | }) 18 | 19 | test('scoped dependency with version', t => { 20 | t.is(parseDependency('@kikobeats/is-number@1.2.3'), '@kikobeats/is-number@1.2.3') 21 | }) 22 | -------------------------------------------------------------------------------- /test/compile/transform-dependencies.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | 5 | const { transformDependencies } = require('../../src/compile') 6 | 7 | test('detect requires', t => { 8 | const code = ` 9 | const isEmoji = require('is-standard-emoji@1.0.0'); 10 | const puppeteer = require('@cloudflare/puppeteer@1.2.3') 11 | const isNumber = require('is-number'); 12 | const isString = require('is-string');` 13 | 14 | t.deepEqual( 15 | transformDependencies(code), 16 | ` 17 | const isEmoji = require('is-standard-emoji'); 18 | const puppeteer = require('@cloudflare/puppeteer') 19 | const isNumber = require('is-number'); 20 | const isString = require('is-string');` 21 | ) 22 | }) 23 | 24 | test('detect imports', t => { 25 | const code = ` 26 | import puppeteer from '@cloudflare/puppeteer@1.2.3'; 27 | import isEmoji from 'is-standard-emoji@1.0.0'; 28 | import isNumber from 'is-number'; 29 | import isString from 'is-string';` 30 | 31 | t.deepEqual( 32 | transformDependencies(code), 33 | ` 34 | import puppeteer from '@cloudflare/puppeteer'; 35 | import isEmoji from 'is-standard-emoji'; 36 | import isNumber from 'is-number'; 37 | import isString from 'is-string';` 38 | ) 39 | }) 40 | -------------------------------------------------------------------------------- /test/error.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const test = require('ava') 4 | 5 | const isolatedFunction = require('..') 6 | 7 | test('throw an error if snippet is not a function or string', t => { 8 | t.throws( 9 | () => { 10 | isolatedFunction(NaN) 11 | }, 12 | { message: 'Expected a function' } 13 | ) 14 | }) 15 | 16 | test('throw code errors by default', async t => { 17 | const [fn, cleanup] = isolatedFunction(() => { 18 | throw new TypeError('oops') 19 | }) 20 | 21 | t.teardown(cleanup) 22 | await t.throwsAsync(fn(), { message: 'oops' }) 23 | }) 24 | 25 | test('pass `throwError: false`', async t => { 26 | { 27 | const [fn, cleanup] = isolatedFunction( 28 | () => { 29 | throw new TypeError('oops') 30 | }, 31 | { throwError: false } 32 | ) 33 | 34 | t.teardown(cleanup) 35 | const result = await fn() 36 | 37 | t.is(result.isFulfilled, false) 38 | t.is(result.value.message, 'oops') 39 | t.is(result.value.name, 'TypeError') 40 | t.is(typeof result.profiling, 'object') 41 | } 42 | { 43 | const [fn, cleanup] = isolatedFunction( 44 | () => { 45 | throw 'oops' 46 | }, 47 | { throwError: false } 48 | ) 49 | 50 | t.teardown(cleanup) 51 | const result = await fn() 52 | 53 | t.is(result.isFulfilled, false) 54 | t.is(result.value.message, '"oops"') 55 | t.is(result.value.name, 'NonError') 56 | t.is(typeof result.profiling, 'object') 57 | } 58 | }) 59 | 60 | test('handle timeout', async t => { 61 | const [fn, cleanup] = isolatedFunction( 62 | () => { 63 | let i = 0 64 | while (true) { 65 | i += 1 66 | } 67 | }, 68 | { timeout: 100 } 69 | ) 70 | t.teardown(cleanup) 71 | 72 | const error = await t.throwsAsync(fn()) 73 | 74 | t.is(error.message, 'Execution timed out') 75 | t.is(typeof error.profiling.duration, 'number') 76 | }) 77 | 78 | test('handle OOM', async t => { 79 | const [fn, cleanup] = isolatedFunction( 80 | () => { 81 | const storage = [] 82 | const twoMegabytes = 1024 * 1024 * 2 83 | while (true) { 84 | const array = new Uint8Array(twoMegabytes) 85 | for (let ii = 0; ii < twoMegabytes; ii += 4096) { 86 | array[ii] = 1 // we have to put something in the array to flush to real memory 87 | } 88 | storage.push(array) 89 | } 90 | }, 91 | { memory: 1 } 92 | ) 93 | t.teardown(cleanup) 94 | 95 | const error = await t.throwsAsync(fn()) 96 | 97 | t.is(error.message, 'Out of memory') 98 | t.is(typeof error.profiling.duration, 'number') 99 | }) 100 | 101 | test('handle filesystem permissions', async t => { 102 | { 103 | const [fn, cleanup] = isolatedFunction(() => { 104 | const fs = require('fs') 105 | fs.readFileSync('/etc/passwd', 'utf8') 106 | }) 107 | 108 | t.teardown(cleanup) 109 | 110 | const error = await t.throwsAsync(fn()) 111 | 112 | t.is(error.message, "Access to 'FileSystemRead' has been restricted") 113 | } 114 | { 115 | const [fn, cleanup] = isolatedFunction(() => { 116 | const fs = require('fs') 117 | fs.writeFileSync('/etc/passwd', 'foo') 118 | }) 119 | 120 | t.teardown(cleanup) 121 | 122 | const error = await t.throwsAsync(fn()) 123 | 124 | t.is(error.message, "Access to 'FileSystemWrite' has been restricted") 125 | } 126 | }) 127 | 128 | test('handle child process', async t => { 129 | { 130 | const [fn, cleanup] = isolatedFunction(() => { 131 | const { execSync } = require('child_process') 132 | return execSync('echo hello').toString() 133 | }) 134 | 135 | t.teardown(cleanup) 136 | 137 | const error = await t.throwsAsync(fn()) 138 | 139 | t.is(error.message, "Access to 'ChildProcess' has been restricted") 140 | } 141 | }) 142 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | 5 | const isolatedFunction = require('..') 6 | 7 | const run = promise => Promise.resolve(promise).then(({ value }) => value) 8 | 9 | test('runs plain javascript', async t => { 10 | { 11 | const [sum, cleanup] = isolatedFunction(() => 2 + 2) 12 | t.teardown(cleanup) 13 | t.is(await run(sum()), 4) 14 | } 15 | { 16 | const [sum, cleanup] = isolatedFunction(String(() => 2 + 2)) 17 | t.teardown(cleanup) 18 | t.is(await run(sum()), 4) 19 | } 20 | { 21 | const [sum, cleanup] = isolatedFunction('() => 2 + 2') 22 | t.teardown(cleanup) 23 | t.is(await run(sum()), 4) 24 | } 25 | { 26 | const [sum, cleanup] = isolatedFunction((x, y) => x + y) 27 | t.teardown(cleanup) 28 | t.is(await run(sum(2, 2)), 4) 29 | } 30 | { 31 | const [fn, cleanup] = isolatedFunction(() => 2 + 2) 32 | t.teardown(cleanup) 33 | t.is(await run(fn()), 4) 34 | } 35 | { 36 | const [fn, cleanup] = isolatedFunction(function () { 37 | return 2 + 2 38 | }) 39 | t.teardown(cleanup) 40 | t.is(await run(fn()), 4) 41 | } 42 | }) 43 | 44 | test('capture logs', async t => { 45 | const [fn, cleanup] = isolatedFunction(() => { 46 | console.log('console.log', { foo: 'bar' }) 47 | console.info('console.info') 48 | console.debug('console.debug') 49 | console.warn('console.warn') 50 | console.error('console.error') 51 | return 'done' 52 | }) 53 | 54 | t.teardown(cleanup) 55 | 56 | const { value, logging } = await fn() 57 | t.is(value, 'done') 58 | t.deepEqual(logging, { 59 | log: [ 60 | [ 61 | 'console.log', 62 | { 63 | foo: 'bar' 64 | } 65 | ] 66 | ], 67 | info: [['console.info']], 68 | debug: [['console.debug']], 69 | warn: [['console.warn']], 70 | error: [['console.error']] 71 | }) 72 | }) 73 | 74 | test('prevent to write to process.stdout', async t => { 75 | const [fn, cleanup] = isolatedFunction(() => { 76 | process.stdout.write('disturbing') 77 | return 'done' 78 | }) 79 | 80 | t.teardown(cleanup) 81 | 82 | const { value, logging } = await fn() 83 | t.is(value, 'done') 84 | t.deepEqual(logging, {}) 85 | }) 86 | 87 | test('resolve require dependencies', async t => { 88 | const [fn, cleanup] = isolatedFunction(emoji => { 89 | const isEmoji = require('is-standard-emoji@1.0.0') 90 | return isEmoji(emoji) 91 | }) 92 | 93 | t.teardown(cleanup) 94 | 95 | t.is(await run(fn('🙌')), true) 96 | t.is(await run(fn('foo')), false) 97 | }) 98 | 99 | test('runs async code', async t => { 100 | const [fn, cleanup] = isolatedFunction(async duration => { 101 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) 102 | await delay(duration) 103 | return 'done' 104 | }) 105 | 106 | t.teardown(cleanup) 107 | t.is(await run(fn(200)), 'done') 108 | }) 109 | 110 | test('escape arguments', async t => { 111 | const [fn, cleanup] = isolatedFunction((...args) => args.length) 112 | t.teardown(cleanup) 113 | 114 | const result = await run( 115 | fn({ 116 | device: { 117 | userAgent: 118 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.89 Safari/537.36', 119 | viewport: { 120 | width: 1280, 121 | height: 800, 122 | deviceScaleFactor: 2, 123 | isMobile: false, 124 | hasTouch: false, 125 | isLandscape: false 126 | } 127 | } 128 | }) 129 | ) 130 | 131 | t.is(result, 1) 132 | }) 133 | 134 | test('memory profiling', async t => { 135 | const [fn, cleanup] = isolatedFunction(() => { 136 | const storage = [] 137 | const oneMegabyte = 1024 * 1024 138 | while (storage.length < 78) { 139 | const array = new Uint8Array(oneMegabyte) 140 | for (let i = 0; i < oneMegabyte; i += 4096) { 141 | array[i] = 1 // we have to put something in the array to flush to real memory 142 | } 143 | storage.push(array) 144 | } 145 | }) 146 | t.teardown(cleanup) 147 | 148 | const { value, profiling } = await fn() 149 | 150 | t.is(value, undefined) 151 | t.is(typeof profiling.memory, 'number') 152 | t.is(typeof profiling.duration, 'number') 153 | }) 154 | --------------------------------------------------------------------------------