locter
v4.1.0
Published
A library to locate files/modules by criteria, read them and write them back!
Maintainers
Readme
Features
- 🔍 Locate —
locate,locateMany,locateUp: fast-glob discovery returning structuredLocatorInforecords. - 📖 Read — one
read()for JSON, YAML,.conf, and JS/TS modules in any runtime (powered byjiti);readAsModule()for the uniform module-record shape. - ✍️ Write —
write()values back without pulling serializers into your app: comment-preserving YAML, indent-preserving JSON, rc9-style.conf. - 🔁 Sync twins — every operation ships as
fn/fnSync, derived from one shared implementation. - 🧩 Extensible — register custom formats with independent reader/writer slots; override built-ins per extension.
- 🚨 Typed errors —
NotFoundError,LoadError,WriteError, … each carryingpathandcause.
Table of Contents
Installation
npm install locter --saveUsage
The following examples are based on some shared assumptions:
- A folder named
filesexists in the root directory. - The folder
filescontains the following files:- example.js
- example.json
- example.ts
- example-long.ts
Locator
Multiple
Locating multiple files will return information about all files matching the pattern.
import { locateMany } from 'locter';
(async () => {
let files = await locateMany(
'files/example.{js,.ts}'
);
console.log(files);
/*
[
{ directory: '/cwd/files', name: 'example', extension: '.js', path: '/cwd/files/example.js' },
{ directory: '/cwd/files', name: 'example', extension: '.ts', path: '/cwd/files/example.ts' }
]
*/
files = await locateMany(
'files/*.{js,ts}'
);
console.log(files);
/*
[
{ directory: '/cwd/files', name: 'example', extension: '.js', path: '/cwd/files/example.js' },
{ directory: '/cwd/files', name: 'example', extension: '.ts', path: '/cwd/files/example.ts' },
{ directory: '/cwd/files', name: 'example-long', extension: '.ts', path: '/cwd/files/example-long.ts' }
]
*/
})A synchronous variant is also available: locateManySync
Single
Locating a single file will return information about the first file matching the pattern.
import { locate } from 'locter';
(async () => {
let file = await locate(
'files/example.{js,.ts}'
);
console.log(file);
/*
{ directory: '/cwd/files', name: 'example', extension: '.js', path: '/cwd/files/example.js' }
*/
})A synchronous variant is also available: locateSync
Walking up
locateUp walks from a starting directory toward the filesystem root and returns the first match (useful for discovering config files at the repo root from any sub-directory):
import { locateUp } from 'locter';
(async () => {
const info = await locateUp('trapi.config.{ts,mts,cts,mjs,cjs,js,json}', {
cwd: process.cwd(),
});
console.log(info);
/*
{ directory: '/repo', name: 'trapi.config', extension: '.ts', path: '/repo/trapi.config.ts' }
or `undefined` if nothing matched
*/
})Pass stopAt: '/repo' to cap the walk at a known ceiling — inclusive, so the ceiling directory itself is still searched.
A synchronous variant locateUpSync is also available.
Options
locate / locateMany (and their sync variants) accept an options object:
| Option | Type | Default | Notes |
|-------------------|-------------------------|-----------------|--------------------------------------------------------|
| cwd | string \| string[] | process.cwd() | Working directory (or directories) the pattern is resolved against |
| ignore | string \| string[] | [] | Patterns to exclude |
| onlyFiles | boolean | true | Match files only |
| onlyDirectories | boolean | false | Match directories only |
| dot | boolean | false | Match dotfiles (e.g. .env, .npmrc) for wildcard patterns |
If both flags are set, onlyDirectories: true takes precedence over onlyFiles; when neither restricts the match (onlyFiles: false), files and directories are returned.
locateUp / locateUpSync take the same options except cwd must be a single string, and additionally accept stopAt?: string (inclusive ceiling for the walk).
File-name helpers
Two small helpers for working with the extensions locter routes on:
import { getFileNameExtension, removeFileNameExtension } from 'locter';
getFileNameExtension('seed.ts'); // '.ts'
getFileNameExtension('seed.ts', ['.js', '.mjs']); // undefined (not allowed)
removeFileNameExtension('seed.ts'); // 'seed'
removeFileNameExtension('seed.ts', ['.js']); // 'seed.ts' (not matched)Reader
Two verbs, two shapes:
read— raw: the plain parsed value for data formats (JSON/YAML/Conf and custom readers) — mutable and round-trip-symmetric withwrite. For modules it returns the normalized module record: a module is a record, and the normalization only irons out the CJS/ESM interop divergence soreadandreadSyncagree on one shape.readAsModule— everything as a module record: the uniform, frozen record shape regardless of format — for export inspection, interop, and uniform handling of mixed-extension config.
Either a string or the output of the locate/locateSync method can be passed
as argument; both verbs have *Sync twins.
import { locate, read, readSync } from 'locter';
(async () => {
const config = await read('config.yml');
// → the plain parsed value: { port: 3000, ... } — mutable,
// round-trip-symmetric with write()
const settings = await read('trapi.config.ts');
// → the module as a normalized record; unwrap explicitly:
// settings.default
const file = await locate('files/example.{js,.ts}');
const content = readSync(file); // sync twin
})Bare module specifiers (no file extension) are routed to the module reader, so
read('yaml') behaves like import('yaml') — note that reading a module
executes it.
Per-call format override
Every read/write verb accepts an options object with a format key — a
registered format id (a built-in: module, conf, json, yaml, text —
or the id of a rule registered via registerFormat) used instead of
extension dispatch. The most useful consequence: module files become readable
without being executed:
import { read } from 'locter';
(async () => {
// the source of a module file — no evaluation, no side effects
const source = await read('vite.config.ts', { format: 'text' });
// extensionless files become readable, too
const license = await read('LICENSE', { format: 'text' });
})Unknown format ids throw LocterError.
Module records — readAsModule
Every readAsModule / readAsModuleSync result is a module record — a
frozen, null-prototype object shaped like an ES module namespace: default
always holds the loaded value, and when that value is an object, its
top-level keys are re-exposed as named exports. For modules, read and
readAsModule agree; for data formats, read returns what
readAsModule(...).default holds.
import { readAsModule } from 'locter';
(async () => {
const pkg = await readAsModule('package.json');
console.log(pkg.default); // the full parsed JSON value
console.log(pkg.version); // top-level keys are named exports
})For JavaScript/TypeScript files the module's own namespace passes through
unchanged; the output of every other reader (built-in JSON/YAML/Conf and
user-registered ones) is always wrapped — even if the parsed data happens to
contain an __esModule key (read of such data likewise returns it
untouched — a literal __esModule key never turns data into a module).
The record is read-only: to mutate the loaded data, work with .default
(for data records that is the same object read returns).
Records additionally carry a private, non-forgeable brand: isModuleRecord
tells records produced by readAsModule() apart from arbitrary data, and
write uses the brand to unwrap .default automatically on
write-back.
To pick an export by predicate, the getModuleExport helper is available:
import { getModuleExport, readAsModule } from 'locter';
(async () => {
const record = await readAsModule('files/example.ts');
const found = getModuleExport(record, (key) => key === 'myExport');
// → { key: 'myExport', value: ... }, or undefined if no key matches
})Reading a package.json field
readPackageField reads a top-level field from the nearest package.json — handy for the common pkg.<your-config-key> config-loading fallback (vite's pkg.vite, eslint's pkg.eslintConfig, etc.):
import { readPackageField } from 'locter';
(async () => {
const cwd = process.cwd();
const value = await readPackageField<{ entry: string }>('myapp', { cwd });
// → the value of the `myapp` field, or undefined if package.json
// or the field is absent.
// Walk parent directories until a package.json is found:
const parentValue = await readPackageField('myapp', {
cwd,
walkUp: true,
stopAt: '/repo', // optional ceiling, inclusive
});
})Throws LoadError if the resolved package.json is malformed. A sync variant readPackageFieldSync is also available.
Writer
The write method serializes a value to a file, dispatched by extension like
read — no need to pull yaml or a .conf serializer into the consuming
application:
import { read, readAsModule, write } from 'locter';
(async () => {
// read → modify → write, fully symmetric
const config = await read('config.yml');
config.port = 8080;
await write('config.yml', config);
// records produced by readAsModule() are unwrapped automatically —
// the file receives the plain value, not the record wrapper
const record = await readAsModule('config.yml');
record.default.port = 8080;
await write('config.yml', record);
// plain values work just as well
await write('generated/settings.json', { port: 8080 });
})A synchronous variant is also available: writeSync.
Write semantics:
- Missing parent directories are created (
mkdir -p). - Output always ends with a single trailing newline.
- Values that are records produced by
readAsModule()(detected via the private brand — an__esModulekey in plain data does not count) are unwrapped to their.defaultvalue before serialization. - Module formats (
.js,.ts, …) are read-only:write('config.ts', …)throwsWriteError, as does writing to a bare module specifier.
An explicit format id lifts both restrictions of the last bullet — naming
the format resolves the ambiguity those guards exist for:
import { write } from 'locter';
(async () => {
// scaffold a module file from raw source ('text' bypasses read-only)
await write('generated/config.ts', 'export default {};', { format: 'text' });
// extensionless target, explicit format
await write('.myapprc', { port: 3000 }, { format: 'json' });
})Per-format behavior:
JSON — 4-space indent by default; configurable per instance:
new JSONWriter({ indent })accepts a number of spaces, a literal indent string, or'auto'(detect and keep the indentation of the existing file).YAML — comment-preserving write-back: when the target file exists, the value is grafted into the parsed document, so comments and anchors attached to surviving keys survive. Arrays and type-changed nodes are replaced wholesale. A corrupt existing file throws instead of being overwritten.
# port used by the dev server port: 3000await write('config.yml', { port: 8080 });# port used by the dev server port: 8080Conf — the inverse of the reader: nested objects become dot-separated
key=valuelines, arrays repeatedkey[]=lines. Round-trips are structural, not textual: comments and line order are dropped, and a string that parses as another type ('123') reads back as that type (123).
Writing a package.json field
writePackageField is the write-side companion of readPackageField (same
cwd / walkUp / stopAt semantics): it sets a top-level field of the
nearest package.json and writes it back preserving the file's existing
indentation.
import { writePackageField } from 'locter';
(async () => {
await writePackageField('myapp', { entry: 'src' }, { cwd: process.cwd() });
// passing undefined removes the field
await writePackageField('myapp', undefined);
})Throws NotFoundError when no package.json could be located. A sync
variant writePackageFieldSync is also available.
Formats
A format couples an extension set with a reader and an optional writer. The built-in formats are pre-registered:
- conf (
.conf):ConfReader+ConfWriter - json (
.json):JSONReader+JSONWriter - yaml (
.yml,.yaml):YAMLReader+YAMLWriter - text (
.txt):TextReader+TextWriter— raw string content, no value coercion (read('notes.txt')is the file content; writes accept strings only). - module (
.js,.mjs,.mts,.cjs,.cts,.ts):ModuleReader(read-only) — loads modules independent of the environment (cjs or esm).
To register a format for other file types, use registerFormat. Rules are
matched in registration order, before the built-ins — registering an
extension a built-in claims (e.g. .json) overrides it. The reader and writer
slots are independent: a reader-only rule overrides how .json is read
without shadowing the built-in JSON writer (and vice versa).
import { registerFormat } from 'locter';
registerFormat({
test: ['.ext'],
reader: {
async read(input: string) {
// ...
},
readSync(input: string) {
// ...
},
},
writer: {
async write(path: string, value: unknown) {
// ...
},
writeSync(path: string, value: unknown) {
// ...
},
},
});For text-based formats, extending the abstract TextFileReader /
TextFileWriter base classes is simpler than implementing the IReader /
IWriter ports by hand: the bases own UTF-8 I/O, typed error wrapping, and
derive the sync and async surface from a single implementation — only
parse(content) respectively stringify(value) must be provided. The
built-in JSON/YAML/Conf formats are implemented this way.
import { TextFileReader, TextFileWriter, registerFormat } from 'locter';
import { parse, stringify } from 'smol-toml';
class TomlReader extends TextFileReader {
parse(content: string) {
return parse(content);
}
}
class TomlWriter extends TextFileWriter {
stringify(value: unknown) {
return stringify(value);
}
}
registerFormat({
test: ['.toml'],
reader: () => new TomlReader(),
writer: () => new TomlWriter(),
});Instead of instances, the slots accept factory functions (as above) — a factory is invoked lazily on the first matching input and the first successfully constructed instance is cached (a factory that throws is retried on the next match).
Every rule has a stable id (auto-generated, or set explicitly). Registering an
existing id replaces that rule in place; built-in ids (module, conf,
json, yaml) are reserved. The registry can be inspected and unwound:
import { registerFormat, unregisterFormat, useFormatRegistry } from 'locter';
const registration = registerFormat({ test: ['.json5'], reader: json5Reader });
unregisterFormat(registration.id); // remove it again
registerFormat({ id: 'json5', test: ['.json5'], reader: otherReader }); // replace by id
const registry = useFormatRegistry(); // the process-global FormatRegistry
registry.entries(); // [{ id, test, builtIn }, ...] in match order
registry.has('json'); // true (built-in)
registry.reset(); // drop all user rules + cached instancesThe global registry belongs to the application. Libraries that need custom
formats should create their own isolated instance instead of mutating the
singleton: new FormatRegistry({ rules: [...] }).
Value codec
For env-var / key-value style contexts, the lenient value codec the .conf
format uses per value is exported directly — handy for building lenient
custom formats (or serializing to stores that hold bare strings):
import { deserializeValue, serializeValue } from 'locter';
serializeValue(true); // 'true'
serializeValue(/^a+$/i); // '/^a+$/i'
serializeValue({ a: 1 }); // '{"a":1}'
deserializeValue('123'); // 123
deserializeValue('{ broken'); // '{ broken' — falls back to the raw string, never throwsErrors
read / write (and every built-in format) throw a typed subclass of
LocterError, so callers can distinguish failure modes without substring
matching on .message:
| Class | When it's thrown |
|--------------------------------|---------------------------------------------------------|
| NotFoundError | File or module does not exist (ENOENT, MODULE_NOT_FOUND, ERR_MODULE_NOT_FOUND) |
| LoadError | A reader threw — parse error, runtime/eval error, etc. |
| WriteError | A write failed — serialization error, filesystem error, read-only format, corrupt existing YAML target |
| UnknownExtensionError | No rule matched the file's extension |
| LocterError | Base class; matches all of the above |
Each error exposes the offending path and preserves the underlying error
on cause:
import { read, NotFoundError, LoadError } from 'locter';
try {
await read('config.json');
} catch (err) {
if (err instanceof NotFoundError) {
console.error(`Config file not found: ${err.path}`);
} else if (err instanceof LoadError) {
console.error(`Failed to parse config: ${err.cause}`);
} else {
throw err;
}
}Note that on the write side ENOENT stays a WriteError — a missing
parent directory is a write failure, not a lookup miss (and write creates
missing parents anyway).
LocterError extends BaseError from @ebec/core, so each instance also carries a code (auto-derived from the class name unless overridden — e.g. 'ENOENT' for NotFoundErrors thrown by the JSON reader) and a toJSON() for structured logging.
Each error class also publishes its Symbol.for(...) marker (LOCTER_ERROR_MARKER, LOCTER_NOT_FOUND_ERROR_MARKER, …), which makes instanceof work across realms — useful when consumers end up with duplicate copies of locter in their dependency tree.
Runtime environments
Locter ships with runtime detection helpers:
isJestRuntimeEnvironment()— true when running under Jest. Built-in: theModuleReaderfalls back torequire()under Jest to avoid a known segmentation fault (nodejs/node#35889).isVitestRuntimeEnvironment()— true when running under Vitest (checksprocess.env.VITEST === 'true').isTsNodeRuntimeEnvironment()— true when ats-noderegister instance is active. Built-in: theModuleReader's async path falls back to its sync loading under ts-node.isTsxRuntimeEnvironment()— true when the process was started through tsx (tsx script.ts,node --import tsx, or a resolved.../node_modules/tsx/...loader path). Useful for callers that need to know whether TypeScript sources can be executed directly.
Using locter with Vitest
Locter's built-in await import(id) runs inside node_modules/locter and
therefore bypasses Vitest's vite-node module graph by default. For most use
cases this is fine, but it produces duplicate module instances when test
code statically imports a module and locter dynamically loads the same module
(or a transitive dependency of it). This breaks class identity for libraries
that rely on it (e.g. TypeORM entity metadata).
Use setModuleReader to inject an import call from user space so Vitest can
rewrite it:
// vitest setup file
import { setModuleReader } from 'locter';
setModuleReader({
load: (id) => import(id),
});setModuleReader returns a restore function that re-applies the previous
configuration — useful for scoped overrides:
const restore = setModuleReader({ load: (id) => import(id) });
// ...
restore();Alternatively, the same effect can be achieved by inlining locter in
vitest.config.ts:
export default defineConfig({
test: {
server: {
deps: {
inline: [/locter/],
},
},
},
});Migration
Migrating from 3.x
The 4.0 release renames the loader subsystem around the format concept (read + write), reworks registration and dispatch, and normalizes results. The list below is exhaustive — no other public APIs changed.
Renames (clean break, no deprecated aliases):
| 3.x | 4.x |
|--------------------------------------|--------------------------------------------|
| load / loadSync | read / readSync (raw: plain data values / module records) or readAsModule / readAsModuleSync (everything as a module record) |
| registerLoader / unregisterLoader| registerFormat / unregisterFormat |
| LoaderManager | FormatRegistry |
| useLoader | useFormatRegistry |
| Loader type (execute/executeSync) | IReader (read/readSync) + IWriter (write/writeSync) |
| JSONLoader / YAMLLoader / ConfLoader | JSONReader+JSONWriter / YAMLReader+YAMLWriter / ConfReader+ConfWriter |
| ModuleLoader | ModuleReader |
| setModuleLoader | setModuleReader |
| loadPackageField / loadPackageFieldSync | readPackageField / readPackageFieldSync |
| LocterLoadError / LocterNotFoundError / LocterUnknownExtensionError | LoadError / NotFoundError / UnknownExtensionError — the LocterError base class keeps its name |
Behavioral changes:
// 1. User rules now override built-ins. Registering an extension a
// built-in claims (e.g. `.json`) previously had NO effect — the
// built-in rule always matched first. It now takes precedence.
registerFormat({ test: ['.json'], reader: myJson5Reader }); // 4.x: overrides the built-in
// 2. Plugin-string loaders are removed, and rules use the object form
// with independent reader/writer slots. Import the implementation
// yourself and register it (optionally lazily, via a factory).
- registerLoader({ test: ['.toml'], loader: 'toml' }); // resolved to @locter/toml, sync require()
+ import { TomlReader, TomlWriter } from '@locter/toml';
+ registerFormat({ test: ['.toml'], reader: () => new TomlReader(), writer: () => new TomlWriter() });
// 3. The registry no longer implements the loader interface: execute() /
// executeSync() become read() / readSync() (accepting LocatorInfo |
// string), and findLoader() / resolve() are replaced by
// findReader() / findWriter() / builtInReader() / builtInWriter().
- const manager = new LoaderManager();
- await manager.execute('file.json');
+ const registry = new FormatRegistry();
+ await registry.read('file.json'); // mirrors the global read() helper
+ registry.findReader('file.json'); // IReader | undefined
+ registry.builtInReader('module'); // statically typed as ModuleReader
// 4. Two verbs replace 3.x load(): read() is RAW (the plain parsed
// value for data — 3.x data loads map to read() nearly 1:1 — and
// the normalized record for modules), while readAsModule() returns
// a frozen, null-prototype module record for EVERY format (see
// "Module records" above).
const value = await read('package.json');
value.version; // the plain parsed JSON
const pkg = await readAsModule('package.json');
pkg.version; // top-level keys as named exports
+ pkg.default; // the full parsed value
+ // The record is frozen and null-prototype: code that mutates the
+ // result, relies on Object.prototype, or enumerates/serializes it
+ // must use pkg.default (or read()) instead.
// 5. ModuleReader.read() / readSync() return the raw module value;
// normalization happens exactly once, at the registry boundary. Only
// relevant when invoking the reader class directly.
- await new ModuleLoader().execute('file.ts'); // normalized record
+ await new ModuleReader().read('file.ts'); // raw module value
+ await registry.read('file.ts'); // normalized recordRemoved from the public surface (internal plumbing that leaked through the old export * barrels; the package now exports a curated, snapshot-tested list):
- Locator internals:
buildLocatorPatterns,buildLocatorOptions,pathToLocatorInfo,buildFilePathWithoutExtension(deleted — it had no callers).buildFilePathandisLocatorInfostay public. - Record internals:
isESModule,toModuleRecord,createModuleRecord. UsereadAsModule()(which normalizes) /read()(raw) and the publicgetModuleExport/isModuleRecord. - Generic helpers:
isObject,isSafeObjectKey,hasOwnProperty,hasStringProperty,toArray,isFilePath,isTypeScriptError. The domain helpers stay public:getFileNameExtension/removeFileNameExtension(see File-name helpers) and the runtime detection helpers (isJestRuntimeEnvironment,isVitestRuntimeEnvironment,isTsNodeRuntimeEnvironment,isTsxRuntimeEnvironment).
Locator option precedence — { onlyFiles: true, onlyDirectories: true } used to resolve to neither restriction (everything matched); onlyDirectories: true now wins. { onlyFiles: false } used to mean directories-only; it now means "no restriction" (files and directories).
New in 4.0:
write/writeSyncwith per-format writers (JSONWriter,YAMLWriter,ConfWriter), including comment-preserving YAML write-back — see Writer.writePackageField/writePackageFieldSync.WriteError+wrapWriteError.isModuleRecord— brand-based detection of records produced byread().- The ports are exported as
IReader/IWriter, alongsideRule,ReaderFactory,WriterFactory— so custom formats can be typed against the package. - The extensions handled by the module reader are exported as
MODULE_FILE_EXTENSIONS. readPackageField/readPackageFieldSyncreadpackage.jsonvia the built-in JSON reader directly: user-registered.jsonrules no longer intercept the read, and synthetic record keys (default,__esModule) no longer resolve as package fields.
Migrating from 2.x
The 3.0 release reshapes a few public surfaces. The list below is exhaustive — no other public APIs changed. (Symbols are shown under their 3.x names; see Migrating from 3.x for the current ones.)
- import { load, locate } from 'locter';
+ import { load, locate, LocterNotFoundError, LocterLoadError } from 'locter';
// 1. LocatorOptions.path → cwd
- await locate('config.json', { path: process.cwd() });
+ await locate('config.json', { cwd: process.cwd() });
// 2. LocatorInfo.path now holds the FULL file path (it was the
// containing directory in 2.x). buildFilePath(info) still works in
// both versions but is now a near-identity for LocatorInfo inputs.
- const filePath = buildFilePath(info); // 2.x: composed from .path + .name + .extension
+ const filePath = info.path; // 3.x: just the path field
+ const dir = info.directory; // 3.x: containing directory (the old `info.path`)
// 3. load() / loadSync() (and every built-in loader) now throw
// LocterError subclasses instead of generic Error / ebec BaseError.
try {
await load('config.json');
- } catch (err) {
- if (err.message.includes('ENOENT')) { /* … */ }
+ } catch (err) {
+ if (err instanceof LocterNotFoundError) { /* … */ }
+ else if (err instanceof LocterLoadError) { /* parse error: err.cause */ }
+ else throw err;
}Additionally:
- The
ebecruntime dependency is replaced by@ebec/core. If you used to importBaseErrorfromebec, switch to@ebec/core. - The package is now ESM-only (no
require('locter')). This change shipped in the 2.x→3.x toolchain modernization commit; mentioned here for completeness.
License
Made with 💚
Published under MIT License.
