@znight/compiler
v0.4.0
Published
Znight compiler: Vite plugin that analyzes your code and generates the DI container at build time
Maintainers
Readme
@znight/compiler
The compile-time half of Znight: it validates your dependency graph and generates the dependency injection container at build time.
The compiler reads your TypeScript sources with oxc, recognises the Znight
decorators, resolves every injection point to a concrete provider, validates the whole graph and
generates the dependency injection container (src/.znight/container.generated.ts) that
@znight/core executes at runtime. No reflect-metadata, no emitDecoratorMetadata, no runtime
type lookup: a misconfiguration is a build error with a precise location and a fix-it.
# bun
bun add -d @znight/compiler vite
# npm
npm install -D @znight/compiler vitevite is an optional peer dependency: install it for the Vite plugin (and znight dev /
znight build); the Bun preload, the programmatic API and znight generate without a Vite config
work without it.
Runs on Node.js >= 24 and Bun >= 1.3. In an application you use it through the
Vite plugin (vite build, the dev server, Vitest), the Bun preload
(bun test, bun --watch) or the znight CLI (znight generate, znight dev, znight build);
this README documents them and what they all share: the compiler, its options, its diagnostics and
its programmatic API.
Documentation: @znight/compiler,
the compiler guide and the
error codes.
Examples: every example application
is compiled by it; rest-api
shares its options between Vite and the Bun preload (znight.config.ts), and
monorepo uses a source
provider library.
- What the compiler does
- The generated module
- Vite plugin
- Bun preload
- How dependencies are resolved
- Decorators
- Diagnostics
- Options
- Programmatic API
- Provider libraries
- Writing a compiler extension
- Compiler contract
- Limitations
What the compiler does
scan (include/exclude + provider libraries) → parse (oxc, cached by content hash)
→ per-file facts → canonical identities → class analysis (core + compiler extensions)
→ compiled provider libraries → binding → validation → code generation → re-parse check- Scan and parse the files matched by
include/exclude. Parsing and per-file facts are cached by content hash; everything else is recomputed on each compile (it is fast: a 500-provider application recompiles in a few tens of milliseconds). - Analyse every decorated class:
@Injectable,@Factory/@Provides, qualifiers, interceptors, and the decorators of the compiler extensions of the Znight packages you use (@znight/http,@znight/messaging,@znight/config...: the packages you import, and the packages declaring the decorators you apply, also through a package re-exporting them). - Bind every constructor /
@Providesparameter to a provider, an external supplied by a plugin, orundefinedfor optional parameters (rules). - Validate: missing providers, cycles, scope mismatches, ambiguities, lifecycle hooks, version skew... (diagnostics).
- Generate the container module, then re-parse it (a syntax error there is a compiler bug, reported with the name of the extension that produced the faulty code).
The generated module
// src/main.ts
import { Application } from '@znight/core'
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
const app = await Application.create({ metadata: __ZNIGHT_METADATA__ })
await app.start()src/.znight/container.generated.ts (option outFile) is a plain, readable TypeScript module:
// AUTO-GENERATED by @znight/compiler 0.1.0. Do not edit.
// biome-ignore-all lint: generated file
/* eslint-disable */
import type { ZnightMetadata } from '@znight/core'
import { InfraFactory } from '../infra/infra.factory.js'
import { UserRepository } from '../users/repository.js'
import { UserService } from '../users/user.service.js'
export const __ZNIGHT_METADATA__: ZnightMetadata = {
version: 1,
compiler: '0.1.0',
providers: {
['src/infra/infra.factory.ts#InfraFactory.database']: {
scope: 'singleton',
deps: ['src/infra/infra.factory.ts#InfraFactory', 'src/infra/infra.factory.ts#InfraFactory.settings'],
factory: d => d[0].database(d[1]),
async: true,
dispose: 'close',
},
['src/users/user.service.ts#UserService']: {
scope: 'singleton',
class: UserService,
deps: [
'src/users/repository.ts#UserRepository',
{ multi: ['src/plugins.ts#AlphaPlugin', 'src/plugins.ts#BetaPlugin'] },
{ lazy: 'src/users/audit.service.ts#AuditService' },
'@znight/observability#Logger',
{ token: 'src/users/user.service.ts#Cache', optional: true },
'src/aop/timing.interceptor.ts#TimingInterceptor',
],
factory: d => new UserService(d[0], d[1], d[2], d[3], d[4]),
interceptors: { ['find']: { async: false, chain: [{ dep: 5, options: { ['label']: 'find' } }] } },
},
// ...
},
resolutionOrder: [
'src/infra/infra.factory.ts#InfraFactory',
/* dependencies first */ 'src/users/user.service.ts#UserService',
],
aliases: { ['src/users/repository.ts#Repository']: 'src/users/repository.ts#UserRepository' },
externals: [{ token: '@znight/observability#Logger', providedBy: 'ObservabilityPlugin' }],
extensions: {
['http']: {
/* routes generated by @znight/http */
},
},
}
export default __ZNIGHT_METADATA__Properties of the generated code:
- Deterministic: no timestamps, everything ordered by canonical identities. A full compile and any sequence of incremental compiles produce byte-identical output, on every OS and runtime.
- Type-checked: it compiles under the strictest user settings (
strict,noUnusedLocals,noUnusedParameters,noPropertyAccessFromIndexSignature,exactOptionalPropertyTypes,verbatimModuleSyntax,noUncheckedIndexedAccess). Route handlers generated by extensions keep typed parameters, so a handler signature change is atscerror. - Safe: every value goes through a single literal emitter (strings escaped, object keys always
computed
['key'],NaN/±Infinity/-0/bigints/RegExp/Datesupported). Imports never shadow each other nor a global: two classes namedUserServicebecomeUserServiceandUserService$1, a class namedDateis imported asDate$1. - Imports point to the declaring module (never through barrels), with the extension mapped by
importExtension(.ts→.jsby default), or to the public entry of a package. - Provider
sourcelocations (used in runtime errors) are emitted inserve/testand omitted inbuild(optionsourceLocations). This only applies to the module served from memory (the bundle): the disk copy is theserverendering in every mode (CompileResult.diskCode), soznight generate,vite build, the Bun preload (anyNODE_ENV) andznight generate --checkall agree on one file.
Git policy. Do not commit the generated file: add .znight to your .gitignore. It is
(re)generated before it is needed: by the Vite plugin, by the Bun preload before tests, or by
znight generate before tsc. In CI, znight generate --check fails when it is stale. The file is
only written when its content changes (watchers are not triggered for nothing), and never when the
compilation fails. The Vite plugin and the Bun preload serve the module from memory: when they
cannot write the disk copy (read-only file system of a container image), they print a warning and
go on (under Bun, the file must exist for the import to resolve: generate it when building the
image).
Vite plugin
// vite.config.ts
import { znight } from '@znight/compiler' // also the default export
import { defineConfig } from 'vite'
export default defineConfig({ plugins: [znight()] })znight(options) (options) keeps one incremental compiler per plugin instance,
shared by every Vite environment it applies to: the server environments (ssr, Vitest's Node.js
environment, custom environments with consumer: 'server'). Client environments are left alone.
| Command | Behaviour |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| vite build | compiles once (buildStart, also for several server environments); compile errors fail the build with the formatted diagnostics and nothing is written; warnings are printed |
| vite build --watch (znight dev) | before each rebuild, recompiles the files reported by the watcher (only those are re-read); diagnostics are printed and, with errors, the generated module is replaced by the error module (importing it throws a ZnightError with the diagnostics) |
| dev server, Vitest | the same, driven by the dev server watcher: events are debounced (50 ms, one compile per burst of saves) and a new result reloads the generated module |
[plugin znight]
ZnightError: ✗ ZNIGHT001: Missing provider for 'Mailer' required by Notifier
--> src/notifier.ts:9:32
|
9 | constructor(readonly mailer: Mailer) {}
| ^^^^^^
= hint: Implement Mailer with a class decorated with @Injectable()
Build failed with 1 error- Generated module: imports of
outFile(relative, with a.js/.tsextension or none) are served from memory with the latest result — also before the file exists, so Vitest works on a fresh checkout. The disk copy (fortscand editors; the same in every mode) is written only when its content changed, never with errors, and watchers ignore it: writing it never triggers a rebuild. A write failure is a warning, never a build failure. - Decorators (
stripDecorators, defaulttrue): the Znight decorators are removed from the bundle — exactly the ranges recorded by the last analysis of that file content (never re-decided; a file changed since is left untouched), with high-resolution source maps. Their@znight/*imports are then tree-shaken. Other decorators are kept. - Watching: every file the result depends on (
watchFiles: the scanned files, and every file the analysis read outsidenode_modules, such as a constant imported fromshared/or a type ofsrc/types.d.ts), the include roots (created and deleted files re-run the globs), a file created where a relative import did not resolve,package.jsonandtsconfig.json(they clear the module resolution caches). - Logs:
[znight] 12 providers compiled in 18 ms(follows Vite'slogLevel). - Compile mode:
servein the dev server (testunder Vitest),buildforvite build(provider source locations omitted), except development builds (vite build --mode development, used byznight dev), compiled asserve. plugin.api:options(the options given toznight(), read byznight generate),compiler,result(latestCompileResult),ready()(compiles the pending changes).
App defaults
Unless app: false, or build.ssr / build.lib is configured, znight() configures a Node.js
server build. Values you set yourself are never overridden.
| Setting | Default |
| ----------------- | ------------------------------------------------------ |
| build.ssr | app.entry ('src/main.ts'), built to dist/main.js |
| build.outDir | 'dist' |
| build.target | 'node24' |
| build.sourcemap | true |
| ssr.target | 'node' |
Dependencies installed in node_modules stay external (Vite's SSR default): dist/main.js
imports them at runtime. Linked packages (workspaces) are bundled; list them in ssr.external when
they ship JavaScript. The bundle runs on Node.js (node dist/main.js) and Bun.
Vitest
Vitest uses your Vite config: the plugin compiles before the tests import the generated module, serves it from memory and recompiles in watch mode (test mode: provider source locations are kept).
Bun preload
# bunfig.toml
[test]
preload = ["@znight/compiler/bun"]bun test then regenerates the container before the tests. For an application:
bun --preload @znight/compiler/bun src/main.ts (also with --watch and --hot), or a top-level
preload in bunfig.toml. The preload:
- reads the options (
znight.config.ts); - generates the container (the disk copy, the same in every mode, is written when its content
changed; a write failure is a warning), in
testmode underbun test,buildmode withNODE_ENV=production, elseserve; - prints the diagnostics on stderr and, on errors, throws a
ZnightError:bun testexits with code 1,bun --watch/bun test --watchwait for a fix; - registers a Bun plugin serving the generated module from memory (recompiled when it is loaded
again, e.g. by
bun --hot) and blanking the Znight decorators of the analysed files: they are replaced by spaces, so every line and column is unchanged and no source map is needed (stripDecorators: falsedisables it).
Bun restarts --watch processes only for the files it loaded. The preload loads every file the
compile result depends on as text (read, never executed), so saving any of them restarts the
process — also after a failed compile, when the application did not even start. A newly created
file is picked up at the next restart.
Preload options
The default export of znight.config.ts (or .mts, .js, .mjs) in the current directory, or
of the file named by the ZNIGHT_CONFIG environment variable: the options (an object,
or a function returning one), plus logLevel ('info' adds the summary line, 'warn' by default,
'silent'). A relative root is resolved against the config file directory. Share the file with
the Vite plugin:
// znight.config.ts
import type { ZnightOptions } from '@znight/compiler'
export default { reportUnused: true } satisfies ZnightOptions// vite.config.ts
import { znight } from '@znight/compiler'
import options from './znight.config.ts'
export default { plugins: [znight(options)] }Custom preload files
Importing @znight/compiler/bun runs the preload. A custom preload file uses the
side-effect-free API of the package root instead:
// preload.ts — bunfig.toml: preload = ["./preload.ts"]
import { createPreload } from '@znight/compiler'
import { myExtension } from './compiler-extension.ts'
await createPreload({ extensions: [myExtension], logLevel: 'info' })| Function | Does |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| createPreload(options) | the preload above with these options; returns the GenerateResult, throws a ZnightError on errors (Bun only) |
| znightBunPlugin(options) | the Bun plugin alone (await Bun.plugin(znightBunPlugin({ root }))): compiles in setup unless given a compiler that already has a result |
| loadZnightConfig(cwd?) | { file, options } of znight.config.* / ZNIGHT_CONFIG ({ root: cwd } without a config file) |
How dependencies are resolved
Every provider has a token: the canonical identity of its declaration
(src/users/user.service.ts#UserService, @acme/auth#JwtService,
src/db/db.factory.ts#DbFactory.createPool for a @Provides method). Messages always show display
names (UserService, Repository<User>) with file:line.
For a constructor (or @Provides method) parameter of type T:
| Rule | Result |
| ----------------------------------------------------------------------------------------------- | ----------------------------------------- |
| Candidates: providers whose provided type is T (exact) or that extend / implement T | interfaces and abstract classes just work |
| @Inject('name') keeps the candidates named name (@Named('name'), { name } options) | qualifiers |
| T[], readonly T[], Array<T>, ReadonlyArray<T> with T a class/interface | every candidate, ordered by token |
| A single exact candidate, else the single @Primary(), else the single candidate | bound |
| Several candidates providing the exact same type / different types | ZNIGHT004 / ZNIGHT008 |
| No candidate, type declared in a package manifest provides or in the externals option | external (supplied by a plugin) |
| No candidate, optional parameter (?, \| undefined, \| null, default value, @Optional()) | undefined is injected |
| No candidate otherwise | ZNIGHT001 with a near-miss analysis |
| Lazy<T> (from @znight/core) | same rules, resolved at call time |
| Primitive or anonymous types (string, { port: number }...) need @Inject('name') | matched by name and kind |
Generic types are compared with their type arguments (Repository<User> ≠ Repository<Order>),
type aliases are expanded and default type arguments filled in — except, for an unqualified
injection point, an alias supplied at runtime (RequestId of @znight/http, declared in its
manifest provides): it binds to that external. Interfaces and base classes reached through an
alias (implements UserRepository with type UserRepository = Repository<User>) count for the
transitive closure. Constructors are inherited:
class UserRepository extends BaseRepository<User> {} is injected through BaseRepository's
constructor with T = User.
@Injectable()
export class UserService {
constructor(
private readonly users: Repository<User>, // the class implementing Repository<User>
@Inject('fake') private readonly clock: Clock, // the Clock named 'fake'
private readonly plugins: Plugin[], // every Plugin provider
private readonly audit: Lazy<AuditService>, // breaks a cycle
private readonly logger: Logger, // provided at runtime by ObservabilityPlugin
private readonly cache?: Cache, // optional
) {}
}Aliases are recorded for every interface / base class / provided type bound to a single provider
(or a single @Primary), so container.get(UserRepository) works at runtime. Types supplied at
runtime (package manifest provides, the externals option, the @znight/core tokens) and
package-internal types are never aliased: class AppLogger implements CoreLogger stays an
application provider, and the core still registers its own logger under CoreLogger.
An optional injection bound to nothing gets undefined: its token is the type identity for an
unqualified, non-generic type (a plugin may register a value under it), the qualifier for
@Inject('name'), otherwise the display of the type (Repository<User>). It never resolves to a
provider that does not match the request (other type arguments, another name).
Decorators
Decorators are recognised by the canonical identity of their callee: renamed imports
(import { Injectable as Service }), barrels, export *, namespace imports (@zn.Injectable())
and packages re-exporting them (a preset re-exporting Controller from @znight/http: the compiler
extension of the package declaring the decorator is loaded) all work. A local function wrapping a
Znight decorator (const Service = () => Injectable()) or a const alias of one
(export const Service = Injectable) is invisible to the compiler and reported as a ZNIGHT005
warning; re-export it instead (export { Injectable as Service } from '@znight/core').
| Decorator (@znight/core) | Analysis |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| @Injectable({ scope?, name?, primary? }) | class provider; options must be statically evaluable |
| @Factory() | singleton provider whose @Provides methods are providers |
| @Provides({ scope?, name?, primary?, dispose? }) | the explicit return type is the provided type; Promise<T> → async (singletons only, ZNIGHT014) |
| @Inject(name) / @Optional() | constructor and @Provides parameters |
| @Named(name) / @Primary() | class or @Provides method |
| @Intercept(Interceptor, options?) | interceptor chain of a method (top-most decorator = outermost) |
Interceptor classes referenced by @Intercept (and guards referenced by extensions) need no
decorator: a class that is not a provider is registered as a singleton automatically. Packages can
declare interceptor decorators in their manifest (@Logged() → LoggedInterceptor).
Decorator arguments are static (names, scopes, paths: must be statically evaluable — literals,
const references across files and packages, simple expressions) or values (options, schemas,
guards: evaluated when possible, otherwise references to exported bindings, which the generated
module imports).
Diagnostics
Every problem is a ZNIGHTxxx diagnostic with a location, a code frame and hints:
✗ ZNIGHT001: Missing provider for 'EmailService' required by Consumer
--> src/consumer.ts:13:21
|
13 | readonly email: EmailService,
| ^^^^^^^^^^^^
|
::: src/mail/email.service.ts:4:14
|
4 | export class EmailService {}
| ------------ 'EmailService' declared here
= hint: Another 'EmailService' (a provider) is declared in src/mail/email.service.ts:4, but this injection refers to the one in src/legacy/email.service.ts:1: check the import
= hint: EmailService (src/legacy/email.service.ts:1) is not a provider: add @Injectable()
✗ ZNIGHT001: Missing provider for 'port' (number) required by Consumer
--> src/consumer.ts:21:36
|
21 | @Inject('port') readonly port: number,
| ^^^^^^
= hint: Provide it from a @Factory class: `@Provides({ name: 'port' }) port(): number`
Build failed with 2 errors| Code | Reported when | Severity |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| ZNIGHT001 | a required dependency has no provider (with hints: wrong import, missing @Injectable, excluded file, missing implements, other type arguments, qualifier) | error |
| ZNIGHT002 | circular dependency (constructor, multi, optional, interceptor and factory edges); the cycle is printed | error |
| ZNIGHT003 | a singleton depends on a request-scoped provider or external, directly or through transients, multi, interceptors or non-lazy extension edges | error |
| ZNIGHT004 | several providers of the exact same type | error |
| ZNIGHT005 | invalid decorator usage (target, arguments, unexported reference, abstract/generic/unexported provider (also export type { X } only), several provider decorators, @Provides misuse, ineffective decorators, composite decorators and const aliases) | error / warning |
| ZNIGHT008 | several candidates for an interface or base type | error |
| ZNIGHT009 | lifecycle hook that is static or has required parameters (error), never called (warning) | error / warning |
| ZNIGHT010 | injection type that cannot be analysed (missing annotation, primitive without qualifier, unresolvable import, inherited constructor of Mixin(Base)) | error |
| ZNIGHT014 | async factory on a non-singleton | error |
| ZNIGHT018 | @znight/* package of another release line (version skew), compiler extension problems (untrusted, apiVersion, failures), manifest and compiled-library problems | error / warning |
| ZNIGHT019 | provider unreachable from any root (with reportUnused) | warning |
| ZNIGHT020 | a provider module imports (transitively) the generated module: ESM cycle (also through an alias, before the file exists) | error |
| ZNIGHT022 | a scanned file has a syntax error (reported first; the file is ignored until it parses) | error |
Feature packages add their own (ZNIGHT007 routes, ZNIGHT011 consumers, ZNIGHT012 config, ZNIGHT017 unvalidated inputs...).
Version skew. All @znight/* packages are released together. @znight/core and every
@znight/* dependency of your package must belong to the compiler's release line: the same minor
in 0.x (0.1.x), the same major from 1.0. Otherwise ZNIGHT018 points at the dependency in your
package.json.
Unused providers (reportUnused, prune). Roots are: providers registered by compiler
extensions (controllers, consumers, config classes...), classes declared as roots by compiler
extensions (the hosts of scheduled jobs, also in compiled libraries), providers with an onStart /
onReady / onStop hook, and providers referenced as a value in your code outside of decorators
and types (app.container.get(UserService), overrides entries — referencing an abstract class or
interface counts for its aliased provider). Everything reachable from a root is used. prune drops
the others from the generated module.
Options
The Vite plugin, the Bun preload, the CLI and compile() take the same options:
| Option | Default | Description |
| ------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| root | Vite root / process.cwd() | project root |
| include | ['src/**/*.{ts,tsx,mts}'] | sources to analyse |
| exclude | ['**/*.{test,spec}.*', '**/*.d.ts', '**/.znight/**'] | excluded sources (node_modules and outFile are always excluded) |
| outFile | 'src/.znight/container.generated.ts' | generated module |
| tsconfig | nearest tsconfig of each file | paths resolution; a path, or false |
| conditions | [] | extra scan conditions ('@znight/source' to analyse workspace packages from source) |
| importExtension | 'js' | extension of relative imports in the generated module: 'js', 'ts' (Node type stripping), 'none' |
| sourceLocations | 'dev' | provider source locations: 'dev' (serve/test only), 'always', false |
| stripDecorators | true | remove the Znight decorators from the bundle (Vite plugin / Bun preload) |
| extensions | [] | additional compiler extensions (win over discovered ones with the same name) |
| trustedExtensions | [] | third-party packages allowed to load a compiler extension |
| externals | [] | identities provided at runtime by your own plugins ('@acme/auth#JwtService') |
| libraries | [] | packages analysed from source (provider libraries) |
| library | false | library mode: also emit dist/znight.providers.json (compiled libraries) |
| strictValidation | false | unvalidated inputs (ZNIGHT017) are errors instead of warnings |
| reportUnused | false | ZNIGHT019 warnings |
| prune | false | drop unreachable providers from the generated module |
| app | { entry: 'src/main.ts' } | Vite build defaults for a server application, or false |
compile(), generate() and createCompiler() also accept mode ('build', 'serve' or
'test', default 'serve': build omits source locations), cwd (base of a relative root),
runtimeConditions (default ['node', 'import', 'default']) and aliases (string aliases). The
Vite plugin derives them from the Vite config (mode, resolve.conditions of the server
environment, string resolve.alias entries); the Bun preload uses the Bun conditions
(['bun', 'node', 'import', 'default']) and also takes logLevel
(preload options).
Programmatic API
import { compile, createCompiler, formatDiagnostics, generate, renderErrorModule } from '@znight/compiler'
// integrations: the Vite plugin and the Bun API
import { createPreload, loadZnightConfig, znight, znightBunPlugin } from '@znight/compiler'generate(options)
Compiles and writes the generated module (diskCode, the same in every mode) and the
extra files of the compiler extensions when their content changed. Nothing is
written when there is an error. With check: true nothing is written, stale tells whether the
files on disk are missing or out of date and staleFiles lists them, the module first (then the
extra files, then znight.providers.json in library mode) (znight generate --check).
const result = await generate({ root: process.cwd() })
if (!result.ok) {
console.error(formatDiagnostics(result.diagnostics))
process.exitCode = 1
}
result.written // files written by this call (only when they changed)compile(options)
The same, without writing anything: returns
| Field | Content |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| code | the generated module, or the error module when there is an error (see renderErrorModule) |
| diskCode | the disk copy written by generate() and the integrations: the serve rendering whatever the mode (differs from code only in build mode, which omits provider source locations) |
| outFile | absolute path of the generated module |
| diagnostics | syntax errors (ZNIGHT022) first, then every other diagnostic sorted by location |
| ok | false when at least one diagnostic is an error |
| graph | the application graph (providers, externals, aliases, edges, extension data) |
| stats | { files, providers, durationMs, parsed } |
| library | library mode: { file, content, data } of znight.providers.json |
| extraFiles | the files emitted by compiler extensions ({ file, content, extension }, sorted by path; empty on errors), written with the module (extra outputs) |
| watchFiles | files the result depends on (scanned sources, every other file read outside node_modules, package manifests, compiled libraries); never the outFile nor the extra files |
| decorators | Znight decorators of each analysed file (path → { hash, ranges, memberStarts }), removed by stripDecorators when the content hash still matches (the first decorator of a class member leaves a ;, so that a field without semicolon cannot absorb the member) |
createCompiler(options)
An incremental compiler, used by the Vite plugin, the Bun preload and znight dev: it keeps the
parse cache and the loaded compiler extensions between compiles.
const compiler = createCompiler({ root, mode: 'serve' })
await compiler.compile() // full
await compiler.compile({ changed: [file] }) // only `file` is re-read (re-parsed if its content changed)
await compiler.compile({ added: [file] }) // created / deleted files re-run the globs
await compiler.generate({ removed: [file] }) // compile + write when changed
compiler.isOutFile(path) // watchers must ignore the generated module
compiler.isGeneratedFile(path) // ... and the extra files of the latest result
compiler.last // latest resultCompiles are serialized: calls made while a compile runs are coalesced into the next one (their changes merged), and every caller receives the result of the compile that included its changes.
Watching: includeRoots(compiler), isRelevantChange(compiler, kind, file, watched, roots)
The rules the Vite plugin, the Bun preload and znight generate --watch share, for a custom
watcher of an incremental compiler:
import { createCompiler, includeRoots, isRelevantChange } from '@znight/compiler'
const compiler = createCompiler({ root })
const result = await compiler.generate()
const roots = includeRoots(compiler) // watch these recursively, plus the directories of result.watchFiles
const watched = new Set(result.watchFiles)
// on a file event (kind: 'changed' | 'added' | 'removed')
if (isRelevantChange(compiler, kind, file, watched, roots)) await compiler.generate({ [kind]: [file] })includeRoots(): the static bases of theincludeglobs in the project and in every source library (known once compiled): absolute, sorted, nested directories dropped.isRelevantChange(): a file the last result depends on, a resolution config file (package.json,tsconfig*.json, lockfiles), a source created or deleted under an include root, a file created where a relative import did not resolve. Never the generated module, nornode_modulesoutsidewatched. For watchers reporting a moved directory as one event (fs.watch): a directory created under an include root, a removed path containing watched files.
formatDiagnostics(diagnostics, { color?, cwd?, readFile?, summary? })
Renders diagnostics as above: code frames with carets under the exact range, related locations,
= hint: lines and a summary line (Build failed with 2 errors and 1 warning). Colors are enabled
when the terminal supports them, never when NO_COLOR is set; paths are relative to cwd.
renderErrorModule(diagnostics, { cwd? })
The module that replaces the generated one in watch/serve modes when the compilation fails: it still
exports a typed __ZNIGHT_METADATA__ (importers keep linking and type-checking), but importing it
throws a ZnightError whose message contains the formatted diagnostics. compile() returns it as
code when there is an error.
Analysis API
The lower-level phases are exported for tooling: Project (scan, parse cache, resolution),
normalizeOptions, analyze() / analyzeProject() (→ ApplicationGraph), loadExtensions().
const project = new Project(normalizeOptions({ root }))
project.refresh()
const { graph, diagnostics } = await analyze(project)Provider libraries
A package can ship providers (classes decorated with @Injectable, @Factory...) used by
applications.
Source libraries
Packages listed in the libraries option, or whose manifest says "znight": { "library": "source" },
are analysed from their TypeScript sources (when their public entries resolve to .ts files with
the scan conditions, e.g. @znight/source in a monorepo). Their providers get package identities
(@acme/auth#JwtService) and are imported through the package entry.
Compiled libraries
A library published as JavaScript runs the compiler in library mode:
await generate({ root: libraryRoot, library: true })Besides its own generated module (handy for the library's tests), this writes
dist/znight.providers.json (schemaVersion: 1): every provider with its package identity and
import route ({ specifier: '@acme/auth', exportName: 'JwtService' }), scope, qualifier, primary,
async/dispose, heritage closure, lifecycle hooks, injection points (types + qualifier + optional),
interceptor bindings with their options, the data stored by compiler extensions (references to
values become package exports) and, when there are some, the roots declared by extensions
(addRoot: a job host of the library survives prune in the application). Reference it from the
library's package.json:
"znight": { "apiVersion": 1, "providers": "./dist/znight.providers.json" }Applications depending on the library merge the fragment before binding: its providers are bound like yours, and its dependencies are validated against your providers (a library can depend on an interface your application implements — in library mode such missing providers are warnings). Requirements:
- every provider (and every value its decorators reference) must be exported by a public entry of
the package; the library's own public entries must resolve to its sources with the scan
conditions when it is compiled (add a source condition to its
exportsand toconditions); - the package must ship its declarations (
.d.ts/.d.mts): the application finds the provider classes there; - providers with an extension-generated factory (e.g.
@Configclasses) cannot be compiled: publish such libraries as source libraries.
Writing a compiler extension
A feature package exposes @acme/feature/compiler, which only import types from
@znight/compiler (building or running the package never loads the compiler):
import type { CompilerExtension } from '@znight/compiler'
interface Route {
token: string
path: string
handler: string
}
export default {
apiVersion: 1,
name: 'acme-http',
decorators: {
'@acme/http#Controller': { targets: ['class'], params: ['static'] },
'@acme/http#Get': { targets: ['method'], params: ['static'] },
'@acme/http#UseGuard': { targets: ['class', 'method'], params: [], rest: 'value' },
},
analyzeClass(cls, ctx) {
const controller = cls.decorators.find(d => d.identity === '@acme/http#Controller')
if (!controller) return
const token = ctx.registerProvider(cls) // ZNIGHT005 if another decorator registered it
for (const guard of cls.decorators.filter(d => d.identity === '@acme/http#UseGuard').flatMap(d => d.args)) {
ctx.resolveClassRef(guard, { from: cls, role: 'guard' }) // auto-registers helper classes
}
for (const method of cls.methods) {
const get = method.decorators.find(d => d.identity === '@acme/http#Get')
if (get) ctx.store({ token, path: String(get.args[0]?.value ?? '/'), handler: method.name })
}
},
validate(ctx) {
// ctx.graph, ctx.stored, ctx.provider(token), ctx.binding(handle), ctx.report(...)
},
generate(ctx) {
return ctx.obj({ routes: ctx.arr(ctx.stored.map(route => ctx.obj({ path: route.path, handler: route.handler }))) })
},
} satisfies CompilerExtension<Route>| API | Purpose |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| decorators: Record<identity, { targets, params, rest? }> | owned decorators; targets and arguments are validated, arguments evaluated (static / value) |
| analyzeClass(cls, ctx) | called for every class carrying one of the extension's decorators, in identity order |
| ClassInfo | identity, import route, decorators (ArgInfo { value, ref, location, text }, typeArgs), constructor (inherited, substituted), methods, properties, heritage closure, lifecycle, JSDoc (member details) |
| ctx.registerProvider(cls, { scope, name, primary, factory }) | registers a provider; factory = custom factory with injected deps and a generate callback |
| ctx.resolveClassRef(arg, { from, role, lazy }) | binds a class reference to a token (auto-registers helper classes) |
| ctx.bindInjection(request, { from, lazy }) | binds a type with the resolution rules; read the result with binding(handle) |
| ctx.addDependency(from, to, { reason, location, lazy }) | runtime dependency resolved by the plugin (ZNIGHT003, ZNIGHT019; with lazy, ZNIGHT019 only: lazy edges) |
| ctx.addRoot(cls, { reason?, location? }) | cls is an entry point of the plugin (a job host): a root of ZNIGHT019 and prune, auto-registered as a singleton when nothing registers it (roots) |
| ctx.definitionsOf(callee) | top-level consts initialised with a call to callee, applied or not (definitions); also in validate |
| ctx.inputFiles(patterns) (in validate) | non-source files the extension reads (a .graphql schema), matching globs relative to the root, part of watchFiles (input files) |
| ctx.types | format, serialize, equals, stripNullish, awaited on TypeInfo, and shape(target, options) (type shapes); in every context |
| ctx.report(diagnostic), ctx.store(data) | diagnostics; data for validate / generate |
| validate(ctx) | after binding: the full ApplicationGraph |
| generate(ctx) | returns a GenNode: the extension's metadata.extensions[name] |
Code is generated with nodes, never strings, so an extension cannot break the module syntax:
| GenerateContext | Generates |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| lit(value) | any literal: primitives, NaN, -0, bigints, RegExp, Date, nested objects/arrays (keys computed) |
| obj(entries) / arr(items) | object (computed keys ['key']) / array of nodes |
| importValue(target) / importType(target) | import of a ClassInfo, ClassRef, ArgInfo/ValueRef (decorator argument) or identity |
| importFrom('@pkg', 'name', { type? }) | import of a package helper (coerce from @znight/http) |
| member(node, 'a', 'b'), call(fn, args), construct | node.a.b, fn(args), new C(args) |
| arrow([['c', type], 'ctx'], (c, ctx) => body) | (c: Type, ctx) => body (typed parameters keep handler signatures type-checked) |
| block(['a'], a => [code`const ${a} = ${v}`]) | an arrow body { ... }, one statement per line, whose locals no import or constant ever takes |
| code`${a}.params[${'id'}]` | an expression template: every interpolation is a node, strings become escaped literals |
| constant('Name$fields', value, { type }) | a module-level const Name$fields: Type = value, referenced from factories and the section |
| tokenOf(cls), binding(handle), provider(token) | provider token (aliases resolved), binding result, provider node |
| emitFile('schema.graphql', content) | not code: an extra file written next to the generated module (extra outputs) |
An extension that throws, returns a forged object instead of a node, or produces invalid syntax
through code is reported as ZNIGHT018 naming it; an import that cannot be routed (not exported,
type used as a value, package not installed) is ZNIGHT005 naming it.
All the additions below belong to extension apiVersion: 1 (lockstep releases guarantee a matching
compiler, ZNIGHT018 otherwise).
Member details
- JSDoc:
ClassInfo.doc,MethodInfo.doc,PropertyInfo.docandParameterInfo.doc(parameter properties) are the nearest/** ... */comment before the declaration (only whitespace, other comments, decorators andexport/default/declare/constin between), orundefined:DocComment { text, summary, description, tags, location }.textis everything before the first block tag;summaryits first paragraph (a wrapped sentence is joined into one line);descriptionthe rest;tagsthe block tags in order,{ name, text, location }(@param search Text→{ name: 'param', text: 'search Text' }; continuation lines keep their line breaks and indentation, so@examplecode survives, also with@lines inside a fenced block). - Decorator type arguments:
DecoratorInfo.typeArgsresolves the type arguments of the call (@ApiResponse<Book[]>(200)→[Book[]]). They are erased at runtime, but the compiler reads them, and{ decorator, typeArgument }is a shape target. - Parameter default values:
ParameterInfo.initializeris the default value (limit = 20) evaluated as avalueargument (evaluated+value,reffor an exported binding,errorfor= new Date(); nothing is reported),undefinedwithout default.
validate(ctx) {
const method = ctx.classInfo(token)?.methods.find(m => m.name === 'list')
method?.doc?.summary // 'List books'
method?.doc?.tags.filter(tag => tag.name === 'param') // [{ name: 'param', text: 'search Case-insensitive search.' }]
method?.params[1]?.initializer?.value // 20
}Type shapes
ctx.types.shape(target, options?) gives the structure of a type annotation, what a value of that
type looks like once serialised as JSON, for schema generators (@znight/openapi). It works on the
declarations, from sources and from .d.ts files alike (the same library gives identical shapes),
so it keeps what TypeInfo loses: object literal types, and the names of aliases.
| Target | Annotation |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| { returnOf: MethodInfo } | the return type, a top-level Promise<T> unwrapped |
| { parameter: ParameterInfo } | a method or constructor parameter (inherited constructors substituted) |
| { property: PropertyInfo } | a class field |
| { decorator: DecoratorInfo, typeArgument } | a type argument of a decorator call |
| { type: TypeInfo } | an already resolved type (aliases expanded, enum members such as Kind.Cat literal, object literal types opaque) |
ShapeResult is { root, named, issues }. A Shape is one of:
| TypeScript | Shape |
| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| string, number, boolean, null; undefined / void; unknown / any; never | { kind: 'string' }... 'undefined', 'unknown', 'never' |
| literal types, template literals without substitution | { kind: 'literal', value } (other template literals: string) |
| Date, URL | 'date', 'url' (their toJSON()) |
| typed arrays, ArrayBuffer, DataView, Buffer, Blob, File, ReadableStream, Readable | 'binary' |
| bigint | 'bigint', and an issue (JSON.stringify throws) |
| T[], readonly T[], Array<T>, ReadonlyArray<T>; tuples [A, B?, ...C[]] | array { items }; tuple { items, rest?, minItems } |
| object literal types, interfaces (extends flattened, merged declarations), index signatures, Record<K, V> | object { properties, additional? }; a property is { name, shape, optional, readonly, doc, location } (plus kind and declaredBy with members: 'instance') |
| classes | the instance fields and parameter properties, inherited ones first (no methods, accessors, static, #private or private members); a toJSON(): X method gives X (members: 'json', the default; 'instance': fields and getters) |
| unions, intersections | normalised like TypeInfo; objects intersected are merged, string & { __brand } is string |
| Partial, Required, Readonly, Pick, Omit, NonNullable, Exclude, Extract, Awaited, keyof, T[K], mapped types over literal keys | applied structurally (homomorphic mapped types keep the property order and modifiers) |
| interfaces, classes, type aliases, enums | { kind: 'named', key }: the structure is in named.get(key) (NamedShape), recursion goes through it |
| a schema alias (z.output<typeof S>), or an alias of one | { kind: 'schema', ref, io } (with options.schemaAliases) |
| a type in options.keep | { kind: 'reference', identity, name, typeArgs, explicitTypeArgs }, not expanded |
| functions, symbols, unbound type parameters, conditional types, Map, Set, RegExp, Error, nested Promise... | { kind: 'opaque', text, reason }, and an issue |
- Named shapes.
NamedShape { key, identity, name, declaration, typeArgs, shape, doc, location, enumMembers?, literals? }. The key is the identity followed by the serialized type arguments, default ones filled in (src/page.ts#Page<src/book.ts#Book>).result.namedholds every named shape the root references, transitively (structures and type arguments), sorted by key; a declaration only used structurally (Partial<Book>,extends) is not listed, its properties are copied. Enums are the union of their member values, in declaration order, and an enum member type (Kind.Cat) is the literal of its value. Key types (Record<K, V>, mapped types,Pick/Omitkeys,T[K]) andExclude/Extract/NonNullablego through the members of enums and of aliases of unions:Record<Genre, number>has a required property per member. An alias whose structure is a schema or a kept reference is transparent (type CreateBook = z.output<typeof CreateBook>is the schema itself). - Enum members and literal order.
enumMembers(enums only) lists{ name, value, doc, location }per member in declaration order, without the reverse mapping of numeric members (Low = 0gives one member).literals(aliases of a union of literal types only) gives the literals in source order (type Sort = 'title' | 'newest' | 'author'→['title', 'newest', 'author']), whileshapeis the sorted union; it is not set when a member is not written as a literal type ('a' | null,Other | 'b') or when normalisation changes the union (true | false). - Issues.
{ reason, path, named, text, location }for everyopaquepart and everybigintthe shapes of the result contain (atypeof Sconsumed by a schema alias is not one), wherepathis readable (ShelfStats.total,items[],[0],[string]) andnamedis the key of the named shape whose declaration contains it. The extension decides what they mean (a warning, an error). - Options.
schemaAliases(identity →'input' | 'output':{ 'zod#output': 'output', 'zod#infer': 'output', 'zod#input': 'input', 'valibot#InferOutput': 'output', 'valibot#InferInput': 'input' }— the identities of the installed libraries),schemaMembers({ infer: 'output', inferIn: 'input' }for ArkType'stypeof S.infer),keep(identities kept as references, such as
