@fluojs/cli 1.0.0-beta.1

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +155 -0
  3. package/README.md +155 -0
  4. package/bin/fluo.mjs +5 -0
  5. package/dist/cli.d.ts +37 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +292 -0
  8. package/dist/commands/generate.d.ts +40 -0
  9. package/dist/commands/generate.d.ts.map +1 -0
  10. package/dist/commands/generate.js +134 -0
  11. package/dist/commands/inspect.d.ts +30 -0
  12. package/dist/commands/inspect.d.ts.map +1 -0
  13. package/dist/commands/inspect.js +221 -0
  14. package/dist/commands/migrate.d.ts +30 -0
  15. package/dist/commands/migrate.d.ts.map +1 -0
  16. package/dist/commands/migrate.js +173 -0
  17. package/dist/commands/new.d.ts +45 -0
  18. package/dist/commands/new.d.ts.map +1 -0
  19. package/dist/commands/new.js +353 -0
  20. package/dist/generator-types.d.ts +21 -0
  21. package/dist/generator-types.d.ts.map +1 -0
  22. package/dist/generator-types.js +1 -0
  23. package/dist/generators/controller.d.ts +3 -0
  24. package/dist/generators/controller.d.ts.map +1 -0
  25. package/dist/generators/controller.js +22 -0
  26. package/dist/generators/guard.d.ts +3 -0
  27. package/dist/generators/guard.d.ts.map +1 -0
  28. package/dist/generators/guard.js +15 -0
  29. package/dist/generators/interceptor.d.ts +3 -0
  30. package/dist/generators/interceptor.d.ts.map +1 -0
  31. package/dist/generators/interceptor.js +15 -0
  32. package/dist/generators/manifest.d.ts +121 -0
  33. package/dist/generators/manifest.d.ts.map +1 -0
  34. package/dist/generators/manifest.js +130 -0
  35. package/dist/generators/middleware.d.ts +3 -0
  36. package/dist/generators/middleware.d.ts.map +1 -0
  37. package/dist/generators/middleware.js +15 -0
  38. package/dist/generators/module.d.ts +6 -0
  39. package/dist/generators/module.d.ts.map +1 -0
  40. package/dist/generators/module.js +143 -0
  41. package/dist/generators/render.d.ts +2 -0
  42. package/dist/generators/render.d.ts.map +1 -0
  43. package/dist/generators/render.js +17 -0
  44. package/dist/generators/repository.d.ts +3 -0
  45. package/dist/generators/repository.d.ts.map +1 -0
  46. package/dist/generators/repository.js +29 -0
  47. package/dist/generators/request-dto.d.ts +3 -0
  48. package/dist/generators/request-dto.d.ts.map +1 -0
  49. package/dist/generators/request-dto.js +17 -0
  50. package/dist/generators/response-dto.d.ts +3 -0
  51. package/dist/generators/response-dto.d.ts.map +1 -0
  52. package/dist/generators/response-dto.js +17 -0
  53. package/dist/generators/service.d.ts +3 -0
  54. package/dist/generators/service.d.ts.map +1 -0
  55. package/dist/generators/service.js +22 -0
  56. package/dist/generators/templates/controller.test.ts.ejs +21 -0
  57. package/dist/generators/templates/controller.ts.ejs +29 -0
  58. package/dist/generators/templates/guard.ts.ejs +7 -0
  59. package/dist/generators/templates/interceptor.ts.ejs +7 -0
  60. package/dist/generators/templates/middleware.ts.ejs +11 -0
  61. package/dist/generators/templates/module.ts.ejs +9 -0
  62. package/dist/generators/templates/repository.slice.test.ts.ejs +15 -0
  63. package/dist/generators/templates/repository.test.ts.ejs +9 -0
  64. package/dist/generators/templates/repository.ts.ejs +10 -0
  65. package/dist/generators/templates/request-dto.ts.ejs +9 -0
  66. package/dist/generators/templates/response-dto.ts.ejs +3 -0
  67. package/dist/generators/templates/service.test.ts.ejs +21 -0
  68. package/dist/generators/templates/service.ts.ejs +24 -0
  69. package/dist/generators/utils.d.ts +4 -0
  70. package/dist/generators/utils.d.ts.map +1 -0
  71. package/dist/generators/utils.js +18 -0
  72. package/dist/help.d.ts +8 -0
  73. package/dist/help.d.ts.map +1 -0
  74. package/dist/help.js +16 -0
  75. package/dist/index.d.ts +4 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +2 -0
  78. package/dist/new/install.d.ts +51 -0
  79. package/dist/new/install.d.ts.map +1 -0
  80. package/dist/new/install.js +140 -0
  81. package/dist/new/package-spec-resolver.d.ts +4 -0
  82. package/dist/new/package-spec-resolver.d.ts.map +1 -0
  83. package/dist/new/package-spec-resolver.js +397 -0
  84. package/dist/new/prompt.d.ts +56 -0
  85. package/dist/new/prompt.d.ts.map +1 -0
  86. package/dist/new/prompt.js +278 -0
  87. package/dist/new/resolver.d.ts +32 -0
  88. package/dist/new/resolver.d.ts.map +1 -0
  89. package/dist/new/resolver.js +93 -0
  90. package/dist/new/scaffold.d.ts +14 -0
  91. package/dist/new/scaffold.d.ts.map +1 -0
  92. package/dist/new/scaffold.js +2010 -0
  93. package/dist/new/starter-profiles.d.ts +91 -0
  94. package/dist/new/starter-profiles.d.ts.map +1 -0
  95. package/dist/new/starter-profiles.js +347 -0
  96. package/dist/new/types.d.ts +63 -0
  97. package/dist/new/types.d.ts.map +1 -0
  98. package/dist/new/types.js +1 -0
  99. package/dist/registry.d.ts +10 -0
  100. package/dist/registry.d.ts.map +1 -0
  101. package/dist/registry.js +30 -0
  102. package/dist/transforms/nestjs-migrate.d.ts +33 -0
  103. package/dist/transforms/nestjs-migrate.d.ts.map +1 -0
  104. package/dist/transforms/nestjs-migrate.js +891 -0
  105. package/dist/types.d.ts +12 -0
  106. package/dist/types.d.ts.map +1 -0
  107. package/dist/types.js +1 -0
  108. package/package.json +65 -0
@@ -0,0 +1,2010 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { initializeGitRepository, installDependencies } from './install.js';
5
+ import { resolvePackageSpecs } from './package-spec-resolver.js';
6
+ import { resolveBootstrapPlan } from './resolver.js';
7
+ const PUBLISHED_DEV_DEPENDENCIES = {
8
+ '@babel/cli': '^7.26.4',
9
+ '@babel/core': '^7.26.10',
10
+ '@babel/plugin-proposal-decorators': '^7.28.0',
11
+ '@babel/preset-typescript': '^7.27.1',
12
+ '@types/babel__core': '^7.20.5',
13
+ '@types/node': '^22.13.10',
14
+ tsx: '^4.20.4',
15
+ typescript: '^6.0.2',
16
+ vite: '^6.2.1',
17
+ vitest: '^3.0.8'
18
+ };
19
+ const PUBLISHED_RUNTIME_DEPENDENCIES = {
20
+ '@types/amqplib': '^0.10.7',
21
+ '@grpc/grpc-js': '^1.0.0',
22
+ '@grpc/proto-loader': '^0.8.0',
23
+ amqplib: '^0.10.5',
24
+ ioredis: '^5.0.0',
25
+ kafkajs: '^2.2.4',
26
+ mqtt: '^5.0.0',
27
+ nats: '^2.29.3'
28
+ };
29
+ function describeApplicationStarter(options) {
30
+ if (options.runtime === 'bun') {
31
+ return {
32
+ adapterCall: 'createBunAdapter({ port })',
33
+ adapterFactory: 'createBunAdapter',
34
+ entrypoint: 'src/main.ts',
35
+ packageName: '@fluojs/platform-bun',
36
+ platformLabel: 'Bun native HTTP',
37
+ runtimeLabel: 'Bun runtime'
38
+ };
39
+ }
40
+ if (options.runtime === 'deno') {
41
+ return {
42
+ entrypoint: 'src/main.ts',
43
+ packageName: '@fluojs/platform-deno',
44
+ platformLabel: 'Deno native HTTP',
45
+ runtimeLabel: 'Deno runtime'
46
+ };
47
+ }
48
+ if (options.runtime === 'cloudflare-workers') {
49
+ return {
50
+ entrypoint: 'src/worker.ts',
51
+ packageName: '@fluojs/platform-cloudflare-workers',
52
+ platformLabel: 'Cloudflare Workers HTTP',
53
+ runtimeLabel: 'Cloudflare Workers runtime'
54
+ };
55
+ }
56
+ switch (options.platform) {
57
+ case 'express':
58
+ return {
59
+ adapterCall: 'createExpressAdapter({ port })',
60
+ adapterFactory: 'createExpressAdapter',
61
+ entrypoint: 'src/main.ts',
62
+ packageName: '@fluojs/platform-express',
63
+ platformLabel: 'Express HTTP',
64
+ runtimeLabel: 'Node.js runtime'
65
+ };
66
+ case 'nodejs':
67
+ return {
68
+ adapterCall: 'createNodejsAdapter({ port })',
69
+ adapterFactory: 'createNodejsAdapter',
70
+ entrypoint: 'src/main.ts',
71
+ packageName: '@fluojs/platform-nodejs',
72
+ platformLabel: 'raw Node.js HTTP',
73
+ runtimeLabel: 'Node.js runtime'
74
+ };
75
+ default:
76
+ return {
77
+ adapterCall: 'createFastifyAdapter({ port })',
78
+ adapterFactory: 'createFastifyAdapter',
79
+ entrypoint: 'src/main.ts',
80
+ packageName: '@fluojs/platform-fastify',
81
+ platformLabel: 'Fastify HTTP',
82
+ runtimeLabel: 'Node.js runtime'
83
+ };
84
+ }
85
+ }
86
+ function packageRootFromImportMeta(importMetaUrl) {
87
+ return resolve(dirname(fileURLToPath(importMetaUrl)), '..', '..');
88
+ }
89
+ function readOwnPackageVersion(importMetaUrl) {
90
+ const packageJson = JSON.parse(readFileSync(join(packageRootFromImportMeta(importMetaUrl), 'package.json'), 'utf8'));
91
+ return packageJson.version;
92
+ }
93
+ function writeTextFile(filePath, content) {
94
+ mkdirSync(dirname(filePath), {
95
+ recursive: true
96
+ });
97
+ writeFileSync(filePath, content, 'utf8');
98
+ }
99
+ function createDependencySpec(packageName, releaseVersion, packageSpecs) {
100
+ return packageSpecs[packageName] ?? PUBLISHED_RUNTIME_DEPENDENCIES[packageName] ?? createPublishedInternalDependencySpec(releaseVersion);
101
+ }
102
+ function createPublishedInternalDependencySpec(version) {
103
+ return `^${version}`;
104
+ }
105
+ function createRunCommand(packageManager, command) {
106
+ switch (packageManager) {
107
+ case 'bun':
108
+ return `bun run ${command}`;
109
+ case 'npm':
110
+ return `npm run ${command}`;
111
+ case 'yarn':
112
+ return `yarn ${command}`;
113
+ default:
114
+ return `pnpm ${command}`;
115
+ }
116
+ }
117
+ function createExecCommand(packageManager, command) {
118
+ switch (packageManager) {
119
+ case 'bun':
120
+ return `bun x ${command}`;
121
+ case 'npm':
122
+ return `npm exec -- ${command}`;
123
+ case 'yarn':
124
+ return `yarn ${command}`;
125
+ default:
126
+ return `pnpm exec ${command}`;
127
+ }
128
+ }
129
+ function createProjectScripts(bootstrapPlan) {
130
+ switch (bootstrapPlan.profile.id) {
131
+ case 'application-bun-bun-http':
132
+ return {
133
+ build: 'bun build ./src/main.ts --outdir ./dist --target bun',
134
+ dev: 'bun --watch src/main.ts',
135
+ test: 'vitest run',
136
+ 'test:watch': 'vitest',
137
+ typecheck: 'tsc -p tsconfig.json --noEmit'
138
+ };
139
+ case 'application-deno-deno-http':
140
+ return {
141
+ build: 'mkdir -p dist && deno compile --allow-env --allow-net --output dist/app src/main.ts',
142
+ dev: 'deno run --allow-env --allow-net --watch src/main.ts',
143
+ test: 'deno test --allow-env --allow-net',
144
+ 'test:watch': 'deno test --allow-env --allow-net --watch',
145
+ typecheck: 'deno check src/main.ts src/app.test.ts'
146
+ };
147
+ case 'application-cloudflare-workers-cloudflare-workers-http':
148
+ return {
149
+ build: 'wrangler deploy --dry-run',
150
+ dev: 'wrangler dev',
151
+ test: 'vitest run',
152
+ 'test:watch': 'vitest',
153
+ typecheck: 'tsc -p tsconfig.json --noEmit'
154
+ };
155
+ default:
156
+ return {
157
+ build: 'babel src --extensions .ts --out-dir dist --config-file ./babel.config.cjs && tsc -p tsconfig.build.json',
158
+ dev: 'node --env-file=.env --watch --watch-preserve-output --import tsx src/main.ts',
159
+ test: 'vitest run',
160
+ 'test:watch': 'vitest',
161
+ typecheck: 'tsc -p tsconfig.json --noEmit'
162
+ };
163
+ }
164
+ }
165
+ function createProjectEngines(bootstrapPlan) {
166
+ switch (bootstrapPlan.profile.id) {
167
+ case 'application-bun-bun-http':
168
+ return {
169
+ bun: '>=1.2.5'
170
+ };
171
+ case 'application-deno-deno-http':
172
+ return {
173
+ deno: '>=2.0.0'
174
+ };
175
+ default:
176
+ return {
177
+ node: '>=20.0.0'
178
+ };
179
+ }
180
+ }
181
+ function createPublishedDevDependencies(bootstrapPlan) {
182
+ if (bootstrapPlan.profile.id === 'application-deno-deno-http') {
183
+ return {};
184
+ }
185
+ if (bootstrapPlan.profile.id === 'application-cloudflare-workers-cloudflare-workers-http') {
186
+ return {
187
+ ...PUBLISHED_DEV_DEPENDENCIES,
188
+ wrangler: '^4.11.1'
189
+ };
190
+ }
191
+ return {
192
+ ...PUBLISHED_DEV_DEPENDENCIES
193
+ };
194
+ }
195
+ function createProjectPackageJson(options, bootstrapPlan, releaseVersion, packageSpecs) {
196
+ const packageManagerField = options.packageManager === 'pnpm' ? {
197
+ packageManager: 'pnpm@10.4.1'
198
+ } : options.packageManager === 'bun' ? {
199
+ packageManager: 'bun@1.2.5'
200
+ } : options.packageManager === 'yarn' ? {
201
+ packageManager: 'yarn@1.22.22'
202
+ } : {};
203
+ const localOverrideConfig = Object.keys(packageSpecs).length ? {
204
+ overrides: packageSpecs,
205
+ resolutions: packageSpecs
206
+ } : {};
207
+ const dependencyEntries = Object.fromEntries(bootstrapPlan.dependencies.dependencies.map(packageName => [packageName, createDependencySpec(packageName, releaseVersion, packageSpecs)]));
208
+ const devDependencyEntries = Object.fromEntries(bootstrapPlan.dependencies.devDependencies.map(packageName => [packageName, createDependencySpec(packageName, releaseVersion, packageSpecs)]));
209
+ return JSON.stringify({
210
+ name: options.projectName,
211
+ version: '0.1.0',
212
+ private: true,
213
+ type: 'module',
214
+ engines: createProjectEngines(bootstrapPlan),
215
+ ...packageManagerField,
216
+ ...localOverrideConfig,
217
+ scripts: createProjectScripts(bootstrapPlan),
218
+ dependencies: dependencyEntries,
219
+ devDependencies: {
220
+ ...devDependencyEntries,
221
+ ...createPublishedDevDependencies(bootstrapPlan)
222
+ }
223
+ }, null, 2);
224
+ }
225
+ function createProjectTsconfig() {
226
+ return `{
227
+ "compilerOptions": {
228
+ "target": "ES2022",
229
+ "module": "ESNext",
230
+ "moduleResolution": "Bundler",
231
+ "strict": true,
232
+ "declaration": true,
233
+ "declarationMap": true,
234
+ "skipLibCheck": true,
235
+ "esModuleInterop": true,
236
+ "forceConsistentCasingInFileNames": true,
237
+ "resolveJsonModule": true,
238
+ "rootDir": "src",
239
+ "types": ["node"]
240
+ },
241
+ "include": ["src/**/*.ts"]
242
+ }
243
+ `;
244
+ }
245
+ function createProjectTsconfigBuild() {
246
+ return `{
247
+ "extends": "./tsconfig.json",
248
+ "compilerOptions": {
249
+ "declaration": true,
250
+ "declarationMap": true,
251
+ "emitDeclarationOnly": true,
252
+ "outDir": "dist"
253
+ },
254
+ "exclude": ["src/**/*.test.ts"]
255
+ }
256
+ `;
257
+ }
258
+ function createBabelConfig() {
259
+ return `module.exports = {
260
+ ignore: ['src/**/*.test.ts'],
261
+ presets: [['@babel/preset-typescript', { allowDeclareFields: true }]],
262
+ plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]],
263
+ };
264
+ `;
265
+ }
266
+ function createViteConfig() {
267
+ return `import { defineConfig } from 'vite';
268
+
269
+ export default defineConfig({
270
+ server: {
271
+ port: 5173,
272
+ },
273
+ });
274
+ `;
275
+ }
276
+ function createVitestConfig() {
277
+ return `import { defineConfig } from 'vitest/config';
278
+
279
+ import { fluoBabelDecoratorsPlugin } from '@fluojs/testing/vitest';
280
+
281
+ export default defineConfig({
282
+ plugins: [fluoBabelDecoratorsPlugin()],
283
+ test: {
284
+ environment: 'node',
285
+ include: ['src/**/*.test.ts'],
286
+ },
287
+ });
288
+ `;
289
+ }
290
+ function createGitignore() {
291
+ return `node_modules
292
+ dist
293
+ .fluo
294
+ .env
295
+ .env.local
296
+ .wrangler
297
+ .dev.vars
298
+ coverage
299
+ `;
300
+ }
301
+ function createHttpProjectReadme(options) {
302
+ const starter = describeApplicationStarter(options);
303
+ const entrypointLabel = starter.entrypoint;
304
+ const starterContract = options.runtime === 'deno' ? `\`${entrypointLabel}\` boots the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`runDenoApplication(...)\`` : options.runtime === 'cloudflare-workers' ? `\`${entrypointLabel}\` exports the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`createCloudflareWorkerEntrypoint(...)\`` : `\`${entrypointLabel}\` wires the selected first-class application starter: ${starter.runtimeLabel} + ${starter.platformLabel} via \`${starter.adapterFactory}(... )\``.replace('(... )', '(...)');
305
+ const corsLine = options.runtime === 'cloudflare-workers' ? '- CORS: defaults to allowOrigin `*`; pass a `cors` option to `createCloudflareWorkerEntrypoint(..., { cors })` when you need edge-specific restrictions' : options.runtime === 'deno' ? '- CORS: defaults to allowOrigin `*`; configure it through the Deno HTTP bootstrap path before exposing the adapter in production' : `- CORS: defaults to allowOrigin '*'; pass a \`cors\` option to \`FluoFactory.create(..., { cors, adapter: ${starter.adapterFactory}(...) })\` to restrict origins`;
306
+ const testingSection = options.runtime === 'deno' ? `## Official generated testing templates\n\n- \`src/app.test.ts\` — Deno-native integration-style dispatch verification for the generated runtime + starter routes.\n\nUse this test when you need confidence that the generated Deno entrypoint and module graph still agree on the same HTTP contract.` : `## Official generated testing templates\n\n- \`src/health/*.test.ts\` — unit templates for the starter-owned health slice.\n- \`src/app.test.ts\` — integration-style dispatch template for runtime + starter routes.\n- \`src/app.e2e.test.ts\` — e2e-style template powered by \`createTestApp\` from \`@fluojs/testing\`.\n- \`${createExecCommand(options.packageManager, 'fluo g repo User')}\` also adds:\n - \`src/users/user.repo.test.ts\` (unit template)\n - \`src/users/user.repo.slice.test.ts\` (slice/integration template via \`createTestingModule\`)\n\nUse unit templates for fast logic checks. Use slice/e2e templates when you need module wiring and route-level confidence.`;
307
+ return `# ${options.projectName}
308
+
309
+ Generated by @fluojs/cli.
310
+
311
+ - Starter contract: ${starterContract}
312
+ - Default baseline: when you omit \`--platform\`, \`fluo new\` still generates the Node.js + Fastify HTTP starter by default
313
+ - Broader runtime/adapter package coverage is documented in the fluo docs and package READMEs; this generated starter intentionally describes only the wired starter path above
314
+ - Package manager: install/run commands can use ${options.packageManager}; runtime choice stays explicit and is independent from the package manager you picked
315
+ - Runtime dependency set: generated manifest entries match the selected runtime contract instead of inheriting the Node-only starter recipe
316
+ ${corsLine}
317
+ - Observability: /health and /ready endpoints are included by default
318
+ - Runtime path: bootstrapApplication -> handler mapping -> dispatcher -> middleware -> guard -> interceptor -> controller
319
+ - Naming policy: runtime module entrypoints use governed canonical names (\`forRoot(...)\`, optional \`forRootAsync(...)\`, \`register(...)\`, \`forFeature(...)\`); helper/builders stay \`create*\` (for example \`createHealthModule()\`, \`createTestingModule(...)\`)
320
+
321
+ ## Commands
322
+
323
+ - Dev: ${createRunCommand(options.packageManager, 'dev')}
324
+ - Build: ${createRunCommand(options.packageManager, 'build')}
325
+ - Typecheck: ${createRunCommand(options.packageManager, 'typecheck')}
326
+ - Test: ${createRunCommand(options.packageManager, 'test')}
327
+
328
+ ## Generator example
329
+
330
+ - Repo generator: ${createExecCommand(options.packageManager, 'fluo g repo User')}
331
+
332
+ ${testingSection}
333
+ `;
334
+ }
335
+ function describeMicroserviceStarter(options) {
336
+ switch (options.transport) {
337
+ case 'redis-streams':
338
+ return {
339
+ configLines: ['- Redis Streams broker: configure `REDIS_URL` in `.env` before you start the service', '- Optional stream tuning: `REDIS_STREAMS_NAMESPACE` and `REDIS_STREAMS_CONSUMER_GROUP` let you align stream names and the consumer group with your broker contract'],
340
+ entrypointNote: '`src/app.ts` wires `RedisStreamsMicroserviceTransport` with dedicated reader/writer `ioredis` clients configured for lazy connection startup',
341
+ generatedProjectVerification: 'The generated-project verification path typechecks, builds, and tests the scaffold while asserting the Redis Streams starter keeps the `ioredis` dependency, `.env` contract, and transport entrypoint wiring intact.',
342
+ packageManagerNote: 'runtime choice stays explicit and independent from the package manager you picked; the generated manifest adds the `ioredis` dependency because Redis Streams transport support lives outside the base fluo packages',
343
+ pattern: 'math.sum',
344
+ readmeTransportLabel: 'redis-streams',
345
+ runtimeDependencyNote: 'runtime dependency set: `@fluojs/microservices` plus `ioredis` for the broker client pair used by `RedisStreamsMicroserviceTransport`',
346
+ starterNote: 'the Redis Streams starter keeps TCP as the default microservice path when you omit `--transport`, but this scaffold becomes runnable as soon as `REDIS_URL` points at a broker',
347
+ testNote: '`src/app.test.ts` preserves a broker-free integration template via an in-memory transport, while generated-project verification still checks the Redis Streams entrypoint/build contract.'
348
+ };
349
+ case 'mqtt':
350
+ return {
351
+ configLines: ['- MQTT broker: configure `MQTT_URL` in `.env` before you start the service', '- Topic namespace: adjust `MQTT_NAMESPACE` when you need transport-owned topics to coexist with other services on the same broker'],
352
+ entrypointNote: '`src/app.ts` wires `MqttMicroserviceTransport` with the generated broker URL and namespace settings so the starter can own its MQTT client at runtime',
353
+ generatedProjectVerification: 'The generated-project verification path typechecks, builds, and tests the scaffold while asserting the MQTT starter keeps the `mqtt` dependency, `.env` contract, and transport entrypoint wiring intact.',
354
+ packageManagerNote: 'runtime choice stays explicit and independent from the package manager you picked; the generated manifest adds the `mqtt` dependency because MQTT transport support lives outside the base fluo packages',
355
+ pattern: 'math.sum',
356
+ readmeTransportLabel: 'mqtt',
357
+ runtimeDependencyNote: 'runtime dependency set: `@fluojs/microservices` plus `mqtt` for the broker client that `MqttMicroserviceTransport` loads at runtime',
358
+ starterNote: 'the MQTT starter keeps TCP as the default microservice path when you omit `--transport`, but this scaffold becomes runnable as soon as `MQTT_URL` points at a broker',
359
+ testNote: '`src/app.test.ts` preserves a broker-free integration template via an in-memory transport, while generated-project verification still checks the MQTT entrypoint/build contract.'
360
+ };
361
+ case 'grpc':
362
+ return {
363
+ configLines: ['- gRPC listener: configure `GRPC_URL` in `.env` before you start the service', '- Protobuf contract: `proto/math.proto` stays in the generated project root and `src/app.ts` resolves it from `process.cwd()` so builds and runtime launches share the same schema path'],
364
+ entrypointNote: '`src/app.ts` wires `GrpcMicroserviceTransport` against `proto/math.proto`, the `fluo.microservices` package, and the generated `MathService` RPC contract',
365
+ extraFiles: ['- `proto/math.proto` — protobuf contract for the generated `MathService.Sum` RPC'],
366
+ generatedProjectVerification: 'The generated-project verification path typechecks, builds, and tests the scaffold while asserting the gRPC starter keeps the protobuf file, gRPC peer dependencies, `.env` contract, and transport entrypoint wiring intact.',
367
+ packageManagerNote: 'runtime choice stays explicit and independent from the package manager you picked; the generated manifest adds `@grpc/grpc-js` and `@grpc/proto-loader` because gRPC transport support lives outside the base fluo packages',
368
+ pattern: 'MathService.Sum',
369
+ readmeTransportLabel: 'grpc',
370
+ runtimeDependencyNote: 'runtime dependency set: `@fluojs/microservices` plus `@grpc/grpc-js` and `@grpc/proto-loader` for the generated protobuf-backed RPC listener',
371
+ starterNote: 'the gRPC starter keeps TCP as the default microservice path when you omit `--transport`, but this scaffold becomes runnable as soon as `GRPC_URL` is reachable for the generated protobuf contract',
372
+ testNote: '`src/app.test.ts` preserves a broker-free integration template via an in-memory transport, while generated-project verification still checks the gRPC entrypoint/build contract and the generated `proto/math.proto` file.'
373
+ };
374
+ case 'nats':
375
+ return {
376
+ configLines: ['- NATS broker: configure `NATS_SERVERS` in `.env` before you start the service', '- Subject contract: keep `NATS_MESSAGE_SUBJECT` and `NATS_EVENT_SUBJECT` aligned with the peer services that share the broker namespace'],
377
+ entrypointNote: '`src/app.ts` opens one caller-owned `nats` client plus `JSONCodec()` and passes both into `NatsMicroserviceTransport` as the canonical starter bootstrap contract',
378
+ generatedProjectVerification: 'The generated-project verification path typechecks, builds, and tests the scaffold while asserting the NATS starter keeps the `nats` dependency, `.env` contract, and transport entrypoint wiring intact.',
379
+ packageManagerNote: 'runtime choice stays explicit and independent from the package manager you picked; the generated manifest adds the `nats` client because the NATS starter depends on an external broker plus a caller-owned client/bootstrap pair',
380
+ pattern: 'math.sum',
381
+ readmeTransportLabel: 'nats',
382
+ runtimeDependencyNote: 'runtime dependency set: `@fluojs/microservices` plus `nats` for the broker client and codec that `NatsMicroserviceTransport` expects the caller to supply',
383
+ starterNote: 'the NATS starter keeps TCP as the default microservice path when you omit `--transport`, but this scaffold becomes runnable as soon as `NATS_SERVERS` points at a reachable broker cluster',
384
+ testNote: '`src/app.test.ts` preserves a broker-free integration template via an in-memory transport, while generated-project verification still checks the NATS entrypoint/build contract and the caller-owned client bootstrap wiring.'
385
+ };
386
+ case 'kafka':
387
+ return {
388
+ configLines: ['- Kafka brokers: configure `KAFKA_BROKERS` in `.env` before you start the service', '- Topic/group contract: `KAFKA_CLIENT_ID`, `KAFKA_CONSUMER_GROUP`, `KAFKA_MESSAGE_TOPIC`, `KAFKA_EVENT_TOPIC`, and `KAFKA_RESPONSE_TOPIC` stay explicit so the starter never hides its shared broker topology'],
389
+ entrypointNote: '`src/app.ts` opens canonical `kafkajs` producer/consumer collaborators and passes them into `KafkaMicroserviceTransport`, keeping the response-topic contract explicit in starter-owned config',
390
+ generatedProjectVerification: 'The generated-project verification path typechecks, builds, and tests the scaffold while asserting the Kafka starter keeps the `kafkajs` dependency, `.env` contract, and transport entrypoint wiring intact.',
391
+ packageManagerNote: 'runtime choice stays explicit and independent from the package manager you picked; the generated manifest adds `kafkajs` because the Kafka starter depends on an external broker plus caller-owned producer/consumer collaborators',
392
+ pattern: 'math.sum',
393
+ readmeTransportLabel: 'kafka',
394
+ runtimeDependencyNote: 'runtime dependency set: `@fluojs/microservices` plus `kafkajs` for the generated producer/consumer/bootstrap contract used by `KafkaMicroserviceTransport`',
395
+ starterNote: 'the Kafka starter keeps TCP as the default microservice path when you omit `--transport`, but this scaffold becomes runnable as soon as `KAFKA_BROKERS` points at a reachable broker set',
396
+ testNote: '`src/app.test.ts` preserves a broker-free integration template via an in-memory transport, while generated-project verification still checks the Kafka entrypoint/build contract and the explicit topic/client bootstrap wiring.'
397
+ };
398
+ case 'rabbitmq':
399
+ return {
400
+ configLines: ['- RabbitMQ broker: configure `RABBITMQ_URL` in `.env` before you start the service', '- Queue contract: `RABBITMQ_MESSAGE_QUEUE`, `RABBITMQ_EVENT_QUEUE`, and `RABBITMQ_RESPONSE_QUEUE` stay explicit so the starter advertises exactly which queues and reply path it owns'],
401
+ entrypointNote: '`src/app.ts` opens a canonical `amqplib` connection/channel pair and passes caller-owned publisher/consumer collaborators into `RabbitMqMicroserviceTransport`',
402
+ generatedProjectVerification: 'The generated-project verification path typechecks, builds, and tests the scaffold while asserting the RabbitMQ starter keeps the `amqplib` dependency, `.env` contract, and transport entrypoint wiring intact.',
403
+ packageManagerNote: 'runtime choice stays explicit and independent from the package manager you picked; the generated manifest adds `amqplib` because the RabbitMQ starter depends on an external broker plus caller-owned publisher/consumer collaborators',
404
+ pattern: 'math.sum',
405
+ readmeTransportLabel: 'rabbitmq',
406
+ runtimeDependencyNote: 'runtime dependency set: `@fluojs/microservices`, `amqplib`, and `@types/amqplib` for the generated queue client/bootstrap contract used by `RabbitMqMicroserviceTransport`',
407
+ starterNote: 'the RabbitMQ starter keeps TCP as the default microservice path when you omit `--transport`, but this scaffold becomes runnable as soon as `RABBITMQ_URL` points at a reachable broker',
408
+ testNote: '`src/app.test.ts` preserves a broker-free integration template via an in-memory transport, while generated-project verification still checks the RabbitMQ entrypoint/build contract and the explicit queue/bootstrap wiring.'
409
+ };
410
+ default:
411
+ return {
412
+ configLines: ['- Local TCP listener: configure `MICROSERVICE_HOST` and `MICROSERVICE_PORT` in `.env`'],
413
+ entrypointNote: '`src/app.ts` wires `TcpMicroserviceTransport` directly with the generated host/port environment settings',
414
+ generatedProjectVerification: 'The generated-project verification path typechecks, builds, and tests the scaffold while asserting the TCP starter keeps the default `.env` contract and transport entrypoint wiring intact.',
415
+ packageManagerNote: 'transport choice stays explicit and is independent from the package manager you picked',
416
+ pattern: 'math.sum',
417
+ readmeTransportLabel: 'tcp',
418
+ runtimeDependencyNote: 'runtime dependency set: `@fluojs/microservices` with no extra transport peer dependencies beyond the base fluo packages',
419
+ starterNote: 'TCP remains the simplest default microservice path when you omit `--transport`.',
420
+ testNote: '`src/app.test.ts` preserves a broker-free integration template via an in-memory transport that matches the generated message pattern contract.'
421
+ };
422
+ }
423
+ }
424
+ function createMicroserviceResponseExpectation(options) {
425
+ return options.transport === 'grpc' ? '{ result: 42 }' : '42';
426
+ }
427
+ function createMicroserviceProjectReadme(options) {
428
+ const starter = describeMicroserviceStarter(options);
429
+ const extraFilesSection = starter.extraFiles?.length ? `${starter.extraFiles.join('\n')}\n` : '';
430
+ return `# ${options.projectName}
431
+
432
+ Generated by @fluojs/cli.
433
+
434
+ - Shape: \`microservice\`
435
+ - Transport: \`${starter.readmeTransportLabel}\` is the generated runnable starter contract for this project
436
+ - Runtime: \`node\`
437
+ - Platform: \`none\` because the microservice starter boots through \`@fluojs/microservices\`, not an HTTP adapter
438
+ - Package manager: install/run commands can use ${options.packageManager}; ${starter.packageManagerNote}
439
+ - Messaging contract: \`src/math/math.handler.ts\` exposes a \`${starter.pattern}\` message pattern and the generated tests verify it through an in-memory transport so the starter stays testable without external brokers
440
+ - Entrypoint contract: ${starter.entrypointNote}
441
+ - Starter contract note: ${starter.starterNote}
442
+ - ${starter.runtimeDependencyNote}
443
+
444
+ ## Commands
445
+
446
+ - Dev: ${createRunCommand(options.packageManager, 'dev')}
447
+ - Build: ${createRunCommand(options.packageManager, 'build')}
448
+ - Typecheck: ${createRunCommand(options.packageManager, 'typecheck')}
449
+ - Test: ${createRunCommand(options.packageManager, 'test')}
450
+
451
+ ## Starter transport notes
452
+
453
+ ${starter.configLines.join('\n')}
454
+ - Default transport behavior: omitting \`--transport\` still resolves to the TCP starter path
455
+ - Broader messaging packages such as \`@fluojs/redis\` remain package-level integration choices, not additional \`fluo new --transport\` starter flags
456
+
457
+ ## Official generated testing templates
458
+
459
+ - \`src/math/math.handler.test.ts\` — unit template for the starter-owned message handler.
460
+ - \`src/app.test.ts\` — integration-style microservice test via an in-memory transport implementation.
461
+
462
+ ## Generated files that define the starter contract
463
+
464
+ - \`src/app.ts\` — transport registration and environment-driven runtime wiring.
465
+ - \`src/main.ts\` — microservice bootstrap entrypoint.
466
+ - \`src/math/*\` — starter-owned message handler slice proving the generated transport contract.
467
+ ${extraFilesSection}
468
+ ## Generated-project verification
469
+
470
+ - ${starter.generatedProjectVerification}
471
+
472
+ Use the unit template for handler logic and the integration template when you need runtime wiring confidence. ${starter.testNote}
473
+ `;
474
+ }
475
+ function createProjectReadme(options, bootstrapPlan) {
476
+ if (bootstrapPlan.profile.emitter.type === 'microservice') {
477
+ return createMicroserviceProjectReadme(options);
478
+ }
479
+ if (bootstrapPlan.profile.id === 'mixed-node-fastify-tcp') {
480
+ return createMixedProjectReadme(options);
481
+ }
482
+ return createHttpProjectReadme(options);
483
+ }
484
+ function createAppFile(options) {
485
+ const importSuffix = options.runtime === 'deno' ? '.ts' : '';
486
+ if (options.runtime === 'cloudflare-workers') {
487
+ return `import { Global, Module } from '@fluojs/core';
488
+ import { createHealthModule } from '@fluojs/runtime';
489
+
490
+ import { HealthModule } from './health/health.module';
491
+
492
+ const RuntimeHealthModule = createHealthModule();
493
+
494
+ @Global()
495
+ @Module({
496
+ imports: [
497
+ HealthModule,
498
+ RuntimeHealthModule,
499
+ ],
500
+ })
501
+ export class AppModule {}
502
+ `;
503
+ }
504
+ const processEnvValue = options.runtime === 'bun' ? 'Bun.env' : options.runtime === 'deno' ? 'Deno.env.toObject()' : 'process.env';
505
+ return `import { Global, Module } from '@fluojs/core';
506
+ import { ConfigModule } from '@fluojs/config';
507
+ import { createHealthModule } from '@fluojs/runtime';
508
+
509
+ import { HealthModule } from './health/health.module${importSuffix}';
510
+
511
+ const RuntimeHealthModule = createHealthModule();
512
+
513
+ @Global()
514
+ @Module({
515
+ imports: [
516
+ ConfigModule.forRoot({
517
+ envFile: '.env',
518
+ processEnv: ${processEnvValue},
519
+ }),
520
+ HealthModule,
521
+ RuntimeHealthModule,
522
+ ],
523
+ })
524
+ export class AppModule {}
525
+ `;
526
+ }
527
+ function createHealthResponseDtoFile() {
528
+ return `export class HealthResponseDto {
529
+ ok!: boolean;
530
+ service!: string;
531
+ }
532
+ `;
533
+ }
534
+ function createHealthRepoFile(projectName, importSuffix = '') {
535
+ return `import type { HealthResponseDto } from './health.response.dto${importSuffix}';
536
+
537
+ export class HealthRepo {
538
+ findHealth(): HealthResponseDto {
539
+ return {
540
+ ok: true,
541
+ service: ${JSON.stringify(projectName)},
542
+ };
543
+ }
544
+ }
545
+ `;
546
+ }
547
+ function createHealthRepoTestFile() {
548
+ return `import { describe, expect, it } from 'vitest';
549
+
550
+ import { HealthRepo } from './health.repo';
551
+
552
+ describe('HealthRepo', () => {
553
+ it('returns health data', () => {
554
+ const repo = new HealthRepo();
555
+ expect(repo.findHealth()).toEqual({ ok: true, service: expect.any(String) });
556
+ });
557
+ });
558
+ `;
559
+ }
560
+ function createHealthServiceFile(importSuffix = '') {
561
+ return `import { Inject } from '@fluojs/core';
562
+ import type { HealthResponseDto } from './health.response.dto${importSuffix}';
563
+
564
+ import { HealthRepo } from './health.repo${importSuffix}';
565
+
566
+ @Inject(HealthRepo)
567
+ export class HealthService {
568
+ constructor(private readonly repo: HealthRepo) {}
569
+
570
+ getHealth(): HealthResponseDto {
571
+ return this.repo.findHealth();
572
+ }
573
+ }
574
+ `;
575
+ }
576
+ function createHealthServiceTestFile() {
577
+ return `import { describe, expect, it } from 'vitest';
578
+
579
+ import { HealthService } from './health.service';
580
+ import { HealthRepo } from './health.repo';
581
+
582
+ class FakeHealthRepo {
583
+ findHealth() {
584
+ return { ok: true, service: 'test' };
585
+ }
586
+ }
587
+
588
+ describe('HealthService', () => {
589
+ it('delegates to the repo', () => {
590
+ const service = new HealthService(new FakeHealthRepo() as HealthRepo);
591
+ expect(service.getHealth()).toEqual({ ok: true, service: 'test' });
592
+ });
593
+ });
594
+ `;
595
+ }
596
+ function createHealthControllerFile(importSuffix = '') {
597
+ return `import { Inject } from '@fluojs/core';
598
+ import { Controller, Get } from '@fluojs/http';
599
+
600
+ import { HealthService } from './health.service${importSuffix}';
601
+ import { HealthResponseDto } from './health.response.dto${importSuffix}';
602
+
603
+ @Inject(HealthService)
604
+ @Controller('/health-info')
605
+ export class HealthController {
606
+ constructor(private readonly service: HealthService) {}
607
+
608
+ @Get('/')
609
+ getHealth(): HealthResponseDto {
610
+ return this.service.getHealth();
611
+ }
612
+ }
613
+ `;
614
+ }
615
+ function createHealthControllerTestFile() {
616
+ return `import { describe, expect, it } from 'vitest';
617
+
618
+ import { HealthController } from './health.controller';
619
+
620
+ class FakeHealthService {
621
+ getHealth() {
622
+ return { ok: true, service: 'test' };
623
+ }
624
+ }
625
+
626
+ describe('HealthController', () => {
627
+ it('delegates to the service', () => {
628
+ const controller = new HealthController(new FakeHealthService() as never);
629
+ expect(controller.getHealth()).toEqual({ ok: true, service: 'test' });
630
+ });
631
+ });
632
+ `;
633
+ }
634
+ function createHealthModuleFile(importSuffix = '') {
635
+ return `import { Module } from '@fluojs/core';
636
+
637
+ import { HealthController } from './health.controller${importSuffix}';
638
+ import { HealthRepo } from './health.repo${importSuffix}';
639
+ import { HealthService } from './health.service${importSuffix}';
640
+
641
+ @Module({
642
+ controllers: [HealthController],
643
+ providers: [HealthRepo, HealthService],
644
+ })
645
+ export class HealthModule {}
646
+ `;
647
+ }
648
+ function createMainFile(options) {
649
+ const starter = describeApplicationStarter(options);
650
+ if (options.runtime === 'deno') {
651
+ return `import { runDenoApplication } from '@fluojs/platform-deno';
652
+
653
+ import { AppModule } from './app.ts';
654
+
655
+ // The generated starter wires the selected first-class fluo new application path:
656
+ // Deno runtime + Deno native HTTP via runDenoApplication(...).
657
+
658
+ const parsedPort = Number.parseInt(Deno.env.get('PORT') ?? '3000', 10);
659
+ const port = Number.isFinite(parsedPort) ? parsedPort : 3000;
660
+
661
+ await runDenoApplication(AppModule, { port });
662
+ `;
663
+ }
664
+ if (options.runtime === 'cloudflare-workers') {
665
+ return `import { createCloudflareWorkerEntrypoint } from '@fluojs/platform-cloudflare-workers';
666
+
667
+ import { AppModule } from './app';
668
+
669
+ // The generated starter wires the selected first-class fluo new application path:
670
+ // Cloudflare Workers runtime + Cloudflare Workers HTTP via createCloudflareWorkerEntrypoint(...).
671
+
672
+ const worker = createCloudflareWorkerEntrypoint(AppModule);
673
+
674
+ export default {
675
+ fetch: worker.fetch,
676
+ };
677
+ `;
678
+ }
679
+ const portExpression = options.runtime === 'bun' ? "Bun.env.PORT ?? '3000'" : "process.env.PORT ?? '3000'";
680
+ return `import { ${starter.adapterFactory} } from '${starter.packageName}';
681
+ import { FluoFactory } from '@fluojs/runtime';
682
+
683
+ import { AppModule } from './app';
684
+
685
+ // The generated starter wires the selected first-class fluo new application path:
686
+ // ${starter.runtimeLabel} + ${starter.platformLabel} via ${starter.adapterFactory}(...).
687
+
688
+ const parsedPort = Number.parseInt(${portExpression}, 10);
689
+ const port = Number.isFinite(parsedPort) ? parsedPort : 3000;
690
+
691
+ const app = await FluoFactory.create(AppModule, {
692
+ adapter: ${starter.adapterCall},
693
+ });
694
+ await app.listen();
695
+ `;
696
+ }
697
+ function createMicroserviceAppFile(options) {
698
+ switch (options.transport) {
699
+ case 'redis-streams':
700
+ return `import Redis from 'ioredis';
701
+ import { Module } from '@fluojs/core';
702
+ import { ConfigModule } from '@fluojs/config';
703
+ import { MicroservicesModule, RedisStreamsMicroserviceTransport, type RedisStreamClientLike } from '@fluojs/microservices';
704
+
705
+ import { MathHandler } from './math/math.handler';
706
+
707
+ const redisUrl = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379';
708
+ const namespace = process.env.REDIS_STREAMS_NAMESPACE ?? 'fluo:streams';
709
+ const consumerGroup = process.env.REDIS_STREAMS_CONSUMER_GROUP ?? 'fluo-handlers';
710
+ const readerRedis = new Redis(redisUrl, { lazyConnect: true, maxRetriesPerRequest: 1 });
711
+ const writerRedis = new Redis(redisUrl, { lazyConnect: true, maxRetriesPerRequest: 1 });
712
+
713
+ const readerClient: RedisStreamClientLike = {
714
+ async decr(key) {
715
+ return await readerRedis.decr(key);
716
+ },
717
+ async del(key) {
718
+ await readerRedis.del(key);
719
+ },
720
+ async get(key) {
721
+ return await readerRedis.get(key);
722
+ },
723
+ async incr(key) {
724
+ return await readerRedis.incr(key);
725
+ },
726
+ async xack(stream, group, id) {
727
+ await readerRedis.xack(stream, group, id);
728
+ },
729
+ async xadd(stream, fields) {
730
+ return await readerRedis.xadd(stream, '*', ...Object.entries(fields).flatMap(([key, value]) => [key, value]));
731
+ },
732
+ async xdel(stream, id) {
733
+ await readerRedis.xdel(stream, id);
734
+ },
735
+ async xgroupCreate(stream, group, startId, mkstream) {
736
+ if (mkstream) {
737
+ await readerRedis.xgroup('CREATE', stream, group, startId, 'MKSTREAM');
738
+ return;
739
+ }
740
+
741
+ await readerRedis.xgroup('CREATE', stream, group, startId);
742
+ },
743
+ async xgroupDestroy(stream, group) {
744
+ await readerRedis.xgroup('DESTROY', stream, group);
745
+ },
746
+ async set(key, value) {
747
+ return await readerRedis.set(key, value);
748
+ },
749
+ async xreadgroup(group, consumer, streams, options) {
750
+ const response = await readerRedis.xreadgroup(
751
+ 'GROUP',
752
+ group,
753
+ consumer,
754
+ options?.count ? 'COUNT' : undefined,
755
+ options?.count ? String(options.count) : undefined,
756
+ options?.blockMs ? 'BLOCK' : undefined,
757
+ options?.blockMs ? String(options.blockMs) : undefined,
758
+ 'STREAMS',
759
+ ...streams,
760
+ ...streams.map(() => '>'),
761
+ );
762
+
763
+ if (!response) {
764
+ return null;
765
+ }
766
+
767
+ return response.flatMap(([, entries]) => entries.map(([id, values]) => ({
768
+ fields: Object.fromEntries(values.reduce<string[][]>((pairs, value, index, source) => {
769
+ if (index % 2 === 0) {
770
+ pairs.push([value, source[index + 1] ?? '']);
771
+ }
772
+ return pairs;
773
+ }, [])),
774
+ id,
775
+ })));
776
+ },
777
+ };
778
+
779
+ const writerClient: RedisStreamClientLike = {
780
+ async decr(key) {
781
+ return await writerRedis.decr(key);
782
+ },
783
+ async del(key) {
784
+ await writerRedis.del(key);
785
+ },
786
+ async get(key) {
787
+ return await writerRedis.get(key);
788
+ },
789
+ async incr(key) {
790
+ return await writerRedis.incr(key);
791
+ },
792
+ async xack(stream, group, id) {
793
+ await writerRedis.xack(stream, group, id);
794
+ },
795
+ async xadd(stream, fields) {
796
+ return await writerRedis.xadd(stream, '*', ...Object.entries(fields).flatMap(([key, value]) => [key, value]));
797
+ },
798
+ async xdel(stream, id) {
799
+ await writerRedis.xdel(stream, id);
800
+ },
801
+ async xgroupCreate(stream, group, startId, mkstream) {
802
+ if (mkstream) {
803
+ await writerRedis.xgroup('CREATE', stream, group, startId, 'MKSTREAM');
804
+ return;
805
+ }
806
+
807
+ await writerRedis.xgroup('CREATE', stream, group, startId);
808
+ },
809
+ async xgroupDestroy(stream, group) {
810
+ await writerRedis.xgroup('DESTROY', stream, group);
811
+ },
812
+ async set(key, value) {
813
+ return await writerRedis.set(key, value);
814
+ },
815
+ async xreadgroup(group, consumer, streams, options) {
816
+ const response = await writerRedis.xreadgroup(
817
+ 'GROUP',
818
+ group,
819
+ consumer,
820
+ options?.count ? 'COUNT' : undefined,
821
+ options?.count ? String(options.count) : undefined,
822
+ options?.blockMs ? 'BLOCK' : undefined,
823
+ options?.blockMs ? String(options.blockMs) : undefined,
824
+ 'STREAMS',
825
+ ...streams,
826
+ ...streams.map(() => '>'),
827
+ );
828
+
829
+ if (!response) {
830
+ return null;
831
+ }
832
+
833
+ return response.flatMap(([, entries]) => entries.map(([id, values]) => ({
834
+ fields: Object.fromEntries(values.reduce<string[][]>((pairs, value, index, source) => {
835
+ if (index % 2 === 0) {
836
+ pairs.push([value, source[index + 1] ?? '']);
837
+ }
838
+ return pairs;
839
+ }, [])),
840
+ id,
841
+ })));
842
+ },
843
+ };
844
+
845
+ @Module({
846
+ imports: [
847
+ ConfigModule.forRoot({
848
+ envFile: '.env',
849
+ processEnv: process.env,
850
+ }),
851
+ MicroservicesModule.forRoot({
852
+ transport: new RedisStreamsMicroserviceTransport({
853
+ consumerGroup,
854
+ namespace,
855
+ readerClient,
856
+ writerClient,
857
+ }),
858
+ }),
859
+ ],
860
+ providers: [MathHandler],
861
+ })
862
+ export class AppModule {}
863
+ `;
864
+ case 'mqtt':
865
+ return `import { Module } from '@fluojs/core';
866
+ import { ConfigModule } from '@fluojs/config';
867
+ import { MicroservicesModule, MqttMicroserviceTransport } from '@fluojs/microservices';
868
+
869
+ import { MathHandler } from './math/math.handler';
870
+
871
+ const url = process.env.MQTT_URL ?? 'mqtt://127.0.0.1:1883';
872
+ const namespace = process.env.MQTT_NAMESPACE ?? 'fluo.microservices';
873
+
874
+ @Module({
875
+ imports: [
876
+ ConfigModule.forRoot({
877
+ envFile: '.env',
878
+ processEnv: process.env,
879
+ }),
880
+ MicroservicesModule.forRoot({
881
+ transport: new MqttMicroserviceTransport({
882
+ namespace,
883
+ requestTimeoutMs: 3_000,
884
+ url,
885
+ }),
886
+ }),
887
+ ],
888
+ providers: [MathHandler],
889
+ })
890
+ export class AppModule {}
891
+ `;
892
+ case 'grpc':
893
+ return `import { resolve } from 'node:path';
894
+
895
+ import { Module } from '@fluojs/core';
896
+ import { ConfigModule } from '@fluojs/config';
897
+ import { GrpcMicroserviceTransport, MicroservicesModule } from '@fluojs/microservices';
898
+
899
+ import { MathHandler } from './math/math.handler';
900
+
901
+ const url = process.env.GRPC_URL ?? '127.0.0.1:50051';
902
+ const protoPath = resolve(process.cwd(), 'proto', 'math.proto');
903
+
904
+ @Module({
905
+ imports: [
906
+ ConfigModule.forRoot({
907
+ envFile: '.env',
908
+ processEnv: process.env,
909
+ }),
910
+ MicroservicesModule.forRoot({
911
+ transport: new GrpcMicroserviceTransport({
912
+ packageName: 'fluo.microservices',
913
+ protoPath,
914
+ services: ['MathService'],
915
+ url,
916
+ }),
917
+ }),
918
+ ],
919
+ providers: [MathHandler],
920
+ })
921
+ export class AppModule {}
922
+ `;
923
+ case 'nats':
924
+ return `import { Module } from '@fluojs/core';
925
+ import { ConfigModule } from '@fluojs/config';
926
+ import { MicroservicesModule, NatsMicroserviceTransport } from '@fluojs/microservices';
927
+ import { JSONCodec, connect } from 'nats';
928
+
929
+ import { MathHandler } from './math/math.handler';
930
+
931
+ const servers = (process.env.NATS_SERVERS ?? 'nats://127.0.0.1:4222')
932
+ .split(',')
933
+ .map((value) => value.trim())
934
+ .filter((value) => value.length > 0);
935
+ const eventSubject = process.env.NATS_EVENT_SUBJECT ?? 'fluo.microservices.events';
936
+ const messageSubject = process.env.NATS_MESSAGE_SUBJECT ?? 'fluo.microservices.messages';
937
+ const codec = JSONCodec();
938
+ const connection = await connect({
939
+ name: 'fluo-microservice-starter',
940
+ servers,
941
+ });
942
+ const client = {
943
+ close() {
944
+ void connection.close();
945
+ },
946
+ publish(subject: string, payload: Uint8Array) {
947
+ connection.publish(subject, payload);
948
+ },
949
+ request(subject: string, payload: Uint8Array, options?: { timeout?: number }) {
950
+ return connection.request(subject, payload, options);
951
+ },
952
+ subscribe(subject: string, handler: (message: { data: Uint8Array; respond(data: Uint8Array): void }) => void) {
953
+ const subscription = connection.subscribe(subject);
954
+
955
+ void (async () => {
956
+ for await (const message of subscription) {
957
+ handler(message);
958
+ }
959
+ })();
960
+
961
+ return {
962
+ unsubscribe() {
963
+ subscription.unsubscribe();
964
+ },
965
+ };
966
+ },
967
+ };
968
+
969
+ @Module({
970
+ imports: [
971
+ ConfigModule.forRoot({
972
+ envFile: '.env',
973
+ processEnv: process.env,
974
+ }),
975
+ MicroservicesModule.forRoot({
976
+ transport: new NatsMicroserviceTransport({
977
+ client,
978
+ codec,
979
+ eventSubject,
980
+ messageSubject,
981
+ requestTimeoutMs: 3_000,
982
+ }),
983
+ }),
984
+ ],
985
+ providers: [MathHandler],
986
+ })
987
+ export class AppModule {}
988
+ `;
989
+ case 'kafka':
990
+ return `import { Module } from '@fluojs/core';
991
+ import { ConfigModule } from '@fluojs/config';
992
+ import { Kafka, logLevel } from 'kafkajs';
993
+ import { KafkaMicroserviceTransport, MicroservicesModule } from '@fluojs/microservices';
994
+
995
+ import { MathHandler } from './math/math.handler';
996
+
997
+ const brokers = (process.env.KAFKA_BROKERS ?? '127.0.0.1:9092')
998
+ .split(',')
999
+ .map((value) => value.trim())
1000
+ .filter((value) => value.length > 0);
1001
+ const clientId = process.env.KAFKA_CLIENT_ID ?? 'fluo-microservice-starter';
1002
+ const consumerGroup = process.env.KAFKA_CONSUMER_GROUP ?? 'fluo-handlers';
1003
+ const eventTopic = process.env.KAFKA_EVENT_TOPIC ?? 'fluo.microservices.events';
1004
+ const messageTopic = process.env.KAFKA_MESSAGE_TOPIC ?? 'fluo.microservices.messages';
1005
+ const responseTopic = process.env.KAFKA_RESPONSE_TOPIC ?? 'fluo.microservices.responses';
1006
+ const kafka = new Kafka({
1007
+ brokers,
1008
+ clientId,
1009
+ logLevel: logLevel.NOTHING,
1010
+ });
1011
+ const producer = kafka.producer();
1012
+ const consumer = kafka.consumer({ groupId: consumerGroup });
1013
+ await Promise.all([producer.connect(), consumer.connect()]);
1014
+
1015
+ const handlers = new Map<string, (message: string) => Promise<void> | void>();
1016
+ let consumerRunning = false;
1017
+
1018
+ const producerClient = {
1019
+ async publish(topic: string, message: string) {
1020
+ await producer.send({
1021
+ messages: [{ value: message }],
1022
+ topic,
1023
+ });
1024
+ },
1025
+ };
1026
+
1027
+ const consumerClient = {
1028
+ async subscribe(topic: string, handler: (message: string) => Promise<void> | void) {
1029
+ handlers.set(topic, handler);
1030
+ await consumer.subscribe({ fromBeginning: false, topic });
1031
+
1032
+ if (consumerRunning) {
1033
+ return;
1034
+ }
1035
+
1036
+ consumerRunning = true;
1037
+ void consumer.run({
1038
+ eachMessage: async ({ topic, message }) => {
1039
+ const value = message.value?.toString();
1040
+
1041
+ if (!value) {
1042
+ return;
1043
+ }
1044
+
1045
+ await handlers.get(topic)?.(value);
1046
+ },
1047
+ });
1048
+ },
1049
+ async unsubscribe(topic: string) {
1050
+ handlers.delete(topic);
1051
+
1052
+ if (handlers.size > 0) {
1053
+ return;
1054
+ }
1055
+
1056
+ consumerRunning = false;
1057
+ await consumer.stop();
1058
+ await Promise.all([consumer.disconnect(), producer.disconnect()]);
1059
+ },
1060
+ };
1061
+
1062
+ @Module({
1063
+ imports: [
1064
+ ConfigModule.forRoot({
1065
+ envFile: '.env',
1066
+ processEnv: process.env,
1067
+ }),
1068
+ MicroservicesModule.forRoot({
1069
+ transport: new KafkaMicroserviceTransport({
1070
+ consumer: consumerClient,
1071
+ eventTopic,
1072
+ messageTopic,
1073
+ producer: producerClient,
1074
+ requestTimeoutMs: 3_000,
1075
+ responseTopic,
1076
+ }),
1077
+ }),
1078
+ ],
1079
+ providers: [MathHandler],
1080
+ })
1081
+ export class AppModule {}
1082
+ `;
1083
+ case 'rabbitmq':
1084
+ return `import { connect } from 'amqplib';
1085
+
1086
+ import { Module } from '@fluojs/core';
1087
+ import { ConfigModule } from '@fluojs/config';
1088
+ import { MicroservicesModule, RabbitMqMicroserviceTransport } from '@fluojs/microservices';
1089
+
1090
+ import { MathHandler } from './math/math.handler';
1091
+
1092
+ const url = process.env.RABBITMQ_URL ?? 'amqp://127.0.0.1:5672';
1093
+ const eventQueue = process.env.RABBITMQ_EVENT_QUEUE ?? 'fluo.microservices.events';
1094
+ const messageQueue = process.env.RABBITMQ_MESSAGE_QUEUE ?? 'fluo.microservices.messages';
1095
+ const responseQueue = process.env.RABBITMQ_RESPONSE_QUEUE ?? 'fluo.microservices.responses';
1096
+ const connection = await connect(url);
1097
+ const channel = await connection.createConfirmChannel();
1098
+ const consumerTags = new Map<string, string>();
1099
+
1100
+ const publisher = {
1101
+ async publish(queue: string, message: string) {
1102
+ await channel.assertQueue(queue, { durable: true });
1103
+ await new Promise<void>((resolve, reject) => {
1104
+ channel.sendToQueue(queue, Buffer.from(message), { persistent: true }, (error) => {
1105
+ if (error) {
1106
+ reject(error);
1107
+ return;
1108
+ }
1109
+
1110
+ resolve();
1111
+ });
1112
+ });
1113
+ },
1114
+ };
1115
+
1116
+ const consumer = {
1117
+ async cancel(queue: string) {
1118
+ const consumerTag = consumerTags.get(queue);
1119
+
1120
+ if (!consumerTag) {
1121
+ return;
1122
+ }
1123
+
1124
+ consumerTags.delete(queue);
1125
+ await channel.cancel(consumerTag);
1126
+
1127
+ if (consumerTags.size === 0) {
1128
+ await channel.close();
1129
+ await connection.close();
1130
+ }
1131
+ },
1132
+ async consume(queue: string, handler: (message: string) => Promise<void> | void) {
1133
+ await channel.assertQueue(queue, { durable: true });
1134
+ const result = await channel.consume(queue, (message) => {
1135
+ if (!message) {
1136
+ return;
1137
+ }
1138
+
1139
+ void Promise.resolve(handler(message.content.toString()))
1140
+ .then(() => {
1141
+ channel.ack(message);
1142
+ })
1143
+ .catch(() => {
1144
+ channel.nack(message, false, false);
1145
+ });
1146
+ });
1147
+
1148
+ consumerTags.set(queue, result.consumerTag);
1149
+ },
1150
+ };
1151
+
1152
+ @Module({
1153
+ imports: [
1154
+ ConfigModule.forRoot({
1155
+ envFile: '.env',
1156
+ processEnv: process.env,
1157
+ }),
1158
+ MicroservicesModule.forRoot({
1159
+ transport: new RabbitMqMicroserviceTransport({
1160
+ consumer,
1161
+ eventQueue,
1162
+ messageQueue,
1163
+ publisher,
1164
+ requestTimeoutMs: 3_000,
1165
+ responseQueue,
1166
+ }),
1167
+ }),
1168
+ ],
1169
+ providers: [MathHandler],
1170
+ })
1171
+ export class AppModule {}
1172
+ `;
1173
+ default:
1174
+ return `import { Module } from '@fluojs/core';
1175
+ import { ConfigModule } from '@fluojs/config';
1176
+ import { MicroservicesModule, TcpMicroserviceTransport } from '@fluojs/microservices';
1177
+
1178
+ import { MathHandler } from './math/math.handler';
1179
+
1180
+ const parsedPort = Number.parseInt(process.env.MICROSERVICE_PORT ?? '4000', 10);
1181
+ const port = Number.isFinite(parsedPort) ? parsedPort : 4000;
1182
+ const host = process.env.MICROSERVICE_HOST ?? '127.0.0.1';
1183
+
1184
+ @Module({
1185
+ imports: [
1186
+ ConfigModule.forRoot({
1187
+ envFile: '.env',
1188
+ processEnv: process.env,
1189
+ }),
1190
+ MicroservicesModule.forRoot({
1191
+ transport: new TcpMicroserviceTransport({ host, port }),
1192
+ }),
1193
+ ],
1194
+ providers: [MathHandler],
1195
+ })
1196
+ export class AppModule {}
1197
+ `;
1198
+ }
1199
+ }
1200
+ function createMicroserviceMainFile() {
1201
+ return `import { FluoFactory } from '@fluojs/runtime';
1202
+
1203
+ import { AppModule } from './app';
1204
+
1205
+ const microservice = await FluoFactory.createMicroservice(AppModule);
1206
+ await microservice.listen();
1207
+ `;
1208
+ }
1209
+ function createMathHandlerFile(options) {
1210
+ const pattern = describeMicroserviceStarter(options).pattern;
1211
+ if (options.transport === 'grpc') {
1212
+ return `import { MessagePattern } from '@fluojs/microservices';
1213
+
1214
+ type SumInput = {
1215
+ a: number;
1216
+ b: number;
1217
+ };
1218
+
1219
+ type SumResponse = {
1220
+ result: number;
1221
+ };
1222
+
1223
+ export class MathHandler {
1224
+ @MessagePattern(${JSON.stringify(pattern)})
1225
+ sum(input: SumInput): SumResponse {
1226
+ return { result: input.a + input.b };
1227
+ }
1228
+ }
1229
+ `;
1230
+ }
1231
+ return `import { MessagePattern } from '@fluojs/microservices';
1232
+
1233
+ type SumInput = {
1234
+ a: number;
1235
+ b: number;
1236
+ };
1237
+
1238
+ export class MathHandler {
1239
+ @MessagePattern(${JSON.stringify(pattern)})
1240
+ sum(input: SumInput): number {
1241
+ return input.a + input.b;
1242
+ }
1243
+ }
1244
+ `;
1245
+ }
1246
+ function createMathHandlerTestFile(options) {
1247
+ const expectation = createMicroserviceResponseExpectation(options);
1248
+ return `import { describe, expect, it } from 'vitest';
1249
+
1250
+ import { MathHandler } from './math.handler';
1251
+
1252
+ describe('MathHandler', () => {
1253
+ it('sums message payload values', () => {
1254
+ const handler = new MathHandler();
1255
+
1256
+ expect(handler.sum({ a: 20, b: 22 })).toEqual(${expectation});
1257
+ });
1258
+ });
1259
+ `;
1260
+ }
1261
+ function createMicroserviceAppTestFile(options) {
1262
+ const pattern = describeMicroserviceStarter(options).pattern;
1263
+ const expectation = createMicroserviceResponseExpectation(options);
1264
+ return `import { describe, expect, it } from 'vitest';
1265
+
1266
+ import { Module } from '@fluojs/core';
1267
+ import {
1268
+ MicroservicesModule,
1269
+ type MicroserviceTransport,
1270
+ } from '@fluojs/microservices';
1271
+ import { FluoFactory } from '@fluojs/runtime';
1272
+
1273
+ import { MathHandler } from './math/math.handler';
1274
+
1275
+ type TransportHandler = Parameters<MicroserviceTransport['listen']>[0];
1276
+
1277
+ class InMemoryLoopbackTransport implements MicroserviceTransport {
1278
+ private handler: TransportHandler | undefined;
1279
+
1280
+ async close(): Promise<void> {
1281
+ this.handler = undefined;
1282
+ }
1283
+
1284
+ async emit(pattern: string, payload: unknown): Promise<void> {
1285
+ if (!this.handler) {
1286
+ throw new Error('Transport handler is not listening.');
1287
+ }
1288
+
1289
+ await this.handler({ kind: 'event', pattern, payload });
1290
+ }
1291
+
1292
+ async listen(handler: TransportHandler): Promise<void> {
1293
+ this.handler = handler;
1294
+ }
1295
+
1296
+ async send(pattern: string, payload: unknown): Promise<unknown> {
1297
+ if (!this.handler) {
1298
+ throw new Error('Transport handler is not listening.');
1299
+ }
1300
+
1301
+ return await this.handler({ kind: 'message', pattern, payload });
1302
+ }
1303
+ }
1304
+
1305
+ describe('AppModule microservice starter', () => {
1306
+ it('routes message patterns through the generated transport contract', async () => {
1307
+ const transport = new InMemoryLoopbackTransport();
1308
+
1309
+ @Module({
1310
+ imports: [MicroservicesModule.forRoot({ transport })],
1311
+ providers: [MathHandler],
1312
+ })
1313
+ class TestAppModule {}
1314
+
1315
+ const microservice = await FluoFactory.createMicroservice(TestAppModule);
1316
+ await microservice.listen();
1317
+
1318
+ await expect(transport.send(${JSON.stringify(pattern)}, { a: 19, b: 23 })).resolves.toEqual(${expectation});
1319
+
1320
+ await microservice.close();
1321
+ });
1322
+ });
1323
+ `;
1324
+ }
1325
+ function createGrpcProtoFile() {
1326
+ return `syntax = "proto3";
1327
+
1328
+ package fluo.microservices;
1329
+
1330
+ service MathService {
1331
+ rpc Sum (SumRequest) returns (SumResponse);
1332
+ }
1333
+
1334
+ message SumRequest {
1335
+ int32 a = 1;
1336
+ int32 b = 2;
1337
+ }
1338
+
1339
+ message SumResponse {
1340
+ int32 result = 1;
1341
+ }
1342
+ `;
1343
+ }
1344
+ function createMixedProjectReadme(options) {
1345
+ return `# ${options.projectName}
1346
+
1347
+ Generated by @fluojs/cli.
1348
+
1349
+ - Shape: \`mixed\`
1350
+ - Supported topology: \`single-package\` generates one shared Node.js package where the Fastify HTTP application also starts an attached TCP microservice from the same \`src/main.ts\`
1351
+ - Runtime contract: \`src/app.ts\` owns one shared module graph, and \`src/main.ts\` bootstraps the HTTP API before calling \`connectMicroservice()\` and \`startAllMicroservices()\`
1352
+ - Intentional limitation: the first mixed release supports only the explicit Fastify HTTP + attached TCP microservice contract; other mixed transports or separate multi-entrypoint layouts fail validation instead of generating partial scaffolds
1353
+
1354
+ ## Commands
1355
+
1356
+ - Dev: ${createRunCommand(options.packageManager, 'dev')}
1357
+ - Build: ${createRunCommand(options.packageManager, 'build')}
1358
+ - Typecheck: ${createRunCommand(options.packageManager, 'typecheck')}
1359
+ - Test: ${createRunCommand(options.packageManager, 'test')}
1360
+
1361
+ ## Generated topology
1362
+
1363
+ - \`src/app.ts\` — shared application module with config, runtime health endpoints, and the TCP microservice registration.
1364
+ - \`src/main.ts\` — Fastify HTTP bootstrap that also starts the attached TCP microservice.
1365
+ - \`src/health/*\` — starter-owned HTTP health slice.
1366
+ - \`src/math/*\` — starter-owned message handler slice that proves the microservice half of the topology.
1367
+
1368
+ ## Official generated testing templates
1369
+
1370
+ - \`src/health/*.test.ts\` — unit templates for the HTTP slice.
1371
+ - \`src/math/math.handler.test.ts\` — unit template for the microservice handler.
1372
+ - \`src/app.test.ts\` — hybrid verification template covering HTTP dispatch plus in-memory message routing with the same shared module contract.
1373
+
1374
+ Use the unit templates for fast logic checks. Use the mixed verification template when you need confidence that both generated entrypoints still share one behavioral contract.
1375
+ `;
1376
+ }
1377
+ function createMixedAppFile() {
1378
+ return `import { Global, Module } from '@fluojs/core';
1379
+ import { ConfigModule } from '@fluojs/config';
1380
+ import { MicroservicesModule, TcpMicroserviceTransport } from '@fluojs/microservices';
1381
+ import { createHealthModule } from '@fluojs/runtime';
1382
+
1383
+ import { HealthModule } from './health/health.module';
1384
+ import { MathHandler } from './math/math.handler';
1385
+
1386
+ const RuntimeHealthModule = createHealthModule();
1387
+ const parsedMicroservicePort = Number.parseInt(process.env.MICROSERVICE_PORT ?? '4000', 10);
1388
+ const microservicePort = Number.isFinite(parsedMicroservicePort) ? parsedMicroservicePort : 4000;
1389
+ const microserviceHost = process.env.MICROSERVICE_HOST ?? '127.0.0.1';
1390
+
1391
+ @Global()
1392
+ @Module({
1393
+ imports: [
1394
+ ConfigModule.forRoot({
1395
+ envFile: '.env',
1396
+ processEnv: process.env,
1397
+ }),
1398
+ HealthModule,
1399
+ RuntimeHealthModule,
1400
+ MicroservicesModule.forRoot({
1401
+ transport: new TcpMicroserviceTransport({ host: microserviceHost, port: microservicePort }),
1402
+ }),
1403
+ ],
1404
+ providers: [MathHandler],
1405
+ })
1406
+ export class AppModule {}
1407
+ `;
1408
+ }
1409
+ function createMixedMainFile() {
1410
+ return `import { createFastifyAdapter } from '@fluojs/platform-fastify';
1411
+ import { FluoFactory } from '@fluojs/runtime';
1412
+
1413
+ import { AppModule } from './app';
1414
+
1415
+ const parsedPort = Number.parseInt(process.env.PORT ?? '3000', 10);
1416
+ const port = Number.isFinite(parsedPort) ? parsedPort : 3000;
1417
+
1418
+ const app = await FluoFactory.create(AppModule, {
1419
+ adapter: createFastifyAdapter({ port }),
1420
+ });
1421
+ await app.connectMicroservice();
1422
+ await app.startAllMicroservices();
1423
+ await app.listen();
1424
+ `;
1425
+ }
1426
+ function createMixedAppTestFile() {
1427
+ return `import { describe, expect, it } from 'vitest';
1428
+
1429
+ import { Global, Module } from '@fluojs/core';
1430
+ import { ConfigModule } from '@fluojs/config';
1431
+ import type { FrameworkRequest, FrameworkResponse } from '@fluojs/http';
1432
+ import {
1433
+ MicroservicesModule,
1434
+ type MicroserviceTransport,
1435
+ } from '@fluojs/microservices';
1436
+ import { FluoFactory, createHealthModule } from '@fluojs/runtime';
1437
+
1438
+ import { HealthModule } from './health/health.module';
1439
+ import { MathHandler } from './math/math.handler';
1440
+
1441
+ type TransportHandler = Parameters<MicroserviceTransport['listen']>[0];
1442
+
1443
+ class InMemoryLoopbackTransport implements MicroserviceTransport {
1444
+ private handler: TransportHandler | undefined;
1445
+
1446
+ async close(): Promise<void> {
1447
+ this.handler = undefined;
1448
+ }
1449
+
1450
+ async emit(pattern: string, payload: unknown): Promise<void> {
1451
+ if (!this.handler) {
1452
+ throw new Error('Transport handler is not listening.');
1453
+ }
1454
+
1455
+ await this.handler({ kind: 'event', pattern, payload });
1456
+ }
1457
+
1458
+ async listen(handler: TransportHandler): Promise<void> {
1459
+ this.handler = handler;
1460
+ }
1461
+
1462
+ async send(pattern: string, payload: unknown): Promise<unknown> {
1463
+ if (!this.handler) {
1464
+ throw new Error('Transport handler is not listening.');
1465
+ }
1466
+
1467
+ return await this.handler({ kind: 'message', pattern, payload });
1468
+ }
1469
+ }
1470
+
1471
+ function createRequest(path: string): FrameworkRequest {
1472
+ return {
1473
+ body: undefined,
1474
+ cookies: {},
1475
+ headers: {},
1476
+ method: 'GET',
1477
+ params: {},
1478
+ path,
1479
+ query: {},
1480
+ raw: {},
1481
+ url: path,
1482
+ };
1483
+ }
1484
+
1485
+ function createResponse(): FrameworkResponse & { body?: unknown } {
1486
+ return {
1487
+ committed: false,
1488
+ headers: {},
1489
+ redirect(status, location) {
1490
+ this.setStatus(status);
1491
+ this.setHeader('Location', location);
1492
+ this.committed = true;
1493
+ },
1494
+ send(body) {
1495
+ this.body = body;
1496
+ this.committed = true;
1497
+ },
1498
+ setHeader(name, value) {
1499
+ this.headers[name] = value;
1500
+ },
1501
+ setStatus(code) {
1502
+ this.statusCode = code;
1503
+ this.statusSet = true;
1504
+ },
1505
+ statusCode: undefined,
1506
+ statusSet: false,
1507
+ };
1508
+ }
1509
+
1510
+ describe('AppModule mixed starter', () => {
1511
+ it('keeps HTTP routes and message handlers in one explicit topology contract', async () => {
1512
+ const transport = new InMemoryLoopbackTransport();
1513
+ const RuntimeHealthModule = createHealthModule();
1514
+
1515
+ @Global()
1516
+ @Module({
1517
+ imports: [
1518
+ ConfigModule.forRoot({
1519
+ envFile: '.env',
1520
+ processEnv: process.env,
1521
+ }),
1522
+ HealthModule,
1523
+ RuntimeHealthModule,
1524
+ MicroservicesModule.forRoot({ transport }),
1525
+ ],
1526
+ providers: [MathHandler],
1527
+ })
1528
+ class TestAppModule {}
1529
+
1530
+ const app = await FluoFactory.create(TestAppModule, {});
1531
+ const healthResponse = createResponse();
1532
+
1533
+ const microservice = await app.connectMicroservice();
1534
+
1535
+ await Promise.all([app.startAllMicroservices(), app.dispatch(createRequest('/health-info/'), healthResponse)]);
1536
+
1537
+ expect(healthResponse.body).toEqual({ ok: true, service: expect.any(String) });
1538
+ await expect(transport.send('math.sum', { a: 20, b: 22 })).resolves.toBe(42);
1539
+ expect(microservice.state).toBe('ready');
1540
+
1541
+ await app.close();
1542
+ });
1543
+ });
1544
+ `;
1545
+ }
1546
+ function createAppTestFile(importSuffix = '') {
1547
+ return `import { describe, expect, it } from 'vitest';
1548
+
1549
+ import type { FrameworkRequest, FrameworkResponse } from '@fluojs/http';
1550
+ import { FluoFactory } from '@fluojs/runtime';
1551
+
1552
+ import { AppModule } from './app${importSuffix}';
1553
+
1554
+ function createRequest(path: string): FrameworkRequest {
1555
+ return {
1556
+ body: undefined,
1557
+ cookies: {},
1558
+ headers: {},
1559
+ method: 'GET',
1560
+ params: {},
1561
+ path,
1562
+ query: {},
1563
+ raw: {},
1564
+ url: path,
1565
+ };
1566
+ }
1567
+
1568
+ function createResponse(): FrameworkResponse & { body?: unknown } {
1569
+ return {
1570
+ committed: false,
1571
+ headers: {},
1572
+ redirect(status, location) {
1573
+ this.setStatus(status);
1574
+ this.setHeader('Location', location);
1575
+ this.committed = true;
1576
+ },
1577
+ send(body) {
1578
+ this.body = body;
1579
+ this.committed = true;
1580
+ },
1581
+ setHeader(name, value) {
1582
+ this.headers[name] = value;
1583
+ },
1584
+ setStatus(code) {
1585
+ this.statusCode = code;
1586
+ this.statusSet = true;
1587
+ },
1588
+ statusCode: undefined,
1589
+ statusSet: false,
1590
+ };
1591
+ }
1592
+
1593
+ describe('AppModule', () => {
1594
+ it('dispatches the runtime health and readiness routes', async () => {
1595
+ const app = await FluoFactory.create(AppModule, {});
1596
+ const healthResponse = createResponse();
1597
+ const readyResponse = createResponse();
1598
+
1599
+ await app.dispatch(createRequest('/health'), healthResponse);
1600
+ await app.dispatch(createRequest('/ready'), readyResponse);
1601
+
1602
+ expect(healthResponse.body).toEqual({ status: 'ok' });
1603
+ expect(readyResponse.body).toEqual({ status: 'ready' });
1604
+
1605
+ await app.close();
1606
+ });
1607
+
1608
+ it('dispatches the health-info route', async () => {
1609
+ const app = await FluoFactory.create(AppModule, {});
1610
+ const response = createResponse();
1611
+
1612
+ await app.dispatch(createRequest('/health-info/'), response);
1613
+
1614
+ expect(response.body).toEqual({ ok: true, service: expect.any(String) });
1615
+
1616
+ await app.close();
1617
+ });
1618
+ });
1619
+ `;
1620
+ }
1621
+ function createAppE2eTestFile(importSuffix = '') {
1622
+ return `import { describe, expect, it } from 'vitest';
1623
+
1624
+ import { createTestApp } from '@fluojs/testing';
1625
+
1626
+ import { AppModule } from './app${importSuffix}';
1627
+
1628
+ describe('AppModule e2e', () => {
1629
+ it('serves runtime and starter routes through createTestApp', async () => {
1630
+ const app = await createTestApp({ rootModule: AppModule });
1631
+
1632
+ await expect(app.dispatch({ method: 'GET', path: '/health' })).resolves.toMatchObject({
1633
+ body: { status: 'ok' },
1634
+ status: 200,
1635
+ });
1636
+ await expect(app.dispatch({ method: 'GET', path: '/ready' })).resolves.toMatchObject({
1637
+ body: { status: 'ready' },
1638
+ status: 200,
1639
+ });
1640
+ await expect(app.dispatch({ method: 'GET', path: '/health-info/' })).resolves.toMatchObject({
1641
+ body: { ok: true, service: expect.any(String) },
1642
+ status: 200,
1643
+ });
1644
+
1645
+ await app.close();
1646
+ });
1647
+ });
1648
+ `;
1649
+ }
1650
+ function createDenoAppTestFile() {
1651
+ return `import { FluoFactory } from '@fluojs/runtime';
1652
+
1653
+ import { AppModule } from './app.ts';
1654
+
1655
+ type ResponseStub = {
1656
+ body?: unknown;
1657
+ committed: boolean;
1658
+ headers: Record<string, string>;
1659
+ redirect(status: number, location: string): void;
1660
+ send(body: unknown): void;
1661
+ setHeader(name: string, value: string): void;
1662
+ setStatus(code: number): void;
1663
+ statusCode?: number;
1664
+ statusSet: boolean;
1665
+ };
1666
+
1667
+ function createResponse(): ResponseStub {
1668
+ return {
1669
+ committed: false,
1670
+ headers: {},
1671
+ redirect(status, location) {
1672
+ this.setStatus(status);
1673
+ this.setHeader('Location', location);
1674
+ this.committed = true;
1675
+ },
1676
+ send(body) {
1677
+ this.body = body;
1678
+ this.committed = true;
1679
+ },
1680
+ setHeader(name, value) {
1681
+ this.headers[name] = value;
1682
+ },
1683
+ setStatus(code) {
1684
+ this.statusCode = code;
1685
+ this.statusSet = true;
1686
+ },
1687
+ statusCode: undefined,
1688
+ statusSet: false,
1689
+ };
1690
+ }
1691
+
1692
+ Deno.test('AppModule dispatches the generated Deno starter routes', async () => {
1693
+ const app = await FluoFactory.create(AppModule, {});
1694
+ const healthResponse = createResponse();
1695
+ const readyResponse = createResponse();
1696
+ const infoResponse = createResponse();
1697
+
1698
+ await app.dispatch({ body: undefined, cookies: {}, headers: {}, method: 'GET', params: {}, path: '/health', query: {}, raw: {}, url: '/health' }, healthResponse as never);
1699
+ await app.dispatch({ body: undefined, cookies: {}, headers: {}, method: 'GET', params: {}, path: '/ready', query: {}, raw: {}, url: '/ready' }, readyResponse as never);
1700
+ await app.dispatch({ body: undefined, cookies: {}, headers: {}, method: 'GET', params: {}, path: '/health-info/', query: {}, raw: {}, url: '/health-info/' }, infoResponse as never);
1701
+
1702
+ if (JSON.stringify(healthResponse.body) !== JSON.stringify({ status: 'ok' })) {
1703
+ throw new Error('Expected /health to return status ok.');
1704
+ }
1705
+
1706
+ if (JSON.stringify(readyResponse.body) !== JSON.stringify({ status: 'ready' })) {
1707
+ throw new Error('Expected /ready to return status ready.');
1708
+ }
1709
+
1710
+ if (typeof infoResponse.body !== 'object' || infoResponse.body === null) {
1711
+ throw new Error('Expected /health-info/ to return an object body.');
1712
+ }
1713
+
1714
+ const infoBody = infoResponse.body as { ok?: unknown; service?: unknown };
1715
+ if (infoBody.ok !== true || typeof infoBody.service !== 'string') {
1716
+ throw new Error('Expected /health-info/ to return the generated service payload.');
1717
+ }
1718
+
1719
+ await app.close();
1720
+ });
1721
+ `;
1722
+ }
1723
+ function createEnvFile(bootstrapPlan) {
1724
+ if (bootstrapPlan.profile.id === 'application-cloudflare-workers-cloudflare-workers-http') {
1725
+ return undefined;
1726
+ }
1727
+ if (bootstrapPlan.profile.id === 'microservice-node-none-tcp' || bootstrapPlan.profile.id === 'mixed-node-fastify-tcp') {
1728
+ return `MICROSERVICE_HOST=127.0.0.1
1729
+ MICROSERVICE_PORT=4000
1730
+ PORT=3000
1731
+ `;
1732
+ }
1733
+ if (bootstrapPlan.profile.id === 'microservice-node-none-redis-streams') {
1734
+ return `REDIS_URL=redis://127.0.0.1:6379
1735
+ REDIS_STREAMS_NAMESPACE=fluo:streams
1736
+ REDIS_STREAMS_CONSUMER_GROUP=fluo-handlers
1737
+ PORT=3000
1738
+ `;
1739
+ }
1740
+ if (bootstrapPlan.profile.id === 'microservice-node-none-mqtt') {
1741
+ return `MQTT_URL=mqtt://127.0.0.1:1883
1742
+ MQTT_NAMESPACE=fluo.microservices
1743
+ PORT=3000
1744
+ `;
1745
+ }
1746
+ if (bootstrapPlan.profile.id === 'microservice-node-none-grpc') {
1747
+ return `GRPC_URL=127.0.0.1:50051
1748
+ PORT=3000
1749
+ `;
1750
+ }
1751
+ if (bootstrapPlan.profile.id === 'microservice-node-none-nats') {
1752
+ return `NATS_SERVERS=nats://127.0.0.1:4222
1753
+ NATS_EVENT_SUBJECT=fluo.microservices.events
1754
+ NATS_MESSAGE_SUBJECT=fluo.microservices.messages
1755
+ PORT=3000
1756
+ `;
1757
+ }
1758
+ if (bootstrapPlan.profile.id === 'microservice-node-none-kafka') {
1759
+ return `KAFKA_BROKERS=127.0.0.1:9092
1760
+ KAFKA_CLIENT_ID=fluo-microservice-starter
1761
+ KAFKA_CONSUMER_GROUP=fluo-handlers
1762
+ KAFKA_EVENT_TOPIC=fluo.microservices.events
1763
+ KAFKA_MESSAGE_TOPIC=fluo.microservices.messages
1764
+ KAFKA_RESPONSE_TOPIC=fluo.microservices.responses
1765
+ PORT=3000
1766
+ `;
1767
+ }
1768
+ if (bootstrapPlan.profile.id === 'microservice-node-none-rabbitmq') {
1769
+ return `RABBITMQ_URL=amqp://127.0.0.1:5672
1770
+ RABBITMQ_EVENT_QUEUE=fluo.microservices.events
1771
+ RABBITMQ_MESSAGE_QUEUE=fluo.microservices.messages
1772
+ RABBITMQ_RESPONSE_QUEUE=fluo.microservices.responses
1773
+ PORT=3000
1774
+ `;
1775
+ }
1776
+ return `PORT=3000
1777
+ `;
1778
+ }
1779
+ function createWranglerConfig(projectName) {
1780
+ return `{
1781
+ "$schema": "node_modules/wrangler/config-schema.json",
1782
+ "name": ${JSON.stringify(projectName)},
1783
+ "main": "src/worker.ts",
1784
+ "compatibility_date": "2026-04-11"
1785
+ }
1786
+ `;
1787
+ }
1788
+ function emitSharedScaffoldFiles(options, bootstrapPlan, releaseVersion, packageSpecs) {
1789
+ const envFile = createEnvFile(bootstrapPlan);
1790
+ const sharedFiles = [{
1791
+ content: createProjectPackageJson(options, bootstrapPlan, releaseVersion, packageSpecs),
1792
+ path: 'package.json'
1793
+ }, {
1794
+ content: createProjectReadme(options, bootstrapPlan),
1795
+ path: 'README.md'
1796
+ }, {
1797
+ content: createGitignore(),
1798
+ path: '.gitignore'
1799
+ }];
1800
+ if (bootstrapPlan.profile.id !== 'application-deno-deno-http') {
1801
+ sharedFiles.push({
1802
+ content: createProjectTsconfig(),
1803
+ path: 'tsconfig.json'
1804
+ }, {
1805
+ content: createProjectTsconfigBuild(),
1806
+ path: 'tsconfig.build.json'
1807
+ }, {
1808
+ content: createBabelConfig(),
1809
+ path: 'babel.config.cjs'
1810
+ }, {
1811
+ content: createViteConfig(),
1812
+ path: 'vite.config.ts'
1813
+ }, {
1814
+ content: createVitestConfig(),
1815
+ path: 'vitest.config.ts'
1816
+ });
1817
+ }
1818
+ if (bootstrapPlan.profile.id === 'application-cloudflare-workers-cloudflare-workers-http') {
1819
+ sharedFiles.push({
1820
+ content: createWranglerConfig(options.projectName),
1821
+ path: 'wrangler.jsonc'
1822
+ });
1823
+ }
1824
+ if (envFile) {
1825
+ sharedFiles.push({
1826
+ content: envFile,
1827
+ path: '.env'
1828
+ });
1829
+ }
1830
+ return sharedFiles;
1831
+ }
1832
+ function emitApplicationScaffoldFiles(options) {
1833
+ const importSuffix = options.runtime === 'deno' ? '.ts' : '';
1834
+ const entrypointPath = describeApplicationStarter(options).entrypoint;
1835
+ const files = [{
1836
+ content: createAppFile(options),
1837
+ path: 'src/app.ts'
1838
+ }, {
1839
+ content: createMainFile(options),
1840
+ path: entrypointPath
1841
+ }, {
1842
+ content: createHealthResponseDtoFile(),
1843
+ path: 'src/health/health.response.dto.ts'
1844
+ }, {
1845
+ content: createHealthRepoFile(options.projectName, importSuffix),
1846
+ path: 'src/health/health.repo.ts'
1847
+ }, {
1848
+ content: createHealthServiceFile(importSuffix),
1849
+ path: 'src/health/health.service.ts'
1850
+ }, {
1851
+ content: createHealthControllerFile(importSuffix),
1852
+ path: 'src/health/health.controller.ts'
1853
+ }, {
1854
+ content: createHealthModuleFile(importSuffix),
1855
+ path: 'src/health/health.module.ts'
1856
+ }];
1857
+ if (options.runtime === 'deno') {
1858
+ files.push({
1859
+ content: createDenoAppTestFile(),
1860
+ path: 'src/app.test.ts'
1861
+ });
1862
+ return files;
1863
+ }
1864
+ files.push({
1865
+ content: createHealthRepoTestFile(),
1866
+ path: 'src/health/health.repo.test.ts'
1867
+ }, {
1868
+ content: createHealthServiceTestFile(),
1869
+ path: 'src/health/health.service.test.ts'
1870
+ }, {
1871
+ content: createHealthControllerTestFile(),
1872
+ path: 'src/health/health.controller.test.ts'
1873
+ }, {
1874
+ content: createAppTestFile(importSuffix),
1875
+ path: 'src/app.test.ts'
1876
+ }, {
1877
+ content: createAppE2eTestFile(importSuffix),
1878
+ path: 'src/app.e2e.test.ts'
1879
+ });
1880
+ return files;
1881
+ }
1882
+ function emitScaffoldFilesForRecipe(options, recipeId) {
1883
+ if (recipeId === 'application-bun-bun-http' || recipeId === 'application-cloudflare-workers-cloudflare-workers-http' || recipeId === 'application-deno-deno-http' || recipeId === 'application-node-fastify-http' || recipeId === 'application-node-express-http' || recipeId === 'application-node-nodejs-http') {
1884
+ return emitApplicationScaffoldFiles(options);
1885
+ }
1886
+ if (recipeId === 'microservice-node-none-tcp' || recipeId === 'microservice-node-none-redis-streams' || recipeId === 'microservice-node-none-mqtt' || recipeId === 'microservice-node-none-grpc' || recipeId === 'microservice-node-none-nats' || recipeId === 'microservice-node-none-kafka' || recipeId === 'microservice-node-none-rabbitmq') {
1887
+ const transport = recipeId === 'microservice-node-none-redis-streams' ? 'redis-streams' : recipeId === 'microservice-node-none-mqtt' ? 'mqtt' : recipeId === 'microservice-node-none-nats' ? 'nats' : recipeId === 'microservice-node-none-kafka' ? 'kafka' : recipeId === 'microservice-node-none-rabbitmq' ? 'rabbitmq' : recipeId === 'microservice-node-none-grpc' ? 'grpc' : 'tcp';
1888
+ const files = [{
1889
+ content: createMicroserviceAppFile({
1890
+ transport
1891
+ }),
1892
+ path: 'src/app.ts'
1893
+ }, {
1894
+ content: createMicroserviceMainFile(),
1895
+ path: 'src/main.ts'
1896
+ }, {
1897
+ content: createMathHandlerFile({
1898
+ transport
1899
+ }),
1900
+ path: 'src/math/math.handler.ts'
1901
+ }, {
1902
+ content: createMathHandlerTestFile({
1903
+ transport
1904
+ }),
1905
+ path: 'src/math/math.handler.test.ts'
1906
+ }, {
1907
+ content: createMicroserviceAppTestFile({
1908
+ transport
1909
+ }),
1910
+ path: 'src/app.test.ts'
1911
+ }];
1912
+ if (recipeId === 'microservice-node-none-grpc') {
1913
+ files.push({
1914
+ content: createGrpcProtoFile(),
1915
+ path: 'proto/math.proto'
1916
+ });
1917
+ }
1918
+ return files;
1919
+ }
1920
+ if (recipeId === 'mixed-node-fastify-tcp') {
1921
+ return [{
1922
+ content: createMixedAppFile(),
1923
+ path: 'src/app.ts'
1924
+ }, {
1925
+ content: createMixedMainFile(),
1926
+ path: 'src/main.ts'
1927
+ }, {
1928
+ content: createHealthResponseDtoFile(),
1929
+ path: 'src/health/health.response.dto.ts'
1930
+ }, {
1931
+ content: createHealthRepoFile(options.projectName),
1932
+ path: 'src/health/health.repo.ts'
1933
+ }, {
1934
+ content: createHealthRepoTestFile(),
1935
+ path: 'src/health/health.repo.test.ts'
1936
+ }, {
1937
+ content: createHealthServiceFile(),
1938
+ path: 'src/health/health.service.ts'
1939
+ }, {
1940
+ content: createHealthServiceTestFile(),
1941
+ path: 'src/health/health.service.test.ts'
1942
+ }, {
1943
+ content: createHealthControllerFile(),
1944
+ path: 'src/health/health.controller.ts'
1945
+ }, {
1946
+ content: createHealthControllerTestFile(),
1947
+ path: 'src/health/health.controller.test.ts'
1948
+ }, {
1949
+ content: createHealthModuleFile(),
1950
+ path: 'src/health/health.module.ts'
1951
+ }, {
1952
+ content: createMathHandlerFile({
1953
+ transport: 'tcp'
1954
+ }),
1955
+ path: 'src/math/math.handler.ts'
1956
+ }, {
1957
+ content: createMathHandlerTestFile({
1958
+ transport: 'tcp'
1959
+ }),
1960
+ path: 'src/math/math.handler.test.ts'
1961
+ }, {
1962
+ content: createMixedAppTestFile(),
1963
+ path: 'src/app.test.ts'
1964
+ }];
1965
+ }
1966
+ return [];
1967
+ }
1968
+ function emitScaffoldFilesForPlan(options, bootstrapPlan) {
1969
+ return emitScaffoldFilesForRecipe(options, bootstrapPlan.profile.id);
1970
+ }
1971
+ function buildScaffoldFiles(options, bootstrapPlan, releaseVersion, packageSpecs) {
1972
+ return [...emitSharedScaffoldFiles(options, bootstrapPlan, releaseVersion, packageSpecs), ...emitScaffoldFilesForPlan(options, bootstrapPlan)];
1973
+ }
1974
+
1975
+ /**
1976
+ * Scaffolds a new fluo application into the target directory.
1977
+ *
1978
+ * @param options Bootstrap configuration for the new project.
1979
+ * @param importMetaUrl Optional URL of the importing module for relative path resolution.
1980
+ * @returns A promise that resolves when the project has been scaffolded.
1981
+ */
1982
+ export async function scaffoldBootstrapApp(options, importMetaUrl = import.meta.url) {
1983
+ const targetDirectory = resolve(options.targetDirectory);
1984
+ const releaseVersion = readOwnPackageVersion(importMetaUrl);
1985
+ const bootstrapPlan = resolveBootstrapPlan(options);
1986
+ const packageSpecs = await resolvePackageSpecs(options, bootstrapPlan);
1987
+ mkdirSync(targetDirectory, {
1988
+ recursive: true
1989
+ });
1990
+ if (!options.force) {
1991
+ const existingFiles = readdirSync(targetDirectory);
1992
+ if (existingFiles.length > 0) {
1993
+ throw new Error(`Target directory "${targetDirectory}" is not empty. ` + 'Remove the existing files or use --force to overwrite.');
1994
+ }
1995
+ }
1996
+ for (const file of buildScaffoldFiles(options, bootstrapPlan, releaseVersion, packageSpecs)) {
1997
+ writeTextFile(join(targetDirectory, file.path), file.content);
1998
+ }
1999
+ if (options.initializeGit) {
2000
+ await initializeGitRepository(targetDirectory);
2001
+ }
2002
+ if (options.installDependencies ?? !options.skipInstall) {
2003
+ await installDependencies(targetDirectory, options.packageManager);
2004
+ }
2005
+ }
2006
+
2007
+ /**
2008
+ * Alias for {@link scaffoldBootstrapApp}.
2009
+ */
2010
+ export const scaffoldFluoApp = scaffoldBootstrapApp;