kavoru 0.5.0 → 0.7.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/features.ts +662 -647
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kavoru",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Scaffold a new Kavoru (Elysia + Bun) backend from the official template",
5
5
  "type": "module",
6
6
  "bin": {
package/src/features.ts CHANGED
@@ -1,647 +1,662 @@
1
- import { rm } from "node:fs/promises";
2
- import path from "node:path";
3
- import { log } from "./log";
4
-
5
- export type FeatureId =
6
- | "auth"
7
- | "prisma"
8
- | "otel"
9
- | "sentry"
10
- | "kafka"
11
- | "websocket"
12
- | "resend"
13
- | "cron"
14
- | "docker";
15
-
16
- export type FeatureSelection = Record<FeatureId, boolean>;
17
-
18
- export type FeatureDef = {
19
- id: FeatureId;
20
- label: string;
21
- description: string;
22
- };
23
-
24
- export const FEATURES: FeatureDef[] = [
25
- {
26
- id: "auth",
27
- label: "JWT Authentication",
28
- description: "Bearer auth, sign-in route, protected routes",
29
- },
30
- {
31
- id: "prisma",
32
- label: "Prisma + PostgreSQL",
33
- description: "Prisma 7 config, migrations, seed scripts",
34
- },
35
- {
36
- id: "otel",
37
- label: "OpenTelemetry",
38
- description: "OTLP tracing with Bun-compatible exporter",
39
- },
40
- {
41
- id: "sentry",
42
- label: "Sentry + Spotlight",
43
- description: "Error monitoring and local Spotlight UI",
44
- },
45
- {
46
- id: "kafka",
47
- label: "Kafka",
48
- description: "Producer, consumer, and example HTTP endpoints",
49
- },
50
- {
51
- id: "websocket",
52
- label: "WebSockets",
53
- description: "Validated real-time connections with rooms",
54
- },
55
- {
56
- id: "resend",
57
- label: "Resend Email",
58
- description: "Transactional email via sendEmail()",
59
- },
60
- {
61
- id: "cron",
62
- label: "Cron Jobs",
63
- description: "Scheduled tasks via @elysiajs/cron",
64
- },
65
- {
66
- id: "docker",
67
- label: "Docker",
68
- description: "Dockerfile and Docker Compose stack",
69
- },
70
- ];
71
-
72
- export const FEATURE_IDS = FEATURES.map((feature) => feature.id);
73
-
74
- export const ALL_FEATURES: FeatureSelection = Object.fromEntries(
75
- FEATURE_IDS.map((id) => [id, true]),
76
- ) as FeatureSelection;
77
-
78
- export const MINIMAL_FEATURES: FeatureSelection = Object.fromEntries(
79
- FEATURE_IDS.map((id) => [id, false]),
80
- ) as FeatureSelection;
81
-
82
- const FEATURE_PATHS: Record<FeatureId, string[]> = {
83
- auth: [
84
- "src/modules/signin",
85
- "src/modules/protected",
86
- "src/middleware/auth.ts",
87
- "src/infra/auth",
88
- "src/constants/jwt.ts",
89
- "src/models/schemas/signin.ts",
90
- ],
91
- prisma: ["prisma.config.ts", "src/infra/prisma"],
92
- otel: ["src/infra/telemetry"],
93
- sentry: [
94
- "src/infra/sentry",
95
- "src/constants/sentry.ts",
96
- "__tests__/sentry.test.ts",
97
- ],
98
- kafka: [
99
- "src/modules/kafka",
100
- "src/infra/kafka",
101
- "src/models/schemas/kafka.ts",
102
- "__tests__/kafka.test.ts",
103
- ],
104
- websocket: [
105
- "src/modules/realtime",
106
- "src/models/schemas/realtime.ts",
107
- "__tests__/realtime.test.ts",
108
- ],
109
- resend: ["src/infra/resend"],
110
- cron: ["src/schedules"],
111
- docker: ["Dockerfile", "docker-compose.yaml"],
112
- };
113
-
114
- const FEATURE_DEPENDENCIES: Partial<
115
- Record<FeatureId, { dependencies?: string[]; devDependencies?: string[] }>
116
- > = {
117
- auth: { dependencies: ["@elysiajs/bearer", "@elysiajs/jwt"] },
118
- prisma: {
119
- dependencies: ["@prisma/adapter-pg", "@prisma/client"],
120
- devDependencies: ["prisma"],
121
- },
122
- otel: {
123
- dependencies: [
124
- "@elysiajs/opentelemetry",
125
- "@opentelemetry/exporter-trace-otlp-http",
126
- "@opentelemetry/otlp-transformer",
127
- "@opentelemetry/sdk-trace-node",
128
- ],
129
- },
130
- sentry: { dependencies: ["@sentry/elysia"] },
131
- kafka: { dependencies: ["kafkajs"] },
132
- resend: { dependencies: ["resend"] },
133
- cron: { dependencies: ["@elysiajs/cron"] },
134
- };
135
-
136
- const FEATURE_SCRIPTS: Partial<Record<FeatureId, string[]>> = {
137
- otel: ["otel:view", "otel:tui"],
138
- sentry: ["sentry:spotlight"],
139
- prisma: ["seed"],
140
- };
141
-
142
- function enabledFeatures(selection: FeatureSelection): FeatureId[] {
143
- return FEATURE_IDS.filter((id) => selection[id]);
144
- }
145
-
146
- function disabledFeatures(selection: FeatureSelection): FeatureId[] {
147
- return FEATURE_IDS.filter((id) => !selection[id]);
148
- }
149
-
150
- export function parseFeatureIncludeList(input: string): FeatureSelection {
151
- const normalized = input.trim().toLowerCase();
152
- if (!normalized || normalized === "all") return { ...ALL_FEATURES };
153
- if (normalized === "minimal" || normalized === "none") {
154
- return { ...MINIMAL_FEATURES };
155
- }
156
-
157
- const requested = normalized
158
- .split(",")
159
- .map((part) => part.trim())
160
- .filter(Boolean);
161
-
162
- const unknown = requested.filter(
163
- (id) => !FEATURE_IDS.includes(id as FeatureId),
164
- );
165
- if (unknown.length > 0) {
166
- throw new Error(
167
- `Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
168
- );
169
- }
170
-
171
- const selection = { ...MINIMAL_FEATURES };
172
- for (const id of requested) {
173
- selection[id as FeatureId] = true;
174
- }
175
- return selection;
176
- }
177
-
178
- export function parseFeatureExcludeList(
179
- excluded: string[],
180
- base: FeatureSelection = ALL_FEATURES,
181
- ): FeatureSelection {
182
- const selection = { ...base };
183
- const unknown: string[] = [];
184
-
185
- for (const raw of excluded) {
186
- const id = raw.trim().toLowerCase();
187
- if (!id) continue;
188
- if (!FEATURE_IDS.includes(id as FeatureId)) {
189
- unknown.push(id);
190
- continue;
191
- }
192
- selection[id as FeatureId] = false;
193
- }
194
-
195
- if (unknown.length > 0) {
196
- throw new Error(
197
- `Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
198
- );
199
- }
200
-
201
- return selection;
202
- }
203
-
204
- export function formatFeatureSelection(selection: FeatureSelection): string {
205
- const enabled = enabledFeatures(selection);
206
- if (enabled.length === 0) return "core only";
207
- return enabled.join(", ");
208
- }
209
-
210
- async function removePaths(projectDir: string, relativePaths: string[]) {
211
- for (const relativePath of relativePaths) {
212
- await rm(path.join(projectDir, relativePath), {
213
- recursive: true,
214
- force: true,
215
- });
216
- }
217
- }
218
-
219
- async function readText(projectDir: string, relativePath: string) {
220
- const file = Bun.file(path.join(projectDir, relativePath));
221
- if (!(await file.exists())) return null;
222
- return file.text();
223
- }
224
-
225
- async function writeText(
226
- projectDir: string,
227
- relativePath: string,
228
- content: string,
229
- ) {
230
- await Bun.write(path.join(projectDir, relativePath), content);
231
- }
232
-
233
- function removeImportLines(content: string, modules: string[]) {
234
- let next = content;
235
- for (const modulePath of modules) {
236
- const escaped = modulePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
237
- next = next.replace(
238
- new RegExp(`^import .* from ["']${escaped}["'];?\\r?\\n`, "gm"),
239
- "",
240
- );
241
- }
242
- return next;
243
- }
244
-
245
- function removeUseLines(content: string, identifiers: string[]) {
246
- let next = content;
247
- for (const identifier of identifiers) {
248
- next = next.replace(
249
- new RegExp(`^\\s*\\.use\\(${identifier}\\)\\r?\\n`, "gm"),
250
- "",
251
- );
252
- }
253
- return next;
254
- }
255
-
256
- async function patchModulesIndex(
257
- projectDir: string,
258
- selection: FeatureSelection,
259
- ) {
260
- const relativePath = "src/modules/index.ts";
261
- const current = await readText(projectDir, relativePath);
262
- if (!current) return;
263
-
264
- let content = current;
265
- if (!selection.sentry) {
266
- content = removeImportLines(content, ["../infra/sentry"]);
267
- content = removeUseLines(content, ["withSentry"]);
268
- }
269
- if (!selection.otel) {
270
- content = removeImportLines(content, ["../infra/telemetry"]);
271
- content = removeUseLines(content, ["withOpenTelemetry"]);
272
- }
273
-
274
- content = content.replace(/app\s+\.use\(/g, "app\n .use(");
275
-
276
- await writeText(projectDir, relativePath, content);
277
- }
278
-
279
- async function patchEntryIndex(projectDir: string, selection: FeatureSelection) {
280
- const imports = [
281
- selection.sentry
282
- ? 'import { initSentry, flushSentry } from "./infra/sentry";'
283
- : null,
284
- selection.kafka
285
- ? 'import { startKafka, stopKafka } from "./infra/kafka";'
286
- : null,
287
- 'import { HttpServer } from "./server/index";',
288
- 'import { logger } from "./common/logger";',
289
- selection.kafka ? 'import { InternalServerError } from "elysia";' : null,
290
- ].filter(Boolean) as string[];
291
-
292
- const body: string[] = [];
293
-
294
- if (selection.sentry) {
295
- body.push("", "initSentry();");
296
- }
297
-
298
- body.push("", "const server = new HttpServer();", "");
299
-
300
- if (selection.kafka) {
301
- body.push(
302
- "void server.start().then(async () => {",
303
- " try {",
304
- " await startKafka();",
305
- " } catch (error) {",
306
- ' logger.error("Failed to start Kafka", { error });',
307
- ' throw new InternalServerError("Failed to start Kafka");',
308
- " }",
309
- "});",
310
- );
311
- } else {
312
- body.push("void server.start();");
313
- }
314
-
315
- body.push(
316
- "",
317
- "const shutdown = (signal: string) => {",
318
- " return async () => {",
319
- " logger.warn(`Received ${signal}, shutting down`);",
320
- " await server.stop();",
321
- );
322
-
323
- if (selection.kafka) {
324
- body.push(" await stopKafka();");
325
- }
326
- if (selection.sentry) {
327
- body.push(" await flushSentry();");
328
- }
329
-
330
- body.push(
331
- " process.exit(0);",
332
- " };",
333
- "};",
334
- "",
335
- 'process.on("SIGINT", () => void shutdown("SIGINT")());',
336
- 'process.on("SIGTERM", () => void shutdown("SIGTERM")());',
337
- "",
338
- );
339
-
340
- await writeText(projectDir, "src/index.ts", [...imports, ...body].join("\n"));
341
- }
342
-
343
- async function patchServerIndex(projectDir: string, selection: FeatureSelection) {
344
- const relativePath = "src/server/index.ts";
345
- const current = await readText(projectDir, relativePath);
346
- if (!current) return;
347
-
348
- let content = current;
349
-
350
- if (selection.cron) {
351
- content = content.replace(
352
- /\/\/import \{ schedules \} from "\.\.\/schedules";.*\n/,
353
- 'import { schedules } from "../schedules";\n',
354
- );
355
- content = content.replace(
356
- /\/\/\.use\(schedules\);.*\n/,
357
- " .use(schedules);\n",
358
- );
359
- } else {
360
- content = content.replace(
361
- /^import \{ schedules \} from "\.\.\/schedules";\n/m,
362
- "",
363
- );
364
- content = content.replace(/^\s*\.use\(schedules\);\n/m, "");
365
- }
366
-
367
- if (!selection.websocket) {
368
- content = content.replace(
369
- /new Elysia\(\{\s*websocket:\s*\{\s*idleTimeout:\s*120,\s*\},\s*\}\)/,
370
- "new Elysia()",
371
- );
372
- }
373
-
374
- await writeText(projectDir, relativePath, content);
375
- }
376
-
377
- async function patchPackageJson(
378
- projectDir: string,
379
- selection: FeatureSelection,
380
- ) {
381
- const pkgPath = path.join(projectDir, "package.json");
382
- const pkgFile = Bun.file(pkgPath);
383
- if (!(await pkgFile.exists())) return;
384
-
385
- const pkg = (await pkgFile.json()) as {
386
- dependencies?: Record<string, string>;
387
- devDependencies?: Record<string, string>;
388
- scripts?: Record<string, string>;
389
- };
390
-
391
- for (const featureId of disabledFeatures(selection)) {
392
- const deps = FEATURE_DEPENDENCIES[featureId];
393
- if (deps?.dependencies) {
394
- for (const name of deps.dependencies) {
395
- delete pkg.dependencies?.[name];
396
- }
397
- }
398
- if (deps?.devDependencies) {
399
- for (const name of deps.devDependencies) {
400
- delete pkg.devDependencies?.[name];
401
- }
402
- }
403
-
404
- const scripts = FEATURE_SCRIPTS[featureId];
405
- if (scripts) {
406
- for (const scriptName of scripts) {
407
- delete pkg.scripts?.[scriptName];
408
- }
409
- }
410
- }
411
-
412
- if (!selection.prisma && pkg.scripts) {
413
- pkg.scripts.start = "bun run src/index.ts";
414
- }
415
-
416
- await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
417
- }
418
-
419
- export function buildEnvExample(
420
- packageName: string,
421
- selection: FeatureSelection,
422
- ): string {
423
- const lines = ["NODE_ENV=development", "PORT=3131", ""];
424
-
425
- if (selection.prisma) {
426
- lines.push(
427
- "DATABASE_URL=postgresql://user:password@localhost:5432/your_database",
428
- "",
429
- );
430
- }
431
-
432
- if (selection.otel) {
433
- lines.push(
434
- "# OpenTelemetry (optional in production; enabled by default in development)",
435
- "# Terminal 1: bun run otel:view",
436
- "# Terminal 2: bun run dev",
437
- "# Disable locally: OTEL_EXPORTER_OTLP_ENDPOINT=",
438
- "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces",
439
- `OTEL_SERVICE_NAME=${packageName}`,
440
- "",
441
- );
442
- }
443
-
444
- if (selection.sentry) {
445
- lines.push(
446
- "# Sentry Spotlight (local UI — enabled by default in development)",
447
- "# Terminal 1: bun run sentry:spotlight",
448
- "# Terminal 2: bun run dev",
449
- "# Disable locally: SENTRY_SPOTLIGHT=false",
450
- "SENTRY_SPOTLIGHT=true",
451
- "",
452
- "# Sentry cloud (optional — events also go to sentry.io when set)",
453
- "# SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
454
- "# SENTRY_TRACES_SAMPLE_RATE=1.0",
455
- "",
456
- );
457
- }
458
-
459
- if (selection.kafka) {
460
- lines.push(
461
- "# Kafka (enabled by default in development; disabled in test)",
462
- "# Start broker: docker compose up -d kafka",
463
- "# Host dev uses EXTERNAL listener on port 9094",
464
- "# KAFKA_ENABLED=false",
465
- "KAFKA_BROKERS=localhost:9094",
466
- `KAFKA_CLIENT_ID=${packageName}`,
467
- `KAFKA_GROUP_ID=${packageName}-consumer`,
468
- "KAFKA_TOPIC=elysia.events",
469
- "",
470
- );
471
- }
472
-
473
- if (selection.resend) {
474
- lines.push(
475
- "# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
476
- "# Create an API key: https://resend.com/api-keys",
477
- "# RESEND_ENABLED=false",
478
- "# RESEND_API_KEY=re_xxxxxxxx",
479
- "# RESEND_FROM=Acme <onboarding@resend.dev>",
480
- "",
481
- );
482
- }
483
-
484
- return `${lines.join("\n").trimEnd()}\n`;
485
- }
486
-
487
- async function patchEnvExample(
488
- projectDir: string,
489
- selection: FeatureSelection,
490
- packageName = "kavoru",
491
- ) {
492
- const content = buildEnvExample(packageName, selection);
493
- await writeText(projectDir, ".env.example", content);
494
- await writeText(projectDir, ".env", content);
495
- }
496
-
497
- async function patchDockerfile(projectDir: string, selection: FeatureSelection) {
498
- if (!selection.docker) return;
499
-
500
- const relativePath = "Dockerfile";
501
- const current = await readText(projectDir, relativePath);
502
- if (!current) return;
503
-
504
- let content = current;
505
- if (!selection.prisma) {
506
- content = content.replace(/^\s*COPY prisma\.config\.ts \.\/.*\n/m, "");
507
- content = content.replace(
508
- /^\s*# generate only needs the schema on disk, not a live database\n/m,
509
- "",
510
- );
511
- content = content.replace(
512
- /^\s*RUN if \[ -f src\/infra\/prisma\/schemas\/schema\.prisma \]; then bunx prisma generate; fi\n/m,
513
- "",
514
- );
515
- }
516
-
517
- await writeText(projectDir, relativePath, content);
518
- }
519
-
520
- function buildAppEnvironment(selection: FeatureSelection): string {
521
- const lines = [" NODE_ENV: production"];
522
- if (selection.kafka) {
523
- lines.push(" KAFKA_BROKERS: kafka:9092");
524
- }
525
- if (selection.otel) {
526
- lines.push(" OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318/v1/traces");
527
- }
528
- return ` environment:\n${lines.join("\n")}\n`;
529
- }
530
-
531
- function generateDockerCompose(selection: FeatureSelection): string {
532
- const appDependsOn = selection.kafka
533
- ? ` depends_on:
534
- kafka:
535
- condition: service_started
536
- `
537
- : "";
538
- const appEnvironment = buildAppEnvironment(selection);
539
-
540
- const kafkaService = selection.kafka
541
- ? `
542
- kafka:
543
- image: confluentinc/cp-kafka:7.6.1
544
- hostname: kafka
545
- ports:
546
- - "9094:9094"
547
- environment:
548
- CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
549
- KAFKA_NODE_ID: "0"
550
- KAFKA_PROCESS_ROLES: broker,controller
551
- KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
552
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
553
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
554
- KAFKA_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
555
- KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
556
- KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
557
- KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
558
- KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
559
- KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
560
- KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
561
- networks:
562
- - app_network
563
- restart: unless-stopped
564
- `
565
- : "";
566
-
567
- const jaegerService = selection.otel
568
- ? `
569
- jaeger:
570
- image: jaegertracing/all-in-one:1.62.0
571
- ports:
572
- - "16686:16686"
573
- - "4318:4318"
574
- environment:
575
- COLLECTOR_OTLP_ENABLED: "true"
576
- networks:
577
- - app_network
578
- restart: unless-stopped
579
- `
580
- : "";
581
-
582
- return `services:
583
- app:
584
- build:
585
- context: .
586
- target: build
587
- args:
588
- PORT: \${PORT:-3131}
589
- command: /app/server
590
- volumes:
591
- - ./src:/app/src
592
- networks:
593
- app_network:
594
- aliases:
595
- - app
596
- extra_hosts:
597
- - "host.docker.internal:host-gateway"
598
- expose:
599
- - "\${PORT}"
600
- restart: unless-stopped
601
- env_file:
602
- - .env
603
- ${appDependsOn}${appEnvironment} healthcheck:
604
- test: ["CMD", "curl", "-f", "http://localhost:\${PORT}/healthz"]
605
- interval: 600s
606
- timeout: 300s
607
- retries: 1
608
- start_period: 40s
609
- ${kafkaService}${jaegerService}
610
- networks:
611
- app_network:
612
- driver: bridge
613
- `;
614
- }
615
-
616
- async function patchDockerCompose(
617
- projectDir: string,
618
- selection: FeatureSelection,
619
- ) {
620
- if (!selection.docker) return;
621
- await writeText(projectDir, "docker-compose.yaml", generateDockerCompose(selection));
622
- }
623
-
624
- export async function applyFeatures(
625
- projectDir: string,
626
- selection: FeatureSelection,
627
- packageName = "kavoru",
628
- ): Promise<void> {
629
- const disabled = disabledFeatures(selection);
630
- log.step(`Applying feature selection (${formatFeatureSelection(selection)})`);
631
-
632
- for (const featureId of disabled) {
633
- await removePaths(projectDir, FEATURE_PATHS[featureId]);
634
- }
635
-
636
- await patchModulesIndex(projectDir, selection);
637
- await patchEntryIndex(projectDir, selection);
638
- await patchServerIndex(projectDir, selection);
639
- await patchPackageJson(projectDir, selection);
640
- await patchEnvExample(projectDir, selection, packageName);
641
- await patchDockerfile(projectDir, selection);
642
- await patchDockerCompose(projectDir, selection);
643
-
644
- if (disabled.length > 0) {
645
- log.success("Feature selection applied");
646
- }
647
- }
1
+ import { rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { log } from "./log";
4
+
5
+ export type FeatureId =
6
+ | "auth"
7
+ | "prisma"
8
+ | "otel"
9
+ | "sentry"
10
+ | "kafka"
11
+ | "websocket"
12
+ | "resend"
13
+ | "cron"
14
+ | "docker";
15
+
16
+ export type FeatureSelection = Record<FeatureId, boolean>;
17
+
18
+ export type FeatureDef = {
19
+ id: FeatureId;
20
+ label: string;
21
+ description: string;
22
+ };
23
+
24
+ export const FEATURES: FeatureDef[] = [
25
+ {
26
+ id: "auth",
27
+ label: "JWT Authentication",
28
+ description: "Bearer auth, sign-in route, protected routes",
29
+ },
30
+ {
31
+ id: "prisma",
32
+ label: "Prisma + PostgreSQL",
33
+ description: "Prisma 7 config, migrations, seed scripts",
34
+ },
35
+ {
36
+ id: "otel",
37
+ label: "OpenTelemetry",
38
+ description: "OTLP tracing with Bun-compatible exporter",
39
+ },
40
+ {
41
+ id: "sentry",
42
+ label: "Sentry + Spotlight",
43
+ description: "Error monitoring and local Spotlight UI",
44
+ },
45
+ {
46
+ id: "kafka",
47
+ label: "Kafka",
48
+ description: "Producer, consumer, and example HTTP endpoints",
49
+ },
50
+ {
51
+ id: "websocket",
52
+ label: "WebSockets",
53
+ description: "Validated real-time connections with rooms",
54
+ },
55
+ {
56
+ id: "resend",
57
+ label: "Resend Email",
58
+ description: "Transactional email via sendEmail()",
59
+ },
60
+ {
61
+ id: "cron",
62
+ label: "Cron Jobs",
63
+ description: "Scheduled tasks via @elysiajs/cron",
64
+ },
65
+ {
66
+ id: "docker",
67
+ label: "Docker",
68
+ description: "Dockerfile and Docker Compose stack",
69
+ },
70
+ ];
71
+
72
+ export const FEATURE_IDS = FEATURES.map((feature) => feature.id);
73
+
74
+ export const ALL_FEATURES: FeatureSelection = Object.fromEntries(
75
+ FEATURE_IDS.map((id) => [id, true]),
76
+ ) as FeatureSelection;
77
+
78
+ export const MINIMAL_FEATURES: FeatureSelection = Object.fromEntries(
79
+ FEATURE_IDS.map((id) => [id, false]),
80
+ ) as FeatureSelection;
81
+
82
+ const FEATURE_PATHS: Record<FeatureId, string[]> = {
83
+ auth: [
84
+ "src/modules/signin",
85
+ "src/modules/protected",
86
+ "src/middleware/auth.ts",
87
+ "src/infra/auth",
88
+ "src/constants/jwt.ts",
89
+ "src/models/schemas/signin.ts",
90
+ ],
91
+ prisma: ["prisma.config.ts", "src/infra/prisma"],
92
+ otel: ["src/infra/telemetry"],
93
+ sentry: [
94
+ "src/infra/sentry",
95
+ "src/constants/sentry.ts",
96
+ "__tests__/sentry.test.ts",
97
+ ],
98
+ kafka: [
99
+ "src/modules/kafka",
100
+ "src/infra/kafka",
101
+ "src/models/schemas/kafka.ts",
102
+ "__tests__/kafka.test.ts",
103
+ ],
104
+ websocket: [
105
+ "src/modules/realtime",
106
+ "src/models/schemas/realtime.ts",
107
+ "__tests__/realtime.test.ts",
108
+ ],
109
+ resend: ["src/infra/resend"],
110
+ cron: ["src/schedules"],
111
+ docker: ["Dockerfile", "docker-compose.yaml", "docker/otel.Dockerfile"],
112
+ };
113
+
114
+ const FEATURE_DEPENDENCIES: Partial<
115
+ Record<FeatureId, { dependencies?: string[]; devDependencies?: string[] }>
116
+ > = {
117
+ auth: { dependencies: ["@elysiajs/bearer", "@elysiajs/jwt"] },
118
+ prisma: {
119
+ dependencies: ["@prisma/adapter-pg", "@prisma/client"],
120
+ devDependencies: ["prisma"],
121
+ },
122
+ otel: {
123
+ dependencies: [
124
+ "@elysiajs/opentelemetry",
125
+ "@opentelemetry/exporter-trace-otlp-http",
126
+ "@opentelemetry/otlp-transformer",
127
+ "@opentelemetry/sdk-trace-node",
128
+ ],
129
+ },
130
+ sentry: { dependencies: ["@sentry/elysia"] },
131
+ kafka: { dependencies: ["kafkajs"] },
132
+ resend: { dependencies: ["resend"] },
133
+ cron: { dependencies: ["@elysiajs/cron"] },
134
+ };
135
+
136
+ const FEATURE_SCRIPTS: Partial<Record<FeatureId, string[]>> = {
137
+ otel: ["otel:view", "otel:tui"],
138
+ sentry: ["sentry:spotlight"],
139
+ prisma: ["seed"],
140
+ };
141
+
142
+ function enabledFeatures(selection: FeatureSelection): FeatureId[] {
143
+ return FEATURE_IDS.filter((id) => selection[id]);
144
+ }
145
+
146
+ function disabledFeatures(selection: FeatureSelection): FeatureId[] {
147
+ return FEATURE_IDS.filter((id) => !selection[id]);
148
+ }
149
+
150
+ export function parseFeatureIncludeList(input: string): FeatureSelection {
151
+ const normalized = input.trim().toLowerCase();
152
+ if (!normalized || normalized === "all") return { ...ALL_FEATURES };
153
+ if (normalized === "minimal" || normalized === "none") {
154
+ return { ...MINIMAL_FEATURES };
155
+ }
156
+
157
+ const requested = normalized
158
+ .split(",")
159
+ .map((part) => part.trim())
160
+ .filter(Boolean);
161
+
162
+ const unknown = requested.filter(
163
+ (id) => !FEATURE_IDS.includes(id as FeatureId),
164
+ );
165
+ if (unknown.length > 0) {
166
+ throw new Error(
167
+ `Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
168
+ );
169
+ }
170
+
171
+ const selection = { ...MINIMAL_FEATURES };
172
+ for (const id of requested) {
173
+ selection[id as FeatureId] = true;
174
+ }
175
+ return selection;
176
+ }
177
+
178
+ export function parseFeatureExcludeList(
179
+ excluded: string[],
180
+ base: FeatureSelection = ALL_FEATURES,
181
+ ): FeatureSelection {
182
+ const selection = { ...base };
183
+ const unknown: string[] = [];
184
+
185
+ for (const raw of excluded) {
186
+ const id = raw.trim().toLowerCase();
187
+ if (!id) continue;
188
+ if (!FEATURE_IDS.includes(id as FeatureId)) {
189
+ unknown.push(id);
190
+ continue;
191
+ }
192
+ selection[id as FeatureId] = false;
193
+ }
194
+
195
+ if (unknown.length > 0) {
196
+ throw new Error(
197
+ `Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
198
+ );
199
+ }
200
+
201
+ return selection;
202
+ }
203
+
204
+ export function formatFeatureSelection(selection: FeatureSelection): string {
205
+ const enabled = enabledFeatures(selection);
206
+ if (enabled.length === 0) return "core only";
207
+ return enabled.join(", ");
208
+ }
209
+
210
+ async function removePaths(projectDir: string, relativePaths: string[]) {
211
+ for (const relativePath of relativePaths) {
212
+ await rm(path.join(projectDir, relativePath), {
213
+ recursive: true,
214
+ force: true,
215
+ });
216
+ }
217
+ }
218
+
219
+ async function readText(projectDir: string, relativePath: string) {
220
+ const file = Bun.file(path.join(projectDir, relativePath));
221
+ if (!(await file.exists())) return null;
222
+ return file.text();
223
+ }
224
+
225
+ async function writeText(
226
+ projectDir: string,
227
+ relativePath: string,
228
+ content: string,
229
+ ) {
230
+ await Bun.write(path.join(projectDir, relativePath), content);
231
+ }
232
+
233
+ function removeImportLines(content: string, modules: string[]) {
234
+ let next = content;
235
+ for (const modulePath of modules) {
236
+ const escaped = modulePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
237
+ next = next.replace(
238
+ new RegExp(`^import .* from ["']${escaped}["'];?\\r?\\n`, "gm"),
239
+ "",
240
+ );
241
+ }
242
+ return next;
243
+ }
244
+
245
+ function removeUseLines(content: string, identifiers: string[]) {
246
+ let next = content;
247
+ for (const identifier of identifiers) {
248
+ next = next.replace(
249
+ new RegExp(`^\\s*\\.use\\(${identifier}\\)\\r?\\n`, "gm"),
250
+ "",
251
+ );
252
+ }
253
+ return next;
254
+ }
255
+
256
+ async function patchModulesIndex(
257
+ projectDir: string,
258
+ selection: FeatureSelection,
259
+ ) {
260
+ const relativePath = "src/modules/index.ts";
261
+ const current = await readText(projectDir, relativePath);
262
+ if (!current) return;
263
+
264
+ let content = current;
265
+ if (!selection.sentry) {
266
+ content = removeImportLines(content, ["../infra/sentry"]);
267
+ content = removeUseLines(content, ["withSentry"]);
268
+ }
269
+ if (!selection.otel) {
270
+ content = removeImportLines(content, ["../infra/telemetry"]);
271
+ content = removeUseLines(content, ["withOpenTelemetry"]);
272
+ }
273
+
274
+ content = content.replace(/app\s+\.use\(/g, "app\n .use(");
275
+
276
+ await writeText(projectDir, relativePath, content);
277
+ }
278
+
279
+ async function patchEntryIndex(projectDir: string, selection: FeatureSelection) {
280
+ const imports = [
281
+ selection.sentry
282
+ ? 'import { initSentry, flushSentry } from "./infra/sentry";'
283
+ : null,
284
+ selection.kafka
285
+ ? 'import { startKafka, stopKafka } from "./infra/kafka";'
286
+ : null,
287
+ 'import { HttpServer } from "./server/index";',
288
+ 'import { logger } from "./common/logger";',
289
+ selection.kafka ? 'import { InternalServerError } from "elysia";' : null,
290
+ ].filter(Boolean) as string[];
291
+
292
+ const body: string[] = [];
293
+
294
+ if (selection.sentry) {
295
+ body.push("", "initSentry();");
296
+ }
297
+
298
+ body.push("", "const server = new HttpServer();", "");
299
+
300
+ if (selection.kafka) {
301
+ body.push(
302
+ "void server.start().then(async () => {",
303
+ " try {",
304
+ " await startKafka();",
305
+ " } catch (error) {",
306
+ ' logger.error("Failed to start Kafka", { error });',
307
+ ' throw new InternalServerError("Failed to start Kafka");',
308
+ " }",
309
+ "});",
310
+ );
311
+ } else {
312
+ body.push("void server.start();");
313
+ }
314
+
315
+ body.push(
316
+ "",
317
+ "const shutdown = (signal: string) => {",
318
+ " return async () => {",
319
+ " logger.warn(`Received ${signal}, shutting down`);",
320
+ " await server.stop();",
321
+ );
322
+
323
+ if (selection.kafka) {
324
+ body.push(" await stopKafka();");
325
+ }
326
+ if (selection.sentry) {
327
+ body.push(" await flushSentry();");
328
+ }
329
+
330
+ body.push(
331
+ " process.exit(0);",
332
+ " };",
333
+ "};",
334
+ "",
335
+ 'process.on("SIGINT", () => void shutdown("SIGINT")());',
336
+ 'process.on("SIGTERM", () => void shutdown("SIGTERM")());',
337
+ "",
338
+ );
339
+
340
+ await writeText(projectDir, "src/index.ts", [...imports, ...body].join("\n"));
341
+ }
342
+
343
+ async function patchServerIndex(projectDir: string, selection: FeatureSelection) {
344
+ const relativePath = "src/server/index.ts";
345
+ const current = await readText(projectDir, relativePath);
346
+ if (!current) return;
347
+
348
+ let content = current;
349
+
350
+ if (selection.cron) {
351
+ content = content.replace(
352
+ /\/\/import \{ schedules \} from "\.\.\/schedules";.*\n/,
353
+ 'import { schedules } from "../schedules";\n',
354
+ );
355
+ content = content.replace(
356
+ /\/\/\.use\(schedules\);.*\n/,
357
+ " .use(schedules);\n",
358
+ );
359
+ } else {
360
+ content = content.replace(
361
+ /^import \{ schedules \} from "\.\.\/schedules";\n/m,
362
+ "",
363
+ );
364
+ content = content.replace(/^\s*\.use\(schedules\);\n/m, "");
365
+ }
366
+
367
+ if (!selection.websocket) {
368
+ content = content.replace(
369
+ /new Elysia\(\{\s*websocket:\s*\{\s*idleTimeout:\s*120,\s*\},\s*\}\)/,
370
+ "new Elysia()",
371
+ );
372
+ }
373
+
374
+ await writeText(projectDir, relativePath, content);
375
+ }
376
+
377
+ async function patchPackageJson(
378
+ projectDir: string,
379
+ selection: FeatureSelection,
380
+ ) {
381
+ const pkgPath = path.join(projectDir, "package.json");
382
+ const pkgFile = Bun.file(pkgPath);
383
+ if (!(await pkgFile.exists())) return;
384
+
385
+ const pkg = (await pkgFile.json()) as {
386
+ dependencies?: Record<string, string>;
387
+ devDependencies?: Record<string, string>;
388
+ scripts?: Record<string, string>;
389
+ };
390
+
391
+ for (const featureId of disabledFeatures(selection)) {
392
+ const deps = FEATURE_DEPENDENCIES[featureId];
393
+ if (deps?.dependencies) {
394
+ for (const name of deps.dependencies) {
395
+ delete pkg.dependencies?.[name];
396
+ }
397
+ }
398
+ if (deps?.devDependencies) {
399
+ for (const name of deps.devDependencies) {
400
+ delete pkg.devDependencies?.[name];
401
+ }
402
+ }
403
+
404
+ const scripts = FEATURE_SCRIPTS[featureId];
405
+ if (scripts) {
406
+ for (const scriptName of scripts) {
407
+ delete pkg.scripts?.[scriptName];
408
+ }
409
+ }
410
+ }
411
+
412
+ if (!selection.prisma && pkg.scripts) {
413
+ pkg.scripts.start = "bun run src/index.ts";
414
+ }
415
+
416
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
417
+ }
418
+
419
+ export function buildEnvExample(
420
+ packageName: string,
421
+ selection: FeatureSelection,
422
+ ): string {
423
+ const lines = ["NODE_ENV=development", "PORT=3131", ""];
424
+
425
+ if (selection.prisma) {
426
+ lines.push(
427
+ "DATABASE_URL=postgresql://user:password@localhost:5432/your_database",
428
+ "",
429
+ );
430
+ }
431
+
432
+ if (selection.otel) {
433
+ lines.push(
434
+ "# OpenTelemetry (optional in production; enabled by default in development)",
435
+ "# Terminal 1: bun run otel:view",
436
+ "# Terminal 2: bun run dev",
437
+ "# Disable locally: OTEL_EXPORTER_OTLP_ENDPOINT=",
438
+ "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces",
439
+ `OTEL_SERVICE_NAME=${packageName}`,
440
+ "",
441
+ );
442
+ }
443
+
444
+ if (selection.sentry) {
445
+ lines.push(
446
+ "# Sentry Spotlight (local UI — enabled by default in development)",
447
+ "# Terminal 1: bun run sentry:spotlight",
448
+ "# Terminal 2: bun run dev",
449
+ "# Disable locally: SENTRY_SPOTLIGHT=false",
450
+ "SENTRY_SPOTLIGHT=true",
451
+ "",
452
+ "# Sentry cloud (optional — events also go to sentry.io when set)",
453
+ "# SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
454
+ "# SENTRY_TRACES_SAMPLE_RATE=1.0",
455
+ "",
456
+ );
457
+ }
458
+
459
+ if (selection.kafka) {
460
+ lines.push(
461
+ "# Kafka (enabled by default in development; disabled in test)",
462
+ "# Start broker: docker compose up -d kafka",
463
+ "# Host dev uses EXTERNAL listener on port 9094",
464
+ "# KAFKA_ENABLED=false",
465
+ "KAFKA_BROKERS=localhost:9094",
466
+ `KAFKA_CLIENT_ID=${packageName}`,
467
+ `KAFKA_GROUP_ID=${packageName}-consumer`,
468
+ "KAFKA_TOPIC=elysia.events",
469
+ "",
470
+ );
471
+ }
472
+
473
+ if (selection.resend) {
474
+ lines.push(
475
+ "# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
476
+ "# Create an API key: https://resend.com/api-keys",
477
+ "# RESEND_ENABLED=false",
478
+ "# RESEND_API_KEY=re_xxxxxxxx",
479
+ "# RESEND_FROM=Acme <onboarding@resend.dev>",
480
+ "",
481
+ );
482
+ }
483
+
484
+ return `${lines.join("\n").trimEnd()}\n`;
485
+ }
486
+
487
+ async function patchEnvExample(
488
+ projectDir: string,
489
+ selection: FeatureSelection,
490
+ packageName = "kavoru",
491
+ ) {
492
+ const content = buildEnvExample(packageName, selection);
493
+ await writeText(projectDir, ".env.example", content);
494
+ await writeText(projectDir, ".env", content);
495
+ }
496
+
497
+ async function patchDockerfile(projectDir: string, selection: FeatureSelection) {
498
+ if (!selection.docker) return;
499
+
500
+ const relativePath = "Dockerfile";
501
+ const current = await readText(projectDir, relativePath);
502
+ if (!current) return;
503
+
504
+ let content = current;
505
+ if (!selection.prisma) {
506
+ content = content.replace(/^\s*COPY prisma\.config\.ts \.\/.*\n/m, "");
507
+ content = content.replace(
508
+ /^\s*# generate only needs the schema on disk, not a live database\n/m,
509
+ "",
510
+ );
511
+ content = content.replace(
512
+ /^\s*RUN if \[ -f src\/infra\/prisma\/schemas\/schema\.prisma \]; then bunx prisma generate; fi\n/m,
513
+ "",
514
+ );
515
+ }
516
+
517
+ await writeText(projectDir, relativePath, content);
518
+ }
519
+
520
+ function buildAppEnvironment(selection: FeatureSelection): string {
521
+ const lines = [" NODE_ENV: production"];
522
+ if (selection.kafka) {
523
+ lines.push(" KAFKA_BROKERS: kafka:9092");
524
+ }
525
+ if (selection.otel) {
526
+ lines.push(" OTEL_EXPORTER_OTLP_ENDPOINT: http://otel:4318/v1/traces");
527
+ lines.push(" OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-kavoru}");
528
+ }
529
+ if (selection.sentry) {
530
+ lines.push(" SENTRY_SPOTLIGHT: http://spotlight:8969/stream");
531
+ }
532
+ return ` environment:\n${lines.join("\n")}\n`;
533
+ }
534
+
535
+ function generateDockerCompose(selection: FeatureSelection): string {
536
+ const appDependsOn = selection.kafka
537
+ ? ` depends_on:
538
+ kafka:
539
+ condition: service_started
540
+ `
541
+ : "";
542
+ const appEnvironment = buildAppEnvironment(selection);
543
+
544
+ const kafkaService = selection.kafka
545
+ ? `
546
+ kafka:
547
+ image: confluentinc/cp-kafka:7.6.1
548
+ hostname: kafka
549
+ ports:
550
+ - "9094:9094"
551
+ environment:
552
+ CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
553
+ KAFKA_NODE_ID: "0"
554
+ KAFKA_PROCESS_ROLES: broker,controller
555
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
556
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
557
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
558
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
559
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
560
+ KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
561
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
562
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
563
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
564
+ KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
565
+ networks:
566
+ - app_network
567
+ restart: unless-stopped
568
+ `
569
+ : "";
570
+
571
+ const otelService = selection.otel
572
+ ? `
573
+ otel:
574
+ build:
575
+ context: .
576
+ dockerfile: docker/otel.Dockerfile
577
+ ports:
578
+ - "4318:4318"
579
+ networks:
580
+ - app_network
581
+ restart: unless-stopped
582
+ `
583
+ : "";
584
+
585
+ const spotlightService = selection.sentry
586
+ ? `
587
+ spotlight:
588
+ image: ghcr.io/getsentry/spotlight:latest
589
+ ports:
590
+ - "8969:8969"
591
+ networks:
592
+ - app_network
593
+ restart: unless-stopped
594
+ `
595
+ : "";
596
+
597
+ return `services:
598
+ app:
599
+ build:
600
+ context: .
601
+ target: build
602
+ args:
603
+ PORT: \${PORT:-3131}
604
+ command: /app/server
605
+ volumes:
606
+ - ./src:/app/src
607
+ networks:
608
+ app_network:
609
+ aliases:
610
+ - app
611
+ extra_hosts:
612
+ - "host.docker.internal:host-gateway"
613
+ ports:
614
+ - "\${PORT:-3131}:\${PORT:-3131}"
615
+ restart: unless-stopped
616
+ env_file:
617
+ - .env
618
+ ${appDependsOn}${appEnvironment} healthcheck:
619
+ test: ["CMD", "curl", "-f", "http://localhost:\${PORT}/healthz"]
620
+ interval: 600s
621
+ timeout: 300s
622
+ retries: 1
623
+ start_period: 40s
624
+ ${kafkaService}${otelService}${spotlightService}
625
+ networks:
626
+ app_network:
627
+ driver: bridge
628
+ `;
629
+ }
630
+
631
+ async function patchDockerCompose(
632
+ projectDir: string,
633
+ selection: FeatureSelection,
634
+ ) {
635
+ if (!selection.docker) return;
636
+ await writeText(projectDir, "docker-compose.yaml", generateDockerCompose(selection));
637
+ }
638
+
639
+ export async function applyFeatures(
640
+ projectDir: string,
641
+ selection: FeatureSelection,
642
+ packageName = "kavoru",
643
+ ): Promise<void> {
644
+ const disabled = disabledFeatures(selection);
645
+ log.step(`Applying feature selection (${formatFeatureSelection(selection)})`);
646
+
647
+ for (const featureId of disabled) {
648
+ await removePaths(projectDir, FEATURE_PATHS[featureId]);
649
+ }
650
+
651
+ await patchModulesIndex(projectDir, selection);
652
+ await patchEntryIndex(projectDir, selection);
653
+ await patchServerIndex(projectDir, selection);
654
+ await patchPackageJson(projectDir, selection);
655
+ await patchEnvExample(projectDir, selection, packageName);
656
+ await patchDockerfile(projectDir, selection);
657
+ await patchDockerCompose(projectDir, selection);
658
+
659
+ if (disabled.length > 0) {
660
+ log.success("Feature selection applied");
661
+ }
662
+ }