Imports & the Rhino Engine

Why SFCC scripts use CommonJS require() instead of ESM import - and how code is scoped, made visible, and executed on the Rhino engine

SFCC server-side scripts do not run in Node.js or a browser. They run on Mozilla Rhino, the JavaScript engine embedded in the B2C Commerce platform. Understanding Rhino explains every import and scoping rule you'll follow in a cartridge - and why TypeScript code must be written a particular way.

What Rhino Is

Rhino is a JavaScript engine written in Java. SFCC (Demandware) embeds it to execute controllers, hooks, jobs, and <isscript> blocks server-side.

  • It implements JavaScript 1.7 / 1.8 plus Mozilla extensions - so you get let, const, generators, destructuring, and for each … in, but not the full modern ES2015+ standard library or syntax.
  • It has no ES-module loader. There is no import x from 'y' / export default at runtime - those are ECMAScript-module constructs Rhino does not implement.
  • Module loading is CommonJS: require() to import, module.exports / exports to export. This is the same module system the SFCC documentation and SFRA are built on.

Because the runtime is Rhino, author your modules with CommonJS, even in TypeScript. Reach for require() / module.exports, not ESM import / export default.

What Rhino Does Not Provide

This is the part that surprises people coming from Node or the browser. The transpile down-levels modern syntax, but it does not add missing runtime built-ins - and Rhino's standard library is small. The following are not available on SFCC and are not polyfilled, so don't reach for them:

  • Asynchrony of any kind. SFCC server scripting is fully synchronous - there is no event loop. Promise, async/await, setTimeout, setInterval, and queueMicrotask do not exist. Async syntax still compiles (it just becomes Promise-based code), but the resulting Promise reference is undefined at runtime, so it cannot run. Write straight-line synchronous code.
  • Browser APIs. No window, document, DOM, fetch, XMLHttpRequest, localStorage, or console (use dw.system.Logger). For HTTP, use dw.net.HTTPClient or a registered service.
  • Node.js APIs. No require('fs'), process, Buffer, __dirname, module-loading from node_modules, or npm runtime packages. Only dw.* modules and your own cartridge modules resolve.
  • Newer standard-library built-ins. Anything beyond the ES5-era baseline is not guaranteed and is not auto-polyfilled - e.g. Object.assign, Array.from, Array.prototype.includes, String.prototype.padStart, Map/Set/Symbol, globalThis. Prefer the equivalent dw.* API, or provide a small, self-contained polyfill for the specific built-in you need.

The rule of thumb: syntax is transpiled, library built-ins are not. If a feature is a runtime object or method (like Promise or Object.assign) rather than syntax (like arrow functions or classes), assume it is absent unless you've confirmed it exists or polyfilled it yourself. See Build & Transpile for why.

Use require(), Not ESM import

In plain JavaScript cartridge code this is just standard SFCC style:

'use strict';

var ProductMgr = require('dw/catalog/ProductMgr');
var collections = require('*/cartridge/scripts/util/collections');

function show(product) { /* ... */ }

module.exports = {
    show: show
};

In TypeScript, write the exact same CommonJS - const x = require(...) and module.exports. You don't need any TypeScript-specific import syntax; plain require() is fully typed against the cartridge overlay and passes through the transpiler 1:1:

const ProductMgr = require('dw/catalog/ProductMgr');   // typed as dw.catalog.ProductMgr
const URLUtils = require('dw/web/URLUtils');

function show(productId: string): string {
    const product = ProductMgr.getProduct(productId);
    return URLUtils.url('Product-Show', 'pid', productId).toString();
}

module.exports = {
    show: show
};

Use const x = require(...), not TypeScript's import x = require(...) / export =. The language server types plain require() against the cartridge overlay just the same, it's identical to how your .js files are written, and it survives transpilation untouched - no import = edge cases, no interop shims. See Module Resolution for how paths (dw/*, */, ~/, relative) resolve.

Why not ESM import / export default?

ConcernESM importCommonJS require
Runs on Rhino✗ no module loader✓ native
Matches SFCC docs & SFRA
module.superModule (cartridge overlay)
Predictable transpiler outputdepends on interop shims✓ direct

If you write ESM import/export default in TypeScript, your transpiler can emit CommonJS - but you add interop shims (__esModule, default-interop wrappers) that obscure what actually loads, complicate module.superModule, and drift from how every other SFCC cartridge is written. Staying in CommonJS keeps the source and the emitted JavaScript one-to-one.

How Modules Load and Are Scoped

CommonJS on SFCC behaves the way you expect from Node, with a few SFCC specifics:

  • A module is a file. Each .js (and each .ts you transpile) is a module with its own scope. Top-level var/function declarations are private to that file unless attached to exports.
  • require() returns module.exports. Only what you put on module.exports (or exports.foo) is visible to callers.
  • Modules are cached. The first require() of a module executes it once; later require() calls of the same resolved module return the same cached module.exports. A module therefore behaves like a singleton for the life of its cache.
// util/counter.js
var count = 0;                       // private - not visible to requirers
function next() { return ++count; }
module.exports = { next: next };     // only `next` is exported
// caller.js
var counter = require('*/cartridge/scripts/util/counter');
counter.next();   // 1
counter.next();   // 2 - same cached module instance

Treat module top-level state as shared, cached state - not as per-request scratch space. Keep request-specific data in the objects you pass around (e.g. pdict, model instances), not in module-level variables.

Visibility & Execution in Endpoints

How code becomes reachable from the web depends on the endpoint style. The rules are about what you export, and Rhino executes the controller module top-to-bottom on each request that routes to it.

A controller exports named route functions. In SFRA these are registered through the server module:

var server = require('server');

server.get('Show', function (req, res, next) {
    res.render('product/productDetails');
    next();
});

module.exports = server.exports();

The route Product-Show maps to controller file Product + exported route Show.

A SiteGenesis controller assigns route functions onto exports, and marks web-reachable ones with a boolean public flag:

function show() { /* ... */ }

exports.Show = guard.ensure(['get'], show);
exports.Show.public = true;     // required to be reachable from the web

Without exports.Show.public = true, the route is not web-exposed. The language server type-checks that this flag is boolean - see Diagnostics.

Custom SCAPI endpoints wire an operationId (declared in the API contract) to an exported function. The exported function name is what the endpoint invokes:

exports.getProductReviews = function (ctx) { /* ... */ };

Completion for these wirings is offered inside api.json - see Code Completion.

What This Means for Your TypeScript

  • Write modules as CommonJS (const x = require(...), module.exports), the same shape as your plain JavaScript - not TypeScript's import = / export =.
  • SFCC scripting is synchronous: no Promise/async, no timers, no fetch - and they aren't polyfilled. See What Rhino Does Not Provide.
  • Don't rely on modern ES standard-library built-ins that Rhino lacks; your CI/CD transpile down-levels syntax, but runtime built-ins still have to exist (polyfill where needed - covered in Build & Transpile).
  • Keep module-level state intentional; it is cached and shared.

On this page