@rootintf/iface-gen
v3.1.29
Published
Single-source generator: one JSON Schema -> one granular npm package + one C++ header. The interface is the schema; the package/header is a generated artifact.
Downloads
85
Readme
npm-iface (@rootintf/iface-gen)
The generator. One JSON Schema → one granular npm package + one C++ header. The interface is the schema; the package/header is a generated artifact you never write by hand — yet each interface stays separate, so a service uses only the few it needs, never a catalog of hundreds.
This repo is the generator (src/gen.cjs) plus a local packages/ output you can inspect.
Two ways to ship interfaces
Model B — publish the generator, generate per project ← recommended for you
You publish one package (this generator, carrying the schemas). Each consuming project generates only the interfaces it needs at build time, and gets the shared runtime from the registry:
// consumer package.json
{
"devDependencies": {
"@rootintf/iface-gen": "^1.0.0" // the generator — build-time only
},
"dependencies": {
"@rootintf/json-msg": "^4.0.2-api.4" // the ONLY runtime dep — base types + constructors
},
"scripts": {
"gen": "iface-gen Discovery 1Store --delete --out-npm ./src/generated",
"build": "npm run gen && <your build>"
}
}// consumer source — import the LOCAL generated code; it pulls the shared runtime above
import * as D from "./src/generated/iface-Discovery/index.mjs"; // ESM (bundler / electron)
if (D.JSON_MsgDiscover.validate(msg)) handle(msg);
// under CJS (e.g. ts-jest): const D = require("./src/generated/iface-Discovery/index.cjs");Why this keeps granularity: the generator + all schemas are a devDependency / build-time tool;
your runtime graph is only (a) the interfaces you generated — local code — plus (b) one shared
runtime, json-msg, deduped. The catalog of all interfaces never enters it.
Make generation an explicit
gen/buildscript, not annpminstall hook — reproducible andnpm ci --ignore-scripts-safe. Commit./src/generated, or.gitignoreit and rungenbeforebuild— your call. (Interfaces that are plainJSON— no envelope — need neither runtime dep.)
Model A — publish each interface
Publish every @rootintf/iface-<name>; consumers just npm i @rootintf/iface-1store. No toolchain at
the consumer, per-interface registry versioning — but you publish N packages. Better for third-party
/ external consumers who shouldn't run your generator.
Both models emit the same granular, dual ESM+CJS packages — envelope interfaces depend only on
json-msg; plain-JSON ones (Echo, Hyperport) have no deps. The only difference is who runs the
generator and when.
Running the generator
npm install # ajv + typescript — build/test only
npm run gen # all interfaces -> ./packages, from ifacegenconfig.json (pinned)
npm test # regenerates fixtures (pretest) + ESM runtime tests; `npm run typecheck` checks the typed responder chainGeneration reads the pinned schemas declared in ifacegenconfig.json (see Config mode below); the
schemas themselves are authored — already carrying x-base — in the central schemas.readonly store.
(C++ headers are generated separately by cpp-iface's own Node-free generator — this generator is npm only.)
Running it as a dependency (npx)
When a consuming project has @rootintf/iface-gen installed, you don't need a script — npm puts the
binary on the path (node_modules/.bin/iface-gen, shebang'd so it works on POSIX and Windows). Run it
directly from the consumer root:
npx iface-gen --config ifacegenconfig.json # generate
npx iface-gen --config ifacegenconfig.json --seed-bases # generate, adopting any pre-existing output that has no basenpx resolves the local node_modules/.bin/iface-gen first, so it uses the installed version — no
download. Equivalents: ./node_modules/.bin/iface-gen … or npm exec -- iface-gen …. Add --no-install
to guarantee npx never reaches the registry (it errors if the local dep is missing rather than silently
fetching): npx --no-install iface-gen --config ifacegenconfig.json. Config paths resolve relative to
the config file, so outputs land in the consumer (not in node_modules).
CLI — two modes:
iface-gen <positional…> [--scope @s] [--prefix p-] [--schemas DIR] [--out-npm DIR] [--delete]
iface-gen --config <file> [--no-debug] [--seed-bases] # bare `--config` prints a starter config to stdout
positional… SCHEMA FILE PATHS (./schema_Discovery.json) or interface NAMES (Discovery) resolved
against --schemas. A positional is a PATH if it ends .json / has a separator / exists;
else a NAME. Paths and names can't be mixed. (no positional + no --delete = all names)
--scope NAME npm scope; default @rootintf ("" = unscoped)
--prefix STR package-name prefix; default iface- (e.g. protocol- for drop-in protocol-* names)
--schemas DIR dir for NAME resolution; default ./schema
--out-npm DIR where to write the <prefix><Name>/ packages; default ./packages
--delete rsync-style: prune any OTHER generated package in --out-npm so it matches THIS run's
set (no positional + --delete = wipe). Only gen-shaped dirs (package.json + index.* +
validate.*) are touched — unrelated files/dirs are invisible to it.
--config FILE generate from a config (registry + targets) instead of positionals; bare --config = template
--no-debug fail if any registry entry has debug:true (CI guard)
--seed-bases adopt: record a base for an existing IPSME_Bridge output that has none, leaving the
output untouched (deliberate one-time opt-in; a plain run errors on a missing base)Ad-hoc examples:
iface-gen ./schema_Discovery.json ./schema_Echo.json --out-npm ./src/generated # SCHEMA_FP (paths)
iface-gen Discovery --delete --out-npm ./gen # name mode + prune
iface-gen --delete --out-npm ./gen # wipe ./genConfig mode (pinned, multi-target)
A schema registry (each defined once, pinned by sha256) + per-output targets. One file routes
every output; npm run gen → iface-gen --config ifacegenconfig.json.
{
"schemas": {
"Discovery": { "src": "../schemas.readonly/schema_Discovery.json", "sha256": "<sha256sum of the schema bytes>", "debug": false }
},
"targets": [
{ "out-npm": "./packages",
"scope": "@rootintf", "prefix": "iface-", "delete": true, "schemas": ["Discovery"] }
]
}This repo's own ifacegenconfig.json is exactly this shape: it points every entry's src at
../schemas.readonly/schema_<Name>.json and pins each to the .sha256 sidecar shipped alongside it.
- Registry: each schema's
src+ pinnedsha256lives once; targets select by name (a name with no registry entry is a hard error). A schema used by several targets is pinned once, fanned out. - Verify + cache: for each pinned schema,
genusesschema-cache/<sha256>.json(content-addressed) if present, else fetches the URL / reads the path, verifies the bytes against the pin (hard error on mismatch), and writes the cache blob. Commitschema-cache→ offline, reproducible builds. A blob whose bytes no longer hash to its name (classic cause: acore.autocrlf=truecheckout smudging LF → CRLF) is a hard error;--forceis the explicit override — it re-reads thesrc, still verifies it against the pin, and rewrites the blob. - LF discipline: schemas must be LF — a CRLF/CR src is rejected (the pin is over the raw bytes),
and the merge-managed scaffold diffs on content (merge inputs normalized to LF). Checkout-side,
genwrites a* text eol=lf.gitattributesinto each managed dir (out-npm,schema-cache) so anautocrlf=truecheckout can't smudge the committed artifacts; marker-guarded — a hand-written.gitattributesis never touched. --deletemirrors both the packages and the cache per target."debug": trueskips the check for one schema and reads itssrclive (loudWARNING:banner);--no-debugrefuses to run if any are set. The pin is the plainsha256sumof the schema bytes — keep the file byte-stable and re-pin by hand when it changes (there is no--freeze).
iface-gen --config with no file prints a starter config to fill in.
Introspection — what does this target support?
Config mode also emits one <prefix>introspection package per target (e.g.
packages/iface-introspection): the registry of WHAT the target supports — friendly name (the
config registry key, e.g. "Discovery-svc") → sha256 of the schema bytes generated from (== the
config pin when verified; a debug entry reports the hash of the live bytes actually read). It's the
runtime end of the config's pins, so consumers stop hand-transcribing hash literals:
import { INTERFACES, sha256 } from "@rootintf/iface-introspection"; // or the relative generated path
json_body["interfaces"] = { ...INTERFACES }; // a capabilities reply: { "Discovery": "6ddb…", … }
sha256("Discovery-svc"); // one pin; null when the interface isn't supportedINTERFACES is frozen, keyed by the config names (not $id-derived identifiers), in config
order. The package is dependency-free (versioned GEN_VERSION, no -api suffix — the registry has
no _v of its own), emitted only when the target generated ≥1 interface, and covered by --delete
like any other package. The C++ generator emits the functional twin (<prefix>introspection.h) from
the same config — same names, same hashes — so a capabilities reply built from either side matches.
IPSME participant scaffold (merge-on-gen)
Beyond the per-interface npm packages, a config target can generate an editable IPSME participant
scaffold — the wiring you'd otherwise write by hand. Add an IPSME block to a target:
{ "root": "app",
"IPSME": { "main": "App.js", "shell": "electron-renderer", "out": "IPSME" },
"out-npm": "packages", "scope": "@rootintf", "prefix": "iface-",
"delete": true, "schemas": ["MessagingEnv", "Discovery", "1Store", "Echo"] }main— the participant filename (defaultmain.js).$main$in the templates → its stem, soApp.js→ the componentAppandinterface_App.shell— the participant's runtime context, which selects its SHAPE (required; unknown or missing is a hard error). Three shapes, five shells — each shape is a different answer to the one hard requirement, construct the bridge exactly once per participant lifetime:node/electron-main→ a plain class (a hosted object; your entrypoint constructs it once).electron-boot→ flat top-level code — THE entry module of electron's main process (the singleton tier'smain.ts). A module body executes exactly once per process, so the once-guarantee needs no guard; the rest of the entry (app lifecycle, windows, reflector wiring) composes around the scaffolded block.electron-renderer/browser→ a React component (auseReflazy-init constructs the bridge exactly once — see below).
The shell names are runtime contexts (not shape names) on purpose: pairs like
node/electron-mainemit identical shapes today, but the runtime identity is what future env differences hang off — which MsgEnv the bridge imports, and each context's reflector obligations (electron-bootownsReflector_IPC_main; a renderer rides the IPC reflector wired at its entry; a browser participant needs the ws SharedWorker reflector) — without configs ever migrating.out— directory for the bridge + responders (defaultIPSME), relative to the target root.root(target-level) — namespaces all the target's write paths (out-npm, the IPSMEout, and the base mirror) under<config-dir>/<root>/, so multiple targets never collide.
What it emits
| file | what |
|---|---|
| <main> (e.g. App.js) | the participant — class, flat entry module, or React component per shell; destructures the responder chain from the bridge |
| <out>/IPSME_Bridge.js | wires the chain: responder imports + logr aliases, the LOGR_.wire list, responder declarations, per-base (json_{msg,eff,ack}) dispatch, and the process_msgs reprocess loop |
| <out>/Responder_<iface>.js | one per envelope schema — a live handler per message + the base handler_json_{ack,eff,msg} dispatch |
Only envelope schemas get a responder. A schema with ≥1 JSON_Msg/JSON_Eff/JSON_Ack message
(an x-base) yields a Responder_<iface>; a plain-JSON schema (Echo, Hyperport) still produces its
npm package but no responder. A participant with zero envelope schemas is valid too — the chain just
collapses to empty ({ MsgEnv, process_msgs }, []).
These files are yours to edit — merge-on-gen keeps your edits
packages/ is regenerated wholesale (never hand-edit). The scaffold is the opposite: a skeleton you
fill in, and re-running gen must not clobber your edits. So each scaffold file is written through a
git 3-way merge:
- base — the pristine generated output from the last run
- ours — the file on disk now (base + your edits)
- theirs — the freshly generated output
git merge-file folds template changes into your file, raising conflict markers only where a
regenerated line overlaps a line you touched — clean, silent merges everywhere else. The base is kept in
a mirror tree next to the config — ifacegen-base/<target>/<out-rel>/<file>.v<version> — each snapshot
stamped with the generator version that produced it (a non-source extension, so it never compiles in).
Commit ifacegen-base/ alongside your scaffold: it stays out of the compile path but travels with
the repo so merges are reproducible.
Per-file status each run: created (first time — writes output + base together) · unchanged ·
merged (clean fold) · CONFLICTS(n) (resolve the markers, as with any git conflict).
Reading a conflict — the markers are git's diff3 style, so a conflict shows three sections (not two), with the base (common ancestor) in the middle:
<<<<<<< yours (edited) ← on disk now (base + your edits)
...A...
||||||| base (generated) ← the last pristine generated skeleton (the common ancestor)
...B...
=======
...C... ← the freshly generated output
>>>>>>> generated (new)Resolve by comparing each side to the base B (the middle):
- A == B → you didn't touch this region, only the generator did → take C.
- C == B → the generator didn't change it, only you did → keep A.
- A and C both differ from B → you each changed it → merge by hand.
That middle (base) section is why diff3 is worth the extra markers: without it you'd see only your version vs the new one and have to guess who changed what.
Conflicts are resolved by hand each run — there is no travelling resolution cache (git rerere).
That's deliberate: rerere is local-only (it doesn't survive clone/push), and the honest portable
alternative is a DIY preimage→postimage cache committed under ifacegen-base/ — essentially
reimplementing rerere. That's held until conflict volume actually justifies the extra machinery.
Adopting a scaffold file that already exists
The invariant is strict — output present ⇒ base must exist, else gen errors. It will not
fabricate an ancestor under a file that never descended from one (that's the self-healing trap; it
silently corrupts on the first merge). So you don't retrofit a base — you reach the managed state through
created, then bring your edits onto the generated skeleton:
cp out/IPSME/IPSME_Bridge.js /tmp/bridge.mine.js # 1. preserve your current bridge
rm out/IPSME/IPSME_Bridge.js # 2. remove it so gen takes the `created` path
npm run gen # 3. [created]: pristine bridge + its base, in sync
git difftool --no-index out/IPSME/IPSME_Bridge.js /tmp/bridge.mine.js # 4. 2-way MERGE your logic into the generated file (meld/vimdiff/VS Code)
git add out/IPSME/IPSME_Bridge.js ifacegen-base/ # 5. commit the reconciled file AND its base
git commit -m "adopt IPSME_Bridge under iface-gen management"After step 3 the base equals the pristine output; in step 4 you edit that file, so your bridge genuinely descends from the base — and every later regen is a clean 3-way.
Never paste your old file over the generated stub. The base must be a true ancestor. Drop a from-scratch file on top and record the stub as base, and the first template change merges as a paste-over — silently duplicating or corrupting your code (a failure reproduced and locked in a test). "Conform to the generated bridge" means re-derive your bridge from the skeleton (step 4), not bolt a base under a hand-written file.
For the reverse direction there's --seed-bases (npm run seed): a deliberate one-time opt-in that
records a base for existing output that has none, leaving the output untouched. Use it only when you're
confident the output already descends from the generated skeleton — otherwise future merges may duplicate.
The React shell: construct-once, and why it doesn't dispose on unmount
The electron-renderer / browser shell constructs the bridge in a useRef lazy-init in the component
body (so the bridge, MsgEnv, and responders are available synchronously during render), guarded so it
runs exactly once. It deliberately does not dispose the MsgEnv on React unmount:
- The bridge is a page-lifetime singleton — its
_constructed_guard never resets, so it can't be reconstructed, which makes the owning component page-lifetime by construction. "Unmount" and "window teardown" are the same event in practice; the browser/OS reclaims the transport's socket/channel/OS observer when the page dies. - Disposing on unmount only introduces a StrictMode footgun (dev's transient unmount would kill the surviving ref-held bridge) for no real gain.
- Known mismatch to reconcile later: the bridge returns
dispose: () => IPSME_MsgEnv.dispose(), but two transports (broadcastchannel,electron-NSDNC) export nodispose— only per-handlerunsubscribe— so calling it there throws. This is another reason dispose is kept out of the shell lifecycle; fix it in the bridge (a no-opdisposeon those transports, or have the bridge tolerate its absence). None of the four transports (MQTT, sdbus, broadcastchannel, NSDNC) emit an application-level "leaving" message on teardown, so nothing needs an explicit goodbye. If a transport ever holds something the OS won't reclaim (a SharedWorker, a server session), tear it down onwindowbeforeunload, not on React unmount.
Where the schemas live
The schemas are authored once, centrally, in the read-only schemas.readonly store (a sibling of
this repo), each already carrying its x-base annotation and pinned by a .sha256 sidecar. That store
is the single source of truth. ifacegenconfig.json references each schema from there by relative path
and hash — so a run verifies the exact bytes before generating, and there is no injection step and no
per-protocol repos to check out. json-msg's own base schema (schema_json-msg.json) is the sole
exception: it lives in json-msg and is not generated (it is the base classes).
Adding a new interface/protocol
The generator reads each schema named in ifacegenconfig.json from schemas.readonly — one file per
protocol — and needs nothing else hand-mapped. To add one:
- Author
schema_<Name>.jsoninschemas.readonly, withx-baseinline.x-baseis the only annotation the generator needs (it selects the base class → the constructor + the typed surface): put"x-base": "JSON_Msg"/"JSON_Eff"/"JSON_Ack"on each envelope message; plain-JSON messages get nox-base. Generate its.sha256sidecar (sha256sum schema_<Name>.json). - Register it in
ifacegenconfig.json: add aschemasentry ("<Name>": { "src": "../schemas.readonly/schema_<Name>.json", "sha256": "<pin>" }) and list"<Name>"in a target. - Schema shape the generator expects (draft 2019-09): a top-level
schemasmap keyed by$idURLs; one sub-schema per message; for a versioned protocol a…/definitionssub-schema with"_v": { "const": "N" }, and each envelope message has a required prop whose$refends in…/_v. - Naming rule (enforced): the generated name is the message's
$idleaf segment verbatim (only non-identifier chars sanitised to_). An x-base (Msg-family) message must be authoredJSON_…in its$id; a plain-JSON message must not start withJSON_. A segment that contradicts its x-base is a hard error. npm run gen(oriface-gen <Name>) →packages/iface-<Name>.- Keep the C++ twin in sync:
cpp-iface's generator reads the sameschemas.readonlyfiles (they already carryx-base), so no copy step — just add the schema to its config too. See that repo's README.
Worked example — MessagingEnv. schemas.readonly/schema_MessagingEnv.json carries x-base:
"JSON_Msg" on both messages, whose $id segments are authored JSON_MsgCtrl / JSON_MsgFilter.
npm run gen emits @rootintf/iface-messagingenv exporting JSON_MsgCtrl and JSON_MsgFilter
(both JSON_Msg, VERSION "4"), each with .validate + .merge.
Publishing each protocol as its own package (Model A, in detail)
You don't have to adopt Model B. Each generated @rootintf/iface-<name> is a complete, standalone
npm package — npm publish it exactly like a hand-written one. The generator already removed the
authoring burden (package.json, dual build, validators), so per-package publishing now costs only
the publish step, and your consumers' experience is unchanged:
npm i @rootintf/iface-discovery # no toolchain, per-package semver — same as the old protocol-* packagesTwo ways to organize it:
- One git repo per protocol (your current topology). Generate into each repo and publish from there:
The repo's contents become generated rather than hand-written; the per-repo, per-package model is otherwise untouched.iface-gen Discovery --out-npm ../npm-protocol-discovery.git cd ../npm-protocol-discovery.git && npm publish - One monorepo (
packages/*with workspaces) — publish each package from there (e.g.changesetspublishes only the ones whose version changed).
What stays separate:
@rootintf/json-msgis published once, on its own — the shared runtime + base types. Every envelopeiface-*package depends on it (^4.0.2-api.4) and installs it transitively. It is the one thing that is not per-protocol.- Versioning is per-package: each publishes as
<iface-gen version>-api.<_v>(e.g.1.0.0-api.3). Bump a schema's_v→ only that package's-api.Nmoves → republish just that one. Independent per-protocol releases, exactly like now. (Consumers pin with the tag in range:^1.0.0-api.3.)
Naming: by default the generator emits @rootintf/iface-<name>. For drop-in replacements of existing
@rootintf/protocol-<name> consumers (no import changes on their side), pass --prefix protocol-
(and --scope @yourorg if it differs); --scope "" emits unscoped packages:
iface-gen Discovery --prefix protocol- # -> @rootintf/protocol-discovery (dir protocol-Discovery)Model A vs Model B is purely who publishes what: Model A = publish each package (familiar, simplest for consumers, N publishes per release); Model B = publish only the generator, consumers generate locally (one publish, but consumers run a build step). Same generated artifacts either way.
What it emits (per interface)
A granular, dual ESM+CJS package (no build step — ajv-standalone is inlined). Envelope interfaces
depend only on @rootintf/json-msg; plain-JSON ones have no deps:
| file | what |
|---|---|
| validate.mjs / validate.cjs | ajv-standalone validators (ESM + CJS), one per message |
| index.mjs / index.cjs | validate_<Msg>(), and for envelope messages create_<Msg>(body) + VERSION |
| index.d.ts | types (base types imported from json-msg) |
| schema/schema_<Name>.json | the source schema, copied in |
| package.json | @rootintf/iface-<name>, dual exports (import/require/types), granular |
const msg = Store.JSON_MsgQuery.merge({ "search-type": "text", q: "hello" });
// -> a real JSON_Msg: { id, referer, dt, query:{…}, "1Store":"3" } (id/dt via json-msg)
Store.JSON_MsgQuery.validate(msg); // true — the IPSME interest-management drop primitive
Store.VERSION; // "3"Base classes, the type layer, and the responder chain
Each message declares its base class in the schema via x-base (JSON_Msg, JSON_Eff, JSON_Ack, or
plain JSON). That single annotation drives both the constructor and the types — but never the
runtime validation:
Naming. A generated type is the schema's
$idleaf segment verbatim (non-identifier chars sanitised to_). The schema carries the final name itself: a Msg-family message (x-baseJSON_Msg/JSON_Eff/JSON_Ack) is authoredJSON_…(JSON_MsgDiscover,JSON_EffAnnounce); a plain-JSONmessage has no prefix (EchoRequest,hyperport). An x-base message whose segment doesn't start withJSON_, or a plain one that does, is a hard generator error. EachJSON_<Type>is a namespace value (.validate,.merge/.create_with_cause_merge) declaration-merged with the same-named type — the same surface as the json-msg base classes (JSON_Msg.merge,JSON_Eff.create_with_cause_merge) and the C++ twin's static methods.
<Type>.validatestays delta-only. It checks only the message's own fields; the base is validated once, upstream in the responder chain, and never re-checked at the leaf. (allOf/$refcomposition would re-validate the base on every message — exactly the waste the chain avoids — so it's deliberately not used.)<Type>.validateis typed as a predicate whose input is the base:
so the compiler refuses to check a leaf delta before its base is confirmed — the chain ordering is compiler-enforced, and insideJSON_EffAnnounce.validate(msg: JSON_Eff): msg is JSON_EffAnnounceif (JSON_MsgX.validate(msg))the handler gets the exact narrowed type. Seedemo/responder.ts+npm run typecheck.<Type>.merge/.create_with_cause_mergedelegate to json-msg, so envelopes are real; effects take the causing message first:JSON_EffAnnounce.create_with_cause_merge(cause, body).
Everything comes from @rootintf/json-msg (single source of truth) — there is no intermediate
package. The generated leaf .d.ts does import type { JSON_Msg, JSON_Eff } from "@rootintf/json-msg"
and extends them; index.mjs/index.cjs import the same names as values and call
JSON_Msg.merge / JSON_Eff.create_with_cause_merge directly. json-msg exports each name as
both a value (the constructor namespace) and an interface (via declaration merging), so one
import is both the constructor and the extendable type. Envelope packages depend only on json-msg;
plain-JSON packages (Echo, Hyperport) have no deps and no create.
Dual ESM + CJS. Every generated package ships index.mjs + index.cjs (and validate.mjs +
validate.cjs) behind an exports map (import/require/types). So bundlers use the ESM build and
CJS consumers (ts-jest, plain require) resolve too — a true drop-in.
Conventions the generator reads (nothing hand-mapped)
| derived | from |
|---|---|
| interface name (1Store) | the bundle's top-level $id leaf segment (source filename as fallback) |
| message names (MsgQuery) | last segment of each sub-schema $id |
| VERSION ("1") | the _v const in the bundle |
| version field ("1Store") | the required prop whose $ref ends in …/_v |
| create body key | the other required prop(s) |
| package version (1.0.0-api.3) | iface-gen's version + -api.<_v> (bare base when no _v) |
Non-envelope messages (arrays, oneOf) get a validate_ only — no create_, no VERSION.
Output is regenerated wholesale: never hand-edit packages/ — change the schema and re-run.
