diff --git a/.changeset/chatty-roses-wink.md b/.changeset/chatty-roses-wink.md new file mode 100644 index 00000000..ebbcaec3 --- /dev/null +++ b/.changeset/chatty-roses-wink.md @@ -0,0 +1,7 @@ +--- +'pac-proxy-agent': major +'pac-resolver': major +'degenerator': major +--- + +Use `quickjs-emscripten` instead of `vm2` to execute PAC file code diff --git a/.changeset/new-goats-arrive.md b/.changeset/new-goats-arrive.md new file mode 100644 index 00000000..c9c51333 --- /dev/null +++ b/.changeset/new-goats-arrive.md @@ -0,0 +1,5 @@ +--- +'proxy-agent': minor +--- + +Use QuickJS version of `pac-proxy-agent` diff --git a/packages/degenerator/package.json b/packages/degenerator/package.json index 14e07514..fa5a8112 100644 --- a/packages/degenerator/package.json +++ b/packages/degenerator/package.json @@ -26,10 +26,10 @@ "dependencies": { "ast-types": "^0.13.4", "escodegen": "^1.14.3", - "esprima": "^4.0.1", - "vm2": "^3.9.19" + "esprima": "^4.0.1" }, "devDependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", "@types/escodegen": "^0.0.6", "@types/esprima": "^4.0.3", "@types/jest": "^29.5.2", diff --git a/packages/degenerator/src/compile.ts b/packages/degenerator/src/compile.ts new file mode 100644 index 00000000..418eb055 --- /dev/null +++ b/packages/degenerator/src/compile.ts @@ -0,0 +1,135 @@ +import { types } from 'util'; +import { degenerator } from './degenerator'; +import type { Context } from 'vm'; +import type { + QuickJSContext, + QuickJSHandle, + QuickJSWASMModule, +} from '@tootallnate/quickjs-emscripten'; +import type { DegeneratorNames } from './degenerator'; + +export interface CompileOptions { + names?: DegeneratorNames; + filename?: string; + sandbox?: Context; +} + +export function compile( + qjs: QuickJSWASMModule, + code: string, + returnName: string, + options: CompileOptions = {} +): (...args: A) => Promise { + const compiled = degenerator(code, options.names ?? []); + + const vm = qjs.newContext(); + + // Add functions to global + if (options.sandbox) { + for (const [name, value] of Object.entries(options.sandbox)) { + if (typeof value !== 'function') { + throw new Error( + `Expected a "function" for sandbox property \`${name}\`, but got "${typeof value}"` + ); + } + const fnHandle = vm.newFunction(name, (...args) => { + //console.log('invoke', { name, args }); + const result = value( + ...args.map((arg) => quickJSHandleToHost(vm, arg)) + ); + vm.runtime.executePendingJobs(); + return hostToQuickJSHandle(vm, result); + }); + fnHandle.consume((handle) => vm.setProp(vm.global, name, handle)); + } + } + + //console.log(compiled); + const fnResult = vm.evalCode(`${compiled};${returnName}`, options.filename); + const fn = vm.unwrapResult(fnResult); + + const t = vm.typeof(fn); + if (t !== 'function') { + throw new Error( + `Expected a "function" named \`${returnName}\` to be defined, but got "${t}"` + ); + } + const r = async function (...args: A): Promise { + let promiseHandle: QuickJSHandle | undefined; + let resolvedHandle: QuickJSHandle | undefined; + try { + const result = vm.callFunction( + fn, + vm.undefined, + ...args.map((arg) => hostToQuickJSHandle(vm, arg)) + ); + promiseHandle = vm.unwrapResult(result); + const resolvedResultP = vm.resolvePromise(promiseHandle); + vm.runtime.executePendingJobs(); + const resolvedResult = await resolvedResultP; + resolvedHandle = vm.unwrapResult(resolvedResult); + return quickJSHandleToHost(vm, resolvedHandle); + } catch (err: unknown) { + if (err && typeof err === 'object' && 'cause' in err && err.cause) { + if ( + typeof err.cause === 'object' && + 'stack' in err.cause && + 'name' in err.cause && + 'message' in err.cause && + typeof err.cause.stack === 'string' && + typeof err.cause.name === 'string' && + typeof err.cause.message === 'string' + ) { + // QuickJS Error `stack` does not include the name + + // message, so patch those in to behave more like V8 + err.cause.stack = `${err.cause.name}: ${err.cause.message}\n${err.cause.stack}`; + } + throw err.cause; + } + throw err; + } finally { + promiseHandle?.dispose(); + resolvedHandle?.dispose(); + } + }; + Object.defineProperty(r, 'toString', { + value: () => compiled, + enumerable: false, + }); + return r; +} + +function quickJSHandleToHost(vm: QuickJSContext, val: QuickJSHandle) { + return vm.dump(val); +} + +function hostToQuickJSHandle(vm: QuickJSContext, val: unknown): QuickJSHandle { + if (typeof val === 'undefined') { + return vm.undefined; + } else if (val === null) { + return vm.null; + } else if (typeof val === 'string') { + return vm.newString(val); + } else if (typeof val === 'number') { + return vm.newNumber(val); + } else if (typeof val === 'bigint') { + return vm.newBigInt(val); + } else if (typeof val === 'boolean') { + return val ? vm.true : vm.false; + } else if (types.isPromise(val)) { + const promise = vm.newPromise(); + promise.settled.then(vm.runtime.executePendingJobs); + val.then( + (r: unknown) => { + promise.resolve(hostToQuickJSHandle(vm, r)); + }, + (err: unknown) => { + promise.reject(hostToQuickJSHandle(vm, err)); + } + ); + return promise.handle; + } else if (types.isNativeError(val)) { + return vm.newError(val); + } + throw new Error(`Unsupported value: ${val}`); +} diff --git a/packages/degenerator/src/degenerator.ts b/packages/degenerator/src/degenerator.ts new file mode 100644 index 00000000..463cba00 --- /dev/null +++ b/packages/degenerator/src/degenerator.ts @@ -0,0 +1,176 @@ +import { types } from 'util'; +import { generate } from 'escodegen'; +import { parseScript } from 'esprima'; +import { visit, namedTypes as n, builders as b } from 'ast-types'; + +export type DegeneratorName = string | RegExp; +export type DegeneratorNames = DegeneratorName[]; + +/** + * Compiles sync JavaScript code into JavaScript with async Functions. + * + * @param {String} code JavaScript string to convert + * @param {Array} names Array of function names to add `await` operators to + * @return {String} Converted JavaScript string with async/await injected + * @api public + */ + +export function degenerator(code: string, _names: DegeneratorNames): string { + if (!Array.isArray(_names)) { + throw new TypeError('an array of async function "names" is required'); + } + + // Duplicate the `names` array since it's rude to augment the user args + const names = _names.slice(0); + + const ast = parseScript(code); + + // First pass is to find the `function` nodes and turn them into async or + // generator functions only if their body includes `CallExpressions` to + // function in `names`. We also add the names of the functions to the `names` + // array. We'll iterate several time, as every iteration might add new items + // to the `names` array, until no new names were added in the iteration. + let lastNamesLength = 0; + do { + lastNamesLength = names.length; + visit(ast, { + visitVariableDeclaration(path) { + if (path.node.declarations) { + for (let i = 0; i < path.node.declarations.length; i++) { + const declaration = path.node.declarations[i]; + if ( + n.VariableDeclarator.check(declaration) && + n.Identifier.check(declaration.init) && + n.Identifier.check(declaration.id) && + checkName(declaration.init.name, names) && + !checkName(declaration.id.name, names) + ) { + names.push(declaration.id.name); + } + } + } + return false; + }, + visitAssignmentExpression(path) { + if ( + n.Identifier.check(path.node.left) && + n.Identifier.check(path.node.right) && + checkName(path.node.right.name, names) && + !checkName(path.node.left.name, names) + ) { + names.push(path.node.left.name); + } + return false; + }, + visitFunction(path) { + if (path.node.id) { + let shouldDegenerate = false; + visit(path.node, { + visitCallExpression(path) { + if (checkNames(path.node, names)) { + shouldDegenerate = true; + } + return false; + }, + }); + + if (!shouldDegenerate) { + return false; + } + + // Got a "function" expression/statement, + // convert it into an async function + path.node.async = true; + + // Add function name to `names` array + if (!checkName(path.node.id.name, names)) { + names.push(path.node.id.name); + } + } + + this.traverse(path); + }, + }); + } while (lastNamesLength !== names.length); + + // Second pass is for adding `await` statements to any function + // invocations that match the given `names` array. + visit(ast, { + visitCallExpression(path) { + if (checkNames(path.node, names)) { + // A "function invocation" expression, + // we need to inject an `AwaitExpression` + const delegate = false; + const { + name, + parent: { node: pNode }, + } = path; + + const expr = b.awaitExpression(path.node, delegate); + + if (n.CallExpression.check(pNode)) { + pNode.arguments[name] = expr; + } else { + pNode[name] = expr; + } + } + + this.traverse(path); + }, + }); + + return generate(ast); +} + +/** + * Returns `true` if `node` has a matching name to one of the entries in the + * `names` array. + * + * @param {types.Node} node + * @param {Array} names Array of function names to return true for + * @return {Boolean} + * @api private + */ + +function checkNames( + { callee }: n.CallExpression, + names: DegeneratorNames +): boolean { + let name: string; + if (n.Identifier.check(callee)) { + name = callee.name; + } else if (n.MemberExpression.check(callee)) { + if ( + n.Identifier.check(callee.object) && + n.Identifier.check(callee.property) + ) { + name = `${callee.object.name}.${callee.property.name}`; + } else { + return false; + } + } else if (n.FunctionExpression.check(callee)) { + if (callee.id) { + name = callee.id.name; + } else { + return false; + } + } else { + throw new Error(`Don't know how to get name for: ${callee.type}`); + } + return checkName(name, names); +} + +function checkName(name: string, names: DegeneratorNames): boolean { + // now that we have the `name`, check if any entries match in the `names` array + for (let i = 0; i < names.length; i++) { + const n = names[i]; + if (types.isRegExp(n)) { + if (n.test(name)) { + return true; + } + } else if (name === n) { + return true; + } + } + return false; +} diff --git a/packages/degenerator/src/index.ts b/packages/degenerator/src/index.ts index 45835f0f..2dc06cd3 100644 --- a/packages/degenerator/src/index.ts +++ b/packages/degenerator/src/index.ts @@ -1,215 +1,2 @@ -import { types } from 'util'; -import { generate } from 'escodegen'; -import { parseScript } from 'esprima'; -import { visit, namedTypes as n, builders as b } from 'ast-types'; -import { Context, RunningScriptOptions } from 'vm'; -import { VM, VMScript } from 'vm2'; - -/** - * Compiles sync JavaScript code into JavaScript with async Functions. - * - * @param {String} code JavaScript string to convert - * @param {Array} names Array of function names to add `await` operators to - * @return {String} Converted JavaScript string with async/await injected - * @api public - */ - -export function degenerator(code: string, _names: DegeneratorNames): string { - if (!Array.isArray(_names)) { - throw new TypeError('an array of async function "names" is required'); - } - - // Duplicate the `names` array since it's rude to augment the user args - const names = _names.slice(0); - - const ast = parseScript(code); - - // First pass is to find the `function` nodes and turn them into async or - // generator functions only if their body includes `CallExpressions` to - // function in `names`. We also add the names of the functions to the `names` - // array. We'll iterate several time, as every iteration might add new items - // to the `names` array, until no new names were added in the iteration. - let lastNamesLength = 0; - do { - lastNamesLength = names.length; - visit(ast, { - visitVariableDeclaration(path) { - if (path.node.declarations) { - for (let i = 0; i < path.node.declarations.length; i++) { - const declaration = path.node.declarations[i]; - if ( - n.VariableDeclarator.check(declaration) && - n.Identifier.check(declaration.init) && - n.Identifier.check(declaration.id) && - checkName(declaration.init.name, names) && - !checkName(declaration.id.name, names) - ) { - names.push(declaration.id.name); - } - } - } - return false; - }, - visitAssignmentExpression(path) { - if ( - n.Identifier.check(path.node.left) && - n.Identifier.check(path.node.right) && - checkName(path.node.right.name, names) && - !checkName(path.node.left.name, names) - ) { - names.push(path.node.left.name); - } - return false; - }, - visitFunction(path) { - if (path.node.id) { - let shouldDegenerate = false; - visit(path.node, { - visitCallExpression(path) { - if (checkNames(path.node, names)) { - shouldDegenerate = true; - } - return false; - }, - }); - - if (!shouldDegenerate) { - return false; - } - - // Got a "function" expression/statement, - // convert it into an async function - path.node.async = true; - - // Add function name to `names` array - if (!checkName(path.node.id.name, names)) { - names.push(path.node.id.name); - } - } - - this.traverse(path); - }, - }); - } while (lastNamesLength !== names.length); - - // Second pass is for adding `await` statements to any function - // invocations that match the given `names` array. - visit(ast, { - visitCallExpression(path) { - if (checkNames(path.node, names)) { - // A "function invocation" expression, - // we need to inject an `AwaitExpression` - const delegate = false; - const { - name, - parent: { node: pNode }, - } = path; - - const expr = b.awaitExpression(path.node, delegate); - - if (n.CallExpression.check(pNode)) { - pNode.arguments[name] = expr; - } else { - pNode[name] = expr; - } - } - - this.traverse(path); - }, - }); - - return generate(ast); -} - -export type DegeneratorName = string | RegExp; -export type DegeneratorNames = DegeneratorName[]; -export interface CompileOptions extends RunningScriptOptions { - sandbox?: Context; -} -export function compile( - code: string, - returnName: string, - names: DegeneratorNames, - options: CompileOptions = {} -): (...args: A) => Promise { - const compiled = degenerator(code, names); - const vm = new VM(options); - const script = new VMScript(`${compiled};${returnName}`, { - filename: options.filename, - }); - const fn = vm.run(script); - if (typeof fn !== 'function') { - throw new Error( - `Expected a "function" to be returned for \`${returnName}\`, but got "${typeof fn}"` - ); - } - const r = function (this: unknown, ...args: A): Promise { - try { - const p = fn.apply(this, args); - if (typeof p?.then === 'function') { - return p; - } - return Promise.resolve(p); - } catch (err) { - return Promise.reject(err); - } - }; - Object.defineProperty(r, 'toString', { - value: fn.toString.bind(fn), - enumerable: false, - }); - return r; -} - -/** - * Returns `true` if `node` has a matching name to one of the entries in the - * `names` array. - * - * @param {types.Node} node - * @param {Array} names Array of function names to return true for - * @return {Boolean} - * @api private - */ - -function checkNames( - { callee }: n.CallExpression, - names: DegeneratorNames -): boolean { - let name: string; - if (n.Identifier.check(callee)) { - name = callee.name; - } else if (n.MemberExpression.check(callee)) { - if ( - n.Identifier.check(callee.object) && - n.Identifier.check(callee.property) - ) { - name = `${callee.object.name}.${callee.property.name}`; - } else { - return false; - } - } else if (n.FunctionExpression.check(callee)) { - if (callee.id) { - name = callee.id.name; - } else { - return false; - } - } else { - throw new Error(`Don't know how to get name for: ${callee.type}`); - } - return checkName(name, names); -} - -function checkName(name: string, names: DegeneratorNames): boolean { - // now that we have the `name`, check if any entries match in the `names` array - for (let i = 0; i < names.length; i++) { - const n = names[i]; - if (types.isRegExp(n)) { - if (n.test(name)) { - return true; - } - } else if (name === n) { - return true; - } - } - return false; -} +export * from './degenerator'; +export * from './compile'; diff --git a/packages/degenerator/test/test.ts b/packages/degenerator/test/test.ts index 3322f9bf..056167e9 100644 --- a/packages/degenerator/test/test.ts +++ b/packages/degenerator/test/test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import assert from 'assert'; import { degenerator, compile } from '../src'; +import { getQuickJS, type QuickJSWASMModule } from '@tootallnate/quickjs-emscripten'; describe('degenerator()', () => { it('should support "async" output functions', () => { @@ -64,79 +65,76 @@ describe('degenerator()', () => { }); describe('`compile()`', () => { + let qjs: QuickJSWASMModule; + + beforeAll(async () => { + qjs = await getQuickJS(); + }); + it('should compile code into an invocable async function', async () => { const a = (v: string) => Promise.resolve(v); const b = () => 'b'; function aPlusB(v: string): string { return a(v) + b(); } - const fn = compile('' + aPlusB, 'aPlusB', ['a'], { + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { + names: ['a'], sandbox: { a, b }, }); const val = await fn('c'); assert.equal(val, 'cb'); }); - it('should contain the compiled code in `toString()` output', () => { + it('should contain the compiled code in `toString()` output', async () => { const a = () => 'a'; const b = () => 'b'; function aPlusB(): string { return a() + b(); } - const fn = compile<() => Promise>( - '' + aPlusB, - 'aPlusB', - ['b'], - { - sandbox: { a, b }, - } - ); + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { + names: ['b'], + sandbox: { a, b }, + }); assert(/await b\(\)/.test(fn + '')); }); - it('should be able to await non-promises', () => { + it('should be able to await non-promises', async () => { const a = () => 'a'; const b = () => 'b'; function aPlusB(): string { return a() + b(); } - const fn = compile<() => Promise>( - '' + aPlusB, - 'aPlusB', - ['a'], - { - sandbox: { a, b }, - } - ); - return fn().then((val) => { - assert.equal(val, 'ab'); + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { + names: ['a'], + sandbox: { a, b }, }); + const val = await fn(); + assert.equal(val, 'ab'); }); - it('should be able to compile functions with no async', () => { + it('should be able to compile functions with no async', async () => { const a = () => 'a'; const b = () => 'b'; function aPlusB(): string { return a() + b(); } - const fn = compile('' + aPlusB, 'aPlusB', [], { + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { sandbox: { a, b }, }); - return fn().then((val: string) => { - assert.equal(val, 'ab'); - }); + const val = await fn(); + assert.equal(val, 'ab'); }); - it('should throw an Error if no function is returned from the `vm`', () => { + it('should throw an Error if no function is returned from the `vm`', async () => { let err: Error | undefined; try { - compile<() => Promise>('const foo = 1', 'foo', []); + compile<() => Promise>(qjs, 'const foo = 1', 'foo'); } catch (_err) { err = _err as Error; } assert(err); assert.equal( err.message, - 'Expected a "function" to be returned for `foo`, but got "number"' + 'Expected a "function" named `foo` to be defined, but got "number"' ); }); - it('should compile if branches', () => { + it('should compile if branches', async () => { function ifA(): string { if (a()) { return 'foo'; @@ -152,43 +150,42 @@ describe('degenerator()', () => { function b() { return false; } - const fn = compile(`${ifA};${a}`, 'ifA', ['b'], { + const fn = compile(qjs, `${ifA};${a}`, 'ifA', { + names: ['b'], sandbox: { b }, }); - return fn().then((val: string) => { - assert.equal(val, 'foo'); - }); + const val = await fn(); + assert.equal(val, 'foo'); }); it('should prevent privilege escalation of untrusted code', async () => { let err: Error | undefined; try { const fn = compile( + qjs, `const f = this.constructor.constructor('return process');`, - 'f', - [] + 'f' ); await fn(); } catch (_err) { err = _err as Error; } assert(err); - assert.equal(err.message, 'process is not defined'); + assert.equal(err.message, "'process' is not defined"); }); - it('should allow to return synchronous undefined', () => { + it('should allow to return synchronous undefined', async () => { function u() { // empty } - const fn = compile(`${u}`, 'u', ['']); - return fn().then((val) => { - assert.strictEqual(val, undefined); - }); + const fn = compile(qjs, `${u}`, 'u'); + const val = await fn(); + assert.strictEqual(typeof val, 'undefined'); }); it('should support "filename" option', async () => { function u() { throw new Error('fail'); } let err: Error | undefined; - const fn = compile(`${u}`, 'u', [''], { + const fn = compile(qjs, `${u}`, 'u', { filename: '/foo/bar/baz.js', }); try { diff --git a/packages/pac-proxy-agent/package.json b/packages/pac-proxy-agent/package.json index 2e524376..3fdf977f 100644 --- a/packages/pac-proxy-agent/package.json +++ b/packages/pac-proxy-agent/package.json @@ -31,6 +31,7 @@ "author": "Nathan Rajlich (http://n8.io/)", "license": "MIT", "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.0.2", "debug": "^4.3.4", "get-uri": "^6.0.1", diff --git a/packages/pac-proxy-agent/src/index.ts b/packages/pac-proxy-agent/src/index.ts index 8bf869cb..76699ae7 100644 --- a/packages/pac-proxy-agent/src/index.ts +++ b/packages/pac-proxy-agent/src/index.ts @@ -20,6 +20,7 @@ import { FindProxyForURL, PacResolverOptions, } from 'pac-resolver'; +import { getQuickJS } from '@tootallnate/quickjs-emscripten'; const debug = createDebug('pac-proxy-agent'); @@ -116,7 +117,10 @@ export class PacProxyAgent extends Agent { private async loadResolver(): Promise { try { // (Re)load the contents of the PAC file URI - const code = await this.loadPacFile(); + const [qjs, code] = await Promise.all([ + getQuickJS(), + this.loadPacFile(), + ]); // Create a sha1 hash of the JS code const hash = crypto.createHash('sha1').update(code).digest('hex'); @@ -130,7 +134,7 @@ export class PacProxyAgent extends Agent { // Cache the resolver debug('Creating new proxy resolver instance'); - this.resolver = createPacResolver(code, this.opts); + this.resolver = createPacResolver(qjs, code, this.opts); // Store that sha1 hash for future comparison purposes this.resolverHash = hash; diff --git a/packages/pac-resolver/README.md b/packages/pac-resolver/README.md index 67bc58df..a7ea4db4 100644 --- a/packages/pac-resolver/README.md +++ b/packages/pac-resolver/README.md @@ -39,7 +39,7 @@ console.log(res); API --- -### pac(pacFileContents: string | Buffer, options?: PacResolverOptions) → Function +### pac(qjs: QuickJSWASMModule, pacFileContents: string | Buffer, options?: PacResolverOptions) → Function Returns an asynchronous `FindProxyForURL()` function based off of the given JS string `pacFileContents` PAC proxy file. An optional `options` object may be @@ -53,6 +53,8 @@ passed in which respects the following options: instance, and the JS code will be able to invoke the function as if it were synchronous. + The `qjs` parameter is a QuickJS module instance as returned from `getQuickJS()` from the `quickjs-emscripten` module. + License ------- diff --git a/packages/pac-resolver/package.json b/packages/pac-resolver/package.json index 9ba2839b..98277af1 100644 --- a/packages/pac-resolver/package.json +++ b/packages/pac-resolver/package.json @@ -13,6 +13,7 @@ "netmask": "^2.0.2" }, "devDependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", "@types/ip": "^1.1.0", "@types/jest": "^29.5.2", "@types/netmask": "^1.0.30", diff --git a/packages/pac-resolver/src/index.ts b/packages/pac-resolver/src/index.ts index 1f83d545..13b6e996 100644 --- a/packages/pac-resolver/src/index.ts +++ b/packages/pac-resolver/src/index.ts @@ -16,12 +16,14 @@ import myIpAddress from './myIpAddress'; import shExpMatch from './shExpMatch'; import timeRange from './timeRange'; import weekdayRange from './weekdayRange'; +import type { QuickJSWASMModule } from '@tootallnate/quickjs-emscripten'; /** * Returns an asynchronous `FindProxyForURL()` function * from the given JS string (from a PAC file). */ export function createPacResolver( + qjs: QuickJSWASMModule, _str: string | Buffer, _opts: PacResolverOptions = {} ) { @@ -33,22 +35,23 @@ export function createPacResolver( ..._opts.sandbox, }; + // Construct the array of async function names to add `await` calls to. + const names = Object.keys(context).filter((k) => + isAsyncFunction(context[k]) + ); + const opts: PacResolverOptions = { filename: 'proxy.pac', + names, ..._opts, sandbox: context, }; - // Construct the array of async function names to add `await` calls to. - const names = Object.keys(context).filter((k) => - isAsyncFunction(context[k]) - ); - // Compile the JS `FindProxyForURL()` function into an async function. const resolver = compile( + qjs, str, 'FindProxyForURL', - names, opts ); @@ -168,13 +171,6 @@ export const sandbox = Object.freeze({ weekdayRange, }); -function toCallback( - promise: Promise, - callback: (err: Error | null, result?: T) => void -): void { - promise.then((rtn) => callback(null, rtn), callback); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any function isAsyncFunction(v: any): boolean { if (typeof v !== 'function') return false; diff --git a/packages/pac-resolver/test/test.ts b/packages/pac-resolver/test/test.ts index fba8b4b5..db718cc5 100644 --- a/packages/pac-resolver/test/test.ts +++ b/packages/pac-resolver/test/test.ts @@ -2,10 +2,20 @@ import assert from 'assert'; import { resolve } from 'path'; import { readFileSync } from 'fs'; import { createPacResolver } from '../src'; +import { getQuickJS, type QuickJSWASMModule } from '@tootallnate/quickjs-emscripten'; + +type FindProxyForURLFn = ReturnType; describe('FindProxyForURL', () => { + let qjs: QuickJSWASMModule; + + beforeAll(async () => { + qjs = await getQuickJS(); + }); + it('should return `undefined` by default', async () => { const FindProxyForURL = createPacResolver( + qjs, 'function FindProxyForURL (url, host) {' + ' /* noop */' + '}' ); const res = await FindProxyForURL('http://foo.com/', 'foo.com'); @@ -14,6 +24,7 @@ describe('FindProxyForURL', () => { it('should return the value that gets returned', async () => { const FindProxyForURL = createPacResolver( + qjs, 'function FindProxyForURL (url, host) {' + ' return { foo: "bar" };' + '}' @@ -28,6 +39,7 @@ describe('FindProxyForURL', () => { } const opts = { sandbox: { foo } }; const FindProxyForURL = createPacResolver( + qjs, 'function FindProxyForURL (url, host) { return typeof foo; }', opts ); @@ -40,6 +52,7 @@ describe('FindProxyForURL', () => { let err: Error | undefined; try { createPacResolver( + qjs, `// Real PAC config: function FindProxyForURL(url, host) { return "DIRECT"; @@ -57,19 +70,24 @@ describe('FindProxyForURL', () => { err = _err as Error; } assert(err); - expect(err.message).toEqual('process is not defined'); + expect(err.message).toEqual("'process' is not defined"); }); describe('official docs Example #1', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host) {' + - ' if (isPlainHostName(host) ||' + - ' dnsDomainIs(host, ".netscape.com"))' + - ' return "DIRECT";' + - ' else' + - ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + - '}' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host) {' + + ' if (isPlainHostName(host) ||' + + ' dnsDomainIs(host, ".netscape.com"))' + + ' return "DIRECT";' + + ' else' + + ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + + '}' + ); + }); it('should return "DIRECT" for "localhost"', async () => { const res = await FindProxyForURL( @@ -97,18 +115,23 @@ describe('FindProxyForURL', () => { }); describe('official docs Example #1b', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host)' + - '{' + - ' if ((isPlainHostName(host) ||' + - ' dnsDomainIs(host, ".netscape.com")) &&' + - ' !localHostOrDomainIs(host, "www.netscape.com") &&' + - ' !localHostOrDomainIs(host, "merchant.netscape.com"))' + - ' return "DIRECT";' + - ' else' + - ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + - '}' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host)' + + '{' + + ' if ((isPlainHostName(host) ||' + + ' dnsDomainIs(host, ".netscape.com")) &&' + + ' !localHostOrDomainIs(host, "www.netscape.com") &&' + + ' !localHostOrDomainIs(host, "merchant.netscape.com"))' + + ' return "DIRECT";' + + ' else' + + ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + + '}' + ); + }); it('should return "DIRECT" for "localhost"', async () => { const res = await FindProxyForURL( @@ -144,27 +167,32 @@ describe('FindProxyForURL', () => { }); describe('official docs Example #5', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host)' + - '{' + - ' if (url.substring(0, 5) == "http:") {' + - ' return "PROXY http-proxy.mydomain.com:8080";' + - ' }' + - ' else if (url.substring(0, 4) == "ftp:") {' + - ' return "PROXY ftp-proxy.mydomain.com:8080";' + - ' }' + - ' else if (url.substring(0, 7) == "gopher:") {' + - ' return "PROXY gopher-proxy.mydomain.com:8080";' + - ' }' + - ' else if (url.substring(0, 6) == "https:" ||' + - ' url.substring(0, 6) == "snews:") {' + - ' return "PROXY security-proxy.mydomain.com:8080";' + - ' }' + - ' else {' + - ' return "DIRECT";' + - ' }' + - '}' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host)' + + '{' + + ' if (url.substring(0, 5) == "http:") {' + + ' return "PROXY http-proxy.mydomain.com:8080";' + + ' }' + + ' else if (url.substring(0, 4) == "ftp:") {' + + ' return "PROXY ftp-proxy.mydomain.com:8080";' + + ' }' + + ' else if (url.substring(0, 7) == "gopher:") {' + + ' return "PROXY gopher-proxy.mydomain.com:8080";' + + ' }' + + ' else if (url.substring(0, 6) == "https:" ||' + + ' url.substring(0, 6) == "snews:") {' + + ' return "PROXY security-proxy.mydomain.com:8080";' + + ' }' + + ' else {' + + ' return "DIRECT";' + + ' }' + + '}' + ); + }); it('should return "DIRECT" for "foo://netscape.com"', async () => { const res = await FindProxyForURL( @@ -216,30 +244,35 @@ describe('FindProxyForURL', () => { }); describe('GitHub issue #3', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host) {\n' + - ' if (isHostInAnySubnet(host, ["10.1.2.0", "10.1.3.0"], "255.255.255.0")) {\n' + - ' return "HTTPS proxy.example.com";\n' + - ' }\n' + - '\n' + - ' if (isHostInAnySubnet(host, ["10.2.2.0", "10.2.3.0"], "255.255.255.0")) {\n' + - ' return "HTTPS proxy.example.com";\n' + - ' }\n' + - '\n' + - ' // Everything else, go direct:\n' + - ' return "DIRECT";\n' + - '}\n' + - '\n' + - '// Checks if the single host is within a list of subnets using the single mask.\n' + - 'function isHostInAnySubnet(host, subnets, mask) {\n' + - ' var subnets_length = subnets.length;\n' + - ' for (i = 0; i < subnets_length; i++) {\n' + - ' if (isInNet(host, subnets[i], mask)) {\n' + - ' return true;\n' + - ' }\n' + - ' }\n' + - '}\n' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host) {\n' + + ' if (isHostInAnySubnet(host, ["10.1.2.0", "10.1.3.0"], "255.255.255.0")) {\n' + + ' return "HTTPS proxy.example.com";\n' + + ' }\n' + + '\n' + + ' if (isHostInAnySubnet(host, ["10.2.2.0", "10.2.3.0"], "255.255.255.0")) {\n' + + ' return "HTTPS proxy.example.com";\n' + + ' }\n' + + '\n' + + ' // Everything else, go direct:\n' + + ' return "DIRECT";\n' + + '}\n' + + '\n' + + '// Checks if the single host is within a list of subnets using the single mask.\n' + + 'function isHostInAnySubnet(host, subnets, mask) {\n' + + ' var subnets_length = subnets.length;\n' + + ' for (i = 0; i < subnets_length; i++) {\n' + + ' if (isInNet(host, subnets[i], mask)) {\n' + + ' return true;\n' + + ' }\n' + + ' }\n' + + '}\n' + ); + }); it('should return "HTTPS proxy.example.com" for "http://10.1.2.3/bar.html"', async () => { const res = await FindProxyForURL( @@ -261,9 +294,14 @@ describe('FindProxyForURL', () => { // https://github.com/breakwa11/gfw_whitelist // https://github.com/TooTallNate/node-pac-resolver/issues/20 describe('GitHub issue #20', () => { - const FindProxyForURL = createPacResolver( - readFileSync(resolve(__dirname, 'fixtures/gfw_whitelist.pac')) - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + readFileSync(resolve(__dirname, 'fixtures/gfw_whitelist.pac')) + ); + }); it('should return "DIRECT" for "https://example.cn"', async () => { const res = await FindProxyForURL('https://example.cn/'); @@ -283,7 +321,7 @@ describe('FindProxyForURL', () => { it('should include `proxy.pac` in stack traces by default', async () => { let err: Error | undefined; - const FindProxyForURL = createPacResolver(code); + const FindProxyForURL = createPacResolver(qjs, code); try { await FindProxyForURL('https://example.com/'); } catch (_err) { @@ -298,7 +336,7 @@ describe('FindProxyForURL', () => { it('should include `fail.pac` in stack traces by option', async () => { let err: Error | undefined; - const FindProxyForURL = createPacResolver(code, { + const FindProxyForURL = createPacResolver(qjs, code, { filename: 'fail.pac', }); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb2f474..0bd65b0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,10 +105,10 @@ importers: esprima: specifier: ^4.0.1 version: 4.0.1 - vm2: - specifier: ^3.9.19 - version: 3.9.19 devDependencies: + '@tootallnate/quickjs-emscripten': + specifier: ^0.23.0 + version: 0.23.0 '@types/escodegen': specifier: ^0.0.6 version: 0.0.6 @@ -268,6 +268,9 @@ importers: packages/pac-proxy-agent: dependencies: + '@tootallnate/quickjs-emscripten': + specifier: ^0.23.0 + version: 0.23.0 agent-base: specifier: ^7.0.2 version: link:../agent-base @@ -333,6 +336,9 @@ importers: specifier: ^2.0.2 version: 2.0.2 devDependencies: + '@tootallnate/quickjs-emscripten': + specifier: ^0.23.0 + version: 0.23.0 '@types/ip': specifier: ^1.1.0 version: 1.1.0 @@ -1763,6 +1769,9 @@ packages: '@sinonjs/commons': 2.0.0 dev: true + /@tootallnate/quickjs-emscripten@0.23.0: + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + /@types/agent-base@4.2.0: resolution: {integrity: sha512-8mrhPstU+ZX0Ugya8tl5DsDZ1I5ZwQzbL/8PA0z8Gj0k9nql7nkaMzmPVLj+l/nixWaliXi+EBiLA8bptw3z7Q==} dependencies: @@ -2128,23 +2137,12 @@ packages: acorn: 7.4.1 dev: true - /acorn-walk@8.2.0: - resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} - engines: {node: '>=0.4.0'} - dev: false - /acorn@7.4.1: resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} engines: {node: '>=0.4.0'} hasBin: true dev: true - /acorn@8.9.0: - resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: false - /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -5762,15 +5760,6 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /vm2@3.9.19: - resolution: {integrity: sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==} - engines: {node: '>=6.0'} - hasBin: true - dependencies: - acorn: 8.9.0 - acorn-walk: 8.2.0 - dev: false - /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: