@rootintf/iface-gen
v0.1.8
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.
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 (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": "^0.1.0" // the generator — build-time only
},
"dependencies": {
"@rootintf/json-msg": "^2.2.2" // 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 vendor # copy the protocol schemas into ./schema, injecting x-base (on schema change)
npm run generate # all interfaces -> ./packages
npm test # ESM runtime tests; `npm run typecheck` checks the typed responder chain(C++ headers are generated separately by cpp-iface's own Node-free generator — this generator is npm only.)
CLI — two modes:
iface-gen <positional…> [--scope @s] [--prefix p-] [--schemas DIR] [--out-npm DIR] [--delete]
iface-gen --config <file> [--no-debug] # 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)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": "<url|path>", "sha256": "<sha256sum of the schema bytes>", "debug": false }
},
"targets": [
{ "schema-cache": "./src/main/schemas", "out-npm": "./src/main/generated",
"scope": "@rootintf", "prefix": "iface-", "delete": true, "schemas": ["Discovery"] }
]
}- 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. --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.
Where the schemas live (the one decision for Model B)
A published generator must carry the schemas (a consumer won't have the npm-protocol-* repos
checked out). Two options:
- Bundle them into
@rootintf/iface-gen(aschema/dir; point--schemasat it). One published package — simplest, matches "publish one thing." - A separate
@rootintf/iface-schemasdata package the generator reads. Cleaner DRY if the schemas are also consumed elsewhere, but it's two packages.
Either way the schema is the single source of truth; today gen.cjs reads the originals in place
(read-only), which is right for in-repo development.
Adding a new interface/protocol
The generator reads schema/schema_<Name>.json — one file per protocol — and needs nothing else
hand-mapped. To add one:
- Get the schema into
schema/, carryingx-base.x-baseis the only annotation the generator needs (it selects the base class → the constructor + the typed surface). Two ways:- Vendored (in-repo protocols): add the source path to
vendor.cjs'sSRC, and add each JSON_Msg-family message to itsBASEmap (MsgX: "JSON_Msg"/"JSON_MsgEffect"/"JSON_MsgAck"; plain-JSON messages get no entry). Thennpm run vendorcopies the file in and injectsx-base. - Authored directly: drop a
schema/schema_<Name>.jsonthat already hasx-baseinline on each Msg-family message. (No vendor step needed.)
- Vendored (in-repo protocols): add the source path to
- 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): a Msg-family message's
$idleaf segment must start withMsg(→JSON_Msg…); a plain-JSON message becomesJSON_<segment>. A family segment that doesn't start withMsgis a hard error. npm run generate(oriface-gen <Name>) →packages/iface-<Name>.- Keep the C++ twin in sync: copy the vendored
schema/schema_<Name>.json(the one withx-base) intocpp-iface/schema/— see that repo's README. Do not copy the rawnpm-protocol-*file; it has nox-base, so its Msg messages would silently demote to plain JSON.
Worked example — MessagingEnv. vendor.cjs has SRC →
npm-protocol-MessagingEnv.git/schema/schema_MessagingEnv.json and BASE →
{ MsgCtrl: "JSON_Msg", MsgFilter: "JSON_Msg" }. npm run vendor writes
schema/schema_MessagingEnv.json with x-base on both messages; npm run generate emits
@rootintf/iface-messagingenv exporting JSON_MsgCtrl and JSON_MsgFilter (both JSON_Msg,
VERSION "1"), 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 --no-cpp 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 (^2.2.2) 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.0.1.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:^0.1.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":"1" } (id/dt via json-msg)
Store.JSON_MsgQuery.validate(msg); // true — the IPSME interest-management drop primitive
Store.VERSION; // "1"Base classes, the type layer, and the responder chain
Each message declares its base class in the schema via x-base (JSON_Msg, JSON_MsgEffect, or
plain JSON). That single annotation drives both the constructor and the types — but never the
runtime validation:
Naming. A generated type is
JSON_+ the schema's$idleaf segment. A JSON_Msg-family message (x-baseJSON_Msg/JSON_MsgEffect/JSON_MsgAck) names its segmentMsg…, so it becomesJSON_Msg…(MsgDiscover→JSON_MsgDiscover,MsgAnnounce→JSON_MsgAnnounce); a plain-JSONmessage becomesJSON_<segment>(EchoRequest→JSON_EchoRequest). A family message whose segment doesn't start withMsgis 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_MsgEffect.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_MsgAnnounce.validate(msg: JSON_MsgEffect): msg is JSON_MsgAnnounceif (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_MsgAnnounce.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_MsgEffect } from "@rootintf/json-msg"
and extends them; index.mjs/index.cjs import the same names as values and call
JSON_Msg.merge / JSON_MsgEffect.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) | source filename schema_1Store.json |
| 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 (0.1.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.
