JavaScript Modules (CommonJS & npm)
File-based JS actions with require() support, module caching, and bundling with esbuild.
On this page (12 sections)
This page is the author-facing contract for OpenClip's JS module runtime: splitting code across
local files with require(), bundling third-party libraries with npm/esbuild, and TypeScript. The
manifest schema itself lives in AGENTS.md (§3b); this page covers what runs and how
to build it.
1. Module mode (local multi-file, no build step)#
A javascript action that runs from a file ("script": "main.js") runs in module mode: the
CommonJS bindings require, module, exports, and __dirname are in scope, and the entry file
may load other files inside the package.
require('./lib/helper.js')— resolve and load a local module.module/exports— the current module's export object (module.exportsand itsexportsalias).__dirname— the package directory (of the entry file).
Note: the entry script's require base and __dirname are bound to the package directory,
not the entry file's directory — so a manifest script: "src/main.js" would resolve
require('./helper.js') against the package root, not src/. Keep entry scripts at the package
root (which both the plain js scaffold and --with-npm scaffold do). Child modules resolve
relative to their own file, Node-style.
Inline scriptCode actions keep the legacy single-file behavior, byte-identical: no require,
no module/exports/__dirname. If you need modules, use a script: file.
1a. Entry dispatch#
Module mode finds the entry function in this order:
module.exportsis a function → it is the entry.module.exports.actionis a function → it is the entry.module.exports.mainis a function → it is the entry.- an in-scope
actionfunction. - an in-scope
mainfunction.
The entry is called as (selection, options), exactly like the single-file contract (§7 of
AGENTS.md). If nothing matches, the script still runs (top-level side effects) and resolves to
.success.
1b. Resolution & caching#
require resolves Node-style, relative to the requiring file's directory:
- the exact file (
require('./lib/helper.js')), - else with
.jsappended (require('./lib/helper')→./lib/helper.js), - else
index.jsin the directory (require('./lib')→./lib/index.js).
Modules are cached per run — a second require of the same resolved path returns the same
exports. Cycles resolve to partial exports, Node-style. Paths are symlink-resolved, so an
in-package symlink and its target are one module and its __dirname is the target's directory
(Node's default without --preserve-symlinks).
1c. Containment#
Module resolution is scoped to the extension package directory. The host resolves
symlinks on the file candidate — the exact match, the .js fallback, and the index.js
fallback alike — and enforces a canonical path-component boundary (OpenClipModuleLoader +
Constants.isPathSafe) on the opened file descriptor. A require that resolves outside the
package directory — such as a ../ escape or a symlink pointing out — throws, and the run
surfaces as .toast(.error) with "resolves outside the extension package". Absolute-path specifiers
are rejected outright.
1d. Rejected specifiers#
- Bare specifiers never resolve. Node builtins (
fs,os,path, …) get an explicit message ("Node builtin … is not available to OpenClip extensions"); any other bare name (require('lodash')) gets "bundle npm libraries with esbuild". - Inline
scriptCodehas norequireat all.
2. The npm + TypeScript path#
When you need third-party libraries or TypeScript, scaffold with --with-npm:
./Extensions/scripts/create.sh Demo --type js --with-npm
cd Extensions/raw/Demo.openclipext
npm install # once
npm run build # after every edit to src/
../../scripts/install.sh .The scaffold writes a package.json (esbuild + TypeScript dev deps), tsconfig.json,
src/main.ts, and src/openclip.d.ts, and generates a manifest whose script points at
dist/main.js.
2a. Build contract#
dist/main.js is the shipped artifact: the manifest's script targets it and the host loads
only that one file. The contract:
npm installonce (regeneratesnode_modules).npm run buildafter every edit tosrc/— the bundle is stale otherwise.- THEN
../../scripts/install.sh .(or from the repository root:./Extensions/scripts/install.sh Extensions/raw/Demo.openclipext).
validate.sh enforces this: an npm package without dist/main.js fails install (exit 1,
"run 'npm install && npm run build'"), and a dist/main.js older than package.json or anything in
src/ produces a rebuild warning.
2b. TypeScript: the bundle path only#
TypeScript is supported only through the bundle path. esbuild transpiles src/*.ts into the CJS
bundle; the host loader runs .js only — the app never sees a .ts file, so a manifest script
must never point at one. Write TypeScript in src/, emit dist/main.js.
npm run typecheck (tsc --noEmit) is optional — a CI/editor aid, not part of the build. The
ambient openclip.* declarations live in src/openclip.d.ts (type-only; the runtime never reads it).
2c. Bundling third-party libraries#
The bundle path is how you use libraries: npm install a pure-JS library, import it from src/,
and esbuild inlines it into dist/main.js. Known-good classes:
- Lodash-style utilities —
lodash,just-*,ramda. - Markdown/HTML parsers & processors —
marked,turndown,cheerio. - Slugify / string helpers —
slugify,change-case. - Date libraries —
date-fns,dayjs.
Node builtins can never be bundled: the build's browser platform rejects them at build time.
3. Limitations#
These are hard limits of the shipped runtime, not open questions:
- No Node builtins. The bundle is built with
--platform=browser --target=es2020, which hard-rejectsrequire('fs')and friends at build time (Could not resolve "fs") — the builtin can never ship. - No native
.nodebindings. esbuild rejects them at build time. - No DOM. The runtime is JavaScriptCore — no
window,document, or browser globals. - No default watchdog. Extension and custom scripts run with no timer by default: a never-settling async script keeps waiting until it settles or the user clicks the loading toast to cancel it immediately. Only runs that pass an explicit
Request.timeout(e.g. the inline-result evaluator) are killed once that budget elapses. - JSC's global set is not Node's. The host runtime is JavaScriptCore, not Node: expect the JS
language and the
openclip.*bridge, not Node globals. A few names overlap (setTimeout,Buffer) but with JSC semantics, not Node's — treat any reliance on Node-global behavior as a known limitation, and use the esbuild--defineshim (below) forprocess.env.NODE_ENVrather than assuming it exists.
4. esbuild shims for browser-platform builds#
Because the bundle is built for esbuild's browser platform, libraries that touch Node-global
expectations need shims at build time (these live in the package.json build script, not the
extension code):
--define:process.env.NODE_ENV='"production"'— libraries that branch onprocess.env.NODE_ENVget a defined constant instead of a missing-global error.--external:<name>— keep a dependency external to the bundle. Rarely useful here (the host cannot provide Node modules); prefer to bundle.--inject:./shims.ts/--alias:...— inject a global (e.g. a minimalprocessstub) or alias a module to a local file, both from undersrc/.
5. Reference#
- Module resolution & containment:
Sources/OpenClip/Platform/Runtimes/OpenClipModuleLoader.swift. - Module prelude/wrappers & entry dispatch:
Sources/OpenClip/Platform/Runtimes/OpenClipJSHost.swift. - Scaffold/validate scripts:
Extensions/scripts/create.sh,Extensions/scripts/validate.sh.