@znight/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +482 -2
- package/bin/znight.mjs +17 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +13 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/dev/orphan-guard.d.mts +1 -0
- package/dist/dev/orphan-guard.mjs +47 -0
- package/dist/dev/orphan-guard.mjs.map +1 -0
- package/dist/index.d.mts +679 -0
- package/dist/index.mjs +1 -0
- package/dist/main-BWsiUsxN.mjs +3865 -0
- package/dist/main-BWsiUsxN.mjs.map +1 -0
- package/dist/orphan-Dn-2xMBd.mjs +45 -0
- package/dist/orphan-Dn-2xMBd.mjs.map +1 -0
- package/package.json +71 -4
- package/src/cli.ts +11 -0
- package/src/command.ts +217 -0
- package/src/commands/build.ts +159 -0
- package/src/commands/dev.ts +249 -0
- package/src/commands/generate.ts +304 -0
- package/src/commands/new.ts +245 -0
- package/src/commands/openapi.ts +160 -0
- package/src/commands/schematic.ts +110 -0
- package/src/commands/shared.ts +106 -0
- package/src/commands/start.ts +79 -0
- package/src/context.ts +126 -0
- package/src/dev/orphan-guard.ts +49 -0
- package/src/dev/orphan.ts +74 -0
- package/src/dev/supervisor.ts +175 -0
- package/src/errors.ts +64 -0
- package/src/index.ts +73 -0
- package/src/integrations/compiler.ts +360 -0
- package/src/integrations/openapi.ts +97 -0
- package/src/integrations/process.ts +180 -0
- package/src/integrations/prompts.ts +108 -0
- package/src/integrations/resolve.ts +38 -0
- package/src/integrations/vite.ts +362 -0
- package/src/integrations/watch.ts +243 -0
- package/src/main.ts +195 -0
- package/src/names.ts +106 -0
- package/src/output.ts +110 -0
- package/src/runtime.ts +86 -0
- package/src/scaffold/create.ts +65 -0
- package/src/scaffold/options.ts +103 -0
- package/src/scaffold/templates.ts +771 -0
- package/src/scaffold/versions.ts +25 -0
- package/src/schematics/index.ts +422 -0
- package/src/version.ts +20 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files of a project created by `znight new`. Pure: {@link renderProject} returns the files for a set
|
|
3
|
+
* of options, `create.ts` writes them. The sample application uses the public APIs of the
|
|
4
|
+
* `@znight/*` packages exactly as documented in their READMEs.
|
|
5
|
+
*/
|
|
6
|
+
import type { Feature, HttpAdapterName, PackageManager, ProjectOptions } from './options.ts'
|
|
7
|
+
|
|
8
|
+
import { execBinary, runScript } from './options.ts'
|
|
9
|
+
import { versionOf } from './versions.ts'
|
|
10
|
+
|
|
11
|
+
/** A file of the new project. */
|
|
12
|
+
export interface ProjectFile {
|
|
13
|
+
/** POSIX path relative to the project directory. */
|
|
14
|
+
readonly path: string
|
|
15
|
+
readonly content: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Flags {
|
|
19
|
+
readonly config: boolean
|
|
20
|
+
readonly observability: boolean
|
|
21
|
+
readonly messaging: boolean
|
|
22
|
+
readonly schedule: boolean
|
|
23
|
+
readonly openapi: boolean
|
|
24
|
+
readonly adapter: HttpAdapterName
|
|
25
|
+
readonly pm: PackageManager
|
|
26
|
+
readonly bun: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function flagsOf(options: ProjectOptions): Flags {
|
|
30
|
+
const has = (feature: Feature): boolean => options.features.includes(feature)
|
|
31
|
+
return {
|
|
32
|
+
config: has('config'),
|
|
33
|
+
observability: has('observability'),
|
|
34
|
+
messaging: has('messaging'),
|
|
35
|
+
schedule: has('schedule'),
|
|
36
|
+
openapi: has('openapi'),
|
|
37
|
+
adapter: options.adapter,
|
|
38
|
+
pm: options.packageManager,
|
|
39
|
+
bun: options.packageManager === 'bun',
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every file of a new project, sorted by path. */
|
|
44
|
+
export function renderProject(options: ProjectOptions): ProjectFile[] {
|
|
45
|
+
const flags = flagsOf(options)
|
|
46
|
+
const files: ProjectFile[] = [
|
|
47
|
+
{ path: 'package.json', content: packageJson(options, flags) },
|
|
48
|
+
{ path: 'tsconfig.json', content: tsconfigJson(flags) },
|
|
49
|
+
{ path: 'vite.config.ts', content: viteConfig() },
|
|
50
|
+
{ path: 'znight.config.ts', content: znightConfig(flags) },
|
|
51
|
+
{ path: '.gitignore', content: gitignore() },
|
|
52
|
+
{ path: 'README.md', content: readme(options, flags) },
|
|
53
|
+
{ path: 'src/main.ts', content: mainTs(flags) },
|
|
54
|
+
{ path: 'src/app/greeting.schemas.ts', content: greetingSchemas(flags) },
|
|
55
|
+
{ path: 'src/app/greeting.service.ts', content: greetingService(flags) },
|
|
56
|
+
{ path: 'src/app/greeting.controller.ts', content: greetingController(flags) },
|
|
57
|
+
{ path: 'src/app/health.controller.ts', content: healthController() },
|
|
58
|
+
{ path: 'test/greeting.service.test.ts', content: greetingServiceTest(flags) },
|
|
59
|
+
{ path: 'test/greeting.controller.test.ts', content: greetingControllerTest(flags) },
|
|
60
|
+
]
|
|
61
|
+
if (flags.config) files.push({ path: 'src/app/app.config.ts', content: appConfig() })
|
|
62
|
+
if (flags.messaging) files.push({ path: 'src/app/greeting.consumer.ts', content: greetingConsumer(flags) })
|
|
63
|
+
if (flags.schedule) {
|
|
64
|
+
files.push({ path: 'src/app/heartbeat.jobs.ts', content: heartbeatJobs() })
|
|
65
|
+
files.push({ path: 'test/heartbeat.jobs.test.ts', content: heartbeatJobsTest(flags) })
|
|
66
|
+
}
|
|
67
|
+
if (flags.openapi) files.push({ path: 'src/api.ts', content: apiDocument(options) })
|
|
68
|
+
if (flags.bun) files.push({ path: 'bunfig.toml', content: bunfig(flags) })
|
|
69
|
+
if (flags.pm === 'yarn') files.push({ path: '.yarnrc.yml', content: 'nodeLinker: node-modules\n' })
|
|
70
|
+
return files.sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------------------------------
|
|
74
|
+
// configuration files
|
|
75
|
+
|
|
76
|
+
function sortRecord(record: Record<string, string>): Record<string, string> {
|
|
77
|
+
return Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function packageJson(options: ProjectOptions, flags: Flags): string {
|
|
81
|
+
const znight = `^${options.znightVersion}`
|
|
82
|
+
const dependencies: Record<string, string> = {
|
|
83
|
+
'@znight/core': znight,
|
|
84
|
+
'@znight/http': znight,
|
|
85
|
+
zod: versionOf('zod'),
|
|
86
|
+
}
|
|
87
|
+
if (flags.config) dependencies['@znight/config'] = znight
|
|
88
|
+
if (flags.observability) dependencies['@znight/observability'] = znight
|
|
89
|
+
if (flags.messaging) dependencies['@znight/messaging'] = znight
|
|
90
|
+
if (flags.schedule) dependencies['@znight/schedule'] = znight
|
|
91
|
+
if (flags.openapi) dependencies['@znight/openapi'] = znight
|
|
92
|
+
if (flags.adapter === 'fastify') dependencies['fastify'] = versionOf('fastify')
|
|
93
|
+
if (flags.adapter === 'express') dependencies['express'] = versionOf('express')
|
|
94
|
+
if (flags.adapter === 'hono') {
|
|
95
|
+
dependencies['hono'] = versionOf('hono')
|
|
96
|
+
dependencies['@hono/node-server'] = versionOf('@hono/node-server')
|
|
97
|
+
}
|
|
98
|
+
const devDependencies: Record<string, string> = {
|
|
99
|
+
'@types/node': versionOf('@types/node'),
|
|
100
|
+
'@znight/cli': znight,
|
|
101
|
+
'@znight/compiler': znight,
|
|
102
|
+
'@znight/testing': znight,
|
|
103
|
+
typescript: versionOf('typescript'),
|
|
104
|
+
vite: versionOf('vite'),
|
|
105
|
+
}
|
|
106
|
+
if (flags.bun) devDependencies['@types/bun'] = versionOf('@types/bun')
|
|
107
|
+
else devDependencies['vitest'] = versionOf('vitest')
|
|
108
|
+
if (flags.adapter === 'express') devDependencies['@types/express'] = versionOf('@types/express')
|
|
109
|
+
// in-memory requests (TestApp.inject) with the node and express adapters
|
|
110
|
+
if (flags.adapter === 'node' || flags.adapter === 'express') {
|
|
111
|
+
devDependencies['light-my-request'] = versionOf('light-my-request')
|
|
112
|
+
}
|
|
113
|
+
const manifest = {
|
|
114
|
+
name: options.name,
|
|
115
|
+
version: '0.1.0',
|
|
116
|
+
private: true,
|
|
117
|
+
type: 'module',
|
|
118
|
+
engines: { node: '>=24' },
|
|
119
|
+
scripts: {
|
|
120
|
+
// `bun run` honours the node shebang of the znight binary: the runtime has to be explicit
|
|
121
|
+
dev: flags.bun ? 'znight dev --runtime bun' : 'znight dev',
|
|
122
|
+
build: 'znight build',
|
|
123
|
+
start: flags.bun ? 'znight start --runtime bun' : 'znight start',
|
|
124
|
+
generate: 'znight generate',
|
|
125
|
+
typecheck: 'znight generate && tsc --noEmit',
|
|
126
|
+
test: flags.bun ? 'bun test' : 'vitest run',
|
|
127
|
+
...(flags.openapi ? { openapi: 'znight openapi', 'openapi:check': 'znight openapi --check' } : {}),
|
|
128
|
+
},
|
|
129
|
+
dependencies: sortRecord(dependencies),
|
|
130
|
+
devDependencies: sortRecord(devDependencies),
|
|
131
|
+
}
|
|
132
|
+
return `${JSON.stringify(manifest, null, 2)}\n`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tsconfigJson(flags: Flags): string {
|
|
136
|
+
return `{
|
|
137
|
+
"compilerOptions": {
|
|
138
|
+
"target": "ES2024",
|
|
139
|
+
"lib": ["ES2024", "ESNext.Disposable"],
|
|
140
|
+
"module": "preserve",
|
|
141
|
+
"moduleResolution": "bundler",
|
|
142
|
+
"moduleDetection": "force",
|
|
143
|
+
"types": ["${flags.bun ? 'bun' : 'node'}"],
|
|
144
|
+
"strict": true,
|
|
145
|
+
"noUncheckedIndexedAccess": true,
|
|
146
|
+
"noImplicitOverride": true,
|
|
147
|
+
// Znight decorators are TypeScript decorators: no emitDecoratorMetadata nor reflect-metadata
|
|
148
|
+
"experimentalDecorators": true,
|
|
149
|
+
"verbatimModuleSyntax": true,
|
|
150
|
+
"isolatedModules": true,
|
|
151
|
+
"resolveJsonModule": true,
|
|
152
|
+
"skipLibCheck": true,
|
|
153
|
+
"noEmit": true,
|
|
154
|
+
// vite.config.ts imports ./znight.config.ts (also loaded as is by Node.js and Bun)
|
|
155
|
+
"allowImportingTsExtensions": true
|
|
156
|
+
},
|
|
157
|
+
"include": ["src", "test", "vite.config.ts", "znight.config.ts"]
|
|
158
|
+
}
|
|
159
|
+
`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function viteConfig(): string {
|
|
163
|
+
return `import { znight } from '@znight/compiler'
|
|
164
|
+
import { defineConfig } from 'vite'
|
|
165
|
+
|
|
166
|
+
import options from './znight.config.ts'
|
|
167
|
+
|
|
168
|
+
// znight() generates the dependency injection container at build time and configures a server
|
|
169
|
+
// build: src/main.ts -> dist/main.js (Node.js 24 target, source maps).
|
|
170
|
+
export default defineConfig({
|
|
171
|
+
plugins: [znight(options)],
|
|
172
|
+
})
|
|
173
|
+
`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function znightConfig(flags: Flags): string {
|
|
177
|
+
const readers = flags.bun
|
|
178
|
+
? `// Options of the Znight compiler. The znight() Vite plugin (vite.config.ts), the Bun preload of the
|
|
179
|
+
// tests (bunfig.toml) and \`znight generate\` read them, so they all generate the same container.`
|
|
180
|
+
: `// Options of the Znight compiler. The znight() Vite plugin (vite.config.ts, also used by Vitest)
|
|
181
|
+
// and \`znight generate\` read them, so they all generate the same container.`
|
|
182
|
+
return `import type { ZnightOptions } from '@znight/compiler'
|
|
183
|
+
|
|
184
|
+
${readers}
|
|
185
|
+
// https://github.com/znight-org/znight/tree/main/packages/compiler#options
|
|
186
|
+
export default {
|
|
187
|
+
// reportUnused: true, // warn about providers nothing uses (ZNIGHT019)
|
|
188
|
+
} satisfies ZnightOptions
|
|
189
|
+
`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function bunfig(flags: Flags): string {
|
|
193
|
+
// Bun loads .env files into process.env with its own precedence, which then wins over dotenv()
|
|
194
|
+
const env = flags.config
|
|
195
|
+
? `# @znight/config loads the .env files (dotenv()), the same way on Node.js and Bun: Bun's own
|
|
196
|
+
# loading would put them in process.env first, with its own precedence
|
|
197
|
+
env = false
|
|
198
|
+
|
|
199
|
+
`
|
|
200
|
+
: ''
|
|
201
|
+
return `${env}[test]
|
|
202
|
+
# Regenerates the DI container (src/.znight/container.generated.ts) before the tests run
|
|
203
|
+
preload = ["@znight/compiler/bun"]
|
|
204
|
+
`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function gitignore(): string {
|
|
208
|
+
return `node_modules
|
|
209
|
+
dist
|
|
210
|
+
coverage
|
|
211
|
+
*.log
|
|
212
|
+
|
|
213
|
+
# generated by the Znight compiler (znight generate, vite build, bun test)
|
|
214
|
+
.znight
|
|
215
|
+
|
|
216
|
+
# local environment
|
|
217
|
+
.env.local
|
|
218
|
+
.env.*.local
|
|
219
|
+
`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------------------------------
|
|
223
|
+
// application
|
|
224
|
+
|
|
225
|
+
function adapterImport(adapter: HttpAdapterName): string | undefined {
|
|
226
|
+
switch (adapter) {
|
|
227
|
+
case 'fastify':
|
|
228
|
+
return "import { fastifyAdapter } from '@znight/http/fastify'"
|
|
229
|
+
case 'express':
|
|
230
|
+
return "import { expressAdapter } from '@znight/http/express'"
|
|
231
|
+
case 'hono':
|
|
232
|
+
return "import { honoAdapter } from '@znight/http/hono'"
|
|
233
|
+
default:
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function adapterCall(adapter: HttpAdapterName): string | undefined {
|
|
239
|
+
return adapter === 'node' ? undefined : `${adapter}Adapter()`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function mainTs(flags: Flags): string {
|
|
243
|
+
const imports = [
|
|
244
|
+
flags.config ? "import { ConfigPlugin } from '@znight/config'" : undefined,
|
|
245
|
+
"import { Application } from '@znight/core'",
|
|
246
|
+
"import { HttpPlugin } from '@znight/http'",
|
|
247
|
+
adapterImport(flags.adapter),
|
|
248
|
+
flags.messaging ? "import { MessagingPlugin } from '@znight/messaging'" : undefined,
|
|
249
|
+
flags.messaging ? "import { memory } from '@znight/messaging/memory'" : undefined,
|
|
250
|
+
flags.observability ? "import { ObservabilityPlugin } from '@znight/observability'" : undefined,
|
|
251
|
+
flags.openapi ? "import { OpenApiPlugin } from '@znight/openapi'" : undefined,
|
|
252
|
+
flags.schedule ? "import { SchedulePlugin } from '@znight/schedule'" : undefined,
|
|
253
|
+
].filter(line => line !== undefined)
|
|
254
|
+
const localImports = [
|
|
255
|
+
"import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'",
|
|
256
|
+
flags.config ? "import { AppConfig } from './app/app.config.js'" : undefined,
|
|
257
|
+
].filter(line => line !== undefined)
|
|
258
|
+
|
|
259
|
+
const plugins: string[] = []
|
|
260
|
+
if (flags.observability) {
|
|
261
|
+
plugins.push(
|
|
262
|
+
' // first, so that its hooks wrap everything: request logs, metrics (with prom-client), traces',
|
|
263
|
+
' ObservabilityPlugin.configure(),',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
if (flags.config) {
|
|
267
|
+
plugins.push(
|
|
268
|
+
' // loads .env files and the environment, validates every @Config class before anything starts',
|
|
269
|
+
' ConfigPlugin.configure(),',
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
if (flags.messaging) {
|
|
273
|
+
plugins.push(
|
|
274
|
+
' // in-memory broker for development: use rabbitmq(), sqs() or kafka() in production',
|
|
275
|
+
' MessagingPlugin.configure({ driver: memory() }),',
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
if (flags.schedule) {
|
|
279
|
+
plugins.push(
|
|
280
|
+
' // before HttpPlugin: plugins stop in reverse order, so HTTP stops first, then running jobs drain',
|
|
281
|
+
' SchedulePlugin.configure(),',
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
const adapter = adapterCall(flags.adapter)
|
|
285
|
+
if (flags.config) {
|
|
286
|
+
plugins.push(
|
|
287
|
+
' HttpPlugin.configure(container => ({',
|
|
288
|
+
...(adapter === undefined ? [] : [` adapter: ${adapter},`]),
|
|
289
|
+
' port: container.get(AppConfig).port,',
|
|
290
|
+
' })),',
|
|
291
|
+
)
|
|
292
|
+
} else {
|
|
293
|
+
const port = 'port: Number(process.env.PORT ?? 3000)'
|
|
294
|
+
plugins.push(
|
|
295
|
+
adapter === undefined
|
|
296
|
+
? ` HttpPlugin.configure({ ${port} }),`
|
|
297
|
+
: ` HttpPlugin.configure({ adapter: ${adapter}, ${port} }),`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
if (flags.openapi) {
|
|
301
|
+
plugins.push(
|
|
302
|
+
' // GET /openapi.json and the API reference at /docs (document options: src/api.ts)',
|
|
303
|
+
' OpenApiPlugin.configure(),',
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return `${imports.join('\n')}
|
|
308
|
+
|
|
309
|
+
${localImports.join('\n')}
|
|
310
|
+
|
|
311
|
+
const app = await Application.create({
|
|
312
|
+
metadata: __ZNIGHT_METADATA__,
|
|
313
|
+
plugins: [
|
|
314
|
+
${plugins.join('\n')}
|
|
315
|
+
],
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// SIGINT / SIGTERM stop the application gracefully (in-flight requests are drained)
|
|
319
|
+
await app.start()
|
|
320
|
+
`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function appConfig(): string {
|
|
324
|
+
return `import { Config, Env } from '@znight/config'
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Settings of the application, validated at boot by ConfigPlugin. Each property is read from the
|
|
328
|
+
* environment (or a .env file): APP_<PROPERTY> by default, or the name given to @Env. Properties
|
|
329
|
+
* with an initializer are optional; their type selects the coercion ("3000" -> 3000).
|
|
330
|
+
*/
|
|
331
|
+
@Config('app')
|
|
332
|
+
export class AppConfig {
|
|
333
|
+
/** HTTP port (PORT). */
|
|
334
|
+
@Env('PORT')
|
|
335
|
+
port = 3000
|
|
336
|
+
|
|
337
|
+
/** Greeting used by GreetingService (APP_GREETING). */
|
|
338
|
+
greeting = 'Hello'
|
|
339
|
+
}
|
|
340
|
+
`
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function greetingSchemas(flags: Flags): string {
|
|
344
|
+
const usage = flags.messaging
|
|
345
|
+
? 'Body of POST /greetings and POST /greetings/async, payload of the "greetings" queue.'
|
|
346
|
+
: 'Body of POST /greetings.'
|
|
347
|
+
return `import { z } from 'zod'
|
|
348
|
+
|
|
349
|
+
/** ${usage} */
|
|
350
|
+
export const CreateGreeting = z.object({
|
|
351
|
+
name: z.string().trim().min(1).max(100),
|
|
352
|
+
excited: z.boolean().default(false),
|
|
353
|
+
})
|
|
354
|
+
export type CreateGreeting = z.output<typeof CreateGreeting>
|
|
355
|
+
`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function greetingService(flags: Flags): string {
|
|
359
|
+
const imports = ["import { Injectable } from '@znight/core'"]
|
|
360
|
+
if (flags.observability) imports.push("import { Logger } from '@znight/observability'")
|
|
361
|
+
const local = flags.config ? "\nimport { AppConfig } from './app.config.js'\n" : ''
|
|
362
|
+
const params: string[] = []
|
|
363
|
+
if (flags.config) params.push(' private readonly config: AppConfig,')
|
|
364
|
+
if (flags.observability) params.push(' private readonly logger: Logger,')
|
|
365
|
+
let members = ''
|
|
366
|
+
if (params.length > 0) {
|
|
367
|
+
members = ` // dependencies are resolved at build time from the parameter types
|
|
368
|
+
constructor(
|
|
369
|
+
${params.join('\n')}
|
|
370
|
+
) {}
|
|
371
|
+
|
|
372
|
+
`
|
|
373
|
+
}
|
|
374
|
+
const greeting = flags.config ? 'this.config.greeting' : "'Hello'"
|
|
375
|
+
const log = flags.observability ? " this.logger.debug('Greeting', { name, excited })\n" : ''
|
|
376
|
+
return `${imports.join('\n')}
|
|
377
|
+
${local}
|
|
378
|
+
@Injectable()
|
|
379
|
+
export class GreetingService {
|
|
380
|
+
${members} greet(name: string, excited = false): string {
|
|
381
|
+
${log} return \`\${${greeting}}, \${name}\${excited ? '!' : '.'}\`
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
`
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function greetingController(flags: Flags): string {
|
|
388
|
+
const httpImports = flags.messaging
|
|
389
|
+
? 'Body, Controller, Get, HttpCode, Param, Post, Query'
|
|
390
|
+
: 'Body, Controller, Get, Param, Post, Query'
|
|
391
|
+
const imports = [`import { ${httpImports} } from '@znight/http'`]
|
|
392
|
+
if (flags.messaging) imports.push("import { MessageBroker } from '@znight/messaging'")
|
|
393
|
+
const ctor = flags.messaging
|
|
394
|
+
? ` constructor(
|
|
395
|
+
private readonly greetings: GreetingService,
|
|
396
|
+
private readonly broker: MessageBroker,
|
|
397
|
+
) {}`
|
|
398
|
+
: ' constructor(private readonly greetings: GreetingService) {}'
|
|
399
|
+
const enqueue = flags.messaging
|
|
400
|
+
? `
|
|
401
|
+
|
|
402
|
+
/** POST /greetings/async: published to the "greetings" queue, handled by GreetingConsumer. */
|
|
403
|
+
@Post('/async')
|
|
404
|
+
@HttpCode(202)
|
|
405
|
+
async enqueue(@Body(CreateGreeting) input: CreateGreeting): Promise<{ id: string }> {
|
|
406
|
+
const { id } = await this.broker.publish('greetings', input, { type: 'greeting.requested' })
|
|
407
|
+
return { id }
|
|
408
|
+
}`
|
|
409
|
+
: ''
|
|
410
|
+
return `${imports.join('\n')}
|
|
411
|
+
|
|
412
|
+
import { CreateGreeting } from './greeting.schemas.js'
|
|
413
|
+
import { GreetingService } from './greeting.service.js'
|
|
414
|
+
|
|
415
|
+
@Controller('/greetings')
|
|
416
|
+
export class GreetingController {
|
|
417
|
+
${ctor}
|
|
418
|
+
|
|
419
|
+
/** GET /greetings/Ada?excited=true: route and query parameters are coerced to their types. */
|
|
420
|
+
@Get('/:name')
|
|
421
|
+
greet(@Param('name') name: string, @Query('excited') excited?: boolean): { message: string } {
|
|
422
|
+
return { message: this.greetings.greet(name, excited) }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** POST /greetings {"name": "Ada"}: the body is validated by the schema (400 otherwise). */
|
|
426
|
+
@Post()
|
|
427
|
+
create(@Body(CreateGreeting) input: CreateGreeting): { message: string } {
|
|
428
|
+
return { message: this.greetings.greet(input.name, input.excited) }
|
|
429
|
+
}${enqueue}
|
|
430
|
+
}
|
|
431
|
+
`
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function healthController(): string {
|
|
435
|
+
return `import { Controller, Get } from '@znight/http'
|
|
436
|
+
|
|
437
|
+
@Controller('/health')
|
|
438
|
+
export class HealthController {
|
|
439
|
+
/** GET /health */
|
|
440
|
+
@Get()
|
|
441
|
+
check(): { status: 'ok'; uptime: number } {
|
|
442
|
+
return { status: 'ok', uptime: Math.round(process.uptime()) }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
`
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function greetingConsumer(flags: Flags): string {
|
|
449
|
+
const imports = ["import { Consumer, Message, OnMessage, Retry } from '@znight/messaging'"]
|
|
450
|
+
if (flags.observability) imports.push("import { Logger } from '@znight/observability'")
|
|
451
|
+
const ctor = flags.observability
|
|
452
|
+
? ` constructor(
|
|
453
|
+
private readonly greetings: GreetingService,
|
|
454
|
+
private readonly logger: Logger,
|
|
455
|
+
) {}`
|
|
456
|
+
: ' constructor(private readonly greetings: GreetingService) {}'
|
|
457
|
+
const output = flags.observability
|
|
458
|
+
? ' this.logger.info(this.greetings.greet(request.name, request.excited))'
|
|
459
|
+
: ' console.log(this.greetings.greet(request.name, request.excited))'
|
|
460
|
+
return `${imports.join('\n')}
|
|
461
|
+
|
|
462
|
+
import { CreateGreeting } from './greeting.schemas.js'
|
|
463
|
+
import { GreetingService } from './greeting.service.js'
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Consumes the "greetings" queue. Delivery is at-least-once: handlers must be idempotent. A failing
|
|
467
|
+
* handler is retried, then the message goes to the dead-letter queue (greetings.dlq).
|
|
468
|
+
*/
|
|
469
|
+
@Consumer('greetings')
|
|
470
|
+
export class GreetingConsumer {
|
|
471
|
+
${ctor}
|
|
472
|
+
|
|
473
|
+
@OnMessage('greeting.requested')
|
|
474
|
+
@Retry({ attempts: 3, backoff: 'exponential', delay: 500 })
|
|
475
|
+
handle(@Message(CreateGreeting) request: CreateGreeting): void {
|
|
476
|
+
${output}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
`
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function heartbeatJobs(): string {
|
|
483
|
+
return `import { Cron, type JobContext } from '@znight/schedule'
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Scheduled jobs. A class with @Cron, @Interval or @Timeout methods becomes a singleton provider
|
|
487
|
+
* (no decorator needed): its dependencies are constructor parameters. Running several instances?
|
|
488
|
+
* Give SchedulePlugin a lock store so that each occurrence runs on one of them.
|
|
489
|
+
*/
|
|
490
|
+
export class HeartbeatJobs {
|
|
491
|
+
/** Number of runs. */
|
|
492
|
+
beats = 0
|
|
493
|
+
/** The occurrence of the last run. */
|
|
494
|
+
last: Date | undefined
|
|
495
|
+
|
|
496
|
+
/** Every minute (cron expressions run in UTC unless they set a timeZone). */
|
|
497
|
+
@Cron('* * * * *', { name: 'heartbeat' })
|
|
498
|
+
beat(ctx: JobContext): void {
|
|
499
|
+
this.beats++
|
|
500
|
+
this.last = ctx.scheduledAt
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
`
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function apiDocument(options: ProjectOptions): string {
|
|
507
|
+
return `import { OpenApiDocument } from '@znight/openapi'
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* ${options.name}
|
|
511
|
+
*
|
|
512
|
+
* The HTTP API of ${options.name}. Title and description of the OpenAPI document come from this
|
|
513
|
+
* comment, the version from package.json.
|
|
514
|
+
*/
|
|
515
|
+
@OpenApiDocument({
|
|
516
|
+
tags: [{ name: 'Greeting', description: 'Greetings' }],
|
|
517
|
+
})
|
|
518
|
+
export class Api {}
|
|
519
|
+
`
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------------------------------
|
|
523
|
+
// tests
|
|
524
|
+
|
|
525
|
+
function heartbeatJobsTest(flags: Flags): string {
|
|
526
|
+
return `${testImports(flags, 'describe, expect, test')}
|
|
527
|
+
import { SchedulePlugin, SchedulerRegistry } from '@znight/schedule'
|
|
528
|
+
import { createTestClock } from '@znight/schedule/testing'
|
|
529
|
+
import { TestApp } from '@znight/testing'
|
|
530
|
+
|
|
531
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
532
|
+
import { HeartbeatJobs } from '../src/app/heartbeat.jobs.js'
|
|
533
|
+
|
|
534
|
+
// virtual time: the clock only moves when the test advances it, and waits for the runs it starts
|
|
535
|
+
describe('HeartbeatJobs', () => {
|
|
536
|
+
test('beats every minute', async () => {
|
|
537
|
+
const clock = createTestClock('2026-01-01T00:00:30Z')
|
|
538
|
+
const app = await TestApp.create({
|
|
539
|
+
metadata,
|
|
540
|
+
plugins: [SchedulePlugin.configure({ clock })],
|
|
541
|
+
only: [HeartbeatJobs],
|
|
542
|
+
})
|
|
543
|
+
try {
|
|
544
|
+
await clock.advance('3m')
|
|
545
|
+
|
|
546
|
+
expect(app.get(HeartbeatJobs).beats).toBe(3)
|
|
547
|
+
expect(app.get(HeartbeatJobs).last?.toISOString()).toBe('2026-01-01T00:03:00.000Z')
|
|
548
|
+
const heartbeat = app.get(SchedulerRegistry).get('heartbeat')
|
|
549
|
+
expect(heartbeat?.nextRun?.toISOString()).toBe('2026-01-01T00:04:00.000Z')
|
|
550
|
+
} finally {
|
|
551
|
+
await app.close()
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
`
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function testImports(flags: Flags, names: string): string {
|
|
559
|
+
return flags.bun ? `import { ${names} } from 'bun:test'` : `import { ${names} } from 'vitest'`
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function greetingServiceTest(flags: Flags): string {
|
|
563
|
+
const imports = [testImports(flags, 'describe, expect, test')]
|
|
564
|
+
if (flags.observability) imports.push("import { Logger } from '@znight/observability'")
|
|
565
|
+
imports.push("import { TestBed } from '@znight/testing'")
|
|
566
|
+
const configOption = flags.config ? "\n config: { app: { greeting: 'Hi' } }, // @Config('app') values" : ''
|
|
567
|
+
const greeting = flags.config ? 'Hi' : 'Hello'
|
|
568
|
+
const loggerTest = flags.observability
|
|
569
|
+
? `
|
|
570
|
+
|
|
571
|
+
test('logs every greeting', async () => {
|
|
572
|
+
const { instance, mocks } = await TestBed.create(GreetingService, { metadata })
|
|
573
|
+
|
|
574
|
+
instance.greet('Grace')
|
|
575
|
+
|
|
576
|
+
// Logger is provided by ObservabilityPlugin at runtime: TestBed injects an auto-mock
|
|
577
|
+
expect(mocks.get(Logger).debug).toHaveBeenCalledWith('Greeting', { name: 'Grace', excited: false })
|
|
578
|
+
})`
|
|
579
|
+
: ''
|
|
580
|
+
return `${imports.join('\n')}
|
|
581
|
+
|
|
582
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
583
|
+
import { GreetingService } from '../src/app/greeting.service.js'
|
|
584
|
+
|
|
585
|
+
// TestBed builds GreetingService with its generated factory and auto-mocks its dependencies
|
|
586
|
+
describe('GreetingService', () => {
|
|
587
|
+
test('greets', async () => {
|
|
588
|
+
const { instance } = await TestBed.create(GreetingService, {
|
|
589
|
+
metadata,${configOption}
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
expect(instance.greet('Ada')).toBe('${greeting}, Ada.')
|
|
593
|
+
expect(instance.greet('Ada', true)).toBe('${greeting}, Ada!')
|
|
594
|
+
})${loggerTest}
|
|
595
|
+
})
|
|
596
|
+
`
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function greetingControllerTest(flags: Flags): string {
|
|
600
|
+
const imports = [
|
|
601
|
+
testImports(flags, 'afterEach, beforeEach, describe, expect, test'),
|
|
602
|
+
"import { HttpPlugin } from '@znight/http'",
|
|
603
|
+
]
|
|
604
|
+
if (flags.messaging) {
|
|
605
|
+
imports.push("import { MessagingPlugin } from '@znight/messaging'")
|
|
606
|
+
imports.push("import { memory } from '@znight/messaging/memory'")
|
|
607
|
+
}
|
|
608
|
+
if (flags.observability) imports.push("import { ObservabilityPlugin } from '@znight/observability'")
|
|
609
|
+
imports.push("import { TestApp } from '@znight/testing'")
|
|
610
|
+
const plugins: string[] = []
|
|
611
|
+
// no metrics server (port 9464 once prom-client is installed): test files and `dev` run side by side
|
|
612
|
+
if (flags.observability) {
|
|
613
|
+
plugins.push("ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: { server: false } })")
|
|
614
|
+
}
|
|
615
|
+
if (flags.messaging) plugins.push('MessagingPlugin.configure({ driver: memory() })')
|
|
616
|
+
// port 0: TestApp.inject() sends requests in memory, the server listens on a free port
|
|
617
|
+
plugins.push('HttpPlugin.configure({ port: 0 })')
|
|
618
|
+
const pluginList =
|
|
619
|
+
plugins.length === 1 ? `[${plugins[0]}]` : `[\n${plugins.map(plugin => ` ${plugin},`).join('\n')}\n ]`
|
|
620
|
+
const greeting = 'Hello'
|
|
621
|
+
return `${imports.join('\n')}
|
|
622
|
+
|
|
623
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
624
|
+
|
|
625
|
+
// TestApp starts the application (without signal handlers) and sends requests in memory
|
|
626
|
+
describe('GreetingController', () => {
|
|
627
|
+
let app: TestApp
|
|
628
|
+
|
|
629
|
+
beforeEach(async () => {
|
|
630
|
+
app = await TestApp.create({
|
|
631
|
+
metadata,
|
|
632
|
+
plugins: ${pluginList},
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
afterEach(async () => {
|
|
637
|
+
await app.close()
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
test('GET /greetings/:name', async () => {
|
|
641
|
+
const response = await app.inject({ method: 'GET', url: '/greetings/Ada?excited=true' })
|
|
642
|
+
|
|
643
|
+
expect(response.status).toBe(200)
|
|
644
|
+
expect(response.json()).toEqual({ message: '${greeting}, Ada!' })
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('POST /greetings validates the body', async () => {
|
|
648
|
+
const created = await app.inject({ method: 'POST', url: '/greetings', body: { name: 'Grace' } })
|
|
649
|
+
expect(created.status).toBe(201)
|
|
650
|
+
expect(created.json()).toEqual({ message: '${greeting}, Grace.' })
|
|
651
|
+
|
|
652
|
+
const invalid = await app.inject({ method: 'POST', url: '/greetings', body: { name: '' } })
|
|
653
|
+
expect(invalid.status).toBe(400)
|
|
654
|
+
})
|
|
655
|
+
})
|
|
656
|
+
`
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------------------------------
|
|
660
|
+
// README
|
|
661
|
+
|
|
662
|
+
function readme(options: ProjectOptions, flags: Flags): string {
|
|
663
|
+
const run = (script: string): string => runScript(flags.pm, script)
|
|
664
|
+
const znight = execBinary(flags.pm, 'znight')
|
|
665
|
+
const features = [
|
|
666
|
+
`- **HTTP** with \`@znight/http\` (${flags.adapter === 'node' ? 'built-in node:http server' : `${flags.adapter} adapter`})`,
|
|
667
|
+
flags.config ? '- **Configuration** with `@znight/config` (`src/app/app.config.ts`, `.env` files)' : undefined,
|
|
668
|
+
flags.observability
|
|
669
|
+
? '- **Observability** with `@znight/observability` (structured logs, metrics, traces)'
|
|
670
|
+
: undefined,
|
|
671
|
+
flags.messaging ? '- **Messaging** with `@znight/messaging` (in-memory broker in `src/main.ts`)' : undefined,
|
|
672
|
+
flags.schedule ? '- **Scheduled jobs** with `@znight/schedule` (`src/app/heartbeat.jobs.ts`)' : undefined,
|
|
673
|
+
flags.openapi
|
|
674
|
+
? '- **OpenAPI** with `@znight/openapi` (`/openapi.json`, API reference at `/docs`, options in `src/api.ts`)'
|
|
675
|
+
: undefined,
|
|
676
|
+
].filter(line => line !== undefined)
|
|
677
|
+
const tree = [
|
|
678
|
+
'src/',
|
|
679
|
+
'├─ main.ts # Application.create() + plugins + start()',
|
|
680
|
+
flags.openapi
|
|
681
|
+
? '├─ api.ts # @OpenApiDocument: title, tags, security of openapi.json'
|
|
682
|
+
: undefined,
|
|
683
|
+
'├─ .znight/ # generated DI container (git-ignored)',
|
|
684
|
+
'└─ app/',
|
|
685
|
+
flags.config ? ' ├─ app.config.ts # @Config: settings read from the environment' : undefined,
|
|
686
|
+
flags.messaging ? ' ├─ greeting.consumer.ts # @Consumer: handles the "greetings" queue' : undefined,
|
|
687
|
+
' ├─ greeting.controller.ts # @Controller: /greetings routes',
|
|
688
|
+
' ├─ greeting.schemas.ts # Zod schemas',
|
|
689
|
+
' ├─ greeting.service.ts # @Injectable',
|
|
690
|
+
flags.schedule ? ' ├─ health.controller.ts # GET /health' : ' └─ health.controller.ts # GET /health',
|
|
691
|
+
flags.schedule ? ' └─ heartbeat.jobs.ts # @Cron: a job every minute' : undefined,
|
|
692
|
+
'test/ # TestBed (unit) and TestApp (HTTP) tests',
|
|
693
|
+
'vite.config.ts # znight() plugin: generates the container, server build',
|
|
694
|
+
'znight.config.ts # compiler options',
|
|
695
|
+
].filter(line => line !== undefined)
|
|
696
|
+
const configNote = flags.config
|
|
697
|
+
? `
|
|
698
|
+
## Configuration
|
|
699
|
+
|
|
700
|
+
\`AppConfig\` (\`src/app/app.config.ts\`) reads \`PORT\` and \`APP_GREETING\` from the environment or from
|
|
701
|
+
\`.env\` / \`.env.local\` files. Missing or invalid values stop the application at boot with every
|
|
702
|
+
problem listed.${
|
|
703
|
+
flags.bun
|
|
704
|
+
? `
|
|
705
|
+
|
|
706
|
+
\`bunfig.toml\` turns Bun's own \`.env\` loading off (\`env = false\`): the files are read by
|
|
707
|
+
\`@znight/config\`, the same way on Node.js and Bun.`
|
|
708
|
+
: ''
|
|
709
|
+
}
|
|
710
|
+
`
|
|
711
|
+
: ''
|
|
712
|
+
const testNote = flags.bun
|
|
713
|
+
? '`bun test` regenerates the container first (`bunfig.toml` preloads `@znight/compiler/bun`).'
|
|
714
|
+
: 'Vitest uses `vite.config.ts`, so the `znight()` plugin regenerates the container before the tests.'
|
|
715
|
+
const compilerNote = flags.bun
|
|
716
|
+
? 'the `znight()` Vite plugin, the Bun preload of the tests and `znight generate`'
|
|
717
|
+
: 'the `znight()` Vite plugin (builds and Vitest) and `znight generate`'
|
|
718
|
+
return `# ${options.name}
|
|
719
|
+
|
|
720
|
+
A [Znight](https://github.com/znight-org/znight) application: dependency injection resolved at **build
|
|
721
|
+
time** — no \`reflect-metadata\`, errors reported by the compiler with their location.
|
|
722
|
+
|
|
723
|
+
${features.join('\n')}
|
|
724
|
+
|
|
725
|
+
## Scripts
|
|
726
|
+
|
|
727
|
+
| Command | Description |
|
|
728
|
+
| --- | --- |
|
|
729
|
+
| \`${run('dev')}\` | Build in watch mode and restart the application on every change |
|
|
730
|
+
| \`${run('build')}\` | Production build (\`dist/main.js\`) |
|
|
731
|
+
| \`${run('start')}\` | Run the production build |
|
|
732
|
+
| \`${run('test')}\` | Run the tests |
|
|
733
|
+
| \`${run('typecheck')}\` | Generate the container, then type-check with \`tsc\` |
|
|
734
|
+
| \`${run('generate')}\` | Generate \`src/.znight/container.generated.ts\` (\`znight generate --check\` in CI) |
|
|
735
|
+
${
|
|
736
|
+
flags.openapi
|
|
737
|
+
? `| \`${run('openapi')}\` | Write \`openapi.json\` (commit it; \`${run('openapi:check')}\` in CI) |
|
|
738
|
+
`
|
|
739
|
+
: ''
|
|
740
|
+
}
|
|
741
|
+
\`\`\`sh
|
|
742
|
+
${run('dev')}
|
|
743
|
+
curl localhost:3000/health
|
|
744
|
+
curl localhost:3000/greetings/Ada?excited=true
|
|
745
|
+
curl -X POST localhost:3000/greetings -H 'content-type: application/json' -d '{"name":"Grace"}'${flags.openapi ? '\ncurl localhost:3000/openapi.json # the API reference is at http://localhost:3000/docs' : ''}
|
|
746
|
+
\`\`\`
|
|
747
|
+
|
|
748
|
+
## Project structure
|
|
749
|
+
|
|
750
|
+
\`\`\`txt
|
|
751
|
+
${tree.join('\n')}
|
|
752
|
+
\`\`\`
|
|
753
|
+
|
|
754
|
+
Providers are discovered by the compiler from their decorators: there are no modules to declare.
|
|
755
|
+
Its options live in \`znight.config.ts\`, read by ${compilerNote}.
|
|
756
|
+
Add code with the generators:
|
|
757
|
+
|
|
758
|
+
\`\`\`sh
|
|
759
|
+
${znight} g service users/user # src/users/user.service.ts
|
|
760
|
+
${znight} g controller users/user # src/users/user.controller.ts${flags.schedule ? `\n${znight} g job reports/digest # src/reports/digest.jobs.ts` : ''}
|
|
761
|
+
\`\`\`
|
|
762
|
+
${configNote}
|
|
763
|
+
## Tests
|
|
764
|
+
|
|
765
|
+
${testNote}
|
|
766
|
+
|
|
767
|
+
## Learn more
|
|
768
|
+
|
|
769
|
+
- [Znight documentation](https://github.com/znight-org/znight#readme)
|
|
770
|
+
`
|
|
771
|
+
}
|