Hosting NetSuite Suitelets Externally

To create a completely custom UI in NetSuite, you’ll need to create a special script known as a “suitelet”. Suitelets are written in JavaScript and allow you to build an HTML document to be delivered to the browser. In the suitelet, you’ll have access to all of NetSuite’s SuiteScript APIs so you can inject contextual data into the HTML document. NetSuite does not, however, provide a very robust way to manage complex permissions on these scripts much less allow for automated deployments.

Users are basically given “all or none” access to a file in the file cabinet. To prevent unwanted changes in production, it is common to have a small subset of users control the scripts in the file cabinet while everyone else submits changes to those users. This results in a rather slow deployment story due to the amount of manual work involved.

The nature of how HTML is written and delivered to the client also makes it cumbersome to write SPA interfaces, which is what most users have come to expect these days when an app loads in their browser.

To get around these restrictions, I decided to host my suitelet content (an Angular app) externally in Azure. Sure, I’m trading off performance in some cases as I need to load all of my data from RESTlets, but it does allow me to make immediate changes to the client app without having to request changes in NetSuite. While this is surprisingly easy to do, it is also important to think carefully about how to secure such a solution to avoid untrusted content from being delivered to the user. After all, the document delivered to the user can call RESTlet and Suitelet endpoints AS the end user.

The script below is an example of how this can be done while limiting which remote hosts we are allowed to request content from. This solution also leverages the caching API to limit the number of times we reach out to the remote server for the client app. We use TypeScript when writing SuiteScript, by the way. Check out the awesome @hitc/netsuite-types package for typings!

/**
 * @NApiVersion 2.x
 * @NScriptType Suitelet
 * @NModuleScope Public
 */

import * as NSCache from "N/cache";
import * as NSHttps from "N/https";
import { EntryPoints } from "N/types";

const BasicUrlRegEx = /^https:\/\/([\w\d\._-]+):?(\d{0,5})?(\/.*)?$/i;

// NOTE: for simplicity, use only lower-case in the whitelist
const HostsWhitelist = [
    "myapp.mydomain.com"
];

function getUrlCacheKey(fullUrl: string): string {
    const hashIndex = fullUrl.indexOf("#");

    if (hashIndex === -1) {
        return fullUrl.toLowerCase();
    }

    else {
        return fullUrl.toLowerCase().substring(0, hashIndex);
    }
}

export function onRequest(context: EntryPoints.Suitelet.onRequestContext): void {
    try {
        let externalUrl = context.request.parameters.url as string | undefined;
        const bustCache = context.request.parameters.bustCache as string | undefined;

        // check for required url parameters
        if (!externalUrl) {
            throw new Error("The 'url' query parameter is required.");
        }

        // force https
        if (externalUrl.indexOf("http://") === 0) {
            externalUrl = "https://" + externalUrl.substr(7);
        }

        // verify host was whitelisted
        const regexResults = BasicUrlRegEx.exec(externalUrl);

        if (!regexResults || regexResults.length < 2 || HostsWhitelist.indexOf(regexResults[1].toLowerCase()) === -1) {
            throw new Error("The provided URL has not been whitelisted. Please contact your administrator.");
        }

        // load content cache
        const cache = NSCache.getCache({
            name: "ExternalUriResults",
            scope: NSCache.Scope.PRIVATE
        });

        const cacheKey = getUrlCacheKey(externalUrl);

        let html = cache.get({
            key: cacheKey
        });

        // if there was no cache result OR if bustCache=true, fetch remote content
        if (!html || (bustCache && bustCache.toLowerCase() === "true")) {
            const response = NSHttps.get({
                url: externalUrl
            });

            html = response.body;

            cache.put({
                key: cacheKey,
                value: html,
                ttl: (60 * 60)
            });
        }

        // respond with content
        context.response.write(html);
    }

    catch (err) {
        // we log the error here using a custom logging service
    }

    finally {
        // we flush the batch of log events to the remote logging server here
    }
}
Josh Johnson

Josh Johnson

Solutions Architect at TrueCommerce
Josh has been writing software for 7 years. He currently works on the .NET stack using .NET Core and Angular to build highly-customized solutions for the Professional Services team at TrueCommerce.
Josh Johnson

Latest posts by Josh Johnson (see all)