@primafuture/netson
v1.2.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:
npm install @primafuture/netsonOfficial Jsonnet runtime backend:
npm install @primafuture/netson @hanazuki/node-jsonnetInside 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 { createHanazukiJsonnetEvaluator } from '@primafuture/netson/hanazuki';
const jsonnet = createHanazukiJsonnetEvaluator({
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.jsonnetLayered 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.idpoints to the owning config layer for that final 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 another tagged value, the child becomes the new owner
Cascade 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
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.
Hanazuki Adapter
The official V1 runtime backend is @hanazuki/node-jsonnet.
import { createHanazukiJsonnetEvaluator } from '@primafuture/netson/hanazuki';
const jsonnet = createHanazukiJsonnetEvaluator({
defaultImportRootDirectories: ['/path/to/project/node_modules'],
});Host custom cascade matchers are supported through this adapter. If you pass customMatchers to Netson APIs, use this adapter or another evaluator that implements the same host native bridge capability.
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. customMatchersare dispatched through the host runtime bridge and require the official adapter or an equivalent capable evaluator.
