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, andfor 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 defaultat runtime - those are ECMAScript-module constructs Rhino does not implement. - Module loading is CommonJS:
require()to import,module.exports/exportsto 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, andqueueMicrotaskdo not exist. Async syntax still compiles (it just becomes Promise-based code), but the resultingPromisereference is undefined at runtime, so it cannot run. Write straight-line synchronous code. - Browser APIs. No
window,document, DOM,fetch,XMLHttpRequest,localStorage, orconsole(usedw.system.Logger). For HTTP, usedw.net.HTTPClientor a registered service. - Node.js APIs. No
require('fs'),process,Buffer,__dirname,module-loading fromnode_modules, or npm runtime packages. Onlydw.*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 equivalentdw.*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?
| Concern | ESM import | CommonJS require |
|---|---|---|
| Runs on Rhino | ✗ no module loader | ✓ native |
| Matches SFCC docs & SFRA | ✗ | ✓ |
module.superModule (cartridge overlay) | ✗ | ✓ |
| Predictable transpiler output | depends 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.tsyou transpile) is a module with its own scope. Top-levelvar/functiondeclarations are private to that file unless attached toexports. require()returnsmodule.exports. Only what you put onmodule.exports(orexports.foo) is visible to callers.- Modules are cached. The first
require()of a module executes it once; laterrequire()calls of the same resolved module return the same cachedmodule.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 instanceTreat 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 webWithout 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'simport =/export =. - SFCC scripting is synchronous: no
Promise/async, no timers, nofetch- 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.