Production-grade infrastructure for Adobe ExtendScript.
|
ESON ESB64 ESARR ESSTR ESCHARS ESHTTP ESTIMER ESRAND ESUUID ESENV ESPATH ESFS ESHASH ESLOG |
ESPACK ESMIN ESABI VectorIPC ESTC ESDB COMTool ESsemble |
Also from the same team: ArcFit.dev, deterministic arc warp for Illustrator.
- Why ESFS?
- Features
- Get the Release
- Installation
- Quick Start
- API
- Validation
- Performance
- Security Model
- Compatibility
- Engine quirks that shaped the design
- Development
- Repository layout
- Credits
- License
Adobe ExtendScript exposes synchronous File and Folder objects, but production code still has to make encoding, BINARY byte strings, directory behavior, host error propagation, alias behavior, and replacement limits explicit. ESFS defines those contracts while keeping ordinary file operations to one File object and one open/read-or-write/close sequence.
Caller path strings pass unchanged to new File(path) / new Folder(path). ESFS does not normalize, decode, join, or canonicalize caller paths. Internal sibling staging is isolated behind PathAdapter; no ESPATH API is assumed.
- Explicit UTF-8/default text operations plus caller-selected text encodings.
- Dedicated BINARY operations for byte strings whose UTF-16 code units are exactly 0x00–0xFF.
- Whole-payload reads/writes with no per-byte JS array conversion.
- Separate file/folder existence and stat APIs; no ambiguous generic
exists(path). - One-call
File.copy()delegation for copy semantics. - Single-level
createDirectory()and explicit parent-walkingensureDirectory(). - Immediate file removal and intentionally non-recursive empty-directory removal.
- Leaf-only rename validation.
- Best-effort staged replacement with explicit
atomicityanddurabilityresult fields plus rollback attempts. - Structured
ESFSErrorfailures with operation/path/detail and rollback/cleanup context. - Portable Node adapter tests plus separately verified live Adobe behavior.
- Adobe File/Folder remains the default correctness and fallback surface. The preferred native distribution is a self-extracting ESPACK bundle that materializes and adopts an ESABI-backed Windows DLL while leaving the default Adobe methods untouched; binary contents never cross the ExternalObject boundary.
All production bundles ship as GitHub release assets — this repo holds sources. Grab the runnable builds from the Releases page.
How it works, in three steps:
- Open the Releases page.
- Pick the latest stable tag.
- Download the asset that matches your use case:
| You are... | Take this release | And this asset |
|---|---|---|
| Using the Adobe File/Folder correctness surface | Latest stable | ESFS.jsx |
| Wanting a self-contained Windows x64 accelerated bundle | Latest stable | ESFS.accel.jsx or ESFS.accel.min.jsx |
| Composing ESFS into an existing ESPACK runtime | Latest stable | ESFS.facade.jsx + ESFS.manifest.json |
| Deploying the direct native compatibility lane | Latest stable | ESFSNative.dll |
| Consuming the portable core from Node tooling | Latest stable | esfs-core.esm.mjs |
npm install
npm run buildThe build emits:
dist/ESFS.jsx— ExtendScript facade installed at$.global.ESFS.dist/ESFS.accel.jsx/ESFS.accel.min.jsx— self-contained ESPACK manifest-v2 compositions with dependency-first[email protected] -> [email protected]closure, one persistent loader/control plane,ESFSNative_v1.dll, and sharedESB64Native_v2.dll.dist/ESFS.facade.jsx+ESFS.manifest.json— loader-free facade and byte-provenanced manifest-v2 composition contract for a shared ESPACK runtime.dist/esfs-core.esm.mjs— portable core for Node/integration use.dist/types/— TypeScript declarations.
The current native-gate build of dist/ESFS.jsx is 18,936 bytes. The preserved evidence/final-live-evidence.json belongs to the earlier 14,651-byte reference artifact whose SHA-256 is a9d44931e627ed02fdd31fe9ade97bc6a822491a6fe95226e796a0478ae183d1; that historical evidence remains intentionally immutable.
For the self-contained native distribution, run npm run native:build && npm run build:accel:strict, then load dist/ESFS.accel.jsx. ESPACK extracts/caches the versioned native payload and ESFS adopts that shared handle. Loading the accelerator does not replace ESFS.copyFile, fileExists, or the other Adobe-backed default methods; native filesystem semantics stay under ESFS['native'].
ESFS.writeText(
new File(Folder.userData.fullName + "/settings.txt"),
"mode=fast\n"
);
var settings = ESFS.readText(
new File(Folder.userData.fullName + "/settings.txt")
);
// Binary values are strings whose UTF-16 code units are bytes 0x00–0xFF.
var raw = ESFS.readBinary(
new File(Folder.temp.fullName + "/payload.bin")
);
ESFS.writeBinary(
new File(Folder.temp.fullName + "/copy.bin"),
raw
);
var createdFolders = ESFS.ensureDirectory(
new Folder(Folder.temp.fullName + "/app/cache/v1")
);
var stat = ESFS.statFile(
new File(Folder.temp.fullName + "/payload.bin")
);Files and folders are separate inputs; ESFS does not guess which one a path denotes.
| Operation | Contract |
|---|---|
readText(path, {encoding?}) |
One File.open("r"), one read(), and one close(). Defaults to UTF-8. Returns the File host's decoded string without ESFS newline rewriting. Adobe's BOM detection remains with File. |
writeText(path, text, {encoding?, lineFeed?}) |
One File.open("w"), one whole-string write(), and one close(). open("w") truncates an existing target. Defaults to UTF-8 and Unix line feeds. ESFS does not add a BOM. |
appendText(...) |
One open in append mode, one write, one close. Separate writers are not serialized. |
readBinary(path) / writeBinary(path, bytes) / appendBinary(...) |
File BINARY mode, one whole string and no per-byte arrays. If File.open() auto-detects a BOM and changes the selected encoding, the reader restores BINARY and seeks back to byte zero. Each write code unit must be in 0x00–0xFF; higher values are rejected. |
writeTextReplace(...) / writeBinaryReplace(...) |
Write and close a same-folder stage, rename the old target to a backup, then publish the stage. On ordinary failures ESFS attempts to restore the old target. Result states atomicity: "best-effort" and durability: "close-confirmed-only". Alias/shortcut targets are rejected. |
fileExists / folderExists |
One object and one .exists query. |
statFile / statFolder |
Rich metadata only when requested. Missing entries return exists: false; file length is read only after existence check and while closed. |
copyFile(source, destination) |
Delegates to one File.copy() call; no ESFS read/write buffer. |
ESFS['native'].load(path?) / .status() / .unload() |
Direct-DLL compatibility/development lane. The ESPACK artifact adopts its already-loaded handle automatically; .unload() detaches from borrowed ESPACK handles without unloading ESPACK's process-wide cache. |
ESFS['native'].fileExists(file) / .fileSize(file) |
OS-side file-only query using File.fsName; returns native filesystem observations and reports Win32 errors distinctly. Native paths must be absolute paths accepted by Windows APIs. |
ESFS['native'].copyFile(source, destination) |
OS CopyFileW copy with overwrite enabled. Operates on File.fsName strings, follows Windows filesystem/reparse-point behavior, and does not claim Adobe alias parity. It throws with the Win32 error code; it does not silently retry through File.copy(). |
createDirectory(path) |
One Folder.create() call for that level; fails if an ancestor is missing. |
ensureDirectory(path) |
Walks only missing parents, creates from the first existing ancestor downward, returns number created; 0 when already present. |
listDirectory(path, mask?) |
Returns Folder.getFiles()'s array directly; null means missing, empty array means existing and empty. |
removeFile / removeEmptyDirectory |
Immediate deletion; folder removal is non-recursive and fails for non-empty folders. |
renameFile(path, newLeafName) |
Uses File.rename with a leaf name only; path separators are rejected. |
Every false File/Folder method result and thrown host error becomes an ESFSError carrying operation, path, detail, and where relevant rollback/cleanup details.
Reads do not perform an .exists preflight. Writes rely on the single open("w") that creates or truncates the target. Text methods reject BINARY mode so code units cannot be silently truncated; use the dedicated binary methods. Explicit custom text encodings are checked with File.isEncodingAvailable and the most recently checked custom encoding is cached by the facade.
createESFS() accepts a PathAdapter with only file, folder, siblingFile, renameSibling, and encodingAvailable. The Adobe adapter uses File/Folder constructors and their documented path/name properties. Caller-supplied strings are never rewritten. Any future ESPATH integration belongs behind this interface.
| Check | Command | Result |
|---|---|---|
| TypeScript | npm run typecheck |
pass |
| Build | npm run build |
pass; 18,936-byte native-gate dist/ESFS.jsx |
| Portable ES3 | npm run estc:static |
emitted facade + benchmark harness pass |
| Portable core | npm test |
14/14 tests pass |
| Live parser | npm run verify:engine |
facade + benchmark harness pass on Illustrator 30.6.0 / ExtendScript 4.5.6 |
| Bound public-API smoke | recorded in evidence/final-live-evidence.json |
20/20 assertions pass with clean filesystem cleanup |
| Native backend smoke | npm run native:live:probe |
9/9 assertions pass, including actual ABI revision 1 and byte-exact NUL copy |
| Packaged native fallback | npm run native:release:live |
pass; staged package layout resolves and loads native/release/ESFSNative.dll without a manifest |
| ESPACK byte/provenance contract | npm run accel:contract |
pass; payload + ESB64Native bytes match build inputs, the bundle contains the current sibling ESB64 runtime byte-for-byte, and the npm whitelist exposes only the stable raw DLL |
| Accelerator static + live parse | npm run estc:accel:live |
3/3 artifacts pass ES3 static checks and live compile-only parsing |
| Full accelerator behavior | npm run accel:live |
11/11 checks pass: adoption, binary-copy parity, default Adobe semantics, ownership, detach/re-adopt |
| Minified accelerator behavior | npm run accel:live:min |
same 11/11 live checks pass on the conservative minified artifact |
Historical reference evidence is captured in evidence/final-live-evidence.json against Illustrator 30.6.0 / ExtendScript 4.5.6. That earlier 14,651-byte artifact passed the 20/20 public-API smoke and File I/O benchmark. The new native-enabled worktree is validated separately so the original evidence is not rewritten to imply it covered code that did not yet exist.
The raw native lane and ESPACK lane have separate gates. npm run native:build validates the immutable deps/esabi/ESABI_PIN, emits a content-addressed x64 DLL, writes dist/native/ESFSNative.current, and refreshes the stable dist/native/release/ESFSNative.dll that also feeds the ESPACK payload. npm run build:accel:strict composes against the current sibling ESB64 runtime + ESB64Native v2, emits the manifest/facade/full/minified artifacts, and fails rather than silently skipping missing accelerator inputs. The live accelerator probes additionally prove borrowed-handle ownership: detaching ESFS does not invalidate ESPACK's cached library, and re-adoption sees the same handle.
The two older evidence/run-*.json files are preserved control-plane failures from before a runnable local COMTool path was available; they never dispatched the probe.
tests/benchmark-file-io.jsx compares ESFS's whole-payload writeBinary() path (one underlying File.write) with direct 16 KiB substring/write chunks at 1 KiB, 64 KiB, and 1 MiB.
It discards two warmups, records seven samples, uses ESTIMER when already loaded or a primed single-delta $.hiresTimer lane otherwise, validates file length outside the timed window, and verifies the exact payload outside timing. Both lanes perform one equivalent BINARY code-unit validation scan; payload construction is outside the timed window.
For each timed write, the harness reports one File object, one BINARY code-unit validation scan, two property assignments (encoding, lineFeed), one open, one close, zero existence probes, and either one whole write or ceil(bytes / 16384) chunk writes.
Live Illustrator 30.6.0 / ExtendScript 4.5.6 medians from evidence/final-live-evidence.json:
| Payload | ESFS whole write | 16 KiB chunked | Host writes | Whole-write latency advantage |
|---|---|---|---|---|
| 1 KiB | 243 µs | 239 µs | 1 vs 1 | -1.7% (effectively tied) |
| 64 KiB | 729 µs | 813 µs | 1 vs 4 | 10.3% |
| 1 MiB | 8,376 µs | 9,681 µs | 1 vs 64 | 13.5% |
The measured gain grows with avoided host write calls: at 1 KiB both lanes issue one write and are effectively tied; by 1 MiB the one-write ESFS path is 13.5% lower latency than 64 chunk writes under the same validation-scan contract.
Measured live on Illustrator 30.6.0 / ExtendScript 4.5.6 on 2026-09-28. The benchmark uses the same source file for Adobe File.copy() and native CopyFileW, with two warmups and seven timed samples per lane. Exact content is verified outside the timed region, and the native DLL is unloaded at the end.
| Payload | Adobe File.copy() observed medians |
Native CopyFileW observed medians |
Observed native speedup |
|---|---|---|---|
| 64 KiB | 344–391 µs | 300–317 µs | 1.15–1.23× |
| 1 MiB | 1,809–2,055 µs | 557–568 µs | 3.18–3.69× |
The 64 KiB difference is small; the 1 MiB result is a clear win for the native copy lane on this host. This does not cause automatic routing: ESFS['native'].copyFile() remains explicit because Win32 reparse-point/alias semantics are not identical to Adobe File.copy(). Live testing also exposed that a previously constructed Adobe File object can retain stale metadata after an out-of-band Win32 mutation, so post-native inspection reconstructs the File from .fsName.
ESFS is an intentional filesystem mutation primitive. Callers choose File/Folder targets; write, append, copy, rename, replace, and remove operations can change persistent data immediately.
The base ESFS.jsx does not load native code. The self-extracting ESFS.accel.jsx intentionally materializes and adopts native code during evaluation, but the native methods still receive only Adobe File.fsName paths (UTF-8 text); no binary payload is sent through ExternalObject strings. Native APIs can have OS path, alias, and reparse-point behavior different from Adobe File/Folder, so accelerator activation never rewires the default API's semantics. Because native Win32 calls mutate paths outside Adobe's File object, a previously constructed File instance can retain stale metadata; reconstruct new File(file.fsName) before inspecting metadata after a native mutation. Replacement remains best effort, not crash-atomic; no native replacement or fsync/durability claim is made.
ESPACK owns libraries it materializes and caches. ESFS tracks that ownership explicitly: direct ESFS['native'].load() handles are unloadable by ESFS, while an ESPACK-adopted handle is borrowed and ESFS['native'].unload() only detaches local state.
Alias/shortcut targets are rejected by staged replacement because Adobe's operation-specific alias behavior differs between open/copy and rename/remove.
| Target | Status |
|---|---|
| Conservative ExtendScript ES3 | ESTC static pass |
| Adobe Illustrator 30.6.0 / ExtendScript 4.5.6 | historical 20/20 default-API smoke + current 9/9 native probe pass |
| File/Folder hosts | runtime requires Adobe File and Folder support |
| Node portable core | Node >=20 development/test tooling |
| Optional native backend | Windows x64 ExternalObject DLL, ESABI 0.3.1 LONG32; not loaded by default |
| ESPACK accelerator | Full + minified bundles live-verified with ESB64Native v2 extraction and ESFSNative v1 adoption |
Illustrator/2022 Types-for-Adobe is the compile-time baseline, not proof of Illustrator 2022 runtime behavior. Runtime claims are scoped to the explicitly named live host above.
- Regex-literal parser hazard: the initial
renameFileseparator regex passed modern Acorn/JS but Illustrator 30.6.0 rejected the literal. The shipped implementation now uses simple separator checks; the rebuilt artifact is the one proven by final live evidence. - BINARY BOM behavior:
File.open()can auto-detect a BOM and changeencoding; the BINARY reader restores BINARY and rewinds to byte zero before reading. - Alias semantics are operation-specific: Adobe File open/copy can resolve aliases while rename/remove act on the alias itself. Staged replacement therefore refuses alias targets.
- Directory recursion is explicit:
ensureDirectory()creates path segments itself rather than treating undocumented parent-creation behavior as portable.
The following are prior measurements from sibling libraries, not ESFS measurements. Unless otherwise stated, their live results apply to Illustrator 30.6.0 / ExtendScript 4.5.6.
| Source | Relevant evidence | ESFS design consequence |
|---|---|---|
| ESARR | Variable-index array reads became severely superlinear; a 512-element traversal was about 0.13 ms versus about 0.8 s at 32k. | Filesystem operations stay independent of JS byte arrays; each File property is read only when its contract needs it. |
| ESB64 | Array writes measured about 15–25 µs each in its fixtures; a 47K-write byte pipeline took about 1.7 s. | BINARY I/O moves one whole byte string through one read/write rather than an array-of-bytes pipeline. |
| ESON | In its measured settings workload, a plain key/value text reader was about 2–4× faster to parse and 13–17× faster to write than ESON JSON. | ESFS stays an I/O primitive rather than taking a JSON dependency. |
| ESSTR | charAt() returned an empty string for U+0000 on the measured engine. |
Binary strings rely on File BINARY semantics; ESFS does not use charAt or per-byte conversion. |
| ESCHARS | A pure-JSX per-unit transform pattern wedged at inputs >=128 KiB after two reproductions. | ESFS avoids JS per-unit binary transforms; its native lane keeps payload bytes OS/file-side and is enabled only explicitly where live end-to-end evidence supports it. |
| ESTIMER | $.hiresTimer is a delta clock; the first read is not a useful sample. |
The benchmark prefers ESTIMER and otherwise primes the raw delta timer immediately before each write. |
| ESPACK | A sibling live test round-tripped all 256 byte values, including NUL, through File BINARY mode. |
This stayed inherited context; ESFS separately validated NUL, 0x80, and 0xFF in its own live smoke. |
npm run typecheck
npm run build
npm run estc:static
npm test
npm run verify
npm run verify:engine
npm run benchmark:static
npm run native:build
npm run build:accel:strict
npm run accel:contract
npm run estc:accel:live
npm run accel:live
npm run accel:live:min
npm run native:build:check
# In Illustrator after building the DLL:
npm run native:live:probe
npm run native:release:live
npm run native:benchmark
npm run release:gateThe optional DLL uses ESABI 0.3.1, ABI revision 1, Windows x64 LONG32 at immutable release commit 65c9c3ce627a26a89d6bf90547678841df0cf981. The required ESABI headers and license are vendored under deps/esabi/ with ESABI_PIN; the build validates that provenance before compiling. Development DLL names are content-addressed from source + ESABI headers + pin + build recipe + MSVC identity so a newly built generation never needs to overwrite a DLL still mapped by Illustrator. A separate stable native/release/ESFSNative.dll copy is packaged for deployment and is used only when the development manifest is absent. Live probes confirmed that ExternalObject.unload() does not guarantee an immediate Windows file unlock, so cleanup of loaded generations is deliberately deferred/best-effort.
- Package:
esfs0.2.0; role: runtime primitive; license: GPL-3.0-or-later. - Build pins: esbuild 0.28.2 and TypeScript 5.9.3; Node >=20.
- ESTC is consumed from the adjacent local
../extendscript-toolchaincheckout and is not a production runtime dependency. - Canonical hard edge:
extendscript-toolchain -> esfs(build-toolchain). - Canonical validation back-edge:
esfs -> extendscript-toolchain(release-test-only) via the workspace-audit manifest. - Canonical native ABI edge:
esabi -> esfs(native-abi), evidenced bydeps/esabi/ESABI_PIN,native/esfs_native.c, andscripts/build-native.ps1. - Canonical accelerator edges:
espack -> esfsandesb64 -> esfs(composed-bundle); both feed the tracked release composition and therefore require recompose/retest on upstream release propagation. - Canonical rollout phase: 1 (
1-bootstrap-and-independent) after ESTC foundation phase 0. - ESTC workspace audit includes
dist/ESFS.jsxand passes it with zero ESFS warnings/errors. - GitHub repository: thelabcorner/es-fs.
esfs/
├─ evidence/ Preserved live/control-plane evidence
├─ scripts/build.mjs Core/declaration/JSX + ESPACK accelerator composition
├─ scripts/build-native.ps1 Pinned-ESABI Windows DLL build
├─ deps/esabi/ Immutable ESABI 0.3.1 header snapshot + pin
├─ native/ ESABI-backed Win32 source
├─ src/ Core, Adobe adapter, facade, and types
├─ tests/ Portable tests, adapter, live probes/benchmarks
├─ extendscript.estc.config.mjs ESTC build profile
├─ package.json
└─ README.md
Generated dist/ output is intentionally ignored.
- Docs for Adobe — File object for open modes, encoding, BINARY, read/write, copy/rename/remove, and metadata semantics.
- Docs for Adobe — Folder object for create,
getFiles, parent, metadata, and empty-folder removal. - Docs for Adobe — Using File and Folder objects for path representation and object usage.
- ESTC and COMTool for portable and live-engine validation.
GPL-3.0-or-later. See LICENSE.
ESFS: explicit synchronous File/Folder I/O contracts for Adobe ExtendScript.