@znight/cli 0.6.2 → 0.6.3
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/README.md +58 -25
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +17 -6
- package/dist/index.mjs +1 -1
- package/dist/{main-DUyElIVZ.mjs → main-CLoS12u4.mjs} +452 -75
- package/dist/main-CLoS12u4.mjs.map +1 -0
- package/package.json +7 -7
- package/src/commands/dev.ts +23 -6
- package/src/commands/generate.ts +36 -27
- package/src/commands/new.ts +41 -8
- package/src/commands/start.ts +7 -2
- package/src/dev/supervisor.ts +38 -14
- package/src/scaffold/options.ts +37 -5
- package/src/scaffold/templates.ts +379 -24
- package/src/scaffold/versions.ts +2 -0
- package/dist/main-DUyElIVZ.mjs.map +0 -1
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* of options, `create.ts` writes them. The sample application uses the public APIs of the
|
|
4
4
|
* `@znight/*` packages exactly as documented in their READMEs.
|
|
5
5
|
*/
|
|
6
|
-
import type { Feature, HttpAdapterName, PackageManager, ProjectOptions } from './options.ts'
|
|
6
|
+
import type { Feature, HttpAdapterName, OpenApiUiName, PackageManager, ProjectOptions } from './options.ts'
|
|
7
7
|
|
|
8
|
-
import { execBinary, runScript } from './options.ts'
|
|
8
|
+
import { DEFAULT_OPENAPI_UI, execBinary, OPENAPI_UI_LABELS, runScript } from './options.ts'
|
|
9
9
|
import { versionOf } from './versions.ts'
|
|
10
10
|
|
|
11
11
|
/** A file of the new project. */
|
|
@@ -16,11 +16,17 @@ export interface ProjectFile {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
interface Flags {
|
|
19
|
+
/** npm package name of the project (the logger name). */
|
|
20
|
+
readonly name: string
|
|
19
21
|
readonly config: boolean
|
|
20
22
|
readonly observability: boolean
|
|
21
23
|
readonly messaging: boolean
|
|
22
24
|
readonly schedule: boolean
|
|
23
25
|
readonly openapi: boolean
|
|
26
|
+
readonly openapiUi: OpenApiUiName
|
|
27
|
+
readonly graphql: boolean
|
|
28
|
+
readonly websocket: boolean
|
|
29
|
+
readonly docker: boolean
|
|
24
30
|
readonly adapter: HttpAdapterName
|
|
25
31
|
readonly pm: PackageManager
|
|
26
32
|
readonly bun: boolean
|
|
@@ -29,11 +35,16 @@ interface Flags {
|
|
|
29
35
|
function flagsOf(options: ProjectOptions): Flags {
|
|
30
36
|
const has = (feature: Feature): boolean => options.features.includes(feature)
|
|
31
37
|
return {
|
|
38
|
+
name: options.name,
|
|
32
39
|
config: has('config'),
|
|
33
40
|
observability: has('observability'),
|
|
34
41
|
messaging: has('messaging'),
|
|
35
42
|
schedule: has('schedule'),
|
|
36
43
|
openapi: has('openapi'),
|
|
44
|
+
openapiUi: options.openapiUi ?? DEFAULT_OPENAPI_UI,
|
|
45
|
+
graphql: has('graphql'),
|
|
46
|
+
websocket: has('websocket'),
|
|
47
|
+
docker: options.docker ?? true,
|
|
37
48
|
adapter: options.adapter,
|
|
38
49
|
pm: options.packageManager,
|
|
39
50
|
bun: options.packageManager === 'bun',
|
|
@@ -65,7 +76,19 @@ export function renderProject(options: ProjectOptions): ProjectFile[] {
|
|
|
65
76
|
files.push({ path: 'test/heartbeat.jobs.test.ts', content: heartbeatJobsTest(flags) })
|
|
66
77
|
}
|
|
67
78
|
if (flags.openapi) files.push({ path: 'src/api.ts', content: apiDocument(options) })
|
|
79
|
+
if (flags.graphql) {
|
|
80
|
+
files.push({ path: 'src/app/greeting.resolver.ts', content: greetingResolver() })
|
|
81
|
+
files.push({ path: 'test/greeting.resolver.test.ts', content: greetingResolverTest(flags) })
|
|
82
|
+
}
|
|
83
|
+
if (flags.websocket) {
|
|
84
|
+
files.push({ path: 'src/app/greeting.gateway.ts', content: greetingGateway() })
|
|
85
|
+
files.push({ path: 'test/greeting.gateway.test.ts', content: greetingGatewayTest(flags) })
|
|
86
|
+
}
|
|
68
87
|
if (flags.bun) files.push({ path: 'bunfig.toml', content: bunfig(flags) })
|
|
88
|
+
if (flags.docker) {
|
|
89
|
+
files.push({ path: 'Dockerfile', content: dockerfile(flags) })
|
|
90
|
+
files.push({ path: '.dockerignore', content: dockerignore() })
|
|
91
|
+
}
|
|
69
92
|
if (flags.pm === 'yarn') files.push({ path: '.yarnrc.yml', content: 'nodeLinker: node-modules\n' })
|
|
70
93
|
return files.sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
71
94
|
}
|
|
@@ -89,6 +112,15 @@ function packageJson(options: ProjectOptions, flags: Flags): string {
|
|
|
89
112
|
if (flags.messaging) dependencies['@znight/messaging'] = znight
|
|
90
113
|
if (flags.schedule) dependencies['@znight/schedule'] = znight
|
|
91
114
|
if (flags.openapi) dependencies['@znight/openapi'] = znight
|
|
115
|
+
if (flags.graphql) {
|
|
116
|
+
dependencies['@znight/graphql'] = znight
|
|
117
|
+
dependencies['graphql'] = versionOf('graphql')
|
|
118
|
+
}
|
|
119
|
+
if (flags.websocket) {
|
|
120
|
+
dependencies['@znight/websocket'] = znight
|
|
121
|
+
// the transport of the node:http based servers (Bun provides its own ws module)
|
|
122
|
+
dependencies['ws'] = versionOf('ws')
|
|
123
|
+
}
|
|
92
124
|
if (flags.adapter === 'fastify') dependencies['fastify'] = versionOf('fastify')
|
|
93
125
|
if (flags.adapter === 'express') dependencies['express'] = versionOf('express')
|
|
94
126
|
if (flags.adapter === 'hono') {
|
|
@@ -219,6 +251,139 @@ coverage
|
|
|
219
251
|
`
|
|
220
252
|
}
|
|
221
253
|
|
|
254
|
+
// ---------------------------------------------------------------------------------------------------
|
|
255
|
+
// Docker
|
|
256
|
+
|
|
257
|
+
/** How the image installs and builds with a package manager. */
|
|
258
|
+
interface DockerSteps {
|
|
259
|
+
/** Files of the install layers (the lockfile is required: installs are frozen). */
|
|
260
|
+
readonly manifests: string
|
|
261
|
+
/** Package manager setup of the base stage. */
|
|
262
|
+
readonly setup: readonly string[]
|
|
263
|
+
/** Every dependency, for the build. */
|
|
264
|
+
readonly install: string
|
|
265
|
+
/** Production dependencies only, without the compiler, Vite, TypeScript... */
|
|
266
|
+
readonly production: string
|
|
267
|
+
readonly build: string
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function dockerSteps(pm: PackageManager): DockerSteps {
|
|
271
|
+
// pnpm and yarn come from Corepack (bundled with Node.js 24); the packageManager field of
|
|
272
|
+
// package.json, when present, picks their version
|
|
273
|
+
const corepack = (tool: string): string[] => [
|
|
274
|
+
'ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0',
|
|
275
|
+
`RUN corepack enable && corepack install --global ${tool}`,
|
|
276
|
+
]
|
|
277
|
+
switch (pm) {
|
|
278
|
+
case 'bun':
|
|
279
|
+
return {
|
|
280
|
+
manifests: 'package.json bun.lock',
|
|
281
|
+
setup: [],
|
|
282
|
+
install: 'bun install --frozen-lockfile',
|
|
283
|
+
// --omit=peer: Bun would also install the optional peers the dev dependencies satisfy (the
|
|
284
|
+
// @znight/* packages declare the compiler as one); required peers are direct dependencies
|
|
285
|
+
production: 'bun install --frozen-lockfile --production --omit=peer',
|
|
286
|
+
build: 'bun run build',
|
|
287
|
+
}
|
|
288
|
+
case 'pnpm':
|
|
289
|
+
return {
|
|
290
|
+
manifests: 'package.json pnpm-lock.yaml',
|
|
291
|
+
setup: corepack('pnpm@10'),
|
|
292
|
+
install: 'pnpm install --frozen-lockfile',
|
|
293
|
+
production: 'pnpm install --frozen-lockfile --prod',
|
|
294
|
+
build: 'pnpm run build',
|
|
295
|
+
}
|
|
296
|
+
case 'yarn':
|
|
297
|
+
return {
|
|
298
|
+
manifests: 'package.json yarn.lock .yarnrc.yml',
|
|
299
|
+
setup: corepack('yarn@4'),
|
|
300
|
+
install: 'yarn install --immutable',
|
|
301
|
+
production: 'yarn workspaces focus --all --production',
|
|
302
|
+
build: 'yarn build',
|
|
303
|
+
}
|
|
304
|
+
default:
|
|
305
|
+
return {
|
|
306
|
+
manifests: 'package.json package-lock.json',
|
|
307
|
+
setup: [],
|
|
308
|
+
install: 'npm ci',
|
|
309
|
+
// without the devDependencies in package.json, npm also leaves out the optional peers they
|
|
310
|
+
// satisfy (the @znight/* packages declare the compiler as one): `npm ci --omit=dev` keeps them
|
|
311
|
+
production: 'npm pkg delete devDependencies && npm ci --omit=dev',
|
|
312
|
+
build: 'npm run build',
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A multi-stage image: build with every dependency, run the bundle with the production ones. */
|
|
318
|
+
function dockerfile(flags: Flags): string {
|
|
319
|
+
const steps = dockerSteps(flags.pm)
|
|
320
|
+
const runtime = flags.bun ? 'bun' : 'node'
|
|
321
|
+
const [baseImage, runImage] = flags.bun ? ['oven/bun:1', 'oven/bun:1-slim'] : ['node:24-slim', 'node:24-slim']
|
|
322
|
+
// the images ship neither curl nor wget: the runtime itself probes GET /health
|
|
323
|
+
const probe =
|
|
324
|
+
"fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/health').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
|
|
325
|
+
const command = flags.bun
|
|
326
|
+
? '["bun", "--no-env-file", "dist/main.js"]'
|
|
327
|
+
: '["node", "--enable-source-maps", "dist/main.js"]'
|
|
328
|
+
const envFiles = flags.bun
|
|
329
|
+
? '\n# --no-env-file: Bun would load .env files into process.env by itself, with its own precedence'
|
|
330
|
+
: ''
|
|
331
|
+
return `# syntax=docker/dockerfile:1
|
|
332
|
+
# Production image: docker build -t ${dockerTag(flags.name)} .
|
|
333
|
+
# Installs are frozen: install the dependencies once (${flags.pm} install) so that the lockfile exists.
|
|
334
|
+
|
|
335
|
+
FROM ${baseImage} AS base
|
|
336
|
+
${steps.setup.map(line => `${line}\n`).join('')}WORKDIR /app
|
|
337
|
+
|
|
338
|
+
# --- production dependencies only, at the versions of the lockfile ----------------------------------
|
|
339
|
+
FROM base AS deps
|
|
340
|
+
COPY ${steps.manifests} ./
|
|
341
|
+
RUN ${steps.production}
|
|
342
|
+
|
|
343
|
+
# --- build: every dependency, since the Znight compiler, Vite and TypeScript run here ---------------
|
|
344
|
+
FROM base AS build
|
|
345
|
+
COPY ${steps.manifests} ./
|
|
346
|
+
RUN ${steps.install}
|
|
347
|
+
COPY . .
|
|
348
|
+
# generates the DI container (src/.znight), fails on any wiring error, bundles dist/main.js
|
|
349
|
+
RUN ${steps.build}
|
|
350
|
+
|
|
351
|
+
# --- runtime: the bundle and the production dependencies, as a non-root user ----------------------
|
|
352
|
+
FROM ${runImage}
|
|
353
|
+
WORKDIR /app
|
|
354
|
+
ENV NODE_ENV=production
|
|
355
|
+
COPY --from=build /app/package.json ./
|
|
356
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
357
|
+
COPY --from=build /app/dist ./dist
|
|
358
|
+
USER ${runtime}
|
|
359
|
+
EXPOSE 3000
|
|
360
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\
|
|
361
|
+
CMD ["${runtime}", "-e", "${probe}"]
|
|
362
|
+
# exec form: the runtime is PID 1 and receives SIGTERM directly (graceful shutdown)${envFiles}
|
|
363
|
+
CMD ${command}
|
|
364
|
+
`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A Docker image name for an npm package name (`@acme/api` -> `acme-api`). */
|
|
368
|
+
function dockerTag(name: string): string {
|
|
369
|
+
return name.replace(/^@/, '').replace('/', '-')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function dockerignore(): string {
|
|
373
|
+
return `# not part of the image: the build installs and generates everything itself
|
|
374
|
+
node_modules
|
|
375
|
+
dist
|
|
376
|
+
coverage
|
|
377
|
+
**/.znight
|
|
378
|
+
*.log
|
|
379
|
+
.git
|
|
380
|
+
.env
|
|
381
|
+
.env.*
|
|
382
|
+
Dockerfile
|
|
383
|
+
.dockerignore
|
|
384
|
+
`
|
|
385
|
+
}
|
|
386
|
+
|
|
222
387
|
// ---------------------------------------------------------------------------------------------------
|
|
223
388
|
// application
|
|
224
389
|
|
|
@@ -243,6 +408,7 @@ function mainTs(flags: Flags): string {
|
|
|
243
408
|
const imports = [
|
|
244
409
|
flags.config ? "import { ConfigPlugin } from '@znight/config'" : undefined,
|
|
245
410
|
"import { Application } from '@znight/core'",
|
|
411
|
+
flags.graphql ? "import { GraphQLPlugin } from '@znight/graphql'" : undefined,
|
|
246
412
|
"import { HttpPlugin } from '@znight/http'",
|
|
247
413
|
adapterImport(flags.adapter),
|
|
248
414
|
flags.messaging ? "import { MessagingPlugin } from '@znight/messaging'" : undefined,
|
|
@@ -250,6 +416,7 @@ function mainTs(flags: Flags): string {
|
|
|
250
416
|
flags.observability ? "import { ObservabilityPlugin } from '@znight/observability'" : undefined,
|
|
251
417
|
flags.openapi ? "import { OpenApiPlugin } from '@znight/openapi'" : undefined,
|
|
252
418
|
flags.schedule ? "import { SchedulePlugin } from '@znight/schedule'" : undefined,
|
|
419
|
+
flags.websocket ? "import { WebSocketPlugin } from '@znight/websocket'" : undefined,
|
|
253
420
|
].filter(line => line !== undefined)
|
|
254
421
|
const localImports = [
|
|
255
422
|
"import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'",
|
|
@@ -260,7 +427,17 @@ function mainTs(flags: Flags): string {
|
|
|
260
427
|
if (flags.observability) {
|
|
261
428
|
plugins.push(
|
|
262
429
|
' // first, so that its hooks wrap everything: request logs, metrics (with prom-client), traces',
|
|
263
|
-
' ObservabilityPlugin.configure(
|
|
430
|
+
' ObservabilityPlugin.configure({',
|
|
431
|
+
' logging: {',
|
|
432
|
+
` name: ${quote(flags.name)},`,
|
|
433
|
+
' },',
|
|
434
|
+
' // metrics: {',
|
|
435
|
+
' // // options of the metrics registry',
|
|
436
|
+
' // },',
|
|
437
|
+
' // tracing: {',
|
|
438
|
+
' // // options of the tracer',
|
|
439
|
+
' // },',
|
|
440
|
+
' }),',
|
|
264
441
|
)
|
|
265
442
|
}
|
|
266
443
|
if (flags.config) {
|
|
@@ -299,8 +476,20 @@ function mainTs(flags: Flags): string {
|
|
|
299
476
|
}
|
|
300
477
|
if (flags.openapi) {
|
|
301
478
|
plugins.push(
|
|
302
|
-
|
|
303
|
-
|
|
479
|
+
` // GET /openapi.json and the API reference at /docs, with ${OPENAPI_UI_LABELS[flags.openapiUi]} (document options: src/api.ts)`,
|
|
480
|
+
` OpenApiPlugin.configure({ ui: ${quote(flags.openapiUi)} }),`,
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
if (flags.graphql) {
|
|
484
|
+
plugins.push(
|
|
485
|
+
' // POST /graphql, GET /graphql (GraphiQL in development): the schema is derived from the resolvers',
|
|
486
|
+
' GraphQLPlugin.configure(),',
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
if (flags.websocket) {
|
|
490
|
+
plugins.push(
|
|
491
|
+
" // WebSocket gateways on the HTTP server's port (ws on Node.js, Bun's own WebSocket on Bun)",
|
|
492
|
+
' WebSocketPlugin.configure(),',
|
|
304
493
|
)
|
|
305
494
|
}
|
|
306
495
|
|
|
@@ -320,6 +509,20 @@ await app.start()
|
|
|
320
509
|
`
|
|
321
510
|
}
|
|
322
511
|
|
|
512
|
+
/** A single-quoted TypeScript string literal of `value` (quotes, backslashes and line breaks escaped). */
|
|
513
|
+
function quote(value: string): string {
|
|
514
|
+
let out = "'"
|
|
515
|
+
for (const char of value) {
|
|
516
|
+
const code = char.codePointAt(0) ?? 0
|
|
517
|
+
if (char === "'" || char === '\\') out += `\\${char}`
|
|
518
|
+
else if (char === '\n') out += '\\n'
|
|
519
|
+
else if (char === '\r') out += '\\r'
|
|
520
|
+
else if (code < 0x20 || code === 0x2028 || code === 0x2029) out += `\\u${code.toString(16).padStart(4, '0')}`
|
|
521
|
+
else out += char
|
|
522
|
+
}
|
|
523
|
+
return `${out}'`
|
|
524
|
+
}
|
|
525
|
+
|
|
323
526
|
function appConfig(): string {
|
|
324
527
|
return `import { Config, Env } from '@znight/config'
|
|
325
528
|
|
|
@@ -519,6 +722,60 @@ export class Api {}
|
|
|
519
722
|
`
|
|
520
723
|
}
|
|
521
724
|
|
|
725
|
+
function greetingResolver(): string {
|
|
726
|
+
return `import { Args, Query, Resolver } from '@znight/graphql'
|
|
727
|
+
|
|
728
|
+
import { GreetingService } from './greeting.service.js'
|
|
729
|
+
|
|
730
|
+
/** A greeting. */
|
|
731
|
+
export interface Greeting {
|
|
732
|
+
readonly message: string
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* GraphQL resolver (code-first): the compiler derives the schema from the TypeScript types and the
|
|
737
|
+
* JSDoc, \`type Query { greeting(name: String!): Greeting! }\` here.
|
|
738
|
+
*/
|
|
739
|
+
@Resolver()
|
|
740
|
+
export class GreetingResolver {
|
|
741
|
+
constructor(private readonly greetings: GreetingService) {}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Greets someone.
|
|
745
|
+
* @param name Who to greet.
|
|
746
|
+
*/
|
|
747
|
+
@Query()
|
|
748
|
+
greeting(@Args('name') name: string): Greeting {
|
|
749
|
+
return { message: this.greetings.greet(name) }
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
`
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function greetingGateway(): string {
|
|
756
|
+
return `import { Gateway, OnMessage, Payload } from '@znight/websocket'
|
|
757
|
+
|
|
758
|
+
import { CreateGreeting } from './greeting.schemas.js'
|
|
759
|
+
import { GreetingService } from './greeting.service.js'
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* WebSocket gateway on ws://localhost:3000/ws, served by the HTTP server. Frames are JSON: the
|
|
763
|
+
* request {"event": "greet", "data": {"name": "Ada"}, "id": 1} is answered with
|
|
764
|
+
* {"id": 1, "data": {"message": "Hello, Ada."}}.
|
|
765
|
+
*/
|
|
766
|
+
@Gateway('/ws')
|
|
767
|
+
export class GreetingGateway {
|
|
768
|
+
constructor(private readonly greetings: GreetingService) {}
|
|
769
|
+
|
|
770
|
+
/** The payload is validated by the schema (a BAD_REQUEST error reply otherwise). */
|
|
771
|
+
@OnMessage('greet')
|
|
772
|
+
greet(@Payload(CreateGreeting) input: CreateGreeting): { message: string } {
|
|
773
|
+
return { message: this.greetings.greet(input.name, input.excited) }
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
`
|
|
777
|
+
}
|
|
778
|
+
|
|
522
779
|
// ---------------------------------------------------------------------------------------------------
|
|
523
780
|
// tests
|
|
524
781
|
|
|
@@ -596,29 +853,45 @@ describe('GreetingService', () => {
|
|
|
596
853
|
`
|
|
597
854
|
}
|
|
598
855
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
856
|
+
/** Imports and `plugins` array of a `TestApp` test, with the plugins the providers need. */
|
|
857
|
+
function testAppPlugins(
|
|
858
|
+
flags: Flags,
|
|
859
|
+
names: string,
|
|
860
|
+
extra: { readonly graphql?: boolean; readonly websocket?: boolean } = {},
|
|
861
|
+
): { imports: string; plugins: string } {
|
|
862
|
+
const imports = [testImports(flags, names)]
|
|
863
|
+
if (extra.graphql === true) {
|
|
864
|
+
imports.push("import { GraphQLPlugin } from '@znight/graphql'")
|
|
865
|
+
imports.push("import { executeOperation } from '@znight/graphql/testing'")
|
|
866
|
+
}
|
|
867
|
+
imports.push("import { HttpPlugin } from '@znight/http'")
|
|
604
868
|
if (flags.messaging) {
|
|
605
869
|
imports.push("import { MessagingPlugin } from '@znight/messaging'")
|
|
606
870
|
imports.push("import { memory } from '@znight/messaging/memory'")
|
|
607
871
|
}
|
|
608
872
|
if (flags.observability) imports.push("import { ObservabilityPlugin } from '@znight/observability'")
|
|
609
873
|
imports.push("import { TestApp } from '@znight/testing'")
|
|
874
|
+
if (extra.websocket === true) imports.push("import { WebSocketPlugin } from '@znight/websocket'")
|
|
610
875
|
const plugins: string[] = []
|
|
611
|
-
// no metrics server
|
|
876
|
+
// metrics off: no metrics server colliding on :9464 with `dev` or other test files, and no need for
|
|
877
|
+
// prom-client (any metrics object requires it)
|
|
612
878
|
if (flags.observability) {
|
|
613
|
-
plugins.push("ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics:
|
|
879
|
+
plugins.push("ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: false })")
|
|
614
880
|
}
|
|
615
881
|
if (flags.messaging) plugins.push('MessagingPlugin.configure({ driver: memory() })')
|
|
616
882
|
// port 0: TestApp.inject() sends requests in memory, the server listens on a free port
|
|
617
883
|
plugins.push('HttpPlugin.configure({ port: 0 })')
|
|
618
|
-
|
|
884
|
+
if (extra.graphql === true) plugins.push('GraphQLPlugin.configure()')
|
|
885
|
+
if (extra.websocket === true) plugins.push('WebSocketPlugin.configure()')
|
|
886
|
+
const list =
|
|
619
887
|
plugins.length === 1 ? `[${plugins[0]}]` : `[\n${plugins.map(plugin => ` ${plugin},`).join('\n')}\n ]`
|
|
888
|
+
return { imports: imports.join('\n'), plugins: list }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function greetingControllerTest(flags: Flags): string {
|
|
892
|
+
const { imports, plugins: pluginList } = testAppPlugins(flags, 'afterEach, beforeEach, describe, expect, test')
|
|
620
893
|
const greeting = 'Hello'
|
|
621
|
-
return `${imports
|
|
894
|
+
return `${imports}
|
|
622
895
|
|
|
623
896
|
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
624
897
|
|
|
@@ -656,6 +929,60 @@ describe('GreetingController', () => {
|
|
|
656
929
|
`
|
|
657
930
|
}
|
|
658
931
|
|
|
932
|
+
function greetingResolverTest(flags: Flags): string {
|
|
933
|
+
const { imports, plugins } = testAppPlugins(flags, 'describe, expect, test', { graphql: true })
|
|
934
|
+
return `${imports}
|
|
935
|
+
|
|
936
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
937
|
+
|
|
938
|
+
// executeOperation() sends the operation through the whole HTTP pipeline, in memory
|
|
939
|
+
describe('GreetingResolver', () => {
|
|
940
|
+
test('query { greeting }', async () => {
|
|
941
|
+
const app = await TestApp.create({
|
|
942
|
+
metadata,
|
|
943
|
+
plugins: ${plugins},
|
|
944
|
+
})
|
|
945
|
+
try {
|
|
946
|
+
const response = await executeOperation(app, { query: '{ greeting(name: "Ada") { message } }' })
|
|
947
|
+
|
|
948
|
+
expect(response.status).toBe(200)
|
|
949
|
+
expect(response.errors).toBeUndefined()
|
|
950
|
+
expect(response.data).toEqual({ greeting: { message: 'Hello, Ada.' } })
|
|
951
|
+
} finally {
|
|
952
|
+
await app.close()
|
|
953
|
+
}
|
|
954
|
+
})
|
|
955
|
+
})
|
|
956
|
+
`
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function greetingGatewayTest(flags: Flags): string {
|
|
960
|
+
const { imports, plugins } = testAppPlugins(flags, 'describe, expect, test', { websocket: true })
|
|
961
|
+
return `${imports}
|
|
962
|
+
|
|
963
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
964
|
+
|
|
965
|
+
// app.connect() opens an in-memory connection, through the real handshake
|
|
966
|
+
describe('GreetingGateway', () => {
|
|
967
|
+
test('answers "greet" requests', async () => {
|
|
968
|
+
const app = await TestApp.create({
|
|
969
|
+
metadata,
|
|
970
|
+
plugins: ${plugins},
|
|
971
|
+
})
|
|
972
|
+
try {
|
|
973
|
+
const client = await app.connect('/ws')
|
|
974
|
+
|
|
975
|
+
expect(await client.request('greet', { name: 'Ada', excited: true })).toEqual({ message: 'Hello, Ada!' })
|
|
976
|
+
await expect(client.request('greet', { name: '' })).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
977
|
+
await client.close()
|
|
978
|
+
} finally {
|
|
979
|
+
await app.close()
|
|
980
|
+
}
|
|
981
|
+
})
|
|
982
|
+
})
|
|
983
|
+
`
|
|
984
|
+
}
|
|
985
|
+
|
|
659
986
|
// ---------------------------------------------------------------------------------------------------
|
|
660
987
|
// README
|
|
661
988
|
|
|
@@ -671,8 +998,25 @@ function readme(options: ProjectOptions, flags: Flags): string {
|
|
|
671
998
|
flags.messaging ? '- **Messaging** with `@znight/messaging` (in-memory broker in `src/main.ts`)' : undefined,
|
|
672
999
|
flags.schedule ? '- **Scheduled jobs** with `@znight/schedule` (`src/app/heartbeat.jobs.ts`)' : undefined,
|
|
673
1000
|
flags.openapi
|
|
674
|
-
?
|
|
1001
|
+
? `- **OpenAPI** with \`@znight/openapi\` (\`/openapi.json\`, ${OPENAPI_UI_LABELS[flags.openapiUi]} API reference at \`/docs\`, options in \`src/api.ts\`)`
|
|
675
1002
|
: undefined,
|
|
1003
|
+
flags.graphql
|
|
1004
|
+
? '- **GraphQL** with `@znight/graphql` (code-first: the schema is derived from `src/app/greeting.resolver.ts`)'
|
|
1005
|
+
: undefined,
|
|
1006
|
+
flags.websocket
|
|
1007
|
+
? '- **WebSocket** with `@znight/websocket` (`src/app/greeting.gateway.ts`, on the HTTP port at `/ws`)'
|
|
1008
|
+
: undefined,
|
|
1009
|
+
].filter(line => line !== undefined)
|
|
1010
|
+
const app = [
|
|
1011
|
+
flags.config ? 'app.config.ts # @Config: settings read from the environment' : undefined,
|
|
1012
|
+
flags.messaging ? 'greeting.consumer.ts # @Consumer: handles the "greetings" queue' : undefined,
|
|
1013
|
+
'greeting.controller.ts # @Controller: /greetings routes',
|
|
1014
|
+
flags.websocket ? 'greeting.gateway.ts # @Gateway: the /ws WebSocket endpoint' : undefined,
|
|
1015
|
+
flags.graphql ? 'greeting.resolver.ts # @Resolver: the greeting GraphQL query' : undefined,
|
|
1016
|
+
'greeting.schemas.ts # Zod schemas',
|
|
1017
|
+
'greeting.service.ts # @Injectable',
|
|
1018
|
+
'health.controller.ts # GET /health',
|
|
1019
|
+
flags.schedule ? 'heartbeat.jobs.ts # @Cron: a job every minute' : undefined,
|
|
676
1020
|
].filter(line => line !== undefined)
|
|
677
1021
|
const tree = [
|
|
678
1022
|
'src/',
|
|
@@ -682,16 +1026,11 @@ function readme(options: ProjectOptions, flags: Flags): string {
|
|
|
682
1026
|
: undefined,
|
|
683
1027
|
'├─ .znight/ # generated DI container (git-ignored)',
|
|
684
1028
|
'└─ app/',
|
|
685
|
-
|
|
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,
|
|
1029
|
+
...app.map((line, index) => ` ${index === app.length - 1 ? '└─' : '├─'} ${line}`),
|
|
692
1030
|
'test/ # TestBed (unit) and TestApp (HTTP) tests',
|
|
693
1031
|
'vite.config.ts # znight() plugin: generates the container, server build',
|
|
694
1032
|
'znight.config.ts # compiler options',
|
|
1033
|
+
flags.docker ? 'Dockerfile # production image (multi-stage, non-root)' : undefined,
|
|
695
1034
|
].filter(line => line !== undefined)
|
|
696
1035
|
const configNote = flags.config
|
|
697
1036
|
? `
|
|
@@ -707,6 +1046,22 @@ problem listed.${
|
|
|
707
1046
|
\`@znight/config\`, the same way on Node.js and Bun.`
|
|
708
1047
|
: ''
|
|
709
1048
|
}
|
|
1049
|
+
`
|
|
1050
|
+
: ''
|
|
1051
|
+
const image = dockerTag(options.name)
|
|
1052
|
+
const dockerNote = flags.docker
|
|
1053
|
+
? `
|
|
1054
|
+
## Docker
|
|
1055
|
+
|
|
1056
|
+
\`\`\`sh
|
|
1057
|
+
docker build -t ${image} .
|
|
1058
|
+
docker run --rm -p 3000:3000 ${image}
|
|
1059
|
+
\`\`\`
|
|
1060
|
+
|
|
1061
|
+
The \`Dockerfile\` builds with every dependency (the Znight compiler runs during the build), then
|
|
1062
|
+
ships \`dist/\` with the production dependencies only, on ${flags.bun ? 'Bun' : 'Node.js'}, as a non-root user, with
|
|
1063
|
+
\`NODE_ENV=production\` and a health check on \`GET /health\`. Installs are frozen: the lockfile of
|
|
1064
|
+
\`${flags.pm} install\` must exist.
|
|
710
1065
|
`
|
|
711
1066
|
: ''
|
|
712
1067
|
const testNote = flags.bun
|
|
@@ -742,7 +1097,7 @@ ${
|
|
|
742
1097
|
${run('dev')}
|
|
743
1098
|
curl localhost:3000/health
|
|
744
1099
|
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' : ''}
|
|
1100
|
+
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' : ''}${flags.graphql ? `\ncurl localhost:3000/graphql -H 'content-type: application/json' -d '{"query":"{ greeting(name: \\"Ada\\") { message } }"}'` : ''}
|
|
746
1101
|
\`\`\`
|
|
747
1102
|
|
|
748
1103
|
## Project structure
|
|
@@ -759,7 +1114,7 @@ Add code with the generators:
|
|
|
759
1114
|
${znight} g service users/user # src/users/user.service.ts
|
|
760
1115
|
${znight} g controller users/user # src/users/user.controller.ts${flags.schedule ? `\n${znight} g job reports/digest # src/reports/digest.jobs.ts` : ''}
|
|
761
1116
|
\`\`\`
|
|
762
|
-
${configNote}
|
|
1117
|
+
${configNote}${dockerNote}
|
|
763
1118
|
## Tests
|
|
764
1119
|
|
|
765
1120
|
${testNote}
|
package/src/scaffold/versions.ts
CHANGED
|
@@ -9,11 +9,13 @@ export const DEPENDENCY_VERSIONS: Readonly<Record<string, string>> = {
|
|
|
9
9
|
'@types/node': '^24.0.0',
|
|
10
10
|
express: '^5.3.0',
|
|
11
11
|
fastify: '^5.12.7',
|
|
12
|
+
graphql: '^17.0.2',
|
|
12
13
|
hono: '^4.13.14',
|
|
13
14
|
'light-my-request': '^6.8.0',
|
|
14
15
|
typescript: '^7.0.0',
|
|
15
16
|
vite: '^8.0.0',
|
|
16
17
|
vitest: '^4.1.0',
|
|
18
|
+
ws: '^8.22.0',
|
|
17
19
|
zod: '^4.6.0',
|
|
18
20
|
}
|
|
19
21
|
|