@ignifx/vite-plugin
v0.12.0
Published
ignifx Vite plugin: asset manifest generation, scene/prefab JSON validation, WASM/asset handling, HMR hooks.
Readme
@ignifx/vite-plugin
The build-time half of the ignifx asset pipeline: it generates the asset manifest, validates the
JSON file formats and the WGSL shaders, injects the project settings (ignifx.settings.json and
ignifx.config.ts), copies the static files
extensions need, and carries asset changes to the running game over HMR.
The package depends on nothing from the engine (docs/architecture/00-overview.md §2). It produces
the files @ignifx/core consumes at runtime.
Status: pre-1.0. Breaking changes ship in minor releases (
CONSTITUTION.md§4.2).
Install
pnpm add -D @ignifx/vite-pluginvite is a peer dependency (8.x).
Usage
// vite.config.ts
import { defineConfig } from "vite";
import { ignifx } from "@ignifx/vite-plugin";
export default defineConfig({
plugins: [ignifx()],
});There is no default export: the plugin is the named factory ignifx (coding standards §4).
What it does
| Concern | Development | Build |
| -------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------- |
| Manifest | served from /assets.manifest.json and virtual:ignifx/manifest | written to <outDir>/assets.manifest.json, URLs content-hashed |
| Assets | served by Vite from the asset root | copied to <outDir>/assets/ with immutable-cacheable names |
| Project config | import.meta.env.IGNIFX_CONFIG | same |
| Validation | reported in the terminal and the browser error overlay | fails the build |
| Shaders | .wgsl files are shader assets, validated with the JSON assets | same |
| Extension WASM/fonts | served from /assets/<name> | copied unhashed to <outDir>/assets/<name> |
| Changes | ignifx:asset-changed over HMR; a settings edit restarts the server | — |
| .ignifx/ | never served: 404 in vite dev and vite preview | never copied |
.ignifx/ is editor-private
The ignifx editor keeps per-project private state in .ignifx/ at the project root, among it
.ignifx/mcp.json with the token of the project's MCP server. The plugin adds .ignifx and
**/.ignifx/** to server.fs.deny (merged with yours; Vite's defaults are kept), and vite dev
and vite preview answer 404 to any request whose path has a .ignifx segment, however it is
encoded or cased, /@fs/ and ?raw/?url/?import forms included. They also follow symbolic
links: a request whose file lies in .ignifx/ once links are resolved (src/link -> ../.ignifx)
gets 404 as well, and a module whose real path is there is never loaded or bundled (IGX-0556).
The asset scan skips dot-prefixed directories and, with an IGX-0556 warning, any link into
.ignifx/. vite build fails with IGX-0556, after removing the copies, if its output ends up
with a .ignifx directory at any depth or a publicDir file that links into one. Never move
.ignifx/ under public/ or point a server at it, and do not import from it.
Options
ignifx({
assetRoot: "assets", // scanned recursively, relative to the Vite root
publicPath: "assets/", // where hashed assets land inside build.outDir
manifestFileName: "assets.manifest.json",
config: undefined, // path to ignifx.config.ts; `false` disables loading
settingsFile: undefined, // path to ignifx.settings.json; `false` disables loading
validate: true, // header + JSON Schema validation; a failure fails the build
schemas: undefined, // { scene?, formats?, settings? } — supplied by the game from @ignifx/core
hashLength: 8, // hex characters kept from each asset's sha256
scripts: "src/scripts/**/*.ts", // glob behind virtual:ignifx/scripts
});The manifest
assets.manifest.json carries the format/formatVersion header every ignifx file format shares
(docs/architecture/06-serialization-and-scene-format.md §6) and one entry per asset, sorted by
address so that two builds of the same tree produce the same bytes:
{
"format": "ignifx.manifest",
"formatVersion": 1,
"root": "assets",
"entries": [
{
"address": "sprites/hero.png",
"url": "/assets/sprites/hero.b7c23e74.png",
"bytes": 10,
"hash": "b7c23e74",
"type": "texture",
"groups": ["boot"],
"meta": { "texture": { "srgb": true } }
}
]
}- address — the path relative to the asset root,
/-separated. This is what game code loads:app.assets.load("sprites/hero.png"). - url — root-relative in development, content-hashed in a build. The hash goes before the
extension, and a two-segment JSON extension stays intact:
level1.scene.jsonbecomeslevel1.<hash>.scene.json, so the loader still recognises the format. - type — derived from the extension (
texture,model,scene,material,shader,environment,font,audio,json,text,binary); an unknown extension isbinary. - groups and meta — from the
.meta.jsonsidecar;metais omitted when there is none.
Sidecars
A file named <asset>.meta.json next to an asset carries its group membership and per-loader import
options. groups becomes the entry's groups; everything else is passed through as meta, except
the keys below.
{ "groups": ["boot", "level1"], "texture": { "srgb": true, "mipMaps": true } }| Key | Meaning |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| groups | An array of strings; the entry's groups. |
| publish | false leaves the asset out: no manifest entry in dev or in a build, no copy in dist/, no validation. true or no key publishes it. Anything else is IGX-0551. It never reaches meta. |
| guid, importer, source, outputs, options | Owned by the ignifx editor (EDITOR_SIDECAR_KEYS). They describe how the asset was imported, so they never reach meta; scanAssetRoot still reports them. |
| anything else | Passed through to meta unchanged, at every depth, for the loader that reads it (texture, audio, …). |
An unpublished asset is typically an editor source whose imported output ships instead, such as a
4K .png beside the .ktx2 made from it. In vite dev the file is still on disk, so a fetch by
path still works; only the manifest leaves it out.
Sidecars are never listed as assets of their own. Dot-prefixed files and directories are skipped.
Virtual modules
import { manifest } from "virtual:ignifx/manifest";
import { scripts } from "virtual:ignifx/scripts";virtual:ignifx/manifestexportsmanifest, the same object the generated JSON holds, so a game can read it without a fetch. It is invalidated whenever an asset changes in development.virtual:ignifx/scriptsexportsscripts, every class exported by thescriptsglob that declares astatic typeId. The list is sorted by module path, so registration order is the same on every machine, and the modules are imported throughimport.meta.glob(..., { eager: true }), which puts each script file in Vite's HMR graph. The hot-reload policies (patch vs. recreate) are@ignifx/devtools' (docs/architecture/15-devtools-and-diagnostics.md§5); this package ships the channel.
Add the declarations to your project so TypeScript knows the modules:
// src/vite-env.d.ts
declare module "virtual:ignifx/manifest" {
import type { AssetManifest } from "@ignifx/vite-plugin";
export const manifest: AssetManifest;
}
declare module "virtual:ignifx/scripts" {
export const scripts: readonly (abstract new (...args: never[]) => unknown)[];
}Project config
Project settings come in two forms at the Vite root, and either, both, or neither may exist:
- Data:
ignifx.settings.json, a JSON object with one property per settings section. This is the form a tool such as the ignifx editor writes. - Code:
ignifx.config.ts(or.mts,.js,.mjs), loaded with Vite's own config loader. It must export a plain, JSON-serializable object by default.
The plugin merges them and injects the result as import.meta.env.IGNIFX_CONFIG
(docs/architecture/04-extensions.md §5). Precedence: the config file wins, a whole section at a
time. A section only one form defines comes from that form; a section both define comes entirely
from ignifx.config.ts, and the plugin warns that the JSON section has no effect (unless the two
are equal, as when the config file spreads the JSON).
// ignifx.config.ts
import { defineConfig } from "@ignifx/vite-plugin";
export default defineConfig({
layers: ["Default", "Ground", "Player"],
time: { fixedDeltaTime: 1 / 60 },
assets: { root: "./assets", preload: ["boot"] },
});defineConfig is exported by @ignifx/vite-plugin only; the ignifx umbrella has no
ignifx/config entry point. It takes any JSON
object, so a config file can keep the data in ignifx.settings.json and add to it in code:
// ignifx.config.ts
import { defineConfig } from "@ignifx/vite-plugin";
import settings from "./ignifx.settings.json";
export default defineConfig({ ...settings, time: { fixedDeltaTime: 1 / 120 } });Pass schemas: { settings }, the JSON Schema @ignifx/core's settingsJsonSchema(app) generates
from every registered section, to validate the merged settings when the dev server starts and when
the build runs: a misspelled key inside a section fails with IGX-0554 and the key's JSON pointer
(/time/fixedDeltaTim). A section the schema does not list only warns, because the schema is a
snapshot of one app's extensions and createApp reports an unregistered section itself. Without it, createApp still validates every section when the game starts
(IGX-0407 for an unknown section, IGX-0408 for a bad value or key).
Validation
Every JSON file under the asset root whose name has a two-segment JSON extension — .scene.json,
.prefab.json, .material.json, .atlas.json, .input.json, and the rest of the table in
docs/architecture/06-serialization-and-scene-format.md §6 — must carry a format string and a
positive integer formatVersion. Plain .json files are game data and are checked only if they
declare a header of their own.
When schemas is supplied, documents are also validated against the matching JSON Schema. The
validator implements the subset the generated schemas use — type, required, properties,
additionalProperties, items, enum, const, minimum, maximum, minItems, maxItems,
oneOf, anyOf, and $ref to #/$defs/* — and reports anything outside it as IGX-0653 rather
than ignoring it. Annotations (title, description, default, readOnly, …) and x- extension
keywords, such as the x-ignifx-step and x-ignifx-group core writes for the inspector, are
skipped. There is no runtime dependency (coding standards §13).
Problems are reported with a JSON pointer:
levels/level1.scene.json /entities/0: missing required property "uid" (IGX-0652)Shaders
Every .wgsl file under the asset root is a shader asset — *.surface.wgsl and *.post.wgsl
included, because the // @ignifx pragma inside the file, not its name, declares which of the three
forms it is. The hash goes before the single .wgsl extension in a build
(snow.surface.wgsl → snow.surface.<hash>.wgsl), and the manifest address stays unhashed, which is
what game code loads.
Each one is parsed with wgsl_reflect (MIT, zero
dependencies) at buildStart and on every dev-server change, and checked against the ignifx WGSL
contract. Six rules, each reported with the file and the line:
| Rule | Reports | Code |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| Syntax | the file does not parse, at the parser's line | IGX-0654 |
| Form | no // @ignifx shader\|surface\|post line, or two of them | IGX-0655 |
| Declared names | a shaderUniforms.x/shaderSystem.x the file never declared — engine-supplied names included, because the struct is generated from the declaration — or a sampled texture with no // @ignifx texture line | IGX-0655 |
| Entry points | shader without @vertex fn mainVertex/@fragment fn mainFragment, post without mainFragment, surface with none of displace/surface/composite | IGX-0655 |
| Hand-declared bindings | a var<uniform>, var<storage>, texture or sampler in the source — ignifx and Lite generate the whole bind-group layout from the declarations | IGX-0655 |
| Vertex-stage sampling | textureSample anywhere the @vertex entry reaches, and any texture read the surface form's displace hook reaches | IGX-0655 |
A shader file has to list every engine uniform it reads on an // @ignifx system line, because
@ignifx/core generates the shaderUniforms and shaderSystem blocks from the declaration alone:
reading shaderSystem.cameraPosition without declaring it leaves the member out of the generated
struct. post files are exempt — their bindings come from the post-process stack, not from a shader
material.
shaders/dissolve.wgsl: line 24: calls textureSample in "mainVertex", which the vertex stage reaches;
WGSL allows textureSample in the fragment stage only — use textureSampleLevel or textureLoad (IGX-0655)A failure fails a production build and shows in the dev server's terminal and browser error overlay,
exactly as a JSON schema failure does; validate: false turns both off. Full type checking stays
with the browser's WGSL compiler, which reports through app.onError. What these rules add is a
file and a line: the browser compiles the source Lite generated — your file with a scene UBO, two
uniform blocks, a sampler pair per texture and a VertexInput struct in front of it — so its
diagnostics point at line numbers that exist in nothing you wrote.
Editing a .wgsl file announces ignifx:asset-changed like any other asset, and @ignifx/core's
shader assets rebuild their material in place.
HMR
import.meta.hot?.on("ignifx:asset-changed", ({ address, kind, url }) => {
// kind: "added" | "changed" | "removed"
});@ignifx/core's assets service subscribes to this and fires AssetHandle.onReplaced
(docs/architecture/05-assets-and-loading.md §7). Editing ignifx.config.ts or
ignifx.settings.json restarts the dev server instead: the settings are injected as a define,
which only a restart recomputes, and they are frozen at app construction anyway. The plugin loads
and validates the edited settings first: if they fail (malformed JSON, a config file that throws, a
schemas.settings violation), the server is not restarted, the game keeps its previous settings,
and the IGX-0554 error goes to the terminal and the browser's error overlay.
plugin.api.whenIdle() resolves once the plugin has finished reacting to every file-system event it
has seen — useful in tests and in other plugins that read the served manifest.
Extension public assets
A package in the @ignifx scope, or any package carrying the ignifx-extension keyword, may
declare files that must be served verbatim:
{ "ignifx": { "assets": { "public": ["./wasm/HavokPhysics.wasm"] } } }They are copied unhashed into the public path, because WASM loaders locate their binary by name.
Two extensions publishing the same file name is an error (IGX-0553).
Each one is also listed in the manifest, at its bare file name, with the served URL — base
included. That is what makes app.assets.resolveUrl("HavokPhysics.wasm") answer correctly on a page
served from a sub-path: without an entry, resolveUrl falls back to a page-relative
<assetRoot>/<name>, which points somewhere no file was written as soon as the document does not sit
directly above the asset root, and @ignifx/physics then reports IGX-0903. A project asset whose
address equals a published file name is an error (IGX-0552), because resolveUrl could not answer
for both.
If the bundler emits its own copy of the same bytes — @babylonjs/havok's ESM build carries a
new URL("HavokPhysics.wasm", import.meta.url) that Rollup resolves, which would ship the binary
twice — the plugin repoints that reference at its own unhashed copy and drops the bundler's, so
exactly one file ships and the URLs that already worked keep working.
Discovery walks node_modules from the Vite root upwards and reads only the packages installed
directly in each one. Under pnpm that is the root's own dependencies/devDependencies, so an
extension reached transitively — through the ignifx umbrella, for instance — publishes nothing. A
project that registers physics() through the umbrella must also list @ignifx/physics in its own
package.json for HavokPhysics.wasm to be copied and listed.
Babylon Lite's decoders
@ignifx/core publishes Babylon Lite's run-time decoders this way: the KTX2 decoder
(babylon.ktx2Decoder.js), its transcoders (msc_basis_transcoder.js and .wasm, six
uastc_*.wasm, zstddec.wasm) and the meshopt decoder (meshopt_decoder.js), 740 KB in all. Lite
fetches them from cdn.babylonjs.com unless told otherwise, and a packaged game's script-src 'self'
refuses that origin. With core installed directly in the project, the plugin serves the eleven files
from /assets/<name> in vite dev, copies them unhashed into <outDir>/assets/, and lists each in
the manifest; core's texture and model loaders read those entries and point Lite at them before the
first .ktx2 texture or glTF load. Every decoder request then stays on the game's origin, which
script-src 'self' 'wasm-unsafe-eval' and connect-src 'self' (@ignifx/electron's cspFor())
allow; core's copy of the Basis transcoder is patched to compile nothing from strings, so the policy needs no 'unsafe-eval'. The files are fetched only when a game loads a .ktx2 or a meshopt-compressed glTF.
A .basis texture is the exception: Lite 1.34 always fetches its transcoder from the CDN. Use
.ktx2. Draco-compressed glTF is not covered yet (engine ask E9).
Error codes
The plugin runs before an App exists, so it throws its own VitePluginError with a code from the
ranges docs/architecture/15-devtools-and-diagnostics.md §1 reserves. The codes are allocated from
the top of each range so that the runtime codes @ignifx/core allocates from the bottom cannot
collide with them.
| Code | Meaning |
| ---------- | ------------------------------------------------------------------------------------------------------------------------- |
| IGX-0550 | The asset root does not exist or is not a directory (a warning in the plugin, which then builds an empty manifest) |
| IGX-0551 | A .meta.json sidecar is malformed |
| IGX-0552 | Two files build to the same output name |
| IGX-0553 | An extension's ignifx.assets.public entry is missing or ambiguous |
| IGX-0554 | ignifx.config.ts or ignifx.settings.json could not be loaded, or the settings fail schemas.settings |
| IGX-0555 | A plugin option is outside its domain |
| IGX-0556 | A file from the editor-private .ignifx/ reached the build output or a module load, or an asset link into it was skipped |
| IGX-0650 | A JSON asset is not parseable |
| IGX-0651 | A format-headed file has no usable format/formatVersion header |
| IGX-0652 | A document failed its JSON Schema |
| IGX-0653 | A supplied schema uses a keyword this validator does not implement |
| IGX-0654 | A .wgsl shader asset does not parse as WGSL |
| IGX-0655 | A .wgsl shader asset breaks the ignifx WGSL contract (see Shaders) |
License
Apache-2.0 © Astrum Forge Studios
