@primafuture/netson
v3.0.0
Published
A reusable layered Jsonnet configuration and cascade helper library.
Maintainers
Readme
@primafuture/netson
A reusable layered Jsonnet configuration and cascade helper library for Node.js.
It provides:
- ordered layered config loading
- visible config merge with explicit delete via
netson.value.unset - computed values that resolve against the final merged config
- value pipelines with
computed,patch,finalize, andreplace - generic tagged values with
configLayerprovenance - helper-root composition for hidden Jsonnet helpers
- deferred placeholder resolution
- a built-in generic cascade engine
- explain/debug traces
- a public Jsonnet asset at
@primafuture/netson/lib/netson.libsonnet
Install
Published package with the default in-process Jsonnet runtime:
npm install @primafuture/netson @primafuture/jsonnet-runtimeOptional Hanazuki compatibility backend:
npm install @hanazuki/node-jsonnetMigration From 2.x To 3.0
Netson 3.0 preserves the original owner when an already owned tag is forwarded through computed, patch, replace, or finalize. New tags still receive the owner of the operation that creates them.
Layered APIs now default to taggedValueOwnershipValidation: 'current-chain'.
Hosts that intentionally use owner IDs outside the input layer chain must pass
taggedValueOwnershipValidation: 'shape-only' to resolveLayeredConfig(...) or
loadLayeredConfig(...), or use --tagged-value-ownership-validation shape-only
in the CLI. Both modes reject malformed ownership and preserve forwarded tags.
npm install @primafuture/netson@^3 @primafuture/jsonnet-runtimeRegenerate downstream configuration snapshots or locks that persisted ownership from Netson 2.x deferred operations, using the host application's normal update workflow. Loading an old snapshot alone does not repair its stored provenance.
Migration From 1.x To 2.0
Netson 2.0 changes the default Jsonnet backend. The package no longer uses an
external jrsonnet executable backend and the CLI no longer accepts
--jrsonnet-path.
Install the new default runtime next to Netson:
npm install @primafuture/netson@^2 @primafuture/jsonnet-runtimeUse the in-process runtime adapter in TypeScript code:
import { createJsonnetRuntimeEvaluator } from '@primafuture/netson/jsonnet-runtime';
const jsonnet = createJsonnetRuntimeEvaluator({
defaultImportRootDirectories: ['/path/to/project/node_modules'],
});If your application explicitly uses Hanazuki, the Hanazuki adapter remains available:
npm install @hanazuki/node-jsonnetimport { createHanazukiJsonnetEvaluator } from '@primafuture/netson/hanazuki';Breaking changes:
- removed the production
jrsonnetexecutable backend - removed the
--jrsonnet-pathCLI option - removed
@primafuture/netson/jrsonnet-cliand@primafuture/netson/jrsonnet-webexports - changed the default CLI engine to
jsonnet-runtime - direct Jsonnet
std.native(...)host callbacks are synchronous in the new runtime V1
Async host custom matchers are still supported by high-level Netson APIs where
matcher calls are dispatched from TypeScript: deferred cascade resolution in
resolveLayeredConfig(...) and explainNetsonCascade(...).
Inside this local package:
npm installBuild the library:
npm run buildTypecheck only:
npm run typecheckRun the smoke example:
npm run smokeRun the CLI tester:
npm run cli -- --helpThis package builds to:
- CommonJS:
dist/index.cjs - ESM:
dist/index.mjs - Type declarations:
dist/index.d.ts - Jsonnet asset:
lib/netson.libsonnet
Quick Example
import { resolveLayeredConfig } from '@primafuture/netson';
import { createJsonnetRuntimeEvaluator } from '@primafuture/netson/jsonnet-runtime';
const jsonnet = createJsonnetRuntimeEvaluator({
defaultImportRootDirectories: ['/path/to/project/node_modules'],
});
const result = await resolveLayeredConfig({
jsonnet,
layers: [
{
id: 'base',
path: '/path/to/config/base.jsonnet',
},
{
id: 'override',
source: '{ featureFlags+: { debug: true } }',
},
],
});
console.log(result.visibleConfig);
console.log(result.deferredResolvedConfig);CLI Tester
The package also ships with a small CLI tester for local config experiments.
Commands:
netson-test resolve ...prints the merged confignetson-test validate ...just checks that layered evaluation and deferred processing succeednetson-test explain --path ...shows how a value evolved across layers and, for deferred cascade placeholders, also prints the cascade explain tracenetson-test explain --format human --path ...renders the same explain data as a short human-readable terminal summary
Resolve a layered example:
npm run cli -- resolve \
examples/layered-config/01-base.jsonnet \
examples/layered-config/02-region.jsonnet \
examples/layered-config/03-local.jsonnetResolve a tagged value example:
npm run cli -- resolve \
examples/tagged-values/01-base.jsonnet \
examples/tagged-values/02-child-helper.jsonnetValidate a deferred cascade example:
npm run cli -- validate \
examples/deferred-cascade/01-base.jsonnet \
examples/deferred-cascade/02-dev.jsonnetExplain why a layered value ended up as it did:
npm run cli -- explain --path features.debug \
examples/layered-config/01-base.jsonnet \
examples/layered-config/02-region.jsonnet \
examples/layered-config/03-local.jsonnetExplain why a value was deleted:
npm run cli -- explain --format human --path removedLater \
examples/layered-config/01-base.jsonnet \
examples/layered-config/02-region.jsonnet \
examples/layered-config/03-local.jsonnetExplain a deferred cascade placeholder:
npm run cli -- explain --path generated \
examples/deferred-cascade/01-base.jsonnet \
examples/deferred-cascade/02-dev.jsonnetRender the explain output in a human-readable terminal format:
npm run cli -- explain --format human --path features.debug \
examples/layered-config/01-base.jsonnet \
examples/layered-config/02-region.jsonnet \
examples/layered-config/03-local.jsonnetnpm run cli -- explain --format human --path generated \
examples/deferred-cascade/01-base.jsonnet \
examples/deferred-cascade/02-dev.jsonnetProfile a resolve run while keeping the resolved config on stdout:
npm run cli -- resolve --profile \
examples/computed-values/01-base.jsonnet \
examples/computed-values/02-local.jsonnetPrint only the machine-readable profile JSON to stderr:
npm run cli -- resolve --profile --profile-format json \
examples/computed-values/01-base.jsonnet \
examples/computed-values/02-local.jsonnetThe profile is a fast wall-clock timing summary for the normal resolve path. It reports layer evaluation, merge, deferred resolution, import scanning, Jsonnet evaluator calls, placeholder counts, and pipeline registry size. It is not a deep per-computed-value benchmark; it does not repeatedly re-resolve each pipeline path in isolation.
Run the same CLI through the Hanazuki compatibility engine:
npm run cli -- resolve --jsonnet-engine hanazuki \
examples/computed-values/01-base.jsonnet \
examples/computed-values/02-local.jsonnetThe default CLI engine is jsonnet-runtime, backed by
@primafuture/jsonnet-runtime. It runs in-process and does not require an
external jrsonnet executable.
Layered Config Example
This example shows only the visible layered merge behavior.
base.jsonnet
{
serviceName: 'api',
port: 8080,
nullableSetting: 'base-value',
features: {
metrics: true,
debug: false,
},
tags: ['base', 'http'],
removedLater: 'remove-me',
}import { resolveLayeredConfig } from '@primafuture/netson';
import { createHanazukiJsonnetEvaluator } from '@primafuture/netson/hanazuki';
const jsonnet = createHanazukiJsonnetEvaluator({
defaultImportRootDirectories: ['/project/node_modules'],
});
const result = await resolveLayeredConfig({
jsonnet,
layers: [
{
id: 'base',
path: '/project/config/base.jsonnet',
},
{
id: 'override',
source: `
local netson = import '@primafuture/netson/lib/netson.libsonnet';
{
port: 9090,
nullableSetting: null,
features: {
debug: true,
},
tags: ['prod'],
removedLater: netson.value.unset,
}
`,
},
],
});result.visibleConfig
{
"serviceName": "api",
"port": 9090,
"nullableSetting": null,
"features": {
"metrics": true,
"debug": true
},
"tags": ["prod"]
}result.deferredResolvedConfig
{
"serviceName": "api",
"port": 9090,
"nullableSetting": null,
"features": {
"metrics": true,
"debug": true
},
"tags": ["prod"]
}What this shows:
- objects are deep-merged, so
features.metricssurvives whilefeatures.debugis overridden - arrays are replaced as a whole, so
tagsbecomes["prod"] nullstays in the result as ordinary datanetson.value.unsetremovesremovedLater- without deferred placeholders,
visibleConfiganddeferredResolvedConfigare identical
Computed Value Example
Computed values are useful when a value in a base layer must be evaluated against the final merged config, including child overrides.
local netson = import '@primafuture/netson/lib/netson.libsonnet';
{
dev: true,
url: netson.value.computed(function(config)
if config.dev then 'http://dev:1234' else 'https://production.com'
),
}If a child layer sets { dev: false }, result.visibleConfig.url is a pipeline placeholder, but result.deferredResolvedConfig.url becomes:
"https://production.com"Computed functions receive a lazy resolved config proxy. They can read other computed or deferred values, and Netson resolves those dependencies on demand. Cycles fail with a config path error.
Computed values can also be used inside deferred cascade params.context. This lets a base layer define a cascade placeholder once while the context reads child layer overrides from the final merged config.
A child layer can still replace the computed field directly:
{
url: 'forced value',
}In that case the plain child value wins and the base computed function is not called.
Value Pipeline Example
Value pipeline helpers are useful when a later layer must change a value that is computed after visible merge.
local netson = import '@primafuture/netson/lib/netson.libsonnet';
{
dev: true,
connection: netson.value.computed(function(config)
if config.dev then
{
type: 'dialup',
phoneNumber: '555-1234',
}
else
{
type: 'wifi',
ssid: 'my-dev-wifi',
}
),
}A child can patch that computed object explicitly:
local netson = import '@primafuture/netson/lib/netson.libsonnet';
{
dev: false,
connection: netson.value.patch({
keepAlive: true,
}),
}or with Jsonnet additive inheritance sugar:
{
connection+: {
keepAlive: true,
},
}Both produce:
{
"type": "wifi",
"ssid": "my-dev-wifi",
"keepAlive": true
}netson.value.finalize(function(args) ...) receives the value built so far and can return a transformed value. netson.value.replace(value) is an explicit hard replacement and resets previous pipeline operations at that path and below it.
Tagged Value Example
Netson also supports generic tagged values that stay opaque to the core runtime and carry the owning config layer in layered results.
local netson = import '@primafuture/netson/lib/netson.libsonnet';
{
certPath: netson.value.tag.relativeToOwningLayer('./certs/dev.pem'),
buildArchive: netson.value.tag.relativeToLastLayer('./.wtm-build-archive'),
}result.visibleConfig
{
"certPath": {
"__netson_value__": {
"tag": "relative-to",
"value": {
"base": "owning-layer",
"value": "./certs/dev.pem"
},
"configLayer": {
"id": "base"
}
}
}
}What this shows:
- Netson preserves the tagged wrapper instead of interpreting it
configLayer.ididentifies the tag's originating config layer, even when another layer forwards it to a different config pathrelativeToOwningLayer(...)andrelativeToLastLayer(...)are explicit sugar overrelativeTo(base, value)- the standard
relative-totag usesvalue.baseto tell the host what base to resolve against owning-layermeans the layer identified byconfigLayer.idlast-layermeans the last layer in the ordered chain being resolved- if a host wants to treat these values as filesystem-relative paths, it must supply a concrete resolution base for the selected mode
- if the chosen base cannot be resolved, for example a source-backed layer without a directory mapping, the host should fail explicitly instead of guessing
- TypeScript hosts can use
getNetsonRelativeToTaggedValue(...)to parse this standard payload shape without resolving it - if a child layer replaces the tagged value with a plain string, the wrapper disappears entirely
- if a child layer replaces it with a newly created tag without ownership, the child becomes the new owner
- forwarding an already owned tag through computed, patch, replace, or finalize preserves its owner, including tags inside arrays and objects
- an identity finalize (
function(args) args.value) preserves provenance; create a new tag explicitly to bind a path to the child layer
Layered APIs validate ownership with taggedValueOwnershipValidation:
| Mode | Behavior |
| --- | --- |
| current-chain (default) | Requires a valid configLayer and an owner ID present in the full input layer chain. |
| shape-only | Requires a valid configLayer, but allows owner IDs outside that chain. The host must validate and map those IDs. |
Both modes assign ownership to new tags and reject malformed existing ownership:
configLayer must contain exactly id, a non-empty string. Values such as
null, { id: 42 }, { id: '' }, or objects with additional fields are errors;
they are never repaired by assigning the current layer.
Validation covers decorated visibleConfig before deferred evaluation and the
final deferredResolvedConfig, including results from host callbacks and custom
or last-layer tags. Tag payloads and internal deferred/pipeline markers remain
opaque. The last-layer path base still means the last input layer; validating
the tag's provenance does not change that base. Netson does not check filesystem
paths or resolve owner IDs to directories.
Chain membership is checked at these public result boundaries, not on every pipeline intermediate. A well-formed tag with an external owner may be consumed or discarded by a later operation, provided it does not remain in either public result. Malformed provenance still fails in intermediate results. Internal resolver parameters are passed unchanged to the host, even when they contain objects resembling malformed or unowned tags; they become subject to tag validation only if returned as public configuration values.
For hosts that intentionally reuse owned tags from another resolution chain,
set taggedValueOwnershipValidation: 'shape-only' on resolveLayeredConfig(...)
or loadLayeredConfig(...). This is the compatibility option for the stricter
default; it does not restore the old deferred ownership overwrite bug.
The CLI exposes the same option for resolve, validate, and explain:
netson-test validate --tagged-value-ownership-validation shape-only base.jsonnet child.jsonnetCascade Model Example
You can also use the cascade engine directly inside Jsonnet when you already have the model, rules, and context in one evaluation.
local netson = import '@primafuture/netson/lib/netson.libsonnet';
local boardModel = netson.cascade.model({
mode: 'merge',
dimensions: ['target', 'profile'],
keys: {
kind: {
contextPath: ['target', 'kind'],
dimensionsSpecificityByMatcher: {
equals: { target: 10 },
},
},
profiles: {
contextPath: ['profiles'],
dimensionsSpecificityByMatcher: {
containsAll: { profile: 10 },
},
},
},
});
local boardRules = {
defaults: netson.cascade.rule({
effect: netson.cascade.patch({
enabled: true,
replicas: 1,
transport: 'serial',
legacyTransport: 'udp',
}),
}),
service: netson.cascade.rule({
match: [
netson.cascade.match.equals({
key: 'kind',
value: 'service',
}),
],
effect: netson.cascade.patch({
transport: 'http',
}),
}),
prod: netson.cascade.rule({
match: [
netson.cascade.match.containsAll({
key: 'profiles',
value: ['prod'],
}),
],
effect: netson.cascade.patch({
replicas: 3,
legacyTransport: netson.value.unset,
}),
}),
};
{
resolved: netson.cascade.resolve({
model: boardModel,
rules: boardRules,
context: {
target: { kind: 'service' },
profiles: ['prod'],
},
initial: {},
}),
explained: netson.cascade.explain({
model: boardModel,
rules: boardRules,
context: {
target: { kind: 'service' },
profiles: ['prod'],
},
initial: {},
}),
}resolved
{
"enabled": true,
"replicas": 3,
"transport": "http"
}The matching order is:
defaultsapplies firstserviceoverridestransportprodoverridesreplicasand removeslegacyTransport
explained then gives you the structured trace:
- which rules matched and which did not
- matcher reasons
- specificity contributions
- step-by-step
before/afterchanges - final value origin by path
Example Folders
The repository now contains a few ready-to-run example config sets:
examples/layered-config- numbered layers for plain visible merge behavior
examples/computed-values- computed-value and value-pipeline examples for final merged config dependencies
examples/deferred-cascade- layered config with a deferred
cascadeplaceholder
- layered config with a deferred
examples/layered-cascade-rules- minimal hidden
cascadeRules::inheritance across base and child layers
- minimal hidden
examples/cascade-model- direct Jsonnet usage of
cascade.resolve(...)andcascade.explain(...)
- direct Jsonnet usage of
examples/css-like-cascade- CSS-like cascade specificity with
id > class/pseudo > element
- CSS-like cascade specificity with
examples/tagged-values- layered config showing
__netson_value__wrappers andconfigLayer.id
- layered config showing
Jsonnet Asset
Use the public asset from Jsonnet:
local netson = import '@primafuture/netson/lib/netson.libsonnet';The asset exposes:
netson.deferred.placeholder(...)netson.deferred.resolveAll(...)netson.value.unsetnetson.value.omitnetson.value.computed(...)netson.value.patch(...)netson.value.finalize(...)netson.value.replace(...)netson.value.tag.custom(...)netson.value.tag.relativeTo(...)netson.value.tag.relativeToOwningLayer(...)netson.value.tag.relativeToLastLayer(...)netson.cascade.model(...)netson.cascade.rule(...)netson.cascade.resolve(...)netson.cascade.explain(...)netson.cascade.patch(...)netson.cascade.transform(...)netson.cascade.validate(...)netson.cascade.match.equals(...)netson.cascade.match.inSet(...)netson.cascade.match.containsAll(...)netson.cascade.match.containsAny(...)netson.cascade.match.prefix(...)netson.cascade.match.exists(...)netson.cascade.match.isMissing(...)netson.cascade.match.isNull(...)netson.cascade.match.predicate(...)netson.cascade.match.custom(...)
Runtime API
resolveLayeredConfig(options)
Low-level layered resolve API.
import type {
NetsonConfigLayer,
NetsonJsonnetEvaluator,
ResolvedLayeredConfig,
} from '@primafuture/netson';Highlights:
- accepts file-backed and source-backed layers
- evaluates visible config layer-by-layer
- can run an optional
postMergeVisibleConfigtransform before deferred resolution - composes helper root internally for deferred resolution
- resolves deferred placeholders into
deferredResolvedConfig - resolves value pipeline placeholders into
deferredResolvedConfig - decorates tagged values in layered results with
configLayer.id - accepts
taggedValueOwnershipValidation: 'current-chain' | 'shape-only'(exported typeNetsonTaggedValueOwnershipValidation); defaults tocurrent-chain - accepts
mergePolicyfor host-specific atomic marker objects while keeping Netson's pipeline-aware layered merge
loadLayeredConfig(options)
High-level sugar over resolveLayeredConfig(...) that delegates chain discovery to your host adapter.
mergeJsonObjects(base, override, options?)
Default visible merge policy:
- object + object => deep merge
netson.value.unsetdeletes the keynetson.value.omitstays as a visible marker and is pruned from the resolved confignullstays asnull- arrays replace whole arrays
- scalars replace
- tagged wrappers replace atomically
- deferred/computed placeholders replace atomically
- pipeline placeholders replace atomically
Hosts that need additional atomic marker objects can pass a merge policy:
import { mergeJsonObjects, type NetsonMergePolicy } from '@primafuture/netson';
const mergePolicy: NetsonMergePolicy = {
isAtomicObject: (value) =>
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
'__host_marker__' in value,
};
const merged = mergeJsonObjects(base, override, { mergePolicy });The same mergePolicy option is available on resolveLayeredConfig(...) and
loadLayeredConfig(...). It only adds host-specific atomic objects; Netson's
own tagged values, unset markers, omit markers, deferred placeholders, and
pipeline placeholders are always atomic.
mergeVisibleConfig is still supported as a legacy escape hatch when a host must
fully own visible merging, but it bypasses Netson's pipeline-aware merge
orchestration and cannot be combined with mergePolicy.
Unset helpers
import {
createNetsonUnsetValue,
isNetsonUnsetValue,
NETSON_UNSET_MARKER_KEY,
} from '@primafuture/netson';These helpers let a host create and detect the explicit delete sentinel in TypeScript.
Omit helpers
import {
createNetsonOmitValue,
isNetsonOmitValue,
NETSON_OMIT_MARKER_KEY,
} from '@primafuture/netson';netson.value.omit means resolve-time absence. It can remain in visibleConfig
as { "__netson_omit__": true }, but deferredResolvedConfig prunes it from
object fields. Array entries become null because removing array positions would
change indexes.
Tagged value helpers
import {
getNetsonRelativeToTaggedValue,
getNetsonTaggedValue,
isNetsonTaggedValue,
NETSON_TAGGED_VALUE_MARKER_KEY,
} from '@primafuture/netson';These helpers let a host detect __netson_value__ wrappers without parsing raw marker objects manually.
getNetsonRelativeToTaggedValue(...) is a convenience parser for the standard relative-to payload shape { base, value }; it returns configLayer.id when present, accepts future non-empty base strings, and does not resolve paths.
explainNetsonCascade(options)
Thin TypeScript wrapper around Jsonnet netson.cascade.explain(...).
This is useful when the model and rules are JSON-like and you want the same structured explain shape from TypeScript.
Jsonnet Runtime Adapter
The default in-process runtime backend is @primafuture/jsonnet-runtime.
import { createJsonnetRuntimeEvaluator } from '@primafuture/netson/jsonnet-runtime';
const jsonnet = createJsonnetRuntimeEvaluator({
defaultImportRootDirectories: ['/path/to/project/node_modules'],
});This backend bundles a patched jrsonnet-web WASM binding through
@primafuture/jsonnet-runtime. Direct Jsonnet std.native(...) callbacks are
synchronous in V1. That means direct Jsonnet calls to
netson.cascade.resolve(...) and netson.cascade.explain(...) support sync
host custom matchers; async host matchers intentionally fail with a clear error.
The high-level TypeScript APIs still support async host matchers for deferred
cascade placeholders in resolveLayeredConfig(...) and for
explainNetsonCascade(...), because those paths dispatch matcher calls outside
direct Jsonnet std.native(...).
Hanazuki Adapter
Hanazuki remains available as a compatibility backend.
import { createHanazukiJsonnetEvaluator } from '@primafuture/netson/hanazuki';
const jsonnet = createHanazukiJsonnetEvaluator({
defaultImportRootDirectories: ['/path/to/project/node_modules'],
});Host custom cascade matchers are supported through this adapter, including direct
Jsonnet calls to netson.cascade.resolve(...) and netson.cascade.explain(...).
The TypeScript APIs also dispatch host matchers outside Jsonnet for deferred
cascade placeholders and explainNetsonCascade(...), so those paths do not need
the native bridge.
Important Behavior
- Netson is object-rooted. Each layer must evaluate to an object.
- Delete is explicit through
netson.value.unset;nullremains ordinary data. - Resolve-time absence is explicit through
netson.value.omit; it is not a merge-time delete. - Visible config merge and helper-root composition are separate artifacts.
- Helper root is internal and is not exposed in the TypeScript result API.
netson.value.computed(...)receives the final lazy resolved config and writes its result todeferredResolvedConfig.netson.value.patch(...)deep-merges object patches over the value built so far; arrays and scalars are not patchable.netson.value.patch(...)treats{ key: netson.value.omit }as removingkeyfrom the patched object, andpatch(function(args) netson.value.omit)as a no-op patch.netson.value.finalize(...)receives{ value, missing, config, path, layer }and returns the next value for that path.netson.value.replace(...)is an explicit hard replacement and resets pipeline state at that path and below it.field+: { ... }can act as sugar for an object patch when helper-root composition proves Jsonnet additive inheritance was used.- Plain
field: { ... }remains a hard replacement. - Deferred cascade
params.contextcan containnetson.value.computed(...)values; they are resolved before cascade matching. - Tagged values stay opaque to Netson; only
configLayer.idis injected by layered runtime APIs. - The built-in deferred resolver
cascadeuses helper-root composition internally, so hidden helpers survive across layer inheritance. - Direct Jsonnet
netson.cascade.resolve(...)/explain(...)custom matchers need an evaluator native bridge. Deferred cascade placeholders and the TypeScriptexplainNetsonCascade(...)API dispatch host matcher calls outside Jsonnet.
