A simple configuration utility for Node.js featuring smart module wiring for unobtrusive dependency injection.
Every Node.js project eventually runs into the same set of annoyances:
- Relative path hell.
require("../../../util/logger")breaks on every refactor and tells you nothing about whatloggeractually is in context. - Config threading. You read
process.envat the top of your entry point and pass values down through five layers of function arguments or a shared global — neither is great. - Environment switching. Swapping a real database driver for a test double means either touching application code or maintaining a tangle of
if (NODE_ENV === ...)branches.
Tools like dotenv solve the env-loading part but leave the rest to you. Dependency injection frameworks solve the wiring part but require you to restructure your code around them.
wires takes a different approach: it stays out of your application code entirely.
wires augments the filename resolution mechanisms used by import and require() under the hood as a means to transparently inject configuration into your code.
Your modules keep using plain require(). You define what those calls resolve to — and what values they return — in JSON files that sit alongside your code. Switch environments, swap implementations, override settings for a subdirectory: none of it requires touching the modules themselves.
For simplicity's sake, we only provide examples using require() in this documentation, but everything described here also applies to import with minor differences discussed in a dedicated section.
Here is a quick before/after. Without wires:
// before: config is threaded manually, paths are fragile
const port = require("../../../config").port;
const db = require("../../../drivers/mysql");
const User = require("../../../db/model-user");With wires:
// after: modules just declare what they need
const port = require("#port");
const db = require(":db_driver");
const User = require(":models/user");The mapping between these expressions and actual values or modules is defined in JSON configuration files:
{
"port": 80,
":db_driver": "mysql/driver",
":models/": "./db/model-"
}require("#port") === 80;
require(":db_driver") === require("mysql/driver");
require(":models/user") === require("/.../db/model-user");
require(":models/article") === require("/.../db/model-article");Hash expressions read settings. Colon expressions resolve to modules via routes. Swapping to a different environment is just a matter of providing a different JSON file:
{
"port": 8080,
":db_driver": "./lib/dbDriver",
":models/": "models/dbo/"
}require("#port") === 8080;
require(":db_driver") === require("/.../lib/dbDriver");
require(":models/user") === require("models/dbo/user");
require(":models/article") === require("models/dbo/article");Note that settings whose keys are prefixed with @ are considered directives and will not appear in your configuration. Currently supported directives are @namespace and @root.
Install wires globally and use the wires command in lieu of node:
npm install -g wires
So, node ... becomes wires ....
When a local installation of wires exists in your project, the global command will automatically defer to it. This lets you pin a specific version per project.
If you don't want to install wires globally, use npx:
npx wires ...
If you don't want to use the wires command, you can use the node executable as follows:
node --require=wires --loader=wires ...
This will enable both the CommonJS and ECMAScript module resolution overrides.
Alternatively, if you only need CommonJS support, you can manually require wires at the beginning of the entry file of your project and use node as usual:
require("wires");
// rest of your codewires uses four types of configuration files:
wires-defaults.jsoncontains default settingswires-defaults.XXX.jsoncontains default settings specific to when theWIRES_ENVenvironment variable is set to"XXX"wires.jsoncontains actual settingswires.XXX.jsoncontains actual settings specific to when theWIRES_ENVenvironment variable is set to"XXX"
Actual configuration depends on the position of the file requiring it in the filesystem hierarchy. Each directory gets its own configuration, which is computed as follows:
- start with an empty object
- override with
wires-defaults.jsonif it exists - if
WIRES_ENVis set to"XXX", override withwires-defaults.XXX.jsonif it exists - override with the configuration of the parent directory if it exists and if the
@rootdirective isn't set totrue - override with
wires.jsonif it exists - if
WIRES_ENVis set to"XXX", override withwires.XXX.jsonif it exists
In practice, you'll rarely use all files at once. Typically, parts of your application will define default values in their respective directories while the main app will set actual settings in the root directory, as in the following example:
// /app/wires.json
{
"mysql": {
"user": "root",
"password": "{#>PASSWORD}",
"database": "test3"
}
}// /app/database/wires-defaults.json
{
"mysql": {
"host": "localhost",
"port": 3306,
"user": "",
"password": ""
}
}// /app/database/lib/someFile.js
require("#mysql.host") === "localhost";
require("#mysql.port") === 3306;
require("#mysql.user") === "root";
require("#mysql.password") === String(process.env.PASSWORD);
require("#mysql.database") === "test3";The @namespace directive lets a sub-directory see only a specific branch of the parent configuration. This is useful for self-contained sub-packages: the sub-package works with flat, unprefixed keys, while the parent configures it through a single namespaced block. The two sides never need to agree on key names.
Let's go back to the previous example:
// /app/wires.json
{
"mysql": {
"user": "root",
"password": "{#>PASSWORD}",
"database": "test3"
}
}// /app/database/wires-defaults.json
{
"@namespace": "mysql",
"host": "localhost",
"port": 3306,
"user": "",
"password": ""
}// /app/database/lib/someFile.js
require("#host") === "localhost";
require("#port") === 3306;
require("#user") === "root";
require("#password") === String(process.env.PASSWORD);
require("#database") === "test3";Note that routes are impervious to namespaces.
Sometimes you want to fully isolate a directory's configuration from its parents. Set the @root directive to true and wires will stop climbing any further up the file hierarchy.
// /parent/wires.json
{
"parentKey": "parent value",
"childKey": "overridden value"
}// /parent/child/wires-defaults.json
{
"@root": true,
"childKey": "child value"
}// /parent/child/someFile.js
require("#parentKey") === undefined;
require("#childKey") === "child value";The following table summarizes the prefixes wires recognizes in require() and import expressions:
| Prefix | Resolves to | Example |
|---|---|---|
| (none) | Standard Node.js resolution | require("fs"), require("./lib/util") |
~/ |
Relative to home directory | require("~/.eslintrc.json") |
>/ |
Relative to current working directory | require(">/logger.js") |
# |
Configuration setting | require("#server.db.host") |
#> |
Environment variable | require("#>PATH") |
: |
Route-based module | require(":cacheFactory") |
{#...} |
Template interpolation | require("./lib/{#vendor}/main.js") |
:: |
Bypass wires entirely |
require("::raw-path") |
Targeting undefined or null values in template expressions may yield undesirable "undefined" or "null" strings. If you replace the leading # with ?, falsy values produce an empty string ("") instead:
"value is '{#undefinedValue}'"yields"value is 'undefined'""value is '{?undefinedValue}'"yields"value is ''"
This is especially handy for environment variables that may or may not be set. While "{#>UNSET_VAR}" would yield "undefined", "{?>UNSET_VAR}" yields "". This feature works hand in hand with Fallbacks.
{ and } are special characters. You can escape them when they're needed verbatim:
- the expression
"{ placeholder }"throws an exception - the expression
"\\{ placeholder \\}"yields"{ placeholder }"
In your configuration files, every object property whose name is not prefixed with a colon is a setting.
A setting can be of any type, including an object. When the value of a setting is a string, it accepts the template syntax seen in the previous section.
// /app/wires.json
{
"number": 56,
"boolean": false,
"string": "some string",
"templateString": "number is {#number}",
"array": [1, 2],
"object": {
"templateString": "boolean is {#boolean}"
},
"env": "{?>SOME_VAR}"
}// /app/file.js
require("#number") === 56;
require("#string") === "some string";
require("#templateString") === "number is 56";
require("#array"); // [1, 2]
require("#object.templateString") === "boolean is false";
require("#env") === (process.env.SOME_VAR || "");Every object property whose name ends with a question mark in your configuration files is a fallback. Fallbacks provide a default value for settings that are empty ("", NaN, null or undefined).
Consider the following situation:
// /app/wires.json
{
"mysql_user": "{?>SQL_USER}"
}// /app/mysql/wires-defaults.json
{
"mysql_user": "root"
}// /app/mysql/index.js
require("#mysql_user") === (process.env.SQL_USER || "");The environment variable SQL_USER may not be set, and so the setting mysql_user may end up as an empty string. Yet it is still set, and the default value defined in wires-defaults.json will never be used.
This is easily solved with a fallback:
// /app/wires.json
{
"mysql_user": "{?>SQL_USER}"
}// /app/mysql/wires-defaults.json
{
"mysql_user?": "root"
}// /app/mysql/index.js
require("#mysql_user") === (process.env.SQL_USER || "root");Fallbacks act like any other setting and can be overridden through cascading configuration. They can also be retrieved programmatically when needed. Within object settings, they work the same way — they simply disappear when you require the object in its entirety:
// /app/wires.json
{
"mysql": {
"user": "{?>SQL_USER}"
}
}// /app/mysql/wires-defaults.json
{
"mysql": {
"user?": "root"
}
}// /app/mysql/index.js
require("#mysql.user") === (process.env.SQL_USER || "root");
require("#mysql.user?") === "root";
assert.deepEqual(
require("#mysql"),
{
"user": process.env.SQL_USER || "root"
}
);String values in configuration files can be cast to booleans or numbers, or parsed as JSON.
// wires.json
{
"bool": "(boolean) true",
"number": "(number) 1204"
}// file.js
require("#bool") === true;
require("#number") === 1204;This is especially useful when dealing with environment variables:
// wires.json
{
"size": "(number){?>SIZE}"
}// file.js
const size = (process.env.SIZE || "").trim();
require("#size") === (size ? Number(size) : NaN);Casting follows these rules:
- For booleans (cast using
(bool)or(boolean)):"true"becomestrue,"false"becomesfalse,- any other string becomes
null.
- For numbers (cast using
(num)or(number)), any string that cannot be parsed as a number will result inNaN.
Whitespace inside and around the parentheses is ignored, so "(bool)true", "( bool ) true", etc., are all equivalent.
String values can also be parsed as JSON using the (json) cast:
// wires.json
{
"json": "(json) \\{ \"property\": [ 1024 ] \\}"
}// file.js
require("#json.property")[0] === 1024;This makes it possible to inject complex data structures from environment variables:
// wires.json
{
"json": "(json) {?>JSON}"
}// file.js
// if env var JSON === '{ "property": [ 1024 ] }'
require("#json.property")[0] === 1024;If the string is not valid JSON, null is returned, allowing the use of a fallback:
// wires.json
{
"jsonWithoutFallback": "(json) not proper json",
"jsonWithFallback": "(json) not proper json",
"jsonWithFallback?": 712
}// file.js
require("#jsonWithoutFallback") === null;
require("#jsonWithFallback") === 712;In your configuration files, every object property whose name starts with a colon and does not end with a slash defines a route.
Routes must be strings. Like settings, they accept the template syntax. The final string must be a path to a file that actually exists.
File paths may refer to a file:
- globally (relying on
NODE_PATH) - relative to the directory of the configuration file (start with
"./"or"../") - relative to the home directory (start with
"~/") - relative to the current working directory (start with
">/")
// /app/wires.json
{
":dbRequest": "mysql/request",
":cacheFactory": "../cache/factory",
":data": ">/data.json",
":eslint": "~/.eslintrc.json"
}// /app/some/path/inside/index.js
require(":dbRequest") === require("mysql/request");
require(":cacheFactory") === require("/cache/factory");
require(":data") === require("/working/dir/data.json");
require(":eslint") === require("/home/me/.eslintrc.json");Any route (regular, generic, or computed) can be set to null. Requiring such a route will return null, which is useful for stubbing out modules that are not yet implemented.
// /app/wires.json
{
":not-implemented-yet": null
}// /app/some/path/inside/index.js
require(":not-implemented-yet") === null;In your configuration files, every object property whose name starts with a colon and ends with a slash defines a generic route.
Like normal routes, they must be strings and they accept the template syntax with the same path semantics (NODE_PATH, "./", "../", "~/", ">/" ). The property name acts as a prefix: wires replaces it with the route value and appends whatever follows.
// /app/wires.json
{
":dbo/": "./db/model-"
}// /app/mvc/controllers/mainPage.js
require(":dbo/client") === require("/app/db/model-client");
require(":dbo/car/bmw") === require("/app/db/model-car/bmw");When simple prefix replacement isn't enough, computed routes let you run custom resolution logic. A computed route key ends with /() and its value points to a CommonJS module exporting a function. wires calls that function with the path segments as arguments and uses the returned string as the resolved path.
Let's re-implement the generic route example from the previous section with a computed route:
// /app/wires.json
{
":dbo/()": "./helpers/dbo.js"
}// /app/helpers/dbo.js
module.exports = (...pathSegments) => "../db/model-" + pathSegments.join("/");// /app/mvc/controllers/mainPage.js
require(":dbo/client") === require("/app/db/model-client");
require(":dbo/car/bmw") === require("/app/db/model-car/bmw");Paths returned by the function are resolved relative to the location of the file where the function is defined. The function may also return null to stub out a route.
Computed route functions must be defined in CommonJS modules. ECMAScript Modules are not supported here.
wires overrides both static and dynamic import statements, making it fully compatible with ES-module-based projects.
Unlike require(), import needs fully qualified paths with file extensions and does not resolve index.js when pointed at a directory. Make sure your routes include the full path:
// /app/wires.json
{
":dir": "./dir/index.js"
}// /app/esm.js
import message from ":dir"; // works!For reference, require() is more lenient and would also accept "./dir" or "./dir/index" as route values.
Additionally, for object values, wires creates a named export for every property whose name is a valid JavaScript identifier:
// /app/wires.json
{
"object": {
"property1": 1,
"property2": 2,
"no name export": 3
}
}// /app/esm.js
import object, { property1, property2 } from "#object";
object.property1 === 1;
object.property2 === 2;
object["no name export"] === 3;
property1 === 1;
property2 === 2;wires is designed to work with bundlers. The bundle does not need wires at runtime and does not include the core code of the package. Routes are resolved statically at bundle time, while values that depend on environment variables remain dynamic — wires adds a few short functions to the bundle to ensure they are read at runtime.
For instance, given the following situation:
// /wires.json
{
"server": {
"keepAlive": "(number) {?>KEEP_ALIVE}",
"keepAlive?": 60,
"port": "(number) {?>PORT}",
"port?": 8080
}
}// /index.js
import { keepAlive, port } from "#server";
console.log(keepAlive, port);then:
node index.jswill output60 8080KEEP_ALIVE=30 PORT=80 node index.jswill output30 80
If index.js is bundled into bundle.js:
node bundle.jswill output60 8080KEEP_ALIVE=30 PORT=80 node bundle.jswill output30 80
wires exposes a Rollup plugin at wires/rollup (it is part of the wires package itself).
Simply create a rollup.config.js configuration file and import the plugin:
import wires from "wires/rollup";
export default {
input: "src/index.js",
output: {
dir: "output",
format: "es"
},
plugins: [wires()],
};Then call rollup either via the CLI or the API.
The plugin is compatible with both @rollup/plugin-commonjs and @rollup/plugin-node-resolve, which makes it possible to bundle CommonJS projects, provided you set the former's requireReturnsDefault option to true and put the latter after the wires plugin in the list of plugins.
It can also make sense to include @rollup/plugin-json.
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import wires from "wires/rollup";
export default {
input: "src/index.js",
output: {
dir: "output",
exports: "auto",
format: "cjs"
},
plugins: [
wires(),
nodeResolve(),
json(),
commonjs({
requireReturnsDefault: true
})
]
};If you are upgrading from an older version of wires, keep the following breaking changes in mind:
3.0:wiresusesWIRES_ENVinstead ofNODE_ENV. Adjust your environment configuration accordingly.4.0: Fallbacks now trigger only on empty values ("",NaN,null,undefined). Previously, any falsy value (0,false, etc.) would trigger a fallback.5.0: Usingnodedirectly now requires--require=wires --loader=wires(previously just--require=wires) to enable ESM support.
© Julian Aubourg, 2012-2026 – licensed under the MIT license.