Build-time artifacts
A module can ship a directory that consumers use at build time rather than
at runtime — most usefully, a package that other modules compile against. Wippy
calls these artifacts: ordinary WAPP filesystem resources marked with
meta.artifact.format.
This is how a shared package reaches a module in a different repository. A path alias only resolves inside one repo; an artifact travels with the module.
The Design Layer explains what belongs in such a package and what does not; this page is the mechanism that ships it.
Declaring an artifact
The producer declares a normal fs.directory and marks it with a format:
# src/_index.yaml
entries:
- name: package_fs
kind: fs.directory
meta:
comment: The npm package consumers materialize at build time.
artifact:
format: node-package
directory: ./package
Nothing else changes: the resource is packed into the WAPP as usual. Declared artifacts are validated during module publish and application pack, so a malformed one fails at publish rather than in a consumer.
Formats
A format adapter decides how a directory is validated, what identity it has, and where it lands. Wippy ships one built-in:
| Format | Owns subtree | Validates |
|---|---|---|
node-package |
npm/ |
package.json |
node-package requires a name and a semantic version, and rejects
preinstall, install, postinstall and prepare lifecycle scripts — a
materialized package may not execute anything on install. It writes to
npm/<package name> under the materialization root.
The format must be registered in the binary doing the work. Hosts may register additional formats; duplicate names and overlapping roots are rejected.
Materializing
Most of the time you do not run anything. Materialized outputs are reconciled automatically during:
- full and targeted
wippy installandwippy update - cold boot
- Hub-backed dynamic install, update and uninstall
Full install, update, cold boot and runtime dependency reconciliation are exact: stale outputs are pruned. A targeted install overlays only the selected modules and preserves outputs belonging to modules it did not select.
Local module replacements go through the same validation and materialization lifecycle as packed resources, so a replaced module's artifact behaves like a published one.
Materializing explicitly
For a build step that needs the artifact before the runtime is involved, the CLI exposes it directly:
wippy artifacts materialize <pack.wapp> <namespace:name> [--root <directory>]
--root defaults to .wippy. The resource must declare meta.artifact.format
and that format must be registered in this CLI.
Be clear about what this command deliberately does not do: it does not
resolve module dependencies, does not mutate wippy.lock, does not invoke
package managers, and does not participate in runtime composition. It validates
one artifact out of one WAPP and writes it to disk.
Where output lands
artifact.materialization_root configures the application-owned output root.
Its default is the parent of the dependency vendor directory. Each format owns
a non-overlapping subtree beneath it, so node-package output is always under
<root>/npm/.
Materialization is transactional. Content is validated and staged, managed roots are swapped atomically under a process lock, a failure rolls back with the surrounding registry transaction, and an interrupted swap is recovered on the next run.
Worked example: a shared frontend package
A producer module whose only job is to publish a package — it serves nothing at runtime:
# platform/ui-kit/src/_index.yaml
version: "1.0"
namespace: kickside.ui_kit
entries:
- name: package_fs
kind: fs.directory
meta:
artifact:
format: node-package
directory: ./package
A consumer materializes it into its own tree before installing dependencies:
wippy artifacts materialize kickside-ui-kit-1.5.0.wapp \
kickside.ui_kit:package_fs --root ./.wippy
That writes ./.wippy/npm/@kickside/ui-kit. The consumer picks it up with an
ordinary workspaces glob, so resolution is plain node resolution from there on:
{
"workspaces": ["./.wippy/npm/@*/*"]
}
npm install
Two things worth copying from this shape:
- The package is its own module, not a directory inside a bigger one. The
artifact carries its own
package.jsonversion, and tying it to a module that changes for unrelated reasons forces a release of one every time the other moves. - The consumer resolves it as a normal dependency. Once materialized there is no Wippy-specific import path, which is what lets the same source build inside the monorepo and outside it.
End to end: authoring, dev loop, CI
Authoring the producer
For a package artifact there is usually nothing to build — the directory is the deliverable. A CSS vocabulary package is just files plus a manifest:
platform/ui-kit/
├── src/_index.yaml # declares package_fs as the artifact
└── package/ # the directory that becomes the npm package
├── package.json
├── kx-card.css
└── kx-state.css
{
"name": "@kickside/ui-kit",
"version": "1.5.0",
"type": "module",
"sideEffects": ["*.css"],
"exports": {
"./kx-card.css": "./kx-card.css",
"./kx-state.css": "./kx-state.css"
},
"files": ["kx-card.css", "kx-state.css", "package.json"]
}
sideEffects matters for a CSS-only package: without it a bundler is free to
treat an imported stylesheet as dead code and drop it.
The package version must equal the module version. wippy publish
validates this and refuses a mismatch, so bump both together. This is also the
reason to give a shared package its own module rather than nesting it inside
a larger one — otherwise every unrelated change to the host module forces a
release of the package, and vice versa.
Publishing
# validate without publishing
wippy publish --dry-run --version 1.5.0
# publish
wippy publish --create --module-type library --module-visibility public --version 1.5.0
Declared artifacts are validated as part of publish, so a package.json that fails the format's rules is rejected here rather than in a consumer's build.
The dev loop
Publishing on every edit is not a dev loop. Pack the producer locally and point the consumer's materialize step at that file instead:
# from the producer module
wippy pack /tmp/ui-kit-dev.wapp
# consumers materialize from the local pack rather than the published one
UI_KIT_WAPP=/tmp/ui-kit-dev.wapp make ui-kit MOD=workflows
Keep that override as the only difference between the dev path and CI — an environment variable that selects the pack file, with everything downstream identical. A dev loop that materializes differently from CI stops predicting CI.
Wiring it into make and CI
Make the materialize step a prerequisite of the consumer's build, not a thing a person remembers to run:
UI_KIT_WAPP ?=
build:
@case " $(UI_KIT_CONSUMERS) " in *" $(MOD) "*) $(MAKE) ui-kit MOD=$(MOD);; esac
cd $(call fe_dir,$(MOD)) && npm run build
CI then needs no artifact-specific step at all: it runs the same make build,
UI_KIT_WAPP is unset, so the fetch-and-materialize path runs against the
published version pinned in build-inputs. A fresh checkout cannot compile
against a stale or missing package, and a contributor who has never heard of
artifacts still gets a correct build.
What you still have to hand-roll
wippy artifacts materialize is deliberately narrow, so a build that consumes
an artifact currently glues four steps together itself. Knowing which four
saves rediscovering them:
1. Getting the .wapp. The command takes a pack file path, not a module
reference, and does not resolve dependencies — so something has to fetch the
producer first. The workable pattern is a tiny Wippy project whose only job is
to pin and download it:
# build-inputs/wippy.lock — a project that exists only to fetch
modules:
- name: kickside/ui-kit
version: 1.5.0
hash: be1eafd5…
( cd build-inputs && wippy install )
wapp=$(ls build-inputs/.wippy/vendor/kickside/ui-kit-*.wapp | grep -v sha256 | sort | tail -1)
Pinning it here rather than in the application lock keeps a build-time input out of the runtime dependency graph.
2. Materializing once per consumer, into a root the consumer's package manager can see:
wippy artifacts materialize "$wapp" kickside.ui_kit:package_fs --root ./ui/.wippy
3. Wiring the consumer's package.json. Materializing writes files; it
does not edit manifests. npm links the package only if the consumer declares
both the workspace glob and the dependency:
{
"workspaces": ["./.wippy/npm/@*/*"],
"dependencies": { "@kickside/ui-kit": "*" }
}
The version is * because the materialized package carries its own. Script
this and make it idempotent — if the wiring is missing, the build fails much
later with a bare ENOENT on a stylesheet, which reads as a missing file
rather than as missing wiring.
4. Running the package manager. materialize does not invoke one, so
npm install is yours to call, after step 3.
Together, in a target that takes the consuming module as a parameter:
ui-kit:
@set -e; \
( cd build-inputs && $(WIPPY) install ); \
wapp=$$(ls build-inputs/.wippy/vendor/kickside/ui-kit-*.wapp | grep -v sha256 | sort | tail -1); \
test -n "$$wapp" || { echo "no ui-kit .wapp; is the module published?"; exit 1; }; \
$(WIPPY) artifacts materialize "$$wapp" kickside.ui_kit:package_fs --root $(DIR)/.wippy; \
cd $(DIR) && node ../../scripts/wire-ui-kit.mjs && npm install --no-audit --no-fund
Make the whole target a prerequisite of the consumer's build, so a fresh checkout cannot compile against a stale or absent package.
Out of scope
Artifacts intentionally do not introduce a second resolver, package registry, archive format, lock schema, Hub API, or module manifest. Build-only dependency semantics, redistribution policy and host ABI validation are separate concerns and are not solved here.
Related
- Dependency Management — resolving modules and local replacements
- Publishing — what a published module contains
- The Design Layer — why a shared frontend vocabulary ships as a package in the first place