├── .gitignore ├── LICENSE ├── index.js ├── lib ├── context.js ├── hook.mjs ├── include.js ├── loader.mjs ├── mockery.js ├── preload.js └── sanity.js ├── package.json └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2020 David Mark Clements 2 | 3 | Permission is hereby granted, 4 | free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { pathToFileURL } = require('url') 3 | const { join } = require('path') 4 | const { promisify } = require('util') 5 | const { readFile } = require('fs').promises 6 | const { Worker } = require('worker_threads') 7 | const mockery = require('./lib/mockery') 8 | const cp = require('child_process') 9 | 10 | async function lazaretto ({ esm = false, entry, scope = [], context = {}, mock, teardown, returnOnError = false, prefix = '' } = {}) { 11 | if (Array.isArray(scope) === false) scope = [scope] 12 | 13 | if (returnOnError === true) returnOnError = (err) => err 14 | 15 | const scoping = scope.reduce((scoping, ref) => { 16 | if (typeof ref === 'string') scoping += `${ref},` 17 | if (ref !== null && typeof ref === 'object') { 18 | scoping += `${ref[0]}:${ref[1]}` 19 | } 20 | return scoping 21 | }, '{') + '}' 22 | const entryUrl = pathToFileURL(entry).href 23 | const include = esm ? 'await import' : 'require' 24 | const mocking = mockery(mock, { esm, entry }) 25 | const shims = esm 26 | ? `import.meta.url = '${entryUrl}';global[Symbol.for('kLazarettoImportMeta')] = import.meta;` 27 | : `module.id = '.'; module.parent = null; require.main = module;${mocking ? mocking.scopeMocks() : ''};` 28 | const overrides = ` 29 | global.process.chdir = () => { 30 | process.stderr.write('Lazaretto: process.chdir is not supported\\n') 31 | } 32 | global.process.abort = () => { 33 | process.stderr.write('Lazeretto: Abort is not supported but will exit\\n' + Error().stack + '\\n') 34 | process.exit(1) 35 | } 36 | ` 37 | const comms = ` 38 | { 39 | const vm = ${include}('vm') 40 | const wt = ${include}('worker_threads') 41 | const { createInclude } = ${include}('${require.resolve('./lib/include')}') 42 | const include = createInclude('${entry}') 43 | const cjs = typeof arguments !== 'undefined' 44 | async function cmds ([cmd, args] = []) { 45 | try { 46 | if (cmd === 'init') this.postMessage([cmd]) 47 | if (cmd === 'sync') { 48 | this.postMessage([cmd, wt.workerData.context, Array.from(global[Symbol.for('kLazarettoMocksLoaded')])]) 49 | } 50 | if (cmd === 'expr') { 51 | const expr = args.shift() 52 | const script = new vm.Script(expr, {filename: 'Lazaretto'}) 53 | const thisContext = Object.getOwnPropertyNames(global).reduce((o, k) => { o[k] = global[k];return o}, {}) 54 | let exports = null 55 | const mod = cjs ? null : await import('data:lazaretto;esm') 56 | if (mod) { 57 | const target = typeof mod.default === 'function' ? mod.default : mod 58 | exports = new Proxy(target, { get (o, p) { 59 | return 'p' in mod ? mod[p] : (mod.default ? mod.default[p] : undefined) 60 | }}) 61 | } else { 62 | exports = module.exports 63 | } 64 | let result = await script.runInNewContext({...thisContext,...(${scoping}), exports, $$$: { include, args, context: global[Symbol.for('kLazarettoContext')]}}) 65 | if (result === exports) { 66 | result = mod || module.exports 67 | } 68 | this.postMessage([cmd, result]) 69 | } 70 | } catch (err) { 71 | if (err.name === 'DataCloneError') { 72 | const e = Error(err.message) 73 | e.name = 'DataCloneError' 74 | throw e 75 | } 76 | 77 | this.postMessage(['err', err, cmd, ...args]) 78 | 79 | } 80 | } 81 | if (wt.parentPort) wt.parentPort.on('message', cmds) 82 | } 83 | `.split('\n').map((s) => s.trim() + ';').join('') 84 | const contents = await readFile(entry, 'utf8') 85 | 86 | const code = `${prefix}${overrides}${shims}${comms}${contents}` 87 | const base64 = Buffer.from(code).toString('base64') 88 | const exec = esm 89 | ? new URL(`data:esm;${entry},${base64}`) 90 | : new URL(`data:cjs;${entry},${base64}`) 91 | 92 | if (esm) { 93 | process.env.LAZARETTO_LOADER_DATA_URL = exec.href 94 | } 95 | process.env.LAZARETTO_LOADER_ENTRY = entry 96 | 97 | if (mocking) process.env.LAZARETTO_OVERRIDES = JSON.stringify(mocking) 98 | const worker = new Worker(exec, { 99 | workerData: { context }, 100 | execArgv: [ 101 | ...process.execArgv, 102 | '--no-warnings', 103 | `--import=${join(__dirname, 'lib', 'hook.mjs')}` 104 | ] 105 | }) 106 | let online = false 107 | 108 | worker.on('message', ([cmd, o]) => { 109 | if (cmd !== 'context') return 110 | Object.assign(context, o) 111 | }) 112 | 113 | worker.on('exit', () => { 114 | online = false 115 | }) 116 | 117 | await hook('init') 118 | 119 | online = true 120 | 121 | const sandbox = async (code, ...args) => { 122 | if (returnOnError === false) { 123 | const [result] = await hook('expr', [code, ...args]) 124 | return result 125 | } 126 | try { 127 | const [result] = await hook('expr', [code, ...args]) 128 | return result 129 | } catch (err) { 130 | return returnOnError(err) 131 | } 132 | } 133 | 134 | sandbox.context = context 135 | sandbox.mocksLoaded = null 136 | 137 | sandbox.fin = async () => { 138 | if (esm) { 139 | delete process.env.LAZARETTO_LOADER_DATA_URL 140 | delete process.env.LAZARETTO_LOADER_ENTRY 141 | } 142 | if (online === false) return 143 | const [ctx, mocksLoaded] = await hook('sync') 144 | Object.assign(context, ctx) 145 | sandbox.mocksLoaded = mocksLoaded 146 | await worker.terminate() 147 | } 148 | 149 | if (typeof teardown === 'function') teardown(sandbox.fin) 150 | 151 | return sandbox 152 | 153 | function hook (cmd, args = []) { 154 | return promisify((worker, cb) => { 155 | let done = false 156 | const msg = ([cmdIn, ...args]) => { 157 | if (cmdIn === 'err') { 158 | error(args[0]) 159 | return 160 | } 161 | if (cmdIn !== cmd) return 162 | worker.removeListener('error', error) 163 | if (done === false) { 164 | cb(null, args) 165 | } 166 | done = true 167 | } 168 | const error = (err) => { 169 | worker.removeListener('message', msg) 170 | done = true 171 | 172 | const stack = err.stack.split('\n') 173 | let frame = esm 174 | ? stack.find((frame) => /data:text\/javascript;base64,/.test(frame)) 175 | : stack[0] 176 | 177 | let restack = esm 178 | ? err.stack.replace(RegExp(`data:text/javascript;base64,${base64}`.replace(/([+|/])/g, '\\$1'), 'gm'), entry) 179 | : err.stack.replace(/\[worker eval\]:/gm, entry + ':') 180 | 181 | if (esm && !frame && err instanceof SyntaxError) { 182 | const { status, stderr } = cp.spawnSync(process.execPath, ['-c', entry], { encoding: 'utf8' }) 183 | if (status) { 184 | frame = ':' + stderr.split('\n')[0] 185 | restack = `${stack[0]}\n at ${entry}:${frame.split(':')[2]}` 186 | } 187 | } 188 | 189 | if (frame) { 190 | const line = esm ? +frame.split(':')[2] : +frame.split(':')[1] 191 | const escrx = require('escape-string-regexp') // lazy require 192 | const msgRx = RegExp(escrx(err.message)) 193 | err = Object.create(err) 194 | Object.defineProperty(err, 'stack', { value: restack }) 195 | err.line = line 196 | err.esm = esm 197 | if (err.name === 'DataCloneError' && cmd === 'expr') { 198 | err.message = `Lazaretto Sandbox Error: \`${args[0].trim()}\` is not clonable: 199 | ${err.message} 200 | See https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm 201 | ` 202 | } else { 203 | const banner = stack.find((line) => msgRx.test(line)) 204 | err.message = 'Lazaretto Sandbox Error: \n' + banner 205 | const diagnosis = contents.split('\n') 206 | diagnosis[line - 1] = `${diagnosis[line - 1]} <--- ### ${banner} ###` 207 | 208 | const nctx = 5 209 | const from = line - nctx < 0 ? 0 : line - nctx 210 | const to = line + nctx > diagnosis.length - 1 ? diagnosis.length - 1 : line + nctx 211 | err.diagnosis = diagnosis.slice(from, to).map((line, n) => `${n + from}: ${line}`).join('\n') 212 | } 213 | } 214 | cb(err) 215 | } 216 | 217 | worker.on('message', msg) 218 | worker.once('error', error) 219 | worker.postMessage([cmd, args]) 220 | })(worker) 221 | } 222 | } 223 | module.exports = lazaretto 224 | -------------------------------------------------------------------------------- /lib/context.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const context = async () => { 4 | const { parentPort, workerData } = await import('worker_threads') 5 | const set = (o, p, v) => { 6 | o[p] = v 7 | if (parentPort) parentPort.postMessage(['context', o]) 8 | return true 9 | } 10 | 11 | return new Proxy(workerData ? workerData.context : {}, { set }) 12 | } 13 | 14 | module.exports = context 15 | -------------------------------------------------------------------------------- /lib/hook.mjs: -------------------------------------------------------------------------------- 1 | import { register } from 'module' 2 | import preload from './preload.js' 3 | 4 | // got to preload here, but also need to comm mocks loaded between threads 5 | global[Symbol.for('kLazarettoMocks')] = (await preload()).mocks 6 | 7 | register('./loader.mjs', import.meta.url) 8 | -------------------------------------------------------------------------------- /lib/include.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { createRequire } = require('module') 3 | const include = (entry) => { 4 | const { resolve } = createRequire(entry) 5 | return async (ns) => { 6 | const mod = await import(resolve(ns)) 7 | const target = typeof mod.default === 'function' ? mod.default : mod 8 | return new Proxy(target, { 9 | get (o, p) { 10 | return mod[p] || (mod.default ? mod.default[p] : undefined) 11 | } 12 | }) 13 | } 14 | } 15 | 16 | module.exports = include 17 | module.exports.createInclude = include 18 | -------------------------------------------------------------------------------- /lib/loader.mjs: -------------------------------------------------------------------------------- 1 | import Module from 'module' 2 | import path from 'path' 3 | import { readFile } from 'fs/promises' 4 | import { pathToFileURL, fileURLToPath } from 'url' 5 | import preload from './preload.js' 6 | import sanity from './sanity.js' 7 | 8 | const { 9 | LAZARETTO_LOADER_ENTRY, 10 | LAZARETTO_LOADER_DATA_URL 11 | } = process.env 12 | 13 | const { mocks, builtins, libraries } = await preload() 14 | 15 | const relativeDir = path.dirname(LAZARETTO_LOADER_ENTRY) 16 | const entryUrl = pathToFileURL(LAZARETTO_LOADER_ENTRY).href 17 | const mocksLoaded = global[Symbol.for('kLazarettoMocksLoaded')] 18 | let esmEntry = null 19 | 20 | global[Symbol.for('kLazarettoMocks')] = mocks 21 | 22 | export function resolve (specifier, ctx, nextResolve) { 23 | if (/data:cjs/.test(specifier)) { 24 | const [, contents] = specifier.split(';') 25 | const [file, encoded] = contents.split(',') 26 | const code = Buffer.from(encoded, 'base64').toString() 27 | const { _compile } = Module.prototype 28 | Module.prototype._compile = function (content, filename) { 29 | if (filename === file) content = code 30 | return _compile.call(this, content, filename) 31 | } 32 | return { 33 | format: 'commonjs', 34 | url: 'file://' + file + '#cjs-' + encoded, 35 | shortCircuit: true 36 | } 37 | } 38 | if (/data:esm/.test(specifier)) { 39 | const [, contents] = specifier.split(';') 40 | const [, code] = contents.split(',') 41 | return { 42 | format: 'module', 43 | url: `esm:${code}`, 44 | shortCircuit: true 45 | } 46 | } 47 | 48 | if (/data:lazaretto/.test(specifier)) { 49 | return nextResolve(`data:text/javascript;base64,${esmEntry}`, ctx, nextResolve) 50 | } 51 | if (/node:/.test(specifier)) specifier = specifier.split(':')[1] 52 | 53 | if (builtins.has(specifier)) { 54 | return { 55 | url: `node:${specifier}#mock-builtin`, 56 | format: 'commonjs', 57 | shortCircuit: true 58 | } 59 | } 60 | 61 | if (libraries.has(specifier)) { 62 | return { 63 | url: `${specifier}#mock-library`, 64 | shortCircuit: true 65 | } 66 | } 67 | const { parentURL = '' } = ctx 68 | if (specifier.startsWith('.') && parentURL && parentURL === LAZARETTO_LOADER_DATA_URL) { 69 | const absolute = path.resolve(relativeDir, specifier) 70 | if (libraries.has(absolute)) { 71 | return { 72 | url: `${absolute}#mock-library`, 73 | shortCircuit: true 74 | } 75 | } 76 | return { 77 | url: pathToFileURL(absolute).href, 78 | shortCircuit: true 79 | } 80 | } 81 | if (path.isAbsolute(specifier)) { 82 | return { 83 | url: pathToFileURL(specifier).href, 84 | shortCircuit: true 85 | } 86 | } 87 | if (parentURL.slice(28) === esmEntry) { 88 | ctx.parentURL = entryUrl 89 | } 90 | return nextResolve(specifier, ctx, nextResolve) 91 | } 92 | 93 | export async function load (url, ctx, nextLoad) { 94 | if (/esm:/.test(url)) { 95 | const [, code] = url.split(':') 96 | esmEntry = code 97 | global[Symbol.for('kLazarettoEntryModule')] = (async () => Object.assign({}, await import('data:lazaretto;esm')))() 98 | const source = ` 99 | export * from 'data:lazaretto;esm' 100 | const mod = await import('data:lazaretto;esm') 101 | global[Symbol.for('kLazarettoEntryModule')] = Object.assign({}, mod) 102 | export default mod.default 103 | ` 104 | return { format: 'module', source, shortCircuit: true } 105 | } 106 | 107 | if (/#cjs-/.test(url)) { 108 | const [, encoded] = url.split('#cjs-') 109 | const code = Buffer.from(encoded, 'base64') 110 | return { format: 'commonjs', source: code, shortCircuit: true } 111 | } 112 | 113 | if (url.endsWith('#mock-builtin')) { 114 | const [name] = url.split(':').pop().split('#') 115 | const source = `module.exports = global[Symbol.for('kLazarettoMocks')].builtin['${name}']` 116 | mocksLoaded.add(name) 117 | return { format: 'commonjs', source, shortCircuit: true } 118 | } 119 | 120 | if (url.endsWith('#mock-library')) { 121 | const [name, mock] = url.split(':').pop().split('#') 122 | const type = mock.split('-').pop() 123 | const mod = mocks[type][name] 124 | const api = Object.getOwnPropertyNames(mod) 125 | const exports = api.map((k) => { 126 | try { 127 | // the following checks if the export value is legal 128 | sanity(`const ${k} = 1`) 129 | return `export const ${k} = mod['${k}']` 130 | } catch { 131 | return '' 132 | } 133 | }).filter(Boolean) 134 | if (api.includes('default') === false) exports.push('export default mod') 135 | 136 | const source = ` 137 | const mod = global[Symbol.for('kLazarettoMocks')].${type}['${name}'] 138 | ${exports.join('\n ')} 139 | ` 140 | mocksLoaded.add(name) 141 | return { format: 'module', source, shortCircuit: true } 142 | } 143 | 144 | const describe = await nextLoad(url, ctx, nextLoad) 145 | 146 | if (describe.format !== 'builtin' && describe.source === null && url.startsWith('file://')) { 147 | describe.source = await readFile(fileURLToPath(url)) 148 | } 149 | 150 | return describe 151 | } 152 | -------------------------------------------------------------------------------- /lib/mockery.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { createRequire } = require('module') 3 | const { isAbsolute } = require('path') 4 | const sanity = require('./sanity') 5 | 6 | const globals = new Set(Object.getOwnPropertyNames(global)) 7 | const possibleConflict = new Set(Object.getOwnPropertyNames(global).filter((s) => { 8 | return s.toLowerCase() === s 9 | })) 10 | 11 | const cjsScoped = ['__dirname', '__filename', 'exports', 'module', 'require'] 12 | 13 | function serializeFn (fn) { 14 | let str = fn.toString() 15 | try { 16 | sanity(str) 17 | return str 18 | } catch (err) { 19 | if (err instanceof SyntaxError) { 20 | if (/async/.test(str)) str = str.replace(/async/, 'async function ') 21 | else str = 'function ' + str 22 | 23 | try { 24 | sanity(str) 25 | } catch (err) { 26 | if (err instanceof SyntaxError && /Unexpected token '\['/.test(err.message)) { 27 | str = str.replace(/\[(.*?)(\()/s, 'anonymous $2') 28 | sanity(str) 29 | } else throw err 30 | } 31 | return str 32 | } 33 | } 34 | } 35 | 36 | function mockery (mock, { esm, entry }) { 37 | if (!mock) return null 38 | const scoped = new Set(esm ? [] : cjsScoped) 39 | const entryRequire = createRequire(entry) 40 | const g = {} // global overrides 41 | const b = {} // builtin overrides 42 | const l = {} // lib overrides 43 | const s = {} // scope overrides 44 | 45 | const isBuiltin = (name) => { 46 | if (isAbsolute(name)) return false 47 | const resolved = entryRequire.resolve(name) 48 | return resolved === name 49 | } 50 | 51 | for (const [name, override] of Object.entries(mock)) { 52 | const serializedFn = serializeFn(override) 53 | 54 | if (name === 'process' || name === 'console') { 55 | // process and console are a special case as they're both 56 | // a global, a builtin module and 57 | // lowercase which means npm i console or npm i process 58 | // could also override the builtin module 59 | if (override.dependency) { 60 | l[entryRequire.resolve(name)] = { name, override: serializedFn } 61 | } 62 | g[name] = serializedFn 63 | b[name] = serializedFn 64 | continue 65 | } 66 | 67 | if (scoped.has(name)) { 68 | s[name] = serializedFn 69 | continue 70 | } 71 | 72 | if (globals.has(name)) { 73 | if (possibleConflict.has(name)) { 74 | if (override.dependency) { 75 | l[entryRequire.resolve(name)] = { name, override: serializedFn } 76 | continue 77 | } 78 | } 79 | g[name] = serializedFn 80 | continue 81 | } 82 | 83 | try { 84 | if (isBuiltin(name)) b[name] = serializedFn 85 | else l[entryRequire.resolve(name)] = { name, override: serializedFn } 86 | } catch (err) { 87 | throw Error(`Lazaretto: mock['${name}'] is not resolvable from ${entry}`) 88 | } 89 | } 90 | 91 | function scopeMocks () { 92 | const lines = [] 93 | for (const [name, override] of Object.entries(s)) { 94 | lines.push(`${name} = (${override})(${name}, {context: global[Symbol.for('kLazarettoContext')], require})`) 95 | } 96 | return lines.join(';') 97 | } 98 | return { entry, g, b, l, scopeMocks } 99 | } 100 | 101 | module.exports = mockery 102 | -------------------------------------------------------------------------------- /lib/preload.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { createRequire } = require('module') 3 | const { LAZARETTO_OVERRIDES } = process.env 4 | const { entry = process.cwd(), g = {}, b = {}, l = {} } = LAZARETTO_OVERRIDES ? JSON.parse(LAZARETTO_OVERRIDES) : {} 5 | const createInclude = require('./include') 6 | const context = require('./context') 7 | 8 | const builtins = new Set(Object.keys(b)) 9 | const libraries = new Set(Object.values(l).map(({ name }) => name)) 10 | 11 | const globalsFromBuiltinExports = { 12 | setTimeout: 'timers', 13 | clearTimeout: 'timers', 14 | setImmediate: 'timers', 15 | clearImmediate: 'timers', 16 | setInterval: 'timers', 17 | clearInterval: 'timers', 18 | Buffer: 'buffer', 19 | URL: 'url', 20 | TextEncoder: 'util', 21 | TextDecoder: 'util' 22 | } 23 | 24 | module.exports = preload 25 | 26 | async function preload () { 27 | const mocks = { 28 | global: {}, 29 | library: {}, 30 | builtin: {} 31 | } 32 | 33 | global[Symbol.for('kLazarettoContext')] = await context() 34 | const mocksLoaded = global[Symbol.for('kLazarettoMocksLoaded')] = new Set() 35 | 36 | if (!LAZARETTO_OVERRIDES) return { mocks, entry, builtins, libraries } 37 | const entryRequire = createRequire(entry) 38 | const include = createInclude(entry) 39 | 40 | for (const [name, override] of Object.entries(g)) { 41 | const mock = Function('...args', `return (${override})(...args)`) // eslint-disable-line 42 | mocks.global[name] = global[name] = await mock(global[name], { context: global[Symbol.for('kLazarettoContext')], include }) 43 | } 44 | 45 | for (const [name, override] of Object.entries(b)) { 46 | const mock = Function('...args', `return (${override})(...args)`) // eslint-disable-line 47 | mocks.builtin[name] = await mock(require(name), { context: global[Symbol.for('kLazarettoContext')], include }) 48 | // special case API's with a .promises export and a /promises namespace 49 | if (name === 'fs') { 50 | if ('promises' in mocks.builtin[name]) { 51 | const slashPromises = `${name}/promises` 52 | builtins.add(slashPromises) 53 | mocks.builtin[slashPromises] = mocks.builtin[name].promises 54 | } 55 | } 56 | } 57 | const paraMocks = {} 58 | for (const name of Object.keys(g)) { 59 | const builtinWithMockedGlobal = globalsFromBuiltinExports[name] 60 | if (!builtinWithMockedGlobal) continue 61 | if (builtinWithMockedGlobal in mocks.builtin) continue 62 | paraMocks[builtinWithMockedGlobal] = paraMocks[builtinWithMockedGlobal] || require(builtinWithMockedGlobal) 63 | paraMocks[builtinWithMockedGlobal] = new Proxy(paraMocks[builtinWithMockedGlobal], { 64 | get (o, k) { 65 | if (k === name) return mocks.global[name] 66 | return o[k] 67 | } 68 | }) 69 | } 70 | Object.assign(mocks.builtin, paraMocks) 71 | for (const name of Object.keys(mocks.builtin)) { 72 | builtins.add(name) 73 | require.cache[name] = { 74 | get exports () { 75 | mocksLoaded.add(name) 76 | return mocks.builtin[name] 77 | }, 78 | set exports (v) { /* swallow future sets */ } 79 | } 80 | } 81 | 82 | for (const [resolvedPath, { name, override }] of Object.entries(l)) { 83 | const mock = Function('...args', `return (${override})(...args)`) // eslint-disable-line 84 | try { 85 | entryRequire(name) // load the cache 86 | const mod = require.cache[resolvedPath] 87 | mocks.library[name] = mod.exports = await mock(mod.exports, { context: global[Symbol.for('kLazarettoContext')], include }) 88 | require.cache[resolvedPath] = { 89 | get exports () { 90 | mocksLoaded.add(name) 91 | return mocks.library[resolvedPath] 92 | }, 93 | set exports (v) { /* swallow future sets */ } 94 | } 95 | } catch (err) { 96 | if (err.code === 'ERR_REQUIRE_ESM') { 97 | const mod = await include(name) 98 | mocks.library[name] = await mock(mod, { context: global[Symbol.for('kLazarettoContext')], include }) 99 | } else { 100 | throw err 101 | } 102 | } 103 | } 104 | return { mocks, entry, builtins, libraries } 105 | } 106 | -------------------------------------------------------------------------------- /lib/sanity.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { compileFunction } = require('vm') 3 | 4 | module.exports = compileFunction 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lazaretto", 3 | "version": "4.0.2", 4 | "description": "Run esm and/or cjs code in a separate V8 isolate with code-injection capabilities", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "standard" 8 | }, 9 | "keywords": [ 10 | "isolate", 11 | "code injection" 12 | ], 13 | "author": "David Mark Clements (@davidmarkclem)", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/davidmarkclements/lazaretto.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/davidmarkclements/lazaretto/issues" 21 | }, 22 | "homepage": "https://github.com/davidmarkclements/lazaretto#readme", 23 | "devDependencies": { 24 | "standard": "^17.1.0" 25 | }, 26 | "dependencies": { 27 | "escape-string-regexp": "^4.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Lazaretto 2 | 3 | > Run esm and/or cjs code in a separate V8 isolate with code-injection capabilities 4 | 5 | ## Support 6 | 7 | * Node 20+ 8 | 9 | ## About 10 | 11 | Lazaretto is for circumstances where you want to execute isolated code that is fully interopable with 12 | either of Node's module systems while also being able to dynamically run expressions inside that code. 13 | This authors use-case is a sort of white-box testing (which is generally not recommended), but which is 14 | necessary for evaluating exam questions for the OpenJS Certifica tions. Lazaretto should not be relied on 15 | for completely safe isolation, the file system and so forth can still be accessed so you still need 16 | containers/vms for safe isolation of user code. 17 | 18 | ## API 19 | 20 | ```js 21 | const lazaretto = require('lazaretto') 22 | ``` 23 | 24 | ```js 25 | import lazaretto from 'lazaretto' 26 | ``` 27 | 28 | ### `await lazaretto({ esm = false, entry, scope, mock, context, teardown, prefix }) => sandbox <(expression: String) => result)>` 29 | 30 | #### Options 31 | 32 | ##### `esm` - Boolean, default: `false`. 33 | 34 | Set to `true` to load a native esm module (eg. `import`), `false` for a cjs module (eg `require`). See [is-file-esm](https://github.com/davidmarkclements/is-file-esm) for automatically determining whether a file is esm or not. 35 | 36 | ##### `entry` - String. Required. 37 | 38 | The entry-point file, must be an absolute path. 39 | 40 | 41 | ##### `scope` - Array, default: []. 42 | 43 | A list of references that we want to have in scope for running dynamic expressions. It can only access references in the outer module scope. 44 | For instance, let's say we want to run code in a sandbox that has a function named `fn`, and then we want to call `fn` and get the result. 45 | We would set the `scope` option to `['fn']`. 46 | 47 | 48 | ##### `mock` - Object 49 | 50 | The `mock` object can be used to override natives and libraries. The mocking of the following is supported 51 | 52 | * builtin modules (`fs`, `path`, `child_process`...) 53 | * globals (`process`, `Buffer`, `setTimeout`...) 54 | * module-scoped variables (`__dirname`, `__filename`, `require`...) *CJS modules only* 55 | * project-local libraries (`./path/to/file.js`, `/absolute/path/to/file.js`), resolution is relative to the `entry` path. 56 | * project dependencies (as specified in `package.json`) 57 | 58 | To mock supply the mocking target name as a key of the object and set it to a handler function: 59 | 60 | ```js 61 | const mock = { 62 | async fs (fs, { context, include }) { return {mock: 'fs'} } 63 | ['./path/to/local-lib.js']: async (mod, { context, include }) => { 64 | return {another: 'mock'} 65 | }, 66 | __dirname(__dirname, { context, require }) { 67 | return '/override/dirname' 68 | } 69 | } 70 | const sandbox = await lazaretto({ esm, entry, mock }) 71 | ``` 72 | 73 | All handler functions except module-scoped variable handler functions **may** return a promise (e.g. be an `async function`). 74 | 75 | Module-scoped variable handler functions (e.g. `__dirname` etc.), **must** be synchronous functions. 76 | 77 | The handler function has the signature `(original, api) => {}` where `original` is the original value of the 78 | mock-target and `api` contains utilities for cross-module-system and cross-isolate interactions. 79 | 80 | For all handler functions, `api.context` is an object which can be used to store state within the sandbox, 81 | this state will then be available in the main thread at [`sandbox.context`](#sandbox.context-object). 82 | 83 | For all handler function except module-scoped, there is an `api.include` function. This works in a similar 84 | way to [Dynamic Import](https://wiki.developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#Dynamic_Imports), 85 | except it smooths over the `default` ugliness and it's relative to the `entry` file: 86 | 87 | ```js 88 | const mock = { 89 | async fs (fs, { include }) { 90 | const stream = await include('stream') 91 | return { 92 | __proto__: fs, 93 | createReadStream() { 94 | return stream.Readable.from(['totally', 'mocked']) 95 | } 96 | } 97 | } 98 | } 99 | const sandbox = await lazaretto({ esm, entry, mock }) 100 | ``` 101 | 102 | For module-scoped functions, there's `api.require` which is a `require` function that performs lookups 103 | relative to the `entry` file: 104 | 105 | ```js 106 | const mock = { 107 | __filename (__filename, { require }) { 108 | const path = require('path') 109 | return path.join(path.dirname(__filename), 'override.js') 110 | } 111 | } 112 | const sandbox = await lazaretto({ esm, entry, mock }) 113 | ``` 114 | 115 | **IMPORTANT, READ THIS**: the handler function are serialized and then executed inside the worker thread. This means 116 | these functions will not be able to access any closure scope references since they are recompiled in a separate environment. 117 | 118 | ###### Implicit mocks 119 | 120 | Some globals are also core modules, for instance, `process` and `console`. When these specified in the `mock` object both the global 121 | and the module will be mocked. However, if a module named `process` or `console` is installed as a dependency, that will be mocked instead. 122 | 123 | Some globals are present as methods in core modules. For instance the `Buffer` global is also exported from the `buffer` module, 124 | and `setTimeout` is exported from `timers` etc. Globals that are parts of other modules will be mocked within those 125 | modules when mocked, unless the module is *also* mocked in which case the export of the mocked module method will 126 | be different from the mocked global. 127 | 128 | For example, if `setTimeout` mock is created the `timers.setTimeout` export will also be mocked the same. However if both `timers` and `setTimeout` is mocked, the `setTimeout` export on `timers` will be prescribed by the `timers` mock. 129 | 130 | Core modules can also have a `/promises` path that exports promisified versions of the module's API which is also available on the as the `promises` property of that module. Currently only the `fs` module that does this. When a method on `fs.promises` is mocked, that method is also mocked on `fs/promises`. For instance given the following: 131 | 132 | ```js 133 | const mock = { 134 | async fs (fs, { include }) { 135 | const { promisify } = await include('util') 136 | const readFile = (file, cb) => { 137 | process.nextTick(() => cb(null, Buffer.from('test'))) 138 | } 139 | return { 140 | __proto__: fs, 141 | readFile, 142 | promises: { 143 | readFile: promisify(readFile) 144 | } 145 | } 146 | } 147 | } 148 | const sandbox = await lazaretto({ esm, entry, mock }) 149 | ``` 150 | 151 | The `fs.promises.readFile` function has been mocked, so if `fs/promises` is required or imported it's `readFile` method 152 | will be the same as `fs.promises.readFile`. 153 | 154 | ##### `context` - Object, default: {} 155 | 156 | Sets the initial context that is then passed to mock handler functions. See [`sandbox.context`](#sandbox.context-object) 157 | 158 | ##### `prefix` - String, default: '' 159 | 160 | Inject code at the top of `entry` contents prior to execution. 161 | 162 | ##### `returnOnError` - Function or Boolean, default: false 163 | 164 | If `false` then the `sandbox` function will propagate the error. 165 | If `true` then the `sandbox` function will return a relevant error object if a particular expression causes a throw or rejection. 166 | If a function then the `sandbox` function will return the result of passing the error to the `returnOnError` function. 167 | 168 | 169 | ##### `teardown` - Function, default: undefined 170 | 171 | A function that takes a cleanup function (which may be an async function) that should be triggered outside of Lazaretto. 172 | 173 | For instance: 174 | 175 | ```js 176 | import lazaretto from 'lazaretto' 177 | let cleanup = () => {} 178 | function teardown (fn) { 179 | cleanup = fn 180 | } 181 | try { 182 | const sandbox = await lazaretto({ esm: true, entry: '/path/to/file.mjs', teardown }) 183 | sandbox('someFunctionThatMightError()') 184 | await sandbox.fin() 185 | } catch (err) { 186 | await cleanup() 187 | } 188 | ``` 189 | 190 | This is useful when using Lazaretto with a test framework, such as `tap`, for instance: 191 | 192 | ```js 193 | import tap from 'tap' 194 | import lazaretto from 'lazaretto' 195 | 196 | test('something', async ({ is, teardown }) => { 197 | const sandbox = await lazaretto({ esm: true, entry: '/path/to/file.mjs', teardown }) 198 | is(sandbox('someFunctionThatMightError()'), true) 199 | await sandbox.fin() 200 | }) 201 | ``` 202 | 203 | 204 | #### `sandbox(expression, args) => Promise` 205 | 206 | Lazaretto returns a promise that resolves to a sandbox function. Pass it an expression to evaluate. 207 | 208 | Imagine a file stored at `/path/to/file.mjs` which contains 209 | 210 | ```js 211 | function fn (inp) { return inp } 212 | export const func = fn 213 | ``` 214 | 215 | The file can be evaluated with Lazaretto like so: 216 | 217 | ```js 218 | import assert from 'assert' 219 | import lazaretto from 'lazaretto' 220 | const sandbox = await lazaretto({ esm: true, entry: '/path/to/file.mjs', scope: ['fn'] }) 221 | assert.strict.equal(await sandbox(`fn(true)`), true) 222 | ``` 223 | 224 | There are two implicit references available in sandbox expressions: `exports` and `$$args$$` 225 | 226 | The `exports` reference holds the exports for `entry` file: 227 | 228 | ```js 229 | import assert from 'assert' 230 | import lazaretto from 'lazaretto' 231 | const sandbox = await lazaretto({ esm: true, entry: '/path/to/file.mjs', scope: ['fn'] }) 232 | assert.strict.equal(await sandbox(`exports.func(42)`), 42) 233 | assert.strict.equal(await sandbox(`exports.func === fn`), true) 234 | ``` 235 | 236 | The `$$args$$` reference holds a clone of the arguments passed to the sandbox after the expression: 237 | 238 | ```js 239 | import assert from 'assert' 240 | import lazaretto from 'lazaretto' 241 | const sandbox = await lazaretto({ esm: true, entry: '/path/to/file.mjs', scope: ['fn'] }) 242 | assert.strict.equal(await sandbox(`exports.func(...$$args$$)`, 'wow'), 'wow') 243 | assert.strict.equal(await sandbox(`fn(...$$args$$)`, 'again'), 'again') 244 | ``` 245 | 246 | Data return from evaluating an expression in the sandbox is cloned from the isolate thread according to the [HTML structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) which means you can't return functions, and a Node `Buffer` will be cloned as a `Uint8Array` - see https://nodejs.org/api/worker_threads.html#worker_threads_considerations_when_transferring_typedarrays_and_buffers. 247 | 248 | ##### `sandbox.context` - Object 249 | 250 | The `sandbox.context` object is synchronised with any changes made to the `api.context` object in any of the mocks. 251 | Any state stored on context is passed between the main thread and the worker thread (and vice-versa), this means the 252 | [HTML structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) 253 | is used to synchronize the main and worker thread context objects. Therefore functions cannot be transferred and there 254 | are caveats around how to handle buffers. 255 | 256 | ##### `sandbox.mocksLoaded` - Array 257 | 258 | The `sandbox.mocksLoaded` will be `null` until after `sandbox.fin()` is called. Afterwards it will be an array of 259 | names (or paths in some cases) of mocks that were required or imported during execution. 260 | 261 | ## License 262 | 263 | MIT 264 | 265 | --------------------------------------------------------------------------------