Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to import modules in Swift's JavaScriptCore?

I'm trying to use Swift's JavaScriptCore framework to take advantage of an existing JavaScript library that uses ES6 modules. Specifically, morse-pro by Stephen C Phillips. I've added the files to an Xcode playground, then used this code to load the library and run it in the JavaScript context:

import JavaScriptCore
var jsContext = JSContext()
// set up exception handler for javascript errors
jsContext?.exceptionHandler = { context, exception in
    if let exc = exception {
        print("JS Exception:", exc.toString())
    }
}
// read the javascript files in and evaluate
if let jsSourcePath = Bundle.main.path(forResource: "morse-pro-master/src/morse-pro-message", ofType: "js") {
    do {
        let jsSourceContents = try String(contentsOfFile: jsSourcePath)
        jsContext?.evaluateScript(jsSourceContents)
    } catch {
        print(error.localizedDescription)
    }
}

This approach works fine with simple "Hello world" sort of tests, but it chokes on the morse-pro library with this JavaScript error:

SyntaxError: Unexpected token '*'. import call expects exactly one argument.

The error appears to be caused by this line in morse-pro-message.js:

import * as Morse from './morse-pro';

which I believe is trying to import all the morse-pro files as a module.

I'm not familiar with ES6 modules, but the library appears to be working for others in normal JavaScript contexts. Is there something wrong with the way I'm loading the library in Swift? Or are modules a feature that JavaScriptCore doesn't support? (The documentation just says it supports "JavaScript" and doesn't get any more specific.)

I would appreciate any suggestions that point me in the direction of getting this library running in a JavaScriptCore VM.

like image 504
Robert Avatar asked Jan 20 '18 10:01

Robert


People also ask

Can we use JavaScript in Swift?

The JavaScriptCore framework provides the ability to evaluate JavaScript programs from within Swift, Objective-C, and C-based apps. You can use also use JavaScriptCore to insert custom objects into the JavaScript environment.

What is JavaScriptCore engine?

JavaScriptCore (JSC) is the JavaScript engine used by Safari, Mail, App Store and many other apps in MacOs. The JSC engine is responsible for executing every line of JavaScript (JS) that needs to be executed, whenever we browse to a new website or simply send/receive emails.

What is Jscontexts?

The JSContext is the central object of the JavaScriptCore namespace. The JSContext maintains a JavaScript environment (manipulated by the Item[NSObject] property) and evaluates scripts with the EvaluateScript(String, NSUrl) method.

What is swift in JavaScript?

Swift is an open source tool with 48.2K GitHub stars and 7.71K GitHub forks. Here's a link to Swift's open source repository on GitHub. reddit, Slack, and StackShare are some of the popular companies that use JavaScript, whereas Swift is used by Slack, Lyft, and Zillow.


2 Answers

While using import for modules is not possible.

You can support require('path/filename'); syntax.

This is done by providing require as a function to JS. The import command is (unfortunately) too exotic to implement in the given restrictions of JSContext.

See the following implementations

Objective-C.

@interface MyContext ()
@property JSContext *context;

@implementation MyContext

- (void) setupRequire {
    MyContext * __weak weakSelf = self;

    self.context[@"require"] = ^(NSString *path) {

        path = [weakSelf resolvePath:path];

        if(![[NSFileManager defaultManager] fileExistsAtPath:path]) {
            NSString *message = [NSString stringWithFormat:@"Require: File “%@” does not exist.", path];
            weakSelf.context.exception = [JSValue valueWithNewErrorFromMessage:message inContext:weakSelf.context];
            return;
        }

        [weakSelf loadScript:path];
    };
} 

- (NSString *) resolvePath:(NSString *)path {
    path = path.stringByResolvingSymlinksInPath;
    return path.stringByStandardizingPath;
}

- (void) loadScript:(NSString *)path {
    NSError *error;
    NSString *script = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&error];

    if (error) {
        script = @"";
        NSLog(@"Error: Could not read file in path “%@” to string. (%@)", path, error);
        // Return void or throw an error here.
        return
    }

    [self.context evaluateScript:script];
}

This example is based on code in Phoenix https://github.com/kasper/phoenix

https://github.com/kasper/phoenix/blob/master/Phoenix/PHContext.m#L195-L206

Swift 4

This example is based on code in CutBox https://github.com/cutbox/CutBox

import JavascriptCore

class JSService {

    let context = JSContext()

    let shared = JSService()

    let require: @convention(block) (String) -> (JSValue?) = { path in
        let expandedPath = NSString(string: path).expandingTildeInPath

        // Return void or throw an error here.
        guard FileManager.default.fileExists(atPath: expandedPath)
            else { debugPrint("Require: filename \(expandedPath) does not exist")
                   return nil }

        guard let fileContent = try? String(contentsOfFile: expandedFilename) 
            else { return nil }

        return JSService.shared.context.evaluateScript(fileContent)
    }

    init() {
        self.context.setObject(self.require, 
                               forKeyedSubscript: "require" as NSString)
    }

    func repl(_ string: String) -> String {
        return self.context.evaluateScript(string).toString()
    }
}
like image 138
ocodo Avatar answered Sep 23 '22 20:09

ocodo


After much bumbling around in the dark, I found a way to make the library available to Swift without having to manually alter it.

First, as @estus suggested, I installed the library using NPM, which converts it to ES5 but does not resolve the dependencies. So it's still a bunch of separate files that call each other with require and export keywords that neither browsers nor JavaScriptCore understand.

Then I used Browserify to bundle all the dependencies into a single file so that JavaScriptCore could understand it. The normal operation of Browserify hides all the code, so I used the "--standalone" flag to tell it to make marked functions available. If you export the ES5 file directly, it creates a generic object and puts your exported functions under .default. I want them slightly more accessible, so I created a new file to list the exports and then ran Browserify on that. So for example, a file called "morse-export.js" containing:

module.exports.MorseMessage = require('./lib/morse-pro-message.js').default;

Then I run Browserify on it like this:

browserify ./morse-export.js --standalone Morse > ./morse-bundle.js

And include the morse-bundle.js file in my Swift code using Bundle.main.path(forResource). Now I can access the MorseMessage class using Morse.MorseMessage, so back in Swift:

jsContext?.evaluateScript("var morseMessage = new Morse.MorseMessage()")
print(jsContext!.evaluateScript("morseMessage.translate('abc')"))

prints ".- -... -.-." as you would expect.

The downside of this is that you have to manually add whatever classes and functions you want exported to your export file. Still, this seems to be the simplest way to do it. If there's a better way, I would love to hear about it!

like image 33
Robert Avatar answered Sep 21 '22 20:09

Robert